> ## Documentation Index
> Fetch the complete documentation index at: https://docs.defiloops.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Non-custodial, and unilateral control

> We never hold your money, and you can take it back without us

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](#the-guard-is-a-denylist-and-that-is-the-honest-description-of-it):
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.

|                      | **A**: you, the owner                 | **B**: the agent                    | **R**: the relayer                                                   |
| -------------------- | ------------------------------------- | ----------------------------------- | -------------------------------------------------------------------- |
| held by              | you                                   | us, in a key service                | us, hot, one per chain                                               |
| holds value          | yes: the account is yours             | nothing                             | native gas balance only                                              |
| signs                | account-level transactions            | authorisations over an action       | transactions                                                         |
| authorises           | everything                            | bounded actions                     | **nothing**                                                          |
| on-chain identity    | the owner in `ECDSAValidator` storage | a non-root validator on the account | `msg.sender` of `handleOps`                                          |
| if the key is stolen | total and unrecoverable               | bounded, see below                  | gas, censorship, and arbitrary calls *of its own*, never your assets |

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.

| A is                                      | root validator               | userOp path | direct-call path                                              |
| ----------------------------------------- | ---------------------------- | ----------- | ------------------------------------------------------------- |
| an EOA                                    | the shipped `ECDSAValidator` | yes         | yes: the caller must be the owner                             |
| a contract answering ERC-1271: a **Safe** | `ERC1271RootValidator`       | **yes**     | yes: the caller must be the owner contract                    |
| a **passkey** (P-256 / WebAuthn)          | `WebAuthnRootValidator`      | yes         | only if an exit caller was named when the account was created |

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:

| vector                                | why it gets through                                  |
| ------------------------------------- | ---------------------------------------------------- |
| `increaseAllowance` on real Base USDC | the guard denies `approve`, not `increaseAllowance`  |
| Aave v3 `withdraw(asset, amount, to)` | `to` is an ordinary parameter of an unnamed selector |
| ERC-721 `setApprovalForAll`           | hands over every tokenized-property NFT in one call  |
| ERC-677 `transferAndCall`             | one call, no allowance step                          |

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](/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:

|                                                     | designed | built today                                                                                                                                                                                               |
| --------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| B cannot express calldata                           | ✅        | ✅: the type has no field for it                                                                                                                                                                           |
| B's signature checked on chain                      | ✅        | ✅: recovered and compared to the registry                                                                                                                                                                 |
| B held in a KMS                                     | ✅        | ❌: the signing service exits under a production environment precisely so a dev backend can never sign for real money                                                                                      |
| per-epoch outflow cap                               | ✅        | ✅ in the adapter base: ❌ on the raw `execute` path                                                                                                                                                        |
| registry rotation as kill switch                    | ✅        | ✅                                                                                                                                                                                                         |
| R authorises nothing                                | ✅        | ✅: both live submit paths move value through the account, and R holds no catalogued token and no allowance on any chain. Its KEY can still sign calls of its own; that is a separate row it does not have |
| target-scoping hook                                 | ✅        | ❌: the one unbuilt edge of the authority graph                                                                                                                                                            |
| unilateral owner exit                               | ✅        | ✅: `ownerexit` builds it from the chain and the owner sends it, with no relayer, bundler, agent or adapter. Balances only: it does not unwind a loan or move a liquidity position                         |
| an owner that is a **Safe**                         | ✅        | ✅: `ERC1271RootValidator`, all three paths, **unaudited** and not the default                                                                                                                             |
| an owner that is a **passkey**                      | ✅        | ✅: `WebAuthnRootValidator`, **unaudited** and not the default. The bundler-free exit needs an exit caller named at creation                                                                               |
| a root validator that can be changed after issuance | ❌        | ❌, and it never will be. The validator's address is in the account's CREATE2 preimage, so the choice is fixed when the address is first derived                                                           |


## Related topics

- [FAQ](/faq.md)
- [DeFiLoops](/index.md)
- [The contracts](/protocol.md)
- [Does splitting cost less?](/splitting.md)
- [How it works](/platform.md)
