EIP-8130 - Account Abstraction by Account Configuration

Created 2025-10-14
Status Draft
Category Core
Type Standards Track
Authors
Requires

Abstract

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 owners with onchain verifier contracts. Transactions declare which verifier to use, enabling nodes to filter transactions without executing 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.

Motivation

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 owner. 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 deploy as verifier contracts and are adopted by nodes independently with no protocol upgrade required.

Specification

Constants

Name Value Comment
AA_TX_TYPE TBD EIP-2718 transaction type
AA_PAYER_TYPE TBD Magic byte for payer signature domain separation
AA_BASE_COST 15000 Base intrinsic gas cost
ACCOUNT_CONFIG_ADDRESS TBD 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 owner slot to block re-authorization
NONCE_MANAGER_ADDRESS TBD Nonce Manager precompile address
TX_CONTEXT_ADDRESS TBD Transaction Context precompile address
DEFAULT_ACCOUNT_ADDRESS TBD 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)

Account Configuration

Each account can authorize a set of owners through the Account Configuration Contract at ACCOUNT_CONFIG_ADDRESS. This contract handles owner authorization, account creation, change sequencing, and delegates signature verification to onchain Verifiers.

Owners are identified by their ownerId, 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 ownerId Conventions). Owners can be modified via calls within EVM execution by calling the authenticated config change functions.

Default behavior: The EOA owner is implicitly authorized by default but can be revoked on the contract.

Storage Layout

Each owner occupies a single owner_config slot containing the verifier address (20 bytes) and a scope byte (1 byte) with 11 bytes reserved. The scope byte controls which authentication contexts the owner is valid for (see Owner Scope). Non-EOA owners are revoked by deleting the owner_config slot. The implicit EOA owner (ownerId == 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)
reserved 21–31 Reserved for future use (must be zero)

Implicit EOA authorization: An unregistered owner (owner_config slot is empty) is implicitly authorized if ownerId == bytes32(bytes20(account)). The empty slot's scope byte is 0x00 (unrestricted), granting full permissions 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 owner can also be explicitly registered with ECRECOVER_VERIFIER (address(1)) to set a custom scope while retaining native ecrecover verification.

Owner Scope

The scope byte in owner_config is a permission bitmask that restricts which authentication contexts an owner can be used in. A value of 0x00 means unrestricted — the owner is valid in all contexts. Any non-zero value restricts the owner 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 owner_config directly and delegating authentication to Verifiers — see Validation for the full flow. Owner enumeration is performed off-chain via OwnerAuthorized / OwnerRevoked event logs. No owner count is enforced on-chain — gas costs naturally bound owner creation.

2D Nonce Storage

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-Free Mode (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 transaction hash.

Account Lock

Account lock state is stored in a single packed 32-byte slot:

Field Description
locked Owner 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 applySignedOwnerChanges() 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:

  1. Lock: Call lock(unlockDelay). Sets locked = true with the specified unlockDelay (seconds).
  2. Initiate unlock: Call initiateUnlock(). Sets unlocks_at = block.timestamp + unlock_delay.
  3. Effective unlock: Once block.timestamp >= unlocks_at, the account is effectively unlocked — config changes are permitted.

Delegation Indicator

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.

Verifiers

Each owner is associated with a verifier, a contract that performs signature verification. The verifier address is stored in owner_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 ownerId against owner_config and checks the owner's scope against the authentication context.

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).

Nodes MAY implement equivalent verification logic natively for well-known verifier addresses, bypassing EVM execution. The native implementation MUST produce identical results to the onchain contract. Native implementations avoid EVM interpreter overhead, have deterministic gas costs, and introduce no external state dependencies — improving mempool validation throughput.

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 ownerId is bytes32(bytes20(recovered_address)). Owners 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 owner's verifier.

Account Types

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 owners 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, owners + verifiers configured, calls handles initialization Wallet-defined

AA Transaction Type

A new EIP-2718 transaction with type AA_TX_TYPE:

AA_TX_TYPE || rlp([
  chain_id,
  from,               // 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 Definitions

Field Description
chain_id Chain ID per EIP-155
from Sending account address. Required (non-empty) for configured owner signatures. Empty for EOA signatures—address recovered via ecrecover. The presence or absence of from is the sole distinguisher between EOA and configured owner 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 (from, 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 Execution gas budget — reserved for call execution. Auth and intrinsic costs are separate (see Intrinsic Gas)
account_changes Empty: No account changes. Non-empty: Array of typed entries — create (type 0x00) for account deployment, config change (type 0x01) for owner 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

Intrinsic Gas

intrinsic_gas = AA_BASE_COST + tx_payload_cost + sender_auth_cost + payer_auth_cost + nonce_key_cost + bytecode_cost + account_changes_cost

Auth verification gas (sender_auth_cost, payer_auth_cost) is metered but does not consume gas_limit. The full gas_limit is reserved for call execution. The payer is charged for total gas consumed: intrinsic_gas + execution_gas_used. Unused execution gas (from gas_limit) is refunded to the payer.

The sender verifier runs first. By the time the payer verifier executes, all intrinsic costs except payer_auth_cost are known — the Transaction Context precompile's getMaxCost() reflects this (see Transaction Context).

sender_auth_cost: For EOA signatures (from empty) or ECRECOVER_VERIFIER (address(1)) signatures: 6,000 gas (ecrecover + 1 SLOAD + overhead). For other configured owner signatures (from set, address(2+) verifier): 1 SLOAD (owner_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) + num_operations × 20,000 per SSTORE. 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

Signature Format

Signature format is determined by the from field:

EOA signature (from empty): Raw 65-byte ECDSA signature (r || s || v). The sender address is recovered via ecrecover.

Configured owner signature (from 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.

Validation
  1. Resolve sender: If from empty, ecrecover derives the sender address (EOA path) with ownerId = bytes32(bytes20(sender)). If from set, read the first 20 bytes of sender_auth as the verifier address.
  2. Set transaction context: Populate the Transaction Context precompile with sender, payer, and calls (see Transaction Context).
  3. Verify: Route by verifier address. For the EOA path (from empty), ecrecover was already performed in step 1. For ECRECOVER_VERIFIER (address(1)), the protocol natively ecrecovers from data (as r || s || v), returning ownerId = bytes32(bytes20(recovered_address)). For all other verifiers (address(2+)), call verifier.verify(hash, data) via STATICCALL, returning ownerId (or bytes32(0) for invalid). Reject REVOKED_VERIFIER as a verifier address.
  4. Authorize: SLOAD owner_config(from, ownerId). Implicit EOA rule: if the slot is empty and ownerId == bytes32(bytes20(from)), treat as implicitly authorized with scope 0x00. Otherwise, require that the stored verifier address matches the effective verifier and is not REVOKED_VERIFIER.
  5. Check scope: Read the scope byte from owner_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.

Signature Payload

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, from, 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, from, nonce_key, nonce_sequence, expiry,
  max_priority_fee_per_gas, max_fee_per_gas, gas_limit,
  account_changes, calls
]))

Payer Modes

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 from Self-pay — no payer validation
address verifier (20) \|\| data payer field Sponsored — any verifier. Reads payer's owner_config, validates against payer address

Any authorized owner with SENDER scope can sign self-pay transactions.

Account Changes

The account_changes field is an array of typed entries for account creation and owner management:

Type Name Description
0x00 Create Deploy a new account with initial owners (must be first, at most one)
0x01 Config change Owner management: authorizeOwner, revokeOwner
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 ownerIds for create entries are salt-committed to the derived address. Delegation requires the sender to be the account's implicit EOA owner 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).

Create Entry

New smart contract accounts can be created with pre-configured owners 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_owners      // Array of [verifier, ownerId, scope] tuples
])

Initial owners are registered with their specified scope. 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.

[0x00, user_salt, runtimeBytecode, initial_owners]
Address Derivation

Addresses are derived using the CREATE2 address formula with the Account Configuration Contract (ACCOUNT_CONFIG_ADDRESS) as the deployer. The initial_owners are sorted by ownerId before hashing to ensure address derivation is order-independent (the same set of owners always produces the same address regardless of the order specified):

sorted_owners = sort(initial_owners, by: ownerId)

owners_commitment = keccak256(ownerId_0 || verifier_0 || scope_0 || ownerId_1 || verifier_1 || scope_1 || ... || ownerId_n || verifier_n || scope_n)

effective_salt = keccak256(user_salt || owners_commitment)
deployment_code = DEPLOYMENT_HEADER(len(code)) || code
address = keccak256(0xff || ACCOUNT_CONFIG_ADDRESS || effective_salt || keccak256(deployment_code))[12:]

The owners_commitment uses ownerId || verifier || scope (53 bytes) per owner — consistent with how the Account Configuration Contract identifies and configures owners.

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.

Validation (Create Entry)

When a create entry is present in account_changes:

  1. Parse [0x00, user_salt, code, initial_owners] where each entry is [verifier, ownerId, scope]
  2. Reject if any duplicate ownerId values exist
  3. Reject if code is empty
  4. Sort by ownerId: sorted_owners = sort(initial_owners, by: ownerId)
  5. Compute owners_commitment = keccak256(ownerId_0 || verifier_0 || scope_0 || ... || ownerId_n || verifier_n || scope_n)
  6. Compute effective_salt = keccak256(user_salt || owners_commitment)
  7. Compute deployment_code = DEPLOYMENT_HEADER(len(code)) || code
  8. Compute expected = keccak256(0xff || ACCOUNT_CONFIG_ADDRESS || effective_salt || keccak256(deployment_code))[12:]
  9. Require from == expected
  10. Require code_size(from) == 0 (account not yet deployed)
  11. Validate sender_auth against one of initial_owners (ownerId resolved from auth must match an entry's ownerId)

Config Change Entry

Config change entries manage the account's owners. Each entry includes a chain_id field where 0 means valid on any chain, allowing replay across chains to synchronize owner state.

Config Change Format
rlp([
  0x01,               // type: config change
  chain_id,           // uint64: 0 = valid on any chain
  sequence,           // uint64: monotonic ordering
  owner_changes,      // Array of owner changes
  auth                // Signature from an owner valid at this sequence
])

owner_change = rlp([
  change_type,          // uint8: operation type (see below)
  verifier,             // address: verifier contract (authorizeOwner only)
  ownerId,              // bytes32: owner identifier
  scope                 // uint8: permission bitmask (authorizeOwner only, 0x00 = unrestricted)
])

Operation types:

change_type Name Description Fields Used
0x01 authorizeOwner Authorize a new owner with scope verifier, ownerId, scope
0x02 revokeOwner Revoke an existing owner — deletes the slot for non-EOA owners; for the implicit EOA owner (ownerId == bytes32(bytes20(account))), overwrites with verifier = REVOKED_VERIFIER (type(uint160).max) to prevent implicit re-authorization ownerId

Config Change Authorization

Each config change entry represents a set of operations authorized at a specific sequence number. The auth must be valid against the account's owner configuration at the point after all previous entries in the list have been applied. The authorizing owner must have CONFIG scope (see Owner 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.

Config Change Signature Payload

Entry signatures use ABI-encoded type hashing. Operations within an entry are individually ABI-encoded and hashed into an array digest:

TYPEHASH = keccak256("SignedOwnerChanges(address account,uint64 chainId,uint64 sequence,OwnerChange[] ownerChanges)OwnerChange(uint8 changeType,address verifier,bytes32 ownerId,uint8 scope)")

ownerChangeHashes = [keccak256(abi.encode(changeType, verifier, ownerId, scope)) for each ownerChange]
ownerChangesHash = keccak256(abi.encodePacked(ownerChangeHashes))

digest = keccak256(abi.encode(TYPEHASH, account, chainId, sequence, ownerChangesHash))

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 owner state at that point in the sequence.

Account Config Change Paths

Owners can be modified through two portable paths:

account_changes (tx field) applySignedOwnerChanges() (EVM)
Authorization Signed operation (any verifier) Direct verification via verifier + owner_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 owner changes and change_sequence counters. applySignedOwnerChanges() parses the verifier address from auth, calls the verifier to get the ownerId, and checks owner_config. Anyone can call these functions; authorization comes from the signed operation, not the caller. All owner modification paths are blocked when the account is locked (see Account Lock).

Delegation Entry

Delegation entries set 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 owner (ownerId == bytes32(bytes20(from))) with CONFIG scope.

Delegation Format
rlp([
  0x02,               // type: delegation
  target              // address: delegate to this contract, or address(0) to clear
])

The delegation is only permitted when:

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).

Execution (Account Changes)

account_changes entries are processed in order before call execution:

  1. Create entry (if present): Register initial_owners in Account Config storage for from — for each [verifier, ownerId, scope] tuple, write owner_config (verifier address and scope byte). Initialize lock state to safe defaults: locked = false, unlockDelay = 0, unlockRequestedAt = 0.
  2. Config change entries (if any): Apply operations in entry order. Reject transaction if account is locked.
  3. Delegation entries (if any): Require the sender's resolved ownerId == bytes32(bytes20(from)) (EOA owner) with CONFIG scope. Reject if account is locked. For each entry, set code(from) = 0xef0100 || target (or clear if target is address(0)). Reject if account has non-delegation bytecode.
  4. Code placement (if create entry present): Place code at from. The runtime bytecode is placed directly — not executed.

Execution

Call Execution

The protocol dispatches calls directly from from to each call's to address:

Parameter Value
from (caller) from (the sender)
to call.to
tx.origin from
msg.sender at target from
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.

Common patterns:

Transaction Context

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
getSender() address — the account being validated (from) Validation + Execution
getPayer() address — gas payer (from for self-pay, payer for sponsored) Validation + Execution
getOwnerId() bytes32 — authenticated owner's ownerId Execution only
getCalls() Call[][] — full calls array Validation + Execution
getMaxCost() uint256(gas_limit + known_intrinsic) * max_fee_per_gas where known_intrinsic includes all intrinsic costs computed so far (excluding payer auth) Validation + Execution
getGasLimit() uint256 — execution gas budget (gas_limit). Auth and intrinsic costs are separate Validation + Execution

If the wallet needs the verifier address or scope, it calls getOwnerConfig(account, ownerId) on the Account Configuration Contract.

Non-8130 chains: No code at TX_CONTEXT_ADDRESS; STATICCALL returns zero/default values.

Portability

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.

Validation Flow

Mempool Acceptance

  1. Parse and structurally validate 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).
  2. Resolve sender: if from set, use it; if empty, ecrecover from sender_auth
  3. Determine effective owner state: a. If create entry present in account_changes: verify address derivation, code_size(from) == 0, use initial_owners b. Else: read from Account Config storage
  4. If config change or delegation entries present in account_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(from) == 0 or existing delegation designator.
  5. Validate sender_auth against resulting owner state (see Validation). Require SENDER scope on the resolved owner. If delegation entries are present, also require ownerId == bytes32(bytes20(from)) (EOA owner) and CONFIG scope.
  6. Resolve payer from payer and payer_auth:
  7. payer empty and payer_auth empty: self-pay. Payer is from. Reject if balance insufficient.
  8. payer = 20-byte address (sponsored): payer_auth uses any verifier. Validate payer_auth against the payer address's owner_config. Require PAYER scope on the resolved owner.
  9. Verify nonce, payer ETH balance, and expiry:
  10. Standard keys (nonce_key != NONCE_KEY_MAX): require nonce_sequence == current_sequence(from, nonce_key).
  11. Nonce-free 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 transaction hash.
  12. Mempool threshold: gas payer's pending count below node-configured limits.

Nodes SHOULD maintain a verifier allowlist of trusted verifiers. Allowlisted verifiers have known gas bounds and no external state dependencies, enabling validation with no tracing and minimal state requirements — only owner_config and the verifier code itself.

Nodes MAY accept transactions with unknown verifiers by enforcing a gas cap and applying validation scope tracing to restrict opcodes and track state dependencies for invalidation.

Nodes MAY apply higher pending transaction rate limits based on account lock state:

Block Execution

  1. If account_changes contains config change or delegation entries, read lock state for from. Reject transaction if account is locked. If delegation entries are present, require the sender's resolved ownerId == bytes32(bytes20(from)) (EOA owner) with CONFIG scope.
  2. ETH gas deduction from payer (sponsor for sponsored, from for self-pay). Transaction is invalid if payer has insufficient balance.
  3. If nonce_key != NONCE_KEY_MAX, increment nonce in Nonce Manager storage for (from, nonce_key). If nonce_key == NONCE_KEY_MAX, skip (nonce-free mode).
  4. If code_size(from) == 0 and no create entry and no delegation entry is present in account_changes, auto-delegate from to DEFAULT_ACCOUNT_ADDRESS (set code to 0xef0100 || DEFAULT_ACCOUNT_ADDRESS). This delegation persists.
  5. Process account_changes entries in order (see Execution (Account Changes)).
  6. Set transaction context on the Transaction Context precompile (sender, payer, ownerId, calls).
  7. Execute calls per Call Execution semantics.

Unused execution gas (from gas_limit) is refunded to the payer. Intrinsic gas (including auth costs) is not refundable. For step 5, the protocol SHOULD inject log entries into the transaction receipt (e.g., OwnerAuthorized, 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.

RPC Extensions

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:

eth_getAcceptedVerifiers: Returns the node's verifier acceptance policy. The response includes a top-level acceptsUnknownVerifiers boolean indicating whether the node accepts transactions with verifiers not on its allowlist. The verifiers array lists each accepted verifier with its address, whether the node has a native implementation, and maxAuthCost — the maximum gas the node will allow for that verifier's auth execution.

Appendix: Storage Layout

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.

Appendix: Deployment Header

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.

Rationale

Why Verifier Contracts?

Enables permissionless extension — deploy a new verifier contract, nodes update their allowlist, no protocol upgrade required. The verifier returns the ownerId rather than accepting it as input, so the protocol never needs algorithm-specific logic — routing, derivation, and validation are all handled by the verifier. All verifiers share a single verify(hash, data) interface with no type-based dispatch. Owner scope provides protocol-enforced role separation without verifier cooperation.

Why 2D Nonce + 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 replay protection by transaction hash. This is useful for operations where nonce ordering coordination is undesirable.

Why a Nonce Precompile?

Nonce state is isolated in a dedicated precompile (NONCE_MANAGER_ADDRESS) because nonce writes occur on nearly every AA transaction, while owner 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.

Why a Transaction Context Precompile?

Transaction context (sender, payer, calls, gas) is immutable transaction metadata — it never changes during execution. ownerId is set after validation and available during execution only. A precompile is the natural fit:

Why CREATE2 for Account Creation?

The create entry uses the CREATE2 address formula with ACCOUNT_CONFIG_ADDRESS as the deployer address for cross-chain portability:

  1. Deterministic addresses: Same user_salt + code + initial_owners produces the same address on any chain
  2. Pre-deployment funding: Users can receive funds at counterfactual addresses before account creation
  3. Portability: Same deployment_code produces the same address on both 8130 and non-8130 chains (see Address Derivation)
  4. Front-running prevention: initial_owners in the salt prevents attackers from deploying with different owners (see Create Entry)

Smart Wallet Migration Path

Existing ERC-4337 smart accounts migrate to native AA without redeployment:

  1. Import account: Call importAccount() on the Account Configuration Contract — this verifies via the account's isValidSignature (ERC-1271) and registers initial owners. Existing ERC-4337 wallets already implement ERC-1271, so initial owner registration works without code changes.
  2. Upgrade wallet logic: Update contract to delegate isValidSignature to the Account Configuration Contract's verifySignature() function for owner and verifier infrastructure, and read getOwnerId() from the Transaction Context precompile during execution to identify which owner authorized the transaction
  3. Backwards compatible: Wallet can still accept ERC-4337 UserOps via EntryPoint alongside native AA transactions

Why Call Phases?

Phases provide two atomic batching levels without per-call mode flags:

Why Direct Dispatch?

The protocol dispatches each call directly to the specified to address with msg.sender = from. Owners 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.

Why No Value in Calls?

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.

Why Delegation via Account Changes?

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 owner (ownerId == bytes32(bytes20(from))) so that code delegation remains portable across non-8130 chains via standard EIP-7702 transactions. Eventually this can be expanded to all verifier types.

Why Account Lock?

Locked accounts have a frozen owner set, so the primary state that can invalidate a validated transaction is nonce consumption. This can enable nodes to cache owner state and apply higher mempool rate limits (see Mempool Acceptance). A per-owner lock alternative was considered but adds mempool tracking complexity — rate limits per (address, ownerId) pair rather than per address.

Why One Slot Per Owner?

The protocol reads everything it needs for authorization and scope checking in one SLOAD. Reserved bytes provide an extension path for future protocol-level owner policy.

Why Owner Scope?

Without scope, all owners have equal authority — any owner can sign as sender, approve gas payment, appear through ERC-1271, and authorize config changes. This is insufficient when accounts have owners serving different roles, like for example running a payer for ERC-20 tokens.

The 0x00 = unrestricted default ensures backward compatibility.

Why No Public Key Storage?

Public keys are not stored in the Account Configuration Contract. Instead, owners are identified by ownerId (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:

The protocol never needs to know how any algorithm works.

Why bytes32 ownerId?

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.

ownerId Conventions

Each verifier defines how it derives ownerId from signature data.

Backwards Compatibility

No breaking changes. Existing EOAs and smart contracts function unchanged. Adoption is opt-in:

Reference Implementation

IAccountConfiguration

interface IAccountConfiguration {
    struct ChangeSequences {
        uint64 multichain; // chain_id 0
        uint64 local;      // chain_id == block.chainid
    }

    struct OwnerConfig {
        address verifier;
        uint8 scopes;      // 0x00 = unrestricted
    }

    struct Owner {
        bytes32 ownerId;
        OwnerConfig config;
    }

    struct OwnerChange {
        bytes32 ownerId;
        uint8 changeType;  // 0x01 = authorizeOwner, 0x02 = revokeOwner
        bytes configData;  // OwnerConfig for authorize, empty for revoke
    }

    event OwnerAuthorized(address indexed account, bytes32 indexed ownerId, OwnerConfig config);
    event OwnerRevoked(address indexed account, bytes32 indexed ownerId);
    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, Owner[] calldata initialOwners) external returns (address);
    function computeAddress(bytes32 userSalt, bytes calldata bytecode, Owner[] calldata initialOwners) external view returns (address);

    // Import existing account (ERC-1271 verification for initial owner registration)
    function importAccount(address account, Owner[] calldata initialOwners, bytes calldata signature) external;

    // Portable owner changes (direct verification via verifier + owner_config)
    function applySignedOwnerChanges(address account, uint64 chainId, OwnerChange[] calldata ownerChanges, bytes calldata auth) external;

    // Account lock (called by the account directly)
    function lock(uint16 unlockDelay) external;
    function initiateUnlock() external;

    // Signature verification
    function verifySignature(address account, bytes32 hash, bytes calldata signature) external view returns (bool verified);
    function verify(address account, bytes32 hash, bytes calldata auth) external view returns (uint8 scopes);

    // Storage views
    function isInitialized(address account) external view returns (bool);
    function isOwner(address account, bytes32 ownerId) external view returns (bool);
    function getOwnerConfig(address account, bytes32 ownerId) external view returns (OwnerConfig memory);
    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);
}

IVerifier

interface IVerifier {
    function verify(
        bytes32 hash,
        bytes calldata data
    ) external view returns (bytes32 ownerId);
}

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.

ITxContext (Precompile)

struct Call {
    address to;
    bytes data;
}

interface ITxContext {
    function getSender() external view returns (address);
    function getPayer() external view returns (address);
    function getOwnerId() external view returns (bytes32);
    function getCalls() external view returns (Call[][] memory);
    function getMaxCost() external view returns (uint256);
    function getGasLimit() external view returns (uint256);
}

Read-only. Gas is charged as a base cost plus 3 gas per 32 bytes of returned data.

INonceManager (Precompile)

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.

Security Considerations

Validation Surface: For pure verifiers, invalidators are owner_config revocation 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 transaction-hash deduplication. The mempool enforces a tight expiry window (e.g., 10-30 seconds) to bound the window. Block builders MUST NOT include duplicate NONCE_KEY_MAX transactions with the same hash.

Owner Scope: Protocol-enforced after verifier execution — a verifier cannot bypass scope checking.

Owner Management: Config change authorization requires CONFIG scope. The EOA owner is implicitly authorized with unrestricted scope; revocable via portable config change. All owner modification paths are blocked when the account is locked.

ownerId Binding: The protocol checks that the verifier's returned ownerId maps back to that verifier in owner_config — preventing a malicious verifier from claiming ownership of another verifier's owners.

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 owners cannot be used as sender_auth, and vice versa.

Account Creation Security: initial_owners (verifier + ownerId + scope tuples) are salt-committed, preventing front-running of owner assignment. Wallet bytecode should be inert when uninitialized as it can be permissionlessly deployed.

Copyright

Copyright and related rights waived via CC0.