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:

key buildersts
import { key } from 'viem/eip8130'
key.k1('0xowner…') // secp256k1 (native ecrecover)
key.p256({ x, y }) // P-256 public key
key.webAuthn({ x, y }) // WebAuthn / FIDO2 passkey
key.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).

ConceptMeaning
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:

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

getConfigSequencets
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:

Atomic rotation (config-only tx)ts
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.
Authorize + act in one txts
// 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:

Delegate + add key on first usets
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.

Lock and initiate unlockts
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 accountChanges
const 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 pattern
const 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.