fix(settlement)!: bind delegated authority to what it attests (v3)#1
Merged
Conversation
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.
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.
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
settleRedemptionAttestedtookbookIdas a free param unbound to the lot;receiptDigestcarries no book andLotstored 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 redemptionSettled(served=qty), confiscating the credit and blockingclaimDefault. Net-profitable: issuer keeps buyer cash + frees 100% collateral. Survived the v2 per-book fix (which scoped onlysettleBatch*).lotBookmapping records each lot's issuing book at mint/resale;settleFillslots recordNO_BOOK(no attested path); attested redemption requireslotBook[lot] == bookId.registerBooka higher fee and skim up to 10% off already-resting sell orders — even an honest quorum routed it.registerBookis first-registration-only (fee/recipient write-once); operator churn moves to a newrotateAttestersthat cannot touch economics.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.Ownable→Ownable2Step;Deploy.s.soladds anOWNERtransfer (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 test65 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