the delegation layer // untoll

How to stop an AI agent
from draining a wallet.

Scope the key, not the prompt. The boundary has to sit somewhere the model never touches, and there is exactly one such place: the settlement path.

The short answer. Give the agent a key whose authority is bounded by contract code, and keep the key that can move everything away from the model entirely.

  1. The owner key installs a scope once and is never handed to the agent.
  2. The agent transacts only with a session key, and every call it makes routes through a delegate contract that checks that scope first: which targets, which function selectors, how much value per call, how much across a window, how long the key lives, and where a call's output is allowed to land.
  3. Anything outside the scope reverts. The check is an allowlist and arithmetic in bytecode, not a policy the model can be argued out of.

A fully jailbroken agent still tries. It just does not get to move anything the installed scope does not already permit, and the attempt is on chain as an event.

run the demo the delegation layer the proofs
01 // the failure everyone starts with

A prompt cannot hold a boundary.

A system prompt, a tool description, a refusal rule and a jailbreak are all the same kind of object: a string the model reads. Putting the boundary in that layer means defending it in the layer the attacker is writing in. The rule that survives is a rule the model cannot address, phrase, or argue with, and that means it has to live below the model, in the code that settles the transaction.

The same reasoning rules out the two habits that feel safest. A hardware wallet or a multisig governs a signature a human is standing next to; an agent that is meant to act signs continuously, so approving every call removes the autonomy and approving in bulk removes the protection. A hot wallet with a small balance bounds exactly one thing, the balance, and inside it the agent can send anything anywhere, forever.

02 // the load-bearing fact

An EIP-7702 authorization carries no scope.

An EIP-7702 authorization tuple signs three things and only three things: [chain_id, delegate_address, nonce]. There is no field in it for a spending limit, an allowlist, or an expiry. That sounds like a gap. It is the property the whole design rests on.

Because scope cannot live in the signature, it has to live in the delegate contract's bytecode, at the settlement path. That is below the model, below the SDK, below anything a prompt reaches. Whatever state the agent's reasoning is in, the transaction it constructs meets the same arithmetic. The worst case is bounded by what the delegate's code permits, by construction rather than by intent.

The mechanical consequence is worth stating plainly: EIP-7702 lets an ordinary EOA execute a delegate contract's code at its own address. The agent keeps a plain address, and the boundary is still enforced by a contract.

03 // what a scope has to carry

Six dimensions, and each one stops a different attack.

The scope model in SessionKeyDelegate.sol, and what each dimension bounds.
dimensionwhat it boundswhat it stops
target allowlistwhich contracts the key may call at allthe agent is talked into calling a contract nobody vetted
selector allowlistwhich functions on those contractsan allowed contract has a withdraw path the agent was never meant to touch
per-transaction caphow much value one call may moveone large transfer out
per-epoch caphow much value moves across a rolling windowthe drip: many small calls that each clear a per-call limit
hard expiryhow long the key stays usablea key that outlives the job it was issued for
recipient policywhere a call's output is allowed to landthe exact allowed call, pointed at somebody else

The last row is the one coarse allowlists miss. A wallet-level rule of the form "this agent may use this protocol" sees the target and the selector and waves the call through. It does not look at where the output goes. The identical in-scope call, with one address changed roughly a hundred bytes into the calldata, is a drain. A per-selector recipient policy is what catches it, and it is the difference between a permission list and a scope.

04 // check it, do not take it on faith

One command, a few seconds, no funds.

git clone https://github.com/hookwright/untoll-agent-demo cd untoll-agent-demo npm install npm run demo

The demo starts a local chain in Prague mode (the hardfork carrying EIP-7702), generates three throwaway keys, deploys the real SessionKeyDelegate, delegates an agent EOA to it, installs a scope, and then runs one allowed action and five forbidden ones. No wallet is connected and no funds are involved. It cleans up the chain it started.

ALLOWED in-scope call, output to owner PASSED tx 0xe68f...2e47 BLOCKED in-scope call, output to attacker execute() reverted: RecipientNotAllowed BLOCKED redirect output to attacker ScopeViolation(RECIPIENT) tx 0xed8f...c5a2 BLOCKED off-scope selector drain() ScopeViolation(SELECTOR) tx 0xeba2...9a79 BLOCKED over the 1 ETH per-tx cap ScopeViolation(PER_TX) tx 0x46ee...e543 BLOCKED off-scope target (unknown) ScopeViolation(TARGET) tx 0x1a88...5eac

The six transaction hashes above are from the documented 2026-07-30 run; a fresh npm run demo mines new receipts with the same six outcomes. Every line is read out of a mined receipt, not printed from a script: the live chain id, the 0xef0100 delegation designator in the owner EOA's code, the revert reason decoded from the deployed ABI, and one ScopeViolation event per blocked vector. npm run verify:bytecode recompiles the contract from source and confirms the executable code the demo deploys is that source. npm run demo:fork runs the same sequence against a local fork of Robinhood testnet, chain 46630, so the chain id and state are the testnet's while execution stays on your machine.

The scope itself is about fifteen lines in scripts/demo.mjs. Change a target, a selector, a cap or the recipient policy, re-run, and watch the boundary move. That is the fastest way to learn what your own agent's scope should say.

05 // questions

The ones that come up.

Can a jailbroken agent drain a wallet if it holds a scoped session key?

It can attempt it. Each attempt outside the scope reaches the delegate contract and reverts, so the funds stay put. What is at stake is whatever the scope granted, which is why the caps and the recipient policy are the real design decisions rather than the prompt.

Is a hardware wallet or a multisig enough for this?

They govern a signature a human is present for. An agent that is meant to act has to sign continuously, so a human approval on every call removes the autonomy and a bulk approval removes the protection. Bounding what a signature is permitted to do is the part that is left over, and it is the part a delegate contract does.

How is this different from giving the agent a hot wallet with a small balance?

A funded hot wallet bounds one thing, the balance. Inside that balance the agent can send anything anywhere, to any contract, at any time, for as long as the key exists. A scoped key bounds destination, function, size per call, size per window, and lifetime, separately.

Does this require a smart-contract wallet?

No. EIP-7702 lets a normal EOA execute a delegate contract's code at its own address, so the agent keeps a plain address and the boundary is still enforced by contract code.

What stops the agent from just removing its own scope?

The owner key installs the scope and the agent never holds it. The session key the agent transacts with routes through execute(), which checks the installed scope before forwarding. Changing the scope is not one of the calls that path permits.

Where can I read the code that does the checking?

contracts/SessionKeyDelegate.sol in github.com/hookwright/untoll-agent-demo. It is the whole delegate: target and selector allowlists, per-transaction and per-epoch value caps, hard expiry, and a per-selector recipient policy. npm run verify:bytecode recompiles it and confirms the executable code the demo deploys is that source.

06 // where this comes from

UNTOLL, and why it needs this.

UNTOLL is a Uniswap v4 hook launchpad on Robinhood Chain where the bonding curve is the pool, and the agent lane is the part of it that needs containment: an agent acting on a launch has to hold a key that can trade and can do nothing else. The delegate above is that containment core, pulled out to stand alone so it is useful whether or not you ever touch UNTOLL.

Adjacent receipts, since a claim about security is worth exactly as much as what you can check: the hook and four sibling contracts are deployed and source-verified on testnet 46630, readable in full at the explorer. The chain enumeration reads every v4 hook on chain 4663 straight from PoolManager Initialize events: as of 2026-08-08, 4,536 of them across 110,487 pools, with the method attached so it can be falsified.

Corrections wanted. If something here is wrong about EIP-7702, about the delegate, or about what the demo does, say so and it gets fixed. Reach @untolling.

the demo: github.com/hookwright/untoll-agent-demo the hook: github.com/hookwright/untoll-hook untoll.ing
Written 2026-08-02. The EIP-7702 authorization-tuple fields are from the EIP-7702 specification; everything else on this page is reproducible with the commands above.