Skip to content
Quantix Security Labs
Technical analysis

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.

Illustration: an agent’s transactions pass a policy gate before a session key signs them for a smart account; one is blocked at the gate, and a revoke path leads back to the key.
Contents
  1. The threat model
  2. Where the controls sit
  3. Delegated authority models compared
  4. Scoping a session
  5. Sizing spending limits
  6. Validating before signing
  7. Revocation and recovery
  8. Stuck, failed and retried transactions
  9. Review checklist
  10. Conclusions
  11. Recommendations
  12. Bibliography

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.

ThreatEnters atExampleBounded by
Prompt or data injectionInputs → modelAn invoice memo asks for payment to a new addressOn-chain scope; delay on new payees
Poisoned tool outputTool → modelAn API returns a wrong address or malicious calldataEngine-built calldata; allowlists; simulation
Key theftAgent hostSession key read from disk or memoryKMS or HSM; on-chain caps and expiry
Runaway loopModel or engineRetries pay one invoice twice, or burn gasIdempotency keys; rate and period caps
ReplaySigner → chainA signature accepted on another chain or accountChain ID and account binding; nonces
Approval abuseChainAn unlimited approve outlives the sessionNo approve in scope; exact, expiring amounts
MEVSubmissionA public swap sandwiched to its slippage limitMinimum output; deadlines; private submission
Threats to an agent that transacts, where each enters, and what bounds it

The model’s output is therefore untrusted input to the signer, and no system prompt changes that.

Where the controls sit

Where an autonomous agent’s transaction is checked, and which checks survive a compromiseUntrusted inputs, such as user and web content, tool and API output and RPC responses, flow into a model or rule-based planner that emits a typed intent. Inside the agent host, a policy engine decodes the intent, checks allowlists, simulates and applies caps and an idempotency key, then a signer held in a KMS or HSM signs the approved operation with a session key. A bundler or RPC endpoint simulates and includes it. On-chain, a smart account built on ERC-4337 and ERC-7579 runs a session validator that checks the key, scope and expiry, and an execution hook that applies caps, a rate limit and a pause flag, before calling target contracts such as a token or router. An owner or guardian key, held offline or in a multisig, can revoke the session and pause the account; monitoring watches account events and alerts the owner. Numbered risks: 1, prompt or data injection reaches the model; 2, poisoned tool output becomes an intent; 3, key or host compromise bypasses every off-chain check; 4, on-chain scope and caps are the limits that still hold when 1 to 3 fail.Agent host (off-chain): a compromise here bypasses every check inside itUntrusted inputsuser and web contenttool and API outputRPC responsesModel or plannerLLM or rule-basedemits a typed intentPolicy enginedecode, allowlist, simulatecaps, rate, idempotency keybuilds the calldata itselfSigner (KMS or HSM)holds the session key onlysigns approved operations123On-chain: enforced by the account, whatever the host doesTarget contractstoken, router, protocolSmart account (ERC-4337, ERC-7579)session validator: key, scope, expiryexecution hook: caps, rate, pause flagowner-only: install, revoke, pauseBundler or RPCsimulates and includes4Owner or guardianrevokes, pausesMonitoringalerts on account events1Prompt or data injection reaches the model2Poisoned tool output becomes an intent3Key or host compromise bypasses off-chain checks4On-chain scope and caps: hold when 1–3 fail
Checks in the agent host are rich but fall with it; checks in the account are narrow but hold when everything off-chain is compromised.

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.

Delegated authority models compared

Statuses are as listed on eips.ethereum.org on 31 October 2026; Draft standards may change.

ModelStatusBoundsCannot boundRevocation
Hot EOA keyProtocol defaultNothing beyond the balanceAnything: the key is the accountNone; move funds first
MPC or remote signer with policiesNot a standardWhat the service checks, while intactWhoever controls the service or enough key shares; on-chain it is a plain EOADisable the credential; signed transactions stay valid
Smart account session keys (ERC-4337, ERC-7579 modules)ERC-4337 Final; ERC-7579 DraftTarget, selector, arguments, value, time; totals in hooks. Survives host compromiseEffects of allowed calls; module bugsOwner removes key or module; independent pause
ERC-7715 permission requestDraftTypes the wallet supports, such as native-token-allowance with an expiry ruleEnforcement, which is left to the walletwallet_revokeExecutionPermission
EIP-7702 delegationFinalSession rules in the delegate contractThe EOA key, which still signs transactions and authorisationsRe-delegate, or delegate to the zero address
Ways to give an agent signing authority, what each bounds and what it cannot

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:

DimensionRuleEnforced inStops
TargetStablecoin only, zero ether value; never the account itselfValidator and hookOther contracts; self-calls to installModule
Functiontransfer(address,uint256), selector 0xa9059cbbValidator and hookapprove (0x095ea7b3), transferFrom
RecipientPayee registered at least 24 h earlierHookPayment to a freshly injected address
Amount≤ 5,000 per callValidator or hookOne large transfer
Period≤ 40,000 per rolling 24 hHookMany small transfers
Rate≤ 20 operations per hourEngine and hookRunaway loops and gas burn
TimeValid for 7 daysvalidUntilUse of a stale or stolen key
Session policy for the model payments agent: each rule, where it is enforced, and what it stops

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:

Target-only session check (illustrative, vulnerable)solidity
// 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:

AgentSessionPolicy.sol (illustrative)solidity
// 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.

LimitsLoss, T = 1 hLoss, T = 12 hPayments to approveValue to approve
None200,000200,00000%
5,000 per call200,000200,00000%
2,500 per call; 20 calls an hour50,000200,00050 (5%)31.25%
5,000 per call; 40,000 per rolling 24 h40,00040,000100 (10%)10%
5,000 per call; 40,000 per calendar day80,00080,000100 (10%)10%
5,000 per call; 90,000 per rolling 24 h90,00090,00000%
Worst-case loss before revocation, and legitimate payments routed to human approval each month, for the model agent (stablecoin units)

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:

send-transaction-tool.ts (illustrative, vulnerable)ts
// 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 }),})
pay-invoice.ts (illustrative)ts
// 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 uint48 expiration after which transfers revert with AllowanceExpired; 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. userOpHash binds an ERC-4337 operation to one chain and EntryPoint; EIP-7702 authorisations with chain_id 0 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, approve and delegatecall, 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

  1. Build all calldata in a deterministic policy engine from typed intents, never from model output.
  2. 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

  1. Exclude the account itself, approve and delegatecall from session scopes by default.
  2. Provide a pause that blocks session keys in validation without relying on the module’s uninstall path.

Reviewers

  1. For each threat, name the control that still bounds it when the agent host is compromised.
  2. 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.

  1. 1ERC-4337: Account Abstraction Using Alt Mempool(opens in a new tab)Ethereum Improvement Proposals
  2. 2ERC-7579: Minimal Modular Smart Accounts(opens in a new tab)Ethereum Improvement Proposals
  3. 3ERC-7715: Request Permissions from Wallets(opens in a new tab)Ethereum Improvement Proposals
  4. 4EIP-7702: Set Code for EOAs(opens in a new tab)Ethereum Improvement Proposals
  5. 5Safe Modules(opens in a new tab)Safe documentation
  6. 6Permit2 IAllowanceTransfer interface (approve, expiration, lockdown)(opens in a new tab)Uniswap, permit2 repository
  7. 7LLM01:2025 Prompt Injection(opens in a new tab)OWASP Gen AI Security Project
  8. 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.

Want this question asked of your code?

Quantix reviewers apply the same reasoning to production systems. Share what you are building and when it ships.