Unilateral control: what you can always do
There is a tool that reads who may drive your account and what it holds, then builds the transactions that empty it to an address you name. It shows them to you before it sends anything. It touches no relayer, no bundler, no agent key, no adapter and no registry, and that is deliberate: every one of those is something that can be broken on the day you need to leave. Proven on a fork, an account funded with ether and WETH was emptied to a fresh address in a single owner transaction, and the same worked on an account whose agent had no execute validator at all. A sweep moves balances. It does not unwind lending positions, does not move ERC-721 or ERC-1155 (Uniswap liquidity positions among them) and cannot move what has not arrived yet. The tool names all of that in its own output rather than omitting it: the failure worth avoiding is an owner who believes they have exited and leaves a leveraged position accruing behind them. The other side of that is the trade we cannot take back. We hold no override. The same property that lets you leave without us means we cannot stop you, or anyone holding your key. If your key is stolen, the account is gone and we cannot recover it.Non-custodial: who holds what
Three keys touch the account and only one of them is an authority over your money. That split is the whole of the property: there is no arrangement under which we are holding it for you. They are called A, B and R in the code and throughout these pages, which is worth knowing when you read one, but the distinction that matters is the second column.
The account itself is a Kernel v3.1 ERC-4337 smart account, vendored at a
pinned commit.
You, the owner (A)
May: everything. A is the owner recorded inECDSAValidator, which the
account’s initialisation installs as the root validator. In one direct
transaction, with no bundler and no EntryPoint, A may call execute with any
target and any calldata including DELEGATECALL, upgrade the implementation,
install or uninstall modules, change the root validator (handing the account to
someone else), or invalidate every non-root validator at once.
May not: nothing. There is no control above A and there is not meant to be.
That is what “you can exit without our cooperation” means, and the section above
is the code that makes it a fact rather than an intention.
The agent (B): authorises, and holds nothing
May: authorise an action. Nothing else. B never appears as an owner, never holds funds, never pays gas, and never appears in the account’s root validation path. May not: own, install or uninstall modules, change the root validator, upgrade the implementation, choose a destination address, or supply calldata. That last one is structural rather than policed. The type B signs has no field capable of expressing an arbitrary call: the entire surface isTaskID, Owner, Chain, Operation, Asset, AssetOut, Amount, MinOut, Deadline, Nonce.
Its own doc comment states the rule and the reason: “no To address, no Data, no
Value, no raw calldata, no venue-supplied route blob. Adding any such field would
defeat the purpose of this type, so don’t.”
What bounds a stolen B: no adapter has a field for an attacker-controlled
destination; a per-epoch outflow cap immutable in the account’s init data that B
cannot raise; and registry revocation as a kill switch. One write stops the key
for every account on that chain, with no redeploy and no migration.
The kill switch is per chain. Each chain carries its own AgentRegistry
deployment, so revoked[agent] is that chain’s storage: a full revocation is one
transaction per chain, three today. Worth knowing before the day it is needed,
along with AgentRegistry.guardian being write-once at initialisation: a
registry deployed without one answers every other question perfectly well and can
revoke nothing.
The relayer (R): pays, and authorises nothing
The relayer builds calldata; it is never given calldata. The agent says “send 100 USDC on Base”. The build step resolves the symbol to a contract address from trusted configuration and ABI-encodes the transfer. There is no input to that function capable of expressing an arbitrary call, and there must never be one. R holds nothing but gas. Both live submission paths route value through the account: a signed batch to the adapters, or onehandleOps to the
EntryPoint, so R is msg.sender of the outer transaction only, and the assets
moved are the account’s. Measured on a long-lived fork after many runs, R holds
zero of every catalogued token, no allowance to any venue, and native ether for
gas.
B’s signature is verified too, and on chain rather than in our code: the adapter
recovers it and refuses anything not signed by the account’s appointed agent.
What is still true about R is about the key, not the money. Nothing in this
system constrains what R’s key signs once someone holds it, and the relayer
service’s configuration carries no destination allowlist. A stolen R can burn
gas, censor, and sign arbitrary calls of its own. It cannot spend your assets,
because it was never an authority over them.
If your owner is a Safe, or a passkey
A is one of three shapes, and the shape is part of the account’s address.
Under the shipped
ECDSAValidator, a contract owner has no userOp path at
all: the direct-call path compares msg.sender to the stored owner and passes a
contract unchanged, but the userOp path is an ECDSA recovery, and recovery can
never return a contract address. The direct path is then not a convenience; it
is the only path A has. The Safe case removes that by swapping one line: a
signature check that asks the owner, in place of a recovery that cannot describe
one. A 2-of-2 Safe then drives an account on all three paths.
Neither is the default and neither is audited. New accounts are still created
with an EOA owner, and the choice matters more than it looks: the root
validator’s address is inside the account’s address, so it is fixed when the
account is first derived and there is no migration afterwards. A passkey’s exit
caller is a custody decision of the same kind: leave it unset and every
withdrawal needs a bundler; set it and that address can spend without a passkey
prompt.
Why “root” does not mean what it sounds like
This is the finding that determines the whole model, and it is worth reading if you are auditing rather than operating.Kernel.execute is guarded by a modifier named onlyEntryPointOrSelfOrRoot.
Kernel performs no ownership check of its own. For a caller that is neither
the EntryPoint nor the account itself, it asks the root validator module “are you
also a hook?” and, if so, hands the entire authorisation decision to that
module’s preCheck.
So “root” means whoever the root validator’s hook implementation admits, and
with ECDSAValidator as root, preCheck is exactly one require: the sender
must equal the stored owner. It performs no check on target, selector or value.
Two things follow, and the second is the sharp one:
- The owner can call
executedirectly: no EntryPoint, no bundler, no userOp, and execute any call to any target. - Swapping the root validator silently changes who may call
execute,upgradeToandinstallModuledirectly. A different validator that is not also a hook returns false, the third-party branch reverts, and nobody can callexecutedirectly.
Which validator signs is chosen by the nonce, not the signature
Kernel v3 picks the validator fromuserOp.nonce, an explicit break from v2. The
nonce is laid out as 1 byte mode | 1 byte type | 20 bytes validator identifier | 2 bytes parallel key | 8 bytes sequence, and the first 24 bytes are exactly the
EntryPoint’s nonce key. Type 0x00 means root: the identifier is ignored and
overwritten with the stored root validator. Type 0x01 means the 20 bytes are
the validator address.
This is why B is addressed as a non-root validator without ever becoming one.
Restricting the agent, and the step people forget
The agent reaches an account through the on-chain adapters and nothing else: each takes typed parameters, never a target, never calldata. But making that true on a given account takes one more step, because the agent’s validator is named in the account’s genesis and therefore in its address. That validator has to be uninstalled. Until it is, the adapters are an additional route rather than a restriction: a user operation scoped toexecute can still name any target, so
nothing on chain bounds where the agent sends the funds. There is a setting that
gives the old unrestricted path back for test forks, and it is named for what it
costs rather than for what it enables.
The guard is a denylist, and that is the honest description of it
AgentSelfCallGuard is the hook on B’s validator that closes the “execute is
scoped to nothing” hole. For a B-signed execute or executeFromExecutor its
preCheck refuses:
- a self-call: target is the account
DELEGATECALL- any call to the registry
- nonzero native value
- a revoked or unappointed agent; this is the kill switch
- and exactly six selectors:
transfer,approve,transferFrom,changeRootValidator,onInstall,onUninstall
The guard’s own header claims it “keeps execute from being a second, uncapped
path”. That is accurate for control and overstated for value. The per-epoch cap
lives in the adapter base contract, not in the guard, so a drain that never
touches an adapter never meets a cap.
This is reachable only in unrestricted mode, where B signs a raw
execute
userOp, or where a directly-held B key bypasses the signer. In default adapter
mode the signer never produces execute userOps, so these gaps are not reachable
through the sanctioned path. That is a real mitigation and it is not the same
thing as the hole being closed.
Before citing any selector as blocked or open, re-read the check in
contracts/src/AgentSelfCallGuard.sol. It is the authority and it is short.
What every adapter must force, by construction
The adapter model’s whole security claim is “the agent is constrained because the chain says so, not because our signer only authorises a closed set.” That claim holds only if each adapter independently forces the owner-binding and the destination. One adapter with a settable recipient reopens the drain the design closed, and it fails late and quietly, as a valid signature and a misdirected transfer. Seven points, and see Adapters for what they look like in the catalogue:- Every state-changing entry point goes through the one consume path that binds the owner, requires B’s signature, and blocks replay and expiry.
- The owner is READ from the account, never trusted from the intent. A valid B signature is not proof the right account was meant.
- The recipient is forced to the account. Never a
tofrom the intent. Where a venue hides the recipient inside an opaque hash, the adapter rebuilds the hash forcing the receiver; it never accepts a pre-hashed destination. - No intent-controlled destination or calldata anywhere. B supplies typed parameters; the adapter composes the call.
- Venue by name from the installed set, a cap per epoch, operation on the allow-list. An intent naming a venue address would be an arbitrary call.
- Approvals cleared in-transaction, or a bounded standing allowance justified in the file header.
- Output measured as a balance delta, not trusted from the venue’s return value, wherever a venue could lie.