This proposal defines the Keystore: an onchain account configuration system in which each account registers actors, each bound to an authenticator contract that verifies signatures, with a per-actor scope, expiry, and optional policy. Authenticators answer "who signed"; the Keystore answers "what may that actor do". The Keystore is independent of any one transaction format. It is usable through ordinary EVM calls on any chain, through ERC-4337, by EIP-8141 frame accounts, and natively by the AA Transaction Type proposal, whose integration this document specifies. This document specifies the Keystore's protocol-visible state, the authorization model, and how consumers integrate with it.
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 authentication from account logic. Each authorization declares its authenticator, a contract that takes a hash and signature data and returns the authenticated actor. Validation is predictable: wallets know the rules, and a consumer can see exactly what computation an authorization requires before running it. Instead of simulating arbitrary code, nodes filter on authenticator identity, accepting a small, standard canonical set.
New signature algorithms are introduced through authenticator contracts and standardized through the canonical authenticator set, so accounts are crypto-agile without migration.
Portability is also a top concern. The Keystore lets accounts manage their authenticators with one set of contracts and one signed change format across chains and transports.
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.
An account manages its authentication configuration via the Keystore, a contract at KEYSTORE_ADDRESS that maps an account to its configured actors. An actor on the account is one that has an authorization in the Keystore. Each actor is bound to an authenticator, an onchain contract that checks a signature and returns the actor's identity (actorId). The Keystore then authorizes the actor by its scope.
A consumer is any system that authorizes actions for an account using the Keystore: a transaction type that validates against it natively, a frame account whose code calls it, an ERC-4337 account, or an application verifying signatures (see Native Integration).
This EIP specifies the Keystore's protocol-visible behavior and state. The Keystore contract and the canonical authenticator and account contracts, including exact storage packing, typehashes, event ABIs, and function bodies, live in the canonical contracts repository (the base organization's EIP-8130 repository on GitHub, src/Keystore.sol), which is authoritative for all contract internals. Where this document gives a byte layout or digest, it is a normative summary; any implementation MUST match the repository, either by calling the deployed contracts or by reproducing their behavior.
| Account Type | How It Works | Key Recovery |
|---|---|---|
| EOAs | An EOA's own secp256k1 key is an implicit admin actor (the self-actor), with no Keystore write. The account MAY add actors, restrict or revoke the self-actor, or lock | Wallet-defined; EOA recoverable via standard transactions |
| Existing Smart Contracts | Already-deployed accounts (e.g., ERC-4337 wallets) call importAccount() themselves; the wallet's code returns the actor set to install via confirmKeystoreImport() |
Wallet-defined |
| New Accounts (No EOA) | Created via createAccount (or a consumer's native create path) with CREATE2 address derivation; runtime bytecode placed at the address and initial actors configured |
Wallet-defined |
Each actor is associated with an authenticator, a contract that performs signature authentication. The authenticator authenticates the actor (it returns the actor's actorId); scope and policy on the Keystore then authorize what that actor may do. The authenticator address is stored in actor_config. All authenticators implement IAuthenticator.authenticate(hash, data). After the authenticator returns an actorId, the consumer validates it against actor_config and checks its scope against the authorization context.
Any contract implementing IAuthenticator can be permissionlessly deployed and registered as an actor's authenticator on any chain. Registration always makes the authenticator usable within EVM execution (for example, authenticating an actor via applySignedAccountChanges, enabling wallet-defined recovery methods). Whether a consumer accepts it on a transaction validation path is that consumer's policy.
The canonical authenticator set is the set of signature algorithms every consumer that validates natively MUST accept, at a constant enshrined cost. The initial set:
| Name | Algorithm | Authenticator | actorId Derivation |
|---|---|---|---|
| k1 | secp256k1 | K1_AUTHENTICATOR (native sentinel) |
bytes32(uint256(uint160(recovered_address))) (the address right-aligned into a 32-byte word) |
| p256 | P-256 | Onchain contract | keccak256(abi.encodePacked(x, y)) |
| passkey | WebAuthn / FIDO2 | Onchain contract | keccak256(abi.encodePacked(x, y)) |
| delegate | Signature delegation | Onchain contract | bytes32(uint256(uint160(delegated_address))): signatures from delegated_address are valid for the registering account (see Delegate Authenticator) |
The canonical authenticators are deployed at deterministic CREATE2 addresses across chains and catalogued in a companion ERC (number TBD). When a canonical authenticator is enshrined, its execution MUST produce results identical to the corresponding contract. On a base layer the enshrined set changes only through a hard fork; other chains MAY extend it through the companion ERC process.
The set is expected to grow as signature algorithms mature (e.g., post-quantum). This does not disturb existing accounts: actors reference their authenticator by address, so an account adopts a newly canonical scheme by authorizing a new actor pointing at it, with no migration, redeployment, or address change.
The delegate authenticator lets one account act on behalf of another: account A registers a delegate actor with actorId = bytes32(uint256(uint160(B))), after which any key that can authenticate as account B may authenticate as A, bounded by the scope A grants that actor. Delegation MUST NOT chain (the nested authenticator MUST NOT itself be the delegate authenticator) and the nested authenticator MUST be canonical, keeping total validation work bounded. The remaining details are defined in the canonical repository.
Each account can authorize a set of actors through the Keystore contract at KEYSTORE_ADDRESS. This contract handles actor authorization, account creation, change sequencing, and account lock, and delegates signature authentication to Authenticators. Its full ABI, storage packing, typehashes, and events are defined in the canonical repository; this section specifies the protocol-visible behavior and the state a consumer reads directly.
Actors are identified by their actorId, a 32-byte identifier derived by the authenticator from public key material. Actors are modified through signed account-change batches (see Account Changes).
Actor enumeration is performed off-chain via ActorAuthorized and ActorRevoked event logs.
Each actor occupies a single actor_config slot containing the authenticator address, an optional expiry, and the scope bitmask. Consumers read this slot directly, so its packing is normative: authenticator(20) ‖ expiry(6) ‖ scope(2) ‖ reserved(4), where expiry is a uint48 Unix timestamp in seconds (0 = no expiry; the actor is invalid once block.timestamp > expiry) and scope is the uint16 permission bitmask (0x0000 = unrestricted, also the admin predicate; see Actor Scope). The exact slot derivation and byte offsets are defined in the canonical repository.
An actor MAY also carry a signed policy commitment and a manager address in the separate policy slots policy_commitment/policy_manager (see Actor Policies). Whether those slots are written is decided by the length of the policyData the authorizing change carried (empty, or exactly 52 bytes), not by any scope bit; whether a consumer gates the actor on them is decided by the POLICY scope bit (0x08). Actors are revoked by deleting the actor_config slot. The self-actor (actorId == bytes32(uint256(uint160(account)))) is the one exception: it is held inline in the packed account-state slot (see Signature Verification).
policy_commitment(account, actorId) → bytes32 // set when policyData was attached (52 bytes)
policy_manager(account, actorId) → address // set when policyData was attached (52 bytes)
These slots are read only during execution (see Actor Policies); validity is decided by the single actor_config read, giving cheap, predictable validation and invalidation.
The scope field in actor_config is a uint16 permission bitmask of grants. A value of 0x00 means unrestricted (all contexts) and is also the account's admin predicate: any check in this specification that requires an "admin" actor is exactly scope == 0x00. Any non-zero value grants only the contexts whose bits are set. Reads are fail-closed: a context is authorized only when scope == 0x00 or the corresponding grant bit is present. Unknown bits grant nothing. All future scope bits MUST be pure grants. An actor whose scope sets only unknown bits is stored verbatim but authorizes no context.
| Bit | Value | Name | Context |
|---|---|---|---|
| 0 | 0x01 |
OPERATOR | Ungated initiation: may originate transactions to any target. Carries operational authority, which also governs account-level signing (ERC-1271); see Signature Verification |
| 1 | 0x02 |
SELF_PAYER | Self-pay gas: authorizes paying the account's own gas |
| 2 | 0x04 |
SPONSOR_PAYER | Sponsor gas: authorizes paying gas for a different sender |
| 3 | 0x08 |
POLICY | Gated initiation: may originate transactions only to the actor's policy_manager (see Actor Policies). Optional grant |
| 4 | 0x10 |
NONCE | Permits a restricted actor to use the consumer's sequenced nonce channels (see Actor Nonce Scope). Optional grant |
| 5–15 | (spare) | Reserved for future pure grants |
The canonical bit assignment is Scopes.sol in the canonical repository; this table MUST match it.
Admin. Config-change authority (authorizeActor, revokeActor, applySignedAccountChanges, code delegation) is exactly the admin predicate scope == 0x00. Accounts are born with a scope-0 root (the implicit EOA, or an unrestricted actor named at create/import); everything below that root is granted.
Initiation grants. OPERATOR and POLICY are the two initiation grants and do not combine into a third behavior: OPERATOR allows initiation to any target; POLICY allows gated initiation to the actor's policy_manager only. OPERATOR is not suppressed by POLICY: an actor holding OPERATOR | POLICY is an ungated operator, so a policy-gated key MUST NOT carry OPERATOR. Wallets SHOULD NOT set OPERATOR | POLICY. POLICY | SELF_PAYER and POLICY | NONCE compose. POLICY | SPONSOR_PAYER also composes: initiation is gated to the manager, but sponsor authority is not gated. authorizeActor stores any scope combination verbatim; combination semantics are checked at the point of use. In short: length decides what gets stored; POLICY decides whether the sender is gated; OPERATOR overrides POLICY.
NONCE (0x10) grants a restricted actor access to a consumer's sequenced nonce channels for sender-context authorization; it does not apply to payer authorization. A consumer whose nonce system has no sequenced/unsequenced distinction ignores it.
| Actor | Nonce channels allowed |
|---|---|
Admin (scope == 0x00) |
Any, including unsequenced (nonce-free) |
Restricted, NONCE unset |
Unsequenced (nonce-free) only |
Restricted, NONCE set |
Any |
An actor is live at now (the inclusion block timestamp) iff expiry == 0 || now <= expiry, evaluated at inclusion. Only the acting key's own expiry is checked; authorizer lineage is never walked, so a live admin's authorization survives that admin's own later expiry or revocation. Removal requires an explicit RevokeActor.
Note for wallets: expiry is set only by a config change (initial actors are always non-expiring), and SHOULD be placed only on non-admin actors. Using it on an admin can break valid change chains for cross-chain replay and account sync, so an admin SHOULD NOT expire unless the wallet keeps a non-expiring owner in place.
To enable uncoordinated session-key, subscription, and just-in-time (JIT) actor additions, the local change channel carries an epoch (local_epoch) alongside its sequence counter (local_sequence); together they form the signed local word local_epoch(high 32) || local_sequence(low 32) (see Change Authorization). The epoch is a blunt cancellation control for the account's own outstanding local signatures:
IncrementLocalEpoch increments local_epoch and resets local_sequence to 0. Every local batch signed at the prior epoch, sequenced or unsequenced, is rejected with StaleEpoch.actor_config. Only unlanded local signatures are invalidated.This is what makes the unsequenced/JIT mode (UNSEQUENCED) safe to hand out: an unsequenced batch is replayable within its epoch, and the account durably retires it by bumping the epoch, typically batching the reducing RevokeActor/AuthorizeActor with IncrementLocalEpoch.
Actor policies gate a key to a single manager contract that enforces application-defined authority on the account's behalf, for example a session key limited to a spend cap or a small set of targets.
Two independent decisions are involved, and the Keystore contract makes only the first:
policyData on the authorizing change. policyData MUST be either empty (config only) or exactly manager (20 bytes) ‖ commitment (32 bytes), written verbatim to policy_manager / policy_commitment; any other length is rejected. A zero commitment is a valid "no parameters" value, and a zero manager gates the key to address(0), leaving no productive target. An authorize with empty policyData clears any prior policy slots.POLICY scope bit and the absence of OPERATOR. A POLICY actor with no stored manager is gated to address(0); an actor with stored policy but no POLICY bit is not gated.POLICY set (no OPERATOR) |
Gate: every target MUST equal | Top-level value | policyData at authorize |
|---|---|---|---|
| no | (no gate) | allowed | empty, or manager (20) ‖ commitment (32) |
| yes | the stored manager (address(0) = no productive target) |
MUST be 0 |
empty, or manager (20) ‖ commitment (32) |
A policy-bearing actor may call exactly one target: its configured manager, which reads the actor's commitment (via getPolicyCommitment or getActorWithPolicy), validates presented parameters against it, enforces what the call may do, and carries out the approved action. A policy-gated actor cannot move ETH as top-level transaction value; ETH movement goes through the manager and account code. The consumer's only responsibilities are the single-target gate and the value restriction.
A key that should be enforced by the account's own code rather than a separate contract sets manager = account. Because the call originates from the account itself, this is only meaningful with policy-aware wallet code; code that implicitly trusts self-calls (e.g. a standard executeBatch) turns such a key into an unrestricted one.
Example (non-normative). A subscription session key limited to 5 USDC per 30-day period with a two-target allowlist, authorized with POLICY | SELF_PAYER, the manager, and a commitment to (USDC, 5_000_000, period). Over-budget transfers, wrong targets, expiry, or revocation all fail.
Lifecycle. Policy state is keyed by (account, actorId); revokeActor clears it.
Account lock state is stored in a single packed 32-byte account-state slot that also holds the change channels, an account-flags byte, and the inline self-actor config. Consumers may read this slot directly (for example for mempool tiering), so its field order and widths are normative.
When LOCKED, all authority changes (authorize/revoke actor) are rejected on every path, and consumers MUST reject code delegation for the account. Only two changes remain permitted while locked: Unlock (which begins the timed release) and IncrementLocalEpoch. The stored unlock_delay is bounded to the uint16 range (~18.2h max): lock exists for mempool permissioning, and a short ceiling prevents an account from bricking config rotation.
Lock and unlock are local-only changes carried in a signed batch through applySignedAccountChanges; there is no separate lock function. Each is admin-authorized, binds chainId = block.chainid, and is consumed against the local channel. Lock and Unlock MUST each be the sole change in their batch.
LOCKED and stores unlock_delay (a zero delay is rejected). Emits AccountLocked.LOCKED with no pending unlock. Sets UNLOCK_INITIATED and unlocks_at = block.timestamp + unlock_delay. Emits AccountUnlockInitiated.block.timestamp >= unlocks_at, the account is unlocked; flags are lazily cleared by the next operation. Locking again requires a fresh Lock.New accounts are created with pre-configured actors by createAccount(userSalt, bytecode, initialActors) or by a consumer's native create path, which MUST produce identical results. The code is placed directly at the account address and is not executed during deployment; initialization logic runs afterwards.
Each initial actor carries an actorId, authenticator, scope (0x0000 = admin), and policyData (empty, or exactly manager ‖ commitment), all committed to the derived address so the counterfactual address binds each initial actor's authority. Two things are not expressible at create: expiry, and a self-referential manager = account. Both are added afterwards with a config change, which MAY accompany creation in the same transaction.
Addresses use the EIP-1014 CREATE2 formula with KEYSTORE_ADDRESS as the deployer. initial_actors MUST be sorted by actorId in strictly ascending order, which makes derivation deterministic and rejects duplicates:
leaf_i = keccak256(actorId_i || authenticator_i || scope_i || policyData_i)
actors_commitment = keccak256(leaf_0 || leaf_1 || ... || leaf_n)
effective_salt = keccak256(user_salt || actors_commitment)
deployment_code = DEPLOYMENT_HEADER(len(code)) || code
address = keccak256(0xff || KEYSTORE_ADDRESS || effective_salt || keccak256(deployment_code))[12:]
computeAddress MUST apply the same initial-actor validation as create (non-empty set, strictly ascending non-zero actorIds, each authenticator >= K1_AUTHENTICATOR, policyData empty or exactly 52 bytes), so a predicted address always corresponds to an actor set that create will accept.
DEPLOYMENT_HEADER(n) is a fixed 14-byte EVM loader that returns the trailing code (see Appendix: Deployment Header). Through createAccount, deployment_code is passed as init code to CREATE2; a native create path constructs the same deployment_code for derivation but places code directly.
initial_actors: each policyData empty or exactly 52 bytes; scope stored verbatim; strictly ascending actorId.code_size == 0 and nonce == 0.code, register initial_actors, set local_sequence = 1 (initialized), initialize lock state to unlocked, and set DEFAULT_EOA_REVOKED unless the self-actor is among initial_actors. Emit AccountCreated.All actor and lock changes are carried in a signed account-change batch (SignedAccountChanges), applied through the Keystore's single entry point applySignedAccountChanges(account, batch) or through a consumer's native path carrying the same batch. The batch binds a replay channel and a sequence, and applies its ordered list of changes atomically.
SignedAccountChanges = (
channel, // 0 = Local (binds block.chainid), 1 = Multichain (binds chain_id 0)
sequence, // Local = local_epoch(high 32) || local_sequence(low 32)
// Multichain = a plain monotonic counter
changes, // ordered list of (change_type, payload)
signature // admin (scope == 0x00): authenticator || data
)
The payload is ABI-encoded so the same bytes decode identically whether applied natively or through the contract.
| change_type | Name | payload |
Description |
|---|---|---|---|
0 |
AuthorizeActor |
abi.encode(bytes32 actorId, ActorConfig config, bytes policyData) |
Upsert an actor: writes actor_config (authenticator, expiry, scope verbatim, reserved bytes zeroed). policyData MUST be empty (clears the policy slots) or exactly 52 bytes (writes them). Emits ActorAuthorized, whose actorData is 32 bytes without policy or 84 bytes with it |
1 |
RevokeActor |
abi.encode(bytes32 actorId) |
Revoke an actor. For a non-self actor, deletes actor_config and the policy slots. For the self-actorId, a k1 self is revoked by setting DEFAULT_EOA_REVOKED, and a non-k1 self has its actor_config deleted. Emits ActorRevoked |
2 |
IncrementLocalEpoch |
empty | Either channel. Increments local_epoch and resets local_sequence. Consumes no sequence itself. See Epoch System |
3 |
Lock |
abi.encode(uint16 unlockDelay) |
Local only, standalone. See Account Lock |
4 |
Unlock |
empty | Local only, standalone. See Account Lock |
Each batch is authorized by a single admin signature over the batch digest, in authenticator || data form. When batches are applied in sequence, each must be valid against the actor state after all previous batches have been applied. Anyone may relay; authorization comes from the signature.
chain_id 0): a plain monotonic uint64 counter, valid on any chain, for synchronizing actor state across chains. sequence MUST equal the current value, which then increments. There is no epoch or unsequenced mode on this channel, though a Multichain batch MAY carry IncrementLocalEpoch.block.chainid): sequence is local_epoch || local_sequence. The batch is rejected with StaleEpoch unless the high half equals the current local_epoch.< UNSEQUENCED): the low half MUST equal the current local_sequence, which then increments.== UNSEQUENCED, i.e. uint32 max): consumes no counter and remains replayable until the epoch moves. Ordering between unsequenced batches is undefined.Expiry on AuthorizeActor never reverts. On the unsequenced path, an already-lapsed grant is silently skipped, so replaying an old expired JIT grant can never clobber a renewed one. A single-consume batch (Multichain, or Sequenced local) installs an already-lapsed grant inert (written but not live) and still consumes its sequence, so a chain catching up on multichain history never strands its counter. A zero expiry is always accepted.
The combined local word doubles as the initialized flag: creation and import set local_sequence = 1, so an all-zero word means uninitialized.
The batch digest is a typed (ABI-encoded, EIP-712-style) struct hash binding account, the resolved chainId (0 for Multichain, else block.chainid), the sequence word, and the ordered changes. It cannot collide with transaction signature hashes of the form keccak256(type_byte || rlp([...])). The exact typehashes are defined in the canonical repository.
applySignedAccountChanges parses the authenticator from signature, resolves the admin actorId, verifies the digest, and applies each change in order. Authority changes are rejected while the account is locked.
importAccount() is a one-time call that registers an already-deployed account into the Keystore with an initial actor set. It is not signature-relayable and takes no parameters: the account itself MUST be msg.sender, and the actor set comes from the account's own code:
interface IKeystoreImport {
/// Returns this account's intended import actor set and `computeImportDigest(account, initialActors)`.
function confirmKeystoreImport() external view returns (bytes32 digest, InitialActor[] memory initialActors);
}
The Keystore STATICCALLs confirmKeystoreImport() on msg.sender, validates the returned set (non-empty, strictly ascending actorIds, authenticator >= K1_AUTHENTICATOR, policyData empty or 52 bytes), and installs it only when digest == computeImportDigest(account, initialActors). The call is rejected when:
Lock advances local_sequence.confirmKeystoreImport() reverts, cannot be decoded, or returns a mismatched digest (ImportNotConfirmed). A malformed set fails with its specific error (NoInitialActors / ActorsNotSortedOrDuplicate / InvalidAuthenticator / InvalidPolicyData) first; this ordering is normative.Import carries no chainId; a wallet that wants to bind import to a chain checks block.chainid inside confirmKeystoreImport. The digest binds the account's own actorId and each initial actor with expiry = 0. Unlike create, manager = account is expressible. On success importAccount sets DEFAULT_EOA_REVOKED; to keep using the native key, include the self-actorId as a K1_AUTHENTICATOR entry.
Because importAccount is reachable through any execute()-style path the wallet exposes, a wallet SHOULD gate the return of a valid (digest, actors) pair on its own owner-controlled import entry, or accept that anyone who can drive execute can finalize the import. After import, the Keystore is the source of truth for the actor set.
The Keystore exposes one raw primitive, authenticateActor(account, hash, auth), which maps a hash and signature to a verified (actorId, scope). Wallet-originated flows (transactions, account changes, other protocol paths) authenticate directly against it over their own digest. With auth = authenticator ‖ data it:
K1_AUTHENTICATOR (address(1)), ecrecover natively, giving actorId = bytes32(uint256(uint160(recovered))). For any other authenticator, call authenticate(hash, data) via STATICCALL, returning actorId (bytes32(0) is invalid). address(0) is never a valid authenticator.actorId is the account's own address, resolve the self-actor from the inline config in the packed account-state slot: reject if DEFAULT_EOA_REVOKED is set; otherwise take scope and expiry from the inline fields (all-zero = unrestricted, non-expiring admin). Otherwise read actor_config(account, actorId), reject if its reserved bytes are non-zero, and require that the stored authenticator equals the one used. Generic authenticator contracts never satisfy the self-actor branch, even if they return the account's own actorId.expiry is non-zero and block.timestamp > expiry.Applications that request a signature (Sign-In with Ethereum, Permit, order flow) use validateSignature(account, hash, auth). This wraps authenticateActor with an identity binding: replaySafeHash(account, chainId, hash) = keccak256(SIGNED_MESSAGE_TYPEHASH, account, chainId, hash) binds the signature to an account and chain (chainId = 0 binds all chains). The auth blob is sigType(1) ‖ authenticator(20) ‖ data, where sigType selects the Local (0x01) or Multichain (0x02) domain.
Both methods return (actorId, scope) rather than a boolean, so a consumer can attribute a signature to a specific key and make a granular authorization decision.
This supersedes ERC-1271 while remaining compatible: an account implements isValidSignature(hash, signature) on top of validateSignature, returning the magic value when the resolved actor is operational (admin, or any scope carrying OPERATOR) and treating any revert as invalid.
Native signature verification. ERC-1271 verifies by calling back into the account, which a precompile cannot do. The canonical authenticator set is small and enshrinable and authority lives in a flat actor_config slot, so validateSignature can run entirely in native code and be exposed as a precompile, producing identical results to EVM execution.
A consumer MAY integrate the Keystore natively instead of calling the contract, either fully enshrined (native Keystore reads, account-change application, and canonical authenticators) or only as a mempool optimization over EVM execution. Either way, authentication MUST be equivalent to authenticateActor(account, hash, auth), and the resolved actor's scope, expiry, policy, and the account's lock apply as specified in Actor Scope, Actor Expiry, Actor Policies, and Account Lock. All Keystore and canonical authenticator contracts are deployed at deterministic CREATE2 addresses across chains. AA Transaction Type Integration specifies one native consumer in full.
The AA Transaction Type proposal is designed to integrate with the Keystore directly. This section applies on chains where both are active. It fills that transaction type's extension points (the authenticator selector in sender_auth / payer_auth and the resolved-actor hook) and assigns account-change types 0x02 and 0x03 from its reserved range. It does not change its wire format or signature payloads, or the behavior of any account whose Keystore state is empty. Field names, K1_AUTH_COST, MAX_AUTHENTICATION_GAS, intrinsic_gas, and payer_gas_reserve are as defined there.
A named sender or payer (sender_auth / payer_auth is authenticator || data) resolves exactly as authenticateActor does (see Signature Verification), over the transaction's sender or payer signature hash, with a non-native authenticator's STATICCALL bounded by MAX_AUTHENTICATION_GAS. A recovered sender or payer (empty sender, open payer) resolves to the recovered address's inline self-actor.
The transaction type's rule for named identities without the Keystore ("recovered address MUST equal the account") is exactly the self-actor case of this resolution.
Every chain accepts the canonical authenticator set on this transaction path at a constant enshrined cost. Whether other authenticators whose role permits transaction authentication are accepted is the chain's acceptance policy:
STATICCALL returns within MAX_AUTHENTICATION_GAS, metered as ordinary EVM execution.A permissive policy admits authenticators that may read mutable state, so nodes MUST track the state they touch and revalidate on change.
The resolved actor's scope is checked per Actor Scope:
OPERATOR, or POLICY; POLICY without OPERATOR gates the actor to its manager).nonce_key != NONCE_KEY_MAX, the sender actor MUST be admin or hold NONCE.SELF_PAYER. Otherwise the payer actor MUST hold SPONSOR_PAYER. Implicit self-pay uses the sender actor; the other forms use the payer_auth-resolved actor.0x02)rlp([
0x02, // type: create
user_salt, // bytes32
code, // bytes: runtime bytecode placed at the account address
initial_actors // [[actorId, authenticator, scope, policyData], ...], strictly ascending actorId
])
sender MUST equal the address derived per Address Derivation, and the address MUST satisfy CREATE2 freshness (code_size == 0 and account nonce 0) in the pre-transaction state, before a nonce_key 0 transaction increments the account nonce.sender_auth MUST authenticate as one of initial_actors (matching actorId and authenticator).code at sender without executing it, registers initial_actors in the Keystore, and initializes the account per Account Creation. Initialization logic runs in calls.0x03)rlp([
0x03, // type: config change
channel, // uint8: 0 = Local, 1 = Multichain
sequence, // uint64
changes, // [[change_type, payload], ...]
auth // admin signature over the batch digest: authenticator || data
])
The entry carries one signed account-change batch (see Account Changes), with the same channels, sequencing, change types, digest, and authorization as applySignedAccountChanges. Each batch's auth is validated against the actor state after all previous entries are applied. Already-applied entries are skipped. Nodes SHOULD enforce a configurable per-transaction limit on config change entries.
Entries are applied in list order before calls, and sender_auth is validated against the actor state that results from applying them. A transaction with a config change or delegation entry for a locked account is invalid, except for changes permitted while locked (see Account Lock).
When the sender actor is policy-gated, the protocol resolves its manager once at the start of calls execution; that snapshot gates every call in every phase. A call whose to is not the manager is not dispatched and fails with:
error ActorPolicyViolation(bytes32 actorId, address target);
This is an execution result, not a validity error: the enclosing phase reverts, later phases are skipped, and only work already performed is charged.
These are validity rules, checked from signed fields and state already read during validation:
POLICY set, OPERATOR unset), every call.value MUST be 0.sender's code is canonical account bytecode (see High-Rate Payers), every call.value MUST be 0.Neither rule can apply to an account with empty Keystore state authenticated through its own secp256k1 key: such an actor is not policy-gated, and an account with placed canonical bytecode has no secp256k1 key.
| Component | Value |
|---|---|
sender_auth_cost / payer_auth_cost |
Canonical authenticator: its enshrined constant cost. Other accepted authenticator: metered STATICCALL execution plus one actor_config read (2,100). The secp256k1 self-actor cost is unchanged at K1_AUTH_COST. For payer_auth this replaces K1_AUTH_COST; payer_data_cost and the calldata floor still apply |
bytecode_cost |
For a create entry: 32,000 plus 200 per deployed byte |
account_changes_cost |
Per create entry: 22,100 per initial actor slot write, plus policy slot writes for actors with policyData. Per applied config change entry: auth authentication cost plus the storage writes it performs. Per skipped config change entry: 2,100 |
bytecode_cost is added to intrinsic_gas. When payer authentication is metered execution rather than an enshrined constant, payer_auth_cost is unknown until it runs, so payer_gas_reserve is MAX_AUTHENTICATION_GAS, and the authenticator's STATICCALL is capped at MAX_AUTHENTICATION_GAS - 2,100 - 10 * payer_tokens so the payer's floored share stays within it. A chain MAY implement Keystore reads, account-change application, and canonical authenticators natively at these fixed costs; results MUST match executing the Keystore contract.
This integration adds the following functions to the AA Transaction Type's Transaction Context precompile, alongside getTransactionPayer(). Gas is the same: a base cost plus 3 gas per 32 bytes of returned data. Outside an AA_TX_TYPE transaction, each of these returns the zero value.
| Function | Returns |
|---|---|
getTransactionSender() |
address, the resolved sender |
getTransactionCalls() |
Call[][], the full calls array |
getTransactionGasLimit() |
uint256, gas_limit |
The precompile, including getTransactionPayer(), is also readable during authentication, so a payer authenticator can read calls and gas_limit to decide onchain whether to sponsor. A chain MAY populate it during execution only.
The protocol SHOULD inject log entries matching the Keystore events (ActorAuthorized, ActorRevoked, AccountCreated, and others) for account changes applied by an AA transaction.
For canonical authenticators, a validated transaction is invalidated only by actor_config changes, nonce consumption, and payer balance. Nodes MAY apply higher pending limits to accounts that qualify for the high-rate payer tier.
eth_estimateGas and eth_callThe AA Transaction Type's requests default to the account's own secp256k1 key. With this integration, a request selects a Keystore actor for the sender or payer with either or both of:
senderActorId / payerActorId (bytes32): the actor to simulate.senderAuth / payerAuth in the named form authenticator || data, where data is a dummy of the length a real signature would have.The node resolves the simulated actor without verifying any signature:
actor_config(account, actorId), or the inline self-actor when actorId is the account's own address. The request fails if the actor does not exist, is revoked, or has expired. If an auth blob is also supplied, its authenticator MUST equal the actor's.When accountChanges contains a create entry, the actor MUST be one of its initial_actors. Config-change entries are applied before resolution, so an actor they install can be simulated in the same request.
Authentication gas. A canonical authenticator is priced at its enshrined constant. For any other authenticator, the node executes it when a real signature is supplied, and otherwise prices authentication at MAX_AUTHENTICATION_GAS. When no auth blob is supplied, the node prices a dummy of the resolved authenticator's expected signature length (65 bytes for secp256k1). Auth bytes are priced as non-zero bytes.
Execution. Calls run as if the resolved actor had signed. The scope checks, policy gate, and value rules of this section apply, so a request from a policy-gated actor fails the same way the transaction would.
For every account whose Keystore state is empty, validity, execution results, and gas are identical with and without this integration: the inline self-actor is an unrevoked, non-expiring admin, so every authorization rule passes; the value rules cannot apply; and K1_AUTH_COST is unchanged.
A node can grant a higher pending-transaction limit to a payer whose balance can only decrease through gas. An account qualifies when:
LOCKED, has no pending unlock (UNLOCK_INITIATED clear), and its unlock_delay is at or above the node's threshold, so its actor set is frozen for at least that window; andFor the tier to hold, a consumer that carries top-level ETH value MUST NOT transfer it from an account with canonical account bytecode; such accounts move ETH through their own code. A node reads the packed account-state slot and the account's code hash to decide eligibility.
| Name | Value | Comment |
|---|---|---|
KEYSTORE_ADDRESS |
CREATE2-derived (resolved at deployment) | Keystore contract address |
K1_AUTHENTICATOR |
address(1) |
Native secp256k1 authenticator (implicit self-actor and explicitly registered k1 actors) |
DEFAULT_EOA_REVOKED |
0x01 |
Account-state flag that disables the implicit self-actor path |
LOCKED |
0x02 |
Account-state flag that freezes actor configuration |
UNLOCK_INITIATED |
0x04 |
Account-state flag selecting the unlock_delay vs unlocks_at interpretation |
UNSEQUENCED |
2^32 - 1 |
Local-channel sentinel for unsequenced (JIT) batches |
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)
]
Account creation only supports runtime bytecode.
Enables signature-algorithm extension through authenticator contracts. The authenticator returns the actorId rather than accepting it as input, so the Keystore never needs algorithm-specific logic. All authenticators share a single authenticate(hash, data) interface. Actor scope and policy provide role separation without authenticator cooperation.
Without a required set, consumers could diverge on which signature algorithms they accept beyond secp256k1, and wallets could not guarantee delivery for other signature types. The canonical set is a shared baseline, expected to remain small, with new algorithms added through the companion ERC process as they gain adoption.
Authority over an account should not depend on which transaction format carries an action. Stating the Keystore once, with every consumer's authentication equivalent to authenticateActor, gives an actor the same meaning on a native transaction type, a frame account, an ERC-4337 account, and in signature verification, and lets each transport integrate on its own schedule.
Session keys often need narrow authority: only this token, only this much per day. POLICY makes gated initiation a first-class grant, distinct from ungated OPERATOR: the key may originate transactions, but only to a single target, bound to a signed opaque commitment. Barring top-level value keeps every ETH movement by such a key inside the manager's enforcement. Allowing POLICY | SELF_PAYER lets a session key self-pay, which exposes the account's ETH balance to gas spend.
Session keys and JIT authorizations should be usable in any order, and the account should be able to cancel any that have not landed. A single counter forces every signature to burn the next slot. Splitting the local word into local_epoch || local_sequence keeps ordered, replay-once changes on the sequence while an epoch bump cancels every unlanded local signature at once. It cancels signatures, not authority, so it cannot brick a live actor set.
Authenticators receive the public key in the signature data and derive the actorId from it. Per-actor state stays at one actor_config read regardless of key size, which matters for large post-quantum keys, and calldata is cheaper than cold storage for material read once per authorization.
The Keystore is a single immutable CREATE2 deployment and cannot know which grants future versions will define. AuthorizeActor stores scope verbatim and validates only timeless structure; combination semantics are checked at the point of use, so an unsatisfiable combination is simply inert.
Locked accounts have a frozen actor set, so the primary state that can invalidate a validated authorization is nonce consumption. Nodes can cache actor state and apply higher limits. Combined with canonical account bytecode, a locked payer's balance only decreases through gas, supporting high-throughput sponsorship.
Transports that carry native value (such as the AA Transaction Type) would otherwise let a canonical account's own keys move ETH outside its code, breaking the property that ETH leaves a locked canonical account only as gas. Restricting top-level value to non-canonical code keeps that property transport-independent. Only placed code qualifies, because a delegated EOA's key can always move ETH with a legacy transaction.
user_salt + code + initial_actors produces the same address on any chain.deployment_code produces the same address through createAccount and native create paths.initial_actors in the salt prevents deployment with different actors.Account creation, import, signed actor changes, and locking are ordinary EVM entry points, so external factories can compose them to mint accounts into a desired end state.
No breaking changes. Existing EOAs and smart contracts function unchanged; an EOA's key is an implicit admin actor with no Keystore write. Adoption is opt-in through actor changes, createAccount, or importAccount.
The actor_config layout, the scope-bit assignment with reserved bytes as a version gate, and the address-derivation commitment are defined by this specification; the full contract ABI surface is defined in the canonical repository. There is no prior deployment, so there is no earlier format to migrate.
The Keystore contract is the canonical ABI surface. Its full source lives in the canonical contracts repository (src/Keystore.sol) and is authoritative. The sketch below mirrors the protocol-relevant shape.
interface IKeystore {
// Packed account state (see Account Lock). The signed local word is localEpoch(high 32) || localSequence(low 32).
struct ChangeSequences {
uint64 multichain; // chain_id 0 channel; a plain monotonic counter
uint32 localEpoch; // local channel epoch; IncrementLocalEpoch bumps it and resets localSequence to 0
uint32 localSequence; // local channel counter; low half of the signed local word
}
struct ActorConfig {
address authenticator;
uint48 expiry; // Unix seconds; 0 = no expiry. Actor invalid once block.timestamp > expiry
uint16 scope; // grants bitmask; 0x0000 = unrestricted (admin). See Actor Scope
}
// Actor used for account creation and import. expiry is NOT expressible here.
// policyData: empty, or manager[20] || commitment[32] (by length, independent of scope).
struct InitialActor {
bytes32 actorId;
address authenticator;
uint16 scope;
bytes policyData;
}
enum AccountChangeChannel { Local, Multichain }
enum ChangeType { AuthorizeActor, RevokeActor, IncrementLocalEpoch, Lock, Unlock }
// Leading byte of a signature envelope: Local (0x01) binds block.chainid, Multichain (0x02) binds chainId 0.
enum SignatureType { Invalid, Local, Multichain }
struct AccountChange {
ChangeType changeType;
bytes payload; // AuthorizeActor: abi.encode(bytes32 actorId, ActorConfig, bytes policyData);
// RevokeActor: abi.encode(bytes32 actorId); Lock: abi.encode(uint16 unlockDelay); others empty
}
struct SignedAccountChanges {
AccountChangeChannel channel;
uint64 sequence; // Local: localEpoch || localSequence; Multichain: monotonic counter
AccountChange[] changes;
bytes signature; // admin (scope == 0x00): authenticator || data
}
uint32 constant UNSEQUENCED = type(uint32).max; // JIT sentinel for the local low half
event ActorAuthorized(address indexed account, bytes32 indexed actorId, bytes actorData);
event ActorRevoked(address indexed account, bytes32 indexed actorId);
event AccountCreated(address indexed account, bytes32 userSalt, bytes32 codeHash);
event AccountImported(address indexed account);
event LocalEpochIncremented(address indexed account, uint32 localEpoch);
event AccountLocked(address indexed account, uint16 unlockDelay);
event AccountUnlockInitiated(address indexed account, uint48 unlocksAt);
// Account creation (factory) and counterfactual address preview.
function createAccount(bytes32 userSalt, bytes calldata bytecode, InitialActor[] calldata initialActors) external returns (address);
function computeAddress(bytes32 userSalt, bytes calldata bytecode, InitialActor[] calldata initialActors) external view returns (address);
// Import msg.sender. The account's code returns (digest, actors) via IKeystoreImport.confirmKeystoreImport().
function importAccount() external;
function computeImportDigest(address account, InitialActor[] memory initialActors) external pure returns (bytes32);
// Single signed entry point for all account changes (authorize/revoke actor, increment epoch, lock/unlock).
function applySignedAccountChanges(address account, SignedAccountChanges calldata changes) external;
// Typed-envelope message signing. auth = sigType(1) || authenticator(20) || data.
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 envelopeDigest(SignatureType sigType, address account, bytes32 hash) external view returns (bytes32);
// Lower-level: authenticate an actor over a raw digest (no envelope).
function authenticateActor(address account, bytes32 hash, bytes calldata auth) external view returns (bytes32 actorId, uint16 scope);
// Storage views (see the canonical repository for the complete set).
function getActorConfig(address account, bytes32 actorId) external view returns (ActorConfig memory);
function getActorWithPolicy(address account, bytes32 actorId) external view returns (ActorConfig memory config, address policyManager, bytes32 policyCommitment);
function getPolicyManager(address account, bytes32 actorId) external view returns (address);
function getPolicyCommitment(address account, bytes32 actorId) external view returns (bytes32);
function getChangeSequences(address account) external view returns (ChangeSequences memory);
function getLockStatus(address account) external view returns (bool locked, bool hasInitiatedUnlock, uint48 unlocksAt, uint16 unlockDelay);
}
interface IAuthenticator {
function authenticate(
bytes32 hash,
bytes calldata data
) external view returns (bytes32 actorId);
}
The full interface under this integration, including the AA Transaction Type's getTransactionPayer():
interface ITransactionContext {
struct Call {
address to;
uint256 value;
bytes data;
}
function getTransactionSender() external view returns (address);
function getTransactionPayer() external view returns (address);
function getTransactionCalls() external view returns (Call[][] memory);
function getTransactionGasLimit() external view returns (uint256);
}
Validation Surface. For canonical authenticators, the invalidators of an authorization are actor_config changes, lock changes, and the consumer's nonce consumption. Canonical authenticators are pure functions of (hash, data) and the resolved actor_config, so the invalidator set is small, enumerable, and keyed by the account. Accepting non-canonical authenticators that read mutable state widens it and requires the consumer to track that state.
Actor Scope and Policy. Scope grants are enforced after authentication and fail closed. The policy gate and the value restriction are enforced by each consumer (see Actor Policies).
Policy Target as Trust Anchor. For a policy-bearing actor, the manager is fully trusted to enforce the committed limits; a buggy or malicious manager can do anything its own authority over the account allows. Accounts SHOULD point restricted keys only at audited managers. A manager SHOULD be non-upgradeable: an upgradeable manager lets whoever controls the upgrade rewrite enforcement. Checking that presented parameters match the stored commitment is the manager's responsibility.
Policy State on Revocation. revokeActor clears actor_config, policy_commitment, and policy_manager, immediately stopping the key from reaching its manager. Parameters a manager keeps in its own storage are not auto-cleared; wallets uninstall them through the manager, and an expiry bounds the window otherwise.
Actor Management. Config change authorization requires an admin actor. The EOA self-actor is implicitly admin and revocable via a config change. All actor modification paths are blocked while the account is locked.
Actor Expiry. Prefer non-expiring admins and apply expiry only to restricted keys; a sole expiring admin bricks the account and breaks cross-chain reconstruction.
Local Epoch and Outstanding Signatures. IncrementLocalEpoch cancels every unlanded local signature at the prior epoch, including unsequenced batches, but does not revoke live actors or affect the multichain channel. Wallets MUST treat an epoch increment, not mere non-inclusion, as the durable retirement of a JIT authorization.
Implicit EOA Rule Scoping. The self-actor rule only applies when authentication used the native secp256k1 path and DEFAULT_EOA_REVOKED is unset. Generic authenticator contracts MUST NOT satisfy it even if they return the account's own actorId; otherwise an arbitrary authenticator could authenticate as any EOA.
Revocation Does Not Disable the Key Elsewhere. Setting DEFAULT_EOA_REVOKED removes the EOA key's authority in every Keystore consumer, but the key can still sign legacy transactions for the account. Accounts that must not be controlled by a secp256k1 key should be created through account creation rather than rely on revocation.
actorId Binding. Consumers check that the authenticator's returned actorId maps back to that authenticator in actor_config, preventing a malicious authenticator from claiming another authenticator's actors.
Account Creation. Initial actors are salt-committed, preventing front-running of actor assignment. Wallet bytecode should be inert when uninitialized, since it can be permissionlessly deployed. Creation applies only to addresses that satisfy CREATE2 freshness.
Copyright and related rights waived via CC0.