Skip to content

fix(solver)!: midnight-intents-swaps conformance — lower exact-output + executable liquidity + compose E2E (stacked on #48) - #52

Merged
acedward merged 7 commits into
feat/cow-solverfrom
codex/00005-cow-solver-remediation-compose-e2e
Sep 1, 2026
Merged

fix(solver)!: midnight-intents-swaps conformance — lower exact-output + executable liquidity + compose E2E (stacked on #48)#52
acedward merged 7 commits into
feat/cow-solverfrom
codex/00005-cow-solver-remediation-compose-e2e

Conversation

@acedward

Copy link
Copy Markdown
Contributor

Stacked on #48 (base: feat/cow-solver). Makes the COW solver conformant with the shieldedtech/midnight-intents-swaps Phase 1 relay (pinned 061f4d3), remediating the findings of the independent 00003 static conformance assessment, and adds a split-component Docker Compose deployment plus a cross-stack E2E that proves settlement end to end.

⚠️ BREAKING CHANGE — lower exact-output semantics (P4-F01, CRITICAL)

The solver previously required a dispatched job's amountOut to equal the interpolated ladder output exactly and refused reference-valid jobs as route_not_current. It now accepts any job with 0 < amountOut <= interpolated(amountIn), pays the taker exactly amountOut, and retains the surplus — matching the reference relay's admission (output >= requiredOutput) and the reference solver's pay-exact-demand behavior. Jobs that used to terminate job-error route_not_current now settle and consume maker offers. All other refusals (above-advertised, out-of-ladder, currentness, pair/minimum policy, DUST, Stock, exact-file identity) are preserved. (ef4b222)

⚠️ AVAILABILITY CHANGE — publish only executable liquidity (P4-F02/F03/F04, HIGH)

One typed JobAdmissionPolicy (supportedPairs, minJobOutput) now flows whole from config through publication to executor admission (it was previously dropped at RelayLadderOptions, so the solver advertised pairs it refused). Ladder publication is now bounded by per-push Stock budgets: rungs whose interpolation interval could demand unaffordable residual tokenOut are withheld, and published rung inputs are capped by spendable tokenIn (the fee-sizing mirror must spend the full amountIn). Executor admission re-checks and refuses JOB_ROUTE_UNAVAILABLE before any journal row or wallet mutation. Operators will see previously advertised liquidity withdrawn — a solver holding no tokenIn publishes nothing for that pair, however deep the maker book. Withheld liquidity is reported via new ladder-budget-limited/ladder-budget-cleared events. (c4ac2bb)

Explicit solver topology + strict typecheck gate (P4-F05/F06/F11-docs)

  • New root entrypoint bun run start:solver (start.solver.ts + side-effect-free packages/solver/src/launch.ts): a single process that validates all seven mandatory env boundaries in one pass and exits non-zero listing every problem before touching a wallet, socket, or journal. start:mainnet deliberately still does not launch the solver (no-auto-trading safety); the mainnet dry-run + SOLVER_MAINNET_LIVE_TRADING_ACK boundary is preserved. (360d216)
  • New bun run typecheck:solver CI gate: strict/no-emit over packages/solver, packages/solver-core, packages/validator (tests included) plus every first-party file importing solver source — 0 diagnostics across 98 roots (from 65). The legacy grand-e2e harness was repaired by deleting three runSolver callbacks removed since N5 (runtime-neutral). (360d216)
  • README/API.md now document the executable topology, supported domain (Midnight 1.x / ledger-v8, single-leg distinct-token shielded, ≤ 8 makers), payFees:false fee ownership, and both changes above; the superseded docs/protocol-scope.md is retired in place. (ff69c88)

Split-component Docker Compose deployment (deploy/)

13 services, every component its own single-process container — the in-repo orchestrator launches nothing: midnight-node 1.0.0, proof-server ledger-8.1.0, indexer 4.3.3, celestia 6.4.10/0.28.4 (all sha256-pinned from effectstream/binaries 0.3.120), pglite, offerfiles-deploy (one-shot), kernel, batcher, relay (built from unmodified midnight-intents-swaps source), solver, plus devnet-gated provisioning one-shots and an E2E scripts service. All host ports parameterized ≥ 10000 on 127.0.0.1. (7357513, a38669e)

Note: this required a scoped .gitignore exception (!deploy/**) — the pre-existing root patterns ignore Dockerfile/.env/.dockerignore at any depth and would have swallowed the deliverable.

Cross-stack E2E (deploy/scripts/e2e.ts)

Against the running stack and the unmodified reference relay — 51 assertions, exit 0:

  • A (exact): maker zswap posted in the kernel → solver ladder at the relay → taker POST /quote + POST /intent → solver settles consuming the kernel offer; taker credited exactly 500000 for 750000 in; offer consumed on-chain.
  • B (lower demand — the P4-F01 proof at the real boundary): demand 400000 against a 500000 quote → settled; taker credited exactly 400000; solver retained exactly the 100000 surplus (asserted on the solver wallet with the solver stopped). Also verifies empirically that the wallet facade accepts the outputs-only surplus leg.
  • C (above-advertised): demand 600000 → 503 no_solver, zero wallet mutations, zero journal rows — with D as the control settling the very offer C was refused against.
  • No leaked claims: no non-terminal journal rows, nothing quarantined, Stock claims empty.

One blocker found en route was a kernel devnet-config defect, not a solver defect: the packaged 1 s Celestia cadence keeps the projection outside MAX_CELESTIA_LAG_BLOCKS=4, so no swap can settle on a stock bun run dev stack. Fixed inside deploy/ (3 s cadence); the dev launcher itself is left for follow-up.

Verification

  • Full suite green at head: 488/0 solver+solver-core tests (33 files), 948 pass / 2 skip across the CI unit set (single pre-existing shared-docker flake), typecheck:solver 0/98, typecheck:backend 0, check:pgtypes pass.
  • Independent final gate audit: PASS WITH FINDINGS, 0 blockers (three LOW documentation findings, corrected) — the auditor re-derived the R1 semantics against reference source and re-ran the static suites.

Known limitation: revertTransaction on a no-coin leg is implemented but unexercised (failure path never hit in E2E).

…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
acedward marked this pull request as ready for review September 1, 2026 17:53
@acedward
acedward merged commit 773e70c into feat/cow-solver Sep 1, 2026
5 checks passed
acedward added a commit that referenced this pull request Sep 2, 2026
…ree-fee-sizing

feat(solver): capital-free fee sizing — lift the tokenIn publication cap (follows #52)
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