COW-Solver - #48
Merged
Merged
Conversation
Adds packages/solver, a solver that mirrors the node's offer book over SSE, prices offers against posted ladders, and settles either from its own inventory or by merging crossing offers through the batcher. packages/solver-core promotes the primitives the e2e scripts already had — API/SSE client, batcher settlement, wallet, offer-files — so the solver and the tests share one copy. packages/tests/lib/* are now re-export shims, leaving the existing e2e scripts untouched. Node changes are additive: a per-IP rate-limit budget and allowlist, offerHash on offer_consumed/offer_expired so events can be correlated with anything REST exposes, and /v1/solver/levels backing /v1/quote with a real posted price instead of the $1 demo fallback. Also fixes four pre-existing bugs found while getting the e2e scripts to run: the spent_nullifiers table was merged into nullifiers by migration 000-init but 12 queries across 5 scripts still named it; an o.id reference on API rows (which carry offerId, a content hash) built raw `WHERE id IN (,)` SQL; and a stale NULLIFIER_SPENT expectation that the content-hash dedup gate has superseded. Known gaps, tracked and not yet fixed: POST /v1/solver/levels is unauthenticated and keyed by pair alone, so the last publisher wins; quotes carry no solver binding or expiry; a Path B top-up is not reverted on every failure path; and the N-cycle search keeps only the most generous offer per edge, which can discard feasible sets. Verified live against a dev stack: an offer posted at the solver's posted price is filled autonomously end to end, and wallet.revert() does release the coins an abandoned balance locked.
URL.pathname percent-encodes, so a checkout under a directory containing a space (or any encoded character) produced paths like .../COW%20Solver/... that readFile cannot open. The node env case was invisible: the midnightContract IIFE catches all errors, so the contract silently loaded as null. All three sites now go through fileURLToPath.
Same defect as the previous commit, found by sweeping instead of fixing only the sites that had already failed: URL.pathname percent-encodes, so any checkout under a directory with a space in its name gets unopenable paths. Eight sites across grand-e2e, the mint script, and the config drift test now go through fileURLToPath.
…eview Complete locally actionable remediation of the 2026-08-13 deep review and red-team findings (R-01..R-46) on top of 71fcd44: declared-segment imbalance guard, strict batcher acknowledgement schemas, authenticated indicative solver levels, exact canonical decimal prices, derived/snapshotted executor economics, SHA-256 blob binding, ambiguity quarantine, bounded SSE/readiness/startup-signal lifecycle, post-commit events, exact persisted expiry, fail-closed fill-vs-cancel classification, and whole-block fail-stop for synchronous STFs. All Path B, cycles, residual top-ups, levels publication, and mainnet live trading remain default-off. Verified gate (pinned oven/bun:1.3.3 Docker, frozen install): 479 pass, 2 skip, 0 fail, 2,727 assertions across 481 tests / 42 files; independently reproduced by the 2026-08-14 implementation-state audit. NO-GO for real funds remains: R-02 batcher identity, R-04/R-28 durable restart reconciliation, R-14 supported-pair economics, R-22 backend type gate, R-29 durable projections, R-31 upstream isolation are still open.
Validation contexts with a shared liveness module, the authenticated POST /v1/offers/validate surface, the solver-side pre-match/dequeue validation gate, and frozen wire fixtures (V0-V3).
…th client Backend-discovery boundary, readiness state machine, and the strict sync-health client (D0-D2).
Authenticated read-only grouped liquidity route, the lineage contract, and the cross-repository black-box provenance e2e (L0-L5, Story 2).
Recording/fault/SSE foundation, real actors, publisher, settlement verifier, secret/proof log scans, and the split-topology runner.
Executor/book/engine/run/sse-sync wiring for the validation gate and readiness state, serialized-transaction fixtures, API surface updates, and env/config additions.
# Conflicts: # .github/workflows/ci.yml # API.md # packages/database/cursor-pagination.test.ts # packages/database/fill-vs-cancel.test.ts # packages/database/leg-kind.test.ts # packages/database/sql/queries.app.test.ts # packages/database/sql/queries.app.ts # packages/node/api.ts # packages/node/event-bus.ts # packages/node/state-machine.ts # packages/tests/grand-e2e/config.ts # packages/tests/lib/batcher.ts
S-1: re-point validation-contexts.characterization.test.ts from the deleted getEarliestRootFirstSeen to getOfferRootTiming, re-pinning the IR identity and the new window_anchor_ms result shape (not just the rename). Its event capture also moves off data.emit onto the event gate, which the state machine now uses. S-2: map the validator's new CROSS_LAYER code to the existing UNSUPPORTED_SHAPE verdict code, forwarding layerSummary()'s text as the reason. Keeps OfferValidationCode a closed enum, so the pinned v1 fixtures stay byte-immutable and FR-006 gets a stable machine-readable verdict instead of an "unavailable" transport failure. Covered by offer-validation.cross-layer.test.ts.
Three test-fixture adaptations where our additions meet upstream's changes: - offer-hash.test.ts, validation-contexts.characterization.test.ts: supply first_seen_at, which upstream's collapsed 000-init.sql made NOT NULL. - unshielded-fill-vs-cancel.test.ts (upstream's): its TTL archive call predates our :expires_at_cutoff! guard and its fixture has no metadata_expires_at, so nothing matched and the archive copied no markers. Supply both; the case's subject — archival preserving exact unshielded identity — is unchanged.
…cher runSolver builds a wallet facade against the indexer and subscribes to its state stream at startup, so an unsequenced solver stacks a third wallet's subscriptions on top of the mint wallet's and can exhaust the packaged indexer's wallet DB pool mid-bootstrap — the exact failure 52f104b gated the batcher against. Upstream gated only the batcher because upstream has no solver. Dev orchestration only.
Adding packages/tests/grand-e2e to the run (upstream put it in the CI list) exposed two races in offer-validation.test.ts that never showed when this file ran alone. Both are test-side; neither is a defect in the route. 1. Upstream's 1 s gate poll (0358d9e) issues getLatestEffectstreamBlock on the API's own connection, which this file counts queries on — and which holdNextApiQuery could trap instead of the validation read it meant to hold. Guarded behind EVENT_GATE_POLL_ENABLED, default true so deployed nodes and CI keep upstream's behavior, and set false only here. Mirrors the existing POST_COMMIT_EVENT_BRIDGE_ENABLED escape hatch. 2. The socket-close case waited only for the held query, then asserted on latestApiSocket — a separate hook with no ordering guarantee. It now waits for both, and the readiness budgets (100-200 ms, sized for this file alone, one of them covering a subprocess spawn) are widened. These are readiness waits, not speed assertions: the expects after them are unchanged, so a real regression still fails. Verified: four consecutive full eight-path runs at 802 pass / 8 fail, the 8 being only the E1 harness tests that need a docker CLI the container lacks (9 pass / 0 fail on the host).
The harness defaulted --platform to linux/arm64: right on the Apple Silicon box it was written on, an `exec format error` on GitHub's amd64 runners. It went unnoticed until this port, because the file only started running in CI when upstream's ci.yml added packages/tests/grand-e2e to the unit-tests job. The scan's subject is an image filesystem, not an architecture, so the default now follows process.arch. Both pinned digests are multi-arch manifests (amd64 + arm64), so either selection resolves. The celestia role still pins linux/amd64 explicitly, on purpose, to mirror the real image. Verified 9 pass / 0 fail on the arm64 host. Forcing linux/amd64 locally runs 8/9, the exception being the fanout case, which gives the scanner a 25 s budget and needs 29.8 s under qemu emulation — on CI that case ran in 825 ms before it hit the arch error, so it has ample margin natively.
Both passed locally and failed on CI for reasons a warm developer machine hides. Neither is a defect in the code under test. E1 image scan: `docker run` writes pull progress to STDERR, and expectClean asserts stderr is empty — the scanner writing nothing to stderr being the actual subject. On a cold runner that assertion saw 32 lines of pull chatter. Both pinned images are now pulled once in beforeAll, which keeps the assertion strict rather than teaching it to ignore noise. Verified by deleting both images locally and re-running: 9 pass / 0 fail. E1 NTP responder: /ntp-stats is an HTTP counter on the harness, the recorder is a separately observed local event stream. The test waited for the counter to reach 8 and then asserted on the stream, which had only 6 (CI run 32129355719). It now waits for the stream too, since the stream is what it asserts on.
…ure fault attribution
…cit surplus disposition
Remediates 00003 P4-F01 (CRITICAL) / 00005 FR-001, SC-001.
The relay only ever promises a taker AT MOST the interpolated output of a
published ladder and then dispatches the taker's OWN demand
(reference relay-ws.ts solverAcceptsPrice `output >= requiredOutput`,
router/jobId.ts sendSwap `quote.requiredOutput`). resolveSwapJobRoute
required `interpolateQuote(levels, amountIn) === amountOut` exactly, so
every reference-valid job with a lowered demand was refused
`route_not_current` and the solver could not settle against the reference
relay at all.
Admission is now `0 < amountOut <= interpolateQuote(levels, amountIn)`.
The maker prefix selection rule is unchanged (largest whole-offer prefix
with `input <= amountIn`); the signed difference to the job is split into
two non-negative fields on ResolvedRoute, never both positive:
residualOut tokenOut the solver PAYS from inventory (demand above the
prefix payout) — Stock payout + reservation + published
residualBound checks unchanged;
surplusOut tokenOut the maker prefix pays OVER the demand and the
solver RETAINS — inflow only, so no Stock reservation
(the reference solver keeps `dy - requiredOutput` the
same way).
The settlement half builds one solver balancing leg: it spends
`residualOut` tokenOut (or nothing at all) and creates outputs to the
solver's own shielded address for the retained `residualIn` tokenIn
and/or `surplusOut` tokenOut. An outputs-only half is a legitimate
unbalanced zswap half; the maker offers balance it on merge. The leg
keeps journal kind RESIDUAL_BUILD and label `residual` in both
directions because restoreRecoveryTargets pairs the unproven leg with
its finalized contribution by that key suffix.
assertInverseHalf is deliberately unchanged: retained value is an output
to the solver's own address inside the same half, so the relay is still
handed exactly the numeric inverse job ({tokenOut: +amountOut,
tokenIn: -amountIn}).
BREAKING CHANGE: observable refusal behavior changes. Jobs with
`0 < amountOut < interpolated(amountIn)` that previously terminated with
`job-error reason=route_not_current` now settle, with the solver
retaining the surplus. Refusal of `amountOut > interpolated(amountIn)`,
of sizes outside the ladder, of non-positive amounts, and every existing
safety refusal (cache currentness, pair/minimum policy, DUST admission,
Stock affordability, exact-file identity) are preserved. Operators
should expect previously-refused lowered-demand jobs to consume maker
offers and settle.
The former strict-equality refusal test is re-encoded, not deleted: it
keeps the above-advertised half of its assertion and is renamed
accordingly, and the accepted half becomes a 12-row route-resolution
acceptance matrix plus four end-to-end executor cases (surplus with
residual input, lowered exact rung, minimum positive demand, residual
payout after lowering).
…ror budgets)
Remediates 00003 findings P4-F02, P4-F03 and P4-F04 (spec 00005 FR-002,
FR-003, FR-004). Before this, the solver advertised liquidity it would
then refuse after a taker's job had already been routed, and the relay
kept quoting the same unexecutable rung afterwards.
P4-F02 — the admission policy evaporated between config and the wire.
run.ts passed supportedPairs/minJobOutput into the relay client's ladder
options, RelayLadderOptions did not declare them, and runPush never
forwarded them, so pairs and sizes the executor refuses were published.
Fixed structurally rather than by adding two fields: a new
@zswap-da/solver-core/admission-policy module owns JobAdmissionPolicy,
ADMISSION_POLICY_FIELDS (with a compile-time exhaustiveness guard over
keyof JobAdmissionPolicy), forwardAdmissionPolicy() driven by that tuple,
and admissionPairKey(). DeriveLadderOptions, LadderPushOptions,
RelayLadderOptions, SwapJobExecutorOptions, resolveSwapJobRoute's options
and SolverAdmissionEnv all extend it, and every per-field spread on the
publication and admission paths is now one forwardAdmissionPolicy() call.
Adding a policy field is now one tuple edit; omitting it is a type error.
P4-F03 — publication had no residual-affordability input. deriveLadder
now takes a spendableInventory snapshot (Stock.available per token, read
once per push) and truncates a pair's rungs at the first one whose
interpolation interval could demand more tokenOut than the solver can
reserve. The requirement is the relay's own arithmetic,
floor(amountOut*(amountIn-1)/amountIn), not the whole-pair residualBound,
which would over-suppress. The first rung is exempt because it opens no
interval, so a solver holding zero tokenOut still publishes it.
P4-F04 — nothing proved the fee-sizing mirror could be funded. buildHalf
opens with initSwap({shielded: {[tokenIn]: amountIn}}), which selects real
coins for the taker's full input out of the solver's own wallet, so an
unfundable job failed part-way through a wallet mutation and an uncertain
revert then quarantined the job, stranding its claim and a capacity slot.
Publication now caps published rung inputs by Stock.available(tokenIn)
(interpolateQuote refuses any size above the last rung's input, so that
tail is the ceiling on a job's amountIn), and resolveSwapJobRoute refuses
JOB_ROUTE_UNAVAILABLE before Stock.reserve and therefore before any
journal row or wallet call. R1 made this more load-bearing: every
lowered-demand job builds the mirror too.
AVAILABILITY / COMPATIBILITY CHANGE — operators will see previously
advertised liquidity withdrawn. Concretely:
* a solver publishes only the ladder prefix its own tokenIn holdings
cover, so a solver holding no tokenIn publishes NOTHING for that pair
however deep the maker book behind it is. Deployments that funded the
solver with tokenOut only must now fund tokenIn as well;
* a rung whose worst-case interval residual exceeds available tokenOut
is withheld, together with every rung above it (a whole-offer
cumulative ladder can be truncated but not punctured);
* an emptied Stock — what an in-flight or failed balance refresh
produces — now withdraws every budget-bounded rung, not just residual
authority. run.ts therefore republishes immediately on both edges of
inventory readiness, so a withdrawal does not lag the executor's
refusals and a recovery does not cost a full push interval after each
settlement.
Withheld liquidity is reported as a loud operator signal
(ladder-budget-limited / ladder-budget-cleared, with counts), kept
separate from the existing cap-truncation signal because funding a wallet
and raising a limit are different remedies.
Budgets are per rung, not aggregated across pairs or concurrent jobs:
that is FR-003's wording, and Stock.reserve remains the atomic aggregate
authority at execution. Absent inventory is OPEN in the pure derivation
so dry-run and derivation tests keep their contract; the live push always
supplies a snapshot and the executor re-checks both numbers.
No safety behavior changes (FR-010): exact-file identity/currentness,
reservation ownership, journal/CAS recovery, quarantine, fail-closed
withdrawal and DUST admission are untouched, and R1's lower-output/
surplus semantics are unchanged.
Two pre-existing tests were amended, both forced by FR-004 and both
documented in place: R1's surplus test funded neither token and is now
"needs no tokenOut inventory" (keeping B at zero, which is the property it
exists for), and the two runSolver integration tests now fund tokenIn.
Tests: 34 new tests across admission-policy (new file), ladder-derivation,
ladder-source, relay-client, swap-job-executor, run and stock, covering
the full publication matrix on the wire including across a reconnect, both
budget boundaries, per-push budget reads, the operator signals, and
executor admission refusing each case independently. Space-free clone:
solver suites 429 -> 463 pass / 0 fail; CI unit-tests command 898 -> 932
pass with the same single pre-existing docker-load flake; typecheck:backend
0 diagnostics with the dependency count unchanged at 23; check:pgtypes and
the docs/api-examples playground unchanged. A strict/noEmit probe over the
solver roots goes 57 -> 56 diagnostics, the one removal being F02's own
run.ts TS2353 — no new strict diagnostics are introduced.
Closes 00003 P4-F05 and P4-F06 (spec 00005 FR-005, FR-006). FR-005 — one explicit solver component. New root `start.solver.ts` (`bun run start:solver`) runs the solver as a single process with no orchestrator: it attaches to an already-running kernel API and Midnight Intents relay. The whole configuration contract lives in the new `packages/solver/src/launch.ts`, which is side-effect free and aggregates EVERY problem into one error instead of failing on the first, before any wallet, socket or journal is touched. Mandatory: MIDNIGHT_NETWORK_ID, ZSWAP_API, SOLVER_RELAY_WS_URL, SOLVER_RELAY_HTTP_URL, SOLVER_RELAY_AUTH_TOKEN (>= 32 chars), SOLVER_JOURNAL_PATH (absolute; :memory: impossible) and SOLVER_SEED — in dry-run as well as live mode, because a rehearsal that leaves half the configuration unvalidated is not one. Three of those have silent developer defaults a deployment must not inherit (the SDK assumes `undeployed`, the API assumes 127.0.0.1:9999, the seed assumes the repository's public dev seed, which is now accepted only on `undeployed`). Mainnet keeps the hardened boundary: SOLVER_DRY_RUN defaults to true there and live settlement still requires the exact SOLVER_MAINNET_LIVE_TRADING_ACK=true, so this entrypoint is not a cheaper route to live trading. The startup banner prints the topology and no secret. SOLVER_ENABLED=false exits 0 without demanding any of it. `start.mainnet.ts` is deliberately UNCHANGED: adding the solver to `start:mainnet` would turn a backend command into a trading command (00003 P5-D02). The per-network entrypoints are untouched. FR-006 — the gate that would have caught this class. New `scripts/solver-typecheck.ts` (`bun run typecheck:solver`, also under a new umbrella `bun run typecheck`, plus a `solver-typecheck` CI job mirroring the backend one). Strict/no-emit over packages/solver, packages/solver-core and packages/validator — production AND test sources — plus every first-party file that imports the solver's source, discovered by scanning rather than from a maintained list (a list is the failure mode P4-F02 already showed). 97 roots, 0 diagnostics, from 65 before this commit. Notable repairs behind that zero: - swap-job-executor: the re-entering awaiting record re-states its narrowed walletTransaction, and `record.reverting` becomes an `in`-guard, which is precisely the previous runtime meaning. No behavior change. - solver-core/wallet.ts: the bech32m cast is documented as a duplicate-scope install artifact (@midnight-ntwrk 3.1.0 vs @midnightntwrk 3.1.2); importing the facade's codec instead would change runtime. - validator: the proof-variant diagnostics 00003 recorded, fixed the way solver-core/api-client.ts already did it. - the legacy grand-E2E solver harness is REPAIRED, not deleted: it is live E1 infrastructure (a unit test imports it, the E1 driver execs it). What was stale is deleted — the `onValidationTrace`/`onOutcome`/`onMatchOutcome` options it passed to runSolver, which SolverOptions has not had since N5 and which were therefore never invoked, plus the ValidationGateTrace type, the recordRealValidationTraceEvidence helper they fed, the offer cache only they populated, and the one test that existed only to drive that helper. Runtime-neutral; a live evidence source is N6's replacement work. Existing safety behavior is preserved (FR-010): exact-file identity and currentness, reservation ownership, journal/CAS recovery, quarantine, fail-closed withdrawal and DUST admission are untouched. Tests: 25 new in packages/solver/launch.test.ts, two of which drive the real root entrypoint as a child process (missing configuration exits 1 listing all seven variables; SOLVER_ENABLED=false exits 0). One grand-E2E test removed with its dead helper. `bun run typecheck:solver` 0 diagnostics, `bun run typecheck:backend` 0 diagnostics (its out-of-gate dependency count drops 23 -> 21 from the validator/wallet fixes), `bun run check:pgtypes` pass, docs playground `tsc -b` and the five api-example bundles exit 0.
Closes 00003 P4-F11's documentation subset (spec 00005 FR-007). README.md — the solver was absent from the Environments table, the project structure and the key-files table, which is half of what made the production topology ambiguous (P4-F05). It now has a row in every one of them, plus a "Running the COW solver" section: `bun run start:solver`, the seven mandatory variables with the reason each one is mandatory, the dry-run/mainnet-ack boundary, the container note, and an explicit statement that neither orchestrator launches the solver and why. Services & ports records that the solver listens on nothing — it is outbound only. Three semantic corrections in that section, each labelled as a change rather than folded into prose: - Supported domain: Midnight 1.x / ledger-v8, single-leg distinct-token shielded offers, at most 8 makers per job, optional shielded residual. Everything else is refused before admission, not partially supported. - Fees: maker offers are built `payFees:false`, so the settling side pays; the solver sizes and funds DUST for what it submits, under the SOLVER_DUST_* budget. - BEHAVIOR CHANGE (from the lower-exact-output commit): a job's amountOut is the taker's exact demand and may sit below the advertised interpolation. Every job with 0 < amountOut <= interpolate(amountIn) now settles, with the maker-prefix difference either paid from inventory (residual) or retained by the solver (surplus). Demands above the advertised output stay refused. Deployments that relied on the old `route_not_current` refusal will see those jobs settle. - AVAILABILITY CHANGE (from the executable-liquidity commit): published liquidity is bounded by what the solver can execute — unaffordable-residual rungs and everything above them are withheld, rung inputs are capped by provably spendable tokenIn (so a solver holding no tokenIn publishes nothing for that pair), the pair/minimum policy now bounds publication as well as admission and survives reconnects, and withheld depth is reported as ladder-budget-limited / ladder-budget-cleared. Operators must fund the solver wallet with BOTH tokens of every pair it quotes. API.md — "Repository validation scope" now describes both strict gates and why the solver gate includes test sources; the solver environment block is corrected (the stale "dry-run does NOT load inventory" line was false since RF3) and completed with the mandatory and policy variables; and a new "The COW solver (Midnight Intents side)" section carries the topology, supported domain, fee ownership, indicative-quote semantics, the job-disposition table and the executability bounds. docs/protocol-scope.md — the unapproved 2026-08-13 scope draft 00003 flagged. It describes a public offer-book market maker that submits directly, which is not this system. Retired in place: a SUPERSEDED banner naming the contradiction and pointing at README/API.md, a Superseded row in its status table, and inline notes on the three decisions affected. Kept rather than deleted because its default-off safety posture is still the one in force and the record of why these decisions were made has value; nothing in the repo references it. .env.mainnet.example — a start:solver block listing what that entrypoint requires, ZSWAP_API, and a "fund both sides" note on SOLVER_SEED. Documentation only; no source or test file is touched by this commit.
… relay, chain, scripts)
Adds `deploy/`: one Docker Compose project in which every component is its own
service running a single process (spec FR-008 / SC-004 static half). The in-repo
orchestrator is never the launcher — `launchMidnight()`/`launchCelestia()` open
by killing whatever holds 9944/8088/6300/26657/26658/3334 (`stopProcessAtPort`),
which in this topology is the sibling services, and their deploy step would mint
a second contract identity while every volume still referred to the first.
Services: midnight-node, proof-server, indexer, celestia, pglite,
offerfiles-deploy (one-shot), kernel, batcher, relay, solver, scripts (profile
`e2e`). One kernel image with six entrypoints; the relay built from the
UNMODIFIED pinned reference.
Chain versions are not chosen here — they are read off this repo's own pinned
dependencies, i.e. what `bun run dev` already runs, all from
effectstream/binaries 0.3.120 and sha256-verified at build time:
midnight-node 1.0.0 @effectstream/npm-midnight-node@0.103.1
proof-server ledger-8.1.0 @effectstream/npm-midnight-proof-server@0.103.1
indexer-standalone v4.3.3 @effectstream/npm-midnight-indexer@0.103.1
celestia-appd v6.4.10 @effectstream/celestia@0.103.1
celestia-node v0.28.4 @effectstream/celestia@0.103.1
Compact CLI 0.30.0 start.dev.ts + contract-offer-files/package.json
bun 1.3.11 the runtime this branch was developed on
Notable decisions, each argued in the file that makes it:
* Celestia runs linux/amd64 (emulated on arm64): release 0.3.120 ships no
celestia linux/arm64 asset. Both binaries were verified to execute under
emulation on this host. Building from upstream Go source is the recorded
fallback, but it would run different binaries than the ones we pin.
* Chain images are on debian:trixie-slim, and each one now proves at BUILD time
that its binary links and starts. celestia-appd v6.4.10 and
indexer-standalone v4.3.3 need GLIBC 2.38, which bookworm (2.36) lacks — a
failure that otherwise only appears minutes into a bring-up.
* A `pglite` service is required. `PGLITE=true` does not mean "embedded":
`launchPglite()` spawns a pg-gateway TCP server wrapping PGlite that the
kernel dials over the Postgres wire protocol.
* No celestia auth token and no shared auth volume: the bridge runs with
`--rpc.skip-auth`, exactly as @effectstream/celestia does.
* The relay is built via a named build context (`reference`), so every line that
reads reference source is marked `--from=reference`. The recipe is a faithful
transcription of the reference's own infra/Dockerfile.relay; it is transcribed
rather than used in place so build-side fixes land here and never there.
* The solver entrypoint deliberately validates nothing about the solver's own
configuration. `start.solver.ts` reports all seven missing boundaries at once
and exits 1; a pre-check would shadow the negative control SC-004 asks for.
* The kernel entrypoints drop empty OPTIONAL variables. Compose renders an unset
variable as "", and `getEnv(x) ?? default` treats "" as a real value — a blank
knob in .env would otherwise silently mean an empty Celestia namespace or an
empty ladder path. The seven mandatory solver variables are excluded.
Shared-host rules are structural: every published port comes from .env, defaults
to >= 10000, and binds ${BIND_ADDR} (127.0.0.1); COMPOSE_PROJECT_NAME namespaces
everything; ./down.sh removes containers, networks AND volumes (all chain-keyed,
so they must go as a set) and prints the proof.
.gitignore gains a scoped un-ignore for deploy/**: the pre-existing "Preview
Docker test setup" block ignores `Dockerfile`, `.dockerignore` and `.env` at any
depth, which would have silently swallowed this deliverable. deploy/.env stays
ignored; only deploy/.env.example is committed.
D1.3 gates: 16/16 pass (compose config renders; all six images build; five
solver fail-fast negative controls driven in-container against the real
entrypoint; disabled-solver clean exit; entrypoints parse; pglite resolver;
Compact artifacts present). Full teardown verified: zero containers, volumes,
networks, images.
Bring-up of the whole stack is D2 and has NOT been performed.
D2 bring-up surfaced three defects that a green build could not catch, plus
the silent empty-ladder trap R2 warned about. All fixes are in deploy/; no
production source and no reference-clone file is touched.
midnight-node could not start. The 0.3.120 release zip is not just a binary:
it carries a `res/` tree (res/cfg/*.toml, res/genesis/*, chain specs) and the
node resolves its config as current_dir()/res/cfg/ -- CFG_ROOT is only ever
set programmatically. D1's image installed the binary and deleted the rest, so
the node panicked at startup with "failed reading default.toml at path
/res/cfg/default.toml". @effectstream/npm-midnight-node hides the requirement
by spawning with cwd set to its extracted directory; nothing in the flag set
hints at it, which is why the `--version` build gate passed on a broken image.
Now res/ is installed to /opt/midnight-node, asserted at build time relative to
a new WORKDIR, and the entrypoint cd's there after checking -- exiting 78 with
an explanation instead of a Rust panic. The proof-server and indexer assets
were checked and are single-file zips, so neither shares the defect.
The stack came up entirely healthy and quoted nothing. Since R2, publication
is bounded by the solver's own spendable inventory, and the ladder derives
from the mirrored maker book -- so an unfunded solver with an empty book
publishes empty capabilities and empty levels forever, with nothing logged as
an error anywhere. Two one-shots now provision both halves, following the
offerfiles-deploy pattern (restart: no, marker-idempotent, downstream
service_completed_successfully), and both fail LOUDLY:
solver-provision runs the repo's own packages/solver/scripts/bootstrap-dev.ts
-- funds solver NIGHT from genesis, registers dust, mints
the solver BOTH tokens of the pair, and regenerates the
ladder config with this chain's real colors. `solver` waits
on its completion, which is not mere ordering: it drives a
wallet on SOLVER_SEED, and two facades on one seed against
one node force each other's connection down.
maker-offer posts ONE real, proven, settle-able offer into the kernel
book via the new deploy/scripts/post-maker-offer.ts.
post-maker-offer.ts exists because neither documented path works: `seed:market`
writes rows whose blob is a placeholder and which its own header calls NOT
settle-able (a ladder built on those would let the smoke pass over a stack that
can never fill anything), and api-examples/10-submit-offer.ts is stale against
the wallet SDK -- it passes a STRING where receiverAddress.coinPublicKey is now
dereferenced, dying with "undefined is not an object". This follows the
maintained path (packages/tests/two-wallet-swap-e2e.ts) and uses the address
OBJECT from wallet.shielded.getAddress(). The api-example itself is source
scope and is left alone; recorded as Q-D2-1.
Token colors derive from the deployed contract address, so they are new on
every stack and mint-test-tokens only PRINTS them. entrypoint-deploy.sh now
publishes minted-tokens.json atomically onto the shared volume beside the
contract address, so downstream provisioning names tokens from a durable
artifact instead of scraping a pruned one-shot's logs.
SOLVER_LADDER_CONFIG now points at the generated ladder on a new chain-keyed
solver-config volume; the checked-in ladders.dev.json names colors from an
older deployment. Publication ignores that file, but engine.ts's direct-fill
path reads it via ladders.maxPayout().
RISK-1 (indexer /api/v3 vs /api/v4) is resolved and was a non-issue:
indexer-standalone v4.3.3 serves the SAME schema at both paths (identical
__schema field sets, both /ws endpoints upgrade, and any other /api/<x>/graphql
308s to /api/v4/<x>/graphql). Each side now uses the path its own codebase
treats as canonical. No code change either way.
Verified: clean-state `docker compose up -d` exits 0 with all services healthy
and three one-shots exited 0; the solver connects to the UNMODIFIED reference
relay and publishes a non-empty ladder observable at the relay -- POST /quote
returns amountOut=500000 for amountIn=750000, exactly the maker offer's terms,
with directional and unknown-pair negative controls returning 503. Static
gates 16/16. Fail-fast negative controls pass through both the real service
entrypoint and the launch contract directly.
…ce relay
Adds the E1 driver (spec 00005 FR-009 / SC-005) and the one deployment fix that
made settlement possible at all. Everything is inside `deploy/`; no product
source is touched.
deploy/scripts/e2e.ts drives four cases against the UNMODIFIED reference relay,
each with its own maker offer because a settled case consumes the one it used:
A demand = the relay's own quote -> settles
B demand = quote x 4/5, strictly BELOW it -> settles, solver keeps
the surplus
C demand = quote x 6/5, ABOVE it -> refused, nothing moves
D demand = quote, on the offer C was refused -> settles
B is the real-boundary proof for FR-001 / P4-F01, and it is producible on the
real wire because POST /intent reads the demand out of the taker's own
transaction deltas (TxProcessor.extractQuote) rather than from the quote it
issued: a solver qualifies when interpolateQuote(levels, dx) >= requiredOutput,
so a lower demand is forwarded verbatim and a higher one leaves no qualifying
solver. quoteId pins the solver, not the amount.
D is C's control. Without it, "the relay refused" and "the relay was accepting
nothing right then" are the same observation, because a bare 503 at /intent is
also what a ladder withdrawal window answers.
FIX: the devnet's Celestia block cadence made settlement impossible.
@effectstream/celestia starts celestia-appd with --delayed-precommit-timeout 1s
(~1 block/s), against which the kernel's Celestia projection holds a persistent
~11-block deficit — while deriveSyncStatus calls anything past
MAX_CELESTIA_LAG_BLOCKS = 4 "syncing" and requireCurrentBackend refuses. So
POST /v1/offers/files, the read the solver makes at job time to rebuild a
maker's exact bytes, answered 60/60 503 FILES_UNAVAILABLE and every dispatched
swap died exact_files_unavailable — with GET /v1/health reporting "ok", because
its cached external tips are up to 60 s old. entrypoint.sh now takes
CELESTIA_BLOCK_TIME (default 3s, the value this repo's own acceptance harness
pins for exactly this reason, citing its e2e open question E1-Q4), and the
kernel/batcher get CELESTIA_POLLING_INTERVAL_MS=1000. After the change the same
probe is 20/20 x 200.
Also: the Compact compile's inputs are copied ahead of the rest of the source in
the kernel image, so editing a script no longer invalidates proving-key
generation.
Recorded for the user as Q-E1-1: `bun run dev` still produces a stack on which
no swap can settle.
… stand-in
The solver no longer needs to hold — or momentarily reserve — any of the
taker's input token to size a DUST fee (00006 FR-001/FR-002).
BEHAVIOUR CHANGE (fee sizing). `buildHalf` used to open with a MIRROR: an
`initSwap` selecting the taker's full `amountIn` of tokenIn out of the solver's
own wallet, immediately reverted, handed to `dust.balanceTransactions` as the
taker-half stand-in. It is replaced by `buildTakerHalfStandIn` — a fabricated
unproven zswap transaction with the taker half's exact shape, built over a
throwaway keypair and a throwaway ZswapLocalState inside the call. Measured off
ledger-v8 8.1.0: the DUST fee is a function of the merged transaction's
STRUCTURE only (element counts), not of coin values, token types or owners, and
is proof-erasure neutral — so a fabricated same-shape half prices the same. The
reserved amount over the merged transaction the balancer actually prices is
delta EXACTLY 0 versus the mirror's shape at 1, 2 and 3 modelled inputs
(2 779 466 641 196 585 SPECKs at the default).
Consequences:
* fee sizing spends, reserves and mutates nothing: one fewer
mutate-then-revert wallet class per job, and no `WalletMutationUncertain`
can originate there;
* new jobs write NO `MIRROR_RESERVATION`/`MIRROR_REVERT` journal rows. Both
kinds stay in the journal grammar and both recovery filters keep their
`MIRROR_RESERVATION` arms so journals written before this change still
recover their real reserved coins (FR-004);
* `dustAdmission` accounting is untouched — `estimateDustAmount` still reads
the amount off the balancer's own transaction (FR-002).
THE n + 2 MODELLING RULE. The real taker half's zswap input count is decided by
the taker's own smallest-coin-first selection and is unknowable to the solver,
so it is now an explicit parameter instead of whatever the solver's coin
selection happened to produce. Measured: a stand-in modelling `n` inputs funds a
real taker half of up to `n + 2` zswap inputs; each extra modelled input costs
+12…14% more DUST, and that DUST is actually SPENT, not merely reserved.
`SOLVER_FEE_SIZING_TAKER_INPUTS` (default 1, bounded [1, 64]) exposes it; the
default reproduces the shape 00005's deployed E2E observed, so the reserved DUST
is unchanged for the proven case. A malformed value is a listed `start:solver`
launch problem, and the startup banner prints the effective coverage.
Also: `startSwapJobExecutor` now requires `networkId` (the stand-in is a real
ledger transaction and the ledger only refuses a network-id mismatch at merge
time, so it is asserted at boot rather than per job), and `runSolver` refuses an
unrecognized `MIDNIGHT_NETWORK_ID` before building a wallet.
The tokenIn publication bound and its admission guard are deliberately RETAINED
unchanged; removing them is 00006-R2's scope (FR-003).
AVAILABILITY RESTORED — publication is no longer capped by solver tokenIn;
the 00005-R2 cap is superseded by capital-free fee sizing.
00005-R2 bounded both publication and admission by the solver's spendable
tokenIn (finding P4-F04), because `buildHalf` opened every job with a fee-sizing
MIRROR that selected real coins for the taker's FULL `amountIn` out of the
solver's own wallet and reverted them immediately. The consequence was that a
solver holding no tokenIn published NOTHING for that pair, however deep the
maker book behind it was. 00006-R1 replaced the mirror with a fabricated
same-shape stand-in built from ledger primitives, so fee sizing spends no
tokenIn at all and the cap protected nothing. R2 removes it at both layers
(spec 00006 FR-003):
* `deriveLadder` no longer reads the pair's tokenIn entry from
`spendableInventory`, and the `"mirror-budget"` exclusion reason is gone
from the union (nothing produces it);
* `resolveSwapJobRoute` no longer refuses a job whose `amountIn` exceeds
`stock.available(tokenIn)`;
* the relay client's `ladder-budget-limited` detail loses `mirrorBudgetOffers`
(event kinds unchanged, so operator alerting still fires).
KEPT BYTE-FOR-BYTE: the F03 residual tokenOut budget at publication and its
defense-in-depth twin at admission, policy propagation through
`forwardAdmissionPolicy`, the fail-closed empty-withdrawal push,
`unavailableOfferHashes`, and the republish-on-inventory-edges hook in `run.ts`
— still load-bearing, because an emptied Stock still withdraws every interior
rung. The `Stock.spendable()` snapshot and its plumbing stay for the same reason.
Every 00005-R2 test that pinned the cap is RE-ENCODED as the new behaviour's
control rather than deleted, one for one, with the old expectation quoted in
place: the zero-tokenIn wallet publishes the FULL whole-rung ladder; zero
tokenIn AND zero tokenOut publishes each pair's whole-maker first rung and no
interior; tokenOut-funded interior rungs are budget-bounded exactly as before
(asserted as an equality over the whole tokenOut matrix). The executor and route
harnesses now default to a wallet holding NO tokenIn, so every test in
`swap-job-executor.test.ts` is a standing SC-002 control alongside R1's standing
SC-001 one.
Docs (FR-006 doc half): README, API.md, deploy/README.md, deploy/.env.example,
.env.mainnet.example, deploy/scripts/README.md and the deploy entrypoint
comments now state that the solver needs NO token inventory for whole-maker
rungs and tokenOut only for interior residuals, and document
SOLVER_FEE_SIZING_TAKER_INPUTS with the measured `n + 2` coverage rule and the
+12-14%-real-DUST-per-extra-input cost. deploy/ behaviour is unchanged on
purpose: 00006-V1 needs the funded provisioning path intact as the control for
its unfunded rerun, so the new variable is documented but not yet forwarded by
compose.yml.
… at all
00006-V1 / spec FR-006, SC-004. Proves at the real chain boundary what R0-R2
argued and unit-tested: a solver holding NIGHT/DUST and ZERO of every swap
token publishes its whole-maker ladder at the unmodified reference relay and
settles jobs through it.
Deployment (all inside deploy/; no product source touched):
* provision-solver-fees.ts — NEW. The fee-currency-only counterpart to
packages/solver/scripts/bootstrap-dev.ts, which stays unchanged as the funded
control. Same NIGHT funding and dust registration; the ladder config's token
colors are READ from the deploy one-shot's minted-tokens.json instead of
derived by minting; nothing is minted or transferred. It writes a receipt of
the solver wallet's measured balances and fails the one-shot if any swap
token is present. SOLVER_PROVISION_MINT_TOKENS chooses between the two modes —
distinct from SOLVER_PROVISION_ENABLED=false, which skips fee currency too.
* SOLVER_FEE_SIZING_TAKER_INPUTS is now wired (R2's handoff item 1). Both halves
were required: compose.yml forwards it AND entrypoint-common.sh unsets it when
blank, because its parser treats "" as malformed rather than as unset. The
three SOLVER_DUST_* names get the same treatment, and DUST admission is now
configured — that is the only way the amount estimateDustAmount computed
becomes observable (reserveDust writes it to journal_dust_reservations), and
the limits are far above the measured per-job estimate so it records rather
than gates.
* e2e.ts asserts the capital-free premise from a MEASUREMENT rather than from
configuration, observes the token-less solver's ladder at the relay, and
records design-note §6's four live-chain numbers per settled job, including
the same stand-in shape priced in-process with initialParameters() so the
offline-vs-live delta is two numbers from one run.
* read-wallet.ts gains EXPECT_SHIELDED_ONLY, which turns the surplus gate into
the decisive proof: after three settlements the solver's ENTIRE shielded
wallet is the case-B surplus and nothing else.
* gates.sh: G3c pinned to a valid fee-sizing value (it bypasses the entrypoint
by design, so it never gets the empty-optional unset pass), plus a new G3f
that keeps the entrypoint IN the loop and asserts that four blank strict
optionals still produce exactly the seven mandatory launch problems.
Three driver defects found and fixed by running it, all of one species — a
single-sample read of an eventually-consistent projection:
1. "accepted first try" tested for a null error_code, but a healthy settlement
carries BACKEND_EVIDENCE_UNKNOWN while the kernel's Celestia-lagged
projection catches up. Narrowed to the RELAY_FAILURE_* family.
2. The per-case journal assertion (inherited from E1) sampled once and lost the
same race. Now polled to a terminal state within a bounded timeout.
3. The provisioner read unshielded NIGHT immediately after registerNightForDust
spent those UTXOs, so it recorded 0 on one run and 2e13 on another. It now
measures the confirmed balance before registration.
Results, definitive clean-state run: DRIVER_FINAL_EXIT=0 over 67 assertions.
Cases A/B/D settle with exact taker credits (500000, 400000, 500000 against a
500000 quote, each debited exactly 750000); case C is refused three times with a
live ladder on both sides and leaves no journal row; 11 journal rows all SETTLED
with empty claim payouts and no MIRROR_* row anywhere; solver wallet ends at
exactly {tokenOut: 100000} and nothing else. Live reserved DUST 3.61e14 /
3.40e14 / 3.16e14 SPECKs against an offline prediction of 2.78e15 under
initialParameters() — the live devnet prices the structure far cheaper, and the
same image reproduces R1's pinned offline constant 1240985184479904 exactly, so
the gap is ledger parameters and nothing else. Taker halves 15479-15480 bytes,
matching 00005-E1's single-input baseline.
Evidence: experiments/00006-evidence/v1/ (SHA256SUMS
14c8dc574be0bfbe408fe18a180d28cdc9783eabeb08d4477bcd11022c7b6849).
Static gates 17/17. Full teardown including images verified twice.
…iation-compose-e2e fix(solver)!: midnight-intents-swaps conformance — lower exact-output + executable liquidity + compose E2E (stacked on #48)
acedward
marked this pull request as ready for review
September 1, 2026 17:53
…ree-fee-sizing feat(solver): capital-free fee sizing — lift the tokenIn publication cap (follows #52)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft — the working PR for the COW-Solver project (workspace item
00001-zswap-posted-price-solver). Supersedes #38, which was closed after drifting out of sync with other work onmain.Status
NO-GO for real funds stands. RF1–RF6 are implemented; the RF7B F1/Q-RF-8 recovered-wallet-revert blocker was corrected fail-closed in RF1B at
a74b945, and RF7A revalidation is pinned at381022d. Hosted Linux run32522704033is SUCCESS at that exact SHA. The same-file independent RF7B re-audit and later explicit user release decision remain pending. PR #48 remains DRAFT.What this branch carries
Built on the solver foundation from #38 (posted-price solver over the offer book,
packages/solver+packages/solver-core), plus:POST /v1/offers/files(see breaking changes below), plus N5's job-time exact-file binding in the relay executor; the obsolete pre-match validation gate is removed.These remove endpoints this branch itself added — they were never on
main, so nothing released is affected — but any deployment or client tracking this branch must be updated:POST /v1/solver/levels,GET /v1/solver/levels, and the backend solver-levels registryGET /v1/solver/liquidity(grouped read-only source)GET /v1/quote/v1/quotekeeps exactly one price source (token-prices, with the demo fallback)POST /v1/offers/validatePOST /v1/offers/files— identities in, exact indexed bytes out for the live+valid ones, stable machine-readable refusals for the restSOLVER_LIQUIDITY_READ_AUTH_SECRET,SOLVER_LEVELS_TTL_SECONDS,SOLVER_LEVELS_QUOTE_ENABLED,SOLVER_LEVELS_AUTH_KEYS/SOLVER_LEVELS_AUTH_SECRET(node);SOLVER_ENABLE_LEVELS_PUBLICATION,SOLVER_LEVELS_PUSH_INTERVAL_MS,SOLVER_LEVELS_TTL_SECONDS(solver)OFFER_VALIDATION_TIMEOUT_MS→OFFER_FILES_READ_TIMEOUT_MS(same bounds)SOLVER_LEVELS_AUTH_TOKEN,BATCHER_SUBMIT_URL, and theSOLVER_ENABLE_PATH_B/cycles/residual flagsSOLVER_RELAY_WS_URL,SOLVER_RELAY_AUTH_TOKEN,SOLVER_RELAY_MAX_PARALLEL_SWAPS, relay cadence, status-poll and settlement-TTL settings; the relay merges/submitsWhy: the integration with the Midnight Intents relay was verified directly against its repository. The relay answers
POST /quoteby interpolating ladders the solver pushes to it over the RFQ WebSocket, and the same ladders gate job admission. The Offer Files backend therefore holds no solver state, never pushes, and serves every client alike; what a solver actually needs from it is the maker bytes it is about to settle with, under a validated guarantee — which isPOST /v1/offers/files.The exact-files read reuses the canonical validation engine unchanged (shared structural validator, ordered liveness descriptors, native proof verification, bracketed by committed state anchors). Contract tests drive the batcher's admission, HTTP submission, STM ingestion, and the read over one shared fixture matrix so their codes cannot drift.
N5 landed (
420aee0): the inert pre-match validation gate and direct-submission executor are deleted.runSolvernow starts the relay client beside the independent mirror, fetches exact maker files only after a numeric job arrives, returns only a proved inverse half, and ownstx-submitted/submit-failedrollback plus missed-signal recovery.N6 landed (
cb15fc1): EN1 wire-recorded ladder provenance/reconnect, EN2 direct Celestia → backend pull-only cache → relay push with split-horizon isolation, EN3 the complete external refusal/order/revert corpus, and EN4 the real chain-backed maker+taker merge/submission/finality/wallet/nullifier loop. EN5 reran stories in 4→3→2→1 order and closed Docker failure/signal teardown discipline. Local exact-candidate gates: 817 pass / 2 intentional skips / 0 fail / 8,943 assertions, docs and pgtyped green.N7 landed (
8ef6e58): closes both non-blocking final-audit findings. Every abnormal installedonSwapoutcome now attempts stable, generation-boundJOB_EXECUTION_UNAVAILABLEon the delivering socket and never a replacement; wire regressions cover resolved undefined, malformed results, synchronous throw and asynchronous rejection. EN4 now requires the exact finalized callmidnight.sendMnTransaction, with a toothful wrong-call negative control. Local gates: focused 58/0, chain-backed EN4 PASS with exact call/markers/wallet deltas, complete Docker set 822 pass / 2 intentional skips / 0 fail / 8,953 assertions, docs/type/bundles green, and settled owned-resource cleanup zero.Known state vs
mainThe planned R2 port is complete, current
mainis contained, and RF1–RF6 real-funds hardening is on this branch. RF1B/Q-RF-8 now uses a durable pre-call recovery-revert authority and retains outcome-ambiguous work without retry or release. RF7A candidate381022dextends the single combined release runner across finalized and unproven recovery crashes, cross-process mutation counters, two later reopens, relay/PGlite evidence, and the existing negative controls. Local revalidation is green apart from the documented Docker Desktop nested-scanner exception, which exact hosted Linux run32522704033closes with SUCCESS; independent re-audit remains the release gate.Remaining before undraft
381022d(hosted run32522704033is SUCCESS)Relay job execution now requires
SOLVER_JOURNAL_PATHto name an absolute SQLite path on a persistent mounted volume. Provision one volume per solver instance before upgrading; never share the journal file between instances. Startup fails closed when the path is missing, unwritable, corrupt, locked, full, or schema-incompatible.:memory:is test-harness-only and is rejected by production runtime configuration.RF2 landed (
438e2c6)RF2 replaces in-memory receipt interpretation with durable relay/backend evidence reconciliation: the solver persists the relay job id, terminal state, finalized Substrate extrinsic hash, and the independently sourced Midnight ledger transaction hash/height; lost WebSocket terminal frames recover through bounded
GET /jobs/:jobId; and the side-effect-free versionedGET /v1/offers/:hash/consumptionroute proves only complete, uniform shielded consumption. The two transaction-hash domains are never equality-compared. Unknown, split, conflicting, or markerless evidence remains quarantined, andoffer_consumedis wake-only. The obsolete solver-to-batcher network settlement/parser/probe surface is now test support; production retains only pure transaction algebra.Relay execution now also requires
SOLVER_RELAY_HTTP_URLto be configured as an explicit absolutehttp:orhttps:relay base URL. It is validated independently and is never derived fromSOLVER_RELAY_WS_URL. Deploy the additive backendGET /v1/offers/:hash/consumptionread before upgrading the solver consumer, and roll back in reverse order; the solver fails closed against an older or malformed backend. This requirement is in addition to RF1s mandatory persistentSOLVER_JOURNAL_PATH. PR #48 remains DRAFT and the real-funds decision remains NO-GO pending RF3–RF7.RF3 landed (
896c431)RF3 adds exact economic-admission grammar and enforcement.
SOLVER_SUPPORTED_PAIRSis a strict JSON allowlist of unique lowercase64hex->64hexdirections;SOLVER_MIN_JOB_OUTPUTis a strict per-output-token JSON minimum map; andSOLVER_DUST_MAX_PER_JOB,SOLVER_DUST_MAX_PER_WINDOW, andSOLVER_DUST_WINDOW_MSform one all-SET/all-UNSET group. SET pair/minimum policy is identical at ladder publication and job admission. DUST fee estimates reserve atomically in journal schema v2 beforeswap-tx, survive restart/concurrency, become rolling-window spend only after proved settlement, and release only after proved revert. Window refusal immediately withdraws every ladder; malformed/partial configuration fails startup. Under the approved Q-RF-2 compatibility amendment, a wholly UNSET group remains OPEN but logs contained startup and periodic[ADMISSION]warnings.Mainnet dry-run now requires a non-dev
SOLVER_SEED, opens and syncs the real wallet, and loads a read-only Stock snapshot so admission matches the funded path. It starts no relay job executor/socket and invokes no mutating wallet method. Operators must provision wallet/indexer/proof connectivity for dry-run as well as live mode. This is additive to RF1s persistentSOLVER_JOURNAL_PATHand RF2s explicitSOLVER_RELAY_HTTP_URL; PR #48 remains DRAFT and real-funds release remains NO-GO pending RF4–RF7.RF6 landed (
6859cf9)RF6 re-proves the accepted Effectstream 0.103.1 fail-stop mode against the real pinned runtime/PGlite executor: JavaScript write-then-throw and SQL-constraint faults both roll back the entire block, preserve no partial application/result/block rows, and retain the scheduled input.
README.mdnow carries the operator sequence for detection, stop, read-only inspection, reviewed remediation, isolated recovery, and replay verification; it explicitly forbids normalizing ad-hoc input deletion. The upstream per-input SAVEPOINT/rejected-promise isolation brief remains local and unsent.Integrity is preserved, but one poison scheduled input can halt backend progress until operator action. This branch does not fork Effectstream and does not claim per-input isolation. Keep PR #48 DRAFT and real-funds release NO-GO pending RF7 evidence, independent audit, and an explicit user decision. Previous RF1/RF2/RF3 deployment warnings remain additive and binding.