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:
- Store account authority independently from account code.
- Authenticate actors through replaceable authenticator contracts.
- Apply signed local or multichain authority changes.
- 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:
| Field | Meaning |
|---|---|
authenticator | Contract or standardized authenticator that resolves the actor |
expiry | Unix timestamp in seconds; zero means no expiry |
scope | Consumer-defined grants; zero means admin |
reserved | Version gate; MUST be zero |
An actor is live when:
expiry == 0 || block.timestamp <= expiryA 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_AUTHENTICATORThe 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_actorEach 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:
| Bit | Value | Name | Meaning |
|---|---|---|---|
| 0 | 0x01 | CONTRACT_ESTABLISHED | Authority was established through Keystore creation or import |
| 1 | 0x02 | DEFAULT_EOA_REVOKED | The implicit address-bound secp256k1 actor is disabled |
| 2–7 | Reserved | Assigned 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 = 0This 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:
| Type | Name | Payload |
|---|---|---|
0 | AuthorizeActor | abi.encode(bytes32 actorId, ActorConfig config) |
1 | RevokeActor | abi.encode(bytes32 actorId) |
2 | IncrementLocalEpoch | Empty |
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,
0xf3It 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:
- Deployed bytecode at
account. - Uninitialized Local and Multichain sequences.
chainId == 0 || chainId == block.chainid.- A valid ERC-1271 signature from
accountover the typed initialization digest. - 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) || dataIt 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) || dataSignature types are:
| Value | Name | Chain binding |
|---|---|---|
0x01 | Local | block.chainid |
0x02 | Multichain | 0 |
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.
| Component | Any EVM chain | Native integration |
|---|---|---|
| Keystore | Ordinary contract | Protocol may read the same storage directly |
| Authenticators | EVM contracts | Canonical authenticators may be implemented natively |
| Configuration | applySignedAccountChanges | Same batch may be transported by a native transaction |
| Account creation | CREATE2 factory | Protocol may place the same runtime code directly |
| Execution | ERC-4337 or wallet-specific | Native transaction standard |
The Keystore does not define transaction nonces, gas payment, call execution, or a transaction type. Those belong to consuming standards.
Constants
| Name | Value | Meaning |
|---|---|---|
KEYSTORE_ADDRESS | Deterministic deployment address | Keystore contract |
K1_AUTHENTICATOR | address(1) | secp256k1 authenticator |
CONTRACT_ESTABLISHED | 0x01 | Keystore-established account |
DEFAULT_EOA_REVOKED | 0x02 | Implicit address-bound actor revoked |
UNSEQUENCED | uint32.max | Local 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
Copyright and related rights waived via CC0.
