An AI agent that holds a wallet decides what to do in English, but what it signs is calldata or a typed message. A poisoned tool can hand it a benign sentence and a malicious signature. That gap is known. This repo does not claim a new attack. It maps something narrower and, I think, more useful: given a poisoned-tool drain, which wallet-defense capability actually stops it, and which drains survive a capability-complete stack.
The short answer, from the runnable harness below: rendering the counterparty, the amount, and the ultimate recipient of a signed action closes the stranger-target and recipient-substitution drains. Transaction and signature simulation add nothing beyond that for the hard cases. What is left is one on-chain time-of-check/time-of-use race that only re-simulation at inclusion catches, plus authorizations (EIP-7702 delegation, Permit2 approval) whose danger is categorical rather than visible in any field. And those residual cases lean on the allowlisted target itself being adversarial.
This is a coverage map, not a measurement, and the arms below are capability models, not the branded products. See "What this is and is not" before quoting any of it.
Paper: An Address Allowlist Gates the Counterparty, Not the Grant —
10.5281/zenodo.21470174 (CC-BY-4.0).
Explainer: https://amarshat.github.io/quantum-commit-authorization/agent-drains.html
Interactive: https://amarshat.github.io/quantum-commit-authorization/agent-drains-demo.html (pick a drain, toggle defenses, generated from out/scorecard.json by demo/build_pages.py)
This lives in the quantum-commit-authorization repo but is a separate piece of work from the post-quantum protocol there. Shared repo, no shared code or threat model; see Also in this repo for the one honest thread that links them.
Requires Foundry (anvil, cast, forge) and
python3. No pip/npm to run the core; the harness shells out to cast.
./run.shIt boots a local anvil (on the Prague hardfork, needed for the EIP-7702 row),
deploys a mock USDC, and runs eight poisoned-tool drains against a ladder of
seven defense capabilities, printing the coverage matrix and writing
out/scorecard.{json,md}.
Real wallet defenses are not one thing. They are a stack of capabilities a wallet may or may not have. The ladder, weakest first:
- plan-review (English only) — reads the agent's stated plan text, never
the bytes. This is an LLM plan-reviewer or a prompt-injection guardrail. It
can be a real one: set
ANTHROPIC_API_KEY(needspip install anthropic) and/orLAKERA_GUARD_API_KEYand this rung calls them. - address allowlist — decode the action and veto if the counterparty is not on the allowlist.
- amount-aware clear-signing — also veto unlimited amounts, even to an allowlisted counterparty.
- recipient rendering — also render the ultimate recipient when it is a
signed field distinct from the counterparty (an order's
taker, a bridge's destination). This is what ERC-7730 descriptors do. - transaction simulation — fork-run the transaction and veto on an adverse state diff. (This rung actually forks the chain.)
- signature simulation — reason about the net transfer a signature enables, which is what Blowfish/Blockaid-style signing checks do.
- tx-type policy — categorical warnings on dangerous action types (unlimited approval, account delegation, Permit2 approval), regardless of target.
Each attack is scored by the lowest rung that would stop the drain.
Chosen to separate two axes: where the malice lives (on-chain calldata vs an off-chain signed message) and whether the value goes to a stranger or to an allowlisted address.
| id | drain | decoded target / recipient |
|---|---|---|
| A | on-chain unlimited approve to a stranger |
counterparty = attacker |
| B | transfer to a stranger, dressed up as paying the merchant |
counterparty = attacker |
| C | off-chain EIP-2612 permit to a stranger |
spender = attacker |
| D | off-chain unlimited permit to the allowlisted router |
spender = router (allowlisted) |
| E | on-chain call to the allowlisted router, armed after the dry-run | callee = router (allowlisted) |
| F | off-chain EIP-712 order to the allowlisted exchange | counterparty = exchange; recipient = attacker |
| G | off-chain EIP-7702 authorization to the allowlisted helper | delegate = helper (allowlisted) |
| H | on-chain max approve to the allowlisted Permit2 contract |
counterparty = Permit2 (allowlisted) |
attack plan allow amt recip txsim sigsim type stopped-at
A-approve-max . STOP . . STOP . STOP L2 address allowlist
B-transfer-swap . STOP . . STOP . . L2 address allowlist
C-permit-attacker . STOP . . . STOP STOP L2 address allowlist
D-permit-router-unlimited . . STOP . . . STOP L3 amount-aware clear-sign
E-honeypot-toctou . . . . . . . RESIDUAL
F-order-sign . . . STOP . STOP . L4 recipient rendering
G-delegate-7702 . . . . . . STOP L7 tx-type policy
H-permit2-approval . . . . . . STOP L7 tx-type policy
How to read it, honestly:
- plan-review (L1) catches none of them, because every stated intent is
benign by construction. That is the one thing the reasoning layer cannot fix:
the malice is not in the text. It is a single conceptual point, not eight, and
it is why the English column is all
.. - A, B, C fall at the address allowlist (L2). On-chain or off-chain, malice to a stranger is caught the moment you decode the counterparty. The classic "AI drains its wallet" is the easy case.
- D falls at amount-awareness (L3). An unlimited permit to an allowlisted router is caught by flagging "unlimited," a check wallets like Rabby ship today. The address allowlist alone misses it; amount rendering catches it.
- F falls at recipient rendering (L4). The order's counterparty is the
allowlisted exchange, but the order's
takeris a signed field, and once a clear-signer renders it the attacker recipient is visible. This is why F is not special: a field-complete decoder sees it. - Simulation (L5, L6) adds nothing here, and that is the point. Every drain simulation would catch was already caught by field rendering. And simulation specifically misses E and G (see below).
- G and H fall only at the categorical tx-type policy (L7). An EIP-7702 authorization has no field to inspect except the delegate address (allowlisted) and no amount; a signature simulation of the authorization shows no transfer, because the sweep is a separate later call. Nothing short of "warn on all delegations" stops G. H is the on-chain max approval to Permit2, which every policy must allow (an amount policy that fired on it would fire on every legitimate DeFi setup), so it passes L2 through L6; only a categorical Permit2-approval warning flags it, and that warning is impractical for the same reason. Note also (see concessions) that H's approval is benign; the actual drain is a later Permit2 signature whose spender is the attacker, which the address allowlist (L2) catches exactly like C.
- E survives the whole ladder. The call target is the allowlisted router, the inner arguments are benign, and a dry-run of the call is a benign no-op. Between that dry-run and the real transaction the attacker arms the contract, so the malicious state does not exist at check time. No static clear-signer or simulator sees it; only re-simulation at inclusion does.
The honest takeaway: a field-complete clear-signing stack (counterparty + amount + recipient), which is where 2026 wallets are heading, closes A through D and F. The genuinely hard residue is two shapes: an on-chain TOCTOU that needs dynamic re-simulation (E), and categorically-dangerous authorization types (EIP-7702, Permit2) that no field analysis flags (G, H). Both hard shapes also require the allowlisted target itself to be adversarial.
The ladder above is capability models. One dimension is wired to a real deployed
service: address reputation, via the GoPlus Security
API. Set GO_PLUS_APP_KEY and GO_PLUS_APP_SECRET in a .env (gitignored) and
./run.sh adds a live pass that queries GoPlus for the reputation of each
attack's counterparty and recipient.
The measured result:
attack counterparty recipient
A-approve-max clean (same)
... (all eight) clean clean/(same)
G-delegate-7702 clean (same)
GoPlus flagged 0/8 attacks.
positive control 0x098B716B8Aaf21512996dC57EB0615e2383E2f96:
flags = blacklist_doubt, sanctioned, stealing_attack
Reputation scanning catches none of the eight, because every sink is a fresh
address with no history. The positive control (a real address GoPlus flags as
stealing_attack / sanctioned) confirms the query works, so the zero is a
real finding, not a broken call: reputation only fires on addresses already
known to be bad, and a poisoned tool routes to a fresh one. Reputation is
orthogonal to the clear-signing ladder and defeated by address rotation.
GoPlus is a reputation service, not a simulator, so it cannot upgrade the simulation rungs (L5, L6); those stay models until wired to a real simulation API (Tenderly, or Blockaid/Blowfish's gated scan APIs).
Powered by Etherscan.io APIs.
Every other tier inspects the transaction. None reads the code of the contract that will govern the outcome, and answering that requires a prior question: which contract actually decides what happens here? For most drain shapes it is not the transaction target.
Set ETHERSCAN_API_KEY in the .env and demo/measure.py adds a fourth column.
It resolves the authority object, then reports whether a code reader would have
anything to read. States are availability, not detection: catch means
verified source exists for the governing object, blind means code but no source,
na means no code at all. It deliberately does not judge whether the code is
malicious; hand-rolling a malice classifier would be a strawman of the agentic
analysers this is meant to characterise.
Measured over the 101 malicious victim-signed cases:
class rows the tx `to` authority object state
approve 21 12 verified blue-chip tokens the spender na (EOA)
(stETH, USDT, BoredApeYachtClub, PEPE,
wstETH, rETH, RPL, an Aave proxy)
transfer 20 OpenSea Seaport TransferHelper the recipient na (EOA) x19
upgradeTo 20 the victim's own OpenSea the new catch x16
OwnableDelegateProxy (verified, benign) implementation blind x3, na x1
other call 40 the attacker's own contract the same contract catch x36, blind x4
totals: catch 52, blind 8, na 41
the tx target is verified but is NOT the authority object: 61 / 101
So in 61 of 101 rows the readable contract is legitimate, verified, famous infrastructure, and a source reader pointed at it correctly answers "fine", because the code is fine. The theft lives in the arguments: an EOA spender, an EOA recipient, or a replacement implementation address. Source availability on the target is nearly total and nearly useless. Object selection is the bottleneck, not availability.
Two bounds. Etherscan exposes no verification date, so we cannot establish whether
a contract's source was public at the moment the victim signed; availability is
measured as of now and is therefore an upper bound, exactly as the reputation
tier's hits are. And 20 upgradeTo rows resolve to only 6 distinct
implementations, so those are reported as counts, not rates.
Powered by Etherscan.io APIs.
Everything above measures a transaction the user signs directly. An intent system inverts that: the user signs an order and never authors the settlement transaction. A solver does, later, and supplies the calls that run against the user's approved balance.
demo/intent_gap.py decodes CoW Protocol settlements and asks how many of the
contracts that actually execute appear anywhere in the signed order.
What this run was expected to show, including the confirmation and falsification thresholds, was written down before it ran: see PREDICTION.md. That file also says plainly what its dates do and do not establish, since it was published here after the measurement rather than before it. One of its three claims was mis-specified and is left in place rather than quietly fixed.
python3 -m demo.intent_gap --windows 7 --per-window 25 --spacing-days 45Over 174 settlements sampled across seven days spread over nine months:
median |T\S|/|T| 0.845 (share of executing contracts named in no order)
mean 0.794
all absent 84/174 settlements naming none of them
none absent 0/174 settlements naming all of them
distinct contracts governing execution but named in no order: 77
Of those 77, ten have no published source (the most used governs 22 of the 174 settlements) and five sit behind upgradeable proxies. Those are the same two shapes as the historical corpus, at a site with different mechanics.
This is not a vulnerability claim. A CoW order's limit price and receiver are enforced at settlement, so the value outcome is bounded no matter which contracts execute. That is the design working. The point is what a defense can see at signing time: the signed artifact governs the value bound and nothing else, while the execution set is governed elsewhere and is mostly invisible.
Two bounds. |T| comes from settlement calldata, so contracts reached through
nested internal calls are invisible, making it a lower bound that works against
the finding rather than for it. And each window samples one day of blocks.
These are load-bearing; do not quote the matrix without them.
- The ladder is a coverage map, not a measurement. The
un-defendeddrain is a constant 1.00 USDC by construction. Rungs 1-4, 6 and 7 apply a policy to decoded/rendered fields (which is what clear-signing is); only rung 5 forks the chain. Rung 6 (signature simulation) is a model of net-transfer reasoning, not a second EVM run. The ladder does not query Blowfish/Blockaid; those are named as the capability each rung models. The one exception is the live GoPlus reputation lens above, which does query a real deployed service and is reported separately as measured. - F, and half of H, are caught by capabilities real wallets already have. If you stop at an address-only allowlist, F and H look like bypasses. They are not, against a recipient-rendering, signature-scanning stack. The demo scores them at the rung that catches them, and does not claim they beat a 2026 stack.
- E is a deterministic stand-in for a race. In the wild the attacker must land the arming transaction between the wallet's simulation and inclusion (a same-block-before, or an upgradeable-proxy swap). The demo makes the ordering deterministic. E also assumes a pre-existing standing allowance to the router, and the "allowlisted router" is itself adversarial code.
- G requires an under-hardened delegate.
AAHelper.sweepis callable by anyone. A genuinely hardened, trusted delegate gates its caller and is not drainable by a third party. So G demonstrates "the agent was induced to delegate to a malicious contract," where the allowlist entry is the poison, not "delegating to a trusted helper is unsafe." Real wallets (MetaMask, Rabby) also apply a categorical high-severity warning to any 7702 authorization, which the ladder models as L7. - Local anvil, mock contracts, free tokens. MockUSDC, and minimal models of Permit2 (SignatureTransfer), a Seaport-style exchange, and a 7702 delegate. On-chain finality is real; economic loss is not. This is a mechanism demo.
It is a runnable enumeration of eight poisoned-tool drain shapes and a per-capability coverage matrix, isolating the residue that a field-complete, simulating clear-signing stack does not close: on-chain TOCTOU and categorical authorization types, both leaning on an adversarial allowlisted target.
It is not a measurement, not a benchmark of any deployed product, and not a claim that off-chain signatures defeat clear-signing in general. The one-line version is: an address allowlist gates the counterparty, not the grant, and the capabilities that close that gap are field rendering (for most of it) and dynamic re-simulation plus type policy (for the rest).
The pieces are known; the runnable capability map and the autonomous-signer framing are the contribution.
- Closest prior statement of the residual is industry, not academia: Blockaid's "whitelist security gaps" and "Dissecting TOCTOU attacks" posts name recipient-substitution-to-an-allowlisted-target (our F), the TOCTOU simulation-evasion mechanism (our E), and malicious EIP-712 approvals. That work is qualitative, for human/treasury wallets, unmeasured and not composed into a coverage map, and not agent-scoped. This repo's delta is the runnable matrix, the capability ladder, and the autonomous-signer setting.
- The reasoning-vs-execution / intent-vs-calldata framing is named as an open problem in the agentic-commerce SoK "T2T" attack, arXiv 2604.15367. Related agent-security work: CrAIBench 2503.16248 injects at the memory/reasoning layer (the agent decides to send funds); "Your Agent Is Mine" 2604.08407 attacks malicious API intermediaries in the supply chain; MCPTox 2508.14925 measures MCP tool poisoning with no on-chain target. None evaluate wallet clear-signing / simulation defenses.
- On the specific rails: EIP-7702 phishing is measured on-chain in arXiv 2512.12174 (single vector, no defense matrix); the reasoning/execution trust boundary is surveyed in 2601.04583 and 2605.16976; agent-to-agent payment security in 2604.03733; and human-facing signature legibility in "What I Sign Is Not What I See" 2601.16751.
run.sh one command: chain up, suite, coverage matrix
src/MockUSDC.sol self-contained ERC-20 with EIP-2612 permit
src/Honeypot.sol allowlisted router that arms after a benign dry-run (E)
src/Settlement.sol allowlisted EIP-712 exchange, recipient set by the order (F)
src/AAHelper.sol allowlisted EIP-7702 delegate that sweeps the account (G)
src/Permit2.sol minimal universal-approval contract, SignatureTransfer (H)
demo/cast.py stdlib wrappers over cast/forge (no web3 dep)
demo/chain.py accounts, token, and the EIP-2612 / EIP-712 / 7702 / Permit2 signing
demo/attacks.py the eight-drain suite
demo/reviewers.py the capability ladder
demo/llm.py optional live plan-review (frontier LLM + injection guardrail)
demo/goplus.py optional live address-reputation lens (GoPlus Security API)
demo/source_tier.py optional source-availability lens on the authority object (Etherscan)
demo/intent_gap.py signed-order vs executed-contract gap in CoW settlements (Etherscan)
demo/race.py quantifies the E residual: P(drain) = 1 - (1-beta)^K
demo/scorecard.py run the suite, emit the coverage matrix
Optional keys (put in a gitignored .env; run.sh sources it): GO_PLUS_APP_KEY
GO_PLUS_APP_SECRETenable the live reputation lens;ETHERSCAN_API_KEYenables the source-availability lens (free tier is 5 calls/sec, and its use requires the "Powered by Etherscan.io APIs" attribution shown above);ANTHROPIC_API_KEY(+pip install anthropic) and/orLAKERA_GUARD_API_KEYmake the L1 plan-review rung call a real model / guardrail.