Skip to content

COW-Solver - #48

Merged
acedward merged 67 commits into
mainfrom
feat/cow-solver
Sep 2, 2026
Merged

COW-Solver#48
acedward merged 67 commits into
mainfrom
feat/cow-solver

Conversation

@acedward

@acedward acedward commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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 on main.

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 at 381022d. Hosted Linux run 32522704033 is 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:

  • Fail-closed safety remediation for the 46-finding deep review: atomic stock reservations, exact bigint residuals, one shared ladder schema (rejects convex/empty/non-ascending), fail-closed imbalance guard, declared-segment enumeration, strict batcher acknowledgement schemas, config domain validation, absolute deadlines with fail-closed status handling, callback containment, and default-off switches for everything beyond single-offer SHIELDED Path A.
  • Validation engine (V0–V3): shared liveness boundary and the canonical validate-for-use pipeline, now served by 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.
  • Backend discovery (D0–D2): currentness characterization, strict health client, and pull-only full-fetch + sequenced WebSocket mirror.
  • Relay execution + acceptance (N3–N7): deterministic cache-derived ladders, outbound authenticated relay client, job-time exact maker bytes, inverse-half proof with solver-funded DUST, per-job rollback cache/TTL sweep, backend consumption recovery, relay-first shutdown, and external EN1–EN5 acceptance against the pinned relay/real chain topology.
  • Liquidity + lineage (L0–L5, Story 2): superseded by the verified relay integration and REMOVED from this branch (see breaking changes below). The recorded evidence stays in the workspace plan.
  • E1 real-service harness: recording/fault/SSE foundation, real actors/publisher/settlement verifier, secret and proof-log scans, split-topology runner.

⚠️ Breaking changes on this branch (2026-08-20)

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:

Removed Replacement
POST /v1/solver/levels, GET /v1/solver/levels, and the backend solver-levels registry none — solver ladders go to the Midnight Intents relay's RFQ WebSocket
GET /v1/solver/liquidity (grouped read-only source) none — the relay pulls nothing from this backend
quote-from-levels sourcing in GET /v1/quote none — /v1/quote keeps exactly one price source (token-prices, with the demo fallback)
POST /v1/offers/validate POST /v1/offers/files — identities in, exact indexed bytes out for the live+valid ones, stable machine-readable refusals for the rest
env: SOLVER_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_MSOFFER_FILES_READ_TIMEOUT_MS (same bounds)
solver direct-submission/pre-match config: SOLVER_LEVELS_AUTH_TOKEN, BATCHER_SUBMIT_URL, and the SOLVER_ENABLE_PATH_B/cycles/residual flags SOLVER_RELAY_WS_URL, SOLVER_RELAY_AUTH_TOKEN, SOLVER_RELAY_MAX_PARALLEL_SWAPS, relay cadence, status-poll and settlement-TTL settings; the relay merges/submits

Why: the integration with the Midnight Intents relay was verified directly against its repository. The relay answers POST /quote by 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 is POST /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. runSolver now 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 owns tx-submitted / submit-failed rollback 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 installed onSwap outcome now attempts stable, generation-bound JOB_EXECUTION_UNAVAILABLE on 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 call midnight.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 main

The planned R2 port is complete, current main is 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 candidate 381022d extends 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 run 32522704033 closes with SUCCESS; independent re-audit remains the release gate.

Remaining before undraft

  • Same-file independent RF7B re-audit against exact RF7A candidate 381022d (hosted run 32522704033 is SUCCESS)
  • Explicit user release decision after a passing audit, with all production admission controls SET and every RF1/RF2/RF3/RF6 deployment requirement accepted

⚠️ BREAKING DEPLOYMENT REQUIREMENT — RF1

Relay job execution now requires SOLVER_JOURNAL_PATH to 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 versioned GET /v1/offers/:hash/consumption route proves only complete, uniform shielded consumption. The two transaction-hash domains are never equality-compared. Unknown, split, conflicting, or markerless evidence remains quarantined, and offer_consumed is wake-only. The obsolete solver-to-batcher network settlement/parser/probe surface is now test support; production retains only pure transaction algebra.

⚠️ BREAKING DEPLOYMENT REQUIREMENT — RF2

Relay execution now also requires SOLVER_RELAY_HTTP_URL to be configured as an explicit absolute http: or https: relay base URL. It is validated independently and is never derived from SOLVER_RELAY_WS_URL. Deploy the additive backend GET /v1/offers/:hash/consumption read 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 persistent SOLVER_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_PAIRS is a strict JSON allowlist of unique lowercase 64hex->64hex directions; SOLVER_MIN_JOB_OUTPUT is a strict per-output-token JSON minimum map; and SOLVER_DUST_MAX_PER_JOB, SOLVER_DUST_MAX_PER_WINDOW, and SOLVER_DUST_WINDOW_MS form 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 before swap-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.

⚠️ BREAKING DRY-RUN DEPLOYMENT CHANGE — RF3

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 persistent SOLVER_JOURNAL_PATH and RF2s explicit SOLVER_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.md now 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.

⚠️ ACCEPTED AVAILABILITY RISK — RF6

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.

acedward added 30 commits August 7, 2026 19:24
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.
…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.
acedward and others added 4 commits August 31, 2026 14:42
… 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
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)
@acedward
acedward merged commit 67bd6d6 into main Sep 2, 2026
5 checks passed
This was referenced Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant