EIP.tools

EIP.tools

⚠️ DraftStandards Track: Core

EIP-8401: Portable Account Keystore

Define portable account actors and authenticators

Authors
Created2026-08-27
Discussion Linkhttps://ethereum-magicians.org/t/composable-native-account-abstraction/29526
Requires
Referenced by

Markdown

https://raw.githubusercontent.com/ethereum/EIPs/8b...
Pull Request#12248PR closed (unmerged)

EIP-GPT summary

Contents
AbstractMotivationSpecificationOverviewActors and AuthenticatorsScope SemanticsAuthenticator InterfaceNative secp256k1 AuthenticatorDelegate AuthenticatorAccount StateImplicit Address-Bound ActorAccount EstablishmentConfiguration ChangesAuthorizationMultichain ChannelLocal ChannelExpired AuthorizationsEVM Configuration PathAccount CreationAddress DerivationAccount ImportSignature VerificationPortabilityConstantsRationaleWhy Separate Storage From Transport?Why Store Authenticator Addresses?Why Two Configuration Channels?Why Commit Initial Actors to the Address?Backwards CompatibilityReference ImplementationSecurity ConsiderationsCopyright

Abstract

This proposal specifies a portable Keystore for smart accounts. The Keystore stores account actors, binds each actor to an authenticator contract and scope word, supports local and multichain configuration changes, and provides deterministic account creation and import. Its contract interface works on any EVM chain and can be used by native account-abstraction transactions, ERC-4337, or other execution transports.

Motivation

Account authority should not depend on one transaction transport. A wallet may use an ERC-4337 UserOperation on one chain, a native account-abstraction transaction on another, and a future transport elsewhere while retaining the same account address, actors, authenticators, and signed configuration changes.

Separating the Keystore from transaction processing gives every transport a common authority layer. Chains may deploy the Keystore as an ordinary contract, while protocol integrations may read the same storage and apply the same operations natively without changing their results.

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Overview

The Keystore is deployed at KEYSTORE_ADDRESS. Each account authorizes actors through the Keystore. An actor is identified by a bytes32 actorId, bound to one authenticator, and assigned an expiry and a uint16 scope word.

An authenticator verifies transport-defined hashes and returns the authenticated actorId. The Keystore then resolves the actor configuration and returns the actor's scope to the caller. Consumers define which non-zero scope bits authorize their operations.

The Keystore provides four portable functions:

  1. Store account authority independently from account code.
  2. Authenticate actors through replaceable authenticator contracts.
  3. Apply signed local or multichain authority changes.
  4. create or import accounts with deterministic initial authority.

Actors and Authenticators

An actor configuration contains:

authenticator (20 bytes) || expiry (6 bytes) || scope (2 bytes) || reserved (4 bytes)

The fields have the following meanings:

FieldMeaning
authenticatorContract or standardized authenticator that resolves the actor
expiryUnix timestamp in seconds; zero means no expiry
scopeConsumer-defined grants; zero means admin
reservedVersion gate; MUST be zero

An actor is live when:

expiry == 0 || block.timestamp <= expiry

A non-zero actorId MAY be authorized only when its authenticator is non-zero. Revocation deletes the complete actor configuration.

Scope Semantics

Admin authority is exactly scope == 0x0000. Only an admin actor may authorize Keystore configuration changes.

For scope != 0, every set bit is a grant interpreted by the consuming standard. This proposal assigns no non-zero scope bits. Consumers MUST treat unknown bits as granting no authority, and future assignments MUST be pure grants.

The Keystore stores the complete scope word verbatim. It MUST NOT reject combinations merely because a deployed consumer does not understand them.

Authenticator Interface

An authenticator implements:

interface IAuthenticator {
    function authenticate(
        bytes32 hash,
        bytes calldata data
    ) external view returns (bytes32 actorId);
}

The authenticator MUST return bytes32(0) for invalid authentication or revert. A caller MUST treat both outcomes as invalid.

The Keystore MUST verify that the returned actorId is bound to the authenticator that was invoked. An authenticator cannot claim an actor stored under another authenticator.

Authenticator addresses SHOULD be deployed deterministically across chains. Dependent standards MAY define canonical authenticators and native implementations, provided native execution returns the same result as the contract.

Native secp256k1 Authenticator

K1_AUTHENTICATOR = address(1) represents the standard secp256k1 authenticator. Its data is the 65-byte r || s || v signature, and its result is:

actorId = bytes32(uint256(uint160(ecrecover(hash, v, r, s))))

A zero recovered address is invalid.

Delegate Authenticator

A delegate authenticator allows account B to authenticate as an actor of account A. Account A registers:

actorId = bytes32(uint256(uint160(B)))
authenticator = DELEGATE_AUTHENTICATOR

The delegate authenticator validates the nested authentication against account B and returns B's address-derived actor ID. Delegation MUST NOT chain. The nested authenticator MUST belong to a canonical set defined by the consuming standard, keeping validation work bounded.

Account State

The Keystore stores:

account_state[account] =
    multichain_sequence
    local_epoch
    local_sequence
    flags
    inline_self_actor

Each non-self actor occupies one actor_config(account, actorId) slot. The address-derived self actor MAY be stored inline in the account-state slot.

The following flags are assigned:

BitValueNameMeaning
00x01CONTRACT_ESTABLISHEDAuthority was established through Keystore creation or import
10x02DEFAULT_EOA_REVOKEDThe implicit address-bound secp256k1 actor is disabled
2–7ReservedAssigned only by dependent standards

Unknown flags have no effect unless assigned by a dependent standard.

Implicit Address-Bound Actor

Before Keystore establishment, an account MAY use the implicit actor:

actorId = bytes32(uint256(uint160(account)))
authenticator = K1_AUTHENTICATOR
scope = 0
expiry = 0

This rule applies only when authentication used the native secp256k1 path and recovered the account's own address. A generic authenticator returning the same actor ID MUST NOT satisfy this rule.

Once DEFAULT_EOA_REVOKED is set, the implicit actor is invalid. Revocation is permanent unless a dependent standard explicitly defines otherwise.

Account Establishment

Both creation and import set CONTRACT_ESTABLISHED. The flag is permanent and does not itself authorize an actor.

Keystore state can outlive account code, including code removed under EIP-6780. Consumers MUST NOT treat empty code or delegated code plus Keystore state as proof that an address-bound private key exists. They MUST inspect CONTRACT_ESTABLISHED.

Configuration Changes

A signed configuration batch contains:

SignedAccountChanges {
    channel
    sequence
    changes[]
    auth
}

AccountChange {
    change_type
    payload
}

The base change types are:

TypeNamePayload
0AuthorizeActorabi.encode(bytes32 actorId, ActorConfig config)
1RevokeActorabi.encode(bytes32 actorId)
2IncrementLocalEpochEmpty

Dependent standards MAY assign additional change types. An implementation that does not support an assigned extension MUST reject that change atomically.

The entire ordered batch is atomic. A rejected change reverts every preceding change in the same batch.

For a non-self actor, RevokeActor deletes actor_config. AuthorizeActor for the address-derived self actor with K1_AUTHENTICATOR writes its inline configuration and clears DEFAULT_EOA_REVOKED. Revoking the self actor clears the inline configuration, deletes any non-secp256k1 self configuration, and sets DEFAULT_EOA_REVOKED.

Authorization

The batch is authorized by one live admin actor. Its auth value is authenticator || data. The EIP-712-style signature digest binds:

account
resolved_chain_id
channel
sequence
keccak256(ordered changes)

The digest MUST use a typed domain distinct from transaction and message signatures.

Anyone may relay a signed batch. Authorization derives from the admin signature rather than msg.sender.

Multichain Channel

The Multichain channel binds resolved_chain_id = 0 and uses a monotonic uint64 sequence.

The supplied sequence MUST equal multichain_sequence. A successful batch increments the stored sequence. The same signed batch can therefore be submitted independently on multiple chains whose counters remain aligned.

A chain-specific extension change SHOULD NOT consume the Multichain counter when the same account is used on chains that do not implement that extension.

Local Channel

The Local channel binds resolved_chain_id = block.chainid. Its sequence is:

local_epoch (high 32 bits) || local_sequence (low 32 bits)

For a sequenced batch, the epoch and sequence MUST match storage. Success increments local_sequence.

UNSEQUENCED = uint32.max selects an unsequenced local batch. It consumes no counter and remains replayable until local_epoch changes. Ordering between unsequenced batches is undefined.

IncrementLocalEpoch increments local_epoch and resets local_sequence to zero. It invalidates unlanded local signatures from the previous epoch without changing live actors or the Multichain channel.

Creation and import initialize local_sequence = 1. An all-zero local word denotes an uninitialized account.

Expired Authorizations

For an unsequenced batch, an AuthorizeActor change with a non-zero expiry already in the past is silently skipped. This prevents replayable just-in-time grants from overwriting newer state after expiry.

For a single-consume Local or Multichain batch, the same actor configuration is installed inert and the sequence is consumed.

EVM Configuration Path

The Keystore exposes:

function applySignedAccountChanges(
    address account,
    SignedAccountChanges calldata changes
) external;

This function MUST authenticate the admin, validate the channel and sequence, and apply the ordered batch atomically.

A protocol integration MAY apply the identical batch natively. Native application MUST produce the same storage changes, events, reverts, and sequence updates as the EVM function.

Account Creation

createAccount deploys runtime code and installs initial actors atomically:

function createAccount(
    bytes32 userSalt,
    bytes calldata code,
    InitialActor[] calldata initialActors
) external returns (address account);

Each initial actor is:

[actorId, authenticator, scope]

Initial actors are non-expiring. The input MUST be sorted by actorId in strictly ascending order. Duplicate actor IDs, zero actor IDs, and zero authenticators are invalid.

Creation sets CONTRACT_ESTABLISHED, initializes local_sequence = 1, and sets DEFAULT_EOA_REVOKED unless the initial actors include the address-derived self actor bound to K1_AUTHENTICATOR.

Address Derivation

For each initial actor:

leaf_i = keccak256(actorId_i || authenticator_i || scope_i)
actors_commitment = keccak256(leaf_0 || leaf_1 || ... || leaf_n)
effective_salt = keccak256(user_salt || actors_commitment)
deployment_code = DEPLOYMENT_HEADER(len(code)) || code
account = keccak256(
    0xff || KEYSTORE_ADDRESS || effective_salt || keccak256(deployment_code)
)[12:]

The complete two-byte scope participates in the address. A wallet seeking the same address on multiple chains MUST use the same Keystore address, salt, code, ordered actors, authenticators, and scopes.

computeAddress MUST perform the same initial-actor validation as createAccount.

DEPLOYMENT_HEADER(n) is the following 14-byte loader:

0x61, n[1], n[0],
0x60, 0x0e,
0x60, 0x00,
0x39,
0x61, n[1], n[0],
0x60, 0x00,
0xf3

It copies the trailing n runtime-code bytes into memory and returns them. Callers supply runtime code only.

Account Import

importAccount registers actors for an existing deployed account:

function importAccount(
    address account,
    uint256 chainId,
    InitialActor[] calldata initialActors,
    bytes calldata signature
) external;

Import requires:

  1. Deployed bytecode at account.
  2. Uninitialized Local and Multichain sequences.
  3. chainId == 0 || chainId == block.chainid.
  4. A valid ERC-1271 signature from account over the typed initialization digest.
  5. A valid, strictly sorted initial-actor set.

Imported actors are non-expiring. Import sets CONTRACT_ESTABLISHED, initializes local_sequence = 1, and sets DEFAULT_EOA_REVOKED unless the initial actors include the address-derived self actor bound to K1_AUTHENTICATOR.

Signature Verification

authenticateActor(account, hash, auth) accepts:

authenticator (20 bytes) || data

It invokes the selected authenticator, resolves the returned actor, validates the authenticator binding and expiry, and returns (actorId, scope). Invalid authentication reverts.

For application messages, validateSignature accepts:

signature_type (1 byte) || authenticator (20 bytes) || data

Signature types are:

ValueNameChain binding
0x01Localblock.chainid
0x02Multichain0

The signed digest is:

replaySafeHash(account, chainId, hash) =
    keccak256(SIGNED_MESSAGE_TYPEHASH, account, chainId, hash)

The Keystore returns (actorId, scope) so the caller can make its own authorization decision.

A smart account MAY expose ERC-1271 isValidSignature on top of validateSignature. The account MUST explicitly define which scopes may sign application messages; this proposal does not grant that authority to every stored actor.

Portability

The same Keystore contract, authenticator contracts, and creation code SHOULD be deployed at deterministic addresses across chains.

ComponentAny EVM chainNative integration
KeystoreOrdinary contractProtocol may read the same storage directly
AuthenticatorsEVM contractsCanonical authenticators may be implemented natively
ConfigurationapplySignedAccountChangesSame batch may be transported by a native transaction
Account creationCREATE2 factoryProtocol may place the same runtime code directly
ExecutionERC-4337 or wallet-specificNative transaction standard

The Keystore does not define transaction nonces, gas payment, call execution, or a transaction type. Those belong to consuming standards.

Constants

NameValueMeaning
KEYSTORE_ADDRESSDeterministic deployment addressKeystore contract
K1_AUTHENTICATORaddress(1)secp256k1 authenticator
CONTRACT_ESTABLISHED0x01Keystore-established account
DEFAULT_EOA_REVOKED0x02Implicit address-bound actor revoked
UNSEQUENCEDuint32.maxLocal unsequenced sentinel

Rationale

Why Separate Storage From Transport?

An account's authority changes less frequently than its execution transport. Keeping authority in a common Keystore lets the same account use ERC-4337, a native transaction, or another transport without changing its actors or address.

Why Store Authenticator Addresses?

Authenticator addresses make signature algorithms replaceable and independently standardizable. An account can rotate algorithms by authorizing a new actor instead of migrating the account.

Why Two Configuration Channels?

The Multichain channel synchronizes authority across chains with one signature. The Local channel permits chain-specific changes and invalidation without disturbing the shared counter.

Why Commit Initial Actors to the Address?

Committing the complete initial actor set prevents an observer from deploying the same code and salt with attacker-controlled authority.

Backwards Compatibility

This proposal introduces new contracts and does not change existing transactions, EOAs, smart accounts, or ERC-4337 infrastructure. Adoption is opt-in.

An ERC-4337 wallet can use the Keystore entirely through EVM calls. A native transaction standard may integrate the same storage and functions without changing contract-visible results.

Reference Implementation

interface IKeystore {
    struct ChangeSequences {
        uint64 multichain;
        uint32 localEpoch;
        uint32 localSequence;
    }

    struct ActorConfig {
        address authenticator;
        uint48 expiry;
        uint16 scope;
    }

    struct InitialActor {
        bytes32 actorId;
        address authenticator;
        uint16 scope;
    }

    enum AccountChangeChannel {
        Local,
        Multichain
    }

    enum ChangeType {
        AuthorizeActor,
        RevokeActor,
        IncrementLocalEpoch
    }

    struct AccountChange {
        ChangeType changeType;
        bytes payload;
    }

    struct SignedAccountChanges {
        AccountChangeChannel channel;
        uint64 sequence;
        AccountChange[] changes;
        bytes auth;
    }

    function createAccount(
        bytes32 userSalt,
        bytes calldata code,
        InitialActor[] calldata initialActors
    ) external returns (address);

    function computeAddress(
        bytes32 userSalt,
        bytes calldata code,
        InitialActor[] calldata initialActors
    ) external view returns (address);

    function importAccount(
        address account,
        uint256 chainId,
        InitialActor[] calldata initialActors,
        bytes calldata signature
    ) external;

    function applySignedAccountChanges(
        address account,
        SignedAccountChanges calldata changes
    ) external;

    function authenticateActor(
        address account,
        bytes32 hash,
        bytes calldata auth
    ) external view returns (bytes32 actorId, uint16 scope);

    function validateSignature(
        address account,
        bytes32 hash,
        bytes calldata auth
    ) external view returns (bytes32 actorId, uint16 scope);

    function replaySafeHash(
        address account,
        uint256 chainId,
        bytes32 hash
    ) external pure returns (bytes32);

    function getActorConfig(
        address account,
        bytes32 actorId
    ) external view returns (ActorConfig memory);

    function getChangeSequences(
        address account
    ) external view returns (ChangeSequences memory);

    function isContractEstablished(
        address account
    ) external view returns (bool);
}

Security Considerations

Authenticator binding: The Keystore must verify that the returned actor ID is stored under the authenticator that produced it. Otherwise, a malicious authenticator could impersonate another actor.

Implicit actor restriction: Only native secp256k1 recovery of the account's own address may use the implicit actor. A generic authenticator returning the same actor ID must not gain implicit admin authority.

Actor expiry: Wallets should retain at least one non-expiring admin. Expiring the only admin can make future configuration impossible.

Configuration replay: Multichain and sequenced Local batches are single-consume. Unsequenced Local batches remain replayable until the local epoch changes.

Cross-chain divergence: Keystore state is chain-local even when signatures are multichain. Wallets must relay batches to every intended chain and preserve counter alignment. Extension-only changes should use the Local channel when unsupported chains share the same Multichain counter.

Account creation: Initial actor commitments prevent authority front-running. Wallet runtime code should remain inert until initialized because deterministic code deployment may be permissionless.

Contract establishment: Keystore state can survive code removal. Empty code does not prove that an account is controlled by an address-bound private key.

Copyright and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth