Skip to main content
Two properties, and everything else on this page is one of them worked out in detail. Non-custodial. We never hold your money. The account is a smart account you own outright, and the address you are given is your deposit address, so value landing there on any chain is already under your control before we do anything with it. Two other keys touch that account and neither can hold a token: an agent key that authorises what your strategy does and can only ever authorise, and a relayer key that pays gas and is not an authority over anything. Neither was ever given your key. Unilateral control. You can empty the account without our help, without our servers being up, and without our permission. Not as a policy we could change, but because there is no contract above you: the code has no override for us to use, and the section below is the command that proves it. If our side is compromised. Worth answering plainly, because it is the question the first two invite. An attacker holding the agent key cannot own your account, cannot change who does, cannot name a destination and cannot supply calldata: those are not policies, they are things the signed type has no field for. It is bounded further by a spend cap fixed in your account at creation that the agent cannot raise, and it can be revoked outright for every account on a chain in one transaction. Bounded is not the same as harmless, and the honest limit is the guard being a denylist: it blocks an agent taking control, and it does not block every way value can move. An attacker holding the relayer key gets gas, censorship, and calls of its own; never your assets, because it was never an authority over them. The phrase for the worst case is “stuck, not stolen”. If things break, value sits somewhere nobody can move it. It does not leave.

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 in ECDSAValidator, 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 is TaskID, 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 one handleOps 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:
  1. The owner can call execute directly: no EntryPoint, no bundler, no userOp, and execute any call to any target.
  2. Swapping the root validator silently changes who may call execute, upgradeTo and installModule directly. A different validator that is not also a hook returns false, the third-party branch reverts, and nobody can call execute directly.

Which validator signs is chosen by the nonce, not the signature

Kernel v3 picks the validator from userOp.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 to execute 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
It is a denylist, not an allowlist. Everything else: any target, any other selector, passes. So it fully blocks control takeover: B can never become owner or hand the account away. It does not block value movement by any selector it did not name. Four drain vectors are proven on a Base fork, in the production shape, with the guard active: 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:
  1. Every state-changing entry point goes through the one consume path that binds the owner, requires B’s signature, and blocks replay and expiry.
  2. The owner is READ from the account, never trusted from the intent. A valid B signature is not proof the right account was meant.
  3. The recipient is forced to the account. Never a to from 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.
  4. No intent-controlled destination or calldata anywhere. B supplies typed parameters; the adapter composes the call.
  5. 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.
  6. Approvals cleared in-transaction, or a bounded standing allowance justified in the file header.
  7. Output measured as a balance delta, not trusted from the venue’s return value, wherever a venue could lie.
Prove the receiver-forcing with a mutation test: point the recipient at an attacker and watch the security assertion fail, or it is decoration, not a control.

Designed versus built

The distinction this page keeps throughout: