This proposal introduces a new EIP-2718 transaction type and an onchain Account Configuration system that together provide account abstraction — custom authentication, call batching, and gas sponsorship. Accounts register actors with onchain verifier contracts. Transactions declare which verifier to use, enabling nodes to filter transactions without executing arbitrary wallet code. No EVM changes are required. The contract infrastructure is designed to be shared across chains as a common base layer for account management.
Account abstraction proposals that delegate validation to wallet code force nodes to simulate arbitrary EVM before accepting a transaction. This requires full state access, tracing infrastructure, and reputation systems to bound the cost of invalid submissions.
This proposal separates verification from account logic. Each transaction explicitly declares its verifier — a contract that takes a hash and signature data and returns the authenticated actor. This makes validation predictable: wallets know the rules, and nodes can see exactly what computation a transaction requires before executing it. Nodes may optionally filter on verifier identity, accepting only known verifiers (ECDSA, P256, WebAuthn, multisig, post-quantum) and rejecting the rest without execution.
New signature algorithms are introduced through verifier contracts and standardized through the canonical verifier set.
| Name | Value | Comment |
|---|---|---|
AA_TX_TYPE |
0x7B |
EIP-2718 transaction type |
AA_PAYER_TYPE |
0x7C |
Magic byte for payer signature domain separation |
AA_BASE_COST |
15000 | Base intrinsic gas cost |
ACCOUNT_CONFIG_ADDRESS |
CREATE2-derived (resolved at deployment) | Account Configuration system contract address |
ECRECOVER_VERIFIER |
address(1) |
Native secp256k1 (ECDSA) verifier for explicit k1 key registration |
REVOKED_VERIFIER |
type(uint160).max |
Revocation marker written to implicit EOA actor slot to block re-authorization |
NONCE_MANAGER_ADDRESS |
0x813000000000000000000000000000000000aa01 |
Nonce Manager precompile address |
TX_CONTEXT_ADDRESS |
0x813000000000000000000000000000000000aa02 |
Transaction Context precompile address |
DEFAULT_ACCOUNT_ADDRESS |
CREATE2-derived (resolved at deployment) | Default wallet implementation for auto-delegation |
DEPLOYMENT_HEADER_SIZE |
14 | Size of the deployment header in bytes |
NONCE_KEY_MAX |
2^256 - 1 |
Nonce-free mode (expiry-only replay protection) |
Each account can authorize a set of actors through the Account Configuration Contract at ACCOUNT_CONFIG_ADDRESS. This contract handles actor authorization, account creation, change sequencing, and delegates signature verification to onchain Verifiers.
Actors are identified by their actorId, a 32-byte identifier derived by the verifier from public key material. The protocol does not enforce a derivation algorithm — each verifier defines its own convention (see actorId Conventions). Actors can be modified via calls within EVM execution by calling the authenticated config change functions.
Default behavior: The EOA actor is implicitly authorized by default but can be revoked on the contract.
Each actor occupies a single actor_config slot containing the verifier address, scope byte, policy type, and an optional expiry. When policyType != 0x00, the actor also carries a signed policy commitment and a manager address in the separate policy slots policy_commitment/policy_manager (see Actor Policies). Non-EOA actors are revoked by deleting the actor_config slot. The implicit EOA actor (actorId == bytes32(bytes20(account))) is revoked by overwriting the slot with verifier = REVOKED_VERIFIER (type(uint160).max), making it distinguishable from an empty (implicitly authorized) slot.
| Field | Bytes | Description |
|---|---|---|
verifier |
0–19 | Verifier contract address |
scope |
20 | Permission bitmask (0x00 = unrestricted; see Actor Scope) |
expiry |
21–26 | uint48 Unix timestamp (seconds); the actor is invalid once block.timestamp > expiry. 0 = no expiry |
policyType |
27 | Policy selector: 0x00 = no policy; any non-zero value gates the actor to its stored policy_manager. The specific non-zero value is interpreted by the manager, not the protocol (see Actor Policies) |
| reserved | 28–31 | Reserved for future use (must be zero) |
When policyType != 0x00, the actor's policy is held in two additional slots:
policy_commitment(account, actorId) → bytes32 // set when policyType != 0x00
policy_manager(account, actorId) → address // set when policyType != 0x00
These slots are read only during execution (see Actor Policies); validity is still decided by the single actor_config SLOAD.
Implicit EOA authorization: An unregistered actor (actor_config slot is empty) is implicitly authorized if actorId == bytes32(bytes20(account)). The empty slot's scope byte and policyType are both 0x00, and expiry is 0 (never expires), granting unrestricted, non-expiring permissions and no protocol-enforced policy by default. This allows every existing EOA to send AA transactions immediately without prior registration. When the implicit rule applies, the protocol verifies using native ecrecover rather than calling an external verifier contract. The implicit authorization is revoked by writing REVOKED_VERIFIER (type(uint160).max) to the verifier field, making the slot non-empty and blocking re-authorization. The EOA actor can also be explicitly registered with ECRECOVER_VERIFIER (address(1)) to set a custom scope or policyType while retaining native ecrecover verification.
The scope byte in actor_config is a permission bitmask that restricts which authentication contexts an actor can be used in. A value of 0x00 means unrestricted — the actor is valid in all contexts. Any non-zero value restricts the actor to contexts where the corresponding bit is set.
| Bit | Value | Name | Context |
|---|---|---|---|
| 0 | 0x01 |
SIGNATURE | ERC-1271 via verifySignature() |
| 1 | 0x02 |
SENDER | sender_auth validation |
| 2 | 0x04 |
PAYER | payer_auth validation |
| 3 | 0x08 |
CONFIG | Config change auth |
The protocol checks scope after verifier execution: scope == 0x00 || (scope & context_bit) != 0.
The protocol validates signatures by reading actor_config directly and delegating authentication to Verifiers — see Validation for the full flow. Actor enumeration is performed off-chain via ActorAuthorized and ActorRevoked event logs. No actor counts are enforced on-chain — gas costs naturally bound them.
The policyType byte selects whether an actor is gated. 0x00 means no policy; any non-zero value gates the actor to a stored manager and carries an opaque 32-byte commitment (keccak256 of the policy parameters). The protocol gates identically on every non-zero value and never interprets the specific byte; it is carried in actor_config for the manager to read as a sub-type if useful.
policyType |
Gate: call.to MUST equal |
policyData |
|---|---|---|
0x00 |
(no gate) | — |
| non-zero | the actor's manager |
manager (20 bytes) ‖ commitment (32 bytes) |
A policy-bearing actor (typically a session key) may call exactly one target: its configured manager. The contract at that target reads the actor's commitment (via getPolicy), validates the presented policy parameters against it (keccak256(params) == commitment), enforces what the call may do, and carries out the approved action. The protocol's only responsibility is the single-target gate; how the target enforces the commitment and how it acts on behalf of the account are out of scope for this specification.
A key that should be enforced by the account's own code rather than a separate contract sets manager = account.
Scope. The policy gate constrains only SENDER-context calls, so a policy-bearing actor MUST be authorized with a restricted scope that is neither unrestricted (0x00) nor includes CONFIG (0x08): a CONFIG-scoped key could authorize new, unrestricted actors and escape its policy entirely. PAYER and SIGNATURE scopes are permitted but are not policy-constrained — a policy-bearing actor with PAYER scope can sponsor gas up to the transaction's limits, and with SIGNATURE scope can produce ERC-1271 signatures the policy never sees — so wallets SHOULD grant a restricted key only the scopes it needs, typically SENDER alone.
Reference flow (non-normative). A session-key policy with a custom manager:
policyType, a manager, and commitment = keccak256(params).params at the manager, which checks that getPolicy(account, actorId) resolves to itself with a matching commitment.getPolicy(account, actorId), requires keccak256(presented params) == commitment, enforces them, and acts for the account.revokeActor (or expiry) clears the slots, so step 3 then fails for that key.The commitment is signed, opaque, and protocol-stored. Because it is part of the signed actor change, one signature fully describes the key's authority (its manager and exact policy) and travels with the portable, multichain actor-change path. Because it is opaque (one word), the protocol stays agnostic to the policy vocabulary: new policy behaviors are new target logic, requiring no protocol change.
Enforcement is at execution, not validation. The protocol does not read policy_commitment or policy_manager when checking transaction validity; the gate is applied later, during Call Execution.
Lifecycle. Policy state is keyed by (account, actorId); clearing on revocation and target-held parameters are covered under Policy State on Revocation.
Nonce state is managed by a precompile at NONCE_MANAGER_ADDRESS. The protocol reads and increments nonce slots directly during AA transaction processing; the precompile exposes a read-only getNonce() interface to the EVM.
The transaction carries two nonce fields: nonce_key (uint256) selects the nonce channel, and nonce_sequence (uint64) is the expected sequence number within that channel.
nonce_key Range |
Name | Description |
|---|---|---|
0 |
Standard | Sequential ordering, mempool default |
1 through NONCE_KEY_MAX - 1 |
User-defined | Parallel transaction channels defined by wallets |
NONCE_KEY_MAX |
Nonce-free | No nonce state read or incremented |
NONCE_KEY_MAX)When nonce_key == NONCE_KEY_MAX, the protocol does not read or increment nonce state. nonce_sequence MUST be 0. Replay protection relies on expiry, which MUST be non-zero.
Nodes SHOULD reject NONCE_KEY_MAX transactions from the mempool if expiry exceeds a short window (e.g., 10 seconds from current time). Replay protection is handled by the nonce-free replay identifier defined below.
Nonce-free deduplication and replay protection MUST key on a signature-invariant identifier rather than the EIP-2718 transaction hash:
replay_id = keccak256(resolved_sender || sender_signature_hash)
where sender_signature_hash is defined in Signature Payload and resolved_sender is the sender address recovered via ecrecover (EOA path, where sender is empty in the wire format) or taken directly from the sender field (configured-actor path). Binding resolved_sender keeps the identifier unique per sender on the EOA path, where two distinct EOAs can sign identical transaction bodies.
The full transaction hash MUST NOT be used for nonce-free deduplication. The transaction hash commits to sender_auth and payer_auth (the authorization blobs), whereas replay_id excludes both. Keying on the transaction hash would allow trivial duplication of a single logical transaction:
payer_auth: the sender signs over the payer address but not the payer's signature bytes, so a sponsor can produce a different payer_auth for the same sender body, yielding a different transaction hash for the same logical transaction.sender_auth malleability: ECDSA signatures are malleable, so an EOA sender_auth can be re-encoded into a distinct-but-valid form, again yielding a different transaction hash.Both variants resolve to the same replay_id, so deduplicating on it collapses them to a single mempool slot and a single includable transaction.
Account lock state is stored in a single packed 32-byte slot:
| Field | Description |
|---|---|
locked |
Actor configuration is frozen — config changes rejected |
unlock_delay |
Seconds required between initiating unlock and becoming unlocked (uint16) |
unlocks_at |
Timestamp when unlock takes effect (uint40, 0 = no unlock initiated) |
When locked is set, all config changes are rejected — both config change entries in account_changes and applySignedActorChanges() via EVM. The lock cannot be removed without a timelock delay.
Lock operations are called directly by the account (msg.sender) on the Account Configuration Contract.
Lifecycle:
lock(unlockDelay). Sets locked = true with the specified unlockDelay (seconds).initiateUnlock(). Sets unlocks_at = block.timestamp + unlock_delay.block.timestamp >= unlocks_at, the account is effectively unlocked — config changes are permitted.This proposal uses the same delegation indicator behavior as EIP-7702 on 8130 chains, even if EIP-7702 transactions are not enabled. An account is delegated when its code is exactly 0xef0100 || target, where target is a 20-byte address. Delegated accounts MAY originate transactions, and all code-executing operations targeting a delegated account MUST load code from target instead of the indicator.
Each actor is associated with a verifier, a contract that performs signature verification. The verifier address is stored in actor_config. All verifiers implement IVerifier.verify(hash, data). Stateful verifiers MAY read transaction context (sender address, calls, payer) from the Transaction Context precompile at TX_CONTEXT_ADDRESS (see Transaction Context). The protocol validates the returned actorId against actor_config and checks the actor's scope against the authentication context; the policy gate (policyType != 0x00) is enforced later, during execution.
Verifiers are executed via STATICCALL. Verifier addresses MUST NOT be delegated accounts — reject if the code at the verifier address starts with the delegation indicator (0xef0100). Execution is metered (see Mempool Acceptance for rules).
On the 8130 path, nodes MAY enshrine canonical verifier execution and charge standard gas targets for those verifiers. Enshrined execution MUST produce identical results to the corresponding verifier contract.
ECRECOVER_VERIFIER (address(1)) is a protocol-reserved address for native secp256k1 verification. When the protocol encounters this address as a verifier in auth data, it performs ecrecover directly rather than making a STATICCALL. The data portion is interpreted as raw ECDSA (r || s || v), and the returned actorId is bytes32(bytes20(recovered_address)). Actors can be explicitly registered with ECRECOVER_VERIFIER to use native ecrecover with a custom scope, without requiring a deployed verifier contract.
Any contract implementing IVerifier can be permissionlessly deployed and registered as an actor's verifier.
This specification defines a canonical verifier set which is the set of signature algorithms that compliant nodes MUST accept. The initial canonical set includes:
| Name | Algorithm | Verifier |
|---|---|---|
| k1 | secp256k1 | ECRECOVER_VERIFIER (native sentinel) |
| p256 | P-256 | Onchain contract |
| passkey | WebAuthn / FIDO2 | Onchain contract |
| delegate | Signature delegation | Onchain contract |
The canonical verifier set and corresponding contract addresses are maintained in a companion ERC (number TBD) and deployed at deterministic CREATE2 addresses across chains. The canonical set is expected to grow as new algorithms are adopted (e.g., post-quantum) through the companion ERC process.
Nodes MUST include all canonical verifiers in their allowlist and SHOULD NOT extend the allowlist with non-canonical verifiers. The 8130 path is intended to use a small, standard set of signature algorithms; accepting additional verifiers is a divergence from this specification.
This proposal supports three paths for accounts to use AA transactions:
| Account Type | How It Works | Key Recovery |
|---|---|---|
| Existing Smart Contracts | Already-deployed accounts (e.g., ERC-4337 wallets) register actors via the system contract (see Smart Wallet Migration Path) | Wallet-defined |
| EOAs | EOAs send AA transactions using their existing secp256k1 key via native ecrecover. If the account has no code, the protocol auto-delegates to DEFAULT_ACCOUNT_ADDRESS (see Block Execution). Accounts MAY override with a delegation entry in account_changes or a standard EIP-7702 transaction |
Wallet-defined; EOA recoverable via 1559/7702 transaction flows |
| New Accounts (No EOA) | Created via a create entry in account_changes with CREATE2 address derivation; runtime bytecode placed at address, actors + verifiers configured, calls handles initialization |
Wallet-defined |
A new EIP-2718 transaction with type AA_TX_TYPE:
AA_TX_TYPE || rlp([
chain_id,
sender, // Sender address (20 bytes) | empty for EOA signature
nonce_key, // uint256: nonce channel selector
nonce_sequence, // uint64: sequence number
expiry, // Unix timestamp (seconds)
max_priority_fee_per_gas,
max_fee_per_gas,
gas_limit,
account_changes, // Account creation, config change, and/or delegation operations | empty
calls, // [[{to, data}, ...], ...] | empty
payer, // empty = sender-paid, payer_address = specific payer
sender_auth,
payer_auth // empty = sender-pay, verifier || data = sponsored (same format as sender_auth)
])
call = rlp([to, data]) // to: address, data: bytes
| Field | Description |
|---|---|
chain_id |
Chain ID per EIP-155 |
sender |
Sending account address. Required (non-empty) for configured actor signatures. Empty for EOA signatures—address recovered via ecrecover. The presence or absence of sender is the sole distinguisher between EOA and configured actor signatures. |
nonce_key |
uint256 nonce channel selector. 0 for standard sequential ordering, 1 through NONCE_KEY_MAX - 1 for parallel channels, NONCE_KEY_MAX for nonce-free mode. |
nonce_sequence |
uint64 expected sequence number within nonce_key. Must match current sequence for (sender, nonce_key). Incremented after inclusion regardless of execution outcome. Must be 0 when nonce_key == NONCE_KEY_MAX. |
expiry |
Unix timestamp (seconds since epoch). Transaction invalid when block.timestamp > expiry. A value of 0 means no expiry. Must be non-zero when nonce_key == NONCE_KEY_MAX. |
max_priority_fee_per_gas |
Priority fee per gas unit (EIP-1559) |
max_fee_per_gas |
Maximum fee per gas unit (EIP-1559) |
gas_limit |
Maximum gas budget for intrinsic costs, sender authentication, account changes, and call execution (see Intrinsic Gas). Payer authentication is metered separately. |
account_changes |
Empty: No account changes. Non-empty: Array of typed entries — create (type 0x00) for account deployment, config change (type 0x01) for actor management, and delegation (type 0x02) for code delegation. See Account Changes |
calls |
Empty: No calls. Non-empty: Array of call phases — see Call Execution |
payer |
Gas payer identity. Empty: Sender pays. 20-byte address: This specific payer required. See Payer Modes |
sender_auth |
See Signature Format |
payer_auth |
Payer authorization. Empty: self-pay. Non-empty: verifier || data — same format as sender_auth. See Payer Modes |
sender_gas = AA_BASE_COST + tx_payload_cost + sender_auth_cost + nonce_key_cost + bytecode_cost + account_changes_cost + auto_delegation_cost + execution_gas_used
total_gas_charged = sender_gas + payer_auth_cost
All sender_gas components consume gas_limit; intrinsic costs are charged before call execution and the remaining gas is available to calls. Unused gas from gas_limit is refunded to the payer. payer_auth_cost is metered separately and charged to the payer, but does not consume gas_limit or reduce gas available to calls.
The sender verifier runs first, and its metered cost is included in gas_limit. This lets the payer sign a single maximum sender-side gas exposure without committing to the sender's verifier choice. Payer authentication is chosen by the payer and metered separately.
sender_auth_cost: For EOA signatures (sender empty) or ECRECOVER_VERIFIER (address(1)) signatures: 6,000 gas (ecrecover + 1 SLOAD + overhead). For other configured actor signatures (sender set, address(2+) verifier): 1 SLOAD (actor_config) + cold code access + actual gas consumed by verifier execution.
payer_auth_cost: 0 for self-pay (payer empty). Otherwise, the same sender_auth_cost model applies to the payer's verifier.
| Component | Value |
|---|---|
tx_payload_cost |
Standard per-byte cost over the entire RLP-serialized transaction: 16 gas per non-zero byte, 4 gas per zero byte, consistent with EIP-2028. Ensures all transaction fields (account_changes, sender_auth, calls, etc.) are charged for data availability |
nonce_key_cost |
NONCE_KEY_MAX: 14,000 gas (replay protection state: 2 cold SLOADs + 1 warm SLOAD + 3 warm SSTORE resets). Otherwise: 22,100 gas for first use of a nonce_key (cold SLOAD + SSTORE set), 5,000 gas for existing keys (cold SLOAD + warm SSTORE reset) |
bytecode_cost |
0 if no create entry in account_changes. Otherwise: 32,000 (deployment base) + code deposit cost (200 gas per deployed byte). Byte costs for code are covered by tx_payload_cost |
account_changes_cost |
Per applied config change entry: auth verification cost (same model as sender_auth_cost) + storage write costs for each mutated actor slot (actor_config; plus policy_commitment and policy_manager when policyType != 0x00). Per applied delegation entry: code deposit cost (200 × 23 bytes for the delegation indicator). Per skipped config change entry (already applied): 2,100 (SLOAD to check sequence). 0 if no config change or delegation entries in account_changes |
auto_delegation_cost |
4,600 (200 × 23 bytes, code deposit for the delegation indicator) when a code-less sender is auto-delegated to DEFAULT_ACCOUNT_ADDRESS (Block Execution step 4). 0 otherwise — i.e. when sender already has code, or account_changes contains a create or delegation entry |
Signature format is determined by the sender field:
EOA signature (sender empty): Raw 65-byte ECDSA signature (r || s || v). The sender address is recovered via ecrecover.
Configured actor signature (sender set):
verifier (20 bytes) || data
The first 20 bytes identify the verifier address. When the verifier is ECRECOVER_VERIFIER, data is raw ECDSA (r || s || v) and the protocol handles ecrecover natively. For all other verifiers, data is verifier-specific — each verifier defines its own wire format.
sender empty, ecrecover derives the sender address (EOA path) with actorId = bytes32(bytes20(sender)). If sender set, read the first 20 bytes of sender_auth as the verifier address.sender empty), ecrecover was already performed in step 1. For ECRECOVER_VERIFIER (address(1)), the protocol natively ecrecovers from data (as r || s || v), returning actorId = bytes32(bytes20(recovered_address)). For all other verifiers (address(2+)), call verifier.verify(hash, data) via STATICCALL, returning actorId (or bytes32(0) for invalid). Reject REVOKED_VERIFIER as a verifier address.actor_config(sender, actorId). Implicit EOA rule: if the slot is empty, actorId == bytes32(bytes20(sender)), and verification in step 3 used the native secp256k1 path (the EOA path or ECRECOVER_VERIFIER), treat as implicitly authorized with scope = 0x00 and policyType = 0x00. Otherwise, require that the stored verifier address matches the effective verifier and is not REVOKED_VERIFIER. If the stored expiry is non-zero, also require block.timestamp <= expiry; an expired actor fails authentication. expiry is read from the same actor_config slot, adding no extra SLOAD.actor_config (or 0x00 for the implicit case). Determine the context bit: 0x02 (SENDER) for sender_auth, 0x04 (PAYER) for payer_auth, 0x01 (SIGNATURE) for verifySignature(), 0x08 (CONFIG) for config change auth. Require scope == 0x00 || (scope & context_bit) != 0.Sender and payer use different type bytes for domain separation, preventing signature reuse attacks:
Sender signature hash — all tx fields through payer, excluding sender_auth and payer_auth:
keccak256(AA_TX_TYPE || rlp([
chain_id, sender, nonce_key, nonce_sequence, expiry,
max_priority_fee_per_gas, max_fee_per_gas, gas_limit,
account_changes, calls,
payer
]))
Payer signature hash — all tx fields through calls, excluding payer, sender_auth, and payer_auth:
keccak256(AA_PAYER_TYPE || rlp([
chain_id, sender, nonce_key, nonce_sequence, expiry,
max_priority_fee_per_gas, max_fee_per_gas, gas_limit,
account_changes, calls
]))
The sender field in the payer signature hash MUST be the resolved sender address. In the EOA path (sender empty in the transaction wire format), the recovered sender address (from sender_auth ecrecover, see Validation step 1) MUST be substituted into the sender position before computing this hash; it MUST NOT be encoded as the empty wire-format value. This binds the payer's signature to the specific resolved sender and prevents cross-sender replay of payer signatures (see Payer Security).
Gas payment and sponsorship are controlled by two independent fields:
payer — the sender's commitment regarding the gas payer, included in the sender's signed hash:
| Value | Mode | Description |
|---|---|---|
| empty | Self-pay | Sender pays their own gas |
payer_address (20 bytes) |
Sponsored | Sender binds tx to a specific sponsor |
payer_auth — uses the same verifier || data format as sender_auth:
payer |
payer_auth |
Payer Address | Validation |
|---|---|---|---|
| empty | empty | sender |
Self-pay — no payer validation |
| address | verifier (20) \|\| data |
payer field |
Sponsored — any verifier. Reads payer's actor_config, validates against payer address |
Any authorized actor with SENDER scope can sign self-pay transactions.
The account_changes field is an array of typed entries for account creation and actor management:
| Type | Name | Description |
|---|---|---|
0x00 |
Create | Deploy a new account with initial actors (must be first, at most one) |
0x01 |
Config change | Actor management: authorizeActor, revokeActor |
0x02 |
Delegation | Set code delegation via the delegation indicator (at most one per account) |
Create and delegation entries are authorized by the transaction's sender_auth — there is no separate authorization field. The initial actorIds for create entries are salt-committed to the derived address. Delegation requires the sender to be the account's implicit EOA actor with CONFIG scope. Config change entries carry their own auth and use a sequence counter for deterministic cross-chain ordering. Nodes SHOULD enforce a configurable per-transaction limit on the number of config change entries (mempool rule).
New smart contract accounts can be created with pre-configured actors in a single transaction. The code is placed directly at the account address — it is not executed during deployment. The account's initialization logic runs via calls in the execution phase that follows:
rlp([
0x00, // type: create
user_salt, // bytes32: User-chosen uniqueness factor
code, // bytes: Runtime bytecode placed at account address
initial_actors // Array of [verifier, actorId, scope, expiry, policyType, policyData] tuples
])
Initial actors are registered with their specified scope, expiry (0 = no expiry), and policyType. The policyData field carries the actor's policy and is sliced by policyType into policy_commitment/policy_manager exactly as for authorizeActor. Wallet initialization code can lock the account via calls in the execution phase (e.g., calling lock() on the Account Configuration Contract).
The code field contains runtime bytecode placed directly at the account address. For delegation, use a delegation entry (type 0x02) in account_changes after account creation.
Addresses are derived using the CREATE2 address formula with the Account Configuration Contract (ACCOUNT_CONFIG_ADDRESS) as the deployer. The initial_actors are sorted by actorId before hashing to ensure address derivation is order-independent (the same set of actors always produces the same address regardless of the order specified):
sorted_actors = sort(initial_actors, by: actorId)
actors_commitment = keccak256(
actorId_0 || verifier_0 || scope_0 || expiry_0 || policyType_0 || manager_0 || commitment_0 ||
...
actorId_n || verifier_n || scope_n || expiry_n || policyType_n || manager_n || commitment_n
)
effective_salt = keccak256(user_salt || actors_commitment)
deployment_code = DEPLOYMENT_HEADER(len(code)) || code
address = keccak256(0xff || ACCOUNT_CONFIG_ADDRESS || effective_salt || keccak256(deployment_code))[12:]
The per-actor contribution is actorId || verifier || scope || expiry || policyType || manager || commitment (112 bytes; expiry is the 6-byte uint48, manager the 20-byte address, and commitment the 32-byte value, with both manager and commitment zero unless policyType != 0x00). Sorting actors by actorId makes the commitment order-independent.
DEPLOYMENT_HEADER(n) is a fixed 14-byte EVM loader that returns the trailing code (see Appendix: Deployment Header for the full opcode sequence). On non-8130 chains, createAccount() constructs deployment_code and passes it as init_code to CREATE2. On 8130 chains, the protocol constructs the same deployment_code for address derivation but places code directly (no execution). Both paths produce the same address — callers only provide code; the header is never user-facing.
Users can receive funds at counterfactual addresses before account creation.
When a create entry is present in account_changes:
[0x00, user_salt, code, initial_actors] where each entry is [verifier, actorId, scope, expiry, policyType, policyData]actorId values existcode is empty or len(code) > MAX_CODE_SIZE (EIP-170: 24576 bytes), to keep the placed code within the EVM contract size limit that CREATE/CREATE2 would otherwise enforcelen(policyData) does not match its policyType (0 for 0x00, 52 for non-zero), or any policy-bearing actor (policyType != 0x00) whose scope is unrestricted (0x00) or includes CONFIG (0x08)actorIdactors_commitment per Address Derivationeffective_salt = keccak256(user_salt || actors_commitment)deployment_code = DEPLOYMENT_HEADER(len(code)) || codeexpected = keccak256(0xff || ACCOUNT_CONFIG_ADDRESS || effective_salt || keccak256(deployment_code))[12:]sender == expectedcode_size(sender) == 0 and nonce(sender) == 0 (matching the conditions under which CREATE2 would be permitted to deploy)sender_auth against one of initial_actors (actorId resolved from auth must match an entry's actorId and the auth verifier must match that entry's verifier)Config change entries manage the account's actors. Each entry includes a chain_id field where 0 means valid on any chain, allowing replay across chains to synchronize actor state.
rlp([
0x01, // type: config change
chain_id, // uint64: 0 = valid on any chain
sequence, // uint64: monotonic ordering
actor_changes, // Array of actor changes
auth // Signature from an actor valid at this sequence
])
actor_change = rlp([
change_type, // uint8: operation type (see below)
actorId, // bytes32: actor identifier
data // bytes: operation-specific (see below)
])
Operation types:
| change_type | Name | data |
Description |
|---|---|---|---|
0x01 |
authorizeActor |
rlp([verifier, scope, expiry, policyType, policyData]) |
Authorize a new actor. Writes actor_config with verifier, scope, expiry (0 = no expiry), and policyType. Slices policyData into policy_commitment/policy_manager: empty for 0x00, manager ‖ commitment when non-zero (see Actor Policies); rejects mismatched len(policyData), and rejects a policy-bearing actor whose scope is unrestricted (0x00) or includes CONFIG (0x08). Emits ActorAuthorized. |
0x02 |
revokeActor |
rlp([]) |
Revoke an existing actor. Deletes actor_config (and policy_commitment/policy_manager) for non-EOA actors; for the implicit EOA actor (actorId == bytes32(bytes20(account))), overwrites with verifier = REVOKED_VERIFIER (type(uint160).max) to prevent implicit re-authorization. Emits ActorRevoked. |
Each config change entry represents a set of operations authorized at a specific sequence number. The auth must be valid against the account's actor configuration at the point after all previous entries in the list have been applied. The authorizing actor must have CONFIG scope (see Actor Scope).
The sequence number is scoped by chain_id: 0 uses the multichain sequence channel (valid on any chain), while a specific chain_id uses that chain's local channel.
Entry signatures use ABI-encoded type hashing. Operations within an entry are individually ABI-encoded and hashed into an array digest:
TYPEHASH = keccak256("SignedActorChanges(address account,uint64 chainId,uint64 sequence,ActorChange[] actorChanges)ActorChange(uint8 changeType,bytes32 actorId,bytes data)")
actorChangeHashes = [keccak256(abi.encode(changeType, actorId, keccak256(data))) for each actorChange]
actorChangesHash = keccak256(abi.encodePacked(actorChangeHashes))
digest = keccak256(abi.encode(TYPEHASH, account, chainId, sequence, actorChangesHash))
Domain separation from transaction signatures (AA_TX_TYPE, AA_PAYER_TYPE) is structural — transaction hashes use keccak256(type_byte || rlp([...])), which cannot produce the same prefix as abi.encode(TYPEHASH, ...).
The auth follows the same Signature Format as sender_auth (verifier || data), validated against the account's actor state at that point in the sequence.
Actors can be modified through two portable paths:
account_changes (tx field) |
applySignedActorChanges() (EVM) |
|
|---|---|---|
| Authorization | Signed operation (any verifier) | Direct verification via verifier + actor_config |
| Availability | Always (8130 chains) | Always (any chain) |
| Portability | Cross-chain (chain_id 0) or chain-specific | Cross-chain (chain_id 0) or chain-specific |
| Sequence | Increments channel's change_sequence |
Increments channel's change_sequence |
| When processed | Before code deployment (8130 only) | During EVM execution (any chain) |
Both paths share the same signed actor changes and change_sequence counters. applySignedActorChanges() parses the verifier address from auth, calls the verifier to get the actorId, and checks actor_config. authorizeActor writes actor_config and, for policy-bearing actors (policyType != 0x00), the policy_commitment and policy_manager slots; revokeActor clears them all. Anyone can call these functions; authorization comes from the signed operation, not the caller. All actor modification paths are blocked when the account is locked (see Account Lock).
Delegation entries set EIP-7702-style code delegation for the sender's account, replacing the need for an authorization_list in the transaction. Delegation is authorized by the transaction's sender_auth — no separate signature is required. The sender must be the account's implicit EOA actor (actorId == bytes32(bytes20(sender))) with CONFIG scope.
rlp([
0x02, // type: delegation
target // address: delegate to this contract, or address(0) to clear
])
The delegation is only permitted when:
code_size(sender) == 0 (empty account), orcode(sender) starts with the delegation designator 0xef0100 (updating an existing delegation)It will not replace non-delegation bytecode.
When target is address(0), the delegation indicator is cleared — the account's code hash is reset to the empty code hash, restoring the account to a pure EOA.
On non-8130 chains, delegation uses standard EIP-7702 transactions (ECDSA authority).
For 8130 transactions, successful delegation updates emit a protocol-injected DelegationApplied(account, target) receipt log, where target is the delegated contract address (or address(0) when clearing delegation).
account_changes entries are processed in order before call execution:
initial_actors in Account Config storage for sender — for each [verifier, actorId, scope, expiry, policyType, policyData] tuple, write actor_config (verifier address, scope byte, expiry, and policyType) and the policy slots from policyData (see Actor Policies). Initialize lock state to safe defaults: locked = false, unlock_delay = 0, unlocks_at = 0.actorId == bytes32(bytes20(sender)) (EOA actor) with CONFIG scope. Reject if account is locked. For each entry, set code(sender) = 0xef0100 || target (or clear if target is address(0)). Reject if account has non-delegation bytecode.code at sender. The runtime bytecode is placed directly — not executed.The protocol dispatches calls directly from sender to each call's to address:
| Parameter | Value |
|---|---|
from (caller) |
sender (the sender) |
to |
call.to |
tx.origin |
sender |
msg.sender at target |
sender |
msg.value |
0 |
data |
call.data |
Calls carry no ETH value. ETH transfers are initiated by the account's wallet bytecode via the CALL opcode (see Why No Value in Calls?).
Phases execute in order from a single gas pool (gas_limit). Within each phase, calls execute in order and are atomic — if any call in a phase reverts, all state changes for that phase are discarded and remaining phases are skipped. Completed phases persist — their state changes are committed and survive later phase reverts.
Policy gate: When the transaction's authenticating actor has policyType != 0x00 (SENDER scope), each call is gated before dispatch. The protocol resolves the actor's allowed target — policy_manager(sender, actorId) — and if call.to is not that address, the call is not dispatched and fails deterministically with the protocol revert PolicyViolation(bytes32 actorId, address target):
error PolicyViolation(bytes32 actorId, address target);
The frame's return data is abi.encodeWithSelector(PolicyViolation.selector, actorId, call.to). This is a consensus-level result, not a validity error, so standard atomicity applies: the enclosing phase's state changes roll back, later phases are skipped, and the phase is reported as failed in phaseStatuses. Only work already performed is charged (intrinsic gas plus the one policy_manager SLOAD); the undispatched call body costs nothing and the transaction is still included with its nonce consumed.
Common patterns:
[[{to, data}]] — one phase, one call[[call_a, call_b, call_c]] — one phase, all-or-nothing[[sponsor_payment], [user_action_a, user_action_b]] — sponsor in phase 0 (committed), user actions in phase 1 (atomic, skipped if sponsor fails)The Transaction Context precompile at TX_CONTEXT_ADDRESS provides read-only access to the current AA transaction's metadata. The precompile reads directly from the client's in-memory transaction state — protocol "writes" are effectively zero-cost. Gas is charged as a base cost plus 3 gas per 32 bytes of returned data, matching CALLDATACOPY pricing.
| Function | Returns | Available |
|---|---|---|
getTransactionSender() |
address — the account being validated (sender) |
Validation + Execution |
getTransactionPayer() |
address — gas payer (sender for self-pay, payer for sponsored) |
Validation + Execution |
getTransactionSenderActorId() |
bytes32 — authenticated actor's actorId |
Execution only |
getTransactionCalls() |
Call[][] — full calls array |
Validation + Execution |
getTransactionMaxCost() |
uint256 — gas_limit * max_fee_per_gas, excluding separately metered payer_auth_cost |
Validation + Execution |
getTransactionGasLimit() |
uint256 — sender-side gas budget (gas_limit) before intrinsic costs and call execution |
Validation + Execution |
If the wallet needs the verifier address or scope, it calls getActorConfig(account, actorId) on the Account Configuration Contract. A policy target reached as a call.to identifies which key it is acting for by combining getTransactionSender() and the authenticated getTransactionSenderActorId() from this precompile, then reads the actor's signed commitment via getPolicy(account, actorId) and validates the presented policy parameters against it. The commitment lives in Account Configuration storage (where it is written and revoked), not the precompile, keeping the precompile to immutable transaction context.
Non-8130 chains: No code at TX_CONTEXT_ADDRESS; STATICCALL returns zero/default values.
The system is split into storage and verification layers with different portability characteristics:
| Component | 8130 chains | Non-8130 chains |
|---|---|---|
| Account Configuration Contract | Protocol reads storage directly for validation; EVM interface available | Standard contract (ERC-4337 compatible factory) |
| Verifier Contracts | Protocol calls verifiers via STATICCALL | Same onchain contracts callable by account config contract and wallets |
| Code Delegation | Delegation entry in account_changes (EOA-only authorization in this version) |
Standard EIP-7702 transactions (ECDSA authority) |
| Transaction Context | Precompile at TX_CONTEXT_ADDRESS — protocol populates, verifiers read |
No code at address; STATICCALL returns zero/default values |
| Nonce Manager | Precompile at NONCE_MANAGER_ADDRESS |
Not applicable; nonce management by existing systems (e.g., ERC-4337 EntryPoint) |
All contracts are deployed at deterministic CREATE2 addresses across chains.
sender_auth. Verify account_changes contains at most one create entry (type 0x00, must be first) and at most one delegation entry (type 0x02). Nodes SHOULD enforce a configurable limit on the number of config change entries (type 0x01).sender set, use it; if empty, ecrecover from sender_authaccount_changes: verify address derivation, code_size(sender) == 0, use initial_actors
b. Else: read from Account Config storageaccount_changes: reject if account is locked (see Account Lock). For config change entries: simulate applying operations in sequence, skip already-applied entries. For delegation entries: verify code_size(sender) == 0 or existing delegation designator.sender_auth against resulting actor state (see Validation). Require SENDER scope on the resolved actor. If delegation entries are present, also require actorId == bytes32(bytes20(sender)) (EOA actor) and CONFIG scope.payer and payer_auth:payer empty and payer_auth empty: self-pay. Payer is sender. Reject if balance insufficient.payer = 20-byte address (sponsored): payer_auth uses any verifier. Validate payer_auth against the payer address's actor_config. Require PAYER scope on the resolved actor.nonce_key != NONCE_KEY_MAX): require nonce_sequence == current_sequence(sender, nonce_key).nonce_key == NONCE_KEY_MAX): skip nonce check, require nonce_sequence == 0, require non-zero expiry, and nodes SHOULD reject if expiry exceeds a short window (e.g., 10 seconds). Deduplicate by the Nonce-Free Replay Identifier (replay_id), not the full transaction hash.Nodes MUST maintain a verifier allowlist that includes all verifiers in the canonical verifier set (see Canonical Verifier Set).
Nodes SHOULD NOT extend their allowlist with additional non-canonical verifiers.
Nodes MAY apply higher pending transaction rate limits based on account lock state:
sender account has a stable signature if combined with a stateless verifier. Nodes can safely allow a higher sender rate.payer account whose bytecode is recognized and restricts eth movement while locked provides an additional guarantee that ETH balance only decreases via gas fees. Nodes can safely allow a higher payer rate for such accounts.account_changes contains config change or delegation entries, read lock state for sender. Reject transaction if account is locked. If delegation entries are present, require the sender's resolved actorId == bytes32(bytes20(sender)) (EOA actor) with CONFIG scope.sender for self-pay). Transaction is invalid if payer has insufficient balance.nonce_key != NONCE_KEY_MAX, increment nonce in Nonce Manager storage for (sender, nonce_key). If nonce_key == NONCE_KEY_MAX, skip (nonce-free mode).code_size(sender) == 0 and no create entry and no delegation entry is present in account_changes, auto-delegate sender to DEFAULT_ACCOUNT_ADDRESS (set code to 0xef0100 || DEFAULT_ACCOUNT_ADDRESS). This delegation persists.account_changes entries in order (see Execution (Account Changes)).calls per Call Execution semantics.Unused gas from gas_limit is refunded to the payer. Intrinsic gas, excluding separately metered payer_auth_cost, consumes gas_limit before call execution. For step 5, the protocol SHOULD inject log entries into the transaction receipt (e.g., ActorAuthorized, ActorRevoked, AccountCreated, DelegationApplied) matching the events defined in the IAccountConfiguration interface, following the protocol-injected log pattern established by EIP-7708. These protocol-injected logs are emitted only for 8130 transactions.
eth_getTransactionCount: Extended with optional nonceKey parameter (uint256) to query 2D nonce channels. Reads from the Nonce Manager precompile at NONCE_MANAGER_ADDRESS.
eth_getTransactionReceipt: AA transaction receipts include:
payer (address): Gas payer address (sender for self-pay, specified payer for sponsored).status (uint8): 0x01 = all phases succeeded (or calls was empty), 0x00 = one or more phases reverted. Existing tools checking status == 1 remain correct for the success path.phaseStatuses (uint8[]): Per-phase status array. Each entry is 0x01 (success) or 0x00 (reverted). Phases after a revert are not executed and reported as 0x00. Empty if calls was empty.The protocol reads storage directly from the Account Configuration Contract (ACCOUNT_CONFIG_ADDRESS) and Nonce Manager (NONCE_MANAGER_ADDRESS). The storage layout is defined by the deployed contract bytecode — slot derivation follows from the contract's Solidity storage declarations. The final deployed contract source serves as the canonical reference for slot locations.
The DEPLOYMENT_HEADER(n) is a 14-byte EVM loader that copies trailing code into memory and returns it. The header encodes code length n into its PUSH2 instructions:
DEPLOYMENT_HEADER(n) = [
0x61, (n >> 8) & 0xFF, n & 0xFF, // PUSH2 n (code length)
0x60, 0x0E, // PUSH1 14 (offset: code starts after 14-byte header)
0x60, 0x00, // PUSH1 0 (memory destination)
0x39, // CODECOPY (copy code from code[14..] to memory[0..])
0x61, (n >> 8) & 0xFF, n & 0xFF, // PUSH2 n (code length)
0x60, 0x00, // PUSH1 0 (memory offset)
0xF3 // RETURN (return code from memory)
]
The create entry only supports runtime bytecode. Delegation is set via delegation entries (type 0x02) in account_changes.
Enables signature-algorithm extension through verifier contracts. The verifier returns the actorId rather than accepting it as input, so the protocol never needs algorithm-specific logic. All verifiers share a single verify(hash, data) interface with no type-based dispatch. Actor scope and policy provide protocol-enforced role separation without verifier cooperation.
NONCE_KEY_MAX?Additional nonce_key values allow parallel transaction lanes without nonce contention between independent workflows.
NONCE_KEY_MAX enables nonce-free transactions where replay protection comes from short-lived expiry and node-level deduplication by the signature-invariant Nonce-Free Replay Identifier. This is useful for operations where nonce ordering coordination is undesirable.
Nonce state is isolated in a dedicated precompile (NONCE_MANAGER_ADDRESS) because nonce writes occur on nearly every AA transaction, while actor config writes are relatively infrequent.
The Nonce Manager has no EVM-writable state and no portability requirement — a precompile is simpler than exposing nonce mutation through the account config contract.
Transaction context (sender, payer, calls, gas) is immutable transaction metadata — it never changes during execution. actorId is set after validation and available during execution only. A precompile is the natural fit:
IVerifier or existing verifier contracts.The create entry uses the CREATE2 address formula with ACCOUNT_CONFIG_ADDRESS as the deployer address for cross-chain portability:
user_salt + code + initial_actors produces the same address on any chaindeployment_code produces the same address on both 8130 and non-8130 chains (see Address Derivation)initial_actors in the salt prevents attackers from deploying with different actors (see Create Entry)Existing ERC-4337 smart accounts migrate to native AA without redeployment:
importAccount() on the Account Configuration Contract — this verifies via the account's isValidSignature (ERC-1271) and registers initial actors. Existing ERC-4337 wallets already implement ERC-1271, so initial actor registration works without code changes.isValidSignature to the Account Configuration Contract's verifySignature() function for actor and verifier infrastructure, and read getTransactionSenderActorId() from the Transaction Context precompile during execution to identify which actor authorized the transactionPhases provide two atomic batching levels without per-call mode flags:
The protocol dispatches each call directly to the specified to address with msg.sender = sender. Actors with SENDER scope are authorized to send transactions at the protocol level. Every account has wallet bytecode (via auto-delegation or explicit deployment), so calls route through the wallet for ETH-carrying operations.
Since every account has wallet bytecode (auto-delegation or explicit deployment), ETH transfers route through wallet code via the CALL opcode — no capability is lost. Removing protocol-level value from calls means the protocol never moves ETH on behalf of the sender.
EIP-7702 introduced authorization_list as a transaction-level field for code delegation, with ECDSA authority. This proposal moves delegation into account_changes, authorized by the transaction's sender_auth. Delegation is restricted to the account's implicit EOA actor (actorId == bytes32(bytes20(sender))) so that code delegation remains portable across non-8130 chains via standard EIP-7702 transactions. Eventually this can be expanded to all verifier types.
Locked accounts have a frozen actor set, so the primary state that can invalidate a validated transaction is nonce consumption. This can enable nodes to cache actor state and apply higher mempool rate limits (see Mempool Acceptance). A per-actor lock alternative was considered but adds mempool tracking complexity — rate limits per (address, actorId) pair rather than per address.
The protocol reads everything it needs to decide transaction validity — verifier binding, scope, and policy selection — in one SLOAD on actor_config. The policy commitment and manager are kept in separate slots (policy_commitment, policy_manager) read only during execution, so policy adds nothing to the validity surface: actors with policyType == 0x00 pay nothing; policy-bearing actors pay their two policy SLOADs at execution time when authenticating as SENDER (see Why Policy Types?).
Session keys and gas-payer hot wallets often need narrow authority: only this token, only this much per day, only this action. policyType expresses that by gating a restricted key to a single call target and binding it to a signed, opaque commitment that the target enforces. The protocol's only policy responsibility is the single-target gate plus storing the commitment; what the target does with the call, and how it ultimately effects actions for the account, is left to the application.
The protocol distinguishes only no policy from a policy:
policyType |
Target | Protocol slots | Policy SLOADs |
|---|---|---|---|
0x00 (none) |
— | 0 | 0 |
| non-zero | stored manager |
2 (commitment + manager) |
2 |
A policy-bearing actor stores both a commitment and a manager: the gate resolves the target with one policy_manager SLOAD and the target reads the commitment with another. Collapsing every policy to this single shape, rather than carving out a cheaper "self" type, keeps the consensus gate branch-free and loses no expressiveness: a key enforced by the account's own code is just manager = account, trading one SLOAD for the account's own address against a separate mode every integrator must understand.
Because the Account Configuration Contract is a single immutable deployment, it can never grow new handling for specific policyType values, so it reserves none and gates on any non-zero value, leaving the byte for a manager to interpret. New policy behaviors are new manager logic, not new protocol values.
Why an opaque commitment rather than a consensus-level policy vocabulary? Encoding policy primitives (selector lists, recipient allowlists, spend limits) into consensus would freeze the vocabulary, requiring a fork and lockstep adoption per new primitive. A single opaque commitment keeps the protocol agnostic (new policy behaviors are new target logic, shippable permissionlessly) while still riding the signed, portable actor-change path so one signature fully describes a key's authority.
Why not a per-target allowlist? An earlier design let a restricted key carry an array of authorized targets, each with its own commitment. Keying policy by (account, actorId) is strictly better on the axes the protocol cares about: bounded, enumerable-free revocation (see Policy State on Revocation) and a single gate resolution rather than one SLOAD per distinct call.to. Multi-target composition (token + lending) moves into the single target's own logic, where it belongs.
Session keys are intended to be short-lived, but revocation requires a transaction, and a forgotten key lingers indefinitely. The per-actor expiry lets a wallet provision a key that lapses automatically at a chosen time, with no revoke transaction and no lingering authority. Because expiry is packed into the same actor_config slot the protocol already reads for validity, enforcement costs nothing extra and happens at validation: an expired actor's transactions are rejected before execution rather than reverting. For policy-bearing keys it also caps how long the key can reach its target: once expired, the key can no longer act even if the policy parameters held by the target are never explicitly uninstalled. A value of 0 preserves the existing always-valid behavior for actors that should not expire.
Without scope, all actors have equal authority — any actor can sign as sender, approve gas payment, appear through ERC-1271, and authorize config changes. This is insufficient when accounts have actors serving different roles, like for example running a payer for ERC-20 tokens.
The 0x00 = unrestricted default ensures backward compatibility.
Without a required verifier set, nodes could diverge on which signature algorithms they accept beyond ECRECOVER_VERIFIER. Wallets would face a fragmented network where each node accepts a different combination of algorithms, making it impossible to guarantee transaction delivery for non-k1 signature types.
The canonical set establishes a shared baseline: wallets that use canonical verifiers know their transactions will be accepted by any compliant node. The set is expected to remain small, with new algorithms added through the companion ERC process as they gain broad adoption.
Public keys are not stored in the Account Configuration Contract. Instead, actors are identified by actorId (bytes32) and public key material is provided at signing time in the verifier-specific data portion of the signature. This design is motivated by three factors:
actor_config). No variable-length public key encoding, no multi-slot public key reads, no length fields. Registration is a single SSTORE in the common case, with two extra SSTOREs (policy_commitment and policy_manager) when an actor carries a policy.The protocol never needs to know how any algorithm works.
The full 32-byte keccak256 output provides ~2^85 quantum collision resistance (vs ~2^53 for bytes20 via BHT), which is adequate for post-quantum keys. It also fits a single storage slot and aligns with keccak256 output without truncation.
Each verifier defines how it derives actorId from signature data.
No breaking changes. Existing EOAs and smart contracts function unchanged. Adoption is opt-in:
DEFAULT_ACCOUNT_ADDRESS if they have no code. EOAs MAY override with a delegation entry in account_changes (EOA-only authorization), a standard EIP-7702 transaction, or use a create entry in account_changes for custom wallet implementationsinterface IAccountConfiguration {
struct ChangeSequences {
uint64 multichain; // chain_id 0
uint64 local; // chain_id == block.chainid
}
struct ActorConfig {
address verifier;
uint8 scope; // 0x00 = unrestricted
uint48 expiry; // Unix seconds; 0 = no expiry. Actor invalid once block.timestamp > expiry
uint8 policyType; // 0x00 = none; any non-zero = gated to stored manager (value interpreted by the manager)
}
struct Actor {
bytes32 actorId;
ActorConfig config;
bytes policyData; // sliced by policyType: empty (0x00); manager[20] || commitment[32] (non-zero)
}
struct ActorChange {
uint8 changeType; // 0x01 = authorizeActor, 0x02 = revokeActor
bytes32 actorId;
bytes data; // operation-specific (see Config Change Format)
}
event ActorAuthorized(address indexed account, bytes32 indexed actorId, ActorConfig config, address policyManager, bytes32 policyCommitment);
event ActorRevoked(address indexed account, bytes32 indexed actorId);
event AccountCreated(address indexed account, bytes32 userSalt, bytes32 codeHash);
event AccountImported(address indexed account);
event DelegationApplied(address indexed account, address target);
event AccountLocked(address indexed account, uint16 unlockDelay);
event AccountUnlockInitiated(address indexed account, uint40 unlocksAt);
// Account creation (factory)
function createAccount(bytes32 userSalt, bytes calldata bytecode, Actor[] calldata initialActors) external returns (address);
function computeAddress(bytes32 userSalt, bytes calldata bytecode, Actor[] calldata initialActors) external view returns (address);
// Import existing account (ERC-1271 verification for initial actor registration)
function importAccount(address account, Actor[] calldata initialActors, bytes calldata signature) external;
// Portable actor changes (direct verification via verifier + actor_config)
function applySignedActorChanges(address account, uint64 chainId, ActorChange[] calldata actorChanges, bytes calldata auth) external;
// Account lock (called by the account directly)
function lock(uint16 unlockDelay) external;
function initiateUnlock() external;
// Signature verification
// verifySignature: ERC-1271-style boolean check; returns false on any failure.
// verifyActor: resolves the authenticating actor and returns its scope byte; reverts on failure
// (invalid signature, unknown/revoked actor, or actorId not bound to the auth verifier).
function verifySignature(address account, bytes32 hash, bytes calldata signature) external view returns (bool verified);
function verifyActor(address account, bytes32 hash, bytes calldata auth) external view returns (uint8 scope);
// Storage views
function isInitialized(address account) external view returns (bool);
function isActor(address account, bytes32 actorId) external view returns (bool);
function getActorConfig(address account, bytes32 actorId) external view returns (ActorConfig memory);
// Resolves the policy gate target and signed commitment:
// 0x00 -> (address(0), bytes32(0)); non-zero -> (manager, commitment)
function getPolicy(address account, bytes32 actorId) external view returns (address target, bytes32 commitment);
function getChangeSequences(address account) external view returns (ChangeSequences memory);
function isLocked(address account) external view returns (bool);
function getLockStatus(address account) external view returns (bool locked, bool hasInitiatedUnlock, uint40 unlocksAt, uint16 unlockDelay);
}
interface IVerifier {
function verify(
bytes32 hash,
bytes calldata data
) external view returns (bytes32 actorId);
}
Stateful verifiers MAY read from the Transaction Context precompile or other state (see Transaction Context). When called outside of an 8130 transaction (e.g., verifySignature() in a legacy transaction), the Transaction Context precompile returns zero/default values, so verifiers that depend on it naturally reject those calls.
struct Call {
address to;
bytes data;
}
interface ITransactionContext {
function getTransactionSender() external view returns (address);
function getTransactionPayer() external view returns (address);
function getTransactionSenderActorId() external view returns (bytes32);
function getTransactionCalls() external view returns (Call[][] memory);
function getTransactionMaxCost() external view returns (uint256);
function getTransactionGasLimit() external view returns (uint256);
}
Read-only. Gas is charged as a base cost plus 3 gas per 32 bytes of returned data.
interface INonceManager {
function getNonce(address account, uint256 nonceKey) external view returns (uint64);
}
Read-only. The protocol manages nonce storage directly; there are no state-modifying functions.
Validation Surface: For pure verifiers, invalidators are actor_config changes and nonce consumption. Stateful verifiers additionally depend on traced state; invalidation tracking is a mempool concern.
Replay Protection: Transactions include chain_id, 2D nonce (nonce_key, nonce_sequence), and expiry. For NONCE_KEY_MAX (nonce-free mode), replay protection relies on short-lived expiry and deduplication by the signature-invariant Nonce-Free Replay Identifier (replay_id). The mempool enforces a tight expiry window (e.g., 10-30 seconds) to bound the window. Block builders MUST NOT include two NONCE_KEY_MAX transactions with the same replay_id. The identifier deliberately excludes sender_auth and payer_auth: keying on the full transaction hash would let a sponsor re-sign payer_auth, or an attacker exploit sender_auth malleability, to admit or include duplicates of one logical transaction (see Nonce-Free Replay Identifier).
Actor Scope and Policy: Scope is protocol-enforced after verifier execution during validation — a verifier cannot bypass it. The policy gate is protocol-enforced during execution (a call.to other than the resolved target reverts with PolicyViolation) and is likewise outside verifier control; because it is an execution-time gate rather than a validity input, it is not a mempool filter for compromised keys (see Why Policy Types?). The gate covers only SENDER-context calls, so a policy-bearing actor is barred at authorization from unrestricted (0x00) and CONFIG scope — a CONFIG-scoped key could authorize new actors and escape its policy — while any PAYER or SIGNATURE scope it holds is not policy-constrained (see Actor Policies).
Policy Target as Trust Anchor: For a policy-bearing actor, the resolved target is fully trusted to enforce the committed limits; the key can only reach that target, which decides what happens next. A buggy or malicious manager can do anything its own authority over the account allows. Accounts SHOULD point restricted keys only at audited managers and treat installing one with the same care as granting that contract authority over the account. A manager (and any policy it enforces) SHOULD be non-upgradeable: an upgradeable manager lets whoever controls the upgrade rewrite enforcement, which is equivalent to granting that party root access over everything the manager can do for the account. The committed parameters and any target-held state are the target's own authorization surface; checking that keccak256(params) matches the stored commitment is the target's responsibility, not the protocol's. How the target obtains authority to act for the account is out of scope for this specification.
Policy State on Revocation: revokeActor clears actor_config, policy_commitment, and policy_manager — all keyed by (account, actorId) — which immediately stops the key from reaching its target; there are no per-target protocol entries to enumerate or resurrect, and storage is fully reclaimed. Any parameters a target keeps in its own storage are not protocol state and are not auto-cleared; wallets uninstall them through the target when retiring a key, and an expiry bounds the window during which a not-yet-uninstalled key could otherwise be used.
Actor Management: Config change authorization requires CONFIG scope. The EOA actor is implicitly authorized with unrestricted scope; revocable via portable config change. All actor modification paths are blocked when the account is locked.
Implicit EOA Rule Scoping: The implicit EOA authorization rule only applies when authentication used the native secp256k1 path — either the EOA path (sender empty) or ECRECOVER_VERIFIER. Generic verifier contracts MUST NOT satisfy the implicit branch even if they return bytes32(bytes20(sender)), otherwise an arbitrary verifier could authenticate as any EOA whose implicit actor slot has never been written.
actorId Binding: The protocol checks that the verifier's returned actorId maps back to that verifier in actor_config — preventing a malicious verifier from claiming control of another verifier's actors.
Payer Security: AA_TX_TYPE vs AA_PAYER_TYPE domain separation prevents signature reuse between sender and payer roles. The payer field in the sender's signed hash binds to a specific payer address. Scope enforcement adds a second layer — PAYER-only actors cannot be used as sender_auth, and vice versa. The payer's exposure to sender-controlled gas is bounded by signed fee fields because gas_limit includes sender authentication, intrinsic costs, account changes, and call execution. Payer authentication uses the payer's chosen verifier, is validated under PAYER scope, and is metered separately so the payer's verifier choice cannot reduce gas available to calls.
Cross-sender Payer Replay: The payer signature hash binds to the resolved sender via the sender field (see Signature Payload). In the EOA path where sender is empty in the wire format, the recovered sender address MUST be substituted into the sender position before computing the hash. Without this substitution, two different EOAs that construct otherwise identical transaction data (same chain_id, nonce_key, nonce_sequence, expiry, fees, account_changes, calls) would produce identical payer hashes, allowing a second EOA to reuse a payer signature originally issued for the first and drain the payer's gas deposit. The 2D nonce alone does not prevent this: nonce_key and nonce_sequence are fields in the transaction payload, so each attacker controls their own values. Substituting the recovered sender into the hash makes the payer's commitment per-sender and closes this replay path. The configured-actor path is unaffected because sender is non-empty by definition.
Account Creation Security: initial_actors (verifier + actorId + scope + policyType + expiry + manager + commitment, per Address Derivation) are salt-committed, preventing front-running of actor assignment or the initial policy binding. Wallet bytecode should be inert when uninitialized as it can be permissionlessly deployed. The create entry applies only to addresses that satisfy CREATE2 freshness. Without the nonce check, a create entry could be replayed against an EOA that has transaction history at the counterfactual address. Direct code placement also bypasses CREATE/CREATE2's EIP-170 MAX_CODE_SIZE check, so the protocol enforces len(code) <= MAX_CODE_SIZE explicitly to keep the placed code within the EVM contract size limit.
Copyright and related rights waived via CC0.