Skip to content

feat(solver): capital-free fee sizing — lift the tokenIn publication cap (follows #52) - #53

Merged
acedward merged 3 commits into
feat/cow-solverfrom
codex/00006-solver-capital-free-fee-sizing
Sep 2, 2026
Merged

feat(solver): capital-free fee sizing — lift the tokenIn publication cap (follows #52)#53
acedward merged 3 commits into
feat/cow-solverfrom
codex/00006-solver-capital-free-fee-sizing

Conversation

@acedward

@acedward acedward commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Follows #52 (base: feat/cow-solver, parent c37bfa6). Makes the COW solver capital-free: it no longer needs to hold any swap-token inventory to publish and settle the whole-maker rungs of every pair its Offer Files book backs. This restores the availability that #52's executable-liquidity change (c4ac2bb) withdrew, where a solver holding no tokenIn published nothing for that pair however deep the maker book behind it.

⚠️ AVAILABILITY CHANGE — advertised depth GROWS on upgrade

The tokenIn publication cap and its executor admission guard are removed (66c9014). After upgrading, a solver publishes every whole-maker rung of every pair regardless of its own token wallet, and resolveSwapJobRoute no longer refuses a job whose amountIn exceeds stock.available(tokenIn). Operators will see previously withheld liquidity appear at the relay.

What stays, byte-for-byte: the F03 residual tokenOut budget at publication and its executor twin. tokenOut still only buys interior (interpolated) sizes between rungs, so a wallet with zero tokenOut publishes each pair's first rung and no more, and the executor still refuses an unaffordable residual fail-closed before any wallet call. forwardAdmissionPolicy, the fail-closed empty-withdrawal push, unavailableOfferHashes, Stock.spendable() and the republish-on-inventory-edges hook are all unchanged.

Capital-free DUST fee sizing (7812e43)

The fee-sizing MIRROR — which did a real initSwap of the job's full amountIn out of the solver wallet and then reverted it — is replaced by a synthetic taker-half stand-in built from ledger primitives over a throwaway keypair (@zswap-da/solver-core/fee-sizing). The DUST fee is a function of the merged transaction's structure only, so the stand-in prices identically: over the merged transaction the balancer prices, the reserved DUST is exactly what the mirror's shape reserved at 1, 2 and 3 modelled taker inputs (delta 0). Fee sizing now performs no coin selection, holds no reservation, mutates no wallet state and needs no revert or journal row.

  • New env knob SOLVER_FEE_SIZING_TAKER_INPUTS (default 1, bounded [1, 64], malformed value is a listed start:solver launch problem). Modelling n inputs funds a real taker half of up to n + 2. That cushion is parameter-dependent: it was measured against LedgerParameters.initialParameters(); the V1 devnet priced structure 40–150× cheaper (live reservation ~8× smaller than the offline figure), so coverage there is far wider, and a chain more expensive than initialParameters() is un-probed. Raising n spends 12–14% more real DUST per input.
  • Legacy MIRROR_* journal rows still recover after the mirror is gone, pinned by a named test.

Observable removals / changed outcomes to be aware of

  • ladder-budget-limited event detail loses mirrorBudgetOffers. Event kinds (ladder-budget-limited / ladder-budget-cleared) are unchanged so kind-keyed alerting still fires; only a consumer reading detail.mirrorBudgetOffers would notice. The residual-budget half of the detail stays.
  • Quarantine test outcome changed, re-asserted honestly. a timed-out wallet call cannot advance beyond its quarantined generation previously ended quarantined: 0 only because the blocked call was the mirror's initSwap and the late work tripped over its own MIRROR_RESERVATION row. With no fee-sizing mutation left to trip on, the late work runs to completion and the injected finalized-revert failure makes it fail-closed instead. The invariant (exactly one generation, no relay half handed out, claim and offer retained, no sweep retry) is unchanged and still asserted.
  • Every fix(solver)!: midnight-intents-swaps conformance — lower exact-output + executable liquidity + compose E2E (stacked on #48) #52 cap test is re-encoded one-for-one as the new behaviour's control with the old expectation quoted in place; nothing deleted. Two executor harness defaults were flipped to a zero-tokenIn wallet so all 37 executor tests are a standing control.

Deploy (6a66369)

  • deploy/.env.example / compose.yml now enable DUST admission in the deployment for the first time (SOLVER_DUST_* forwarded), which is the only way the reserved amount becomes observable. SOLVER_FEE_SIZING_TAKER_INPUTS is forwarded through both compose.yml and entrypoint-common.sh's empty-optional unset list (an empty value is malformed for this knob; static gate G3f pins the wiring).
  • solver-provision gains a fee-currency-only mode (SOLVER_PROVISION_MINT_TOKENS, SOLVER_PROVISION_RECEIPT) beside the unchanged funded control; new deploy/scripts/provision-solver-fees.ts.
  • README, API.md, .env.mainnet.example and the deploy docs rewritten: the solver needs no token inventory for whole-maker rungs, tokenOut only for interior residuals.

Unfunded-solver E2E (deploy/scripts/e2e.ts, E2E_REQUIRE_UNFUNDED_SOLVER)

Clean-state run against the unmodified reference relay, DRIVER_FINAL_EXIT=0, 63 assert() checks + 4 case verdicts, 0 failures. The solver wallet holds ONLY NIGHT/DUST — zero of every swap token, asserted from the provisioning receipt (shielded: {}) and confirmed on chain afterwards: with the solver stopped its entire shielded wallet is {tokenOut: 100000}, case B's retained surplus, and nothing else.

  • /tokens carries both colors, /state connectedCount:1, POST /quote 750000 → 500000 (the maker offer's exact whole-rung terms).
  • A exact-advertised settles, taker +500000 / −750000 exactly; B lower demand 400000 settles with surplus retained; C above-advertised 600000 refused three times, each followed by a live quote, zero journal rows; D settles the very offer C was refused against.
  • 11 journal rows, all SETTLED, nothing quarantined, no MIRROR_* row anywhere, no leaked Stock claim.

Verification

  • bun run typecheck:solver: 100 roots / 0 diagnostics. bun test packages/solver-core packages/solver: 511 pass / 0 fail. Full CI unit set 981/1 (sole failure is the known shared-docker-daemon flake, reproduced as environmental on the pristine base). Fresh clones need bun run build:midnight before the CI unit set.
  • Static deploy gates 17/17; full teardown including images verified twice.

… 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.
@acedward
acedward merged commit f0e4dfe into feat/cow-solver Sep 2, 2026
9 of 10 checks passed
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