Agents that sign: securing autonomous on-chain execution
An agent that signs will one day be asked to sign the wrong transaction. A threat model for AI agents that transact, delegation from hot keys to ERC-4337 session keys and EIP-7702, and spending caps sized step by step.
- Written by
- Quantix Research, Wallet Security
- Published
- Reading time
- 11 minutes
- Category
- Wallet Security
Technical analysis. Built from the public sources in the bibliography. Incident details are as reported there; no new discovery is claimed.

Contents
An agent that can sign turns everything it reads into a possible transaction. Trading bots, treasury automations and LLM-driven assistants share that property; LLM agents add a planner whose output can be steered by the data it consumes.
Assuming that the planner will at some point propose a harmful transaction, this analysis asks where the damage stops, using current Ethereum standards and a model agent. No product is evaluated.
The threat model
An agent that transacts has five parts: a model or planner; a tool layer of APIs, web pages and an RPC endpoint; a policy engine that approves or rejects actions; a signer; and the chain, where an account contract may enforce its own rules. Each hand-off is a trust boundary.
The weakest lies between the model and what it reads. OWASP distinguishes direct prompt injection from indirect injection through external content such as websites or files, and states that it is unclear whether fool-proof prevention exists. Research on a Web3 agent framework showed that instructions injected into prompts or stored history can trigger unauthorised transfers, and that prompt-injection defences gave limited protection once stored context was corrupted. Rule-based bots escape injection, not the other rows.
| Threat | Enters at | Example | Bounded by |
|---|---|---|---|
| Prompt or data injection | Inputs → model | An invoice memo asks for payment to a new address | On-chain scope; delay on new payees |
| Poisoned tool output | Tool → model | An API returns a wrong address or malicious calldata | Engine-built calldata; allowlists; simulation |
| Key theft | Agent host | Session key read from disk or memory | KMS or HSM; on-chain caps and expiry |
| Runaway loop | Model or engine | Retries pay one invoice twice, or burn gas | Idempotency keys; rate and period caps |
| Replay | Signer → chain | A signature accepted on another chain or account | Chain ID and account binding; nonces |
| Approval abuse | Chain | An unlimited approve outlives the session | No approve in scope; exact, expiring amounts |
| MEV | Submission | A public swap sandwiched to its slippage limit | Minimum output; deadlines; private submission |
The model’s output is therefore untrusted input to the signer, and no system prompt changes that.
Where the controls sit
Controls can live in two places. A policy engine in the agent host, deterministic code kept apart from the model, can decode calldata, simulate and consult history. An account contract enforces only what it can check cheaply on-chain: targets, selectors, argument bounds, amounts and time. The difference is what survives a compromise. An attacker running code on the host simply asks the signer, and a key in a KMS or HSM, though it cannot be copied, signs what the host submits.
Maximum loss is therefore set by the on-chain rules and the account’s balance; the off-chain layer reduces how often a harmful but permitted transaction is sent.
Statuses are as listed on eips.ethereum.org on 31 October 2026; Draft standards may change.
| Model | Status | Bounds | Cannot bound | Revocation |
|---|---|---|---|---|
| Hot EOA key | Protocol default | Nothing beyond the balance | Anything: the key is the account | None; move funds first |
| MPC or remote signer with policies | Not a standard | What the service checks, while intact | Whoever controls the service or enough key shares; on-chain it is a plain EOA | Disable the credential; signed transactions stay valid |
| Smart account session keys (ERC-4337, ERC-7579 modules) | ERC-4337 Final; ERC-7579 Draft | Target, selector, arguments, value, time; totals in hooks. Survives host compromise | Effects of allowed calls; module bugs | Owner removes key or module; independent pause |
| ERC-7715 permission request | Draft | Types the wallet supports, such as native-token-allowance with an expiry rule | Enforcement, which is left to the wallet | wallet_revokeExecutionPermission |
| EIP-7702 delegation | Final | Session rules in the delegate contract | The EOA key, which still signs transactions and authorisations | Re-delegate, or delegate to the zero address |
ERC-4337 accounts validate each UserOperation in validateUserOp, which returns the signature result packed with a validUntil/validAfter range; the EntryPoint executes only after validation passes. ERC-7579 standardises modules such as validators (type 1) and optional hooks (type 4), whose preCheck and postCheck wrap every execution; a session key can be built as a validator for a second key plus a hook that keeps totals.
ERC-7715 defines wallet_requestExecutionPermissions; the wallet returns a context redeemed through an ERC-7710 delegation manager. It specifies the request “but not how the wallet enforces permissions”, so a grant is only as narrow as the wallet’s implementation. EIP-7702 sets an EOA’s code to 0xef0100 plus a delegate address through a type 0x04 transaction. The delegate “has unrestricted access to the account” and the original key keeps working, so a 7702 design is bounded only if that key stays out of the agent’s reach.
Scoping a session
A session permission is a set of predicates over each call. For the model payments agent defined in the next section:
| Dimension | Rule | Enforced in | Stops |
|---|---|---|---|
| Target | Stablecoin only, zero ether value; never the account itself | Validator and hook | Other contracts; self-calls to installModule |
| Function | transfer(address,uint256), selector 0xa9059cbb | Validator and hook | approve (0x095ea7b3), transferFrom |
| Recipient | Payee registered at least 24 h earlier | Hook | Payment to a freshly injected address |
| Amount | ≤ 5,000 per call | Validator or hook | One large transfer |
| Period | ≤ 40,000 per rolling 24 h | Hook | Many small transfers |
| Rate | ≤ 20 operations per hour | Engine and hook | Runaway loops and gas burn |
| Time | Valid for 7 days | validUntil | Use of a stale or stolen key |
ERC-4337 bundlers constrain the opcodes and storage that validation may use, so time bounds belong in validUntil and validAfter, and checks on shared state, such as a payee registry or running totals, usually sit in an execution hook. ERC-7579 warns that delegatecall execution needs care and that extra authorisation on configuration functions can be bypassed through calls from the account to itself, so sessions get single calls only and never target their own account.
The common error is to scope the target alone:
// Simplified, illustrative. Not production code.// Vulnerable: scopes the target, not the function, the arguments or the total.function checkSessionCall(address target, uint256 value, bytes calldata data) external view { require(target == address(stablecoin), "target");}That permits approve(attacker, type(uint256).max), and the allowance survives the session that created it. A scoped check names the function and decodes its arguments:
// Simplified, illustrative. Not production code.// Execution hook for calls made with the agent's session key.contract AgentSessionPolicy { address public immutable account; address public immutable guardian; IERC20 public immutable stablecoin; // 6 decimals IPayeeRegistry public immutable payees; uint256 public constant PER_CALL_CAP = 5_000e6; uint256 public constant PER_DAY_CAP = 40_000e6; uint256 public constant PAYEE_DELAY = 24 hours; bool public paused; function checkSessionCall(address target, uint256 value, bytes calldata data) external { require(msg.sender == account, "only account"); require(!paused, "paused"); require(target == address(stablecoin) && value == 0, "target or value"); require(data.length == 68 && bytes4(data[:4]) == IERC20.transfer.selector, "function"); (address to, uint256 amount) = abi.decode(data[4:], (address, uint256)); uint256 added = payees.addedAt(to); require(added != 0 && block.timestamp >= added + PAYEE_DELAY, "payee not yet payable"); require(amount <= PER_CALL_CAP, "per-call cap"); _recordSpend(amount, PER_DAY_CAP); // reverts if the rolling 24 h total would exceed the cap } function pause() external { require(msg.sender == guardian, "only guardian"); paused = true; }}Sizing spending limits
Consider a model agent that pays supplier invoices from a smart account with a float B of 200,000 units of a dollar stablecoin. Its assumed workload, over 22 business days a month:
- 20 ordinary days: 40 payments (30 of 400, 8 of 1,250, 2 of 5,000), 32,000 a day;
- 2 peak days: 100 payments (75 of 400, 20 of 1,250, 5 of 5,000), 80,000 a day;
- 1,000 payments worth 800,000 a month, spread evenly over a 10-hour day, so at most 10 an hour;
- detection-to-revoke time
T: 1 hour when staffed, 12 hours overnight.
With the allowance unused, the worst-case loss before revocation is
L(T) = min(B, c × R × T, C × W(T))
where c is the per-call cap, R the hourly rate limit (with none, a transfer splits into as many calls as the chain includes, and the term drops), C the period cap, and W(T) the number of periods an interval T can touch: ⌈T / 24 h⌉ for a rolling window, one more for a fixed reset.
| Limits | Loss, T = 1 h | Loss, T = 12 h | Payments to approve | Value to approve |
|---|---|---|---|---|
| None | 200,000 | 200,000 | 0 | 0% |
| 5,000 per call | 200,000 | 200,000 | 0 | 0% |
| 2,500 per call; 20 calls an hour | 50,000 | 200,000 | 50 (5%) | 31.25% |
| 5,000 per call; 40,000 per rolling 24 h | 40,000 | 40,000 | 100 (10%) | 10% |
| 5,000 per call; 40,000 per calendar day | 80,000 | 80,000 | 100 (10%) | 10% |
| 5,000 per call; 90,000 per rolling 24 h | 90,000 | 90,000 | 0 | 0% |
With 2,500 per call and 20 calls an hour, one hour allows 2,500 × 20 × 1 = 50,000; twelve hours allow 600,000, capped at the 200,000 float. The cap stops all 20 × 2 + 2 × 5 = 50 payments of 5,000, worth 250,000, or 31.25% of 800,000. A 40,000 period cap passes ordinary days and halts each peak day at 40,000: 2 × (80,000 − 40,000) = 80,000, or 10% of value, which with an even mix is half of each peak day’s 100 payments. A rolling window gives the same figures, because the window closing at the end of a peak day holds nothing from the day before; a fixed reset doubles the loss, because an attack that straddles it gets two allowances.
A per-call cap alone bounds nothing, and a rate limit makes it a bound that grows with detection time. A period cap bounds loss for a full period whatever the detection time, so it is the primary control: set it from the loss the operator can absorb, then check how often it interrupts peaks. A float near one peak day’s volume is a second, independent bound.
Validating before signing
The policy engine’s first rule is that the model never writes calldata:
// Simplified, illustrative. Not production code.// Vulnerable: whatever the model writes is what gets signed.const sendTransaction = tool({ input: { to: "address", data: "bytes", value: "uint256" }, run: ({ to, data, value }) => wallet.sendTransaction({ to, data, value }),})// Simplified, illustrative. Not production code.// The model supplies a typed intent; the policy engine builds and checks the call.type PaymentIntent = { invoiceId: string; payee: Address; amount: bigint } export async function payInvoice(intent: PaymentIntent) { const prior = await ledger.get(intent.invoiceId) if (prior) return reconcile(prior) // resend the same signed op, or report its result const payee = await payees.lookup(intent.payee) // exact 20-byte match if (!payee || now() - payee.addedAt < PAYEE_DELAY) throw new PolicyError("payee not yet payable") if (intent.amount > PER_CALL_CAP) throw new PolicyError("above per-call cap") if (spentLast24h() + intent.amount > PER_DAY_CAP) throw new PolicyError("above daily cap") const callData = encodeExecute(STABLECOIN, 0n, encodeTransfer(intent.payee, intent.amount)) const effect = await simulate(ACCOUNT, callData, PINNED_BLOCK) if (!sameEffect(effect, [{ token: STABLECOIN, to: intent.payee, amount: intent.amount }])) throw new PolicyError("simulated effect differs from intent") // ERC-4337 nonce = 192-bit key || 64-bit sequence: one key per intent, sequence always 0. const nonce = (BigInt(keccak256(toBytes(intent.invoiceId))) >> 64n) << 64n const op = await signer.sign(buildUserOp({ callData, nonce })) await ledger.put(intent.invoiceId, { op, nonce }) // persist before sending return bundler.send(op)}- Decoded intent. Decode the final calldata independently and compare each field with the intent. Decode third-party calldata, such as a quoted route, against a known ABI; reject unknown selectors.
- Simulation. Simulate at a pinned block and compare asset changes with the intent. A simulation is only as honest as its node, as our RPC analysis explains.
- Allowlists. Compare full 20-byte addresses. An agent that copies payees from history can be fed a lookalike planted by a dust transfer; address analysis of first-time recipients is a useful second signal.
- Approvals. Approve exact amounts. Permit2 allowances carry a
uint48expiration after which transfers revert withAllowanceExpired; set it in minutes. Treat typed-data requests from tools as approvals, as in our analysis of approval phishing, and sign only allowlisted schemas. - Chain ID and nonces. Take the chain ID from configuration, not the RPC endpoint.
userOpHashbinds an ERC-4337 operation to one chain and EntryPoint; EIP-7702 authorisations withchain_id0 are valid on every chain, so an agent should never sign one. Owner-signed session grants need the bindings in signature replay across EVM chains. - Ordering. Swaps need a minimum output from an independent price and a short deadline; MEV and protocol risk shows how slippage tolerance becomes extractable value.
Revocation and recovery
Pause independently of the module. ERC-7579 notes that a module can revert in onUninstall to block its own removal, and Safe’s documentation warns that modules “can execute arbitrary transactions”. A guardian pause checked on every session call, ideally in validation so that blocked operations pay no gas, stops the agent without the module’s cooperation.
Pending operations. An ERC-4337 operation is validated at inclusion, so once the pause lands, pending session operations fail; anything included first still executes, so the revocation needs fees that win. A hot key or remote signer cannot recall what it has signed: consume each outstanding nonce with a replacement.
Stuck, failed and retried transactions
Frameworks retry tool calls on timeouts, and models repeat them; without idempotency, each retry is a second payment.
EOA nonces are sequential, so one dropped transaction stalls every later nonce. A stuck transaction is replaced at the same nonce with higher fees, or cancelled with a zero-value transfer to self; until one is included either may execute, so outcomes are tracked by nonce, not hash. ERC-4337 bundlers accept a replacement with the same sender and nonce, a higher maxPriorityFeePerGas and an equally increased maxFeePerGas.
The ERC-4337 nonce, a 192-bit key and a 64-bit sequence in which a new key starts at 0, also gives on-chain idempotency. Derive the key from the intent ID and always use sequence 0, as the code above does, and two operations for one invoice share a nonce, so the EntryPoint accepts only one. That holds only if retries never fetch a fresh sequence with getNonce. Timed-out calls are reconciled, never rebuilt.
Review checklist
- Value-moving tools take typed intents, never raw calldata, targets or amounts.
- The host holds only a session key, in a KMS or HSM.
- The scope names target, selector and argument bounds, excludes the account,
approveanddelegatecall, and expires. - The period cap and the float are sized to the loss from a full period without detection.
- New payees and targets wait longer than the detection-to-revoke time.
- Each operation is simulated at a pinned block and compared with its intent.
- Approvals are exact and expire; typed-data signing is limited to allowlisted schemas.
- Chain IDs come from configuration; no EIP-7702 authorisation uses chain ID 0.
- Each intent has a stable ID and a nonce fixed when it is first built.
- The pause works without the module; revocation and allowance clean-up are rehearsed.
- Monitoring alerts on first-time recipients, cap utilisation and reverted operations.
Most of these are off-chain code paths: penetration testing of the host, tools and signer complements a review of the account modules.
Conclusions
An agent that signs should be built on the assumption that its planner will one day propose the wrong transaction. Host-side checks decide how often that happens; on-chain rules and the float decide what it costs.
Of the models compared, smart-account session keys keep the agent’s limits on-chain under an owner that can be a multisig and can be replaced. EIP-7702 delegates can enforce similar rules but keep a root key that can never be rotated, and ERC-7715 leaves enforcement to the wallet. In every model, a period cap sized against detection time, idempotent intents and rehearsed revocation matter most.
Recommendations
Teams building agents
- Build all calldata in a deterministic policy engine from typed intents, never from model output.
- Give the agent host only a scoped, expiring session key, and size the period cap and float from the loss you can absorb without detection.
Account and module developers
- Exclude the account itself,
approveanddelegatecallfrom session scopes by default. - Provide a pause that blocks session keys in validation without relying on the module’s uninstall path.
Reviewers
- For each threat, name the control that still bounds it when the agent host is compromised.
- Stop the agent between signing and inclusion, restart it, and confirm that no intent executes twice.
Bibliography
Primary sources: standards, official documentation, incident reports and papers.
- 1ERC-4337: Account Abstraction Using Alt Mempool(opens in a new tab)Ethereum Improvement Proposals
- 2ERC-7579: Minimal Modular Smart Accounts(opens in a new tab)Ethereum Improvement Proposals
- 3ERC-7715: Request Permissions from Wallets(opens in a new tab)Ethereum Improvement Proposals
- 4EIP-7702: Set Code for EOAs(opens in a new tab)Ethereum Improvement Proposals
- 5Safe Modules(opens in a new tab)Safe documentation
- 6Permit2 IAllowanceTransfer interface (approve, expiration, lockdown)(opens in a new tab)Uniswap, permit2 repository
- 7LLM01:2025 Prompt Injection(opens in a new tab)OWASP Gen AI Security Project
- 8Real AI Agents with Fake Memories: Fatal Context Manipulation Attacks on Web3 Agents(opens in a new tab)Patlan, Sheng, Hebbar, Mittal and Viswanath, arXiv:2503.16248
Applied on this site
Assessments, case studies and tools that apply this publication.






