← back to lessons

Intent-Settlement Router Witness-Binding Primitives (C209/C210 inversion)

Banked Cycle 215 from 0x Settler (github.com/0xProject/0x-settler, Immunefi $1M, WALK CLEAN). Refines the C209 DEX-router + C210 allowlisted-aggregator primitives for the intent / solver-settled case where the funds-pull from is the ORDER SIGNER, not msg.sender. Use as the pre-read checklist on any intent/solver router: 0x Settler, 1inch AggregationRouterV6, CoW Settlement, UniswapX/Reactor, Bebop, RFQ-style fillers.

The C209 canary, INVERTED

C209 P1: "can the funds-pull from ever be set to anyone other than msg.sender?" On an intent router, from is DELIBERATELY not msg.sender (a relayer/solver submits; the signer pays). So the canary becomes:

"Is from (and recipient, buyToken, minAmountOut, deadline, and the action list when relevant) bound into the signed Permit2 witness / order hash, and does the verifier RECOMPUTE that witness at spend time?"

If a value-affecting field is NOT in the recomputed signed hash, a relayer/solver substitutes it (redirect output, lower minOut, swap recipient). That single question collapses the audit.

CANARY grep sequence: 1. Find the witness typestring(s): grep -rn "TYPEHASH\|_witnessTypeSuffix\|witnessTypeString". Enumerate EXACTLY which fields are inside. Cross-check against what the entrypoint actually executes (recipient, buyToken, minOut, the action/route list, deadline, taker). 2. Confirm the witness is computed from the SAME calldata that gets executed (not a separate user-supplied hash), set into transient/storage, then SPENT by a verifier call (permitWitnessTransferFrom / isValidSignature / ecrecover) that recomputes it. A checkSpentWitness-style assert that it was consumed is the belt-and-braces signal. 3. Confirm the final output transfer goes to the WITNESS-bound recipient, by MEASURED balance delta

= witness-bound minOut (not an adapter-reported number, not a relayer-supplied address).

R1. Witness-completeness gradient across flavors (the novel lesson)

The SAME contract family can carry MULTIPLE witness typestrings with different completeness, one per entrypoint "regime". 0x Settler: - MetaTxn (relayer-open, anyone calls): witness = SlippageAndActions(recipient,buyToken,minAmountOut, bytes[] actions) -> the FULL action list is in the signed hash. - Intent (solver-gated, onlySolver): witness = Slippage(recipient,buyToken,minAmountOut) -> action list DROPPED from the witness; the solver supplies the route.

Two regimes, two invariants. For EACH entrypoint, classify then check: - relayer-open -> EVERY value-affecting field INCLUDING the action/route list MUST be in the witness. Miss one -> substitution drain. - solver-gated -> (a) the caller allowlist is the ONLY thing between the solver and route choice, so audit who can write that allowlist (must be TRUSTED owner) and that EVERY such entrypoint actually carries the onlySolver-style modifier; AND (b) the OUTPUT (recipient/buyToken/minOut) must STILL be witness-bound, so even a fully malicious solver can only WASTE the route, never redirect funds or lower the floor.

Bug appears if: a relayer-open flavor omits a value-affecting field from its witness; OR a flavor is solver-gated but its caller gate is weaker than assumed (allowlist writable by non-owner, or an entrypoint that forgot the modifier); OR the output fields are NOT witness-bound in the solver-gated flavor (then the trusted-solver assumption becomes load-bearing for FUNDS, not just route quality).

R2. from provenance: transient payer vs arbitrary calldata

Good shape (0x): _msgSender() returns a transient _PAYER_SLOT set ONCE per tx to the signed msgSender inside the entry modifier, doubling as an implicit reentrancy guard. Every internal pull reads this, never a calldata field. The ONLY explicit-from sink is a clearly-named _transferFromIKnowWhatImDoing, reachable only from (a) the taker leg (from = payer) and (b) RFQ maker legs (from = maker, where the maker's OWN witness binds counterparty/token/amount). CANARY: enumerate every permitTransferFrom/transferFrom sink; for each, the from must be either the transient payer/signer or a counterparty whose own signature/witness binds the trade. An explicit from taken from generic action calldata = drain.

R3. Replay is the verifier's job, but check the per-deployment binding

Permit2-style verifiers own nonce + EIP-712 domain (chainid + verifyingContract) -> cross-chain / cross-deployment / reuse replay are closed BY THE VERIFIER. Verify the router does not roll its own weaker scheme. 0x ALSO binds per-flavor: SettlerBase ctor asserts DEPLOYER.ownerOf(_tokenId())== this and _tokenId() differs per flavor, so a sig is bound to a specific Settler role/address. CANARY: confirm the verifier's domain separator includes chainid; confirm nonce is consumed in the same call as the transfer (atomic); confirm any router-side replay guard is consistent with it.

R4. The arbitrary action .call is intended (do not chase it)

Same as C209 P1 anti-pattern: the user/solver-supplied .call(pool, data) (0x Basic.basicSellToPool) looks like a drain, is not. Defense: _isRestrictedTarget(pool) blocks the verifier + allowance holder (confused-deputy), the call uses only the router's OWN in-flight balance, EOA targets rejected, and the final measured slippage check to the witness-bound recipient caps loss. Lax calldata decoders (negative offsets / aliasing, 0x CalldataDecoder) only reshape data the witness already covers (or the trusted solver supplies); they cannot redirect a pull from. Spending audit time on the call itself instead of the funds-pull is the dead-end.

Where these travel

Any intent/solver/RFQ settlement router: 0x Settler, 1inch AggregationRouterV6, CoW Protocol GPv2Settlement, UniswapX (Reactors), Bebop, Hashflow, Native, RFQ fillers. Apply R1's flavor-gradient classification FIRST (which entrypoints are relayer-open vs solver-gated), then the matching invariant. Combine with C209 P1-P6 (bare router) and C210 R1-R3 (allowlisted aggregator) when the target mixes models.

Generated 2026-07-02 13:15:03 UTC | auto-sync /15min