Skip to content

fix(settlement)!: bind delegated authority to what it attests (v3)#1

Merged
drewstone merged 11 commits into
mainfrom
fix/settlement-v3-authority-binding
Jun 12, 2026
Merged

fix(settlement)!: bind delegated authority to what it attests (v3)#1
drewstone merged 11 commits into
mainfrom
fix/settlement-v3-authority-binding

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

What & why

A multi-agent red-team against the "ungameable by any operator in any instance" bar found that Surplus's money-accounting is sound but its authenticity layer rests on quorum honesty, not proofs — and two attacks were live and net-profitable against the book-scoped v2 contract. Both share one root: a quorum/owner held power not bound to the thing it was supposed to attest. This PR is the surgical fix (Move 1 of the cure).

No signed wire format changes (Order / receipt / batch EIP-712 untouched) — only contract storage + the disabled proven path move, so the running fleet's signing stays valid. Requires a v3 redeploy; this PR does not deploy.

Closed

  • Cross-instance redemption confiscation (CRITICAL). settleRedemptionAttested took bookId as a free param unbound to the lot; receiptDigest carries no book and Lot stored no issuing book, so any registered book's quorum — another instance, or an owner-minted 1-of-1 book — could mark any holder's open redemption Settled (served=qty), confiscating the credit and blocking claimDefault. Net-profitable: issuer keeps buyer cash + frees 100% collateral. Survived the v2 per-book fix (which scoped only settleBatch*).
    • Fix: new lotBook mapping records each lot's issuing book at mint/resale; settleFills lots record NO_BOOK (no attested path); attested redemption requires lotBook[lot] == bookId.
  • Retroactive book-fee skim (HIGH). Orders carry no fee commitment and the fee was read from mutable book state, outside the attested digest, so the owner could re-registerBook a higher fee and skim up to 10% off already-resting sell orders — even an honest quorum routed it.
    • Fix: registerBook is first-registration-only (fee/recipient write-once); operator churn moves to a new rotateAttesters that cannot touch economics.
  • SP1 proof binding. Public values are now abi.encode(domainSeparator, bookId, batchNonce, fillsHash), so a proof can't be replayed under a different/higher-fee book or re-submitted after the nonce advances. Proven path still disabled on-chain (sp1Verifier=0); vkey changes on next build.
  • Governance + deploy hygiene. OwnableOwnable2Step; Deploy.s.sol adds an OWNER transfer (intended target: TimelockController + multisig) and hard-gates the mock SP1 verifier to anvil (chainid 31337) so it can never sit on a value chain.

Tests

forge test 65 passed (was 58) — incl. cross-instance + bookless-lot confiscation reverts, book-id proof binding, write-once book + rotateAttesters. Rust workspace + chain-gated anvil e2e build green.

Follow-ups (not in this PR)

Move 2 (work-committed receipt + holder-challenge + forbid router-proxy on bonded issuing rails), Move 3 (match-in-circuit + order-inclusion commitments — the keystone), Move 4 (n≥3 independent custody, key separation, registry-driven membership, CI). And the v3 redeploy + fleet cutover itself.

🤖 Generated with Claude Code

drewstone added 11 commits June 11, 2026 19:44
Both epoch proposers and tick-keepers burn Base Sepolia gas continuously;
when they run dry, batches stop landing. FUNDER_KEY=<any funded key>
node scripts/topup-keepers.mjs tops up both fleet keepers.
… include a cancelled order

Closes the "cancel propagation" open box. A trader cancels an order off-chain
with an EIP-712 SurplusCancel/1 signature over the order hash — the portable
analogue of the contract's cancelOrder (msg.sender == trader). The cancel rides
the same ClobNet transport as orders (HttpNet /clob/cancel + MeshNet
MeshWire::Cancel), so every pool drops the order before the next epoch matches it.

Safety, layered so a cancelled order can never reach a co-signed batch (which
would revert OrderIsCancelled on-chain and grief the whole batch):
  - admit refuses any order in the cancelled set (handles a cancel that races
    AHEAD of its order, and replays after);
  - the co-sign path refuses a proposal that includes a cancelled order — the
    last line of defense if a cancel reached a peer but not the proposer;
  - cancel auth: the signature must recover to the CLAIMED trader (422), and if
    the order is in hand its trader must match (403) — a cancel can't kill
    someone else's order.
The cancelled set self-bounds by expiry (the order's own expiry once seen, a
2-day cap for a pre-order cancel).

Also adds the on-chain `cancelled(orderHash)` getter to SettlementClient as the
authoritative backstop for a future proposer-side chain filter (the one residual
gap: a trader who ONLY cancels on-chain, never gossiping).

Proof: operator/tests/clob_e2e.rs::signed_cancel_propagates_and_blocks_rematch —
cancel entered at a different node than the order propagates, both pools drop it,
re-admission is refused everywhere, a forged cancel is rejected, and the epoch
produces no batch. Full suite green on both feature sets.
…t (domainSeparator, fillsHash)

The settleBatchProven proving half is now validated end to end locally. Adds a
real fixture generator (crates/settlement/examples/sign_fixture.rs: a mutually
EIP-712-signed crossing pair under the SurplusSettlement domain) and a
reproducible runbook (scripts/prove-batch.sh: execute | groth16 | vkey).

Validated on this batch:
  - guest `execute`: ok, 1 fill, 5.75M cycles; commits publicValues =
    abi.encode(domainSeparator, fillsHash) — byte-for-byte what settleBatchProven
    recomputes on-chain (the guest re-verifies both EIP-712 sigs per fill; price/
    crossing/caps/balances are re-enforced in _applyFill, so the proof's TCB is
    "these orders were really signed").
  - program vkey: 0x00d51deca1866f3ebd566284e4718ebb97d5655c717a02194f737a1a99a23439

Remaining for Phase-C acceptance #3 is FUNDED on-chain ops only (the runbook
prints the exact commands): setSp1Verifier(realGateway 0x397A5f7f…, vkey) on the
live Base Sepolia contract, then submit one real groth16 proof via
SettlementClient::settle_batch_proven. The contract verify + wrong-public-values
rejection are already Foundry-tested (Batch.t.sol) against a strict mock.
…ladder

The cross-venue execution layer of the two-layer market. Phase A already
aggregates every venue's book into one price-sorted ladder (fetchAggBook);
this adds planRoute, which walks that merged ladder best-first (a buy lifts
asks ascending, a sell hits bids descending) and decomposes one order into a
split plan across venues — so a taker sweeps the genuinely-cheapest liquidity
wherever it rests, not just one venue's best quote. Each leg names the operator
it lifts; the caller executes legs as portable signed orders that clear on the
one global SurplusSettlement contract.

Pure and venue-agnostic (type-only imports → runnable in isolation): supports a
limit price (stops the walk so the route never crosses it → partial), coalesces
same-(operator, price) levels, and reports filled/partial, a precise size-
weighted avg price (from the unrounded price·qty sum), and rounded settlement
notional (matching the Rust Fill::notional_micro unit). 15 runnable checks
(src/lib/router.check.ts — split, limit, coalesce, thin book, sell side, NBBO
touch); typecheck clean.

Remaining for the box: wire planRoute into the buy/sell flow with a split-plan
preview before signing (lives in trade.tsx — the app session's lane).
…UEST_CHANGES

Four-lens audit (security, architecture, tests/docs, product personas) of the
contracts, matcher consensus, clob epoch service, redemption path, e2e suites,
and the customer/operator/instance-payer experience. Findings + ordered fix
plan under .evolve/critical-audit/2026-06-12T02:16:28Z/.
…-closed config

Audit findings C5/H1/H5 (run 2026-06-12T02:16:28Z):

- WireProposal now carries the proposer's signature over the claimed batch
  digest (the same one it self-attests with). Peers verify it recovers to the
  elected proposer with ONE ecrecover before any expensive work. Closes the
  unauthenticated-propose attack: anyone could replay public gossip data and
  trigger co-sign side effects (pool prune + settled marking) — stranding
  orders keylessly — while burning a full match_epoch per request.

- Rate limiter moved off the venue sub-router onto the MERGED app in both
  bins: Router::merge does not propagate layers, so /clob/* was entirely
  unlimited while the module docs claimed otherwise. /clob/propose costs 5,
  gossip surfaces 2. Localhost fleet gossip stays exempt (XFF-gated).

- SURPLUS_CLOB_* config is fail-closed: a SET but malformed value is a boot
  error, never a silent skip — a node that boots green while not participating
  also stalls every peer needing its co-signature. The blueprint bin now
  refuses to start instead of logging and continuing.

New e2e: elected-proposer-claimed + wrong-key signature -> 401 before any
verification work; forged/tampered tests now authenticate properly so they
still reach the inner verdicts. 46 tests green.
… proof

- ROADMAP: stale footer ('not yet live: no on-chain trigger, no live spend')
  contradicted its own checked gates — replaced with the real test counts and
  the actual open list; G7 reworded to the limiter's true boundary (merged
  app, XFF-gated behind Caddy).
- shared-clob.md: one status line instead of two contradictory ones.
- README: package map and current state match the repo that exists today.
- clob-e2e-live.mjs now PROVES 'attested 2-of-2' instead of asserting it:
  decodes the settle tx calldata, recovers every quorum signature over the
  EIP-712 batch digest, requires >=2 distinct signers and on-chain
  threshold >= 2 (audit finding H9 — the old script passed under a
  misconfigured threshold of 1).
- clob.rs module doc updated for authenticated proposals.
…oped quorums, instance economics

The audit's instance-#2 blocker (findings C3/C4/P2), as one contract redesign:

- Book registry: registerBook(bookId, attesters, threshold, feeBps, recipient).
  Each matching domain owns its attester set (a new instance's operators gain
  zero signing power over existing books), its own nonce (concurrent quorums
  on different books never race — the global batchNonce serialized every
  domain and permanently invalidated the loser's signatures AFTER peers had
  pruned), and its own fee cut (whoever funds an instance finally earns from
  its flow; protocol feeBps/feeRecipient unchanged on top).
- BATCH_TYPEHASH binds bookId: SettlementBatch(bytes32,uint64,bytes32).
  settleBatchAttested/Proven/RedemptionAttested take the bookId; BatchSettled
  is book-indexed. Registration stays owner-gated deliberately: a quorum
  vouches for signatures the contract never sees, so book membership is as
  trust-critical as the old setAttesters — the SP1 path is what eventually
  makes it permissionless.
- Slop eliminated en route: require-string errors -> custom errors;
  Verdict::Sign now carries the verified batch so a peer never re-runs
  match_epoch just to prune (audit B5, halves attest cost); proposals and
  mesh correlation bind the book id end to end.
- Threaded through settlement-core (batch_digest), chain client
  (register_book/book_nonce/settle_batch_*), consensus, clob service
  (SURPLUS_CLOB_BOOK env, foreign-book refusal), mesh, every test, and the
  e2e scripts. EIP-712 parity pins regenerated in lockstep on both stacks.
  58 forge + 46 Rust green; full anvil rehearsal settles through book 0x0.

BREAKING: requires a contract redeploy; quorum signatures over the old digest
are invalid by construction. SP1 guest still commits (domainSeparator,
fillsHash) — binding bookId+nonce into the proof is the open M3 follow-up.
…ase Sepolia

SurplusSettlement v2 0x3fa622488fD970ECdE23b8384a98de6fFa5A1763 deployed
(same tsUSD token), book 0x0 registered 2-of-2 [op3, op4], operator
collateral funded, fleet env flipped via systemd drop-in, app + scripts +
docs repointed. Live proof on v2: book 0x0 nonce 0->1, calldata-verified
2-of-2 quorum, tx 0xbd2c5874… . Pre-cutover tx hashes in docs settled on v1
(0x1cD49739…, retired but on-chain).
…ain proof

The app opened into a cold trading terminal: a 6-column order-book table with
'Top discount —' and skeleton rows, built for a speculator. The actual buyer
is a developer who wants cheaper Claude in their code — they want the answer,
not the mechanism (audit P1, customer UX 3/10).

New home (/):
- The savings instrument is the hero: pick a model, set monthly input/output
  usage, see what you'd pay vs list and save — live NBBO across every healthy
  operator, one click to 'Lock in this price'.
- One honest discount metric everywhere: the chip, the hero, and the per-leg
  breakdown all show the discount REALIZED for the chosen size (walks the real
  book), so a thin top-of-book level can't advertise a deal you can't fill and
  the numbers never disagree.
- Live proof strip: recent on-chain FillSettled events stream with tx links —
  the trust anchor a new market needs. Degrades to hidden (never faked) when
  no settlements are in the RPC's recent-block window.
- The order books / depth / operator quotes are demoted to an engine room at
  /markets, reachable in one click, not the lobby.
- Nav reordered outcome-first: Buy inference, Portfolio, Order books, Sell,
  Operators, Activity. Markets moved /  ->  /markets.

Typechecks + builds clean. Next: the API-key consumption rail (audit P1's
other half) so spending a credit is an endpoint, not a per-prompt signature.
Closes two live, net-profitable criticals on the book-scoped v2 contract, both
rooted in the same flaw: a quorum/owner was handed power not bound to the thing
it was supposed to attest. No signed wire format changes (Order/receipt/batch
EIP-712 are untouched); only contract storage + the disabled proven path move,
so the running fleet's signing stays valid. Requires a v3 redeploy.

- Cross-instance redemption confiscation (CRITICAL). settleRedemptionAttested
  took bookId as a free param unbound to the lot; receiptDigest carries no book
  and the Lot stored no issuing book, so ANY registered book's quorum — another
  instance, or an owner-minted 1-of-1 book — could mark any holder's open
  redemption Settled (served=qty), confiscating the credit and blocking
  claimDefault. Fix: a new lotBook mapping records each lot's issuing book at
  mint/resale; settleFills lots record NO_BOOK (no attested path); the attested
  redemption requires lotBook[lot] == bookId. The per-instance isolation the v2
  batch path claimed now actually holds for redemptions too.

- Retroactive book-fee skim (HIGH). Orders carry no fee commitment and the fee
  was read from mutable book state, outside the attested digest, so the owner
  could re-registerBook a higher fee and skim up to 10% off already-resting
  sell orders (even an honest quorum routed it). Fix: registerBook is
  first-registration-only (fee/recipient write-once); operator churn moves to a
  new rotateAttesters that cannot touch economics.

- SP1 proof binding. Public values are now
  abi.encode(domainSeparator, bookId, batchNonce, fillsHash) (was domain+fills
  only), so a proof can no longer be replayed under a different/higher-fee book
  or re-submitted after the nonce advances (partial-fill replay). Proven path is
  still disabled on-chain (sp1Verifier=0); vkey changes on next build.

- Governance + deploy hygiene. Ownable -> Ownable2Step; Deploy.s.sol gains an
  OWNER transfer (intended: TimelockController + multisig) and hard-gates the
  mock SP1 verifier to anvil (chainid 31337) so it can never sit on a value
  chain.

Tests: 65 forge (was 58) incl. cross-instance + bookless confiscation reverts,
book-id proof binding, write-once book + rotateAttesters; Rust workspace +
chain-gated anvil e2e build green.
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