Guide
Keys & scopes
An EIP-8130 account is controlled by a set of actors. You rotate ownership by applying a signed config account-change that authorizes new actors and/or revokes existing ones. Because a config change is just an accountChanges entry, it rides inside a normal transaction.
Actors and keys
Each actor is { actorId, authenticator }. Build them with the key.* helpers:
import { key } from 'viem/eip8130'
key.k1('0xowner…') // secp256k1 (native ecrecover)key.p256({ x, y }) // P-256 public keykey.webAuthn({ x, y }) // WebAuthn / FIDO2 passkeykey.delegate('0xotherAccount') // signatures for another account act for this one// Drive-only contracts (PolicyManager, EntryPoint) are also K1:// authorizeActor(key.k1(manager), { scope: actorScope.operator })Scopes
A scope is a uint16 bitmask of grants. Combine flags with toScope. Scope 0x0000 is unrestricted admin: config changes, lock, delegation, and ERC-1271 signing all ride on admin scope (there is no separate signature or config bit). actorScope.operator and actorScope.policy are mutually exclusive. If a transaction fails with actor scope insufficient, the acting actor is missing a required bit: name it explicitly (e.g. add actorScope.operator for ungated initiation, or actorScope.nonce for a sequenced channel).
| Concept | Meaning |
|---|---|
| actorScope.operator (0x01) | Ungated initiation: may originate transactions as the account. Mutually exclusive with POLICY. |
| actorScope.selfPayer (0x02) | May pay for its own transactions (payer == sender). |
| actorScope.sponsorPayer (0x04) | May sponsor other accounts (payer != sender). |
| actorScope.policy (0x08) | Actor is gated to its policy manager. Session keys use POLICY | NONCE. Mutually exclusive with OPERATOR. |
| actorScope.nonce (0x10) | May use sequenced 2D nonce keys. Without it, the actor is restricted to nonce-free (expiring) transactions. Always set this for session keys. |
| scopeUnrestricted (0x0000, admin) | Unrestricted full owner. Omit scope (or pass 0) for a full-owner key. |
authorizeActor attaches a scope, optional expiry, and optional policy to a key:
import { actorScope, authorizeActor, key, toScope } from 'viem/eip8130'
authorizeActor(key.p256({ x, y }), { // Combine scope flags; 0 (omitted) = unrestricted admin. scope: toScope(actorScope.operator, actorScope.nonce), // Optional expiry (unix seconds); 0 / omitted = no expiry. expiry: BigInt(Math.floor(Date.now() / 1000) + 86_400),})Read the config sequence
Every config change is signed against the account's live local sequence. Read it with getConfigSequence immediately before signing. Never hardcode it.
import { getConfigSequence } from 'viem/eip8130'
// The keystore is enshrined — no address argument. Defaults to keystoreAddress.const { local: sequence } = await getConfigSequence(client, { account: account.address,})The returned local value is the per-chain sequence. The action also returns a multichain sequence for replayable chain_id 0 changes that propagate to every chain, covered in Multichain.
Authorize, revoke, rotate
Sign a change with account.change(...), then include it in a transaction's accountChanges. revokeActor accepts an actor or a raw actorId. Combine authorize + revoke in one change to rotate a key atomically:
import { actorScope, authorizeActor, key, revokeActor, sendTransaction,} from 'viem/eip8130'
// Authorize the new key and revoke the old one in one atomic change.const rotate = await account.change( [ authorizeActor(key.k1('0xnewOwner…'), { scope: actorScope.operator }), revokeActor(key.k1('0xoldOwner…')), ], { chainId: client.chain.id, sequence: Number(sequence) },)
const hash = await sendTransaction(client, { account, accountChanges: [rotate], calls: [], // config-only transaction gas: 200_000n,})Just-in-time vs. immediate
- Immediate: land the change now with a config-only transaction (
calls: []), as above. - Just-in-time: attach the change to a transaction that also does work, so the new key is authorized and used in the same send.
// Add a key and use it in the same transaction.const hash = await sendTransaction(client, { account, accountChanges: [addKey], calls: [{ to, data }], gas: 300_000n,})Rotate during deployment
For a delegated EOA, you can delegate and install new keys in the very first transaction, no separate account handle required:
import { actorScope, authorizeActor, canonicalEip8130Deployment, key, sendTransaction, toEoaAccount,} from 'viem/eip8130'import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'
const account = toEoaAccount(privateKeyToAccount(generatePrivateKey()))
const addP256 = await account.change( [authorizeActor(key.p256({ x, y }), { scope: actorScope.operator })], { chainId: client.chain.id, sequence: 0 },)
// Delegate the EOA and install the P-256 key in the very first transaction.const hash = await sendTransaction(client, { account, accountChanges: [ account.delegate(canonicalEip8130Deployment.accounts.default), addP256, ], calls: [], gas: 300_000n,})Account locks
Locking freezes configuration changes and delegation, and unlocking is time-delayed (unlockDelay), giving the owner a window to react to a compromised key. Locked accounts are also eligible for elevated mempool rate limits. Lock/unlock are single-op SignedAccountChanges batches: build the op with lockChange / unlockChange, sign it with an admin key via signAccountChanges (local channel), then apply it with encodeApplySignedAccountChangesData. They are local-channel only and consume the live local config sequence. Node support is currently deferred.
import { encodeApplySignedAccountChangesData, getConfigSequence, getLockStatus, keystoreAddress, lockChange, sendTransaction, signAccountChanges, unlockChange,} from 'viem/eip8130'
// Lock/unlock are single-op SignedAccountChanges on the LOCAL channel, signed by// the account's admin (scope 0) actor. Node support is deferred today — the// builders are contract-accurate but a native tx carrying one is still rejected.// The keystore is enshrined — no address argument.const { local } = await getConfigSequence(client, { account: account.address,})const unlockDelay = 3600 // seconds (uint16, 1…65535)
// 1) Sign a batch carrying a single lock op (admin key, local channel)const entry = await signAccountChanges({ signer: admin, account: account.address, channel: 'local', chainId: client.chain.id, sequence: local, changes: [lockChange({ unlockDelay })],})
// 2) Apply it — standalone call, or fold `entry` into a tx's accountChangesconst data = encodeApplySignedAccountChangesData({ account: account.address, ...entry,})await sendTransaction(client, { account, calls: [{ to: keystoreAddress, data }], gas: 300_000n,})
const status = await getLockStatus(client, { account: account.address })// status.locked, status.unlockDelay, …
// Later: initiate the (delayed) unlock — a single unlock op, same patternconst next = await getConfigSequence(client, { account: account.address,})const unlockEntry = await signAccountChanges({ signer: admin, account: account.address, channel: 'local', chainId: client.chain.id, sequence: next.local, changes: [unlockChange()],})await sendTransaction(client, { account, calls: [{ to: keystoreAddress, data: encodeApplySignedAccountChangesData({ account: account.address, ...unlockEntry, }), }], gas: 250_000n,})Read state with getLockStatus / isLocked. Why locks matter for throughput: Protocol · Authenticators & locks.