feat(solver): capital-free fee sizing — lift the tokenIn publication cap (follows #52) - #53
Merged
acedward merged 3 commits intoSep 2, 2026
Conversation
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows #52 (base:
feat/cow-solver, parentc37bfa6). 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.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, andresolveSwapJobRouteno longer refuses a job whoseamountInexceedsstock.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
initSwapof the job's fullamountInout 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.SOLVER_FEE_SIZING_TAKER_INPUTS(default 1, bounded [1, 64], malformed value is a listedstart:solverlaunch problem). Modellingninputs funds a real taker half of up ton + 2. That cushion is parameter-dependent: it was measured againstLedgerParameters.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 thaninitialParameters()is un-probed. Raisingnspends 12–14% more real DUST per input.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-limitedevent detail losesmirrorBudgetOffers. Event kinds (ladder-budget-limited/ladder-budget-cleared) are unchanged so kind-keyed alerting still fires; only a consumer readingdetail.mirrorBudgetOfferswould notice. Theresidual-budgethalf of the detail stays.a timed-out wallet call cannot advance beyond its quarantined generationpreviously endedquarantined: 0only because the blocked call was the mirror'sinitSwapand the late work tripped over its ownMIRROR_RESERVATIONrow. 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.Deploy (
6a66369)deploy/.env.example/compose.ymlnow 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_INPUTSis forwarded through bothcompose.ymlandentrypoint-common.sh's empty-optional unset list (an empty value is malformed for this knob; static gate G3f pins the wiring).solver-provisiongains a fee-currency-only mode (SOLVER_PROVISION_MINT_TOKENS,SOLVER_PROVISION_RECEIPT) beside the unchanged funded control; newdeploy/scripts/provision-solver-fees.ts..env.mainnet.exampleand 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, 63assert()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./tokenscarries both colors,/stateconnectedCount:1,POST /quote750000 → 500000 (the maker offer's exact whole-rung terms).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 needbun run build:midnightbefore the CI unit set.