On an order book, the depth you see is a promise. The market maker showing you a bid can pull it in the same block you try to take it, and nothing on the screen tells you in advance which levels will hold.
On-chain books make that answerable in a way no off-chain exchange can. A resting order has an Order.owner field, and that field is readable by anyone. So if the owner is a contract with no cancel function, the order can't be withdrawn — and you can prove it before you trade against it.
The obvious way to check is one opcode:
if (owner.code.length > 0) {
// it's a contract, not a wallet — this quote is firm
}
That check is forgeable. I built six contracts that all pass it and all still get their depth out, deployed every one of them to a public testnet, and executed the escapes as real transactions. Here is what breaks, and what to use instead.
Why EXTCODESIZE feels like the right question
You probably know the classic footgun: EXTCODESIZE(addr) == 0 doesn't mean "this is an EOA", because during a constructor the deploying address has no code yet. That one is well documented.
This is a different failure, and I haven't seen it written up. Here the direction is flipped — we're not asking "is this an EOA?" to gate access. We're asking "is this a contract?" to make a positive guarantee to a third party: this depth cannot be withdrawn.
EXTCODESIZE > 0 tells you code exists at an address. It tells you nothing whatsoever about what that code can do. Every attack below lives in that gap.
And a wrong signal here is worse than no signal, because it launders unreliable liquidity through a metric people are trusting.
Six ways to look firm and still get out
Each of these rests a quote exactly the way an honest contract would. Each has a real exit.
1 — Hide the cancel behind a boring name. The pool accepts cancelOrder from the order's owner. The owner is this contract. So it just calls it:
contract HiddenCancel is QuoteBase {
/// @notice "Re-arm after a fill." Actually: cancel the resting order.
function poke() external {
pool.cancelOrder(orders[orders.length - 1]);
}
}
Twelve lines, code.length > 0, reads FIRM to a naive classifier.
2 — Sit behind an upgradeable proxy. The runtime bytecode at the address is an ERC-1967 proxy with no cancel anywhere in it. That's true right up until the implementation is swapped, post-rest, for one that has a cancel. The address never changes. The code at it does.
3 — DELEGATECALL out. Same idea without the proxy ceremony. The contract holds no cancel; it borrows one from an attacker-controlled target and runs it in its own storage context.
4 — Grant an operator after resting. This is the sharpest one, because nothing in this contract's bytecode resembles a cancel:
function openBackDoor(address op) external {
bytes4[] memory sels = new bytes4[](1);
sels[0] = CANCEL_ORDER_FOR; // 0xe37b444b
REGISTRY.setOperatorApprovalForPool(address(pool), op, sels, true);
REGISTRY.setOperatorApprovalGlobal(op, sels, true);
}
The contract calls a shared permissions registry and hands a plain wallet the right to call cancelOrderFor(thisContract, id). The pool accepts, because the owner approved it. The withdrawal then happens entirely outside this contract's own selectors. Auditing this contract for cancel paths finds nothing.
5 — Shrink instead of cancel. The subtlest. reduceOrder drops a resting order's remaining quantity in place and refunds the freed escrow — no taker, no fill:
/// @notice "Rebalance the quote." Actually: shrink to a stub, pull collateral back.
function trim(uint256 newRemaining) external {
pool.reduceOrder(orders[orders.length - 1], newRemaining);
}
A 1,000,000-unit quote becomes a minimum-size stub. The order technically survives, which is exactly why this is easy to overlook — but the liquidity it advertised is gone. If your claim is "this depth cannot be withdrawn," a reduce falsifies it directly.
6 — Use the plural. Block the single cancelOrder selector and there's still cancelOrders(uint128[]). Enumerating one selector is not enumerating a capability.
Executed, not theorised
All six are deployed on Somnia Shannon (chain 50312) and each escape was attempted as a real transaction:
| # | Contract | On-chain result |
|---|---|---|
| S1 | HiddenCancel |
pulled — poke() cancelled it |
| S2 | Erc1967Proxy |
pulled — upgraded impl, then pull()
|
| S3 | DelegateEscape |
pulled — escape() delegatecalled a cancel |
| S4 | OperatorGranter |
grant executed; the pool then blocked the operator cancel |
| S5 | QuietReduce |
pulled — trim() shrank 2,000,000 → 1,000,000, no fill |
| S6 | BatchCancel |
rested; tidy() gas-blocked on this pool |
Four completed a full on-chain withdrawal. S4's back-door grant landed on-chain but the pool's authorizer refused the resulting cancel, and S6's batch path ran out of gas against this particular pool. I'm stating those two honestly rather than rounding them up to six — but note it doesn't rescue the naive check either way. Classification happens while the order is resting, and at rest time all six read FIRM under code.length > 0. Whether a given escape later lands is the attacker's problem, not the classifier's defence.
What to use instead: hash, not size
The fix is to stop asking "is there code here" and start asking "is this exact code something I have examined."
EXTCODEHASH (EIP-1052) is a keccak-256 commitment to the precise runtime bytecode. Attest the hash, not the address. Then:
- FIRM — owner's code hash is attested, and still inside its lock window
- PULLABLE — owner is a wallet
- UNVERIFIED — owner is a contract nobody attested → no claim is made
That third state is the load-bearing one. Every attack above mints UNVERIFIED depth, never FIRM. The metric refuses to speak rather than speaking wrongly.
Before a human attests anything, a static pass over the runtime rejects the obvious escapes — no DELEGATECALL, no SELFDESTRUCT, no CREATE/CREATE2, no EIP-1967 slot constants or EIP-1167 proxy prologue, and no forbidden selector at any byte alignment:
export const FORBIDDEN_SELECTORS = {
'0xdbc91396': 'cancelOrder(uint128)',
'0x0dce6933': 'cancelOrders(uint128[])',
'0x33407b60': 'reduceOrder(uint128,uint256)',
'0x7bbc67e6': 'setOperatorApprovalForPool(address,address,bytes4[],bool)',
'0x7f1e31ce': 'setOperatorApprovalGlobal(address,bytes4[],bool)',
'0x558a7297': 'setOperator(address,bool)',
'0x605e0222': 'approveBuilder(address,uint256)',
};
Two details that cost me time. The scan has to disassemble rather than substring-match, so a selector-shaped run of bytes inside a PUSH32 immediate doesn't produce a false hit. And Solidity's CBOR metadata trailer must be stripped before opcode analysis — otherwise a stray 0xff in it reads as SELFDESTRUCT. But the declared trailer length is attacker-controlled, so an implausible length (mine caps at 128 bytes) must be refused rather than honoured, or a contract can hide live code from the scanner by lying about where its metadata starts.
Against a corpus of the honest contract, the six attackers, and a plain EOA:
Attested classifier: 8/8
Naive EXTCODESIZE classifier: 2/8
The naive check gets only the two trivial ends right — the pure-firm contract and the pure-EOA — and is fooled by all six attacks in between.
The part I can't claim
A green static verdict is not a proof of irrevocability, and I'd rather say so than let the number oversell.
The policy only sees selector bytes that literally appear in the runtime. A contract that computes the selector at execution time — add(0xdbc91395, 1) in Yul, then mstore(shl(224, sel)) and call(...) — invokes cancelOrder(uint128) while the bytes dbc91396 appear nowhere in its code. It passes every clause above and is fully withdrawable. That isn't hypothetical; I wrote it as StealthCancel and it beats my own analyzer.
More generally: CALL can't be banned (the honest contract needs it to place the order), and linear disassembly can't follow jumps, so it can't always tell code from jump-reachable data. No static scan over a language permitting arbitrary CALL can be made sound.
So attestation is a human-reviewed transparency list, and the analyzer is the cheap pre-filter that runs before a human looks — a precondition for review, never a replacement. The honest version of the claim is narrow: this specific bytecode, which I have read, has no path to withdraw.
The transferable bit
If you are using EXTCODESIZE/code.length anywhere to make a promise about behaviour rather than to check identity, it will not hold. Code size is not a capability. Address identity is not code identity, because proxies exist and implementations move.
Hash the runtime, pin the hash, and make "I haven't checked this one" a first-class answer your system is willing to give.
Full evidence trail with every transaction hash, the reproduction commands, and the honest-limits section: github.com/edycutjong/rampart · live typed-book viewer at rampart.edycu.dev/viewer.
Everything above reproduces offline — forge test (93 passing) and node script/headline.mjs (8/8 vs 2/8) need no wallet, no gas, and no network. If you find a seventh escape, I'd genuinely like to know.
Top comments (0)