Pre-ERC: Agent Pre-Sign Risk Report

AI agents now prepare and sign transactions with no person reading each one. the losses that follow are mostly not exotic, and each is visible before signing to anyone who reads the chain and the request:

· an unlimited approval to a spender that is a plain wallet or a week-old contract
· a transfer to an address that shares its first and last characters with one the signer used before
· a buy of a copy of a real token (on robinhood chain there are 3,148 copies of robinhood’s own stock tokens) or through a pool priced far from its oracle
· an x402 payment to a payTo that is not the paid agent’s registered wallet
· an instruction hidden in a token’s name that the agent reads as a command

the Prepared Transaction Envelope proposal (ERC-8265 thread) already gives the consumer a risk slot: an action (ALLOW / WARN / BLOCK), an optional score, free-form warnings and scanner verdicts. what it doesn’t define is what a risk source says, so a consumer that gets WARN can’t see what was read, can’t apply its own thresholds, and can’t tell “found nothing” from “couldn’t read it”.

the draft

a report a risk source returns about one prepared, unsigned request:

· binds to the exact request by a digest (for an envelope: the same pre-image its coverage uses, so writing the report into risk doesn’t move it)
· names its source by an ERC-8004 identity and is EIP-712 signed; the consumer rechecks the signer is that identity’s agentWallet or owner
· carries typed findings in five groups:
· tokens: launch facts read from a Launch Observations registry; a token that copies an issuer’s listed token; a pool’s deviation from a price feed
· approvals: amount, unlimited, share of supply, expiry, current allowance
· addresses: code (none / contract / 7702-delegated), first seen, verified source, prior transfers, lookalikes
· payments: x402 payTo against the payee’s registered agentWallet, with the outcomes of x402 Payee Verification (ERC-8478)
· texts: control characters, mixed scripts, addresses and instruction patterns in text the agent read
· every finding states the block it holds at and its evidence, and is read or not read. not read is never zero or false
· no score, no verdict, no recommended action: the consumer applies its own policy, and MAY project findings into the envelope’s warnings

it runs today

toll (ERC-8004 agent #20030) runs this as a live pre-sign check on robinhood chain: send a prepared tx, an EIP-712 request or an x402 requirement and get ALLOW / WARN / BLOCK with typed reasons, free over HTTP and MCP. a one-line client (@tollxyz/guard) puts it in front of a viem or ethers wallet. examples it blocks today: an unlimited approval to a plain wallet, a copy of an official stock token, an x402 payTo that is not the seller’s wallet.

it’s the third piece of one stack: Launch Observations (ERC-8470, what happened at a coin’s launch, on chain), x402 Payee Verification (ERC-8478, is this payee the agent you mean to pay), and this one, the format a wallet or agent reads before it signs.

questions for this thread

  1. reports as a member of the envelope’s risk slot, or a separate consumer-written field?
  2. is a fixed list of finding kinds right for v1, or should kinds be open from the start (namespaced, like Launch Observations)?
  3. should a signed report be anchorable on chain (a hash in a registry), or is off-chain EIP-712 enough?

PR: Add ERC: Agent Pre-Sign Risk Report by soli0x · Pull Request #2134 · ethereum/ERCs · GitHub

On the addresses group: you already list code, first seen and verified source. For a 7702-delegated address I would add what the delegate lets whom do, because “7702-delegated” reads the same for a delegate with limits and for one without. A concrete case from 29 October (UTC) on Ethereum, all public data: the delegate at 0x63e842b054bd624a465454d533c33f573e9c6fae. I simulated calls (eth_call) against the deployed code: its four value-moving functions (sweep, withdrawETH, withdrawToken, callExecute) revert with NotAuthorized for any caller except one hard-coded address, and that address can withdraw a whole balance to any recipient in one call: no limit, allowlist, delay or pause. The type-4 transaction 0xad99264d5532ae0dd04b2004eebda05f25657e70dacb764f7ac8d4ccd051298b (block 26300753) carried 50 authorizations for this delegate with consecutive nonces, and its sender was that same hard-coded address.

Findings a report could carry for a delegated address, each at the block it holds at:

  1. whether any value-moving function is gated by a single constant or storage address
  2. whether value can leave to any recipient without a per-call limit or delay
  3. how many authorizations for that delegate were seen in recent blocks from the same signer, and whether the transaction sender is the delegate’s only allowed caller

Items 1 and 2 need verified source or bytecode analysis, so they should be allowed to be “not read” with a reason instead of false, which is what your rule that not read is never zero or false asks for.

On your second question, I would open the kinds from the start, namespaced: delegate patterns will change faster than a fixed list.

agreed, “delegated” alone hides the part that matters. added three kinds to the addresses group, each for a delegated address only and at the report’s block:
· delegate-single-caller: every value-moving function is callable by one fixed address (constant or one storage slot) and nobody else
· delegate-unbounded-outflow: the whole balance can go to a caller-chosen recipient in one call, no per-call limit, allowlist or delay
· delegate-authorizations { blocks }: 7702 authorizations for the same delegate seen in the last N blocks, how many came from one sender, and whether that sender is the delegate’s only allowed caller the first two need source or bytecode analysis, so the draft says a source that didn’t finish it reports not read, never false. our implementation lists all three as not read for now.
on kinds: they’re already open. erc.presign. is reserved for ERC-defined kinds, anyone can report others under a prefix they control, and a consumer treats an unknown kind as informational. so delegate patterns can move faster than the draft

thanks, the three kinds read right. Two precision points from running them against the delegate I tested.

  1. The account itself. An ordinary smart-account delegate lets the account call itself or accepts a signature of the account, and that matters for both wordings. For delegate-single-caller, “one fixed address and nobody else” can be defeated by one extra path in a sweeper (the account itself, or a signature of the account): that counts as another caller, so the kind reads false while the fixed address can still empty the account. For delegate-unbounded-outflow, almost every ordinary delegate reads true, because the account can send its whole balance wherever it chooses. I would word both as “a caller other than the account itself and not authorized by a signature of the account”.

  2. My item 3 said “from the same signer”, which was ambiguous, and the draft now reads it as the transaction sender. Those are two signals: one account signing many consecutive nonces (the 29 October case: 50 authorizations, all signed by one account, with consecutive nonces, sent by the delegate’s only allowed caller) versus one sender carrying authorizations from many accounts (a sponsor sweeping many wallets). I would report both: sameSender and distinctAuthorities, the number of distinct accounts that signed.

both right, thanks for running them against the real delegate. pushed to the PR:
· delegate-single-caller and delegate-unbounded-outflow now count outside callers only: a caller other than the account itself and not authorized by a signature of the account. so an ordinary smart-account delegate no longer reads true just because the account can spend its own balance, and an extra account path in a sweeper no longer flips single-caller to false
· delegate-authorizations is now { count, sameSender, distinctAuthorities, senderIsOnlyCaller }: one account signing many consecutive nonces and one sponsor carrying many accounts’ authorizations are reported as the two signals they are

The Test Cases section has no case for the three delegate kinds yet, so here are vectors for them. V1 to V6 are simulated calls against delegated code (account code = 0xef0100 plus the delegate address, 100 ETH on the account). Every caller tries 1 wei, and the whole balance to each of two different recipients. The account itself and a signature of the account are not counted as outside callers, as in the new wording.

Results as (delegate-single-caller, delegate-unbounded-outflow):

  • V1, delegate callable only by the account itself: false, false
  • V2, delegate that only accepts a signature of the account: false, false (a random caller that brings the signature gets through, and is excluded by the definition)
  • V3, one hard-coded caller, no limits: true, true
  • V4, V3 plus a path for the account itself: true, true
  • V5, V3 with a 1 ether cap per call: true, false
  • V6, two hard-coded callers, no limits: false, true
  • V7, the real delegate from the 29 October case (0x63e842B054Bd624a465454D533C33F573e9c6fAe, deployed runtime code, 1674 bytes). Same idea against all four of its value-moving functions (sweep, withdrawETH, withdrawToken, callExecute): random callers and the account itself revert on every one, and the one hard-coded caller succeeds on every one. withdrawETH moves the whole 100 ETH to either recipient, withdrawToken the whole balance of a test token: true, true.

For delegate-authorizations, real chain data. Report block 26300754, blocks = 1: block 26300753 holds one type-4 transaction (0xad99264d5532ae0dd04b2004eebda05f25657e70dacb764f7ac8d4ccd051298b) with 50 authorizations for the V7 delegate, sent by the hard-coded caller. Block 26300754 holds none for it, and neither does 26300752, so blocks = 2 gives the same numbers. count 50, sameSender 50, distinctAuthorities 1 (one account signed all 50, nonces 18914775 to 18914824), senderIsOnlyCaller true. This is the case the split into count and distinctAuthorities is for.

V1 to V6 are small contracts I wrote; V7 and the authorizations case are real code and real data. The harness is handed the fixed caller (for V7 I read it from the contract). Finding such addresses in bytecode and storage is the hard part: until a source can do that, a false here only means no outside caller was found among the ones tried, so it should read not read when the analysis is incomplete. I can paste the Foundry test if it helps.

thanks, these are exactly what the section was missing. added all seven and the 29 october authorizations case to the test cases, with credit, in the PR.

and agreed on the last point: a false from a partial analysis is only “no outside caller among the ones tried”, so it reads not read until the source can find every caller. that’s how ours reports them today.

yes please on the foundry test. if you’re ok with it we’d add it to the assets folder under your name, so implementers can run the same vectors

The new delegate findings describe code at the report’s block. In the draft’s calls binding, though, subjectDigest covers chainId, from, calls and typedData, with no field for an EIP-7702 authorization list. This appears to leave a gap for transactions that install or replace a delegate: EIP-7702 processes authorizations before execution, so the same calldata could run against a different delegate without changing that digest.

I’d include the ordered authorization list in the subject and its digest, and distinguish the existing delegation from the one the transaction would install. Sources unable to evaluate that transition should mark the relevant findings not-read. A useful test would change only the authorized delegate and require a different digest; another would cover delegation persisting when execution reverts. Could the draft cover this path or explicitly exclude transactions carrying authorizations until it does?

1 Like

good catch, that’s a real hole. the delegate findings read code at the report’s block, but a type-4 tx changes that code before its calls run, and the digest couldn’t tell.

fixed in the PR:
· the subject carries the authorizationList, in order, and subjectDigest covers it
· new finding address.delegation {before, after}: the delegate in place at the block and the one the tx would leave
· the delegate findings read the code after the list is applied. a source that can’t apply it reports them not read, never the old code
· none of it depends on the simulation succeeding, since 7702 keeps the delegation when execution reverts
· both your tests are in: change only the authorized delegate and the digest changes, and a reverting tx still reports the new delegation

ours doesn’t apply authorization lists yet, so it refuses a request that carries one rather than report on code the tx would replace.

1 Like