Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 215 additions & 0 deletions docs/EIP7702.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# Sequence EIP-7702 Wallet Documentation

This document describes how a plain EOA becomes a Sequence wallet through [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702), how its configuration is derived before the first update, and what the resulting trust model is. The trust model of a 7702 wallet is **not** the same as the trust model of a Stage1 or Stage2 wallet, and the difference matters for anything that presents key rotation or recovery to a user. Read section 4 before building product surfaces on top of these wallets.

---

## **1. Overview**

`Stage7702Module` is the only stage of an EIP-7702 wallet. It is the contract an EOA delegates to, and it is composed of the same modules as the other stages:

- `Calls`, for `execute` and `selfExecute`.
- `Stage7702Auth`, which is `BaseAuth` plus `Implementation7702`.
- `Hooks`, for the selector to implementation mapping and the fallback.
- `ERC4337v07`, for `validateUserOp` and `executeUserOp`.

There is no second stage and no `updateImplementation`. The wallet does not own its own code: the code of a 7702 account is a delegation designator that only the account's own key can set or change, through a type-4 transaction. Everything else about the wallet, the configuration, the nonces, the hooks and the static signatures, lives in the account's storage and works exactly as it does for a deployed Sequence wallet.

Compared to Stage1 and Stage2, a 7702 wallet gains a keyholder that the configuration tree does not describe and cannot remove. That is the subject of section 4.

---

## **2. Delegation and implementation discovery**

An EOA becomes a Sequence wallet by signing an EIP-7702 authorization for the address of a deployed `Stage7702Module`. Once the authorization is applied, the account's runtime code is the 23 byte delegation designator:

```
0xef0100 || implementation
\____/ \____________/
3 bytes 20 bytes
```

Calls into the account execute the implementation's code in the account's own context, so `address(this)` is the EOA address and the storage read and written is the EOA's storage.

`Implementation7702.getImplementation` reports the current target by reading the account's own code rather than a storage slot:

```solidity
if eq(extcodesize(authority), 23) {
extcodecopy(authority, 0, 0, 23)
let word := mload(0)
if eq(shr(232, word), 0xef0100) {
implementation := and(shr(72, word), 0xffffffffffffffffffffffffffffffffffffffff)
}
}
```

This is the canonical source: the code is what the EVM will actually run on the next call. It returns `address(0)` if the account is not a delegation designator. Integrators SHOULD use it to confirm that an account is delegated to the `Stage7702Module` build they expect, and MUST NOT assume the target is immutable, since the account's key can re-delegate at any time.

---

## **3. The initial configuration**

`Stage7702Auth.imageHash()` reads the stored image hash first and falls back to a counterfactual value:

```solidity
bytes32 onchain = Storage.readBytes32(IMAGE_HASH_KEY);
if (onchain != bytes32(0)) {
return onchain;
}
// otherwise, the counterfactual 1-of-1 configuration
```

The counterfactual value is the image hash of a 1-of-1 configuration whose only signer is the account itself:

```
┌────────────────┐
│ Image hash │
└───▲────────▲───┘
│ │
┌───┘ └──────────┐
│ │
┌───────────┴────────────┐ ╔═══════╧════════════════════════════╗
│ Signer: address(this) │ ║ Checkpointer: DEFAULT_CHECKPOINTER ║
│ Weight: 1 │ ║ Checkpoint: 0 ║
└────────────────────────┘ ║ Threshold: 1 ║
╚════════════════════════════════════╝
```

Concretely, with `DEFAULT_CHECKPOINTER` set at deployment of the module:

```
leaf = keccak256("Sequence signer:\n", address(this), 1)
imageHash = keccak256(leaf, 1) // threshold
imageHash = keccak256(imageHash, 0) // checkpoint
imageHash = keccak256(imageHash, DEFAULT_CHECKPOINTER) // checkpointer
```

So a freshly delegated account is a wallet whose only signer is its own EOA key, signing with plain ECDSA against the threshold of 1. No deployment, no factory and no initialization transaction is involved.

The first successful `updateImageHash` writes `IMAGE_HASH_KEY` and `imageHash()` switches to the stored value from then on. Because `_updateImageHash` reverts with `ImageHashIsZero` on a zero argument, the account can never fall back to the counterfactual configuration once it has been updated.

---

## **4. The authority key**

### 4.1 Why `onlySelf` accepts the authority key

`SelfAuth.onlySelf` requires `msg.sender == address(this)`. For a deployed wallet the only way to satisfy that is a call the wallet makes to itself, which means a payload that already cleared `execute` with a valid Sequence signature, or the `ERC4337v07` path where the EntryPoint validated the signature through `validateUserOp` first.

For a 7702 account, `address(this)` is the EOA. A plain transaction sent from the EOA to its own address has `msg.sender == address(this)`, so `onlySelf` passes with nothing else checked:

```
EOA key signs an ordinary transaction
│ from: 0xAUTH
│ to: 0xAUTH <- the account itself
│ data: updateImageHash(0x...)
┌───────────────────────────────────────────────┐
│ code at 0xAUTH is 0xef0100 || Stage7702Module │
│ the module runs in the account's context │
│ msg.sender == address(this) == 0xAUTH │
└───────────────────────────────────────────────┘
onlySelf passes
```

No Sequence signature is recovered, no nonce is consumed, no checkpointer is consulted and the reentrancy guard is not involved, because none of those live in the `onlySelf` path. For a 7702 wallet `onlySelf` therefore gates on **the wallet or its authority key**, not on the wallet alone.

### 4.2 Entry points reachable by the authority key

Every `onlySelf` function of `Stage7702Module` is directly callable by the authority key:

| Entry point | Module | What the key can do with it |
| -------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------- |
| `updateImageHash(bytes32)` | `BaseAuth` | Replace the whole configuration, including signers, threshold, checkpointer and extensions. |
| `setStaticSignature(bytes32,address,uint96)` | `BaseAuth` | Mark an arbitrary payload hash as signed until a chosen timestamp, for a chosen caller or for anyone. |
| `addHook(bytes4,address)` | `Hooks` | Register an arbitrary `delegatecall` target for a selector, reachable through the fallback by any caller. |
| `removeHook(bytes4)` | `Hooks` | Remove a registered hook. |
| `selfExecute(bytes)` | `Calls` | Execute an arbitrary batch of calls, with no signature and no nonce consumption. |

`selfExecute` alone is full control of the account. The others are listed because they persist: a planted hook or static signature stays effective even if the key is never used again.

### 4.3 Re-delegation

Independently of any of the above, the authority key can sign a new EIP-7702 authorization and point the account at a different implementation, or clear the delegation entirely. This happens at the transaction level, before any contract code runs. No contract can observe it, gate it or prevent it. A 7702 account's code is under the permanent control of its key.

### 4.4 Rotation and recovery do not revoke the key

Rotating the configuration to a new set of signers changes what `execute` accepts. It does not change `msg.sender` for a transaction the EOA sends to itself, so it does not remove the authority key's `onlySelf` access. The same is true of a `Recovery` module takeover: recovery ends with a configuration update, and a configuration update does not touch the delegation.

The consequence is blunt. **A compromised authority key cannot be revoked.** The only remedy is to move the assets to an account whose key the owner controls. Any product surface that describes rotation, recovery or "removing a device" as revoking access is wrong when applied to a 7702 wallet, and MUST be worded differently for these accounts.

### 4.5 Compromised initial key: Stage1 or Stage2 versus 7702

| | Stage1 / Stage2 wallet | EIP-7702 wallet |
| ----------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------- |
| Where the initial key's power comes from | A signer leaf in the configuration tree | Being the account itself |
| `execute` path | Sequence signature meeting the threshold | Same |
| `onlySelf` path | Only through a signed payload | Directly, by a transaction from the key to the account |
| Nonce, checkpointer and reentrancy guard on that path | Enforced | Not on the path at all |
| Effect of a configuration update | The old key loses all signing power | The old key keeps `onlySelf` access |
| Effect of a `Recovery` takeover | Same as a configuration update, the old key is out | The old key keeps `onlySelf` access |
| Changing the implementation | `updateImplementation`, behind `onlySelf` | A type-4 authorization signed by the key, no contract involved |
| Remedy after key compromise | Rotate the configuration, the wallet address survives | Move the assets out, the address cannot be saved |

### 4.6 Guidance for integrators

- An EIP-7702 authorization SHOULD be treated as being as sensitive as the account's private key, because it is authorized by that key and cannot be undone without it.
- Wallet software MUST NOT present configuration rotation or recovery as revocation for 7702 accounts.
- A 7702 account SHOULD NOT be used as the "cold" or highest value account of a setup that relies on rotation to contain a key compromise. A Stage1 wallet, whose initial key is only a configuration leaf, is the right shape for that.
- If the goal is to give an existing EOA smart wallet features while retaining the ability to revoke the original key, the migration MUST move the assets to a Stage1 wallet rather than delegate the EOA.

---

## **5. Checkpointer liveness for fresh wallets**

The checkpointer address is folded into the image hash, and `BaseSig.recover` takes it from the signature itself, not from storage. A fresh 7702 wallet has to reproduce the counterfactual image hash, which includes `DEFAULT_CHECKPOINTER`. So when `DEFAULT_CHECKPOINTER` is non-zero:

1. The first signature of every fresh wallet MUST set bit 6 of the top-level signature flag and name that exact address, otherwise the recovered image hash folds in `address(0)` and does not match.
2. Naming it means `BaseAuth` reaches `BaseSig.recover` in `RecoverMode.Initial`, which calls `ICheckpointer(checkpointer).snapshotFor(address(this), checkpointerData)`.

A checkpointer that reverts, runs out of gas or returns a snapshot with a non-zero image hash different from the counterfactual one blocks signature validation for every wallet that has not yet called `updateImageHash`. In the last case the revert is `UnusedSnapshot`, because the counterfactual checkpoint is `0` and cannot exceed the snapshot's.

The bound on that power is worth being precise about: the checkpointer can **block**, it can never **forge**. Validity still comes from `_isValidImage`, which compares the recovered image hash against `imageHash()`. A checkpointer cannot make a configuration that the account never adopted valid.

Two practical consequences:

- Deployments SHOULD leave `DEFAULT_CHECKPOINTER` as `address(0)` unless a keystore rollup is actually in use, since a non-zero value puts a live external contract on the critical path of every fresh wallet.
- The dependency ends at the first `updateImageHash`. After that the checkpointer is whatever the stored configuration says, which may be `address(0)`.

---

## **6. Storage persists across delegations**

EIP-7702 re-delegation does not clear the account's storage. An account that was delegated to some other implementation, then re-delegated to `Stage7702Module`, arrives with whatever that implementation left behind. If the previous implementation wrote to the slots Sequence uses, the values are live from the first call:

| Slot | Constant | Effect of a planted value |
| ------------------------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `keccak256("org.arcadeum.module.auth.upgradable.image.hash")` | `IMAGE_HASH_KEY` | `imageHash()` returns it instead of the counterfactual configuration, so the account is already controlled by someone else's signers. |
| `keccak256("org.arcadeum.module.hooks.hooks")` map | `HOOKS_KEY` | Any caller reaching the fallback with that selector triggers a `delegatecall` into the planted target. |
| `keccak256("org.sequence.module.auth.static")` map | `STATIC_SIGNATURE_KEY` | A pre-approved payload hash passes `signatureValidation` with no signature until its timestamp. |
| `keccak256("org.sequence.module.reentrancyguard.status")` | `STATUS_KEY` | A value of `2` makes every `nonReentrant` entry point, `execute` and `executeUserOp`, revert permanently. `selfExecute` still works, so the account is usable only by its authority key. |
| `keccak256("org.arcadeum.module.calls.nonce")` map | `NONCE_KEY` | Signed payloads have to start from the planted nonce for that space. |

Sequence's namespaced slots prevent **accidental** collisions with an unrelated implementation. They do not prevent **deliberate** ones: any previous delegate could compute the same constants and write to them. Treat inherited storage as attacker controlled whenever the account was delegated elsewhere before.

---

## **7. Onboarding checks**

Before treating a 7702 account as a fresh, owner controlled Sequence wallet, onboarding SHOULD read the following and refuse or warn on anything unexpected:

| Check | Expected for a fresh account |
| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `getImplementation()` | The `Stage7702Module` address the integrator expects. |
| `imageHash()` | Equal to the locally recomputed counterfactual value from section 3. Anything else means storage was already written. |
| `readHook(selector)` | `address(0)` for every selector the integrator cares about. |
| `getStaticSignature(hash)` | A zero timestamp for any payload hash being relied on. |
| `readNonce(space)` | `0` for the spaces being used. |
| Storage slot `0xfc6e07e3992c7c3694a921dc9e412b6cfe475380556756a19805a9e3ddfe2fde` | `0` or `1`. A value of `2` means the reentrancy guard is stuck. |

Two of these need care. The hooks mapping cannot be enumerated onchain, so onboarding MUST check the specific selectors it depends on rather than expecting to prove that no hook exists. The reentrancy status has no getter, so it has to be read with `eth_getStorageAt` on the slot above.

The account's history is also worth checking offchain. An account that has never had code is safe from section 6 entirely. An account that has been delegated before SHOULD be treated as carrying unknown state.
6 changes: 6 additions & 0 deletions src/modules/auth/SelfAuth.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ abstract contract SelfAuth {
/// @notice Error thrown when the caller is not the same as the contract
error OnlySelf(address _sender);

/// @notice Restricts a function to calls where the caller is the contract itself
/// @dev Under EIP-7702 the authority EOA also satisfies this check: a plain transaction from the EOA to its
/// own address runs the delegated code with `msg.sender == address(this)`. For an EIP-7702 wallet this
/// modifier therefore gates on "the wallet or its authority key", and the authority key reaches it without a
/// Sequence signature, a nonce or a checkpointer. Rotating the configuration does not revoke that access.
/// See `docs/EIP7702.md` for the full trust model.
modifier onlySelf() {
if (msg.sender != address(this)) {
revert OnlySelf(msg.sender);
Expand Down
Loading