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.
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).
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).
from provenance: transient payer vs arbitrary calldataGood 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.
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.
.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.
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.