From f31beb640c56285fc4ce503c982788361fa9400a Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 9 Jun 2026 14:44:19 -0400 Subject: [PATCH 01/96] feat(experimental): add EIP-8130 AA transaction support Adds a ground-up `viem/experimental/eip8130` module implementing the EIP-8130 (`AA_TX_TYPE` = 0x7b) wire format: - serializeTransaction8130 / parseTransaction8130 (round-trip) - sender (AA_TX_TYPE) and payer (AA_PAYER_TYPE) signature hashes - account_changes encoders/decoders: create, config (actor changes with scope/expiry/policy), and delegation entries - 2D nonce, nonce-free mode, and self-pay/sponsored payer modes - constants, actor/authenticator types, and structural assertions --- package.json | 2 +- src/experimental/eip8130/constants.ts | 73 ++++++ src/experimental/eip8130/index.ts | 56 ++++ src/experimental/eip8130/package.json | 6 + src/experimental/eip8130/types/transaction.ts | 160 +++++++++++ .../eip8130/utils/assertTransaction.ts | 41 +++ .../eip8130/utils/hashTransaction.ts | 99 +++++++ .../eip8130/utils/parseTransaction.ts | 180 +++++++++++++ .../utils/serializeTransaction.test.ts | 248 ++++++++++++++++++ .../eip8130/utils/serializeTransaction.ts | 150 +++++++++++ src/package.json | 5 + 11 files changed, 1019 insertions(+), 1 deletion(-) create mode 100644 src/experimental/eip8130/constants.ts create mode 100644 src/experimental/eip8130/index.ts create mode 100644 src/experimental/eip8130/package.json create mode 100644 src/experimental/eip8130/types/transaction.ts create mode 100644 src/experimental/eip8130/utils/assertTransaction.ts create mode 100644 src/experimental/eip8130/utils/hashTransaction.ts create mode 100644 src/experimental/eip8130/utils/parseTransaction.ts create mode 100644 src/experimental/eip8130/utils/serializeTransaction.test.ts create mode 100644 src/experimental/eip8130/utils/serializeTransaction.ts diff --git a/package.json b/package.json index b594629a31..6af4a40d09 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "src": { "entry": [ "index.ts!", - "{account-abstraction,accounts,actions,celo,chains,ens,experimental,experimental/erc7739,experimental/erc7821,experimental/erc7811,experimental/erc7846,experimental/erc7895,linea,node,nonce,op-stack,siwe,tempo,tempo/actions,tempo/chains,tempo/zones,utils,window,zksync}/index.ts!", + "{account-abstraction,accounts,actions,celo,chains,ens,experimental,experimental/eip8130,experimental/erc7739,experimental/erc7821,experimental/erc7811,experimental/erc7846,experimental/erc7895,linea,node,nonce,op-stack,siwe,tempo,tempo/actions,tempo/chains,tempo/zones,utils,window,zksync}/index.ts!", "chains/utils.ts!" ], "ignore": [ diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts new file mode 100644 index 0000000000..03656d298a --- /dev/null +++ b/src/experimental/eip8130/constants.ts @@ -0,0 +1,73 @@ +import type { Hex } from '../../types/misc.js' + +/** + * EIP-2718 transaction type for EIP-8130 AA transactions (`AA_TX_TYPE`). + */ +export const aaTransactionType = '0x7b' satisfies Hex + +/** + * Magic byte for payer signature domain separation (`AA_PAYER_TYPE`). + */ +export const aaPayerType = '0x7c' satisfies Hex + +/** Base intrinsic gas cost (`AA_BASE_COST`). */ +export const aaBaseCost = 15000n + +/** + * Account change entry type discriminators (first element of each + * `account_changes` entry). + */ +export const accountChangeType = { + create: '0x00', + config: '0x01', + delegation: '0x02', +} as const satisfies Record + +/** + * Actor change operation types used within a config-change entry. + */ +export const actorChangeType = { + authorizeActor: 0x01, + revokeActor: 0x02, +} as const + +/** + * Actor scope permission bitmask values. + * + * `0x00` (unrestricted) is represented by the absence of any bit. + */ +export const actorScope = { + signature: 0x01, + sender: 0x02, + payer: 0x04, + config: 0x08, +} as const + +/** + * Nonce-free mode selector (`NONCE_KEY_MAX`). When `nonceKey` equals this value, + * no nonce state is read or incremented and replay protection relies on + * `expiry`. + */ +export const nonceKeyMax = 2n ** 256n - 1n + +/** + * Protocol-reserved native secp256k1 (ECDSA) authenticator address + * (`ECRECOVER_AUTHENTICATOR`). + */ +export const ecrecoverAuthenticator = + '0x0000000000000000000000000000000000000001' satisfies Hex + +/** + * Revocation marker written to an implicit EOA actor slot + * (`REVOKED_AUTHENTICATOR` = `type(uint160).max`). + */ +export const revokedAuthenticator = + '0xffffffffffffffffffffffffffffffffffffffff' satisfies Hex + +/** Nonce Manager precompile address (`NONCE_MANAGER_ADDRESS`). */ +export const nonceManagerAddress = + '0x813000000000000000000000000000000000aa01' satisfies Hex + +/** Transaction Context precompile address (`TX_CONTEXT_ADDRESS`). */ +export const txContextAddress = + '0x813000000000000000000000000000000000aa02' satisfies Hex diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts new file mode 100644 index 0000000000..518cb1dd16 --- /dev/null +++ b/src/experimental/eip8130/index.ts @@ -0,0 +1,56 @@ +// biome-ignore lint/performance/noBarrelFile: entrypoint +export { + aaBaseCost, + aaPayerType, + aaTransactionType, + accountChangeType, + actorChangeType, + actorScope, + ecrecoverAuthenticator, + nonceKeyMax, + nonceManagerAddress, + revokedAuthenticator, + txContextAddress, +} from './constants.js' + +export type { + AaAccountChange, + AaAccountChangeConfig, + AaAccountChangeCreate, + AaAccountChangeDelegation, + AaActor, + AaActorChange, + AaAuthorizeActor, + AaCall, + AaCalls, + AaRevokeActor, + TransactionSerializable8130, + TransactionSerialized8130, +} from './types/transaction.js' + +export { + type AssertTransaction8130ErrorType, + assertTransaction8130, +} from './utils/assertTransaction.js' + +export { + type GetPayerSignatureHash8130ErrorType, + type GetSenderSignatureHash8130ErrorType, + type GetSignatureHash8130Parameters, + type GetSignatureHash8130ReturnType, + getPayerSignatureHash8130, + getSenderSignatureHash8130, +} from './utils/hashTransaction.js' + +export { + type ParseTransaction8130ErrorType, + parseTransaction8130, +} from './utils/parseTransaction.js' + +export { + type SerializeTransaction8130ErrorType, + serializeTransaction8130, + toAccountChangesList, + toCallsList, + toTransactionBody, +} from './utils/serializeTransaction.js' diff --git a/src/experimental/eip8130/package.json b/src/experimental/eip8130/package.json new file mode 100644 index 0000000000..cef396dc06 --- /dev/null +++ b/src/experimental/eip8130/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "types": "../../_types/experimental/eip8130/index.d.ts", + "module": "../../_esm/experimental/eip8130/index.js", + "main": "../../_cjs/experimental/eip8130/index.js" +} diff --git a/src/experimental/eip8130/types/transaction.ts b/src/experimental/eip8130/types/transaction.ts new file mode 100644 index 0000000000..73112854c8 --- /dev/null +++ b/src/experimental/eip8130/types/transaction.ts @@ -0,0 +1,160 @@ +import type { Address } from 'abitype' +import type { Hex } from '../../../types/misc.js' + +/** + * A single call within a phase. Calls carry no ETH value (per EIP-8130); value + * transfers are initiated by the account's wallet bytecode. + */ +export type AaCall = { + /** Target address. */ + to: Address + /** Calldata. @default '0x' */ + data?: Hex | undefined +} + +/** + * Calls are grouped into ordered phases. Each phase is an atomic batch: if any + * call in a phase reverts, that phase's state changes are discarded and + * remaining phases are skipped. Completed phases persist. + * + * @example + * ```ts + * // Simple call: one phase, one call + * const calls: AaCalls = [[{ to, data }]] + * // Sponsor (phase 0) + user actions (phase 1) + * const calls: AaCalls = [[sponsorPayment], [userActionA, userActionB]] + * ``` + */ +export type AaCalls = readonly (readonly AaCall[])[] + +/** + * An initial actor for a `create` entry. Initial actors are always registered as + * unrestricted owners (`scope = 0x00`, no policy, no expiry); only `actorId` and + * `authenticator` participate in address derivation. + */ +export type AaActor = { + /** 32-byte actor identifier. */ + actorId: Hex + /** Authenticator contract address. */ + authenticator: Address +} + +/** `create` (type `0x00`) account-change entry: deploy a new account. */ +export type AaAccountChangeCreate = { + type: 'create' + /** User-chosen uniqueness factor (bytes32). */ + userSalt: Hex + /** Runtime bytecode placed at the account address. */ + code: Hex + /** + * Initial actors registered at creation. MUST be sorted by `actorId` in + * strictly ascending order (required for deterministic address derivation). + */ + initialActors: readonly AaActor[] +} + +/** `authorizeActor` (change type `0x01`) operation within a config-change entry. */ +export type AaAuthorizeActor = { + changeType: 0x01 + /** 32-byte actor identifier. */ + actorId: Hex + /** Authenticator contract address. */ + authenticator: Address + /** Permission bitmask. `0` (or omitted) = unrestricted. */ + scope?: number | undefined + /** Actor expiry (unix seconds). `0` (or omitted) = no expiry. */ + expiry?: bigint | undefined + /** Policy type. `0` (or omitted) = no policy. */ + policyType?: number | undefined + /** Policy data (`manager || commitment` when `policyType != 0`). */ + policyData?: Hex | undefined +} + +/** `revokeActor` (change type `0x02`) operation within a config-change entry. */ +export type AaRevokeActor = { + changeType: 0x02 + /** 32-byte actor identifier. */ + actorId: Hex +} + +export type AaActorChange = AaAuthorizeActor | AaRevokeActor + +/** `config` (type `0x01`) account-change entry: actor management. */ +export type AaAccountChangeConfig = { + type: 'config' + /** Chain ID scope. `0` = valid on any chain (multichain channel). */ + chainId: number + /** Monotonic ordering sequence within the channel. */ + sequence: number + /** Actor change operations. */ + actorChanges: readonly AaActorChange[] + /** Authorization signature (`authenticator || data`). */ + auth: Hex +} + +/** `delegation` (type `0x02`) account-change entry: code delegation. */ +export type AaAccountChangeDelegation = { + type: 'delegation' + /** Delegate target, or the zero address to clear delegation. */ + target: Address +} + +export type AaAccountChange = + | AaAccountChangeCreate + | AaAccountChangeConfig + | AaAccountChangeDelegation + +/** + * An EIP-8130 (`AA_TX_TYPE`) serializable transaction. + * + * The wire format is: + * + * ``` + * AA_TX_TYPE || rlp([ + * chain_id, sender, nonce_key, nonce_sequence, expiry, + * max_priority_fee_per_gas, max_fee_per_gas, gas_limit, + * account_changes, calls, payer, sender_auth, payer_auth + * ]) + * ``` + */ +export type TransactionSerializable8130 = { + /** Chain ID per EIP-155. */ + chainId: number + /** + * Sending account address (the wire `sender` field). Omit (EOA path) to have + * the address recovered from `senderAuth` via ecrecover; set it for configured + * actor signatures. + */ + from?: Address | undefined + /** Nonce channel selector (`uint256`). `0` = standard sequential ordering. */ + nonceKey?: bigint | undefined + /** Expected sequence number within `nonceKey` (`uint64`). */ + nonceSequence?: bigint | undefined + /** Unix timestamp (seconds) after which the transaction is invalid. `0` = no expiry. */ + expiry?: bigint | undefined + /** Max priority fee per gas (EIP-1559). */ + maxPriorityFeePerGas?: bigint | undefined + /** Max fee per gas (EIP-1559). */ + maxFeePerGas?: bigint | undefined + /** Gas budget for sender-intrinsic gas and call execution (`gas_limit`). */ + gas?: bigint | undefined + /** Account creation, config change, and/or delegation operations. */ + accountChanges?: readonly AaAccountChange[] | undefined + /** Ordered call phases. */ + calls?: AaCalls | undefined + /** Gas payer. Omit for self-pay; set to a 20-byte address for sponsored. */ + payer?: Address | undefined + /** + * Sender authorization. EOA path: raw 65-byte ECDSA signature. Configured + * actor: `authenticator (20 bytes) || data`. + */ + senderAuth?: Hex | undefined + /** + * Payer authorization. Omit for self-pay; otherwise `authenticator || data` + * (same format as `senderAuth`). + */ + payerAuth?: Hex | undefined +} + +/** A serialized EIP-8130 transaction (hex envelope). */ +export type TransactionSerialized8130 = Hex diff --git a/src/experimental/eip8130/utils/assertTransaction.ts b/src/experimental/eip8130/utils/assertTransaction.ts new file mode 100644 index 0000000000..f0b4325076 --- /dev/null +++ b/src/experimental/eip8130/utils/assertTransaction.ts @@ -0,0 +1,41 @@ +import { BaseError } from '../../../errors/base.js' +import { InvalidChainIdError } from '../../../errors/chain.js' +import type { ErrorType } from '../../../errors/utils.js' +import { nonceKeyMax } from '../constants.js' +import type { TransactionSerializable8130 } from '../types/transaction.js' + +export type AssertTransaction8130ErrorType = + | InvalidChainIdError + | BaseError + | ErrorType + +/** + * Validates the structural invariants of an EIP-8130 transaction prior to + * serialization or hashing. + */ +export function assertTransaction8130( + transaction: TransactionSerializable8130, +): void { + const { chainId, nonceKey, nonceSequence, expiry, payer, payerAuth } = + transaction + + if (chainId <= 0) throw new InvalidChainIdError({ chainId }) + + // Nonce-free mode (`NONCE_KEY_MAX`): sequence must be 0 and expiry non-zero. + if (typeof nonceKey === 'bigint' && nonceKey === nonceKeyMax) { + if (nonceSequence !== undefined && nonceSequence !== 0n) + throw new BaseError( + '`nonceSequence` must be `0n` when `nonceKey` is `nonceKeyMax` (nonce-free mode).', + ) + if (!expiry || expiry === 0n) + throw new BaseError( + '`expiry` must be non-zero when `nonceKey` is `nonceKeyMax` (nonce-free mode).', + ) + } + + // Self-pay (no `payer`) must not carry a `payerAuth`. + if (!payer && payerAuth && payerAuth !== '0x') + throw new BaseError( + '`payerAuth` must be empty for self-pay transactions (no `payer` set).', + ) +} diff --git a/src/experimental/eip8130/utils/hashTransaction.ts b/src/experimental/eip8130/utils/hashTransaction.ts new file mode 100644 index 0000000000..e9d3e0abc9 --- /dev/null +++ b/src/experimental/eip8130/utils/hashTransaction.ts @@ -0,0 +1,99 @@ +import type { ErrorType } from '../../../errors/utils.js' +import type { ByteArray, Hex } from '../../../types/misc.js' +import { + type ConcatHexErrorType, + concatHex, +} from '../../../utils/data/concat.js' +import { + type HexToBytesErrorType, + hexToBytes, +} from '../../../utils/encoding/toBytes.js' +import { toRlp } from '../../../utils/encoding/toRlp.js' +import { + type Keccak256ErrorType, + keccak256, +} from '../../../utils/hash/keccak256.js' +import { aaPayerType, aaTransactionType } from '../constants.js' +import type { TransactionSerializable8130 } from '../types/transaction.js' +import { toTransactionBody } from './serializeTransaction.js' + +type To = 'hex' | 'bytes' + +export type GetSignatureHash8130Parameters = + TransactionSerializable8130 & { + /** Output format. @default 'hex' */ + to?: to | To | undefined + } + +export type GetSignatureHash8130ReturnType = + | (to extends 'bytes' ? ByteArray : never) + | (to extends 'hex' ? Hex : never) + +export type GetSenderSignatureHash8130ErrorType = + | Keccak256ErrorType + | ConcatHexErrorType + | HexToBytesErrorType + | ErrorType + +/** + * Computes the EIP-8130 **sender** signature hash — all transaction fields + * through `payer`, excluding `sender_auth` and `payer_auth`: + * + * ``` + * keccak256(AA_TX_TYPE || rlp([ + * chain_id, from, nonce_key, nonce_sequence, expiry, + * max_priority_fee_per_gas, max_fee_per_gas, gas_limit, + * account_changes, calls, payer + * ])) + * ``` + */ +export function getSenderSignatureHash8130( + parameters: GetSignatureHash8130Parameters, +): GetSignatureHash8130ReturnType { + const { to = 'hex', payer } = parameters + const hash = keccak256( + concatHex([ + aaTransactionType, + toRlp([...toTransactionBody(parameters), payer ?? '0x']), + ]), + ) + if (to === 'bytes') + return hexToBytes(hash) as GetSignatureHash8130ReturnType + return hash as GetSignatureHash8130ReturnType +} + +export type GetPayerSignatureHash8130ErrorType = + | Keccak256ErrorType + | ConcatHexErrorType + | HexToBytesErrorType + | ErrorType + +/** + * Computes the EIP-8130 **payer** signature hash — all transaction fields + * through `calls`, excluding `payer`, `sender_auth`, and `payer_auth`: + * + * ``` + * keccak256(AA_PAYER_TYPE || rlp([ + * chain_id, from, nonce_key, nonce_sequence, expiry, + * max_priority_fee_per_gas, max_fee_per_gas, gas_limit, + * account_changes, calls + * ])) + * ``` + * + * @remarks + * `from` MUST be the **resolved** sender address. In the EOA path (`from` + * omitted from the wire format) the recovered sender address MUST be set on + * `parameters.from` before computing this hash, to bind the payer's signature to + * the specific sender and prevent cross-sender replay. + */ +export function getPayerSignatureHash8130( + parameters: GetSignatureHash8130Parameters, +): GetSignatureHash8130ReturnType { + const { to = 'hex' } = parameters + const hash = keccak256( + concatHex([aaPayerType, toRlp(toTransactionBody(parameters))]), + ) + if (to === 'bytes') + return hexToBytes(hash) as GetSignatureHash8130ReturnType + return hash as GetSignatureHash8130ReturnType +} diff --git a/src/experimental/eip8130/utils/parseTransaction.ts b/src/experimental/eip8130/utils/parseTransaction.ts new file mode 100644 index 0000000000..cf01dfb88c --- /dev/null +++ b/src/experimental/eip8130/utils/parseTransaction.ts @@ -0,0 +1,180 @@ +import type { Address } from 'abitype' +import { BaseError } from '../../../errors/base.js' +import type { ErrorType } from '../../../errors/utils.js' +import type { Hex } from '../../../types/misc.js' +import { type SliceErrorType, sliceHex } from '../../../utils/data/slice.js' +import { + type HexToBigIntErrorType, + type HexToNumberErrorType, + hexToBigInt, + hexToNumber, +} from '../../../utils/encoding/fromHex.js' +import { + type FromRlpErrorType, + fromRlp, +} from '../../../utils/encoding/fromRlp.js' +import type { RecursiveArray } from '../../../utils/encoding/toRlp.js' +import { + aaTransactionType, + accountChangeType, + actorChangeType, +} from '../constants.js' +import type { + AaAccountChange, + AaActor, + AaActorChange, + AaCalls, + TransactionSerializable8130, +} from '../types/transaction.js' + +export type ParseTransaction8130ErrorType = + | SliceErrorType + | FromRlpErrorType + | HexToBigIntErrorType + | HexToNumberErrorType + | ErrorType + +type RlpHex = RecursiveArray + +function toOptionalAddress(value: Hex): Address | undefined { + return value === '0x' ? undefined : (value as Address) +} + +function toOptionalBigInt(value: Hex): bigint | undefined { + return value === '0x' ? undefined : hexToBigInt(value) +} + +function parseCalls(value: RlpHex): AaCalls { + const phases = value as RlpHex[] + return phases.map((phase) => + (phase as RlpHex[]).map((call) => { + const [to, data] = call as Hex[] + return { to: to as Address, data: data === '0x' ? undefined : data } + }), + ) +} + +function parseActor(value: RlpHex): AaActor { + const [actorId, authenticator] = value as Hex[] + return { actorId, authenticator: authenticator as Address } +} + +function parseActorChange(value: RlpHex): AaActorChange { + const [changeType, actorId, data] = value as [Hex, Hex, Hex] + const type = changeType === '0x' ? 0 : hexToNumber(changeType) + if (type === actorChangeType.authorizeActor) { + const [authenticator, scope, expiry, policyType, policyData] = fromRlp( + data, + 'hex', + ) as Hex[] + const change: AaActorChange = { + changeType: actorChangeType.authorizeActor, + actorId, + authenticator: authenticator as Address, + } + if (scope !== '0x') change.scope = hexToNumber(scope) + if (expiry !== '0x') change.expiry = hexToBigInt(expiry) + if (policyType !== '0x') change.policyType = hexToNumber(policyType) + if (policyData !== '0x') change.policyData = policyData + return change + } + return { changeType: actorChangeType.revokeActor, actorId } +} + +function parseAccountChanges(value: RlpHex): readonly AaAccountChange[] { + const entries = value as RlpHex[] + return entries.map((entry): AaAccountChange => { + const fields = entry as RlpHex[] + const type = fields[0] as Hex + if (type === accountChangeType.create) { + const [, userSalt, code, actors] = fields + return { + type: 'create', + userSalt: userSalt as Hex, + code: code as Hex, + initialActors: (actors as RlpHex[]).map(parseActor), + } + } + if (type === accountChangeType.config) { + const [, chainId, sequence, actorChanges, auth] = fields + return { + type: 'config', + chainId: chainId === '0x' ? 0 : hexToNumber(chainId as Hex), + sequence: sequence === '0x' ? 0 : hexToNumber(sequence as Hex), + actorChanges: (actorChanges as RlpHex[]).map(parseActorChange), + auth: auth as Hex, + } + } + if (type === accountChangeType.delegation) { + const [, target] = fields + return { type: 'delegation', target: target as Address } + } + throw new BaseError(`Unknown account change entry type: "${type}".`) + }) +} + +/** + * Parses a serialized EIP-8130 (`AA_TX_TYPE`) transaction back into a + * {@link TransactionSerializable8130}. + */ +export function parseTransaction8130( + serialized: Hex, +): TransactionSerializable8130 { + const type = sliceHex(serialized, 0, 1) + if (type !== aaTransactionType) + throw new BaseError( + `Serialized transaction type "${type}" is not an EIP-8130 transaction.`, + ) + + const fields = fromRlp(sliceHex(serialized, 1), 'hex') as RlpHex[] + const [ + chainId, + from, + nonceKey, + nonceSequence, + expiry, + maxPriorityFeePerGas, + maxFeePerGas, + gas, + accountChanges, + calls, + payer, + senderAuth, + payerAuth, + ] = fields + + const transaction: TransactionSerializable8130 = { + chainId: hexToNumber(chainId as Hex), + } + + const fromAddress = toOptionalAddress(from as Hex) + if (fromAddress) transaction.from = fromAddress + const nonceKeyValue = toOptionalBigInt(nonceKey as Hex) + if (nonceKeyValue !== undefined) transaction.nonceKey = nonceKeyValue + const nonceSequenceValue = toOptionalBigInt(nonceSequence as Hex) + if (nonceSequenceValue !== undefined) + transaction.nonceSequence = nonceSequenceValue + const expiryValue = toOptionalBigInt(expiry as Hex) + if (expiryValue !== undefined) transaction.expiry = expiryValue + const maxPriorityFeePerGasValue = toOptionalBigInt( + maxPriorityFeePerGas as Hex, + ) + if (maxPriorityFeePerGasValue !== undefined) + transaction.maxPriorityFeePerGas = maxPriorityFeePerGasValue + const maxFeePerGasValue = toOptionalBigInt(maxFeePerGas as Hex) + if (maxFeePerGasValue !== undefined) + transaction.maxFeePerGas = maxFeePerGasValue + const gasValue = toOptionalBigInt(gas as Hex) + if (gasValue !== undefined) transaction.gas = gasValue + + if ((accountChanges as RlpHex[]).length > 0) + transaction.accountChanges = parseAccountChanges(accountChanges) + if ((calls as RlpHex[]).length > 0) transaction.calls = parseCalls(calls) + + const payerAddress = toOptionalAddress(payer as Hex) + if (payerAddress) transaction.payer = payerAddress + if ((senderAuth as Hex) !== '0x') transaction.senderAuth = senderAuth as Hex + if ((payerAuth as Hex) !== '0x') transaction.payerAuth = payerAuth as Hex + + return transaction +} diff --git a/src/experimental/eip8130/utils/serializeTransaction.test.ts b/src/experimental/eip8130/utils/serializeTransaction.test.ts new file mode 100644 index 0000000000..5b1fb3ac53 --- /dev/null +++ b/src/experimental/eip8130/utils/serializeTransaction.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, test } from 'vitest' +import { keccak256 } from '../../../utils/hash/keccak256.js' +import { aaPayerType, aaTransactionType, nonceKeyMax } from '../constants.js' +import type { TransactionSerializable8130 } from '../types/transaction.js' +import { + getPayerSignatureHash8130, + getSenderSignatureHash8130, +} from './hashTransaction.js' +import { parseTransaction8130 } from './parseTransaction.js' +import { serializeTransaction8130 } from './serializeTransaction.js' + +const alice = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const +const bob = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as const +const payer = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' as const +const token = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const + +const senderAuth = + '0x1111111111111111111111111111111111111111111111111111111111111111' as const +const payerAuth = + '0x2222222222222222222222222222222222222222222222222222222222222222' as const + +describe('serializeTransaction (EIP-8130)', () => { + test('self-pay: simple call', () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: alice, + nonceSequence: 3n, + maxPriorityFeePerGas: 1_000_000_000n, + maxFeePerGas: 2_000_000_000n, + gas: 100_000n, + calls: [[{ to: bob, data: '0xdeadbeef' }]], + senderAuth, + } + const serialized = serializeTransaction8130(transaction) + expect(serialized.startsWith(aaTransactionType)).toBe(true) + // canonical codec round-trip (addresses are returned lowercase, matching viem) + expect(serializeTransaction8130(parseTransaction8130(serialized))).toEqual( + serialized, + ) + expect(parseTransaction8130(serialized)).toMatchObject({ + chainId: 8453, + nonceSequence: 3n, + senderAuth, + }) + }) + + test('sponsored: payer + payerAuth', () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: alice, + nonceKey: 7n, + nonceSequence: 1n, + expiry: 1_900_000_000n, + maxPriorityFeePerGas: 1n, + maxFeePerGas: 2n, + gas: 50_000n, + calls: [[{ to: token, data: '0xabcd' }], [{ to: bob }]], + payer, + senderAuth, + payerAuth, + } + const serialized = serializeTransaction8130(transaction) + expect(serializeTransaction8130(parseTransaction8130(serialized))).toEqual( + serialized, + ) + }) + + test('EOA path: no from', () => { + const transaction: TransactionSerializable8130 = { + chainId: 1, + maxFeePerGas: 2n, + calls: [[{ to: bob, data: '0x' }]], + senderAuth, + } + const serialized = serializeTransaction8130(transaction) + const parsed = parseTransaction8130(serialized) + expect(parsed.from).toBeUndefined() + // re-serialization is stable + expect(serializeTransaction8130(parsed)).toEqual(serialized) + }) + + test('account changes: create + delegation', () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: alice, + maxFeePerGas: 2n, + accountChanges: [ + { + type: 'create', + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + code: '0x6080', + initialActors: [ + { + actorId: + '0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8', + authenticator: '0x0000000000000000000000000000000000000001', + }, + ], + }, + { type: 'delegation', target: bob }, + ], + calls: [[{ to: alice, data: '0x' }]], + senderAuth, + } + const serialized = serializeTransaction8130(transaction) + expect(serializeTransaction8130(parseTransaction8130(serialized))).toEqual( + serialized, + ) + }) + + test('account changes: config (actor management)', () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: alice, + maxFeePerGas: 2n, + accountChanges: [ + { + type: 'config', + chainId: 0, + sequence: 5, + actorChanges: [ + { + changeType: 0x01, + actorId: + '0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc', + authenticator: '0x0000000000000000000000000000000000000001', + scope: 0x04, + expiry: 1_900_000_000n, + policyType: 0x01, + policyData: '0xc0ffee', + }, + { + changeType: 0x02, + actorId: + '0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266', + }, + ], + auth: '0xfeed', + }, + ], + senderAuth, + } + const serialized = serializeTransaction8130(transaction) + expect(serializeTransaction8130(parseTransaction8130(serialized))).toEqual( + serialized, + ) + // structural round-trip on the parsed actor changes + const parsed = parseTransaction8130(serialized) + const config = parsed.accountChanges?.[0] + expect(config).toMatchObject({ type: 'config', chainId: 0, sequence: 5 }) + }) + + test('nonce-free mode (nonceKeyMax)', () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: alice, + nonceKey: nonceKeyMax, + expiry: 1_900_000_000n, + maxFeePerGas: 2n, + calls: [[{ to: bob }]], + senderAuth, + } + const serialized = serializeTransaction8130(transaction) + expect(serializeTransaction8130(parseTransaction8130(serialized))).toEqual( + serialized, + ) + }) +}) + +describe('assertions', () => { + test('rejects invalid chainId', () => { + expect(() => + serializeTransaction8130({ chainId: 0, senderAuth }), + ).toThrowError() + }) + + test('nonce-free mode requires expiry', () => { + expect(() => + serializeTransaction8130({ + chainId: 1, + nonceKey: nonceKeyMax, + senderAuth, + }), + ).toThrowError() + }) + + test('nonce-free mode rejects non-zero sequence', () => { + expect(() => + serializeTransaction8130({ + chainId: 1, + nonceKey: nonceKeyMax, + nonceSequence: 1n, + expiry: 1_900_000_000n, + senderAuth, + }), + ).toThrowError() + }) + + test('self-pay rejects payerAuth', () => { + expect(() => + serializeTransaction8130({ chainId: 1, senderAuth, payerAuth }), + ).toThrowError() + }) +}) + +describe('signature hashes', () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: alice, + nonceSequence: 3n, + maxFeePerGas: 2n, + gas: 100_000n, + calls: [[{ to: bob, data: '0xdeadbeef' }]], + payer, + } + + test('sender hash is domain-separated from payer hash', () => { + const senderHash = getSenderSignatureHash8130(transaction) + const payerHash = getPayerSignatureHash8130(transaction) + expect(senderHash).not.toEqual(payerHash) + expect(senderHash).toMatch(/^0x[0-9a-f]{64}$/) + expect(payerHash).toMatch(/^0x[0-9a-f]{64}$/) + }) + + test('sender hash binds the payer field', () => { + const withPayer = getSenderSignatureHash8130(transaction) + const withoutPayer = getSenderSignatureHash8130({ + ...transaction, + payer: undefined, + }) + expect(withPayer).not.toEqual(withoutPayer) + }) + + test('payer hash excludes the payer field', () => { + const a = getPayerSignatureHash8130(transaction) + const b = getPayerSignatureHash8130({ ...transaction, payer: undefined }) + expect(a).toEqual(b) + }) + + test('uses the correct domain-separation type bytes', () => { + expect(aaTransactionType).not.toEqual(aaPayerType) + // bytes output supported + const bytes = getSenderSignatureHash8130({ ...transaction, to: 'bytes' }) + expect(bytes).toBeInstanceOf(Uint8Array) + expect(keccak256(bytes)).toMatch(/^0x/) + }) +}) diff --git a/src/experimental/eip8130/utils/serializeTransaction.ts b/src/experimental/eip8130/utils/serializeTransaction.ts new file mode 100644 index 0000000000..ca90a65abd --- /dev/null +++ b/src/experimental/eip8130/utils/serializeTransaction.ts @@ -0,0 +1,150 @@ +import type { ErrorType } from '../../../errors/utils.js' +import type { Hex } from '../../../types/misc.js' +import { + type ConcatHexErrorType, + concatHex, +} from '../../../utils/data/concat.js' +import { + type NumberToHexErrorType, + numberToHex, +} from '../../../utils/encoding/toHex.js' +import { + type RecursiveArray, + type ToRlpErrorType, + toRlp, +} from '../../../utils/encoding/toRlp.js' +import { + aaTransactionType, + accountChangeType, + actorChangeType, +} from '../constants.js' +import type { + AaAccountChange, + AaActorChange, + AaCalls, + TransactionSerializable8130, + TransactionSerialized8130, +} from '../types/transaction.js' +import { + type AssertTransaction8130ErrorType, + assertTransaction8130, +} from './assertTransaction.js' + +/** Encodes the `calls` field into a nested RLP-ready array. */ +export function toCallsList(calls: AaCalls | undefined): RecursiveArray[] { + return (calls ?? []).map((phase) => + phase.map((call) => [call.to, call.data ?? '0x']), + ) +} + +/** + * Encodes a single `actor_change` operation. The operation-specific `data` is an + * opaque bytes field containing a nested RLP encoding. + */ +function toActorChange(change: AaActorChange): RecursiveArray { + if (change.changeType === actorChangeType.authorizeActor) { + const data = toRlp([ + change.authenticator, + change.scope ? numberToHex(change.scope) : '0x', + change.expiry ? numberToHex(change.expiry) : '0x', + change.policyType ? numberToHex(change.policyType) : '0x', + change.policyData ?? '0x', + ]) + return [numberToHex(change.changeType), change.actorId, data] + } + // revokeActor: empty data (`rlp([])`) + return [numberToHex(change.changeType), change.actorId, toRlp([])] +} + +/** Encodes the `account_changes` field into a nested RLP-ready array. */ +export function toAccountChangesList( + accountChanges: readonly AaAccountChange[] | undefined, +): RecursiveArray[] { + return (accountChanges ?? []).map((entry): RecursiveArray => { + if (entry.type === 'create') + return [ + accountChangeType.create, + entry.userSalt, + entry.code, + entry.initialActors.map((actor) => [ + actor.actorId, + actor.authenticator, + ]), + ] + if (entry.type === 'config') + return [ + accountChangeType.config, + entry.chainId ? numberToHex(entry.chainId) : '0x', + entry.sequence ? numberToHex(entry.sequence) : '0x', + entry.actorChanges.map(toActorChange), + entry.auth, + ] + return [accountChangeType.delegation, entry.target] + }) +} + +/** + * Returns the RLP-ready field array for the transaction body **through `calls`** + * (i.e. excluding `payer`, `sender_auth`, and `payer_auth`). This is the body + * used for the payer signature hash, and the prefix shared by the sender hash + * and the full envelope. + */ +export function toTransactionBody( + transaction: TransactionSerializable8130, +): RecursiveArray[] { + const { + chainId, + from, + nonceKey, + nonceSequence, + expiry, + maxPriorityFeePerGas, + maxFeePerGas, + gas, + accountChanges, + calls, + } = transaction + return [ + numberToHex(chainId), + from ?? '0x', + nonceKey ? numberToHex(nonceKey) : '0x', + nonceSequence ? numberToHex(nonceSequence) : '0x', + expiry ? numberToHex(expiry) : '0x', + maxPriorityFeePerGas ? numberToHex(maxPriorityFeePerGas) : '0x', + maxFeePerGas ? numberToHex(maxFeePerGas) : '0x', + gas ? numberToHex(gas) : '0x', + toAccountChangesList(accountChanges), + toCallsList(calls), + ] +} + +export type SerializeTransaction8130ErrorType = + | AssertTransaction8130ErrorType + | ConcatHexErrorType + | NumberToHexErrorType + | ToRlpErrorType + | ErrorType + +/** + * Serializes an EIP-8130 (`AA_TX_TYPE`) transaction into its EIP-2718 envelope. + * + * Requires `senderAuth`. For sponsored transactions, also provide `payer` and + * `payerAuth`; omit both for self-pay. + */ +export function serializeTransaction8130( + transaction: TransactionSerializable8130, +): TransactionSerialized8130 { + assertTransaction8130(transaction) + + const { payer, senderAuth, payerAuth } = transaction + + return concatHex([ + aaTransactionType, + toRlp([ + ...toTransactionBody(transaction), + payer ?? '0x', + senderAuth ?? '0x', + payerAuth ?? '0x', + ]), + ]) as TransactionSerialized8130 +} diff --git a/src/package.json b/src/package.json index 9a1167b297..f4f3c338a2 100644 --- a/src/package.json +++ b/src/package.json @@ -64,6 +64,11 @@ "import": "./_esm/experimental/index.js", "default": "./_cjs/experimental/index.js" }, + "./experimental/eip8130": { + "types": "./_types/experimental/eip8130/index.d.ts", + "import": "./_esm/experimental/eip8130/index.js", + "default": "./_cjs/experimental/eip8130/index.js" + }, "./experimental/erc7739": { "types": "./_types/experimental/erc7739/index.d.ts", "import": "./_esm/experimental/erc7739/index.js", From c793f86db893f35fbd1ca06101fc87442a22ff27 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 9 Jun 2026 14:51:12 -0400 Subject: [PATCH 02/96] feat(experimental): add EIP-8130 transaction signing Adds signTransaction8130 producing sender_auth (EOA raw signature or ECRECOVER_AUTHENTICATOR || signature for configured actors) and, for sponsored transactions, payer_auth bound to the resolved sender. --- src/experimental/eip8130/index.ts | 7 +- .../eip8130/utils/signTransaction.test.ts | 135 ++++++++++++++++++ .../eip8130/utils/signTransaction.ts | 110 ++++++++++++++ 3 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/experimental/eip8130/utils/signTransaction.test.ts create mode 100644 src/experimental/eip8130/utils/signTransaction.ts diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 518cb1dd16..f9d51f95c4 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -46,7 +46,6 @@ export { type ParseTransaction8130ErrorType, parseTransaction8130, } from './utils/parseTransaction.js' - export { type SerializeTransaction8130ErrorType, serializeTransaction8130, @@ -54,3 +53,9 @@ export { toCallsList, toTransactionBody, } from './utils/serializeTransaction.js' +export { + type Signer, + type SignTransaction8130ErrorType, + type SignTransaction8130Parameters, + signTransaction8130, +} from './utils/signTransaction.js' diff --git a/src/experimental/eip8130/utils/signTransaction.test.ts b/src/experimental/eip8130/utils/signTransaction.test.ts new file mode 100644 index 0000000000..e9166f8335 --- /dev/null +++ b/src/experimental/eip8130/utils/signTransaction.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from 'vitest' +import { accounts } from '~test/constants.js' +import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' +import { sliceHex } from '../../../utils/data/slice.js' +import { recoverAddress } from '../../../utils/signature/recoverAddress.js' +import { ecrecoverAuthenticator } from '../constants.js' +import type { TransactionSerializable8130 } from '../types/transaction.js' +import { + getPayerSignatureHash8130, + getSenderSignatureHash8130, +} from './hashTransaction.js' +import { parseTransaction8130 } from './parseTransaction.js' +import { signTransaction8130 } from './signTransaction.js' + +const sender = privateKeyToAccount(accounts[0].privateKey) +const sponsor = privateKeyToAccount(accounts[1].privateKey) +const bob = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const + +describe('signTransaction (EIP-8130)', () => { + test('EOA path: raw 65-byte sender_auth recovers sender', async () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + nonceSequence: 1n, + maxFeePerGas: 2n, + gas: 100_000n, + calls: [[{ to: bob, data: '0xdeadbeef' }]], + } + const serialized = await signTransaction8130({ + transaction, + account: sender, + }) + const parsed = parseTransaction8130(serialized) + + expect(parsed.from).toBeUndefined() + expect(parsed.senderAuth).toBeDefined() + // raw 65-byte signature + expect(sliceHex(parsed.senderAuth!).length).toBe(2 + 65 * 2) + + const hash = getSenderSignatureHash8130(parsed) + const recovered = await recoverAddress({ + hash, + signature: parsed.senderAuth!, + }) + expect(recovered.toLowerCase()).toBe(sender.address.toLowerCase()) + }) + + test('configured-actor path: ECRECOVER_AUTHENTICATOR || signature', async () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: sender.address, + nonceSequence: 1n, + maxFeePerGas: 2n, + calls: [[{ to: bob }]], + } + const serialized = await signTransaction8130({ + transaction, + account: sender, + }) + const parsed = parseTransaction8130(serialized) + + expect(parsed.from?.toLowerCase()).toBe(sender.address.toLowerCase()) + // first 20 bytes are the authenticator + expect(sliceHex(parsed.senderAuth!, 0, 20)).toBe(ecrecoverAuthenticator) + + const hash = getSenderSignatureHash8130(parsed) + const recovered = await recoverAddress({ + hash, + signature: sliceHex(parsed.senderAuth!, 20), + }) + expect(recovered.toLowerCase()).toBe(sender.address.toLowerCase()) + }) + + test('sponsored: payer_auth recovers sponsor, bound to resolved sender', async () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + nonceSequence: 1n, + maxFeePerGas: 2n, + gas: 50_000n, + calls: [[{ to: bob }]], + } + const serialized = await signTransaction8130({ + transaction, + account: sender, + payer: { account: sponsor }, + }) + const parsed = parseTransaction8130(serialized) + + expect(parsed.payer?.toLowerCase()).toBe(sponsor.address.toLowerCase()) + expect(sliceHex(parsed.payerAuth!, 0, 20)).toBe(ecrecoverAuthenticator) + + // payer hash binds the resolved (recovered) sender address + const payerHash = getPayerSignatureHash8130({ + ...parsed, + from: sender.address, + }) + const recovered = await recoverAddress({ + hash: payerHash, + signature: sliceHex(parsed.payerAuth!, 20), + }) + expect(recovered.toLowerCase()).toBe(sponsor.address.toLowerCase()) + }) + + test('explicit payer address overrides default', async () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: sender.address, + maxFeePerGas: 2n, + calls: [[{ to: bob }]], + } + const serialized = await signTransaction8130({ + transaction, + account: sender, + payer: { account: sponsor, address: sponsor.address }, + }) + const parsed = parseTransaction8130(serialized) + expect(parsed.payer?.toLowerCase()).toBe(sponsor.address.toLowerCase()) + }) + + test('throws without sender account or preset senderAuth', async () => { + await expect( + signTransaction8130({ + transaction: { chainId: 1, maxFeePerGas: 2n }, + }), + ).rejects.toThrowError() + }) + + test('preset senderAuth skips signing', async () => { + const senderAuth = `0x${'11'.repeat(65)}` as const + const serialized = await signTransaction8130({ + transaction: { chainId: 1, maxFeePerGas: 2n, senderAuth }, + }) + const parsed = parseTransaction8130(serialized) + expect(parsed.senderAuth).toBe(senderAuth) + }) +}) diff --git a/src/experimental/eip8130/utils/signTransaction.ts b/src/experimental/eip8130/utils/signTransaction.ts new file mode 100644 index 0000000000..2a2eddaf87 --- /dev/null +++ b/src/experimental/eip8130/utils/signTransaction.ts @@ -0,0 +1,110 @@ +import type { Address } from 'abitype' +import type { LocalAccount } from '../../../accounts/types.js' +import { BaseError } from '../../../errors/base.js' +import type { ErrorType } from '../../../errors/utils.js' +import { + type ConcatHexErrorType, + concatHex, +} from '../../../utils/data/concat.js' +import { ecrecoverAuthenticator } from '../constants.js' +import type { + TransactionSerializable8130, + TransactionSerialized8130, +} from '../types/transaction.js' +import { + type GetPayerSignatureHash8130ErrorType, + type GetSenderSignatureHash8130ErrorType, + getPayerSignatureHash8130, + getSenderSignatureHash8130, +} from './hashTransaction.js' +import { + type SerializeTransaction8130ErrorType, + serializeTransaction8130, +} from './serializeTransaction.js' + +/** A signer capable of producing a raw secp256k1 signature over a hash. */ +export type Signer = Pick & { + sign?: + | ((parameters: { hash: `0x${string}` }) => Promise<`0x${string}`>) + | undefined +} + +export type SignTransaction8130Parameters = { + transaction: TransactionSerializable8130 + /** + * Sender signer (secp256k1). Used to produce `sender_auth` over the sender + * signature hash. Not required if `transaction.senderAuth` is already set. + * + * - EOA path (`transaction.from` unset): `sender_auth` is the raw 65-byte + * signature and the recovered address is the sender. + * - Configured-actor path (`transaction.from` set): `sender_auth` is + * `ECRECOVER_AUTHENTICATOR || signature`. + */ + account?: Signer | undefined + /** + * Payer signer for sponsored transactions (secp256k1). Produces `payer_auth` + * as `ECRECOVER_AUTHENTICATOR || signature` over the payer signature hash. + */ + payer?: + | { + account: Signer + /** The `payer` wire address. Defaults to the payer account's address. */ + address?: Address | undefined + } + | undefined +} + +export type SignTransaction8130ErrorType = + | GetSenderSignatureHash8130ErrorType + | GetPayerSignatureHash8130ErrorType + | SerializeTransaction8130ErrorType + | ConcatHexErrorType + | ErrorType + +/** + * Signs an EIP-8130 (`AA_TX_TYPE`) transaction. + * + * Produces `sender_auth` (and, for sponsored transactions, `payer_auth`) using + * native secp256k1 signers, then returns the serialized envelope. For custom + * authenticators, set `transaction.senderAuth` / `transaction.payerAuth` + * directly and the corresponding signer is skipped. + */ +export async function signTransaction8130( + parameters: SignTransaction8130Parameters, +): Promise { + const { account, payer } = parameters + const transaction: TransactionSerializable8130 = { ...parameters.transaction } + + // Sender authorization. + if (!transaction.senderAuth) { + if (!account?.sign) + throw new BaseError( + 'A sender `account` with raw signing support, or a preset `transaction.senderAuth`, is required.', + ) + const senderHash = getSenderSignatureHash8130(transaction) + const signature = await account.sign({ hash: senderHash }) + transaction.senderAuth = transaction.from + ? // Configured actor: ECRECOVER_AUTHENTICATOR || (r || s || v) + concatHex([ecrecoverAuthenticator, signature]) + : // EOA path: raw 65-byte signature + signature + } + + // Sponsored: bind the payer over the resolved sender. + if (payer && !transaction.payer) + transaction.payer = payer.address ?? payer.account.address + + if (transaction.payer && !transaction.payerAuth) { + if (!payer?.account.sign) + throw new BaseError( + 'A `payer` signer with raw signing support, or a preset `transaction.payerAuth`, is required for sponsored transactions.', + ) + // The payer hash MUST bind to the resolved sender address. + const from = transaction.from ?? account?.address + const payerHash = getPayerSignatureHash8130({ ...transaction, from }) + const signature = await payer.account.sign({ hash: payerHash }) + transaction.payerAuth = concatHex([ecrecoverAuthenticator, signature]) + } + + return serializeTransaction8130(transaction) +} From 0f854de1cf37dd3867c7d6869b1f9b4ef477de1e Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 9 Jun 2026 14:55:15 -0400 Subject: [PATCH 03/96] feat(experimental): add EIP-8130 CREATE2 account address derivation Adds computeAddress8130 (and the DEPLOYMENT_HEADER builder) for create entries: actors_commitment over sorted [actorId || authenticator], an effective salt over user_salt, and CREATE2 derivation against ACCOUNT_CONFIG_ADDRESS. Address constants are placeholders pending the canonical base/eip-8130 deployment and are overridable per call. --- src/experimental/eip8130/constants.ts | 30 ++++ src/experimental/eip8130/index.ts | 12 +- .../eip8130/utils/computeAddress.test.ts | 120 ++++++++++++++++ .../eip8130/utils/computeAddress.ts | 131 ++++++++++++++++++ 4 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 src/experimental/eip8130/utils/computeAddress.test.ts create mode 100644 src/experimental/eip8130/utils/computeAddress.ts diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts index 03656d298a..c46f39378c 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/experimental/eip8130/constants.ts @@ -71,3 +71,33 @@ export const nonceManagerAddress = /** Transaction Context precompile address (`TX_CONTEXT_ADDRESS`). */ export const txContextAddress = '0x813000000000000000000000000000000000aa02' satisfies Hex + +/** + * Account Configuration system contract address (`ACCOUNT_CONFIG_ADDRESS`), + * used as the CREATE2 deployer for account address derivation. + * + * @remarks + * **Placeholder.** This address is CREATE2-derived at deployment in the + * reference implementation ([base/eip-8130](https://github.com/base/eip-8130)) + * and resolved per-network. Override via the `accountConfigAddress` parameter of + * {@link computeAddress8130} until the canonical value is finalized. + */ +export const accountConfigAddress = + '0x8130000000000000000000000000000000008130' satisfies Hex + +/** + * Default wallet implementation for EOA auto-delegation + * (`DEFAULT_ACCOUNT_ADDRESS`). + * + * @remarks + * **Placeholder.** CREATE2-derived at deployment; see + * {@link accountConfigAddress}. + */ +export const defaultAccountAddress = + '0x8130000000000000000000000000000000000acc' satisfies Hex + +/** Size of the deployment header in bytes (`DEPLOYMENT_HEADER_SIZE`). */ +export const deploymentHeaderSize = 14 + +/** Maximum placed runtime code size for a create entry (EIP-170). */ +export const maxCodeSize = 24576 diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index f9d51f95c4..2a5e15ba9c 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -4,15 +4,18 @@ export { aaPayerType, aaTransactionType, accountChangeType, + accountConfigAddress, actorChangeType, actorScope, + defaultAccountAddress, + deploymentHeaderSize, ecrecoverAuthenticator, + maxCodeSize, nonceKeyMax, nonceManagerAddress, revokedAuthenticator, txContextAddress, } from './constants.js' - export type { AaAccountChange, AaAccountChangeConfig, @@ -27,11 +30,16 @@ export type { TransactionSerializable8130, TransactionSerialized8130, } from './types/transaction.js' - export { type AssertTransaction8130ErrorType, assertTransaction8130, } from './utils/assertTransaction.js' +export { + type ComputeAddress8130ErrorType, + type ComputeAddress8130Parameters, + computeAddress8130, + deploymentHeader, +} from './utils/computeAddress.js' export { type GetPayerSignatureHash8130ErrorType, diff --git a/src/experimental/eip8130/utils/computeAddress.test.ts b/src/experimental/eip8130/utils/computeAddress.test.ts new file mode 100644 index 0000000000..bff79d6014 --- /dev/null +++ b/src/experimental/eip8130/utils/computeAddress.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'vitest' +import { getCreate2Address } from '../../../utils/address/getContractAddress.js' +import { isAddress } from '../../../utils/address/isAddress.js' +import { concatHex } from '../../../utils/data/concat.js' +import { keccak256 } from '../../../utils/hash/keccak256.js' +import { accountConfigAddress } from '../constants.js' +import type { AaActor } from '../types/transaction.js' +import { computeAddress8130, deploymentHeader } from './computeAddress.js' + +const actorA: AaActor = { + actorId: '0x0000000000000000000000000000000000000000000000000000000000000001', + authenticator: '0x0000000000000000000000000000000000000001', +} +const actorB: AaActor = { + actorId: '0x0000000000000000000000000000000000000000000000000000000000000002', + authenticator: '0x0000000000000000000000000000000000000002', +} + +describe('computeAddress (EIP-8130)', () => { + test('deterministic + valid checksum address', () => { + const params = { + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + code: '0x6080604052', + initialActors: [actorA, actorB], + } as const + const address = computeAddress8130(params) + expect(isAddress(address)).toBe(true) + expect(computeAddress8130(params)).toBe(address) + }) + + test('matches manual CREATE2 derivation', () => { + const userSalt = + '0x00000000000000000000000000000000000000000000000000000000000000aa' as const + const code = '0x6080' as const + const actorsCommitment = keccak256( + concatHex([ + actorA.actorId, + actorA.authenticator, + actorB.actorId, + actorB.authenticator, + ]), + ) + const effectiveSalt = keccak256(concatHex([userSalt, actorsCommitment])) + const deploymentCode = concatHex([deploymentHeader(2), code]) + const expected = getCreate2Address({ + from: accountConfigAddress, + salt: effectiveSalt, + bytecode: deploymentCode, + }) + expect( + computeAddress8130({ userSalt, code, initialActors: [actorA, actorB] }), + ).toBe(expected) + }) + + test('different salt yields different address', () => { + const base = { code: '0x6080', initialActors: [actorA] } as const + const a = computeAddress8130({ + ...base, + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }) + const b = computeAddress8130({ + ...base, + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000002', + }) + expect(a).not.toBe(b) + }) + + test('custom accountConfigAddress changes the address', () => { + const base = { + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + code: '0x6080', + initialActors: [actorA], + } as const + const a = computeAddress8130(base) + const b = computeAddress8130({ + ...base, + accountConfigAddress: '0x00000000000000000000000000000000000000ff', + }) + expect(a).not.toBe(b) + }) + + test('deploymentHeader encodes code length into PUSH2 operands', () => { + expect(deploymentHeader(2)).toBe('0x610002600e60003961000260' + '00f3') + expect(deploymentHeader(0x1234)).toBe('0x611234600e6000396112346000f3') + }) + + test('rejects unsorted / duplicate actors', () => { + expect(() => + computeAddress8130({ + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + code: '0x6080', + initialActors: [actorB, actorA], + }), + ).toThrowError() + expect(() => + computeAddress8130({ + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + code: '0x6080', + initialActors: [actorA, actorA], + }), + ).toThrowError() + }) + + test('rejects empty code', () => { + expect(() => + computeAddress8130({ + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + code: '0x', + initialActors: [actorA], + }), + ).toThrowError() + }) +}) diff --git a/src/experimental/eip8130/utils/computeAddress.ts b/src/experimental/eip8130/utils/computeAddress.ts new file mode 100644 index 0000000000..2a6a1726c8 --- /dev/null +++ b/src/experimental/eip8130/utils/computeAddress.ts @@ -0,0 +1,131 @@ +import type { Address } from 'abitype' +import { BaseError } from '../../../errors/base.js' +import type { ErrorType } from '../../../errors/utils.js' +import type { Hex } from '../../../types/misc.js' +import { + type GetCreate2AddressErrorType, + getCreate2Address, +} from '../../../utils/address/getContractAddress.js' +import { + type ConcatHexErrorType, + concatHex, +} from '../../../utils/data/concat.js' +import { size } from '../../../utils/data/size.js' +import { hexToBigInt } from '../../../utils/encoding/fromHex.js' +import { bytesToHex } from '../../../utils/encoding/toHex.js' +import { + type Keccak256ErrorType, + keccak256, +} from '../../../utils/hash/keccak256.js' +import { + accountConfigAddress as defaultAccountConfigAddress, + maxCodeSize, +} from '../constants.js' +import type { AaActor } from '../types/transaction.js' + +/** + * Builds the 14-byte `DEPLOYMENT_HEADER(n)` EVM loader that copies the trailing + * runtime code into memory and returns it. + */ +export function deploymentHeader(codeSize: number): Hex { + const hi = (codeSize >> 8) & 0xff + const lo = codeSize & 0xff + return bytesToHex( + Uint8Array.from([ + 0x61, + hi, + lo, + 0x60, + 0x0e, + 0x60, + 0x00, + 0x39, + 0x61, + hi, + lo, + 0x60, + 0x00, + 0xf3, + ]), + ) +} + +export type ComputeAddress8130Parameters = { + /** User-chosen uniqueness factor (bytes32). */ + userSalt: Hex + /** Runtime bytecode to be placed at the account address. */ + code: Hex + /** + * Initial actors. MUST be sorted by `actorId` in strictly ascending order + * (this also rejects duplicate `actorId`s). + */ + initialActors: readonly AaActor[] + /** + * Account Configuration contract address (CREATE2 deployer). Defaults to the + * placeholder {@link accountConfigAddress} constant. + */ + accountConfigAddress?: Address | undefined +} + +export type ComputeAddress8130ErrorType = + | GetCreate2AddressErrorType + | ConcatHexErrorType + | Keccak256ErrorType + | BaseError + | ErrorType + +/** + * Computes the counterfactual address for an EIP-8130 `create` entry using the + * CREATE2 derivation: + * + * ``` + * actors_commitment = keccak256(actorId_0 || authenticator_0 || ...) + * effective_salt = keccak256(user_salt || actors_commitment) + * deployment_code = DEPLOYMENT_HEADER(len(code)) || code + * address = keccak256(0xff || ACCOUNT_CONFIG_ADDRESS || effective_salt || keccak256(deployment_code))[12:] + * ``` + */ +export function computeAddress8130( + parameters: ComputeAddress8130Parameters, +): Address { + const { + userSalt, + code, + initialActors, + accountConfigAddress = defaultAccountConfigAddress, + } = parameters + + const codeSize = size(code) + if (codeSize === 0) throw new BaseError('`code` must not be empty.') + if (codeSize > maxCodeSize) + throw new BaseError( + `\`code\` exceeds the maximum code size (${maxCodeSize} bytes): got ${codeSize} bytes.`, + ) + if (initialActors.length === 0) + throw new BaseError('`initialActors` must not be empty.') + + // Require strictly ascending `actorId` order (also rejects duplicates). + for (let i = 1; i < initialActors.length; i++) { + if ( + hexToBigInt(initialActors[i].actorId) <= + hexToBigInt(initialActors[i - 1].actorId) + ) + throw new BaseError( + '`initialActors` must be sorted by `actorId` in strictly ascending order (no duplicates).', + ) + } + + const actorsCommitment = keccak256( + concatHex( + initialActors.flatMap((actor) => [actor.actorId, actor.authenticator]), + ), + ) + const effectiveSalt = keccak256(concatHex([userSalt, actorsCommitment])) + const deploymentCode = concatHex([deploymentHeader(codeSize), code]) + + return getCreate2Address({ + from: accountConfigAddress, + salt: effectiveSalt, + bytecode: deploymentCode, + }) +} From b2a8ef993e05e931c86a8d066065c1eae76ac656 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 9 Jun 2026 15:02:06 -0400 Subject: [PATCH 04/96] feat(experimental): add EIP-8130 account configuration helpers Adds account-configuration support: - canonical authenticator set constants (k1 native sentinel; p256, passkey, delegate placeholders) and actorIdFromAddress - hashActorChanges8130 (SignedActorChanges ABI digest) and signActorChanges8130, returning a ready config account-change entry - IAccountConfiguration / IAuthenticator / precompile ABIs Shares the actor-change `data` encoder with the transaction serializer. --- src/experimental/eip8130/abis.ts | 52 ++++++++ src/experimental/eip8130/constants.ts | 22 ++++ src/experimental/eip8130/index.ts | 27 +++- src/experimental/eip8130/utils/actorId.ts | 17 +++ .../eip8130/utils/hashActorChanges.ts | 110 ++++++++++++++++ .../eip8130/utils/serializeTransaction.ts | 24 ++-- .../eip8130/utils/signActorChanges.test.ts | 120 ++++++++++++++++++ .../eip8130/utils/signActorChanges.ts | 77 +++++++++++ 8 files changed, 439 insertions(+), 10 deletions(-) create mode 100644 src/experimental/eip8130/abis.ts create mode 100644 src/experimental/eip8130/utils/actorId.ts create mode 100644 src/experimental/eip8130/utils/hashActorChanges.ts create mode 100644 src/experimental/eip8130/utils/signActorChanges.test.ts create mode 100644 src/experimental/eip8130/utils/signActorChanges.ts diff --git a/src/experimental/eip8130/abis.ts b/src/experimental/eip8130/abis.ts new file mode 100644 index 0000000000..ddfcf01b96 --- /dev/null +++ b/src/experimental/eip8130/abis.ts @@ -0,0 +1,52 @@ +import { parseAbi } from 'abitype' + +/** + * ABI for the EIP-8130 Account Configuration system contract + * (`IAccountConfiguration`) at `ACCOUNT_CONFIG_ADDRESS`. + */ +export const accountConfigurationAbi = parseAbi([ + 'struct InitialActor { bytes32 actorId; address authenticator; }', + 'struct ActorConfig { address authenticator; uint8 scope; uint48 expiry; uint8 policyType; }', + 'struct ActorChange { uint8 changeType; bytes32 actorId; bytes data; }', + 'struct ChangeSequences { uint64 multichain; uint64 local; }', + + 'event ActorAuthorized(address indexed account, bytes32 indexed actorId, ActorConfig config, address policyManager, bytes32 policyCommitment)', + 'event ActorRevoked(address indexed account, bytes32 indexed actorId)', + 'event AccountCreated(address indexed account, bytes32 userSalt, bytes32 codeHash)', + 'event AccountImported(address indexed account)', + 'event DelegationChanged(address indexed account, address target)', + 'event AccountLocked(address indexed account, uint16 unlockDelay)', + 'event AccountUnlockInitiated(address indexed account, uint40 unlocksAt)', + + 'function createAccount(bytes32 userSalt, bytes bytecode, InitialActor[] initialActors) returns (address)', + 'function computeAddress(bytes32 userSalt, bytes bytecode, InitialActor[] initialActors) view returns (address)', + 'function importAccount(address account, InitialActor[] initialActors, bytes signature)', + 'function applySignedActorChanges(address account, uint64 chainId, ActorChange[] actorChanges, bytes auth)', + 'function lock(uint16 unlockDelay)', + 'function initiateUnlock()', + 'function verifySignature(address account, bytes32 hash, bytes signature) view returns (bool verified)', + 'function authenticateActor(address account, bytes32 hash, bytes auth) view returns (uint8 scope)', + 'function isActor(address account, bytes32 actorId) view returns (bool)', + 'function getActorConfig(address account, bytes32 actorId) view returns (ActorConfig)', + 'function getPolicy(address account, bytes32 actorId) view returns (address target, bytes32 commitment)', + 'function getChangeSequences(address account) view returns (ChangeSequences)', + 'function isLocked(address account) view returns (bool)', + 'function getLockStatus(address account) view returns (bool locked, bool hasInitiatedUnlock, uint40 unlocksAt, uint16 unlockDelay)', +]) + +/** ABI for an EIP-8130 authenticator contract (`IAuthenticator`). */ +export const authenticatorAbi = parseAbi([ + 'function authenticate(bytes32 hash, bytes data) view returns (bytes32 actorId)', +]) + +/** ABI for the Transaction Context precompile (`ITransactionContext`). */ +export const transactionContextAbi = parseAbi([ + 'function getTransactionSender() view returns (address)', + 'function getTransactionPayer() view returns (address)', + 'function getTransactionSenderActorId() view returns (bytes32)', +]) + +/** ABI for the Nonce Manager precompile (`INonceManager`). */ +export const nonceManagerAbi = parseAbi([ + 'function getNonce(address account, uint256 nonceKey) view returns (uint64)', +]) diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts index c46f39378c..c418c22916 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/experimental/eip8130/constants.ts @@ -64,6 +64,28 @@ export const ecrecoverAuthenticator = export const revokedAuthenticator = '0xffffffffffffffffffffffffffffffffffffffff' satisfies Hex +/** + * Canonical authenticator set (the signature algorithms compliant nodes MUST + * accept). `k1` is the native `ECRECOVER_AUTHENTICATOR` sentinel; the others are + * onchain contracts. + * + * @remarks + * The non-native addresses are **placeholders**. The canonical set and its + * CREATE2-derived addresses are maintained in a companion ERC and resolved per + * deployment ([base/eip-8130](https://github.com/base/eip-8130)). Override as + * needed until the canonical values are finalized. + */ +export const canonicalAuthenticators = { + /** secp256k1 — native sentinel (`ECRECOVER_AUTHENTICATOR`). */ + k1: '0x0000000000000000000000000000000000000001', + /** P-256 (raw). Placeholder address. */ + p256: '0x8130000000000000000000000000000000000256', + /** WebAuthn / FIDO2 passkey. Placeholder address. */ + passkey: '0x8130000000000000000000000000000000007e6b', + /** Signature delegation (1-hop). Placeholder address. */ + delegate: '0x81300000000000000000000000000000000de1e6', +} as const satisfies Record + /** Nonce Manager precompile address (`NONCE_MANAGER_ADDRESS`). */ export const nonceManagerAddress = '0x813000000000000000000000000000000000aa01' satisfies Hex diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 2a5e15ba9c..d8c843c8ac 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -1,4 +1,11 @@ // biome-ignore lint/performance/noBarrelFile: entrypoint +export { + accountConfigurationAbi, + authenticatorAbi, + nonceManagerAbi, + transactionContextAbi, +} from './abis.js' + export { aaBaseCost, aaPayerType, @@ -7,6 +14,7 @@ export { accountConfigAddress, actorChangeType, actorScope, + canonicalAuthenticators, defaultAccountAddress, deploymentHeaderSize, ecrecoverAuthenticator, @@ -30,6 +38,10 @@ export type { TransactionSerializable8130, TransactionSerialized8130, } from './types/transaction.js' +export { + type ActorIdFromAddressErrorType, + actorIdFromAddress, +} from './utils/actorId.js' export { type AssertTransaction8130ErrorType, assertTransaction8130, @@ -40,7 +52,13 @@ export { computeAddress8130, deploymentHeader, } from './utils/computeAddress.js' - +export { + actorChangeTypehash, + type HashActorChanges8130ErrorType, + type HashActorChanges8130Parameters, + hashActorChanges8130, + signedActorChangesTypehash, +} from './utils/hashActorChanges.js' export { type GetPayerSignatureHash8130ErrorType, type GetSenderSignatureHash8130ErrorType, @@ -49,18 +67,23 @@ export { getPayerSignatureHash8130, getSenderSignatureHash8130, } from './utils/hashTransaction.js' - export { type ParseTransaction8130ErrorType, parseTransaction8130, } from './utils/parseTransaction.js' export { + encodeActorChangeData, type SerializeTransaction8130ErrorType, serializeTransaction8130, toAccountChangesList, toCallsList, toTransactionBody, } from './utils/serializeTransaction.js' +export { + type SignActorChanges8130ErrorType, + type SignActorChanges8130Parameters, + signActorChanges8130, +} from './utils/signActorChanges.js' export { type Signer, type SignTransaction8130ErrorType, diff --git a/src/experimental/eip8130/utils/actorId.ts b/src/experimental/eip8130/utils/actorId.ts new file mode 100644 index 0000000000..c21145206b --- /dev/null +++ b/src/experimental/eip8130/utils/actorId.ts @@ -0,0 +1,17 @@ +import type { Address } from 'abitype' +import type { ErrorType } from '../../../errors/utils.js' +import type { Hex } from '../../../types/misc.js' +import { type PadErrorType, pad } from '../../../utils/data/pad.js' + +export type ActorIdFromAddressErrorType = PadErrorType | ErrorType + +/** + * Derives the `actorId` for an address-based actor: `bytes32(bytes20(address))`. + * + * Used for the implicit EOA actor, `k1` (`ECRECOVER_AUTHENTICATOR`), and + * `delegate` actors. `bytesN` widening is left-aligned, so the 20-byte address + * occupies the high-order bytes and the remaining 12 bytes are zero. + */ +export function actorIdFromAddress(address: Address): Hex { + return pad(address, { dir: 'right', size: 32 }) +} diff --git a/src/experimental/eip8130/utils/hashActorChanges.ts b/src/experimental/eip8130/utils/hashActorChanges.ts new file mode 100644 index 0000000000..692d12d595 --- /dev/null +++ b/src/experimental/eip8130/utils/hashActorChanges.ts @@ -0,0 +1,110 @@ +import type { Address } from 'abitype' +import type { ErrorType } from '../../../errors/utils.js' +import type { Hex } from '../../../types/misc.js' +import { + type EncodeAbiParametersErrorType, + encodeAbiParameters, +} from '../../../utils/abi/encodeAbiParameters.js' +import { + type ConcatHexErrorType, + concatHex, +} from '../../../utils/data/concat.js' +import { + stringToHex, + type ToHexErrorType, +} from '../../../utils/encoding/toHex.js' +import { + type Keccak256ErrorType, + keccak256, +} from '../../../utils/hash/keccak256.js' +import type { AaActorChange } from '../types/transaction.js' +import { encodeActorChangeData } from './serializeTransaction.js' + +/** `keccak256("ActorChange(uint8 changeType,bytes32 actorId,bytes data)")` */ +export const actorChangeTypehash = keccak256( + stringToHex('ActorChange(uint8 changeType,bytes32 actorId,bytes data)'), +) + +/** + * `keccak256("SignedActorChanges(address account,uint64 chainId,uint64 sequence,ActorChange[] actorChanges)ActorChange(uint8 changeType,bytes32 actorId,bytes data)")` + */ +export const signedActorChangesTypehash = keccak256( + stringToHex( + 'SignedActorChanges(address account,uint64 chainId,uint64 sequence,ActorChange[] actorChanges)ActorChange(uint8 changeType,bytes32 actorId,bytes data)', + ), +) + +export type HashActorChanges8130Parameters = { + /** The account whose actor configuration is changing. */ + account: Address + /** Chain ID scope. `0` = valid on any chain (multichain channel). */ + chainId: number + /** Monotonic ordering sequence within the channel. */ + sequence: number + /** Actor change operations. */ + actorChanges: readonly AaActorChange[] +} + +export type HashActorChanges8130ErrorType = + | EncodeAbiParametersErrorType + | ConcatHexErrorType + | Keccak256ErrorType + | ToHexErrorType + | ErrorType + +/** + * Computes the EIP-8130 config-change (`SignedActorChanges`) signature digest: + * + * ``` + * actorChangeHashes = [keccak256(abi.encode(ACTORCHANGE_TYPEHASH, changeType, actorId, keccak256(data)))] + * actorChangesHash = keccak256(abi.encodePacked(actorChangeHashes)) + * digest = keccak256(abi.encode(TYPEHASH, account, chainId, sequence, actorChangesHash)) + * ``` + * + * The resulting digest is signed (in `authenticator || data` form) to produce + * the config-change entry's `auth`. + */ +export function hashActorChanges8130( + parameters: HashActorChanges8130Parameters, +): Hex { + const { account, chainId, sequence, actorChanges } = parameters + + const actorChangeHashes = actorChanges.map((change) => + keccak256( + encodeAbiParameters( + [ + { type: 'bytes32' }, + { type: 'uint8' }, + { type: 'bytes32' }, + { type: 'bytes32' }, + ], + [ + actorChangeTypehash, + change.changeType, + change.actorId, + keccak256(encodeActorChangeData(change)), + ], + ), + ), + ) + const actorChangesHash = keccak256(concatHex(actorChangeHashes)) + + return keccak256( + encodeAbiParameters( + [ + { type: 'bytes32' }, + { type: 'address' }, + { type: 'uint64' }, + { type: 'uint64' }, + { type: 'bytes32' }, + ], + [ + signedActorChangesTypehash, + account, + BigInt(chainId), + BigInt(sequence), + actorChangesHash, + ], + ), + ) +} diff --git a/src/experimental/eip8130/utils/serializeTransaction.ts b/src/experimental/eip8130/utils/serializeTransaction.ts index ca90a65abd..847b4f5c34 100644 --- a/src/experimental/eip8130/utils/serializeTransaction.ts +++ b/src/experimental/eip8130/utils/serializeTransaction.ts @@ -38,22 +38,30 @@ export function toCallsList(calls: AaCalls | undefined): RecursiveArray[] { } /** - * Encodes a single `actor_change` operation. The operation-specific `data` is an - * opaque bytes field containing a nested RLP encoding. + * Encodes the operation-specific `data` bytes of an `actor_change`: + * `authorizeActor` -> `rlp([authenticator, scope, expiry, policyType, policyData])`, + * `revokeActor` -> `rlp([])`. */ -function toActorChange(change: AaActorChange): RecursiveArray { - if (change.changeType === actorChangeType.authorizeActor) { - const data = toRlp([ +export function encodeActorChangeData(change: AaActorChange): Hex { + if (change.changeType === actorChangeType.authorizeActor) + return toRlp([ change.authenticator, change.scope ? numberToHex(change.scope) : '0x', change.expiry ? numberToHex(change.expiry) : '0x', change.policyType ? numberToHex(change.policyType) : '0x', change.policyData ?? '0x', ]) - return [numberToHex(change.changeType), change.actorId, data] - } // revokeActor: empty data (`rlp([])`) - return [numberToHex(change.changeType), change.actorId, toRlp([])] + return toRlp([]) +} + +/** Encodes a single `actor_change` operation into a nested RLP-ready array. */ +function toActorChange(change: AaActorChange): RecursiveArray { + return [ + numberToHex(change.changeType), + change.actorId, + encodeActorChangeData(change), + ] } /** Encodes the `account_changes` field into a nested RLP-ready array. */ diff --git a/src/experimental/eip8130/utils/signActorChanges.test.ts b/src/experimental/eip8130/utils/signActorChanges.test.ts new file mode 100644 index 0000000000..8bfbea42f1 --- /dev/null +++ b/src/experimental/eip8130/utils/signActorChanges.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'vitest' +import { accounts } from '~test/constants.js' +import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' +import { sliceHex } from '../../../utils/data/slice.js' +import { recoverAddress } from '../../../utils/signature/recoverAddress.js' +import { ecrecoverAuthenticator } from '../constants.js' +import type { AaActorChange } from '../types/transaction.js' +import { actorIdFromAddress } from './actorId.js' +import { hashActorChanges8130 } from './hashActorChanges.js' +import { signActorChanges8130 } from './signActorChanges.js' + +const signer = privateKeyToAccount(accounts[0].privateKey) +const account = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const + +const authorize: AaActorChange = { + changeType: 0x01, + actorId: '0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc', + authenticator: '0x0000000000000000000000000000000000000001', + scope: 0x04, + expiry: 1_900_000_000n, +} +const revoke: AaActorChange = { + changeType: 0x02, + actorId: '0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266', +} + +describe('actorIdFromAddress', () => { + test('left-aligned bytes32(bytes20(address))', () => { + expect( + actorIdFromAddress('0x0000000000000000000000000000000000000001'), + ).toBe('0x0000000000000000000000000000000000000001000000000000000000000000') + expect(actorIdFromAddress(account).toLowerCase()).toBe( + `0x${account.slice(2).toLowerCase()}000000000000000000000000`, + ) + }) +}) + +describe('hashActorChanges (EIP-8130)', () => { + test('deterministic 32-byte digest', () => { + const digest = hashActorChanges8130({ + account, + chainId: 0, + sequence: 1, + actorChanges: [authorize, revoke], + }) + expect(digest).toMatch(/^0x[0-9a-f]{64}$/) + expect( + hashActorChanges8130({ + account, + chainId: 0, + sequence: 1, + actorChanges: [authorize, revoke], + }), + ).toBe(digest) + }) + + test('sequence and account are bound', () => { + const base = { account, chainId: 0, actorChanges: [authorize] } as const + expect(hashActorChanges8130({ ...base, sequence: 1 })).not.toBe( + hashActorChanges8130({ ...base, sequence: 2 }), + ) + expect(hashActorChanges8130({ ...base, sequence: 1 })).not.toBe( + hashActorChanges8130({ + account: '0x0000000000000000000000000000000000000009', + chainId: 0, + sequence: 1, + actorChanges: [authorize], + }), + ) + }) +}) + +describe('signActorChanges (EIP-8130)', () => { + test('returns a config entry whose auth recovers the signer', async () => { + const entry = await signActorChanges8130({ + signer, + account, + chainId: 0, + sequence: 1, + actorChanges: [authorize, revoke], + }) + + expect(entry.type).toBe('config') + expect(entry.chainId).toBe(0) + expect(entry.sequence).toBe(1) + expect(sliceHex(entry.auth, 0, 20)).toBe(ecrecoverAuthenticator) + + const digest = hashActorChanges8130({ + account, + chainId: 0, + sequence: 1, + actorChanges: [authorize, revoke], + }) + const recovered = await recoverAddress({ + hash: digest, + signature: sliceHex(entry.auth, 20), + }) + expect(recovered.toLowerCase()).toBe(signer.address.toLowerCase()) + }) + + test('defaults account to signer address', async () => { + const entry = await signActorChanges8130({ + signer, + chainId: 0, + sequence: 3, + actorChanges: [revoke], + }) + const digest = hashActorChanges8130({ + account: signer.address, + chainId: 0, + sequence: 3, + actorChanges: [revoke], + }) + const recovered = await recoverAddress({ + hash: digest, + signature: sliceHex(entry.auth, 20), + }) + expect(recovered.toLowerCase()).toBe(signer.address.toLowerCase()) + }) +}) diff --git a/src/experimental/eip8130/utils/signActorChanges.ts b/src/experimental/eip8130/utils/signActorChanges.ts new file mode 100644 index 0000000000..dab6a792e5 --- /dev/null +++ b/src/experimental/eip8130/utils/signActorChanges.ts @@ -0,0 +1,77 @@ +import type { Address } from 'abitype' +import { BaseError } from '../../../errors/base.js' +import type { ErrorType } from '../../../errors/utils.js' +import { + type ConcatHexErrorType, + concatHex, +} from '../../../utils/data/concat.js' +import { ecrecoverAuthenticator } from '../constants.js' +import type { + AaAccountChangeConfig, + AaActorChange, +} from '../types/transaction.js' +import { + type HashActorChanges8130ErrorType, + hashActorChanges8130, +} from './hashActorChanges.js' +import type { Signer } from './signTransaction.js' + +export type SignActorChanges8130Parameters = { + /** Signer producing the config-change `auth` (the authorizing actor's key). */ + signer: Signer + /** + * The account whose actor configuration is changing. Defaults to the signer's + * address (an account authorizing its own changes). + */ + account?: Address | undefined + /** Chain ID scope. `0` = valid on any chain (multichain channel). */ + chainId: number + /** Monotonic ordering sequence within the channel. */ + sequence: number + /** Actor change operations. */ + actorChanges: readonly AaActorChange[] + /** + * Authenticator address for the `auth` blob. Defaults to + * `ECRECOVER_AUTHENTICATOR` (native secp256k1). + */ + authenticator?: Address | undefined +} + +export type SignActorChanges8130ErrorType = + | HashActorChanges8130ErrorType + | ConcatHexErrorType + | BaseError + | ErrorType + +/** + * Signs a set of EIP-8130 actor changes and returns a ready-to-use `config` + * account-change entry (with `auth` in `authenticator || data` form) that can be + * placed in a transaction's `accountChanges` or submitted via + * `applySignedActorChanges`. + */ +export async function signActorChanges8130( + parameters: SignActorChanges8130Parameters, +): Promise { + const { + signer, + chainId, + sequence, + actorChanges, + authenticator = ecrecoverAuthenticator, + } = parameters + const account = parameters.account ?? signer.address + + if (!signer.sign) + throw new BaseError('`signer` does not support raw signing.') + + const digest = hashActorChanges8130({ + account, + chainId, + sequence, + actorChanges, + }) + const signature = await signer.sign({ hash: digest }) + const auth = concatHex([authenticator, signature]) + + return { type: 'config', chainId, sequence, actorChanges, auth } +} From 78c8bbd60874ba592c46a99addd0cbf52cf05615 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 9 Jun 2026 15:10:06 -0400 Subject: [PATCH 05/96] feat(experimental): add EIP-8130 cross-chain portability helpers Enables routing the same account between 8130 and non-8130 chains: - is8130Enabled chain registry + predicate (AA_TX_TYPE vs ERC-4337) - toFactoryArgs8130 / encodeCreateAccountData: ERC-4337 factory args via the AccountConfiguration contract (address matches computeAddress8130) - encodeApplySignedActorChangesData: portable any-chain config-change path --- src/experimental/eip8130/chains.ts | 51 ++++++++ src/experimental/eip8130/index.ts | 20 ++- .../eip8130/utils/accountConfigCalls.test.ts | 116 +++++++++++++++++ .../eip8130/utils/accountConfigCalls.ts | 121 ++++++++++++++++++ 4 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/experimental/eip8130/chains.ts create mode 100644 src/experimental/eip8130/utils/accountConfigCalls.test.ts create mode 100644 src/experimental/eip8130/utils/accountConfigCalls.ts diff --git a/src/experimental/eip8130/chains.ts b/src/experimental/eip8130/chains.ts new file mode 100644 index 0000000000..863849e46f --- /dev/null +++ b/src/experimental/eip8130/chains.ts @@ -0,0 +1,51 @@ +/** + * Registry of chain IDs known to support EIP-8130 natively (the `AA_TX_TYPE` + * transaction type and the protocol precompiles). + * + * @remarks + * EIP-8130 is enabled per-chain. Accounts are portable: on an 8130-enabled chain + * use the `AA_TX_TYPE` flow; on other chains the same account operates through an + * alternative AA mechanism (e.g. ERC-4337) with the `AccountConfiguration` + * contract as its factory. + * + * This set is empty by default (no networks have shipped 8130 yet). Populate it + * with {@link register8130Chains}, or pass an explicit `chainIds` set to + * {@link is8130Enabled}. + */ +export const eip8130ChainIds: Set = new Set() + +/** Registers one or more chain IDs as EIP-8130 enabled. */ +export function register8130Chains(...chainIds: number[]): void { + for (const id of chainIds) eip8130ChainIds.add(id) +} + +/** Unregisters one or more chain IDs. */ +export function unregister8130Chains(...chainIds: number[]): void { + for (const id of chainIds) eip8130ChainIds.delete(id) +} + +export type Is8130EnabledParameters = { + /** + * Explicit set of 8130-enabled chain IDs to check against. Defaults to the + * shared {@link eip8130ChainIds} registry. + */ + chainIds?: Iterable | undefined +} + +/** + * Returns whether a chain supports EIP-8130 natively — i.e. whether to route a + * transaction through the `AA_TX_TYPE` flow (`true`) or an ERC-4337-style + * fallback (`false`). + */ +export function is8130Enabled( + chain: number | { id: number }, + parameters: Is8130EnabledParameters = {}, +): boolean { + const id = typeof chain === 'number' ? chain : chain.id + const set = parameters.chainIds + ? parameters.chainIds instanceof Set + ? parameters.chainIds + : new Set(parameters.chainIds) + : eip8130ChainIds + return set.has(id) +} diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index d8c843c8ac..f305d4c52d 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -5,7 +5,13 @@ export { nonceManagerAbi, transactionContextAbi, } from './abis.js' - +export { + eip8130ChainIds, + type Is8130EnabledParameters, + is8130Enabled, + register8130Chains, + unregister8130Chains, +} from './chains.js' export { aaBaseCost, aaPayerType, @@ -38,6 +44,18 @@ export type { TransactionSerializable8130, TransactionSerialized8130, } from './types/transaction.js' +export { + type EncodeApplySignedActorChangesDataErrorType, + type EncodeApplySignedActorChangesDataParameters, + type EncodeCreateAccountDataErrorType, + type EncodeCreateAccountDataParameters, + encodeApplySignedActorChangesData, + encodeCreateAccountData, + type ToFactoryArgs8130ErrorType, + type ToFactoryArgs8130Parameters, + type ToFactoryArgs8130ReturnType, + toFactoryArgs8130, +} from './utils/accountConfigCalls.js' export { type ActorIdFromAddressErrorType, actorIdFromAddress, diff --git a/src/experimental/eip8130/utils/accountConfigCalls.test.ts b/src/experimental/eip8130/utils/accountConfigCalls.test.ts new file mode 100644 index 0000000000..cceade4a48 --- /dev/null +++ b/src/experimental/eip8130/utils/accountConfigCalls.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from 'vitest' +import { decodeFunctionData } from '../../../utils/abi/decodeFunctionData.js' +import { accountConfigurationAbi } from '../abis.js' +import { + eip8130ChainIds, + is8130Enabled, + register8130Chains, + unregister8130Chains, +} from '../chains.js' +import { accountConfigAddress } from '../constants.js' +import type { AaActor, AaActorChange } from '../types/transaction.js' +import { + encodeApplySignedActorChangesData, + encodeCreateAccountData, + toFactoryArgs8130, +} from './accountConfigCalls.js' +import { computeAddress8130 } from './computeAddress.js' + +const actor: AaActor = { + actorId: '0x0000000000000000000000000000000000000000000000000000000000000001', + authenticator: '0x0000000000000000000000000000000000000001', +} + +describe('is8130Enabled (routing)', () => { + test('default registry is empty; register/unregister works', () => { + expect(is8130Enabled(8453)).toBe(false) + register8130Chains(8453) + expect(is8130Enabled(8453)).toBe(true) + expect(is8130Enabled({ id: 8453 })).toBe(true) + unregister8130Chains(8453) + expect(is8130Enabled(8453)).toBe(false) + expect(eip8130ChainIds.has(8453)).toBe(false) + }) + + test('accepts an explicit chainIds set without touching the registry', () => { + expect(is8130Enabled(10, { chainIds: [10, 8453] })).toBe(true) + expect(is8130Enabled(1, { chainIds: [10, 8453] })).toBe(false) + expect(eip8130ChainIds.has(10)).toBe(false) + }) +}) + +describe('toFactoryArgs8130 (ERC-4337 factory)', () => { + const params = { + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + code: '0x6080', + initialActors: [actor], + } as const + + test('factory is the account config contract; factoryData is createAccount', () => { + const { factory, factoryData } = toFactoryArgs8130(params) + expect(factory).toBe(accountConfigAddress) + expect(factoryData).toBe(encodeCreateAccountData(params)) + + const { functionName, args } = decodeFunctionData({ + abi: accountConfigurationAbi, + data: factoryData, + }) + expect(functionName).toBe('createAccount') + expect(args[0]).toBe(params.userSalt) + expect(args[1]).toBe(params.code) + expect(args[2]).toEqual([ + { actorId: actor.actorId, authenticator: actor.authenticator }, + ]) + }) + + test('custom factory address', () => { + const factoryAddress = '0x00000000000000000000000000000000000000aa' as const + const { factory } = toFactoryArgs8130({ + ...params, + accountConfigAddress: factoryAddress, + }) + expect(factory).toBe(factoryAddress) + }) + + test('factory deploys to the computeAddress8130 address', () => { + // both derive from the same inputs/config address -> portable address + const address = computeAddress8130(params) + expect(address).toMatch(/^0x[0-9a-fA-F]{40}$/) + }) +}) + +describe('encodeApplySignedActorChangesData (portable path)', () => { + test('encodes account, chainId, actorChanges, auth', () => { + const actorChanges: readonly AaActorChange[] = [ + { + changeType: 0x01, + actorId: + '0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc', + authenticator: '0x0000000000000000000000000000000000000001', + scope: 0x04, + }, + { + changeType: 0x02, + actorId: + '0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266', + }, + ] + const data = encodeApplySignedActorChangesData({ + account: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + chainId: 0, + actorChanges, + auth: '0xfeed', + }) + const decoded = decodeFunctionData({ + abi: accountConfigurationAbi, + data, + }) + expect(decoded.functionName).toBe('applySignedActorChanges') + expect(decoded.args[1]).toBe(0n) + expect((decoded.args[2] as readonly { changeType: number }[]).length).toBe( + 2, + ) + expect(decoded.args[3]).toBe('0xfeed') + }) +}) diff --git a/src/experimental/eip8130/utils/accountConfigCalls.ts b/src/experimental/eip8130/utils/accountConfigCalls.ts new file mode 100644 index 0000000000..11dc2a8557 --- /dev/null +++ b/src/experimental/eip8130/utils/accountConfigCalls.ts @@ -0,0 +1,121 @@ +import type { Address } from 'abitype' +import type { ErrorType } from '../../../errors/utils.js' +import type { Hex } from '../../../types/misc.js' +import { + type EncodeFunctionDataErrorType, + encodeFunctionData, +} from '../../../utils/abi/encodeFunctionData.js' +import { accountConfigurationAbi } from '../abis.js' +import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' +import type { AaActor, AaActorChange } from '../types/transaction.js' +import { encodeActorChangeData } from './serializeTransaction.js' + +function toInitialActors(actors: readonly AaActor[]) { + return actors.map((actor) => ({ + actorId: actor.actorId, + authenticator: actor.authenticator, + })) +} + +function toAbiActorChanges(changes: readonly AaActorChange[]) { + return changes.map((change) => ({ + changeType: change.changeType, + actorId: change.actorId, + data: encodeActorChangeData(change), + })) +} + +export type EncodeCreateAccountDataParameters = { + /** User-chosen uniqueness factor (bytes32). */ + userSalt: Hex + /** Runtime bytecode placed at the account address. */ + code: Hex + /** Initial actors (sorted by `actorId`, strictly ascending). */ + initialActors: readonly AaActor[] +} + +export type EncodeCreateAccountDataErrorType = + | EncodeFunctionDataErrorType + | ErrorType + +/** + * Encodes calldata for `AccountConfiguration.createAccount` — the ERC-4337 + * factory call that deploys an EIP-8130 account on a non-8130 chain (and is the + * `factoryData` returned by {@link toFactoryArgs8130}). + */ +export function encodeCreateAccountData( + parameters: EncodeCreateAccountDataParameters, +): Hex { + const { userSalt, code, initialActors } = parameters + return encodeFunctionData({ + abi: accountConfigurationAbi, + functionName: 'createAccount', + args: [userSalt, code, toInitialActors(initialActors)], + }) +} + +export type ToFactoryArgs8130Parameters = EncodeCreateAccountDataParameters & { + /** + * Account Configuration contract address (the ERC-4337 factory). Defaults to + * the placeholder {@link accountConfigAddress} constant. + */ + accountConfigAddress?: Address | undefined +} + +export type ToFactoryArgs8130ReturnType = { + factory: Address + factoryData: Hex +} + +export type ToFactoryArgs8130ErrorType = + | EncodeCreateAccountDataErrorType + | ErrorType + +/** + * Returns the ERC-4337 `{ factory, factoryData }` for deploying an EIP-8130 + * account through the `AccountConfiguration` contract on a non-8130 chain. The + * resulting account address matches {@link computeAddress8130}. + */ +export function toFactoryArgs8130( + parameters: ToFactoryArgs8130Parameters, +): ToFactoryArgs8130ReturnType { + const { + accountConfigAddress = defaultAccountConfigAddress, + ...createParameters + } = parameters + return { + factory: accountConfigAddress, + factoryData: encodeCreateAccountData(createParameters), + } +} + +export type EncodeApplySignedActorChangesDataParameters = { + /** The account whose actor configuration is changing. */ + account: Address + /** Chain ID scope. `0` = valid on any chain (multichain channel). */ + chainId: number + /** Actor change operations. */ + actorChanges: readonly AaActorChange[] + /** Authorization signature (`authenticator || data`). */ + auth: Hex +} + +export type EncodeApplySignedActorChangesDataErrorType = + | EncodeFunctionDataErrorType + | ErrorType + +/** + * Encodes calldata for `AccountConfiguration.applySignedActorChanges` — the + * portable (any-chain) path to apply signed actor changes via plain EVM + * execution. Pair with {@link signActorChanges8130} to produce the `auth`. + */ +export function encodeApplySignedActorChangesData( + parameters: EncodeApplySignedActorChangesDataParameters, +): Hex { + const { account, chainId, actorChanges, auth } = parameters + return encodeFunctionData({ + abi: accountConfigurationAbi, + functionName: 'applySignedActorChanges', + args: [account, BigInt(chainId), toAbiActorChanges(actorChanges), auth], + }) +} From 4ed8ff73e12ba12e085fd2c2a798c54ba81442f3 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 9 Jun 2026 15:21:24 -0400 Subject: [PATCH 06/96] feat(experimental): add toSmartAccount8130 (ERC-4337 wallet adapter) Lets the same EIP-8130 account run on non-8130 chains via a bundlerClient, backed by the canonical BackwardCompatibleERC4337Account wallet: - toSmartAccount8130: encodeCalls via executeBatch(Call[]), getFactoryArgs via AccountConfiguration.createAccount, getAddress via computeAddress8130, and signatures in authenticator||data form for authenticateActor validation - erc4337AccountAbi for the wallet (executeBatch/validateUserOp/isValidSignature) - erc1167Bytecode helper for the minimal-proxy deployment code --- src/experimental/eip8130/abis.ts | 19 ++ .../accounts/toSmartAccount8130.test.ts | 137 ++++++++++ .../eip8130/accounts/toSmartAccount8130.ts | 250 ++++++++++++++++++ src/experimental/eip8130/index.ts | 8 + src/experimental/eip8130/utils/proxy.ts | 16 ++ 5 files changed, 430 insertions(+) create mode 100644 src/experimental/eip8130/accounts/toSmartAccount8130.test.ts create mode 100644 src/experimental/eip8130/accounts/toSmartAccount8130.ts create mode 100644 src/experimental/eip8130/utils/proxy.ts diff --git a/src/experimental/eip8130/abis.ts b/src/experimental/eip8130/abis.ts index ddfcf01b96..c344124215 100644 --- a/src/experimental/eip8130/abis.ts +++ b/src/experimental/eip8130/abis.ts @@ -34,6 +34,25 @@ export const accountConfigurationAbi = parseAbi([ 'function getLockStatus(address account) view returns (bool locked, bool hasInitiatedUnlock, uint40 unlocksAt, uint16 unlockDelay)', ]) +/** + * ABI for the canonical EIP-8130 wallet implementation + * (`BackwardCompatibleERC4337Account`) — the account behind the ERC-1167 proxy. + * Used for ERC-4337 execution on non-8130 chains. Validation is delegated to the + * Account Configuration contract via `authenticateActor`. + */ +export const erc4337AccountAbi = parseAbi([ + 'struct Call { address target; uint256 value; bytes data; }', + 'struct PackedUserOperation { address sender; uint256 nonce; bytes initCode; bytes callData; bytes32 accountGasLimits; uint256 preVerificationGas; bytes32 gasFees; bytes paymasterAndData; bytes signature; }', + 'event CallerAuthorized(address indexed caller)', + 'event CallerRevoked(address indexed caller)', + 'function executeBatch(Call[] calls)', + 'function authorizeCaller(address caller)', + 'function revokeCaller(address caller)', + 'function isAuthorizedCaller(address caller) view returns (bool)', + 'function validateUserOp(PackedUserOperation userOp, bytes32 userOpHash, uint256 missingAccountFunds) returns (uint256 validationData)', + 'function isValidSignature(bytes32 hash, bytes signature) view returns (bytes4)', +]) + /** ABI for an EIP-8130 authenticator contract (`IAuthenticator`). */ export const authenticatorAbi = parseAbi([ 'function authenticate(bytes32 hash, bytes data) view returns (bytes32 actorId)', diff --git a/src/experimental/eip8130/accounts/toSmartAccount8130.test.ts b/src/experimental/eip8130/accounts/toSmartAccount8130.test.ts new file mode 100644 index 0000000000..f627693fcd --- /dev/null +++ b/src/experimental/eip8130/accounts/toSmartAccount8130.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' +import { mainnet } from '../../../chains/index.js' +import { createClient } from '../../../clients/createClient.js' +import { custom } from '../../../clients/transports/custom.js' +import { decodeFunctionData } from '../../../utils/abi/decodeFunctionData.js' +import { slice } from '../../../utils/data/slice.js' +import { recoverMessageAddress } from '../../../utils/signature/recoverMessageAddress.js' +import { accountConfigurationAbi } from '../abis.js' +import { ecrecoverAuthenticator } from '../constants.js' +import type { AaActor } from '../types/transaction.js' +import { computeAddress8130 } from '../utils/computeAddress.js' +import { erc1167Bytecode } from '../utils/proxy.js' +import { toSmartAccount8130 } from './toSmartAccount8130.js' + +// Offline stub transports. `client` reports the account as counterfactual +// (not deployed); `deployedClient` reports it as deployed (so signatures are +// not ERC-6492 wrapped). +function stubClient(code: '0x' | '0x01') { + return createClient({ + chain: mainnet, + transport: custom({ + async request({ method }: { method: string }) { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_getCode') return code + throw new Error(`unexpected RPC call: ${method}`) + }, + }), + }) +} +const client = stubClient('0x') +const deployedClient = stubClient('0x01') +const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const implementation = '0x00000000000000000000000000000000000000Ec' as const +const actor: AaActor = { + actorId: '0x0000000000000000000000000000000000000000000000000000000000000001', + authenticator: ecrecoverAuthenticator, +} +const base = { + client, + owner, + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + initialActors: [actor], + implementation, +} as const + +describe('toSmartAccount8130', () => { + test('getAddress matches computeAddress8130', async () => { + const account = await toSmartAccount8130(base) + expect(await account.getAddress()).toBe( + computeAddress8130({ + userSalt: base.userSalt, + code: erc1167Bytecode(implementation), + initialActors: base.initialActors, + }), + ) + }) + + test('getFactoryArgs -> AccountConfiguration.createAccount', async () => { + const account = await toSmartAccount8130(base) + const { factory, factoryData } = await account.getFactoryArgs() + expect(factory).toMatch(/^0x[0-9a-fA-F]{40}$/) + const decoded = decodeFunctionData({ + abi: accountConfigurationAbi, + data: factoryData!, + }) + expect(decoded.functionName).toBe('createAccount') + expect(decoded.args[0]).toBe(base.userSalt) + expect(decoded.args[1]).toBe(erc1167Bytecode(implementation).toLowerCase()) + }) + + test('encodeCalls/decodeCalls round-trip via executeBatch', async () => { + const account = await toSmartAccount8130(base) + const calls = [ + { + to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + value: 1n, + data: '0x', + }, + { to: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', data: '0xdeadbeef' }, + ] as const + const encoded = await account.encodeCalls(calls as any) + const decoded = await account.decodeCalls!(encoded) + expect(decoded).toEqual([ + { + to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + value: 1n, + data: '0x', + }, + { + to: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', + value: 0n, + data: '0xdeadbeef', + }, + ]) + }) + + test('getStubSignature is authenticator-prefixed', async () => { + const account = await toSmartAccount8130(base) + const stub = await account.getStubSignature() + expect(slice(stub, 0, 20).toLowerCase()).toBe( + ecrecoverAuthenticator.toLowerCase(), + ) + }) + + test('signMessage = authenticator || recoverable ECDSA', async () => { + const account = await toSmartAccount8130({ + ...base, + client: deployedClient, + }) + const message = 'hello 8130' + const sig = await account.signMessage({ message }) + expect(slice(sig, 0, 20).toLowerCase()).toBe( + ecrecoverAuthenticator.toLowerCase(), + ) + const recovered = await recoverMessageAddress({ + message, + signature: slice(sig, 20), + }) + expect(recovered).toBe(owner.address) + }) + + test('throws without identity inputs when deriving factory args', async () => { + const account = await toSmartAccount8130({ + client, + owner, + address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + }) + expect(await account.getAddress()).toBe( + '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + ) + await expect(account.getFactoryArgs()).rejects.toThrow() + }) +}) diff --git a/src/experimental/eip8130/accounts/toSmartAccount8130.ts b/src/experimental/eip8130/accounts/toSmartAccount8130.ts new file mode 100644 index 0000000000..feaa9f3072 --- /dev/null +++ b/src/experimental/eip8130/accounts/toSmartAccount8130.ts @@ -0,0 +1,250 @@ +import type { Abi, Address } from 'abitype' +import { toSmartAccount } from '../../../account-abstraction/accounts/toSmartAccount.js' +import type { + SmartAccount, + SmartAccountImplementation, +} from '../../../account-abstraction/accounts/types.js' +import { entryPoint07Abi } from '../../../account-abstraction/constants/abis.js' +import { entryPoint07Address } from '../../../account-abstraction/constants/address.js' +import type { EntryPointVersion } from '../../../account-abstraction/types/entryPointVersion.js' +import { getUserOperationHash } from '../../../account-abstraction/utils/userOperation/getUserOperationHash.js' +import { parseAccount } from '../../../accounts/utils/parseAccount.js' +import { signMessage as signMessage_ } from '../../../actions/wallet/signMessage.js' +import { signTypedData as signTypedData_ } from '../../../actions/wallet/signTypedData.js' +import { BaseError } from '../../../errors/base.js' +import type { Account } from '../../../types/account.js' +import type { Hex } from '../../../types/misc.js' +import type { Prettify } from '../../../types/utils.js' +import { decodeFunctionData } from '../../../utils/abi/decodeFunctionData.js' +import { encodeFunctionData } from '../../../utils/abi/encodeFunctionData.js' +import { concatHex } from '../../../utils/data/concat.js' +import { getAction } from '../../../utils/getAction.js' +import { erc4337AccountAbi } from '../abis.js' +import { + accountConfigAddress as defaultAccountConfigAddress, + ecrecoverAuthenticator, +} from '../constants.js' +import type { AaActor } from '../types/transaction.js' +import { toFactoryArgs8130 } from '../utils/accountConfigCalls.js' +import { computeAddress8130 } from '../utils/computeAddress.js' +import { erc1167Bytecode } from '../utils/proxy.js' + +export type ToSmartAccount8130Parameters< + entryPointAbi extends Abi = Abi, + entryPointVersion extends EntryPointVersion = EntryPointVersion, +> = { + /** Signer for the controlling actor. */ + owner: Address | Account + client: Eip8130SmartAccountImplementation['client'] + entryPoint?: + | { + abi: entryPointAbi + address: Address + version: entryPointVersion | EntryPointVersion + } + | undefined + getNonce?: SmartAccountImplementation['getNonce'] | undefined + /** + * Authenticator address that validates the owner's signatures. Defaults to the + * native ECRECOVER authenticator (secp256k1 EOA). + */ + authenticator?: Address | undefined + /** Account Configuration contract (the ERC-4337 factory). */ + accountConfigAddress?: Address | undefined +} & ( + | { + /** Pre-deployed / known account address. */ + address: Address + userSalt?: Hex | undefined + initialActors?: readonly AaActor[] | undefined + implementation?: Address | undefined + code?: Hex | undefined + } + | { + address?: undefined + /** User-chosen uniqueness factor (bytes32). */ + userSalt: Hex + /** Initial actors (sorted by `actorId`, strictly ascending). */ + initialActors: readonly AaActor[] + /** Wallet implementation address (proxied via ERC-1167). */ + implementation?: Address | undefined + /** Deployment bytecode override (defaults to ERC-1167 proxy to `implementation`). */ + code?: Hex | undefined + } +) + +export type Eip8130SmartAccountImplementation< + entryPointAbi extends Abi = Abi, + entryPointVersion extends EntryPointVersion = EntryPointVersion, +> = SmartAccountImplementation< + entryPointAbi, + entryPointVersion, + { abi: typeof erc4337AccountAbi } +> + +export type ToSmartAccount8130ReturnType< + entryPointAbi extends Abi = Abi, + entryPointVersion extends EntryPointVersion = EntryPointVersion, +> = Prettify< + SmartAccount< + Eip8130SmartAccountImplementation + > +> + +/** + * Wraps an EIP-8130 account as a viem ERC-4337 Smart Account so the *same* + * account can be used on non-8130 chains through a `bundlerClient`. + * + * Execution goes through `executeBatch(Call[])` on the canonical + * `BackwardCompatibleERC4337Account` wallet; deployment uses the Account + * Configuration contract as the ERC-4337 factory (`createAccount`); and + * signature validation is delegated to the Account Configuration system via the + * `authenticator || data` auth format. + * + * @example + * import { toSmartAccount8130 } from 'viem/experimental' + * + * const account = await toSmartAccount8130({ + * client, + * owner, + * userSalt: '0x...', + * initialActors: [{ actorId, authenticator }], + * implementation: '0x...', // ERC4337Account impl + * }) + */ +export async function toSmartAccount8130< + entryPointAbi extends Abi = typeof entryPoint07Abi, + entryPointVersion extends EntryPointVersion = '0.7', +>( + parameters: ToSmartAccount8130Parameters, +): Promise> { + const { + client, + entryPoint: entryPoint_ = { + abi: entryPoint07Abi, + address: entryPoint07Address, + version: '0.7', + }, + getNonce, + authenticator = ecrecoverAuthenticator, + accountConfigAddress = defaultAccountConfigAddress, + } = parameters + + const entryPoint = { + abi: entryPoint_.abi as entryPointAbi, + address: entryPoint_.address, + version: entryPoint_.version as entryPointVersion, + } as const + const owner = parseAccount(parameters.owner) + + // Resolve the account's deployment bytecode (ERC-1167 proxy to the wallet impl). + const code = + parameters.code ?? + (parameters.implementation + ? erc1167Bytecode(parameters.implementation) + : undefined) + + function getCreateParameters() { + if (!parameters.userSalt || !parameters.initialActors) + throw new BaseError( + '`userSalt` and `initialActors` are required to derive factory args / address.', + ) + if (!code) + throw new BaseError( + 'Provide `implementation` (wallet impl address) or `code` (deployment bytecode).', + ) + return { + userSalt: parameters.userSalt, + code, + initialActors: parameters.initialActors, + accountConfigAddress, + } + } + + return toSmartAccount({ + client, + entryPoint, + getNonce, + + extend: { abi: erc4337AccountAbi }, + + async decodeCalls(data) { + const result = decodeFunctionData({ abi: erc4337AccountAbi, data }) + if (result.functionName === 'executeBatch') + return result.args[0].map((call) => ({ + to: call.target, + value: call.value, + data: call.data, + })) + throw new BaseError(`unable to decode calls for "${result.functionName}"`) + }, + + async encodeCalls(calls) { + return encodeFunctionData({ + abi: erc4337AccountAbi, + functionName: 'executeBatch', + args: [ + calls.map((call) => ({ + target: call.to, + value: call.value ?? 0n, + data: call.data ?? '0x', + })), + ], + }) + }, + + async getAddress() { + if (parameters.address) return parameters.address + return computeAddress8130(getCreateParameters()) + }, + + async getFactoryArgs() { + return toFactoryArgs8130(getCreateParameters()) + }, + + async getStubSignature() { + return concatHex([ + authenticator, + '0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c', + ]) + }, + + async signMessage(parameters_) { + const signature = await getAction( + client, + signMessage_, + 'signMessage', + )({ account: owner, message: parameters_.message }) + return concatHex([authenticator, signature]) + }, + + async signTypedData(parameters_) { + const signature = await getAction( + client, + signTypedData_, + 'signTypedData', + )({ account: owner, ...(parameters_ as any) }) + return concatHex([authenticator, signature]) + }, + + async signUserOperation(parameters_) { + const { chainId = client.chain!.id, ...userOperation } = parameters_ + const address = await this.getAddress() + const userOpHash = getUserOperationHash({ + chainId, + entryPointAddress: entryPoint.address, + entryPointVersion: entryPoint.version, + userOperation: { + ...(userOperation as any), + sender: address, + }, + }) + const signature = await getAction( + client, + signMessage_, + 'signMessage', + )({ account: owner, message: { raw: userOpHash } }) + return concatHex([authenticator, signature]) + }, + }) +} diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index f305d4c52d..53186ecbbe 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -2,9 +2,16 @@ export { accountConfigurationAbi, authenticatorAbi, + erc4337AccountAbi, nonceManagerAbi, transactionContextAbi, } from './abis.js' +export { + type Eip8130SmartAccountImplementation, + type ToSmartAccount8130Parameters, + type ToSmartAccount8130ReturnType, + toSmartAccount8130, +} from './accounts/toSmartAccount8130.js' export { eip8130ChainIds, type Is8130EnabledParameters, @@ -89,6 +96,7 @@ export { type ParseTransaction8130ErrorType, parseTransaction8130, } from './utils/parseTransaction.js' +export { erc1167Bytecode } from './utils/proxy.js' export { encodeActorChangeData, type SerializeTransaction8130ErrorType, diff --git a/src/experimental/eip8130/utils/proxy.ts b/src/experimental/eip8130/utils/proxy.ts new file mode 100644 index 0000000000..c2a1052a84 --- /dev/null +++ b/src/experimental/eip8130/utils/proxy.ts @@ -0,0 +1,16 @@ +import type { Address } from 'abitype' +import type { Hex } from '../../../types/misc.js' +import { concatHex } from '../../../utils/data/concat.js' + +/** + * Builds the 45-byte ERC-1167 minimal proxy runtime bytecode that delegates to + * `implementation`. This is the `code` deployed at an EIP-8130 account address + * (see {@link computeAddress8130} and {@link toFactoryArgs8130}). + */ +export function erc1167Bytecode(implementation: Address): Hex { + return concatHex([ + '0x363d3d373d3d3d363d73', + implementation, + '0x5af43d82803e903d91602b57fd5bf3', + ]) +} From 97b1f6887398b878fe475160eef6d00e2194e662 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 9 Jun 2026 17:22:05 -0400 Subject: [PATCH 07/96] feat(experimental): add ergonomic EIP-8130 DevX (account, keys, sendCalls) High-level surface over the 8130 primitives: - to8130Account: create()/change()/delegate()/signTransaction() lifecycle - key builders (k1/p256/passkey/delegate) + actorIdFromPublicKey (keccak256(x||y)) and scope/policy helpers (toScope, authorizeActor, revokeActor, encodePolicyData) - sendCalls8130 / prepareTransaction8130: prepare (fees + nonce), sign, serialize and submit an AA_TX_TYPE transaction --- .../eip8130/accounts/to8130Account.ts | 141 ++++++++++++++ src/experimental/eip8130/actions/sendCalls.ts | 161 ++++++++++++++++ src/experimental/eip8130/devx.test.ts | 181 ++++++++++++++++++ src/experimental/eip8130/index.ts | 22 +++ src/experimental/eip8130/keys.ts | 137 +++++++++++++ src/experimental/eip8130/utils/actorId.ts | 27 +++ 6 files changed, 669 insertions(+) create mode 100644 src/experimental/eip8130/accounts/to8130Account.ts create mode 100644 src/experimental/eip8130/actions/sendCalls.ts create mode 100644 src/experimental/eip8130/devx.test.ts create mode 100644 src/experimental/eip8130/keys.ts diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts new file mode 100644 index 0000000000..00e9ef024e --- /dev/null +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -0,0 +1,141 @@ +import type { Address } from 'abitype' +import { BaseError } from '../../../errors/base.js' +import type { Hex } from '../../../types/misc.js' +import { + accountConfigAddress as defaultAccountConfigAddress, + ecrecoverAuthenticator, +} from '../constants.js' +import type { + AaAccountChangeConfig, + AaAccountChangeCreate, + AaAccountChangeDelegation, + AaActor, + AaActorChange, + TransactionSerializable8130, + TransactionSerialized8130, +} from '../types/transaction.js' +import { computeAddress8130 } from '../utils/computeAddress.js' +import { signActorChanges8130 } from '../utils/signActorChanges.js' +import { type Signer, signTransaction8130 } from '../utils/signTransaction.js' + +export type To8130AccountParameters = { + /** Signer for the controlling actor (produces `auth` / `sender_auth`). */ + signer: Signer + /** User-chosen uniqueness factor (bytes32). */ + userSalt: Hex + /** Runtime bytecode placed at the account address (e.g. ERC-1167 proxy). */ + code: Hex + /** + * Initial actors registered at creation. MUST be sorted by `actorId` in + * strictly ascending order. + */ + initialActors: readonly AaActor[] + /** + * Authenticator address used for this account's `auth` blobs. Defaults to the + * native `ECRECOVER_AUTHENTICATOR`. + */ + authenticator?: Address | undefined + /** Account Configuration contract (CREATE2 deployer). */ + accountConfigAddress?: Address | undefined + /** Override the derived account address. */ + address?: Address | undefined +} + +export type To8130AccountReturnType = { + readonly address: Address + readonly signer: Signer + readonly initialActors: readonly AaActor[] + /** Builds the `create` account-change entry (place in the first transaction). */ + create(): AaAccountChangeCreate + /** Signs an `authorizeActor` / `revokeActor` set into a `config` entry. */ + change( + actorChanges: readonly AaActorChange[], + options?: { chainId?: number; sequence?: number }, + ): Promise + /** Builds a `delegation` account-change entry. */ + delegate(target: Address): AaAccountChangeDelegation + /** Signs an `AA_TX_TYPE` transaction as this account (configured-actor path). */ + signTransaction( + transaction: TransactionSerializable8130, + options?: { + payer?: { account: Signer; address?: Address } | undefined + }, + ): Promise +} + +/** + * Creates a local EIP-8130 account helper around a signer and an account + * identity (`userSalt` + `code` + `initialActors`). Provides ergonomic builders + * for the account lifecycle: + * + * - `create()` — the `create` account-change entry that deploys the account + * - `change([...])` — a signed `config` entry (authorize / revoke actors) + * - `delegate(target)` — a `delegation` entry + * - `signTransaction(tx)` — signs an `AA_TX_TYPE` transaction as this account + * + * @example + * import { to8130Account, key, authorizeActor, actorScope } from 'viem/experimental' + * + * const account = to8130Account({ + * signer, + * userSalt, + * code: erc1167Bytecode(impl), + * initialActors: [key.k1(signer.address)], + * }) + * + * const create = account.create() + * const change = await account.change([ + * authorizeActor(key.p256({ x, y }), { scope: actorScope.sender }), + * ]) + */ +export function to8130Account( + parameters: To8130AccountParameters, +): To8130AccountReturnType { + const { + signer, + userSalt, + code, + initialActors, + authenticator = ecrecoverAuthenticator, + accountConfigAddress = defaultAccountConfigAddress, + } = parameters + + const address = + parameters.address ?? + computeAddress8130({ userSalt, code, initialActors, accountConfigAddress }) + + return { + address, + signer, + initialActors, + + create() { + return { type: 'create', userSalt, code, initialActors } + }, + + async change(actorChanges, options = {}) { + return signActorChanges8130({ + signer, + account: address, + chainId: options.chainId ?? 0, + sequence: options.sequence ?? 0, + actorChanges, + authenticator, + }) + }, + + delegate(target) { + return { type: 'delegation', target } + }, + + async signTransaction(transaction, options = {}) { + if (!signer.sign) + throw new BaseError('`signer` does not support raw signing.') + return signTransaction8130({ + transaction: { ...transaction, from: transaction.from ?? address }, + account: signer, + payer: options.payer, + }) + }, + } +} diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/experimental/eip8130/actions/sendCalls.ts new file mode 100644 index 0000000000..934821f734 --- /dev/null +++ b/src/experimental/eip8130/actions/sendCalls.ts @@ -0,0 +1,161 @@ +import { estimateFeesPerGas } from '../../../actions/public/estimateFeesPerGas.js' +import { readContract } from '../../../actions/public/readContract.js' +import { sendRawTransaction } from '../../../actions/wallet/sendRawTransaction.js' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import { BaseError } from '../../../errors/base.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import type { Hex } from '../../../types/misc.js' +import { getAction } from '../../../utils/getAction.js' +import { nonceManagerAbi } from '../abis.js' +import type { To8130AccountReturnType } from '../accounts/to8130Account.js' +import { nonceManagerAddress as defaultNonceManagerAddress } from '../constants.js' +import type { + AaAccountChange, + AaCall, + AaCalls, + TransactionSerializable8130, +} from '../types/transaction.js' +import type { Signer } from '../utils/signTransaction.js' + +type FeeOverrides = { + maxFeePerGas?: bigint | undefined + maxPriorityFeePerGas?: bigint | undefined +} + +export type PrepareTransaction8130Parameters = FeeOverrides & { + account: To8130AccountReturnType + /** Ordered call phases. */ + calls: AaCalls + accountChanges?: readonly AaAccountChange[] | undefined + payer?: { account: Signer; address?: `0x${string}` } | undefined + /** Required gas budget (AA_TX_TYPE gas estimation is node-specific). */ + gas: bigint + nonceKey?: bigint | undefined + nonceSequence?: bigint | undefined + expiry?: bigint | undefined + /** Override the Nonce Manager precompile address. */ + nonceManagerAddress?: `0x${string}` | undefined +} + +/** + * Builds a fully-populated {@link TransactionSerializable8130} for an + * `AA_TX_TYPE` transaction, filling chain id, nonce sequence (from the Nonce + * Manager precompile), and EIP-1559 fees from the client when not provided. + */ +export async function prepareTransaction8130( + client: Client, + parameters: PrepareTransaction8130Parameters, +): Promise { + const { + account, + calls, + accountChanges, + payer, + gas, + expiry, + nonceKey = 0n, + nonceManagerAddress = defaultNonceManagerAddress, + } = parameters + + const chainId = client.chain?.id + if (!chainId) + throw new BaseError('`client` must be configured with a `chain`.') + + let { maxFeePerGas, maxPriorityFeePerGas } = parameters + if (maxFeePerGas === undefined || maxPriorityFeePerGas === undefined) { + const fees = await getAction( + client, + estimateFeesPerGas, + 'estimateFeesPerGas', + )({}) + maxFeePerGas ??= fees.maxFeePerGas + maxPriorityFeePerGas ??= fees.maxPriorityFeePerGas + } + + let nonceSequence = parameters.nonceSequence + if (nonceSequence === undefined) + nonceSequence = BigInt( + await getAction( + client, + readContract, + 'readContract', + )({ + abi: nonceManagerAbi, + address: nonceManagerAddress, + functionName: 'getNonce', + args: [account.address, nonceKey], + }), + ) + + return { + chainId, + from: account.address, + nonceKey, + nonceSequence, + maxFeePerGas, + maxPriorityFeePerGas, + gas, + expiry, + accountChanges, + calls, + payer: payer?.address ?? payer?.account.address, + } +} + +export type SendCalls8130Parameters = FeeOverrides & { + account: To8130AccountReturnType + /** + * Calls to execute. A flat list runs as a single atomic phase; pass a nested + * array to control phases explicitly. + */ + calls: readonly AaCall[] | AaCalls + accountChanges?: readonly AaAccountChange[] | undefined + payer?: { account: Signer; address?: `0x${string}` } | undefined + gas: bigint + nonceKey?: bigint | undefined + nonceSequence?: bigint | undefined + expiry?: bigint | undefined + nonceManagerAddress?: `0x${string}` | undefined +} + +function toPhases(calls: SendCalls8130Parameters['calls']): AaCalls { + if (calls.length === 0) return [] + // Already phased (array of arrays)? + if (Array.isArray(calls[0])) return calls as AaCalls + return [calls as readonly AaCall[]] +} + +/** + * Sends an EIP-8130 (`AA_TX_TYPE`) transaction for an account: prepares the + * transaction body, signs `sender_auth` (and `payer_auth` when sponsored), + * serializes, and submits via `eth_sendRawTransaction`. + * + * @example + * const hash = await sendCalls8130(client, { + * account, + * calls: [{ to, data }], + * gas: 200_000n, + * }) + */ +export async function sendCalls8130( + client: Client, + parameters: SendCalls8130Parameters, +): Promise { + const { account, calls, payer, ...rest } = parameters + const transaction = await prepareTransaction8130(client, { + ...rest, + account, + calls: toPhases(calls), + payer, + }) + const serializedTransaction = await account.signTransaction(transaction, { + payer, + }) + return getAction( + client, + sendRawTransaction, + 'sendRawTransaction', + )({ serializedTransaction }) +} diff --git a/src/experimental/eip8130/devx.test.ts b/src/experimental/eip8130/devx.test.ts new file mode 100644 index 0000000000..3d069e6b32 --- /dev/null +++ b/src/experimental/eip8130/devx.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { mainnet } from '../../chains/index.js' +import { createClient } from '../../clients/createClient.js' +import { custom } from '../../clients/transports/custom.js' +import type { Hex } from '../../types/misc.js' +import { keccak256 } from '../../utils/hash/keccak256.js' +import { to8130Account } from './accounts/to8130Account.js' +import { sendCalls8130 } from './actions/sendCalls.js' +import { actorScope, canonicalAuthenticators } from './constants.js' +import { + authorizeActor, + encodePolicyData, + key, + revokeActor, + toScope, +} from './keys.js' +import { actorIdFromAddress, actorIdFromPublicKey } from './utils/actorId.js' +import { parseTransaction8130 } from './utils/parseTransaction.js' +import { erc1167Bytecode } from './utils/proxy.js' + +const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const code = erc1167Bytecode('0x00000000000000000000000000000000000000Ec') +const userSalt = + '0x0000000000000000000000000000000000000000000000000000000000000001' + +const pubkey = { + x: '0x1111111111111111111111111111111111111111111111111111111111111111', + y: '0x2222222222222222222222222222222222222222222222222222222222222222', +} as const + +describe('key builders + actorId derivation', () => { + test('k1 actor', () => { + expect(key.k1(owner.address)).toEqual({ + actorId: actorIdFromAddress(owner.address), + authenticator: canonicalAuthenticators.k1, + }) + }) + + test('p256 actor derives actorId = keccak256(x || y)', () => { + const actor = key.p256(pubkey) + expect(actor.authenticator).toBe(canonicalAuthenticators.p256) + expect(actor.actorId).toBe(actorIdFromPublicKey(pubkey)) + expect(actor.actorId).toBe(keccak256(`${pubkey.x}${pubkey.y.slice(2)}`)) + }) +}) + +describe('scope + policy helpers', () => { + test('toScope combines flags', () => { + expect(toScope(actorScope.sender, actorScope.payer)).toBe(0x06) + }) + + test('encodePolicyData = manager || commitment', () => { + const manager = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' + const commitment = + '0x00000000000000000000000000000000000000000000000000000000000000aa' + const data = encodePolicyData({ type: 1, manager, commitment }) + expect(data.toLowerCase()).toBe( + `${manager.toLowerCase()}${commitment.slice(2)}`, + ) + }) + + test('authorizeActor rejects unrestricted / CONFIG-scoped policy actor', () => { + const commitment = `0x${'aa'.repeat(32)}` as const + const policy = { + type: 1, + manager: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + commitment, + } as const + expect(() => authorizeActor(key.p256(pubkey), { policy })).toThrow() + expect(() => + authorizeActor(key.p256(pubkey), { scope: actorScope.config, policy }), + ).toThrow() + // sender-only is allowed + const change = authorizeActor(key.p256(pubkey), { + scope: actorScope.sender, + policy, + }) + expect(change.policyType).toBe(1) + expect(change.scope).toBe(actorScope.sender) + }) +}) + +describe('to8130Account', () => { + const account = to8130Account({ + signer: owner, + userSalt, + code, + initialActors: [key.k1(owner.address)], + }) + + test('create() entry', () => { + expect(account.create()).toEqual({ + type: 'create', + userSalt, + code, + initialActors: [key.k1(owner.address)], + }) + }) + + test('change() produces a signed config entry (add p256 session key)', async () => { + const change = await account.change([ + authorizeActor(key.p256(pubkey), { + scope: actorScope.sender, + policy: { + type: 1, + manager: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + commitment: `0x${'aa'.repeat(32)}`, + }, + }), + revokeActor(key.k1('0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC')), + ]) + expect(change.type).toBe('config') + expect(change.actorChanges).toHaveLength(2) + // auth = ecrecover authenticator (20 bytes) || 65-byte sig = 85 bytes + expect(change.auth.length).toBe(2 + 85 * 2) + }) + + test('delegate() entry', () => { + expect( + account.delegate('0x0000000000000000000000000000000000000000'), + ).toEqual({ + type: 'delegation', + target: '0x0000000000000000000000000000000000000000', + }) + }) +}) + +describe('sendCalls8130', () => { + let sent: Hex | undefined + const client = createClient({ + chain: mainnet, + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_sendRawTransaction') { + sent = params[0] + return keccak256(params[0]) + } + throw new Error(`unexpected RPC: ${method}`) + }, + }), + }) + const account = to8130Account({ + signer: owner, + userSalt, + code, + initialActors: [key.k1(owner.address)], + }) + + test('builds, signs, serializes and submits an AA_TX_TYPE tx', async () => { + const hash = await sendCalls8130(client, { + account, + calls: [ + { to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }, + { + to: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', + data: '0xdeadbeef', + }, + ], + accountChanges: [account.create()], + // explicit values to keep the test offline + gas: 200_000n, + maxFeePerGas: 2_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonceSequence: 0n, + }) + expect(hash).toMatch(/^0x[0-9a-f]{64}$/) + expect(sent?.startsWith('0x7b')).toBe(true) + + const parsed = parseTransaction8130(sent!) + expect(parsed.from?.toLowerCase()).toBe(account.address.toLowerCase()) + expect(parsed.chainId).toBe(1) + expect(parsed.calls).toHaveLength(1) // single atomic phase + expect(parsed.calls?.[0]).toHaveLength(2) + expect(parsed.accountChanges?.[0]?.type).toBe('create') + expect(parsed.senderAuth).toBeDefined() + }) +}) diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 53186ecbbe..f1ac750eac 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -6,12 +6,23 @@ export { nonceManagerAbi, transactionContextAbi, } from './abis.js' +export { + type To8130AccountParameters, + type To8130AccountReturnType, + to8130Account, +} from './accounts/to8130Account.js' export { type Eip8130SmartAccountImplementation, type ToSmartAccount8130Parameters, type ToSmartAccount8130ReturnType, toSmartAccount8130, } from './accounts/toSmartAccount8130.js' +export { + type PrepareTransaction8130Parameters, + prepareTransaction8130, + type SendCalls8130Parameters, + sendCalls8130, +} from './actions/sendCalls.js' export { eip8130ChainIds, type Is8130EnabledParameters, @@ -37,6 +48,15 @@ export { revokedAuthenticator, txContextAddress, } from './constants.js' +export { + type AuthorizeActorOptions, + authorizeActor, + encodePolicyData, + key, + type Policy, + revokeActor, + toScope, +} from './keys.js' export type { AaAccountChange, AaAccountChangeConfig, @@ -65,7 +85,9 @@ export { } from './utils/accountConfigCalls.js' export { type ActorIdFromAddressErrorType, + type ActorIdFromPublicKeyErrorType, actorIdFromAddress, + actorIdFromPublicKey, } from './utils/actorId.js' export { type AssertTransaction8130ErrorType, diff --git a/src/experimental/eip8130/keys.ts b/src/experimental/eip8130/keys.ts new file mode 100644 index 0000000000..3b8d2fcb9b --- /dev/null +++ b/src/experimental/eip8130/keys.ts @@ -0,0 +1,137 @@ +import type { Address } from 'abitype' +import { BaseError } from '../../errors/base.js' +import type { Hex } from '../../types/misc.js' +import { concatHex } from '../../utils/data/concat.js' +import { pad } from '../../utils/data/pad.js' +import { size } from '../../utils/data/size.js' +import { + actorScope, + canonicalAuthenticators, + ecrecoverAuthenticator, +} from './constants.js' +import type { + AaActor, + AaAuthorizeActor, + AaRevokeActor, +} from './types/transaction.js' +import { actorIdFromAddress, actorIdFromPublicKey } from './utils/actorId.js' + +/** + * Builders for canonical-authenticator actors. Each returns an {@link AaActor} + * (`{ actorId, authenticator }`) for use as an initial actor or as the identity + * of an `authorizeActor` change (see {@link authorizeActor}). + */ +export const key = { + /** secp256k1 (native ecrecover) actor for `address`. */ + k1(address: Address): AaActor { + return { + actorId: actorIdFromAddress(address), + authenticator: ecrecoverAuthenticator, + } + }, + /** P-256 actor for a public key (`{ x, y }` or 64-byte `x || y`). */ + p256( + publicKey: { x: Hex; y: Hex } | Hex, + options: { authenticator?: Address } = {}, + ): AaActor { + return { + actorId: actorIdFromPublicKey(publicKey), + authenticator: options.authenticator ?? canonicalAuthenticators.p256, + } + }, + /** WebAuthn / FIDO2 passkey actor for a public key. */ + passkey( + publicKey: { x: Hex; y: Hex } | Hex, + options: { authenticator?: Address } = {}, + ): AaActor { + return { + actorId: actorIdFromPublicKey(publicKey), + authenticator: options.authenticator ?? canonicalAuthenticators.passkey, + } + }, + /** Delegate actor: signatures valid for `delegatedAccount` act for this account. */ + delegate( + delegatedAccount: Address, + options: { authenticator?: Address } = {}, + ): AaActor { + return { + actorId: actorIdFromAddress(delegatedAccount), + authenticator: options.authenticator ?? canonicalAuthenticators.delegate, + } + }, +} as const + +/** Combines {@link actorScope} flags into a single scope bitmask. */ +export function toScope(...flags: number[]): number { + return flags.reduce((acc, flag) => acc | flag, 0) +} + +export type Policy = { + /** Non-zero policy selector (interpreted by the manager, not the protocol). */ + type: number + /** Manager contract the policy-bearing actor is gated to call. */ + manager: Address + /** 32-byte commitment (`keccak256` of the policy parameters). */ + commitment: Hex +} + +/** Encodes a {@link Policy} into the wire `policyData` blob (`manager || commitment`). */ +export function encodePolicyData(policy: Policy): Hex { + if (policy.type === 0) + throw new BaseError('`policy.type` must be non-zero (0 = no policy).') + if (size(policy.commitment) !== 32) + throw new BaseError('`policy.commitment` must be 32 bytes.') + return concatHex([pad(policy.manager, { size: 20 }), policy.commitment]) +} + +export type AuthorizeActorOptions = { + /** Permission bitmask (see {@link actorScope}/{@link toScope}). `0` = unrestricted. */ + scope?: number | undefined + /** Actor expiry (unix seconds). `0`/omitted = no expiry. */ + expiry?: bigint | undefined + /** Policy gate. Omit for an unrestricted (non-policy) actor. */ + policy?: Policy | undefined +} + +/** + * Builds an `authorizeActor` change from a {@link key} actor plus scope, expiry, + * and optional policy. The result can be signed via `signActorChanges8130` / + * `to8130Account#authorize`. + * + * @example + * authorizeActor(key.p256({ x, y }), { + * scope: actorScope.sender, + * policy: { type: 1, manager, commitment }, + * }) + */ +export function authorizeActor( + actor: AaActor, + options: AuthorizeActorOptions = {}, +): AaAuthorizeActor { + const change: AaAuthorizeActor = { + changeType: 0x01, + actorId: actor.actorId, + authenticator: actor.authenticator, + } + if (options.scope) change.scope = options.scope + if (options.expiry) change.expiry = options.expiry + if (options.policy) { + if ( + options.scope === undefined || + options.scope === 0 || + (options.scope & actorScope.config) !== 0 + ) + throw new BaseError( + 'A policy-bearing actor MUST have a restricted scope that excludes CONFIG (e.g. `actorScope.sender`).', + ) + change.policyType = options.policy.type + change.policyData = encodePolicyData(options.policy) + } + return change +} + +/** Builds a `revokeActor` change for an actor (or raw `actorId`). */ +export function revokeActor(actor: AaActor | Hex): AaRevokeActor { + const actorId = typeof actor === 'string' ? actor : actor.actorId + return { changeType: 0x02, actorId } +} diff --git a/src/experimental/eip8130/utils/actorId.ts b/src/experimental/eip8130/utils/actorId.ts index c21145206b..55255d7d22 100644 --- a/src/experimental/eip8130/utils/actorId.ts +++ b/src/experimental/eip8130/utils/actorId.ts @@ -1,7 +1,11 @@ import type { Address } from 'abitype' +import { BaseError } from '../../../errors/base.js' import type { ErrorType } from '../../../errors/utils.js' import type { Hex } from '../../../types/misc.js' +import { concatHex } from '../../../utils/data/concat.js' import { type PadErrorType, pad } from '../../../utils/data/pad.js' +import { size } from '../../../utils/data/size.js' +import { keccak256 } from '../../../utils/hash/keccak256.js' export type ActorIdFromAddressErrorType = PadErrorType | ErrorType @@ -15,3 +19,26 @@ export type ActorIdFromAddressErrorType = PadErrorType | ErrorType export function actorIdFromAddress(address: Address): Hex { return pad(address, { dir: 'right', size: 32 }) } + +export type ActorIdFromPublicKeyErrorType = BaseError | ErrorType + +/** + * Derives the `actorId` for a public-key actor (`p256`, `passkey`): + * `keccak256(abi.encodePacked(x, y))`. + * + * Accepts the affine coordinates as 32-byte hex values, or a single 64-byte + * concatenated `x || y` public key. + */ +export function actorIdFromPublicKey(publicKey: { x: Hex; y: Hex } | Hex): Hex { + if (typeof publicKey === 'string') { + if (size(publicKey) !== 64) + throw new BaseError( + `Public key must be 64 bytes (x || y); got ${size(publicKey)} bytes.`, + ) + return keccak256(publicKey) + } + const { x, y } = publicKey + if (size(x) !== 32 || size(y) !== 32) + throw new BaseError('Public key coordinates `x` and `y` must be 32 bytes.') + return keccak256(concatHex([x, y])) +} From 688510ca951f2dd638f3b59e6ff37525f5e45e49 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 9 Jun 2026 17:33:45 -0400 Subject: [PATCH 08/96] feat(experimental): add ERC-8168 payer web service client Ties payer sponsorship into the 8130 transaction flow: - createPayerClient: payer_* JSON-RPC (getTerms/sendTransaction/ signTransaction/getBalance/getSponsorshipOptions/getCapabilities) - buildSponsoredCalls: phase construction from terms (full sponsorship, token payment, required calls) + ERC-20 transfer encoding - sendSponsoredCalls: getTerms -> build -> prepare -> sender co-sign (payer_auth empty) -> payer_sendTransaction / payer_signTransaction - full ERC-8168 types + error codes; viem/experimental/eip8168 subpath --- .../eip8168/actions/sendSponsoredCalls.ts | 142 ++++++++++++ src/experimental/eip8168/client.ts | 110 +++++++++ src/experimental/eip8168/constants.ts | 27 +++ src/experimental/eip8168/eip8168.test.ts | 215 ++++++++++++++++++ src/experimental/eip8168/index.ts | 41 ++++ src/experimental/eip8168/types.ts | 192 ++++++++++++++++ .../eip8168/utils/buildSponsoredCalls.ts | 110 +++++++++ src/package.json | 11 + 8 files changed, 848 insertions(+) create mode 100644 src/experimental/eip8168/actions/sendSponsoredCalls.ts create mode 100644 src/experimental/eip8168/client.ts create mode 100644 src/experimental/eip8168/constants.ts create mode 100644 src/experimental/eip8168/eip8168.test.ts create mode 100644 src/experimental/eip8168/index.ts create mode 100644 src/experimental/eip8168/types.ts create mode 100644 src/experimental/eip8168/utils/buildSponsoredCalls.ts diff --git a/src/experimental/eip8168/actions/sendSponsoredCalls.ts b/src/experimental/eip8168/actions/sendSponsoredCalls.ts new file mode 100644 index 0000000000..1992da5c3c --- /dev/null +++ b/src/experimental/eip8168/actions/sendSponsoredCalls.ts @@ -0,0 +1,142 @@ +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import { BaseError } from '../../../errors/base.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import { hexToBigInt } from '../../../utils/encoding/fromHex.js' +import { numberToHex } from '../../../utils/encoding/toHex.js' +import type { To8130AccountReturnType } from '../../eip8130/accounts/to8130Account.js' +import { prepareTransaction8130 } from '../../eip8130/actions/sendCalls.js' +import type { AaCall } from '../../eip8130/types/transaction.js' +import type { PayerClient } from '../client.js' +import type { + GetTermsReturnType, + SendTransactionReturnType, + SignTransactionReturnType, +} from '../types.js' +import { buildSponsoredCalls } from '../utils/buildSponsoredCalls.js' + +export type SendSponsoredCallsParameters = { + /** The sending account (signs `sender_auth`). */ + account: To8130AccountReturnType + /** Payer service client (ERC-8168). */ + payerClient: PayerClient + /** User's intended calls (run in the final phase). */ + calls: readonly AaCall[] + /** + * `"send"` (default) asks the payer to co-sign and submit; `"sign"` asks the + * payer to co-sign and return the transaction for the wallet to submit. + */ + mode?: 'send' | 'sign' | undefined + /** Pre-fetched terms. When omitted, `payer_getTerms` is called. */ + terms?: GetTermsReturnType | undefined + /** Token to pay with (token-payment terms). Defaults to the first option. */ + token?: `0x${string}` | undefined + /** Opaque app context forwarded to `payer_*` calls (e.g. `policyId`). */ + context?: Record | undefined + /** Override transaction expiry (defaults to `conditions.maxExpiry`). */ + expiry?: bigint | undefined + /** Override gas (defaults to the payer's `gasEstimate.gasLimit`). */ + gas?: bigint | undefined + maxFeePerGas?: bigint | undefined + maxPriorityFeePerGas?: bigint | undefined + nonceKey?: bigint | undefined + nonceSequence?: bigint | undefined +} + +export type SendSponsoredCallsReturnType = + | SendTransactionReturnType + | SignTransactionReturnType + +/** + * End-to-end ERC-8168 sponsored-transaction flow: + * + * 1. Fetch terms (`payer_getTerms`) unless provided. + * 2. Build the phase-0 token transfer / required calls + user calls. + * 3. Prepare the EIP-8130 transaction (nonce, gas from the payer's estimate). + * 4. Sign `sender_auth` with `payer` set and `payer_auth` left empty. + * 5. Hand to the payer via `payer_sendTransaction` (submit) or + * `payer_signTransaction` (co-sign only). + * + * @example + * const { transactionHash } = await sendSponsoredCalls(client, { + * account, + * payerClient, + * calls: [{ to, data }], + * }) + */ +export async function sendSponsoredCalls( + client: Client, + parameters: SendSponsoredCallsParameters, +): Promise { + const { + account, + payerClient, + calls, + mode = 'send', + token, + context, + } = parameters + + const chainId = client.chain?.id + if (!chainId) + throw new BaseError('`client` must be configured with a `chain`.') + + const terms = + parameters.terms ?? + (await payerClient.getTerms({ + chainId: numberToHex(chainId), + from: account.address, + calls: calls.map((call) => ({ to: call.to, data: call.data ?? '0x' })), + ...(token ? { preferredTokens: [token] } : {}), + ...(context ? { context } : {}), + })) + + const built = buildSponsoredCalls({ terms, calls, token }) + + const gas = + parameters.gas ?? + (terms.gasEstimate ? hexToBigInt(terms.gasEstimate.gasLimit) : undefined) + if (gas === undefined) + throw new BaseError( + 'Unable to determine `gas`: terms carry no `gasEstimate.gasLimit` and no `gas` override was provided.', + ) + + const maxFeePerGas = + parameters.maxFeePerGas ?? + (terms.gasEstimate + ? hexToBigInt(terms.gasEstimate.maxFeePerGas) + : undefined) + const maxPriorityFeePerGas = + parameters.maxPriorityFeePerGas ?? + (terms.gasEstimate + ? hexToBigInt(terms.gasEstimate.maxPriorityFeePerGas) + : undefined) + + const expiry = + parameters.expiry ?? + (terms.conditions?.maxExpiry !== undefined + ? BigInt(terms.conditions.maxExpiry) + : undefined) + + const transaction = await prepareTransaction8130(client, { + account, + calls: built.calls, + gas, + maxFeePerGas, + maxPriorityFeePerGas, + expiry, + nonceKey: parameters.nonceKey, + nonceSequence: parameters.nonceSequence, + }) + + // Sender co-signs; the payer fills `payer_auth`. + transaction.payer = built.payer + transaction.payerAuth = '0x' + + const signedTransaction = await account.signTransaction(transaction) + + if (mode === 'sign') + return payerClient.signTransaction({ signedTransaction, context }) + return payerClient.sendTransaction({ signedTransaction, context }) +} diff --git a/src/experimental/eip8168/client.ts b/src/experimental/eip8168/client.ts new file mode 100644 index 0000000000..38e75eb3d5 --- /dev/null +++ b/src/experimental/eip8168/client.ts @@ -0,0 +1,110 @@ +import { createClient } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import { http } from '../../clients/transports/http.js' +import type { + GetBalanceParameters, + GetBalanceReturnType, + GetCapabilitiesParameters, + GetCapabilitiesReturnType, + GetSponsorshipOptionsParameters, + GetSponsorshipOptionsReturnType, + GetTermsParameters, + GetTermsReturnType, + SendTransactionParameters, + SendTransactionReturnType, + SignTransactionParameters, + SignTransactionReturnType, +} from './types.js' + +export type CreatePayerClientParameters = { + /** Payer service endpoint URL (path-versioned, e.g. `https://payer.example.com/v1`). */ + url?: string | undefined + /** + * Transport to reach the payer service. Defaults to `http(url)`. Provide a + * custom transport to inject auth headers or for testing. + */ + transport?: Transport | undefined +} + +export type PayerClient = { + /** Sponsorship/token-payment terms for a transaction intent (pre-signature). */ + getTerms(parameters: GetTermsParameters): Promise + /** Co-sign a sender-signed EIP-8130 transaction and submit it. */ + sendTransaction( + parameters: SendTransactionParameters, + ): Promise + /** Co-sign a sender-signed EIP-8130 transaction and return it (no submit). */ + signTransaction( + parameters: SignTransactionParameters, + ): Promise + /** Standing, intent-free balances (sponsorship allowance / prepaid credit). */ + getBalance(parameters: GetBalanceParameters): Promise + /** Ranked sponsorship options for a transaction intent. */ + getSponsorshipOptions( + parameters: GetSponsorshipOptionsParameters, + ): Promise + /** Static, intent-free descriptor of what the payer accepts. */ + getCapabilities( + parameters?: GetCapabilitiesParameters, + ): Promise +} + +/** + * Creates a client for an ERC-8168 payer web service. Wraps the `payer_*` + * JSON-RPC methods used by wallets to negotiate and obtain gas sponsorship / + * token payment for EIP-8130 transactions. + * + * @example + * import { createPayerClient } from 'viem/experimental' + * + * const payer = createPayerClient({ url: 'https://payer.example.com/v1' }) + * const terms = await payer.getTerms({ chainId: '0x2105', from, calls }) + */ +export function createPayerClient( + parameters: CreatePayerClientParameters, +): PayerClient { + const { url, transport = http(url) } = parameters + if (!url && !parameters.transport) + throw new Error('`url` or `transport` is required.') + + const { request } = createClient({ transport }) + + return { + getTerms(params) { + return request({ + method: 'payer_getTerms', + params: [params], + }) as Promise + }, + sendTransaction(params) { + return request({ + method: 'payer_sendTransaction', + params: [params], + }) as Promise + }, + signTransaction(params) { + return request({ + method: 'payer_signTransaction', + params: [params], + }) as Promise + }, + getBalance(params) { + return request({ + method: 'payer_getBalance', + params: [params], + }) as Promise + }, + getSponsorshipOptions(params) { + return request({ + method: 'payer_getSponsorshipOptions', + params: [params], + }) as Promise + }, + getCapabilities(params) { + return request({ + method: 'payer_getCapabilities', + params: [params ?? {}], + }) as Promise + }, + } +} diff --git a/src/experimental/eip8168/constants.ts b/src/experimental/eip8168/constants.ts new file mode 100644 index 0000000000..062fca3db9 --- /dev/null +++ b/src/experimental/eip8168/constants.ts @@ -0,0 +1,27 @@ +/** + * ERC-8168 payer-service JSON-RPC error codes. Payer services use these when + * rejecting requests; responses SHOULD include actionable `data`. + */ +export const payerErrorCode = { + /** Malformed or invalid EIP-8130 transaction. */ + invalidTransaction: -32600, + /** Token in the phase-0 transfer not accepted by this payer. */ + unsupportedToken: -32601, + /** Terms from `payer_getTerms` have expired; re-request. */ + rateExpired: -32602, + /** Token transfer amount in phase 0 is below the required cost. */ + paymentInsufficient: -32603, + /** Transaction expiry does not satisfy payer conditions. */ + expiryOutOfBounds: -32604, + /** Payer policy rejected this transaction. */ + policyRejected: -32605, + /** Payer lacks ETH to cover gas. */ + payerBalanceInsufficient: -32606, + /** Sender is blocklisted for the specified token. */ + senderBlocklisted: -32607, + /** Sender's sponsorship budget or prepaid credit is depleted. */ + balanceExhausted: -32608, +} as const + +export type PayerErrorCode = + (typeof payerErrorCode)[keyof typeof payerErrorCode] diff --git a/src/experimental/eip8168/eip8168.test.ts b/src/experimental/eip8168/eip8168.test.ts new file mode 100644 index 0000000000..a696d1d645 --- /dev/null +++ b/src/experimental/eip8168/eip8168.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { mainnet } from '../../chains/index.js' +import { createClient } from '../../clients/createClient.js' +import { custom } from '../../clients/transports/custom.js' +import { erc20Abi } from '../../constants/abis.js' +import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' +import { hexToBigInt } from '../../utils/encoding/fromHex.js' +import { keccak256 } from '../../utils/hash/keccak256.js' +import { to8130Account } from '../eip8130/accounts/to8130Account.js' +import { key } from '../eip8130/keys.js' +import { parseTransaction8130 } from '../eip8130/utils/parseTransaction.js' +import { erc1167Bytecode } from '../eip8130/utils/proxy.js' +import { sendSponsoredCalls } from './actions/sendSponsoredCalls.js' +import { createPayerClient } from './client.js' +import type { GetTermsReturnType } from './types.js' +import { buildSponsoredCalls } from './utils/buildSponsoredCalls.js' + +const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const account = to8130Account({ + signer: owner, + userSalt: `0x${'01'.padStart(64, '0')}`, + code: erc1167Bytecode('0x00000000000000000000000000000000000000Ec'), + initialActors: [key.k1(owner.address)], +}) +const PAYER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' as const +const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const +const userCalls = [ + { to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' as const }, +] + +const gasEstimate = { + gasLimit: '0xC350', + maxFeePerGas: '0x59682F00', + maxPriorityFeePerGas: '0x59682F00', +} as const + +const sponsoredTerms: GetTermsReturnType = { + sponsored: true, + expiry: 1735689900, + ttl: 300, + gasEstimate, + conditions: { maxExpiry: 1735689720 }, + payer: PAYER, + endpoint: 'https://payer.example.com/v1', +} + +const tokenTerms: GetTermsReturnType = { + sponsored: false, + expiry: 1735689900, + ttl: 300, + gasEstimate, + tokenOptions: [ + { + token: USDC, + symbol: 'USDC', + decimals: 6, + maxCost: '0x30D40', + rate: { numerator: '0x7A308480', denominator: '0xDE0B6B3A7640000' }, + rateExpiry: 1735689720, + }, + ], + conditions: { maxExpiry: 1735689720 }, + payer: PAYER, + endpoint: 'https://payer.example.com/v1', +} + +describe('buildSponsoredCalls', () => { + test('full sponsorship -> single phase, no transfer', () => { + const built = buildSponsoredCalls({ + terms: sponsoredTerms, + calls: userCalls, + }) + expect(built.payer).toBe(PAYER) + expect(built.calls).toEqual([userCalls]) + expect(built.maxCost).toBeUndefined() + }) + + test('token payment -> phase 0 transfer(payer, maxCost) + phase 1 user calls', () => { + const built = buildSponsoredCalls({ terms: tokenTerms, calls: userCalls }) + expect(built.maxCost).toBe(hexToBigInt('0x30D40')) + expect(built.calls).toHaveLength(2) + const transfer = built.calls[0][0] + expect(transfer.to).toBe(USDC) + const decoded = decodeFunctionData({ abi: erc20Abi, data: transfer.data! }) + expect(decoded.functionName).toBe('transfer') + expect(decoded.args[0]).toBe(PAYER) + expect(decoded.args[1]).toBe(hexToBigInt('0x30D40')) + expect(built.calls[1]).toEqual(userCalls) + }) + + test('required calls are prepended to phase 0', () => { + const built = buildSponsoredCalls({ + terms: { + ...sponsoredTerms, + requiredCalls: [{ to: PAYER, data: '0xdeadbeef' }], + }, + calls: userCalls, + }) + expect(built.calls).toHaveLength(2) + expect(built.calls[0]).toEqual([{ to: PAYER, data: '0xdeadbeef' }]) + }) + + test('throws when unsponsored with no token options', () => { + expect(() => + buildSponsoredCalls({ + terms: { ...tokenTerms, tokenOptions: [] }, + calls: userCalls, + }), + ).toThrow() + }) +}) + +describe('createPayerClient', () => { + test('maps methods to payer_* JSON-RPC', async () => { + const seen: { method: string; params: any }[] = [] + const payer = createPayerClient({ + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + seen.push({ method, params }) + if (method === 'payer_getTerms') return sponsoredTerms + if (method === 'payer_getBalance') return { balances: [], ttl: 30 } + throw new Error(`unexpected ${method}`) + }, + }), + }) + const terms = await payer.getTerms({ + chainId: '0x1', + from: owner.address, + calls: userCalls, + }) + expect(terms.payer).toBe(PAYER) + expect(seen[0].method).toBe('payer_getTerms') + expect(seen[0].params[0].from).toBe(owner.address) + + await payer.getBalance({ from: owner.address, kind: ['credit'] }) + expect(seen[1].method).toBe('payer_getBalance') + }) +}) + +describe('sendSponsoredCalls (end-to-end)', () => { + function makeClient() { + return createClient({ + chain: mainnet, + transport: custom({ + async request({ method }: { method: string }) { + if (method === 'eth_chainId') return '0x1' + throw new Error(`unexpected chain RPC: ${method}`) + }, + }), + }) + } + + test('full sponsorship: sender-signs, payer relays', async () => { + let relayed: `0x${string}` | undefined + const payer = createPayerClient({ + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'payer_getTerms') return sponsoredTerms + if (method === 'payer_sendTransaction') { + relayed = params[0].signedTransaction + return { transactionHash: keccak256(params[0].signedTransaction) } + } + throw new Error(`unexpected ${method}`) + }, + }), + }) + + const result = await sendSponsoredCalls(makeClient(), { + account, + payerClient: payer, + calls: userCalls, + nonceSequence: 0n, + }) + expect(result).toHaveProperty('transactionHash') + + const parsed = parseTransaction8130(relayed!) + expect(parsed.payer?.toLowerCase()).toBe(PAYER.toLowerCase()) + expect(parsed.payerAuth ?? '0x').toBe('0x') // payer fills this in + expect(parsed.senderAuth).toBeDefined() + expect(parsed.calls).toHaveLength(1) // single phase, full sponsorship + expect(parsed.calls?.[0]?.[0]?.to).toBe(userCalls[0].to.toLowerCase()) + expect(parsed.gas).toBe(hexToBigInt(gasEstimate.gasLimit)) + expect(parsed.expiry).toBe(BigInt(1735689720)) // conditions.maxExpiry + }) + + test('token payment: phase 0 transfer present; co-sign mode returns signed tx', async () => { + const payer = createPayerClient({ + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'payer_getTerms') return tokenTerms + if (method === 'payer_signTransaction') + return { signedTransaction: params[0].signedTransaction } + throw new Error(`unexpected ${method}`) + }, + }), + }) + + const result = await sendSponsoredCalls(makeClient(), { + account, + payerClient: payer, + calls: userCalls, + mode: 'sign', + nonceSequence: 0n, + }) + expect(result).toHaveProperty('signedTransaction') + const parsed = parseTransaction8130( + (result as { signedTransaction: `0x${string}` }).signedTransaction, + ) + expect(parsed.calls).toHaveLength(2) + expect(parsed.calls?.[0]?.[0]?.to).toBe(USDC.toLowerCase()) + }) +}) diff --git a/src/experimental/eip8168/index.ts b/src/experimental/eip8168/index.ts new file mode 100644 index 0000000000..2c405af496 --- /dev/null +++ b/src/experimental/eip8168/index.ts @@ -0,0 +1,41 @@ +// biome-ignore lint/performance/noBarrelFile: entrypoint +export { + type SendSponsoredCallsParameters, + type SendSponsoredCallsReturnType, + sendSponsoredCalls, +} from './actions/sendSponsoredCalls.js' +export { + type CreatePayerClientParameters, + createPayerClient, + type PayerClient, +} from './client.js' +export { type PayerErrorCode, payerErrorCode } from './constants.js' +export type { + GetBalanceParameters, + GetBalanceReturnType, + GetCapabilitiesParameters, + GetCapabilitiesReturnType, + GetSponsorshipOptionsParameters, + GetSponsorshipOptionsReturnType, + GetTermsParameters, + GetTermsReturnType, + PayerBalance, + PayerChainCapabilities, + PayerConditions, + PayerGasEstimate, + PayerRpcCall, + PayerSponsor, + PayerTokenOption, + SendTransactionParameters, + SendTransactionReturnType, + SignTransactionParameters, + SignTransactionReturnType, + SponsorshipOption, + TokenCharged, +} from './types.js' +export { + type BuildSponsoredCallsParameters, + type BuildSponsoredCallsReturnType, + buildSponsoredCalls, + encodeTokenTransfer, +} from './utils/buildSponsoredCalls.js' diff --git a/src/experimental/eip8168/types.ts b/src/experimental/eip8168/types.ts new file mode 100644 index 0000000000..92cacf5564 --- /dev/null +++ b/src/experimental/eip8168/types.ts @@ -0,0 +1,192 @@ +import type { Address } from 'abitype' +import type { Hex } from '../../types/misc.js' + +/** A call in a payer RPC request (`value`/`data` optional). */ +export type PayerRpcCall = { + to: Address + value?: Hex | undefined + data?: Hex | undefined +} + +/** Shared balance shape returned by `payer_getTerms` and `payer_getBalance`. */ +export type PayerBalance = { + kind: 'sponsorship' | 'credit' + /** Remaining amount, atomic units of `asset`. */ + available: string + /** Total budget or cap, atomic units. */ + limit?: string | undefined + /** Amount used this period, atomic units. */ + spent?: string | undefined + /** Token contract address, `"native"`, or ISO-4217 code. */ + asset: string + symbol?: string | undefined + decimals?: number | undefined + /** When a periodic sponsorship budget next refills (seconds). */ + resetAt?: number | undefined + /** When the balance/credit expires (seconds). */ + expiry?: number | undefined + /** Source attribution (REQUIRED from aggregators). */ + payer?: Address | undefined + endpoint?: string | undefined + name?: string | undefined +} + +export type PayerGasEstimate = { + gasLimit: Hex + maxFeePerGas: Hex + maxPriorityFeePerGas: Hex +} + +export type PayerTokenOption = { + token: Address + symbol: string + decimals: number + /** Canonical phase-0 transfer amount, quoted at the gas cap. */ + maxCost: Hex + rate: { + /** Token atomic units... */ + numerator: Hex + /** ...per this many native wei. */ + denominator: Hex + } + rateDisplay?: string | undefined + rateExpiry: number +} + +export type PayerConditions = { + maxExpiry?: number | undefined + minExpiry?: number | undefined + maxGasLimit?: Hex | undefined + requiredChainId?: Hex | undefined +} + +export type PayerSponsor = { + name: string + icon?: string | undefined + reason?: string | undefined +} + +export type GetTermsParameters = { + chainId: Hex + from: Address + calls: readonly PayerRpcCall[] + gasLimit?: Hex | undefined + preferredTokens?: readonly Address[] | undefined + /** Opaque app context (e.g. `policyId`) from the `paymasterService` capability. */ + context?: Record | undefined +} + +export type GetTermsReturnType = { + sponsored: boolean + /** Absolute, seconds. */ + expiry: number + /** Lifetime in seconds from time of response. */ + ttl: number + gasEstimate?: PayerGasEstimate | undefined + tokenOptions?: readonly PayerTokenOption[] | undefined + requiredCalls?: readonly PayerRpcCall[] | undefined + recipient?: Address | undefined + balance?: PayerBalance | undefined + conditions?: PayerConditions | undefined + payer: Address + endpoint: string + sponsor?: PayerSponsor | undefined +} + +export type TokenCharged = { + token: Address + amount: Hex +} + +export type SendTransactionParameters = { + signedTransaction: Hex + context?: Record | undefined +} + +export type SendTransactionReturnType = { + transactionHash: Hex + tokenCharged?: TokenCharged | undefined +} + +export type SignTransactionParameters = { + signedTransaction: Hex + context?: Record | undefined +} + +export type SignTransactionReturnType = { + signedTransaction: Hex + tokenCharged?: TokenCharged | undefined +} + +export type GetBalanceParameters = { + from: Address + chainId?: Hex | undefined + /** Aggregator: scope to a single service by address. */ + payer?: Address | undefined + /** Aggregator: scope to a single service by endpoint URL. */ + endpoint?: string | undefined + kind?: readonly ('sponsorship' | 'credit')[] | undefined + context?: Record | undefined +} + +export type GetBalanceReturnType = { + balances: readonly PayerBalance[] + ttl: number +} + +export type SponsorshipOption = { + type: 'full_sponsorship' | 'conditional' + payer: Address + endpoint: string + sponsor?: PayerSponsor | undefined + tokenPayment?: + | { + token: Address + tokenSymbol: string + decimals: number + estimatedCost: Hex + rate: { numerator: Hex; denominator: Hex } + rateDisplay?: string | undefined + rateExpiry: number + } + | undefined + conditions?: PayerConditions | undefined + priority?: number | undefined +} + +export type GetSponsorshipOptionsParameters = { + chainId: Hex + from: Address + calls: readonly PayerRpcCall[] + paymentToken?: Address | undefined + gasLimit?: Hex | undefined + context?: Record | undefined +} + +export type GetSponsorshipOptionsReturnType = { + options: readonly SponsorshipOption[] +} + +export type PayerChainCapabilities = { + chainId: Hex + payer: Address + endpoint: string + fullSponsorship: boolean + acceptedTokens: readonly { + token: Address + symbol: string + decimals: number + }[] + methods: readonly string[] + conditions?: PayerConditions | undefined + sponsor?: PayerSponsor | undefined +} + +export type GetCapabilitiesParameters = { + chainId?: Hex | undefined +} + +export type GetCapabilitiesReturnType = { + chains: readonly PayerChainCapabilities[] + ttl: number +} diff --git a/src/experimental/eip8168/utils/buildSponsoredCalls.ts b/src/experimental/eip8168/utils/buildSponsoredCalls.ts new file mode 100644 index 0000000000..967ae7baac --- /dev/null +++ b/src/experimental/eip8168/utils/buildSponsoredCalls.ts @@ -0,0 +1,110 @@ +import type { Address } from 'abitype' +import { erc20Abi } from '../../../constants/abis.js' +import { BaseError } from '../../../errors/base.js' +import { encodeFunctionData } from '../../../utils/abi/encodeFunctionData.js' +import { hexToBigInt } from '../../../utils/encoding/fromHex.js' +import type { AaCall, AaCalls } from '../../eip8130/types/transaction.js' +import type { + GetTermsReturnType, + PayerRpcCall, + PayerTokenOption, +} from '../types.js' + +/** Encodes an ERC-20 `transfer(to, amount)` call. */ +export function encodeTokenTransfer(parameters: { + token: Address + to: Address + amount: bigint +}): AaCall { + return { + to: parameters.token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [parameters.to, parameters.amount], + }), + } +} + +function toAaCall(call: PayerRpcCall): AaCall { + return { to: call.to, data: call.data ?? '0x' } +} + +export type BuildSponsoredCallsParameters = { + /** Terms returned by `payer_getTerms`. */ + terms: GetTermsReturnType + /** The user's intended calls (placed in the final phase). */ + calls: readonly AaCall[] + /** + * Token to pay with when not fully sponsored. Defaults to the first + * `tokenOptions` entry (or `preferredToken` if it matches an option). + */ + token?: Address | undefined +} + +export type BuildSponsoredCallsReturnType = { + /** Payer address to set on the transaction. */ + payer: Address + /** Ordered call phases (phase 0 = sponsorship requirements, last = user calls). */ + calls: AaCalls + /** The selected token option, when paying with a token. */ + tokenOption?: PayerTokenOption | undefined + /** Phase-0 token transfer amount (`maxCost`), when paying with a token. */ + maxCost?: bigint | undefined +} + +/** + * Constructs the EIP-8130 `calls` phases and `payer` from `payer_getTerms` + * output, per the ERC-8168 phase table: + * + * | Model | Phase 0 | Last phase | + * |---|---|---| + * | Full sponsorship | — | user calls | + * | Token payment | `transfer(payer, maxCost)` | user calls | + * | Required calls | required calls | user calls | + * | Token + required | transfer + required calls | user calls | + * + * Balance-funded sponsorship uses the full-sponsorship construction (no phase-0 + * transfer); the payer is reimbursed off-chain from the sender's budget/credit. + */ +export function buildSponsoredCalls( + parameters: BuildSponsoredCallsParameters, +): BuildSponsoredCallsReturnType { + const { terms, calls } = parameters + + const phase0: AaCall[] = [] + let tokenOption: PayerTokenOption | undefined + let maxCost: bigint | undefined + + if (!terms.sponsored) { + const options = terms.tokenOptions ?? [] + if (options.length === 0) + throw new BaseError( + 'Terms are not sponsored and carry no `tokenOptions` to pay with.', + ) + tokenOption = parameters.token + ? options.find( + (o) => o.token.toLowerCase() === parameters.token!.toLowerCase(), + ) + : options[0] + if (!tokenOption) + throw new BaseError( + `No token option matches the requested token "${parameters.token}".`, + ) + maxCost = hexToBigInt(tokenOption.maxCost) + phase0.push( + encodeTokenTransfer({ + token: tokenOption.token, + to: terms.payer, + amount: maxCost, + }), + ) + } + + if (terms.requiredCalls?.length) + phase0.push(...terms.requiredCalls.map(toAaCall)) + + const phases: AaCalls = phase0.length > 0 ? [phase0, calls] : [calls] + + return { payer: terms.payer, calls: phases, tokenOption, maxCost } +} diff --git a/src/package.json b/src/package.json index f4f3c338a2..729f56b521 100644 --- a/src/package.json +++ b/src/package.json @@ -69,6 +69,11 @@ "import": "./_esm/experimental/eip8130/index.js", "default": "./_cjs/experimental/eip8130/index.js" }, + "./experimental/eip8168": { + "types": "./_types/experimental/eip8168/index.d.ts", + "import": "./_esm/experimental/eip8168/index.js", + "default": "./_cjs/experimental/eip8168/index.js" + }, "./experimental/erc7739": { "types": "./_types/experimental/erc7739/index.d.ts", "import": "./_esm/experimental/erc7739/index.js", @@ -179,6 +184,12 @@ "experimental": [ "./_types/experimental/index.d.ts" ], + "experimental/eip8130": [ + "./_types/experimental/eip8130/index.d.ts" + ], + "experimental/eip8168": [ + "./_types/experimental/eip8168/index.d.ts" + ], "experimental/erc7739": [ "./_types/experimental/erc7739/index.d.ts" ], From 55ebaac562615a63f2fa061dd691dea0186db9d2 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 10 Jun 2026 07:52:44 -0400 Subject: [PATCH 09/96] feat(experimental): wire base/eip-8130 Base Sepolia deployment addresses - add eip8130Deployments registry (AccountConfiguration, account impls, authenticator contracts) with getEip8130Deployment(chainId) - replace placeholder accountConfigAddress / defaultAccountAddress and canonical p256/passkey/delegate authenticators with the deployed values --- src/experimental/eip8130/constants.ts | 33 +++++++------ src/experimental/eip8130/deployments.ts | 62 +++++++++++++++++++++++++ src/experimental/eip8130/index.ts | 6 +++ 3 files changed, 84 insertions(+), 17 deletions(-) create mode 100644 src/experimental/eip8130/deployments.ts diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts index c418c22916..8dbf6fffcb 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/experimental/eip8130/constants.ts @@ -70,20 +70,19 @@ export const revokedAuthenticator = * onchain contracts. * * @remarks - * The non-native addresses are **placeholders**. The canonical set and its - * CREATE2-derived addresses are maintained in a companion ERC and resolved per - * deployment ([base/eip-8130](https://github.com/base/eip-8130)). Override as - * needed until the canonical values are finalized. + * The non-native addresses below are the [base/eip-8130](https://github.com/base/eip-8130) + * deployment (Base Sepolia). They may differ per chain — resolve via + * {@link eip8130Deployments} / {@link getEip8130Deployment}, or override per call. */ export const canonicalAuthenticators = { /** secp256k1 — native sentinel (`ECRECOVER_AUTHENTICATOR`). */ k1: '0x0000000000000000000000000000000000000001', - /** P-256 (raw). Placeholder address. */ - p256: '0x8130000000000000000000000000000000000256', - /** WebAuthn / FIDO2 passkey. Placeholder address. */ - passkey: '0x8130000000000000000000000000000000007e6b', - /** Signature delegation (1-hop). Placeholder address. */ - delegate: '0x81300000000000000000000000000000000de1e6', + /** P-256 (raw). base/eip-8130 deployment (Base Sepolia). */ + p256: '0x3AE129D846CD1CAf0369b4Caa56c188E18E11B15', + /** WebAuthn / FIDO2 passkey. base/eip-8130 deployment (Base Sepolia). */ + passkey: '0x1CB75BE39Fb950202BF4239010534B86EdA66c31', + /** Signature delegation (1-hop). base/eip-8130 deployment (Base Sepolia). */ + delegate: '0x0d10CfB3D0CD016bf20b7254C4a869FBbc0ad8C7', } as const satisfies Record /** Nonce Manager precompile address (`NONCE_MANAGER_ADDRESS`). */ @@ -99,24 +98,24 @@ export const txContextAddress = * used as the CREATE2 deployer for account address derivation. * * @remarks - * **Placeholder.** This address is CREATE2-derived at deployment in the - * reference implementation ([base/eip-8130](https://github.com/base/eip-8130)) - * and resolved per-network. Override via the `accountConfigAddress` parameter of - * {@link computeAddress8130} until the canonical value is finalized. + * Defaults to the [base/eip-8130](https://github.com/base/eip-8130) deployment + * (Base Sepolia). The address may differ per chain — resolve via + * {@link getEip8130Deployment}, or override via the `accountConfigAddress` + * parameter of {@link computeAddress8130}. */ export const accountConfigAddress = - '0x8130000000000000000000000000000000008130' satisfies Hex + '0xe6BB4A62034c4F7494A411E28d0a18B1BB55DEE6' satisfies Hex /** * Default wallet implementation for EOA auto-delegation * (`DEFAULT_ACCOUNT_ADDRESS`). * * @remarks - * **Placeholder.** CREATE2-derived at deployment; see + * Defaults to the base/eip-8130 deployment (Base Sepolia); see * {@link accountConfigAddress}. */ export const defaultAccountAddress = - '0x8130000000000000000000000000000000000acc' satisfies Hex + '0xE69fca5270f01c40E9884E503a9961195438E6fD' satisfies Hex /** Size of the deployment header in bytes (`DEPLOYMENT_HEADER_SIZE`). */ export const deploymentHeaderSize = 14 diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts new file mode 100644 index 0000000000..50a0b887af --- /dev/null +++ b/src/experimental/eip8130/deployments.ts @@ -0,0 +1,62 @@ +import type { Address } from 'abitype' + +/** + * On-chain addresses for an EIP-8130 deployment ([base/eip-8130](https://github.com/base/eip-8130)). + * + * On chains **without** native EIP-8130 support, these contracts provide the + * portable path: `accountConfiguration` is the ERC-4337 factory / config + * registry, `accounts` are the wallet implementations (proxied via ERC-1167), + * and `authenticators` are the deployed authenticator contracts used during EVM + * execution (native chains use the protocol sentinels instead). + */ +export type Eip8130Deployment = { + /** AccountConfiguration system contract (factory + actor-config registry). */ + accountConfiguration: Address + /** Wallet implementation contracts (deployed behind ERC-1167 proxies). */ + accounts: { + /** DefaultAccount implementation. */ + default: Address + /** DefaultHighRateAccount implementation. */ + defaultHighRate: Address + /** BackwardCompatibleERC4337Account — pass to {@link toSmartAccount8130}. */ + erc4337: Address + } + /** Deployed authenticator contracts (for EVM execution on non-native chains). */ + authenticators: { + /** secp256k1. Note: the native 8130 path uses `ECRECOVER_AUTHENTICATOR` (`address(1)`). */ + k1: Address + p256: Address + webAuthn: Address + delegate: Address + alwaysValid: Address + } +} + +/** EIP-8130 deployment on Base Sepolia (chain id `84532`). */ +export const baseSepoliaDeployment = { + accountConfiguration: '0xe6BB4A62034c4F7494A411E28d0a18B1BB55DEE6', + accounts: { + default: '0xE69fca5270f01c40E9884E503a9961195438E6fD', + defaultHighRate: '0x8aba250115EAE82A9C3df830DF8B47b255a593a4', + erc4337: '0x1feBaCc134664AaCf8C15910460426699F1Ef92b', + }, + authenticators: { + k1: '0x39221FB37Df105B22316328e88632C9684861466', + p256: '0x3AE129D846CD1CAf0369b4Caa56c188E18E11B15', + webAuthn: '0x1CB75BE39Fb950202BF4239010534B86EdA66c31', + delegate: '0x0d10CfB3D0CD016bf20b7254C4a869FBbc0ad8C7', + alwaysValid: '0x520fBA4840729CB57b3Dc7B40D548AcF354DBA25', + }, +} as const satisfies Eip8130Deployment + +/** Known EIP-8130 deployments, keyed by chain id. */ +export const eip8130Deployments: Record = { + 84532: baseSepoliaDeployment, +} + +/** Returns the EIP-8130 deployment for a chain id, if known. */ +export function getEip8130Deployment( + chainId: number, +): Eip8130Deployment | undefined { + return eip8130Deployments[chainId] +} diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index f1ac750eac..87f91115a9 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -48,6 +48,12 @@ export { revokedAuthenticator, txContextAddress, } from './constants.js' +export { + baseSepoliaDeployment, + type Eip8130Deployment, + eip8130Deployments, + getEip8130Deployment, +} from './deployments.js' export { type AuthorizeActorOptions, authorizeActor, From 2f1c8eba33fbc6317e245d9d06af625f758c3640 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 10 Jun 2026 08:14:25 -0400 Subject: [PATCH 10/96] feat(eip8130): ABI-encode actor-change data to match AccountConfiguration The deployed AccountConfiguration contract decodes an authorizeActor change's `data` via abi.decode(data, (ActorConfig, bytes)), but our codec used RLP per the EIP-8130 text. RLP data reverts at abi.decode, so applySignedActorChanges could not land on-chain. Consolidate on a single ABI encoder/decoder (actorChangeData.ts) used by the config-change digest, the applySignedActorChanges calldata, and the native-wire serialize/parse paths. Validated end-to-end on Base Sepolia: account creation and a P-256 session-key authorization both land and read back as expected. Adds gated (PRIVATE_KEY) integration scripts for the two on-chain flows. --- scripts/authorizeSessionKey.test.ts | 125 ++++++++++++++++++ scripts/setup8130Account.test.ts | 89 +++++++++++++ src/experimental/eip8130/index.ts | 8 +- .../eip8130/utils/accountConfigCalls.ts | 2 +- .../eip8130/utils/actorChangeData.ts | 83 ++++++++++++ .../eip8130/utils/hashActorChanges.ts | 2 +- .../eip8130/utils/parseTransaction.ts | 15 +-- .../eip8130/utils/serializeTransaction.ts | 25 +--- test/vitest.eip8130.config.ts | 21 +++ 9 files changed, 336 insertions(+), 34 deletions(-) create mode 100644 scripts/authorizeSessionKey.test.ts create mode 100644 scripts/setup8130Account.test.ts create mode 100644 src/experimental/eip8130/utils/actorChangeData.ts create mode 100644 test/vitest.eip8130.config.ts diff --git a/scripts/authorizeSessionKey.test.ts b/scripts/authorizeSessionKey.test.ts new file mode 100644 index 0000000000..2d7792c1f3 --- /dev/null +++ b/scripts/authorizeSessionKey.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' +import { getCode } from '../src/actions/public/getCode.js' +import { readContract } from '../src/actions/public/readContract.js' +import { waitForTransactionReceipt } from '../src/actions/public/waitForTransactionReceipt.js' +import { sendTransaction } from '../src/actions/wallet/sendTransaction.js' +import { baseSepolia } from '../src/chains/index.js' +import { createClient } from '../src/clients/createClient.js' +import { http } from '../src/clients/transports/http.js' +import { accountConfigurationAbi } from '../src/experimental/eip8130/abis.js' +import { actorScope } from '../src/experimental/eip8130/constants.js' +import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' +import { authorizeActor, key } from '../src/experimental/eip8130/keys.js' +import { encodeApplySignedActorChangesData } from '../src/experimental/eip8130/utils/accountConfigCalls.js' +import { computeAddress8130 } from '../src/experimental/eip8130/utils/computeAddress.js' +import { erc1167Bytecode } from '../src/experimental/eip8130/utils/proxy.js' +import { signActorChanges8130 } from '../src/experimental/eip8130/utils/signActorChanges.js' +import { stringToHex } from '../src/utils/encoding/toHex.js' +import { keccak256 } from '../src/utils/hash/keccak256.js' + +const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined +const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' +const SALT_LABEL = process.env.SALT_LABEL ?? 'viem-eip8130-demo-1' + +describe.runIf(PRIVATE_KEY)( + 'authorize a P-256 session key on Base Sepolia', + () => { + test('applySignedActorChanges authorizes a scoped P-256 actor', async () => { + const owner = privateKeyToAccount(PRIVATE_KEY!) + const client = createClient({ + account: owner, + chain: baseSepolia, + transport: http(RPC_URL), + }) + + const deployment = getEip8130Deployment(baseSepolia.id)! + + // Re-derive the account deployed by setup8130Account.test.ts. + const code = erc1167Bytecode(deployment.accounts.erc4337) + const initialActors = [key.k1(owner.address)] + const userSalt = keccak256(stringToHex(SALT_LABEL)) + const account = computeAddress8130({ userSalt, code, initialActors }) + + const deployed = await getCode(client, { address: account }) + if (!deployed || deployed === '0x') + throw new Error('account not deployed; run setup8130Account first') + + // A new P-256 session key (any 32-byte x/y; on-curve validity is only + // checked by the authenticator at use-time, not at authorization). + const x = keccak256(stringToHex('viem-eip8130-p256-x')) + const y = keccak256(stringToHex('viem-eip8130-p256-y')) + const sessionKey = key.p256({ x, y }) + + const change = authorizeActor(sessionKey, { scope: actorScope.sender }) + + // applySignedActorChanges consumes the current channel sequence (the + // post-increment read). createAccount sets the local channel to 1. + const seq = await readContract(client, { + abi: accountConfigurationAbi, + address: deployment.accountConfiguration, + functionName: 'getChangeSequences', + args: [account], + }) + + const chainId = baseSepolia.id + const signed = await signActorChanges8130({ + signer: owner, + account, + chainId, + sequence: Number(seq.local), + actorChanges: [change], + }) + + console.log('\n— EIP-8130 authorize session key (Base Sepolia) —') + console.log('account: ', account) + console.log('session actorId: ', sessionKey.actorId) + console.log('p256 authenticator:', sessionKey.authenticator) + console.log('local sequence: ', seq.local.toString()) + + const data = encodeApplySignedActorChangesData({ + account, + chainId, + actorChanges: [change], + auth: signed.auth, + }) + + const hash = await sendTransaction(client, { + to: deployment.accountConfiguration, + data, + chain: baseSepolia, + account: owner, + }) + console.log( + 'tx: ', + `https://sepolia.basescan.org/tx/${hash}`, + ) + + const receipt = await waitForTransactionReceipt(client, { hash }) + console.log('status: ', receipt.status) + expect(receipt.status).toBe('success') + + // Verify the actor was written with the expected authenticator + scope. + let config: { authenticator: string; scope: number } | undefined + for (let i = 0; i < 10; i++) { + config = (await readContract(client, { + abi: accountConfigurationAbi, + address: deployment.accountConfiguration, + functionName: 'getActorConfig', + args: [account, sessionKey.actorId], + })) as { authenticator: string; scope: number } + if ( + config.authenticator.toLowerCase() === + sessionKey.authenticator.toLowerCase() + ) + break + await new Promise((r) => setTimeout(r, 1500)) + } + console.log('actor config: ', config) + expect(config?.authenticator.toLowerCase()).toBe( + sessionKey.authenticator.toLowerCase(), + ) + expect(Number(config?.scope)).toBe(actorScope.sender) + }, 120_000) + }, +) diff --git a/scripts/setup8130Account.test.ts b/scripts/setup8130Account.test.ts new file mode 100644 index 0000000000..2be64d58de --- /dev/null +++ b/scripts/setup8130Account.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' +import { getCode } from '../src/actions/public/getCode.js' +import { readContract } from '../src/actions/public/readContract.js' +import { waitForTransactionReceipt } from '../src/actions/public/waitForTransactionReceipt.js' +import { writeContract } from '../src/actions/wallet/writeContract.js' +import { baseSepolia } from '../src/chains/index.js' +import { createClient } from '../src/clients/createClient.js' +import { http } from '../src/clients/transports/http.js' +import { accountConfigurationAbi } from '../src/experimental/eip8130/abis.js' +import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' +import { key } from '../src/experimental/eip8130/keys.js' +import { computeAddress8130 } from '../src/experimental/eip8130/utils/computeAddress.js' +import { erc1167Bytecode } from '../src/experimental/eip8130/utils/proxy.js' +import { stringToHex } from '../src/utils/encoding/toHex.js' +import { keccak256 } from '../src/utils/hash/keccak256.js' + +const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined +const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' +const SALT_LABEL = process.env.SALT_LABEL ?? 'viem-eip8130-demo-1' + +describe.runIf(PRIVATE_KEY)('setup an EIP-8130 account on Base Sepolia', () => { + test('computeAddress matches on-chain and createAccount lands', async () => { + const owner = privateKeyToAccount(PRIVATE_KEY!) + const client = createClient({ + account: owner, + chain: baseSepolia, + transport: http(RPC_URL), + }) + + const deployment = getEip8130Deployment(baseSepolia.id)! + const code = erc1167Bytecode(deployment.accounts.erc4337) + const initialActors = [key.k1(owner.address)] + const userSalt = keccak256(stringToHex(SALT_LABEL)) + const initialActorsArg = initialActors.map((a) => ({ + actorId: a.actorId, + authenticator: a.authenticator, + })) + + const local = computeAddress8130({ userSalt, code, initialActors }) + const onchain = await readContract(client, { + abi: accountConfigurationAbi, + address: deployment.accountConfiguration, + functionName: 'computeAddress', + args: [userSalt, code, initialActorsArg], + }) + + console.log('\n— EIP-8130 account setup (Base Sepolia) —') + console.log('owner (EOA): ', owner.address) + console.log('account (local): ', local) + console.log('account (onchain):', onchain) + expect(local.toLowerCase()).toBe(onchain.toLowerCase()) + + const existing = await getCode(client, { address: local }) + if (existing && existing !== '0x') { + console.log('already deployed; skipping createAccount.') + return + } + + console.log('sending createAccount...') + const hash = await writeContract(client, { + abi: accountConfigurationAbi, + address: deployment.accountConfiguration, + functionName: 'createAccount', + args: [userSalt, code, initialActorsArg], + chain: baseSepolia, + account: owner, + }) + console.log('tx: ', `https://sepolia.basescan.org/tx/${hash}`) + + const receipt = await waitForTransactionReceipt(client, { hash }) + console.log('status: ', receipt.status) + console.log( + 'account: ', + `https://sepolia.basescan.org/address/${local}`, + ) + expect(receipt.status).toBe('success') + + // Public RPCs are load-balanced; poll to avoid reading a lagging replica. + let deployed: `0x${string}` | undefined + for (let i = 0; i < 10; i++) { + deployed = await getCode(client, { address: local }) + if (deployed && deployed !== '0x') break + await new Promise((r) => setTimeout(r, 1500)) + } + console.log('code: ', deployed) + expect(deployed && deployed !== '0x').toBeTruthy() + }, 120_000) +}) diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 87f91115a9..0eacb38c14 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -89,6 +89,13 @@ export { type ToFactoryArgs8130ReturnType, toFactoryArgs8130, } from './utils/accountConfigCalls.js' +export { + type DecodeAuthorizeActorDataErrorType, + type DecodedAuthorizeActorData, + decodeAuthorizeActorData, + type EncodeActorChangeDataErrorType, + encodeActorChangeData, +} from './utils/actorChangeData.js' export { type ActorIdFromAddressErrorType, type ActorIdFromPublicKeyErrorType, @@ -126,7 +133,6 @@ export { } from './utils/parseTransaction.js' export { erc1167Bytecode } from './utils/proxy.js' export { - encodeActorChangeData, type SerializeTransaction8130ErrorType, serializeTransaction8130, toAccountChangesList, diff --git a/src/experimental/eip8130/utils/accountConfigCalls.ts b/src/experimental/eip8130/utils/accountConfigCalls.ts index 11dc2a8557..56bf19e66e 100644 --- a/src/experimental/eip8130/utils/accountConfigCalls.ts +++ b/src/experimental/eip8130/utils/accountConfigCalls.ts @@ -8,7 +8,7 @@ import { import { accountConfigurationAbi } from '../abis.js' import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' import type { AaActor, AaActorChange } from '../types/transaction.js' -import { encodeActorChangeData } from './serializeTransaction.js' +import { encodeActorChangeData } from './actorChangeData.js' function toInitialActors(actors: readonly AaActor[]) { return actors.map((actor) => ({ diff --git a/src/experimental/eip8130/utils/actorChangeData.ts b/src/experimental/eip8130/utils/actorChangeData.ts new file mode 100644 index 0000000000..44c6bacf31 --- /dev/null +++ b/src/experimental/eip8130/utils/actorChangeData.ts @@ -0,0 +1,83 @@ +import type { Address } from 'abitype' +import type { ErrorType } from '../../../errors/utils.js' +import type { Hex } from '../../../types/misc.js' +import { + type DecodeAbiParametersErrorType, + decodeAbiParameters, +} from '../../../utils/abi/decodeAbiParameters.js' +import { + type EncodeAbiParametersErrorType, + encodeAbiParameters, +} from '../../../utils/abi/encodeAbiParameters.js' +import { actorChangeType } from '../constants.js' +import type { AaActorChange } from '../types/transaction.js' + +const authorizeDataParameters = [ + { + type: 'tuple', + components: [ + { name: 'authenticator', type: 'address' }, + { name: 'scope', type: 'uint8' }, + { name: 'expiry', type: 'uint48' }, + { name: 'policyType', type: 'uint8' }, + ], + }, + { type: 'bytes' }, +] as const + +export type EncodeActorChangeDataErrorType = + | EncodeAbiParametersErrorType + | ErrorType + +/** + * Encodes the operation-specific `data` of an `actor_change`: + * + * - `authorizeActor` -> `abi.encode((address,uint8,uint48,uint8) config, bytes policyData)` + * - `revokeActor` -> empty bytes (`0x`) + * + * @remarks + * The `data` is ABI-encoded (not RLP) so the same blob is decoded identically by + * the native protocol and by `AccountConfiguration.applySignedActorChanges` + * (`abi.decode(data, (ActorConfig, bytes))`). It is also the value hashed in the + * config-change signature digest (see {@link hashActorChanges8130}). + */ +export function encodeActorChangeData(change: AaActorChange): Hex { + if (change.changeType === actorChangeType.authorizeActor) + return encodeAbiParameters(authorizeDataParameters, [ + { + authenticator: change.authenticator, + scope: change.scope ?? 0, + expiry: change.expiry ?? 0n, + policyType: change.policyType ?? 0, + }, + change.policyData ?? '0x', + ]) + return '0x' +} + +export type DecodedAuthorizeActorData = { + authenticator: Address + scope: number + expiry: bigint + policyType: number + policyData: Hex +} + +export type DecodeAuthorizeActorDataErrorType = + | DecodeAbiParametersErrorType + | ErrorType + +/** Decodes the `authorizeActor` `data` produced by {@link encodeActorChangeData}. */ +export function decodeAuthorizeActorData(data: Hex): DecodedAuthorizeActorData { + const [config, policyData] = decodeAbiParameters( + authorizeDataParameters, + data, + ) + return { + authenticator: config.authenticator, + scope: config.scope, + expiry: BigInt(config.expiry), + policyType: config.policyType, + policyData, + } +} diff --git a/src/experimental/eip8130/utils/hashActorChanges.ts b/src/experimental/eip8130/utils/hashActorChanges.ts index 692d12d595..cdfae94bc4 100644 --- a/src/experimental/eip8130/utils/hashActorChanges.ts +++ b/src/experimental/eip8130/utils/hashActorChanges.ts @@ -18,7 +18,7 @@ import { keccak256, } from '../../../utils/hash/keccak256.js' import type { AaActorChange } from '../types/transaction.js' -import { encodeActorChangeData } from './serializeTransaction.js' +import { encodeActorChangeData } from './actorChangeData.js' /** `keccak256("ActorChange(uint8 changeType,bytes32 actorId,bytes data)")` */ export const actorChangeTypehash = keccak256( diff --git a/src/experimental/eip8130/utils/parseTransaction.ts b/src/experimental/eip8130/utils/parseTransaction.ts index cf01dfb88c..79748c3b3c 100644 --- a/src/experimental/eip8130/utils/parseTransaction.ts +++ b/src/experimental/eip8130/utils/parseTransaction.ts @@ -26,6 +26,7 @@ import type { AaCalls, TransactionSerializable8130, } from '../types/transaction.js' +import { decodeAuthorizeActorData } from './actorChangeData.js' export type ParseTransaction8130ErrorType = | SliceErrorType @@ -63,18 +64,16 @@ function parseActorChange(value: RlpHex): AaActorChange { const [changeType, actorId, data] = value as [Hex, Hex, Hex] const type = changeType === '0x' ? 0 : hexToNumber(changeType) if (type === actorChangeType.authorizeActor) { - const [authenticator, scope, expiry, policyType, policyData] = fromRlp( - data, - 'hex', - ) as Hex[] + const { authenticator, scope, expiry, policyType, policyData } = + decodeAuthorizeActorData(data) const change: AaActorChange = { changeType: actorChangeType.authorizeActor, actorId, - authenticator: authenticator as Address, + authenticator, } - if (scope !== '0x') change.scope = hexToNumber(scope) - if (expiry !== '0x') change.expiry = hexToBigInt(expiry) - if (policyType !== '0x') change.policyType = hexToNumber(policyType) + if (scope !== 0) change.scope = scope + if (expiry !== 0n) change.expiry = expiry + if (policyType !== 0) change.policyType = policyType if (policyData !== '0x') change.policyData = policyData return change } diff --git a/src/experimental/eip8130/utils/serializeTransaction.ts b/src/experimental/eip8130/utils/serializeTransaction.ts index 847b4f5c34..d3e13cd79c 100644 --- a/src/experimental/eip8130/utils/serializeTransaction.ts +++ b/src/experimental/eip8130/utils/serializeTransaction.ts @@ -13,11 +13,7 @@ import { type ToRlpErrorType, toRlp, } from '../../../utils/encoding/toRlp.js' -import { - aaTransactionType, - accountChangeType, - actorChangeType, -} from '../constants.js' +import { aaTransactionType, accountChangeType } from '../constants.js' import type { AaAccountChange, AaActorChange, @@ -25,6 +21,7 @@ import type { TransactionSerializable8130, TransactionSerialized8130, } from '../types/transaction.js' +import { encodeActorChangeData } from './actorChangeData.js' import { type AssertTransaction8130ErrorType, assertTransaction8130, @@ -37,24 +34,6 @@ export function toCallsList(calls: AaCalls | undefined): RecursiveArray[] { ) } -/** - * Encodes the operation-specific `data` bytes of an `actor_change`: - * `authorizeActor` -> `rlp([authenticator, scope, expiry, policyType, policyData])`, - * `revokeActor` -> `rlp([])`. - */ -export function encodeActorChangeData(change: AaActorChange): Hex { - if (change.changeType === actorChangeType.authorizeActor) - return toRlp([ - change.authenticator, - change.scope ? numberToHex(change.scope) : '0x', - change.expiry ? numberToHex(change.expiry) : '0x', - change.policyType ? numberToHex(change.policyType) : '0x', - change.policyData ?? '0x', - ]) - // revokeActor: empty data (`rlp([])`) - return toRlp([]) -} - /** Encodes a single `actor_change` operation into a nested RLP-ready array. */ function toActorChange(change: AaActorChange): RecursiveArray { return [ diff --git a/test/vitest.eip8130.config.ts b/test/vitest.eip8130.config.ts new file mode 100644 index 0000000000..9b324e3f1e --- /dev/null +++ b/test/vitest.eip8130.config.ts @@ -0,0 +1,21 @@ +import { join } from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + disableConsoleIntercept: true, + alias: [ + { find: '~contracts', replacement: join(__dirname, '../contracts') }, + { find: '~test', replacement: join(__dirname, './src') }, + { find: /^viem$/, replacement: join(__dirname, '../src/index.ts') }, + { find: /^viem\/(.*)/, replacement: join(__dirname, '../src/$1') }, + ], + include: [ + 'src/experimental/eip813*/**/*.test.ts', + 'src/experimental/eip8168/**/*.test.ts', + 'scripts/setup8130Account.test.ts', + 'scripts/authorizeSessionKey.test.ts', + ], + testTimeout: 120_000, + }, +}) From a0a0bd71e6f1b70882d4e6509b977483b8224310 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 10 Jun 2026 10:54:04 -0400 Subject: [PATCH 11/96] feat(eip8130): self-bundle scripts + raw userOpHash signing in adapter toSmartAccount8130 now signs the raw userOpHash (no EIP-191 prefix) to match on-chain validateUserOp, with a pluggable `sign` and configurable `stubData` for non-k1 authenticators. Adds Base Sepolia scripts that drive ERC-4337 flows, including self-bundled create+execute via EntryPoint.handleOps (AccountConfiguration as the factory, no staking). --- scripts/bundlerCreateAndExecute.test.ts | 115 +++++++++++++++ scripts/bundlerProbeDeployed.test.ts | 86 +++++++++++ scripts/selfBundleCreate.test.ts | 133 ++++++++++++++++++ .../eip8130/accounts/toSmartAccount8130.ts | 42 ++++-- 4 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 scripts/bundlerCreateAndExecute.test.ts create mode 100644 scripts/bundlerProbeDeployed.test.ts create mode 100644 scripts/selfBundleCreate.test.ts diff --git a/scripts/bundlerCreateAndExecute.test.ts b/scripts/bundlerCreateAndExecute.test.ts new file mode 100644 index 0000000000..fc3a177a1a --- /dev/null +++ b/scripts/bundlerCreateAndExecute.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' +import { createBundlerClient } from '../src/account-abstraction/clients/createBundlerClient.js' +import { estimateFeesPerGas } from '../src/actions/public/estimateFeesPerGas.js' +import { getBalance } from '../src/actions/public/getBalance.js' +import { getCode } from '../src/actions/public/getCode.js' +import { waitForTransactionReceipt } from '../src/actions/public/waitForTransactionReceipt.js' +import { sendTransaction } from '../src/actions/wallet/sendTransaction.js' +import { baseSepolia } from '../src/chains/index.js' +import { createClient } from '../src/clients/createClient.js' +import { http } from '../src/clients/transports/http.js' +import { parseEther } from '../src/utils/unit/parseEther.js' +import { stringToHex } from '../src/utils/encoding/toHex.js' +import { keccak256 } from '../src/utils/hash/keccak256.js' +import { toSmartAccount8130 } from '../src/experimental/eip8130/accounts/toSmartAccount8130.js' +import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' +import { key } from '../src/experimental/eip8130/keys.js' + +const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined +const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' +const BUNDLER_URL = + process.env.BUNDLER_URL ?? + 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/7YlYO9viupy6QeNdPG4bzerRepbnoPQT' + +describe.runIf(PRIVATE_KEY)( + 'bundler: create EIP-8130 account + execute in a single user op', + () => { + test( + 'AccountConfiguration is the factory; deploy + action in one userOp', + async () => { + const owner = privateKeyToAccount(PRIVATE_KEY!) + const client = createClient({ + account: owner, + chain: baseSepolia, + transport: http(RPC_URL), + }) + const bundlerClient = createBundlerClient({ + client, + transport: http(BUNDLER_URL), + }) + const deployment = getEip8130Deployment(baseSepolia.id)! + + // Fresh salt so the account is purely counterfactual (not pre-created). + const userSalt = keccak256(stringToHex(`viem-8130-bundler-${Date.now()}`)) + const account = await toSmartAccount8130({ + client, + owner, + userSalt, + initialActors: [key.k1(owner.address)], + implementation: deployment.accounts.erc4337, + }) + + console.log('\n— bundler create + execute (Base Sepolia) —') + console.log('owner (EOA): ', owner.address) + console.log('smart account: ', account.address) + console.log('factory (config):', deployment.accountConfiguration) + + const codeBefore = await getCode(client, { address: account.address }) + expect(codeBefore ?? '0x').toBe('0x') + + // Prefund the counterfactual sender so the EntryPoint can pull its prefund. + const fundHash = await sendTransaction(client, { + account: owner, + to: account.address, + value: parseEther('0.01'), + chain: baseSepolia, + }) + await waitForTransactionReceipt(client, { hash: fundHash }) + console.log('funded sender: ', '0.01 ETH') + + // CDP validates signatures during eth_estimateUserOperationGas, which a + // counterfactual account cannot satisfy with a stub. Provide explicit gas + // limits so viem skips estimation and submits with the real signature. + const fees = await estimateFeesPerGas(client) + const ownerBalanceBefore = await getBalance(client, { + address: owner.address, + }) + const userOpHash = await bundlerClient.sendUserOperation({ + account, + calls: [{ to: owner.address, value: 1n }], + callGasLimit: 500_000n, + verificationGasLimit: 1_500_000n, + preVerificationGas: 500_000n, + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, + }) + console.log('userOp: ', userOpHash) + + const receipt = await bundlerClient.waitForUserOperationReceipt({ + hash: userOpHash, + }) + console.log( + 'tx: ', + `https://sepolia.basescan.org/tx/${receipt.receipt.transactionHash}`, + ) + console.log('success: ', receipt.success) + expect(receipt.success).toBe(true) + + // Account is now deployed and the action ran (1 wei returned to owner). + const codeAfter = await getCode(client, { address: account.address }) + expect(codeAfter && codeAfter !== '0x').toBeTruthy() + const ownerBalanceAfter = await getBalance(client, { + address: owner.address, + }) + // owner received 1 wei from the account's executeBatch (net of gas it paid + // to fund — we only assert the account executed by checking it deployed). + console.log( + 'owner +1 wei? ', + ownerBalanceAfter > ownerBalanceBefore - parseEther('0.001'), + ) + }, + 180_000, + ) + }, +) diff --git a/scripts/bundlerProbeDeployed.test.ts b/scripts/bundlerProbeDeployed.test.ts new file mode 100644 index 0000000000..5d84327ef4 --- /dev/null +++ b/scripts/bundlerProbeDeployed.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' +import { createBundlerClient } from '../src/account-abstraction/clients/createBundlerClient.js' +import { estimateFeesPerGas } from '../src/actions/public/estimateFeesPerGas.js' +import { getBalance } from '../src/actions/public/getBalance.js' +import { waitForTransactionReceipt } from '../src/actions/public/waitForTransactionReceipt.js' +import { sendTransaction } from '../src/actions/wallet/sendTransaction.js' +import { baseSepolia } from '../src/chains/index.js' +import { createClient } from '../src/clients/createClient.js' +import { http } from '../src/clients/transports/http.js' +import { parseEther } from '../src/utils/unit/parseEther.js' +import { stringToHex } from '../src/utils/encoding/toHex.js' +import { keccak256 } from '../src/utils/hash/keccak256.js' +import { toSmartAccount8130 } from '../src/experimental/eip8130/accounts/toSmartAccount8130.js' +import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' +import { key } from '../src/experimental/eip8130/keys.js' + +const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined +const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' +const BUNDLER_URL = + process.env.BUNDLER_URL ?? + 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/7YlYO9viupy6QeNdPG4bzerRepbnoPQT' + +describe.runIf(PRIVATE_KEY)('bundler probe: transact on a pre-deployed account', () => { + test( + 'userOp on already-deployed account (no factory phase)', + async () => { + const owner = privateKeyToAccount(PRIVATE_KEY!) + const client = createClient({ + account: owner, + chain: baseSepolia, + transport: http(RPC_URL), + }) + const bundlerClient = createBundlerClient({ + client, + transport: http(BUNDLER_URL), + }) + const deployment = getEip8130Deployment(baseSepolia.id)! + + const account = await toSmartAccount8130({ + client, + owner, + address: '0x64609Df27EFb3ecB241B349a3985DFdE2B98dc6b', + userSalt: keccak256(stringToHex('viem-eip8130-demo-1')), + initialActors: [key.k1(owner.address)], + implementation: deployment.accounts.erc4337, + }) + + console.log('\n— bundler probe (deployed account) —') + console.log('account: ', account.address) + + const bal = await getBalance(client, { address: account.address }) + if (bal < parseEther('0.002')) { + const fundHash = await sendTransaction(client, { + account: owner, + to: account.address, + value: parseEther('0.005'), + chain: baseSepolia, + }) + await waitForTransactionReceipt(client, { hash: fundHash }) + } + + const fees = await estimateFeesPerGas(client) + const userOpHash = await bundlerClient.sendUserOperation({ + account, + calls: [{ to: owner.address, value: 1n }], + callGasLimit: 300_000n, + verificationGasLimit: 600_000n, + preVerificationGas: 500_000n, + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, + }) + console.log('userOp: ', userOpHash) + const receipt = await bundlerClient.waitForUserOperationReceipt({ + hash: userOpHash, + }) + console.log( + 'tx: ', + `https://sepolia.basescan.org/tx/${receipt.receipt.transactionHash}`, + ) + console.log('success: ', receipt.success) + expect(receipt.success).toBe(true) + }, + 180_000, + ) +}) diff --git a/scripts/selfBundleCreate.test.ts b/scripts/selfBundleCreate.test.ts new file mode 100644 index 0000000000..4e1055a78d --- /dev/null +++ b/scripts/selfBundleCreate.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' +import { entryPoint07Abi } from '../src/account-abstraction/constants/abis.js' +import { entryPoint07Address } from '../src/account-abstraction/constants/address.js' +import { toPackedUserOperation } from '../src/account-abstraction/utils/userOperation/toPackedUserOperation.js' +import { estimateFeesPerGas } from '../src/actions/public/estimateFeesPerGas.js' +import { getCode } from '../src/actions/public/getCode.js' +import { readContract } from '../src/actions/public/readContract.js' +import { waitForTransactionReceipt } from '../src/actions/public/waitForTransactionReceipt.js' +import { writeContract } from '../src/actions/wallet/writeContract.js' +import { baseSepolia } from '../src/chains/index.js' +import { createClient } from '../src/clients/createClient.js' +import { http } from '../src/clients/transports/http.js' +import { stringToHex } from '../src/utils/encoding/toHex.js' +import { keccak256 } from '../src/utils/hash/keccak256.js' +import { parseEther } from '../src/utils/unit/parseEther.js' +import { toSmartAccount8130 } from '../src/experimental/eip8130/accounts/toSmartAccount8130.js' +import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' +import { key } from '../src/experimental/eip8130/keys.js' + +const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined +const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' + +describe.runIf(PRIVATE_KEY)( + 'self-bundle: create EIP-8130 account + execute via EntryPoint.handleOps', + () => { + test( + 'AccountConfiguration is the factory; deploy + action in a single userOp (no staking)', + async () => { + const owner = privateKeyToAccount(PRIVATE_KEY!) + const client = createClient({ + account: owner, + chain: baseSepolia, + transport: http(RPC_URL), + }) + const deployment = getEip8130Deployment(baseSepolia.id)! + + const userSalt = keccak256(stringToHex(`viem-8130-self-${Date.now()}`)) + const account = await toSmartAccount8130({ + client, + owner, + userSalt, + initialActors: [key.k1(owner.address)], + implementation: deployment.accounts.erc4337, + }) + + console.log('\n— self-bundled create + execute (Base Sepolia) —') + console.log('owner / bundler: ', owner.address) + console.log('smart account: ', account.address) + console.log('factory (config):', deployment.accountConfiguration) + + const codeBefore = await getCode(client, { address: account.address }) + expect(codeBefore ?? '0x').toBe('0x') + + // Pre-fund the account's EntryPoint *deposit* so missingAccountFunds = 0. + // (Avoids the account having to repay prefund mid-validation, which public + // RPCs mis-simulate during eth_estimateGas.) + const depositHash = await writeContract(client, { + abi: entryPoint07Abi, + address: entryPoint07Address, + functionName: 'depositTo', + args: [account.address], + value: parseEther('0.003'), + account: owner, + chain: baseSepolia, + }) + await waitForTransactionReceipt(client, { hash: depositHash }) + + // Build the user operation by hand. + const { factory, factoryData } = await account.getFactoryArgs() + const callData = await account.encodeCalls([ + { to: owner.address, value: 0n, data: '0x' }, + ]) + const nonce = await readContract(client, { + abi: entryPoint07Abi, + address: entryPoint07Address, + functionName: 'getNonce', + args: [account.address, 0n], + }) + const fees = await estimateFeesPerGas(client) + + const userOperation = { + sender: account.address, + nonce, + factory, + factoryData, + callData, + callGasLimit: 200_000n, + verificationGasLimit: 1_000_000n, + preVerificationGas: 100_000n, + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, + } as const + + const signature = await account.signUserOperation({ + ...userOperation, + chainId: baseSepolia.id, + }) + const packed = toPackedUserOperation({ ...userOperation, signature }) + + // We are the bundler: submit handleOps directly, collecting the refund. + const hash = await writeContract(client, { + abi: entryPoint07Abi, + address: entryPoint07Address, + functionName: 'handleOps', + args: [[packed], owner.address], + account: owner, + chain: baseSepolia, + // Public RPC eth_estimateGas mis-simulates handleOps prefund; set manually. + gas: 2_000_000n, + }) + const receipt = await waitForTransactionReceipt(client, { hash }) + console.log( + 'tx: ', + `https://sepolia.basescan.org/tx/${receipt.transactionHash}`, + ) + console.log('status: ', receipt.status) + expect(receipt.status).toBe('success') + + // Public RPCs are load-balanced; poll to avoid reading a lagging replica. + let codeAfter: `0x${string}` | undefined + for (let i = 0; i < 10; i++) { + codeAfter = await getCode(client, { address: account.address }) + if (codeAfter && codeAfter !== '0x') break + await new Promise((r) => setTimeout(r, 1500)) + } + expect(codeAfter && codeAfter !== '0x').toBeTruthy() + console.log('account deployed:', !!(codeAfter && codeAfter !== '0x')) + }, + 180_000, + ) + }, +) diff --git a/src/experimental/eip8130/accounts/toSmartAccount8130.ts b/src/experimental/eip8130/accounts/toSmartAccount8130.ts index feaa9f3072..3765939dc6 100644 --- a/src/experimental/eip8130/accounts/toSmartAccount8130.ts +++ b/src/experimental/eip8130/accounts/toSmartAccount8130.ts @@ -49,6 +49,19 @@ export type ToSmartAccount8130Parameters< * native ECRECOVER authenticator (secp256k1 EOA). */ authenticator?: Address | undefined + /** + * Produces the authenticator `data` (the bytes after the 20-byte authenticator + * prefix) for a given hash. Defaults to raw ECDSA (`r || s || v`) over the hash + * via `owner`. Override for non-ECDSA authenticators (e.g. P-256), where the + * account validates the signature over the raw `userOpHash`. + */ + sign?: ((hash: Hex) => Promise) | undefined + /** + * Stub authenticator `data` used for gas estimation. Defaults to a 65-byte + * ECDSA-shaped stub; override to match the `data` length of a custom + * {@link sign} (e.g. 129 bytes for P-256). + */ + stubData?: Hex | undefined /** Account Configuration contract (the ERC-4337 factory). */ accountConfigAddress?: Address | undefined } & ( @@ -137,6 +150,23 @@ export async function toSmartAccount8130< } as const const owner = parseAccount(parameters.owner) + const stubData = + parameters.stubData ?? + '0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c' + + // Produces the auth `data` (after the authenticator prefix). The account + // validates this over the raw hash (no EIP-191 prefix), so the default path + // raw-signs with the owner's key. + const sign = + parameters.sign ?? + (async (hash: Hex) => { + if (!owner.sign) + throw new BaseError( + '`owner` must be a local account exposing `sign`, or pass a custom `sign` function. EIP-8130 validates the raw `userOpHash` (no EIP-191 prefix).', + ) + return owner.sign({ hash }) + }) + // Resolve the account's deployment bytecode (ERC-1167 proxy to the wallet impl). const code = parameters.code ?? @@ -203,10 +233,7 @@ export async function toSmartAccount8130< }, async getStubSignature() { - return concatHex([ - authenticator, - '0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c', - ]) + return concatHex([authenticator, stubData]) }, async signMessage(parameters_) { @@ -239,12 +266,7 @@ export async function toSmartAccount8130< sender: address, }, }) - const signature = await getAction( - client, - signMessage_, - 'signMessage', - )({ account: owner, message: { raw: userOpHash } }) - return concatHex([authenticator, signature]) + return concatHex([authenticator, await sign(userOpHash)]) }, }) } From 7e98879d508e7e6acb78d2be251d83e3310c378d Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 10 Jun 2026 10:54:12 -0400 Subject: [PATCH 12/96] feat(eip8130): encode validation-phase signed actor-changes signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add encodeSignedActorChangesSignature (+ signedActorChangesMagic): builds a BackwardCompatibleERC4337Account userOp signature carrying SignedActorChanges[] (abi.encode(magic, [{ changes, auth }, ...])). Applying the signed change chain during validateUserOp authorizes the op — no separate op signature. Sets apply in order, enabling chained rotations. Adds a Base Sepolia script that creates an account and authorizes a P-256 actor in the validation phase in one self-bundled userOp. --- scripts/selfBundleRotateP256.test.ts | 163 ++++++++++++++++++ src/experimental/eip8130/index.ts | 6 + .../utils/signedActorChangesSignature.test.ts | 152 ++++++++++++++++ .../utils/signedActorChangesSignature.ts | 112 ++++++++++++ test/vitest.eip8130.config.ts | 4 + 5 files changed, 437 insertions(+) create mode 100644 scripts/selfBundleRotateP256.test.ts create mode 100644 src/experimental/eip8130/utils/signedActorChangesSignature.test.ts create mode 100644 src/experimental/eip8130/utils/signedActorChangesSignature.ts diff --git a/scripts/selfBundleRotateP256.test.ts b/scripts/selfBundleRotateP256.test.ts new file mode 100644 index 0000000000..86c6483368 --- /dev/null +++ b/scripts/selfBundleRotateP256.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from 'vitest' +import { entryPoint07Abi } from '../src/account-abstraction/constants/abis.js' +import { entryPoint07Address } from '../src/account-abstraction/constants/address.js' +import { toPackedUserOperation } from '../src/account-abstraction/utils/userOperation/toPackedUserOperation.js' +import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' +import { estimateFeesPerGas } from '../src/actions/public/estimateFeesPerGas.js' +import { getCode } from '../src/actions/public/getCode.js' +import { readContract } from '../src/actions/public/readContract.js' +import { waitForTransactionReceipt } from '../src/actions/public/waitForTransactionReceipt.js' +import { writeContract } from '../src/actions/wallet/writeContract.js' +import { baseSepolia } from '../src/chains/index.js' +import { createClient } from '../src/clients/createClient.js' +import { http } from '../src/clients/transports/http.js' +import { accountConfigurationAbi } from '../src/experimental/eip8130/abis.js' +import { toSmartAccount8130 } from '../src/experimental/eip8130/accounts/toSmartAccount8130.js' +import { actorScope } from '../src/experimental/eip8130/constants.js' +import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' +import { authorizeActor, key } from '../src/experimental/eip8130/keys.js' +import { actorIdFromPublicKey } from '../src/experimental/eip8130/utils/actorId.js' +import { signActorChanges8130 } from '../src/experimental/eip8130/utils/signActorChanges.js' +import { encodeSignedActorChangesSignature } from '../src/experimental/eip8130/utils/signedActorChangesSignature.js' +import { stringToHex } from '../src/utils/encoding/toHex.js' +import { keccak256 } from '../src/utils/hash/keccak256.js' +import { parseEther } from '../src/utils/unit/parseEther.js' + +const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined +const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' + +// A fixed P-256 public key. The op is *not* signed by this key (opAuth was +// dropped) — the current k1 owner authorizes it — so any well-formed (x, y) is +// sufficient to prove the actor is added during validation. +const p256PubKey = { + x: '0x1c1bc89a2b4f5d2e6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f7081929394', + y: '0x9495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3', +} as const + +describe.runIf(PRIVATE_KEY)( + 'self-bundle: create + rotate to P-256 in validation phase + execute', + () => { + test('create, authorize a P-256 actor during validateUserOp, and execute — single userOp (no staking, no opAuth)', async () => { + const owner = privateKeyToAccount(PRIVATE_KEY!) + const client = createClient({ + account: owner, + chain: baseSepolia, + transport: http(RPC_URL), + }) + const deployment = getEip8130Deployment(baseSepolia.id)! + + const userSalt = keccak256(stringToHex(`viem-8130-rotate-${Date.now()}`)) + const account = await toSmartAccount8130({ + client, + owner, + userSalt, + initialActors: [key.k1(owner.address)], + implementation: deployment.accounts.erc4337, + }) + + const p256Actor = key.p256(p256PubKey) + const p256ActorId = actorIdFromPublicKey(p256PubKey) + + console.log('\n— self-bundled create + rotate-to-P256 (Base Sepolia) —') + console.log('owner / bundler: ', owner.address) + console.log('smart account: ', account.address) + console.log('factory (config):', deployment.accountConfiguration) + console.log('new p256 actorId:', p256ActorId) + + const codeBefore = await getCode(client, { address: account.address }) + expect(codeBefore ?? '0x').toBe('0x') + + // Current k1 owner authorizes the new P-256 actor. The account is created + // by the factory phase of this same op, so the local sequence starts at 0. + const set = await signActorChanges8130({ + signer: owner, + account: account.address, + chainId: baseSepolia.id, + sequence: 0, + actorChanges: [authorizeActor(p256Actor, { scope: actorScope.sender })], + }) + // Applying this signed change authorizes the op — there is no opAuth. + const signature = encodeSignedActorChangesSignature([set]) + + // Pre-fund the account's EntryPoint deposit so missingAccountFunds = 0. + const depositHash = await writeContract(client, { + abi: entryPoint07Abi, + address: entryPoint07Address, + functionName: 'depositTo', + args: [account.address], + value: parseEther('0.003'), + account: owner, + chain: baseSepolia, + }) + await waitForTransactionReceipt(client, { hash: depositHash }) + + const { factory, factoryData } = await account.getFactoryArgs() + const callData = await account.encodeCalls([ + { to: owner.address, value: 0n, data: '0x' }, + ]) + const nonce = await readContract(client, { + abi: entryPoint07Abi, + address: entryPoint07Address, + functionName: 'getNonce', + args: [account.address, 0n], + }) + const fees = await estimateFeesPerGas(client) + + const userOperation = { + sender: account.address, + nonce, + factory, + factoryData, + callData, + callGasLimit: 200_000n, + verificationGasLimit: 1_500_000n, + preVerificationGas: 100_000n, + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, + } as const + + // The signature is the validation-phase actor-changes blob (not an op auth). + const packed = toPackedUserOperation({ ...userOperation, signature }) + + const hash = await writeContract(client, { + abi: entryPoint07Abi, + address: entryPoint07Address, + functionName: 'handleOps', + args: [[packed], owner.address], + account: owner, + chain: baseSepolia, + gas: 2_500_000n, + }) + const receipt = await waitForTransactionReceipt(client, { hash }) + console.log( + 'tx: ', + `https://sepolia.basescan.org/tx/${receipt.transactionHash}`, + ) + console.log('status: ', receipt.status) + expect(receipt.status).toBe('success') + + // Public RPCs are load-balanced; poll to avoid reading a lagging replica. + let deployed = false + let isP256Actor = false + for (let i = 0; i < 10; i++) { + const code = await getCode(client, { address: account.address }) + deployed = !!(code && code !== '0x') + if (deployed) { + isP256Actor = await readContract(client, { + abi: accountConfigurationAbi, + address: deployment.accountConfiguration, + functionName: 'isActor', + args: [account.address, p256ActorId], + }) + } + if (deployed && isP256Actor) break + await new Promise((r) => setTimeout(r, 1500)) + } + + console.log('account deployed:', deployed) + console.log('p256 is actor: ', isP256Actor) + expect(deployed).toBeTruthy() + expect(isP256Actor).toBeTruthy() + }, 180_000) + }, +) diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 0eacb38c14..80a44cf302 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -144,6 +144,12 @@ export { type SignActorChanges8130Parameters, signActorChanges8130, } from './utils/signActorChanges.js' +export { + type EncodeSignedActorChangesSignatureErrorType, + encodeSignedActorChangesSignature, + type SignedActorChangeSet, + signedActorChangesMagic, +} from './utils/signedActorChangesSignature.js' export { type Signer, type SignTransaction8130ErrorType, diff --git a/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts b/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts new file mode 100644 index 0000000000..40c69279c6 --- /dev/null +++ b/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from 'vitest' +import { decodeAbiParameters } from '../../../utils/abi/decodeAbiParameters.js' +import { slice } from '../../../utils/data/slice.js' +import { stringToHex } from '../../../utils/encoding/toHex.js' +import { keccak256 } from '../../../utils/hash/keccak256.js' +import { actorScope, canonicalAuthenticators } from '../constants.js' +import { authorizeActor, key, revokeActor } from '../keys.js' +import { encodeActorChangeData } from './actorChangeData.js' +import { + encodeSignedActorChangesSignature, + signedActorChangesMagic, +} from './signedActorChangesSignature.js' + +const pubKey = { + x: '0x1111111111111111111111111111111111111111111111111111111111111111', + y: '0x2222222222222222222222222222222222222222222222222222222222222222', +} as const + +describe('signedActorChangesMagic', () => { + test('matches the contract discriminator', () => { + expect(signedActorChangesMagic).toBe( + keccak256(stringToHex('ERC4337Account.signedActorChanges.v1')), + ) + }) +}) + +describe('encodeSignedActorChangesSignature', () => { + test('prefixes the 32-byte magic', () => { + const signature = encodeSignedActorChangesSignature([ + { + actorChanges: [authorizeActor(key.p256(pubKey))], + auth: '0xdeadbeef', + }, + ]) + expect(slice(signature, 0, 32)).toBe(signedActorChangesMagic) + }) + + test('round-trips a single set through abi.decode', () => { + const change = authorizeActor(key.p256(pubKey), { + scope: actorScope.sender, + }) + const auth = '0xc0ffee' + const signature = encodeSignedActorChangesSignature([ + { actorChanges: [change], auth }, + ]) + + const [magic, changeSets] = decodeAbiParameters( + [ + { type: 'bytes32' }, + { + type: 'tuple[]', + components: [ + { + name: 'changes', + type: 'tuple[]', + components: [ + { name: 'changeType', type: 'uint8' }, + { name: 'actorId', type: 'bytes32' }, + { name: 'data', type: 'bytes' }, + ], + }, + { name: 'auth', type: 'bytes' }, + ], + }, + ], + signature, + ) + + expect(magic).toBe(signedActorChangesMagic) + expect(changeSets).toHaveLength(1) + expect(changeSets[0].auth).toBe(auth) + expect(changeSets[0].changes).toHaveLength(1) + expect(changeSets[0].changes[0].changeType).toBe(change.changeType) + expect(changeSets[0].changes[0].actorId).toBe(change.actorId) + expect(changeSets[0].changes[0].data).toBe(encodeActorChangeData(change)) + }) + + test('encodes multiple sets in order (chained rotations)', () => { + const setA = { + actorChanges: [authorizeActor(key.p256(pubKey))], + auth: '0xaaaa', + } as const + const setB = { + actorChanges: [ + revokeActor(key.p256(pubKey)), + authorizeActor(key.k1('0x0000000000000000000000000000000000000abc')), + ], + auth: '0xbbbb', + } as const + + const signature = encodeSignedActorChangesSignature([setA, setB]) + const [, changeSets] = decodeAbiParameters( + [ + { type: 'bytes32' }, + { + type: 'tuple[]', + components: [ + { + name: 'changes', + type: 'tuple[]', + components: [ + { name: 'changeType', type: 'uint8' }, + { name: 'actorId', type: 'bytes32' }, + { name: 'data', type: 'bytes' }, + ], + }, + { name: 'auth', type: 'bytes' }, + ], + }, + ], + signature, + ) + + expect(changeSets).toHaveLength(2) + expect(changeSets[0].auth).toBe(setA.auth) + expect(changeSets[1].auth).toBe(setB.auth) + // revokeActor encodes empty `data`. + expect(changeSets[1].changes[0].data).toBe('0x') + expect(changeSets[1].changes[1].changeType).toBe(0x01) + }) + + test('p256 authorize data carries the canonical authenticator', () => { + const change = authorizeActor(key.p256(pubKey)) + const signature = encodeSignedActorChangesSignature([ + { actorChanges: [change], auth: '0x' }, + ]) + const [, changeSets] = decodeAbiParameters( + [ + { type: 'bytes32' }, + { + type: 'tuple[]', + components: [ + { + name: 'changes', + type: 'tuple[]', + components: [ + { name: 'changeType', type: 'uint8' }, + { name: 'actorId', type: 'bytes32' }, + { name: 'data', type: 'bytes' }, + ], + }, + { name: 'auth', type: 'bytes' }, + ], + }, + ], + signature, + ) + expect(changeSets[0].changes[0].data.toLowerCase()).toContain( + canonicalAuthenticators.p256.slice(2).toLowerCase(), + ) + }) +}) diff --git a/src/experimental/eip8130/utils/signedActorChangesSignature.ts b/src/experimental/eip8130/utils/signedActorChangesSignature.ts new file mode 100644 index 0000000000..0ed2eaba7e --- /dev/null +++ b/src/experimental/eip8130/utils/signedActorChangesSignature.ts @@ -0,0 +1,112 @@ +import type { ErrorType } from '../../../errors/utils.js' +import type { Hex } from '../../../types/misc.js' +import { + type EncodeAbiParametersErrorType, + encodeAbiParameters, +} from '../../../utils/abi/encodeAbiParameters.js' +import { + stringToHex, + type ToHexErrorType, +} from '../../../utils/encoding/toHex.js' +import { + type Keccak256ErrorType, + keccak256, +} from '../../../utils/hash/keccak256.js' +import type { AaActorChange } from '../types/transaction.js' +import { encodeActorChangeData } from './actorChangeData.js' + +/** + * `keccak256("ERC4337Account.signedActorChanges.v1")` — the 32-byte discriminator + * that prefixes a `BackwardCompatibleERC4337Account` UserOperation signature + * carrying validation-phase actor changes. + */ +export const signedActorChangesMagic = keccak256( + stringToHex('ERC4337Account.signedActorChanges.v1'), +) + +export type SignedActorChangeSet = { + /** + * Actor change operations applied as one batch (consuming one sequence). Use a + * {@link key} builder + {@link authorizeActor}/{@link revokeActor} to construct. + */ + actorChanges: readonly AaActorChange[] + /** + * Authorization over the batch digest in `authenticator || data` form, as + * produced by {@link signActorChanges8130} (its `auth` field). + */ + auth: Hex +} + +const signatureParameters = [ + { type: 'bytes32' }, + { + type: 'tuple[]', + components: [ + { + name: 'changes', + type: 'tuple[]', + components: [ + { name: 'changeType', type: 'uint8' }, + { name: 'actorId', type: 'bytes32' }, + { name: 'data', type: 'bytes' }, + ], + }, + { name: 'auth', type: 'bytes' }, + ], + }, +] as const + +export type EncodeSignedActorChangesSignatureErrorType = + | EncodeAbiParametersErrorType + | Keccak256ErrorType + | ToHexErrorType + | ErrorType + +/** + * Encodes a `BackwardCompatibleERC4337Account` validation-phase signature that + * carries signed actor changes: + * + * ``` + * abi.encode(bytes32 SIGNED_ACTOR_CHANGES_MAGIC, SignedActorChanges[] changeSets) + * ``` + * + * where each `SignedActorChanges` is `(ActorChange[] changes, bytes auth)`. + * + * Use the result as a UserOperation's `signature`. During `validateUserOp` the + * account applies each set in order via `AccountConfiguration.applySignedActorChanges`; + * successfully applying the (non-empty) chain authorizes the op — there is no + * separate op-over-`userOpHash` signature. Sets are applied in array order, so a + * later set may rely on an actor authorized by an earlier one (e.g. owner → key B, + * then key B → key C). + * + * @remarks + * The change digest does not bind `userOpHash`/`callData`, so this path is + * intended for self-bundled (direct `EntryPoint.handleOps`) submission. + * + * @example + * ```ts + * const set = await signActorChanges8130({ + * signer: owner, // current k1 owner authorizes the change + * account: smartAccount, + * chainId: baseSepolia.id, + * sequence, + * actorChanges: [authorizeActor(key.p256({ x, y }), { scope: actorScope.sender })], + * }) + * const signature = encodeSignedActorChangesSignature([set]) + * ``` + */ +export function encodeSignedActorChangesSignature( + changeSets: readonly SignedActorChangeSet[], +): Hex { + return encodeAbiParameters(signatureParameters, [ + signedActorChangesMagic, + changeSets.map((set) => ({ + changes: set.actorChanges.map((change) => ({ + changeType: change.changeType, + actorId: change.actorId, + data: encodeActorChangeData(change), + })), + auth: set.auth, + })), + ]) +} diff --git a/test/vitest.eip8130.config.ts b/test/vitest.eip8130.config.ts index 9b324e3f1e..bf2cf34307 100644 --- a/test/vitest.eip8130.config.ts +++ b/test/vitest.eip8130.config.ts @@ -15,6 +15,10 @@ export default defineConfig({ 'src/experimental/eip8168/**/*.test.ts', 'scripts/setup8130Account.test.ts', 'scripts/authorizeSessionKey.test.ts', + 'scripts/bundlerCreateAndExecute.test.ts', + 'scripts/bundlerProbeDeployed.test.ts', + 'scripts/selfBundleCreate.test.ts', + 'scripts/selfBundleRotateP256.test.ts', ], testTimeout: 120_000, }, From b28db2926399d46effbabc544f22fcb7a0bc8312 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 10 Jun 2026 14:13:29 -0400 Subject: [PATCH 13/96] chore(eip8130): update Base Sepolia deployment addresses New Deploy.s.sol + CreateAccounts.s.sol run: AccountConfiguration: 0xb0198a714872EE5bfDF829e7986DB5C5899a6b50 DefaultAccount: 0x124b52d5D57a76ed064c414975beA11Beffe0251 DefaultHighRateAccount:0x13dD0F222cCF60B7C08a95C2d1FcC85A38DD675D ERC4337Account: 0x9748aeA1e1762E50a4d8927777FeDB63A2Ef06C0 DelegateAuthenticator: 0xE67D299Ff3F0a185398B6C5a28998696969265d7 --- src/experimental/eip8130/deployments.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index 50a0b887af..c6de8be7d5 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -34,17 +34,17 @@ export type Eip8130Deployment = { /** EIP-8130 deployment on Base Sepolia (chain id `84532`). */ export const baseSepoliaDeployment = { - accountConfiguration: '0xe6BB4A62034c4F7494A411E28d0a18B1BB55DEE6', + accountConfiguration: '0xb0198a714872EE5bfDF829e7986DB5C5899a6b50', accounts: { - default: '0xE69fca5270f01c40E9884E503a9961195438E6fD', - defaultHighRate: '0x8aba250115EAE82A9C3df830DF8B47b255a593a4', - erc4337: '0x1feBaCc134664AaCf8C15910460426699F1Ef92b', + default: '0x124b52d5D57a76ed064c414975beA11Beffe0251', + defaultHighRate: '0x13dD0F222cCF60B7C08a95C2d1FcC85A38DD675D', + erc4337: '0x9748aeA1e1762E50a4d8927777FeDB63A2Ef06C0', }, authenticators: { k1: '0x39221FB37Df105B22316328e88632C9684861466', p256: '0x3AE129D846CD1CAf0369b4Caa56c188E18E11B15', webAuthn: '0x1CB75BE39Fb950202BF4239010534B86EdA66c31', - delegate: '0x0d10CfB3D0CD016bf20b7254C4a869FBbc0ad8C7', + delegate: '0xE67D299Ff3F0a185398B6C5a28998696969265d7', alwaysValid: '0x520fBA4840729CB57b3Dc7B40D548AcF354DBA25', }, } as const satisfies Eip8130Deployment From 46d3791a4c1d9f4502c893a667bc4ad00b78d34b Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 10 Jun 2026 18:05:26 -0400 Subject: [PATCH 14/96] fix(eip8130): include opAuth in validation-phase signature; update deployment The deployed BackwardCompatibleERC4337Account decodes the signed-actor-changes signature as (bytes32 magic, SignedActorChanges[] changeSets, bytes opAuth) and authenticates the op via the trailing opAuth blob over userOpHash. Re-add the opAuth parameter to encodeSignedActorChangesSignature to match, and pass the correct AccountConfiguration address through the self-bundle scripts. - constants: point accountConfigAddress / defaultAccountAddress at the latest Base Sepolia deployment - signedActorChangesSignature: add required opAuth arg + update tests - scripts: pass accountConfigAddress explicitly; selfBundleRotateP256 signs userOpHash as opAuth and uses sequence 1 (createAccount seeds localSequence=1) --- scripts/bundlerProbeDeployed.test.ts | 1 + scripts/selfBundleCreate.test.ts | 1 + scripts/selfBundleRotateP256.test.ts | 75 +++++++---- src/experimental/eip8130/constants.ts | 4 +- .../utils/signedActorChangesSignature.test.ts | 117 +++++++----------- .../utils/signedActorChangesSignature.ts | 24 ++-- 6 files changed, 108 insertions(+), 114 deletions(-) diff --git a/scripts/bundlerProbeDeployed.test.ts b/scripts/bundlerProbeDeployed.test.ts index 5d84327ef4..e3eeb4f876 100644 --- a/scripts/bundlerProbeDeployed.test.ts +++ b/scripts/bundlerProbeDeployed.test.ts @@ -43,6 +43,7 @@ describe.runIf(PRIVATE_KEY)('bundler probe: transact on a pre-deployed account', address: '0x64609Df27EFb3ecB241B349a3985DFdE2B98dc6b', userSalt: keccak256(stringToHex('viem-eip8130-demo-1')), initialActors: [key.k1(owner.address)], + accountConfigAddress: deployment.accountConfiguration, implementation: deployment.accounts.erc4337, }) diff --git a/scripts/selfBundleCreate.test.ts b/scripts/selfBundleCreate.test.ts index 4e1055a78d..de34abab61 100644 --- a/scripts/selfBundleCreate.test.ts +++ b/scripts/selfBundleCreate.test.ts @@ -42,6 +42,7 @@ describe.runIf(PRIVATE_KEY)( userSalt, initialActors: [key.k1(owner.address)], implementation: deployment.accounts.erc4337, + accountConfigAddress: deployment.accountConfiguration, }) console.log('\n— self-bundled create + execute (Base Sepolia) —') diff --git a/scripts/selfBundleRotateP256.test.ts b/scripts/selfBundleRotateP256.test.ts index 86c6483368..bfefe29043 100644 --- a/scripts/selfBundleRotateP256.test.ts +++ b/scripts/selfBundleRotateP256.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest' import { entryPoint07Abi } from '../src/account-abstraction/constants/abis.js' import { entryPoint07Address } from '../src/account-abstraction/constants/address.js' +import { getUserOperationHash } from '../src/account-abstraction/utils/userOperation/getUserOperationHash.js' import { toPackedUserOperation } from '../src/account-abstraction/utils/userOperation/toPackedUserOperation.js' import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' import { estimateFeesPerGas } from '../src/actions/public/estimateFeesPerGas.js' @@ -13,12 +14,16 @@ import { createClient } from '../src/clients/createClient.js' import { http } from '../src/clients/transports/http.js' import { accountConfigurationAbi } from '../src/experimental/eip8130/abis.js' import { toSmartAccount8130 } from '../src/experimental/eip8130/accounts/toSmartAccount8130.js' -import { actorScope } from '../src/experimental/eip8130/constants.js' +import { + actorScope, + ecrecoverAuthenticator, +} from '../src/experimental/eip8130/constants.js' import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' import { authorizeActor, key } from '../src/experimental/eip8130/keys.js' import { actorIdFromPublicKey } from '../src/experimental/eip8130/utils/actorId.js' import { signActorChanges8130 } from '../src/experimental/eip8130/utils/signActorChanges.js' import { encodeSignedActorChangesSignature } from '../src/experimental/eip8130/utils/signedActorChangesSignature.js' +import { concatHex } from '../src/utils/data/concat.js' import { stringToHex } from '../src/utils/encoding/toHex.js' import { keccak256 } from '../src/utils/hash/keccak256.js' import { parseEther } from '../src/utils/unit/parseEther.js' @@ -26,18 +31,18 @@ import { parseEther } from '../src/utils/unit/parseEther.js' const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' -// A fixed P-256 public key. The op is *not* signed by this key (opAuth was -// dropped) — the current k1 owner authorizes it — so any well-formed (x, y) is -// sufficient to prove the actor is added during validation. +// P-256 generator point (Gx, Gy) — a valid, well-known public key used purely +// to prove the actor is registered on-chain during validateUserOp. The op is +// authorized by the k1 owner signing the actor change, not by this key. const p256PubKey = { - x: '0x1c1bc89a2b4f5d2e6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f7081929394', - y: '0x9495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3', + x: '0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296', + y: '0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5', } as const describe.runIf(PRIVATE_KEY)( 'self-bundle: create + rotate to P-256 in validation phase + execute', () => { - test('create, authorize a P-256 actor during validateUserOp, and execute — single userOp (no staking, no opAuth)', async () => { + test('create, authorize a P-256 actor during validateUserOp, and execute — single userOp (no staking)', async () => { const owner = privateKeyToAccount(PRIVATE_KEY!) const client = createClient({ account: owner, @@ -47,13 +52,14 @@ describe.runIf(PRIVATE_KEY)( const deployment = getEip8130Deployment(baseSepolia.id)! const userSalt = keccak256(stringToHex(`viem-8130-rotate-${Date.now()}`)) - const account = await toSmartAccount8130({ - client, - owner, - userSalt, - initialActors: [key.k1(owner.address)], - implementation: deployment.accounts.erc4337, - }) + const account = await toSmartAccount8130({ + client, + owner, + userSalt, + initialActors: [key.k1(owner.address)], + implementation: deployment.accounts.erc4337, + accountConfigAddress: deployment.accountConfiguration, + }) const p256Actor = key.p256(p256PubKey) const p256ActorId = actorIdFromPublicKey(p256PubKey) @@ -67,18 +73,6 @@ describe.runIf(PRIVATE_KEY)( const codeBefore = await getCode(client, { address: account.address }) expect(codeBefore ?? '0x').toBe('0x') - // Current k1 owner authorizes the new P-256 actor. The account is created - // by the factory phase of this same op, so the local sequence starts at 0. - const set = await signActorChanges8130({ - signer: owner, - account: account.address, - chainId: baseSepolia.id, - sequence: 0, - actorChanges: [authorizeActor(p256Actor, { scope: actorScope.sender })], - }) - // Applying this signed change authorizes the op — there is no opAuth. - const signature = encodeSignedActorChangesSignature([set]) - // Pre-fund the account's EntryPoint deposit so missingAccountFunds = 0. const depositHash = await writeContract(client, { abi: entryPoint07Abi, @@ -116,7 +110,34 @@ describe.runIf(PRIVATE_KEY)( maxPriorityFeePerGas: fees.maxPriorityFeePerGas, } as const - // The signature is the validation-phase actor-changes blob (not an op auth). + // Compute the userOpHash so we can produce opAuth before finalizing the signature. + const userOpHash = getUserOperationHash({ + chainId: baseSepolia.id, + entryPointAddress: entryPoint07Address, + entryPointVersion: '0.7', + userOperation: { ...userOperation, sender: account.address }, + }) + + // Current k1 owner authorizes the new P-256 actor. createAccount() sets + // localSequence = 1 (as the initialized flag), so the first + // applySignedActorChanges call on a fresh account must sign over sequence 1. + const set = await signActorChanges8130({ + signer: owner, + account: account.address, + chainId: baseSepolia.id, + sequence: 1, + actorChanges: [authorizeActor(p256Actor, { scope: actorScope.sender })], + }) + + // opAuth: k1 owner signs the userOpHash in authenticator || data format. + // The owner is the initial actor so this always passes. A rotate-only op + // could use the newly added P-256 key here instead. + const opAuth = concatHex([ + ecrecoverAuthenticator, + await owner.sign({ hash: userOpHash }), + ]) + const signature = encodeSignedActorChangesSignature([set], opAuth) + const packed = toPackedUserOperation({ ...userOperation, signature }) const hash = await writeContract(client, { diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts index 8dbf6fffcb..a0870154a7 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/experimental/eip8130/constants.ts @@ -104,7 +104,7 @@ export const txContextAddress = * parameter of {@link computeAddress8130}. */ export const accountConfigAddress = - '0xe6BB4A62034c4F7494A411E28d0a18B1BB55DEE6' satisfies Hex + '0xb0198a714872EE5bfDF829e7986DB5C5899a6b50' satisfies Hex /** * Default wallet implementation for EOA auto-delegation @@ -115,7 +115,7 @@ export const accountConfigAddress = * {@link accountConfigAddress}. */ export const defaultAccountAddress = - '0xE69fca5270f01c40E9884E503a9961195438E6fD' satisfies Hex + '0x124b52d5D57a76ed064c414975beA11Beffe0251' satisfies Hex /** Size of the deployment header in bytes (`DEPLOYMENT_HEADER_SIZE`). */ export const deploymentHeaderSize = 14 diff --git a/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts b/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts index 40c69279c6..d6e008abf0 100644 --- a/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts +++ b/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts @@ -16,6 +16,26 @@ const pubKey = { y: '0x2222222222222222222222222222222222222222222222222222222222222222', } as const +const decodeParameters = [ + { type: 'bytes32' }, + { + type: 'tuple[]', + components: [ + { + name: 'changes', + type: 'tuple[]', + components: [ + { name: 'changeType', type: 'uint8' }, + { name: 'actorId', type: 'bytes32' }, + { name: 'data', type: 'bytes' }, + ], + }, + { name: 'auth', type: 'bytes' }, + ], + }, + { name: 'opAuth', type: 'bytes' }, +] as const + describe('signedActorChangesMagic', () => { test('matches the contract discriminator', () => { expect(signedActorChangesMagic).toBe( @@ -26,12 +46,15 @@ describe('signedActorChangesMagic', () => { describe('encodeSignedActorChangesSignature', () => { test('prefixes the 32-byte magic', () => { - const signature = encodeSignedActorChangesSignature([ - { - actorChanges: [authorizeActor(key.p256(pubKey))], - auth: '0xdeadbeef', - }, - ]) + const signature = encodeSignedActorChangesSignature( + [ + { + actorChanges: [authorizeActor(key.p256(pubKey))], + auth: '0xdeadbeef', + }, + ], + '0x', + ) expect(slice(signature, 0, 32)).toBe(signedActorChangesMagic) }) @@ -40,29 +63,14 @@ describe('encodeSignedActorChangesSignature', () => { scope: actorScope.sender, }) const auth = '0xc0ffee' - const signature = encodeSignedActorChangesSignature([ - { actorChanges: [change], auth }, - ]) + const opAuth = '0xdeadbeef' + const signature = encodeSignedActorChangesSignature( + [{ actorChanges: [change], auth }], + opAuth, + ) - const [magic, changeSets] = decodeAbiParameters( - [ - { type: 'bytes32' }, - { - type: 'tuple[]', - components: [ - { - name: 'changes', - type: 'tuple[]', - components: [ - { name: 'changeType', type: 'uint8' }, - { name: 'actorId', type: 'bytes32' }, - { name: 'data', type: 'bytes' }, - ], - }, - { name: 'auth', type: 'bytes' }, - ], - }, - ], + const [magic, changeSets, decodedOpAuth] = decodeAbiParameters( + decodeParameters, signature, ) @@ -73,6 +81,7 @@ describe('encodeSignedActorChangesSignature', () => { expect(changeSets[0].changes[0].changeType).toBe(change.changeType) expect(changeSets[0].changes[0].actorId).toBe(change.actorId) expect(changeSets[0].changes[0].data).toBe(encodeActorChangeData(change)) + expect(decodedOpAuth).toBe(opAuth) }) test('encodes multiple sets in order (chained rotations)', () => { @@ -88,63 +97,23 @@ describe('encodeSignedActorChangesSignature', () => { auth: '0xbbbb', } as const - const signature = encodeSignedActorChangesSignature([setA, setB]) - const [, changeSets] = decodeAbiParameters( - [ - { type: 'bytes32' }, - { - type: 'tuple[]', - components: [ - { - name: 'changes', - type: 'tuple[]', - components: [ - { name: 'changeType', type: 'uint8' }, - { name: 'actorId', type: 'bytes32' }, - { name: 'data', type: 'bytes' }, - ], - }, - { name: 'auth', type: 'bytes' }, - ], - }, - ], - signature, - ) + const signature = encodeSignedActorChangesSignature([setA, setB], '0x1234') + const [, changeSets] = decodeAbiParameters(decodeParameters, signature) expect(changeSets).toHaveLength(2) expect(changeSets[0].auth).toBe(setA.auth) expect(changeSets[1].auth).toBe(setB.auth) - // revokeActor encodes empty `data`. expect(changeSets[1].changes[0].data).toBe('0x') expect(changeSets[1].changes[1].changeType).toBe(0x01) }) test('p256 authorize data carries the canonical authenticator', () => { const change = authorizeActor(key.p256(pubKey)) - const signature = encodeSignedActorChangesSignature([ - { actorChanges: [change], auth: '0x' }, - ]) - const [, changeSets] = decodeAbiParameters( - [ - { type: 'bytes32' }, - { - type: 'tuple[]', - components: [ - { - name: 'changes', - type: 'tuple[]', - components: [ - { name: 'changeType', type: 'uint8' }, - { name: 'actorId', type: 'bytes32' }, - { name: 'data', type: 'bytes' }, - ], - }, - { name: 'auth', type: 'bytes' }, - ], - }, - ], - signature, + const signature = encodeSignedActorChangesSignature( + [{ actorChanges: [change], auth: '0x' }], + '0x', ) + const [, changeSets] = decodeAbiParameters(decodeParameters, signature) expect(changeSets[0].changes[0].data.toLowerCase()).toContain( canonicalAuthenticators.p256.slice(2).toLowerCase(), ) diff --git a/src/experimental/eip8130/utils/signedActorChangesSignature.ts b/src/experimental/eip8130/utils/signedActorChangesSignature.ts index 0ed2eaba7e..1a2614da68 100644 --- a/src/experimental/eip8130/utils/signedActorChangesSignature.ts +++ b/src/experimental/eip8130/utils/signedActorChangesSignature.ts @@ -54,6 +54,7 @@ const signatureParameters = [ { name: 'auth', type: 'bytes' }, ], }, + { name: 'opAuth', type: 'bytes' }, ] as const export type EncodeSignedActorChangesSignatureErrorType = @@ -67,36 +68,36 @@ export type EncodeSignedActorChangesSignatureErrorType = * carries signed actor changes: * * ``` - * abi.encode(bytes32 SIGNED_ACTOR_CHANGES_MAGIC, SignedActorChanges[] changeSets) + * abi.encode(bytes32 SIGNED_ACTOR_CHANGES_MAGIC, SignedActorChanges[] changeSets, bytes opAuth) * ``` * - * where each `SignedActorChanges` is `(ActorChange[] changes, bytes auth)`. + * where each `SignedActorChanges` is `(ActorChange[] changes, bytes auth)` and + * `opAuth` is an `authenticator || data` blob that authorizes the operation over + * `userOpHash` (may be produced by a key the changes just added/rotated to). * * Use the result as a UserOperation's `signature`. During `validateUserOp` the - * account applies each set in order via `AccountConfiguration.applySignedActorChanges`; - * successfully applying the (non-empty) chain authorizes the op — there is no - * separate op-over-`userOpHash` signature. Sets are applied in array order, so a + * account first applies each set in order via `AccountConfiguration.applySignedActorChanges`, + * then authenticates the op via `opAuth`. Sets are applied in array order, so a * later set may rely on an actor authorized by an earlier one (e.g. owner → key B, * then key B → key C). * - * @remarks - * The change digest does not bind `userOpHash`/`callData`, so this path is - * intended for self-bundled (direct `EntryPoint.handleOps`) submission. - * * @example * ```ts * const set = await signActorChanges8130({ - * signer: owner, // current k1 owner authorizes the change + * signer: owner, * account: smartAccount, * chainId: baseSepolia.id, * sequence, * actorChanges: [authorizeActor(key.p256({ x, y }), { scope: actorScope.sender })], * }) - * const signature = encodeSignedActorChangesSignature([set]) + * // opAuth: authenticator-prefixed signature over the userOpHash by any authorized actor + * const opAuth = concatHex([ecrecoverAuthenticator, await owner.sign({ hash: userOpHash })]) + * const signature = encodeSignedActorChangesSignature([set], opAuth) * ``` */ export function encodeSignedActorChangesSignature( changeSets: readonly SignedActorChangeSet[], + opAuth: Hex, ): Hex { return encodeAbiParameters(signatureParameters, [ signedActorChangesMagic, @@ -108,5 +109,6 @@ export function encodeSignedActorChangesSignature( })), auth: set.auth, })), + opAuth, ]) } From f5f020deb982e67c2a3345c28d0233115bf55260 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 10 Jun 2026 18:26:24 -0400 Subject: [PATCH 15/96] chore(eip8130): consolidate manual scripts under scripts/eip8130 + add tx demo Move the EIP-8130 dev/integration scripts into a dedicated scripts/eip8130/ folder and collapse the vitest include to a single glob so new scripts are picked up automatically. Add build8130Transaction: an offline demo that builds, signs, serializes, and parses an EIP-8130 transaction, printing the JSON object, the RLP envelope, and the decoded 13-field wire layout. The demo highlights that a call is only { to, data } (no per-call value). --- .../{ => eip8130}/authorizeSessionKey.test.ts | 36 ++-- scripts/eip8130/build8130Transaction.test.ts | 181 ++++++++++++++++++ .../bundlerCreateAndExecute.test.ts | 32 ++-- .../bundlerProbeDeployed.test.ts | 30 +-- .../{ => eip8130}/selfBundleCreate.test.ts | 36 ++-- .../selfBundleRotateP256.test.ts | 50 ++--- .../{ => eip8130}/setup8130Account.test.ts | 30 +-- test/vitest.eip8130.config.ts | 8 +- 8 files changed, 290 insertions(+), 113 deletions(-) rename scripts/{ => eip8130}/authorizeSessionKey.test.ts (74%) create mode 100644 scripts/eip8130/build8130Transaction.test.ts rename scripts/{ => eip8130}/bundlerCreateAndExecute.test.ts (76%) rename scripts/{ => eip8130}/bundlerProbeDeployed.test.ts (69%) rename scripts/{ => eip8130}/selfBundleCreate.test.ts (76%) rename scripts/{ => eip8130}/selfBundleRotateP256.test.ts (75%) rename scripts/{ => eip8130}/setup8130Account.test.ts (72%) diff --git a/scripts/authorizeSessionKey.test.ts b/scripts/eip8130/authorizeSessionKey.test.ts similarity index 74% rename from scripts/authorizeSessionKey.test.ts rename to scripts/eip8130/authorizeSessionKey.test.ts index 2d7792c1f3..5ebc6217af 100644 --- a/scripts/authorizeSessionKey.test.ts +++ b/scripts/eip8130/authorizeSessionKey.test.ts @@ -1,22 +1,22 @@ import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' -import { getCode } from '../src/actions/public/getCode.js' -import { readContract } from '../src/actions/public/readContract.js' -import { waitForTransactionReceipt } from '../src/actions/public/waitForTransactionReceipt.js' -import { sendTransaction } from '../src/actions/wallet/sendTransaction.js' -import { baseSepolia } from '../src/chains/index.js' -import { createClient } from '../src/clients/createClient.js' -import { http } from '../src/clients/transports/http.js' -import { accountConfigurationAbi } from '../src/experimental/eip8130/abis.js' -import { actorScope } from '../src/experimental/eip8130/constants.js' -import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' -import { authorizeActor, key } from '../src/experimental/eip8130/keys.js' -import { encodeApplySignedActorChangesData } from '../src/experimental/eip8130/utils/accountConfigCalls.js' -import { computeAddress8130 } from '../src/experimental/eip8130/utils/computeAddress.js' -import { erc1167Bytecode } from '../src/experimental/eip8130/utils/proxy.js' -import { signActorChanges8130 } from '../src/experimental/eip8130/utils/signActorChanges.js' -import { stringToHex } from '../src/utils/encoding/toHex.js' -import { keccak256 } from '../src/utils/hash/keccak256.js' +import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' +import { getCode } from '../../src/actions/public/getCode.js' +import { readContract } from '../../src/actions/public/readContract.js' +import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' +import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' +import { baseSepolia } from '../../src/chains/index.js' +import { createClient } from '../../src/clients/createClient.js' +import { http } from '../../src/clients/transports/http.js' +import { accountConfigurationAbi } from '../../src/experimental/eip8130/abis.js' +import { actorScope } from '../../src/experimental/eip8130/constants.js' +import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' +import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' +import { encodeApplySignedActorChangesData } from '../../src/experimental/eip8130/utils/accountConfigCalls.js' +import { computeAddress8130 } from '../../src/experimental/eip8130/utils/computeAddress.js' +import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' +import { signActorChanges8130 } from '../../src/experimental/eip8130/utils/signActorChanges.js' +import { stringToHex } from '../../src/utils/encoding/toHex.js' +import { keccak256 } from '../../src/utils/hash/keccak256.js' const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' diff --git a/scripts/eip8130/build8130Transaction.test.ts b/scripts/eip8130/build8130Transaction.test.ts new file mode 100644 index 0000000000..fc2a2be350 --- /dev/null +++ b/scripts/eip8130/build8130Transaction.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' +import { baseSepolia } from '../../src/chains/index.js' +import { + actorScope, + canonicalAuthenticators, +} from '../../src/experimental/eip8130/constants.js' +import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' +import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' +import type { + AaCalls, + TransactionSerializable8130, +} from '../../src/experimental/eip8130/types/transaction.js' +import { parseTransaction8130 } from '../../src/experimental/eip8130/utils/parseTransaction.js' +import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' +import { serializeTransaction8130 } from '../../src/experimental/eip8130/utils/serializeTransaction.js' +import { signTransaction8130 } from '../../src/experimental/eip8130/utils/signTransaction.js' +import { sliceHex } from '../../src/utils/data/slice.js' +import { fromRlp } from '../../src/utils/encoding/fromRlp.js' +import { stringToHex } from '../../src/utils/encoding/toHex.js' +import { keccak256 } from '../../src/utils/hash/keccak256.js' + +// Publicly-known Hardhat test key #0 — NOT a secret, used only to make the +// demo's signature/output deterministic. +const DEMO_KEY = + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as const + +// Pretty-prints an object whose leaves may be bigint (JSON can't serialize those). +function jsonify(value: unknown): string { + return JSON.stringify( + value, + (_, v) => (typeof v === 'bigint' ? `${v.toString()} (bigint)` : v), + 2, + ) +} + +describe('build an EIP-8130 transaction (offline demo)', () => { + test('serialize → JSON + RLP envelope, then parse back', async () => { + const owner = privateKeyToAccount(DEMO_KEY) + const deployment = getEip8130Deployment(baseSepolia.id)! + + const p256PubKey = { + x: '0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296', + y: '0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5', + } as const + + // ── calls ────────────────────────────────────────────────────────────── + // Calls are grouped into ORDERED PHASES. Each phase is its own atomic batch. + // NOTE on `value`: an EIP-8130 call is ONLY `{ to, data }` — there is no + // per-call `value` field at all (a call is RLP `[to, data]`). ETH value + // movement is driven by the account's wallet bytecode via `data`, not by a + // value on each call. (The TS type `AaCall` reflects this: it has no `value`.) + const calls: AaCalls = [ + // Phase 0 — e.g. an ERC-20 approve. + [ + { + to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + data: '0x095ea7b3', // approve(...) selector (args elided for brevity) + }, + ], + // Phase 1 — two calls executed atomically after phase 0 succeeds. + [ + { + to: '0x2626664c2603336E57B271c5C0b26F421741e481', + data: '0x3593564c', // execute(...) selector + }, + { + to: owner.address, + data: '0x', + }, + ], + ] + + // ── the transaction ────────────────────────────────────────────────────── + // A self-paid tx that ALSO deploys the account (a `create` account change) + // and registers a P-256 session key (a `config` change is shown via the + // higher-level helpers elsewhere; here we keep the create + calls focused). + const transaction: TransactionSerializable8130 = { + chainId: baseSepolia.id, + // EOA path: omit `from` and let the sender be recovered from senderAuth. + nonceSequence: 0n, + maxPriorityFeePerGas: 1_000_000n, // 0.001 gwei + maxFeePerGas: 100_000_000n, // 0.1 gwei + gas: 500_000n, + accountChanges: [ + { + type: 'create', + userSalt: keccak256(stringToHex('viem-8130-demo')), + // ERC-1167 minimal proxy → the canonical wallet implementation. + code: erc1167Bytecode(deployment.accounts.default), + // Initial actors MUST be sorted by actorId ascending. One owner here. + initialActors: [key.k1(owner.address)], + }, + ], + calls, + // self-pay → no payer / payerAuth + } + + console.log('\n══════════════════════════════════════════════════════════') + console.log(' EIP-8130 transaction (unsigned, serializable form)') + console.log('══════════════════════════════════════════════════════════') + console.log(jsonify(transaction)) + console.log( + '\nnote: each call is only { to, data } — EIP-8130 calls carry no `value`.', + ) + + // Show that an authorizeActor change (a P-256 session key) is built the same + // way — for reference, not added to the tx above. + const sessionKeyChange = authorizeActor(key.p256(p256PubKey), { + scope: actorScope.sender, + }) + console.log('\n— example authorizeActor change (P-256 session key) —') + console.log(jsonify(sessionKeyChange)) + console.log(' p256 authenticator:', canonicalAuthenticators.p256) + + // ── sign + serialize ───────────────────────────────────────────────────── + const serialized = await signTransaction8130({ + transaction, + account: owner, + }) + + console.log('\n══════════════════════════════════════════════════════════') + console.log(' Serialized envelope (EIP-2718: AA_TX_TYPE || rlp(body))') + console.log('══════════════════════════════════════════════════════════') + console.log('type byte:', sliceHex(serialized, 0, 1), '(AA_TX_TYPE = 0x7b)') + console.log('byte length:', (serialized.length - 2) / 2) + console.log('\nrlp envelope:') + console.log(serialized) + + // ── decode the raw RLP to show the 13-field wire layout ────────────────── + const fields = fromRlp(sliceHex(serialized, 1), 'hex') as unknown[] + const fieldNames = [ + 'chain_id', + 'sender', + 'nonce_key', + 'nonce_sequence', + 'expiry', + 'max_priority_fee_per_gas', + 'max_fee_per_gas', + 'gas_limit', + 'account_changes', + 'calls', + 'payer', + 'sender_auth', + 'payer_auth', + ] + console.log('\n— raw RLP fields (13 elements) —') + fields.forEach((value, i) => { + const rendered = Array.isArray(value) + ? jsonify(value) + : (value as string) + console.log(` [${i}] ${fieldNames[i]}: ${rendered}`) + }) + + // ── round-trip: parse the envelope back to a structured tx ─────────────── + const parsed = parseTransaction8130(serialized) + console.log('\n══════════════════════════════════════════════════════════') + console.log(' Parsed back from the envelope') + console.log('══════════════════════════════════════════════════════════') + console.log(jsonify(parsed)) + + // The round-trip must reproduce the input (sans the bogus `value`). + expect(parsed.chainId).toBe(baseSepolia.id) + expect(parsed.calls).toHaveLength(2) + expect(parsed.calls?.[1]).toHaveLength(2) + // A call is only `{ to, data }` — there is never a per-call `value`. + expect(parsed.calls?.[1][0].to.toLowerCase()).toBe( + '0x2626664c2603336e57b271c5c0b26f421741e481', + ) + expect(parsed.calls?.[1][0].data).toBe('0x3593564c') + expect((parsed.calls?.[1][0] as { value?: unknown }).value).toBeUndefined() + expect(parsed.accountChanges?.[0].type).toBe('create') + expect(parsed.senderAuth).toBeDefined() + // Self-pay: no payer / payerAuth. + expect(parsed.payer).toBeUndefined() + expect(parsed.payerAuth).toBeUndefined() + + // Re-serializing the parsed tx yields the identical envelope. + expect(serializeTransaction8130(parsed)).toBe(serialized) + }) +}) diff --git a/scripts/bundlerCreateAndExecute.test.ts b/scripts/eip8130/bundlerCreateAndExecute.test.ts similarity index 76% rename from scripts/bundlerCreateAndExecute.test.ts rename to scripts/eip8130/bundlerCreateAndExecute.test.ts index fc3a177a1a..95c8fec901 100644 --- a/scripts/bundlerCreateAndExecute.test.ts +++ b/scripts/eip8130/bundlerCreateAndExecute.test.ts @@ -1,20 +1,20 @@ import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' -import { createBundlerClient } from '../src/account-abstraction/clients/createBundlerClient.js' -import { estimateFeesPerGas } from '../src/actions/public/estimateFeesPerGas.js' -import { getBalance } from '../src/actions/public/getBalance.js' -import { getCode } from '../src/actions/public/getCode.js' -import { waitForTransactionReceipt } from '../src/actions/public/waitForTransactionReceipt.js' -import { sendTransaction } from '../src/actions/wallet/sendTransaction.js' -import { baseSepolia } from '../src/chains/index.js' -import { createClient } from '../src/clients/createClient.js' -import { http } from '../src/clients/transports/http.js' -import { parseEther } from '../src/utils/unit/parseEther.js' -import { stringToHex } from '../src/utils/encoding/toHex.js' -import { keccak256 } from '../src/utils/hash/keccak256.js' -import { toSmartAccount8130 } from '../src/experimental/eip8130/accounts/toSmartAccount8130.js' -import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' -import { key } from '../src/experimental/eip8130/keys.js' +import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' +import { createBundlerClient } from '../../src/account-abstraction/clients/createBundlerClient.js' +import { estimateFeesPerGas } from '../../src/actions/public/estimateFeesPerGas.js' +import { getBalance } from '../../src/actions/public/getBalance.js' +import { getCode } from '../../src/actions/public/getCode.js' +import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' +import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' +import { baseSepolia } from '../../src/chains/index.js' +import { createClient } from '../../src/clients/createClient.js' +import { http } from '../../src/clients/transports/http.js' +import { parseEther } from '../../src/utils/unit/parseEther.js' +import { stringToHex } from '../../src/utils/encoding/toHex.js' +import { keccak256 } from '../../src/utils/hash/keccak256.js' +import { toSmartAccount8130 } from '../../src/experimental/eip8130/accounts/toSmartAccount8130.js' +import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' +import { key } from '../../src/experimental/eip8130/keys.js' const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' diff --git a/scripts/bundlerProbeDeployed.test.ts b/scripts/eip8130/bundlerProbeDeployed.test.ts similarity index 69% rename from scripts/bundlerProbeDeployed.test.ts rename to scripts/eip8130/bundlerProbeDeployed.test.ts index e3eeb4f876..85496ef3f1 100644 --- a/scripts/bundlerProbeDeployed.test.ts +++ b/scripts/eip8130/bundlerProbeDeployed.test.ts @@ -1,19 +1,19 @@ import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' -import { createBundlerClient } from '../src/account-abstraction/clients/createBundlerClient.js' -import { estimateFeesPerGas } from '../src/actions/public/estimateFeesPerGas.js' -import { getBalance } from '../src/actions/public/getBalance.js' -import { waitForTransactionReceipt } from '../src/actions/public/waitForTransactionReceipt.js' -import { sendTransaction } from '../src/actions/wallet/sendTransaction.js' -import { baseSepolia } from '../src/chains/index.js' -import { createClient } from '../src/clients/createClient.js' -import { http } from '../src/clients/transports/http.js' -import { parseEther } from '../src/utils/unit/parseEther.js' -import { stringToHex } from '../src/utils/encoding/toHex.js' -import { keccak256 } from '../src/utils/hash/keccak256.js' -import { toSmartAccount8130 } from '../src/experimental/eip8130/accounts/toSmartAccount8130.js' -import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' -import { key } from '../src/experimental/eip8130/keys.js' +import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' +import { createBundlerClient } from '../../src/account-abstraction/clients/createBundlerClient.js' +import { estimateFeesPerGas } from '../../src/actions/public/estimateFeesPerGas.js' +import { getBalance } from '../../src/actions/public/getBalance.js' +import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' +import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' +import { baseSepolia } from '../../src/chains/index.js' +import { createClient } from '../../src/clients/createClient.js' +import { http } from '../../src/clients/transports/http.js' +import { parseEther } from '../../src/utils/unit/parseEther.js' +import { stringToHex } from '../../src/utils/encoding/toHex.js' +import { keccak256 } from '../../src/utils/hash/keccak256.js' +import { toSmartAccount8130 } from '../../src/experimental/eip8130/accounts/toSmartAccount8130.js' +import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' +import { key } from '../../src/experimental/eip8130/keys.js' const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' diff --git a/scripts/selfBundleCreate.test.ts b/scripts/eip8130/selfBundleCreate.test.ts similarity index 76% rename from scripts/selfBundleCreate.test.ts rename to scripts/eip8130/selfBundleCreate.test.ts index de34abab61..eda012782c 100644 --- a/scripts/selfBundleCreate.test.ts +++ b/scripts/eip8130/selfBundleCreate.test.ts @@ -1,22 +1,22 @@ import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' -import { entryPoint07Abi } from '../src/account-abstraction/constants/abis.js' -import { entryPoint07Address } from '../src/account-abstraction/constants/address.js' -import { toPackedUserOperation } from '../src/account-abstraction/utils/userOperation/toPackedUserOperation.js' -import { estimateFeesPerGas } from '../src/actions/public/estimateFeesPerGas.js' -import { getCode } from '../src/actions/public/getCode.js' -import { readContract } from '../src/actions/public/readContract.js' -import { waitForTransactionReceipt } from '../src/actions/public/waitForTransactionReceipt.js' -import { writeContract } from '../src/actions/wallet/writeContract.js' -import { baseSepolia } from '../src/chains/index.js' -import { createClient } from '../src/clients/createClient.js' -import { http } from '../src/clients/transports/http.js' -import { stringToHex } from '../src/utils/encoding/toHex.js' -import { keccak256 } from '../src/utils/hash/keccak256.js' -import { parseEther } from '../src/utils/unit/parseEther.js' -import { toSmartAccount8130 } from '../src/experimental/eip8130/accounts/toSmartAccount8130.js' -import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' -import { key } from '../src/experimental/eip8130/keys.js' +import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' +import { entryPoint07Abi } from '../../src/account-abstraction/constants/abis.js' +import { entryPoint07Address } from '../../src/account-abstraction/constants/address.js' +import { toPackedUserOperation } from '../../src/account-abstraction/utils/userOperation/toPackedUserOperation.js' +import { estimateFeesPerGas } from '../../src/actions/public/estimateFeesPerGas.js' +import { getCode } from '../../src/actions/public/getCode.js' +import { readContract } from '../../src/actions/public/readContract.js' +import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' +import { writeContract } from '../../src/actions/wallet/writeContract.js' +import { baseSepolia } from '../../src/chains/index.js' +import { createClient } from '../../src/clients/createClient.js' +import { http } from '../../src/clients/transports/http.js' +import { stringToHex } from '../../src/utils/encoding/toHex.js' +import { keccak256 } from '../../src/utils/hash/keccak256.js' +import { parseEther } from '../../src/utils/unit/parseEther.js' +import { toSmartAccount8130 } from '../../src/experimental/eip8130/accounts/toSmartAccount8130.js' +import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' +import { key } from '../../src/experimental/eip8130/keys.js' const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' diff --git a/scripts/selfBundleRotateP256.test.ts b/scripts/eip8130/selfBundleRotateP256.test.ts similarity index 75% rename from scripts/selfBundleRotateP256.test.ts rename to scripts/eip8130/selfBundleRotateP256.test.ts index bfefe29043..d1088c78c3 100644 --- a/scripts/selfBundleRotateP256.test.ts +++ b/scripts/eip8130/selfBundleRotateP256.test.ts @@ -1,32 +1,32 @@ import { describe, expect, test } from 'vitest' -import { entryPoint07Abi } from '../src/account-abstraction/constants/abis.js' -import { entryPoint07Address } from '../src/account-abstraction/constants/address.js' -import { getUserOperationHash } from '../src/account-abstraction/utils/userOperation/getUserOperationHash.js' -import { toPackedUserOperation } from '../src/account-abstraction/utils/userOperation/toPackedUserOperation.js' -import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' -import { estimateFeesPerGas } from '../src/actions/public/estimateFeesPerGas.js' -import { getCode } from '../src/actions/public/getCode.js' -import { readContract } from '../src/actions/public/readContract.js' -import { waitForTransactionReceipt } from '../src/actions/public/waitForTransactionReceipt.js' -import { writeContract } from '../src/actions/wallet/writeContract.js' -import { baseSepolia } from '../src/chains/index.js' -import { createClient } from '../src/clients/createClient.js' -import { http } from '../src/clients/transports/http.js' -import { accountConfigurationAbi } from '../src/experimental/eip8130/abis.js' -import { toSmartAccount8130 } from '../src/experimental/eip8130/accounts/toSmartAccount8130.js' +import { entryPoint07Abi } from '../../src/account-abstraction/constants/abis.js' +import { entryPoint07Address } from '../../src/account-abstraction/constants/address.js' +import { getUserOperationHash } from '../../src/account-abstraction/utils/userOperation/getUserOperationHash.js' +import { toPackedUserOperation } from '../../src/account-abstraction/utils/userOperation/toPackedUserOperation.js' +import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' +import { estimateFeesPerGas } from '../../src/actions/public/estimateFeesPerGas.js' +import { getCode } from '../../src/actions/public/getCode.js' +import { readContract } from '../../src/actions/public/readContract.js' +import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' +import { writeContract } from '../../src/actions/wallet/writeContract.js' +import { baseSepolia } from '../../src/chains/index.js' +import { createClient } from '../../src/clients/createClient.js' +import { http } from '../../src/clients/transports/http.js' +import { accountConfigurationAbi } from '../../src/experimental/eip8130/abis.js' +import { toSmartAccount8130 } from '../../src/experimental/eip8130/accounts/toSmartAccount8130.js' import { actorScope, ecrecoverAuthenticator, -} from '../src/experimental/eip8130/constants.js' -import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' -import { authorizeActor, key } from '../src/experimental/eip8130/keys.js' -import { actorIdFromPublicKey } from '../src/experimental/eip8130/utils/actorId.js' -import { signActorChanges8130 } from '../src/experimental/eip8130/utils/signActorChanges.js' -import { encodeSignedActorChangesSignature } from '../src/experimental/eip8130/utils/signedActorChangesSignature.js' -import { concatHex } from '../src/utils/data/concat.js' -import { stringToHex } from '../src/utils/encoding/toHex.js' -import { keccak256 } from '../src/utils/hash/keccak256.js' -import { parseEther } from '../src/utils/unit/parseEther.js' +} from '../../src/experimental/eip8130/constants.js' +import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' +import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' +import { actorIdFromPublicKey } from '../../src/experimental/eip8130/utils/actorId.js' +import { signActorChanges8130 } from '../../src/experimental/eip8130/utils/signActorChanges.js' +import { encodeSignedActorChangesSignature } from '../../src/experimental/eip8130/utils/signedActorChangesSignature.js' +import { concatHex } from '../../src/utils/data/concat.js' +import { stringToHex } from '../../src/utils/encoding/toHex.js' +import { keccak256 } from '../../src/utils/hash/keccak256.js' +import { parseEther } from '../../src/utils/unit/parseEther.js' const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' diff --git a/scripts/setup8130Account.test.ts b/scripts/eip8130/setup8130Account.test.ts similarity index 72% rename from scripts/setup8130Account.test.ts rename to scripts/eip8130/setup8130Account.test.ts index 2be64d58de..45631d10e0 100644 --- a/scripts/setup8130Account.test.ts +++ b/scripts/eip8130/setup8130Account.test.ts @@ -1,19 +1,19 @@ import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.js' -import { getCode } from '../src/actions/public/getCode.js' -import { readContract } from '../src/actions/public/readContract.js' -import { waitForTransactionReceipt } from '../src/actions/public/waitForTransactionReceipt.js' -import { writeContract } from '../src/actions/wallet/writeContract.js' -import { baseSepolia } from '../src/chains/index.js' -import { createClient } from '../src/clients/createClient.js' -import { http } from '../src/clients/transports/http.js' -import { accountConfigurationAbi } from '../src/experimental/eip8130/abis.js' -import { getEip8130Deployment } from '../src/experimental/eip8130/deployments.js' -import { key } from '../src/experimental/eip8130/keys.js' -import { computeAddress8130 } from '../src/experimental/eip8130/utils/computeAddress.js' -import { erc1167Bytecode } from '../src/experimental/eip8130/utils/proxy.js' -import { stringToHex } from '../src/utils/encoding/toHex.js' -import { keccak256 } from '../src/utils/hash/keccak256.js' +import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' +import { getCode } from '../../src/actions/public/getCode.js' +import { readContract } from '../../src/actions/public/readContract.js' +import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' +import { writeContract } from '../../src/actions/wallet/writeContract.js' +import { baseSepolia } from '../../src/chains/index.js' +import { createClient } from '../../src/clients/createClient.js' +import { http } from '../../src/clients/transports/http.js' +import { accountConfigurationAbi } from '../../src/experimental/eip8130/abis.js' +import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' +import { key } from '../../src/experimental/eip8130/keys.js' +import { computeAddress8130 } from '../../src/experimental/eip8130/utils/computeAddress.js' +import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' +import { stringToHex } from '../../src/utils/encoding/toHex.js' +import { keccak256 } from '../../src/utils/hash/keccak256.js' const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' diff --git a/test/vitest.eip8130.config.ts b/test/vitest.eip8130.config.ts index bf2cf34307..2db92e58b5 100644 --- a/test/vitest.eip8130.config.ts +++ b/test/vitest.eip8130.config.ts @@ -13,12 +13,8 @@ export default defineConfig({ include: [ 'src/experimental/eip813*/**/*.test.ts', 'src/experimental/eip8168/**/*.test.ts', - 'scripts/setup8130Account.test.ts', - 'scripts/authorizeSessionKey.test.ts', - 'scripts/bundlerCreateAndExecute.test.ts', - 'scripts/bundlerProbeDeployed.test.ts', - 'scripts/selfBundleCreate.test.ts', - 'scripts/selfBundleRotateP256.test.ts', + // Manual / integration demo scripts (most require PRIVATE_KEY + network). + 'scripts/eip8130/**/*.test.ts', ], testTimeout: 120_000, }, From 58d324e09503e7006aac9d9c409119b718942054 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 10 Jun 2026 18:28:46 -0400 Subject: [PATCH 16/96] docs(eip8130): add README for the manual scripts folder --- scripts/eip8130/README.md | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 scripts/eip8130/README.md diff --git a/scripts/eip8130/README.md b/scripts/eip8130/README.md new file mode 100644 index 0000000000..fe0391f39f --- /dev/null +++ b/scripts/eip8130/README.md @@ -0,0 +1,44 @@ +# EIP-8130 manual scripts + +Temporary dev/integration scripts for the experimental EIP-8130 work in +`src/experimental/eip8130`. These are **not** public examples (see top-level +`examples/` for those) — they import local source (`../../src`) and most hit a +live testnet. Keep them here until EIP-8130 graduates from experimental. + +## Run + +```bash +# offline, no setup +npx vitest run --config test/vitest.eip8130.config.ts scripts/eip8130/build8130Transaction.test.ts + +# network scripts (skipped unless PRIVATE_KEY is set; funded Base Sepolia EOA) +PRIVATE_KEY=0x... npx vitest run --config test/vitest.eip8130.config.ts scripts/eip8130/.test.ts +``` + +All scripts are auto-included via the `scripts/eip8130/**/*.test.ts` glob in +`test/vitest.eip8130.config.ts` — just drop a new `*.test.ts` here. + +Env: `PRIVATE_KEY` (required for network scripts), `BASE_SEPOLIA_RPC`, +`BUNDLER_URL`, `SALT_LABEL` (all optional, with defaults). + +## Scripts + +| Script | Network? | What it does | +| --- | --- | --- | +| `build8130Transaction` | no | Build/sign/serialize/parse an 8130 tx; prints JSON, RLP envelope, and the 13-field wire layout. | +| `setup8130Account` | yes | Create an 8130 account on Base Sepolia. | +| `authorizeSessionKey` | yes | Authorize a session-key actor on an existing account. | +| `selfBundleCreate` | yes | Deploy + execute in one self-bundled userOp via `EntryPoint.handleOps` (no staking). | +| `selfBundleRotateP256` | yes | Create + validation-phase P-256 key rotation + execute in one userOp. | +| `bundlerCreateAndExecute` | yes | Create + execute through a real ERC-4337 bundler (`BUNDLER_URL`). | +| `bundlerProbeDeployed` | yes | Send a userOp to an already-deployed account (no factory phase). | + +## Notes + +- An 8130 call is only `{ to, data }` — there is no per-call `value`. +- Validation-phase signature (key rotation) is + `abi.encode(magic, SignedActorChanges[], bytes opAuth)`; `opAuth` authorizes the + op over `userOpHash`. See `encodeSignedActorChangesSignature`. +- `createAccount` seeds `localSequence = 1`, so the first `applySignedActorChanges` + on a fresh account signs over sequence `1`. +- Deployment addresses live in `src/experimental/eip8130/deployments.ts`. From b89df37107a716900fa026761a3bd0ba437457f4 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 10 Jun 2026 18:51:01 -0400 Subject: [PATCH 17/96] feat(eip8130): support ERC-5792 call value on the native path Add optional `value` to `AaCall` and `encodeWalletCalls`, which routes value-bearing phases through the account's `executeBatch` (pluggable via `encodeExecute`) while passing value-less calls through as `[to, data]`. Wire into `sendCalls8130` and reject non-zero `value` at serialization. --- src/experimental/eip8130/actions/sendCalls.ts | 15 ++- src/experimental/eip8130/index.ts | 6 ++ src/experimental/eip8130/types/transaction.ts | 16 +++- .../eip8130/utils/assertTransaction.ts | 13 ++- .../eip8130/utils/encodeWalletCalls.test.ts | 94 +++++++++++++++++++ .../eip8130/utils/encodeWalletCalls.ts | 83 ++++++++++++++++ 6 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 src/experimental/eip8130/utils/encodeWalletCalls.test.ts create mode 100644 src/experimental/eip8130/utils/encodeWalletCalls.ts diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/experimental/eip8130/actions/sendCalls.ts index 934821f734..eef165ccbc 100644 --- a/src/experimental/eip8130/actions/sendCalls.ts +++ b/src/experimental/eip8130/actions/sendCalls.ts @@ -17,6 +17,7 @@ import type { AaCalls, TransactionSerializable8130, } from '../types/transaction.js' +import { type EncodeExecute, encodeWalletCalls } from '../utils/encodeWalletCalls.js' import type { Signer } from '../utils/signTransaction.js' type FeeOverrides = { @@ -118,6 +119,12 @@ export type SendCalls8130Parameters = FeeOverrides & { nonceSequence?: bigint | undefined expiry?: bigint | undefined nonceManagerAddress?: `0x${string}` | undefined + /** + * Encoder for value-bearing phases. Defaults to a self-call to the account's + * `executeBatch`. Override when the wallet bytecode exposes a different + * executor. See {@link encodeWalletCalls}. + */ + encodeExecute?: EncodeExecute | undefined } function toPhases(calls: SendCalls8130Parameters['calls']): AaCalls { @@ -143,11 +150,15 @@ export async function sendCalls8130( client: Client, parameters: SendCalls8130Parameters, ): Promise { - const { account, calls, payer, ...rest } = parameters + const { account, calls, payer, encodeExecute, ...rest } = parameters const transaction = await prepareTransaction8130(client, { ...rest, account, - calls: toPhases(calls), + calls: encodeWalletCalls({ + account: account.address, + calls: toPhases(calls), + encodeExecute, + }), payer, }) const serializedTransaction = await account.signTransaction(transaction, { diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 80a44cf302..98678e1c72 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -127,6 +127,12 @@ export { getPayerSignatureHash8130, getSenderSignatureHash8130, } from './utils/hashTransaction.js' +export { + defaultEncodeExecute, + type EncodeExecute, + type EncodeExecuteParameters, + encodeWalletCalls, +} from './utils/encodeWalletCalls.js' export { type ParseTransaction8130ErrorType, parseTransaction8130, diff --git a/src/experimental/eip8130/types/transaction.ts b/src/experimental/eip8130/types/transaction.ts index 73112854c8..3a1f0ce43c 100644 --- a/src/experimental/eip8130/types/transaction.ts +++ b/src/experimental/eip8130/types/transaction.ts @@ -2,14 +2,26 @@ import type { Address } from 'abitype' import type { Hex } from '../../../types/misc.js' /** - * A single call within a phase. Calls carry no ETH value (per EIP-8130); value - * transfers are initiated by the account's wallet bytecode. + * A single call within a phase. + * + * On the wire, EIP-8130 calls carry no ETH value — each call executes with + * `msg.value == 0`. A `value` MAY be supplied as ERC-5792-style intent: actions + * that build the wire (e.g. {@link sendCalls8130}) realize any non-zero `value` + * by routing the phase through the account's wallet bytecode (`executeBatch`), + * collapsing it back into a value-less `[to, data]`. A call whose `value` is `0` + * (or omitted) is encoded directly as `[to, data]`. A non-zero `value` that + * reaches serialization unwrapped is rejected (it would be silently dropped). */ export type AaCall = { /** Target address. */ to: Address /** Calldata. @default '0x' */ data?: Hex | undefined + /** + * ERC-5792-style intent value (wei). Realized via the account's wallet + * bytecode; never carried on the EIP-8130 wire. @default 0n + */ + value?: bigint | undefined } /** diff --git a/src/experimental/eip8130/utils/assertTransaction.ts b/src/experimental/eip8130/utils/assertTransaction.ts index f0b4325076..8e328dc446 100644 --- a/src/experimental/eip8130/utils/assertTransaction.ts +++ b/src/experimental/eip8130/utils/assertTransaction.ts @@ -16,11 +16,22 @@ export type AssertTransaction8130ErrorType = export function assertTransaction8130( transaction: TransactionSerializable8130, ): void { - const { chainId, nonceKey, nonceSequence, expiry, payer, payerAuth } = + const { chainId, nonceKey, nonceSequence, expiry, payer, payerAuth, calls } = transaction if (chainId <= 0) throw new InvalidChainIdError({ chainId }) + // EIP-8130 calls carry no value on the wire. A non-zero `value` here means it + // was not realized through the wallet bytecode (e.g. `executeBatch`) and would + // be silently dropped — reject instead. See `encodeWalletCalls`. + if (calls) + for (const phase of calls) + for (const call of phase) + if (call.value && call.value !== 0n) + throw new BaseError( + 'EIP-8130 calls cannot carry `value` on the wire. Route value-bearing calls through the account wallet (e.g. `encodeWalletCalls` / `sendCalls8130`).', + ) + // Nonce-free mode (`NONCE_KEY_MAX`): sequence must be 0 and expiry non-zero. if (typeof nonceKey === 'bigint' && nonceKey === nonceKeyMax) { if (nonceSequence !== undefined && nonceSequence !== 0n) diff --git a/src/experimental/eip8130/utils/encodeWalletCalls.test.ts b/src/experimental/eip8130/utils/encodeWalletCalls.test.ts new file mode 100644 index 0000000000..0c6ae5a580 --- /dev/null +++ b/src/experimental/eip8130/utils/encodeWalletCalls.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'vitest' +import { decodeFunctionData } from '../../../utils/abi/decodeFunctionData.js' +import { erc4337AccountAbi } from '../abis.js' +import type { AaCalls } from '../types/transaction.js' +import { encodeWalletCalls } from './encodeWalletCalls.js' + +const account = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const +const to = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' as const + +describe('encodeWalletCalls', () => { + test('passes value-less phases through as `[to, data]`', () => { + const calls: AaCalls = [[{ to, data: '0xdead' }, { to }]] + expect(encodeWalletCalls({ account, calls })).toEqual([ + [ + { to, data: '0xdead' }, + { to, data: '0x' }, + ], + ]) + }) + + test('treats `value: 0n` as a plain call', () => { + const calls: AaCalls = [[{ to, value: 0n, data: '0xbeef' }]] + expect(encodeWalletCalls({ account, calls })).toEqual([ + [{ to, data: '0xbeef' }], + ]) + }) + + test('wraps a value-bearing phase into a single executeBatch self-call', () => { + const calls: AaCalls = [[{ to, value: 1n, data: '0xbeef' }]] + const [phase] = encodeWalletCalls({ account, calls }) + + expect(phase).toHaveLength(1) + expect(phase[0].to).toBe(account) + + const decoded = decodeFunctionData({ + abi: erc4337AccountAbi, + data: phase[0].data!, + }) + expect(decoded.functionName).toBe('executeBatch') + expect(decoded.args).toEqual([ + [{ target: to, value: 1n, data: '0xbeef' }], + ]) + }) + + test('collapses every call in a value-bearing phase (incl. value-less ones)', () => { + const calls: AaCalls = [ + [ + { to, data: '0xaa' }, + { to, value: 5n, data: '0xbb' }, + ], + ] + const [phase] = encodeWalletCalls({ account, calls }) + + expect(phase).toHaveLength(1) + const decoded = decodeFunctionData({ + abi: erc4337AccountAbi, + data: phase[0].data!, + }) + expect(decoded.args).toEqual([ + [ + { target: to, value: 0n, data: '0xaa' }, + { target: to, value: 5n, data: '0xbb' }, + ], + ]) + }) + + test('decides wrapping per phase', () => { + const calls: AaCalls = [ + [{ to, data: '0xaa' }], + [{ to, value: 2n, data: '0xbb' }], + ] + const result = encodeWalletCalls({ account, calls }) + + expect(result[0]).toEqual([{ to, data: '0xaa' }]) + expect(result[1]).toHaveLength(1) + expect(result[1][0].to).toBe(account) + }) + + test('honors a custom `encodeExecute` override', () => { + const calls: AaCalls = [[{ to, value: 9n, data: '0x1234' }]] + const result = encodeWalletCalls({ + account, + calls, + encodeExecute: ({ calls }) => ({ + to: '0x000000000000000000000000000000000000dEaD', + data: calls[0].data, + }), + }) + + expect(result).toEqual([ + [{ to: '0x000000000000000000000000000000000000dEaD', data: '0x1234' }], + ]) + }) +}) diff --git a/src/experimental/eip8130/utils/encodeWalletCalls.ts b/src/experimental/eip8130/utils/encodeWalletCalls.ts new file mode 100644 index 0000000000..73f7b47571 --- /dev/null +++ b/src/experimental/eip8130/utils/encodeWalletCalls.ts @@ -0,0 +1,83 @@ +import type { Address } from 'abitype' +import type { Hex } from '../../../types/misc.js' +import { encodeFunctionData } from '../../../utils/abi/encodeFunctionData.js' +import { erc4337AccountAbi } from '../abis.js' +import type { AaCall, AaCalls } from '../types/transaction.js' + +/** + * Parameters passed to an {@link EncodeExecute} function: the account whose + * wallet bytecode runs the batch, and the (value-bearing) calls in a phase. + */ +export type EncodeExecuteParameters = { + /** The EIP-8130 account whose wallet bytecode executes the batch. */ + account: Address + /** The phase's calls, with normalized `value`/`data`. */ + calls: readonly Required[] +} + +/** + * Encodes a phase of (potentially value-bearing) calls into a single value-less + * wire call `[to, data]` routed through the account's wallet bytecode. + * + * The default ({@link defaultEncodeExecute}) self-calls the account's + * `executeBatch(Call[])`. Wallets whose bytecode does not expose `executeBatch` + * MUST supply their own encoder mapping the call intent to a wallet call. + */ +export type EncodeExecute = (parameters: EncodeExecuteParameters) => AaCall + +/** + * Default executor encoding: a self-call to the account's + * `executeBatch(Call[])`, which performs each value-bearing `CALL`. + */ +export const defaultEncodeExecute: EncodeExecute = ({ account, calls }) => ({ + to: account, + data: encodeFunctionData({ + abi: erc4337AccountAbi, + functionName: 'executeBatch', + args: [ + calls.map((call) => ({ + target: call.to, + value: call.value, + data: call.data, + })), + ], + }) as Hex, +}) + +/** + * Normalizes a phased call list into the EIP-8130 wire shape (value-less + * `[to, data]` per call). + * + * A phase that contains no value-bearing call passes through unchanged (each + * call emitted directly as `[to, data]`). A phase that contains any value-bearing + * call is collapsed into a single wallet-routed call via `encodeExecute` + * (default: a self-call to `executeBatch`), preserving the phase's atomicity. + * + * @example + * const wire = encodeWalletCalls({ + * account: account.address, + * calls: [[{ to, value: parseEther('1'), data }]], + * }) + */ +export function encodeWalletCalls(parameters: { + account: Address + calls: AaCalls + encodeExecute?: EncodeExecute | undefined +}): AaCalls { + const { account, calls, encodeExecute = defaultEncodeExecute } = parameters + + return calls.map((phase) => { + const hasValue = phase.some((call) => call.value && call.value !== 0n) + if (!hasValue) + return phase.map((call) => ({ to: call.to, data: call.data ?? '0x' })) + + const normalized = phase.map( + (call): Required => ({ + to: call.to, + value: call.value ?? 0n, + data: call.data ?? '0x', + }), + ) + return [encodeExecute({ account, calls: normalized })] + }) +} From a98d11d8fde55a69eb9e0d2dfc5ca80038b499d7 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 11 Jun 2026 13:55:47 -0400 Subject: [PATCH 18/96] fix(eip8168): align implementation with ERC-8168 spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename payer_getSponsorshipOptions → payer_getOptions (method + types) - Rename maxCost → paymentAmount in PayerTokenOption and buildSponsoredCalls - Fix SponsorshipOption: type 'full_sponsorship' → 'sponsored', tokenSymbol → symbol, estimatedCost → estimatedAmount; rename to PayerOption - Fix error codes: -32600–32608 range → -32001–32009 (JSON-RPC impl-defined range; the old codes conflicted with JSON-RPC 2.0 protocol errors) - Add overhead to PayerGasEstimate; add usdRate + refund to PayerTokenOption - Add RefundPolicy type; add feeRecipient, callPolicy, refund to PayerChainCapabilities; remove non-spec requiredChainId from PayerConditions - Add payer_fillTransaction method to PayerClient - Fix expiry semantics: terms.expiry is relative (seconds from now); convert to absolute on-chain timestamp in sendSponsoredCalls, clamped to conditions.maxExpiry / minExpiry - Update tests: use relative durations, freeze time for deterministic expiry assertions, add payer_getOptions mapping test --- .../eip8168/actions/sendSponsoredCalls.ts | 28 ++++-- src/experimental/eip8168/client.ts | 34 +++++-- src/experimental/eip8168/constants.ts | 28 +++--- src/experimental/eip8168/eip8168.test.ts | 60 ++++++++++--- src/experimental/eip8168/index.ts | 9 +- src/experimental/eip8168/types.ts | 89 ++++++++++++++++--- .../eip8168/utils/buildSponsoredCalls.ts | 14 +-- 7 files changed, 201 insertions(+), 61 deletions(-) diff --git a/src/experimental/eip8168/actions/sendSponsoredCalls.ts b/src/experimental/eip8168/actions/sendSponsoredCalls.ts index 1992da5c3c..98e59209fb 100644 --- a/src/experimental/eip8168/actions/sendSponsoredCalls.ts +++ b/src/experimental/eip8168/actions/sendSponsoredCalls.ts @@ -34,7 +34,11 @@ export type SendSponsoredCallsParameters = { token?: `0x${string}` | undefined /** Opaque app context forwarded to `payer_*` calls (e.g. `policyId`). */ context?: Record | undefined - /** Override transaction expiry (defaults to `conditions.maxExpiry`). */ + /** + * Override transaction expiry as an absolute Unix timestamp (seconds). + * When omitted the payer's recommended `terms.expiry` relative duration is + * applied: `current_time + terms.expiry`, clamped to `conditions.maxExpiry`. + */ expiry?: bigint | undefined /** Override gas (defaults to the payer's `gasEstimate.gasLimit`). */ gas?: bigint | undefined @@ -113,11 +117,23 @@ export async function sendSponsoredCalls( ? hexToBigInt(terms.gasEstimate.maxPriorityFeePerGas) : undefined) - const expiry = - parameters.expiry ?? - (terms.conditions?.maxExpiry !== undefined - ? BigInt(terms.conditions.maxExpiry) - : undefined) + // `terms.expiry` is a relative duration (seconds from now). Convert to an + // absolute on-chain timestamp, then clamp to [now+minExpiry, now+maxExpiry]. + let expiry = parameters.expiry + if (expiry === undefined) { + const now = BigInt(Math.floor(Date.now() / 1000)) + expiry = now + BigInt(terms.expiry) + if ( + terms.conditions?.maxExpiry !== undefined && + expiry > now + BigInt(terms.conditions.maxExpiry) + ) + expiry = now + BigInt(terms.conditions.maxExpiry) + if ( + terms.conditions?.minExpiry !== undefined && + expiry < now + BigInt(terms.conditions.minExpiry) + ) + expiry = now + BigInt(terms.conditions.minExpiry) + } const transaction = await prepareTransaction8130(client, { account, diff --git a/src/experimental/eip8168/client.ts b/src/experimental/eip8168/client.ts index 38e75eb3d5..05a75d0adb 100644 --- a/src/experimental/eip8168/client.ts +++ b/src/experimental/eip8168/client.ts @@ -2,12 +2,14 @@ import { createClient } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' import { http } from '../../clients/transports/http.js' import type { + FillTransactionParameters, + FillTransactionReturnType, GetBalanceParameters, GetBalanceReturnType, GetCapabilitiesParameters, GetCapabilitiesReturnType, - GetSponsorshipOptionsParameters, - GetSponsorshipOptionsReturnType, + GetOptionsParameters, + GetOptionsReturnType, GetTermsParameters, GetTermsReturnType, SendTransactionParameters, @@ -37,12 +39,20 @@ export type PayerClient = { signTransaction( parameters: SignTransactionParameters, ): Promise + /** + * Fill a transaction intent into a complete unsigned EIP-8130 transaction + * with phases assembled and `payer` set. Wallet MUST verify before signing. + * (OPTIONAL per ERC-8168.) + */ + fillTransaction( + parameters: FillTransactionParameters, + ): Promise /** Standing, intent-free balances (sponsorship allowance / prepaid credit). */ getBalance(parameters: GetBalanceParameters): Promise - /** Ranked sponsorship options for a transaction intent. */ - getSponsorshipOptions( - parameters: GetSponsorshipOptionsParameters, - ): Promise + /** Ranked payer options for a transaction intent. */ + getOptions( + parameters: GetOptionsParameters, + ): Promise /** Static, intent-free descriptor of what the payer accepts. */ getCapabilities( parameters?: GetCapabilitiesParameters, @@ -88,17 +98,23 @@ export function createPayerClient( params: [params], }) as Promise }, + fillTransaction(params) { + return request({ + method: 'payer_fillTransaction', + params: [params], + }) as Promise + }, getBalance(params) { return request({ method: 'payer_getBalance', params: [params], }) as Promise }, - getSponsorshipOptions(params) { + getOptions(params) { return request({ - method: 'payer_getSponsorshipOptions', + method: 'payer_getOptions', params: [params], - }) as Promise + }) as Promise }, getCapabilities(params) { return request({ diff --git a/src/experimental/eip8168/constants.ts b/src/experimental/eip8168/constants.ts index 062fca3db9..f3ac393161 100644 --- a/src/experimental/eip8168/constants.ts +++ b/src/experimental/eip8168/constants.ts @@ -1,26 +1,32 @@ /** - * ERC-8168 payer-service JSON-RPC error codes. Payer services use these when - * rejecting requests; responses SHOULD include actionable `data`. + * ERC-8168 payer-service JSON-RPC error codes. These occupy the `-32000` to + * `-32099` range that JSON-RPC 2.0 reserves for implementation-defined server + * errors. They MUST NOT reuse the codes reserved for protocol errors + * (`-32600` Invalid Request, `-32601` Method not found, `-32602` Invalid + * params, `-32603` Internal error, `-32700` Parse error). + * + * Payer services use these when rejecting requests; responses SHOULD include + * actionable `data`. */ export const payerErrorCode = { /** Malformed or invalid EIP-8130 transaction. */ - invalidTransaction: -32600, + invalidTransaction: -32001, /** Token in the phase-0 transfer not accepted by this payer. */ - unsupportedToken: -32601, + unsupportedToken: -32002, /** Terms from `payer_getTerms` have expired; re-request. */ - rateExpired: -32602, + rateExpired: -32003, /** Token transfer amount in phase 0 is below the required cost. */ - paymentInsufficient: -32603, + paymentInsufficient: -32004, /** Transaction expiry does not satisfy payer conditions. */ - expiryOutOfBounds: -32604, + expiryOutOfBounds: -32005, /** Payer policy rejected this transaction. */ - policyRejected: -32605, + policyRejected: -32006, /** Payer lacks ETH to cover gas. */ - payerBalanceInsufficient: -32606, + payerBalanceInsufficient: -32007, /** Sender is blocklisted for the specified token. */ - senderBlocklisted: -32607, + senderBlocklisted: -32008, /** Sender's sponsorship budget or prepaid credit is depleted. */ - balanceExhausted: -32608, + balanceExhausted: -32009, } as const export type PayerErrorCode = diff --git a/src/experimental/eip8168/eip8168.test.ts b/src/experimental/eip8168/eip8168.test.ts index a696d1d645..ef898bb4ba 100644 --- a/src/experimental/eip8168/eip8168.test.ts +++ b/src/experimental/eip8168/eip8168.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' import { mainnet } from '../../chains/index.js' import { createClient } from '../../clients/createClient.js' @@ -37,19 +37,23 @@ const gasEstimate = { maxPriorityFeePerGas: '0x59682F00', } as const +// Relative durations (seconds from now), as per ERC-8168 spec. +const EXPIRY_REL = 30 // payer-recommended transaction lifetime +const MAX_EXPIRY_REL = 60 // upper bound from conditions + const sponsoredTerms: GetTermsReturnType = { sponsored: true, - expiry: 1735689900, + expiry: EXPIRY_REL, ttl: 300, gasEstimate, - conditions: { maxExpiry: 1735689720 }, + conditions: { maxExpiry: MAX_EXPIRY_REL }, payer: PAYER, endpoint: 'https://payer.example.com/v1', } const tokenTerms: GetTermsReturnType = { sponsored: false, - expiry: 1735689900, + expiry: EXPIRY_REL, ttl: 300, gasEstimate, tokenOptions: [ @@ -57,16 +61,28 @@ const tokenTerms: GetTermsReturnType = { token: USDC, symbol: 'USDC', decimals: 6, - maxCost: '0x30D40', + paymentAmount: '0x30D40', rate: { numerator: '0x7A308480', denominator: '0xDE0B6B3A7640000' }, - rateExpiry: 1735689720, + rateExpiry: MAX_EXPIRY_REL, }, ], - conditions: { maxExpiry: 1735689720 }, + conditions: { maxExpiry: MAX_EXPIRY_REL }, payer: PAYER, endpoint: 'https://payer.example.com/v1', } +// Freeze time so expiry assertions are deterministic. +const FROZEN_NOW_MS = 1_700_000_000_000 // arbitrary fixed epoch +const FROZEN_NOW_S = Math.floor(FROZEN_NOW_MS / 1000) + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(FROZEN_NOW_MS) +}) +afterEach(() => { + vi.useRealTimers() +}) + describe('buildSponsoredCalls', () => { test('full sponsorship -> single phase, no transfer', () => { const built = buildSponsoredCalls({ @@ -75,12 +91,12 @@ describe('buildSponsoredCalls', () => { }) expect(built.payer).toBe(PAYER) expect(built.calls).toEqual([userCalls]) - expect(built.maxCost).toBeUndefined() + expect(built.paymentAmount).toBeUndefined() }) - test('token payment -> phase 0 transfer(payer, maxCost) + phase 1 user calls', () => { + test('token payment -> phase 0 transfer(payer, paymentAmount) + phase 1 user calls', () => { const built = buildSponsoredCalls({ terms: tokenTerms, calls: userCalls }) - expect(built.maxCost).toBe(hexToBigInt('0x30D40')) + expect(built.paymentAmount).toBe(hexToBigInt('0x30D40')) expect(built.calls).toHaveLength(2) const transfer = built.calls[0][0] expect(transfer.to).toBe(USDC) @@ -138,6 +154,25 @@ describe('createPayerClient', () => { await payer.getBalance({ from: owner.address, kind: ['credit'] }) expect(seen[1].method).toBe('payer_getBalance') }) + + test('getOptions calls payer_getOptions', async () => { + const seen: { method: string }[] = [] + const payer = createPayerClient({ + transport: custom({ + async request({ method }: { method: string }) { + seen.push({ method }) + if (method === 'payer_getOptions') return { options: [] } + throw new Error(`unexpected ${method}`) + }, + }), + }) + await payer.getOptions({ + chainId: '0x1', + from: owner.address, + calls: userCalls, + }) + expect(seen[0].method).toBe('payer_getOptions') + }) }) describe('sendSponsoredCalls (end-to-end)', () => { @@ -153,7 +188,7 @@ describe('sendSponsoredCalls (end-to-end)', () => { }) } - test('full sponsorship: sender-signs, payer relays', async () => { + test('full sponsorship: sender-signs, payer relays; expiry = now + terms.expiry', async () => { let relayed: `0x${string}` | undefined const payer = createPayerClient({ transport: custom({ @@ -183,7 +218,8 @@ describe('sendSponsoredCalls (end-to-end)', () => { expect(parsed.calls).toHaveLength(1) // single phase, full sponsorship expect(parsed.calls?.[0]?.[0]?.to).toBe(userCalls[0].to.toLowerCase()) expect(parsed.gas).toBe(hexToBigInt(gasEstimate.gasLimit)) - expect(parsed.expiry).toBe(BigInt(1735689720)) // conditions.maxExpiry + // expiry = now + terms.expiry (relative), clamped to maxExpiry + expect(parsed.expiry).toBe(BigInt(FROZEN_NOW_S + EXPIRY_REL)) }) test('token payment: phase 0 transfer present; co-sign mode returns signed tx', async () => { diff --git a/src/experimental/eip8168/index.ts b/src/experimental/eip8168/index.ts index 2c405af496..b39d023c1a 100644 --- a/src/experimental/eip8168/index.ts +++ b/src/experimental/eip8168/index.ts @@ -11,26 +11,29 @@ export { } from './client.js' export { type PayerErrorCode, payerErrorCode } from './constants.js' export type { + FillTransactionParameters, + FillTransactionReturnType, GetBalanceParameters, GetBalanceReturnType, GetCapabilitiesParameters, GetCapabilitiesReturnType, - GetSponsorshipOptionsParameters, - GetSponsorshipOptionsReturnType, + GetOptionsParameters, + GetOptionsReturnType, GetTermsParameters, GetTermsReturnType, PayerBalance, PayerChainCapabilities, PayerConditions, PayerGasEstimate, + PayerOption, PayerRpcCall, PayerSponsor, PayerTokenOption, + RefundPolicy, SendTransactionParameters, SendTransactionReturnType, SignTransactionParameters, SignTransactionReturnType, - SponsorshipOption, TokenCharged, } from './types.js' export { diff --git a/src/experimental/eip8168/types.ts b/src/experimental/eip8168/types.ts index 92cacf5564..d3a5d455c7 100644 --- a/src/experimental/eip8168/types.ts +++ b/src/experimental/eip8168/types.ts @@ -31,33 +31,62 @@ export type PayerBalance = { name?: string | undefined } +/** + * When/how unused gas is refunded. Presence means refunds are offered; + * absence means none are. + */ +export type RefundPolicy = { + /** `"in_block"`: settled by the builder in the same block. `"deferred"`: settled later. */ + settlement: 'in_block' | 'deferred' + /** Deferred only: upper bound in seconds until settled (e.g. 86400 ≈ next day). */ + window?: number | undefined +} + export type PayerGasEstimate = { gasLimit: Hex maxFeePerGas: Hex maxPriorityFeePerGas: Hex + /** + * Flat gas added on top of `gasLimit` to cover `payer_auth_cost` and payer + * operational overhead; included in reimbursement. + * Reimbursement is sized at `(gasLimit + overhead) × maxFeePerGas`. + */ + overhead?: Hex | undefined } export type PayerTokenOption = { token: Address symbol: string decimals: number - /** Canonical phase-0 transfer amount, quoted at the gas cap. */ - maxCost: Hex + /** + * Token amount the wallet transfers to `payer` in phase 0, quoted at the + * gas cap including `gasEstimate.overhead`. Transferring this amount always + * covers the transaction while terms are valid. + */ + paymentAmount: Hex rate: { /** Token atomic units... */ numerator: Hex /** ...per this many native wei. */ denominator: Hex } + /** + * Optional: native token price in USD, 6-decimal fixed-point hex integer + * (e.g. `0x75BCD15` ≈ $1234.56). Shares the same `rateExpiry`. + */ + usdRate?: Hex | undefined rateDisplay?: string | undefined + /** Relative, seconds from current time. */ rateExpiry: number + refund?: RefundPolicy | undefined } export type PayerConditions = { + /** Relative, seconds from current time. */ maxExpiry?: number | undefined + /** Relative, seconds from current time. */ minExpiry?: number | undefined maxGasLimit?: Hex | undefined - requiredChainId?: Hex | undefined } export type PayerSponsor = { @@ -78,9 +107,9 @@ export type GetTermsParameters = { export type GetTermsReturnType = { sponsored: boolean - /** Absolute, seconds. */ + /** Relative, seconds from current time. Wallet computes on-chain expiry as `current_time + expiry`. */ expiry: number - /** Lifetime in seconds from time of response. */ + /** Lifetime of this quote in seconds from time of response. */ ttl: number gasEstimate?: PayerGasEstimate | undefined tokenOptions?: readonly PayerTokenOption[] | undefined @@ -118,6 +147,26 @@ export type SignTransactionReturnType = { tokenCharged?: TokenCharged | undefined } +export type FillTransactionParameters = { + chainId: Hex + from: Address + calls: readonly PayerRpcCall[] + /** Omit to request full sponsorship; set to pay gas in this token. */ + paymentToken?: Address | undefined + /** Wallet-selected parallel-nonce lane; payer MUST honor if present. */ + nonceKey?: Hex | undefined + nonceSequence?: Hex | undefined + gasLimit?: Hex | undefined + context?: Record | undefined +} + +export type FillTransactionReturnType = { + /** EIP-8130 transaction with `payer` set, phases built, `payer_auth` empty. */ + unsignedTransaction: Hex + /** Terms this transaction was filled against; wallet MUST verify before signing. */ + terms: GetTermsReturnType +} + export type GetBalanceParameters = { from: Address chainId?: Hex | undefined @@ -134,19 +183,21 @@ export type GetBalanceReturnType = { ttl: number } -export type SponsorshipOption = { - type: 'full_sponsorship' | 'conditional' +export type PayerOption = { + type: 'sponsored' | 'conditional' payer: Address endpoint: string sponsor?: PayerSponsor | undefined tokenPayment?: | { token: Address - tokenSymbol: string + symbol: string decimals: number - estimatedCost: Hex + estimatedAmount: Hex rate: { numerator: Hex; denominator: Hex } + usdRate?: Hex | undefined rateDisplay?: string | undefined + /** Relative, seconds from current time. */ rateExpiry: number } | undefined @@ -154,7 +205,7 @@ export type SponsorshipOption = { priority?: number | undefined } -export type GetSponsorshipOptionsParameters = { +export type GetOptionsParameters = { chainId: Hex from: Address calls: readonly PayerRpcCall[] @@ -163,21 +214,33 @@ export type GetSponsorshipOptionsParameters = { context?: Record | undefined } -export type GetSponsorshipOptionsReturnType = { - options: readonly SponsorshipOption[] +export type GetOptionsReturnType = { + options: readonly PayerOption[] } export type PayerChainCapabilities = { chainId: Hex payer: Address - endpoint: string + /** Omitted when served by a node acting as payer (the node's own RPC is the endpoint). */ + endpoint?: string | undefined + /** Phase 0 token destination; defaults to `payer`. */ + feeRecipient?: Address | undefined + /** Offers unconditional gas sponsorship, subject to policy. */ fullSponsorship: boolean + /** + * `"unrestricted"`: firm guarantee the payer will not reject based on call + * content; behaves as a pure financial exchange. + * `"filtered"`: payer MAY reject based on call content. + */ + callPolicy: 'unrestricted' | 'filtered' acceptedTokens: readonly { token: Address symbol: string decimals: number }[] + /** Authoritative list of `payer_*` methods this responder implements. */ methods: readonly string[] + refund?: RefundPolicy | undefined conditions?: PayerConditions | undefined sponsor?: PayerSponsor | undefined } diff --git a/src/experimental/eip8168/utils/buildSponsoredCalls.ts b/src/experimental/eip8168/utils/buildSponsoredCalls.ts index 967ae7baac..10eb8ae664 100644 --- a/src/experimental/eip8168/utils/buildSponsoredCalls.ts +++ b/src/experimental/eip8168/utils/buildSponsoredCalls.ts @@ -49,8 +49,8 @@ export type BuildSponsoredCallsReturnType = { calls: AaCalls /** The selected token option, when paying with a token. */ tokenOption?: PayerTokenOption | undefined - /** Phase-0 token transfer amount (`maxCost`), when paying with a token. */ - maxCost?: bigint | undefined + /** Phase-0 token transfer amount (`paymentAmount`), when paying with a token. */ + paymentAmount?: bigint | undefined } /** @@ -60,7 +60,7 @@ export type BuildSponsoredCallsReturnType = { * | Model | Phase 0 | Last phase | * |---|---|---| * | Full sponsorship | — | user calls | - * | Token payment | `transfer(payer, maxCost)` | user calls | + * | Token payment | `transfer(payer, paymentAmount)` | user calls | * | Required calls | required calls | user calls | * | Token + required | transfer + required calls | user calls | * @@ -74,7 +74,7 @@ export function buildSponsoredCalls( const phase0: AaCall[] = [] let tokenOption: PayerTokenOption | undefined - let maxCost: bigint | undefined + let paymentAmount: bigint | undefined if (!terms.sponsored) { const options = terms.tokenOptions ?? [] @@ -91,12 +91,12 @@ export function buildSponsoredCalls( throw new BaseError( `No token option matches the requested token "${parameters.token}".`, ) - maxCost = hexToBigInt(tokenOption.maxCost) + paymentAmount = hexToBigInt(tokenOption.paymentAmount) phase0.push( encodeTokenTransfer({ token: tokenOption.token, to: terms.payer, - amount: maxCost, + amount: paymentAmount, }), ) } @@ -106,5 +106,5 @@ export function buildSponsoredCalls( const phases: AaCalls = phase0.length > 0 ? [phase0, calls] : [calls] - return { payer: terms.payer, calls: phases, tokenOption, maxCost } + return { payer: terms.payer, calls: phases, tokenOption, paymentAmount } } From 7a88350c0d5d674ffa56315a0b9800640a35eb0e Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 24 Jun 2026 14:34:09 -0400 Subject: [PATCH 19/96] feat(eip8130): RPC nonce/estimate/receipt actions, policies, signers Add EIP-8130-aware JSON-RPC actions matching the base node RPC surface: - getTransactionCount8130: read the 2D channel nonce via the eth_getTransactionCount nonce_key extension. The NonceManager precompile is not callable via eth_call, so this replaces the reverting readContract path in prepareTransaction8130. - estimateGas8130: eth_estimateGas for AA_TX_TYPE with declared sender/payer auth scheme + size and an optional sponsoring payer (MAX_AUTH_SIZE guarded). - getTransactionReceipt8130 + parseEip8130ReceiptFields/allPhasesSucceeded: surface payer, phaseStatuses, and metadata off the receipt (graceful when absent on older nodes). Also brings in the session-policy helpers (policies.ts), signer adapters (signers.ts), serialization/parse alignment, and the vibenet devnet deployment with their tests. --- src/experimental/eip8130/abis.ts | 9 +- .../eip8130/accounts/to8130Account.ts | 1 + .../eip8130/actions/estimateGas8130.ts | 124 ++++++ .../actions/getTransactionCount8130.ts | 88 ++++ .../actions/getTransactionReceipt8130.ts | 94 ++++ src/experimental/eip8130/actions/sendCalls.ts | 46 +- src/experimental/eip8130/constants.ts | 18 +- src/experimental/eip8130/deployments.ts | 65 ++- src/experimental/eip8130/index.ts | 61 ++- src/experimental/eip8130/policies.test.ts | 115 +++++ src/experimental/eip8130/policies.ts | 417 ++++++++++++++++++ src/experimental/eip8130/types/transaction.ts | 9 +- .../eip8130/utils/actorChangeData.ts | 4 +- .../eip8130/utils/encodeWalletCalls.ts | 11 +- .../eip8130/utils/parseTransaction.ts | 35 +- .../eip8130/utils/serializeTransaction.ts | 40 +- .../eip8130/utils/signActorChanges.ts | 12 +- .../eip8130/utils/signTransaction.test.ts | 106 ++++- .../eip8130/utils/signTransaction.ts | 56 ++- .../eip8130/utils/signers.test.ts | 130 ++++++ src/experimental/eip8130/utils/signers.ts | 182 ++++++++ 21 files changed, 1528 insertions(+), 95 deletions(-) create mode 100644 src/experimental/eip8130/actions/estimateGas8130.ts create mode 100644 src/experimental/eip8130/actions/getTransactionCount8130.ts create mode 100644 src/experimental/eip8130/actions/getTransactionReceipt8130.ts create mode 100644 src/experimental/eip8130/policies.test.ts create mode 100644 src/experimental/eip8130/policies.ts create mode 100644 src/experimental/eip8130/utils/signers.test.ts create mode 100644 src/experimental/eip8130/utils/signers.ts diff --git a/src/experimental/eip8130/abis.ts b/src/experimental/eip8130/abis.ts index c344124215..6c382a6adf 100644 --- a/src/experimental/eip8130/abis.ts +++ b/src/experimental/eip8130/abis.ts @@ -7,6 +7,7 @@ import { parseAbi } from 'abitype' export const accountConfigurationAbi = parseAbi([ 'struct InitialActor { bytes32 actorId; address authenticator; }', 'struct ActorConfig { address authenticator; uint8 scope; uint48 expiry; uint8 policyType; }', + 'struct Actor { bytes32 actorId; ActorConfig config; bytes policyData; }', 'struct ActorChange { uint8 changeType; bytes32 actorId; bytes data; }', 'struct ChangeSequences { uint64 multichain; uint64 local; }', @@ -14,7 +15,7 @@ export const accountConfigurationAbi = parseAbi([ 'event ActorRevoked(address indexed account, bytes32 indexed actorId)', 'event AccountCreated(address indexed account, bytes32 userSalt, bytes32 codeHash)', 'event AccountImported(address indexed account)', - 'event DelegationChanged(address indexed account, address target)', + 'event DelegationApplied(address indexed account, address target)', 'event AccountLocked(address indexed account, uint16 unlockDelay)', 'event AccountUnlockInitiated(address indexed account, uint40 unlocksAt)', @@ -25,10 +26,12 @@ export const accountConfigurationAbi = parseAbi([ 'function lock(uint16 unlockDelay)', 'function initiateUnlock()', 'function verifySignature(address account, bytes32 hash, bytes signature) view returns (bool verified)', - 'function authenticateActor(address account, bytes32 hash, bytes auth) view returns (uint8 scope)', + 'function authenticateActor(address account, bytes32 hash, bytes auth) view returns (uint8 scope, uint8 policyType, address policyTarget)', 'function isActor(address account, bytes32 actorId) view returns (bool)', 'function getActorConfig(address account, bytes32 actorId) view returns (ActorConfig)', - 'function getPolicy(address account, bytes32 actorId) view returns (address target, bytes32 commitment)', + 'function getPolicy(address account, bytes32 actorId) view returns (uint8 policyType, address target, bytes32 commitment)', + 'function getPolicyCommitment(address account, bytes32 actorId) view returns (bytes32)', + 'function getPolicyManager(address account, bytes32 actorId) view returns (address)', 'function getChangeSequences(address account) view returns (ChangeSequences)', 'function isLocked(address account) view returns (bool)', 'function getLockStatus(address account) view returns (bool locked, bool hasInitiatedUnlock, uint40 unlocksAt, uint16 unlockDelay)', diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index 00e9ef024e..25754397a3 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -134,6 +134,7 @@ export function to8130Account( return signTransaction8130({ transaction: { ...transaction, from: transaction.from ?? address }, account: signer, + authenticator, payer: options.payer, }) }, diff --git a/src/experimental/eip8130/actions/estimateGas8130.ts b/src/experimental/eip8130/actions/estimateGas8130.ts new file mode 100644 index 0000000000..8218c9d836 --- /dev/null +++ b/src/experimental/eip8130/actions/estimateGas8130.ts @@ -0,0 +1,124 @@ +import type { Address } from 'abitype' + +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import { BaseError } from '../../../errors/base.js' +import type { Account } from '../../../types/account.js' +import type { BlockTag } from '../../../types/block.js' +import type { Chain } from '../../../types/chain.js' +import type { Hex } from '../../../types/misc.js' +import { hexToBigInt } from '../../../utils/encoding/fromHex.js' +import { numberToHex } from '../../../utils/encoding/toHex.js' +import { aaTransactionType } from '../constants.js' + +/** + * Authentication scheme an EIP-8130 actor uses to sign. The node prices the + * authentication gas deterministically from the auth-blob shape, so declaring + * the scheme lets `eth_estimateGas` charge the right amount without a real + * signature. + */ +export type Eip8130AuthScheme = 'secp256k1' | 'p256' | 'webAuthn' + +export type EstimateGas8130Parameters = { + /** + * Sender address. **Required** — the sender drives actor/policy resolution, + * and the node returns `INVALID_PARAMS` for an EIP-8130 estimate without it. + */ + from: Address + /** Target of the (representative) call being estimated. */ + to?: Address | undefined + /** Calldata of the call being estimated. */ + data?: Hex | undefined + /** Native value sent with the call. */ + value?: bigint | undefined + /** Sender authentication scheme. Defaults to `secp256k1` on the node. */ + senderAuthScheme?: Eip8130AuthScheme | undefined + /** Override the sender auth-payload byte length (otherwise scheme-derived). */ + senderAuthSize?: number | undefined + /** Optional sponsoring payer; priced into the estimate when set. */ + payer?: Address | undefined + /** Payer authentication scheme. Defaults to `secp256k1` on the node. */ + payerAuthScheme?: Eip8130AuthScheme | undefined + /** Override the payer auth-payload byte length (otherwise scheme-derived). */ + payerAuthSize?: number | undefined + /** Block number to estimate against. */ + blockNumber?: bigint | undefined + /** Block tag to estimate against. Defaults to `'pending'`. */ + blockTag?: BlockTag | undefined +} + +export type EstimateGas8130ReturnType = bigint + +/** Generous cap matching the node's `MAX_AUTH_SIZE`; rejects OOM-sized inputs. */ +const maxAuthSize = 8_192 + +/** + * Estimates gas for an EIP-8130 (`AA_TX_TYPE`) call via `eth_estimateGas`. + * + * Requires a node with the EIP-8130 `eth_estimateGas` extension + * (base `feat(eip8130): RPC support for estimateGas`). The estimate is a single + * read-only `simulate` (no binary search): it prices the EIP-8130 intrinsic-gas + * schedule for the declared authentication scheme plus the executed call. + * + * Note: unlike standard `eth_estimateGas`, an EIP-8130 estimate returns the + * charged gas **even when a phase reverts**, because a reverted EIP-8130 tx is + * still included (nonce consumed, fee paid). + */ +export async function estimateGas8130< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: EstimateGas8130Parameters, +): Promise { + const { + from, + to, + data, + value, + senderAuthScheme, + senderAuthSize, + payer, + payerAuthScheme, + payerAuthSize, + blockNumber, + blockTag = 'pending', + } = parameters + + if (!from) + throw new BaseError( + '`from` is required for an EIP-8130 gas estimate: the sender drives actor/policy resolution.', + ) + + for (const size of [senderAuthSize, payerAuthSize]) + if (size !== undefined && (size < 0 || size > maxAuthSize)) + throw new BaseError( + `auth size ${size} out of range (0..=${maxAuthSize}).`, + ) + + const request: Record = { + type: aaTransactionType, + from, + } + if (to !== undefined) request.to = to + if (data !== undefined) request.data = data + if (value !== undefined) request.value = numberToHex(value) + if (senderAuthScheme !== undefined) request.senderAuthScheme = senderAuthScheme + if (senderAuthSize !== undefined) + request.senderAuthSize = numberToHex(senderAuthSize) + if (payer !== undefined) request.payer = payer + if (payerAuthScheme !== undefined) request.payerAuthScheme = payerAuthScheme + if (payerAuthSize !== undefined) + request.payerAuthSize = numberToHex(payerAuthSize) + + const block = blockNumber !== undefined ? numberToHex(blockNumber) : blockTag + + const gas = await ( + client.request as (args: { + method: 'eth_estimateGas' + params: [Record, string] + }) => Promise + )({ method: 'eth_estimateGas', params: [request, block] }) + + return hexToBigInt(gas) +} diff --git a/src/experimental/eip8130/actions/getTransactionCount8130.ts b/src/experimental/eip8130/actions/getTransactionCount8130.ts new file mode 100644 index 0000000000..70133f5505 --- /dev/null +++ b/src/experimental/eip8130/actions/getTransactionCount8130.ts @@ -0,0 +1,88 @@ +import type { Address } from 'abitype' + +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { BlockTag } from '../../../types/block.js' +import type { Chain } from '../../../types/chain.js' +import type { Hex } from '../../../types/misc.js' +import { hexToBigInt } from '../../../utils/encoding/fromHex.js' +import { numberToHex } from '../../../utils/encoding/toHex.js' +import { nonceKeyMax } from '../constants.js' + +export type GetTransactionCount8130Parameters = { + /** The account address. */ + address: Address + /** + * EIP-8130 2D nonce channel key. Defaults to `0n` (the protocol nonce, read + * from account state). A non-zero key returns the channel nonce + * `nonces[address][nonceKey]` from the Nonce Manager precompile. + * + * `nonceKey === NONCE_KEY_MAX` selects the expiring-nonce channel, which has + * no per-channel counter — the node returns `INVALID_PARAMS` for it. + */ + nonceKey?: bigint | undefined + /** The block number. */ + blockNumber?: bigint | undefined + /** The block tag. Defaults to `'pending'` so freshly-sent txs are counted. */ + blockTag?: BlockTag | undefined +} + +export type GetTransactionCount8130ReturnType = bigint + +/** + * Reads an EIP-8130 nonce via `eth_getTransactionCount`, including the 2D + * channel-nonce extension (base `feat(eip8130): EIP-8130 rpc extensions`). + * + * This is the correct way to read an EIP-8130 sequence number for the next + * transaction. The Nonce Manager precompile is **not** a normal contract — a + * direct `eth_call` to its `getNonce` reverts — so the value must be read + * through this RPC extension (the third `nonce_key` parameter), not via + * `readContract`. + * + * - `nonceKey === 0n` → protocol nonce from account state (standard resolution). + * - `nonceKey !== 0n` → 2D channel nonce from the precompile storage. + * + * @example + * const sequence = await getTransactionCount8130(client, { + * address: account.address, + * nonceKey: 0n, + * }) + */ +export async function getTransactionCount8130< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetTransactionCount8130Parameters, +): Promise { + const { + address, + nonceKey = 0n, + blockNumber, + blockTag = 'pending', + } = parameters + + if (nonceKey === nonceKeyMax) + throw new Error( + 'nonceKey NONCE_KEY_MAX selects the expiring-nonce channel, which has no per-channel counter. Use an `expiry` instead of reading a sequence number.', + ) + + const block = blockNumber !== undefined ? numberToHex(blockNumber) : blockTag + + // Third positional `nonce_key` is the EIP-8130 RPC extension. Omitting it + // (or passing 0x0) yields the standard protocol-nonce resolution. + const params: [Address, string] | [Address, string, Hex] = + nonceKey === 0n + ? [address, block] + : [address, block, numberToHex(nonceKey)] + + const count = await ( + client.request as (args: { + method: 'eth_getTransactionCount' + params: [Address, string] | [Address, string, Hex] + }) => Promise + )({ method: 'eth_getTransactionCount', params }) + + return hexToBigInt(count) +} diff --git a/src/experimental/eip8130/actions/getTransactionReceipt8130.ts b/src/experimental/eip8130/actions/getTransactionReceipt8130.ts new file mode 100644 index 0000000000..75a7944d41 --- /dev/null +++ b/src/experimental/eip8130/actions/getTransactionReceipt8130.ts @@ -0,0 +1,94 @@ +import type { Address } from 'abitype' + +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import type { Hash, Hex } from '../../../types/misc.js' + +/** + * EIP-8130 receipt fields surfaced by `eth_getTransactionReceipt` + * (base `feat(eip8130): RPC support for ... AA receipts`). + * + * These are present only for `AA_TX_TYPE` (`0x7b`) receipts on a node with the + * extension; they are `undefined` for standard receipts or older nodes. + */ +export type Eip8130ReceiptFields = { + /** + * Gas payer: the sender for self-pay, or the named payer for a sponsored tx. + */ + payer?: Address | undefined + /** + * Per-phase execution status (`0x1` success / `0x0` revert), in phase order. + * Phases after a revert are reported `0x0`. Empty when `calls` is empty. + */ + phaseStatuses?: readonly Hex[] | undefined + /** Opaque EIP-8130 transaction metadata, echoed from the signed tx. */ + metadata?: Hex | undefined +} + +/** Raw JSON-RPC receipt with optional EIP-8130 fields (hex-encoded). */ +type RawReceipt = { + status?: Hex + payer?: Address + phaseStatuses?: readonly Hex[] + metadata?: Hex + [key: string]: unknown +} + +export type GetTransactionReceipt8130Parameters = { + /** Transaction hash to fetch the receipt for. */ + hash: Hash +} + +export type GetTransactionReceipt8130ReturnType = + | (RawReceipt & { + /** Parsed EIP-8130 receipt fields (decoded from the raw receipt). */ + eip8130: Eip8130ReceiptFields + }) + | null + +/** Reads the EIP-8130 fields off a raw JSON-RPC receipt (graceful if absent). */ +export function parseEip8130ReceiptFields( + receipt: RawReceipt | null | undefined, +): Eip8130ReceiptFields { + if (!receipt) return {} + return { + payer: receipt.payer, + phaseStatuses: receipt.phaseStatuses, + metadata: receipt.metadata, + } +} + +/** Returns `true` when every reported call phase succeeded. */ +export function allPhasesSucceeded( + fields: Pick, +): boolean { + const phases = fields.phaseStatuses + if (!phases) return true + return phases.every((s) => s === '0x1' || s === '0x01') +} + +/** + * Fetches a transaction receipt and surfaces the EIP-8130 AA fields (`payer`, + * `phaseStatuses`, `metadata`) alongside the raw receipt. Returns `null` when + * the receipt is not yet available. + */ +export async function getTransactionReceipt8130< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetTransactionReceipt8130Parameters, +): Promise { + const { hash } = parameters + const receipt = await ( + client.request as (args: { + method: 'eth_getTransactionReceipt' + params: [Hash] + }) => Promise + )({ method: 'eth_getTransactionReceipt', params: [hash] }) + + if (!receipt) return null + return { ...receipt, eip8130: parseEip8130ReceiptFields(receipt) } +} diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/experimental/eip8130/actions/sendCalls.ts index eef165ccbc..62f11154e0 100644 --- a/src/experimental/eip8130/actions/sendCalls.ts +++ b/src/experimental/eip8130/actions/sendCalls.ts @@ -1,5 +1,4 @@ import { estimateFeesPerGas } from '../../../actions/public/estimateFeesPerGas.js' -import { readContract } from '../../../actions/public/readContract.js' import { sendRawTransaction } from '../../../actions/wallet/sendRawTransaction.js' import type { Client } from '../../../clients/createClient.js' import type { Transport } from '../../../clients/transports/createTransport.js' @@ -8,9 +7,7 @@ import type { Account } from '../../../types/account.js' import type { Chain } from '../../../types/chain.js' import type { Hex } from '../../../types/misc.js' import { getAction } from '../../../utils/getAction.js' -import { nonceManagerAbi } from '../abis.js' import type { To8130AccountReturnType } from '../accounts/to8130Account.js' -import { nonceManagerAddress as defaultNonceManagerAddress } from '../constants.js' import type { AaAccountChange, AaCall, @@ -19,6 +16,7 @@ import type { } from '../types/transaction.js' import { type EncodeExecute, encodeWalletCalls } from '../utils/encodeWalletCalls.js' import type { Signer } from '../utils/signTransaction.js' +import { getTransactionCount8130 } from './getTransactionCount8130.js' type FeeOverrides = { maxFeePerGas?: bigint | undefined @@ -36,29 +34,20 @@ export type PrepareTransaction8130Parameters = FeeOverrides & { nonceKey?: bigint | undefined nonceSequence?: bigint | undefined expiry?: bigint | undefined - /** Override the Nonce Manager precompile address. */ - nonceManagerAddress?: `0x${string}` | undefined } /** * Builds a fully-populated {@link TransactionSerializable8130} for an - * `AA_TX_TYPE` transaction, filling chain id, nonce sequence (from the Nonce - * Manager precompile), and EIP-1559 fees from the client when not provided. + * `AA_TX_TYPE` transaction, filling chain id, nonce sequence (via + * `eth_getTransactionCount`'s 2D channel-nonce extension), and EIP-1559 fees + * from the client when not provided. */ export async function prepareTransaction8130( client: Client, parameters: PrepareTransaction8130Parameters, ): Promise { - const { - account, - calls, - accountChanges, - payer, - gas, - expiry, - nonceKey = 0n, - nonceManagerAddress = defaultNonceManagerAddress, - } = parameters + const { account, calls, accountChanges, payer, gas, expiry, nonceKey = 0n } = + parameters const chainId = client.chain?.id if (!chainId) @@ -70,25 +59,21 @@ export async function prepareTransaction8130( client, estimateFeesPerGas, 'estimateFeesPerGas', - )({}) + )({ chain: client.chain }) maxFeePerGas ??= fees.maxFeePerGas maxPriorityFeePerGas ??= fees.maxPriorityFeePerGas } + // Read the next sequence via `eth_getTransactionCount` (with the 2D + // `nonce_key` extension). The Nonce Manager precompile is not callable via + // `eth_call`, so this RPC path is the correct nonce source. let nonceSequence = parameters.nonceSequence if (nonceSequence === undefined) - nonceSequence = BigInt( - await getAction( - client, - readContract, - 'readContract', - )({ - abi: nonceManagerAbi, - address: nonceManagerAddress, - functionName: 'getNonce', - args: [account.address, nonceKey], - }), - ) + nonceSequence = await getAction( + client, + getTransactionCount8130, + 'getTransactionCount8130', + )({ address: account.address, nonceKey }) return { chainId, @@ -118,7 +103,6 @@ export type SendCalls8130Parameters = FeeOverrides & { nonceKey?: bigint | undefined nonceSequence?: bigint | undefined expiry?: bigint | undefined - nonceManagerAddress?: `0x${string}` | undefined /** * Encoder for value-bearing phases. Defaults to a self-call to the account's * `executeBatch`. Override when the wallet bytecode exposes a different diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts index a0870154a7..d8352dd3cc 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/experimental/eip8130/constants.ts @@ -64,6 +64,18 @@ export const ecrecoverAuthenticator = export const revokedAuthenticator = '0xffffffffffffffffffffffffffffffffffffffff' satisfies Hex +/** + * Sentinel authenticator for execution-enabled "external caller" actors + * (`EXTERNAL_CALLER_AUTHENTICATOR = address(uint160(uint256(keccak256("externalCaller"))))`). + * + * No contract is deployed here. An actor whose `authenticator` is this sentinel + * is authorized to drive the account via `executeBatch` when it is the + * `msg.sender` (e.g. an ERC-4337 EntryPoint or a {@link policyManagerAbi} + * PolicyManager). It cannot produce signatures — only direct calls. + */ +export const externalCallerAuthenticator = + '0x345249274eE98994AbBf79ef955319e4Cb3f6849' satisfies Hex + /** * Canonical authenticator set (the signature algorithms compliant nodes MUST * accept). `k1` is the native `ECRECOVER_AUTHENTICATOR` sentinel; the others are @@ -82,7 +94,7 @@ export const canonicalAuthenticators = { /** WebAuthn / FIDO2 passkey. base/eip-8130 deployment (Base Sepolia). */ passkey: '0x1CB75BE39Fb950202BF4239010534B86EdA66c31', /** Signature delegation (1-hop). base/eip-8130 deployment (Base Sepolia). */ - delegate: '0x0d10CfB3D0CD016bf20b7254C4a869FBbc0ad8C7', + delegate: '0x4C4D27e56087797Feca62262417d57be4e30dD1F', } as const satisfies Record /** Nonce Manager precompile address (`NONCE_MANAGER_ADDRESS`). */ @@ -104,7 +116,7 @@ export const txContextAddress = * parameter of {@link computeAddress8130}. */ export const accountConfigAddress = - '0xb0198a714872EE5bfDF829e7986DB5C5899a6b50' satisfies Hex + '0xAff8A7A86605D61197C1b98630d93B9d9702afb5' satisfies Hex /** * Default wallet implementation for EOA auto-delegation @@ -115,7 +127,7 @@ export const accountConfigAddress = * {@link accountConfigAddress}. */ export const defaultAccountAddress = - '0x124b52d5D57a76ed064c414975beA11Beffe0251' satisfies Hex + '0xD67D6ae50521A0ea9Aa1e174C536F346E87a1903' satisfies Hex /** Size of the deployment header in bytes (`DEPLOYMENT_HEADER_SIZE`). */ export const deploymentHeaderSize = 14 diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index c6de8be7d5..9347ec3aab 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -20,6 +20,8 @@ export type Eip8130Deployment = { defaultHighRate: Address /** BackwardCompatibleERC4337Account — pass to {@link toSmartAccount8130}. */ erc4337: Address + /** UpgradeableAccount implementation (proxied; upgradeable wallet logic). */ + upgradeable: Address } /** Deployed authenticator contracts (for EVM execution on non-native chains). */ authenticators: { @@ -30,28 +32,87 @@ export type Eip8130Deployment = { delegate: Address alwaysValid: Address } + /** + * Example actor-policy contracts (unaudited reference). A restricted actor is + * gated to the `manager`; the manager forwards committed call plans built by + * the policy. See `viem/experimental/eip8130` policy helpers. + */ + policies?: { + /** PolicyManager — the single target a policy-gated actor may call. */ + manager: Address + /** + * SessionPolicy — unified session-key policy (target allowlist + selector + * rules + recipient allowlists + per-token/native spend limits). + */ + sessionPolicy: Address + } } /** EIP-8130 deployment on Base Sepolia (chain id `84532`). */ export const baseSepoliaDeployment = { + accountConfiguration: '0xAff8A7A86605D61197C1b98630d93B9d9702afb5', + accounts: { + default: '0xD67D6ae50521A0ea9Aa1e174C536F346E87a1903', + defaultHighRate: '0xED15A3590597120f11F320801291f4d7A38156bD', + erc4337: '0xc0072312BB152278C0CaEb31d034a051ed4a86b9', + upgradeable: '0x0c5daDDb66Af134D3FD4e69874F665d78b3a4533', + }, + authenticators: { + k1: '0x0000000000000000000000000000000000000001', + p256: '0x3AE129D846CD1CAf0369b4Caa56c188E18E11B15', + webAuthn: '0x1CB75BE39Fb950202BF4239010534B86EdA66c31', + delegate: '0x4C4D27e56087797Feca62262417d57be4e30dD1F', + alwaysValid: '0x520fBA4840729CB57b3Dc7B40D548AcF354DBA25', + }, + policies: { + manager: '0x9736ad211D56164bEBA5Fa486c6dfA77E586a7fE', + sessionPolicy: '0x1c30e92C01B242a748625330777d8A7B5E51EAAE', + }, +} as const satisfies Eip8130Deployment + +/** + * EIP-8130 deployment for the Base "vibenet" devnet (chain id `84538453`). + * + * This devnet runs EIP-8130 **natively**, so the `accountConfiguration` and the + * native account/authenticator addresses are the ones the execution client + * enshrines (`base` `Eip8130Contracts`) — *not* the addresses of the example + * contracts deployed from `base/eip-8130`. Account-address derivation and the + * native authorization path read this enshrined `accountConfiguration` + * (`0xb019…`); using any other value derives a different address and the create + * transaction's sender fails to authorize. + * + * The EVM-execution-only contracts the client does not enshrine (`erc4337` and + * `upgradeable` account implementations, and the example `policies`) are taken + * from the `base/eip-8130` devnet broadcast; they are only relevant to the + * ERC-4337 / policy-gated execution path, not native `AA_TX_TYPE` inclusion. + */ +export const vibenetDevnetDeployment = { + // Enshrined by the execution client (native path) — verified on-devnet. accountConfiguration: '0xb0198a714872EE5bfDF829e7986DB5C5899a6b50', accounts: { default: '0x124b52d5D57a76ed064c414975beA11Beffe0251', defaultHighRate: '0x13dD0F222cCF60B7C08a95C2d1FcC85A38DD675D', - erc4337: '0x9748aeA1e1762E50a4d8927777FeDB63A2Ef06C0', + // EVM-execution-only (base/eip-8130 devnet broadcast). + erc4337: '0xfd054f275750DA23893aECaDE788825f8A3F434C', + upgradeable: '0x7Cf83aB369Fefabe2C9cb6D7C9DE816cc4f68Eaa', }, authenticators: { - k1: '0x39221FB37Df105B22316328e88632C9684861466', + k1: '0x0000000000000000000000000000000000000001', p256: '0x3AE129D846CD1CAf0369b4Caa56c188E18E11B15', webAuthn: '0x1CB75BE39Fb950202BF4239010534B86EdA66c31', delegate: '0xE67D299Ff3F0a185398B6C5a28998696969265d7', alwaysValid: '0x520fBA4840729CB57b3Dc7B40D548AcF354DBA25', }, + policies: { + manager: '0x95540b6dA4EaEf672c767477e84EeEa94E318135', + sessionPolicy: '0x1577b86A7F621B2274909BeD3D9e7dE2a008151C', + }, } as const satisfies Eip8130Deployment /** Known EIP-8130 deployments, keyed by chain id. */ export const eip8130Deployments: Record = { 84532: baseSepoliaDeployment, + 84538453: vibenetDevnetDeployment, } /** Returns the EIP-8130 deployment for a chain id, if known. */ diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 98678e1c72..a2022f1e00 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -17,6 +17,25 @@ export { type ToSmartAccount8130ReturnType, toSmartAccount8130, } from './accounts/toSmartAccount8130.js' +export { + type Eip8130AuthScheme, + type EstimateGas8130Parameters, + type EstimateGas8130ReturnType, + estimateGas8130, +} from './actions/estimateGas8130.js' +export { + type GetTransactionCount8130Parameters, + type GetTransactionCount8130ReturnType, + getTransactionCount8130, +} from './actions/getTransactionCount8130.js' +export { + allPhasesSucceeded, + type Eip8130ReceiptFields, + type GetTransactionReceipt8130Parameters, + type GetTransactionReceipt8130ReturnType, + getTransactionReceipt8130, + parseEip8130ReceiptFields, +} from './actions/getTransactionReceipt8130.js' export { type PrepareTransaction8130Parameters, prepareTransaction8130, @@ -42,6 +61,7 @@ export { defaultAccountAddress, deploymentHeaderSize, ecrecoverAuthenticator, + externalCallerAuthenticator, maxCodeSize, nonceKeyMax, nonceManagerAddress, @@ -53,6 +73,7 @@ export { type Eip8130Deployment, eip8130Deployments, getEip8130Deployment, + vibenetDevnetDeployment, } from './deployments.js' export { type AuthorizeActorOptions, @@ -63,6 +84,27 @@ export { revokeActor, toScope, } from './keys.js' +export { + type CommitmentOfErrorType, + commitmentOf, + type DefineSessionPolicyErrorType, + type DefineSessionPolicyParameters, + defineSessionPolicy, + type EncodeSessionPolicyActionErrorType, + type EncodeSessionPolicyConfigErrorType, + encodeSessionPolicyAction, + encodeSessionPolicyConfig, + type PolicyBinding, + policyManagerAbi, + type SessionPolicy, + type SessionPolicyAction, + type SessionPolicyCallScope, + type SessionPolicyConfig, + type SessionPolicySelectorRule, + type SessionPolicyTokenLimit, + sessionPolicyAbi, + sessionPolicyAddress, +} from './policies.js' export type { AaAccountChange, AaAccountChangeConfig, @@ -112,6 +154,12 @@ export { computeAddress8130, deploymentHeader, } from './utils/computeAddress.js' +export { + defaultEncodeExecute, + type EncodeExecute, + type EncodeExecuteParameters, + encodeWalletCalls, +} from './utils/encodeWalletCalls.js' export { actorChangeTypehash, type HashActorChanges8130ErrorType, @@ -127,12 +175,6 @@ export { getPayerSignatureHash8130, getSenderSignatureHash8130, } from './utils/hashTransaction.js' -export { - defaultEncodeExecute, - type EncodeExecute, - type EncodeExecuteParameters, - encodeWalletCalls, -} from './utils/encodeWalletCalls.js' export { type ParseTransaction8130ErrorType, parseTransaction8130, @@ -156,6 +198,13 @@ export { type SignedActorChangeSet, signedActorChangesMagic, } from './utils/signedActorChangesSignature.js' +export { + type ToP256SignerParameters, + type ToWebAuthnSignerParameters, + toP256Signer, + toWebAuthnSigner, + type WebAuthnSignSource, +} from './utils/signers.js' export { type Signer, type SignTransaction8130ErrorType, diff --git a/src/experimental/eip8130/policies.test.ts b/src/experimental/eip8130/policies.test.ts new file mode 100644 index 0000000000..2ffa4aec82 --- /dev/null +++ b/src/experimental/eip8130/policies.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'vitest' +import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' +import { baseSepoliaDeployment } from './deployments.js' +import { + commitmentOf, + defineSessionPolicy, + encodeSessionPolicyAction, + encodeSessionPolicyConfig, + type PolicyBinding, + policyManagerAbi, + sessionPolicyAddress, +} from './policies.js' + +const account = '0x0000000000000000000000000000000000000a11' +const token = '0x0000000000000000000000000000000000000a22' +const policy = sessionPolicyAddress + +// ≤ 100 USDC / week, only `transfer` on the token. +const config = encodeSessionPolicyConfig({ + tokenLimits: [{ token, limit: 100_000_000n, period: 604_800n }], + callScopes: [{ target: token, selectorRules: [{ selector: '0xa9059cbb' }] }], +}) + +const binding: PolicyBinding = { + account, + policy, + policyConfig: config, + validAfter: 0n, + validUntil: 0n, + salt: 0n, +} + +describe('encoders', () => { + test('encodeSessionPolicyConfig matches abi.encode(Config)', () => { + // Reference vector via `cast abi-encode` of the SessionPolicy Config tuple. + expect(config).toBe( + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000a220000000000000000000000000000000000000000000000000000000005f5e1000000000000000000000000000000000000000000000000000000000000093a80000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000a22000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020a9059cbb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000', + ) + }) + + test('encodeSessionPolicyAction matches abi.encode(Action)', () => { + expect( + encodeSessionPolicyAction({ target: token, data: '0xa9059cbb' }), + ).toBe( + '0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000a22000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000004a9059cbb00000000000000000000000000000000000000000000000000000000', + ) + }) +}) + +describe('commitmentOf', () => { + test('matches PolicyManager.commitmentOf reference vector', () => { + expect(commitmentOf(binding)).toBe( + '0x8355affb37ecd58a95f726ba9bfa5025d41ca52273e4e4287afca2f3d30819d0', + ) + }) + + test('salt changes the commitment', () => { + expect(commitmentOf({ ...binding, salt: 1n })).not.toBe( + commitmentOf(binding), + ) + }) +}) + +describe('defineSessionPolicy', () => { + const session = defineSessionPolicy({ account, policyConfig: config }) + + test('defaults policy + manager to the Base Sepolia deployment', () => { + expect(session.policy).toBe(baseSepoliaDeployment.policies.sessionPolicy) + expect(session.manager).toBe(baseSepoliaDeployment.policies.manager) + }) + + test('actorPolicy carries type, manager, commitment', () => { + expect(session.actorPolicy).toEqual({ + type: 1, + manager: baseSepoliaDeployment.policies.manager, + commitment: commitmentOf(binding), + }) + expect(session.commitment).toBe(commitmentOf(binding)) + }) + + test('rejects a zero policyType', () => { + expect(() => + defineSessionPolicy({ account, policyConfig: config, policyType: 0 }), + ).toThrow() + }) + + test('installCall encodes PolicyManager.install(actorId, binding)', () => { + const actorId = `0x${'11'.repeat(32)}` as const + const call = session.installCall(actorId) + expect(call.to).toBe(baseSepoliaDeployment.policies.manager) + const { functionName, args } = decodeFunctionData({ + abi: policyManagerAbi, + data: call.data!, + }) + expect(functionName).toBe('install') + expect(args[0]).toBe(actorId) + // uint40 fields decode to `number`; uint256 (salt) to `bigint`. + expect(args[1]).toEqual({ ...binding, validAfter: 0, validUntil: 0 }) + }) + + test('executeCall encodes PolicyManager.execute(policy, executionData)', () => { + const action = encodeSessionPolicyAction({ + target: token, + data: '0xa9059cbb', + }) + const call = session.executeCall(action) + expect(call.to).toBe(baseSepoliaDeployment.policies.manager) + const { functionName, args } = decodeFunctionData({ + abi: policyManagerAbi, + data: call.data!, + }) + expect(functionName).toBe('execute') + expect(args).toEqual([policy, action]) + }) +}) diff --git a/src/experimental/eip8130/policies.ts b/src/experimental/eip8130/policies.ts new file mode 100644 index 0000000000..4cc844f5fe --- /dev/null +++ b/src/experimental/eip8130/policies.ts @@ -0,0 +1,417 @@ +import type { Address } from 'abitype' +import { parseAbi } from 'abitype' +import { BaseError } from '../../errors/base.js' +import type { Hex } from '../../types/misc.js' +import { + type EncodeAbiParametersErrorType, + encodeAbiParameters, +} from '../../utils/abi/encodeAbiParameters.js' +import { encodeFunctionData } from '../../utils/abi/encodeFunctionData.js' +import { keccak256 } from '../../utils/hash/keccak256.js' +import { baseSepoliaDeployment } from './deployments.js' +import type { Policy } from './keys.js' +import type { AaCall } from './types/transaction.js' + +/** + * Actor policies (EIP-8130 restricted actors). + * + * A restricted actor (e.g. a session key) is authorized with a non-zero + * `policyType`, a `policy_manager`, and a `policy_commitment`. The protocol gate + * forces every call that actor makes to land on the manager, which enforces the + * committed policy and then drives the account via `executeBatch`. + * + * This module provides the off-chain glue for the [base/eip-8130 example + * policies](https://github.com/base/eip-8130/tree/main/src/examples/policies) — + * the {@link policyManagerAbi PolicyManager} plus the unified + * {@link encodeSessionPolicyConfig SessionPolicy}. Flow: + * + * 1. **Author** a `SessionPolicy` config ({@link encodeSessionPolicyConfig}). + * 2. **Bind** it with {@link defineSessionPolicy} to get the `commitment`, the + * {@link Policy} to pass to `authorizeActor`, and the `install` call. + * 3. **Authorize + install**: ride `authorizeActor(key, { scope, policy })` and + * the install call in one transaction (the install initializes the binding; + * it MUST land before the key's first `execute`). + * 4. **Use**: the session key sends `executeCall(action)` — its only reachable + * target is the manager. + * + * @remarks These contracts are an unaudited reference. Addresses default to the + * Base Sepolia deployment; override `manager` / `policy` for other chains. + */ + +// ───────────────────────────────────────────────────────────────────────────── +// ABIs +// ───────────────────────────────────────────────────────────────────────────── + +/** ABI for the example `PolicyManager` reference contract. */ +export const policyManagerAbi = parseAbi([ + 'struct PolicyBinding { address account; address policy; bytes policyConfig; uint40 validAfter; uint40 validUntil; uint256 salt; }', + 'struct PolicyRecord { bool installed; address account; uint40 validAfter; uint40 validUntil; }', + 'event PolicyInstalled(address indexed account, address indexed policy, bytes32 indexed commitment)', + 'event PolicyExecuted(address indexed account, address indexed policy, bytes32 indexed commitment, address caller)', + 'function commitmentOf(PolicyBinding binding) pure returns (bytes32)', + 'function getPolicyRecord(address policy, bytes32 commitment) view returns (PolicyRecord)', + 'function install(bytes32 actorId, PolicyBinding binding) returns (bytes32 commitment)', + 'function execute(address policy, bytes executionData)', +]) + +/** + * ABI for the example `SessionPolicy` reference contract — the unified session-key + * policy (target allowlist + selector rules + recipient allowlists + per-token / + * native recurring spend limits). + */ +export const sessionPolicyAbi = parseAbi([ + 'struct TokenLimit { address token; uint256 limit; uint40 period; }', + 'struct SelectorRule { bytes4 selector; address[] recipients; }', + 'struct CallScope { address target; SelectorRule[] selectorRules; }', + 'struct Config { TokenLimit[] tokenLimits; CallScope[] callScopes; }', + 'struct Action { address target; uint256 value; bytes data; }', + 'struct PeriodUsage { uint48 start; uint48 end; uint160 spend; }', + 'function isTargetAllowed(bytes32 commitment, address target) view returns (bool allowed, bool anySelector)', + 'function getSelectorRule(bytes32 commitment, address target, bytes4 selector) view returns (bool allowed, bool recipientBound)', + 'function isRecipientAllowed(bytes32 commitment, address target, bytes4 selector, address recipient) view returns (bool)', + 'function getTokenLimit(bytes32 commitment, address token) view returns (bool set, uint160 allowance, uint40 period)', + 'function getCurrentSpend(bytes32 commitment, address token) view returns (PeriodUsage)', +]) + +// ───────────────────────────────────────────────────────────────────────────── +// Binding + commitment +// ───────────────────────────────────────────────────────────────────────────── + +/** + * An account-authorized policy binding. Its `keccak256` (see + * {@link commitmentOf}) is the `policy_commitment` stored on the actor. + */ +export type PolicyBinding = { + /** Account that authorizes installation and is the execution target. */ + account: Address + /** Policy contract implementing the hook interface. */ + policy: Address + /** Committed, policy-defined configuration bytes. */ + policyConfig: Hex + /** Earliest timestamp (unix seconds) execution is allowed. `0n` = no bound. */ + validAfter: bigint + /** Timestamp (unix seconds) at/after which execution is disallowed. `0n` = no bound. */ + validUntil: bigint + /** Salt allowing distinct bindings for the same (account, policy, config). */ + salt: bigint +} + +const commitmentParameters = [ + { type: 'address' }, + { type: 'address' }, + { type: 'bytes32' }, + { type: 'uint40' }, + { type: 'uint40' }, + { type: 'uint256' }, +] as const + +export type CommitmentOfErrorType = EncodeAbiParametersErrorType + +/** + * Computes the policy commitment for a {@link PolicyBinding}: + * `keccak256(abi.encode(account, policy, keccak256(policyConfig), validAfter, validUntil, salt))`. + * + * Matches `PolicyManager.commitmentOf` exactly. Portable by construction (no + * chain/domain mixed in). + */ +export function commitmentOf(binding: PolicyBinding): Hex { + return keccak256( + encodeAbiParameters(commitmentParameters, [ + binding.account, + binding.policy, + keccak256(binding.policyConfig), + // `uint40` fits JS `number`; viem's typed encoder expects it. + Number(binding.validAfter), + Number(binding.validUntil), + binding.salt, + ]), + ) +} + +/** Normalizes a {@link PolicyBinding} into the `PolicyManager.PolicyBinding` struct args. */ +function toBindingArgs(binding: PolicyBinding) { + return { + account: binding.account, + policy: binding.policy, + policyConfig: binding.policyConfig, + validAfter: Number(binding.validAfter), + validUntil: Number(binding.validUntil), + salt: binding.salt, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Session policy bundle +// ───────────────────────────────────────────────────────────────────────────── + +export type DefineSessionPolicyParameters = { + /** Account that authorizes + installs the binding (the execution target). */ + account: Address + /** Committed, policy-defined configuration bytes (e.g. {@link encodeSessionPolicyConfig}). */ + policyConfig: Hex + /** Policy contract. @default baseSepolia SessionPolicy */ + policy?: Address | undefined + /** PolicyManager the actor is gated to. @default baseSepolia PolicyManager */ + manager?: Address | undefined + /** Earliest timestamp (unix seconds) execution is allowed. @default 0n */ + validAfter?: bigint | undefined + /** Timestamp (unix seconds) at/after which execution is disallowed. @default 0n */ + validUntil?: bigint | undefined + /** Salt for distinct bindings of the same (account, policy, config). @default 0n */ + salt?: bigint | undefined + /** Non-zero `policyType` stored on the actor. @default 1 */ + policyType?: number | undefined +} + +export type SessionPolicy = { + /** PolicyManager the gated actor may call. */ + manager: Address + /** Policy contract enforcing the binding. */ + policy: Address + /** The account-authorized binding. */ + binding: PolicyBinding + /** The binding's commitment. */ + commitment: Hex + /** Pass to `authorizeActor(key, { scope, policy })`. */ + actorPolicy: Policy + /** + * Account call that installs (initializes) the binding: + * `PolicyManager.install(actorId, binding)`. Ride this alongside the + * `authorizeActor` change — it MUST land before the key's first `execute`. + */ + installCall(actorId: Hex): AaCall + /** + * Account call the session key sends: `PolicyManager.execute(policy, executionData)`. + * This is the only target a policy-gated actor may reach. Build `executionData` + * with {@link encodeSessionPolicyAction}. + */ + executeCall(executionData: Hex): AaCall +} + +export type DefineSessionPolicyErrorType = CommitmentOfErrorType + +/** + * Binds a committed policy config to an account and returns everything needed to + * authorize, install (initialize), and use a policy-gated session key. + * + * @example + * import { + * defineSessionPolicy, + * encodeSessionPolicyConfig, + * encodeSessionPolicyAction, + * authorizeActor, + * actorScope, + * key, + * } from 'viem/experimental/eip8130' + * + * const session = defineSessionPolicy({ + * account: account.address, + * policyConfig: encodeSessionPolicyConfig({ + * // ≤ 100 USDC / week, only `transfer` on the token + * tokenLimits: [{ token: usdc, limit: parseUnits('100', 6), period: 7n * 86400n }], + * callScopes: [{ target: usdc, selectorRules: [{ selector: '0xa9059cbb' }] }], + * }), + * }) + * + * // 1) authorize + install (initialize) in one transaction (sent by the account) + * const change = await account.change([ + * authorizeActor(key.p256(pub), { scope: actorScope.sender, policy: session.actorPolicy }), + * ]) + * const calls = [[session.installCall(key.p256(pub).actorId)]] + * + * // 2) later, the session key spends within its limit + * const spend = session.executeCall( + * encodeSessionPolicyAction({ target: usdc, data: transferCalldata }), + * ) + */ +export function defineSessionPolicy( + parameters: DefineSessionPolicyParameters, +): SessionPolicy { + const { + account, + policyConfig, + policy = baseSepoliaDeployment.policies.sessionPolicy, + manager = baseSepoliaDeployment.policies.manager, + validAfter = 0n, + validUntil = 0n, + salt = 0n, + policyType = 1, + } = parameters + + if (policyType === 0) + throw new BaseError('`policyType` must be non-zero (0 = no policy).') + + const binding: PolicyBinding = { + account, + policy, + policyConfig, + validAfter, + validUntil, + salt, + } + const commitment = commitmentOf(binding) + + return { + manager, + policy, + binding, + commitment, + actorPolicy: { type: policyType, manager, commitment }, + installCall(actorId) { + return { + to: manager, + value: 0n, + data: encodeFunctionData({ + abi: policyManagerAbi, + functionName: 'install', + args: [actorId, toBindingArgs(binding)], + }), + } + }, + executeCall(executionData) { + return { + to: manager, + value: 0n, + data: encodeFunctionData({ + abi: policyManagerAbi, + functionName: 'execute', + args: [policy, executionData], + }), + } + }, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// SessionPolicy config + action encoders +// ───────────────────────────────────────────────────────────────────────────── + +/** Reference `SessionPolicy` deployment address (Base Sepolia). */ +export const sessionPolicyAddress = baseSepoliaDeployment.policies + .sessionPolicy as Address + +/** A per-token (or native-ETH) recurring / one-time spend cap. */ +export type SessionPolicyTokenLimit = { + /** ERC-20 token, or the zero address for native ETH (gated on each call's `value`). */ + token: Address + /** Maximum spend per period (atomic units). One-time = total cap. Must fit `uint160`. */ + limit: bigint + /** Period length in seconds. `0n` (default) = one-time (never resets). */ + period?: bigint | undefined +} + +/** An allowed function selector on a {@link SessionPolicyCallScope} target. */ +export type SessionPolicySelectorRule = { + /** 4-byte function selector. */ + selector: Hex + /** + * Allowed recipients (empty/omitted = any). Only valid for the standard ERC-20 + * selectors (`transfer`, `transferFrom`, `approve`). + */ + recipients?: readonly Address[] | undefined +} + +/** A target contract and its allowed selector rules. */ +export type SessionPolicyCallScope = { + /** Target contract this binding may call. */ + target: Address + /** Allowed selectors on `target` (empty/omitted = any selector, no recipient gating). */ + selectorRules?: readonly SessionPolicySelectorRule[] | undefined +} + +/** The full committed `SessionPolicy` configuration for a binding. */ +export type SessionPolicyConfig = { + tokenLimits?: readonly SessionPolicyTokenLimit[] | undefined + callScopes?: readonly SessionPolicyCallScope[] | undefined +} + +/** A per-use `SessionPolicy` action: a single call the session key wants to make. */ +export type SessionPolicyAction = { + target: Address + value?: bigint | undefined + data?: Hex | undefined +} + +const sessionConfigParameters = [ + { + type: 'tuple', + components: [ + { + name: 'tokenLimits', + type: 'tuple[]', + components: [ + { name: 'token', type: 'address' }, + { name: 'limit', type: 'uint256' }, + { name: 'period', type: 'uint40' }, + ], + }, + { + name: 'callScopes', + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { + name: 'selectorRules', + type: 'tuple[]', + components: [ + { name: 'selector', type: 'bytes4' }, + { name: 'recipients', type: 'address[]' }, + ], + }, + ], + }, + ], + }, +] as const + +const sessionActionParameters = [ + { + type: 'tuple', + components: [ + { name: 'target', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + }, +] as const + +export type EncodeSessionPolicyConfigErrorType = EncodeAbiParametersErrorType + +/** + * Encodes a {@link SessionPolicyConfig} into the committed `policyConfig`: + * `abi.encode(SessionPolicy.Config)`. + */ +export function encodeSessionPolicyConfig(config: SessionPolicyConfig): Hex { + return encodeAbiParameters(sessionConfigParameters, [ + { + tokenLimits: (config.tokenLimits ?? []).map((t) => ({ + token: t.token, + limit: t.limit, + period: Number(t.period ?? 0n), + })), + callScopes: (config.callScopes ?? []).map((s) => ({ + target: s.target, + selectorRules: (s.selectorRules ?? []).map((r) => ({ + selector: r.selector, + recipients: (r.recipients ?? []) as Address[], + })), + })), + }, + ]) +} + +export type EncodeSessionPolicyActionErrorType = EncodeAbiParametersErrorType + +/** + * Encodes a {@link SessionPolicyAction} into the `executionData` passed to + * {@link SessionPolicy.executeCall}: `abi.encode(SessionPolicy.Action)`. + */ +export function encodeSessionPolicyAction(action: SessionPolicyAction): Hex { + return encodeAbiParameters(sessionActionParameters, [ + { + target: action.target, + value: action.value ?? 0n, + data: action.data ?? '0x', + }, + ]) +} diff --git a/src/experimental/eip8130/types/transaction.ts b/src/experimental/eip8130/types/transaction.ts index 3a1f0ce43c..130ae18d8a 100644 --- a/src/experimental/eip8130/types/transaction.ts +++ b/src/experimental/eip8130/types/transaction.ts @@ -125,7 +125,7 @@ export type AaAccountChange = * AA_TX_TYPE || rlp([ * chain_id, sender, nonce_key, nonce_sequence, expiry, * max_priority_fee_per_gas, max_fee_per_gas, gas_limit, - * account_changes, calls, payer, sender_auth, payer_auth + * account_changes, calls, metadata, payer, sender_auth, payer_auth * ]) * ``` */ @@ -154,6 +154,13 @@ export type TransactionSerializable8130 = { accountChanges?: readonly AaAccountChange[] | undefined /** Ordered call phases. */ calls?: AaCalls | undefined + /** + * Opaque, application-defined metadata (arbitrary bytes) carried at the top + * level of the transaction. Appended after `calls` in the signed body, so it + * is authenticated by both the sender and (when present) the payer. Omit or + * `'0x'` for none. + */ + metadata?: Hex | undefined /** Gas payer. Omit for self-pay; set to a 20-byte address for sponsored. */ payer?: Address | undefined /** diff --git a/src/experimental/eip8130/utils/actorChangeData.ts b/src/experimental/eip8130/utils/actorChangeData.ts index 44c6bacf31..ecee828c17 100644 --- a/src/experimental/eip8130/utils/actorChangeData.ts +++ b/src/experimental/eip8130/utils/actorChangeData.ts @@ -47,7 +47,9 @@ export function encodeActorChangeData(change: AaActorChange): Hex { { authenticator: change.authenticator, scope: change.scope ?? 0, - expiry: change.expiry ?? 0n, + // `uint48` maps to `number` in viem's ABI encoder; expiry (unix seconds) + // fits comfortably. + expiry: Number(change.expiry ?? 0n), policyType: change.policyType ?? 0, }, change.policyData ?? '0x', diff --git a/src/experimental/eip8130/utils/encodeWalletCalls.ts b/src/experimental/eip8130/utils/encodeWalletCalls.ts index 73f7b47571..1ef94d4c75 100644 --- a/src/experimental/eip8130/utils/encodeWalletCalls.ts +++ b/src/experimental/eip8130/utils/encodeWalletCalls.ts @@ -4,6 +4,13 @@ import { encodeFunctionData } from '../../../utils/abi/encodeFunctionData.js' import { erc4337AccountAbi } from '../abis.js' import type { AaCall, AaCalls } from '../types/transaction.js' +/** An {@link AaCall} with `value`/`data` normalized to defined values. */ +export type NormalizedAaCall = { + to: Address + data: Hex + value: bigint +} + /** * Parameters passed to an {@link EncodeExecute} function: the account whose * wallet bytecode runs the batch, and the (value-bearing) calls in a phase. @@ -12,7 +19,7 @@ export type EncodeExecuteParameters = { /** The EIP-8130 account whose wallet bytecode executes the batch. */ account: Address /** The phase's calls, with normalized `value`/`data`. */ - calls: readonly Required[] + calls: readonly NormalizedAaCall[] } /** @@ -72,7 +79,7 @@ export function encodeWalletCalls(parameters: { return phase.map((call) => ({ to: call.to, data: call.data ?? '0x' })) const normalized = phase.map( - (call): Required => ({ + (call): NormalizedAaCall => ({ to: call.to, value: call.value ?? 0n, data: call.data ?? '0x', diff --git a/src/experimental/eip8130/utils/parseTransaction.ts b/src/experimental/eip8130/utils/parseTransaction.ts index 79748c3b3c..702f232cc5 100644 --- a/src/experimental/eip8130/utils/parseTransaction.ts +++ b/src/experimental/eip8130/utils/parseTransaction.ts @@ -81,35 +81,42 @@ function parseActorChange(value: RlpHex): AaActorChange { } function parseAccountChanges(value: RlpHex): readonly AaAccountChange[] { - const entries = value as RlpHex[] - return entries.map((entry): AaAccountChange => { - const fields = entry as RlpHex[] - const type = fields[0] as Hex + // Wire format: each AccountChange is encoded as type_byte || rlp([body_fields...]). + // After RLP decoding the outer list we receive alternating [type_hex, body_array] pairs. + const flat = value as RlpHex[] + const result: AaAccountChange[] = [] + for (let i = 0; i < flat.length; i += 2) { + const type = flat[i] as Hex + const body = flat[i + 1] as RlpHex[] if (type === accountChangeType.create) { - const [, userSalt, code, actors] = fields - return { + const [userSalt, code, actors] = body + result.push({ type: 'create', userSalt: userSalt as Hex, code: code as Hex, initialActors: (actors as RlpHex[]).map(parseActor), - } + }) + continue } if (type === accountChangeType.config) { - const [, chainId, sequence, actorChanges, auth] = fields - return { + const [chainId, sequence, actorChanges, auth] = body + result.push({ type: 'config', chainId: chainId === '0x' ? 0 : hexToNumber(chainId as Hex), sequence: sequence === '0x' ? 0 : hexToNumber(sequence as Hex), actorChanges: (actorChanges as RlpHex[]).map(parseActorChange), auth: auth as Hex, - } + }) + continue } if (type === accountChangeType.delegation) { - const [, target] = fields - return { type: 'delegation', target: target as Address } + const [target] = body + result.push({ type: 'delegation', target: target as Address }) + continue } throw new BaseError(`Unknown account change entry type: "${type}".`) - }) + } + return result } /** @@ -137,6 +144,7 @@ export function parseTransaction8130( gas, accountChanges, calls, + metadata, payer, senderAuth, payerAuth, @@ -169,6 +177,7 @@ export function parseTransaction8130( if ((accountChanges as RlpHex[]).length > 0) transaction.accountChanges = parseAccountChanges(accountChanges) if ((calls as RlpHex[]).length > 0) transaction.calls = parseCalls(calls) + if ((metadata as Hex) !== '0x') transaction.metadata = metadata as Hex const payerAddress = toOptionalAddress(payer as Hex) if (payerAddress) transaction.payer = payerAddress diff --git a/src/experimental/eip8130/utils/serializeTransaction.ts b/src/experimental/eip8130/utils/serializeTransaction.ts index d3e13cd79c..d996a024c4 100644 --- a/src/experimental/eip8130/utils/serializeTransaction.ts +++ b/src/experimental/eip8130/utils/serializeTransaction.ts @@ -43,30 +43,42 @@ function toActorChange(change: AaActorChange): RecursiveArray { ] } -/** Encodes the `account_changes` field into a nested RLP-ready array. */ +/** + * Encodes the `account_changes` field into a flat RLP-ready array. + * + * Each AccountChange is encoded on the wire as `type_byte || rlp([fields...])`: + * the type byte is a raw prefix, NOT wrapped in its own RLP list. To produce + * this with `toRlp`, we flatMap each entry into two sibling items in the outer + * list: the type-byte hex string (encodes as a single raw byte ≤ 0x7f) and + * the body array (encodes as an RLP list of fields). + */ export function toAccountChangesList( accountChanges: readonly AaAccountChange[] | undefined, ): RecursiveArray[] { - return (accountChanges ?? []).map((entry): RecursiveArray => { + return (accountChanges ?? []).flatMap((entry): RecursiveArray[] => { if (entry.type === 'create') return [ accountChangeType.create, - entry.userSalt, - entry.code, - entry.initialActors.map((actor) => [ - actor.actorId, - actor.authenticator, - ]), + [ + entry.userSalt, + entry.code, + entry.initialActors.map((actor) => [ + actor.actorId, + actor.authenticator, + ]), + ], ] if (entry.type === 'config') return [ accountChangeType.config, - entry.chainId ? numberToHex(entry.chainId) : '0x', - entry.sequence ? numberToHex(entry.sequence) : '0x', - entry.actorChanges.map(toActorChange), - entry.auth, + [ + entry.chainId ? numberToHex(entry.chainId) : '0x', + entry.sequence ? numberToHex(entry.sequence) : '0x', + entry.actorChanges.map(toActorChange), + entry.auth, + ], ] - return [accountChangeType.delegation, entry.target] + return [accountChangeType.delegation, [entry.target]] }) } @@ -90,6 +102,7 @@ export function toTransactionBody( gas, accountChanges, calls, + metadata, } = transaction return [ numberToHex(chainId), @@ -102,6 +115,7 @@ export function toTransactionBody( gas ? numberToHex(gas) : '0x', toAccountChangesList(accountChanges), toCallsList(calls), + metadata ?? '0x', ] } diff --git a/src/experimental/eip8130/utils/signActorChanges.ts b/src/experimental/eip8130/utils/signActorChanges.ts index dab6a792e5..3542e30ff4 100644 --- a/src/experimental/eip8130/utils/signActorChanges.ts +++ b/src/experimental/eip8130/utils/signActorChanges.ts @@ -32,7 +32,7 @@ export type SignActorChanges8130Parameters = { actorChanges: readonly AaActorChange[] /** * Authenticator address for the `auth` blob. Defaults to - * `ECRECOVER_AUTHENTICATOR` (native secp256k1). + * `signer.authenticator`, then `ECRECOVER_AUTHENTICATOR` (native secp256k1). */ authenticator?: Address | undefined } @@ -52,13 +52,9 @@ export type SignActorChanges8130ErrorType = export async function signActorChanges8130( parameters: SignActorChanges8130Parameters, ): Promise { - const { - signer, - chainId, - sequence, - actorChanges, - authenticator = ecrecoverAuthenticator, - } = parameters + const { signer, chainId, sequence, actorChanges } = parameters + const authenticator = + parameters.authenticator ?? signer.authenticator ?? ecrecoverAuthenticator const account = parameters.account ?? signer.address if (!signer.sign) diff --git a/src/experimental/eip8130/utils/signTransaction.test.ts b/src/experimental/eip8130/utils/signTransaction.test.ts index e9166f8335..14b471b71f 100644 --- a/src/experimental/eip8130/utils/signTransaction.test.ts +++ b/src/experimental/eip8130/utils/signTransaction.test.ts @@ -1,21 +1,38 @@ import { describe, expect, test } from 'vitest' import { accounts } from '~test/constants.js' import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' +import type { Hex } from '../../../types/misc.js' import { sliceHex } from '../../../utils/data/slice.js' import { recoverAddress } from '../../../utils/signature/recoverAddress.js' -import { ecrecoverAuthenticator } from '../constants.js' +import { canonicalAuthenticators, ecrecoverAuthenticator } from '../constants.js' import type { TransactionSerializable8130 } from '../types/transaction.js' import { getPayerSignatureHash8130, getSenderSignatureHash8130, } from './hashTransaction.js' import { parseTransaction8130 } from './parseTransaction.js' -import { signTransaction8130 } from './signTransaction.js' +import { type Signer, signTransaction8130 } from './signTransaction.js' const sender = privateKeyToAccount(accounts[0].privateKey) const sponsor = privateKeyToAccount(accounts[1].privateKey) const bob = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const +// A non-ECDSA signer (e.g. P-256 / WebAuthn) whose `sign` returns the +// authenticator-specific `data` blob rather than a 65-byte ECDSA signature. +// P-256 data layout is r || s || x || y || preHash = 129 bytes. +const p256Data = `0x${'ab'.repeat(129)}` as const +function toMockP256Signer(options: { authenticator?: Hex } = {}): Signer { + return { + address: '0x0000000000000000000000000000000000000000', + async sign() { + return p256Data + }, + ...(options.authenticator + ? { authenticator: options.authenticator as `0x${string}` } + : {}), + } +} + describe('signTransaction (EIP-8130)', () => { test('EOA path: raw 65-byte sender_auth recovers sender', async () => { const transaction: TransactionSerializable8130 = { @@ -116,6 +133,91 @@ describe('signTransaction (EIP-8130)', () => { expect(parsed.payer?.toLowerCase()).toBe(sponsor.address.toLowerCase()) }) + test('configured-actor path: custom authenticator || data (P-256/passkey)', async () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: sender.address, + nonceSequence: 1n, + maxFeePerGas: 2n, + calls: [[{ to: bob }]], + } + const serialized = await signTransaction8130({ + transaction, + account: toMockP256Signer(), + authenticator: canonicalAuthenticators.p256, + }) + const parsed = parseTransaction8130(serialized) + + expect(parsed.from?.toLowerCase()).toBe(sender.address.toLowerCase()) + // first 20 bytes are the P-256 authenticator, remainder is the raw data blob + expect(sliceHex(parsed.senderAuth!, 0, 20).toLowerCase()).toBe( + canonicalAuthenticators.p256.toLowerCase(), + ) + expect(sliceHex(parsed.senderAuth!, 20)).toBe(p256Data) + }) + + test('configured-actor path: authenticator read from the signer', async () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: sender.address, + maxFeePerGas: 2n, + calls: [[{ to: bob }]], + } + const serialized = await signTransaction8130({ + transaction, + account: toMockP256Signer({ authenticator: canonicalAuthenticators.p256 }), + }) + const parsed = parseTransaction8130(serialized) + expect(sliceHex(parsed.senderAuth!, 0, 20).toLowerCase()).toBe( + canonicalAuthenticators.p256.toLowerCase(), + ) + }) + + test('explicit authenticator overrides the signer authenticator', async () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: sender.address, + maxFeePerGas: 2n, + calls: [[{ to: bob }]], + } + const serialized = await signTransaction8130({ + transaction, + account: toMockP256Signer({ + authenticator: canonicalAuthenticators.passkey, + }), + authenticator: canonicalAuthenticators.p256, + }) + const parsed = parseTransaction8130(serialized) + expect(sliceHex(parsed.senderAuth!, 0, 20).toLowerCase()).toBe( + canonicalAuthenticators.p256.toLowerCase(), + ) + }) + + test('sponsored: payer with a custom authenticator', async () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: sender.address, + maxFeePerGas: 2n, + gas: 50_000n, + calls: [[{ to: bob }]], + } + const serialized = await signTransaction8130({ + transaction, + account: sender, + payer: { + account: toMockP256Signer(), + address: bob, + authenticator: canonicalAuthenticators.passkey, + }, + }) + const parsed = parseTransaction8130(serialized) + expect(parsed.payer?.toLowerCase()).toBe(bob.toLowerCase()) + expect(sliceHex(parsed.payerAuth!, 0, 20).toLowerCase()).toBe( + canonicalAuthenticators.passkey.toLowerCase(), + ) + expect(sliceHex(parsed.payerAuth!, 20)).toBe(p256Data) + }) + test('throws without sender account or preset senderAuth', async () => { await expect( signTransaction8130({ diff --git a/src/experimental/eip8130/utils/signTransaction.ts b/src/experimental/eip8130/utils/signTransaction.ts index 2a2eddaf87..1787e06e48 100644 --- a/src/experimental/eip8130/utils/signTransaction.ts +++ b/src/experimental/eip8130/utils/signTransaction.ts @@ -22,11 +22,25 @@ import { serializeTransaction8130, } from './serializeTransaction.js' -/** A signer capable of producing a raw secp256k1 signature over a hash. */ +/** + * A signer capable of producing an authenticator `data` blob over a hash. + * + * For the native secp256k1 path `sign` returns a raw 65-byte ECDSA signature. + * For non-ECDSA authenticators (P-256, WebAuthn/passkey, delegate) `sign` + * returns the authenticator-specific `data` (the bytes after the 20-byte + * authenticator prefix), and `authenticator` identifies the authenticator + * contract that validates it. + */ export type Signer = Pick & { sign?: | ((parameters: { hash: `0x${string}` }) => Promise<`0x${string}`>) | undefined + /** + * Authenticator address for this signer's auth blob. Defaults to + * `ECRECOVER_AUTHENTICATOR` (native secp256k1). Set to a P-256 / WebAuthn / + * delegate authenticator to sign as a non-ECDSA configured actor. + */ + authenticator?: Address | undefined } export type SignTransaction8130Parameters = { @@ -42,14 +56,26 @@ export type SignTransaction8130Parameters = { */ account?: Signer | undefined /** - * Payer signer for sponsored transactions (secp256k1). Produces `payer_auth` - * as `ECRECOVER_AUTHENTICATOR || signature` over the payer signature hash. + * Authenticator for the sender's `auth` blob (configured-actor path). Defaults + * to `account.authenticator`, then `ECRECOVER_AUTHENTICATOR`. Set to a P-256 / + * WebAuthn (passkey) / delegate authenticator to sign as a non-ECDSA actor. + */ + authenticator?: Address | undefined + /** + * Payer signer for sponsored transactions. Produces `payer_auth` as + * `authenticator || data` over the payer signature hash. Defaults the + * authenticator to `ECRECOVER_AUTHENTICATOR` (native secp256k1). */ payer?: | { account: Signer /** The `payer` wire address. Defaults to the payer account's address. */ address?: Address | undefined + /** + * Authenticator for the payer's `auth` blob. Defaults to + * `account.authenticator`, then `ECRECOVER_AUTHENTICATOR`. + */ + authenticator?: Address | undefined } | undefined } @@ -64,10 +90,13 @@ export type SignTransaction8130ErrorType = /** * Signs an EIP-8130 (`AA_TX_TYPE`) transaction. * - * Produces `sender_auth` (and, for sponsored transactions, `payer_auth`) using - * native secp256k1 signers, then returns the serialized envelope. For custom - * authenticators, set `transaction.senderAuth` / `transaction.payerAuth` - * directly and the corresponding signer is skipped. + * Produces `sender_auth` (and, for sponsored transactions, `payer_auth`) and + * returns the serialized envelope. Defaults to native secp256k1 + * (`ECRECOVER_AUTHENTICATOR`); pass `authenticator` (and/or `payer.authenticator`, + * or set `authenticator` on the signer) to sign as a P-256 / WebAuthn (passkey) / + * delegate configured actor, in which case the signer's `sign` returns the + * authenticator-specific `data`. Presetting `transaction.senderAuth` / + * `transaction.payerAuth` skips the corresponding signer entirely. */ export async function signTransaction8130( parameters: SignTransaction8130Parameters, @@ -81,11 +110,14 @@ export async function signTransaction8130( throw new BaseError( 'A sender `account` with raw signing support, or a preset `transaction.senderAuth`, is required.', ) + const authenticator = + parameters.authenticator ?? account.authenticator ?? ecrecoverAuthenticator const senderHash = getSenderSignatureHash8130(transaction) const signature = await account.sign({ hash: senderHash }) transaction.senderAuth = transaction.from - ? // Configured actor: ECRECOVER_AUTHENTICATOR || (r || s || v) - concatHex([ecrecoverAuthenticator, signature]) + ? // Configured actor: AUTHENTICATOR || data + // (ecrecover: r || s || v; P-256/WebAuthn: authenticator-specific blob) + concatHex([authenticator, signature]) : // EOA path: raw 65-byte signature signature } @@ -99,11 +131,15 @@ export async function signTransaction8130( throw new BaseError( 'A `payer` signer with raw signing support, or a preset `transaction.payerAuth`, is required for sponsored transactions.', ) + const authenticator = + payer.authenticator ?? + payer.account.authenticator ?? + ecrecoverAuthenticator // The payer hash MUST bind to the resolved sender address. const from = transaction.from ?? account?.address const payerHash = getPayerSignatureHash8130({ ...transaction, from }) const signature = await payer.account.sign({ hash: payerHash }) - transaction.payerAuth = concatHex([ecrecoverAuthenticator, signature]) + transaction.payerAuth = concatHex([authenticator, signature]) } return serializeTransaction8130(transaction) diff --git a/src/experimental/eip8130/utils/signers.test.ts b/src/experimental/eip8130/utils/signers.test.ts new file mode 100644 index 0000000000..5993977af6 --- /dev/null +++ b/src/experimental/eip8130/utils/signers.test.ts @@ -0,0 +1,130 @@ +import * as P256 from 'ox/P256' +import { describe, expect, test } from 'vitest' +import { decodeAbiParameters } from '../../../utils/abi/decodeAbiParameters.js' +import { size } from '../../../utils/data/size.js' +import { sliceHex } from '../../../utils/data/slice.js' +import { hexToBigInt } from '../../../utils/encoding/fromHex.js' +import { canonicalAuthenticators } from '../constants.js' +import type { TransactionSerializable8130 } from '../types/transaction.js' +import { parseTransaction8130 } from './parseTransaction.js' +import { toP256Signer, toWebAuthnSigner } from './signers.js' +import { signTransaction8130 } from './signTransaction.js' + +const bob = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const +const privateKey = `0x${'a'.repeat(64)}` as const +const hash = `0x${'42'.repeat(32)}` as const + +describe('toP256Signer', () => { + test('produces 129-byte `r || s || x || y || preHash` data', async () => { + const signer = toP256Signer({ privateKey }) + const data = await signer.sign!({ hash }) + + expect(size(data)).toBe(129) + expect(signer.authenticator).toBe(canonicalAuthenticators.p256) + + const r = sliceHex(data, 0, 32) + const s = sliceHex(data, 32, 64) + const x = sliceHex(data, 64, 96) + const y = sliceHex(data, 96, 128) + expect(x).toBe(signer.publicKey.x) + expect(y).toBe(signer.publicKey.y) + expect(sliceHex(data, 128, 129)).toBe('0x00') + + // The signature verifies over the raw digest with the signer's public key. + const verified = P256.verify({ + hash: false, + payload: hash, + publicKey: P256.getPublicKey({ privateKey }), + signature: { r: hexToBigInt(r), s: hexToBigInt(s) }, + }) + expect(verified).toBe(true) + }) + + test('signs a configured-actor transaction (authenticator || data)', async () => { + const signer = toP256Signer({ privateKey }) + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: bob, + nonceSequence: 1n, + maxFeePerGas: 2n, + calls: [[{ to: bob }]], + } + const serialized = await signTransaction8130({ + transaction, + account: signer, + }) + const parsed = parseTransaction8130(serialized) + + expect(sliceHex(parsed.senderAuth!, 0, 20).toLowerCase()).toBe( + canonicalAuthenticators.p256.toLowerCase(), + ) + expect(size(sliceHex(parsed.senderAuth!, 20))).toBe(129) + }) +}) + +describe('toWebAuthnSigner', () => { + const x = `0x${'11'.repeat(32)}` as const + const y = `0x${'22'.repeat(32)}` as const + const r = `0x${'33'.repeat(32)}` as const + const s = `0x${'44'.repeat(32)}` as const + const authenticatorData = `0x${'ab'.repeat(37)}` as const + const clientDataJSON = + '{"type":"webauthn.get","challenge":"...","origin":"https://account.vibes.base.org"}' + + const source = { + publicKey: { x, y }, + async sign() { + return { + signature: `0x${r.slice(2)}${s.slice(2)}` as `0x${string}`, + webauthn: { + authenticatorData, + clientDataJSON, + challengeIndex: 23, + typeIndex: 1, + }, + } + }, + } + + test('ABI-encodes `(WebAuthnAuth, x, y)` data the authenticator can decode', async () => { + const signer = toWebAuthnSigner(source) + expect(signer.authenticator).toBe(canonicalAuthenticators.passkey) + + const data = await signer.sign!({ hash }) + const [auth, decodedX, decodedY] = decodeAbiParameters( + [ + { + type: 'tuple', + components: [ + { name: 'r', type: 'bytes32' }, + { name: 's', type: 'bytes32' }, + { name: 'challengeIndex', type: 'uint256' }, + { name: 'typeIndex', type: 'uint256' }, + { name: 'authenticatorData', type: 'bytes' }, + { name: 'clientDataJSON', type: 'string' }, + ], + }, + { name: 'x', type: 'bytes32' }, + { name: 'y', type: 'bytes32' }, + ], + data, + ) + + expect(decodedX).toBe(x) + expect(decodedY).toBe(y) + expect(auth.r).toBe(r) + expect(auth.s).toBe(s) + expect(auth.challengeIndex).toBe(23n) + expect(auth.typeIndex).toBe(1n) + expect(auth.authenticatorData).toBe(authenticatorData) + expect(auth.clientDataJSON).toBe(clientDataJSON) + }) + + test('accepts a 64-byte `x || y` public key hex', async () => { + const signer = toWebAuthnSigner({ + ...source, + publicKey: `0x${x.slice(2)}${y.slice(2)}`, + }) + expect(signer.publicKey).toEqual({ x, y }) + }) +}) diff --git a/src/experimental/eip8130/utils/signers.ts b/src/experimental/eip8130/utils/signers.ts new file mode 100644 index 0000000000..177edc734d --- /dev/null +++ b/src/experimental/eip8130/utils/signers.ts @@ -0,0 +1,182 @@ +import type { Address } from 'abitype' +import * as P256 from 'ox/P256' +import { zeroAddress } from '../../../constants/address.js' +import type { Hex } from '../../../types/misc.js' +import { encodeAbiParameters } from '../../../utils/abi/encodeAbiParameters.js' +import { concatHex } from '../../../utils/data/concat.js' +import { size } from '../../../utils/data/size.js' +import { sliceHex } from '../../../utils/data/slice.js' +import { numberToHex } from '../../../utils/encoding/toHex.js' +import { canonicalAuthenticators } from '../constants.js' +import type { Signer } from './signTransaction.js' + +function publicKeyToXY(publicKey: Hex | { x: Hex; y: Hex }): { + x: Hex + y: Hex +} { + if (typeof publicKey !== 'string') return publicKey + // Accept an uncompressed key with or without the 0x04 prefix. + const body = size(publicKey) === 65 ? sliceHex(publicKey, 1) : publicKey + if (size(body) !== 64) + throw new Error( + '`publicKey` must be a 64-byte `x || y` hex (optionally 0x04-prefixed) or `{ x, y }`.', + ) + return { x: sliceHex(body, 0, 32), y: sliceHex(body, 32, 64) } +} + +export type ToP256SignerParameters = { + /** P-256 (secp256r1) private key. */ + privateKey: Hex + /** + * Authenticator address that validates this key. Defaults to the canonical + * P-256 authenticator. Resolve per-chain via `getEip8130Deployment` when the + * deployment differs. + */ + authenticator?: Address | undefined + /** Placeholder `Signer.address` (P-256 keys have no EVM address). */ + address?: Address | undefined +} + +/** + * Builds a {@link Signer} for a raw P-256 (secp256r1) configured actor. The + * signer's `sign` returns the P-256 authenticator `data` + * (`r || s || x || y || preHash`, 129 bytes) and carries the P-256 + * `authenticator`, so it can be passed straight to `to8130Account` / + * `signTransaction8130` to sign as a non-ECDSA actor. + * + * @example + * import { key, to8130Account, toP256Signer } from 'viem/experimental' + * + * const signer = toP256Signer({ privateKey }) + * const account = to8130Account({ + * signer, + * authenticator: signer.authenticator, + * userSalt, + * code, + * initialActors: [key.p256(signer.publicKey)], + * }) + */ +export function toP256Signer( + parameters: ToP256SignerParameters, +): Signer & { publicKey: { x: Hex; y: Hex } } { + const { privateKey } = parameters + const authenticator = parameters.authenticator ?? canonicalAuthenticators.p256 + const pub = P256.getPublicKey({ privateKey }) + const x = numberToHex(pub.x, { size: 32 }) + const y = numberToHex(pub.y, { size: 32 }) + + return { + address: parameters.address ?? zeroAddress, + authenticator, + publicKey: { x, y }, + async sign({ hash }) { + // Sign over the digest directly (no re-hash): the P-256 authenticator + // verifies `P256.verify(hash, r, s, x, y)`. + const { r, s } = P256.sign({ payload: hash, privateKey }) + return concatHex([ + numberToHex(r, { size: 32 }), + numberToHex(s, { size: 32 }), + x, + y, + '0x00', // preHash flag (digest already provided) + ]) + }, + } +} + +/** WebAuthn assertion metadata, as returned by viem's `toWebAuthnAccount`. */ +export type WebAuthnSignSource = { + /** Credential public key: 64-byte `x || y` hex, or `{ x, y }`. */ + publicKey: Hex | { x: Hex; y: Hex } + /** + * Produces a WebAuthn assertion over `hash`. Structurally compatible with the + * `sign` of viem's `toWebAuthnAccount` (`viem/account-abstraction`). + */ + sign: (parameters: { hash: Hex }) => Promise<{ + /** secp256r1 signature, `r || s` (64 bytes). */ + signature: Hex + webauthn: { + authenticatorData: Hex + clientDataJSON: string + challengeIndex: number + typeIndex: number + } + }> +} + +const webAuthnAuthParameters = [ + { + type: 'tuple', + components: [ + { name: 'r', type: 'bytes32' }, + { name: 's', type: 'bytes32' }, + { name: 'challengeIndex', type: 'uint256' }, + { name: 'typeIndex', type: 'uint256' }, + { name: 'authenticatorData', type: 'bytes' }, + { name: 'clientDataJSON', type: 'string' }, + ], + }, + { name: 'x', type: 'bytes32' }, + { name: 'y', type: 'bytes32' }, +] as const + +export type ToWebAuthnSignerParameters = { + /** + * Authenticator address that validates this passkey. Defaults to the canonical + * WebAuthn/passkey authenticator. + */ + authenticator?: Address | undefined + /** Placeholder `Signer.address` (passkeys have no EVM address). */ + address?: Address | undefined +} + +/** + * Builds a {@link Signer} for a WebAuthn (passkey / FIDO2) configured actor. The + * signer's `sign` runs the assertion and ABI-encodes the WebAuthn authenticator + * `data` (`(WebAuthnAuth, x, y)`), carrying the passkey `authenticator`. + * + * @example + * import { createWebAuthnCredential, toWebAuthnAccount } from 'viem/account-abstraction' + * import { key, to8130Account, toWebAuthnSigner } from 'viem/experimental' + * + * const credential = await createWebAuthnCredential({ name: 'vibes' }) + * const signer = toWebAuthnSigner(toWebAuthnAccount({ credential })) + * const account = to8130Account({ + * signer, + * authenticator: signer.authenticator, + * userSalt, + * code, + * initialActors: [key.passkey(signer.publicKey)], + * }) + */ +export function toWebAuthnSigner( + source: WebAuthnSignSource, + parameters: ToWebAuthnSignerParameters = {}, +): Signer & { publicKey: { x: Hex; y: Hex } } { + const authenticator = + parameters.authenticator ?? canonicalAuthenticators.passkey + const { x, y } = publicKeyToXY(source.publicKey) + + return { + address: parameters.address ?? zeroAddress, + authenticator, + publicKey: { x, y }, + async sign({ hash }) { + const { signature, webauthn } = await source.sign({ hash }) + const r = sliceHex(signature, 0, 32) + const s = sliceHex(signature, 32, 64) + return encodeAbiParameters(webAuthnAuthParameters, [ + { + r, + s, + challengeIndex: BigInt(webauthn.challengeIndex), + typeIndex: BigInt(webauthn.typeIndex), + authenticatorData: webauthn.authenticatorData, + clientDataJSON: webauthn.clientDataJSON, + }, + x, + y, + ]) + }, + } +} From 16b5bdf121924d7ddf1b47a49ef461e9ea666984 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 24 Jun 2026 14:34:17 -0400 Subject: [PATCH 20/96] fix(eip8168): align payer client with latest ERC-8168 spec Update the ERC-8168 module to the revised spec: offer-based terms with a top-level gasEstimate, the sponsored/sponsored_declined/token discriminants, the single -32000 payerRejected error envelope with a string data.code, and count/asset balance limits. Adds parsePayerError and refreshes the tests. --- .../eip8168/actions/sendSponsoredCalls.ts | 212 +++++++--- src/experimental/eip8168/client.ts | 106 ++--- src/experimental/eip8168/constants.ts | 82 ++-- src/experimental/eip8168/eip8168.test.ts | 175 +++++--- src/experimental/eip8168/index.ts | 43 +- src/experimental/eip8168/types.ts | 390 +++++++++++------- .../eip8168/utils/buildSponsoredCalls.ts | 186 ++++++--- .../eip8168/utils/parsePayerError.ts | 64 +++ 8 files changed, 867 insertions(+), 391 deletions(-) create mode 100644 src/experimental/eip8168/utils/parsePayerError.ts diff --git a/src/experimental/eip8168/actions/sendSponsoredCalls.ts b/src/experimental/eip8168/actions/sendSponsoredCalls.ts index 98e59209fb..81cebdd5f1 100644 --- a/src/experimental/eip8168/actions/sendSponsoredCalls.ts +++ b/src/experimental/eip8168/actions/sendSponsoredCalls.ts @@ -7,14 +7,48 @@ import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import { numberToHex } from '../../../utils/encoding/toHex.js' import type { To8130AccountReturnType } from '../../eip8130/accounts/to8130Account.js' import { prepareTransaction8130 } from '../../eip8130/actions/sendCalls.js' -import type { AaCall } from '../../eip8130/types/transaction.js' +import type { Address } from 'abitype' +import type { AaCall, AaCalls } from '../../eip8130/types/transaction.js' import type { PayerClient } from '../client.js' import type { GetTermsReturnType, + PayerRejectedData, SendTransactionReturnType, SignTransactionReturnType, } from '../types.js' -import { buildSponsoredCalls } from '../utils/buildSponsoredCalls.js' +import { + buildSponsoredCalls, + encodeTokenTransfer, +} from '../utils/buildSponsoredCalls.js' +import { parsePayerError } from '../utils/parsePayerError.js' + +/** + * Passed to {@link SendSponsoredCallsParameters.confirmRetry} before each + * re-sign. Carries the recoverable rejection (`reason`) and the concrete change + * the next attempt will make, so the wallet can show the user exactly what + * they're reconfirming (a higher token charge, or a higher gas limit). + */ +export type ResignRequest = { + /** The parsed payer rejection that triggered the re-sign. */ + reason: PayerRejectedData + /** 0-based index of the attempt that failed (the retry will be `attempt + 1`). */ + attempt: number +} & ( + | { + /** Phase-0 token transfer is being corrected (`PAYMENT_INSUFFICIENT`). */ + kind: 'requote' + token: Address + /** The new phase-0 transfer amount the user would pay. */ + paymentAmount: bigint + feeRecipient: Address + } + | { + /** Gas limit is being raised (`GAS_TOO_LOW`). */ + kind: 'gas' + /** The new `gasLimit` the next attempt will sign over. */ + gasLimit: bigint + } +) export type SendSponsoredCallsParameters = { /** The sending account (signs `sender_auth`). */ @@ -25,27 +59,48 @@ export type SendSponsoredCallsParameters = { calls: readonly AaCall[] /** * `"send"` (default) asks the payer to co-sign and submit; `"sign"` asks the - * payer to co-sign and return the transaction for the wallet to submit. + * payer to co-sign and return the transaction for the wallet to submit. A + * `"sign"` request is only made when the selected offer lists + * `payer_signTransaction` in its (additive) `methods`. */ mode?: 'send' | 'sign' | undefined /** Pre-fetched terms. When omitted, `payer_getTerms` is called. */ terms?: GetTermsReturnType | undefined - /** Token to pay with (token-payment terms). Defaults to the first option. */ + /** Prefer token payment with this token. Defaults to a selectable sponsored offer. */ token?: `0x${string}` | undefined /** Opaque app context forwarded to `payer_*` calls (e.g. `policyId`). */ context?: Record | undefined /** - * Override transaction expiry as an absolute Unix timestamp (seconds). - * When omitted the payer's recommended `terms.expiry` relative duration is - * applied: `current_time + terms.expiry`, clamped to `conditions.maxExpiry`. + * Override transaction expiry as an absolute Unix timestamp (seconds). When + * omitted, the selected offer's `conditions.maxExpiry` (a relative duration) + * is applied: `current_time + maxExpiry`. With no `maxExpiry`, expiry is `0` + * (no protocol-enforced lifetime) unless overridden here. */ expiry?: bigint | undefined - /** Override gas (defaults to the payer's `gasEstimate.gasLimit`). */ + /** Override gas (defaults to the terms' top-level `gasEstimate.gasLimit`). */ gas?: bigint | undefined maxFeePerGas?: bigint | undefined maxPriorityFeePerGas?: bigint | undefined nonceKey?: bigint | undefined nonceSequence?: bigint | undefined + /** + * Upper bound on re-signs after a recoverable payer rejection + * (`PAYMENT_INSUFFICIENT` → re-sign phase 0 from `requote`; `GAS_TOO_LOW` → + * raise `gasLimit` to `minGasLimit`), to avoid looping on a degraded payer. + * Only takes effect when `confirmRetry` approves each re-sign. Default `2`. + */ + retries?: number | undefined + /** + * Gate for every re-sign. Re-signing produces a NEW `sender_auth` over the + * corrected fields — and for an interactive sender (passkey/WebAuthn) a fresh + * user gesture — so a retry MUST be reconfirmed, never silent. Called with the + * proposed change ({@link ResignRequest}); return `true` to proceed. + * + * When omitted, the action does NOT retry: the rejection is thrown for the + * caller to handle (decode with `parsePayerError`). This makes "reconfirm + * before the second signature" the default — there is no auto-resign path. + */ + confirmRetry?: (request: ResignRequest) => boolean | Promise } export type SendSponsoredCallsReturnType = @@ -56,8 +111,9 @@ export type SendSponsoredCallsReturnType = * End-to-end ERC-8168 sponsored-transaction flow: * * 1. Fetch terms (`payer_getTerms`) unless provided. - * 2. Build the phase-0 token transfer / required calls + user calls. - * 3. Prepare the EIP-8130 transaction (nonce, gas from the payer's estimate). + * 2. Select an offer (and token choice) and build the phase-0 token transfer + + * user calls. + * 3. Prepare the EIP-8130 transaction (nonce, gas from the offer's estimate). * 4. Sign `sender_auth` with `payer` set and `payer_auth` left empty. * 5. Hand to the payer via `payer_sendTransaction` (submit) or * `payer_signTransaction` (co-sign only). @@ -80,6 +136,8 @@ export async function sendSponsoredCalls( mode = 'send', token, context, + retries = 2, + confirmRetry, } = parameters const chainId = client.chain?.id @@ -97,62 +155,124 @@ export async function sendSponsoredCalls( })) const built = buildSponsoredCalls({ terms, calls, token }) + const { option } = built + + // `methods` is additive over the implied required pair (getTerms + send). + // `payer_signTransaction` is available only when explicitly listed. + if (mode === 'sign' && !option.methods?.includes('payer_signTransaction')) + throw new BaseError( + 'Selected offer does not advertise `payer_signTransaction`; use `mode: "send"` or pick an offer whose `methods` includes it.', + ) - const gas = + // Recommended gas params are shared at the top level of the terms response. + const gasEstimate = terms.gasEstimate + const initialGas = parameters.gas ?? - (terms.gasEstimate ? hexToBigInt(terms.gasEstimate.gasLimit) : undefined) - if (gas === undefined) + (gasEstimate ? hexToBigInt(gasEstimate.gasLimit) : undefined) + if (initialGas === undefined) throw new BaseError( - 'Unable to determine `gas`: terms carry no `gasEstimate.gasLimit` and no `gas` override was provided.', + 'Unable to determine `gas`: the terms carry no top-level `gasEstimate.gasLimit` and no `gas` override was provided.', ) const maxFeePerGas = parameters.maxFeePerGas ?? - (terms.gasEstimate - ? hexToBigInt(terms.gasEstimate.maxFeePerGas) - : undefined) + (gasEstimate ? hexToBigInt(gasEstimate.maxFeePerGas) : undefined) const maxPriorityFeePerGas = parameters.maxPriorityFeePerGas ?? - (terms.gasEstimate - ? hexToBigInt(terms.gasEstimate.maxPriorityFeePerGas) - : undefined) - - // `terms.expiry` is a relative duration (seconds from now). Convert to an - // absolute on-chain timestamp, then clamp to [now+minExpiry, now+maxExpiry]. - let expiry = parameters.expiry - if (expiry === undefined) { - const now = BigInt(Math.floor(Date.now() / 1000)) - expiry = now + BigInt(terms.expiry) - if ( - terms.conditions?.maxExpiry !== undefined && - expiry > now + BigInt(terms.conditions.maxExpiry) - ) - expiry = now + BigInt(terms.conditions.maxExpiry) - if ( - terms.conditions?.minExpiry !== undefined && - expiry < now + BigInt(terms.conditions.minExpiry) - ) - expiry = now + BigInt(terms.conditions.minExpiry) + (gasEstimate ? hexToBigInt(gasEstimate.maxPriorityFeePerGas) : undefined) + + const maxGasLimit = option.conditions?.maxGasLimit + ? hexToBigInt(option.conditions.maxGasLimit) + : undefined + + // `conditions.maxExpiry` is a relative duration (seconds from now). The wallet + // sets the on-chain expiry to `now + maxExpiry`; the payer keeps `maxExpiry` + // short. Recomputed per attempt so a retry doesn't inherit a near-expiry + // window. A caller-supplied absolute `expiry` is used as-is. + const computeExpiry = (): bigint => { + if (parameters.expiry !== undefined) return parameters.expiry + const maxExpiry = option.conditions?.maxExpiry + return maxExpiry !== undefined + ? BigInt(Math.floor(Date.now() / 1000)) + BigInt(maxExpiry) + : 0n } + // Prepared once so the nonce stays stable across re-signs; only the phase-0 + // transfer (on `requote`) and `gas` (on `minGasLimit`) change between attempts. const transaction = await prepareTransaction8130(client, { account, calls: built.calls, - gas, + gas: initialGas, maxFeePerGas, maxPriorityFeePerGas, - expiry, + expiry: computeExpiry(), nonceKey: parameters.nonceKey, nonceSequence: parameters.nonceSequence, }) - - // Sender co-signs; the payer fills `payer_auth`. transaction.payer = built.payer - transaction.payerAuth = '0x' - const signedTransaction = await account.signTransaction(transaction) + let phases: AaCalls = built.calls + let gas = initialGas + + // Try, and on a recoverable rejection re-sign from the payer's `requote` / + // `minGasLimit` (ERC-8168 "Re-Quote on PAYMENT_INSUFFICIENT"). Re-signing + // mints a new `sender_auth` (and, for a passkey, a fresh user gesture), so a + // retry is GATED on `confirmRetry`: with no callback there is no auto-resign — + // the rejection is thrown for the caller to handle. + for (let attempt = 0; ; attempt++) { + transaction.calls = phases + transaction.gas = gas + transaction.expiry = computeExpiry() + transaction.payerAuth = '0x' + + const signedTransaction = await account.signTransaction(transaction) + + try { + if (mode === 'sign') + return await payerClient.signTransaction({ signedTransaction, context }) + return await payerClient.sendTransaction({ signedTransaction, context }) + } catch (error) { + if (attempt >= retries) throw error + const rejected = parsePayerError(error) + if (!rejected) throw error - if (mode === 'sign') - return payerClient.signTransaction({ signedTransaction, context }) - return payerClient.sendTransaction({ signedTransaction, context }) + // Resolve the concrete change the next attempt would sign over, so the + // user can reconfirm exactly what changes (a higher charge, or more gas). + let request: ResignRequest | undefined + let nextPhases = phases + let nextGas = gas + + if (rejected.code === 'PAYMENT_INSUFFICIENT' && rejected.requote) { + const { token: t, paymentAmount, feeRecipient } = rejected.requote + const to = feeRecipient ?? built.payer + const amount = hexToBigInt(paymentAmount) + nextPhases = [[encodeTokenTransfer({ token: t, to, amount })], calls] + request = { + kind: 'requote', + reason: rejected, + attempt, + token: t, + paymentAmount: amount, + feeRecipient: to, + } + } else if (rejected.code === 'GAS_TOO_LOW' && rejected.minGasLimit) { + const minGas = hexToBigInt(rejected.minGasLimit) + // Unsatisfiable (over the offer's cap) or non-progressing → give up. + if (maxGasLimit !== undefined && minGas > maxGasLimit) throw error + if (minGas <= gas) throw error + nextGas = minGas + request = { kind: 'gas', reason: rejected, attempt, gasLimit: minGas } + } + + // Not a recoverable condition we know how to re-sign for. + if (!request) throw error + + // Reconfirm before the second signature. No callback ⇒ no silent resign. + const approved = confirmRetry ? await confirmRetry(request) : false + if (!approved) throw error + + phases = nextPhases + gas = nextGas + } + } } diff --git a/src/experimental/eip8168/client.ts b/src/experimental/eip8168/client.ts index 05a75d0adb..062ba15860 100644 --- a/src/experimental/eip8168/client.ts +++ b/src/experimental/eip8168/client.ts @@ -2,14 +2,8 @@ import { createClient } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' import { http } from '../../clients/transports/http.js' import type { - FillTransactionParameters, - FillTransactionReturnType, - GetBalanceParameters, - GetBalanceReturnType, - GetCapabilitiesParameters, - GetCapabilitiesReturnType, - GetOptionsParameters, - GetOptionsReturnType, + GetSponsorshipBalanceParameters, + GetSponsorshipBalanceReturnType, GetTermsParameters, GetTermsReturnType, SendTransactionParameters, @@ -18,6 +12,30 @@ import type { SignTransactionReturnType, } from './types.js' +/** JSON-RPC schema for the ERC-8168 `payer_*` methods. */ +export type PayerRpcSchema = [ + { + Method: 'payer_getTerms' + Parameters: [GetTermsParameters] + ReturnType: GetTermsReturnType + }, + { + Method: 'payer_sendTransaction' + Parameters: [SendTransactionParameters] + ReturnType: SendTransactionReturnType + }, + { + Method: 'payer_signTransaction' + Parameters: [SignTransactionParameters] + ReturnType: SignTransactionReturnType + }, + { + Method: 'payer_getSponsorshipBalance' + Parameters: [GetSponsorshipBalanceParameters] + ReturnType: GetSponsorshipBalanceReturnType + }, +] + export type CreatePayerClientParameters = { /** Payer service endpoint URL (path-versioned, e.g. `https://payer.example.com/v1`). */ url?: string | undefined @@ -29,34 +47,33 @@ export type CreatePayerClientParameters = { } export type PayerClient = { - /** Sponsorship/token-payment terms for a transaction intent (pre-signature). */ + /** + * Payment offers (sponsorship / token payment) for a transaction intent, + * pre-signature. REQUIRED on every payer. + */ getTerms(parameters: GetTermsParameters): Promise - /** Co-sign a sender-signed EIP-8130 transaction and submit it. */ + /** + * Co-sign a sender-signed EIP-8130 transaction and submit it (returns the tx + * hash). REQUIRED on every payer. + */ sendTransaction( parameters: SendTransactionParameters, ): Promise - /** Co-sign a sender-signed EIP-8130 transaction and return it (no submit). */ + /** + * Co-sign a sender-signed EIP-8130 transaction and return the bytes without + * submitting. OPTIONAL — only call when the picked offer advertises it via + * `methods`. Returns JSON-RPC `-32601` when unimplemented. + */ signTransaction( parameters: SignTransactionParameters, ): Promise /** - * Fill a transaction intent into a complete unsigned EIP-8130 transaction - * with phases assembled and `payer` set. Wallet MUST verify before signing. - * (OPTIONAL per ERC-8168.) + * Standing, intent-free balances (sponsorship allowance / prepaid credit). + * OPTIONAL. */ - fillTransaction( - parameters: FillTransactionParameters, - ): Promise - /** Standing, intent-free balances (sponsorship allowance / prepaid credit). */ - getBalance(parameters: GetBalanceParameters): Promise - /** Ranked payer options for a transaction intent. */ - getOptions( - parameters: GetOptionsParameters, - ): Promise - /** Static, intent-free descriptor of what the payer accepts. */ - getCapabilities( - parameters?: GetCapabilitiesParameters, - ): Promise + getSponsorshipBalance( + parameters: GetSponsorshipBalanceParameters, + ): Promise } /** @@ -68,7 +85,7 @@ export type PayerClient = { * import { createPayerClient } from 'viem/experimental' * * const payer = createPayerClient({ url: 'https://payer.example.com/v1' }) - * const terms = await payer.getTerms({ chainId: '0x2105', from, calls }) + * const { options } = await payer.getTerms({ chainId: '0x2105', from, calls }) */ export function createPayerClient( parameters: CreatePayerClientParameters, @@ -77,7 +94,14 @@ export function createPayerClient( if (!url && !parameters.transport) throw new Error('`url` or `transport` is required.') - const { request } = createClient({ transport }) + const { request } = createClient< + Transport, + undefined, + undefined, + PayerRpcSchema + >({ + transport, + }) return { getTerms(params) { @@ -98,29 +122,11 @@ export function createPayerClient( params: [params], }) as Promise }, - fillTransaction(params) { + getSponsorshipBalance(params) { return request({ - method: 'payer_fillTransaction', + method: 'payer_getSponsorshipBalance', params: [params], - }) as Promise - }, - getBalance(params) { - return request({ - method: 'payer_getBalance', - params: [params], - }) as Promise - }, - getOptions(params) { - return request({ - method: 'payer_getOptions', - params: [params], - }) as Promise - }, - getCapabilities(params) { - return request({ - method: 'payer_getCapabilities', - params: [params ?? {}], - }) as Promise + }) as Promise }, } } diff --git a/src/experimental/eip8168/constants.ts b/src/experimental/eip8168/constants.ts index f3ac393161..ea59231d2c 100644 --- a/src/experimental/eip8168/constants.ts +++ b/src/experimental/eip8168/constants.ts @@ -1,33 +1,67 @@ /** - * ERC-8168 payer-service JSON-RPC error codes. These occupy the `-32000` to - * `-32099` range that JSON-RPC 2.0 reserves for implementation-defined server - * errors. They MUST NOT reuse the codes reserved for protocol errors - * (`-32600` Invalid Request, `-32601` Method not found, `-32602` Invalid - * params, `-32603` Internal error, `-32700` Parse error). + * Single JSON-RPC error code for every actionable payer rejection from + * `payer_sendTransaction` / `payer_signTransaction`: `PAYER_REJECTED`. It sits + * in the `-32000`..`-32099` range JSON-RPC 2.0 reserves for implementation + * server errors, and MUST NOT reuse the protocol codes (`-32600` Invalid + * Request, `-32601` Method not found, `-32602` Invalid params, `-32603` Internal + * error, `-32700` Parse error). * - * Payer services use these when rejecting requests; responses SHOULD include - * actionable `data`. + * The machine-readable reason rides in `error.data.code` as a + * {@link PayerErrorCode} string; `error.data` SHOULD carry actionable context. + * `payer_getTerms` never returns this error — a declined sponsorship rides on a + * `sponsored_declined` option carrying a {@link SponsorshipDeclineCode} instead. + */ +export const payerRejectedCode = -32000 as const + +/** + * Well-known `error.data.code` strings for a `PAYER_REJECTED` JSON-RPC error. + * A superset of {@link sponsorshipDeclineCode}: the decline vocabulary plus the + * co-sign-time validation failures. Open — payers MAY return their own strings + * and wallets MUST treat unknown codes as a generic failure (showing any + * human-readable `error.message`). */ export const payerErrorCode = { + /** Calls or contracts rejected by this sponsor's policy for this intent. */ + policyRejected: 'POLICY_REJECTED', + /** Sender rejected entirely (blocklisted, or missing a required attestation). */ + senderIneligible: 'SENDER_INELIGIBLE', + /** Sender's sponsorship budget or prepaid credit is depleted; check `balance.validFor`. */ + budgetExhausted: 'BUDGET_EXHAUSTED', + /** Per-sender cap reached (tx count or USD); check `balance.validFor`. */ + senderLimitReached: 'SENDER_LIMIT_REACHED', + /** Gas price exceeds the sponsor's per-tx cost ceiling; retry when gas drops. */ + gasExceedsLimit: 'GAS_EXCEEDS_LIMIT', + /** Payer is degraded (e.g. rate feed down); retry shortly. */ + temporarilyUnavailable: 'TEMPORARILY_UNAVAILABLE', /** Malformed or invalid EIP-8130 transaction. */ - invalidTransaction: -32001, + invalidTransaction: 'INVALID_TRANSACTION', /** Token in the phase-0 transfer not accepted by this payer. */ - unsupportedToken: -32002, - /** Terms from `payer_getTerms` have expired; re-request. */ - rateExpired: -32003, - /** Token transfer amount in phase 0 is below the required cost. */ - paymentInsufficient: -32004, - /** Transaction expiry does not satisfy payer conditions. */ - expiryOutOfBounds: -32005, - /** Payer policy rejected this transaction. */ - policyRejected: -32006, - /** Payer lacks ETH to cover gas. */ - payerBalanceInsufficient: -32007, - /** Sender is blocklisted for the specified token. */ - senderBlocklisted: -32008, - /** Sender's sponsorship budget or prepaid credit is depleted. */ - balanceExhausted: -32009, + unsupportedToken: 'UNSUPPORTED_TOKEN', + /** Phase-0 transfer no longer covers the cost (gas/rate moved, or the quote went stale); SHOULD carry a `requote`. */ + paymentInsufficient: 'PAYMENT_INSUFFICIENT', + /** Transaction `gasLimit` is below what the calls need to execute; SHOULD carry a `minGasLimit`. */ + gasTooLow: 'GAS_TOO_LOW', + /** Transaction `expiry` does not satisfy `conditions.maxExpiry`. */ + expiryOutOfBounds: 'EXPIRY_OUT_OF_BOUNDS', + /** Payer lacks ETH to cover gas at the protocol level. */ + payerBalanceInsufficient: 'PAYER_BALANCE_INSUFFICIENT', } as const export type PayerErrorCode = - (typeof payerErrorCode)[keyof typeof payerErrorCode] + | (typeof payerErrorCode)[keyof typeof payerErrorCode] + // biome-ignore lint/suspicious/noExplicitAny: keep the well-known union while staying open to custom strings + | (string & {}) + +/** + * The subset of {@link payerErrorCode} a payer may surface pre-signature, on a + * `sponsored_declined` option from `payer_getTerms`. The same string reappears + * as `error.data.code` if the equivalent co-sign call is attempted and fails. + */ +export const sponsorshipDeclineCode = { + policyRejected: 'POLICY_REJECTED', + senderIneligible: 'SENDER_INELIGIBLE', + budgetExhausted: 'BUDGET_EXHAUSTED', + senderLimitReached: 'SENDER_LIMIT_REACHED', + gasExceedsLimit: 'GAS_EXCEEDS_LIMIT', + temporarilyUnavailable: 'TEMPORARILY_UNAVAILABLE', +} as const diff --git a/src/experimental/eip8168/eip8168.test.ts b/src/experimental/eip8168/eip8168.test.ts index ef898bb4ba..238052ff2d 100644 --- a/src/experimental/eip8168/eip8168.test.ts +++ b/src/experimental/eip8168/eip8168.test.ts @@ -14,7 +14,10 @@ import { erc1167Bytecode } from '../eip8130/utils/proxy.js' import { sendSponsoredCalls } from './actions/sendSponsoredCalls.js' import { createPayerClient } from './client.js' import type { GetTermsReturnType } from './types.js' -import { buildSponsoredCalls } from './utils/buildSponsoredCalls.js' +import { + buildSponsoredCalls, + selectPaymentOption, +} from './utils/buildSponsoredCalls.js' const owner = privateKeyToAccount( '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', @@ -26,6 +29,7 @@ const account = to8130Account({ initialActors: [key.k1(owner.address)], }) const PAYER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' as const +const FEE_RECIPIENT = '0x90F79bf6EB2c4f870365E785982E1f101E93b906' as const const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const const userCalls = [ { to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' as const }, @@ -37,38 +41,43 @@ const gasEstimate = { maxPriorityFeePerGas: '0x59682F00', } as const -// Relative durations (seconds from now), as per ERC-8168 spec. -const EXPIRY_REL = 30 // payer-recommended transaction lifetime -const MAX_EXPIRY_REL = 60 // upper bound from conditions +const MAX_EXPIRY_REL = 60 // upper bound from conditions (relative seconds) const sponsoredTerms: GetTermsReturnType = { - sponsored: true, - expiry: EXPIRY_REL, - ttl: 300, gasEstimate, - conditions: { maxExpiry: MAX_EXPIRY_REL }, - payer: PAYER, - endpoint: 'https://payer.example.com/v1', + options: [ + { + kind: 'sponsored', + payer: PAYER, + // Required pair is implied; methods lists only OPTIONAL extras (none here). + ttl: 300, + conditions: { maxExpiry: MAX_EXPIRY_REL }, + provider: { name: 'My App' }, + }, + ], } const tokenTerms: GetTermsReturnType = { - sponsored: false, - expiry: EXPIRY_REL, - ttl: 300, gasEstimate, - tokenOptions: [ + options: [ { - token: USDC, - symbol: 'USDC', - decimals: 6, - paymentAmount: '0x30D40', - rate: { numerator: '0x7A308480', denominator: '0xDE0B6B3A7640000' }, - rateExpiry: MAX_EXPIRY_REL, + kind: 'token', + payer: PAYER, + methods: ['payer_signTransaction'], + ttl: 60, + conditions: { maxExpiry: MAX_EXPIRY_REL }, + tokens: [ + { + token: USDC, + symbol: 'USDC', + decimals: 6, + paymentAmount: '0x30D40', + feeRecipient: FEE_RECIPIENT, + rate: { numerator: '0x7A308480', denominator: '0xDE0B6B3A7640000' }, + }, + ], }, ], - conditions: { maxExpiry: MAX_EXPIRY_REL }, - payer: PAYER, - endpoint: 'https://payer.example.com/v1', } // Freeze time so expiry assertions are deterministic. @@ -83,6 +92,41 @@ afterEach(() => { vi.useRealTimers() }) +describe('selectPaymentOption', () => { + test('prefers a selectable sponsored offer', () => { + const { option, tokenChoice } = selectPaymentOption(sponsoredTerms) + expect(option.kind).toBe('sponsored') + expect(tokenChoice).toBeUndefined() + }) + + test('picks a token offer + choice when a token is requested', () => { + const { option, tokenChoice } = selectPaymentOption(tokenTerms, { + token: USDC, + }) + expect(option.kind).toBe('token') + expect(tokenChoice?.token).toBe(USDC) + }) + + test('skips declined sponsored entries', () => { + const { option } = selectPaymentOption({ + gasEstimate, + options: [ + { kind: 'sponsored_declined', code: 'BUDGET_EXHAUSTED' }, + ...tokenTerms.options, + ], + }) + expect(option.kind).toBe('token') + }) + + test('throws when only declined entries exist', () => { + expect(() => + selectPaymentOption({ + options: [{ kind: 'sponsored_declined', code: 'POLICY_REJECTED' }], + }), + ).toThrow() + }) +}) + describe('buildSponsoredCalls', () => { test('full sponsorship -> single phase, no transfer', () => { const built = buildSponsoredCalls({ @@ -94,37 +138,43 @@ describe('buildSponsoredCalls', () => { expect(built.paymentAmount).toBeUndefined() }) - test('token payment -> phase 0 transfer(payer, paymentAmount) + phase 1 user calls', () => { + test('token payment -> phase 0 transfer(feeRecipient, paymentAmount) + phase 1 user calls', () => { const built = buildSponsoredCalls({ terms: tokenTerms, calls: userCalls }) expect(built.paymentAmount).toBe(hexToBigInt('0x30D40')) + expect(built.feeRecipient).toBe(FEE_RECIPIENT) expect(built.calls).toHaveLength(2) const transfer = built.calls[0][0] expect(transfer.to).toBe(USDC) const decoded = decodeFunctionData({ abi: erc20Abi, data: transfer.data! }) expect(decoded.functionName).toBe('transfer') - expect(decoded.args[0]).toBe(PAYER) + expect(decoded.args[0]).toBe(FEE_RECIPIENT) expect(decoded.args[1]).toBe(hexToBigInt('0x30D40')) expect(built.calls[1]).toEqual(userCalls) }) - test('required calls are prepended to phase 0', () => { + test('token payment defaults destination to offer payer when feeRecipient absent', () => { const built = buildSponsoredCalls({ terms: { - ...sponsoredTerms, - requiredCalls: [{ to: PAYER, data: '0xdeadbeef' }], + options: [ + { + ...tokenTerms.options[0], + tokens: [{ ...tokenTerms.options[0].tokens[0], feeRecipient: undefined }], + } as (typeof tokenTerms.options)[number], + ], }, calls: userCalls, }) - expect(built.calls).toHaveLength(2) - expect(built.calls[0]).toEqual([{ to: PAYER, data: '0xdeadbeef' }]) + expect(built.feeRecipient).toBe(PAYER) + const decoded = decodeFunctionData({ + abi: erc20Abi, + data: built.calls[0][0].data!, + }) + expect(decoded.args[0]).toBe(PAYER) }) - test('throws when unsponsored with no token options', () => { + test('throws when there is no selectable offer', () => { expect(() => - buildSponsoredCalls({ - terms: { ...tokenTerms, tokenOptions: [] }, - calls: userCalls, - }), + buildSponsoredCalls({ terms: { options: [] }, calls: userCalls }), ).toThrow() }) }) @@ -137,7 +187,8 @@ describe('createPayerClient', () => { async request({ method, params }: { method: string; params: any }) { seen.push({ method, params }) if (method === 'payer_getTerms') return sponsoredTerms - if (method === 'payer_getBalance') return { balances: [], ttl: 30 } + if (method === 'payer_getSponsorshipBalance') + return { balances: [], ttl: 30 } throw new Error(`unexpected ${method}`) }, }), @@ -147,31 +198,12 @@ describe('createPayerClient', () => { from: owner.address, calls: userCalls, }) - expect(terms.payer).toBe(PAYER) + expect(terms.options[0].kind).toBe('sponsored') expect(seen[0].method).toBe('payer_getTerms') expect(seen[0].params[0].from).toBe(owner.address) - await payer.getBalance({ from: owner.address, kind: ['credit'] }) - expect(seen[1].method).toBe('payer_getBalance') - }) - - test('getOptions calls payer_getOptions', async () => { - const seen: { method: string }[] = [] - const payer = createPayerClient({ - transport: custom({ - async request({ method }: { method: string }) { - seen.push({ method }) - if (method === 'payer_getOptions') return { options: [] } - throw new Error(`unexpected ${method}`) - }, - }), - }) - await payer.getOptions({ - chainId: '0x1', - from: owner.address, - calls: userCalls, - }) - expect(seen[0].method).toBe('payer_getOptions') + await payer.getSponsorshipBalance({ from: owner.address, kind: ['credit'] }) + expect(seen[1].method).toBe('payer_getSponsorshipBalance') }) }) @@ -188,7 +220,7 @@ describe('sendSponsoredCalls (end-to-end)', () => { }) } - test('full sponsorship: sender-signs, payer relays; expiry = now + terms.expiry', async () => { + test('full sponsorship: sender-signs, payer relays; expiry = now + maxExpiry', async () => { let relayed: `0x${string}` | undefined const payer = createPayerClient({ transport: custom({ @@ -218,8 +250,8 @@ describe('sendSponsoredCalls (end-to-end)', () => { expect(parsed.calls).toHaveLength(1) // single phase, full sponsorship expect(parsed.calls?.[0]?.[0]?.to).toBe(userCalls[0].to.toLowerCase()) expect(parsed.gas).toBe(hexToBigInt(gasEstimate.gasLimit)) - // expiry = now + terms.expiry (relative), clamped to maxExpiry - expect(parsed.expiry).toBe(BigInt(FROZEN_NOW_S + EXPIRY_REL)) + // expiry = now + maxExpiry (relative) + expect(parsed.expiry).toBe(BigInt(FROZEN_NOW_S + MAX_EXPIRY_REL)) }) test('token payment: phase 0 transfer present; co-sign mode returns signed tx', async () => { @@ -239,6 +271,7 @@ describe('sendSponsoredCalls (end-to-end)', () => { payerClient: payer, calls: userCalls, mode: 'sign', + token: USDC, nonceSequence: 0n, }) expect(result).toHaveProperty('signedTransaction') @@ -248,4 +281,24 @@ describe('sendSponsoredCalls (end-to-end)', () => { expect(parsed.calls).toHaveLength(2) expect(parsed.calls?.[0]?.[0]?.to).toBe(USDC.toLowerCase()) }) + + test('mode "sign" throws when the selected offer omits payer_signTransaction', async () => { + const payer = createPayerClient({ + transport: custom({ + async request({ method }: { method: string }) { + if (method === 'payer_getTerms') return sponsoredTerms // methods: send only + throw new Error(`unexpected ${method}`) + }, + }), + }) + await expect( + sendSponsoredCalls(makeClient(), { + account, + payerClient: payer, + calls: userCalls, + mode: 'sign', + nonceSequence: 0n, + }), + ).rejects.toThrow() + }) }) diff --git a/src/experimental/eip8168/index.ts b/src/experimental/eip8168/index.ts index b39d023c1a..77f950690c 100644 --- a/src/experimental/eip8168/index.ts +++ b/src/experimental/eip8168/index.ts @@ -1,5 +1,6 @@ // biome-ignore lint/performance/noBarrelFile: entrypoint export { + type ResignRequest, type SendSponsoredCallsParameters, type SendSponsoredCallsReturnType, sendSponsoredCalls, @@ -8,37 +9,53 @@ export { type CreatePayerClientParameters, createPayerClient, type PayerClient, + type PayerRpcSchema, } from './client.js' -export { type PayerErrorCode, payerErrorCode } from './constants.js' +export { + type PayerErrorCode, + payerErrorCode, + payerRejectedCode, + sponsorshipDeclineCode, +} from './constants.js' export type { - FillTransactionParameters, - FillTransactionReturnType, - GetBalanceParameters, - GetBalanceReturnType, - GetCapabilitiesParameters, - GetCapabilitiesReturnType, - GetOptionsParameters, - GetOptionsReturnType, + BalanceLimit, + BaseOffer, + GetSponsorshipBalanceParameters, + GetSponsorshipBalanceReturnType, GetTermsParameters, GetTermsReturnType, + PaymentOption, PayerBalance, - PayerChainCapabilities, PayerConditions, PayerGasEstimate, - PayerOption, + PayerProvider, + PayerRejectedData, + PayerRequote, PayerRpcCall, - PayerSponsor, - PayerTokenOption, RefundPolicy, SendTransactionParameters, SendTransactionReturnType, SignTransactionParameters, SignTransactionReturnType, + SponsoredOffer, + SponsoredOfferDeclined, + SponsoredOfferSelectable, + SponsorshipDeclineCode, + TokenChoice, TokenCharged, + TokenPaymentOffer, } from './types.js' export { type BuildSponsoredCallsParameters, type BuildSponsoredCallsReturnType, buildSponsoredCalls, encodeTokenTransfer, + isDeclinedOffer, + isSelectableOffer, + isSponsoredOffer, + isTokenOffer, + type SelectPaymentOptionParameters, + type SelectPaymentOptionReturnType, + selectPaymentOption, } from './utils/buildSponsoredCalls.js' +export { parsePayerError } from './utils/parsePayerError.js' diff --git a/src/experimental/eip8168/types.ts b/src/experimental/eip8168/types.ts index d3a5d455c7..9ac27b1586 100644 --- a/src/experimental/eip8168/types.ts +++ b/src/experimental/eip8168/types.ts @@ -1,5 +1,6 @@ import type { Address } from 'abitype' import type { Hex } from '../../types/misc.js' +import type { PayerErrorCode } from './constants.js' /** A call in a payer RPC request (`value`/`data` optional). */ export type PayerRpcCall = { @@ -8,62 +9,183 @@ export type PayerRpcCall = { data?: Hex | undefined } -/** Shared balance shape returned by `payer_getTerms` and `payer_getBalance`. */ -export type PayerBalance = { - kind: 'sponsorship' | 'credit' - /** Remaining amount, atomic units of `asset`. */ - available: string - /** Total budget or cap, atomic units. */ - limit?: string | undefined - /** Amount used this period, atomic units. */ - spent?: string | undefined - /** Token contract address, `"native"`, or ISO-4217 code. */ - asset: string +/** + * One axis of a {@link PayerBalance} grant. A single-axis grant has one entry + * ("$5 of $10"); a compound grant has several ("$0.05 OR 3 txns weekly"). All + * amounts are unsigned hex, like other on-wire amounts. + */ +export type BalanceLimit = { + /** + * `"asset"` → amounts in base units of `asset`. `"count"` → integer counts of + * transactions / grants (`asset` / `symbol` / `decimals` are unused). + */ + unit: 'asset' | 'count' + /** Remaining within this axis. */ + available: Hex + /** Total budget or cap for this axis, same units as `available`. */ + limit?: Hex | undefined + /** Total lifetime spent on this axis (if tracked), same units as `available`. */ + spent?: Hex | undefined + /** REQUIRED when `unit: "asset"`: token address, `"native"`, or ISO-4217 code (e.g. `"USD"`). */ + asset?: string | undefined symbol?: string | undefined decimals?: number | undefined - /** When a periodic sponsorship budget next refills (seconds). */ - resetAt?: number | undefined - /** When the balance/credit expires (seconds). */ - expiry?: number | undefined - /** Source attribution (REQUIRED from aggregators). */ +} + +/** + * Shared balance shape returned by `payer_getTerms` (inline on a `sponsored` + * offer) and `payer_getSponsorshipBalance`. The grant's binding axis is the + * most-depleted `limits` entry (smallest `available / limit` fraction). + */ +export type PayerBalance = { + kind: 'sponsorship' | 'credit' + /** ≥1 co-active limits; every limit MUST hold for the next transaction. */ + limits: readonly BalanceLimit[] + /** + * Relative seconds the snapshot stays accurate. For a `sponsorship` this is + * when the periodic budget refills; for a `credit` this is when it lapses to + * zero. The wallet picks the verb from `kind` ("resets in" / "expires in"). + */ + validFor?: number | undefined + /** Source attribution (REQUIRED from aggregators): the service address. */ payer?: Address | undefined endpoint?: string | undefined name?: string | undefined } /** - * When/how unused gas is refunded. Presence means refunds are offered; - * absence means none are. + * Describes when unused gas is refunded to `from`. Its **presence** on a + * {@link TokenChoice} means refunds are offered for that choice; absence means + * `paymentAmount` is final. Settlement is an onchain ERC-20 transfer from the + * payer, gas paid by the payer, within `window` seconds — trust-based, with no + * onchain recourse. */ export type RefundPolicy = { - /** `"in_block"`: settled by the builder in the same block. `"deferred"`: settled later. */ - settlement: 'in_block' | 'deferred' - /** Deferred only: upper bound in seconds until settled (e.g. 86400 ≈ next day). */ - window?: number | undefined + /** Upper bound in seconds until the refund is settled (e.g. `86400` ≈ next day). */ + window: number } export type PayerGasEstimate = { + /** + * Gas for this offer's calls. A token-payment offer includes its phase-0 + * transfer in the budget, so its `gasLimit` exceeds an equivalent sponsored + * offer's. + */ gasLimit: Hex maxFeePerGas: Hex maxPriorityFeePerGas: Hex +} + +export type PayerConditions = { + /** Upper bound on the transaction's relative `expiry`, in seconds from now. */ + maxExpiry?: number | undefined + /** Binding gas cap. `gasEstimate.gasLimit` MUST be ≤ this when both are present. */ + maxGasLimit?: Hex | undefined /** - * Flat gas added on top of `gasLimit` to cover `payer_auth_cost` and payer - * operational overhead; included in reimbursement. - * Reimbursement is sized at `(gasLimit + overhead) × maxFeePerGas`. + * Binding cost cap in native wei on `gasLimit × maxFeePerGas` — the payer's + * per-tx ceiling. Lets a wallet preflight a cached offer against a fresh + * intent without round-tripping. The top-level `gasEstimate` MUST satisfy + * `gasLimit × maxFeePerGas ≤ maxCost` for every selectable offer. */ - overhead?: Hex | undefined + maxCost?: Hex | undefined } -export type PayerTokenOption = { +/** + * Display info for the party behind an offer: the sponsor for a `sponsored` + * offer, the swap/liquidity or payment provider for a `token` offer, the + * would-be sponsor for a `sponsored_declined` entry. The offer's `kind` gives + * the role. + */ +export type PayerProvider = { + name: string + /** Data URI (RFC-2397), min 96×96px; SVG icons MUST be rendered sandboxed. */ + icon?: string | undefined +} + +/** + * Stable string enum for why sponsorship was declined. Open: payers MAY return + * their own strings and wallets MUST treat unknown codes as opaque (display + * `reason` when present, otherwise treat as a generic decline). + */ +export type SponsorshipDeclineCode = + | 'POLICY_REJECTED' + | 'SENDER_INELIGIBLE' + | 'BUDGET_EXHAUSTED' + | 'SENDER_LIMIT_REACHED' + | 'GAS_EXCEEDS_LIMIT' + | 'TEMPORARILY_UNAVAILABLE' + // biome-ignore lint/suspicious/noExplicitAny: keep the well-known union while staying open to custom strings + | (string & {}) + +/** + * Fields shared by every selectable offer. Recommended gas params live ONCE at + * the top level (`GetTermsReturnType.gasEstimate`); per-offer binding caps live + * on `conditions`. + */ +export type BaseOffer = { + /** Co-signing account for THIS offer; the wallet sets it on the transaction. */ + payer: Address + /** Co-sign URL for this offer; OPTIONAL, defaults to the called URL. */ + endpoint?: string | undefined + /** + * Additive list of OPTIONAL offer-scoped `payer_*` methods beyond the + * always-implied required pair (`payer_getTerms`, `payer_sendTransaction`) — + * chiefly `payer_signTransaction`. The required pair MUST NOT be restated. + */ + methods?: readonly string[] | undefined + /** How long this quote (rates included) stays valid, in seconds. */ + ttl: number + conditions?: PayerConditions | undefined + provider?: PayerProvider | undefined +} + +/** A selectable full-sponsorship offer (the wallet MAY pick it). */ +export type SponsoredOfferSelectable = BaseOffer & { + kind: 'sponsored' + /** Present when this sponsorship draws from an allowance/credit; absent = fully paid by sponsor. */ + balance?: PayerBalance | undefined +} + +/** + * A declined sponsorship entry: informational only, never selectable. A sibling + * discriminant — consumers branch on `kind === "sponsored_declined"` directly. + */ +export type SponsoredOfferDeclined = { + kind: 'sponsored_declined' + code: SponsorshipDeclineCode + reason?: string | undefined + balance?: PayerBalance | undefined + /** + * Cost-cap diagnostic, present (and only meaningful) when `code` is + * `GAS_EXCEEDS_LIMIT`. Both values are native wei. + */ + gas?: + | { + /** Wei cost the payer estimated for this intent at current gas prices. */ + estimatedCost: Hex + /** Per-tx ceiling in wei. */ + maxCost: Hex + } + | undefined + provider?: PayerProvider | undefined +} + +/** A `sponsored` entry: either a selectable offer or a declined sibling. */ +export type SponsoredOffer = SponsoredOfferSelectable | SponsoredOfferDeclined + +/** One accepted token under a {@link TokenPaymentOffer}'s shared terms. */ +export type TokenChoice = { token: Address symbol: string decimals: number /** - * Token amount the wallet transfers to `payer` in phase 0, quoted at the - * gas cap including `gasEstimate.overhead`. Transferring this amount always - * covers the transaction while terms are valid. + * Token amount the wallet transfers in phase 0 to `feeRecipient` (or the + * offer's `payer` when absent), quoted against the response's top-level + * `gasEstimate` (worst-case gas cap). Part of the co-sign fingerprint. */ paymentAmount: Hex + /** Optional phase-0 destination; defaults to the offer's `payer`. Per-token by design. */ + feeRecipient?: Address | undefined rate: { /** Token atomic units... */ numerator: Hex @@ -71,29 +193,30 @@ export type PayerTokenOption = { denominator: Hex } /** - * Optional: native token price in USD, 6-decimal fixed-point hex integer - * (e.g. `0x75BCD15` ≈ $1234.56). Shares the same `rateExpiry`. + * Optional: the PAYMENT TOKEN's price in the response's `fiatCurrency`, + * 6-decimal fixed-point hex integer (e.g. `0xF4240` = 1.00 for a USD + * stablecoin priced in USD). Quoted on `token`, so fiat cost = + * `paymentAmount × fiatRate / (10^decimals × 10^6)`. Shares the offer's `ttl`. */ - usdRate?: Hex | undefined - rateDisplay?: string | undefined - /** Relative, seconds from current time. */ - rateExpiry: number + fiatRate?: Hex | undefined refund?: RefundPolicy | undefined } -export type PayerConditions = { - /** Relative, seconds from current time. */ - maxExpiry?: number | undefined - /** Relative, seconds from current time. */ - minExpiry?: number | undefined - maxGasLimit?: Hex | undefined +/** + * A token-payment offer: a single co-signer accepting one or more tokens for + * gas under shared terms. Per-token differences live on each {@link TokenChoice}. + * Entries MAY share `token` for distinct terms, but their phase-0 + * `(token, paymentAmount, feeRecipient)` fingerprints MUST be distinct across + * every token construction sharing this offer's `payer` in the response. + */ +export type TokenPaymentOffer = BaseOffer & { + kind: 'token' + /** ≥1 accepted tokens. */ + tokens: readonly TokenChoice[] } -export type PayerSponsor = { - name: string - icon?: string | undefined - reason?: string | undefined -} +/** A payment offer for an intent's gas: full sponsorship, decline, or token payment. */ +export type PaymentOption = SponsoredOffer | TokenPaymentOffer export type GetTermsParameters = { chainId: Hex @@ -101,30 +224,44 @@ export type GetTermsParameters = { calls: readonly PayerRpcCall[] gasLimit?: Hex | undefined preferredTokens?: readonly Address[] | undefined + /** + * OPTIONAL ISO-4217 code (e.g. `"USD"`, `"EUR"`) the wallet would like + * `fiatRate`s quoted in. The payer SHOULD honor it and MUST echo the currency + * it used in `GetTermsReturnType.fiatCurrency`. Absent means no preference. + */ + fiatCurrency?: string | undefined /** Opaque app context (e.g. `policyId`) from the `paymasterService` capability. */ context?: Record | undefined } export type GetTermsReturnType = { - sponsored: boolean - /** Relative, seconds from current time. Wallet computes on-chain expiry as `current_time + expiry`. */ - expiry: number - /** Lifetime of this quote in seconds from time of response. */ - ttl: number + /** + * Payment offers for this intent, best-first. An empty array means the payer + * has nothing to offer. + */ + options: readonly PaymentOption[] + /** + * RECOMMENDED gas params shared by every offer in this response. The wallet + * uses these to size the transaction; each offer's binding caps live on its + * `conditions`. A token offer's `paymentAmount` is quoted against this + * estimate's worst-case gas cap. + */ gasEstimate?: PayerGasEstimate | undefined - tokenOptions?: readonly PayerTokenOption[] | undefined - requiredCalls?: readonly PayerRpcCall[] | undefined - recipient?: Address | undefined - balance?: PayerBalance | undefined - conditions?: PayerConditions | undefined - payer: Address - endpoint: string - sponsor?: PayerSponsor | undefined + /** + * ISO-4217 code every `TokenChoice.fiatRate` in this response is quoted in. + * Echoes the requested `GetTermsParameters.fiatCurrency` when honored, else + * the currency the payer used (conventionally `"USD"`). REQUIRED whenever any + * offer carries a `fiatRate`. + */ + fiatCurrency?: string | undefined } export type TokenCharged = { token: Address + /** Gross phase-0 transfer (the selected choice's `paymentAmount`). */ amount: Hex + /** Payer's estimate of the refund to settle later, when a `refund` applies. */ + estimatedRefund?: Hex | undefined } export type SendTransactionParameters = { @@ -147,28 +284,9 @@ export type SignTransactionReturnType = { tokenCharged?: TokenCharged | undefined } -export type FillTransactionParameters = { - chainId: Hex - from: Address - calls: readonly PayerRpcCall[] - /** Omit to request full sponsorship; set to pay gas in this token. */ - paymentToken?: Address | undefined - /** Wallet-selected parallel-nonce lane; payer MUST honor if present. */ - nonceKey?: Hex | undefined - nonceSequence?: Hex | undefined - gasLimit?: Hex | undefined - context?: Record | undefined -} - -export type FillTransactionReturnType = { - /** EIP-8130 transaction with `payer` set, phases built, `payer_auth` empty. */ - unsignedTransaction: Hex - /** Terms this transaction was filled against; wallet MUST verify before signing. */ - terms: GetTermsReturnType -} - -export type GetBalanceParameters = { +export type GetSponsorshipBalanceParameters = { from: Address + /** Optional execution-chain filter. */ chainId?: Hex | undefined /** Aggregator: scope to a single service by address. */ payer?: Address | undefined @@ -178,78 +296,58 @@ export type GetBalanceParameters = { context?: Record | undefined } -export type GetBalanceReturnType = { +export type GetSponsorshipBalanceReturnType = { balances: readonly PayerBalance[] + /** How long this snapshot may be cached, in seconds. */ ttl: number } -export type PayerOption = { - type: 'sponsored' | 'conditional' - payer: Address - endpoint: string - sponsor?: PayerSponsor | undefined - tokenPayment?: +/** + * The corrected phase-0 transfer a payer would accept now, carried on a + * `PAYMENT_INSUFFICIENT` rejection. The wallet re-signs phase 0 with these + * values and resubmits WITHOUT calling `payer_getTerms` again — the token (and + * by default `feeRecipient`) match the construction already built; typically + * only `paymentAmount` changes. + */ +export type PayerRequote = { + token: Address + /** The sufficient amount now. */ + paymentAmount: Hex + /** Defaults to the offer's `payer`. */ + feeRecipient?: Address | undefined + rate?: { numerator: Hex; denominator: Hex } | undefined + /** Relative seconds this corrected amount holds. */ + ttl?: number | undefined +} + +/** + * The `error.data` payload on a `-32000` {@link payerRejectedCode} + * (`PAYER_REJECTED`) error from `payer_sendTransaction` / + * `payer_signTransaction`. Consumers MUST branch on the string `code` and MAY + * ignore the numeric JSON-RPC code (a single envelope). + */ +export type PayerRejectedData = { + code: PayerErrorCode + /** Human-readable detail for display/logs. */ + reason?: string | undefined + /** Context for `BUDGET_EXHAUSTED` / `SENDER_LIMIT_REACHED` (its `validFor` says when to retry). */ + balance?: PayerBalance | undefined + /** Present (and only meaningful) for `GAS_EXCEEDS_LIMIT`. Both values native wei. */ + gas?: | { - token: Address - symbol: string - decimals: number - estimatedAmount: Hex - rate: { numerator: Hex; denominator: Hex } - usdRate?: Hex | undefined - rateDisplay?: string | undefined - /** Relative, seconds from current time. */ - rateExpiry: number + /** Wei cost the payer estimated for this intent at current gas prices. */ + estimatedCost: Hex + /** Per-tx ceiling in wei. */ + maxCost: Hex } | undefined - conditions?: PayerConditions | undefined - priority?: number | undefined -} - -export type GetOptionsParameters = { - chainId: Hex - from: Address - calls: readonly PayerRpcCall[] - paymentToken?: Address | undefined - gasLimit?: Hex | undefined - context?: Record | undefined -} - -export type GetOptionsReturnType = { - options: readonly PayerOption[] -} - -export type PayerChainCapabilities = { - chainId: Hex - payer: Address - /** Omitted when served by a node acting as payer (the node's own RPC is the endpoint). */ - endpoint?: string | undefined - /** Phase 0 token destination; defaults to `payer`. */ - feeRecipient?: Address | undefined - /** Offers unconditional gas sponsorship, subject to policy. */ - fullSponsorship: boolean /** - * `"unrestricted"`: firm guarantee the payer will not reject based on call - * content; behaves as a pure financial exchange. - * `"filtered"`: payer MAY reject based on call content. + * Present (SHOULD) for `GAS_TOO_LOW`: the smallest `gasLimit` the payer's + * simulation needs for the calls to succeed. The wallet raises `gasLimit` to + * at least this, re-signs, and resubmits (bounded above by + * `conditions.maxGasLimit`). */ - callPolicy: 'unrestricted' | 'filtered' - acceptedTokens: readonly { - token: Address - symbol: string - decimals: number - }[] - /** Authoritative list of `payer_*` methods this responder implements. */ - methods: readonly string[] - refund?: RefundPolicy | undefined - conditions?: PayerConditions | undefined - sponsor?: PayerSponsor | undefined -} - -export type GetCapabilitiesParameters = { - chainId?: Hex | undefined -} - -export type GetCapabilitiesReturnType = { - chains: readonly PayerChainCapabilities[] - ttl: number + minGasLimit?: Hex | undefined + /** Present (SHOULD) for `PAYMENT_INSUFFICIENT`: the corrected phase-0 transfer. */ + requote?: PayerRequote | undefined } diff --git a/src/experimental/eip8168/utils/buildSponsoredCalls.ts b/src/experimental/eip8168/utils/buildSponsoredCalls.ts index 10eb8ae664..489d21909d 100644 --- a/src/experimental/eip8168/utils/buildSponsoredCalls.ts +++ b/src/experimental/eip8168/utils/buildSponsoredCalls.ts @@ -6,8 +6,11 @@ import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import type { AaCall, AaCalls } from '../../eip8130/types/transaction.js' import type { GetTermsReturnType, - PayerRpcCall, - PayerTokenOption, + PaymentOption, + SponsoredOfferDeclined, + SponsoredOfferSelectable, + TokenChoice, + TokenPaymentOffer, } from '../types.js' /** Encodes an ERC-20 `transfer(to, amount)` call. */ @@ -26,8 +29,98 @@ export function encodeTokenTransfer(parameters: { } } -function toAaCall(call: PayerRpcCall): AaCall { - return { to: call.to, data: call.data ?? '0x' } +/** A `token` offer. */ +export function isTokenOffer(option: PaymentOption): option is TokenPaymentOffer { + return option.kind === 'token' +} + +/** A declined sponsorship entry (`sponsored_declined`, never selectable). */ +export function isDeclinedOffer( + option: PaymentOption, +): option is SponsoredOfferDeclined { + return option.kind === 'sponsored_declined' +} + +/** A selectable full-sponsorship offer (`sponsored`). */ +export function isSponsoredOffer( + option: PaymentOption, +): option is SponsoredOfferSelectable { + return option.kind === 'sponsored' +} + +/** Any offer the wallet may build a transaction from (excludes declined entries). */ +export function isSelectableOffer( + option: PaymentOption, +): option is SponsoredOfferSelectable | TokenPaymentOffer { + return isSponsoredOffer(option) || isTokenOffer(option) +} + +export type SelectPaymentOptionParameters = { + /** Prefer token payment, optionally constrained to this token, over sponsorship. */ + token?: Address | undefined +} + +export type SelectPaymentOptionReturnType = { + option: SponsoredOfferSelectable | TokenPaymentOffer + /** The chosen token, when `option` is a `token` offer. */ + tokenChoice?: TokenChoice | undefined +} + +/** + * Picks one selectable offer (and, for a token offer, one `TokenChoice`) from a + * `payer_getTerms` result, per ERC-8168: + * + * - When `token` is set, picks the first `token` offer whose `tokens` contains a + * matching choice (by `token`). + * - Otherwise prefers the first selectable `sponsored` offer; falling back to + * the first `token` offer and its first choice. + * + * `sponsored_declined` entries are never selected. + */ +export function selectPaymentOption( + terms: GetTermsReturnType, + parameters: SelectPaymentOptionParameters = {}, +): SelectPaymentOptionReturnType { + const { token } = parameters + const options = terms.options ?? [] + + const matchChoice = (offer: TokenPaymentOffer): TokenChoice | undefined => { + const choices = offer.tokens ?? [] + if (token) + return choices.find( + (c) => c.token.toLowerCase() === token.toLowerCase(), + ) + return choices[0] + } + + // Explicit token request: only a matching token offer satisfies it. + if (token) { + for (const option of options) { + if (!isTokenOffer(option)) continue + const tokenChoice = matchChoice(option) + if (tokenChoice) return { option, tokenChoice } + } + throw new BaseError( + `No token offer matches the requested token "${token}".`, + ) + } + + // Otherwise prefer a selectable sponsored offer. + const sponsored = options.find(isSponsoredOffer) + if (sponsored) return { option: sponsored } + + // Fall back to the first token offer. + const tokenOffer = options.find(isTokenOffer) + if (tokenOffer) { + const tokenChoice = matchChoice(tokenOffer) + if (!tokenChoice) + throw new BaseError('Token offer carries no `tokens` to pay with.') + return { option: tokenOffer, tokenChoice } + } + + throw new BaseError( + 'Terms carry no selectable offer (only `sponsored_declined` entries, or an empty `options` array).', + ) } export type BuildSponsoredCallsParameters = { @@ -35,20 +128,21 @@ export type BuildSponsoredCallsParameters = { terms: GetTermsReturnType /** The user's intended calls (placed in the final phase). */ calls: readonly AaCall[] - /** - * Token to pay with when not fully sponsored. Defaults to the first - * `tokenOptions` entry (or `preferredToken` if it matches an option). - */ + /** Prefer token payment with this token over sponsorship. */ token?: Address | undefined } export type BuildSponsoredCallsReturnType = { - /** Payer address to set on the transaction. */ + /** Payer address (the selected offer's `payer`) to set on the transaction. */ payer: Address - /** Ordered call phases (phase 0 = sponsorship requirements, last = user calls). */ + /** Ordered call phases (phase 0 = token transfer when paying, last = user calls). */ calls: AaCalls - /** The selected token option, when paying with a token. */ - tokenOption?: PayerTokenOption | undefined + /** The selected payment offer. */ + option: SponsoredOfferSelectable | TokenPaymentOffer + /** The selected token choice, when paying with a token. */ + tokenChoice?: TokenChoice | undefined + /** Phase-0 transfer destination (`feeRecipient` ?? offer `payer`), when paying with a token. */ + feeRecipient?: Address | undefined /** Phase-0 token transfer amount (`paymentAmount`), when paying with a token. */ paymentAmount?: bigint | undefined } @@ -57,54 +151,44 @@ export type BuildSponsoredCallsReturnType = { * Constructs the EIP-8130 `calls` phases and `payer` from `payer_getTerms` * output, per the ERC-8168 phase table: * - * | Model | Phase 0 | Last phase | + * | Offer `kind` | Phase 0 | Phase 1 | * |---|---|---| - * | Full sponsorship | — | user calls | - * | Token payment | `transfer(payer, paymentAmount)` | user calls | - * | Required calls | required calls | user calls | - * | Token + required | transfer + required calls | user calls | + * | `sponsored` | — | user calls | + * | `token` | `transfer(choice.feeRecipient ?? offer.payer, choice.paymentAmount)` | user calls | * - * Balance-funded sponsorship uses the full-sponsorship construction (no phase-0 - * transfer); the payer is reimbursed off-chain from the sender's budget/credit. + * Balance-funded sponsorship (a `sponsored` offer carrying a `balance`) uses the + * full-sponsorship construction (no phase 0); the payer is reimbursed off-chain + * from the sender's allowance/credit. */ export function buildSponsoredCalls( parameters: BuildSponsoredCallsParameters, ): BuildSponsoredCallsReturnType { - const { terms, calls } = parameters + const { terms, calls, token } = parameters - const phase0: AaCall[] = [] - let tokenOption: PayerTokenOption | undefined - let paymentAmount: bigint | undefined + const { option, tokenChoice } = selectPaymentOption(terms, { token }) - if (!terms.sponsored) { - const options = terms.tokenOptions ?? [] - if (options.length === 0) - throw new BaseError( - 'Terms are not sponsored and carry no `tokenOptions` to pay with.', - ) - tokenOption = parameters.token - ? options.find( - (o) => o.token.toLowerCase() === parameters.token!.toLowerCase(), - ) - : options[0] - if (!tokenOption) - throw new BaseError( - `No token option matches the requested token "${parameters.token}".`, - ) - paymentAmount = hexToBigInt(tokenOption.paymentAmount) - phase0.push( - encodeTokenTransfer({ - token: tokenOption.token, - to: terms.payer, - amount: paymentAmount, - }), - ) - } + if (option.kind === 'sponsored') + return { payer: option.payer, calls: [calls], option } - if (terms.requiredCalls?.length) - phase0.push(...terms.requiredCalls.map(toAaCall)) + if (!tokenChoice) + throw new BaseError('Selected token offer resolved no `TokenChoice`.') - const phases: AaCalls = phase0.length > 0 ? [phase0, calls] : [calls] + const feeRecipient = tokenChoice.feeRecipient ?? option.payer + const paymentAmount = hexToBigInt(tokenChoice.paymentAmount) + const phase0: AaCall[] = [ + encodeTokenTransfer({ + token: tokenChoice.token, + to: feeRecipient, + amount: paymentAmount, + }), + ] - return { payer: terms.payer, calls: phases, tokenOption, paymentAmount } + return { + payer: option.payer, + calls: [phase0, calls], + option, + tokenChoice, + feeRecipient, + paymentAmount, + } } diff --git a/src/experimental/eip8168/utils/parsePayerError.ts b/src/experimental/eip8168/utils/parsePayerError.ts new file mode 100644 index 0000000000..42d7b61029 --- /dev/null +++ b/src/experimental/eip8168/utils/parsePayerError.ts @@ -0,0 +1,64 @@ +import { payerRejectedCode } from '../constants.js' +import type { PayerRejectedData } from '../types.js' + +/** + * Extracts the {@link PayerRejectedData} payload from a thrown payer error. + * + * A `payer_sendTransaction` / `payer_signTransaction` rejection surfaces as the + * single `-32000` ({@link payerRejectedCode}) JSON-RPC envelope, with the + * machine-readable condition and any actionable detail (`requote`, + * `minGasLimit`, `gas`, `balance`) on `error.data`. viem wraps that response in + * an `RpcRequestError` (carrying the numeric `code` and raw `data`) nested in + * the thrown error's `cause` chain. + * + * Walks that chain and returns the first `data`-shaped payload whose `code` is a + * string (the canonical condition name) — distinguishing it from the numeric + * JSON-RPC envelope code. Returns `undefined` for non-payer errors so callers + * can rethrow. + * + * @example + * try { + * await payerClient.sendTransaction({ signedTransaction }) + * } catch (error) { + * const rejected = parsePayerError(error) + * if (rejected?.code === 'PAYMENT_INSUFFICIENT' && rejected.requote) { + * // re-sign phase 0 from `rejected.requote` and resubmit + * } + * } + */ +export function parsePayerError( + error: unknown, +): PayerRejectedData | undefined { + const seen = new Set() + let current: unknown = error + + while (current && typeof current === 'object' && !seen.has(current)) { + seen.add(current) + + const node = current as { + code?: unknown + data?: unknown + cause?: unknown + } + + // The JSON-RPC `error.data` rides on the carrier (e.g. `RpcRequestError`); + // its `code` is the canonical string condition, vs. the numeric envelope. + const data = node.data + if ( + data && + typeof data === 'object' && + typeof (data as { code?: unknown }).code === 'string' + ) + return data as PayerRejectedData + + // A directly-thrown payload (no viem wrapping) is itself the data. + if (typeof node.code === 'string') return current as PayerRejectedData + + current = node.cause + } + + return undefined +} + +/** The numeric envelope every payer rejection rides on. Re-exported for routing. */ +export { payerRejectedCode } From 85e5ae3f7aaaba46848724312302494741e72518 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 24 Jun 2026 20:51:55 -0400 Subject: [PATCH 21/96] feat(eip8130): add getConfigSequence8130 action Reads the local and multichain config-change sequences from the AccountConfiguration system contract. Always supply the active chain's deployment address so vibenet and Base Sepolia use the correct contract rather than a hardcoded constant. --- .../eip8130/actions/getConfigSequence8130.ts | 67 +++++++++++++++++++ src/experimental/eip8130/index.ts | 5 ++ 2 files changed, 72 insertions(+) create mode 100644 src/experimental/eip8130/actions/getConfigSequence8130.ts diff --git a/src/experimental/eip8130/actions/getConfigSequence8130.ts b/src/experimental/eip8130/actions/getConfigSequence8130.ts new file mode 100644 index 0000000000..84a775d2a2 --- /dev/null +++ b/src/experimental/eip8130/actions/getConfigSequence8130.ts @@ -0,0 +1,67 @@ +import type { Address } from 'abitype' + +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import { readContract } from '../../../actions/public/readContract.js' +import { accountConfigurationAbi } from '../abis.js' + +export type GetConfigSequence8130Parameters = { + /** The EIP-8130 AccountConfiguration system contract address. */ + accountConfiguration: Address + /** The account whose local config sequence to read. */ + account: Address +} + +export type GetConfigSequence8130ReturnType = { + /** + * The local (single-chain) change sequence. This is the NEXT sequence + * number to use when signing an `ActorChange` — it equals the count of + * config changes that have been applied to the account on this chain. + */ + local: bigint + /** + * The multi-chain change sequence (cross-chain actor changes via EIP-8130 + * multi-chain signing). + */ + multichain: bigint +} + +/** + * Reads the current config-change sequences for an EIP-8130 account from the + * `AccountConfiguration` system contract. Use the returned `local` value as + * the `sequence` parameter when building an `AccountChange` — it is the NEXT + * expected sequence, not the last one used. + * + * Calling this before signing any owner change (authorize / revoke) prevents + * sequence-mismatch rejections caused by a stale local cache. + * + * @example + * const { local } = await getConfigSequence8130(client, { + * accountConfiguration: deployment.accountConfiguration, + * account: accountAddress, + * }) + * // Use `local` as the sequence for the next AccountChange. + */ +export async function getConfigSequence8130< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetConfigSequence8130Parameters, +): Promise { + const { accountConfiguration, account } = parameters + + const result = await readContract(client, { + address: accountConfiguration, + abi: accountConfigurationAbi, + functionName: 'getChangeSequences', + args: [account], + }) + + return { + local: result.local, + multichain: result.multichain, + } +} diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index a2022f1e00..c41401c71d 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -23,6 +23,11 @@ export { type EstimateGas8130ReturnType, estimateGas8130, } from './actions/estimateGas8130.js' +export { + type GetConfigSequence8130Parameters, + type GetConfigSequence8130ReturnType, + getConfigSequence8130, +} from './actions/getConfigSequence8130.js' export { type GetTransactionCount8130Parameters, type GetTransactionCount8130ReturnType, From 6343bf584e0b439a1637f2b2b1137a43068acf32 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 25 Jun 2026 13:38:59 -0400 Subject: [PATCH 22/96] fix(eip8130): correct payer hash and update canonical deployment addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getPayerSignatureHash8130: include `payer` field in the RLP body to match the Rust node's payer_signature_hash (which encodes all fields including the payer slot). Previously the payer field was excluded, producing a hash mismatch that caused "actor is not bound" errors. - deployments: replace stale per-chain address objects with a single canonicalEip8130Deployment derived from solc 0.8.33 via Deploy.s.sol. Both baseSepoliaDeployment and vibenetDevnetDeployment now reference the same canonical set (accountConfiguration 0xC659…, P256 0x3AE1…, etc.). The execution client enshrines these addresses; using any other accountConfiguration causes "create address mismatch" on account creation. --- src/experimental/eip8130/deployments.ts | 72 ++++++++----------- .../eip8130/utils/hashTransaction.ts | 17 +++-- 2 files changed, 41 insertions(+), 48 deletions(-) diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index 9347ec3aab..dc219d4f76 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -48,59 +48,31 @@ export type Eip8130Deployment = { } } -/** EIP-8130 deployment on Base Sepolia (chain id `84532`). */ -export const baseSepoliaDeployment = { - accountConfiguration: '0xAff8A7A86605D61197C1b98630d93B9d9702afb5', - accounts: { - default: '0xD67D6ae50521A0ea9Aa1e174C536F346E87a1903', - defaultHighRate: '0xED15A3590597120f11F320801291f4d7A38156bD', - erc4337: '0xc0072312BB152278C0CaEb31d034a051ed4a86b9', - upgradeable: '0x0c5daDDb66Af134D3FD4e69874F665d78b3a4533', - }, - authenticators: { - k1: '0x0000000000000000000000000000000000000001', - p256: '0x3AE129D846CD1CAf0369b4Caa56c188E18E11B15', - webAuthn: '0x1CB75BE39Fb950202BF4239010534B86EdA66c31', - delegate: '0x4C4D27e56087797Feca62262417d57be4e30dD1F', - alwaysValid: '0x520fBA4840729CB57b3Dc7B40D548AcF354DBA25', - }, - policies: { - manager: '0x9736ad211D56164bEBA5Fa486c6dfA77E586a7fE', - sessionPolicy: '0x1c30e92C01B242a748625330777d8A7B5E51EAAE', - }, -} as const satisfies Eip8130Deployment - /** - * EIP-8130 deployment for the Base "vibenet" devnet (chain id `84538453`). + * Canonical EIP-8130 deployment addresses, derived deterministically from + * `base/eip-8130` contract bytecode compiled with **solc 0.8.33** via + * `Deploy.s.sol`. These addresses are identical on every chain that runs the + * same bytecode (Base Sepolia, vibenet devnet, mainnet when live). * - * This devnet runs EIP-8130 **natively**, so the `accountConfiguration` and the - * native account/authenticator addresses are the ones the execution client - * enshrines (`base` `Eip8130Contracts`) — *not* the addresses of the example - * contracts deployed from `base/eip-8130`. Account-address derivation and the - * native authorization path read this enshrined `accountConfiguration` - * (`0xb019…`); using any other value derives a different address and the create - * transaction's sender fails to authorize. + * `accountConfiguration` is enshrined in the execution client; using any other + * value derives a different account address and the create transaction fails. * - * The EVM-execution-only contracts the client does not enshrine (`erc4337` and - * `upgradeable` account implementations, and the example `policies`) are taken - * from the `base/eip-8130` devnet broadcast; they are only relevant to the - * ERC-4337 / policy-gated execution path, not native `AA_TX_TYPE` inclusion. + * When the `base/eip-8130` contracts are recompiled (e.g. Solidity upgrade or + * bytecode change), all addresses must be re-derived and this object updated. */ -export const vibenetDevnetDeployment = { - // Enshrined by the execution client (native path) — verified on-devnet. - accountConfiguration: '0xb0198a714872EE5bfDF829e7986DB5C5899a6b50', +const canonicalEip8130Deployment = { + accountConfiguration: '0xC6595B992AF49099B476690d4D7CAb7D1890388F', accounts: { - default: '0x124b52d5D57a76ed064c414975beA11Beffe0251', - defaultHighRate: '0x13dD0F222cCF60B7C08a95C2d1FcC85A38DD675D', - // EVM-execution-only (base/eip-8130 devnet broadcast). - erc4337: '0xfd054f275750DA23893aECaDE788825f8A3F434C', - upgradeable: '0x7Cf83aB369Fefabe2C9cb6D7C9DE816cc4f68Eaa', + default: '0xca8D7419FEC024a5CEDB8D427615f3A74E3ebA6b', + defaultHighRate: '0x9bB1a51927A7B8Fc433956E1a417DB9f97465527', + erc4337: '0xe8e6317b1440ead4a3fc93e17cee77324a509923', + upgradeable: '0x7Cf83aB369Fefabe2C9cb6D7C9DE816cc4f68Ea', }, authenticators: { k1: '0x0000000000000000000000000000000000000001', p256: '0x3AE129D846CD1CAf0369b4Caa56c188E18E11B15', webAuthn: '0x1CB75BE39Fb950202BF4239010534B86EdA66c31', - delegate: '0xE67D299Ff3F0a185398B6C5a28998696969265d7', + delegate: '0xCc81575121084c3538773478577e04CA7e9b35B1', alwaysValid: '0x520fBA4840729CB57b3Dc7B40D548AcF354DBA25', }, policies: { @@ -109,6 +81,20 @@ export const vibenetDevnetDeployment = { }, } as const satisfies Eip8130Deployment +/** EIP-8130 deployment on Base Sepolia (chain id `84532`). */ +export const baseSepoliaDeployment = canonicalEip8130Deployment + +/** + * EIP-8130 deployment for the Base "vibenet" devnet (chain id `84538453`). + * + * The devnet runs EIP-8130 **natively** and the execution client enshrines the + * canonical CREATE2 addresses (solc 0.8.33 via `Deploy.s.sol`), identical to + * Base Sepolia and every other supported chain. Using any other + * `accountConfiguration` derives a different account address and create + * transactions fail with "create address mismatch". + */ +export const vibenetDevnetDeployment = canonicalEip8130Deployment + /** Known EIP-8130 deployments, keyed by chain id. */ export const eip8130Deployments: Record = { 84532: baseSepoliaDeployment, diff --git a/src/experimental/eip8130/utils/hashTransaction.ts b/src/experimental/eip8130/utils/hashTransaction.ts index e9d3e0abc9..a7847b9341 100644 --- a/src/experimental/eip8130/utils/hashTransaction.ts +++ b/src/experimental/eip8130/utils/hashTransaction.ts @@ -69,17 +69,21 @@ export type GetPayerSignatureHash8130ErrorType = | ErrorType /** - * Computes the EIP-8130 **payer** signature hash — all transaction fields - * through `calls`, excluding `payer`, `sender_auth`, and `payer_auth`: + * Computes the EIP-8130 **payer** signature hash — the full transaction body + * including the `payer` field, but excluding `sender_auth` and `payer_auth`: * * ``` * keccak256(AA_PAYER_TYPE || rlp([ * chain_id, from, nonce_key, nonce_sequence, expiry, * max_priority_fee_per_gas, max_fee_per_gas, gas_limit, - * account_changes, calls + * account_changes, calls, metadata, payer * ])) * ``` * + * This matches the Rust node's `payer_signature_hash` which encodes all fields + * of the transaction body including the `payer` slot (mirrors `rlp_encode_fields` + * in `TxEip8130`). + * * @remarks * `from` MUST be the **resolved** sender address. In the EOA path (`from` * omitted from the wire format) the recovered sender address MUST be set on @@ -89,9 +93,12 @@ export type GetPayerSignatureHash8130ErrorType = export function getPayerSignatureHash8130( parameters: GetSignatureHash8130Parameters, ): GetSignatureHash8130ReturnType { - const { to = 'hex' } = parameters + const { to = 'hex', payer } = parameters const hash = keccak256( - concatHex([aaPayerType, toRlp(toTransactionBody(parameters))]), + concatHex([ + aaPayerType, + toRlp([...toTransactionBody(parameters), payer ?? '0x']), + ]), ) if (to === 'bytes') return hexToBytes(hash) as GetSignatureHash8130ReturnType From 948b869ed55966caa812bdb74d7c0fac121b585f Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 25 Jun 2026 13:42:16 -0400 Subject: [PATCH 23/96] feat(eip8130): getTransaction8130, waitForTransactionReceipt8130, type 0x7b formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getTransaction8130 action: fetches an EIP-8130 tx by hash, flattens the nested `tx` body returned by the node, injects the request hash (absent from the RPC response), and returns a fully-typed Transaction8130 object with all EIP-8130 fields. - Add waitForTransactionReceipt8130 action: polls until an EIP-8130 tx is mined using getTransactionReceipt8130 (which surfaces the EIP-8130-specific receipt fields). Skips the replacement-detection path since EIP-8130 uses 2D nonces. - getTransaction: flatten type 0x7b responses inline so the standard path also works for EIP-8130 txs (injects hash, maps nested body fields to the expected flat shape). - waitForTransactionReceipt: skip candidates with no hash — can occur when a block contains a type 0x7b tx that the generic parser can't fully deserialize. - formatters/transaction: register '0x7b' -> 'eip8130' in transactionType. - Add vibenet 6-part integration test covering: EOA plain tx, EOA owner change, EOA with new key, smart account create, smart account tx, and smart account key rotation. --- scripts/eip8130/vibenet6PartTest.test.ts | 259 ++++++++++++++++++ src/actions/public/getTransaction.ts | 41 +++ .../public/waitForTransactionReceipt.ts | 7 +- .../eip8130/actions/getTransaction8130.ts | 152 ++++++++++ .../actions/waitForTransactionReceipt8130.ts | 68 +++++ src/experimental/eip8130/index.ts | 11 + src/utils/formatters/transaction.ts | 1 + 7 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 scripts/eip8130/vibenet6PartTest.test.ts create mode 100644 src/experimental/eip8130/actions/getTransaction8130.ts create mode 100644 src/experimental/eip8130/actions/waitForTransactionReceipt8130.ts diff --git a/scripts/eip8130/vibenet6PartTest.test.ts b/scripts/eip8130/vibenet6PartTest.test.ts new file mode 100644 index 0000000000..083cc26d5c --- /dev/null +++ b/scripts/eip8130/vibenet6PartTest.test.ts @@ -0,0 +1,259 @@ +/** + * 6-part EIP-8130 native transaction test against the local vibenet devnet. + * + * Tests (in order): + * 1. EOA plain tx — delegate + send ETH (EIP-7702 style) + * 2. EOA owner change — authorize a second K1 key + * 3. EOA with new key — send tx signed by the newly authorized key + * 4. Smart account create — createAccount + send ETH + * 5. Smart account tx — follow-up send using same account + * 6. Smart account rotate — authorize a new K1 key + * + * Run: + * npx vitest run --config test/vitest.eip8130.config.ts \ + * scripts/eip8130/vibenet6PartTest.test.ts + */ + +import { describe, expect, test } from 'vitest' +import { generatePrivateKey, privateKeyToAccount } from '../../src/accounts/index.js' +import { getBalance } from '../../src/actions/public/getBalance.js' +import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' +import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' +import { waitForTransactionReceipt8130 } from '../../src/experimental/eip8130/actions/waitForTransactionReceipt8130.js' +import { createClient } from '../../src/clients/createClient.js' +import { http } from '../../src/clients/transports/http.js' +import { to8130Account } from '../../src/experimental/eip8130/accounts/to8130Account.js' +import { getConfigSequence8130 } from '../../src/experimental/eip8130/actions/getConfigSequence8130.js' +import { getTransactionCount8130 } from '../../src/experimental/eip8130/actions/getTransactionCount8130.js' +import { sendCalls8130 } from '../../src/experimental/eip8130/actions/sendCalls.js' +import { vibenetDevnetDeployment } from '../../src/experimental/eip8130/deployments.js' +import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' +import type { AaCalls } from '../../src/experimental/eip8130/types/transaction.js' +import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' +import { keccak256 } from '../../src/utils/hash/keccak256.js' +import { stringToHex } from '../../src/utils/encoding/toHex.js' +import { parseEther } from '../../src/utils/unit/parseEther.js' +import type { Address, Hex } from '../../src/types/index.js' + +// --------------------------------------------------------------------------- +// Config — defaults target the local vibenet devnet +// --------------------------------------------------------------------------- + +const RPC = process.env.VIBENET_RPC ?? 'http://localhost:8645' +// Anvil account 0 — vibenet-setup sweeps all anvil balances into this address +// on both L1 and L2, so it becomes the rich faucet after setup completes. +const FAUCET_KEY = (process.env.FAUCET_KEY ?? + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') as Hex +const CHAIN_ID = 84538453 +const D = vibenetDevnetDeployment + +const vibenetChain = { + id: CHAIN_ID, + name: 'vibenet', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [RPC] } }, +} as const + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function log(label: string, value: string) { + console.log(` ${label.padEnd(22)} ${value}`) +} + +const client = createClient({ transport: http(RPC), chain: vibenetChain }) + +async function fund(to: Address, amount = parseEther('0.5')) { + const faucet = privateKeyToAccount(FAUCET_KEY) + const hash = await sendTransaction(client as any, { + account: faucet, + to, + value: amount, + chain: vibenetChain, + }) + await waitForTransactionReceipt(client as any, { hash }) + log('funded', `${to} ← ${amount} wei`) +} + +async function send8130( + account: ReturnType, + calls: AaCalls, + accountChanges?: any[], +): Promise { + const nonce = await getTransactionCount8130(client as any, { + address: account.address as Address, + nonceKey: 0n, + }) + + const hash = await sendCalls8130(client as any, { + account, + calls, + accountChanges: accountChanges ?? [], + nonceSequence: nonce, + gas: 500_000n, + }) + log('tx hash', hash) + const receipt = await waitForTransactionReceipt8130(client as any, { hash, timeout: 30_000 }) + const ok = receipt.status === '0x1' || receipt.status === 'success' + log('status', ok ? '✓ success' : `✗ FAILED (status=${receipt.status}, phases=${JSON.stringify(receipt.eip8130?.phaseStatuses)})`) + if (!ok) throw new Error(`Transaction reverted: ${hash}`) + return hash +} + +async function sendWithOwnerChange( + account: ReturnType, + calls: AaCalls, + actorChanges: Parameters[0], +): Promise { + const { local } = await getConfigSequence8130(client as any, { + accountConfiguration: D.accountConfiguration as Address, + account: account.address as Address, + }) + const configChange = await account.change(actorChanges, { + chainId: CHAIN_ID, + sequence: Number(local), + }) + return send8130(account, calls, [configChange]) +} + +// --------------------------------------------------------------------------- +// Test data — fresh keys per run so tests are fully independent +// --------------------------------------------------------------------------- + +const code = erc1167Bytecode(D.accounts.erc4337) +const RECIPIENT = '0x1111111111111111111111111111111111111111' as Address + +// EOA account — signer IS the account address +const eoaKey1 = generatePrivateKey() +const eoaKey2 = generatePrivateKey() +const eoa1 = privateKeyToAccount(eoaKey1) +const eoa2 = privateKeyToAccount(eoaKey2) + +const eoaAccount1 = to8130Account({ + signer: eoa1, + userSalt: '0x' + '00'.repeat(32) as Hex, + code, + initialActors: [key.k1(eoa1.address)], + accountConfigAddress: D.accountConfiguration as Address, + address: eoa1.address, // EOA: address == signer address +}) + +// On first use eoaAccount2 still points at eoa1's address but signs with eoa2 +const eoaAccount2 = to8130Account({ + signer: eoa2, + userSalt: '0x' + '00'.repeat(32) as Hex, + code, + initialActors: [key.k1(eoa1.address)], + accountConfigAddress: D.accountConfiguration as Address, + address: eoa1.address, +}) + +// Smart account — address derived from salt + initialActors +const smartKey1 = generatePrivateKey() +const smart1 = privateKeyToAccount(smartKey1) +const smartSalt = keccak256(stringToHex(`vibe-6part-${Date.now()}`)) + +const smartAccount = to8130Account({ + signer: smart1, + userSalt: smartSalt, + code, + initialActors: [key.k1(smart1.address)], + accountConfigAddress: D.accountConfiguration as Address, +}) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe.sequential('6-part vibenet EIP-8130 native tx test', () => { + test('1. EOA — plain tx (delegate + ETH send)', async () => { + console.log('\n══ Test 1: EOA plain tx ══') + log('EOA address', eoa1.address) + + await fund(eoa1.address) + const balBefore = await getBalance(client as any, { address: RECIPIENT }) + + // Include a `delegation` account-change so the EOA is backed by DefaultAccount + // bytecode before executeBatch is invoked. Without this the EOA has no code + // and the executeBatch self-call is a no-op (succeeds silently, value not sent). + await send8130( + eoaAccount1, + [[{ to: RECIPIENT, value: parseEther('0.001') }]], + [eoaAccount1.delegate(D.accounts.default as Address)], + ) + + const balAfter = await getBalance(client as any, { address: RECIPIENT }) + expect(balAfter).toBeGreaterThan(balBefore) + log('recipient Δ', `+${(Number(balAfter - balBefore) / 1e15).toFixed(3)} mETH`) + }, 60_000) + + test('2. EOA — owner change (authorize second K1 key)', async () => { + console.log('\n══ Test 2: EOA owner change ══') + log('new key', eoa2.address) + + await sendWithOwnerChange( + eoaAccount1, + [[{ to: RECIPIENT, value: 0n }]], + [authorizeActor({ actorId: key.k1(eoa2.address).actorId, authenticator: D.authenticators.k1 as Address })], + ) + log('authorized', eoa2.address) + }, 60_000) + + test('3. EOA — tx signed by newly authorized key', async () => { + console.log('\n══ Test 3: EOA tx with new key ══') + const balBefore = await getBalance(client as any, { address: RECIPIENT }) + + await send8130( + eoaAccount2, + [[{ to: RECIPIENT, value: parseEther('0.001') }]], + ) + + const balAfter = await getBalance(client as any, { address: RECIPIENT }) + expect(balAfter).toBeGreaterThan(balBefore) + }, 60_000) + + test('4. Smart account — createAccount + ETH send', async () => { + console.log('\n══ Test 4: Smart account create ══') + log('smart account', smartAccount.address) + await fund(smartAccount.address) + + const balBefore = await getBalance(client as any, { address: RECIPIENT }) + + // First tx includes the create account-change so the account is deployed + await send8130( + smartAccount, + [[{ to: RECIPIENT, value: parseEther('0.001') }]], + [smartAccount.create()], + ) + + const balAfter = await getBalance(client as any, { address: RECIPIENT }) + expect(balAfter).toBeGreaterThan(balBefore) + }, 60_000) + + test('5. Smart account — follow-up tx (no redeploy)', async () => { + console.log('\n══ Test 5: Smart account follow-up tx ══') + const balBefore = await getBalance(client as any, { address: RECIPIENT }) + + await send8130( + smartAccount, + [[{ to: RECIPIENT, value: parseEther('0.001') }]], + ) + + const balAfter = await getBalance(client as any, { address: RECIPIENT }) + expect(balAfter).toBeGreaterThan(balBefore) + }, 60_000) + + test('6. Smart account — owner change (add new K1 key)', async () => { + console.log('\n══ Test 6: Smart account owner change ══') + const newKey = privateKeyToAccount(generatePrivateKey()) + log('new owner key', newKey.address) + + await sendWithOwnerChange( + smartAccount, + [[{ to: RECIPIENT, value: 0n }]], + [authorizeActor({ actorId: key.k1(newKey.address).actorId, authenticator: D.authenticators.k1 as Address })], + ) + log('authorized', newKey.address) + }, 60_000) +}) diff --git a/src/actions/public/getTransaction.ts b/src/actions/public/getTransaction.ts index fe6cf8b79f..2442f05ae5 100644 --- a/src/actions/public/getTransaction.ts +++ b/src/actions/public/getTransaction.ts @@ -157,6 +157,47 @@ export async function getTransaction< index, }) + // EIP-8130 (`AA_TX_TYPE`, type 0x7b) responses wrap the transaction body in a + // nested `tx` object and omit the `hash` field (unlike standard tx responses). + // Flatten the nested body and inject the request hash so downstream formatters + // (and `waitForTransactionReceipt`) have all the fields they expect. + if ((transaction as any).type === '0x7b') { + const raw = transaction as any + const body = raw.tx ?? {} + transaction = { + // Inject the request hash — not present in the RPC response. + hash: hash ?? undefined, + type: '0x7b', + // Top-level block context fields (present in mined txs). + blockHash: raw.blockHash, + blockNumber: raw.blockNumber, + blockTimestamp: raw.blockTimestamp, + transactionIndex: raw.transactionIndex, + gasPrice: raw.gasPrice, + // Tx body fields mapped to standard hex form. + from: raw.from ?? body.sender, + chainId: body.chainId != null ? numberToHex(body.chainId) : undefined, + gas: body.gasLimit != null ? numberToHex(body.gasLimit) : undefined, + maxFeePerGas: body.maxFeePerGas, + maxPriorityFeePerGas: body.maxPriorityFeePerGas, + // Map 2D nonce: use nonceSequence as the canonical nonce for compat. + nonce: body.nonceSequence != null ? numberToHex(body.nonceSequence) : undefined, + // EIP-8130 txs have no single `to` — calls are a structured list. + to: null, + value: '0x0', + input: '0x', + // EIP-8130 extra fields (preserved as-is for `getTransaction8130`). + nonceKey: body.nonceKey, + expiry: body.expiry, + calls: body.calls, + accountChanges: body.accountChanges, + metadata: body.metadata, + payer: body.payer ?? null, + senderAuth: raw.senderAuth, + payerAuth: raw.payerAuth, + } as unknown as RpcTransaction + } + const format = client.chain?.formatters?.transaction?.format || formatTransaction return format(transaction, 'getTransaction') diff --git a/src/actions/public/waitForTransactionReceipt.ts b/src/actions/public/waitForTransactionReceipt.ts index 98246a5411..81dfd2a222 100644 --- a/src/actions/public/waitForTransactionReceipt.ts +++ b/src/actions/public/waitForTransactionReceipt.ts @@ -319,7 +319,12 @@ export async function waitForTransactionReceipt< ) // If we couldn't find a replacement transaction, continue polling. - if (!replacementTransaction) return + // Also skip if the candidate transaction has no hash — this can + // happen when the block contains a transaction type that viem + // does not yet know how to deserialize (e.g. EIP-8130 type 0x7b), + // in which case the parsed object will have `hash: undefined`. + if (!replacementTransaction || !replacementTransaction.hash) + return // If we found a replacement transaction, return it's receipt. receipt = await getAction( diff --git a/src/experimental/eip8130/actions/getTransaction8130.ts b/src/experimental/eip8130/actions/getTransaction8130.ts new file mode 100644 index 0000000000..3061b0572e --- /dev/null +++ b/src/experimental/eip8130/actions/getTransaction8130.ts @@ -0,0 +1,152 @@ +import type { Address } from 'abitype' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import type { Hash, Hex } from '../../../types/misc.js' +import type { AaAccountChange, AaCalls } from '../types/transaction.js' + +/** + * Strongly-typed representation of an EIP-8130 (`AA_TX_TYPE`, type `0x7b`) + * transaction as returned by `eth_getTransactionByHash` on a node with the + * EIP-8130 extension. + */ +export type Transaction8130 = { + /** EIP-8130 transaction type marker. */ + type: '0x7b' + /** Transaction hash (injected from the request — not present in the raw RPC response). */ + hash: Hash + /** Account address that sent the transaction (sender). */ + from: Address + /** Chain id. */ + chainId: number + /** 2D nonce: channel key. */ + nonceKey: Hex + /** 2D nonce: sequence within the channel. */ + nonceSequence: number + /** Expiry timestamp (0 = no expiry). */ + expiry: number + /** Maximum fee per gas (EIP-1559). */ + maxFeePerGas: bigint + /** Maximum priority fee per gas (EIP-1559). */ + maxPriorityFeePerGas: bigint + /** Gas limit. */ + gas: bigint + /** Ordered list of call phases. */ + calls: AaCalls + /** Account-configuration changes bundled in this transaction. */ + accountChanges: readonly AaAccountChange[] + /** Opaque tx metadata (echoed in the receipt). */ + metadata: Hex + /** Payer address for gas sponsorship, or `null` for self-pay. */ + payer: Address | null + /** Sender authentication blob. */ + senderAuth: Hex + /** Payer authentication blob (empty for self-pay). */ + payerAuth: Hex + /** Gas price at execution. `null` for pending transactions. */ + gasPrice: bigint | null + /** Block hash of the block containing this transaction, or `null` if pending. */ + blockHash: Hash | null + /** Block number of the block containing this transaction, or `null` if pending. */ + blockNumber: bigint | null + /** Zero-based index within the block, or `null` if pending. */ + transactionIndex: number | null +} + +export type GetTransaction8130Parameters = { + /** The hash of the EIP-8130 transaction to fetch. */ + hash: Hash +} + +export type GetTransaction8130ReturnType = Transaction8130 + +/** Raw RPC response shape for `eth_getTransactionByHash` on an 8130 node. */ +type RawTx8130 = { + type: '0x7b' + tx: { + chainId: number + sender: Address + nonceKey: Hex + nonceSequence: number + expiry: number + maxFeePerGas: Hex + maxPriorityFeePerGas: Hex + gasLimit: number + calls: AaCalls + accountChanges: readonly AaAccountChange[] + metadata: Hex + payer: Address | null + } + senderAuth: Hex + payerAuth: Hex + from: Address + gasPrice: Hex | null + blockHash: Hash | null + blockNumber: Hex | null + transactionIndex: Hex | null +} + +/** + * Fetches an EIP-8130 (`AA_TX_TYPE`) transaction by hash and returns a + * fully-typed `Transaction8130` object. + * + * Unlike the generic `getTransaction`, this action: + * - Understands the nested `tx` body format returned by the EIP-8130 RPC node. + * - Injects the request `hash` (absent from the raw response) into the result. + * - Converts all numeric fields from raw form to `bigint` / `number`. + * + * @example + * const tx = await getTransaction8130(client, { hash: '0xabc...' }) + * console.log(tx.calls) // AaCalls + * console.log(tx.accountChanges) // AaAccountChange[] + * console.log(tx.nonceSequence) // number + */ +export async function getTransaction8130< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetTransaction8130Parameters, +): Promise { + const { hash } = parameters + + const raw = await ( + client.request as (args: { + method: 'eth_getTransactionByHash' + params: [Hash] + }) => Promise + )({ method: 'eth_getTransactionByHash', params: [hash] }) + + if (!raw || raw.type !== '0x7b') + throw new Error( + `getTransaction8130: expected type 0x7b but got type ${(raw as any)?.type ?? 'null'} for hash ${hash}`, + ) + + const body = raw.tx + + return { + type: '0x7b', + hash, + from: raw.from ?? body.sender, + chainId: body.chainId, + nonceKey: body.nonceKey, + nonceSequence: body.nonceSequence, + expiry: body.expiry, + maxFeePerGas: BigInt(body.maxFeePerGas), + maxPriorityFeePerGas: BigInt(body.maxPriorityFeePerGas), + gas: BigInt(body.gasLimit), + calls: body.calls, + accountChanges: body.accountChanges, + metadata: body.metadata, + payer: body.payer, + senderAuth: raw.senderAuth, + payerAuth: raw.payerAuth, + gasPrice: raw.gasPrice ? BigInt(raw.gasPrice) : null, + blockHash: raw.blockHash ?? null, + blockNumber: raw.blockNumber ? BigInt(raw.blockNumber) : null, + transactionIndex: raw.transactionIndex + ? Number(raw.transactionIndex) + : null, + } +} diff --git a/src/experimental/eip8130/actions/waitForTransactionReceipt8130.ts b/src/experimental/eip8130/actions/waitForTransactionReceipt8130.ts new file mode 100644 index 0000000000..41921a4d14 --- /dev/null +++ b/src/experimental/eip8130/actions/waitForTransactionReceipt8130.ts @@ -0,0 +1,68 @@ +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import type { Hash } from '../../../types/misc.js' +import { + type GetTransactionReceipt8130ReturnType, + getTransactionReceipt8130, +} from './getTransactionReceipt8130.js' + +export type WaitForTransactionReceipt8130Parameters = { + /** Transaction hash to wait for. */ + hash: Hash + /** + * How often to poll for the receipt (ms). + * @default 500 + */ + pollingInterval?: number | undefined + /** + * Maximum time to wait before rejecting (ms). + * @default 60_000 + */ + timeout?: number | undefined +} + +export type WaitForTransactionReceipt8130ReturnType = NonNullable + +/** + * Polls `eth_getTransactionReceipt` until an EIP-8130 (`AA_TX_TYPE`) transaction + * is included in a block, then returns the receipt with the EIP-8130 fields + * (`payer`, `phaseStatuses`, `metadata`) attached. + * + * Unlike the generic `waitForTransactionReceipt`, this action: + * - Uses `getTransactionReceipt8130` so EIP-8130 receipt fields are surfaced. + * - Skips the standard replacement-detection path (EIP-8130 uses 2D nonces and + * cannot be replaced via the same-nonce mechanism). + * + * @example + * const receipt = await waitForTransactionReceipt8130(client, { + * hash: '0xabc...', + * }) + * console.log(receipt.eip8130.phaseStatuses) // ['0x1'] + */ +export async function waitForTransactionReceipt8130< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: WaitForTransactionReceipt8130Parameters, +): Promise { + const { + hash, + pollingInterval = 500, + timeout = 60_000, + } = parameters + + const deadline = Date.now() + timeout + + while (Date.now() < deadline) { + const receipt = await getTransactionReceipt8130(client, { hash }) + if (receipt !== null) return receipt + await new Promise((resolve) => setTimeout(resolve, pollingInterval)) + } + + throw new Error( + `waitForTransactionReceipt8130: timed out after ${timeout}ms waiting for ${hash}`, + ) +} diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index c41401c71d..571a8edd6d 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -41,6 +41,17 @@ export { getTransactionReceipt8130, parseEip8130ReceiptFields, } from './actions/getTransactionReceipt8130.js' +export { + type GetTransaction8130Parameters, + type GetTransaction8130ReturnType, + type Transaction8130, + getTransaction8130, +} from './actions/getTransaction8130.js' +export { + type WaitForTransactionReceipt8130Parameters, + type WaitForTransactionReceipt8130ReturnType, + waitForTransactionReceipt8130, +} from './actions/waitForTransactionReceipt8130.js' export { type PrepareTransaction8130Parameters, prepareTransaction8130, diff --git a/src/utils/formatters/transaction.ts b/src/utils/formatters/transaction.ts index 931a17ce77..925cfa4c52 100644 --- a/src/utils/formatters/transaction.ts +++ b/src/utils/formatters/transaction.ts @@ -41,6 +41,7 @@ export const transactionType = { '0x2': 'eip1559', '0x3': 'eip4844', '0x4': 'eip7702', + '0x7b': 'eip8130', } as const satisfies Record export type FormatTransactionErrorType = ErrorType From 728f4579824efae750f464fec40610df13fa2a44 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 25 Jun 2026 14:11:58 -0400 Subject: [PATCH 24/96] test(eip8130): add P256 smart account create + follow-up tx tests (7 & 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests 7 and 8 cover the full P256 lifecycle on vibenet: 7. P256 smart account — createAccount + ETH send (P-256 signer) 8. P256 smart account — follow-up send with same P-256 key (no redeploy) Uses toP256Signer + ox/P256.randomPrivateKey to generate a fresh key each run and verifies end-to-end: fund → create → send → balance check. --- scripts/eip8130/vibenet6PartTest.test.ts | 60 +++++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/scripts/eip8130/vibenet6PartTest.test.ts b/scripts/eip8130/vibenet6PartTest.test.ts index 083cc26d5c..f76cc48340 100644 --- a/scripts/eip8130/vibenet6PartTest.test.ts +++ b/scripts/eip8130/vibenet6PartTest.test.ts @@ -1,13 +1,15 @@ /** - * 6-part EIP-8130 native transaction test against the local vibenet devnet. + * 8-part EIP-8130 native transaction test against the local vibenet devnet. * * Tests (in order): * 1. EOA plain tx — delegate + send ETH (EIP-7702 style) * 2. EOA owner change — authorize a second K1 key * 3. EOA with new key — send tx signed by the newly authorized key - * 4. Smart account create — createAccount + send ETH + * 4. Smart account create — createAccount + send ETH (K1 signer) * 5. Smart account tx — follow-up send using same account * 6. Smart account rotate — authorize a new K1 key + * 7. P256 smart account create — createAccount + send ETH (P-256 signer) + * 8. P256 smart account tx — follow-up send signed by P-256 key * * Run: * npx vitest run --config test/vitest.eip8130.config.ts \ @@ -28,6 +30,8 @@ import { getTransactionCount8130 } from '../../src/experimental/eip8130/actions/ import { sendCalls8130 } from '../../src/experimental/eip8130/actions/sendCalls.js' import { vibenetDevnetDeployment } from '../../src/experimental/eip8130/deployments.js' import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' +import { toP256Signer } from '../../src/experimental/eip8130/utils/signers.js' +import * as P256 from 'ox/P256' import type { AaCalls } from '../../src/experimental/eip8130/types/transaction.js' import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' @@ -162,6 +166,20 @@ const smartAccount = to8130Account({ accountConfigAddress: D.accountConfiguration as Address, }) +// P256 smart account — address derived from P-256 public key +const p256PrivateKey = P256.randomPrivateKey() +const p256Signer = toP256Signer({ privateKey: p256PrivateKey }) +const p256Salt = keccak256(stringToHex(`vibe-p256-${Date.now()}`)) + +const p256Account = to8130Account({ + signer: p256Signer, + authenticator: p256Signer.authenticator, + userSalt: p256Salt, + code, + initialActors: [key.p256(p256Signer.publicKey)], + accountConfigAddress: D.accountConfiguration as Address, +}) + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -256,4 +274,42 @@ describe.sequential('6-part vibenet EIP-8130 native tx test', () => { ) log('authorized', newKey.address) }, 60_000) + + test('7. P256 smart account — createAccount + ETH send', async () => { + console.log('\n══ Test 7: P256 smart account create ══') + log('p256 account', p256Account.address) + log('p256 pubkey x', p256Signer.publicKey.x) + log('p256 pubkey y', p256Signer.publicKey.y) + log('authenticator', p256Signer.authenticator!) + + await fund(p256Account.address) + + const balBefore = await getBalance(client as any, { address: RECIPIENT }) + + // First tx: create account change deploys the account, then sends ETH. + await send8130( + p256Account, + [[{ to: RECIPIENT, value: parseEther('0.001') }]], + [p256Account.create()], + ) + + const balAfter = await getBalance(client as any, { address: RECIPIENT }) + expect(balAfter).toBeGreaterThan(balBefore) + log('recipient Δ', `+${(Number(balAfter - balBefore) / 1e15).toFixed(3)} mETH`) + }, 60_000) + + test('8. P256 smart account — follow-up tx (no redeploy)', async () => { + console.log('\n══ Test 8: P256 smart account follow-up tx ══') + const balBefore = await getBalance(client as any, { address: RECIPIENT }) + + // Subsequent tx: no account changes needed, P-256 signer signs directly. + await send8130( + p256Account, + [[{ to: RECIPIENT, value: parseEther('0.001') }]], + ) + + const balAfter = await getBalance(client as any, { address: RECIPIENT }) + expect(balAfter).toBeGreaterThan(balBefore) + log('recipient Δ', `+${(Number(balAfter - balBefore) / 1e15).toFixed(3)} mETH`) + }, 60_000) }) From 1d01cf4f10787184446630ac4a3d471e0cd79d67 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 25 Jun 2026 20:35:34 -0400 Subject: [PATCH 25/96] feat(eip8130): extend estimateGas8130 to support full tx body Add accountChanges + calls parameters to EstimateGas8130Parameters. When present, the request is sent as a complete EIP-8130 tx body so the node routes through the real executor simulation (required for create account-change pricing and accurate per-phase call cost). When omitted, the existing simplified mode is used unchanged (backward compatible). The serialize helper converts typed AaAccountChange objects to the plain JSON the node's eth_estimateGas deserialiser expects. --- .../eip8130/actions/estimateGas8130.ts | 162 ++++++++++++++++-- 1 file changed, 146 insertions(+), 16 deletions(-) diff --git a/src/experimental/eip8130/actions/estimateGas8130.ts b/src/experimental/eip8130/actions/estimateGas8130.ts index 8218c9d836..b6a14279d5 100644 --- a/src/experimental/eip8130/actions/estimateGas8130.ts +++ b/src/experimental/eip8130/actions/estimateGas8130.ts @@ -10,6 +10,7 @@ import type { Hex } from '../../../types/misc.js' import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import { numberToHex } from '../../../utils/encoding/toHex.js' import { aaTransactionType } from '../constants.js' +import type { AaAccountChange, AaCalls } from '../types/transaction.js' /** * Authentication scheme an EIP-8130 actor uses to sign. The node prices the @@ -25,6 +26,11 @@ export type EstimateGas8130Parameters = { * and the node returns `INVALID_PARAMS` for an EIP-8130 estimate without it. */ from: Address + + // ── Simplified mode (no accountChanges/calls) ───────────────────────────── + // The node synthesises stub auth blobs from the declared scheme. Use this + // when the caller only needs to price a call from an already-deployed account + // without knowing the full transaction shape. /** Target of the (representative) call being estimated. */ to?: Address | undefined /** Calldata of the call being estimated. */ @@ -35,6 +41,37 @@ export type EstimateGas8130Parameters = { senderAuthScheme?: Eip8130AuthScheme | undefined /** Override the sender auth-payload byte length (otherwise scheme-derived). */ senderAuthSize?: number | undefined + + // ── Full-body mode (accountChanges + calls) ──────────────────────────────── + // When `accountChanges` or `calls` is provided, the request is sent as a + // full EIP-8130 tx body (sender + nonceKey + nonceSequence + accountChanges + + // calls + metadata). The node routes this through the real EIP-8130 executor + // simulation, which is required to correctly price account-creation + // (`create` account-change) and per-phase call overhead. The simplified + // `to`/`data`/`value`/`senderAuthScheme` fields are ignored in this mode. + /** + * Account-change operations included in the transaction (e.g. `create` for + * new smart-account deployment). Pass an empty array for follow-up txs on an + * already-deployed account. + */ + accountChanges?: readonly AaAccountChange[] | undefined + /** + * Phased calls array (each inner array is one phase / `executeBatch` call). + * Typically a single phase: `[[{ to, value, data }]]`. + */ + calls?: AaCalls | undefined + /** + * 2-D nonce key. Defaults to `0n`. Only relevant when the account has + * multiple open channels; leave as default for single-channel accounts. + */ + nonceKey?: bigint | undefined + /** + * Nonce sequence within `nonceKey`. Defaults to `0` (first tx / deploy). + * Pass the account's current sequence for follow-up-tx estimation. + */ + nonceSequence?: number | undefined + + // ── Common ──────────────────────────────────────────────────────────────── /** Optional sponsoring payer; priced into the estimate when set. */ payer?: Address | undefined /** Payer authentication scheme. Defaults to `secp256k1` on the node. */ @@ -56,9 +93,22 @@ const maxAuthSize = 8_192 * Estimates gas for an EIP-8130 (`AA_TX_TYPE`) call via `eth_estimateGas`. * * Requires a node with the EIP-8130 `eth_estimateGas` extension - * (base `feat(eip8130): RPC support for estimateGas`). The estimate is a single - * read-only `simulate` (no binary search): it prices the EIP-8130 intrinsic-gas - * schedule for the declared authentication scheme plus the executed call. + * (base `feat(eip8130): add eth_estimateGas for 8130`). The estimate runs a + * read-only `simulate` on the executor: no signature verification, no fee + * settlement, all state reverted. It shares the pre-call pipeline + * (account-change apply, auto-delegation, intrinsic gas) with the verifying + * `execute` path, so the estimate cannot drift from real execution gas. + * + * Two request modes: + * + * **Simplified** — omit `accountChanges`/`calls`. The node synthesises stub + * auth blobs from the declared `senderAuthScheme` and `senderAuthSize`. + * Suitable for pricing individual calls from an already-deployed account. + * + * **Full-body** — supply `accountChanges` and/or `calls`. The request is sent + * as a complete EIP-8130 tx body and routed through the real executor + * simulation. Required to correctly price account-creation (`create` + * account-change) and per-phase call overhead. * * Note: unlike standard `eth_estimateGas`, an EIP-8130 estimate returns the * charged gas **even when a phase reverts**, because a reverted EIP-8130 tx is @@ -78,6 +128,10 @@ export async function estimateGas8130< value, senderAuthScheme, senderAuthSize, + accountChanges, + calls, + nonceKey = 0n, + nonceSequence = 0, payer, payerAuthScheme, payerAuthSize, @@ -96,20 +150,61 @@ export async function estimateGas8130< `auth size ${size} out of range (0..=${maxAuthSize}).`, ) - const request: Record = { - type: aaTransactionType, - from, + const useFullBody = accountChanges !== undefined || calls !== undefined + + let request: Record + + if (useFullBody) { + // Full-body mode: send the complete EIP-8130 tx body so the node routes + // through the real executor simulation (required for create estimation). + // nonceKey / nonceSequence use integers (not hex) per the node's JSON + // deserialiser (expects u64, not a hex string). + request = { + type: aaTransactionType, + from, + sender: from, + nonceKey: Number(nonceKey), + nonceSequence, + expiry: 0, + // Reasonable fee defaults — the estimate skips fee validation. + maxFeePerGas: numberToHex(1_000_000_000n), + maxPriorityFeePerGas: numberToHex(1_000_000n), + // Large gas cap so the simulation isn't capped below real execution. + gasLimit: 30_000_000, + accountChanges: (accountChanges ?? []).map(serializeAccountChange), + calls: (calls ?? [[{ to: from, value: 0n, data: '0x' as Hex }]]).map( + (phase) => + phase.map((c) => ({ + to: c.to, + value: numberToHex(c.value ?? 0n), + data: c.data ?? '0x', + })), + ), + metadata: '0x', + payer: payer ?? null, + } + if (payerAuthScheme !== undefined) request.payerAuthScheme = payerAuthScheme + if (payerAuthSize !== undefined) + request.payerAuthSize = numberToHex(payerAuthSize) + } else { + // Simplified mode: let the node synthesise stub auth blobs from the scheme. + request = { + type: aaTransactionType, + from, + } + if (to !== undefined) request.to = to + if (data !== undefined) request.data = data + if (value !== undefined) request.value = numberToHex(value) + if (senderAuthScheme !== undefined) + request.senderAuthScheme = senderAuthScheme + if (senderAuthSize !== undefined) + request.senderAuthSize = numberToHex(senderAuthSize) + if (payer !== undefined) request.payer = payer + if (payerAuthScheme !== undefined) + request.payerAuthScheme = payerAuthScheme + if (payerAuthSize !== undefined) + request.payerAuthSize = numberToHex(payerAuthSize) } - if (to !== undefined) request.to = to - if (data !== undefined) request.data = data - if (value !== undefined) request.value = numberToHex(value) - if (senderAuthScheme !== undefined) request.senderAuthScheme = senderAuthScheme - if (senderAuthSize !== undefined) - request.senderAuthSize = numberToHex(senderAuthSize) - if (payer !== undefined) request.payer = payer - if (payerAuthScheme !== undefined) request.payerAuthScheme = payerAuthScheme - if (payerAuthSize !== undefined) - request.payerAuthSize = numberToHex(payerAuthSize) const block = blockNumber !== undefined ? numberToHex(blockNumber) : blockTag @@ -122,3 +217,38 @@ export async function estimateGas8130< return hexToBigInt(gas) } + +/** + * Converts a typed `AaAccountChange` to the plain JSON object the node's + * `eth_estimateGas` deserialiser expects. For `create` changes, all fields are + * passed through directly. For `delegation`, only `target` is required (the + * node does not need the proxy bytecode for gas pricing). For `config`, the + * full change is forwarded so the node can price the auth-blob calldata. + */ +function serializeAccountChange( + change: AaAccountChange, +): Record { + if (change.type === 'create') { + return { + type: 'create', + userSalt: change.userSalt, + code: change.code, + initialActors: change.initialActors.map((a) => ({ + actorId: a.actorId, + authenticator: a.authenticator, + })), + } + } + if (change.type === 'delegation') { + return { type: 'delegation', target: change.target } + } + // config: forward as-is; the node doesn't verify the auth in simulate mode + // but does price its byte-length into the intrinsic gas. + return { + type: 'config', + chainId: change.chainId, + sequence: change.sequence, + actorChanges: change.actorChanges, + auth: change.auth, + } +} From fe3931677a985366ac68bea4bbc6835e3df7f413 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 25 Jun 2026 20:57:51 -0400 Subject: [PATCH 26/96] feat(eip8130): pass senderAuthScheme hint in full-body estimateGas Forward senderAuthScheme/senderAuthSize to the node even when accountChanges/calls are present, so the node can price intrinsic auth gas correctly for accounts not yet deployed on-chain (where it can't infer the scheme from account state). --- src/experimental/eip8130/actions/estimateGas8130.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/experimental/eip8130/actions/estimateGas8130.ts b/src/experimental/eip8130/actions/estimateGas8130.ts index b6a14279d5..d34168f923 100644 --- a/src/experimental/eip8130/actions/estimateGas8130.ts +++ b/src/experimental/eip8130/actions/estimateGas8130.ts @@ -183,6 +183,14 @@ export async function estimateGas8130< metadata: '0x', payer: payer ?? null, } + // Pass auth-scheme hints so the node can price intrinsic gas correctly even + // when the authenticator type cannot be fully inferred from on-chain state + // (e.g. the account hasn't been deployed yet). The node ignores these in + // simulate mode when it can determine the scheme from initialActors, so + // providing them is always safe. + if (senderAuthScheme !== undefined) request.senderAuthScheme = senderAuthScheme + if (senderAuthSize !== undefined) + request.senderAuthSize = numberToHex(senderAuthSize) if (payerAuthScheme !== undefined) request.payerAuthScheme = payerAuthScheme if (payerAuthSize !== undefined) request.payerAuthSize = numberToHex(payerAuthSize) From 9f4567bc3bac2ee3b02de3308f6e87b6e253ad00 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 25 Jun 2026 21:29:01 -0400 Subject: [PATCH 27/96] =?UTF-8?q?feat(eip8130):=20DX=20improvements=20?= =?UTF-8?q?=E2=80=94=20newSmartAccount8130,=20toEoa8130Account,=20key.webA?= =?UTF-8?q?uthn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - key.webAuthn: alias for key.passkey to match deployment naming convention - newSmartAccount8130(params): factory that auto-derives actor from signer type (K1/P256/WebAuthn detected from publicKey + authenticator), generates a random salt, defaults code to canonical DefaultAccount, and exposes createChange as a property for convenient first-tx usage - toEoa8130Account(signer): wraps a K1 EOA for EIP-8130 txs using the implicit self-actor path (raw 65-byte sig, no authenticator prefix, no smart contract) - Export canonicalEip8130Deployment for downstream consumers --- .../eip8130/accounts/to8130Account.ts | 230 ++++++++++++++++++ src/experimental/eip8130/deployments.ts | 2 +- src/experimental/eip8130/index.ts | 6 + src/experimental/eip8130/keys.ts | 12 +- 4 files changed, 248 insertions(+), 2 deletions(-) diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index 25754397a3..b5b1b5dd98 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -1,10 +1,15 @@ import type { Address } from 'abitype' import { BaseError } from '../../../errors/base.js' import type { Hex } from '../../../types/misc.js' +import { bytesToHex } from '../../../utils/encoding/toHex.js' +import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import { accountConfigAddress as defaultAccountConfigAddress, + canonicalAuthenticators, ecrecoverAuthenticator, } from '../constants.js' +import { canonicalEip8130Deployment } from '../deployments.js' +import { key } from '../keys.js' import type { AaAccountChangeConfig, AaAccountChangeCreate, @@ -15,6 +20,7 @@ import type { TransactionSerialized8130, } from '../types/transaction.js' import { computeAddress8130 } from '../utils/computeAddress.js' +import { erc1167Bytecode } from '../utils/proxy.js' import { signActorChanges8130 } from '../utils/signActorChanges.js' import { type Signer, signTransaction8130 } from '../utils/signTransaction.js' @@ -140,3 +146,227 @@ export function to8130Account( }, } } + +// ───────────────────────────────────────────────────────────────────────────── +// newSmartAccount8130 +// ───────────────────────────────────────────────────────────────────────────── + +export type NewSmartAccount8130Parameters = { + /** + * The signing key for this account's controlling actor. + * + * - **K1 (secp256k1)** — a `LocalAccount` from `privateKeyToAccount(pk)` + * - **P-256** — from `toP256Signer({ privateKey })` + * - **WebAuthn / passkey** — from `toWebAuthnSigner(toWebAuthnAccount({ credential }))` + * + * The signer's type is detected automatically: K1 signers expose `.address`; + * P-256 / WebAuthn signers expose `.publicKey` and `.authenticator`. + */ + signer: Signer & { publicKey?: { x: Hex; y: Hex } } + /** + * Uniqueness factor for CREATE2 (bytes32). Randomly generated if omitted. + * Pass the same salt across sessions to recover a deterministic address. + */ + salt?: Hex | undefined + /** + * Wallet implementation address (proxied via ERC-1167). + * Defaults to the canonical `DefaultAccount`. + * Ignored if `code` is provided. + */ + implementation?: Address | undefined + /** + * Deployment bytecode override. Defaults to `erc1167Bytecode(implementation)` + * (or the canonical `DefaultAccount` implementation if neither is provided). + */ + code?: Hex | undefined + /** + * Additional actors to include at account creation alongside the signer's own + * actor. All actors are sorted by `actorId` in strictly ascending order (as + * required by the protocol). + */ + extraActors?: readonly AaActor[] | undefined + /** AccountConfiguration contract override (advanced). Defaults to canonical. */ + accountConfigAddress?: Address | undefined +} + +export type NewSmartAccount8130ReturnType = To8130AccountReturnType & { + /** + * The `create` account-change entry — include in `accountChanges` for the + * first transaction to deploy this account. + * + * @example + * const gas = await estimateGas8130(client, { + * from: account.address, + * accountChanges: [account.createChange], + * calls: [[{ to: recipient, value: parseEther('0.01') }]], + * }) + * const tx = await account.signTransaction({ + * accountChanges: [account.createChange], + * calls: wire, + * gas: (gas * 120n) / 100n, + * ... + * }) + */ + readonly createChange: AaAccountChangeCreate +} + +/** + * Creates a new EIP-8130 smart account from a signer, automatically deriving + * the actor type, deployment bytecode, and counterfactual address. The account + * is not yet deployed on-chain — include `account.createChange` in the first + * transaction's `accountChanges` to atomically deploy and call in one shot. + * + * Supports K1 (secp256k1), P-256, and WebAuthn (passkey) signers, detected + * automatically from the signer object. + * + * @example + * // K1 (EOA private key) + * const account = newSmartAccount8130({ signer: privateKeyToAccount(pk) }) + * + * @example + * // P-256 + * const p256 = toP256Signer({ privateKey: P256.randomPrivateKey() }) + * const account = newSmartAccount8130({ signer: p256 }) + * + * @example + * // WebAuthn / passkey + * const webAuthn = toWebAuthnSigner(toWebAuthnAccount({ credential })) + * const account = newSmartAccount8130({ signer: webAuthn }) + * + * @example + * // First tx: create + call in one shot + * const gas = await estimateGas8130(client, { + * from: account.address, + * accountChanges: [account.createChange], + * calls: [[{ to: recipient, value }]], + * senderAuthScheme: 'secp256k1', + * }) + * const signed = await account.signTransaction({ + * chainId, nonceKey: 0n, nonceSequence: 0n, + * accountChanges: [account.createChange], + * calls: wire, + * gas: (gas * 120n) / 100n, + * maxFeePerGas: 1_000_000_000n, + * maxPriorityFeePerGas: 1_000_000n, + * }) + */ +export function newSmartAccount8130( + parameters: NewSmartAccount8130Parameters, +): NewSmartAccount8130ReturnType { + const { signer, implementation, extraActors = [], accountConfigAddress } = parameters + + // Detect signer type and derive the primary actor. + // P256 / WebAuthn signers expose `.publicKey`; K1 signers have `.address`. + const primaryActor: AaActor = + 'publicKey' in signer && signer.publicKey + ? signer.authenticator === canonicalAuthenticators.passkey + ? key.webAuthn(signer.publicKey) + : key.p256(signer.publicKey) + : key.k1(signer.address) + + // Sort all actors by actorId (strictly ascending — protocol requirement). + const allActors: AaActor[] = [primaryActor, ...extraActors].sort((a, b) => { + const ai = hexToBigInt(a.actorId as Hex) + const bi = hexToBigInt(b.actorId as Hex) + return ai < bi ? -1 : ai > bi ? 1 : 0 + }) + + const salt = parameters.salt ?? randomBytes32() + + const code = + parameters.code ?? + erc1167Bytecode( + implementation ?? canonicalEip8130Deployment.accounts.default, + ) + + const inner = to8130Account({ + signer, + userSalt: salt, + code, + initialActors: allActors, + authenticator: signer.authenticator, + accountConfigAddress, + }) + + return { ...inner, createChange: inner.create() } +} + +// ───────────────────────────────────────────────────────────────────────────── +// toEoa8130Account +// ───────────────────────────────────────────────────────────────────────────── + +export type ToEoa8130AccountReturnType = { + /** The EOA address — used as both `sender` and signer identity. */ + readonly address: Address + readonly signer: Signer + /** + * Signs an EIP-8130 transaction as a bare EOA (implicit self-actor). + * + * The `senderAuth` is a raw 65-byte secp256k1 signature with no authenticator + * prefix — the node recovers the sender address directly via `ecrecover`. + * Use this when you want native EIP-8130 features (e.g. payer sponsorship) + * without deploying a smart-contract account. + */ + signTransaction( + transaction: TransactionSerializable8130, + options?: { + payer?: { account: Signer; address?: Address } | undefined + }, + ): Promise +} + +/** + * Wraps a secp256k1 EOA signer for EIP-8130 transactions using the implicit + * self-actor path. The `senderAuth` is a raw 65-byte ECDSA signature (no + * authenticator prefix); the node recovers the sender via `ecrecover`. + * + * Use this when your EOA address IS the account — no smart contract deployment + * needed. The signer can still receive payer sponsorship and use all other + * EIP-8130 features. + * + * For smart contract accounts (create / delegate / configure), use + * {@link newSmartAccount8130} or {@link to8130Account} instead. + * + * @example + * const eoa = toEoa8130Account(privateKeyToAccount(pk)) + * + * const signed = await eoa.signTransaction({ + * chainId, nonceKey: 0n, nonceSequence: 0n, + * calls: wire, + * gas: gasLimit, + * maxFeePerGas: 1_000_000_000n, + * maxPriorityFeePerGas: 1_000_000n, + * // No `from` → raw 65-byte EOA auth + * }) + */ +export function toEoa8130Account(signer: Signer): ToEoa8130AccountReturnType { + if (!signer.address) + throw new BaseError( + '`signer.address` is required for an EOA account. Use `privateKeyToAccount(pk)` or equivalent.', + ) + const address = signer.address + + return { + address, + signer, + + async signTransaction(transaction, options = {}) { + if (!signer.sign) + throw new BaseError('`signer` does not support raw signing.') + return signTransaction8130({ + // Do NOT set `from` — signals the EOA path: raw 65-byte sig, no prefix. + transaction, + account: signer, + authenticator: ecrecoverAuthenticator, + payer: options.payer, + }) + }, + } +} + +/** Generates a cryptographically random bytes32 salt. */ +function randomBytes32(): Hex { + const buf = new Uint8Array(32) + globalThis.crypto.getRandomValues(buf) + return bytesToHex(buf) +} diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index dc219d4f76..d38779194c 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -60,7 +60,7 @@ export type Eip8130Deployment = { * When the `base/eip-8130` contracts are recompiled (e.g. Solidity upgrade or * bytecode change), all addresses must be re-derived and this object updated. */ -const canonicalEip8130Deployment = { +export const canonicalEip8130Deployment = { accountConfiguration: '0xC6595B992AF49099B476690d4D7CAb7D1890388F', accounts: { default: '0xca8D7419FEC024a5CEDB8D427615f3A74E3ebA6b', diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 571a8edd6d..5a5d17b670 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -10,6 +10,11 @@ export { type To8130AccountParameters, type To8130AccountReturnType, to8130Account, + type NewSmartAccount8130Parameters, + type NewSmartAccount8130ReturnType, + newSmartAccount8130, + type ToEoa8130AccountReturnType, + toEoa8130Account, } from './accounts/to8130Account.js' export { type Eip8130SmartAccountImplementation, @@ -86,6 +91,7 @@ export { } from './constants.js' export { baseSepoliaDeployment, + canonicalEip8130Deployment, type Eip8130Deployment, eip8130Deployments, getEip8130Deployment, diff --git a/src/experimental/eip8130/keys.ts b/src/experimental/eip8130/keys.ts index 3b8d2fcb9b..3a4f400c89 100644 --- a/src/experimental/eip8130/keys.ts +++ b/src/experimental/eip8130/keys.ts @@ -39,7 +39,17 @@ export const key = { authenticator: options.authenticator ?? canonicalAuthenticators.p256, } }, - /** WebAuthn / FIDO2 passkey actor for a public key. */ + /** WebAuthn / FIDO2 passkey actor for a public key. Alias: `key.passkey`. */ + webAuthn( + publicKey: { x: Hex; y: Hex } | Hex, + options: { authenticator?: Address } = {}, + ): AaActor { + return { + actorId: actorIdFromPublicKey(publicKey), + authenticator: options.authenticator ?? canonicalAuthenticators.passkey, + } + }, + /** WebAuthn / FIDO2 passkey actor for a public key. Alias: `key.webAuthn`. */ passkey( publicKey: { x: Hex; y: Hex } | Hex, options: { authenticator?: Address } = {}, From 69cf58a08848cac77d3ffb6a1a30c255cd98f52e Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 25 Jun 2026 21:38:16 -0400 Subject: [PATCH 28/96] fix(eip8130): to8130Account supports address-only mode for delegated EOAs Split To8130AccountParameters into two shapes: - Smart account (userSalt + code + initialActors): derives CREATE2 address, exposes create() - Delegated EOA (address only): binds to a known address, no salt/code/actors needed, create() throws with a clear message directing to delegate(impl) This fixes the DX smell where EOA delegation required dummy userSalt/code/ initialActors. Now: to8130Account({ signer, address: eoaAddress }) is all that's needed for the delegated-EOA path; the first tx uses delegate(impl) in accountChanges and change([...]) to add actors. --- .../eip8130/accounts/to8130Account.ts | 163 +++++++++++++----- 1 file changed, 118 insertions(+), 45 deletions(-) diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index b5b1b5dd98..514e49e2b4 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -24,41 +24,86 @@ import { erc1167Bytecode } from '../utils/proxy.js' import { signActorChanges8130 } from '../utils/signActorChanges.js' import { type Signer, signTransaction8130 } from '../utils/signTransaction.js' -export type To8130AccountParameters = { - /** Signer for the controlling actor (produces `auth` / `sender_auth`). */ +/** + * Common base params shared by both `to8130Account` shapes. + * @internal + */ +type To8130AccountBase = { + /** Signer that produces `sender_auth` / `auth` blobs for this account. */ signer: Signer - /** User-chosen uniqueness factor (bytes32). */ - userSalt: Hex - /** Runtime bytecode placed at the account address (e.g. ERC-1167 proxy). */ - code: Hex /** - * Initial actors registered at creation. MUST be sorted by `actorId` in - * strictly ascending order. - */ - initialActors: readonly AaActor[] - /** - * Authenticator address used for this account's `auth` blobs. Defaults to the - * native `ECRECOVER_AUTHENTICATOR`. + * Authenticator address for the signer's auth blobs. Defaults to the + * native `ECRECOVER_AUTHENTICATOR` (secp256k1). Set to the P-256 / + * WebAuthn / delegate authenticator address for non-K1 signers. */ authenticator?: Address | undefined - /** Account Configuration contract (CREATE2 deployer). */ - accountConfigAddress?: Address | undefined - /** Override the derived account address. */ - address?: Address | undefined } +/** + * Parameters for `to8130Account` — two mutually exclusive shapes: + * + * **Smart-account shape** (`userSalt` + `code` + `initialActors`): derives the + * counterfactual CREATE2 address and exposes `create()` for first-deployment. + * + * **Address shape** (`address` only): binds to a known address (e.g. an EOA that + * will delegate via EIP-7702). `create()` is unavailable — use `delegate(impl)` + * in the first transaction's `accountChanges` instead. No `userSalt`, `code`, or + * `initialActors` are needed. + */ +export type To8130AccountParameters = To8130AccountBase & + ( + | { + /** + * User-chosen uniqueness factor (bytes32). Required to derive the + * counterfactual CREATE2 address. + */ + userSalt: Hex + /** Runtime bytecode placed at the account address (ERC-1167 proxy). */ + code: Hex + /** + * Initial actors registered at creation, sorted by `actorId` in strictly + * ascending order. + */ + initialActors: readonly AaActor[] + /** Account Configuration contract (CREATE2 deployer). Defaults to canonical. */ + accountConfigAddress?: Address | undefined + /** Override the derived address (advanced). */ + address?: Address | undefined + } + | { + /** + * A known account address — e.g. an EOA address for EIP-7702 delegation. + * When provided, `userSalt`, `code`, and `initialActors` are not needed + * and `create()` is not available. Use `delegate(impl)` in the first + * transaction's `accountChanges` to install delegation code. + */ + address: Address + userSalt?: undefined + code?: undefined + initialActors?: undefined + accountConfigAddress?: undefined + } + ) + export type To8130AccountReturnType = { readonly address: Address readonly signer: Signer readonly initialActors: readonly AaActor[] - /** Builds the `create` account-change entry (place in the first transaction). */ + /** + * Builds the `create` account-change entry (include in the first tx for + * smart accounts). Throws if the account was constructed with a known `address` + * (e.g. delegated EOA) — use `delegate(impl)` instead. + */ create(): AaAccountChangeCreate /** Signs an `authorizeActor` / `revokeActor` set into a `config` entry. */ change( actorChanges: readonly AaActorChange[], options?: { chainId?: number; sequence?: number }, ): Promise - /** Builds a `delegation` account-change entry. */ + /** + * Builds an EIP-7702 `delegation` account-change entry. + * Include in the first transaction's `accountChanges` for delegated EOA accounts. + */ delegate(target: Address): AaAccountChangeDelegation /** Signs an `AA_TX_TYPE` transaction as this account (configured-actor path). */ signTransaction( @@ -70,45 +115,63 @@ export type To8130AccountReturnType = { } /** - * Creates a local EIP-8130 account helper around a signer and an account - * identity (`userSalt` + `code` + `initialActors`). Provides ergonomic builders - * for the account lifecycle: + * Creates a local EIP-8130 account helper for the **configured-actor** signing + * path (authenticator-prefixed `senderAuth`). Two shapes: * - * - `create()` — the `create` account-change entry that deploys the account - * - `change([...])` — a signed `config` entry (authorize / revoke actors) - * - `delegate(target)` — a `delegation` entry - * - `signTransaction(tx)` — signs an `AA_TX_TYPE` transaction as this account + * **Smart-account** — supply `userSalt + code + initialActors`: + * ```ts + * const account = to8130Account({ signer, userSalt, code, initialActors }) + * // first tx: accountChanges: [account.create()] + * ``` * - * @example - * import { to8130Account, key, authorizeActor, actorScope } from 'viem/experimental' + * **Delegated EOA** — supply `address` only (no salt, no code, no actors): + * ```ts + * const account = to8130Account({ signer, address: eoaSigner.address }) + * // first tx: accountChanges: [account.delegate(deployment.accounts.default)] + * // add keys: accountChanges: [account.delegate(...), await account.change([...])] + * ``` * - * const account = to8130Account({ - * signer, - * userSalt, - * code: erc1167Bytecode(impl), - * initialActors: [key.k1(signer.address)], + * For the P256 / WebAuthn actor to drive the EOA after delegation, construct + * a second handle with the new signer but the same `address`: + * ```ts + * const accountAsP256 = to8130Account({ + * signer: p256, + * authenticator: p256.authenticator, + * address: eoaSigner.address, * }) + * ``` * - * const create = account.create() - * const change = await account.change([ - * authorizeActor(key.p256({ x, y }), { scope: actorScope.sender }), - * ]) + * For pure EOA K1 signing (no contract, raw 65-byte sig) see {@link toEoa8130Account}. + * For a new smart account with auto-derived address see {@link newSmartAccount8130}. */ export function to8130Account( parameters: To8130AccountParameters, ): To8130AccountReturnType { const { signer, - userSalt, - code, - initialActors, authenticator = ecrecoverAuthenticator, - accountConfigAddress = defaultAccountConfigAddress, } = parameters - const address = - parameters.address ?? - computeAddress8130({ userSalt, code, initialActors, accountConfigAddress }) + // Address-only mode (delegated EOA): address is fixed, no CREATE2 derivation. + const isAddressOnly = parameters.userSalt === undefined + + const address: Address = (() => { + if (parameters.address) return parameters.address + if (isAddressOnly) + throw new BaseError( + 'Provide `address` or `userSalt + code + initialActors` to derive the account address.', + ) + return computeAddress8130({ + userSalt: parameters.userSalt!, + code: parameters.code!, + initialActors: parameters.initialActors!, + accountConfigAddress: + (parameters as { accountConfigAddress?: Address }).accountConfigAddress ?? + defaultAccountConfigAddress, + }) + })() + + const initialActors = parameters.initialActors ?? [] return { address, @@ -116,7 +179,17 @@ export function to8130Account( initialActors, create() { - return { type: 'create', userSalt, code, initialActors } + if (isAddressOnly) + throw new BaseError( + '`create()` is not available for address-only (delegated EOA) accounts. ' + + 'Include `account.delegate(impl)` in `accountChanges` instead.', + ) + return { + type: 'create', + userSalt: parameters.userSalt!, + code: parameters.code!, + initialActors: parameters.initialActors!, + } }, async change(actorChanges, options = {}) { From 3852a4725b0ea459dfd41d21e7989488ed47ddaa Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Fri, 26 Jun 2026 06:07:54 -0400 Subject: [PATCH 29/96] =?UTF-8?q?feat(eip8130):=20give=20toEoa8130Account?= =?UTF-8?q?=20the=20full=20interface=20=E2=80=94=20delegate=20+=20change?= =?UTF-8?q?=20+=20sign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `delegate(target)` and `change(actorChanges, opts)` to `toEoa8130Account` so a bare EOA can build its first-tx delegation and actor-config changes without needing a second account handle. `signTransaction` still uses the implicit self-actor path: no `from` field, raw 65-byte K1 signature, sender recovered via ecrecover. --- .../eip8130/accounts/to8130Account.ts | 105 ++++++++++++++---- 1 file changed, 81 insertions(+), 24 deletions(-) diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index 514e49e2b4..0573245977 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -369,16 +369,47 @@ export function newSmartAccount8130( // ───────────────────────────────────────────────────────────────────────────── export type ToEoa8130AccountReturnType = { - /** The EOA address — used as both `sender` and signer identity. */ + /** The EOA address — both the sender identity and the key recovery target. */ readonly address: Address readonly signer: Signer /** - * Signs an EIP-8130 transaction as a bare EOA (implicit self-actor). + * Builds an EIP-7702 `delegation` account-change entry that sets the code + * at this EOA address. Include in the first transaction's `accountChanges` + * to enable smart-account execution (e.g. `executeBatch`, multi-actor auth). * - * The `senderAuth` is a raw 65-byte secp256k1 signature with no authenticator - * prefix — the node recovers the sender address directly via `ecrecover`. - * Use this when you want native EIP-8130 features (e.g. payer sponsorship) - * without deploying a smart-contract account. + * @example + * await account.signTransaction({ + * accountChanges: [account.delegate(deployment.accounts.default)], + * calls: wire, ... + * }) + */ + delegate(target: Address): AaAccountChangeDelegation + /** + * Signs an `authorizeActor` / `revokeActor` set into a `config` account-change + * entry. Use to add P-256 / WebAuthn keys or remove the K1 actor after + * delegation without needing a separate account handle. + * + * @example + * // Atomically delegate + add a P256 key in the first tx: + * const addP256 = await account.change([ + * authorizeActor(key.p256(p256.publicKey), { scope: actorScope.sender }), + * ], { chainId, sequence: 0 }) + * await account.signTransaction({ + * accountChanges: [account.delegate(impl), addP256], + * calls: wire, ... + * }) + */ + change( + actorChanges: readonly AaActorChange[], + options?: { chainId?: number; sequence?: number }, + ): Promise + /** + * Signs an EIP-8130 transaction using the EOA implicit self-actor path. + * + * `senderAuth` is a **raw 65-byte secp256k1 signature** — no authenticator + * address prefix, no `from` field in the tx body. The node recovers the sender + * via `ecrecover` and validates it as the implicit K1 self-actor. This is the + * cheapest auth path and works for both plain-EOA and delegated-EOA cases. */ signTransaction( transaction: TransactionSerializable8130, @@ -389,33 +420,43 @@ export type ToEoa8130AccountReturnType = { } /** - * Wraps a secp256k1 EOA signer for EIP-8130 transactions using the implicit - * self-actor path. The `senderAuth` is a raw 65-byte ECDSA signature (no - * authenticator prefix); the node recovers the sender via `ecrecover`. + * Wraps a secp256k1 EOA signer for EIP-8130 transactions using the **implicit + * self-actor** path. `senderAuth` is a raw 65-byte ECDSA signature (no + * authenticator prefix, no `from` field); the node recovers the sender via + * `ecrecover`. * - * Use this when your EOA address IS the account — no smart contract deployment - * needed. The signer can still receive payer sponsorship and use all other - * EIP-8130 features. + * Use this when the EOA key IS the account — whether the EOA is undelegated + * (pure K1, no contract) or delegated via EIP-7702 (use `delegate(impl)` in + * the first tx's `accountChanges`). Both cases use the same signing path. * - * For smart contract accounts (create / delegate / configure), use - * {@link newSmartAccount8130} or {@link to8130Account} instead. + * To drive the same EOA address with a **different** actor (P-256 / WebAuthn) + * after delegation, use {@link to8130Account} with `address`: + * ```ts + * const accountAsP256 = to8130Account({ + * signer: p256, + * authenticator: p256.authenticator, + * address: eoaSigner.address, + * }) + * ``` * * @example - * const eoa = toEoa8130Account(privateKeyToAccount(pk)) + * // Pure EOA — no contract, payer-sponsored + * const account = toEoa8130Account(privateKeyToAccount(pk)) + * const signed = await account.signTransaction({ calls: wire, payer: payerAddr, ... }) * - * const signed = await eoa.signTransaction({ - * chainId, nonceKey: 0n, nonceSequence: 0n, - * calls: wire, - * gas: gasLimit, - * maxFeePerGas: 1_000_000_000n, - * maxPriorityFeePerGas: 1_000_000n, - * // No `from` → raw 65-byte EOA auth + * @example + * // Delegated EOA — delegate + add P256 in one shot + * const account = toEoa8130Account(privateKeyToAccount(pk)) + * const addP256 = await account.change([authorizeActor(key.p256(...))], { chainId, sequence: 0 }) + * const signed = await account.signTransaction({ + * accountChanges: [account.delegate(deployment.accounts.default), addP256], + * calls: wire, ... * }) */ export function toEoa8130Account(signer: Signer): ToEoa8130AccountReturnType { if (!signer.address) throw new BaseError( - '`signer.address` is required for an EOA account. Use `privateKeyToAccount(pk)` or equivalent.', + '`signer.address` is required. Use `privateKeyToAccount(pk)` or equivalent.', ) const address = signer.address @@ -423,11 +464,27 @@ export function toEoa8130Account(signer: Signer): ToEoa8130AccountReturnType { address, signer, + delegate(target) { + return { type: 'delegation', target } + }, + + async change(actorChanges, options = {}) { + return signActorChanges8130({ + signer, + account: address, + chainId: options.chainId ?? 0, + sequence: options.sequence ?? 0, + actorChanges, + authenticator: ecrecoverAuthenticator, + }) + }, + async signTransaction(transaction, options = {}) { if (!signer.sign) throw new BaseError('`signer` does not support raw signing.') return signTransaction8130({ - // Do NOT set `from` — signals the EOA path: raw 65-byte sig, no prefix. + // Omit `from` → EOA implicit self-actor path: + // senderAuth = raw 65-byte sig, sender recovered via ecrecover. transaction, account: signer, authenticator: ecrecoverAuthenticator, From d6fd5f029bd3f378477bb7063a2de07e2aa8381f Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Fri, 26 Jun 2026 08:05:44 -0400 Subject: [PATCH 30/96] feat(eip8130): add recoverSenderAddress8130 helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the sender (`from`) of an EIP-8130 transaction: returns `transaction.from` when present, otherwise (EOA path, where the wire omits `from`) recovers it via ecrecover over the sender signature hash computed with `from` empty — matching the node. Lets relayers / payers bind to the resolved sender for EOA-path txs. --- src/experimental/eip8130/index.ts | 5 +++ .../eip8130/utils/recoverSender.ts | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/experimental/eip8130/utils/recoverSender.ts diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 5a5d17b670..2e3db21a86 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -202,6 +202,11 @@ export { parseTransaction8130, } from './utils/parseTransaction.js' export { erc1167Bytecode } from './utils/proxy.js' +export { + type RecoverSenderAddress8130ErrorType, + type RecoverSenderAddress8130Parameters, + recoverSenderAddress8130, +} from './utils/recoverSender.js' export { type SerializeTransaction8130ErrorType, serializeTransaction8130, diff --git a/src/experimental/eip8130/utils/recoverSender.ts b/src/experimental/eip8130/utils/recoverSender.ts new file mode 100644 index 0000000000..0cb72d9917 --- /dev/null +++ b/src/experimental/eip8130/utils/recoverSender.ts @@ -0,0 +1,45 @@ +import type { Address } from 'abitype' +import type { ErrorType } from '../../../errors/utils.js' +import { recoverAddress } from '../../../utils/signature/recoverAddress.js' +import type { TransactionSerializable8130 } from '../types/transaction.js' +import { getSenderSignatureHash8130 } from './hashTransaction.js' + +export type RecoverSenderAddress8130Parameters = { + /** + * A parsed / serializable EIP-8130 transaction. Must carry `senderAuth`. + */ + transaction: TransactionSerializable8130 +} + +export type RecoverSenderAddress8130ErrorType = ErrorType + +/** + * Resolves the sender (`from`) address of an EIP-8130 transaction. + * + * - **Configured-actor path** (`transaction.from` set): returns it as-is. The + * `senderAuth` is `authenticator || data` and the sender is explicit. + * - **EOA path** (`transaction.from` omitted): the `senderAuth` is a raw 65-byte + * secp256k1 signature with no authenticator prefix. The sender is recovered + * via `ecrecover` over the sender signature hash (computed with `from` empty, + * exactly as the wire encodes it), matching the node's behaviour. + * + * Use this where a relayer / payer needs the resolved sender for an EOA-path + * transaction (e.g. to bind the payer signature to the recovered sender), since + * the wire format omits `from` in that case. + * + * @example + * const from = await recoverSenderAddress8130({ transaction: parsed }) + */ +export async function recoverSenderAddress8130( + parameters: RecoverSenderAddress8130Parameters, +): Promise
{ + const { transaction } = parameters + if (transaction.from) return transaction.from + if (!transaction.senderAuth || transaction.senderAuth === '0x') + throw new Error( + 'Cannot recover sender: transaction has neither `from` nor `senderAuth`.', + ) + // EOA path: sender hash is computed with `from` empty (the wire form). + const hash = getSenderSignatureHash8130({ ...transaction, from: undefined }) + return recoverAddress({ hash, signature: transaction.senderAuth }) +} From eef1c7a9d40aa2c0b4f90ef90e198c83eb0b7d68 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Fri, 26 Jun 2026 09:03:01 -0400 Subject: [PATCH 31/96] fix(eip8130): correct canonical vibenet erc4337 + upgradeable addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `erc4337` implementation address (0xe8e6317b…) was stale and has no code on the devnet, so ERC-1167 proxies pointed at it would delegatecall into the void — succeeding silently while executing nothing (success receipt, no state change). Point it at the actually-deployed ERC4337Account (0xfd054f…). Also fix the malformed 39-hex-char `upgradeable` address (…4f68Ea → …4f68Eaa). --- src/experimental/eip8130/deployments.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index d38779194c..a2bbbddcb7 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -65,8 +65,8 @@ export const canonicalEip8130Deployment = { accounts: { default: '0xca8D7419FEC024a5CEDB8D427615f3A74E3ebA6b', defaultHighRate: '0x9bB1a51927A7B8Fc433956E1a417DB9f97465527', - erc4337: '0xe8e6317b1440ead4a3fc93e17cee77324a509923', - upgradeable: '0x7Cf83aB369Fefabe2C9cb6D7C9DE816cc4f68Ea', + erc4337: '0xfd054f275750DA23893aECaDE788825f8A3F434C', + upgradeable: '0x7Cf83aB369Fefabe2C9cb6D7C9DE816cc4f68Eaa', }, authenticators: { k1: '0x0000000000000000000000000000000000000001', From 0e20d5a75cdf152eede7c12b41eaee82bd5b1b8a Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 7 Jul 2026 17:06:10 -0400 Subject: [PATCH 32/96] feat(eip8130): estimateGas8130 speaks raw sender/payer auth blobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the updated base/base eth_estimateGas RPC shape (feat(eip8130-rpc): price estimateGas from raw sender/payer auth blobs, base/base#3855), which replaced the senderAuthScheme / senderAuthSize / payerAuthScheme / payerAuthSize fields with raw senderAuth / payerAuth blobs priced by shape. - senderAuth / payerAuth: pass a raw blob through verbatim. - senderAuthVerifier / payerAuthVerifier: a verifier (authenticator contract) address hint — more general than the old named-scheme enum, since it prices any authenticator (including future/custom ones) with no API change. Synthesizes `verifier || filler`, using a new canonicalAuthDataLength default length unless senderAuthSize/payerAuthSize overrides it. - senderAuthSize alone (no verifier) now prices a bare filler blob — the default-EOA path at an explicit length. - sender accepted alongside from (must agree if both set, at least one required), matching the node's account resolution. Both request-building branches always send `sender` now so the node still recognizes the request as EIP-8130 even with no auth blob. Drops the Eip8130AuthScheme enum and its senderAuthScheme / payerAuthScheme fields entirely. --- .../eip8130/accounts/to8130Account.ts | 1 - .../eip8130/actions/estimateGas8130.ts | 208 ++++++++++++------ src/experimental/eip8130/constants.ts | 18 ++ src/experimental/eip8130/index.ts | 2 +- 4 files changed, 162 insertions(+), 67 deletions(-) diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index 0573245977..45e4073a5e 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -312,7 +312,6 @@ export type NewSmartAccount8130ReturnType = To8130AccountReturnType & { * from: account.address, * accountChanges: [account.createChange], * calls: [[{ to: recipient, value }]], - * senderAuthScheme: 'secp256k1', * }) * const signed = await account.signTransaction({ * chainId, nonceKey: 0n, nonceSequence: 0n, diff --git a/src/experimental/eip8130/actions/estimateGas8130.ts b/src/experimental/eip8130/actions/estimateGas8130.ts index d34168f923..4f45376888 100644 --- a/src/experimental/eip8130/actions/estimateGas8130.ts +++ b/src/experimental/eip8130/actions/estimateGas8130.ts @@ -7,40 +7,36 @@ import type { Account } from '../../../types/account.js' import type { BlockTag } from '../../../types/block.js' import type { Chain } from '../../../types/chain.js' import type { Hex } from '../../../types/misc.js' +import { concatHex } from '../../../utils/data/concat.js' import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import { numberToHex } from '../../../utils/encoding/toHex.js' -import { aaTransactionType } from '../constants.js' +import { aaTransactionType, canonicalAuthDataLength } from '../constants.js' import type { AaAccountChange, AaCalls } from '../types/transaction.js' -/** - * Authentication scheme an EIP-8130 actor uses to sign. The node prices the - * authentication gas deterministically from the auth-blob shape, so declaring - * the scheme lets `eth_estimateGas` charge the right amount without a real - * signature. - */ -export type Eip8130AuthScheme = 'secp256k1' | 'p256' | 'webAuthn' - export type EstimateGas8130Parameters = { /** - * Sender address. **Required** — the sender drives actor/policy resolution, - * and the node returns `INVALID_PARAMS` for an EIP-8130 estimate without it. + * Sender address. **Required** (or {@link EstimateGas8130Parameters.sender}) + * — the sender drives actor/policy resolution, and the node returns + * `INVALID_PARAMS` for an EIP-8130 estimate without one. + */ + from?: Address | undefined + /** + * The EIP-8130 sender account. Interchangeable with `from` — the two must + * agree if both are set. Prefer this when you don't otherwise need `from` + * (e.g. no plain-tx fallback), since it's the field the wire transaction + * itself carries. */ - from: Address + sender?: Address | undefined // ── Simplified mode (no accountChanges/calls) ───────────────────────────── - // The node synthesises stub auth blobs from the declared scheme. Use this - // when the caller only needs to price a call from an already-deployed account - // without knowing the full transaction shape. + // Use this when the caller only needs to price a call from an + // already-deployed account without knowing the full transaction shape. /** Target of the (representative) call being estimated. */ to?: Address | undefined /** Calldata of the call being estimated. */ data?: Hex | undefined /** Native value sent with the call. */ value?: bigint | undefined - /** Sender authentication scheme. Defaults to `secp256k1` on the node. */ - senderAuthScheme?: Eip8130AuthScheme | undefined - /** Override the sender auth-payload byte length (otherwise scheme-derived). */ - senderAuthSize?: number | undefined // ── Full-body mode (accountChanges + calls) ──────────────────────────────── // When `accountChanges` or `calls` is provided, the request is sent as a @@ -48,7 +44,7 @@ export type EstimateGas8130Parameters = { // calls + metadata). The node routes this through the real EIP-8130 executor // simulation, which is required to correctly price account-creation // (`create` account-change) and per-phase call overhead. The simplified - // `to`/`data`/`value`/`senderAuthScheme` fields are ignored in this mode. + // `to`/`data`/`value` fields are ignored in this mode. /** * Account-change operations included in the transaction (e.g. `create` for * new smart-account deployment). Pass an empty array for follow-up txs on an @@ -71,12 +67,43 @@ export type EstimateGas8130Parameters = { */ nonceSequence?: number | undefined + // ── Sender authentication ─────────────────────────────────────────────── + // The node prices authentication gas from the *shape* of the auth blob it + // is given, exactly as it would from a real signature — so declaring the + // right shape here (without signing) gets an accurate estimate. Provide + // exactly one of the following (in priority order if more than one is set): + /** + * The full raw `senderAuth` blob to price verbatim: `authenticator(20) || + * data` for a configured account, or a bare signature for the default EOA. + * Use when you already have (or want full control over) the exact blob. + */ + senderAuth?: Hex | undefined + /** + * Verifier (authenticator contract) address hint. The blob is synthesized + * as `verifier || filler`, where `filler` is `senderAuthSize` bytes if + * given, else a representative default length for known canonical + * verifiers ({@link canonicalAuthenticators}) — pass `senderAuthSize` + * explicitly for a custom verifier with no known default. + */ + senderAuthVerifier?: Address | undefined + /** + * Sender auth-payload byte length. Combined with `senderAuthVerifier`, it + * overrides the verifier's default length. Alone (no verifier), it prices a + * bare (unprefixed, default-EOA-path) filler blob of this length. + */ + senderAuthSize?: number | undefined + // ── Common ──────────────────────────────────────────────────────────────── /** Optional sponsoring payer; priced into the estimate when set. */ payer?: Address | undefined - /** Payer authentication scheme. Defaults to `secp256k1` on the node. */ - payerAuthScheme?: Eip8130AuthScheme | undefined - /** Override the payer auth-payload byte length (otherwise scheme-derived). */ + /** + * The full raw `payerAuth` blob to price verbatim (always the prefixed + * `authenticator(20) || data` form). See `senderAuth`. + */ + payerAuth?: Hex | undefined + /** Payer verifier address hint. See `senderAuthVerifier`. */ + payerAuthVerifier?: Address | undefined + /** Payer auth-payload byte length override. See `senderAuthSize`. */ payerAuthSize?: number | undefined /** Block number to estimate against. */ blockNumber?: bigint | undefined @@ -93,23 +120,28 @@ const maxAuthSize = 8_192 * Estimates gas for an EIP-8130 (`AA_TX_TYPE`) call via `eth_estimateGas`. * * Requires a node with the EIP-8130 `eth_estimateGas` extension - * (base `feat(eip8130): add eth_estimateGas for 8130`). The estimate runs a - * read-only `simulate` on the executor: no signature verification, no fee - * settlement, all state reverted. It shares the pre-call pipeline - * (account-change apply, auto-delegation, intrinsic gas) with the verifying - * `execute` path, so the estimate cannot drift from real execution gas. + * (base `feat(eip8130-rpc): price estimateGas from raw sender/payer auth + * blobs`). The estimate runs a read-only `simulate` on the executor: no + * signature verification, no fee settlement, all state reverted. It shares + * the pre-call pipeline (account-change apply, auto-delegation, intrinsic + * gas) with the verifying `execute` path, so the estimate cannot drift from + * real execution gas. * * Two request modes: * - * **Simplified** — omit `accountChanges`/`calls`. The node synthesises stub - * auth blobs from the declared `senderAuthScheme` and `senderAuthSize`. - * Suitable for pricing individual calls from an already-deployed account. + * **Simplified** — omit `accountChanges`/`calls`. Suitable for pricing + * individual calls from an already-deployed account. * * **Full-body** — supply `accountChanges` and/or `calls`. The request is sent * as a complete EIP-8130 tx body and routed through the real executor * simulation. Required to correctly price account-creation (`create` * account-change) and per-phase call overhead. * + * In both modes, the node prices authentication gas from the auth blob's + * *shape*, never a real signature — pass `senderAuthVerifier` (or a raw + * `senderAuth`) for a configured account, and leave both unset for the + * default EOA. See {@link EstimateGas8130Parameters}. + * * Note: unlike standard `eth_estimateGas`, an EIP-8130 estimate returns the * charged gas **even when a phase reverts**, because a reverted EIP-8130 tx is * still included (nonce consumed, fee paid). @@ -123,25 +155,33 @@ export async function estimateGas8130< ): Promise { const { from, + sender, to, data, value, - senderAuthScheme, + senderAuth: senderAuthExplicit, + senderAuthVerifier, senderAuthSize, accountChanges, calls, nonceKey = 0n, nonceSequence = 0, payer, - payerAuthScheme, + payerAuth: payerAuthExplicit, + payerAuthVerifier, payerAuthSize, blockNumber, blockTag = 'pending', } = parameters - if (!from) + const account_ = sender ?? from + if (!account_) throw new BaseError( - '`from` is required for an EIP-8130 gas estimate: the sender drives actor/policy resolution.', + '`sender` (or `from`) is required for an EIP-8130 gas estimate: the sender drives actor/policy resolution.', + ) + if (sender && from && sender !== from) + throw new BaseError( + `\`sender\` (${sender}) and \`from\` (${from}) must agree when both are set.`, ) for (const size of [senderAuthSize, payerAuthSize]) @@ -150,6 +190,17 @@ export async function estimateGas8130< `auth size ${size} out of range (0..=${maxAuthSize}).`, ) + const senderAuth = buildAuthBlob( + senderAuthExplicit, + senderAuthVerifier, + senderAuthSize, + ) + const payerAuth = buildAuthBlob( + payerAuthExplicit, + payerAuthVerifier, + payerAuthSize, + ) + const useFullBody = accountChanges !== undefined || calls !== undefined let request: Record @@ -161,8 +212,7 @@ export async function estimateGas8130< // deserialiser (expects u64, not a hex string). request = { type: aaTransactionType, - from, - sender: from, + sender: account_, nonceKey: Number(nonceKey), nonceSequence, expiry: 0, @@ -172,46 +222,35 @@ export async function estimateGas8130< // Large gas cap so the simulation isn't capped below real execution. gasLimit: 30_000_000, accountChanges: (accountChanges ?? []).map(serializeAccountChange), - calls: (calls ?? [[{ to: from, value: 0n, data: '0x' as Hex }]]).map( - (phase) => - phase.map((c) => ({ - to: c.to, - value: numberToHex(c.value ?? 0n), - data: c.data ?? '0x', - })), + calls: ( + calls ?? [[{ to: account_, value: 0n, data: '0x' as Hex }]] + ).map((phase) => + phase.map((c) => ({ + to: c.to, + value: numberToHex(c.value ?? 0n), + data: c.data ?? '0x', + })), ), metadata: '0x', payer: payer ?? null, } - // Pass auth-scheme hints so the node can price intrinsic gas correctly even - // when the authenticator type cannot be fully inferred from on-chain state - // (e.g. the account hasn't been deployed yet). The node ignores these in - // simulate mode when it can determine the scheme from initialActors, so - // providing them is always safe. - if (senderAuthScheme !== undefined) request.senderAuthScheme = senderAuthScheme - if (senderAuthSize !== undefined) - request.senderAuthSize = numberToHex(senderAuthSize) - if (payerAuthScheme !== undefined) request.payerAuthScheme = payerAuthScheme - if (payerAuthSize !== undefined) - request.payerAuthSize = numberToHex(payerAuthSize) + if (senderAuth !== undefined) request.senderAuth = senderAuth + if (payerAuth !== undefined) request.payerAuth = payerAuth } else { - // Simplified mode: let the node synthesise stub auth blobs from the scheme. + // Simplified mode. `sender` is always set so the node recognizes the + // request as an EIP-8130 estimate even when no auth blob is supplied + // (an absent auth blob is a valid default-EOA/configured-k1 request, not + // an indication this is a plain, non-8130 request). request = { type: aaTransactionType, - from, + sender: account_, } if (to !== undefined) request.to = to if (data !== undefined) request.data = data if (value !== undefined) request.value = numberToHex(value) - if (senderAuthScheme !== undefined) - request.senderAuthScheme = senderAuthScheme - if (senderAuthSize !== undefined) - request.senderAuthSize = numberToHex(senderAuthSize) if (payer !== undefined) request.payer = payer - if (payerAuthScheme !== undefined) - request.payerAuthScheme = payerAuthScheme - if (payerAuthSize !== undefined) - request.payerAuthSize = numberToHex(payerAuthSize) + if (senderAuth !== undefined) request.senderAuth = senderAuth + if (payerAuth !== undefined) request.payerAuth = payerAuth } const block = blockNumber !== undefined ? numberToHex(blockNumber) : blockTag @@ -226,6 +265,45 @@ export async function estimateGas8130< return hexToBigInt(gas) } +/** + * Builds the raw `senderAuth`/`payerAuth` blob to price, in priority order: + * + * 1. `explicit` — pass the caller's raw blob through verbatim. + * 2. `verifier` set — synthesize `verifier || filler`, where `filler` is + * `size` bytes if given, else the verifier's known default length + * ({@link canonicalAuthDataLength}). Throws if neither is available. + * 3. `size` alone (no verifier) — a bare (unprefixed) filler blob of `size` + * bytes, pricing the default-EOA path at a specific length. + * 4. All unset — `undefined` (no auth blob on the request; the node applies + * its own default). + * + * The filler byte (`0xff`) is arbitrary but non-zero, so its EIP-2028 + * calldata cost matches a real (high-entropy) signature rather than + * under-pricing it as zero bytes. It is never recovered — the estimate prices + * shape only, never verifies a signature. + */ +function buildAuthBlob( + explicit: Hex | undefined, + verifier: Address | undefined, + size: number | undefined, +): Hex | undefined { + if (explicit !== undefined) return explicit + if (verifier === undefined) { + if (size === undefined) return undefined + return filler(size) + } + const dataLength = size ?? canonicalAuthDataLength[verifier.toLowerCase()] + if (dataLength === undefined) + throw new BaseError( + `No default auth-payload length is known for verifier ${verifier}. Pass an explicit auth size.`, + ) + return concatHex([verifier, filler(dataLength)]) +} + +function filler(length: number): Hex { + return `0x${'ff'.repeat(length)}` as Hex +} + /** * Converts a typed `AaAccountChange` to the plain JSON object the node's * `eth_estimateGas` deserialiser expects. For `create` changes, all fields are diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts index d8352dd3cc..15230c1ff1 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/experimental/eip8130/constants.ts @@ -97,6 +97,24 @@ export const canonicalAuthenticators = { delegate: '0x4C4D27e56087797Feca62262417d57be4e30dD1F', } as const satisfies Record +/** + * Representative authentication-payload byte length (the bytes after a + * prefixed blob's 20-byte authenticator selector) for each canonical + * authenticator, keyed by lowercased address. Used by `estimateGas8130` to + * synthesize an auth-blob stub from a verifier-address hint alone, without + * requiring the caller to know the exact real signature length. + * + * @remarks + * These are representative defaults, not exact sizes — e.g. a real WebAuthn + * payload's length varies with client-data JSON length. Mirrors the node's + * `Eip8130AuthScheme::default_data_len`. Pass an explicit size to override. + */ +export const canonicalAuthDataLength: Record = { + [canonicalAuthenticators.k1.toLowerCase()]: 65, + [canonicalAuthenticators.p256.toLowerCase()]: 128, + [canonicalAuthenticators.passkey.toLowerCase()]: 256, +} + /** Nonce Manager precompile address (`NONCE_MANAGER_ADDRESS`). */ export const nonceManagerAddress = '0x813000000000000000000000000000000000aa01' satisfies Hex diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 2e3db21a86..637738f7ee 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -23,7 +23,6 @@ export { toSmartAccount8130, } from './accounts/toSmartAccount8130.js' export { - type Eip8130AuthScheme, type EstimateGas8130Parameters, type EstimateGas8130ReturnType, estimateGas8130, @@ -78,6 +77,7 @@ export { accountConfigAddress, actorChangeType, actorScope, + canonicalAuthDataLength, canonicalAuthenticators, defaultAccountAddress, deploymentHeaderSize, From 43950d19076301066adb8a9af8e37cde54f65d94 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 8 Jul 2026 15:41:48 -0400 Subject: [PATCH 33/96] feat(eip8130): default to UpgradeableAccount + repin AA tx type - Default newSmartAccount8130 to UpgradeableAccount behind the 93-byte ERC-1967 UpgradeableProxy (add upgradeableProxyBytecode); opt out with `upgradeable: false` for the immutable DefaultHighRateAccount (ERC-1167). - Refresh canonical deployment / authenticator / policy addresses to the current base/eip-8130 deploy; drop the never-deployed standalone DefaultAccount and make BackwardsCompatible4337Account opt-in. - Repin AA_TX_TYPE 0x7b->0x79 and AA_PAYER_TYPE 0x7c->0x7a (ethereum/EIPs#11903, base/base#3887). - Expose the experimental/eip8168 entrypoint. --- package.json | 2 +- .../eip8130/accounts/to8130Account.ts | 45 +++++++++----- .../eip8130/actions/getTransaction8130.ts | 13 +++-- .../actions/getTransactionReceipt8130.ts | 2 +- src/experimental/eip8130/constants.ts | 18 +++--- src/experimental/eip8130/deployments.ts | 58 +++++++++++-------- src/experimental/eip8130/devx.test.ts | 2 +- src/experimental/eip8130/index.ts | 2 +- src/experimental/eip8130/policies.test.ts | 2 +- src/experimental/eip8130/utils/proxy.ts | 37 +++++++++++- 10 files changed, 121 insertions(+), 60 deletions(-) diff --git a/package.json b/package.json index 6af4a40d09..426a9bfcac 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "src": { "entry": [ "index.ts!", - "{account-abstraction,accounts,actions,celo,chains,ens,experimental,experimental/eip8130,experimental/erc7739,experimental/erc7821,experimental/erc7811,experimental/erc7846,experimental/erc7895,linea,node,nonce,op-stack,siwe,tempo,tempo/actions,tempo/chains,tempo/zones,utils,window,zksync}/index.ts!", + "{account-abstraction,accounts,actions,celo,chains,ens,experimental,experimental/eip8130,experimental/eip8168,experimental/erc7739,experimental/erc7821,experimental/erc7811,experimental/erc7846,experimental/erc7895,linea,node,nonce,op-stack,siwe,tempo,tempo/actions,tempo/chains,tempo/zones,utils,window,zksync}/index.ts!", "chains/utils.ts!" ], "ignore": [ diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index 45e4073a5e..55105e2385 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -20,7 +20,7 @@ import type { TransactionSerialized8130, } from '../types/transaction.js' import { computeAddress8130 } from '../utils/computeAddress.js' -import { erc1167Bytecode } from '../utils/proxy.js' +import { erc1167Bytecode, upgradeableProxyBytecode } from '../utils/proxy.js' import { signActorChanges8130 } from '../utils/signActorChanges.js' import { type Signer, signTransaction8130 } from '../utils/signTransaction.js' @@ -127,7 +127,7 @@ export type To8130AccountReturnType = { * **Delegated EOA** — supply `address` only (no salt, no code, no actors): * ```ts * const account = to8130Account({ signer, address: eoaSigner.address }) - * // first tx: accountChanges: [account.delegate(deployment.accounts.default)] + * // first tx: accountChanges: [account.delegate(deployment.accounts.defaultHighRate)] * // add keys: accountChanges: [account.delegate(...), await account.change([...])] * ``` * @@ -242,14 +242,21 @@ export type NewSmartAccount8130Parameters = { */ salt?: Hex | undefined /** - * Wallet implementation address (proxied via ERC-1167). - * Defaults to the canonical `DefaultAccount`. - * Ignored if `code` is provided. + * When `true` (default), the account is deployed as an `UpgradeableAccount` + * behind an ERC-1967 `UpgradeableProxy` (upgradeable via `upgradeBySignature`). + * When `false`, it is deployed as an immutable `DefaultHighRateAccount` behind + * a 45-byte ERC-1167 proxy. Ignored if `code` is provided. + */ + upgradeable?: boolean | undefined + /** + * Wallet implementation address the account proxies to. Defaults to the + * canonical `UpgradeableAccount` (or `DefaultHighRateAccount` when + * `upgradeable` is `false`). Ignored if `code` is provided. */ implementation?: Address | undefined /** - * Deployment bytecode override. Defaults to `erc1167Bytecode(implementation)` - * (or the canonical `DefaultAccount` implementation if neither is provided). + * Deployment bytecode override. Defaults to `upgradeableProxyBytecode(implementation)` + * (or `erc1167Bytecode(implementation)` when `upgradeable` is `false`). */ code?: Hex | undefined /** @@ -325,7 +332,13 @@ export type NewSmartAccount8130ReturnType = To8130AccountReturnType & { export function newSmartAccount8130( parameters: NewSmartAccount8130Parameters, ): NewSmartAccount8130ReturnType { - const { signer, implementation, extraActors = [], accountConfigAddress } = parameters + const { + signer, + implementation, + upgradeable = true, + extraActors = [], + accountConfigAddress, + } = parameters // Detect signer type and derive the primary actor. // P256 / WebAuthn signers expose `.publicKey`; K1 signers have `.address`. @@ -345,11 +358,17 @@ export function newSmartAccount8130( const salt = parameters.salt ?? randomBytes32() + // Default to the upgradeable account (ERC-1967 proxy); opt into the immutable + // DefaultHighRateAccount (ERC-1167 proxy) via `upgradeable: false`. const code = parameters.code ?? - erc1167Bytecode( - implementation ?? canonicalEip8130Deployment.accounts.default, - ) + (upgradeable + ? upgradeableProxyBytecode( + implementation ?? canonicalEip8130Deployment.accounts.upgradeable, + ) + : erc1167Bytecode( + implementation ?? canonicalEip8130Deployment.accounts.defaultHighRate, + )) const inner = to8130Account({ signer, @@ -378,7 +397,7 @@ export type ToEoa8130AccountReturnType = { * * @example * await account.signTransaction({ - * accountChanges: [account.delegate(deployment.accounts.default)], + * accountChanges: [account.delegate(deployment.accounts.defaultHighRate)], * calls: wire, ... * }) */ @@ -448,7 +467,7 @@ export type ToEoa8130AccountReturnType = { * const account = toEoa8130Account(privateKeyToAccount(pk)) * const addP256 = await account.change([authorizeActor(key.p256(...))], { chainId, sequence: 0 }) * const signed = await account.signTransaction({ - * accountChanges: [account.delegate(deployment.accounts.default), addP256], + * accountChanges: [account.delegate(deployment.accounts.defaultHighRate), addP256], * calls: wire, ... * }) */ diff --git a/src/experimental/eip8130/actions/getTransaction8130.ts b/src/experimental/eip8130/actions/getTransaction8130.ts index 3061b0572e..7af1d450de 100644 --- a/src/experimental/eip8130/actions/getTransaction8130.ts +++ b/src/experimental/eip8130/actions/getTransaction8130.ts @@ -4,16 +4,17 @@ import type { Transport } from '../../../clients/transports/createTransport.js' import type { Account } from '../../../types/account.js' import type { Chain } from '../../../types/chain.js' import type { Hash, Hex } from '../../../types/misc.js' +import { aaTransactionType } from '../constants.js' import type { AaAccountChange, AaCalls } from '../types/transaction.js' /** - * Strongly-typed representation of an EIP-8130 (`AA_TX_TYPE`, type `0x7b`) + * Strongly-typed representation of an EIP-8130 (`AA_TX_TYPE`, type `0x79`) * transaction as returned by `eth_getTransactionByHash` on a node with the * EIP-8130 extension. */ export type Transaction8130 = { /** EIP-8130 transaction type marker. */ - type: '0x7b' + type: typeof aaTransactionType /** Transaction hash (injected from the request — not present in the raw RPC response). */ hash: Hash /** Account address that sent the transaction (sender). */ @@ -63,7 +64,7 @@ export type GetTransaction8130ReturnType = Transaction8130 /** Raw RPC response shape for `eth_getTransactionByHash` on an 8130 node. */ type RawTx8130 = { - type: '0x7b' + type: typeof aaTransactionType tx: { chainId: number sender: Address @@ -118,15 +119,15 @@ export async function getTransaction8130< }) => Promise )({ method: 'eth_getTransactionByHash', params: [hash] }) - if (!raw || raw.type !== '0x7b') + if (!raw || raw.type !== aaTransactionType) throw new Error( - `getTransaction8130: expected type 0x7b but got type ${(raw as any)?.type ?? 'null'} for hash ${hash}`, + `getTransaction8130: expected type ${aaTransactionType} but got type ${(raw as any)?.type ?? 'null'} for hash ${hash}`, ) const body = raw.tx return { - type: '0x7b', + type: aaTransactionType, hash, from: raw.from ?? body.sender, chainId: body.chainId, diff --git a/src/experimental/eip8130/actions/getTransactionReceipt8130.ts b/src/experimental/eip8130/actions/getTransactionReceipt8130.ts index 75a7944d41..caacf36b4c 100644 --- a/src/experimental/eip8130/actions/getTransactionReceipt8130.ts +++ b/src/experimental/eip8130/actions/getTransactionReceipt8130.ts @@ -10,7 +10,7 @@ import type { Hash, Hex } from '../../../types/misc.js' * EIP-8130 receipt fields surfaced by `eth_getTransactionReceipt` * (base `feat(eip8130): RPC support for ... AA receipts`). * - * These are present only for `AA_TX_TYPE` (`0x7b`) receipts on a node with the + * These are present only for `AA_TX_TYPE` (`0x79`) receipts on a node with the * extension; they are `undefined` for standard receipts or older nodes. */ export type Eip8130ReceiptFields = { diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts index 15230c1ff1..fd3c5911ac 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/experimental/eip8130/constants.ts @@ -3,12 +3,12 @@ import type { Hex } from '../../types/misc.js' /** * EIP-2718 transaction type for EIP-8130 AA transactions (`AA_TX_TYPE`). */ -export const aaTransactionType = '0x7b' satisfies Hex +export const aaTransactionType = '0x79' satisfies Hex /** * Magic byte for payer signature domain separation (`AA_PAYER_TYPE`). */ -export const aaPayerType = '0x7c' satisfies Hex +export const aaPayerType = '0x7a' satisfies Hex /** Base intrinsic gas cost (`AA_BASE_COST`). */ export const aaBaseCost = 15000n @@ -89,12 +89,12 @@ export const externalCallerAuthenticator = export const canonicalAuthenticators = { /** secp256k1 — native sentinel (`ECRECOVER_AUTHENTICATOR`). */ k1: '0x0000000000000000000000000000000000000001', - /** P-256 (raw). base/eip-8130 deployment (Base Sepolia). */ - p256: '0x3AE129D846CD1CAf0369b4Caa56c188E18E11B15', - /** WebAuthn / FIDO2 passkey. base/eip-8130 deployment (Base Sepolia). */ - passkey: '0x1CB75BE39Fb950202BF4239010534B86EdA66c31', - /** Signature delegation (1-hop). base/eip-8130 deployment (Base Sepolia). */ - delegate: '0x4C4D27e56087797Feca62262417d57be4e30dD1F', + /** P-256 (raw). Canonical base/eip-8130 deployment. */ + p256: '0x28096E6f98996799A08fBbCFF0B7c0D512D1f503', + /** WebAuthn / FIDO2 passkey. Canonical base/eip-8130 deployment. */ + passkey: '0xD9B8d163a34FBaD781057F7B68889F0bbd70D7ed', + /** Signature delegation (1-hop). Canonical base/eip-8130 deployment. */ + delegate: '0xb1f064A99919E4199b45F1b553b6ecb8d5d62a11', } as const satisfies Record /** @@ -134,7 +134,7 @@ export const txContextAddress = * parameter of {@link computeAddress8130}. */ export const accountConfigAddress = - '0xAff8A7A86605D61197C1b98630d93B9d9702afb5' satisfies Hex + '0x2403408177dB7F8512a9593343a7C80371D8f2dF' satisfies Hex /** * Default wallet implementation for EOA auto-delegation diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index a2bbbddcb7..015e284d96 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -12,16 +12,25 @@ import type { Address } from 'abitype' export type Eip8130Deployment = { /** AccountConfiguration system contract (factory + actor-config registry). */ accountConfiguration: Address - /** Wallet implementation contracts (deployed behind ERC-1167 proxies). */ + /** Deployed wallet implementation contracts (the singletons account proxies delegate to). */ accounts: { - /** DefaultAccount implementation. */ - default: Address - /** DefaultHighRateAccount implementation. */ - defaultHighRate: Address - /** BackwardCompatibleERC4337Account — pass to {@link toSmartAccount8130}. */ - erc4337: Address - /** UpgradeableAccount implementation (proxied; upgradeable wallet logic). */ + /** + * UpgradeableAccount implementation — the default. Accounts are deployed + * behind an ERC-1967 `UpgradeableProxy` (see {@link upgradeableProxyBytecode}) + * so they can be upgraded via `upgradeBySignature`. + */ upgradeable: Address + /** + * DefaultHighRateAccount implementation — the immutable account. Deployed + * behind a 45-byte ERC-1167 proxy (see {@link erc1167Bytecode}). + */ + defaultHighRate: Address + /** + * BackwardsCompatible4337Account — an opt-in ERC-4337 example, not part of + * the canonical deployment. Deploy it yourself and pass to + * {@link toSmartAccount8130}. + */ + erc4337?: Address | undefined } /** Deployed authenticator contracts (for EVM execution on non-native chains). */ authenticators: { @@ -49,35 +58,34 @@ export type Eip8130Deployment = { } /** - * Canonical EIP-8130 deployment addresses, derived deterministically from - * `base/eip-8130` contract bytecode compiled with **solc 0.8.33** via - * `Deploy.s.sol`. These addresses are identical on every chain that runs the - * same bytecode (Base Sepolia, vibenet devnet, mainnet when live). + * Canonical EIP-8130 deployment addresses. Every contract is deployed through + * Nick's deterministic CREATE2 factory with `salt = 0` (see `base/eip-8130` + * `script/Deploy.s.sol`), so each address is a pure function of its compiled + * bytecode — identical on every chain (Base Sepolia, vibenet devnet, mainnet + * when live). * * `accountConfiguration` is enshrined in the execution client; using any other * value derives a different account address and the create transaction fails. * - * When the `base/eip-8130` contracts are recompiled (e.g. Solidity upgrade or + * When the `base/eip-8130` contracts are recompiled (Solidity upgrade or * bytecode change), all addresses must be re-derived and this object updated. */ export const canonicalEip8130Deployment = { - accountConfiguration: '0xC6595B992AF49099B476690d4D7CAb7D1890388F', + accountConfiguration: '0x2403408177dB7F8512a9593343a7C80371D8f2dF', accounts: { - default: '0xca8D7419FEC024a5CEDB8D427615f3A74E3ebA6b', - defaultHighRate: '0x9bB1a51927A7B8Fc433956E1a417DB9f97465527', - erc4337: '0xfd054f275750DA23893aECaDE788825f8A3F434C', - upgradeable: '0x7Cf83aB369Fefabe2C9cb6D7C9DE816cc4f68Eaa', + upgradeable: '0xF8dafa4DA35F664cf2CF842f00482ebb68a982b3', + defaultHighRate: '0x6c4230a4101849a3CB6438C40D3d47EdE9aca096', }, authenticators: { k1: '0x0000000000000000000000000000000000000001', - p256: '0x3AE129D846CD1CAf0369b4Caa56c188E18E11B15', - webAuthn: '0x1CB75BE39Fb950202BF4239010534B86EdA66c31', - delegate: '0xCc81575121084c3538773478577e04CA7e9b35B1', - alwaysValid: '0x520fBA4840729CB57b3Dc7B40D548AcF354DBA25', + p256: '0x28096E6f98996799A08fBbCFF0B7c0D512D1f503', + webAuthn: '0xD9B8d163a34FBaD781057F7B68889F0bbd70D7ed', + delegate: '0xb1f064A99919E4199b45F1b553b6ecb8d5d62a11', + alwaysValid: '0x4299a796C1D3ffCe7885ce13d9815C1b4DB2Ea94', }, policies: { - manager: '0x95540b6dA4EaEf672c767477e84EeEa94E318135', - sessionPolicy: '0x1577b86A7F621B2274909BeD3D9e7dE2a008151C', + manager: '0x5E5c3D54078d1000309233fEc116A83Df5a07E67', + sessionPolicy: '0xbd26BdA18Ee35F767ef03fD72356ae598ed6f793', }, } as const satisfies Eip8130Deployment @@ -88,7 +96,7 @@ export const baseSepoliaDeployment = canonicalEip8130Deployment * EIP-8130 deployment for the Base "vibenet" devnet (chain id `84538453`). * * The devnet runs EIP-8130 **natively** and the execution client enshrines the - * canonical CREATE2 addresses (solc 0.8.33 via `Deploy.s.sol`), identical to + * canonical CREATE2 addresses (via `Deploy.s.sol`), identical to * Base Sepolia and every other supported chain. Using any other * `accountConfiguration` derives a different account address and create * transactions fail with "create address mismatch". diff --git a/src/experimental/eip8130/devx.test.ts b/src/experimental/eip8130/devx.test.ts index 3d069e6b32..cda7bcc919 100644 --- a/src/experimental/eip8130/devx.test.ts +++ b/src/experimental/eip8130/devx.test.ts @@ -168,7 +168,7 @@ describe('sendCalls8130', () => { nonceSequence: 0n, }) expect(hash).toMatch(/^0x[0-9a-f]{64}$/) - expect(sent?.startsWith('0x7b')).toBe(true) + expect(sent?.startsWith('0x79')).toBe(true) const parsed = parseTransaction8130(sent!) expect(parsed.from?.toLowerCase()).toBe(account.address.toLowerCase()) diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 637738f7ee..38ee118fa8 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -201,7 +201,7 @@ export { type ParseTransaction8130ErrorType, parseTransaction8130, } from './utils/parseTransaction.js' -export { erc1167Bytecode } from './utils/proxy.js' +export { erc1167Bytecode, upgradeableProxyBytecode } from './utils/proxy.js' export { type RecoverSenderAddress8130ErrorType, type RecoverSenderAddress8130Parameters, diff --git a/src/experimental/eip8130/policies.test.ts b/src/experimental/eip8130/policies.test.ts index 2ffa4aec82..671f4a3049 100644 --- a/src/experimental/eip8130/policies.test.ts +++ b/src/experimental/eip8130/policies.test.ts @@ -50,7 +50,7 @@ describe('encoders', () => { describe('commitmentOf', () => { test('matches PolicyManager.commitmentOf reference vector', () => { expect(commitmentOf(binding)).toBe( - '0x8355affb37ecd58a95f726ba9bfa5025d41ca52273e4e4287afca2f3d30819d0', + '0x2addca9de83bd448fa36975b4bc653c10a237ea7fe2c6374ed514cf32c3d64d3', ) }) diff --git a/src/experimental/eip8130/utils/proxy.ts b/src/experimental/eip8130/utils/proxy.ts index c2a1052a84..3b3f49a708 100644 --- a/src/experimental/eip8130/utils/proxy.ts +++ b/src/experimental/eip8130/utils/proxy.ts @@ -4,8 +4,9 @@ import { concatHex } from '../../../utils/data/concat.js' /** * Builds the 45-byte ERC-1167 minimal proxy runtime bytecode that delegates to - * `implementation`. This is the `code` deployed at an EIP-8130 account address - * (see {@link computeAddress8130} and {@link toFactoryArgs8130}). + * `implementation`. This is the `code` deployed at an **immutable** EIP-8130 + * account address (e.g. `DefaultHighRateAccount`). See {@link computeAddress8130} + * and {@link toFactoryArgs8130}. */ export function erc1167Bytecode(implementation: Address): Hex { return concatHex([ @@ -14,3 +15,35 @@ export function erc1167Bytecode(implementation: Address): Hex { '0x5af43d82803e903d91602b57fd5bf3', ]) } + +/** + * Builds the 93-byte `UpgradeableProxy` runtime bytecode: an ERC-1967 proxy with + * a hardcoded default `implementation`. This is the `code` deployed at an + * **upgradeable** EIP-8130 account address (an `UpgradeableAccount`), and the + * per-account counterpart to the singleton implementation it delegates to. + * + * Proxy logic (see [base/eip-8130 `UpgradeableProxy`](https://github.com/base/eip-8130/blob/main/src/accounts/UpgradeableProxy.sol)): + * 1. `SLOAD` the ERC-1967 implementation slot. + * 2. If non-zero, `delegatecall` to that address (the upgraded path). + * 3. If zero, `delegatecall` to the hardcoded default (a fresh account). + * + * Pass an `UpgradeableAccount` implementation — only a UUPS-capable + * implementation can ever write the slot this proxy reads. Immutable accounts + * use {@link erc1167Bytecode} instead. + */ +export function upgradeableProxyBytecode(implementation: Address): Hex { + return concatHex([ + // PUSH32 ERC1967_SLOT; SLOAD; DUP1; ISZERO; PUSH2 default(0x002c); JUMPI; + // PUSH2 delegate(0x0043); JUMP + '0x7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc', + '0x5480156100', + '0x2c', + '0x576100', + '0x4356', + // default_label: JUMPDEST; POP; PUSH20 + '0x5b5073', + implementation, + // delegate_label: JUMPDEST; delegatecall; return/revert + '0x5b363d3d373d3d3d363d855af43d82803e903d91605b57fd5bf3', + ]) +} From c7a908960408751f22ce3789c7237d637fbe022b Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 8 Jul 2026 15:41:55 -0400 Subject: [PATCH 34/96] docs(eip8130): add EIP-8130 developer guide Overview + guides for creating accounts, sending transactions, calls & batching, receipts, metadata, rotating owners, session keys, sub accounts, sponsoring transactions, and ERC-8168 payer services. --- site/pages/experimental/eip8130.mdx | 91 +++++++++ .../eip8130/calls-and-batching.mdx | 99 ++++++++++ .../eip8130/creating-an-account.mdx | 115 +++++++++++ site/pages/experimental/eip8130/metadata.mdx | 58 ++++++ .../experimental/eip8130/payer-services.mdx | 185 ++++++++++++++++++ site/pages/experimental/eip8130/receipts.mdx | 63 ++++++ .../experimental/eip8130/rotating-owners.mdx | 145 ++++++++++++++ .../eip8130/sending-a-transaction.mdx | 138 +++++++++++++ .../experimental/eip8130/session-keys.mdx | 131 +++++++++++++ .../eip8130/sponsoring-transactions.mdx | 100 ++++++++++ .../experimental/eip8130/sub-accounts.mdx | 105 ++++++++++ site/vocs.config.ts | 49 +++++ 12 files changed, 1279 insertions(+) create mode 100644 site/pages/experimental/eip8130.mdx create mode 100644 site/pages/experimental/eip8130/calls-and-batching.mdx create mode 100644 site/pages/experimental/eip8130/creating-an-account.mdx create mode 100644 site/pages/experimental/eip8130/metadata.mdx create mode 100644 site/pages/experimental/eip8130/payer-services.mdx create mode 100644 site/pages/experimental/eip8130/receipts.mdx create mode 100644 site/pages/experimental/eip8130/rotating-owners.mdx create mode 100644 site/pages/experimental/eip8130/sending-a-transaction.mdx create mode 100644 site/pages/experimental/eip8130/session-keys.mdx create mode 100644 site/pages/experimental/eip8130/sponsoring-transactions.mdx create mode 100644 site/pages/experimental/eip8130/sub-accounts.mdx diff --git a/site/pages/experimental/eip8130.mdx b/site/pages/experimental/eip8130.mdx new file mode 100644 index 0000000000..ee6fa342e1 --- /dev/null +++ b/site/pages/experimental/eip8130.mdx @@ -0,0 +1,91 @@ +--- +description: Getting started with EIP-8130 native account abstraction in Viem +--- + +# EIP-8130 (Native Account Abstraction) [Overview] + +[EIP-8130](https://github.com/base/eip-8130) is a native account-abstraction transaction type (`AA_TX_TYPE`, `0x79`). Every account is a smart account governed by an on-chain **Account Configuration** contract, and transactions are sent directly to the chain — no bundler, no EntryPoint. Viem exposes the full flow through the `viem/experimental/eip8130` entrypoint. + +:::warning[Warning] +EIP-8130 is experimental and enabled per-chain. It is not yet live on mainnet. Do not solely rely on experimental features in production. +::: + +## What you get + +- **One account, many keys** — a single account address controlled by a set of *actors* (secp256k1 / P-256 / WebAuthn passkeys / delegates), each with its own scope and expiry. +- **Deploy-on-first-use** — the counterfactual address is known up front; the account is deployed atomically inside its first transaction. +- **Native transactions** — sign an `AA_TX_TYPE` transaction and submit it with `eth_sendRawTransaction`. Gas is priced by the node via an EIP-8130-aware `eth_estimateGas`. +- **Sponsored gas** — an optional `payer` co-signs the transaction so a third party pays the fee. +- **Session keys** — policy-gated actors (spend limits, target/selector allowlists) for scoped, delegated signing. + +## Mental model + +| Concept | Meaning | +| --- | --- | +| **Account** | Smart account at a CREATE2 address derived from `userSalt`, wallet `code`, and `initialActors`. | +| **Actor** | A key authorized on the account (`{ actorId, authenticator }`), built via the [`key`](/experimental/eip8130/rotating-owners#actors-and-keys) helpers. | +| **Account change** | A `create`, `config` (actor changes), or `delegation` operation applied atomically within a transaction. | +| **Auth blob** | `sender_auth` / `payer_auth` bytes: a bare 65-byte signature for the implicit EOA path, or `authenticator(20) || data` for a configured actor. | + +## Installation + +The helpers live under the dedicated entrypoint: + +```ts +import { + newSmartAccount8130, + sendCalls8130, + estimateGas8130, +} from 'viem/experimental/eip8130' +``` + +## Setup + +EIP-8130 actions are standalone — they take a Viem `Client` as their first argument (there is no `.extend()` client decorator). Configure a client for your 8130-enabled chain and register the chain id so `is8130Enabled` can route correctly: + +```ts +import { createClient, http, defineChain } from 'viem' +import { register8130Chains } from 'viem/experimental/eip8130' + +export const vibenet = defineChain({ + id: 84_538_453, + name: 'Vibenet Devnet', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['http://127.0.0.1:8545'] } }, +}) + +export const client = createClient({ chain: vibenet, transport: http() }) + +// Mark the chain as EIP-8130 enabled (empty by default). +register8130Chains(vibenet.id) +``` + +## Deployment addresses + +The protocol contracts (Account Configuration, wallet implementations, authenticators, example policies) are deployed deterministically and are identical on every chain running the same bytecode. Resolve them per chain: + +```ts +import { getEip8130Deployment, canonicalEip8130Deployment } from 'viem/experimental/eip8130' + +const deployment = getEip8130Deployment(84_538_453) ?? canonicalEip8130Deployment +deployment.accountConfiguration // factory + actor-config registry +deployment.accounts.upgradeable // UpgradeableAccount — the default wallet impl +deployment.accounts.defaultHighRate // DefaultHighRateAccount — immutable wallet impl +deployment.authenticators.p256 // P-256 authenticator +deployment.policies?.manager // example PolicyManager (session keys) +``` + +Two account implementations are deployed: **`UpgradeableAccount`** (the default — deployed behind an ERC-1967 `UpgradeableProxy`, upgradeable via `upgradeBySignature`) and **`DefaultHighRateAccount`** (immutable — deployed behind a 45-byte ERC-1167 proxy). `DefaultAccount` is a base building block inherited by both and is not deployed standalone; `BackwardsCompatible4337Account` is an opt-in ERC-4337 example you deploy yourself. + +## Guides + +- [Creating an Account](/experimental/eip8130/creating-an-account) — K1, P-256, and passkey accounts. +- [Sending a Transaction](/experimental/eip8130/sending-a-transaction) — estimate, deploy-on-first-use, sponsor gas, batch calls. +- [Calls & Batching](/experimental/eip8130/calls-and-batching) — atomic phases and value-bearing calls. +- [Receipts](/experimental/eip8130/receipts) — per-phase statuses, payer, and metadata. +- [Metadata](/experimental/eip8130/metadata) — attach opaque, authenticated application data. +- [Rotating Owners](/experimental/eip8130/rotating-owners) — authorize and revoke actors. +- [Session Keys](/experimental/eip8130/session-keys) — policy-gated, scoped signing keys. +- [Sub Accounts](/experimental/eip8130/sub-accounts) — many accounts per owner, linked via delegate actors. +- [Sponsoring Transactions](/experimental/eip8130/sponsoring-transactions) — pay another account's gas with a co-signing payer. +- [Payer Services (ERC-8168)](/experimental/eip8130/payer-services) — negotiate sponsorship / token payment with a web service. diff --git a/site/pages/experimental/eip8130/calls-and-batching.mdx b/site/pages/experimental/eip8130/calls-and-batching.mdx new file mode 100644 index 0000000000..73bda920b3 --- /dev/null +++ b/site/pages/experimental/eip8130/calls-and-batching.mdx @@ -0,0 +1,99 @@ +--- +description: Group EIP-8130 calls into atomic phases and control how value-bearing calls execute +--- + +# Calls & Batching + +Every EIP-8130 transaction carries a list of **calls** grouped into ordered **phases**. A phase is an atomic batch: if any call in a phase reverts, that phase's state changes are discarded and every later phase is skipped — but completed phases persist, and the transaction is still included (nonce consumed, fee paid). This phased model is what powers deploy-on-first-use, sponsor-then-act, and multi-step flows. + +## The `AaCalls` shape + +`calls` is a nested array — an array of phases, each an array of `AaCall`: + +```ts +import { type AaCalls, parseEther } from 'viem/experimental/eip8130' + +const calls: AaCalls = [ + // phase 0 — runs first, atomically + [{ to: tokenA, data: approveData }], + // phase 1 — runs only if phase 0 succeeded + [ + { to: router, data: swapData }, + { to: recipient, value: parseEther('0.01') }, + ], +] +``` + +Each `AaCall` is `{ to, data?, value? }`: + +- `to` — target address. +- `data` — calldata (defaults to `'0x'`). +- `value` — wei to send (defaults to `0n`). This is an ERC-5792-style *intent*; it is realized by the account's wallet bytecode and never travels on the EIP-8130 wire. + +## Flat vs. phased + +`sendCalls8130` accepts either shape. A **flat** array is sugar for a single phase; pass a **nested** array to control phases explicitly: + +```ts +import { sendCalls8130 } from 'viem/experimental/eip8130' + +// One atomic phase (flat): +await sendCalls8130(client, { + account, + calls: [{ to: a, data }, { to: b, data }], + gas: 300_000n, +}) + +// Two phases (nested): +await sendCalls8130(client, { + account, + calls: [[{ to: a, data }], [{ to: b, data }]], + gas: 300_000n, +}) +``` + +`estimateGas8130` and `prepareTransaction8130` always take the **phased** form (`AaCalls`). + +## How value-bearing calls execute + +A phase with no `value` passes each call straight to the wire as `[to, data]`. As soon as a phase contains any value-bearing call, the whole phase is collapsed into a single wallet-routed call — by default a self-call to the account's `executeBatch(Call[])`, which performs each value-bearing `CALL` while preserving the phase's atomicity. `encodeWalletCalls` implements this normalization: + +```ts +import { encodeWalletCalls, parseEther } from 'viem/experimental/eip8130' + +const wire = encodeWalletCalls({ + account: account.address, + calls: [[{ to: recipient, value: parseEther('1'), data: '0x' }]], +}) +// -> [[{ to: account.address, data: executeBatch([...]) }]] +``` + +## Custom executors + +Wallets whose bytecode does not expose `executeBatch` must supply their own `encodeExecute` — a function mapping a phase's normalized calls to a single wallet call. Pass it to `sendCalls8130` (or `encodeWalletCalls`): + +```ts +import { type EncodeExecute, sendCalls8130 } from 'viem/experimental/eip8130' +import { encodeFunctionData } from 'viem' + +const encodeExecute: EncodeExecute = ({ account, calls }) => ({ + to: account, + data: encodeFunctionData({ + abi: myWalletAbi, + functionName: 'execute', + args: [calls], + }), +}) + +await sendCalls8130(client, { account, calls, gas: 300_000n, encodeExecute }) +``` + +## Choosing phases + +- **Single atomic batch** → one phase. Everything succeeds together or nothing does. +- **Ordered, independently-committing steps** → multiple phases. Use this for pay-then-act (a [sponsored](/experimental/eip8130/sponsoring-transactions) phase-0 token transfer followed by the user's calls), or any flow where an early step must persist even if a later one reverts. + +## Next + +- Inspect per-phase outcomes on the [receipt](/experimental/eip8130/receipts). +- Attach application data with [metadata](/experimental/eip8130/metadata). diff --git a/site/pages/experimental/eip8130/creating-an-account.mdx b/site/pages/experimental/eip8130/creating-an-account.mdx new file mode 100644 index 0000000000..3ef4b0d97b --- /dev/null +++ b/site/pages/experimental/eip8130/creating-an-account.mdx @@ -0,0 +1,115 @@ +--- +description: Create an EIP-8130 smart account from a secp256k1, P-256, or WebAuthn passkey signer +--- + +# Creating an Account + +`newSmartAccount8130` turns a signer into an EIP-8130 smart account. It auto-derives the controlling actor, the deployment bytecode, and the counterfactual CREATE2 address. The account is **not** deployed yet — it is deployed atomically inside its [first transaction](/experimental/eip8130/sending-a-transaction#deploy-on-first-use). + +By default the account is an **`UpgradeableAccount`** deployed behind an ERC-1967 `UpgradeableProxy`, so its implementation can later be swapped via a CONFIG-key-signed `upgradeBySignature`. Pass `upgradeable: false` to deploy an immutable `DefaultHighRateAccount` behind a 45-byte ERC-1167 proxy instead. + +The signer type (K1 / P-256 / WebAuthn) is detected automatically. + +## secp256k1 (EOA key) + +```ts +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' +import { newSmartAccount8130 } from 'viem/experimental/eip8130' + +const owner = privateKeyToAccount(generatePrivateKey()) + +const account = newSmartAccount8130({ signer: owner }) + +account.address // counterfactual address (deterministic from the salt) +account.createChange // include in `accountChanges` to deploy on first use +``` + +Pass a fixed `salt` to recover the same address across sessions: + +```ts +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' +import { newSmartAccount8130 } from 'viem/experimental/eip8130' +const owner = privateKeyToAccount(generatePrivateKey()) +const account = newSmartAccount8130({ + signer: owner, + salt: '0x0000000000000000000000000000000000000000000000000000000000000001', +}) +``` + +## P-256 + +```ts +import * as P256 from 'ox/P256' +import { newSmartAccount8130, toP256Signer } from 'viem/experimental/eip8130' + +const signer = toP256Signer({ privateKey: P256.randomPrivateKey() }) + +const account = newSmartAccount8130({ signer }) +``` + +## WebAuthn / passkey + +```ts +import { createWebAuthnCredential, toWebAuthnAccount } from 'viem/account-abstraction' +import { newSmartAccount8130, toWebAuthnSigner } from 'viem/experimental/eip8130' + +const credential = await createWebAuthnCredential({ name: 'vibes' }) +const signer = toWebAuthnSigner(toWebAuthnAccount({ credential })) + +const account = newSmartAccount8130({ signer }) +``` + +## Multiple initial keys + +Register additional actors at creation with `extraActors`. Use the [`key`](/experimental/eip8130/rotating-owners#actors-and-keys) builders — the library sorts actors by `actorId` for you (a protocol requirement). + +```ts +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' +import { key, newSmartAccount8130, toP256Signer } from 'viem/experimental/eip8130' +import * as P256 from 'ox/P256' + +const owner = privateKeyToAccount(generatePrivateKey()) +const p256 = toP256Signer({ privateKey: P256.randomPrivateKey() }) + +const account = newSmartAccount8130({ + signer: owner, + extraActors: [key.p256(p256.publicKey)], +}) +``` + +## Plain / delegated EOAs + +To use a raw secp256k1 EOA as its own account (cheapest auth path — a bare 65-byte signature, no contract required), use `toEoa8130Account`. The same account can later be upgraded to a smart account via an EIP-7702 delegation in its first transaction. + +```ts +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' +import { toEoa8130Account } from 'viem/experimental/eip8130' + +const account = toEoa8130Account(privateKeyToAccount(generatePrivateKey())) + +// Later, install smart-account code with `account.delegate(impl)` in the first +// transaction's `accountChanges`. +``` + +## Cross-chain (ERC-4337) portability + +The same EIP-8130 account works on chains **without** native 8130 support through a bundler, by wrapping it as a standard Viem Smart Account. Execution routes through `executeBatch` on the `BackwardsCompatible4337Account` implementation, and deployment uses the Account Configuration contract as the ERC-4337 factory. + +`BackwardsCompatible4337Account` is an opt-in example — it is **not** part of the canonical deployment. Deploy it yourself (via the Account Configuration `CREATE2` factory) and pass its address as `implementation`: + +```ts +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' +import { toSmartAccount8130 } from 'viem/experimental/eip8130' + +const account = await toSmartAccount8130({ + client, + owner: privateKeyToAccount(generatePrivateKey()), + userSalt: '0x0000000000000000000000000000000000000000000000000000000000000001', + initialActors: [/* key.k1(owner.address), ... */], + implementation: erc4337AccountImplementation, // address of your deployed BackwardsCompatible4337Account +}) +``` + +## Next + +Now [send a transaction](/experimental/eip8130/sending-a-transaction) with your account. diff --git a/site/pages/experimental/eip8130/metadata.mdx b/site/pages/experimental/eip8130/metadata.mdx new file mode 100644 index 0000000000..c5f7b75217 --- /dev/null +++ b/site/pages/experimental/eip8130/metadata.mdx @@ -0,0 +1,58 @@ +--- +description: Attach opaque, authenticated application data to an EIP-8130 transaction +--- + +# Metadata + +An EIP-8130 transaction can carry an opaque, application-defined `metadata` blob — arbitrary bytes with no protocol meaning. It is appended **after** `calls` in the signed body, so it is authenticated by the sender (and, when present, the [payer](/experimental/eip8130/sponsoring-transactions)): it cannot be altered in flight without invalidating the signature. The node echoes it back on the [receipt](/experimental/eip8130/receipts). + +Use it to bind off-chain context to a transaction — a payer `policyId`, a client request id, an app tag — where you want the value tamper-evident but don't want to spend calldata inside a call. + +:::info +`metadata` is not interpreted on-chain and does not affect execution. It is a signed, echoed annotation — not a substitute for on-chain state or event logs. +::: + +## Setting metadata + +`metadata` is a field on the transaction, not a parameter of `sendCalls8130`. Build the transaction with `prepareTransaction8130`, set `metadata`, then sign and submit: + +```ts +import { sendRawTransaction } from 'viem/actions' +import { prepareTransaction8130 } from 'viem/experimental/eip8130' +import { stringToHex } from 'viem' + +const tx = await prepareTransaction8130(client, { + account, + calls: [[{ to: recipient, data }]], + gas: 250_000n, +}) + +// Appended after calls — authenticated by the signature(s). +tx.metadata = stringToHex(JSON.stringify({ requestId: 'abc-123' })) + +const serialized = await account.signTransaction(tx) +const hash = await sendRawTransaction(client, { serializedTransaction: serialized }) +``` + +Any hex value works; `'0x'` (or omitting the field) means no metadata. Because it is signed, a sponsored transaction's payer also commits to the exact bytes. + +## Reading metadata back + +The value round-trips onto the receipt: + +```ts +import { hexToString } from 'viem' +import { waitForTransactionReceipt8130 } from 'viem/experimental/eip8130' + +const receipt = await waitForTransactionReceipt8130(client, { hash }) +const raw = receipt.eip8130.metadata // e.g. '0x7b22...' +if (raw && raw !== '0x') { + const decoded = JSON.parse(hexToString(raw)) +} +``` + +Keep it small — every byte is signed and stored on-chain. For large payloads, store the data off-chain and put only a hash or id in `metadata`. + +## Next + +- Read per-phase outcomes and the payer on the [receipt](/experimental/eip8130/receipts). diff --git a/site/pages/experimental/eip8130/payer-services.mdx b/site/pages/experimental/eip8130/payer-services.mdx new file mode 100644 index 0000000000..ecaf0d874e --- /dev/null +++ b/site/pages/experimental/eip8130/payer-services.mdx @@ -0,0 +1,185 @@ +--- +description: Negotiate gas sponsorship and token payment with an ERC-8168 payer web service +--- + +# Payer Services (ERC-8168) + +[ERC-8168](https://github.com/base/eip-8130) standardizes a **payer web service**: a JSON-RPC endpoint a wallet queries to sponsor an EIP-8130 transaction or accept an ERC-20 as gas payment. The service holds the payer key and co-signs `payer_auth` (see [Sponsoring Transactions](/experimental/eip8130/sponsoring-transactions) for the underlying mechanism). + +Viem exposes the client and helpers under `viem/experimental/eip8168`. + +:::warning[Warning] +ERC-8168 is experimental. A payer's offers are trust-based — always preflight caps (`conditions`) and confirm token charges with the user before re-signing. +::: + +## The RPC surface + +A payer implements four `payer_*` methods (two required, two optional): + +| Method | Purpose | Required | +| --- | --- | --- | +| `payer_getTerms` | Quote payment offers for an intent (pre-signature). | Yes | +| `payer_sendTransaction` | Co-sign the sender-signed tx and submit it. | Yes | +| `payer_signTransaction` | Co-sign and return the bytes without submitting. | No | +| `payer_getSponsorshipBalance` | Standing, intent-free allowance / credit. | No | + +Create a client for the endpoint: + +```ts +import { createPayerClient } from 'viem/experimental/eip8168' + +const payerClient = createPayerClient({ url: 'https://payer.example.com/v1' }) +``` + +## End-to-end: `sendSponsoredCalls` + +`sendSponsoredCalls` runs the whole flow: fetch terms, select an offer, build the phases (including any phase-0 token transfer), sign `sender_auth` with the payer named and `payer_auth` empty, then hand off to the payer to co-sign and submit. + +```ts +import { sendSponsoredCalls } from 'viem/experimental/eip8168' + +const { transactionHash, tokenCharged } = await sendSponsoredCalls(client, { + account, // signs sender_auth + payerClient, + calls: [{ to: recipient, data }], +}) +``` + +- Default selects a **sponsored** (fully-paid) offer. +- Pass `token` to prefer paying gas in a specific ERC-20. +- Pass `context` (e.g. a `policyId`) to forward to the payer. +- Pass `mode: 'sign'` to have the payer co-sign and return the bytes (only when the selected offer advertises `payer_signTransaction`). + +### Confirming re-signs + +If the payer rejects with a recoverable reason (`PAYMENT_INSUFFICIENT` → a higher token charge; `GAS_TOO_LOW` → a higher gas limit), `sendSponsoredCalls` can re-sign — but only when you approve each attempt via `confirmRetry`. Re-signing mints a **new** `sender_auth` (and, for a passkey, a fresh user gesture), so there is no silent retry: with no callback, the rejection is thrown for you to handle. + +```ts +import { sendSponsoredCalls } from 'viem/experimental/eip8168' +import { formatUnits } from 'viem' + +const result = await sendSponsoredCalls(client, { + account, + payerClient, + calls: [{ to: recipient, data }], + token: usdc, + retries: 2, + confirmRetry: async (request) => { + if (request.kind === 'requote') + return confirm(`Pay ${formatUnits(request.paymentAmount, 6)} USDC?`) + // request.kind === 'gas' + return confirm(`Raise gas limit to ${request.gasLimit}?`) + }, +}) +``` + +## Step-by-step + +For full control, drive the primitives directly. + +### 1. Get terms + +```ts +import { numberToHex } from 'viem' + +const terms = await payerClient.getTerms({ + chainId: numberToHex(client.chain.id), + from: account.address, + calls: [{ to: recipient, data }], + preferredTokens: [usdc], + fiatCurrency: 'USD', +}) +``` + +`terms.options` is a best-first list of offers; `terms.gasEstimate` carries the recommended gas params shared by every offer. + +### 2. Inspect and select an offer + +Offers come in three kinds. Use the type guards, or let `selectPaymentOption` pick per the spec (prefer a selectable `sponsored` offer, else the first `token` offer; never a declined entry). + +```ts +import { + isDeclinedOffer, + isSponsoredOffer, + isTokenOffer, + selectPaymentOption, +} from 'viem/experimental/eip8168' + +for (const option of terms.options) { + if (isSponsoredOffer(option)) console.log('free via', option.payer) + else if (isTokenOffer(option)) console.log('pay in', option.tokens[0].symbol) + else if (isDeclinedOffer(option)) console.log('declined:', option.code) +} + +const { option, tokenChoice } = selectPaymentOption(terms, { token: usdc }) +``` + +### 3. Build the calls + +`buildSponsoredCalls` turns the selected offer into the EIP-8130 `payer` + phased `calls` (adding the phase-0 token transfer when paying with a token). + +```ts +import { buildSponsoredCalls } from 'viem/experimental/eip8168' + +const { payer, calls, paymentAmount } = buildSponsoredCalls({ + terms, + calls: [{ to: recipient, data }], + token: usdc, +}) +``` + +### 4. Sign and hand off + +Prepare the transaction with the offer's gas, sign `sender_auth` with `payer` named and `payer_auth` empty, then submit through the payer. + +```ts +import { hexToBigInt } from 'viem' +import { prepareTransaction8130 } from 'viem/experimental/eip8130' + +const tx = await prepareTransaction8130(client, { + account, + calls, + gas: hexToBigInt(terms.gasEstimate!.gasLimit), + maxFeePerGas: hexToBigInt(terms.gasEstimate!.maxFeePerGas), + maxPriorityFeePerGas: hexToBigInt(terms.gasEstimate!.maxPriorityFeePerGas), +}) +tx.payer = payer +tx.payerAuth = '0x' // the service fills this in + +const signedTransaction = await account.signTransaction(tx) +const { transactionHash } = await payerClient.sendTransaction({ signedTransaction }) +``` + +## Handling rejections + +`payer_sendTransaction` / `payer_signTransaction` fail with a `PAYER_REJECTED` envelope. Decode it with `parsePayerError` to branch on the stable string `code`. + +```ts +import { parsePayerError } from 'viem/experimental/eip8168' + +try { + await payerClient.sendTransaction({ signedTransaction }) +} catch (error) { + const rejected = parsePayerError(error) + if (rejected?.code === 'PAYMENT_INSUFFICIENT' && rejected.requote) { + // Re-sign phase 0 with rejected.requote.paymentAmount and resubmit. + } else if (rejected?.code === 'GAS_TOO_LOW' && rejected.minGasLimit) { + // Raise gasLimit to rejected.minGasLimit and resubmit. + } else { + throw error + } +} +``` + +## Standing balances + +When a payer implements `payer_getSponsorshipBalance`, query a sender's intent-free allowance or prepaid credit to show "sponsored gas remaining" before building any transaction. + +```ts +import { numberToHex } from 'viem' + +const { balances } = await payerClient.getSponsorshipBalance({ + from: account.address, + chainId: numberToHex(client.chain.id), +}) +``` diff --git a/site/pages/experimental/eip8130/receipts.mdx b/site/pages/experimental/eip8130/receipts.mdx new file mode 100644 index 0000000000..27762d3e2a --- /dev/null +++ b/site/pages/experimental/eip8130/receipts.mdx @@ -0,0 +1,63 @@ +--- +description: Read EIP-8130 receipt fields — per-phase statuses, payer, and metadata +--- + +# Receipts + +An `AA_TX_TYPE` (`0x79`) receipt carries extra fields beyond a standard receipt: the resolved gas **payer**, the **per-phase execution statuses**, and the echoed **metadata**. `getTransactionReceipt8130` and `waitForTransactionReceipt8130` surface these under a parsed `eip8130` property while still returning the raw receipt. + +## Wait for a receipt + +```ts +import { waitForTransactionReceipt8130 } from 'viem/experimental/eip8130' + +const receipt = await waitForTransactionReceipt8130(client, { hash }) + +receipt.status // top-level tx status ('0x1' / '0x0') +receipt.eip8130.payer // who paid gas (sender for self-pay) +receipt.eip8130.phaseStatuses // e.g. ['0x1', '0x0'] — per phase, in order +receipt.eip8130.metadata // opaque bytes echoed from the signed tx +``` + +It polls `eth_getTransactionReceipt` until the transaction is mined (default every `500ms`, timing out after `60_000ms` — both configurable). Unlike the generic `waitForTransactionReceipt`, it skips same-nonce replacement detection, since EIP-8130 uses 2D channel nonces. + +## Fetch without waiting + +`getTransactionReceipt8130` returns `null` if the receipt is not yet available: + +```ts +import { getTransactionReceipt8130 } from 'viem/experimental/eip8130' + +const receipt = await getTransactionReceipt8130(client, { hash }) +if (receipt) console.log(receipt.eip8130.phaseStatuses) +``` + +The `eip8130` fields are only populated for `AA_TX_TYPE` receipts on a node with the extension; they are `undefined` for standard receipts or older nodes. + +## Per-phase statuses + +`phaseStatuses` reports each [call phase](/experimental/eip8130/calls-and-batching) in order — `0x1` for success, `0x0` for revert. Because a reverted phase halts execution, every phase after the first failure is reported `0x0`. The array is empty when the transaction has no calls (e.g. a config-only change). + +`allPhasesSucceeded` collapses this to a single boolean: + +```ts +import { allPhasesSucceeded, waitForTransactionReceipt8130 } from 'viem/experimental/eip8130' + +const receipt = await waitForTransactionReceipt8130(client, { hash }) +if (!allPhasesSucceeded(receipt.eip8130)) { + // The tx was still included and the fee was paid, but a phase reverted. + const failedPhase = receipt.eip8130.phaseStatuses?.findIndex((s) => s !== '0x1') +} +``` + +:::warning[A reverted 8130 transaction is still included] +Unlike a failed EVM call, a reverted `AA_TX_TYPE` transaction is mined: its nonce is consumed and its fee is paid. Always check `phaseStatuses` / `allPhasesSucceeded` (not just `receipt.status`) to confirm your calls actually executed. +::: + +## The payer field + +`eip8130.payer` is the account that paid the gas: the sender for a self-paid transaction, or the named [payer](/experimental/eip8130/sponsoring-transactions) for a sponsored one. Use it to confirm sponsorship landed as expected. + +## Next + +- Attach and read application data with [metadata](/experimental/eip8130/metadata). diff --git a/site/pages/experimental/eip8130/rotating-owners.mdx b/site/pages/experimental/eip8130/rotating-owners.mdx new file mode 100644 index 0000000000..5665ab5770 --- /dev/null +++ b/site/pages/experimental/eip8130/rotating-owners.mdx @@ -0,0 +1,145 @@ +--- +description: Authorize and revoke actors on an EIP-8130 account +--- + +# Rotating Owners + +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 along inside a normal transaction. + +## Actors and keys + +Each actor is `{ actorId, authenticator }`. Build them with the `key` helpers: + +```ts +import { key } from 'viem/experimental/eip8130' + +key.k1('0xowner...') // secp256k1 (native ecrecover) +key.p256({ x, y }) // P-256 public key +key.passkey({ x, y }) // WebAuthn / FIDO2 passkey +key.delegate('0xotherAccount') // signatures for another account act for this one +``` + +## Scope and expiry + +`authorizeActor` attaches a permission scope and optional expiry to an actor. Combine scope flags with `toScope`: + +```ts +import { actorScope, authorizeActor, key, toScope } from 'viem/experimental/eip8130' + +authorizeActor(key.p256({ x, y }), { + // What the actor may do: sign / act as sender / act as payer / change config. + scope: toScope(actorScope.sender, actorScope.signature), + // Optional expiry (unix seconds); 0/omitted = no expiry. + expiry: BigInt(Math.floor(Date.now() / 1000) + 86_400), +}) +``` + +| Flag | Grants | +| --- | --- | +| `actorScope.signature` | Producing signatures for the account (e.g. ERC-1271). | +| `actorScope.sender` | Sending transactions (the `sender` auth path). | +| `actorScope.payer` | Sponsoring transactions as the `payer`. | +| `actorScope.config` | Changing the account's actor configuration. | + +An unrestricted (full-owner) actor uses scope `0`. + +## Read the config sequence + +Every config change is signed against the account's **next** config sequence. Read it first to avoid sequence-mismatch rejections: + +```ts +import { getConfigSequence8130, getEip8130Deployment } from 'viem/experimental/eip8130' + +const { accountConfiguration } = getEip8130Deployment(client.chain.id)! +const { local: sequence } = await getConfigSequence8130(client, { + accountConfiguration, + account: account.address, +}) +``` + +## Add an owner + +Sign the change with `account.change(...)`, then include it in a transaction's `accountChanges`: + +```ts +import { + actorScope, + authorizeActor, + key, + sendCalls8130, +} from 'viem/experimental/eip8130' + +const change = await account.change( + [ + authorizeActor(key.k1('0xnewOwner...'), { + scope: actorScope.sender, // full owner? use scope 0 + }), + ], + { chainId: client.chain.id, sequence: Number(sequence) }, +) + +const hash = await sendCalls8130(client, { + account, + accountChanges: [change], + calls: [], // config-only transaction + gas: 200_000n, +}) +``` + +## Revoke an owner + +`revokeActor` accepts an actor or a raw `actorId`. Combine authorize + revoke in one change to atomically rotate a key: + +```ts +import { + actorScope, + authorizeActor, + key, + revokeActor, +} from 'viem/experimental/eip8130' + +const rotate = await account.change( + [ + authorizeActor(key.k1('0xnewOwner...'), { scope: actorScope.sender }), + revokeActor(key.k1('0xoldOwner...')), + ], + { chainId: client.chain.id, sequence: Number(sequence) }, +) +``` + +## Rotate during deployment + +For a delegated EOA, you can delegate *and* install new keys in the very first transaction — no separate account handle required: + +```ts +import { + actorScope, + authorizeActor, + canonicalEip8130Deployment, + key, + sendCalls8130, + toEoa8130Account, +} from 'viem/experimental/eip8130' +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' + +const account = toEoa8130Account(privateKeyToAccount(generatePrivateKey())) + +const addP256 = await account.change( + [authorizeActor(key.p256({ x, y }), { scope: actorScope.sender })], + { chainId: client.chain.id, sequence: 0 }, +) + +const hash = await sendCalls8130(client, { + account, + accountChanges: [ + account.delegate(canonicalEip8130Deployment.accounts.defaultHighRate), + addP256, + ], + calls: [], + gas: 300_000n, +}) +``` + +## Next + +Authorize a scoped, policy-gated [session key](/experimental/eip8130/session-keys). diff --git a/site/pages/experimental/eip8130/sending-a-transaction.mdx b/site/pages/experimental/eip8130/sending-a-transaction.mdx new file mode 100644 index 0000000000..1d2314ac3c --- /dev/null +++ b/site/pages/experimental/eip8130/sending-a-transaction.mdx @@ -0,0 +1,138 @@ +--- +description: Estimate, sign, and submit an EIP-8130 AA_TX_TYPE transaction +--- + +# Sending a Transaction + +An EIP-8130 transaction is an `AA_TX_TYPE` (`0x79`) envelope. `sendCalls8130` handles the full lifecycle: it fills the nonce and fees, encodes the calls, signs `sender_auth` (and `payer_auth` when sponsored), serializes, and submits via `eth_sendRawTransaction`. + +## Estimate gas + +Unlike EVM transactions, the gas budget for an `AA_TX_TYPE` transaction is node-computed and required up front. `estimateGas8130` prices authentication gas from the *shape* of the auth blob — never a real signature — so you can estimate before signing. Pass a `senderAuthVerifier` hint matching your signer so the node prices the right authenticator: + +```ts +import { parseEther } from 'viem' +import { canonicalAuthenticators, estimateGas8130 } from 'viem/experimental/eip8130' + +const gas = await estimateGas8130(client, { + sender: account.address, + // Include `createChange` only on the first (deploying) transaction. + accountChanges: [account.createChange], + // Phased calls: each inner array is one atomic `executeBatch` phase. + calls: [[{ to: recipient, value: parseEther('0.001') }]], + senderAuthVerifier: canonicalAuthenticators.k1, // .p256 / .passkey for other signers +}) +``` + +Pick the verifier from the signer kind: + +```ts +import { canonicalAuthenticators } from 'viem/experimental/eip8130' +const senderAuthVerifier = + kind === 'p256' ? canonicalAuthenticators.p256 + : kind === 'passkey' ? canonicalAuthenticators.passkey + : canonicalAuthenticators.k1 +``` + +:::tip +An EIP-8130 estimate returns the charged gas **even when an inner call reverts** (a reverted 8130 tx is still included — nonce consumed, fee paid). Because the node can converge on the gas where an inner call runs out of gas, add headroom (e.g. `gas * 120n / 100n`) for transactions whose inner calls must succeed. +::: + +## Deploy on first use + +Deploy the account and run its first calls in a single transaction by including `account.createChange` in `accountChanges`. `waitForTransactionReceipt8130` surfaces the EIP-8130 receipt fields (per-phase statuses, `payer`, `metadata`). + +```ts +import { parseEther } from 'viem' +import { + canonicalAuthenticators, + estimateGas8130, + sendCalls8130, + waitForTransactionReceipt8130, +} from 'viem/experimental/eip8130' + +const calls = [{ to: recipient, value: parseEther('0.001') }] + +const gas = await estimateGas8130(client, { + sender: account.address, + accountChanges: [account.createChange], + calls: [calls], + senderAuthVerifier: canonicalAuthenticators.k1, +}) + +const hash = await sendCalls8130(client, { + account, + accountChanges: [account.createChange], // deploy — omit on later txs + calls, + gas: (gas * 120n) / 100n, +}) + +const receipt = await waitForTransactionReceipt8130(client, { hash }) +receipt.eip8130.phaseStatuses // e.g. ['0x1'] +``` + +## Follow-up transactions + +Once the account is deployed, drop `accountChanges` and just pass `calls`. The nonce sequence is read automatically via the 2D channel-nonce RPC extension. + +```ts +import { encodeFunctionData } from 'viem' +import { estimateGas8130, sendCalls8130 } from 'viem/experimental/eip8130' + +const gas = await estimateGas8130(client, { + sender: account.address, + calls: [[{ to: token, data: transferData }]], +}) + +const hash = await sendCalls8130(client, { + account, + calls: [{ to: token, data: transferData }], + gas: (gas * 120n) / 100n, +}) +``` + +## Batching calls + +A flat `calls` array runs as one atomic phase. Pass a nested array to control phases explicitly (each phase is a separate `executeBatch`) — see [Calls & Batching](/experimental/eip8130/calls-and-batching) for the full phased model: + +```ts +import { sendCalls8130 } from 'viem/experimental/eip8130' + +const hash = await sendCalls8130(client, { + account, + calls: [ + { to: tokenA, data: approveData }, + { to: router, data: swapData }, + ], + gas: 400_000n, +}) +``` + +## Sponsored (payer) gas + +A `payer` account can co-sign the transaction and pay its gas — either a key you hold or a payer web service: + +```ts +import { sendCalls8130 } from 'viem/experimental/eip8130' + +const hash = await sendCalls8130(client, { + account, + calls: [{ to: recipient, data }], + gas: 250_000n, + payer: { account: sponsor }, +}) +``` + +See [Sponsoring Transactions](/experimental/eip8130/sponsoring-transactions) for the co-signing mechanism (and token payment), and [Payer Services (ERC-8168)](/experimental/eip8130/payer-services) to negotiate sponsorship with a service. + +## Lower-level control + +`sendCalls8130` composes two primitives you can use directly: + +- `prepareTransaction8130(client, params)` — fills chain id, nonce sequence, and EIP-1559 fees into a `TransactionSerializable8130`. +- `account.signTransaction(tx, { payer })` — produces the `sender_auth`/`payer_auth` blobs and returns the serialized envelope, which you can submit with `sendRawTransaction`. + +## Next + +- [Rotate owners](/experimental/eip8130/rotating-owners) by authorizing and revoking actors. +- Add scoped [session keys](/experimental/eip8130/session-keys). diff --git a/site/pages/experimental/eip8130/session-keys.mdx b/site/pages/experimental/eip8130/session-keys.mdx new file mode 100644 index 0000000000..ecadde2aca --- /dev/null +++ b/site/pages/experimental/eip8130/session-keys.mdx @@ -0,0 +1,131 @@ +--- +description: Create policy-gated session keys on an EIP-8130 account +--- + +# Session Keys + +A session key is a **policy-gated actor**: a key authorized with a restricted scope and a policy commitment. The protocol forces every call that key makes to land on a `PolicyManager`, which enforces the committed policy (spend limits, target/selector allowlists) and then drives the account. This lets you hand out a scoped key without granting full ownership. + +:::warning[Warning] +The `PolicyManager` and `SessionPolicy` contracts are an unaudited reference implementation from [base/eip-8130](https://github.com/base/eip-8130/tree/main/src/examples/policies). Addresses default to the Base Sepolia deployment — override `manager` / `policy` for other chains. +::: + +## The flow + +1. **Author** a `SessionPolicy` config — the allowlists and spend limits. +2. **Bind** it to the account with `defineSessionPolicy` to get the commitment, the `authorizeActor` policy, and the install call. +3. **Authorize + install** the key in one transaction. The install initializes the binding and MUST land before the key's first use. +4. **Use** the key: it sends `executeCall(action)`, and its only reachable target is the manager. + +## Author + bind + +```ts +import { parseUnits } from 'viem' +import { + defineSessionPolicy, + encodeSessionPolicyConfig, +} from 'viem/experimental/eip8130' + +const session = defineSessionPolicy({ + account: account.address, + policyConfig: encodeSessionPolicyConfig({ + // ≤ 100 USDC per week. + tokenLimits: [ + { token: usdc, limit: parseUnits('100', 6), period: 7n * 86_400n }, + ], + // Only `transfer(address,uint256)` on the USDC contract. + callScopes: [ + { target: usdc, selectorRules: [{ selector: '0xa9059cbb' }] }, + ], + }), +}) + +session.commitment // the policy commitment stored on the actor +session.actorPolicy // pass to `authorizeActor(key, { scope, policy })` +``` + +`selectorRules` may bind recipients for the standard ERC-20 selectors (`transfer`, `transferFrom`, `approve`): + +```ts +import { encodeSessionPolicyConfig } from 'viem/experimental/eip8130' + +encodeSessionPolicyConfig({ + callScopes: [ + { + target: usdc, + selectorRules: [{ selector: '0xa9059cbb', recipients: [payroll] }], + }, + ], +}) +``` + +## Authorize + install + +Authorize the session key with a **restricted** scope (a policy-bearing actor must not have `config` scope) and ride the install call in the same transaction: + +```ts +import { + actorScope, + authorizeActor, + key, + sendCalls8130, +} from 'viem/experimental/eip8130' + +const sessionKey = key.p256({ x, y }) + +const change = await account.change( + [ + authorizeActor(sessionKey, { + scope: actorScope.sender, + policy: session.actorPolicy, + }), + ], + { chainId: client.chain.id, sequence: Number(sequence) }, +) + +const hash = await sendCalls8130(client, { + account, + accountChanges: [change], + // The install call initializes the binding — it must land before first use. + calls: [session.installCall(sessionKey.actorId)], + gas: 300_000n, +}) +``` + +## Use the session key + +Build the account with the session signer and send an `executeCall`. The manager verifies the action against the committed policy, then executes it: + +```ts +import { encodeFunctionData, erc20Abi, parseUnits } from 'viem' +import { + encodeSessionPolicyAction, + newSmartAccount8130, + sendCalls8130, + toP256Signer, +} from 'viem/experimental/eip8130' + +// Same account address, driven by the session key. +const sessionAccount = newSmartAccount8130({ + signer: toP256Signer({ privateKey: sessionPrivateKey }), + salt: accountSalt, +}) + +const transfer = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [recipient, parseUnits('10', 6)], +}) + +const hash = await sendCalls8130(client, { + account: sessionAccount, + calls: [ + session.executeCall( + encodeSessionPolicyAction({ target: usdc, data: transfer }), + ), + ], + gas: 250_000n, +}) +``` + +A transfer that exceeds the weekly limit, or targets a contract/selector outside the allowlist, reverts inside the manager — so the key can only ever act within its committed policy. diff --git a/site/pages/experimental/eip8130/sponsoring-transactions.mdx b/site/pages/experimental/eip8130/sponsoring-transactions.mdx new file mode 100644 index 0000000000..2e0df53d69 --- /dev/null +++ b/site/pages/experimental/eip8130/sponsoring-transactions.mdx @@ -0,0 +1,100 @@ +--- +description: Have a third party pay gas for an EIP-8130 transaction via a co-signing payer +--- + +# Sponsoring Transactions + +An EIP-8130 transaction can name a **payer** — a second account that co-signs the transaction and pays its gas. The sender authorizes the calls; the payer authorizes paying. This is the native mechanism behind gas sponsorship and pay-with-token flows. + +Two auth blobs are produced: + +- `sender_auth` — the account authorizes the calls. +- `payer_auth` — the payer, bound to the resolved sender, authorizes paying the fee. + +There are two ways to obtain `payer_auth`: + +- **You hold the payer key** (self-sponsor, or your own backend) → co-sign locally. Covered here. +- **A service holds the payer key** → negotiate terms and have it co-sign over RPC. See [Payer Services (ERC-8168)](/experimental/eip8130/payer-services). + +## Co-signing locally + +Pass a `payer` signer to `sendCalls8130`. It signs `payer_auth` bound to the sender and sets the `payer` wire field. Set `payer.address` when the on-chain payer account differs from the signing key. + +```ts +import { privateKeyToAccount } from 'viem/accounts' +import { sendCalls8130 } from 'viem/experimental/eip8130' + +const sponsor = privateKeyToAccount(process.env.SPONSOR_KEY as `0x${string}`) + +const hash = await sendCalls8130(client, { + account, // signs sender_auth + calls: [{ to: recipient, data }], + gas: 250_000n, + payer: { account: sponsor }, // signs payer_auth, pays the fee +}) +``` + +## Estimating a sponsored transaction + +Pass the `payer` address (and, if the payer uses a non-K1 key, `payerAuthVerifier`) so the node prices the payer authentication too: + +```ts +import { canonicalAuthenticators, estimateGas8130 } from 'viem/experimental/eip8130' + +const gas = await estimateGas8130(client, { + sender: account.address, + calls: [[{ to: recipient, data }]], + payer: sponsor.address, + payerAuthVerifier: canonicalAuthenticators.k1, +}) +``` + +## Pay with a token + +To charge the sender in an ERC-20 while the payer fronts native gas, run a two-phase transaction: phase 0 transfers the token to the payer, phase 1 runs the user's calls. The payer only co-signs because it can see its fee is paid in phase 0. + +```ts +import { encodeTokenTransfer } from 'viem/experimental/eip8168' +import { sendCalls8130 } from 'viem/experimental/eip8130' + +const hash = await sendCalls8130(client, { + account, + calls: [ + // phase 0 — pay the payer in USDC + [encodeTokenTransfer({ token: usdc, to: sponsor.address, amount: fee })], + // phase 1 — the actual calls + [{ to: recipient, data }], + ], + gas: 300_000n, + payer: { account: sponsor }, +}) +``` + +In practice the token amount and payer are negotiated with a [payer service](/experimental/eip8130/payer-services), which quotes the fee and builds these phases for you. + +## Lower-level control + +`sendCalls8130` wraps two primitives when you need to inspect or persist the transaction between signing and submitting: + +```ts +import { sendRawTransaction } from 'viem/actions' +import { prepareTransaction8130 } from 'viem/experimental/eip8130' + +const tx = await prepareTransaction8130(client, { + account, + calls: /* AaCalls (phased) */ phases, + gas: 250_000n, + payer: { account: sponsor }, +}) + +// Produces sender_auth + payer_auth and returns the serialized envelope. +const serialized = await account.signTransaction(tx, { payer: { account: sponsor } }) + +const hash = await sendRawTransaction(client, { serializedTransaction: serialized }) +``` + +If you already have a `payer_auth` blob (e.g. returned by a service), preset `transaction.payerAuth` before signing to skip local payer signing entirely. + +## Next + +Negotiate sponsorship or token payment with a web service — [Payer Services (ERC-8168)](/experimental/eip8130/payer-services). diff --git a/site/pages/experimental/eip8130/sub-accounts.mdx b/site/pages/experimental/eip8130/sub-accounts.mdx new file mode 100644 index 0000000000..6524f8dc08 --- /dev/null +++ b/site/pages/experimental/eip8130/sub-accounts.mdx @@ -0,0 +1,105 @@ +--- +description: Derive multiple EIP-8130 accounts per owner and link them with delegate actors +--- + +# Sub Accounts + +EIP-8130 has no dedicated "sub account" type — instead you compose two primitives: + +1. **Derive many accounts from one owner** — each `salt` yields a distinct, independent account address controlled by the same signer. +2. **Link accounts with delegate actors** — a `delegate` actor lets one account's keys authorize transactions on another, so a primary account can drive its sub accounts. + +## Many accounts, one owner + +The account address is a deterministic function of the salt (plus the wallet code and initial actors). Pass a distinct `salt` to derive independent sub accounts for the same owner — useful for per-app, per-user, or per-purpose isolation. + +```ts +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' +import { newSmartAccount8130 } from 'viem/experimental/eip8130' + +const owner = privateKeyToAccount(generatePrivateKey()) + +// A stable, human-meaningful salt derivation is convenient here. +const main = newSmartAccount8130({ signer: owner, salt: saltFor('main') }) +const trading = newSmartAccount8130({ signer: owner, salt: saltFor('trading') }) +const savings = newSmartAccount8130({ signer: owner, salt: saltFor('savings') }) + +main.address !== trading.address // distinct accounts, same owner key +``` + +Each sub account is deployed independently on its first transaction (see [Sending a Transaction](/experimental/eip8130/sending-a-transaction#deploy-on-first-use)). + +## Linking with delegate actors + +A **delegate actor** authorizes signatures that are valid for another account to act for *this* account. Add `key.delegate(primary)` to a sub account and the primary account's keys can drive it — a "controlled by" link, without sharing raw private keys. + +```ts +import { + actorScope, + authorizeActor, + key, + sendCalls8130, +} from 'viem/experimental/eip8130' + +// On the sub account, authorize the primary account as a delegate. +const link = await subAccount.change( + [ + authorizeActor(key.delegate(main.address), { + scope: actorScope.sender, + }), + ], + { chainId: client.chain.id, sequence: Number(sequence) }, +) + +const hash = await sendCalls8130(client, { + account: subAccount, + accountChanges: [link], + calls: [], + gas: 200_000n, +}) +``` + +Once linked, build an account handle that signs for the sub account using the **primary** signer via the delegate authenticator: + +```ts +import { + canonicalAuthenticators, + sendCalls8130, + to8130Account, +} from 'viem/experimental/eip8130' + +// Drive `subAccount.address` with `main`'s key through the delegate authenticator. +const subAsDelegate = to8130Account({ + signer: main.signer, + authenticator: canonicalAuthenticators.delegate, + address: subAccount.address, +}) + +const hash = await sendCalls8130(client, { + account: subAsDelegate, + calls: [{ to: recipient, value: 1n }], + gas: 200_000n, +}) +``` + +:::note +The delegate authenticator validates a signature produced for the linked account, so authentication gas is priced from the delegate blob's shape. Pass `senderAuthVerifier: canonicalAuthenticators.delegate` to [`estimateGas8130`](/experimental/eip8130/sending-a-transaction#estimate-gas) when pricing delegate-signed transactions. +::: + +## Revoking a link + +Revoke the delegate actor to unlink a sub account at any time: + +```ts +import { key, revokeActor } from 'viem/experimental/eip8130' + +const unlink = await subAccount.change( + [revokeActor(key.delegate(main.address))], + { chainId: client.chain.id, sequence: Number(sequence) }, +) +``` + +## Next + +- Let a third party pay for a sub account's gas — [Sponsoring Transactions](/experimental/eip8130/sponsoring-transactions). +- Negotiate sponsorship with a service — [Payer Services (ERC-8168)](/experimental/eip8130/payer-services). diff --git a/site/vocs.config.ts b/site/vocs.config.ts index 6297581508..f20072de88 100644 --- a/site/vocs.config.ts +++ b/site/vocs.config.ts @@ -1567,6 +1567,55 @@ export default defineConfig({ }, ], }, + { + text: 'EIP-8130', + items: [ + { + text: 'Overview', + link: '/experimental/eip8130', + }, + { + text: 'Creating an Account', + link: '/experimental/eip8130/creating-an-account', + }, + { + text: 'Sending a Transaction', + link: '/experimental/eip8130/sending-a-transaction', + }, + { + text: 'Calls & Batching', + link: '/experimental/eip8130/calls-and-batching', + }, + { + text: 'Receipts', + link: '/experimental/eip8130/receipts', + }, + { + text: 'Metadata', + link: '/experimental/eip8130/metadata', + }, + { + text: 'Rotating Owners', + link: '/experimental/eip8130/rotating-owners', + }, + { + text: 'Session Keys', + link: '/experimental/eip8130/session-keys', + }, + { + text: 'Sub Accounts', + link: '/experimental/eip8130/sub-accounts', + }, + { + text: 'Sponsoring Transactions', + link: '/experimental/eip8130/sponsoring-transactions', + }, + { + text: 'Payer Services (ERC-8168)', + link: '/experimental/eip8130/payer-services', + }, + ], + }, { text: 'ERC-7715', items: [ From 7a03931b2d0a7df7928fa1cb6141a5316b936065 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 8 Jul 2026 16:26:51 -0400 Subject: [PATCH 35/96] fix(eip8130): repin core AA tx type references to 0x79 The experimental module was repinned to 0x79, but viem core still hardcoded 0x7b for EIP-8130: the transactionType formatter map and the getTransaction nested-body unwrap. Move both to 0x79 (also frees 0x7b back to Celo CIP-64). --- src/actions/public/getTransaction.ts | 6 +++--- src/actions/public/waitForTransactionReceipt.ts | 2 +- src/utils/formatters/transaction.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/actions/public/getTransaction.ts b/src/actions/public/getTransaction.ts index 2442f05ae5..13a6d1b966 100644 --- a/src/actions/public/getTransaction.ts +++ b/src/actions/public/getTransaction.ts @@ -157,17 +157,17 @@ export async function getTransaction< index, }) - // EIP-8130 (`AA_TX_TYPE`, type 0x7b) responses wrap the transaction body in a + // EIP-8130 (`AA_TX_TYPE`, type 0x79) responses wrap the transaction body in a // nested `tx` object and omit the `hash` field (unlike standard tx responses). // Flatten the nested body and inject the request hash so downstream formatters // (and `waitForTransactionReceipt`) have all the fields they expect. - if ((transaction as any).type === '0x7b') { + if ((transaction as any).type === '0x79') { const raw = transaction as any const body = raw.tx ?? {} transaction = { // Inject the request hash — not present in the RPC response. hash: hash ?? undefined, - type: '0x7b', + type: '0x79', // Top-level block context fields (present in mined txs). blockHash: raw.blockHash, blockNumber: raw.blockNumber, diff --git a/src/actions/public/waitForTransactionReceipt.ts b/src/actions/public/waitForTransactionReceipt.ts index 81dfd2a222..ffe1c50a42 100644 --- a/src/actions/public/waitForTransactionReceipt.ts +++ b/src/actions/public/waitForTransactionReceipt.ts @@ -321,7 +321,7 @@ export async function waitForTransactionReceipt< // If we couldn't find a replacement transaction, continue polling. // Also skip if the candidate transaction has no hash — this can // happen when the block contains a transaction type that viem - // does not yet know how to deserialize (e.g. EIP-8130 type 0x7b), + // does not yet know how to deserialize (e.g. EIP-8130 type 0x79), // in which case the parsed object will have `hash: undefined`. if (!replacementTransaction || !replacementTransaction.hash) return diff --git a/src/utils/formatters/transaction.ts b/src/utils/formatters/transaction.ts index 925cfa4c52..c9f98666b6 100644 --- a/src/utils/formatters/transaction.ts +++ b/src/utils/formatters/transaction.ts @@ -41,7 +41,7 @@ export const transactionType = { '0x2': 'eip1559', '0x3': 'eip4844', '0x4': 'eip7702', - '0x7b': 'eip8130', + '0x79': 'eip8130', } as const satisfies Record export type FormatTransactionErrorType = ErrorType From 5ca53b4b8c6be1d7cea8b5632d95b6a2997ac6c6 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 8 Jul 2026 16:26:51 -0400 Subject: [PATCH 36/96] fix(eip8130): correct trusted-executor sentinel + add key.trustedExecutor The exported sentinel used keccak256("externalCaller") but base/eip-8130's DefaultAccount checks keccak256("trustedExecutor"). Rename to trustedExecutorAuthenticator with the correct value (0xbe114b191a3ac7519670cac0c5e74aac1d819a13) and add a key.trustedExecutor builder so a PolicyManager / EntryPoint can be registered as an execution-enabled ("external caller") actor. --- src/experimental/eip8130/constants.ts | 14 +++++++++----- src/experimental/eip8130/index.ts | 2 +- src/experimental/eip8130/keys.ts | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts index fd3c5911ac..e41eec7db8 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/experimental/eip8130/constants.ts @@ -65,16 +65,20 @@ export const revokedAuthenticator = '0xffffffffffffffffffffffffffffffffffffffff' satisfies Hex /** - * Sentinel authenticator for execution-enabled "external caller" actors - * (`EXTERNAL_CALLER_AUTHENTICATOR = address(uint160(uint256(keccak256("externalCaller"))))`). + * Sentinel authenticator for execution-enabled "trusted executor" actors + * (`TRUSTED_EXECUTOR = address(uint160(uint256(keccak256("trustedExecutor"))))`, + * as defined in `base/eip-8130`'s `DefaultAccount`). * * No contract is deployed here. An actor whose `authenticator` is this sentinel * is authorized to drive the account via `executeBatch` when it is the * `msg.sender` (e.g. an ERC-4337 EntryPoint or a {@link policyManagerAbi} - * PolicyManager). It cannot produce signatures — only direct calls. + * PolicyManager). It cannot produce signatures — only direct calls. A + * policy-gated session key therefore requires its `manager` to also be + * registered as a trusted-executor actor (see {@link key.trustedExecutor}), + * otherwise the manager's forwarded `executeBatch` reverts. */ -export const externalCallerAuthenticator = - '0x345249274eE98994AbBf79ef955319e4Cb3f6849' satisfies Hex +export const trustedExecutorAuthenticator = + '0xbe114b191a3ac7519670cac0c5e74aac1d819a13' satisfies Hex /** * Canonical authenticator set (the signature algorithms compliant nodes MUST diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 38ee118fa8..ee7fa1dc81 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -82,11 +82,11 @@ export { defaultAccountAddress, deploymentHeaderSize, ecrecoverAuthenticator, - externalCallerAuthenticator, maxCodeSize, nonceKeyMax, nonceManagerAddress, revokedAuthenticator, + trustedExecutorAuthenticator, txContextAddress, } from './constants.js' export { diff --git a/src/experimental/eip8130/keys.ts b/src/experimental/eip8130/keys.ts index 3a4f400c89..99260037af 100644 --- a/src/experimental/eip8130/keys.ts +++ b/src/experimental/eip8130/keys.ts @@ -8,6 +8,7 @@ import { actorScope, canonicalAuthenticators, ecrecoverAuthenticator, + trustedExecutorAuthenticator, } from './constants.js' import type { AaActor, @@ -69,6 +70,19 @@ export const key = { authenticator: options.authenticator ?? canonicalAuthenticators.delegate, } }, + /** + * Trusted-executor ("external caller") actor for `caller` — an address (e.g. a + * PolicyManager or ERC-4337 EntryPoint) authorized to drive the account via + * `executeBatch` by matching `msg.sender`, not by producing a signature. Pair + * with `authorizeActor(..., { scope: actorScope.sender })` and no policy. A + * policy-gated session key needs its `manager` registered this way. + */ + trustedExecutor(caller: Address): AaActor { + return { + actorId: actorIdFromAddress(caller), + authenticator: trustedExecutorAuthenticator, + } + }, } as const /** Combines {@link actorScope} flags into a single scope bitmask. */ From 41c93f2e27ea0b331ed1d783e32f981375d3e501 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 8 Jul 2026 16:39:27 -0400 Subject: [PATCH 37/96] feat(eip8130): add DefaultAccount + BackwardsCompatible4337Account to canonical deployment Re-adds accounts.default (the standalone EIP-7702 delegation target for EOAs, 0xaF0973...) and accounts.erc4337 (BackwardsCompatible4337Account, 0x8812ee...) to Eip8130Deployment. The 4337 impl gives cross-chain portability: an account runs on non-native chains via a bundler + EntryPoint at the same address, with the EntryPoint registered as a trusted-executor actor. Point EOA-delegation examples back to accounts.default; document the 4337 impl and its canonical CREATE2 address. --- site/pages/experimental/eip8130.mdx | 8 +++-- .../eip8130/creating-an-account.mdx | 8 +++-- .../experimental/eip8130/rotating-owners.mdx | 2 +- .../eip8130/accounts/to8130Account.ts | 6 ++-- src/experimental/eip8130/deployments.ts | 31 +++++++++++++------ 5 files changed, 36 insertions(+), 19 deletions(-) diff --git a/site/pages/experimental/eip8130.mdx b/site/pages/experimental/eip8130.mdx index ee6fa342e1..7c61637d27 100644 --- a/site/pages/experimental/eip8130.mdx +++ b/site/pages/experimental/eip8130.mdx @@ -69,13 +69,15 @@ import { getEip8130Deployment, canonicalEip8130Deployment } from 'viem/experimen const deployment = getEip8130Deployment(84_538_453) ?? canonicalEip8130Deployment deployment.accountConfiguration // factory + actor-config registry -deployment.accounts.upgradeable // UpgradeableAccount — the default wallet impl -deployment.accounts.defaultHighRate // DefaultHighRateAccount — immutable wallet impl +deployment.accounts.upgradeable // UpgradeableAccount — default smart-account impl +deployment.accounts.default // DefaultAccount — default EOA (EIP-7702) delegate +deployment.accounts.defaultHighRate // DefaultHighRateAccount — immutable smart-account impl +deployment.accounts.erc4337 // BackwardsCompatible4337Account — cross-chain (ERC-4337) deployment.authenticators.p256 // P-256 authenticator deployment.policies?.manager // example PolicyManager (session keys) ``` -Two account implementations are deployed: **`UpgradeableAccount`** (the default — deployed behind an ERC-1967 `UpgradeableProxy`, upgradeable via `upgradeBySignature`) and **`DefaultHighRateAccount`** (immutable — deployed behind a 45-byte ERC-1167 proxy). `DefaultAccount` is a base building block inherited by both and is not deployed standalone; `BackwardsCompatible4337Account` is an opt-in ERC-4337 example you deploy yourself. +Account implementations: **`UpgradeableAccount`** (the default for smart accounts — behind an ERC-1967 `UpgradeableProxy`, upgradeable via `upgradeBySignature`), **`DefaultAccount`** (the bare building block, deployed standalone as the direct EIP-7702 delegation target for EOAs), **`DefaultHighRateAccount`** (immutable — behind a 45-byte ERC-1167 proxy), and **`BackwardsCompatible4337Account`** (the ERC-4337 portable implementation for non-native chains). The first three are deployed by base's canonical `Deploy.s.sol`; the 4337 implementation is deployed separately but has a canonical CREATE2 address. ## Guides diff --git a/site/pages/experimental/eip8130/creating-an-account.mdx b/site/pages/experimental/eip8130/creating-an-account.mdx index 3ef4b0d97b..2340d62d51 100644 --- a/site/pages/experimental/eip8130/creating-an-account.mdx +++ b/site/pages/experimental/eip8130/creating-an-account.mdx @@ -95,21 +95,23 @@ const account = toEoa8130Account(privateKeyToAccount(generatePrivateKey())) The same EIP-8130 account works on chains **without** native 8130 support through a bundler, by wrapping it as a standard Viem Smart Account. Execution routes through `executeBatch` on the `BackwardsCompatible4337Account` implementation, and deployment uses the Account Configuration contract as the ERC-4337 factory. -`BackwardsCompatible4337Account` is an opt-in example — it is **not** part of the canonical deployment. Deploy it yourself (via the Account Configuration `CREATE2` factory) and pass its address as `implementation`: +`BackwardsCompatible4337Account` has a canonical CREATE2 address (`canonicalEip8130Deployment.accounts.erc4337`), but base's canonical `Deploy.s.sol` does **not** deploy it — deploy it on the target chain (via the Account Configuration `CREATE2` factory) before use, then pass its address as `implementation`: ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { toSmartAccount8130 } from 'viem/experimental/eip8130' +import { canonicalEip8130Deployment, toSmartAccount8130 } from 'viem/experimental/eip8130' const account = await toSmartAccount8130({ client, owner: privateKeyToAccount(generatePrivateKey()), userSalt: '0x0000000000000000000000000000000000000000000000000000000000000001', initialActors: [/* key.k1(owner.address), ... */], - implementation: erc4337AccountImplementation, // address of your deployed BackwardsCompatible4337Account + implementation: canonicalEip8130Deployment.accounts.erc4337, }) ``` +On a chain without native EIP-8130, the account must delegate to (or be deployed as) this ERC-4337 implementation, and its EntryPoint must be registered as a trusted-executor actor (`key.trustedExecutor(entryPoint)`) so it can drive `executeBatch`. + ## Next Now [send a transaction](/experimental/eip8130/sending-a-transaction) with your account. diff --git a/site/pages/experimental/eip8130/rotating-owners.mdx b/site/pages/experimental/eip8130/rotating-owners.mdx index 5665ab5770..2e8c09c85b 100644 --- a/site/pages/experimental/eip8130/rotating-owners.mdx +++ b/site/pages/experimental/eip8130/rotating-owners.mdx @@ -132,7 +132,7 @@ const addP256 = await account.change( const hash = await sendCalls8130(client, { account, accountChanges: [ - account.delegate(canonicalEip8130Deployment.accounts.defaultHighRate), + account.delegate(canonicalEip8130Deployment.accounts.default), addP256, ], calls: [], diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index 55105e2385..79b3a1d5fe 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -127,7 +127,7 @@ export type To8130AccountReturnType = { * **Delegated EOA** — supply `address` only (no salt, no code, no actors): * ```ts * const account = to8130Account({ signer, address: eoaSigner.address }) - * // first tx: accountChanges: [account.delegate(deployment.accounts.defaultHighRate)] + * // first tx: accountChanges: [account.delegate(deployment.accounts.default)] * // add keys: accountChanges: [account.delegate(...), await account.change([...])] * ``` * @@ -397,7 +397,7 @@ export type ToEoa8130AccountReturnType = { * * @example * await account.signTransaction({ - * accountChanges: [account.delegate(deployment.accounts.defaultHighRate)], + * accountChanges: [account.delegate(deployment.accounts.default)], * calls: wire, ... * }) */ @@ -467,7 +467,7 @@ export type ToEoa8130AccountReturnType = { * const account = toEoa8130Account(privateKeyToAccount(pk)) * const addP256 = await account.change([authorizeActor(key.p256(...))], { chainId, sequence: 0 }) * const signed = await account.signTransaction({ - * accountChanges: [account.delegate(deployment.accounts.defaultHighRate), addP256], + * accountChanges: [account.delegate(deployment.accounts.default), addP256], * calls: wire, ... * }) */ diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index 015e284d96..23212577b7 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -15,22 +15,33 @@ export type Eip8130Deployment = { /** Deployed wallet implementation contracts (the singletons account proxies delegate to). */ accounts: { /** - * UpgradeableAccount implementation — the default. Accounts are deployed - * behind an ERC-1967 `UpgradeableProxy` (see {@link upgradeableProxyBytecode}) - * so they can be upgraded via `upgradeBySignature`. + * UpgradeableAccount implementation — the default for smart accounts. + * Accounts are deployed behind an ERC-1967 `UpgradeableProxy` (see + * {@link upgradeableProxyBytecode}) so they can be upgraded via + * `upgradeBySignature`. */ upgradeable: Address /** - * DefaultHighRateAccount implementation — the immutable account. Deployed - * behind a 45-byte ERC-1167 proxy (see {@link erc1167Bytecode}). + * DefaultAccount implementation — the bare account, deployed standalone as + * the direct EIP-7702 delegation target for EOAs (no proxy). This is the + * default `delegate` target when an EOA adopts an EIP-8130 account. + */ + default: Address + /** + * DefaultHighRateAccount implementation — the immutable smart-account + * variant. Deployed behind a 45-byte ERC-1167 proxy (see + * {@link erc1167Bytecode}). */ defaultHighRate: Address /** - * BackwardsCompatible4337Account — an opt-in ERC-4337 example, not part of - * the canonical deployment. Deploy it yourself and pass to - * {@link toSmartAccount8130}. + * BackwardsCompatible4337Account — the ERC-4337 portable implementation + * (`DefaultAccount` + `validateUserOp`). Lets an account run on non-native + * chains via a bundler + EntryPoint at the same address; the EntryPoint is + * registered as a trusted-executor actor (see {@link key.trustedExecutor}). + * Not deployed by base's canonical `Deploy.s.sol` — deploy it separately; + * the CREATE2 address below is canonical. */ - erc4337?: Address | undefined + erc4337: Address } /** Deployed authenticator contracts (for EVM execution on non-native chains). */ authenticators: { @@ -74,7 +85,9 @@ export const canonicalEip8130Deployment = { accountConfiguration: '0x2403408177dB7F8512a9593343a7C80371D8f2dF', accounts: { upgradeable: '0xF8dafa4DA35F664cf2CF842f00482ebb68a982b3', + default: '0xaF0973bbebe12BDaE6B61c96019dc0DcA554b67c', defaultHighRate: '0x6c4230a4101849a3CB6438C40D3d47EdE9aca096', + erc4337: '0x8812ee1c9BA2395b5f113412769f22C6e7b89B11', }, authenticators: { k1: '0x0000000000000000000000000000000000000001', From 97f77b0aa541011b8eccb7cc4bde056adf1b68df Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 8 Jul 2026 17:02:54 -0400 Subject: [PATCH 38/96] docs(eip8130): 4337 account is now deployed by canonical Deploy.s.sol base/eip-8130#27 adds BackwardsCompatible4337Account as a fourth deployed singleton, so accounts.erc4337 (0x8812ee...) is a canonical deployed address rather than an opt-in the caller must deploy. Update the deployment doc comment and guides accordingly. --- site/pages/experimental/eip8130.mdx | 2 +- site/pages/experimental/eip8130/creating-an-account.mdx | 2 +- src/experimental/eip8130/deployments.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/site/pages/experimental/eip8130.mdx b/site/pages/experimental/eip8130.mdx index 7c61637d27..1ff67aabb1 100644 --- a/site/pages/experimental/eip8130.mdx +++ b/site/pages/experimental/eip8130.mdx @@ -77,7 +77,7 @@ deployment.authenticators.p256 // P-256 authenticator deployment.policies?.manager // example PolicyManager (session keys) ``` -Account implementations: **`UpgradeableAccount`** (the default for smart accounts — behind an ERC-1967 `UpgradeableProxy`, upgradeable via `upgradeBySignature`), **`DefaultAccount`** (the bare building block, deployed standalone as the direct EIP-7702 delegation target for EOAs), **`DefaultHighRateAccount`** (immutable — behind a 45-byte ERC-1167 proxy), and **`BackwardsCompatible4337Account`** (the ERC-4337 portable implementation for non-native chains). The first three are deployed by base's canonical `Deploy.s.sol`; the 4337 implementation is deployed separately but has a canonical CREATE2 address. +Account implementations: **`UpgradeableAccount`** (the default for smart accounts — behind an ERC-1967 `UpgradeableProxy`, upgradeable via `upgradeBySignature`), **`DefaultAccount`** (the bare building block, deployed standalone as the direct EIP-7702 delegation target for EOAs), **`DefaultHighRateAccount`** (immutable — behind a 45-byte ERC-1167 proxy), and **`BackwardsCompatible4337Account`** (the ERC-4337 portable implementation for non-native chains). All four are deployed as singletons by base's canonical `Deploy.s.sol` at canonical CREATE2 addresses. ## Guides diff --git a/site/pages/experimental/eip8130/creating-an-account.mdx b/site/pages/experimental/eip8130/creating-an-account.mdx index 2340d62d51..af31ddcdb8 100644 --- a/site/pages/experimental/eip8130/creating-an-account.mdx +++ b/site/pages/experimental/eip8130/creating-an-account.mdx @@ -95,7 +95,7 @@ const account = toEoa8130Account(privateKeyToAccount(generatePrivateKey())) The same EIP-8130 account works on chains **without** native 8130 support through a bundler, by wrapping it as a standard Viem Smart Account. Execution routes through `executeBatch` on the `BackwardsCompatible4337Account` implementation, and deployment uses the Account Configuration contract as the ERC-4337 factory. -`BackwardsCompatible4337Account` has a canonical CREATE2 address (`canonicalEip8130Deployment.accounts.erc4337`), but base's canonical `Deploy.s.sol` does **not** deploy it — deploy it on the target chain (via the Account Configuration `CREATE2` factory) before use, then pass its address as `implementation`: +`BackwardsCompatible4337Account` is deployed as a fourth singleton by base's canonical `Deploy.s.sol` at `canonicalEip8130Deployment.accounts.erc4337` (a canonical CREATE2 address). Pass it as `implementation`: ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index 23212577b7..926cd29836 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -38,8 +38,8 @@ export type Eip8130Deployment = { * (`DefaultAccount` + `validateUserOp`). Lets an account run on non-native * chains via a bundler + EntryPoint at the same address; the EntryPoint is * registered as a trusted-executor actor (see {@link key.trustedExecutor}). - * Not deployed by base's canonical `Deploy.s.sol` — deploy it separately; - * the CREATE2 address below is canonical. + * Deployed as a fourth singleton by base's canonical `Deploy.s.sol` + * (base/eip-8130#27) at the canonical CREATE2 address below. */ erc4337: Address } From 9700e746410643b5ba58b9514f74b28468d857b4 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 8 Jul 2026 20:51:11 -0400 Subject: [PATCH 39/96] feat(eip8130): accept senderActorId hint on estimateGas8130 Orthogonal to senderAuthVerifier (auth-gas pricing): names the acting actor for simulation so policy-gated session-key estimates resolve the right policy instead of the account's self-actor. --- .../eip8130/actions/estimateGas8130.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/experimental/eip8130/actions/estimateGas8130.ts b/src/experimental/eip8130/actions/estimateGas8130.ts index 4f45376888..e08240e115 100644 --- a/src/experimental/eip8130/actions/estimateGas8130.ts +++ b/src/experimental/eip8130/actions/estimateGas8130.ts @@ -92,6 +92,17 @@ export type EstimateGas8130Parameters = { * bare (unprefixed, default-EOA-path) filler blob of this length. */ senderAuthSize?: number | undefined + /** + * Acting-actor hint for simulation. Estimation never recovers a signature, so + * without this the node publishes the account's self-actor to the `TxContext` + * precompile — which makes policy-gated session-key calls look up the wrong + * policy and revert. Pass the session (or other non-self) actor id you intend + * to sign with; the node resolves that actor's policy after applying + * `accountChanges`, so an actor authorized in the same estimate request is + * visible. Orthogonal to {@link EstimateGas8130Parameters.senderAuthVerifier} + * (auth-gas pricing) — accept both when estimating a session-key send. + */ + senderActorId?: Hex | undefined // ── Common ──────────────────────────────────────────────────────────────── /** Optional sponsoring payer; priced into the estimate when set. */ @@ -140,11 +151,13 @@ const maxAuthSize = 8_192 * In both modes, the node prices authentication gas from the auth blob's * *shape*, never a real signature — pass `senderAuthVerifier` (or a raw * `senderAuth`) for a configured account, and leave both unset for the - * default EOA. See {@link EstimateGas8130Parameters}. + * default EOA. For policy-gated actors (session keys), also pass + * `senderActorId` so the simulate path publishes the intended acting actor + * instead of the account's self-actor. See {@link EstimateGas8130Parameters}. * - * Note: unlike standard `eth_estimateGas`, an EIP-8130 estimate returns the - * charged gas **even when a phase reverts**, because a reverted EIP-8130 tx is - * still included (nonce consumed, fee paid). + * Note: an EIP-8130 estimate that reverts a phase surfaces the revert (like + * standard `eth_estimateGas`), even though a reverted EIP-8130 tx would still + * be included on-chain (nonce consumed, fee paid). */ export async function estimateGas8130< chain extends Chain | undefined, @@ -162,6 +175,7 @@ export async function estimateGas8130< senderAuth: senderAuthExplicit, senderAuthVerifier, senderAuthSize, + senderActorId, accountChanges, calls, nonceKey = 0n, @@ -236,6 +250,7 @@ export async function estimateGas8130< } if (senderAuth !== undefined) request.senderAuth = senderAuth if (payerAuth !== undefined) request.payerAuth = payerAuth + if (senderActorId !== undefined) request.senderActorId = senderActorId } else { // Simplified mode. `sender` is always set so the node recognizes the // request as an EIP-8130 estimate even when no auth blob is supplied @@ -251,6 +266,7 @@ export async function estimateGas8130< if (payer !== undefined) request.payer = payer if (senderAuth !== undefined) request.senderAuth = senderAuth if (payerAuth !== undefined) request.payerAuth = payerAuth + if (senderActorId !== undefined) request.senderActorId = senderActorId } const block = blockNumber !== undefined ? numberToHex(blockNumber) : blockTag From ba81c6f2a76458b36ea901e28a96fb73d5a5b0d8 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 9 Jul 2026 16:57:22 -0400 Subject: [PATCH 40/96] fix(eip8130): serialize config changes for estimateGas RPC shape Node deserializes accountChanges with tag `configChange` and wire-form actorChanges (`Authorize`/`Revoke` + opaque `data`). Typed `config` + expanded authorize fields were rejected as Invalid params. --- .../eip8130/actions/estimateGas8130.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/experimental/eip8130/actions/estimateGas8130.ts b/src/experimental/eip8130/actions/estimateGas8130.ts index e08240e115..39384f7384 100644 --- a/src/experimental/eip8130/actions/estimateGas8130.ts +++ b/src/experimental/eip8130/actions/estimateGas8130.ts @@ -10,8 +10,13 @@ import type { Hex } from '../../../types/misc.js' import { concatHex } from '../../../utils/data/concat.js' import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import { numberToHex } from '../../../utils/encoding/toHex.js' -import { aaTransactionType, canonicalAuthDataLength } from '../constants.js' +import { + aaTransactionType, + actorChangeType, + canonicalAuthDataLength, +} from '../constants.js' import type { AaAccountChange, AaCalls } from '../types/transaction.js' +import { encodeActorChangeData } from '../utils/actorChangeData.js' export type EstimateGas8130Parameters = { /** @@ -325,7 +330,8 @@ function filler(length: number): Hex { * `eth_estimateGas` deserialiser expects. For `create` changes, all fields are * passed through directly. For `delegation`, only `target` is required (the * node does not need the proxy bytecode for gas pricing). For `config`, the - * full change is forwarded so the node can price the auth-blob calldata. + * node tags the variant as `configChange` and expects each actor change in + * wire form (`changeType` + opaque `data`), not the typed authorize fields. */ function serializeAccountChange( change: AaAccountChange, @@ -344,13 +350,20 @@ function serializeAccountChange( if (change.type === 'delegation') { return { type: 'delegation', target: change.target } } - // config: forward as-is; the node doesn't verify the auth in simulate mode - // but does price its byte-length into the intrinsic gas. + // config → RPC tag `configChange`. Simulate does not verify auth, but still + // prices its byte-length into intrinsic gas and applies actorChanges. return { - type: 'config', + type: 'configChange', chainId: change.chainId, sequence: change.sequence, - actorChanges: change.actorChanges, + actorChanges: change.actorChanges.map((ac) => ({ + changeType: + ac.changeType === actorChangeType.revokeActor + ? 'Revoke' + : 'Authorize', + actorId: ac.actorId, + data: encodeActorChangeData(ac), + })), auth: change.auth, } } From 4553aad421a78d6b08ce0cb752278a247336a683 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Fri, 10 Jul 2026 10:15:45 -0400 Subject: [PATCH 41/96] onchain --- scripts/eip8130/selfBundleRotateP256.test.ts | 2 +- scripts/eip8130/setup8130Account.test.ts | 2 +- scripts/smoke-estimate-sender-actor.mjs | 145 ++++++++++++++++++ site/pages/circle-usdc/guides/integrating.mdx | 2 +- site/pages/experimental/eip8130.mdx | 2 +- site/pages/experimental/eip8130/metadata.mdx | 4 +- .../eip8130/sponsoring-transactions.mdx | 2 +- .../tempo/actions/zone.encryptedDeposit.mdx | 2 +- .../tempo/guides/multisig-transactions.mdx | 2 +- src/CHANGELOG.md | 2 +- .../eip8130/accounts/to8130Account.ts | 2 +- .../eip8130/actions/estimateGas8130.ts | 2 +- src/experimental/eip8130/deployments.ts | 2 +- .../eip8168/actions/sendSponsoredCalls.ts | 2 +- src/experimental/eip8168/types.ts | 2 +- src/tempo/actions/simulate.test.ts | 4 +- 16 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 scripts/smoke-estimate-sender-actor.mjs diff --git a/scripts/eip8130/selfBundleRotateP256.test.ts b/scripts/eip8130/selfBundleRotateP256.test.ts index d1088c78c3..881f6b6f78 100644 --- a/scripts/eip8130/selfBundleRotateP256.test.ts +++ b/scripts/eip8130/selfBundleRotateP256.test.ts @@ -32,7 +32,7 @@ const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' // P-256 generator point (Gx, Gy) — a valid, well-known public key used purely -// to prove the actor is registered on-chain during validateUserOp. The op is +// to prove the actor is registered onchain during validateUserOp. The op is // authorized by the k1 owner signing the actor change, not by this key. const p256PubKey = { x: '0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296', diff --git a/scripts/eip8130/setup8130Account.test.ts b/scripts/eip8130/setup8130Account.test.ts index 45631d10e0..6200532385 100644 --- a/scripts/eip8130/setup8130Account.test.ts +++ b/scripts/eip8130/setup8130Account.test.ts @@ -20,7 +20,7 @@ const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' const SALT_LABEL = process.env.SALT_LABEL ?? 'viem-eip8130-demo-1' describe.runIf(PRIVATE_KEY)('setup an EIP-8130 account on Base Sepolia', () => { - test('computeAddress matches on-chain and createAccount lands', async () => { + test('computeAddress matches onchain and createAccount lands', async () => { const owner = privateKeyToAccount(PRIVATE_KEY!) const client = createClient({ account: owner, diff --git a/scripts/smoke-estimate-sender-actor.mjs b/scripts/smoke-estimate-sender-actor.mjs new file mode 100644 index 0000000000..6e9f9a9905 --- /dev/null +++ b/scripts/smoke-estimate-sender-actor.mjs @@ -0,0 +1,145 @@ +/** + * Live smoke: estimateGas8130 with/without senderActorId against vibenet. + * + * Proves the node (#3892) + viem hint path for policy-gated session keys. + * + * node --experimental-vm-modules scripts/smoke-estimate-sender-actor.mjs + * # or: bun scripts/smoke-estimate-sender-actor.mjs + */ +import { createPublicClient, http, parseEther, zeroAddress } from '../src/index.ts' +import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.ts' +import { toP256Signer } from '../src/experimental/eip8130/utils/signers.ts' +import { to8130Account } from '../src/experimental/eip8130/accounts/to8130Account.ts' +import { estimateGas8130 } from '../src/experimental/eip8130/actions/estimateGas8130.ts' +import { + authorizeActor, + key, +} from '../src/experimental/eip8130/keys.ts' +import { actorScope, canonicalAuthenticators } from '../src/experimental/eip8130/constants.ts' +import { + defineSessionPolicy, + encodeSessionPolicyAction, + encodeSessionPolicyConfig, +} from '../src/experimental/eip8130/policies.ts' +import { erc1167Bytecode } from '../src/experimental/eip8130/utils/proxy.ts' +import * as P256 from 'ox/P256' + +const RPC = process.env.VIBENET_RPC ?? 'https://rpc.vibes.base.org' +const ACCOUNT_CONFIG = '0x2403408177dB7F8512a9593343a7C80371D8f2dF' +const DEFAULT_ACCOUNT = '0xaF0973bbebe12BDaE6B61c96019dc0DcA554b67c' +const POLICY_MANAGER = '0x5E5c3D54078d1000309233fEc116A83Df5a07E67' +const SESSION_POLICY = '0xbd26BdA18Ee35F767ef03fD72356ae598ed6f793' + +const client = createPublicClient({ transport: http(RPC) }) + +const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const p256 = toP256Signer({ privateKey: P256.randomPrivateKey() }) +const sessionActor = key.p256(p256.publicKey) + +const userSalt = + '0x00000000000000000000000000000000000000000000000000000000000000a1' +const initialActors = [ + key.k1(owner.address), + key.trustedExecutor(POLICY_MANAGER), +].sort((a, b) => (a.actorId < b.actorId ? -1 : a.actorId > b.actorId ? 1 : 0)) + +const account = to8130Account({ + signer: owner, + userSalt, + code: erc1167Bytecode(DEFAULT_ACCOUNT), + initialActors, + accountConfigAddress: ACCOUNT_CONFIG, +}) +const createChange = account.create() + +const session = defineSessionPolicy({ + account: account.address, + manager: POLICY_MANAGER, + policy: SESSION_POLICY, + policyConfig: encodeSessionPolicyConfig({ + tokenLimits: [ + { token: zeroAddress, limit: parseEther('1'), period: 0n }, + ], + callScopes: [{ target: account.address }], + }), +}) + +const authChange = await account.change( + [ + authorizeActor(sessionActor, { + scope: actorScope.sender, + policy: session.actorPolicy, + }), + ], + { chainId: Number(await client.getChainId()), sequence: 1 }, +) + +const install = session.installCall(sessionActor.actorId) +const spend = session.executeCall( + encodeSessionPolicyAction({ + target: account.address, + value: 0n, + data: '0x', + }), +) + +const baseParams = { + sender: account.address, + accountChanges: [createChange, authChange], + calls: [[install], [spend]], + nonceSequence: 0, + senderAuthVerifier: canonicalAuthenticators.p256, +} + +console.log('rpc', RPC) +console.log('account', account.address) +console.log('sessionActorId', sessionActor.actorId) +console.log('chainId', await client.getChainId()) + +async function tryEstimate(label, params) { + try { + const gas = await estimateGas8130(client, params) + console.log(`OK ${label}: gas=${gas}`) + return { ok: true, gas } + } catch (err) { + const msg = err?.shortMessage ?? err?.message ?? String(err) + const details = err?.details ?? '' + console.log(`FAIL ${label}: ${msg}${details ? ` | ${details}` : ''}`) + return { ok: false, err } + } +} + +// Owner create-only estimate (sanity — self actor, no session). +await tryEstimate('owner create+noop', { + sender: account.address, + accountChanges: [createChange], + calls: [[{ to: account.address, value: 0n, data: '0x' }]], + nonceSequence: 0, + senderAuthVerifier: canonicalAuthenticators.k1, +}) + +// Session-key estimate WITHOUT actor hint — historically NoActivePolicy. +const without = await tryEstimate('session WITHOUT senderActorId', baseParams) + +// Session-key estimate WITH actor hint — should succeed after #3892. +const withHint = await tryEstimate('session WITH senderActorId', { + ...baseParams, + senderActorId: sessionActor.actorId, +}) + +if (!withHint.ok) { + console.error('\nSMOKE FAILED: senderActorId estimate still throws') + process.exit(1) +} +if (without.ok) { + console.log( + '\nNOTE: estimate without senderActorId also succeeded (self-actor path may not hit policy gate for this shape).', + ) +} else { + console.log( + '\nExpected: without hint fails, with hint succeeds — confirms the fix.', + ) +} +console.log('\nSMOKE PASSED') diff --git a/site/pages/circle-usdc/guides/integrating.mdx b/site/pages/circle-usdc/guides/integrating.mdx index 2bc5fd59c3..82bc3fc140 100644 --- a/site/pages/circle-usdc/guides/integrating.mdx +++ b/site/pages/circle-usdc/guides/integrating.mdx @@ -20,7 +20,7 @@ By the end of this guide, you'll know how to: * Read and display USDC balances * Send USDC between wallets * Approve contracts (e.g., Uniswap) to spend USDC on your behalf -* Monitor on-chain Transfer events in real-time +* Monitor onchain Transfer events in real-time * Optimize data loading with batched readContract calls Each step is self-contained and modular — designed to be easily copied into your own project, whether you're building a wallet, a dashboard, a DeFi tool, or anything else powered by stable digital dollars. diff --git a/site/pages/experimental/eip8130.mdx b/site/pages/experimental/eip8130.mdx index 1ff67aabb1..5a8485c61c 100644 --- a/site/pages/experimental/eip8130.mdx +++ b/site/pages/experimental/eip8130.mdx @@ -4,7 +4,7 @@ description: Getting started with EIP-8130 native account abstraction in Viem # EIP-8130 (Native Account Abstraction) [Overview] -[EIP-8130](https://github.com/base/eip-8130) is a native account-abstraction transaction type (`AA_TX_TYPE`, `0x79`). Every account is a smart account governed by an on-chain **Account Configuration** contract, and transactions are sent directly to the chain — no bundler, no EntryPoint. Viem exposes the full flow through the `viem/experimental/eip8130` entrypoint. +[EIP-8130](https://github.com/base/eip-8130) is a native account-abstraction transaction type (`AA_TX_TYPE`, `0x79`). Every account is a smart account governed by an onchain **Account Configuration** contract, and transactions are sent directly to the chain — no bundler, no EntryPoint. Viem exposes the full flow through the `viem/experimental/eip8130` entrypoint. :::warning[Warning] EIP-8130 is experimental and enabled per-chain. It is not yet live on mainnet. Do not solely rely on experimental features in production. diff --git a/site/pages/experimental/eip8130/metadata.mdx b/site/pages/experimental/eip8130/metadata.mdx index c5f7b75217..1d80c0c013 100644 --- a/site/pages/experimental/eip8130/metadata.mdx +++ b/site/pages/experimental/eip8130/metadata.mdx @@ -9,7 +9,7 @@ An EIP-8130 transaction can carry an opaque, application-defined `metadata` blob Use it to bind off-chain context to a transaction — a payer `policyId`, a client request id, an app tag — where you want the value tamper-evident but don't want to spend calldata inside a call. :::info -`metadata` is not interpreted on-chain and does not affect execution. It is a signed, echoed annotation — not a substitute for on-chain state or event logs. +`metadata` is not interpreted onchain and does not affect execution. It is a signed, echoed annotation — not a substitute for onchain state or event logs. ::: ## Setting metadata @@ -51,7 +51,7 @@ if (raw && raw !== '0x') { } ``` -Keep it small — every byte is signed and stored on-chain. For large payloads, store the data off-chain and put only a hash or id in `metadata`. +Keep it small — every byte is signed and stored onchain. For large payloads, store the data off-chain and put only a hash or id in `metadata`. ## Next diff --git a/site/pages/experimental/eip8130/sponsoring-transactions.mdx b/site/pages/experimental/eip8130/sponsoring-transactions.mdx index 2e0df53d69..0bb683c841 100644 --- a/site/pages/experimental/eip8130/sponsoring-transactions.mdx +++ b/site/pages/experimental/eip8130/sponsoring-transactions.mdx @@ -18,7 +18,7 @@ There are two ways to obtain `payer_auth`: ## Co-signing locally -Pass a `payer` signer to `sendCalls8130`. It signs `payer_auth` bound to the sender and sets the `payer` wire field. Set `payer.address` when the on-chain payer account differs from the signing key. +Pass a `payer` signer to `sendCalls8130`. It signs `payer_auth` bound to the sender and sets the `payer` wire field. Set `payer.address` when the onchain payer account differs from the signing key. ```ts import { privateKeyToAccount } from 'viem/accounts' diff --git a/site/pages/tempo/actions/zone.encryptedDeposit.mdx b/site/pages/tempo/actions/zone.encryptedDeposit.mdx index f5da530b41..5511421ddc 100644 --- a/site/pages/tempo/actions/zone.encryptedDeposit.mdx +++ b/site/pages/tempo/actions/zone.encryptedDeposit.mdx @@ -89,6 +89,6 @@ Optional deposit memo. Encrypted along with the recipient. - **Type:** `Address` - **Default:** `account.address` -Recipient address in the zone. Encrypted in the on-chain payload. +Recipient address in the zone. Encrypted in the onchain payload. diff --git a/site/pages/tempo/guides/multisig-transactions.mdx b/site/pages/tempo/guides/multisig-transactions.mdx index 3bc74745f5..f4aa4072cb 100644 --- a/site/pages/tempo/guides/multisig-transactions.mdx +++ b/site/pages/tempo/guides/multisig-transactions.mdx @@ -28,7 +28,7 @@ With Viem, the flow is: 4. Broadcast the transaction with the collected `signatures` (the prepared request already carries the multisig account as sender). -The first transaction from a multisig account **auto-bootstraps** (registers) it on-chain – +The first transaction from a multisig account **auto-bootstraps** (registers) it onchain – you don't need to pass an explicit `init` flag. Subsequent transactions are sent normally. [See the Tempo Transactions specification](https://docs.tempo.xyz/protocol/transactions) diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 6900a74df7..d1a586ca2f 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -140,7 +140,7 @@ - [#4621](https://github.com/wevm/viem/pull/4621) [`6d80eaeea315c552a57e9683607ed36f7d219a9e`](https://github.com/wevm/viem/commit/6d80eaeea315c552a57e9683607ed36f7d219a9e) Thanks [@Blessing-Circle](https://github.com/Blessing-Circle)! - Added Arc chain. -- [#4622](https://github.com/wevm/viem/pull/4622) [`c5dc4d63506f787e92417eff77dd0ef84e3a2c8c`](https://github.com/wevm/viem/commit/c5dc4d63506f787e92417eff77dd0ef84e3a2c8c) Thanks [@struong](https://github.com/struong)! - `viem/tempo`: Preserved `feeToken` on broadcast envelope when `feePayerSignature` is present. Previously stripped unconditionally when `feePayer === true`, breaking fee payer signature verification on-chain. +- [#4622](https://github.com/wevm/viem/pull/4622) [`c5dc4d63506f787e92417eff77dd0ef84e3a2c8c`](https://github.com/wevm/viem/commit/c5dc4d63506f787e92417eff77dd0ef84e3a2c8c) Thanks [@struong](https://github.com/struong)! - `viem/tempo`: Preserved `feeToken` on broadcast envelope when `feePayerSignature` is present. Previously stripped unconditionally when `feePayer === true`, breaking fee payer signature verification onchain. ## 2.49.2 diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index 79b3a1d5fe..d51389bcb6 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -293,7 +293,7 @@ export type NewSmartAccount8130ReturnType = To8130AccountReturnType & { /** * Creates a new EIP-8130 smart account from a signer, automatically deriving * the actor type, deployment bytecode, and counterfactual address. The account - * is not yet deployed on-chain — include `account.createChange` in the first + * is not yet deployed onchain — include `account.createChange` in the first * transaction's `accountChanges` to atomically deploy and call in one shot. * * Supports K1 (secp256k1), P-256, and WebAuthn (passkey) signers, detected diff --git a/src/experimental/eip8130/actions/estimateGas8130.ts b/src/experimental/eip8130/actions/estimateGas8130.ts index 39384f7384..8bd5f8bd59 100644 --- a/src/experimental/eip8130/actions/estimateGas8130.ts +++ b/src/experimental/eip8130/actions/estimateGas8130.ts @@ -162,7 +162,7 @@ const maxAuthSize = 8_192 * * Note: an EIP-8130 estimate that reverts a phase surfaces the revert (like * standard `eth_estimateGas`), even though a reverted EIP-8130 tx would still - * be included on-chain (nonce consumed, fee paid). + * be included onchain (nonce consumed, fee paid). */ export async function estimateGas8130< chain extends Chain | undefined, diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index 926cd29836..4d80dc7a4e 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -1,7 +1,7 @@ import type { Address } from 'abitype' /** - * On-chain addresses for an EIP-8130 deployment ([base/eip-8130](https://github.com/base/eip-8130)). + * Onchain addresses for an EIP-8130 deployment ([base/eip-8130](https://github.com/base/eip-8130)). * * On chains **without** native EIP-8130 support, these contracts provide the * portable path: `accountConfiguration` is the ERC-4337 factory / config diff --git a/src/experimental/eip8168/actions/sendSponsoredCalls.ts b/src/experimental/eip8168/actions/sendSponsoredCalls.ts index 81cebdd5f1..96e243f18e 100644 --- a/src/experimental/eip8168/actions/sendSponsoredCalls.ts +++ b/src/experimental/eip8168/actions/sendSponsoredCalls.ts @@ -186,7 +186,7 @@ export async function sendSponsoredCalls( : undefined // `conditions.maxExpiry` is a relative duration (seconds from now). The wallet - // sets the on-chain expiry to `now + maxExpiry`; the payer keeps `maxExpiry` + // sets the onchain expiry to `now + maxExpiry`; the payer keeps `maxExpiry` // short. Recomputed per attempt so a retry doesn't inherit a near-expiry // window. A caller-supplied absolute `expiry` is used as-is. const computeExpiry = (): bigint => { diff --git a/src/experimental/eip8168/types.ts b/src/experimental/eip8168/types.ts index 9ac27b1586..9f7c248bd5 100644 --- a/src/experimental/eip8168/types.ts +++ b/src/experimental/eip8168/types.ts @@ -286,7 +286,7 @@ export type SignTransactionReturnType = { export type GetSponsorshipBalanceParameters = { from: Address - /** Optional execution-chain filter. */ + /** Optional executionchain filter. */ chainId?: Hex | undefined /** Aggregator: scope to a single service by address. */ payer?: Address | undefined diff --git a/src/tempo/actions/simulate.test.ts b/src/tempo/actions/simulate.test.ts index e33d63b612..e154389a6e 100644 --- a/src/tempo/actions/simulate.test.ts +++ b/src/tempo/actions/simulate.test.ts @@ -390,7 +390,7 @@ describe('simulateCalls', () => { }) test('behavior: approve + dex swap + transfer', async () => { - // Set up token pair + liquidity on-chain + // Set up token pair + liquidity onchain const { base, quote } = await setupTokenPair(client as never) // Place sell order so there's liquidity to buy against @@ -458,7 +458,7 @@ describe('simulateCalls', () => { // Fund seller with fee tokens for gas await setupFeeToken(client, { account: seller }) - // Set up token pair + liquidity on-chain + // Set up token pair + liquidity onchain const { base, quote } = await setupTokenPair(client as never) // Fund seller with base tokens and approve DEX From 1a7d69e1d0bb80e747956648f490f3a5d9ec4087 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 15 Jul 2026 20:14:39 -0400 Subject: [PATCH 42/96] clean up --- .../eip8130/sending-a-transaction.mdx | 2 +- src/experimental/eip8130/abis.ts | 21 +- .../eip8130/actions/estimateGas8130.ts | 46 ++-- .../eip8130/actions/getActorConfig8130.ts | 83 ++++++++ .../eip8130/actions/getLockStatus8130.ts | 67 ++++++ .../eip8130/actions/getPolicy8130.ts | 72 +++++++ .../eip8130/actions/getSessionSpend8130.ts | 98 +++++++++ .../eip8130/actions/isActor8130.ts | 64 ++++++ .../eip8130/actions/isLocked8130.ts | 55 +++++ src/experimental/eip8130/actions/sendCalls.ts | 19 +- src/experimental/eip8130/constants.ts | 98 +++++++-- src/experimental/eip8130/deployments.ts | 21 +- src/experimental/eip8130/devx.test.ts | 15 +- src/experimental/eip8130/index.ts | 53 +++++ src/experimental/eip8130/keys.ts | 15 +- src/experimental/eip8130/lock.test.ts | 100 +++++++++ src/experimental/eip8130/lock.ts | 197 ++++++++++++++++++ src/experimental/eip8130/nonce.test.ts | 155 ++++++++++++++ src/experimental/eip8130/nonce.ts | 121 +++++++++++ src/experimental/eip8130/policies.test.ts | 2 +- src/experimental/eip8130/queries.test.ts | 116 +++++++++++ src/experimental/eip8130/types/transaction.ts | 21 +- .../eip8130/utils/accountConfigCalls.test.ts | 7 +- .../eip8130/utils/accountConfigCalls.ts | 2 + .../eip8130/utils/actorChangeData.ts | 10 +- .../eip8130/utils/computeAddress.test.ts | 5 + .../eip8130/utils/computeAddress.ts | 12 +- .../eip8130/utils/hashActorChanges.ts | 6 +- .../eip8130/utils/parseTransaction.ts | 11 +- .../utils/serializeTransaction.test.ts | 11 +- .../eip8130/utils/serializeTransaction.ts | 2 + 31 files changed, 1406 insertions(+), 101 deletions(-) create mode 100644 src/experimental/eip8130/actions/getActorConfig8130.ts create mode 100644 src/experimental/eip8130/actions/getLockStatus8130.ts create mode 100644 src/experimental/eip8130/actions/getPolicy8130.ts create mode 100644 src/experimental/eip8130/actions/getSessionSpend8130.ts create mode 100644 src/experimental/eip8130/actions/isActor8130.ts create mode 100644 src/experimental/eip8130/actions/isLocked8130.ts create mode 100644 src/experimental/eip8130/lock.test.ts create mode 100644 src/experimental/eip8130/lock.ts create mode 100644 src/experimental/eip8130/nonce.test.ts create mode 100644 src/experimental/eip8130/nonce.ts create mode 100644 src/experimental/eip8130/queries.test.ts diff --git a/site/pages/experimental/eip8130/sending-a-transaction.mdx b/site/pages/experimental/eip8130/sending-a-transaction.mdx index 1d2314ac3c..1d3691b753 100644 --- a/site/pages/experimental/eip8130/sending-a-transaction.mdx +++ b/site/pages/experimental/eip8130/sending-a-transaction.mdx @@ -24,7 +24,7 @@ const gas = await estimateGas8130(client, { }) ``` -Pick the verifier from the signer kind: +Pick the authenticator from the signer kind: ```ts import { canonicalAuthenticators } from 'viem/experimental/eip8130' diff --git a/src/experimental/eip8130/abis.ts b/src/experimental/eip8130/abis.ts index 6c382a6adf..a70a15e87f 100644 --- a/src/experimental/eip8130/abis.ts +++ b/src/experimental/eip8130/abis.ts @@ -5,13 +5,17 @@ import { parseAbi } from 'abitype' * (`IAccountConfiguration`) at `ACCOUNT_CONFIG_ADDRESS`. */ export const accountConfigurationAbi = parseAbi([ - 'struct InitialActor { bytes32 actorId; address authenticator; }', - 'struct ActorConfig { address authenticator; uint8 scope; uint48 expiry; uint8 policyType; }', + 'struct InitialActor { bytes32 actorId; address authenticator; uint8 scope; bytes policyData; }', + 'struct ActorConfig { address authenticator; uint8 scope; uint48 expiry; }', 'struct Actor { bytes32 actorId; ActorConfig config; bytes policyData; }', 'struct ActorChange { uint8 changeType; bytes32 actorId; bytes data; }', 'struct ChangeSequences { uint64 multichain; uint64 local; }', - 'event ActorAuthorized(address indexed account, bytes32 indexed actorId, ActorConfig config, address policyManager, bytes32 policyCommitment)', + // `actorData` is tightly packed: authenticator(20) || scope(1) || expiry(6) || + // reserved(5 zero bytes) = 32 bytes, plus manager(20) || commitment(32) when + // scope & SCOPE_POLICY != 0 (84 bytes total). Policy presence is the + // SCOPE_POLICY bit — there is no `policyType` field. + '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)', @@ -21,15 +25,14 @@ export const accountConfigurationAbi = parseAbi([ 'function createAccount(bytes32 userSalt, bytes bytecode, InitialActor[] initialActors) returns (address)', 'function computeAddress(bytes32 userSalt, bytes bytecode, InitialActor[] initialActors) view returns (address)', - 'function importAccount(address account, InitialActor[] initialActors, bytes signature)', - 'function applySignedActorChanges(address account, uint64 chainId, ActorChange[] actorChanges, bytes auth)', - 'function lock(uint16 unlockDelay)', - 'function initiateUnlock()', + 'function importAccount(address account, uint256 chainId, InitialActor[] initialActors, bytes signature)', + 'function applySignedActorChanges(address account, uint256 chainId, ActorChange[] actorChanges, bytes auth)', + 'function applySignedLockChanges(address account, uint8 op, uint16 unlockDelay, bytes auth)', 'function verifySignature(address account, bytes32 hash, bytes signature) view returns (bool verified)', - 'function authenticateActor(address account, bytes32 hash, bytes auth) view returns (uint8 scope, uint8 policyType, address policyTarget)', + 'function authenticateActor(address account, bytes32 hash, bytes auth) view returns (uint8 scope, address policyTarget)', 'function isActor(address account, bytes32 actorId) view returns (bool)', 'function getActorConfig(address account, bytes32 actorId) view returns (ActorConfig)', - 'function getPolicy(address account, bytes32 actorId) view returns (uint8 policyType, address target, bytes32 commitment)', + 'function getPolicy(address account, bytes32 actorId) view returns (address target, bytes32 commitment)', 'function getPolicyCommitment(address account, bytes32 actorId) view returns (bytes32)', 'function getPolicyManager(address account, bytes32 actorId) view returns (address)', 'function getChangeSequences(address account) view returns (ChangeSequences)', diff --git a/src/experimental/eip8130/actions/estimateGas8130.ts b/src/experimental/eip8130/actions/estimateGas8130.ts index 8bd5f8bd59..5e68ac22a2 100644 --- a/src/experimental/eip8130/actions/estimateGas8130.ts +++ b/src/experimental/eip8130/actions/estimateGas8130.ts @@ -84,16 +84,16 @@ export type EstimateGas8130Parameters = { */ senderAuth?: Hex | undefined /** - * Verifier (authenticator contract) address hint. The blob is synthesized - * as `verifier || filler`, where `filler` is `senderAuthSize` bytes if + * Authenticator (authenticator contract) address hint. The blob is synthesized + * as `authenticator || filler`, where `filler` is `senderAuthSize` bytes if * given, else a representative default length for known canonical - * verifiers ({@link canonicalAuthenticators}) — pass `senderAuthSize` - * explicitly for a custom verifier with no known default. + * authenticators ({@link canonicalAuthenticators}) — pass `senderAuthSize` + * explicitly for a custom authenticator with no known default. */ - senderAuthVerifier?: Address | undefined + senderAuthAuthenticator?: Address | undefined /** - * Sender auth-payload byte length. Combined with `senderAuthVerifier`, it - * overrides the verifier's default length. Alone (no verifier), it prices a + * Sender auth-payload byte length. Combined with `senderAuthAuthenticator`, it + * overrides the authenticator's default length. Alone (no authenticator), it prices a * bare (unprefixed, default-EOA-path) filler blob of this length. */ senderAuthSize?: number | undefined @@ -104,7 +104,7 @@ export type EstimateGas8130Parameters = { * policy and revert. Pass the session (or other non-self) actor id you intend * to sign with; the node resolves that actor's policy after applying * `accountChanges`, so an actor authorized in the same estimate request is - * visible. Orthogonal to {@link EstimateGas8130Parameters.senderAuthVerifier} + * visible. Orthogonal to {@link EstimateGas8130Parameters.senderAuthAuthenticator} * (auth-gas pricing) — accept both when estimating a session-key send. */ senderActorId?: Hex | undefined @@ -117,8 +117,8 @@ export type EstimateGas8130Parameters = { * `authenticator(20) || data` form). See `senderAuth`. */ payerAuth?: Hex | undefined - /** Payer verifier address hint. See `senderAuthVerifier`. */ - payerAuthVerifier?: Address | undefined + /** Payer authenticator address hint. See `senderAuthAuthenticator`. */ + payerAuthAuthenticator?: Address | undefined /** Payer auth-payload byte length override. See `senderAuthSize`. */ payerAuthSize?: number | undefined /** Block number to estimate against. */ @@ -154,7 +154,7 @@ const maxAuthSize = 8_192 * account-change) and per-phase call overhead. * * In both modes, the node prices authentication gas from the auth blob's - * *shape*, never a real signature — pass `senderAuthVerifier` (or a raw + * *shape*, never a real signature — pass `senderAuthAuthenticator` (or a raw * `senderAuth`) for a configured account, and leave both unset for the * default EOA. For policy-gated actors (session keys), also pass * `senderActorId` so the simulate path publishes the intended acting actor @@ -178,7 +178,7 @@ export async function estimateGas8130< data, value, senderAuth: senderAuthExplicit, - senderAuthVerifier, + senderAuthAuthenticator, senderAuthSize, senderActorId, accountChanges, @@ -187,7 +187,7 @@ export async function estimateGas8130< nonceSequence = 0, payer, payerAuth: payerAuthExplicit, - payerAuthVerifier, + payerAuthAuthenticator, payerAuthSize, blockNumber, blockTag = 'pending', @@ -211,12 +211,12 @@ export async function estimateGas8130< const senderAuth = buildAuthBlob( senderAuthExplicit, - senderAuthVerifier, + senderAuthAuthenticator, senderAuthSize, ) const payerAuth = buildAuthBlob( payerAuthExplicit, - payerAuthVerifier, + payerAuthAuthenticator, payerAuthSize, ) @@ -290,10 +290,10 @@ export async function estimateGas8130< * Builds the raw `senderAuth`/`payerAuth` blob to price, in priority order: * * 1. `explicit` — pass the caller's raw blob through verbatim. - * 2. `verifier` set — synthesize `verifier || filler`, where `filler` is - * `size` bytes if given, else the verifier's known default length + * 2. `authenticator` set — synthesize `authenticator || filler`, where `filler` is + * `size` bytes if given, else the authenticator's known default length * ({@link canonicalAuthDataLength}). Throws if neither is available. - * 3. `size` alone (no verifier) — a bare (unprefixed) filler blob of `size` + * 3. `size` alone (no authenticator) — a bare (unprefixed) filler blob of `size` * bytes, pricing the default-EOA path at a specific length. * 4. All unset — `undefined` (no auth blob on the request; the node applies * its own default). @@ -305,20 +305,20 @@ export async function estimateGas8130< */ function buildAuthBlob( explicit: Hex | undefined, - verifier: Address | undefined, + authenticator: Address | undefined, size: number | undefined, ): Hex | undefined { if (explicit !== undefined) return explicit - if (verifier === undefined) { + if (authenticator === undefined) { if (size === undefined) return undefined return filler(size) } - const dataLength = size ?? canonicalAuthDataLength[verifier.toLowerCase()] + const dataLength = size ?? canonicalAuthDataLength[authenticator.toLowerCase()] if (dataLength === undefined) throw new BaseError( - `No default auth-payload length is known for verifier ${verifier}. Pass an explicit auth size.`, + `No default auth-payload length is known for authenticator ${authenticator}. Pass an explicit auth size.`, ) - return concatHex([verifier, filler(dataLength)]) + return concatHex([authenticator, filler(dataLength)]) } function filler(length: number): Hex { diff --git a/src/experimental/eip8130/actions/getActorConfig8130.ts b/src/experimental/eip8130/actions/getActorConfig8130.ts new file mode 100644 index 0000000000..b6896fa428 --- /dev/null +++ b/src/experimental/eip8130/actions/getActorConfig8130.ts @@ -0,0 +1,83 @@ +import type { Address } from 'abitype' + +import { readContract } from '../../../actions/public/readContract.js' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import type { Hex } from '../../../types/misc.js' +import { accountConfigurationAbi } from '../abis.js' +import { + accountConfigAddress as defaultAccountConfigAddress, + actorScope, +} from '../constants.js' + +export type GetActorConfig8130Parameters = { + /** The account whose actor to read. */ + account: Address + /** The 32-byte actor identifier (see `key.*(...).actorId`). */ + actorId: Hex + /** + * `AccountConfiguration` system contract. Defaults to the canonical + * (enshrined) address, which is identical on every supported chain. + */ + accountConfiguration?: Address | undefined +} + +export type GetActorConfig8130ReturnType = { + /** Authenticator contract address (or protocol sentinel) for the actor. */ + authenticator: Address + /** Permission bitmask (see `actorScope`). `0` = unrestricted. */ + scope: number + /** Actor expiry (unix seconds). `0` = no expiry. */ + expiry: number + /** Whether the actor is policy-gated (derived from the `SCOPE_POLICY` bit in `scope`). */ + hasPolicy: boolean +} + +/** + * Reads an actor's configuration (authenticator, scope, expiry, policy type) + * from the `AccountConfiguration` system contract (`getActorConfig`). Use it to + * inspect owners / session keys, e.g. to enrich a "sign with" picker. + * + * @example + * ```ts + * import { getActorConfig8130, key } from 'viem/experimental/eip8130' + * + * const config = await getActorConfig8130(client, { + * account: account.address, + * actorId: key.p256({ x, y }).actorId, + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The actor's configuration. + */ +export async function getActorConfig8130< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetActorConfig8130Parameters, +): Promise { + const { + account, + actorId, + accountConfiguration = defaultAccountConfigAddress, + } = parameters + + const config = await readContract(client, { + address: accountConfiguration, + abi: accountConfigurationAbi, + functionName: 'getActorConfig', + args: [account, actorId], + }) + + return { + authenticator: config.authenticator, + scope: config.scope, + expiry: config.expiry, + hasPolicy: (config.scope & actorScope.policy) !== 0, + } +} diff --git a/src/experimental/eip8130/actions/getLockStatus8130.ts b/src/experimental/eip8130/actions/getLockStatus8130.ts new file mode 100644 index 0000000000..95ea6170fb --- /dev/null +++ b/src/experimental/eip8130/actions/getLockStatus8130.ts @@ -0,0 +1,67 @@ +import type { Address } from 'abitype' + +import { readContract } from '../../../actions/public/readContract.js' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import { accountConfigurationAbi } from '../abis.js' +import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' + +export type GetLockStatus8130Parameters = { + /** The account whose lock status to read. */ + account: Address + /** + * `AccountConfiguration` system contract. Defaults to the canonical + * (enshrined) address, which is identical on every supported chain. + */ + accountConfiguration?: Address | undefined +} + +export type GetLockStatus8130ReturnType = { + /** Whether the account is currently locked. */ + locked: boolean + /** Whether an unlock has been initiated (the delay is counting down). */ + hasInitiatedUnlock: boolean + /** Unix timestamp (seconds) at which an initiated unlock takes effect (`0` if none). */ + unlocksAt: number + /** The configured unlock delay in seconds. */ + unlockDelay: number +} + +/** + * Reads the full lock status of an EIP-8130 account from the + * `AccountConfiguration` system contract (`getLockStatus`). + * + * @example + * ```ts + * import { getLockStatus8130 } from 'viem/experimental/eip8130' + * + * const { locked, hasInitiatedUnlock, unlocksAt, unlockDelay } = + * await getLockStatus8130(client, { account: account.address }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The account's lock status. + */ +export async function getLockStatus8130< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetLockStatus8130Parameters, +): Promise { + const { account, accountConfiguration = defaultAccountConfigAddress } = + parameters + + const [locked, hasInitiatedUnlock, unlocksAt, unlockDelay] = + await readContract(client, { + address: accountConfiguration, + abi: accountConfigurationAbi, + functionName: 'getLockStatus', + args: [account], + }) + + return { locked, hasInitiatedUnlock, unlocksAt, unlockDelay } +} diff --git a/src/experimental/eip8130/actions/getPolicy8130.ts b/src/experimental/eip8130/actions/getPolicy8130.ts new file mode 100644 index 0000000000..d4b33b07de --- /dev/null +++ b/src/experimental/eip8130/actions/getPolicy8130.ts @@ -0,0 +1,72 @@ +import type { Address } from 'abitype' + +import { readContract } from '../../../actions/public/readContract.js' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import type { Hex } from '../../../types/misc.js' +import { accountConfigurationAbi } from '../abis.js' +import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' + +export type GetPolicy8130Parameters = { + /** The account whose actor policy to read. */ + account: Address + /** The 32-byte actor identifier (see `key.*(...).actorId`). */ + actorId: Hex + /** + * `AccountConfiguration` system contract. Defaults to the canonical + * (enshrined) address, which is identical on every supported chain. + */ + accountConfiguration?: Address | undefined +} + +export type GetPolicy8130ReturnType = { + /** Policy manager the actor is gated to (the zero address when unset). */ + target: Address + /** 32-byte policy commitment stored on the actor. */ + commitment: Hex +} + +/** + * Reads the policy binding for an actor (manager, commitment) from + * the `AccountConfiguration` system contract (`getPolicy`). Use it to resolve a + * session key's policy commitment for {@link getSessionSpend8130}. + * + * @example + * ```ts + * import { getPolicy8130, getSessionSpend8130, key } from 'viem/experimental/eip8130' + * + * const { commitment } = await getPolicy8130(client, { + * account: account.address, + * actorId: key.p256({ x, y }).actorId, + * }) + * const spend = await getSessionSpend8130(client, { commitment, token: usdc }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The actor's policy binding. + */ +export async function getPolicy8130< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetPolicy8130Parameters, +): Promise { + const { + account, + actorId, + accountConfiguration = defaultAccountConfigAddress, + } = parameters + + const [target, commitment] = await readContract(client, { + address: accountConfiguration, + abi: accountConfigurationAbi, + functionName: 'getPolicy', + args: [account, actorId], + }) + + return { target, commitment } +} diff --git a/src/experimental/eip8130/actions/getSessionSpend8130.ts b/src/experimental/eip8130/actions/getSessionSpend8130.ts new file mode 100644 index 0000000000..9d793caa94 --- /dev/null +++ b/src/experimental/eip8130/actions/getSessionSpend8130.ts @@ -0,0 +1,98 @@ +import type { Address } from 'abitype' + +import { readContract } from '../../../actions/public/readContract.js' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import type { Hex } from '../../../types/misc.js' +import { getAction } from '../../../utils/getAction.js' +import { sessionPolicyAbi, sessionPolicyAddress } from '../policies.js' + +export type GetSessionSpend8130Parameters = { + /** The session policy binding commitment (see `defineSessionPolicy().commitment`). */ + commitment: Hex + /** The token whose limit to read, or the zero address for native ETH. */ + token: Address + /** + * `SessionPolicy` contract. Defaults to the reference Base Sepolia deployment. + */ + sessionPolicy?: Address | undefined +} + +export type GetSessionSpend8130ReturnType = { + /** Whether a limit is configured for this token (unconfigured = no cap). */ + set: boolean + /** The spend cap per period (atomic units). */ + allowance: bigint + /** Period length in seconds. `0` = one-time (never resets). */ + period: number + /** Amount already spent in the current period (atomic units). */ + spent: bigint + /** Remaining budget in the current period (`allowance - spent`, clamped at `0`). */ + remaining: bigint + /** Unix timestamp (seconds) the current period started (`0` if never spent). */ + periodStart: number + /** Unix timestamp (seconds) the current period ends (`0` if one-time / unused). */ + periodEnd: number +} + +/** + * Reads the live spend / remaining budget for a session key against a specific + * token from the reference `SessionPolicy` contract (combining `getTokenLimit` + * and `getCurrentSpend`). Use it to render a "remaining budget" view for a + * policy-gated key. + * + * @example + * ```ts + * import { getSessionSpend8130 } from 'viem/experimental/eip8130' + * + * const { allowance, spent, remaining, periodEnd } = await getSessionSpend8130( + * client, + * { commitment: session.commitment, token: usdc }, + * ) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The session key's limit and current-period spend for the token. + */ +export async function getSessionSpend8130< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetSessionSpend8130Parameters, +): Promise { + const { commitment, token, sessionPolicy = sessionPolicyAddress } = parameters + + const read = getAction(client, readContract, 'readContract') + + const [[set, allowance, period], usage] = await Promise.all([ + read({ + address: sessionPolicy, + abi: sessionPolicyAbi, + functionName: 'getTokenLimit', + args: [commitment, token], + }), + read({ + address: sessionPolicy, + abi: sessionPolicyAbi, + functionName: 'getCurrentSpend', + args: [commitment, token], + }), + ]) + + const spent = usage.spend + const remaining = allowance > spent ? allowance - spent : 0n + + return { + set, + allowance, + period, + spent, + remaining, + periodStart: usage.start, + periodEnd: usage.end, + } +} diff --git a/src/experimental/eip8130/actions/isActor8130.ts b/src/experimental/eip8130/actions/isActor8130.ts new file mode 100644 index 0000000000..f7937087a6 --- /dev/null +++ b/src/experimental/eip8130/actions/isActor8130.ts @@ -0,0 +1,64 @@ +import type { Address } from 'abitype' + +import { readContract } from '../../../actions/public/readContract.js' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import type { Hex } from '../../../types/misc.js' +import { accountConfigurationAbi } from '../abis.js' +import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' + +export type IsActor8130Parameters = { + /** The account to check. */ + account: Address + /** The 32-byte actor identifier (see `key.*(...).actorId`). */ + actorId: Hex + /** + * `AccountConfiguration` system contract. Defaults to the canonical + * (enshrined) address, which is identical on every supported chain. + */ + accountConfiguration?: Address | undefined +} + +export type IsActor8130ReturnType = boolean + +/** + * Reads whether an actor is currently authorized on an EIP-8130 account, from + * the `AccountConfiguration` system contract (`isActor`). For the actor's full + * configuration, use {@link getActorConfig8130}. + * + * @example + * ```ts + * import { isActor8130, key } from 'viem/experimental/eip8130' + * + * const authorized = await isActor8130(client, { + * account: account.address, + * actorId: key.p256({ x, y }).actorId, + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns Whether the actor is authorized. + */ +export async function isActor8130< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: IsActor8130Parameters, +): Promise { + const { + account, + actorId, + accountConfiguration = defaultAccountConfigAddress, + } = parameters + + return readContract(client, { + address: accountConfiguration, + abi: accountConfigurationAbi, + functionName: 'isActor', + args: [account, actorId], + }) +} diff --git a/src/experimental/eip8130/actions/isLocked8130.ts b/src/experimental/eip8130/actions/isLocked8130.ts new file mode 100644 index 0000000000..eb4038105a --- /dev/null +++ b/src/experimental/eip8130/actions/isLocked8130.ts @@ -0,0 +1,55 @@ +import type { Address } from 'abitype' + +import { readContract } from '../../../actions/public/readContract.js' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import { accountConfigurationAbi } from '../abis.js' +import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' + +export type IsLocked8130Parameters = { + /** The account to check. */ + account: Address + /** + * `AccountConfiguration` system contract. Defaults to the canonical + * (enshrined) address, which is identical on every supported chain. + */ + accountConfiguration?: Address | undefined +} + +export type IsLocked8130ReturnType = boolean + +/** + * Reads whether an EIP-8130 account is currently locked, from the + * `AccountConfiguration` system contract (`isLocked`). For the full status + * (unlock timing, delay), use {@link getLockStatus8130}. + * + * @example + * ```ts + * import { isLocked8130 } from 'viem/experimental/eip8130' + * + * const locked = await isLocked8130(client, { account: account.address }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns Whether the account is locked. + */ +export async function isLocked8130< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: IsLocked8130Parameters, +): Promise { + const { account, accountConfiguration = defaultAccountConfigAddress } = + parameters + + return readContract(client, { + address: accountConfiguration, + abi: accountConfigurationAbi, + functionName: 'isLocked', + args: [account], + }) +} diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/experimental/eip8130/actions/sendCalls.ts index 62f11154e0..10ca9bd4cb 100644 --- a/src/experimental/eip8130/actions/sendCalls.ts +++ b/src/experimental/eip8130/actions/sendCalls.ts @@ -8,6 +8,7 @@ import type { Chain } from '../../../types/chain.js' import type { Hex } from '../../../types/misc.js' import { getAction } from '../../../utils/getAction.js' import type { To8130AccountReturnType } from '../accounts/to8130Account.js' +import { nonceKeyMax } from '../constants.js' import type { AaAccountChange, AaCall, @@ -64,16 +65,26 @@ export async function prepareTransaction8130( maxPriorityFeePerGas ??= fees.maxPriorityFeePerGas } - // Read the next sequence via `eth_getTransactionCount` (with the 2D - // `nonce_key` extension). The Nonce Manager precompile is not callable via - // `eth_call`, so this RPC path is the correct nonce source. + // Resolve the sequence for the selected nonce channel. let nonceSequence = parameters.nonceSequence - if (nonceSequence === undefined) + if (nonceKey === nonceKeyMax) { + // Nonce-free (expiring) mode: there is no per-channel counter to read; + // replay protection relies on `expiry`. Pin the sequence to `0n`. + if (!expiry || expiry === 0n) + throw new BaseError( + '`expiry` is required for nonce-free transactions (`nonceKey` = `NONCE_KEY_MAX`). Build the nonce with `nonce.nonceless({ expiresIn })`.', + ) + nonceSequence ??= 0n + } else if (nonceSequence === undefined) { + // Read the next sequence via `eth_getTransactionCount` (with the 2D + // `nonce_key` extension). The Nonce Manager precompile is not callable via + // `eth_call`, so this RPC path is the correct nonce source. nonceSequence = await getAction( client, getTransactionCount8130, 'getTransactionCount8130', )({ address: account.address, nonceKey }) + } return { chainId, diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts index e41eec7db8..e5fdfa8610 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/experimental/eip8130/constants.ts @@ -13,6 +13,42 @@ export const aaPayerType = '0x7a' satisfies Hex /** Base intrinsic gas cost (`AA_BASE_COST`). */ export const aaBaseCost = 15000n +/** + * `nonce_key_cost` for nonce-free (`NONCE_KEY_MAX`) transactions: 13,000 gas for + * the enshrined ring-buffer replay state (2·COLD_SLOAD + WARM_SLOAD + + * 3·SSTORE_RESET). Base's current schedule; a chain MAY price differently. + */ +export const nonceFreeCost = 13000n + +/** `nonce_key_cost` for the first use of a sequenced nonce key (cold SLOAD + SSTORE set). */ +export const nonceKeyFirstUseCost = 22100n + +/** `nonce_key_cost` for a previously-used sequenced nonce key (cold SLOAD + SSTORE reset). */ +export const nonceKeyExistingCost = 5000n + +/** + * Domain-separation prefix for the nonce-free `replay_id` preimage + * (`REPLAY_ID_TYPE`): `keccak256(REPLAY_ID_TYPE || rlp([chain_id, + * resolved_sender, expiry, account_changes, calls, metadata, payer]))`. + */ +export const replayIdType = '0x7901' satisfies Hex + +/** + * Consensus/execution replay window (seconds) for nonce-free transactions + * (`NONCE_FREE_EXPIRY_WINDOW`). A nonce-free tx's `expiry` must fall within + * `(now, now + NONCE_FREE_EXPIRY_WINDOW]`. + */ +export const nonceFreeExpiryWindow = 30n + +/** + * Mempool-admission cap (seconds) on a nonce-free tx's `expiry` window + * (`NONCE_FREE_MAX_EXPIRY_WINDOW`); tighter than the consensus window. + */ +export const nonceFreeMaxExpiryWindow = 10n + +/** Enshrined nonce-free replay ring-buffer capacity (`REPLAY_BUFFER_CAPACITY`). */ +export const replayBufferCapacity = 300000n + /** * Account change entry type discriminators (first element of each * `account_changes` entry). @@ -32,17 +68,55 @@ export const actorChangeType = { } as const /** - * Actor scope permission bitmask values. + * Actor scope permission bitmask values (base/eip-8130 `AccountConfiguration`). * - * `0x00` (unrestricted) is represented by the absence of any bit. + * `0x00` (unrestricted) is admin: an actor is admin iff `scope == 0`. There is + * no `SCOPE_SIGNATURE` / `SCOPE_CONFIG` bit — ERC-1271 signing and config rights + * ride on admin scope (or a SENDER actor without POLICY). Bits `0x20`, `0x40`, + * `0x80` are spare. */ export const actorScope = { - signature: 0x01, - sender: 0x02, - payer: 0x04, - config: 0x08, + /** `SCOPE_SENDER` — may originate transactions with the account as sender. */ + sender: 0x01, + /** `SCOPE_POLICY` — actor is gated to its policy manager; a bit in the scope word (replaces the old `policyType` field). */ + policy: 0x02, + /** `SCOPE_NONCE` — may use sequenced nonce keys; without it, restricted to nonce-free (`NONCE_KEY_MAX`). */ + nonce: 0x04, + /** `SCOPE_SELF_PAYER` — may pay for its own transactions (`payer == sender`). */ + selfPayer: 0x08, + /** `SCOPE_SPONSOR_PAYER` — may sponsor others (`payer != sender`). */ + sponsorPayer: 0x10, +} as const + +/** Unrestricted (admin) scope value (`SCOPE_UNRESTRICTED`). An actor is admin iff `scope == 0`. */ +export const scopeUnrestricted = 0x00 + +/** + * `AccountState.flags` bits (base/eip-8130 `AccountConfiguration`). + * + * The lock state is derived from these flags plus the `lockUnion` field: while + * `unlockInitiated` is clear, `lockUnion` holds the configured unlock delay + * (seconds); while set, it holds `unlocksAt` (the timestamp the unlock takes + * effect). Only meaningful when `locked` is set. + */ +export const accountStateFlags = { + /** `FLAG_REVOKE_DEFAULT_EOA` — disables the implicit k1 self key. */ + revokeDefaultEoa: 0x01, + /** `FLAG_LOCKED` — actor configuration is frozen. */ + locked: 0x02, + /** `FLAG_UNLOCK_INITIATED` — selects how `lockUnion` is interpreted. */ + unlockInitiated: 0x04, } as const +/** `applySignedLockChanges` op selecting a hard lock (`LOCK_OP`). */ +export const lockOp = 0x01 + +/** `applySignedLockChanges` op initiating a (delayed) unlock (`UNLOCK_OP`). */ +export const unlockOp = 0x02 + +/** Exact `policyData` byte length for a policy-bearing actor: `manager (20) || commitment (32)` (`POLICY_DATA_LEN`). */ +export const policyDataLength = 52 + /** * Nonce-free mode selector (`NONCE_KEY_MAX`). When `nonceKey` equals this value, * no nonce state is read or incremented and replay protection relies on @@ -91,14 +165,14 @@ export const trustedExecutorAuthenticator = * {@link eip8130Deployments} / {@link getEip8130Deployment}, or override per call. */ export const canonicalAuthenticators = { - /** secp256k1 — native sentinel (`ECRECOVER_AUTHENTICATOR`). */ + /** secp256k1 — native sentinel (`ECRECOVER_AUTHENTICATOR` / `K1_AUTHENTICATOR`). */ k1: '0x0000000000000000000000000000000000000001', /** P-256 (raw). Canonical base/eip-8130 deployment. */ - p256: '0x28096E6f98996799A08fBbCFF0B7c0D512D1f503', + p256: '0xf8847a74F8067CabaE5fe56B70b372A7D670f0f8', /** WebAuthn / FIDO2 passkey. Canonical base/eip-8130 deployment. */ - passkey: '0xD9B8d163a34FBaD781057F7B68889F0bbd70D7ed', + passkey: '0x871c72d3950308A028E9c4917591bcfd3D6a1EF7', /** Signature delegation (1-hop). Canonical base/eip-8130 deployment. */ - delegate: '0xb1f064A99919E4199b45F1b553b6ecb8d5d62a11', + delegate: '0x1B0195ba5E3FCdB387DD619816eeF8b510Ed0855', } as const satisfies Record /** @@ -138,7 +212,7 @@ export const txContextAddress = * parameter of {@link computeAddress8130}. */ export const accountConfigAddress = - '0x2403408177dB7F8512a9593343a7C80371D8f2dF' satisfies Hex + '0xe7Bb8eF3728ea9f0A8be6D7e9585FeAb12dE086A' satisfies Hex /** * Default wallet implementation for EOA auto-delegation @@ -149,7 +223,7 @@ export const accountConfigAddress = * {@link accountConfigAddress}. */ export const defaultAccountAddress = - '0xD67D6ae50521A0ea9Aa1e174C536F346E87a1903' satisfies Hex + '0xDd802113C9FF6964cD2A61A16e075D5271cC82c9' satisfies Hex /** Size of the deployment header in bytes (`DEPLOYMENT_HEADER_SIZE`). */ export const deploymentHeaderSize = 14 diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index 4d80dc7a4e..957b52fa2b 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -82,23 +82,26 @@ export type Eip8130Deployment = { * bytecode change), all addresses must be re-derived and this object updated. */ export const canonicalEip8130Deployment = { - accountConfiguration: '0x2403408177dB7F8512a9593343a7C80371D8f2dF', + accountConfiguration: '0xe7Bb8eF3728ea9f0A8be6D7e9585FeAb12dE086A', accounts: { + // `upgradeable` / `erc4337` are unaudited example wallets (base/eip-8130-examples), + // not part of base/eip-8130's canonical `Deploy.s.sol` set. They cascade off + // `accountConfiguration`; re-pin once the examples repo publishes a broadcast. upgradeable: '0xF8dafa4DA35F664cf2CF842f00482ebb68a982b3', - default: '0xaF0973bbebe12BDaE6B61c96019dc0DcA554b67c', - defaultHighRate: '0x6c4230a4101849a3CB6438C40D3d47EdE9aca096', + default: '0xDd802113C9FF6964cD2A61A16e075D5271cC82c9', + defaultHighRate: '0xe5edfB7E7365893d685c2FbFBAC3e022f51d942F', erc4337: '0x8812ee1c9BA2395b5f113412769f22C6e7b89B11', }, authenticators: { k1: '0x0000000000000000000000000000000000000001', - p256: '0x28096E6f98996799A08fBbCFF0B7c0D512D1f503', - webAuthn: '0xD9B8d163a34FBaD781057F7B68889F0bbd70D7ed', - delegate: '0xb1f064A99919E4199b45F1b553b6ecb8d5d62a11', - alwaysValid: '0x4299a796C1D3ffCe7885ce13d9815C1b4DB2Ea94', + p256: '0xf8847a74F8067CabaE5fe56B70b372A7D670f0f8', + webAuthn: '0x871c72d3950308A028E9c4917591bcfd3D6a1EF7', + delegate: '0x1B0195ba5E3FCdB387DD619816eeF8b510Ed0855', + alwaysValid: '0xA550545Da91720c23483c5B3493412A02D1cF9F9', }, policies: { - manager: '0x5E5c3D54078d1000309233fEc116A83Df5a07E67', - sessionPolicy: '0xbd26BdA18Ee35F767ef03fD72356ae598ed6f793', + manager: '0x18B545EfC321644eE2dB9644c8f94f3f3d5e8624', + sessionPolicy: '0x6Ef50425716c134162C5c289E02162dde75b23Ea', }, } as const satisfies Eip8130Deployment diff --git a/src/experimental/eip8130/devx.test.ts b/src/experimental/eip8130/devx.test.ts index cda7bcc919..5d27f18545 100644 --- a/src/experimental/eip8130/devx.test.ts +++ b/src/experimental/eip8130/devx.test.ts @@ -49,7 +49,7 @@ describe('key builders + actorId derivation', () => { describe('scope + policy helpers', () => { test('toScope combines flags', () => { - expect(toScope(actorScope.sender, actorScope.payer)).toBe(0x06) + expect(toScope(actorScope.sender, actorScope.selfPayer)).toBe(0x09) }) test('encodePolicyData = manager || commitment', () => { @@ -62,24 +62,27 @@ describe('scope + policy helpers', () => { ) }) - test('authorizeActor rejects unrestricted / CONFIG-scoped policy actor', () => { + test('authorizeActor rejects unrestricted (admin) policy actor; sets SCOPE_POLICY bit otherwise', () => { const commitment = `0x${'aa'.repeat(32)}` as const const policy = { type: 1, manager: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', commitment, } as const + // A policy-bearing actor must have a restricted (non-admin) scope. expect(() => authorizeActor(key.p256(pubkey), { policy })).toThrow() expect(() => - authorizeActor(key.p256(pubkey), { scope: actorScope.config, policy }), + authorizeActor(key.p256(pubkey), { scope: 0, policy }), ).toThrow() - // sender-only is allowed + // sender-scoped policy actor: SCOPE_POLICY bit is set, policyData populated. const change = authorizeActor(key.p256(pubkey), { scope: actorScope.sender, policy, }) - expect(change.policyType).toBe(1) - expect(change.scope).toBe(actorScope.sender) + expect(change.scope).toBe(actorScope.sender | actorScope.policy) + expect(change.policyData?.toLowerCase()).toBe( + `${policy.manager.toLowerCase()}${commitment.slice(2)}`, + ) }) }) diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index ee7fa1dc81..b98f977f46 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -27,11 +27,41 @@ export { type EstimateGas8130ReturnType, estimateGas8130, } from './actions/estimateGas8130.js' +export { + type GetActorConfig8130Parameters, + type GetActorConfig8130ReturnType, + getActorConfig8130, +} from './actions/getActorConfig8130.js' export { type GetConfigSequence8130Parameters, type GetConfigSequence8130ReturnType, getConfigSequence8130, } from './actions/getConfigSequence8130.js' +export { + type GetLockStatus8130Parameters, + type GetLockStatus8130ReturnType, + getLockStatus8130, +} from './actions/getLockStatus8130.js' +export { + type GetPolicy8130Parameters, + type GetPolicy8130ReturnType, + getPolicy8130, +} from './actions/getPolicy8130.js' +export { + type GetSessionSpend8130Parameters, + type GetSessionSpend8130ReturnType, + getSessionSpend8130, +} from './actions/getSessionSpend8130.js' +export { + type IsActor8130Parameters, + type IsActor8130ReturnType, + isActor8130, +} from './actions/isActor8130.js' +export { + type IsLocked8130Parameters, + type IsLocked8130ReturnType, + isLocked8130, +} from './actions/isLocked8130.js' export { type GetTransactionCount8130Parameters, type GetTransactionCount8130ReturnType, @@ -75,6 +105,7 @@ export { aaTransactionType, accountChangeType, accountConfigAddress, + accountStateFlags, actorChangeType, actorScope, canonicalAuthDataLength, @@ -82,12 +113,23 @@ export { defaultAccountAddress, deploymentHeaderSize, ecrecoverAuthenticator, + lockOp, maxCodeSize, + nonceFreeCost, + nonceFreeExpiryWindow, + nonceFreeMaxExpiryWindow, + nonceKeyExistingCost, + nonceKeyFirstUseCost, nonceKeyMax, nonceManagerAddress, + policyDataLength, + replayBufferCapacity, + replayIdType, revokedAuthenticator, + scopeUnrestricted, trustedExecutorAuthenticator, txContextAddress, + unlockOp, } from './constants.js' export { baseSepoliaDeployment, @@ -106,6 +148,17 @@ export { revokeActor, toScope, } from './keys.js' +export { + type HashLockChange8130Parameters, + hashLockChange8130, + type InitiateUnlockCallParameters, + initiateUnlockCall, + type LockCallParameters, + type LockChangeOp, + lockChangeTypehash, + lockCall, +} from './lock.js' +export { type Nonce, nonce } from './nonce.js' export { type CommitmentOfErrorType, commitmentOf, diff --git a/src/experimental/eip8130/keys.ts b/src/experimental/eip8130/keys.ts index 99260037af..48338616ad 100644 --- a/src/experimental/eip8130/keys.ts +++ b/src/experimental/eip8130/keys.ts @@ -137,20 +137,19 @@ export function authorizeActor( actorId: actor.actorId, authenticator: actor.authenticator, } - if (options.scope) change.scope = options.scope + let scope = options.scope ?? 0 if (options.expiry) change.expiry = options.expiry if (options.policy) { - if ( - options.scope === undefined || - options.scope === 0 || - (options.scope & actorScope.config) !== 0 - ) + // Admin (scope 0) is unrestricted; a policy-gated actor must be restricted. + if (scope === 0) throw new BaseError( - 'A policy-bearing actor MUST have a restricted scope that excludes CONFIG (e.g. `actorScope.sender`).', + 'A policy-bearing actor MUST have a restricted (non-admin) scope (e.g. `actorScope.sender`).', ) - change.policyType = options.policy.type + // Policy presence is the SCOPE_POLICY bit (there is no `policyType` field). + scope |= actorScope.policy change.policyData = encodePolicyData(options.policy) } + if (scope) change.scope = scope return change } diff --git a/src/experimental/eip8130/lock.test.ts b/src/experimental/eip8130/lock.test.ts new file mode 100644 index 0000000000..9460d2f109 --- /dev/null +++ b/src/experimental/eip8130/lock.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'vitest' +import { mainnet } from '../../chains/index.js' +import { createClient } from '../../clients/createClient.js' +import { custom } from '../../clients/transports/custom.js' +import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' +import { encodeFunctionResult } from '../../utils/abi/encodeFunctionResult.js' +import { accountConfigurationAbi } from './abis.js' +import { getLockStatus8130 } from './actions/getLockStatus8130.js' +import { isLocked8130 } from './actions/isLocked8130.js' +import { accountConfigAddress, lockOp, unlockOp } from './constants.js' +import { initiateUnlockCall, lockCall } from './lock.js' + +const account = '0x0000000000000000000000000000000000000a11' +// Signed-lock-change `auth` blob (authenticator || data); opaque to the call builder. +const auth = `0x${'ab'.repeat(85)}` as const + +describe('lockCall', () => { + test('encodes applySignedLockChanges(LOCK_OP) to the canonical AccountConfiguration', () => { + const call = lockCall({ account, unlockDelay: 3600, auth }) + expect(call.to).toBe(accountConfigAddress) + const { functionName, args } = decodeFunctionData({ + abi: accountConfigurationAbi, + data: call.data!, + }) + expect(functionName).toBe('applySignedLockChanges') + expect(args).toEqual([account, lockOp, 3600, auth]) + }) + + test('respects an accountConfiguration override', () => { + const accountConfiguration = '0x00000000000000000000000000000000000000cc' + expect( + lockCall({ account, unlockDelay: 3600, auth, accountConfiguration }).to, + ).toBe(accountConfiguration) + }) + + test('rejects out-of-range unlockDelay (uint16)', () => { + expect(() => lockCall({ account, unlockDelay: 0, auth })).toThrow() + expect(() => lockCall({ account, unlockDelay: -1, auth })).toThrow() + expect(() => lockCall({ account, unlockDelay: 65_536, auth })).toThrow() + expect(() => lockCall({ account, unlockDelay: 1.5, auth })).toThrow() + }) +}) + +describe('initiateUnlockCall', () => { + test('encodes applySignedLockChanges(UNLOCK_OP) to the canonical AccountConfiguration', () => { + const call = initiateUnlockCall({ account, auth }) + expect(call.to).toBe(accountConfigAddress) + const { functionName, args } = decodeFunctionData({ + abi: accountConfigurationAbi, + data: call.data!, + }) + expect(functionName).toBe('applySignedLockChanges') + expect(args).toEqual([account, unlockOp, 0, auth]) + }) +}) + +function lockClient(handlers: Record) { + return createClient({ + chain: mainnet, + transport: custom({ + async request({ method }: { method: string; params: any }) { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_call') return handlers.eth_call + throw new Error(`unexpected RPC: ${method}`) + }, + }), + }) +} + +describe('getLockStatus8130', () => { + test('decodes the AccountConfiguration.getLockStatus tuple', async () => { + const client = lockClient({ + eth_call: encodeFunctionResult({ + abi: accountConfigurationAbi, + functionName: 'getLockStatus', + result: [true, true, 1_800_000_000, 3600], + }), + }) + const status = await getLockStatus8130(client, { account }) + expect(status).toEqual({ + locked: true, + hasInitiatedUnlock: true, + unlocksAt: 1_800_000_000, + unlockDelay: 3600, + }) + }) +}) + +describe('isLocked8130', () => { + test('decodes the AccountConfiguration.isLocked bool', async () => { + const client = lockClient({ + eth_call: encodeFunctionResult({ + abi: accountConfigurationAbi, + functionName: 'isLocked', + result: true, + }), + }) + expect(await isLocked8130(client, { account })).toBe(true) + }) +}) diff --git a/src/experimental/eip8130/lock.ts b/src/experimental/eip8130/lock.ts new file mode 100644 index 0000000000..f0653508e0 --- /dev/null +++ b/src/experimental/eip8130/lock.ts @@ -0,0 +1,197 @@ +import type { Address } from 'abitype' +import { BaseError } from '../../errors/base.js' +import { encodeAbiParameters } from '../../utils/abi/encodeAbiParameters.js' +import { encodeFunctionData } from '../../utils/abi/encodeFunctionData.js' +import { stringToHex } from '../../utils/encoding/toHex.js' +import { keccak256 } from '../../utils/hash/keccak256.js' +import type { Hex } from '../../types/misc.js' +import { accountConfigurationAbi } from './abis.js' +import { + accountConfigAddress as defaultAccountConfigAddress, + lockOp, + unlockOp, +} from './constants.js' +import type { AaCall } from './types/transaction.js' + +/** + * Account locking (EIP-8130 `AccountConfiguration`). + * + * Locking an account freezes it against a compromised key: it blocks + * configuration changes and delegation, and unlocking is time-delayed + * (`unlockDelay`), giving the owner a window to react. Locked accounts are + * eligible for elevated per-account rate limits. + * + * Lock changes are a **signed** operation (like `applySignedActorChanges`): the + * account's admin (`scope == 0`) actor signs the {@link hashLockChange8130} + * digest, and the resulting `authenticator || data` blob is passed to + * `AccountConfiguration.applySignedLockChanges(account, op, unlockDelay, auth)`. + * Lock changes are local-channel only, so the digest binds the current + * `chainId` and consumes the account's local change `sequence`. Read the current + * state with {@link getLockStatus8130} / {@link isLocked8130}. + * + * @example + * ```ts + * import { + * hashLockChange8130, + * lockCall, + * getChangeSequences8130, // local sequence source + * sendCalls8130, + * } from 'viem/experimental/eip8130' + * + * // 1) hash + sign the lock (1-hour unlock delay) with an admin key + * const digest = hashLockChange8130({ account, chainId, op: 'lock', unlockDelay: 3600, sequence }) + * const auth = await signDigest(digest) // `authenticator || data` + * + * // 2) submit the signed lock change + * await sendCalls8130(client, { account, calls: [lockCall({ account, unlockDelay: 3600, auth })], gas }) + * ``` + */ + +/** Maximum `unlockDelay` (the ABI field is `uint16`). */ +const maxUnlockDelay = 0xffff + +/** `keccak256("SignedLockChange(address account,uint256 chainId,uint8 op,uint16 unlockDelay,uint64 sequence)")` */ +export const lockChangeTypehash = keccak256( + stringToHex( + 'SignedLockChange(address account,uint256 chainId,uint8 op,uint16 unlockDelay,uint64 sequence)', + ), +) + +export type LockChangeOp = 'lock' | 'unlock' + +function opByte(op: LockChangeOp): number { + return op === 'lock' ? lockOp : unlockOp +} + +export type HashLockChange8130Parameters = { + /** The account whose lock state is changing. */ + account: Address + /** Chain ID (lock changes are local-channel only; use the current chain). */ + chainId: number + /** `'lock'` (hard-lock) or `'unlock'` (initiate the delayed unlock). */ + op: LockChangeOp + /** + * Unlock delay in seconds (`uint16`, `1 … 65535`). Required and non-zero for + * `'lock'`; MUST be `0` for `'unlock'` (which consumes the stored delay). + */ + unlockDelay: number + /** The account's local change sequence (from `getChangeSequences8130`). */ + sequence: number +} + +/** + * Computes the EIP-8130 `SignedLockChange` signature digest: + * `keccak256(abi.encode(LOCK_CHANGE_TYPEHASH, account, chainId, op, unlockDelay, sequence))`. + * + * Sign it (in `authenticator || data` form, with an admin key) to produce the + * `auth` passed to {@link lockCall} / {@link initiateUnlockCall}. + */ +export function hashLockChange8130( + parameters: HashLockChange8130Parameters, +): Hex { + const { account, chainId, op, unlockDelay, sequence } = parameters + return keccak256( + encodeAbiParameters( + [ + { type: 'bytes32' }, + { type: 'address' }, + { type: 'uint256' }, + { type: 'uint8' }, + { type: 'uint16' }, + { type: 'uint64' }, + ], + [ + lockChangeTypehash, + account, + BigInt(chainId), + opByte(op), + unlockDelay, + BigInt(sequence), + ], + ), + ) +} + +export type LockCallParameters = { + /** The account being locked (bound into the signed digest and the call). */ + account: Address + /** + * Delay in seconds between {@link initiateUnlockCall} and the account becoming + * unlocked (`uint16`, `1 … 65535`). A larger delay gives more time to respond + * to a compromised key. + */ + unlockDelay: number + /** Admin signature over {@link hashLockChange8130} (`authenticator || data`). */ + auth: Hex + /** + * `AccountConfiguration` system contract. Defaults to the canonical + * (enshrined) address, which is identical on every supported chain. + */ + accountConfiguration?: Address | undefined +} + +/** + * Builds the account call that hard-locks the account: + * `AccountConfiguration.applySignedLockChanges(account, LOCK_OP, unlockDelay, auth)`. + * Include it in a {@link sendCalls8130} phase. + */ +export function lockCall(parameters: LockCallParameters): AaCall { + const { + account, + unlockDelay, + auth, + accountConfiguration = defaultAccountConfigAddress, + } = parameters + if ( + !Number.isInteger(unlockDelay) || + unlockDelay < 1 || + unlockDelay > maxUnlockDelay + ) + throw new BaseError( + `\`unlockDelay\` must be an integer in \`1 … ${maxUnlockDelay}\` (uint16 seconds). Received ${unlockDelay}.`, + ) + return { + to: accountConfiguration, + data: encodeFunctionData({ + abi: accountConfigurationAbi, + functionName: 'applySignedLockChanges', + args: [account, lockOp, unlockDelay, auth], + }), + } +} + +export type InitiateUnlockCallParameters = { + /** The account being unlocked (bound into the signed digest and the call). */ + account: Address + /** Admin signature over {@link hashLockChange8130} (`authenticator || data`). */ + auth: Hex + /** + * `AccountConfiguration` system contract. Defaults to the canonical + * (enshrined) address, which is identical on every supported chain. + */ + accountConfiguration?: Address | undefined +} + +/** + * Builds the account call that begins the (time-delayed) unlock: + * `AccountConfiguration.applySignedLockChanges(account, UNLOCK_OP, 0, auth)`. + * Include it in a {@link sendCalls8130} phase. The account becomes unlocked + * `unlockDelay` seconds later (see {@link getLockStatus8130}). + */ +export function initiateUnlockCall( + parameters: InitiateUnlockCallParameters, +): AaCall { + const { + account, + auth, + accountConfiguration = defaultAccountConfigAddress, + } = parameters + return { + to: accountConfiguration, + data: encodeFunctionData({ + abi: accountConfigurationAbi, + functionName: 'applySignedLockChanges', + args: [account, unlockOp, 0, auth], + }), + } +} diff --git a/src/experimental/eip8130/nonce.test.ts b/src/experimental/eip8130/nonce.test.ts new file mode 100644 index 0000000000..4405770b52 --- /dev/null +++ b/src/experimental/eip8130/nonce.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { mainnet } from '../../chains/index.js' +import { createClient } from '../../clients/createClient.js' +import { custom } from '../../clients/transports/custom.js' +import type { Hex } from '../../types/misc.js' +import { keccak256 } from '../../utils/hash/keccak256.js' +import { to8130Account } from './accounts/to8130Account.js' +import { sendCalls8130 } from './actions/sendCalls.js' +import { nonceKeyMax } from './constants.js' +import { key } from './keys.js' +import { nonce } from './nonce.js' +import { parseTransaction8130 } from './utils/parseTransaction.js' +import { erc1167Bytecode } from './utils/proxy.js' + +const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const code = erc1167Bytecode('0x00000000000000000000000000000000000000Ec') +const userSalt = + '0x0000000000000000000000000000000000000000000000000000000000000001' + +const account = to8130Account({ + signer: owner, + userSalt, + code, + initialActors: [key.k1(owner.address)], +}) + +describe('nonce builders', () => { + test('sequential → channel 0', () => { + expect(nonce.sequential()).toEqual({ nonceKey: 0n }) + }) + + test('channel(key)', () => { + expect(nonce.channel(7n)).toEqual({ nonceKey: 7n }) + }) + + test('channel rejects out-of-range / reserved keys', () => { + expect(() => nonce.channel(-1n)).toThrow() + expect(() => nonce.channel(nonceKeyMax + 1n)).toThrow() + // NONCE_KEY_MAX is nonce-free mode, not a counter channel. + expect(() => nonce.channel(nonceKeyMax)).toThrow() + }) + + test('randomChannel is in 1 … NONCE_KEY_MAX - 1', () => { + for (let i = 0; i < 50; i++) { + const { nonceKey } = nonce.randomChannel() + expect(nonceKey).toBeGreaterThanOrEqual(1n) + expect(nonceKey).toBeLessThan(nonceKeyMax) + } + // Distinct across calls (collision astronomically unlikely). + expect(nonce.randomChannel().nonceKey).not.toBe( + nonce.randomChannel().nonceKey, + ) + }) + + test('nonceless with absolute expiry', () => { + expect(nonce.nonceless({ expiry: 1_800_000_000n })).toEqual({ + nonceKey: nonceKeyMax, + nonceSequence: 0n, + expiry: 1_800_000_000n, + }) + }) + + test('nonceless with relative expiresIn', () => { + const now = Math.floor(Date.now() / 1000) + const result = nonce.nonceless({ expiresIn: 600 }) + expect(result.nonceKey).toBe(nonceKeyMax) + expect(result.nonceSequence).toBe(0n) + expect(Number(result.expiry)).toBeGreaterThanOrEqual(now + 600) + expect(Number(result.expiry)).toBeLessThanOrEqual(now + 601) + }) + + test('nonceless requires an expiry', () => { + expect(() => nonce.nonceless({})).toThrow() + expect(() => nonce.nonceless({ expiry: 0n })).toThrow() + }) +}) + +describe('sendCalls8130 nonce integration', () => { + function makeClient() { + const methods: string[] = [] + let sent: Hex | undefined + let lastGetCountParams: unknown[] | undefined + const client = createClient({ + chain: mainnet, + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + methods.push(method) + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_getTransactionCount') { + lastGetCountParams = params + return '0x3' + } + if (method === 'eth_sendRawTransaction') { + sent = params[0] + return keccak256(params[0]) + } + throw new Error(`unexpected RPC: ${method}`) + }, + }), + }) + return { + client, + methods, + get sent() { + return sent + }, + get lastGetCountParams() { + return lastGetCountParams + }, + } + } + + const fees = { + gas: 200_000n, + maxFeePerGas: 2_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + } + const calls = [{ to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }] + + test('nonceless: no nonce read, tx carries NONCE_KEY_MAX + expiry', async () => { + const ctx = makeClient() + await sendCalls8130(ctx.client, { + account, + calls, + ...fees, + ...nonce.nonceless({ expiry: 1_800_000_000n }), + }) + expect(ctx.methods).not.toContain('eth_getTransactionCount') + const parsed = parseTransaction8130(ctx.sent!) + expect(parsed.nonceKey).toBe(nonceKeyMax) + // `0n` sequence RLP-encodes as empty and parses back as `undefined`/`0n`. + expect(parsed.nonceSequence ?? 0n).toBe(0n) + expect(parsed.expiry).toBe(1_800_000_000n) + }) + + test('channel: reads the sequence with the 2D nonce_key param', async () => { + const ctx = makeClient() + await sendCalls8130(ctx.client, { + account, + calls, + ...fees, + ...nonce.channel(5n), + }) + expect(ctx.methods).toContain('eth_getTransactionCount') + // [address, blockTag, nonce_key] + expect(ctx.lastGetCountParams).toHaveLength(3) + expect(ctx.lastGetCountParams?.[2]).toBe('0x5') + const parsed = parseTransaction8130(ctx.sent!) + expect(parsed.nonceKey).toBe(5n) + expect(parsed.nonceSequence).toBe(3n) + }) +}) diff --git a/src/experimental/eip8130/nonce.ts b/src/experimental/eip8130/nonce.ts new file mode 100644 index 0000000000..2a39514287 --- /dev/null +++ b/src/experimental/eip8130/nonce.ts @@ -0,0 +1,121 @@ +import { BaseError } from '../../errors/base.js' +import { hexToBigInt } from '../../utils/encoding/fromHex.js' +import { bytesToHex } from '../../utils/encoding/toHex.js' +import { nonceKeyMax } from './constants.js' + +/** + * A resolved EIP-8130 nonce selection: the channel key and (for nonce-free + * mode) the fixed sequence and required expiry. Spread the result directly into + * {@link sendCalls8130} / {@link prepareTransaction8130} parameters. + */ +export type Nonce = { + /** 2D nonce channel selector (`uint256`). `0` = standard sequential ordering. */ + nonceKey: bigint + /** + * Expected sequence within the channel. Omitted for counter-backed channels + * (read from the node); pinned to `0n` for nonce-free mode. + */ + nonceSequence?: bigint | undefined + /** + * Unix timestamp (seconds) after which the transaction is invalid. Required + * for nonce-free mode (it is the sole replay protection there). + */ + expiry?: bigint | undefined +} + +/** + * Builders for EIP-8130 nonce selection. EIP-8130 accounts support three + * distinct nonce strategies; these helpers produce the correct + * `nonceKey` / `nonceSequence` / `expiry` fields for each, ready to spread into + * {@link sendCalls8130} / {@link prepareTransaction8130}. + * + * - {@link nonce.sequential} — the classic single-file nonce (channel `0`). + * - {@link nonce.channel} / {@link nonce.randomChannel} — independent 2D nonce + * channels, each with its own counter, so transactions in different channels + * can be submitted and mined in parallel / out of order (high throughput). + * - {@link nonce.nonceless} — nonce-free (expiring) mode: no counter is read or + * incremented; replay protection relies entirely on `expiry`. + * + * @example + * ```ts + * import { nonce, sendCalls8130 } from 'viem/experimental/eip8130' + * + * // Two independent channels → can be mined in either order. + * await sendCalls8130(client, { account, calls: a, gas, ...nonce.channel(1n) }) + * await sendCalls8130(client, { account, calls: b, gas, ...nonce.channel(2n) }) + * + * // Fire-and-forget parallel txs on random channels. + * await sendCalls8130(client, { account, calls, gas, ...nonce.randomChannel() }) + * + * // Nonce-free: valid for the next 10 minutes, no sequencing. + * await sendCalls8130(client, { account, calls, gas, ...nonce.nonceless({ expiresIn: 600 }) }) + * ``` + */ +export const nonce = { + /** + * Standard sequential nonce (channel `0`). The node reads and increments the + * account's protocol nonce; transactions are strictly ordered. + */ + sequential(): Nonce { + return { nonceKey: 0n } + }, + + /** + * A specific 2D nonce channel. Each channel maintains its own sequential + * counter, so transactions in different channels are independent and may be + * mined out of order relative to one another. The next sequence for the + * channel is read from the node when not supplied. + * + * @param key - Channel selector (`1 … NONCE_KEY_MAX - 1`). `0` is the standard + * channel (use {@link nonce.sequential}); `NONCE_KEY_MAX` is reserved for + * nonce-free mode (use {@link nonce.nonceless}). + */ + channel(key: bigint): Nonce { + if (key < 0n || key > nonceKeyMax) + throw new BaseError( + `\`nonceKey\` must be in \`0 … NONCE_KEY_MAX\`. Received ${key}.`, + ) + if (key === nonceKeyMax) + throw new BaseError( + '`NONCE_KEY_MAX` selects nonce-free mode, which has no counter. Use `nonce.nonceless({ expiry })` instead.', + ) + return { nonceKey: key } + }, + + /** + * A pseudo-random 2D nonce channel (uniform in `1 … NONCE_KEY_MAX - 1`). Use + * for fire-and-forget parallel transactions where ordering between them does + * not matter and a fresh, collision-free channel is desired per send. + */ + randomChannel(): Nonce { + const buffer = new Uint8Array(32) + globalThis.crypto.getRandomValues(buffer) + // Map uniformly into `1 … NONCE_KEY_MAX - 1` (never `0` or `NONCE_KEY_MAX`). + const key = (hexToBigInt(bytesToHex(buffer)) % (nonceKeyMax - 1n)) + 1n + return { nonceKey: key } + }, + + /** + * Nonce-free (expiring) mode (`nonceKey = NONCE_KEY_MAX`). No per-account + * counter is read or incremented, so the transaction is not ordered against + * any other; replay protection is provided solely by `expiry`. Ideal for + * fully parallel, retry-safe sends. + * + * @param parameters.expiry - Absolute expiry (unix seconds). + * @param parameters.expiresIn - Relative expiry (seconds from now). Ignored + * when `expiry` is provided. One of `expiry` / `expiresIn` is required. + */ + nonceless(parameters: { expiry?: bigint; expiresIn?: number }): Nonce { + const { expiry, expiresIn } = parameters + const resolvedExpiry = + expiry ?? + (expiresIn !== undefined + ? BigInt(Math.floor(Date.now() / 1000) + expiresIn) + : undefined) + if (resolvedExpiry === undefined || resolvedExpiry <= 0n) + throw new BaseError( + 'Nonce-free mode requires a non-zero `expiry` (or `expiresIn`).', + ) + return { nonceKey: nonceKeyMax, nonceSequence: 0n, expiry: resolvedExpiry } + }, +} as const diff --git a/src/experimental/eip8130/policies.test.ts b/src/experimental/eip8130/policies.test.ts index 671f4a3049..10f072e3c7 100644 --- a/src/experimental/eip8130/policies.test.ts +++ b/src/experimental/eip8130/policies.test.ts @@ -50,7 +50,7 @@ describe('encoders', () => { describe('commitmentOf', () => { test('matches PolicyManager.commitmentOf reference vector', () => { expect(commitmentOf(binding)).toBe( - '0x2addca9de83bd448fa36975b4bc653c10a237ea7fe2c6374ed514cf32c3d64d3', + '0x8dd99af2418214f90f5f77acda96c564ef33f54f714cb5df58c39f33ba1c820d', ) }) diff --git a/src/experimental/eip8130/queries.test.ts b/src/experimental/eip8130/queries.test.ts new file mode 100644 index 0000000000..a1b7ca3822 --- /dev/null +++ b/src/experimental/eip8130/queries.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from 'vitest' +import type { Abi } from 'abitype' +import { mainnet } from '../../chains/index.js' +import { createClient } from '../../clients/createClient.js' +import { custom } from '../../clients/transports/custom.js' +import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' +import { encodeFunctionResult } from '../../utils/abi/encodeFunctionResult.js' +import { accountConfigurationAbi } from './abis.js' +import { getActorConfig8130 } from './actions/getActorConfig8130.js' +import { getPolicy8130 } from './actions/getPolicy8130.js' +import { getSessionSpend8130 } from './actions/getSessionSpend8130.js' +import { isActor8130 } from './actions/isActor8130.js' +import { canonicalAuthenticators } from './constants.js' +import { sessionPolicyAbi } from './policies.js' + +const account = '0x0000000000000000000000000000000000000a11' +const actorId = `0x${'11'.repeat(32)}` as const +const token = '0x0000000000000000000000000000000000000a22' +const commitment = `0x${'cc'.repeat(32)}` as const + +/** + * A client whose `eth_call` decodes the requested function and returns the + * matching pre-encoded result from `results`. + */ +function readClient(abi: Abi, results: Record) { + return createClient({ + chain: mainnet, + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_call') { + const { functionName } = decodeFunctionData({ + abi, + data: params[0].data, + }) + return encodeFunctionResult({ + abi, + functionName, + result: results[functionName] as never, + }) + } + throw new Error(`unexpected RPC: ${method}`) + }, + }), + }) +} + +describe('getSessionSpend8130', () => { + test('combines getTokenLimit + getCurrentSpend into a budget view', async () => { + const client = readClient(sessionPolicyAbi, { + // set, allowance, period + getTokenLimit: [true, 100_000_000n, 604_800], + // PeriodUsage { start, end, spend } + getCurrentSpend: { start: 1_000, end: 605_800, spend: 40_000_000n }, + }) + const spend = await getSessionSpend8130(client, { commitment, token }) + expect(spend).toEqual({ + set: true, + allowance: 100_000_000n, + period: 604_800, + spent: 40_000_000n, + remaining: 60_000_000n, + periodStart: 1_000, + periodEnd: 605_800, + }) + }) + + test('clamps remaining at zero when overspent/unset', async () => { + const client = readClient(sessionPolicyAbi, { + getTokenLimit: [false, 0n, 0], + getCurrentSpend: { start: 0, end: 0, spend: 0n }, + }) + const spend = await getSessionSpend8130(client, { commitment, token }) + expect(spend.set).toBe(false) + expect(spend.remaining).toBe(0n) + }) +}) + +describe('getActorConfig8130', () => { + test('decodes the ActorConfig struct', async () => { + const client = readClient(accountConfigurationAbi, { + getActorConfig: { + authenticator: canonicalAuthenticators.p256, + scope: 2, + expiry: 1_800_000_000, + }, + }) + expect(await getActorConfig8130(client, { account, actorId })).toEqual({ + authenticator: canonicalAuthenticators.p256, + scope: 2, + expiry: 1_800_000_000, + // SCOPE_POLICY (0x02) is set → hasPolicy. + hasPolicy: true, + }) + }) +}) + +describe('isActor8130', () => { + test('decodes the isActor bool', async () => { + const client = readClient(accountConfigurationAbi, { isActor: true }) + expect(await isActor8130(client, { account, actorId })).toBe(true) + }) +}) + +describe('getPolicy8130', () => { + test('decodes (target, commitment)', async () => { + const manager = '0x00000000000000000000000000000000000000dd' + const client = readClient(accountConfigurationAbi, { + getPolicy: [manager, commitment], + }) + expect(await getPolicy8130(client, { account, actorId })).toEqual({ + target: manager, + commitment, + }) + }) +}) diff --git a/src/experimental/eip8130/types/transaction.ts b/src/experimental/eip8130/types/transaction.ts index 130ae18d8a..9583162d6b 100644 --- a/src/experimental/eip8130/types/transaction.ts +++ b/src/experimental/eip8130/types/transaction.ts @@ -40,15 +40,24 @@ export type AaCall = { export type AaCalls = readonly (readonly AaCall[])[] /** - * An initial actor for a `create` entry. Initial actors are always registered as - * unrestricted owners (`scope = 0x00`, no policy, no expiry); only `actorId` and - * `authenticator` participate in address derivation. + * An initial actor for a `create` entry, and the identity returned by the + * {@link key} builders. + * + * Initial actors carry their `scope` and (when `scope & SCOPE_POLICY`) their + * `policyData`; `expiry` is always `0` at creation. The address-derivation + * commitment is `actorId || authenticator || scope || policyData` per actor + * (`policyData` is empty unless the `SCOPE_POLICY` bit is set, then exactly + * 52 bytes). */ export type AaActor = { /** 32-byte actor identifier. */ actorId: Hex /** Authenticator contract address. */ authenticator: Address + /** Scope bitmask committed at creation. `0` (or omitted) = unrestricted admin. */ + scope?: number | undefined + /** Policy data (`manager || commitment`) — required iff `scope & SCOPE_POLICY`, else empty/omitted. */ + policyData?: Hex | undefined } /** `create` (type `0x00`) account-change entry: deploy a new account. */ @@ -72,13 +81,11 @@ export type AaAuthorizeActor = { actorId: Hex /** Authenticator contract address. */ authenticator: Address - /** Permission bitmask. `0` (or omitted) = unrestricted. */ + /** Permission bitmask. `0` (or omitted) = unrestricted admin. Set the `SCOPE_POLICY` bit for a policy-gated actor. */ scope?: number | undefined /** Actor expiry (unix seconds). `0` (or omitted) = no expiry. */ expiry?: bigint | undefined - /** Policy type. `0` (or omitted) = no policy. */ - policyType?: number | undefined - /** Policy data (`manager || commitment` when `policyType != 0`). */ + /** Policy data (`manager || commitment`) — required iff `scope & SCOPE_POLICY`, else empty/omitted. */ policyData?: Hex | undefined } diff --git a/src/experimental/eip8130/utils/accountConfigCalls.test.ts b/src/experimental/eip8130/utils/accountConfigCalls.test.ts index cceade4a48..bc8ab89c03 100644 --- a/src/experimental/eip8130/utils/accountConfigCalls.test.ts +++ b/src/experimental/eip8130/utils/accountConfigCalls.test.ts @@ -60,7 +60,12 @@ describe('toFactoryArgs8130 (ERC-4337 factory)', () => { expect(args[0]).toBe(params.userSalt) expect(args[1]).toBe(params.code) expect(args[2]).toEqual([ - { actorId: actor.actorId, authenticator: actor.authenticator }, + { + actorId: actor.actorId, + authenticator: actor.authenticator, + scope: 0, + policyData: '0x', + }, ]) }) diff --git a/src/experimental/eip8130/utils/accountConfigCalls.ts b/src/experimental/eip8130/utils/accountConfigCalls.ts index 56bf19e66e..482702c704 100644 --- a/src/experimental/eip8130/utils/accountConfigCalls.ts +++ b/src/experimental/eip8130/utils/accountConfigCalls.ts @@ -14,6 +14,8 @@ function toInitialActors(actors: readonly AaActor[]) { return actors.map((actor) => ({ actorId: actor.actorId, authenticator: actor.authenticator, + scope: actor.scope ?? 0, + policyData: actor.policyData ?? ('0x' as Hex), })) } diff --git a/src/experimental/eip8130/utils/actorChangeData.ts b/src/experimental/eip8130/utils/actorChangeData.ts index ecee828c17..13b20201bb 100644 --- a/src/experimental/eip8130/utils/actorChangeData.ts +++ b/src/experimental/eip8130/utils/actorChangeData.ts @@ -19,7 +19,6 @@ const authorizeDataParameters = [ { name: 'authenticator', type: 'address' }, { name: 'scope', type: 'uint8' }, { name: 'expiry', type: 'uint48' }, - { name: 'policyType', type: 'uint8' }, ], }, { type: 'bytes' }, @@ -32,14 +31,16 @@ export type EncodeActorChangeDataErrorType = /** * Encodes the operation-specific `data` of an `actor_change`: * - * - `authorizeActor` -> `abi.encode((address,uint8,uint48,uint8) config, bytes policyData)` + * - `authorizeActor` -> `abi.encode((address,uint8,uint48) config, bytes policyData)` * - `revokeActor` -> empty bytes (`0x`) * * @remarks * The `data` is ABI-encoded (not RLP) so the same blob is decoded identically by * the native protocol and by `AccountConfiguration.applySignedActorChanges` * (`abi.decode(data, (ActorConfig, bytes))`). It is also the value hashed in the - * config-change signature digest (see {@link hashActorChanges8130}). + * config-change signature digest (see {@link hashActorChanges8130}). Policy + * presence is the `SCOPE_POLICY` bit in `scope`; `policyData` is empty unless + * that bit is set (then `manager (20) || commitment (32)`). */ export function encodeActorChangeData(change: AaActorChange): Hex { if (change.changeType === actorChangeType.authorizeActor) @@ -50,7 +51,6 @@ export function encodeActorChangeData(change: AaActorChange): Hex { // `uint48` maps to `number` in viem's ABI encoder; expiry (unix seconds) // fits comfortably. expiry: Number(change.expiry ?? 0n), - policyType: change.policyType ?? 0, }, change.policyData ?? '0x', ]) @@ -61,7 +61,6 @@ export type DecodedAuthorizeActorData = { authenticator: Address scope: number expiry: bigint - policyType: number policyData: Hex } @@ -79,7 +78,6 @@ export function decodeAuthorizeActorData(data: Hex): DecodedAuthorizeActorData { authenticator: config.authenticator, scope: config.scope, expiry: BigInt(config.expiry), - policyType: config.policyType, policyData, } } diff --git a/src/experimental/eip8130/utils/computeAddress.test.ts b/src/experimental/eip8130/utils/computeAddress.test.ts index bff79d6014..3fcecb88de 100644 --- a/src/experimental/eip8130/utils/computeAddress.test.ts +++ b/src/experimental/eip8130/utils/computeAddress.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest' import { getCreate2Address } from '../../../utils/address/getContractAddress.js' import { isAddress } from '../../../utils/address/isAddress.js' import { concatHex } from '../../../utils/data/concat.js' +import { toHex } from '../../../utils/encoding/toHex.js' import { keccak256 } from '../../../utils/hash/keccak256.js' import { accountConfigAddress } from '../constants.js' import type { AaActor } from '../types/transaction.js' @@ -37,8 +38,12 @@ describe('computeAddress (EIP-8130)', () => { concatHex([ actorA.actorId, actorA.authenticator, + toHex(actorA.scope ?? 0, { size: 1 }), + actorA.policyData ?? '0x', actorB.actorId, actorB.authenticator, + toHex(actorB.scope ?? 0, { size: 1 }), + actorB.policyData ?? '0x', ]), ) const effectiveSalt = keccak256(concatHex([userSalt, actorsCommitment])) diff --git a/src/experimental/eip8130/utils/computeAddress.ts b/src/experimental/eip8130/utils/computeAddress.ts index 2a6a1726c8..6417e3f09e 100644 --- a/src/experimental/eip8130/utils/computeAddress.ts +++ b/src/experimental/eip8130/utils/computeAddress.ts @@ -12,7 +12,7 @@ import { } from '../../../utils/data/concat.js' import { size } from '../../../utils/data/size.js' import { hexToBigInt } from '../../../utils/encoding/fromHex.js' -import { bytesToHex } from '../../../utils/encoding/toHex.js' +import { bytesToHex, toHex } from '../../../utils/encoding/toHex.js' import { type Keccak256ErrorType, keccak256, @@ -79,7 +79,8 @@ export type ComputeAddress8130ErrorType = * CREATE2 derivation: * * ``` - * actors_commitment = keccak256(actorId_0 || authenticator_0 || ...) + * // per actor: actorId(32) || authenticator(20) || scope(1) || policyData(0|52) + * actors_commitment = keccak256(actorId_0 || authenticator_0 || scope_0 || policyData_0 || ...) * effective_salt = keccak256(user_salt || actors_commitment) * deployment_code = DEPLOYMENT_HEADER(len(code)) || code * address = keccak256(0xff || ACCOUNT_CONFIG_ADDRESS || effective_salt || keccak256(deployment_code))[12:] @@ -117,7 +118,12 @@ export function computeAddress8130( const actorsCommitment = keccak256( concatHex( - initialActors.flatMap((actor) => [actor.actorId, actor.authenticator]), + initialActors.flatMap((actor) => [ + actor.actorId, + actor.authenticator, + toHex(actor.scope ?? 0, { size: 1 }), + actor.policyData ?? '0x', + ]), ), ) const effectiveSalt = keccak256(concatHex([userSalt, actorsCommitment])) diff --git a/src/experimental/eip8130/utils/hashActorChanges.ts b/src/experimental/eip8130/utils/hashActorChanges.ts index cdfae94bc4..4df595d2a8 100644 --- a/src/experimental/eip8130/utils/hashActorChanges.ts +++ b/src/experimental/eip8130/utils/hashActorChanges.ts @@ -26,11 +26,11 @@ export const actorChangeTypehash = keccak256( ) /** - * `keccak256("SignedActorChanges(address account,uint64 chainId,uint64 sequence,ActorChange[] actorChanges)ActorChange(uint8 changeType,bytes32 actorId,bytes data)")` + * `keccak256("SignedActorChanges(address account,uint256 chainId,uint64 sequence,ActorChange[] actorChanges)ActorChange(uint8 changeType,bytes32 actorId,bytes data)")` */ export const signedActorChangesTypehash = keccak256( stringToHex( - 'SignedActorChanges(address account,uint64 chainId,uint64 sequence,ActorChange[] actorChanges)ActorChange(uint8 changeType,bytes32 actorId,bytes data)', + 'SignedActorChanges(address account,uint256 chainId,uint64 sequence,ActorChange[] actorChanges)ActorChange(uint8 changeType,bytes32 actorId,bytes data)', ), ) @@ -94,7 +94,7 @@ export function hashActorChanges8130( [ { type: 'bytes32' }, { type: 'address' }, - { type: 'uint64' }, + { type: 'uint256' }, { type: 'uint64' }, { type: 'bytes32' }, ], diff --git a/src/experimental/eip8130/utils/parseTransaction.ts b/src/experimental/eip8130/utils/parseTransaction.ts index 702f232cc5..10d51747b6 100644 --- a/src/experimental/eip8130/utils/parseTransaction.ts +++ b/src/experimental/eip8130/utils/parseTransaction.ts @@ -56,15 +56,19 @@ function parseCalls(value: RlpHex): AaCalls { } function parseActor(value: RlpHex): AaActor { - const [actorId, authenticator] = value as Hex[] - return { actorId, authenticator: authenticator as Address } + const [actorId, authenticator, scope, policyData] = value as Hex[] + const actor: AaActor = { actorId, authenticator: authenticator as Address } + const scopeNum = !scope || scope === '0x' ? 0 : hexToNumber(scope) + if (scopeNum !== 0) actor.scope = scopeNum + if (policyData && policyData !== '0x') actor.policyData = policyData + return actor } function parseActorChange(value: RlpHex): AaActorChange { const [changeType, actorId, data] = value as [Hex, Hex, Hex] const type = changeType === '0x' ? 0 : hexToNumber(changeType) if (type === actorChangeType.authorizeActor) { - const { authenticator, scope, expiry, policyType, policyData } = + const { authenticator, scope, expiry, policyData } = decodeAuthorizeActorData(data) const change: AaActorChange = { changeType: actorChangeType.authorizeActor, @@ -73,7 +77,6 @@ function parseActorChange(value: RlpHex): AaActorChange { } if (scope !== 0) change.scope = scope if (expiry !== 0n) change.expiry = expiry - if (policyType !== 0) change.policyType = policyType if (policyData !== '0x') change.policyData = policyData return change } diff --git a/src/experimental/eip8130/utils/serializeTransaction.test.ts b/src/experimental/eip8130/utils/serializeTransaction.test.ts index 5b1fb3ac53..2c23881755 100644 --- a/src/experimental/eip8130/utils/serializeTransaction.test.ts +++ b/src/experimental/eip8130/utils/serializeTransaction.test.ts @@ -125,9 +125,10 @@ describe('serializeTransaction (EIP-8130)', () => { actorId: '0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc', authenticator: '0x0000000000000000000000000000000000000001', - scope: 0x04, + // SCOPE_NONCE (0x04) | SCOPE_POLICY (0x02); policy presence is a + // scope bit now (no standalone policyType field). + scope: 0x06, expiry: 1_900_000_000n, - policyType: 0x01, policyData: '0xc0ffee', }, { @@ -232,10 +233,12 @@ describe('signature hashes', () => { expect(withPayer).not.toEqual(withoutPayer) }) - test('payer hash excludes the payer field', () => { + test('payer hash binds the payer field', () => { + // The payer signature commits to the full body INCLUDING the `payer` slot + // (matches the Rust node's `payer_signature_hash`). const a = getPayerSignatureHash8130(transaction) const b = getPayerSignatureHash8130({ ...transaction, payer: undefined }) - expect(a).toEqual(b) + expect(a).not.toEqual(b) }) test('uses the correct domain-separation type bytes', () => { diff --git a/src/experimental/eip8130/utils/serializeTransaction.ts b/src/experimental/eip8130/utils/serializeTransaction.ts index d996a024c4..8f4e7658b3 100644 --- a/src/experimental/eip8130/utils/serializeTransaction.ts +++ b/src/experimental/eip8130/utils/serializeTransaction.ts @@ -65,6 +65,8 @@ export function toAccountChangesList( entry.initialActors.map((actor) => [ actor.actorId, actor.authenticator, + actor.scope ? numberToHex(actor.scope) : '0x', + actor.policyData ?? '0x', ]), ], ] From 46956e408edf69ce2ea9d6d9d58fe4b6d7292250 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 15 Jul 2026 20:41:57 -0400 Subject: [PATCH 43/96] fix(eip8130): send scope + policyData for create initialActors in estimateGas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node deserializes each estimateGas request `initialActors` entry directly into the consensus `InitialActor` struct, whose `scope` (u8) and `policyData` (bytes) fields are non-optional with no serde default. Omitting them made the whole `eth_estimateGas` request fail deserialization with `-32602 invalid params` before ever reaching the estimator — so estimating ANY create-bearing tx (new smart-account deployment) was rejected outright. Serialize both per actor with the same defaults used for address derivation: `scope` as a JSON number (0 = unrestricted admin) and `policyData` as hex (`0x` unless `scope & SCOPE_POLICY`). Verified against a live node: the old shape returns -32602, the corrected shape prices the create+batch correctly. Adds estimateGas8130.test.ts covering the create serialization (plain + policy -gated actor) — the previous create coverage was send-path only, so this drift went unnoticed. --- .../eip8130/actions/estimateGas8130.test.ts | 124 ++++++++++++++++++ .../eip8130/actions/estimateGas8130.ts | 10 ++ 2 files changed, 134 insertions(+) create mode 100644 src/experimental/eip8130/actions/estimateGas8130.test.ts diff --git a/src/experimental/eip8130/actions/estimateGas8130.test.ts b/src/experimental/eip8130/actions/estimateGas8130.test.ts new file mode 100644 index 0000000000..1685e93c42 --- /dev/null +++ b/src/experimental/eip8130/actions/estimateGas8130.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' +import { mainnet } from '../../../chains/index.js' +import { createClient } from '../../../clients/createClient.js' +import { custom } from '../../../clients/transports/custom.js' +import { numberToHex } from '../../../utils/encoding/toHex.js' +import { to8130Account } from '../accounts/to8130Account.js' +import { actorScope, canonicalAuthenticators } from '../constants.js' +import { authorizeActor, encodePolicyData, key } from '../keys.js' +import { estimateGas8130 } from './estimateGas8130.js' + +const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const code = '0x6080604052' as const +const userSalt = + '0x0000000000000000000000000000000000000000000000000000000000000001' as const + +/** A client whose `eth_estimateGas` records the request object and returns a stub gas. */ +function recordingClient() { + let request: any + const client = createClient({ + chain: mainnet, + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_estimateGas') { + request = params[0] + return numberToHex(100_000n) + } + throw new Error(`unexpected RPC: ${method}`) + }, + }), + }) + return { + client, + get request() { + return request + }, + } +} + +describe('estimateGas8130 — create account-change serialization', () => { + test('each initialActor carries scope (number) and policyData (hex)', async () => { + const rec = recordingClient() + const account = to8130Account({ + signer: owner, + userSalt, + code, + initialActors: [key.k1(owner.address)], + }) + + await estimateGas8130(rec.client, { + sender: account.address, + accountChanges: [account.create()], + calls: [[{ to: owner.address, value: 1n }]], + senderAuthAuthenticator: canonicalAuthenticators.k1, + }) + + const create = rec.request.accountChanges[0] + expect(create.type).toBe('create') + const actor = create.initialActors[0] + // The node deserializes into the consensus `InitialActor` struct, whose + // `scope` / `policyData` are non-optional (no serde default). Omitting + // either makes the whole request fail with -32602 invalid params. + expect(actor).toHaveProperty('scope') + expect(actor).toHaveProperty('policyData') + // scope must be a JSON NUMBER (u8), not a quantity hex string. + expect(typeof actor.scope).toBe('number') + expect(actor.scope).toBe(0) + // empty policyData is required, serialized as "0x". + expect(actor.policyData).toBe('0x') + }) + + test('policy-gated initial actor preserves its scope bits and policyData', async () => { + const rec = recordingClient() + const commitment = `0x${'aa'.repeat(32)}` as const + const policy = { + type: 1, + manager: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + commitment, + } as const + // Build a policy-gated actor and register it as an initial actor via its + // scope/policyData (as computeAddress commits them). + const gated = authorizeActor(key.p256({ + x: '0x1111111111111111111111111111111111111111111111111111111111111111', + y: '0x2222222222222222222222222222222222222222222222222222222222222222', + }), { scope: actorScope.sender, policy }) + + const initialActors = [ + { + actorId: gated.actorId, + authenticator: gated.authenticator, + scope: gated.scope, + policyData: gated.policyData, + }, + key.k1(owner.address), + ].sort((a, b) => (BigInt(a.actorId) < BigInt(b.actorId) ? -1 : 1)) + + const account = to8130Account({ + signer: owner, + userSalt, + code, + initialActors, + }) + + await estimateGas8130(rec.client, { + sender: account.address, + accountChanges: [account.create()], + calls: [[{ to: owner.address }]], + senderAuthAuthenticator: canonicalAuthenticators.k1, + }) + + const actors = rec.request.accountChanges[0].initialActors + const gatedOut = actors.find( + (a: any) => a.actorId === gated.actorId, + ) + expect(gatedOut.scope).toBe(actorScope.sender | actorScope.policy) + expect(typeof gatedOut.scope).toBe('number') + expect(gatedOut.policyData?.toLowerCase()).toBe( + encodePolicyData(policy).toLowerCase(), + ) + }) +}) diff --git a/src/experimental/eip8130/actions/estimateGas8130.ts b/src/experimental/eip8130/actions/estimateGas8130.ts index 5e68ac22a2..848ebbd04a 100644 --- a/src/experimental/eip8130/actions/estimateGas8130.ts +++ b/src/experimental/eip8130/actions/estimateGas8130.ts @@ -341,9 +341,19 @@ function serializeAccountChange( type: 'create', userSalt: change.userSalt, code: change.code, + // The node deserializes each entry directly into the consensus + // `InitialActor` struct, whose `scope` and `policyData` fields are + // non-optional with no serde default — omitting them makes the whole + // `eth_estimateGas` request fail deserialization with `-32602 invalid + // params` before it ever reaches the estimator. Always send both, with + // the same defaults used for address derivation: `scope` as a JSON + // NUMBER (u8; 0 = unrestricted admin), and `policyData` as hex bytes + // (`0x` unless `scope & SCOPE_POLICY`, then `manager || commitment`). initialActors: change.initialActors.map((a) => ({ actorId: a.actorId, authenticator: a.authenticator, + scope: a.scope ?? 0, + policyData: a.policyData ?? '0x', })), } } From 9056b063495f2748823aaf0fb2b1073f9ef0f5ff Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 15 Jul 2026 21:24:00 -0400 Subject: [PATCH 44/96] feat(eip8130): add toDelegate8130Signer for sub-account delegate signing A sub-account whose only owner is `key.delegate(parent)` must be authenticated through the DelegateAuthenticator: senderAuth = DELEGATE(20) || delegate(20) || nestedAuthenticator(20) || nestedSignature, where the nested signature resolves to an admin (scope 0) actor of the parent. Signing with a plain [ecrecover||sig] blob makes the node recover the k1 actorId (not bound on the sub) and reject the tx with "actor is not bound". `toDelegate8130Signer` wraps a parent-admin signer so its `sign` returns the delegate `data` payload; passed as the `signer` to `to8130Account` (with its `authenticator`), the existing configured-actor path serializes the full delegate senderAuth with no core changes. `delegateAuthSize` gives the blob length for `senderAuthSize` when estimating gas (the delegate authenticator has no default). --- .../eip8130/accounts/to8130Account.ts | 90 +++++++++++++++++++ src/experimental/eip8130/devx.test.ts | 66 +++++++++++++- src/experimental/eip8130/index.ts | 3 + 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index d51389bcb6..756dfc4e1b 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -1,6 +1,7 @@ import type { Address } from 'abitype' import { BaseError } from '../../../errors/base.js' import type { Hex } from '../../../types/misc.js' +import { concatHex } from '../../../utils/data/concat.js' import { bytesToHex } from '../../../utils/encoding/toHex.js' import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import { @@ -512,6 +513,95 @@ export function toEoa8130Account(signer: Signer): ToEoa8130AccountReturnType { } } +// ───────────────────────────────────────────────────────────────────────────── +// toDelegate8130Signer +// ───────────────────────────────────────────────────────────────────────────── + +export type ToDelegate8130SignerParameters = { + /** + * The delegate (parent) account address that controls the sub-account. The + * sub-account must have a `key.delegate(delegateAccount)` actor authorized + * (its `actorId` is `bytes32(bytes20(delegateAccount))`). + */ + delegateAccount: Address + /** + * A signer for an **admin** (scope `0x00`) actor of the delegate (parent) + * account — the nested signature must resolve to an admin, or the + * `DelegateAuthenticator` rejects the vouch (`InvalidNestedSignature`). + */ + nestedSigner: Signer + /** + * Authenticator of the nested (parent admin) signer. Defaults to the native + * `ECRECOVER_AUTHENTICATOR` (secp256k1). Set to the P-256 / WebAuthn + * authenticator when the parent admin actor is non-K1. + */ + nestedAuthenticator?: Address | undefined + /** DelegateAuthenticator address. Defaults to the canonical deployment. */ + authenticator?: Address | undefined +} + +/** + * Wraps a **parent admin** signer into a {@link Signer} that authenticates a + * sub-account through the `DelegateAuthenticator` (one delegation hop). The + * produced `sign` returns the delegate authenticator's `data` payload — + * `delegateAccount(20) ‖ nestedAuthenticator(20) ‖ nestedSignature` — so that + * `to8130Account`'s configured-actor path serializes the full `senderAuth` as + * `DELEGATE_AUTHENTICATOR ‖ data`. + * + * Use it as the `signer` (and pass its `authenticator`) to {@link to8130Account} + * for an account whose only owner is `key.delegate(parent)`: + * ```ts + * const delegateSigner = toDelegate8130Signer({ + * delegateAccount: parent.address, + * nestedSigner: parentAdmin, // an admin (scope 0) owner of the parent + * }) + * const sub = to8130Account({ + * signer: delegateSigner, + * authenticator: delegateSigner.authenticator, // DelegateAuthenticator + * userSalt, code, + * initialActors: [key.delegate(parent.address)], + * }) + * // sub.signTransaction(...) now produces a valid delegate senderAuth. + * ``` + * + * The parent account MUST be deployed (its admin actor config must be on-chain) + * before the delegate vouch can be validated. + */ +export function toDelegate8130Signer( + parameters: ToDelegate8130SignerParameters, +): Signer { + const { + delegateAccount, + nestedSigner, + nestedAuthenticator = ecrecoverAuthenticator, + authenticator = canonicalAuthenticators.delegate, + } = parameters + if (!nestedSigner.sign) + throw new BaseError('`nestedSigner` must support raw signing.') + return { + address: nestedSigner.address, + authenticator, + async sign({ hash }) { + const nestedSignature = await nestedSigner.sign!({ hash }) + // DelegateAuthenticator `data` layout (bytes after the 20-byte selector): + // delegate_address(20) || nested_authenticator(20) || nested_data + return concatHex([delegateAccount, nestedAuthenticator, nestedSignature]) + }, + } +} + +/** + * Byte length of a delegate `senderAuth`/`auth` blob for a given nested-auth + * payload length (default: 65-byte K1 signature). Useful as `senderAuthSize` / + * `payerAuthSize` when estimating gas for a delegate-signed transaction, since + * the delegate authenticator has no fixed default length. + * + * Layout: DELEGATE_AUTHENTICATOR(20) ‖ delegate_address(20) ‖ nested_auth(20) ‖ nested_data. + */ +export function delegateAuthSize(nestedDataLength = 65): number { + return 20 + 20 + 20 + nestedDataLength +} + /** Generates a cryptographically random bytes32 salt. */ function randomBytes32(): Hex { const buf = new Uint8Array(32) diff --git a/src/experimental/eip8130/devx.test.ts b/src/experimental/eip8130/devx.test.ts index 5d27f18545..572769227b 100644 --- a/src/experimental/eip8130/devx.test.ts +++ b/src/experimental/eip8130/devx.test.ts @@ -5,9 +5,17 @@ import { createClient } from '../../clients/createClient.js' import { custom } from '../../clients/transports/custom.js' import type { Hex } from '../../types/misc.js' import { keccak256 } from '../../utils/hash/keccak256.js' -import { to8130Account } from './accounts/to8130Account.js' +import { + delegateAuthSize, + to8130Account, + toDelegate8130Signer, +} from './accounts/to8130Account.js' import { sendCalls8130 } from './actions/sendCalls.js' -import { actorScope, canonicalAuthenticators } from './constants.js' +import { + actorScope, + canonicalAuthenticators, + ecrecoverAuthenticator, +} from './constants.js' import { authorizeActor, encodePolicyData, @@ -182,3 +190,57 @@ describe('sendCalls8130', () => { expect(parsed.senderAuth).toBeDefined() }) }) + +describe('toDelegate8130Signer (sub-account via key.delegate)', () => { + const parent = '0x00112233445566778899aabbccddeeff00112233' as const + + test('delegateAuthSize defaults to a 125-byte K1 blob', () => { + expect(delegateAuthSize()).toBe(125) + expect(delegateAuthSize(128)).toBe(188) + }) + + test('signer wraps the nested sig into the delegate data blob', async () => { + const signer = toDelegate8130Signer({ + delegateAccount: parent, + nestedSigner: owner, + }) + expect(signer.authenticator).toBe(canonicalAuthenticators.delegate) + const hash = keccak256('0xdeadbeef') + const blob = await signer.sign!({ hash }) + // data = delegate(20) || nestedAuthenticator(20) || nestedSig(65) = 105 bytes + expect((blob.length - 2) / 2).toBe(105) + expect(blob.slice(0, 42).toLowerCase()).toBe(parent.toLowerCase()) + expect(blob.slice(42, 82).toLowerCase()).toBe( + ecrecoverAuthenticator.slice(2).toLowerCase(), + ) + }) + + test('to8130Account serializes the full delegate senderAuth', async () => { + const signer = toDelegate8130Signer({ + delegateAccount: parent, + nestedSigner: owner, + }) + const sub = to8130Account({ + signer, + authenticator: signer.authenticator, + userSalt, + code, + initialActors: [key.delegate(parent)], + }) + const serialized = await sub.signTransaction({ + chainId: 1, + calls: [[{ to: parent, data: '0x' }]], + accountChanges: [sub.create()], + gas: 200_000n, + maxFeePerGas: 2_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonceSequence: 0n, + }) + const parsed = parseTransaction8130(serialized) + // senderAuth = DELEGATE(20) || delegate(20) || nested(20) || sig(65) = 125 bytes + expect((parsed.senderAuth!.length - 2) / 2).toBe(delegateAuthSize()) + expect(parsed.senderAuth!.slice(0, 42).toLowerCase()).toBe( + canonicalAuthenticators.delegate.toLowerCase(), + ) + }) +}) diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index b98f977f46..47c9799d06 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -15,6 +15,9 @@ export { newSmartAccount8130, type ToEoa8130AccountReturnType, toEoa8130Account, + type ToDelegate8130SignerParameters, + toDelegate8130Signer, + delegateAuthSize, } from './accounts/to8130Account.js' export { type Eip8130SmartAccountImplementation, From 319a1ad7719b9fab0336405f47c525ef12987d98 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 16 Jul 2026 11:50:19 -0400 Subject: [PATCH 45/96] fix(eip8130): encode account_changes entry as a single flat RLP list Match base/base #3985: each account_changes entry is now one flat RLP list `rlp([type_byte, ...fields])` instead of the EIP-2718-style `type_byte || rlp([fields])` bare-prefix framing. The type byte is a genuine list element (RLP-encoded as an integer), so `create` (0) serializes as the canonical empty item `0x` (-> 0x80), not `0x00`. Updates the serializer, parser, and the `accountChangeType.create` discriminator to stay symmetric. --- src/experimental/eip8130/constants.ts | 9 ++-- .../eip8130/utils/parseTransaction.ts | 15 +++--- .../eip8130/utils/serializeTransaction.ts | 47 +++++++++---------- 3 files changed, 37 insertions(+), 34 deletions(-) diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts index e5fdfa8610..ce4fdd93a8 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/experimental/eip8130/constants.ts @@ -50,11 +50,14 @@ export const nonceFreeMaxExpiryWindow = 10n export const replayBufferCapacity = 300000n /** - * Account change entry type discriminators (first element of each - * `account_changes` entry). + * Account change entry type discriminators — the first element of each + * `account_changes` entry's flat RLP list `rlp([type_byte, ...fields])`. + * + * The discriminant is RLP-encoded as an integer (`u8`), so `create` (`0`) is the + * canonical empty item `'0x'` (which RLP-encodes to `0x80`), not `'0x00'`. */ export const accountChangeType = { - create: '0x00', + create: '0x', config: '0x01', delegation: '0x02', } as const satisfies Record diff --git a/src/experimental/eip8130/utils/parseTransaction.ts b/src/experimental/eip8130/utils/parseTransaction.ts index 10d51747b6..8c8efc760c 100644 --- a/src/experimental/eip8130/utils/parseTransaction.ts +++ b/src/experimental/eip8130/utils/parseTransaction.ts @@ -84,13 +84,14 @@ function parseActorChange(value: RlpHex): AaActorChange { } function parseAccountChanges(value: RlpHex): readonly AaAccountChange[] { - // Wire format: each AccountChange is encoded as type_byte || rlp([body_fields...]). - // After RLP decoding the outer list we receive alternating [type_hex, body_array] pairs. - const flat = value as RlpHex[] + // Wire format (base/base #3985): each AccountChange is a single flat RLP list + // rlp([type_byte, ...body_fields]); the type byte is the first list element, + // RLP-encoded as an integer (create=0 -> 0x80 -> '0x'). After RLP decoding the + // outer list we receive one sub-list per entry. + const entries = value as RlpHex[] const result: AaAccountChange[] = [] - for (let i = 0; i < flat.length; i += 2) { - const type = flat[i] as Hex - const body = flat[i + 1] as RlpHex[] + for (const entry of entries) { + const [type, ...body] = entry as RlpHex[] if (type === accountChangeType.create) { const [userSalt, code, actors] = body result.push({ @@ -117,7 +118,7 @@ function parseAccountChanges(value: RlpHex): readonly AaAccountChange[] { result.push({ type: 'delegation', target: target as Address }) continue } - throw new BaseError(`Unknown account change entry type: "${type}".`) + throw new BaseError(`Unknown account change entry type: "${type as Hex}".`) } return result } diff --git a/src/experimental/eip8130/utils/serializeTransaction.ts b/src/experimental/eip8130/utils/serializeTransaction.ts index 8f4e7658b3..bc361ceab3 100644 --- a/src/experimental/eip8130/utils/serializeTransaction.ts +++ b/src/experimental/eip8130/utils/serializeTransaction.ts @@ -44,43 +44,42 @@ function toActorChange(change: AaActorChange): RecursiveArray { } /** - * Encodes the `account_changes` field into a flat RLP-ready array. + * Encodes the `account_changes` field into a nested RLP-ready array. * - * Each AccountChange is encoded on the wire as `type_byte || rlp([fields...])`: - * the type byte is a raw prefix, NOT wrapped in its own RLP list. To produce - * this with `toRlp`, we flatMap each entry into two sibling items in the outer - * list: the type-byte hex string (encodes as a single raw byte ≤ 0x7f) and - * the body array (encodes as an RLP list of fields). + * Per [EIP-8130] (base/base #3985), each AccountChange is a single flat RLP list + * whose first element is the type discriminant, followed by the body fields + * inline: `rlp([type_byte, ...fields])`. The type byte is a genuine list element + * (RLP-encoded as an integer, so `create` = `0` → `0x80`), NOT an EIP-2718-style + * bare prefix — so each entry frames as exactly one item in the outer + * `account_changes` list. + * + * [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 */ export function toAccountChangesList( accountChanges: readonly AaAccountChange[] | undefined, ): RecursiveArray[] { - return (accountChanges ?? []).flatMap((entry): RecursiveArray[] => { + return (accountChanges ?? []).map((entry): RecursiveArray => { if (entry.type === 'create') return [ accountChangeType.create, - [ - entry.userSalt, - entry.code, - entry.initialActors.map((actor) => [ - actor.actorId, - actor.authenticator, - actor.scope ? numberToHex(actor.scope) : '0x', - actor.policyData ?? '0x', - ]), - ], + entry.userSalt, + entry.code, + entry.initialActors.map((actor) => [ + actor.actorId, + actor.authenticator, + actor.scope ? numberToHex(actor.scope) : '0x', + actor.policyData ?? '0x', + ]), ] if (entry.type === 'config') return [ accountChangeType.config, - [ - entry.chainId ? numberToHex(entry.chainId) : '0x', - entry.sequence ? numberToHex(entry.sequence) : '0x', - entry.actorChanges.map(toActorChange), - entry.auth, - ], + entry.chainId ? numberToHex(entry.chainId) : '0x', + entry.sequence ? numberToHex(entry.sequence) : '0x', + entry.actorChanges.map(toActorChange), + entry.auth, ] - return [accountChangeType.delegation, [entry.target]] + return [accountChangeType.delegation, entry.target] }) } From 2720b54b4a0224f0c43eafbcf48931b2db9bab25 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Fri, 17 Jul 2026 17:49:07 -0400 Subject: [PATCH 46/96] feat(eip8130): port example policies to base/eip-8130 #43 Updates the PolicyManager / SessionPolicy off-chain glue to the #43 contract model ("Pass PolicyBinding at execute; drop config storage", plus #42 authenticateActor -> actorId): - policyManagerAbi: drop `install` / `PolicyRecord` / `PolicyInstalled`; `execute` (and executeFor/executeForMany) now take the full `PolicyBinding`. Authorization is solely the account's signed actor change commitment (no install step). - SessionPolicy bundle: remove `installCall`; `executeCall` now encodes `execute(binding, executionData)`. - sessionPolicyAbi: gating views are `pure` over the supplied `Config`; `getCurrentSpend(bytes32, TokenLimit)`. - getSessionSpend8130: takes the committed `tokenLimit` (config is no longer stored on-chain). - abis: authenticateActor returns (actorId, scope, policyTarget) (#42). - deployments: vibenetDevnetDeployment pins the #43 PolicyManager / SessionPolicy redeployed against the enshrined AccountConfiguration. For the vibenet devnet only; Base Sepolia policy addresses unchanged. --- src/experimental/eip8130/abis.ts | 2 +- .../eip8130/actions/getSessionSpend8130.ts | 68 ++++++++------ src/experimental/eip8130/deployments.ts | 21 ++++- src/experimental/eip8130/policies.test.ts | 20 +---- src/experimental/eip8130/policies.ts | 90 +++++++++---------- src/experimental/eip8130/queries.test.ts | 21 ++--- 6 files changed, 121 insertions(+), 101 deletions(-) diff --git a/src/experimental/eip8130/abis.ts b/src/experimental/eip8130/abis.ts index a70a15e87f..2df93e62f8 100644 --- a/src/experimental/eip8130/abis.ts +++ b/src/experimental/eip8130/abis.ts @@ -29,7 +29,7 @@ export const accountConfigurationAbi = parseAbi([ 'function applySignedActorChanges(address account, uint256 chainId, ActorChange[] actorChanges, bytes auth)', 'function applySignedLockChanges(address account, uint8 op, uint16 unlockDelay, bytes auth)', 'function verifySignature(address account, bytes32 hash, bytes signature) view returns (bool verified)', - 'function authenticateActor(address account, bytes32 hash, bytes auth) view returns (uint8 scope, address policyTarget)', + 'function authenticateActor(address account, bytes32 hash, bytes auth) view returns (bytes32 actorId, uint8 scope, address policyTarget)', 'function isActor(address account, bytes32 actorId) view returns (bool)', 'function getActorConfig(address account, bytes32 actorId) view returns (ActorConfig)', 'function getPolicy(address account, bytes32 actorId) view returns (address target, bytes32 commitment)', diff --git a/src/experimental/eip8130/actions/getSessionSpend8130.ts b/src/experimental/eip8130/actions/getSessionSpend8130.ts index 9d793caa94..a624caea38 100644 --- a/src/experimental/eip8130/actions/getSessionSpend8130.ts +++ b/src/experimental/eip8130/actions/getSessionSpend8130.ts @@ -7,13 +7,24 @@ import type { Account } from '../../../types/account.js' import type { Chain } from '../../../types/chain.js' import type { Hex } from '../../../types/misc.js' import { getAction } from '../../../utils/getAction.js' -import { sessionPolicyAbi, sessionPolicyAddress } from '../policies.js' +import { + sessionPolicyAbi, + sessionPolicyAddress, + type SessionPolicyTokenLimit, +} from '../policies.js' export type GetSessionSpend8130Parameters = { /** The session policy binding commitment (see `defineSessionPolicy().commitment`). */ commitment: Hex - /** The token whose limit to read, or the zero address for native ETH. */ - token: Address + /** + * The committed token limit whose usage to read — the exact `{ token, limit, + * period }` from the binding's `SessionPolicyConfig`. + * + * base/eip-8130#43 dropped on-chain config storage: `getCurrentSpend` now takes + * the token limit explicitly (usage is meaningful only when this matches the + * limit the account signed). Use the zero address for the native-ETH cap. + */ + tokenLimit: SessionPolicyTokenLimit /** * `SessionPolicy` contract. Defaults to the reference Base Sepolia deployment. */ @@ -21,9 +32,7 @@ export type GetSessionSpend8130Parameters = { } export type GetSessionSpend8130ReturnType = { - /** Whether a limit is configured for this token (unconfigured = no cap). */ - set: boolean - /** The spend cap per period (atomic units). */ + /** The spend cap per period (atomic units) — echoes the supplied `tokenLimit.limit`. */ allowance: bigint /** Period length in seconds. `0` = one-time (never resets). */ period: number @@ -39,17 +48,21 @@ export type GetSessionSpend8130ReturnType = { /** * Reads the live spend / remaining budget for a session key against a specific - * token from the reference `SessionPolicy` contract (combining `getTokenLimit` - * and `getCurrentSpend`). Use it to render a "remaining budget" view for a + * committed token limit from the reference `SessionPolicy` contract + * (`getCurrentSpend`). Use it to render a "remaining budget" view for a * policy-gated key. * * @example * ```ts * import { getSessionSpend8130 } from 'viem/experimental/eip8130' * + * // Pass the exact token limit from the binding's config. * const { allowance, spent, remaining, periodEnd } = await getSessionSpend8130( * client, - * { commitment: session.commitment, token: usdc }, + * { + * commitment: session.commitment, + * tokenLimit: { token: usdc, limit: parseUnits('100', 6), period: 604800n }, + * }, * ) * ``` * @@ -64,30 +77,35 @@ export async function getSessionSpend8130< client: Client, parameters: GetSessionSpend8130Parameters, ): Promise { - const { commitment, token, sessionPolicy = sessionPolicyAddress } = parameters + const { + commitment, + tokenLimit, + sessionPolicy = sessionPolicyAddress, + } = parameters const read = getAction(client, readContract, 'readContract') - const [[set, allowance, period], usage] = await Promise.all([ - read({ - address: sessionPolicy, - abi: sessionPolicyAbi, - functionName: 'getTokenLimit', - args: [commitment, token], - }), - read({ - address: sessionPolicy, - abi: sessionPolicyAbi, - functionName: 'getCurrentSpend', - args: [commitment, token], - }), - ]) + const allowance = tokenLimit.limit + const period = Number(tokenLimit.period ?? 0n) + + const usage = await read({ + address: sessionPolicy, + abi: sessionPolicyAbi, + functionName: 'getCurrentSpend', + args: [ + commitment, + { + token: tokenLimit.token, + limit: tokenLimit.limit, + period, + }, + ], + }) const spent = usage.spend const remaining = allowance > spent ? allowance - spent : 0n return { - set, allowance, period, spent, diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index 957b52fa2b..7e23d2b6da 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -111,13 +111,26 @@ export const baseSepoliaDeployment = canonicalEip8130Deployment /** * EIP-8130 deployment for the Base "vibenet" devnet (chain id `84538453`). * - * The devnet runs EIP-8130 **natively** and the execution client enshrines the - * canonical CREATE2 addresses (via `Deploy.s.sol`), identical to - * Base Sepolia and every other supported chain. Using any other + * The devnet runs EIP-8130 **natively**: the execution client enshrines the + * canonical `accountConfiguration` (and the account/authenticator CREATE2 + * addresses), so those stay identical to Base Sepolia — using any other * `accountConfiguration` derives a different account address and create * transactions fail with "create address mismatch". + * + * The example `policies` are the exception: they are ordinary (non-enshrined) + * contracts, redeployed on vibenet at base/eip-8130 **#43** ("Pass PolicyBinding + * at execute; drop config storage"). They are bound to the enshrined + * AccountConfiguration above and expose the #43 PolicyManager/SessionPolicy ABI + * (no `install`; `execute(binding, executionData)`). Base Sepolia still points at + * the earlier (#41) policy addresses. */ -export const vibenetDevnetDeployment = canonicalEip8130Deployment +export const vibenetDevnetDeployment = { + ...canonicalEip8130Deployment, + policies: { + manager: '0x5cF2a01d34d1B244C63D9F2215E53F9aac06de60', + sessionPolicy: '0x865D22bA9B452E38c7c4c83a619D3C25e5AC3F18', + }, +} as const satisfies Eip8130Deployment /** Known EIP-8130 deployments, keyed by chain id. */ export const eip8130Deployments: Record = { diff --git a/src/experimental/eip8130/policies.test.ts b/src/experimental/eip8130/policies.test.ts index 10f072e3c7..80bef22337 100644 --- a/src/experimental/eip8130/policies.test.ts +++ b/src/experimental/eip8130/policies.test.ts @@ -84,21 +84,7 @@ describe('defineSessionPolicy', () => { ).toThrow() }) - test('installCall encodes PolicyManager.install(actorId, binding)', () => { - const actorId = `0x${'11'.repeat(32)}` as const - const call = session.installCall(actorId) - expect(call.to).toBe(baseSepoliaDeployment.policies.manager) - const { functionName, args } = decodeFunctionData({ - abi: policyManagerAbi, - data: call.data!, - }) - expect(functionName).toBe('install') - expect(args[0]).toBe(actorId) - // uint40 fields decode to `number`; uint256 (salt) to `bigint`. - expect(args[1]).toEqual({ ...binding, validAfter: 0, validUntil: 0 }) - }) - - test('executeCall encodes PolicyManager.execute(policy, executionData)', () => { + test('executeCall encodes PolicyManager.execute(binding, executionData)', () => { const action = encodeSessionPolicyAction({ target: token, data: '0xa9059cbb', @@ -110,6 +96,8 @@ describe('defineSessionPolicy', () => { data: call.data!, }) expect(functionName).toBe('execute') - expect(args).toEqual([policy, action]) + // #43: the full binding is passed at execute (not just the policy address). + // uint40 fields decode to `number`; uint256 (salt) to `bigint`. + expect(args).toEqual([{ ...binding, validAfter: 0, validUntil: 0 }, action]) }) }) diff --git a/src/experimental/eip8130/policies.ts b/src/experimental/eip8130/policies.ts index 4cc844f5fe..83ed26f8e5 100644 --- a/src/experimental/eip8130/policies.ts +++ b/src/experimental/eip8130/policies.ts @@ -21,18 +21,21 @@ import type { AaCall } from './types/transaction.js' * committed policy and then drives the account via `executeBatch`. * * This module provides the off-chain glue for the [base/eip-8130 example - * policies](https://github.com/base/eip-8130/tree/main/src/examples/policies) — - * the {@link policyManagerAbi PolicyManager} plus the unified - * {@link encodeSessionPolicyConfig SessionPolicy}. Flow: + * policies](https://github.com/base/eip-8130/tree/main/src/policies) — the + * {@link policyManagerAbi PolicyManager} plus the unified + * {@link encodeSessionPolicyConfig SessionPolicy}. Flow (base/eip-8130#43): * * 1. **Author** a `SessionPolicy` config ({@link encodeSessionPolicyConfig}). - * 2. **Bind** it with {@link defineSessionPolicy} to get the `commitment`, the - * {@link Policy} to pass to `authorizeActor`, and the `install` call. - * 3. **Authorize + install**: ride `authorizeActor(key, { scope, policy })` and - * the install call in one transaction (the install initializes the binding; - * it MUST land before the key's first `execute`). - * 4. **Use**: the session key sends `executeCall(action)` — its only reachable - * target is the manager. + * 2. **Bind** it with {@link defineSessionPolicy} to get the `commitment` and the + * {@link Policy} to pass to `authorizeActor`. + * 3. **Authorize**: ride `authorizeActor(key, { scope, policy })` in one + * transaction. That signed actor change *is* the authorization — there is no + * separate install step (the account's signed commitment is what the manager + * checks at execute). + * 4. **Use**: the session key sends `executeCall(executionData)` — its only + * reachable target is the manager. The full {@link PolicyBinding} is passed + * on-chain at execute; the manager recomputes its commitment and requires it + * to equal the account's live signed commitment. * * @remarks These contracts are an unaudited reference. Addresses default to the * Base Sepolia deployment; override `manager` / `policy` for other chains. @@ -42,16 +45,24 @@ import type { AaCall } from './types/transaction.js' // ABIs // ───────────────────────────────────────────────────────────────────────────── -/** ABI for the example `PolicyManager` reference contract. */ +/** + * ABI for the example `PolicyManager` reference contract (base/eip-8130#43). + * + * There is no `install` step: the account's signed actor change (which stores + * `policy_manager` + `policy_commitment` in AccountConfiguration) *is* the + * authorization. Every execute path takes the full {@link PolicyBinding}; the + * manager recomputes its commitment and compares it to the live signed + * commitment, authenticating config, validity window, and owning account in one + * check — with zero config storage on the manager or policy. + */ export const policyManagerAbi = parseAbi([ 'struct PolicyBinding { address account; address policy; bytes policyConfig; uint40 validAfter; uint40 validUntil; uint256 salt; }', - 'struct PolicyRecord { bool installed; address account; uint40 validAfter; uint40 validUntil; }', - 'event PolicyInstalled(address indexed account, address indexed policy, bytes32 indexed commitment)', 'event PolicyExecuted(address indexed account, address indexed policy, bytes32 indexed commitment, address caller)', + 'event ExecutionSkipped(address indexed account, address indexed policy, bytes32 indexed actorId)', 'function commitmentOf(PolicyBinding binding) pure returns (bytes32)', - 'function getPolicyRecord(address policy, bytes32 commitment) view returns (PolicyRecord)', - 'function install(bytes32 actorId, PolicyBinding binding) returns (bytes32 commitment)', - 'function execute(address policy, bytes executionData)', + 'function execute(PolicyBinding binding, bytes executionData)', + 'function executeFor(PolicyBinding binding, bytes executionData)', + 'function executeForMany(PolicyBinding[] bindings, bytes[] executionData) returns (bool[] results)', ]) /** @@ -66,11 +77,15 @@ export const sessionPolicyAbi = parseAbi([ 'struct Config { TokenLimit[] tokenLimits; CallScope[] callScopes; }', 'struct Action { address target; uint256 value; bytes data; }', 'struct PeriodUsage { uint48 start; uint48 end; uint160 spend; }', - 'function isTargetAllowed(bytes32 commitment, address target) view returns (bool allowed, bool anySelector)', - 'function getSelectorRule(bytes32 commitment, address target, bytes4 selector) view returns (bool allowed, bool recipientBound)', - 'function isRecipientAllowed(bytes32 commitment, address target, bytes4 selector, address recipient) view returns (bool)', - 'function getTokenLimit(bytes32 commitment, address token) view returns (bool set, uint160 allowance, uint40 period)', - 'function getCurrentSpend(bytes32 commitment, address token) view returns (PeriodUsage)', + // #43: config is no longer stored on-chain — the gating views are `pure` over + // the supplied `Config` preimage (the same bytes the account signed). Only + // `getCurrentSpend` is a `view` (it reads mutable spend usage keyed by the + // binding commitment + the explicit token limit). + 'function isTargetAllowed(Config config, address target) pure returns (bool allowed, bool anySelector)', + 'function getSelectorRule(Config config, address target, bytes4 selector) pure returns (bool allowed, bool recipientBound)', + 'function isRecipientAllowed(Config config, address target, bytes4 selector, address recipient) pure returns (bool)', + 'function getTokenLimit(Config config, address token) pure returns (bool set, uint160 allowance, uint40 period)', + 'function getCurrentSpend(bytes32 commitment, TokenLimit limit) view returns (PeriodUsage)', ]) // ───────────────────────────────────────────────────────────────────────────── @@ -175,15 +190,11 @@ export type SessionPolicy = { /** Pass to `authorizeActor(key, { scope, policy })`. */ actorPolicy: Policy /** - * Account call that installs (initializes) the binding: - * `PolicyManager.install(actorId, binding)`. Ride this alongside the - * `authorizeActor` change — it MUST land before the key's first `execute`. - */ - installCall(actorId: Hex): AaCall - /** - * Account call the session key sends: `PolicyManager.execute(policy, executionData)`. - * This is the only target a policy-gated actor may reach. Build `executionData` - * with {@link encodeSessionPolicyAction}. + * Account call the session key sends: + * `PolicyManager.execute(binding, executionData)`. This is the only target a + * policy-gated actor may reach. The full {@link PolicyBinding} is passed so the + * manager can recompute + match the commitment (no install / config storage). + * Build `executionData` with {@link encodeSessionPolicyAction}. */ executeCall(executionData: Hex): AaCall } @@ -192,7 +203,7 @@ export type DefineSessionPolicyErrorType = CommitmentOfErrorType /** * Binds a committed policy config to an account and returns everything needed to - * authorize, install (initialize), and use a policy-gated session key. + * authorize and use a policy-gated session key (base/eip-8130#43: no install). * * @example * import { @@ -213,11 +224,11 @@ export type DefineSessionPolicyErrorType = CommitmentOfErrorType * }), * }) * - * // 1) authorize + install (initialize) in one transaction (sent by the account) + * // 1) authorize in one transaction (sent by the account). The signed actor + * // change stores the commitment — that IS the authorization (no install). * const change = await account.change([ * authorizeActor(key.p256(pub), { scope: actorScope.sender, policy: session.actorPolicy }), * ]) - * const calls = [[session.installCall(key.p256(pub).actorId)]] * * // 2) later, the session key spends within its limit * const spend = session.executeCall( @@ -257,17 +268,6 @@ export function defineSessionPolicy( binding, commitment, actorPolicy: { type: policyType, manager, commitment }, - installCall(actorId) { - return { - to: manager, - value: 0n, - data: encodeFunctionData({ - abi: policyManagerAbi, - functionName: 'install', - args: [actorId, toBindingArgs(binding)], - }), - } - }, executeCall(executionData) { return { to: manager, @@ -275,7 +275,7 @@ export function defineSessionPolicy( data: encodeFunctionData({ abi: policyManagerAbi, functionName: 'execute', - args: [policy, executionData], + args: [toBindingArgs(binding), executionData], }), } }, diff --git a/src/experimental/eip8130/queries.test.ts b/src/experimental/eip8130/queries.test.ts index a1b7ca3822..38dbd64cda 100644 --- a/src/experimental/eip8130/queries.test.ts +++ b/src/experimental/eip8130/queries.test.ts @@ -46,16 +46,16 @@ function readClient(abi: Abi, results: Record) { } describe('getSessionSpend8130', () => { - test('combines getTokenLimit + getCurrentSpend into a budget view', async () => { + test('reads getCurrentSpend into a budget view (#43: limit supplied)', async () => { const client = readClient(sessionPolicyAbi, { - // set, allowance, period - getTokenLimit: [true, 100_000_000n, 604_800], // PeriodUsage { start, end, spend } getCurrentSpend: { start: 1_000, end: 605_800, spend: 40_000_000n }, }) - const spend = await getSessionSpend8130(client, { commitment, token }) + const spend = await getSessionSpend8130(client, { + commitment, + tokenLimit: { token, limit: 100_000_000n, period: 604_800n }, + }) expect(spend).toEqual({ - set: true, allowance: 100_000_000n, period: 604_800, spent: 40_000_000n, @@ -65,13 +65,14 @@ describe('getSessionSpend8130', () => { }) }) - test('clamps remaining at zero when overspent/unset', async () => { + test('clamps remaining at zero when overspent', async () => { const client = readClient(sessionPolicyAbi, { - getTokenLimit: [false, 0n, 0], - getCurrentSpend: { start: 0, end: 0, spend: 0n }, + getCurrentSpend: { start: 1_000, end: 605_800, spend: 150_000_000n }, + }) + const spend = await getSessionSpend8130(client, { + commitment, + tokenLimit: { token, limit: 100_000_000n, period: 604_800n }, }) - const spend = await getSessionSpend8130(client, { commitment, token }) - expect(spend.set).toBe(false) expect(spend.remaining).toBe(0n) }) }) From 13b859416316db82a51b417aadb22184f7c2b0e3 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Fri, 17 Jul 2026 18:53:04 -0400 Subject: [PATCH 47/96] feat(eip8168): carry accountChanges through sendSponsoredCalls sendSponsoredCalls only sponsored plain calls; any create/authorize/revoke account changes were dropped, so the common sponsored deploy + session-key authorize flow silently landed without applying the changes. Thread an optional `accountChanges` into prepareTransaction8130 so a single sponsored tx can carry both account changes and calls. --- .../eip8168/actions/sendSponsoredCalls.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/experimental/eip8168/actions/sendSponsoredCalls.ts b/src/experimental/eip8168/actions/sendSponsoredCalls.ts index 96e243f18e..1d1d2c6567 100644 --- a/src/experimental/eip8168/actions/sendSponsoredCalls.ts +++ b/src/experimental/eip8168/actions/sendSponsoredCalls.ts @@ -8,7 +8,11 @@ import { numberToHex } from '../../../utils/encoding/toHex.js' import type { To8130AccountReturnType } from '../../eip8130/accounts/to8130Account.js' import { prepareTransaction8130 } from '../../eip8130/actions/sendCalls.js' import type { Address } from 'abitype' -import type { AaCall, AaCalls } from '../../eip8130/types/transaction.js' +import type { + AaAccountChange, + AaCall, + AaCalls, +} from '../../eip8130/types/transaction.js' import type { PayerClient } from '../client.js' import type { GetTermsReturnType, @@ -57,6 +61,14 @@ export type SendSponsoredCallsParameters = { payerClient: PayerClient /** User's intended calls (run in the final phase). */ calls: readonly AaCall[] + /** + * Account changes (create / authorize / revoke / delegate) applied before the + * calls, in the same sponsored transaction. Use this for a sponsored deploy or + * a sponsored session-key authorize. The payer's gas estimate covers `calls` + * only; if the added changes push gas past the quote, the payer's `GAS_TOO_LOW` + * re-quote raises it (gated by `confirmRetry`), or pass an explicit `gas`. + */ + accountChanges?: readonly AaAccountChange[] | undefined /** * `"send"` (default) asks the payer to co-sign and submit; `"sign"` asks the * payer to co-sign and return the transaction for the wallet to submit. A @@ -133,6 +145,7 @@ export async function sendSponsoredCalls( account, payerClient, calls, + accountChanges, mode = 'send', token, context, @@ -202,6 +215,7 @@ export async function sendSponsoredCalls( const transaction = await prepareTransaction8130(client, { account, calls: built.calls, + accountChanges, gas: initialGas, maxFeePerGas, maxPriorityFeePerGas, From 0560c20f73ff23bc54542f9b7d6e7611e496dfec Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Sat, 18 Jul 2026 08:46:53 -0400 Subject: [PATCH 48/96] feat(eip8130): scope-driven nonce mode (nonce-free for admin / non-SCOPE_NONCE actors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An EIP-8130 actor may use a sequenced nonce key only if it holds SCOPE_NONCE; admin actors (scope 0) and any actor authorized without that bit are restricted by the node to nonce-free (expiring) transactions. Make the client select the correct mode automatically instead of leaving it as tribal knowledge: - keys: `canUseSequencedNonce(scope)` / `isNoncelessOnly(scope)` predicates. - nonce: `nonce.forScope(scope, …)` builder. - errors: `NonceScopeError`, thrown when a sequenced `nonceKey` is requested for a nonce-free-only signing actor. - to8130Account: account handles carry the signing actor's `scope`; `newSmartAccount8130` (admin owner) and `toEoa8130Account` (implicit self actor) default to admin → nonce-free. - prepareTransaction8130 / sendSponsoredCalls: when the actor is nonce-free-only, auto-select `nonceKey = NONCE_KEY_MAX` and default a non-zero in-window expiry; reject an explicit sequenced `nonceKey` with `NonceScopeError`. - authorizeActor: steer policy (session) keys to POLICY-only scope. --- .../eip8130/accounts/to8130Account.ts | 48 ++++++++++++++++++- src/experimental/eip8130/actions/sendCalls.ts | 39 ++++++++++++--- src/experimental/eip8130/errors.ts | 32 +++++++++++++ src/experimental/eip8130/index.ts | 3 ++ src/experimental/eip8130/keys.ts | 36 +++++++++++++- src/experimental/eip8130/nonce.ts | 42 +++++++++++++++- .../eip8168/actions/sendSponsoredCalls.ts | 14 ++++++ 7 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 src/experimental/eip8130/errors.ts diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index 756dfc4e1b..d6d994f0f8 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -8,6 +8,7 @@ import { accountConfigAddress as defaultAccountConfigAddress, canonicalAuthenticators, ecrecoverAuthenticator, + scopeUnrestricted, } from '../constants.js' import { canonicalEip8130Deployment } from '../deployments.js' import { key } from '../keys.js' @@ -38,6 +39,17 @@ type To8130AccountBase = { * WebAuthn / delegate authenticator address for non-K1 signers. */ authenticator?: Address | undefined + /** + * Scope bitmask of the **signing actor** on this account (see + * {@link actorScope}). When set, transaction preparation enforces the + * protocol's nonce rule: an actor may use a sequenced nonce key only if it + * holds `SCOPE_NONCE`; admin actors (`scope == 0`) and any actor without that + * bit are automatically restricted to nonce-free (expiring) transactions. + * + * Leave `undefined` to disable the automatic nonce-mode selection (the caller + * fully controls `nonceKey`). + */ + scope?: number | undefined } /** @@ -90,6 +102,13 @@ export type To8130AccountReturnType = { readonly address: Address readonly signer: Signer readonly initialActors: readonly AaActor[] + /** + * Scope of the signing actor, when known. Drives automatic nonce-mode + * selection in {@link prepareTransaction8130} / {@link sendCalls8130}: an actor + * lacking `SCOPE_NONCE` (incl. admin `scope == 0`) is restricted to nonce-free + * mode. `undefined` disables the enforcement. + */ + readonly scope?: number | undefined /** * Builds the `create` account-change entry (include in the first tx for * smart accounts). Throws if the account was constructed with a known `address` @@ -151,6 +170,7 @@ export function to8130Account( const { signer, authenticator = ecrecoverAuthenticator, + scope, } = parameters // Address-only mode (delegated EOA): address is fixed, no CREATE2 derivation. @@ -178,6 +198,7 @@ export function to8130Account( address, signer, initialActors, + scope, create() { if (isAddressOnly) @@ -378,6 +399,10 @@ export function newSmartAccount8130( initialActors: allActors, authenticator: signer.authenticator, accountConfigAddress, + // The primary (controlling) actor is registered without a scope, i.e. as an + // admin actor. Admin actors lack `SCOPE_NONCE`, so this account is + // nonce-free-only — surface that so nonce mode is selected automatically. + scope: primaryActor.scope ?? scopeUnrestricted, }) return { ...inner, createChange: inner.create() } @@ -387,10 +412,26 @@ export function newSmartAccount8130( // toEoa8130Account // ───────────────────────────────────────────────────────────────────────────── +export type ToEoa8130AccountParameters = { + /** + * Scope of the EOA's implicit self-actor. Defaults to admin + * ({@link scopeUnrestricted}), which lacks `SCOPE_NONCE` and is therefore + * nonce-free-only. Override only if the self-actor was reconfigured with the + * `SCOPE_NONCE` bit and you want sequenced nonces. + */ + scope?: number | undefined +} + export type ToEoa8130AccountReturnType = { /** The EOA address — both the sender identity and the key recovery target. */ readonly address: Address readonly signer: Signer + /** + * Scope of the implicit self-actor (admin by default). Drives automatic + * nonce-mode selection: admin lacks `SCOPE_NONCE`, so sends default to + * nonce-free (expiring) mode. See {@link prepareTransaction8130}. + */ + readonly scope?: number | undefined /** * Builds an EIP-7702 `delegation` account-change entry that sets the code * at this EOA address. Include in the first transaction's `accountChanges` @@ -472,16 +513,21 @@ export type ToEoa8130AccountReturnType = { * calls: wire, ... * }) */ -export function toEoa8130Account(signer: Signer): ToEoa8130AccountReturnType { +export function toEoa8130Account( + signer: Signer, + parameters: ToEoa8130AccountParameters = {}, +): ToEoa8130AccountReturnType { if (!signer.address) throw new BaseError( '`signer.address` is required. Use `privateKeyToAccount(pk)` or equivalent.', ) const address = signer.address + const scope = parameters.scope ?? scopeUnrestricted return { address, signer, + scope, delegate(target) { return { type: 'delegation', target } diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/experimental/eip8130/actions/sendCalls.ts index 10ca9bd4cb..622dd289e5 100644 --- a/src/experimental/eip8130/actions/sendCalls.ts +++ b/src/experimental/eip8130/actions/sendCalls.ts @@ -8,7 +8,9 @@ import type { Chain } from '../../../types/chain.js' import type { Hex } from '../../../types/misc.js' import { getAction } from '../../../utils/getAction.js' import type { To8130AccountReturnType } from '../accounts/to8130Account.js' -import { nonceKeyMax } from '../constants.js' +import { nonceFreeMaxExpiryWindow, nonceKeyMax } from '../constants.js' +import { NonceScopeError } from '../errors.js' +import { isNoncelessOnly } from '../keys.js' import type { AaAccountChange, AaCall, @@ -47,13 +49,29 @@ export async function prepareTransaction8130( client: Client, parameters: PrepareTransaction8130Parameters, ): Promise { - const { account, calls, accountChanges, payer, gas, expiry, nonceKey = 0n } = - parameters + const { account, calls, accountChanges, payer, gas } = parameters const chainId = client.chain?.id if (!chainId) throw new BaseError('`client` must be configured with a `chain`.') + // Scope-driven nonce mode: an actor may use a sequenced nonce key only if it + // holds `SCOPE_NONCE`. Admin actors (`scope == 0`) and any actor without that + // bit are restricted to nonce-free (expiring) mode. When the signing actor's + // scope is known, select the mode automatically and reject a sequenced key. + const noncelessOnly = + account.scope !== undefined && isNoncelessOnly(account.scope) + let nonceKey = parameters.nonceKey + if (noncelessOnly) { + if (nonceKey !== undefined && nonceKey !== nonceKeyMax) + throw new NonceScopeError({ scope: account.scope!, nonceKey }) + nonceKey = nonceKeyMax + } else { + nonceKey ??= 0n + } + + let expiry = parameters.expiry + let { maxFeePerGas, maxPriorityFeePerGas } = parameters if (maxFeePerGas === undefined || maxPriorityFeePerGas === undefined) { const fees = await getAction( @@ -70,10 +88,17 @@ export async function prepareTransaction8130( if (nonceKey === nonceKeyMax) { // Nonce-free (expiring) mode: there is no per-channel counter to read; // replay protection relies on `expiry`. Pin the sequence to `0n`. - if (!expiry || expiry === 0n) - throw new BaseError( - '`expiry` is required for nonce-free transactions (`nonceKey` = `NONCE_KEY_MAX`). Build the nonce with `nonce.nonceless({ expiresIn })`.', - ) + if (!expiry || expiry === 0n) { + if (noncelessOnly) + // Auto-selected nonce-free mode: default the expiry to the mempool + // admission window rather than forcing the caller to supply one. + expiry = + BigInt(Math.floor(Date.now() / 1000)) + nonceFreeMaxExpiryWindow + else + throw new BaseError( + '`expiry` is required for nonce-free transactions (`nonceKey` = `NONCE_KEY_MAX`). Build the nonce with `nonce.nonceless({ expiresIn })`.', + ) + } nonceSequence ??= 0n } else if (nonceSequence === undefined) { // Read the next sequence via `eth_getTransactionCount` (with the 2D diff --git a/src/experimental/eip8130/errors.ts b/src/experimental/eip8130/errors.ts new file mode 100644 index 0000000000..dfaedb1746 --- /dev/null +++ b/src/experimental/eip8130/errors.ts @@ -0,0 +1,32 @@ +import { BaseError } from '../../errors/base.js' + +export type NonceScopeErrorType = NonceScopeError & { name: 'NonceScopeError' } + +/** + * Thrown when a sequenced (counter-backed) nonce key is requested for a signing + * actor that lacks `SCOPE_NONCE` — admin actors (`scope == 0`) and any actor + * authorized without the nonce bit are restricted to nonce-free (expiring) + * transactions. + */ +export class NonceScopeError extends BaseError { + override name = 'NonceScopeError' + constructor({ + scope, + nonceKey, + }: { + scope: number + nonceKey?: bigint | undefined + }) { + super( + `Signing actor scope \`0x${scope.toString(16)}\` lacks \`SCOPE_NONCE\`, so it may only send nonce-free (expiring) transactions${ + nonceKey !== undefined ? ` — received \`nonceKey\` ${nonceKey}.` : '.' + }`, + { + metaMessages: [ + 'Admin actors (scope 0) and any actor without the `SCOPE_NONCE` bit are restricted to nonce-free mode.', + 'Omit `nonceKey` to let the library select nonce-free mode automatically, or pass `...nonce.nonceless({ expiresIn })`.', + ], + }, + ) + } +} diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 47c9799d06..a85092b33d 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -142,10 +142,13 @@ export { getEip8130Deployment, vibenetDevnetDeployment, } from './deployments.js' +export { type NonceScopeErrorType, NonceScopeError } from './errors.js' export { type AuthorizeActorOptions, authorizeActor, + canUseSequencedNonce, encodePolicyData, + isNoncelessOnly, key, type Policy, revokeActor, diff --git a/src/experimental/eip8130/keys.ts b/src/experimental/eip8130/keys.ts index 48338616ad..a23fcf184a 100644 --- a/src/experimental/eip8130/keys.ts +++ b/src/experimental/eip8130/keys.ts @@ -90,6 +90,30 @@ export function toScope(...flags: number[]): number { return flags.reduce((acc, flag) => acc | flag, 0) } +/** + * Whether an actor with `scope` may use sequenced (counter-backed) nonce keys — + * i.e. standard sequential ordering or a 2D nonce channel. Requires the + * `SCOPE_NONCE` bit. + * + * Admin actors (`scope == 0`) do **not** have `SCOPE_NONCE` set, so — like any + * restricted actor lacking the bit — they are restricted to nonce-free + * (expiring) transactions. Use {@link isNoncelessOnly} for the inverse. + */ +export function canUseSequencedNonce(scope: number | undefined): boolean { + return ((scope ?? 0) & actorScope.nonce) !== 0 +} + +/** + * Whether an actor with `scope` is restricted to nonce-free (expiring) + * transactions (`nonceKey = NONCE_KEY_MAX`) because it lacks `SCOPE_NONCE`. + * + * True for admin actors (`scope == 0`) and any actor authorized without the + * `SCOPE_NONCE` bit. Inverse of {@link canUseSequencedNonce}. + */ +export function isNoncelessOnly(scope: number | undefined): boolean { + return !canUseSequencedNonce(scope) +} + export type Policy = { /** Non-zero policy selector (interpreted by the manager, not the protocol). */ type: number @@ -122,9 +146,17 @@ export type AuthorizeActorOptions = { * and optional policy. The result can be signed via `signActorChanges8130` / * `to8130Account#authorize`. * + * A policy-gated actor (session key) should be authorized as POLICY-only + * (`scope: actorScope.policy`): `SCOPE_POLICY` grants "gated initiation", so the + * key can originate a transaction but every call is routed through — and + * validated by — its manager. Per base/eip-8130, do NOT add `SCOPE_SENDER` (the + * policy gate governs regardless). OR in `actorScope.selfPayer` for self-pay or + * `actorScope.nonce` to allow sequenced nonces (without it the key is + * nonce-free-only). + * * @example * authorizeActor(key.p256({ x, y }), { - * scope: actorScope.sender, + * scope: actorScope.policy, // POLICY-only; optionally | actorScope.selfPayer | actorScope.nonce * policy: { type: 1, manager, commitment }, * }) */ @@ -143,7 +175,7 @@ export function authorizeActor( // Admin (scope 0) is unrestricted; a policy-gated actor must be restricted. if (scope === 0) throw new BaseError( - 'A policy-bearing actor MUST have a restricted (non-admin) scope (e.g. `actorScope.sender`).', + 'A policy-bearing actor MUST have a restricted (non-admin) scope (e.g. `actorScope.policy`).', ) // Policy presence is the SCOPE_POLICY bit (there is no `policyType` field). scope |= actorScope.policy diff --git a/src/experimental/eip8130/nonce.ts b/src/experimental/eip8130/nonce.ts index 2a39514287..811b3c0509 100644 --- a/src/experimental/eip8130/nonce.ts +++ b/src/experimental/eip8130/nonce.ts @@ -1,7 +1,8 @@ import { BaseError } from '../../errors/base.js' import { hexToBigInt } from '../../utils/encoding/fromHex.js' import { bytesToHex } from '../../utils/encoding/toHex.js' -import { nonceKeyMax } from './constants.js' +import { nonceFreeMaxExpiryWindow, nonceKeyMax } from './constants.js' +import { isNoncelessOnly } from './keys.js' /** * A resolved EIP-8130 nonce selection: the channel key and (for nonce-free @@ -118,4 +119,43 @@ export const nonce = { ) return { nonceKey: nonceKeyMax, nonceSequence: 0n, expiry: resolvedExpiry } }, + + /** + * Selects the correct nonce strategy for an actor's `scope`. An actor may use + * a sequenced nonce key only if it holds `SCOPE_NONCE`; admin actors + * (`scope == 0`) and any actor authorized without that bit are restricted to + * nonce-free (expiring) mode. + * + * - Actor **has** `SCOPE_NONCE` → sequenced {@link nonce.channel} (default + * channel `0`, i.e. {@link nonce.sequential}). + * - Actor **lacks** `SCOPE_NONCE` (incl. admin) → {@link nonce.nonceless}, + * defaulting the expiry to `NONCE_FREE_MAX_EXPIRY_WINDOW` seconds from now. + * + * @param scope - The signing actor's scope bitmask. + * @param parameters.key - Sequenced channel selector (ignored in nonce-free + * mode). @default 0n + * @param parameters.expiry - Absolute expiry (unix seconds) for nonce-free + * mode. Overrides `expiresIn`. + * @param parameters.expiresIn - Relative expiry (seconds) for nonce-free + * mode. @default Number(NONCE_FREE_MAX_EXPIRY_WINDOW) + */ + forScope( + scope: number, + parameters: { + key?: bigint | undefined + expiry?: bigint | undefined + expiresIn?: number | undefined + } = {}, + ): Nonce { + if (isNoncelessOnly(scope)) + return nonce.nonceless( + parameters.expiry !== undefined + ? { expiry: parameters.expiry } + : { + expiresIn: + parameters.expiresIn ?? Number(nonceFreeMaxExpiryWindow), + }, + ) + return nonce.channel(parameters.key ?? 0n) + }, } as const diff --git a/src/experimental/eip8168/actions/sendSponsoredCalls.ts b/src/experimental/eip8168/actions/sendSponsoredCalls.ts index 1d1d2c6567..3bf7041008 100644 --- a/src/experimental/eip8168/actions/sendSponsoredCalls.ts +++ b/src/experimental/eip8168/actions/sendSponsoredCalls.ts @@ -7,6 +7,8 @@ import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import { numberToHex } from '../../../utils/encoding/toHex.js' import type { To8130AccountReturnType } from '../../eip8130/accounts/to8130Account.js' import { prepareTransaction8130 } from '../../eip8130/actions/sendCalls.js' +import { nonceFreeMaxExpiryWindow } from '../../eip8130/constants.js' +import { isNoncelessOnly } from '../../eip8130/keys.js' import type { Address } from 'abitype' import type { AaAccountChange, @@ -198,12 +200,24 @@ export async function sendSponsoredCalls( ? hexToBigInt(option.conditions.maxGasLimit) : undefined + // A nonce-free-only sending actor (admin or no `SCOPE_NONCE`) MUST carry a + // non-zero, in-window expiry — it is the sole replay protection. When the + // payer's offer has no `maxExpiry`, fall back to the mempool admission window + // instead of `0` (which the node would reject as a nonce-free tx). + const noncelessOnly = + account.scope !== undefined && isNoncelessOnly(account.scope) + // `conditions.maxExpiry` is a relative duration (seconds from now). The wallet // sets the onchain expiry to `now + maxExpiry`; the payer keeps `maxExpiry` // short. Recomputed per attempt so a retry doesn't inherit a near-expiry // window. A caller-supplied absolute `expiry` is used as-is. const computeExpiry = (): bigint => { if (parameters.expiry !== undefined) return parameters.expiry + // Nonce-free-only actor: expiry is the sole replay protection and must fall + // within the protocol's tight replay window, so pin it to the mempool + // admission window regardless of the payer's (possibly larger) `maxExpiry`. + if (noncelessOnly) + return BigInt(Math.floor(Date.now() / 1000)) + nonceFreeMaxExpiryWindow const maxExpiry = option.conditions?.maxExpiry return maxExpiry !== undefined ? BigInt(Math.floor(Date.now() / 1000)) + BigInt(maxExpiry) From 74a9df9008e415d15035e6e924eeb59cb6c8fab5 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Fri, 17 Jul 2026 18:53:04 -0400 Subject: [PATCH 49/96] feat(eip8168): carry accountChanges through sendSponsoredCalls sendSponsoredCalls only sponsored plain calls; any create/authorize/revoke account changes were dropped, so the common sponsored deploy + session-key authorize flow silently landed without applying the changes. Thread an optional `accountChanges` into prepareTransaction8130 so a single sponsored tx can carry both account changes and calls. --- .../eip8168/actions/sendSponsoredCalls.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/experimental/eip8168/actions/sendSponsoredCalls.ts b/src/experimental/eip8168/actions/sendSponsoredCalls.ts index 96e243f18e..1d1d2c6567 100644 --- a/src/experimental/eip8168/actions/sendSponsoredCalls.ts +++ b/src/experimental/eip8168/actions/sendSponsoredCalls.ts @@ -8,7 +8,11 @@ import { numberToHex } from '../../../utils/encoding/toHex.js' import type { To8130AccountReturnType } from '../../eip8130/accounts/to8130Account.js' import { prepareTransaction8130 } from '../../eip8130/actions/sendCalls.js' import type { Address } from 'abitype' -import type { AaCall, AaCalls } from '../../eip8130/types/transaction.js' +import type { + AaAccountChange, + AaCall, + AaCalls, +} from '../../eip8130/types/transaction.js' import type { PayerClient } from '../client.js' import type { GetTermsReturnType, @@ -57,6 +61,14 @@ export type SendSponsoredCallsParameters = { payerClient: PayerClient /** User's intended calls (run in the final phase). */ calls: readonly AaCall[] + /** + * Account changes (create / authorize / revoke / delegate) applied before the + * calls, in the same sponsored transaction. Use this for a sponsored deploy or + * a sponsored session-key authorize. The payer's gas estimate covers `calls` + * only; if the added changes push gas past the quote, the payer's `GAS_TOO_LOW` + * re-quote raises it (gated by `confirmRetry`), or pass an explicit `gas`. + */ + accountChanges?: readonly AaAccountChange[] | undefined /** * `"send"` (default) asks the payer to co-sign and submit; `"sign"` asks the * payer to co-sign and return the transaction for the wallet to submit. A @@ -133,6 +145,7 @@ export async function sendSponsoredCalls( account, payerClient, calls, + accountChanges, mode = 'send', token, context, @@ -202,6 +215,7 @@ export async function sendSponsoredCalls( const transaction = await prepareTransaction8130(client, { account, calls: built.calls, + accountChanges, gas: initialGas, maxFeePerGas, maxPriorityFeePerGas, From 820de3889bc62b4594b7043ac3c15e210687c42f Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Sat, 18 Jul 2026 08:46:53 -0400 Subject: [PATCH 50/96] feat(eip8130): scope-driven nonce mode (nonce-free for admin / non-SCOPE_NONCE actors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An EIP-8130 actor may use a sequenced nonce key only if it holds SCOPE_NONCE; admin actors (scope 0) and any actor authorized without that bit are restricted by the node to nonce-free (expiring) transactions. Make the client select the correct mode automatically instead of leaving it as tribal knowledge: - keys: `canUseSequencedNonce(scope)` / `isNoncelessOnly(scope)` predicates. - nonce: `nonce.forScope(scope, …)` builder. - errors: `NonceScopeError`, thrown when a sequenced `nonceKey` is requested for a nonce-free-only signing actor. - to8130Account: account handles carry the signing actor's `scope`; `newSmartAccount8130` (admin owner) and `toEoa8130Account` (implicit self actor) default to admin → nonce-free. - prepareTransaction8130 / sendSponsoredCalls: when the actor is nonce-free-only, auto-select `nonceKey = NONCE_KEY_MAX` and default a non-zero in-window expiry; reject an explicit sequenced `nonceKey` with `NonceScopeError`. - authorizeActor: steer policy (session) keys to POLICY-only scope. --- .../eip8130/accounts/to8130Account.ts | 48 ++++++++++++++++++- src/experimental/eip8130/actions/sendCalls.ts | 39 ++++++++++++--- src/experimental/eip8130/errors.ts | 32 +++++++++++++ src/experimental/eip8130/index.ts | 3 ++ src/experimental/eip8130/keys.ts | 36 +++++++++++++- src/experimental/eip8130/nonce.ts | 42 +++++++++++++++- .../eip8168/actions/sendSponsoredCalls.ts | 14 ++++++ 7 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 src/experimental/eip8130/errors.ts diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index 756dfc4e1b..d6d994f0f8 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -8,6 +8,7 @@ import { accountConfigAddress as defaultAccountConfigAddress, canonicalAuthenticators, ecrecoverAuthenticator, + scopeUnrestricted, } from '../constants.js' import { canonicalEip8130Deployment } from '../deployments.js' import { key } from '../keys.js' @@ -38,6 +39,17 @@ type To8130AccountBase = { * WebAuthn / delegate authenticator address for non-K1 signers. */ authenticator?: Address | undefined + /** + * Scope bitmask of the **signing actor** on this account (see + * {@link actorScope}). When set, transaction preparation enforces the + * protocol's nonce rule: an actor may use a sequenced nonce key only if it + * holds `SCOPE_NONCE`; admin actors (`scope == 0`) and any actor without that + * bit are automatically restricted to nonce-free (expiring) transactions. + * + * Leave `undefined` to disable the automatic nonce-mode selection (the caller + * fully controls `nonceKey`). + */ + scope?: number | undefined } /** @@ -90,6 +102,13 @@ export type To8130AccountReturnType = { readonly address: Address readonly signer: Signer readonly initialActors: readonly AaActor[] + /** + * Scope of the signing actor, when known. Drives automatic nonce-mode + * selection in {@link prepareTransaction8130} / {@link sendCalls8130}: an actor + * lacking `SCOPE_NONCE` (incl. admin `scope == 0`) is restricted to nonce-free + * mode. `undefined` disables the enforcement. + */ + readonly scope?: number | undefined /** * Builds the `create` account-change entry (include in the first tx for * smart accounts). Throws if the account was constructed with a known `address` @@ -151,6 +170,7 @@ export function to8130Account( const { signer, authenticator = ecrecoverAuthenticator, + scope, } = parameters // Address-only mode (delegated EOA): address is fixed, no CREATE2 derivation. @@ -178,6 +198,7 @@ export function to8130Account( address, signer, initialActors, + scope, create() { if (isAddressOnly) @@ -378,6 +399,10 @@ export function newSmartAccount8130( initialActors: allActors, authenticator: signer.authenticator, accountConfigAddress, + // The primary (controlling) actor is registered without a scope, i.e. as an + // admin actor. Admin actors lack `SCOPE_NONCE`, so this account is + // nonce-free-only — surface that so nonce mode is selected automatically. + scope: primaryActor.scope ?? scopeUnrestricted, }) return { ...inner, createChange: inner.create() } @@ -387,10 +412,26 @@ export function newSmartAccount8130( // toEoa8130Account // ───────────────────────────────────────────────────────────────────────────── +export type ToEoa8130AccountParameters = { + /** + * Scope of the EOA's implicit self-actor. Defaults to admin + * ({@link scopeUnrestricted}), which lacks `SCOPE_NONCE` and is therefore + * nonce-free-only. Override only if the self-actor was reconfigured with the + * `SCOPE_NONCE` bit and you want sequenced nonces. + */ + scope?: number | undefined +} + export type ToEoa8130AccountReturnType = { /** The EOA address — both the sender identity and the key recovery target. */ readonly address: Address readonly signer: Signer + /** + * Scope of the implicit self-actor (admin by default). Drives automatic + * nonce-mode selection: admin lacks `SCOPE_NONCE`, so sends default to + * nonce-free (expiring) mode. See {@link prepareTransaction8130}. + */ + readonly scope?: number | undefined /** * Builds an EIP-7702 `delegation` account-change entry that sets the code * at this EOA address. Include in the first transaction's `accountChanges` @@ -472,16 +513,21 @@ export type ToEoa8130AccountReturnType = { * calls: wire, ... * }) */ -export function toEoa8130Account(signer: Signer): ToEoa8130AccountReturnType { +export function toEoa8130Account( + signer: Signer, + parameters: ToEoa8130AccountParameters = {}, +): ToEoa8130AccountReturnType { if (!signer.address) throw new BaseError( '`signer.address` is required. Use `privateKeyToAccount(pk)` or equivalent.', ) const address = signer.address + const scope = parameters.scope ?? scopeUnrestricted return { address, signer, + scope, delegate(target) { return { type: 'delegation', target } diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/experimental/eip8130/actions/sendCalls.ts index 10ca9bd4cb..622dd289e5 100644 --- a/src/experimental/eip8130/actions/sendCalls.ts +++ b/src/experimental/eip8130/actions/sendCalls.ts @@ -8,7 +8,9 @@ import type { Chain } from '../../../types/chain.js' import type { Hex } from '../../../types/misc.js' import { getAction } from '../../../utils/getAction.js' import type { To8130AccountReturnType } from '../accounts/to8130Account.js' -import { nonceKeyMax } from '../constants.js' +import { nonceFreeMaxExpiryWindow, nonceKeyMax } from '../constants.js' +import { NonceScopeError } from '../errors.js' +import { isNoncelessOnly } from '../keys.js' import type { AaAccountChange, AaCall, @@ -47,13 +49,29 @@ export async function prepareTransaction8130( client: Client, parameters: PrepareTransaction8130Parameters, ): Promise { - const { account, calls, accountChanges, payer, gas, expiry, nonceKey = 0n } = - parameters + const { account, calls, accountChanges, payer, gas } = parameters const chainId = client.chain?.id if (!chainId) throw new BaseError('`client` must be configured with a `chain`.') + // Scope-driven nonce mode: an actor may use a sequenced nonce key only if it + // holds `SCOPE_NONCE`. Admin actors (`scope == 0`) and any actor without that + // bit are restricted to nonce-free (expiring) mode. When the signing actor's + // scope is known, select the mode automatically and reject a sequenced key. + const noncelessOnly = + account.scope !== undefined && isNoncelessOnly(account.scope) + let nonceKey = parameters.nonceKey + if (noncelessOnly) { + if (nonceKey !== undefined && nonceKey !== nonceKeyMax) + throw new NonceScopeError({ scope: account.scope!, nonceKey }) + nonceKey = nonceKeyMax + } else { + nonceKey ??= 0n + } + + let expiry = parameters.expiry + let { maxFeePerGas, maxPriorityFeePerGas } = parameters if (maxFeePerGas === undefined || maxPriorityFeePerGas === undefined) { const fees = await getAction( @@ -70,10 +88,17 @@ export async function prepareTransaction8130( if (nonceKey === nonceKeyMax) { // Nonce-free (expiring) mode: there is no per-channel counter to read; // replay protection relies on `expiry`. Pin the sequence to `0n`. - if (!expiry || expiry === 0n) - throw new BaseError( - '`expiry` is required for nonce-free transactions (`nonceKey` = `NONCE_KEY_MAX`). Build the nonce with `nonce.nonceless({ expiresIn })`.', - ) + if (!expiry || expiry === 0n) { + if (noncelessOnly) + // Auto-selected nonce-free mode: default the expiry to the mempool + // admission window rather than forcing the caller to supply one. + expiry = + BigInt(Math.floor(Date.now() / 1000)) + nonceFreeMaxExpiryWindow + else + throw new BaseError( + '`expiry` is required for nonce-free transactions (`nonceKey` = `NONCE_KEY_MAX`). Build the nonce with `nonce.nonceless({ expiresIn })`.', + ) + } nonceSequence ??= 0n } else if (nonceSequence === undefined) { // Read the next sequence via `eth_getTransactionCount` (with the 2D diff --git a/src/experimental/eip8130/errors.ts b/src/experimental/eip8130/errors.ts new file mode 100644 index 0000000000..dfaedb1746 --- /dev/null +++ b/src/experimental/eip8130/errors.ts @@ -0,0 +1,32 @@ +import { BaseError } from '../../errors/base.js' + +export type NonceScopeErrorType = NonceScopeError & { name: 'NonceScopeError' } + +/** + * Thrown when a sequenced (counter-backed) nonce key is requested for a signing + * actor that lacks `SCOPE_NONCE` — admin actors (`scope == 0`) and any actor + * authorized without the nonce bit are restricted to nonce-free (expiring) + * transactions. + */ +export class NonceScopeError extends BaseError { + override name = 'NonceScopeError' + constructor({ + scope, + nonceKey, + }: { + scope: number + nonceKey?: bigint | undefined + }) { + super( + `Signing actor scope \`0x${scope.toString(16)}\` lacks \`SCOPE_NONCE\`, so it may only send nonce-free (expiring) transactions${ + nonceKey !== undefined ? ` — received \`nonceKey\` ${nonceKey}.` : '.' + }`, + { + metaMessages: [ + 'Admin actors (scope 0) and any actor without the `SCOPE_NONCE` bit are restricted to nonce-free mode.', + 'Omit `nonceKey` to let the library select nonce-free mode automatically, or pass `...nonce.nonceless({ expiresIn })`.', + ], + }, + ) + } +} diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 47c9799d06..a85092b33d 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -142,10 +142,13 @@ export { getEip8130Deployment, vibenetDevnetDeployment, } from './deployments.js' +export { type NonceScopeErrorType, NonceScopeError } from './errors.js' export { type AuthorizeActorOptions, authorizeActor, + canUseSequencedNonce, encodePolicyData, + isNoncelessOnly, key, type Policy, revokeActor, diff --git a/src/experimental/eip8130/keys.ts b/src/experimental/eip8130/keys.ts index 48338616ad..a23fcf184a 100644 --- a/src/experimental/eip8130/keys.ts +++ b/src/experimental/eip8130/keys.ts @@ -90,6 +90,30 @@ export function toScope(...flags: number[]): number { return flags.reduce((acc, flag) => acc | flag, 0) } +/** + * Whether an actor with `scope` may use sequenced (counter-backed) nonce keys — + * i.e. standard sequential ordering or a 2D nonce channel. Requires the + * `SCOPE_NONCE` bit. + * + * Admin actors (`scope == 0`) do **not** have `SCOPE_NONCE` set, so — like any + * restricted actor lacking the bit — they are restricted to nonce-free + * (expiring) transactions. Use {@link isNoncelessOnly} for the inverse. + */ +export function canUseSequencedNonce(scope: number | undefined): boolean { + return ((scope ?? 0) & actorScope.nonce) !== 0 +} + +/** + * Whether an actor with `scope` is restricted to nonce-free (expiring) + * transactions (`nonceKey = NONCE_KEY_MAX`) because it lacks `SCOPE_NONCE`. + * + * True for admin actors (`scope == 0`) and any actor authorized without the + * `SCOPE_NONCE` bit. Inverse of {@link canUseSequencedNonce}. + */ +export function isNoncelessOnly(scope: number | undefined): boolean { + return !canUseSequencedNonce(scope) +} + export type Policy = { /** Non-zero policy selector (interpreted by the manager, not the protocol). */ type: number @@ -122,9 +146,17 @@ export type AuthorizeActorOptions = { * and optional policy. The result can be signed via `signActorChanges8130` / * `to8130Account#authorize`. * + * A policy-gated actor (session key) should be authorized as POLICY-only + * (`scope: actorScope.policy`): `SCOPE_POLICY` grants "gated initiation", so the + * key can originate a transaction but every call is routed through — and + * validated by — its manager. Per base/eip-8130, do NOT add `SCOPE_SENDER` (the + * policy gate governs regardless). OR in `actorScope.selfPayer` for self-pay or + * `actorScope.nonce` to allow sequenced nonces (without it the key is + * nonce-free-only). + * * @example * authorizeActor(key.p256({ x, y }), { - * scope: actorScope.sender, + * scope: actorScope.policy, // POLICY-only; optionally | actorScope.selfPayer | actorScope.nonce * policy: { type: 1, manager, commitment }, * }) */ @@ -143,7 +175,7 @@ export function authorizeActor( // Admin (scope 0) is unrestricted; a policy-gated actor must be restricted. if (scope === 0) throw new BaseError( - 'A policy-bearing actor MUST have a restricted (non-admin) scope (e.g. `actorScope.sender`).', + 'A policy-bearing actor MUST have a restricted (non-admin) scope (e.g. `actorScope.policy`).', ) // Policy presence is the SCOPE_POLICY bit (there is no `policyType` field). scope |= actorScope.policy diff --git a/src/experimental/eip8130/nonce.ts b/src/experimental/eip8130/nonce.ts index 2a39514287..811b3c0509 100644 --- a/src/experimental/eip8130/nonce.ts +++ b/src/experimental/eip8130/nonce.ts @@ -1,7 +1,8 @@ import { BaseError } from '../../errors/base.js' import { hexToBigInt } from '../../utils/encoding/fromHex.js' import { bytesToHex } from '../../utils/encoding/toHex.js' -import { nonceKeyMax } from './constants.js' +import { nonceFreeMaxExpiryWindow, nonceKeyMax } from './constants.js' +import { isNoncelessOnly } from './keys.js' /** * A resolved EIP-8130 nonce selection: the channel key and (for nonce-free @@ -118,4 +119,43 @@ export const nonce = { ) return { nonceKey: nonceKeyMax, nonceSequence: 0n, expiry: resolvedExpiry } }, + + /** + * Selects the correct nonce strategy for an actor's `scope`. An actor may use + * a sequenced nonce key only if it holds `SCOPE_NONCE`; admin actors + * (`scope == 0`) and any actor authorized without that bit are restricted to + * nonce-free (expiring) mode. + * + * - Actor **has** `SCOPE_NONCE` → sequenced {@link nonce.channel} (default + * channel `0`, i.e. {@link nonce.sequential}). + * - Actor **lacks** `SCOPE_NONCE` (incl. admin) → {@link nonce.nonceless}, + * defaulting the expiry to `NONCE_FREE_MAX_EXPIRY_WINDOW` seconds from now. + * + * @param scope - The signing actor's scope bitmask. + * @param parameters.key - Sequenced channel selector (ignored in nonce-free + * mode). @default 0n + * @param parameters.expiry - Absolute expiry (unix seconds) for nonce-free + * mode. Overrides `expiresIn`. + * @param parameters.expiresIn - Relative expiry (seconds) for nonce-free + * mode. @default Number(NONCE_FREE_MAX_EXPIRY_WINDOW) + */ + forScope( + scope: number, + parameters: { + key?: bigint | undefined + expiry?: bigint | undefined + expiresIn?: number | undefined + } = {}, + ): Nonce { + if (isNoncelessOnly(scope)) + return nonce.nonceless( + parameters.expiry !== undefined + ? { expiry: parameters.expiry } + : { + expiresIn: + parameters.expiresIn ?? Number(nonceFreeMaxExpiryWindow), + }, + ) + return nonce.channel(parameters.key ?? 0n) + }, } as const diff --git a/src/experimental/eip8168/actions/sendSponsoredCalls.ts b/src/experimental/eip8168/actions/sendSponsoredCalls.ts index 1d1d2c6567..3bf7041008 100644 --- a/src/experimental/eip8168/actions/sendSponsoredCalls.ts +++ b/src/experimental/eip8168/actions/sendSponsoredCalls.ts @@ -7,6 +7,8 @@ import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import { numberToHex } from '../../../utils/encoding/toHex.js' import type { To8130AccountReturnType } from '../../eip8130/accounts/to8130Account.js' import { prepareTransaction8130 } from '../../eip8130/actions/sendCalls.js' +import { nonceFreeMaxExpiryWindow } from '../../eip8130/constants.js' +import { isNoncelessOnly } from '../../eip8130/keys.js' import type { Address } from 'abitype' import type { AaAccountChange, @@ -198,12 +200,24 @@ export async function sendSponsoredCalls( ? hexToBigInt(option.conditions.maxGasLimit) : undefined + // A nonce-free-only sending actor (admin or no `SCOPE_NONCE`) MUST carry a + // non-zero, in-window expiry — it is the sole replay protection. When the + // payer's offer has no `maxExpiry`, fall back to the mempool admission window + // instead of `0` (which the node would reject as a nonce-free tx). + const noncelessOnly = + account.scope !== undefined && isNoncelessOnly(account.scope) + // `conditions.maxExpiry` is a relative duration (seconds from now). The wallet // sets the onchain expiry to `now + maxExpiry`; the payer keeps `maxExpiry` // short. Recomputed per attempt so a retry doesn't inherit a near-expiry // window. A caller-supplied absolute `expiry` is used as-is. const computeExpiry = (): bigint => { if (parameters.expiry !== undefined) return parameters.expiry + // Nonce-free-only actor: expiry is the sole replay protection and must fall + // within the protocol's tight replay window, so pin it to the mempool + // admission window regardless of the payer's (possibly larger) `maxExpiry`. + if (noncelessOnly) + return BigInt(Math.floor(Date.now() / 1000)) + nonceFreeMaxExpiryWindow const maxExpiry = option.conditions?.maxExpiry return maxExpiry !== undefined ? BigInt(Math.floor(Date.now() / 1000)) + BigInt(maxExpiry) From 2b72375dff2bd54cfecd3f303fb3e17badf26c31 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Sat, 18 Jul 2026 08:59:41 -0400 Subject: [PATCH 51/96] feat(eip8130): derive nonce mode from on-chain actor scope at prepare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop trusting a redeclared `scope` on session account handles — that drifted from authorize-time bits and selected the wrong nonce mode. - prepareTransaction8130 reads isActor/getActorConfig when actorId is known (auto-derived for K1); on-chain scope wins; declared scope is only a fallback for pre-bind sends (create) and must match when both exist (ScopeMismatchError). - to8130Account surfaces actorId + accountConfigAddress for that lookup. - defineSessionPolicy.executeCall accepts {target,value,data} directly. - Add ActorNotBoundError / ScopeMismatchError typed errors for clearer diagnosis vs builder lag. --- .../eip8130/accounts/to8130Account.ts | 53 +++++++++++----- src/experimental/eip8130/actions/sendCalls.ts | 47 +++++++++++--- src/experimental/eip8130/errors.ts | 62 +++++++++++++++++++ src/experimental/eip8130/index.ts | 9 ++- src/experimental/eip8130/policies.ts | 16 +++-- 5 files changed, 159 insertions(+), 28 deletions(-) diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index d6d994f0f8..8abed7c472 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -41,15 +41,21 @@ type To8130AccountBase = { authenticator?: Address | undefined /** * Scope bitmask of the **signing actor** on this account (see - * {@link actorScope}). When set, transaction preparation enforces the - * protocol's nonce rule: an actor may use a sequenced nonce key only if it - * holds `SCOPE_NONCE`; admin actors (`scope == 0`) and any actor without that - * bit are automatically restricted to nonce-free (expiring) transactions. + * {@link actorScope}). Prefer omitting this once the actor is on-chain — + * {@link prepareTransaction8130} reads `getActorConfig` and derives nonce + * mode from chain truth. When set on an already-bound actor, it must match + * the on-chain value or prepare throws {@link ScopeMismatchError}. * - * Leave `undefined` to disable the automatic nonce-mode selection (the caller - * fully controls `nonceKey`). + * Still useful before the actor is bound (e.g. the create tx for an admin + * owner): pass {@link scopeUnrestricted} so nonce-free mode is selected. */ scope?: number | undefined + /** + * 32-byte actor id of the signing actor. Defaults to `key.k1(signer.address)` + * when the authenticator is the native ecrecover / K1 authenticator. Required + * for P-256 / passkey / delegate signers so prepare can read on-chain scope. + */ + actorId?: Hex | undefined } /** @@ -94,7 +100,8 @@ export type To8130AccountParameters = To8130AccountBase & userSalt?: undefined code?: undefined initialActors?: undefined - accountConfigAddress?: undefined + /** AccountConfiguration for on-chain actor reads. Defaults to canonical. */ + accountConfigAddress?: Address | undefined } ) @@ -103,12 +110,18 @@ export type To8130AccountReturnType = { readonly signer: Signer readonly initialActors: readonly AaActor[] /** - * Scope of the signing actor, when known. Drives automatic nonce-mode - * selection in {@link prepareTransaction8130} / {@link sendCalls8130}: an actor - * lacking `SCOPE_NONCE` (incl. admin `scope == 0`) is restricted to nonce-free - * mode. `undefined` disables the enforcement. + * Scope of the signing actor, when known off-chain. Prefer leaving this + * unset once the actor is authorized — prepare reads chain truth instead. + * See {@link prepareTransaction8130}. */ readonly scope?: number | undefined + /** + * Actor id used for on-chain scope lookup / auth. Derived for K1 signers; + * set explicitly for non-K1 authenticators. + */ + readonly actorId?: Hex | undefined + /** AccountConfiguration address used for on-chain actor reads (when known). */ + readonly accountConfigAddress?: Address | undefined /** * Builds the `create` account-change entry (include in the first tx for * smart accounts). Throws if the account was constructed with a known `address` @@ -176,6 +189,9 @@ export function to8130Account( // Address-only mode (delegated EOA): address is fixed, no CREATE2 derivation. const isAddressOnly = parameters.userSalt === undefined + const accountConfigAddress = + parameters.accountConfigAddress ?? defaultAccountConfigAddress + const address: Address = (() => { if (parameters.address) return parameters.address if (isAddressOnly) @@ -186,19 +202,28 @@ export function to8130Account( userSalt: parameters.userSalt!, code: parameters.code!, initialActors: parameters.initialActors!, - accountConfigAddress: - (parameters as { accountConfigAddress?: Address }).accountConfigAddress ?? - defaultAccountConfigAddress, + accountConfigAddress, }) })() const initialActors = parameters.initialActors ?? [] + // K1 / ecrecover signers: actorId is a pure function of the signer address. + // Non-K1 authenticators must pass `actorId` explicitly for on-chain scope reads. + const isK1Authenticator = + authenticator === ecrecoverAuthenticator || + authenticator === canonicalAuthenticators.k1 + const actorId = + parameters.actorId ?? + (isK1Authenticator ? key.k1(signer.address).actorId : undefined) + return { address, signer, initialActors, scope, + actorId, + accountConfigAddress, create() { if (isAddressOnly) diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/experimental/eip8130/actions/sendCalls.ts index 622dd289e5..17929d8f69 100644 --- a/src/experimental/eip8130/actions/sendCalls.ts +++ b/src/experimental/eip8130/actions/sendCalls.ts @@ -9,7 +9,7 @@ import type { Hex } from '../../../types/misc.js' import { getAction } from '../../../utils/getAction.js' import type { To8130AccountReturnType } from '../accounts/to8130Account.js' import { nonceFreeMaxExpiryWindow, nonceKeyMax } from '../constants.js' -import { NonceScopeError } from '../errors.js' +import { NonceScopeError, ScopeMismatchError } from '../errors.js' import { isNoncelessOnly } from '../keys.js' import type { AaAccountChange, @@ -19,7 +19,9 @@ import type { } from '../types/transaction.js' import { type EncodeExecute, encodeWalletCalls } from '../utils/encodeWalletCalls.js' import type { Signer } from '../utils/signTransaction.js' +import { getActorConfig8130 } from './getActorConfig8130.js' import { getTransactionCount8130 } from './getTransactionCount8130.js' +import { isActor8130 } from './isActor8130.js' type FeeOverrides = { maxFeePerGas?: bigint | undefined @@ -39,6 +41,36 @@ export type PrepareTransaction8130Parameters = FeeOverrides & { expiry?: bigint | undefined } +/** + * Resolves the signing actor's scope for nonce-mode selection. + * On-chain config wins when the actor is bound; a declared handle `scope` is + * only a fallback for pre-bind sends (create) and must match when both exist. + */ +async function resolveSigningScope( + client: Client, + account: To8130AccountReturnType, +): Promise { + const { actorId, scope: declared } = account + if (!actorId) return declared + + const accountConfiguration = account.accountConfigAddress + const bound = await isActor8130(client, { + account: account.address, + actorId, + ...(accountConfiguration ? { accountConfiguration } : {}), + }) + if (!bound) return declared + + const { scope: onChain } = await getActorConfig8130(client, { + account: account.address, + actorId, + ...(accountConfiguration ? { accountConfiguration } : {}), + }) + if (declared !== undefined && declared !== onChain) + throw new ScopeMismatchError({ declared, onChain }) + return onChain +} + /** * Builds a fully-populated {@link TransactionSerializable8130} for an * `AA_TX_TYPE` transaction, filling chain id, nonce sequence (via @@ -56,15 +88,16 @@ export async function prepareTransaction8130( throw new BaseError('`client` must be configured with a `chain`.') // Scope-driven nonce mode: an actor may use a sequenced nonce key only if it - // holds `SCOPE_NONCE`. Admin actors (`scope == 0`) and any actor without that - // bit are restricted to nonce-free (expiring) mode. When the signing actor's - // scope is known, select the mode automatically and reject a sequenced key. - const noncelessOnly = - account.scope !== undefined && isNoncelessOnly(account.scope) + // holds `SCOPE_NONCE`. Prefer chain truth (`getActorConfig`) over a redeclared + // handle `scope` — drift between authorize-time and send-time declarations is + // a live footgun. Fall back to the declared scope only when the actor is not + // yet bound (e.g. the create tx). + const scope = await resolveSigningScope(client, account) + const noncelessOnly = scope !== undefined && isNoncelessOnly(scope) let nonceKey = parameters.nonceKey if (noncelessOnly) { if (nonceKey !== undefined && nonceKey !== nonceKeyMax) - throw new NonceScopeError({ scope: account.scope!, nonceKey }) + throw new NonceScopeError({ scope: scope!, nonceKey }) nonceKey = nonceKeyMax } else { nonceKey ??= 0n diff --git a/src/experimental/eip8130/errors.ts b/src/experimental/eip8130/errors.ts index dfaedb1746..b71519fcaa 100644 --- a/src/experimental/eip8130/errors.ts +++ b/src/experimental/eip8130/errors.ts @@ -30,3 +30,65 @@ export class NonceScopeError extends BaseError { ) } } + +export type ScopeMismatchErrorType = ScopeMismatchError & { + name: 'ScopeMismatchError' +} + +/** + * Thrown when an account handle declares a `scope` that does not match the + * on-chain actor config. Authority is the chain's to report — a redeclared + * scope that drifts from authorization is a live footgun for nonce-mode + * selection. + */ +export class ScopeMismatchError extends BaseError { + override name = 'ScopeMismatchError' + constructor({ + declared, + onChain, + }: { + declared: number + onChain: number + }) { + super( + `Declared signing scope \`0x${declared.toString(16)}\` does not match on-chain actor scope \`0x${onChain.toString(16)}\`.`, + { + metaMessages: [ + 'Omit `scope` on the account handle and let prepare read `getActorConfig` — the chain is authoritative for nonce-mode selection.', + 'If you pass `scope`, it must equal the authorized on-chain value.', + ], + }, + ) + } +} + +export type ActorNotBoundErrorType = ActorNotBoundError & { + name: 'ActorNotBoundError' +} + +/** + * Thrown when the signing actor is not bound on the account according to the + * authoritative RPC (`isActor` / `getActorConfig`). Distinct from builder-state + * lag, where the public RPC shows the actor bound but the sequencer briefly + * rejects with "actor is not bound". + */ +export class ActorNotBoundError extends BaseError { + override name = 'ActorNotBoundError' + constructor({ + account, + actorId, + }: { + account: `0x${string}` + actorId: `0x${string}` + }) { + super( + `Signing actor \`${actorId}\` is not bound on account \`${account}\`.`, + { + metaMessages: [ + 'Check actorId derivation (`key.k1` / `key.p256` / …) and that authorize used the same actorId + authenticator.', + 'If the public RPC shows the actor bound but broadcast still fails, that is builder lag — not this error.', + ], + }, + ) + } +} diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index a85092b33d..12b8c426a3 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -142,7 +142,14 @@ export { getEip8130Deployment, vibenetDevnetDeployment, } from './deployments.js' -export { type NonceScopeErrorType, NonceScopeError } from './errors.js' +export { + type ActorNotBoundErrorType, + ActorNotBoundError, + type NonceScopeErrorType, + NonceScopeError, + type ScopeMismatchErrorType, + ScopeMismatchError, +} from './errors.js' export { type AuthorizeActorOptions, authorizeActor, diff --git a/src/experimental/eip8130/policies.ts b/src/experimental/eip8130/policies.ts index 83ed26f8e5..feaf232b96 100644 --- a/src/experimental/eip8130/policies.ts +++ b/src/experimental/eip8130/policies.ts @@ -194,9 +194,11 @@ export type SessionPolicy = { * `PolicyManager.execute(binding, executionData)`. This is the only target a * policy-gated actor may reach. The full {@link PolicyBinding} is passed so the * manager can recompute + match the commitment (no install / config storage). - * Build `executionData` with {@link encodeSessionPolicyAction}. + * + * Pass either raw `executionData` ({@link encodeSessionPolicyAction}) or the + * action fields `{ target, value?, data? }` and the encoding is done for you. */ - executeCall(executionData: Hex): AaCall + executeCall(executionData: Hex | SessionPolicyAction): AaCall } export type DefineSessionPolicyErrorType = CommitmentOfErrorType @@ -231,9 +233,7 @@ export type DefineSessionPolicyErrorType = CommitmentOfErrorType * ]) * * // 2) later, the session key spends within its limit - * const spend = session.executeCall( - * encodeSessionPolicyAction({ target: usdc, data: transferCalldata }), - * ) + * const spend = session.executeCall({ target: usdc, data: transferCalldata }) */ export function defineSessionPolicy( parameters: DefineSessionPolicyParameters, @@ -268,7 +268,11 @@ export function defineSessionPolicy( binding, commitment, actorPolicy: { type: policyType, manager, commitment }, - executeCall(executionData) { + executeCall(executionDataOrAction) { + const executionData = + typeof executionDataOrAction === 'string' + ? executionDataOrAction + : encodeSessionPolicyAction(executionDataOrAction) return { to: manager, value: 0n, From ea5d3a3c60ca3aad811d3affc44175098cb36738 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Mon, 20 Jul 2026 09:15:51 -0400 Subject: [PATCH 52/96] feat(eip8130): map data suffixes to transaction metadata Preserve attribution for native AA transactions by authenticating viem data suffixes in the EIP-8130 metadata field. --- .../eip8130/actions/estimateGas8130.test.ts | 41 +++++++++++++++ .../eip8130/actions/estimateGas8130.ts | 15 +++++- src/experimental/eip8130/actions/sendCalls.ts | 19 +++++++ src/experimental/eip8130/devx.test.ts | 52 +++++++++++++++++++ src/experimental/eip8130/types/transaction.ts | 4 ++ 5 files changed, 130 insertions(+), 1 deletion(-) diff --git a/src/experimental/eip8130/actions/estimateGas8130.test.ts b/src/experimental/eip8130/actions/estimateGas8130.test.ts index 1685e93c42..c3ca17fc13 100644 --- a/src/experimental/eip8130/actions/estimateGas8130.test.ts +++ b/src/experimental/eip8130/actions/estimateGas8130.test.ts @@ -122,3 +122,44 @@ describe('estimateGas8130 — create account-change serialization', () => { ) }) }) + +describe('estimateGas8130 — dataSuffix → metadata', () => { + test('full-body mode writes dataSuffix to metadata', async () => { + const rec = recordingClient() + const account = to8130Account({ + signer: owner, + userSalt, + code, + initialActors: [key.k1(owner.address)], + }) + + await estimateGas8130(rec.client, { + sender: account.address, + accountChanges: [account.create()], + calls: [[{ to: owner.address }]], + senderAuthAuthenticator: canonicalAuthenticators.k1, + dataSuffix: '0xabcdef', + }) + + expect(rec.request.metadata).toBe('0xabcdef') + }) + + test('full-body mode defaults metadata to 0x', async () => { + const rec = recordingClient() + const account = to8130Account({ + signer: owner, + userSalt, + code, + initialActors: [key.k1(owner.address)], + }) + + await estimateGas8130(rec.client, { + sender: account.address, + accountChanges: [account.create()], + calls: [[{ to: owner.address }]], + senderAuthAuthenticator: canonicalAuthenticators.k1, + }) + + expect(rec.request.metadata).toBe('0x') + }) +}) diff --git a/src/experimental/eip8130/actions/estimateGas8130.ts b/src/experimental/eip8130/actions/estimateGas8130.ts index 848ebbd04a..f0bb01e784 100644 --- a/src/experimental/eip8130/actions/estimateGas8130.ts +++ b/src/experimental/eip8130/actions/estimateGas8130.ts @@ -121,6 +121,12 @@ export type EstimateGas8130Parameters = { payerAuthAuthenticator?: Address | undefined /** Payer auth-payload byte length override. See `senderAuthSize`. */ payerAuthSize?: number | undefined + /** + * Attribution / opaque suffix. Written to the EIP-8130 `metadata` field in + * full-body mode (not appended to call calldata). Takes precedence over + * `client.dataSuffix`. + */ + dataSuffix?: Hex | undefined /** Block number to estimate against. */ blockNumber?: bigint | undefined /** Block tag to estimate against. Defaults to `'pending'`. */ @@ -189,10 +195,17 @@ export async function estimateGas8130< payerAuth: payerAuthExplicit, payerAuthAuthenticator, payerAuthSize, + dataSuffix: dataSuffixParam, blockNumber, blockTag = 'pending', } = parameters + const dataSuffix = + dataSuffixParam ?? + (typeof client.dataSuffix === 'string' + ? client.dataSuffix + : client.dataSuffix?.value) + const account_ = sender ?? from if (!account_) throw new BaseError( @@ -250,7 +263,7 @@ export async function estimateGas8130< data: c.data ?? '0x', })), ), - metadata: '0x', + metadata: dataSuffix ?? '0x', payer: payer ?? null, } if (senderAuth !== undefined) request.senderAuth = senderAuth diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/experimental/eip8130/actions/sendCalls.ts index 17929d8f69..97410a116c 100644 --- a/src/experimental/eip8130/actions/sendCalls.ts +++ b/src/experimental/eip8130/actions/sendCalls.ts @@ -39,6 +39,11 @@ export type PrepareTransaction8130Parameters = FeeOverrides & { nonceKey?: bigint | undefined nonceSequence?: bigint | undefined expiry?: bigint | undefined + /** + * Attribution / opaque suffix. Written to the EIP-8130 `metadata` field + * (not appended to call calldata). Takes precedence over `client.dataSuffix`. + */ + dataSuffix?: Hex | undefined } /** @@ -87,6 +92,14 @@ export async function prepareTransaction8130( if (!chainId) throw new BaseError('`client` must be configured with a `chain`.') + // EIP-8130 has no calldata to append to; `dataSuffix` maps to top-level + // `metadata` so attribution remains authenticated with the signed body. + const dataSuffix = + parameters.dataSuffix ?? + (typeof client.dataSuffix === 'string' + ? client.dataSuffix + : client.dataSuffix?.value) + // Scope-driven nonce mode: an actor may use a sequenced nonce key only if it // holds `SCOPE_NONCE`. Prefer chain truth (`getActorConfig`) over a redeclared // handle `scope` — drift between authorize-time and send-time declarations is @@ -155,6 +168,7 @@ export async function prepareTransaction8130( expiry, accountChanges, calls, + ...(dataSuffix ? { metadata: dataSuffix } : {}), payer: payer?.address ?? payer?.account.address, } } @@ -172,6 +186,11 @@ export type SendCalls8130Parameters = FeeOverrides & { nonceKey?: bigint | undefined nonceSequence?: bigint | undefined expiry?: bigint | undefined + /** + * Attribution / opaque suffix. Written to the EIP-8130 `metadata` field + * (not appended to call calldata). Takes precedence over `client.dataSuffix`. + */ + dataSuffix?: Hex | undefined /** * Encoder for value-bearing phases. Defaults to a self-call to the account's * `executeBatch`. Override when the wallet bytecode exposes a different diff --git a/src/experimental/eip8130/devx.test.ts b/src/experimental/eip8130/devx.test.ts index 572769227b..73008c1fbe 100644 --- a/src/experimental/eip8130/devx.test.ts +++ b/src/experimental/eip8130/devx.test.ts @@ -146,6 +146,8 @@ describe('sendCalls8130', () => { transport: custom({ async request({ method, params }: { method: string; params: any }) { if (method === 'eth_chainId') return '0x1' + // Offline: actor is not yet bound — fall back to declared handle scope. + if (method === 'eth_call') return `0x${'0'.repeat(64)}` if (method === 'eth_sendRawTransaction') { sent = params[0] return keccak256(params[0]) @@ -188,6 +190,56 @@ describe('sendCalls8130', () => { expect(parsed.calls?.[0]).toHaveLength(2) expect(parsed.accountChanges?.[0]?.type).toBe('create') expect(parsed.senderAuth).toBeDefined() + expect(parsed.metadata).toBeUndefined() + }) + + test('behavior: dataSuffix is written to metadata', async () => { + await sendCalls8130(client, { + account, + calls: [{ to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }], + accountChanges: [account.create()], + gas: 200_000n, + maxFeePerGas: 2_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonceSequence: 0n, + dataSuffix: '0xdeadbeef', + }) + + const parsed = parseTransaction8130(sent!) + expect(parsed.metadata).toBe('0xdeadbeef') + // Calldata is untouched — attribution lives in metadata, not call data. + expect(parsed.calls?.[0]?.[0]?.data ?? '0x').toBe('0x') + }) + + test('behavior: client.dataSuffix populates metadata', async () => { + let clientSent: Hex | undefined + const suffixedClient = createClient({ + chain: mainnet, + dataSuffix: '0x12345678', + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_call') return `0x${'0'.repeat(64)}` + if (method === 'eth_sendRawTransaction') { + clientSent = params[0] + return keccak256(params[0]) + } + throw new Error(`unexpected RPC: ${method}`) + }, + }), + }) + + await sendCalls8130(suffixedClient, { + account, + calls: [{ to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }], + accountChanges: [account.create()], + gas: 200_000n, + maxFeePerGas: 2_000_000_000n, + maxPriorityFeePerGas: 1_000_000_000n, + nonceSequence: 0n, + }) + + expect(parseTransaction8130(clientSent!).metadata).toBe('0x12345678') }) }) diff --git a/src/experimental/eip8130/types/transaction.ts b/src/experimental/eip8130/types/transaction.ts index 9583162d6b..4413f739b4 100644 --- a/src/experimental/eip8130/types/transaction.ts +++ b/src/experimental/eip8130/types/transaction.ts @@ -166,6 +166,10 @@ export type TransactionSerializable8130 = { * level of the transaction. Appended after `calls` in the signed body, so it * is authenticated by both the sender and (when present) the payer. Omit or * `'0x'` for none. + * + * High-level helpers (`prepareTransaction8130` / `sendCalls8130`) populate + * this from `dataSuffix` / `client.dataSuffix` (EIP-8130 has no calldata + * suffix; attribution lands here instead). */ metadata?: Hex | undefined /** Gas payer. Omit for self-pay; set to a 20-byte address for sponsored. */ From 48620a3481c910b39688feffa40eda5473c8d435 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Mon, 20 Jul 2026 09:15:51 -0400 Subject: [PATCH 53/96] feat(eip8168): describe flexible token payment offers Let payers advertise whether they require canonical transfers or accept any simulated payment construction with the expected outcome. --- src/experimental/eip8168/types.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/experimental/eip8168/types.ts b/src/experimental/eip8168/types.ts index 9f7c248bd5..e7e540724c 100644 --- a/src/experimental/eip8168/types.ts +++ b/src/experimental/eip8168/types.ts @@ -213,6 +213,20 @@ export type TokenPaymentOffer = BaseOffer & { kind: 'token' /** ≥1 accepted tokens. */ tokens: readonly TokenChoice[] + /** + * What phase-0 constructions this offer's payer accepts, and how it verifies + * them. OPTIONAL; absent is treated as `"transfer"`. + * + * - `"transfer"`: phase 0 MUST be the single canonical + * `IERC20.transfer(recipient, paymentAmount)` — verified by inspection, no + * simulation, no approvals. Any other phase-0 shape is rejected. + * - `"any"`: the payer pins only the OUTCOME (`recipient` credited exactly + * `paymentAmount` of `token` by the end of phase 0) and accepts any calls + * that achieve it (`transferFrom`, session-key module calls, pool + * withdrawal + split, other token standards), verifying the net credit by + * simulation/trace. + */ + paymentMode?: 'transfer' | 'any' | undefined } /** A payment offer for an intent's gas: full sponsorship, decline, or token payment. */ From bae82d74c9f733f15747f09f26de414a58f1241d Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Mon, 20 Jul 2026 09:27:30 -0400 Subject: [PATCH 54/96] feat(eip8130): update canonical deployment addresses Track the new AccountConfiguration deployment and default canonical accounts to ERC-1167 proxies over DefaultAccount, avoiding stale unaudited example implementations. --- .../eip8130/accounts/to8130Account.ts | 56 ++++++++------- src/experimental/eip8130/constants.ts | 6 +- src/experimental/eip8130/deployments.ts | 68 +++++++++++-------- src/experimental/eip8130/devx.test.ts | 22 ++++++ 4 files changed, 93 insertions(+), 59 deletions(-) diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index 8abed7c472..41222a202d 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -2,11 +2,11 @@ import type { Address } from 'abitype' import { BaseError } from '../../../errors/base.js' import type { Hex } from '../../../types/misc.js' import { concatHex } from '../../../utils/data/concat.js' -import { bytesToHex } from '../../../utils/encoding/toHex.js' import { hexToBigInt } from '../../../utils/encoding/fromHex.js' +import { bytesToHex } from '../../../utils/encoding/toHex.js' import { - accountConfigAddress as defaultAccountConfigAddress, canonicalAuthenticators, + accountConfigAddress as defaultAccountConfigAddress, ecrecoverAuthenticator, scopeUnrestricted, } from '../constants.js' @@ -180,11 +180,7 @@ export type To8130AccountReturnType = { export function to8130Account( parameters: To8130AccountParameters, ): To8130AccountReturnType { - const { - signer, - authenticator = ecrecoverAuthenticator, - scope, - } = parameters + const { signer, authenticator = ecrecoverAuthenticator, scope } = parameters // Address-only mode (delegated EOA): address is fixed, no CREATE2 derivation. const isAddressOnly = parameters.userSalt === undefined @@ -289,16 +285,16 @@ export type NewSmartAccount8130Parameters = { */ salt?: Hex | undefined /** - * When `true` (default), the account is deployed as an `UpgradeableAccount` - * behind an ERC-1967 `UpgradeableProxy` (upgradeable via `upgradeBySignature`). - * When `false`, it is deployed as an immutable `DefaultHighRateAccount` behind - * a 45-byte ERC-1167 proxy. Ignored if `code` is provided. + * When `false` (default), the account is deployed as an ERC-1167 proxy to the + * canonical `DefaultAccount`. When `true`, `implementation` is required and + * is deployed behind an ERC-1967 `UpgradeableProxy`. Ignored if `code` is + * provided. */ upgradeable?: boolean | undefined /** * Wallet implementation address the account proxies to. Defaults to the - * canonical `UpgradeableAccount` (or `DefaultHighRateAccount` when - * `upgradeable` is `false`). Ignored if `code` is provided. + * canonical `DefaultAccount` when `upgradeable` is `false`. Required when + * `upgradeable` is `true`. Ignored if `code` is provided. */ implementation?: Address | undefined /** @@ -382,15 +378,18 @@ export function newSmartAccount8130( const { signer, implementation, - upgradeable = true, + upgradeable = false, extraActors = [], accountConfigAddress, } = parameters // Detect signer type and derive the primary actor. - // P256 / WebAuthn signers expose `.publicKey`; K1 signers have `.address`. + // P256 / WebAuthn signers expose an `{ x, y }` public key. K1 local accounts + // may also expose `.publicKey`, but as a 65-byte SEC1 hex string. const primaryActor: AaActor = - 'publicKey' in signer && signer.publicKey + 'publicKey' in signer && + signer.publicKey && + typeof signer.publicKey !== 'string' ? signer.authenticator === canonicalAuthenticators.passkey ? key.webAuthn(signer.publicKey) : key.p256(signer.publicKey) @@ -405,17 +404,22 @@ export function newSmartAccount8130( const salt = parameters.salt ?? randomBytes32() - // Default to the upgradeable account (ERC-1967 proxy); opt into the immutable - // DefaultHighRateAccount (ERC-1167 proxy) via `upgradeable: false`. - const code = - parameters.code ?? - (upgradeable - ? upgradeableProxyBytecode( - implementation ?? canonicalEip8130Deployment.accounts.upgradeable, + // Canonical accounts use an ERC-1167 proxy to DefaultAccount. Upgradeability + // remains available only when the caller explicitly supplies an implementation. + let code = parameters.code + if (!code) { + if (upgradeable) { + if (!implementation) + throw new BaseError( + '`implementation` is required for `upgradeable: true`; the canonical deployment does not include the unaudited UpgradeableAccount example.', ) - : erc1167Bytecode( - implementation ?? canonicalEip8130Deployment.accounts.defaultHighRate, - )) + code = upgradeableProxyBytecode(implementation) + } else { + code = erc1167Bytecode( + implementation ?? canonicalEip8130Deployment.accounts.default, + ) + } + } const inner = to8130Account({ signer, diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts index ce4fdd93a8..beec92eaeb 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/experimental/eip8130/constants.ts @@ -175,7 +175,7 @@ export const canonicalAuthenticators = { /** WebAuthn / FIDO2 passkey. Canonical base/eip-8130 deployment. */ passkey: '0x871c72d3950308A028E9c4917591bcfd3D6a1EF7', /** Signature delegation (1-hop). Canonical base/eip-8130 deployment. */ - delegate: '0x1B0195ba5E3FCdB387DD619816eeF8b510Ed0855', + delegate: '0xbb73E3871FBaC8aef1a7Ee8A24E21139916f14C2', } as const satisfies Record /** @@ -215,7 +215,7 @@ export const txContextAddress = * parameter of {@link computeAddress8130}. */ export const accountConfigAddress = - '0xe7Bb8eF3728ea9f0A8be6D7e9585FeAb12dE086A' satisfies Hex + '0x53648Cf00356fbAA1F2B531715c6B64AaBDE1555' satisfies Hex /** * Default wallet implementation for EOA auto-delegation @@ -226,7 +226,7 @@ export const accountConfigAddress = * {@link accountConfigAddress}. */ export const defaultAccountAddress = - '0xDd802113C9FF6964cD2A61A16e075D5271cC82c9' satisfies Hex + '0x58da469ef71Dd4B092B010CdA37DE124C926EebD' satisfies Hex /** Size of the deployment header in bytes (`DEPLOYMENT_HEADER_SIZE`). */ export const deploymentHeaderSize = 14 diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index 7e23d2b6da..56b43a2353 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -15,12 +15,12 @@ export type Eip8130Deployment = { /** Deployed wallet implementation contracts (the singletons account proxies delegate to). */ accounts: { /** - * UpgradeableAccount implementation — the default for smart accounts. + * Optional unaudited UpgradeableAccount example implementation. * Accounts are deployed behind an ERC-1967 `UpgradeableProxy` (see * {@link upgradeableProxyBytecode}) so they can be upgraded via * `upgradeBySignature`. */ - upgradeable: Address + upgradeable?: Address | undefined /** * DefaultAccount implementation — the bare account, deployed standalone as * the direct EIP-7702 delegation target for EOAs (no proxy). This is the @@ -28,20 +28,18 @@ export type Eip8130Deployment = { */ default: Address /** - * DefaultHighRateAccount implementation — the immutable smart-account - * variant. Deployed behind a 45-byte ERC-1167 proxy (see - * {@link erc1167Bytecode}). + * CanonicalHighRatePayerAccount implementation. Deployed behind a 45-byte + * ERC-1167 proxy (see {@link erc1167Bytecode}). */ defaultHighRate: Address /** - * BackwardsCompatible4337Account — the ERC-4337 portable implementation + * Optional unaudited BackwardsCompatible4337Account example implementation * (`DefaultAccount` + `validateUserOp`). Lets an account run on non-native * chains via a bundler + EntryPoint at the same address; the EntryPoint is * registered as a trusted-executor actor (see {@link key.trustedExecutor}). - * Deployed as a fourth singleton by base's canonical `Deploy.s.sol` - * (base/eip-8130#27) at the canonical CREATE2 address below. + * This is not deployed by base's canonical `Deploy.s.sol`. */ - erc4337: Address + erc4337?: Address | undefined } /** Deployed authenticator contracts (for EVM execution on non-native chains). */ authenticators: { @@ -82,11 +80,36 @@ export type Eip8130Deployment = { * bytecode change), all addresses must be re-derived and this object updated. */ export const canonicalEip8130Deployment = { + accountConfiguration: '0x53648Cf00356fbAA1F2B531715c6B64AaBDE1555', + accounts: { + // `upgradeable` / `erc4337` are unaudited example wallets and are not part + // of the canonical deployment. Callers must provide those implementations + // explicitly if they choose an example-specific path. + default: '0x58da469ef71Dd4B092B010CdA37DE124C926EebD', + defaultHighRate: '0x23Fe6949d6370330Ae32e7c17E1265D65955C92a', + }, + authenticators: { + k1: '0x0000000000000000000000000000000000000001', + p256: '0xf8847a74F8067CabaE5fe56B70b372A7D670f0f8', + webAuthn: '0x871c72d3950308A028E9c4917591bcfd3D6a1EF7', + delegate: '0xbb73E3871FBaC8aef1a7Ee8A24E21139916f14C2', + alwaysValid: '0xA550545Da91720c23483c5B3493412A02D1cF9F9', + }, + policies: { + manager: '0x6e9E627770C1c90371A2E4CB9474A7Af577a4306', + sessionPolicy: '0x58ef2d572a1bC528f0B9121d686B2618809604Dc', + }, +} as const satisfies Eip8130Deployment + +/** + * Current EIP-8130 deployment on Base Sepolia (chain id `84532`). + * + * Base Sepolia has not yet migrated to the latest canonical AccountConfiguration + * deployment, so keep its live addresses separate until that network upgrades. + */ +export const baseSepoliaDeployment = { accountConfiguration: '0xe7Bb8eF3728ea9f0A8be6D7e9585FeAb12dE086A', accounts: { - // `upgradeable` / `erc4337` are unaudited example wallets (base/eip-8130-examples), - // not part of base/eip-8130's canonical `Deploy.s.sol` set. They cascade off - // `accountConfiguration`; re-pin once the examples repo publishes a broadcast. upgradeable: '0xF8dafa4DA35F664cf2CF842f00482ebb68a982b3', default: '0xDd802113C9FF6964cD2A61A16e075D5271cC82c9', defaultHighRate: '0xe5edfB7E7365893d685c2FbFBAC3e022f51d942F', @@ -105,31 +128,16 @@ export const canonicalEip8130Deployment = { }, } as const satisfies Eip8130Deployment -/** EIP-8130 deployment on Base Sepolia (chain id `84532`). */ -export const baseSepoliaDeployment = canonicalEip8130Deployment - /** * EIP-8130 deployment for the Base "vibenet" devnet (chain id `84538453`). * * The devnet runs EIP-8130 **natively**: the execution client enshrines the - * canonical `accountConfiguration` (and the account/authenticator CREATE2 - * addresses), so those stay identical to Base Sepolia — using any other - * `accountConfiguration` derives a different account address and create - * transactions fail with "create address mismatch". - * - * The example `policies` are the exception: they are ordinary (non-enshrined) - * contracts, redeployed on vibenet at base/eip-8130 **#43** ("Pass PolicyBinding - * at execute; drop config storage"). They are bound to the enshrined - * AccountConfiguration above and expose the #43 PolicyManager/SessionPolicy ABI - * (no `install`; `execute(binding, executionData)`). Base Sepolia still points at - * the earlier (#41) policy addresses. + * canonical `accountConfiguration`. Using any other value derives a different + * account address and create transactions fail with "create address mismatch". + * The example policy contracts use the #43 binding-at-execute ABI. */ export const vibenetDevnetDeployment = { ...canonicalEip8130Deployment, - policies: { - manager: '0x5cF2a01d34d1B244C63D9F2215E53F9aac06de60', - sessionPolicy: '0x865D22bA9B452E38c7c4c83a619D3C25e5AC3F18', - }, } as const satisfies Eip8130Deployment /** Known EIP-8130 deployments, keyed by chain id. */ diff --git a/src/experimental/eip8130/devx.test.ts b/src/experimental/eip8130/devx.test.ts index 73008c1fbe..a8ce78ae04 100644 --- a/src/experimental/eip8130/devx.test.ts +++ b/src/experimental/eip8130/devx.test.ts @@ -7,6 +7,7 @@ import type { Hex } from '../../types/misc.js' import { keccak256 } from '../../utils/hash/keccak256.js' import { delegateAuthSize, + newSmartAccount8130, to8130Account, toDelegate8130Signer, } from './accounts/to8130Account.js' @@ -16,6 +17,7 @@ import { canonicalAuthenticators, ecrecoverAuthenticator, } from './constants.js' +import { canonicalEip8130Deployment } from './deployments.js' import { authorizeActor, encodePolicyData, @@ -39,6 +41,26 @@ const pubkey = { y: '0x2222222222222222222222222222222222222222222222222222222222222222', } as const +describe('canonical smart-account deployment', () => { + test('defaults to an ERC-1167 proxy to DefaultAccount', () => { + const account = newSmartAccount8130({ signer: owner, salt: userSalt }) + + expect(account.createChange.code).toBe( + erc1167Bytecode(canonicalEip8130Deployment.accounts.default), + ) + }) + + test('requires an explicit implementation for upgradeable accounts', () => { + expect(() => + newSmartAccount8130({ + signer: owner, + salt: userSalt, + upgradeable: true, + }), + ).toThrow('`implementation` is required for `upgradeable: true`') + }) +}) + describe('key builders + actorId derivation', () => { test('k1 actor', () => { expect(key.k1(owner.address)).toEqual({ From e31b1f737f5901a5e5927f8fa2a6f19885841151 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Mon, 20 Jul 2026 13:35:45 -0400 Subject: [PATCH 55/96] fix(eip8130): drop duplicate NonceScopeError export after merge --- src/experimental/eip8130/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index c16a198b9b..12b8c426a3 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -142,7 +142,6 @@ export { getEip8130Deployment, vibenetDevnetDeployment, } from './deployments.js' -export { type NonceScopeErrorType, NonceScopeError } from './errors.js' export { type ActorNotBoundErrorType, ActorNotBoundError, From ca4c2910079b7f05b8d18de6131063d08ef8371d Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Mon, 20 Jul 2026 19:27:35 -0400 Subject: [PATCH 56/96] feat(eip8130): fail wait on expired transactions Detect when a signed expiry has passed while waiting for inclusion and throw TransactionExpiredError. Thread the resolved expiry via onTransaction on sendCalls8130 and sendSponsoredCalls so callers do not rely on pending eth_getTransactionByHash. --- site/pages/experimental/eip8130/receipts.mdx | 32 ++++++++++ src/experimental/eip8130/actions/sendCalls.ts | 21 +++++- .../actions/waitForTransactionReceipt8130.ts | 64 ++++++++++++++++++- src/experimental/eip8130/errors.ts | 36 +++++++++++ src/experimental/eip8130/index.ts | 2 + .../eip8168/actions/sendSponsoredCalls.ts | 24 +++++++ 6 files changed, 177 insertions(+), 2 deletions(-) diff --git a/site/pages/experimental/eip8130/receipts.mdx b/site/pages/experimental/eip8130/receipts.mdx index 27762d3e2a..8b9a8f353e 100644 --- a/site/pages/experimental/eip8130/receipts.mdx +++ b/site/pages/experimental/eip8130/receipts.mdx @@ -21,6 +21,38 @@ receipt.eip8130.metadata // opaque bytes echoed from the signed tx It polls `eth_getTransactionReceipt` until the transaction is mined (default every `500ms`, timing out after `60_000ms` — both configurable). Unlike the generic `waitForTransactionReceipt`, it skips same-nonce replacement detection, since EIP-8130 uses 2D channel nonces. +### Expiring transactions + +Any transaction with a non-zero `expiry` (sequenced or nonce-free) can no longer land once the chain's latest block timestamp passes it. Pass the `expiry` you signed with and the wait rejects with a `TransactionExpiredError` the moment it lapses, instead of silently spinning until the timeout. + +Thread the resolved `expiry` out of `sendCalls8130` with `onTransaction` — this is reliable because it's the exact value the tx was signed with (including auto-computed nonce-free expiries), and it doesn't depend on the node returning a pending transaction: + +```ts +import { + TransactionExpiredError, + sendCalls8130, + waitForTransactionReceipt8130, +} from 'viem/experimental/eip8130' + +let expiry: bigint | undefined +const hash = await sendCalls8130(client, { + account, + calls, + gas, + onTransaction: (tx) => { expiry = tx.expiry }, +}) + +try { + const receipt = await waitForTransactionReceipt8130(client, { hash, expiry }) +} catch (e) { + if (e instanceof TransactionExpiredError) { + // tx can never land — resubmit with a fresh `expiry` + } +} +``` + +If you omit `expiry`, the wait still tries to read it off the pending transaction, but that is best-effort only — a node is not obligated to serve a pending `eth_getTransactionByHash`. + ## Fetch without waiting `getTransactionReceipt8130` returns `null` if the receipt is not yet available: diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/experimental/eip8130/actions/sendCalls.ts index 97410a116c..580cdfe0c6 100644 --- a/src/experimental/eip8130/actions/sendCalls.ts +++ b/src/experimental/eip8130/actions/sendCalls.ts @@ -197,6 +197,23 @@ export type SendCalls8130Parameters = FeeOverrides & { * executor. See {@link encodeWalletCalls}. */ encodeExecute?: EncodeExecute | undefined + /** + * Invoked with the fully-resolved transaction just before it is signed and + * sent. Use it to thread the resolved `expiry` (which may be auto-computed for + * nonce-free sends) into `waitForTransactionReceipt8130` without re-preparing: + * + * ```ts + * let expiry: bigint | undefined + * const hash = await sendCalls8130(client, { + * ...params, + * onTransaction: (tx) => { expiry = tx.expiry }, + * }) + * await waitForTransactionReceipt8130(client, { hash, expiry }) + * ``` + */ + onTransaction?: + | ((transaction: TransactionSerializable8130) => void) + | undefined } function toPhases(calls: SendCalls8130Parameters['calls']): AaCalls { @@ -222,7 +239,8 @@ export async function sendCalls8130( client: Client, parameters: SendCalls8130Parameters, ): Promise { - const { account, calls, payer, encodeExecute, ...rest } = parameters + const { account, calls, payer, encodeExecute, onTransaction, ...rest } = + parameters const transaction = await prepareTransaction8130(client, { ...rest, account, @@ -233,6 +251,7 @@ export async function sendCalls8130( }), payer, }) + onTransaction?.(transaction) const serializedTransaction = await account.signTransaction(transaction, { payer, }) diff --git a/src/experimental/eip8130/actions/waitForTransactionReceipt8130.ts b/src/experimental/eip8130/actions/waitForTransactionReceipt8130.ts index 41921a4d14..8e196ad553 100644 --- a/src/experimental/eip8130/actions/waitForTransactionReceipt8130.ts +++ b/src/experimental/eip8130/actions/waitForTransactionReceipt8130.ts @@ -2,7 +2,9 @@ import type { Client } from '../../../clients/createClient.js' import type { Transport } from '../../../clients/transports/createTransport.js' import type { Account } from '../../../types/account.js' import type { Chain } from '../../../types/chain.js' -import type { Hash } from '../../../types/misc.js' +import type { Hash, Hex } from '../../../types/misc.js' +import { TransactionExpiredError } from '../errors.js' +import { getTransaction8130 } from './getTransaction8130.js' import { type GetTransactionReceipt8130ReturnType, getTransactionReceipt8130, @@ -11,6 +13,19 @@ import { export type WaitForTransactionReceipt8130Parameters = { /** Transaction hash to wait for. */ hash: Hash + /** + * `expiry` (unix seconds) of the transaction being awaited. When provided, + * the wait fails fast with a {@link TransactionExpiredError} once the chain's + * latest block timestamp passes it. This is the reliable path: it is the + * value the transaction was signed with (thread it out of `sendCalls8130` via + * `onTransaction`, or read it off `prepareTransaction8130`'s result). + * + * If omitted, the wait *opportunistically* tries to read the expiry off the + * still-pending transaction — but nodes are not obligated to return a pending + * transaction from `eth_getTransactionByHash`, so pass `expiry` when you need + * a guarantee. Applies to any expiring transaction (sequenced or nonce-free). + */ + expiry?: bigint | number | undefined /** * How often to poll for the receipt (ms). * @default 500 @@ -34,6 +49,13 @@ export type WaitForTransactionReceipt8130ReturnType = NonNullable 0n + let expiry = suppliedExpiry ? BigInt(parameters.expiry!) : undefined + while (Date.now() < deadline) { const receipt = await getTransactionReceipt8130(client, { hash }) if (receipt !== null) return receipt + + // Opportunistic fallback only: if the caller didn't supply `expiry`, try to + // read it off the still-pending tx. This is best-effort — a node is not + // obligated to serve a pending `eth_getTransactionByHash`, so it may never + // resolve. Applies to any expiring tx (sequenced or nonce-free). + if (expiry === undefined) { + try { + const tx = await getTransaction8130(client, { hash }) + if (tx.expiry > 0) expiry = BigInt(tx.expiry) + } catch {} + } + + if (expiry !== undefined) { + const blockTimestamp = await getLatestBlockTimestamp(client) + if (blockTimestamp !== undefined && blockTimestamp > expiry) + throw new TransactionExpiredError({ hash, expiry, blockTimestamp }) + } + await new Promise((resolve) => setTimeout(resolve, pollingInterval)) } @@ -66,3 +112,19 @@ export async function waitForTransactionReceipt8130< `waitForTransactionReceipt8130: timed out after ${timeout}ms waiting for ${hash}`, ) } + +async function getLatestBlockTimestamp( + client: Client, +): Promise { + try { + const block = await ( + client.request as (args: { + method: 'eth_getBlockByNumber' + params: ['latest', false] + }) => Promise<{ timestamp: Hex } | null> + )({ method: 'eth_getBlockByNumber', params: ['latest', false] }) + return block?.timestamp ? BigInt(block.timestamp) : undefined + } catch { + return undefined + } +} diff --git a/src/experimental/eip8130/errors.ts b/src/experimental/eip8130/errors.ts index b71519fcaa..281d4e4fda 100644 --- a/src/experimental/eip8130/errors.ts +++ b/src/experimental/eip8130/errors.ts @@ -62,6 +62,42 @@ export class ScopeMismatchError extends BaseError { } } +export type TransactionExpiredErrorType = TransactionExpiredError & { + name: 'TransactionExpiredError' +} + +/** + * Thrown while waiting on a nonce-free (expiring) EIP-8130 transaction when the + * chain's latest block timestamp passes the transaction's `expiry` before a + * receipt is observed. The transaction can no longer be included — it has been + * (or will be) dropped from the mempool — so waiting further is pointless. + * + * Distinct from a plain wait timeout: this is a definitive terminal state, not + * "we gave up early". Callers can catch this to resubmit with a fresh `expiry`. + */ +export class TransactionExpiredError extends BaseError { + override name = 'TransactionExpiredError' + constructor({ + hash, + expiry, + blockTimestamp, + }: { + hash: `0x${string}` + expiry: bigint + blockTimestamp: bigint + }) { + super( + `Transaction \`${hash}\` expired before landing: \`expiry\` ${expiry} passed (latest block timestamp ${blockTimestamp}).`, + { + metaMessages: [ + 'Nonce-free (expiring) transactions are only valid until their `expiry`; once the block timestamp passes it the tx is dropped and can never be mined.', + 'Resubmit with a fresh `expiry` (e.g. `nonce.nonceless({ expiresIn })`).', + ], + }, + ) + } +} + export type ActorNotBoundErrorType = ActorNotBoundError & { name: 'ActorNotBoundError' } diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 12b8c426a3..4c8b3d0ce4 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -149,6 +149,8 @@ export { NonceScopeError, type ScopeMismatchErrorType, ScopeMismatchError, + type TransactionExpiredErrorType, + TransactionExpiredError, } from './errors.js' export { type AuthorizeActorOptions, diff --git a/src/experimental/eip8168/actions/sendSponsoredCalls.ts b/src/experimental/eip8168/actions/sendSponsoredCalls.ts index 3bf7041008..49d8e268e0 100644 --- a/src/experimental/eip8168/actions/sendSponsoredCalls.ts +++ b/src/experimental/eip8168/actions/sendSponsoredCalls.ts @@ -14,6 +14,7 @@ import type { AaAccountChange, AaCall, AaCalls, + TransactionSerializable8130, } from '../../eip8130/types/transaction.js' import type { PayerClient } from '../client.js' import type { @@ -115,6 +116,27 @@ export type SendSponsoredCallsParameters = { * before the second signature" the default — there is no auto-resign path. */ confirmRetry?: (request: ResignRequest) => boolean | Promise + /** + * Invoked with the fully-resolved transaction just before each submit attempt + * (before `payer_sendTransaction` / `payer_signTransaction`). Use it to thread + * the resolved `expiry` — which is auto-computed here from the offer's + * `maxExpiry` (or the nonce-free window) when not overridden — into + * `waitForTransactionReceipt8130` without re-deriving it: + * + * ```ts + * let expiry: bigint | undefined + * await sendSponsoredCalls(client, { + * ...params, + * onTransaction: (tx) => { expiry = tx.expiry }, + * }) + * ``` + * + * On a re-signed retry it fires again with the corrected transaction, so the + * final invocation reflects the transaction that was actually submitted. + */ + onTransaction?: + | ((transaction: TransactionSerializable8130) => void) + | undefined } export type SendSponsoredCallsReturnType = @@ -153,6 +175,7 @@ export async function sendSponsoredCalls( context, retries = 2, confirmRetry, + onTransaction, } = parameters const chainId = client.chain?.id @@ -253,6 +276,7 @@ export async function sendSponsoredCalls( transaction.expiry = computeExpiry() transaction.payerAuth = '0x' + onTransaction?.(transaction) const signedTransaction = await account.signTransaction(transaction) try { From 467aeeee86a99efbfb3db8d9ca2b6408f8bd9898 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 21 Jul 2026 14:28:55 -0400 Subject: [PATCH 57/96] fix(eip8130): admin actors may use ordered nonces, not just nonce-free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node rule is: an actor must use nonce-free (expiring) mode only if it is non-admin AND lacks SCOPE_NONCE. Admin actors (scope 0x00) and SCOPE_NONCE actors may use ordered (sequenced) OR nonce-free nonces. canUseSequencedNonce / isNoncelessOnly wrongly treated admin (scope 0) as nonceless-only because bit 0x04 was unset, forcing admin owners into nonce-free/expiring mode (and throwing NonceScopeError on a sequenced nonceKey). Fix the predicate so admin is allowed ordered nonces; sends now default to ordered (expiry-free) for admin owners. Also auto-default the expiry for any nonce-free send (including admin/SCOPE_NONCE actors opting in) rather than throwing. Update NonceScopeError text and doc comments, and fix the nonce integration test mock to answer eth_call (actor-not-bound → declared scope fallback). --- .../eip8130/accounts/to8130Account.ts | 14 +++++------ src/experimental/eip8130/actions/sendCalls.ts | 16 ++++--------- src/experimental/eip8130/errors.ts | 12 +++++----- src/experimental/eip8130/keys.ts | 24 ++++++++++++------- src/experimental/eip8130/nonce.test.ts | 3 +++ src/experimental/eip8130/nonce.ts | 14 +++++------ 6 files changed, 43 insertions(+), 40 deletions(-) diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/to8130Account.ts index 41222a202d..a7f9ad8bd6 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/to8130Account.ts @@ -429,8 +429,9 @@ export function newSmartAccount8130( authenticator: signer.authenticator, accountConfigAddress, // The primary (controlling) actor is registered without a scope, i.e. as an - // admin actor. Admin actors lack `SCOPE_NONCE`, so this account is - // nonce-free-only — surface that so nonce mode is selected automatically. + // admin actor (`scopeUnrestricted`). Admin actors may use ordered *or* + // nonce-free nonces, so sends default to ordered (sequenced) mode — surface + // the scope so nonce mode is selected automatically. scope: primaryActor.scope ?? scopeUnrestricted, }) @@ -444,9 +445,8 @@ export function newSmartAccount8130( export type ToEoa8130AccountParameters = { /** * Scope of the EOA's implicit self-actor. Defaults to admin - * ({@link scopeUnrestricted}), which lacks `SCOPE_NONCE` and is therefore - * nonce-free-only. Override only if the self-actor was reconfigured with the - * `SCOPE_NONCE` bit and you want sequenced nonces. + * ({@link scopeUnrestricted}), which may use ordered *or* nonce-free nonces + * (sends default to ordered). Override for a restricted self-actor. */ scope?: number | undefined } @@ -457,8 +457,8 @@ export type ToEoa8130AccountReturnType = { readonly signer: Signer /** * Scope of the implicit self-actor (admin by default). Drives automatic - * nonce-mode selection: admin lacks `SCOPE_NONCE`, so sends default to - * nonce-free (expiring) mode. See {@link prepareTransaction8130}. + * nonce-mode selection: admin may use ordered *or* nonce-free, so sends + * default to ordered (sequenced) mode. See {@link prepareTransaction8130}. */ readonly scope?: number | undefined /** diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/experimental/eip8130/actions/sendCalls.ts index 580cdfe0c6..40430d6e5f 100644 --- a/src/experimental/eip8130/actions/sendCalls.ts +++ b/src/experimental/eip8130/actions/sendCalls.ts @@ -134,17 +134,11 @@ export async function prepareTransaction8130( if (nonceKey === nonceKeyMax) { // Nonce-free (expiring) mode: there is no per-channel counter to read; // replay protection relies on `expiry`. Pin the sequence to `0n`. - if (!expiry || expiry === 0n) { - if (noncelessOnly) - // Auto-selected nonce-free mode: default the expiry to the mempool - // admission window rather than forcing the caller to supply one. - expiry = - BigInt(Math.floor(Date.now() / 1000)) + nonceFreeMaxExpiryWindow - else - throw new BaseError( - '`expiry` is required for nonce-free transactions (`nonceKey` = `NONCE_KEY_MAX`). Build the nonce with `nonce.nonceless({ expiresIn })`.', - ) - } + // Default the expiry to the mempool admission window when the caller did + // not supply one — whether nonce-free was auto-selected (restricted actor) + // or explicitly chosen (admin / `SCOPE_NONCE` actor opting in). + if (!expiry || expiry === 0n) + expiry = BigInt(Math.floor(Date.now() / 1000)) + nonceFreeMaxExpiryWindow nonceSequence ??= 0n } else if (nonceSequence === undefined) { // Read the next sequence via `eth_getTransactionCount` (with the 2D diff --git a/src/experimental/eip8130/errors.ts b/src/experimental/eip8130/errors.ts index 281d4e4fda..17ae53c79b 100644 --- a/src/experimental/eip8130/errors.ts +++ b/src/experimental/eip8130/errors.ts @@ -4,9 +4,9 @@ export type NonceScopeErrorType = NonceScopeError & { name: 'NonceScopeError' } /** * Thrown when a sequenced (counter-backed) nonce key is requested for a signing - * actor that lacks `SCOPE_NONCE` — admin actors (`scope == 0`) and any actor - * authorized without the nonce bit are restricted to nonce-free (expiring) - * transactions. + * actor that may not use one: a *restricted* (non-admin) actor authorized + * **without** `SCOPE_NONCE`. Admin actors (`scope == 0`) and `SCOPE_NONCE` + * actors are unaffected — they may use ordered *or* nonce-free nonces. */ export class NonceScopeError extends BaseError { override name = 'NonceScopeError' @@ -18,13 +18,13 @@ export class NonceScopeError extends BaseError { nonceKey?: bigint | undefined }) { super( - `Signing actor scope \`0x${scope.toString(16)}\` lacks \`SCOPE_NONCE\`, so it may only send nonce-free (expiring) transactions${ + `Restricted signing actor scope \`0x${scope.toString(16)}\` lacks \`SCOPE_NONCE\`, so it may only send nonce-free (expiring) transactions${ nonceKey !== undefined ? ` — received \`nonceKey\` ${nonceKey}.` : '.' }`, { metaMessages: [ - 'Admin actors (scope 0) and any actor without the `SCOPE_NONCE` bit are restricted to nonce-free mode.', - 'Omit `nonceKey` to let the library select nonce-free mode automatically, or pass `...nonce.nonceless({ expiresIn })`.', + 'Only a restricted (non-admin) actor without the `SCOPE_NONCE` bit is confined to nonce-free mode; admin (scope 0) and `SCOPE_NONCE` actors may use ordered nonces too.', + 'Omit `nonceKey` to let the library select the default nonce mode automatically, or pass `...nonce.nonceless({ expiresIn })` for nonce-free.', ], }, ) diff --git a/src/experimental/eip8130/keys.ts b/src/experimental/eip8130/keys.ts index a23fcf184a..f1a77ba5a5 100644 --- a/src/experimental/eip8130/keys.ts +++ b/src/experimental/eip8130/keys.ts @@ -8,6 +8,7 @@ import { actorScope, canonicalAuthenticators, ecrecoverAuthenticator, + scopeUnrestricted, trustedExecutorAuthenticator, } from './constants.js' import type { @@ -92,23 +93,28 @@ export function toScope(...flags: number[]): number { /** * Whether an actor with `scope` may use sequenced (counter-backed) nonce keys — - * i.e. standard sequential ordering or a 2D nonce channel. Requires the - * `SCOPE_NONCE` bit. + * i.e. standard sequential ordering or a 2D nonce channel. * - * Admin actors (`scope == 0`) do **not** have `SCOPE_NONCE` set, so — like any - * restricted actor lacking the bit — they are restricted to nonce-free - * (expiring) transactions. Use {@link isNoncelessOnly} for the inverse. + * Mirrors the node rule: an actor may use ordered (sequenced) nonces if it is + * **admin** (`scope == scopeUnrestricted`, `0x00`) **or** it holds the + * `SCOPE_NONCE` bit. Both admin and `SCOPE_NONCE` actors may additionally use + * nonce-free (expiring) mode. Only a *restricted* actor **without** + * `SCOPE_NONCE` is confined to nonce-free. Use {@link isNoncelessOnly} for the + * inverse. */ export function canUseSequencedNonce(scope: number | undefined): boolean { - return ((scope ?? 0) & actorScope.nonce) !== 0 + const s = scope ?? scopeUnrestricted + return s === scopeUnrestricted || (s & actorScope.nonce) !== 0 } /** * Whether an actor with `scope` is restricted to nonce-free (expiring) - * transactions (`nonceKey = NONCE_KEY_MAX`) because it lacks `SCOPE_NONCE`. + * transactions (`nonceKey = NONCE_KEY_MAX`). * - * True for admin actors (`scope == 0`) and any actor authorized without the - * `SCOPE_NONCE` bit. Inverse of {@link canUseSequencedNonce}. + * True **only** for a restricted actor (non-admin) authorized **without** the + * `SCOPE_NONCE` bit. Admin actors (`scope == 0`) and `SCOPE_NONCE` actors are + * **not** restricted — they may use ordered *or* nonce-free. Inverse of + * {@link canUseSequencedNonce}. */ export function isNoncelessOnly(scope: number | undefined): boolean { return !canUseSequencedNonce(scope) diff --git a/src/experimental/eip8130/nonce.test.ts b/src/experimental/eip8130/nonce.test.ts index 4405770b52..1da83b3ad7 100644 --- a/src/experimental/eip8130/nonce.test.ts +++ b/src/experimental/eip8130/nonce.test.ts @@ -89,6 +89,9 @@ describe('sendCalls8130 nonce integration', () => { async request({ method, params }: { method: string; params: any }) { methods.push(method) if (method === 'eth_chainId') return '0x1' + // Actor not yet bound on-chain → resolveSigningScope falls back to the + // declared handle scope (offline nonce-mode selection). + if (method === 'eth_call') return `0x${'0'.repeat(64)}` if (method === 'eth_getTransactionCount') { lastGetCountParams = params return '0x3' diff --git a/src/experimental/eip8130/nonce.ts b/src/experimental/eip8130/nonce.ts index 811b3c0509..054d51b245 100644 --- a/src/experimental/eip8130/nonce.ts +++ b/src/experimental/eip8130/nonce.ts @@ -121,14 +121,14 @@ export const nonce = { }, /** - * Selects the correct nonce strategy for an actor's `scope`. An actor may use - * a sequenced nonce key only if it holds `SCOPE_NONCE`; admin actors - * (`scope == 0`) and any actor authorized without that bit are restricted to - * nonce-free (expiring) mode. + * Selects the default nonce strategy for an actor's `scope`, mirroring the + * node rule. Admin actors (`scope == 0`) and actors holding `SCOPE_NONCE` may + * use ordered *or* nonce-free nonces; only a restricted actor **without** + * `SCOPE_NONCE` is confined to nonce-free. * - * - Actor **has** `SCOPE_NONCE` → sequenced {@link nonce.channel} (default - * channel `0`, i.e. {@link nonce.sequential}). - * - Actor **lacks** `SCOPE_NONCE` (incl. admin) → {@link nonce.nonceless}, + * - Admin (`scope == 0`) **or** `SCOPE_NONCE` set → sequenced + * {@link nonce.channel} (default channel `0`, i.e. {@link nonce.sequential}). + * - Restricted actor **without** `SCOPE_NONCE` → {@link nonce.nonceless}, * defaulting the expiry to `NONCE_FREE_MAX_EXPIRY_WINDOW` seconds from now. * * @param scope - The signing actor's scope bitmask. From 9e83a178fce60efd6fc387bd89ce916c0d10c00b Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Mon, 10 Aug 2026 09:32:52 -0400 Subject: [PATCH 58/96] feat(eip8130): parity with finalized Keystore contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the experimental EIP-8130 module to parity with the finalized Keystore.sol contracts: - Update canonical CREATE2 addresses to the per-contract vanity-mined 0x8130… deployments (Keystore, account impls, authenticators, policies). - Switch counterfactual address derivation to the leaves-then-list `_computeActorsCommitment` scheme with `scope` widened to uint16. - Replace the transaction `expiry` field with `validAfter`/`validBefore` (unix milliseconds) across serialize/parse/hash/assert and consumers. - Adopt the SignedAccountChanges / AccountChange(changeType, payload) model with the full ChangeType enum (authorize/revoke/incrementLocalEpoch /lock/unlock), ABI-encoded payloads, channel+sequence, and the reworked digest and applySignedAccountChanges calldata. - Rebuild lock.ts as lockChange()/unlockChange() batch ops. - Drop redundant `8130` suffixes from module filenames. - Refresh colocated unit tests, golden vectors, docs, and scripts. --- scripts/eip8130/README.md | 15 +- scripts/eip8130/authorizeSessionKey.test.ts | 12 +- scripts/eip8130/baseSepolia4337E2E.test.ts | 253 +++++++++++ ...ction.test.ts => buildTransaction.test.ts} | 12 +- .../eip8130/bundlerCreateAndExecute.test.ts | 4 +- scripts/eip8130/bundlerProbeDeployed.test.ts | 4 +- scripts/eip8130/policySmoke.test.ts | 418 ++++++++++++++++++ scripts/eip8130/selfBundleCreate.test.ts | 4 +- scripts/eip8130/selfBundleRotateP256.test.ts | 8 +- ...30Account.test.ts => setupAccount.test.ts} | 4 +- scripts/eip8130/vibenet6PartTest.test.ts | 55 +-- scripts/smoke-estimate-sender-actor.mjs | 10 +- site/pages/experimental/eip8130.mdx | 6 +- .../eip8130/calls-and-batching.mdx | 16 +- .../eip8130/creating-an-account.mdx | 32 +- site/pages/experimental/eip8130/metadata.mdx | 10 +- .../experimental/eip8130/payer-services.mdx | 4 +- site/pages/experimental/eip8130/receipts.mdx | 26 +- .../experimental/eip8130/rotating-owners.mdx | 16 +- .../eip8130/sending-a-transaction.mdx | 40 +- .../experimental/eip8130/session-keys.mdx | 12 +- .../eip8130/sponsoring-transactions.mdx | 20 +- .../experimental/eip8130/sub-accounts.mdx | 22 +- src/actions/public/getTransaction.ts | 2 +- src/experimental/eip8130/abis.ts | 18 +- .../{to8130Account.ts => toAccount.ts} | 143 +++--- ...unt8130.test.ts => toSmartAccount.test.ts} | 22 +- ...oSmartAccount8130.ts => toSmartAccount.ts} | 26 +- ...ateGas8130.test.ts => estimateGas.test.ts} | 39 +- .../{estimateGas8130.ts => estimateGas.ts} | 75 ++-- ...etActorConfig8130.ts => getActorConfig.ts} | 16 +- ...igSequence8130.ts => getConfigSequence.ts} | 37 +- ...{getLockStatus8130.ts => getLockStatus.ts} | 14 +- .../{getPolicy8130.ts => getPolicy.ts} | 18 +- ...SessionSpend8130.ts => getSessionSpend.ts} | 16 +- ...etTransaction8130.ts => getTransaction.ts} | 34 +- ...ionCount8130.ts => getTransactionCount.ts} | 16 +- ...eceipt8130.ts => getTransactionReceipt.ts} | 22 +- .../actions/{isActor8130.ts => isActor.ts} | 16 +- .../actions/{isLocked8130.ts => isLocked.ts} | 16 +- src/experimental/eip8130/actions/sendCalls.ts | 83 ++-- .../actions/waitForTransactionReceipt.ts | 136 ++++++ .../actions/waitForTransactionReceipt8130.ts | 130 ------ src/experimental/eip8130/constants.ts | 62 +-- src/experimental/eip8130/deployments.ts | 52 +-- src/experimental/eip8130/devx.test.ts | 54 +-- src/experimental/eip8130/errors.ts | 17 +- src/experimental/eip8130/index.ts | 276 ++++++------ src/experimental/eip8130/keys.ts | 8 +- src/experimental/eip8130/lock.test.ts | 68 ++- src/experimental/eip8130/lock.ts | 202 ++------- src/experimental/eip8130/nonce.test.ts | 46 +- src/experimental/eip8130/nonce.ts | 70 +-- src/experimental/eip8130/policies.test.ts | 2 +- src/experimental/eip8130/queries.test.ts | 28 +- src/experimental/eip8130/types/transaction.ts | 121 +++-- .../eip8130/utils/accountConfigCalls.test.ts | 55 ++- .../eip8130/utils/accountConfigCalls.ts | 73 +-- .../eip8130/utils/actorChangeData.ts | 80 ++-- .../eip8130/utils/assertTransaction.ts | 23 +- .../eip8130/utils/computeAddress.test.ts | 47 +- .../eip8130/utils/computeAddress.ts | 32 +- .../eip8130/utils/encodeWalletCalls.test.ts | 4 +- .../eip8130/utils/hashActorChanges.ts | 74 ++-- .../eip8130/utils/hashTransaction.ts | 36 +- .../eip8130/utils/parseTransaction.ts | 60 ++- src/experimental/eip8130/utils/proxy.ts | 4 +- .../eip8130/utils/recoverSender.ts | 14 +- .../utils/serializeTransaction.test.ts | 89 ++-- .../eip8130/utils/serializeTransaction.ts | 42 +- .../eip8130/utils/signActorChanges.test.ts | 79 ++-- .../eip8130/utils/signActorChanges.ts | 66 +-- .../eip8130/utils/signTransaction.test.ts | 61 +-- .../eip8130/utils/signTransaction.ts | 36 +- .../utils/signedActorChangesSignature.test.ts | 32 +- .../utils/signedActorChangesSignature.ts | 24 +- .../eip8130/utils/signers.test.ts | 8 +- src/experimental/eip8130/utils/signers.ts | 12 +- .../eip8168/actions/sendSponsoredCalls.ts | 62 +-- src/experimental/eip8168/eip8168.test.ts | 10 +- 80 files changed, 2343 insertions(+), 1568 deletions(-) create mode 100644 scripts/eip8130/baseSepolia4337E2E.test.ts rename scripts/eip8130/{build8130Transaction.test.ts => buildTransaction.test.ts} (94%) create mode 100644 scripts/eip8130/policySmoke.test.ts rename scripts/eip8130/{setup8130Account.test.ts => setupAccount.test.ts} (95%) rename src/experimental/eip8130/accounts/{to8130Account.ts => toAccount.ts} (88%) rename src/experimental/eip8130/accounts/{toSmartAccount8130.test.ts => toSmartAccount.test.ts} (88%) rename src/experimental/eip8130/accounts/{toSmartAccount8130.ts => toSmartAccount.ts} (92%) rename src/experimental/eip8130/actions/{estimateGas8130.test.ts => estimateGas.test.ts} (84%) rename src/experimental/eip8130/actions/{estimateGas8130.ts => estimateGas.ts} (88%) rename src/experimental/eip8130/actions/{getActorConfig8130.ts => getActorConfig.ts} (87%) rename src/experimental/eip8130/actions/{getConfigSequence8130.ts => getConfigSequence.ts} (59%) rename src/experimental/eip8130/actions/{getLockStatus8130.ts => getLockStatus.ts} (85%) rename src/experimental/eip8130/actions/{getPolicy8130.ts => getPolicy.ts} (79%) rename src/experimental/eip8130/actions/{getSessionSpend8130.ts => getSessionSpend.ts} (91%) rename src/experimental/eip8130/actions/{getTransaction8130.ts => getTransaction.ts} (84%) rename src/experimental/eip8130/actions/{getTransactionCount8130.ts => getTransactionCount.ts} (87%) rename src/experimental/eip8130/actions/{getTransactionReceipt8130.ts => getTransactionReceipt.ts} (83%) rename src/experimental/eip8130/actions/{isActor8130.ts => isActor.ts} (82%) rename src/experimental/eip8130/actions/{isLocked8130.ts => isLocked.ts} (78%) create mode 100644 src/experimental/eip8130/actions/waitForTransactionReceipt.ts delete mode 100644 src/experimental/eip8130/actions/waitForTransactionReceipt8130.ts diff --git a/scripts/eip8130/README.md b/scripts/eip8130/README.md index fe0391f39f..8eaadc504a 100644 --- a/scripts/eip8130/README.md +++ b/scripts/eip8130/README.md @@ -9,29 +9,36 @@ live testnet. Keep them here until EIP-8130 graduates from experimental. ```bash # offline, no setup -npx vitest run --config test/vitest.eip8130.config.ts scripts/eip8130/build8130Transaction.test.ts +npx vitest run --config test/vitest.eip8130.config.ts scripts/eip8130/buildTransaction.test.ts # network scripts (skipped unless PRIVATE_KEY is set; funded Base Sepolia EOA) PRIVATE_KEY=0x... npx vitest run --config test/vitest.eip8130.config.ts scripts/eip8130/.test.ts + +# hosted vibenet policy smoke (sponsored; no PRIVATE_KEY required) +npx vitest run --config test/vitest.eip8130.config.ts scripts/eip8130/policySmoke.test.ts ``` All scripts are auto-included via the `scripts/eip8130/**/*.test.ts` glob in `test/vitest.eip8130.config.ts` — just drop a new `*.test.ts` here. Env: `PRIVATE_KEY` (required for network scripts), `BASE_SEPOLIA_RPC`, -`BUNDLER_URL`, `SALT_LABEL` (all optional, with defaults). +`BUNDLER_URL`, `SALT_LABEL`. `baseSepolia4337E2E` requires `BUNDLER_URL` with no +default so no bundler credential is committed; do not hardcode API keys in these +scripts. ## Scripts | Script | Network? | What it does | | --- | --- | --- | -| `build8130Transaction` | no | Build/sign/serialize/parse an 8130 tx; prints JSON, RLP envelope, and the 13-field wire layout. | -| `setup8130Account` | yes | Create an 8130 account on Base Sepolia. | +| `buildTransaction` | no | Build/sign/serialize/parse an 8130 tx; prints JSON, RLP envelope, and the 13-field wire layout. | +| `setupAccount` | yes | Create an 8130 account on Base Sepolia. | | `authorizeSessionKey` | yes | Authorize a session-key actor on an existing account. | | `selfBundleCreate` | yes | Deploy + execute in one self-bundled userOp via `EntryPoint.handleOps` (no staking). | | `selfBundleRotateP256` | yes | Create + validation-phase P-256 key rotation + execute in one userOp. | | `bundlerCreateAndExecute` | yes | Create + execute through a real ERC-4337 bundler (`BUNDLER_URL`). | | `bundlerProbeDeployed` | yes | Send a userOp to an already-deployed account (no factory phase). | +| `baseSepolia4337E2E` | yes | Full ERC-4337 e2e on Base Sepolia: create + execute, a follow-up userOp, then authorize/revoke an actor via `applySignedActorChanges`. Requires `PRIVATE_KEY` **and** `BUNDLER_URL`. | +| `policySmoke` | yes (hosted vibenet) | Sponsored session-key + policy smoke: create → authorize manager+session → `Counter.increment` via PolicyManager. No `PRIVATE_KEY` needed. | ## Notes diff --git a/scripts/eip8130/authorizeSessionKey.test.ts b/scripts/eip8130/authorizeSessionKey.test.ts index 5ebc6217af..a99582cbf7 100644 --- a/scripts/eip8130/authorizeSessionKey.test.ts +++ b/scripts/eip8130/authorizeSessionKey.test.ts @@ -12,9 +12,9 @@ import { actorScope } from '../../src/experimental/eip8130/constants.js' import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' import { encodeApplySignedActorChangesData } from '../../src/experimental/eip8130/utils/accountConfigCalls.js' -import { computeAddress8130 } from '../../src/experimental/eip8130/utils/computeAddress.js' +import { computeAddress } from '../../src/experimental/eip8130/utils/computeAddress.js' import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' -import { signActorChanges8130 } from '../../src/experimental/eip8130/utils/signActorChanges.js' +import { signActorChanges } from '../../src/experimental/eip8130/utils/signActorChanges.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' @@ -35,15 +35,15 @@ describe.runIf(PRIVATE_KEY)( const deployment = getEip8130Deployment(baseSepolia.id)! - // Re-derive the account deployed by setup8130Account.test.ts. + // Re-derive the account deployed by setupAccount.test.ts. const code = erc1167Bytecode(deployment.accounts.erc4337) const initialActors = [key.k1(owner.address)] const userSalt = keccak256(stringToHex(SALT_LABEL)) - const account = computeAddress8130({ userSalt, code, initialActors }) + const account = computeAddress({ userSalt, code, initialActors }) const deployed = await getCode(client, { address: account }) if (!deployed || deployed === '0x') - throw new Error('account not deployed; run setup8130Account first') + throw new Error('account not deployed; run setupAccount first') // A new P-256 session key (any 32-byte x/y; on-curve validity is only // checked by the authenticator at use-time, not at authorization). @@ -63,7 +63,7 @@ describe.runIf(PRIVATE_KEY)( }) const chainId = baseSepolia.id - const signed = await signActorChanges8130({ + const signed = await signActorChanges({ signer: owner, account, chainId, diff --git a/scripts/eip8130/baseSepolia4337E2E.test.ts b/scripts/eip8130/baseSepolia4337E2E.test.ts new file mode 100644 index 0000000000..355b7aa2c1 --- /dev/null +++ b/scripts/eip8130/baseSepolia4337E2E.test.ts @@ -0,0 +1,253 @@ +/** + * Base Sepolia ERC-4337 end-to-end for EIP-8130. + * + * Base Sepolia is NOT a native EIP-8130 chain, so accounts run through the + * portable ERC-4337 path: the `AccountConfiguration` contract is the factory, + * the `BackwardsCompatible4337Account` is the wallet implementation, and a real + * bundler + EntryPoint drive execution. This is the flow every non-vibenet chain + * uses until native 8130 ships. + * + * This single test proves the three things that must hold before merge: + * 1. CREATE — deploy-on-first-use: a counterfactual account is deployed inside + * its first userOp and executes a call in the same op. + * 2. USER OPS — a second userOp runs against the now-deployed account. + * 3. CHANGE ACTORS — authorize (then revoke) a new actor via a userOp that + * calls `AccountConfiguration.applySignedActorChanges`, signed by the owner + * over the live on-chain config sequence, verified by read-back. + * + * Run (requires a funded Base Sepolia EOA + an ERC-4337 bundler; skipped in CI): + * PRIVATE_KEY=0x... BUNDLER_URL=https://... \ + * npx vitest run --config test/vitest.eip8130.config.ts \ + * scripts/eip8130/baseSepolia4337E2E.test.ts + * + * Env: PRIVATE_KEY (required), BUNDLER_URL (required — no default, so no bundler + * credential is ever committed), BASE_SEPOLIA_RPC (optional). + */ + +import { describe, expect, test } from 'vitest' +import { createBundlerClient } from '../../src/account-abstraction/clients/createBundlerClient.js' +import { entryPoint07Address } from '../../src/account-abstraction/constants/address.js' +import { generatePrivateKey, privateKeyToAccount } from '../../src/accounts/index.js' +import { estimateFeesPerGas } from '../../src/actions/public/estimateFeesPerGas.js' +import { getBalance } from '../../src/actions/public/getBalance.js' +import { getCode } from '../../src/actions/public/getCode.js' +import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' +import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' +import { baseSepolia } from '../../src/chains/index.js' +import { createClient } from '../../src/clients/createClient.js' +import { http } from '../../src/clients/transports/http.js' +import { toSmartAccount } from '../../src/experimental/eip8130/accounts/toSmartAccount.js' +import { getConfigSequence } from '../../src/experimental/eip8130/actions/getConfigSequence.js' +import { isActor } from '../../src/experimental/eip8130/actions/isActor.js' +import { actorScope } from '../../src/experimental/eip8130/constants.js' +import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' +import { authorizeActor, key, revokeActor } from '../../src/experimental/eip8130/keys.js' +import { encodeApplySignedActorChangesData } from '../../src/experimental/eip8130/utils/accountConfigCalls.js' +import { signActorChanges } from '../../src/experimental/eip8130/utils/signActorChanges.js' +import { parseEther } from '../../src/utils/unit/parseEther.js' +import { keccak256 } from '../../src/utils/hash/keccak256.js' +import { stringToHex } from '../../src/utils/encoding/toHex.js' + +const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined +const BUNDLER_URL = process.env.BUNDLER_URL +const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +// Explicit gas limits so the bundler skips `eth_estimateUserOperationGas` (which +// validates signatures a counterfactual account can't satisfy with a stub). +const gasLimits = { + callGasLimit: 500_000n, + verificationGasLimit: 1_500_000n, + preVerificationGas: 500_000n, +} as const + +describe.runIf(PRIVATE_KEY && BUNDLER_URL)( + 'Base Sepolia ERC-4337 e2e: create → userOp → change actors', + () => { + test( + 'create + execute, a follow-up userOp, and authorize/revoke an actor', + async () => { + const owner = privateKeyToAccount(PRIVATE_KEY!) + const client = createClient({ + account: owner, + chain: baseSepolia, + transport: http(RPC_URL), + }) + const bundlerClient = createBundlerClient({ + client, + transport: http(BUNDLER_URL!), + }) + + const deployment = getEip8130Deployment(baseSepolia.id) + if (!deployment?.accounts.erc4337) + throw new Error( + 'Base Sepolia deployment is missing the erc4337 wallet implementation.', + ) + const accountConfiguration = deployment.accountConfiguration + + const userSalt = keccak256(stringToHex(`viem-8130-e2e-${Date.now()}`)) + // A 4337-compat account must register the EntryPoint as a trusted-executor + // actor so it may drive `executeBatch`; without it `validateUserOp` reverts + // (AA23). Actors must be sorted by `actorId`, strictly ascending. + const initialActors = [ + key.k1(owner.address), + key.trustedExecutor(entryPoint07Address), + ].sort((a, b) => (a.actorId < b.actorId ? -1 : a.actorId > b.actorId ? 1 : 0)) + const account = await toSmartAccount({ + client, + owner, + userSalt, + initialActors, + implementation: deployment.accounts.erc4337, + accountConfigAddress: accountConfiguration, + }) + + console.log('\n— Base Sepolia ERC-4337 e2e —') + console.log('owner (EOA): ', owner.address) + console.log('smart account: ', account.address) + console.log('factory (config):', accountConfiguration) + + const fees = await estimateFeesPerGas(client) + const feeParams = { + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, + } + + // Wait for a userOp receipt and assert it succeeded. + async function runUserOp( + label: string, + calls: readonly { to: `0x${string}`; value?: bigint; data?: `0x${string}` }[], + ) { + // The bundler maintains its own state view that can lag the RPC node + // right after a funding tx. Retry the transient prefund-precheck race. + let hash: `0x${string}` | undefined + for (let attempt = 0; ; attempt++) { + try { + hash = await bundlerClient.sendUserOperation({ + account, + calls, + ...gasLimits, + ...feeParams, + }) + break + } catch (err) { + const msg = (err as Error).message ?? '' + const transient = /precheck failed|balance.*is 0|deposit/i.test(msg) + if (!transient || attempt >= 8) throw err + await sleep(3000) + } + } + const receipt = await bundlerClient.waitForUserOperationReceipt({ hash }) + console.log( + `${label}:`.padEnd(18), + `https://sepolia.basescan.org/tx/${receipt.receipt.transactionHash}`, + `(success=${receipt.success})`, + ) + expect(receipt.success, `${label} userOp must succeed`).toBe(true) + return receipt + } + + // Poll a read-back predicate to defeat public-RPC replica lag. + async function pollUntil( + fn: () => Promise, + { tries = 12, delay = 1500 } = {}, + ) { + for (let i = 0; i < tries; i++) { + if (await fn()) return true + await sleep(delay) + } + return false + } + + // === 1. CREATE — deploy-on-first-use ============================== + expect((await getCode(client, { address: account.address })) ?? '0x').toBe( + '0x', + ) + // Prefund the counterfactual sender so the EntryPoint can pull prefund. + // Each userOp needs ~1.75e13 wei of gas; 0.005 ETH covers the whole run + // with headroom while keeping unrecoverable testnet spend low. + const prefund = parseEther('0.005') + const fundHash = await sendTransaction(client, { + account: owner, + to: account.address, + value: prefund, + chain: baseSepolia, + }) + await waitForTransactionReceipt(client, { hash: fundHash }) + // Confirm the RPC node reflects the prefund before touching the bundler. + await pollUntil(async () => { + const bal = await getBalance(client, { address: account.address }) + return bal >= prefund + }) + + await runUserOp('create+execute', [{ to: owner.address, value: 1n }]) + const deployed = await pollUntil(async () => { + const code = await getCode(client, { address: account.address }) + return !!(code && code !== '0x') + }) + expect(deployed, 'account must be deployed after the first userOp').toBe( + true, + ) + + // === 2. USER OPS — a follow-up op on the deployed account ========= + await runUserOp('follow-up op', [{ to: owner.address, value: 1n }]) + + // === 3. CHANGE ACTORS — authorize then revoke a fresh k1 actor ==== + const newActor = key.k1(privateKeyToAccount(generatePrivateKey()).address) + + async function applyActorChange( + label: string, + change: ReturnType | ReturnType, + ) { + // Sign over the LIVE local sequence — never hardcode it. + const { local } = await getConfigSequence(client, { + accountConfiguration, + account: account.address, + }) + const changes = [change] + const set = await signActorChanges({ + signer: owner, + account: account.address, + chainId: baseSepolia.id, + sequence: Number(local), + actorChanges: changes, + }) + const data = encodeApplySignedActorChangesData({ + account: account.address, + chainId: baseSepolia.id, + actorChanges: changes, + auth: set.auth, + }) + await runUserOp(label, [{ to: accountConfiguration, data }]) + } + + await applyActorChange( + 'authorize actor', + authorizeActor(newActor, { scope: actorScope.sender }), + ) + const authorized = await pollUntil(() => + isActor(client, { + account: account.address, + actorId: newActor.actorId, + accountConfiguration, + }), + ) + console.log('actor authorized:', authorized) + expect(authorized, 'new actor must be bound after authorize').toBe(true) + + await applyActorChange('revoke actor', revokeActor(newActor)) + const revoked = await pollUntil(async () => + !(await isActor(client, { + account: account.address, + actorId: newActor.actorId, + accountConfiguration, + })), + ) + console.log('actor revoked: ', revoked) + expect(revoked, 'new actor must be unbound after revoke').toBe(true) + }, + 240_000, + ) + }, +) diff --git a/scripts/eip8130/build8130Transaction.test.ts b/scripts/eip8130/buildTransaction.test.ts similarity index 94% rename from scripts/eip8130/build8130Transaction.test.ts rename to scripts/eip8130/buildTransaction.test.ts index fc2a2be350..abe177e4ab 100644 --- a/scripts/eip8130/build8130Transaction.test.ts +++ b/scripts/eip8130/buildTransaction.test.ts @@ -11,10 +11,10 @@ import type { AaCalls, TransactionSerializable8130, } from '../../src/experimental/eip8130/types/transaction.js' -import { parseTransaction8130 } from '../../src/experimental/eip8130/utils/parseTransaction.js' +import { parseTransaction } from '../../src/experimental/eip8130/utils/parseTransaction.js' import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' -import { serializeTransaction8130 } from '../../src/experimental/eip8130/utils/serializeTransaction.js' -import { signTransaction8130 } from '../../src/experimental/eip8130/utils/signTransaction.js' +import { serializeTransaction } from '../../src/experimental/eip8130/utils/serializeTransaction.js' +import { signTransaction } from '../../src/experimental/eip8130/utils/signTransaction.js' import { sliceHex } from '../../src/utils/data/slice.js' import { fromRlp } from '../../src/utils/encoding/fromRlp.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' @@ -114,7 +114,7 @@ describe('build an EIP-8130 transaction (offline demo)', () => { console.log(' p256 authenticator:', canonicalAuthenticators.p256) // ── sign + serialize ───────────────────────────────────────────────────── - const serialized = await signTransaction8130({ + const serialized = await signTransaction({ transaction, account: owner, }) @@ -153,7 +153,7 @@ describe('build an EIP-8130 transaction (offline demo)', () => { }) // ── round-trip: parse the envelope back to a structured tx ─────────────── - const parsed = parseTransaction8130(serialized) + const parsed = parseTransaction(serialized) console.log('\n══════════════════════════════════════════════════════════') console.log(' Parsed back from the envelope') console.log('══════════════════════════════════════════════════════════') @@ -176,6 +176,6 @@ describe('build an EIP-8130 transaction (offline demo)', () => { expect(parsed.payerAuth).toBeUndefined() // Re-serializing the parsed tx yields the identical envelope. - expect(serializeTransaction8130(parsed)).toBe(serialized) + expect(serializeTransaction(parsed)).toBe(serialized) }) }) diff --git a/scripts/eip8130/bundlerCreateAndExecute.test.ts b/scripts/eip8130/bundlerCreateAndExecute.test.ts index 95c8fec901..ddab338df1 100644 --- a/scripts/eip8130/bundlerCreateAndExecute.test.ts +++ b/scripts/eip8130/bundlerCreateAndExecute.test.ts @@ -12,7 +12,7 @@ import { http } from '../../src/clients/transports/http.js' import { parseEther } from '../../src/utils/unit/parseEther.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' -import { toSmartAccount8130 } from '../../src/experimental/eip8130/accounts/toSmartAccount8130.js' +import { toSmartAccount } from '../../src/experimental/eip8130/accounts/toSmartAccount.js' import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' import { key } from '../../src/experimental/eip8130/keys.js' @@ -42,7 +42,7 @@ describe.runIf(PRIVATE_KEY)( // Fresh salt so the account is purely counterfactual (not pre-created). const userSalt = keccak256(stringToHex(`viem-8130-bundler-${Date.now()}`)) - const account = await toSmartAccount8130({ + const account = await toSmartAccount({ client, owner, userSalt, diff --git a/scripts/eip8130/bundlerProbeDeployed.test.ts b/scripts/eip8130/bundlerProbeDeployed.test.ts index 85496ef3f1..6055957906 100644 --- a/scripts/eip8130/bundlerProbeDeployed.test.ts +++ b/scripts/eip8130/bundlerProbeDeployed.test.ts @@ -11,7 +11,7 @@ import { http } from '../../src/clients/transports/http.js' import { parseEther } from '../../src/utils/unit/parseEther.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' -import { toSmartAccount8130 } from '../../src/experimental/eip8130/accounts/toSmartAccount8130.js' +import { toSmartAccount } from '../../src/experimental/eip8130/accounts/toSmartAccount.js' import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' import { key } from '../../src/experimental/eip8130/keys.js' @@ -37,7 +37,7 @@ describe.runIf(PRIVATE_KEY)('bundler probe: transact on a pre-deployed account', }) const deployment = getEip8130Deployment(baseSepolia.id)! - const account = await toSmartAccount8130({ + const account = await toSmartAccount({ client, owner, address: '0x64609Df27EFb3ecB241B349a3985DFdE2B98dc6b', diff --git a/scripts/eip8130/policySmoke.test.ts b/scripts/eip8130/policySmoke.test.ts new file mode 100644 index 0000000000..3c60075d0b --- /dev/null +++ b/scripts/eip8130/policySmoke.test.ts @@ -0,0 +1,418 @@ +/** + * EIP-8130 session-key + policy smoke test — base/eip-8130 #43. + * + * Runs LIVE against the hosted vibenet devnet; gas is sponsored by the vibenet + * payer, so you don't need to fund anything. It proves the full session-key + * flow and pins down two things that make a working authorize LOOK broken. + * + * Run: + * npx vitest run --config test/vitest.eip8130.config.ts \ + * scripts/eip8130/policySmoke.test.ts + * + * Env (optional): RPC_URL, PAYER_URL, BROADCAST_URL. + * + * Flow: + * 1. Create a fresh smart account (sponsored). + * 2. Register the PolicyManager (trusted-executor) + a session key (policy) + * in ONE config change, on the LOCAL channel, at the LIVE sequence. + * 3. Use the session key to drive the ONE permitted policy-gated call — + * Counter.increment() — via PolicyManager.execute(binding, action). + * (#43: no install step; the full PolicyBinding is passed at execute.) + * + * TWO GOTCHAS currently broken in this VIBENET build: + * + * (a) NO EVENTS IN THE RECEIPT. A *successful* authorize emits ZERO + * `receipt.logs` — `ActorAuthorized` is not surfaced as a normal EVM log. + * "No events" is NOT a failure signal. The reliable success check is a + * READ-BACK: isActor / getActorConfig / a bumped getConfigSequence. + * + * (b) READ-BACK LAG. State reads trail the receipt by ~1 block (~2s). Reading + * isActor/sequence right after the receipt returns STALE values — poll. + * + * Sequence correctness: the digest binds (account, chainId, sequence). Sign + * with the LIVE on-chain counter for the channel (LOCAL for session keys); + * never hardcode it. First-authorize local seq is 1 for a created smart wallet + * (create bumps local 0->1) or 0 for a bare 7702-delegated EOA — so read it. + * A stale sequence is rejected loudly at broadcast ("config change sequence + * mismatch") or mines as a silent no-op. + * + * NONCE MODE: the session key is authorized with POLICY | SCOPE_NONCE, so + * prepare uses sequenced nonces (`getTransactionCount`, channel 0). + * Prepare reads on-chain scope via getActorConfig when the actor is bound — + * do NOT redeclare scope on the session account handle. Owner (admin, + * scope 0) remains nonce-free — admin cannot hold SCOPE_NONCE. + */ + +import { describe, expect, test } from 'vitest' +import { generatePrivateKey, privateKeyToAccount } from '../../src/accounts/index.js' +import { createPublicClient } from '../../src/clients/createPublicClient.js' +import { http } from '../../src/clients/transports/http.js' +import { createPayerClient } from '../../src/experimental/eip8168/client.js' +import { sendSponsoredCalls } from '../../src/experimental/eip8168/actions/sendSponsoredCalls.js' +import { toAccount } from '../../src/experimental/eip8130/accounts/toAccount.js' +import { getActorConfig } from '../../src/experimental/eip8130/actions/getActorConfig.js' +import { getConfigSequence } from '../../src/experimental/eip8130/actions/getConfigSequence.js' +import { isActor } from '../../src/experimental/eip8130/actions/isActor.js' +import { allPhasesSucceeded } from '../../src/experimental/eip8130/actions/getTransactionReceipt.js' +import { waitForTransactionReceipt } from '../../src/experimental/eip8130/actions/waitForTransactionReceipt.js' +import { + actorScope, + canonicalAuthenticators, + scopeUnrestricted, +} from '../../src/experimental/eip8130/constants.js' +import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' +import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' +import { + defineSessionPolicy, + encodeSessionPolicyConfig, +} from '../../src/experimental/eip8130/policies.js' +import type { AaAccountChange, AaCall } from '../../src/experimental/eip8130/types/transaction.js' +import { upgradeableProxyBytecode } from '../../src/experimental/eip8130/utils/proxy.js' +import type { Hex } from '../../src/types/misc.js' +import { hexToBigInt } from '../../src/utils/encoding/fromHex.js' + +const RPC_URL = process.env.RPC_URL ?? 'https://vibes.base.org/api/vibenet/account/rpc' +const PAYER_URL = + process.env.PAYER_URL ?? 'https://vibes.base.org/api/vibenet/account/payer' +const BROADCAST_URL = + process.env.BROADCAST_URL ?? 'https://vibes.base.org/api/vibenet/account/rpc' + +// The ONE contract this session key is allowed to touch, and the ONE selector. +// Counter.increment() @ vibenet devnet — a real, verifiable on-chain effect. +const COUNTER = '0x7ec1445f7019949B1A1d85e49d29a2ae5dEcF9B0' as const +const INCREMENT = '0xd09de08a' as const // increment() +const COUNT = '0x06661abd' as const // count() (public getter) + +describe('EIP-8130 policy smoke (hosted vibenet)', () => { + test( + 'create → authorize manager+session → session Counter.increment', + async () => { + // --- client + chain ------------------------------------------------- + const bootstrap = createPublicClient({ transport: http(RPC_URL) }) + const chainId = Number( + await bootstrap.request({ method: 'eth_chainId' }), + ) + const chain = { + id: chainId, + name: 'vibenet', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [RPC_URL] } }, + } as const + const client = createPublicClient({ chain, transport: http(RPC_URL) }) + const bclient = createPublicClient({ + chain, + transport: http(BROADCAST_URL), + }) + const payer = createPayerClient({ url: PAYER_URL }) + + const deployment = getEip8130Deployment(chainId) + if (!deployment?.policies) { + throw new Error( + `No EIP-8130 policy addresses for chain ${chainId}. Refusing to borrow another chain's deployment.`, + ) + } + const { policies, accountConfiguration } = deployment + console.log('chainId:', chainId) + console.log('policyManager:', policies.manager) + console.log('sessionPolicy:', policies.sessionPolicy, '\n') + + // --- helpers -------------------------------------------------------- + const now = () => BigInt(Math.floor(Date.now() / 1000)) + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + + /** + * Send a sponsored EIP-8130 tx that may carry BOTH account changes and calls. + * Hosted vibenet payer only CO-SIGNS (`mode: "sign"`); we self-broadcast. + */ + async function sponsor(parameters: { + account: ReturnType + accountChanges?: readonly AaAccountChange[] + calls?: readonly AaCall[] + }) { + const cosigned = await sendSponsoredCalls(client, { + account: parameters.account, + payerClient: payer, + mode: 'sign', + accountChanges: parameters.accountChanges, + calls: parameters.calls ?? [], + // Payer estimates from `calls` only; floor covers account-change application. + gas: 2_000_000n, + context: { flow: 'transact' }, + }) + const finalTx = + 'signedTransaction' in cosigned + ? cosigned.signedTransaction + : (cosigned as Hex) + const hash = await bclient.request({ + method: 'eth_sendRawTransaction', + params: [finalTx], + }) + const receipt = await waitForTransactionReceipt(client, { hash }) + return { hash, receipt } + } + + const readSeq = () => + getConfigSequence(client, { + accountConfiguration, + account: account.address, + }) + const readIsActor = (actorId: Hex) => + isActor(client, { + account: account.address, + actorId, + accountConfiguration, + }) + + /** Poll a read-back predicate to defeat recall/state lag (~1 block). */ + async function pollUntil( + fn: () => Promise, + { tries = 20, delay = 1500 } = {}, + ): Promise { + let last: T | null | undefined | false + for (let i = 0; i < tries; i++) { + last = await fn() + if (last) return last + await sleep(delay) + } + return last + } + + let ok = true + function report(name: string, pass: boolean, extra = '') { + if (!pass) ok = false + console.log( + `${pass ? 'PASS' : 'FAIL'} ${name}${extra ? ` :: ${extra}` : ''}`, + ) + } + + // --- actors --------------------------------------------------------- + const owner = privateKeyToAccount(generatePrivateKey()) + const account = toAccount({ + signer: owner, + userSalt: generatePrivateKey(), + code: upgradeableProxyBytecode(deployment.accounts.default), + initialActors: [key.k1(owner.address)], + authenticator: canonicalAuthenticators.k1, + accountConfigAddress: accountConfiguration, + // Owner is admin (scope 0) and not yet on-chain at create time, so prepare + // cannot read scope from getActorConfig — declare admin here once. After + // create, prepare prefers on-chain scope when the actor is bound. + scope: scopeUnrestricted, + }) + console.log('owner: ', owner.address) + console.log('account:', account.address, '\n') + + // Session key (a k1 EOA so we can sign its execute tx) + its policy. + const sessionSigner = privateKeyToAccount(generatePrivateKey()) + const sessionActor = key.k1(sessionSigner.address) + const managerActor = key.trustedExecutor(policies.manager) + + // ── Session-key scope (declared ONCE — at authorize) ──────────────── + // POLICY | NONCE → sequenced nonces via getTransactionCount. + // Do NOT also pass this scope into toAccount for the session handle — + // prepare reads getActorConfig and selects nonce mode from chain truth. + const SESSION_SELF_PAY = false + const SESSION_USE_NONCE = true + const sessionScope = + actorScope.policy | + (SESSION_USE_NONCE ? actorScope.nonce : 0) | + (SESSION_SELF_PAY ? actorScope.selfPayer : 0) + + const policyConfig = encodeSessionPolicyConfig({ + tokenLimits: [], + callScopes: [ + { target: COUNTER, selectorRules: [{ selector: INCREMENT }] }, + ], + }) + const expiry = now() + 86_400n + const session = defineSessionPolicy({ + account: account.address, + policy: policies.sessionPolicy, + policyConfig, + manager: policies.manager, + validUntil: expiry, + }) + + // ===================================================================== + // STEP 1 — create the smart account (sponsored). `create` bumps local -> 1. + // ===================================================================== + console.log('── STEP 1: create smart account ──') + try { + const { hash, receipt } = await sponsor({ + account, + accountChanges: [account.create()], + calls: [{ to: account.address, data: '0x' }], + }) + console.log( + 'tx:', + hash, + '| status:', + receipt.status, + '| allPhases:', + allPhasesSucceeded(receipt.eip8130), + ) + const seq = await pollUntil(async () => { + const s = await readSeq() + return s.local >= 1n ? s : null + }) + const local = + seq && typeof seq === 'object' && 'local' in seq ? seq.local : 0n + report( + 'account created (local sequence bumped to 1)', + local >= 1n, + `local=${local}`, + ) + } catch (e) { + report('create', false, (e as Error)?.message ?? String(e)) + } + + // ===================================================================== + // STEP 2 — register PolicyManager (trusted-executor) + session key (policy) + // in ONE config change, LOCAL channel, at the LIVE sequence. + // ===================================================================== + console.log('\n── STEP 2: register PolicyManager + session key ──') + try { + const live = await readSeq() // read live local counter — never guess + const configChanges = [ + authorizeActor(managerActor, { scope: actorScope.sender }), + authorizeActor(sessionActor, { + scope: sessionScope, + expiry, + policy: session.actorPolicy, + }), + ] + const authChange = await account.change(configChanges, { + chainId, // LOCAL channel + sequence: Number(live.local), + }) + const { hash, receipt } = await sponsor({ + account, + accountChanges: [authChange], + calls: [{ to: account.address, data: '0x' }], + }) + console.log( + 'tx:', + hash, + '| status:', + receipt.status, + '| allPhases:', + allPhasesSucceeded(receipt.eip8130), + '| receipt.logs:', + Array.isArray(receipt.logs) ? receipt.logs.length : 0, + ) + console.log( + ' NOTE: receipt.logs is 0 even on success — ActorAuthorized is not a', + '\n normal EVM log here. Verify via read-back, not logs.', + ) + + const mgrBound = await pollUntil(async () => + (await readIsActor(managerActor.actorId)) ? true : null, + ) + report('PolicyManager bound as trusted-executor actor', mgrBound === true) + + const skBound = await pollUntil(async () => + (await readIsActor(sessionActor.actorId)) ? true : null, + ) + report('session key bound as actor', skBound === true) + + if (skBound) { + const cfg = await getActorConfig(client, { + account: account.address, + actorId: sessionActor.actorId, + accountConfiguration, + }) + report( + 'session key scope is POLICY | NONCE (sequenced)', + cfg.hasPolicy === true && (cfg.scope & actorScope.nonce) !== 0, + `scope=0x${cfg.scope.toString(16)}`, + ) + } else { + console.log( + ' ↳ If this ever FAILS: the authorize was skipped fail-closed. The tx', + '\n still reports success with no failed phase — check the signed', + '\n sequence against the LIVE on-chain counter for this channel.', + ) + } + } catch (e) { + report( + 'register manager + session key', + false, + (e as Error)?.message ?? String(e), + ) + } + + // ===================================================================== + // STEP 3 — use the session key: execute the ONE permitted policy-gated call. + // ===================================================================== + console.log( + '\n── STEP 3: use the session key (Counter.increment via PolicyManager) ──', + ) + try { + const readCount = async () => + hexToBigInt( + await client.request({ + method: 'eth_call', + params: [{ to: COUNTER, data: COUNT }, 'latest'], + }), + ) + const before = await readCount() + + // Signer + address only — no redeclared `scope`. prepare reads on-chain + // getActorConfig (POLICY|NONCE) and fills nonceKey=0 + nonceSequence + // via getTransactionCount. + const sessionAccount = toAccount({ + signer: sessionSigner, + address: account.address, + authenticator: canonicalAuthenticators.k1, + accountConfigAddress: accountConfiguration, + }) + const executeCall = session.executeCall({ + target: COUNTER, + value: 0n, + data: INCREMENT, + }) + + const { hash, receipt } = await sponsor({ + account: sessionAccount, + calls: [executeCall], + }) + console.log( + 'tx:', + hash, + '| status:', + receipt.status, + '| allPhases:', + allPhasesSucceeded(receipt.eip8130), + '| phaseStatuses:', + JSON.stringify(receipt.eip8130.phaseStatuses), + ) + report( + 'session-key execute landed (all call phases succeeded)', + allPhasesSucceeded(receipt.eip8130), + ) + + const bumped = await pollUntil(async () => { + const c = await readCount() + return c === before + 1n ? c : null + }) + report( + 'Counter.increment ran via the session key', + bumped === before + 1n, + `count ${before} -> ${bumped ?? '?'}`, + ) + } catch (e) { + report( + 'session-key execute send', + false, + (e as Error)?.message ?? String(e), + ) + } + + console.log('') + expect(ok, 'one or more policy-smoke checks failed — see PASS/FAIL above').toBe( + true, + ) + }, + 180_000, + ) +}) diff --git a/scripts/eip8130/selfBundleCreate.test.ts b/scripts/eip8130/selfBundleCreate.test.ts index eda012782c..49aa78c42b 100644 --- a/scripts/eip8130/selfBundleCreate.test.ts +++ b/scripts/eip8130/selfBundleCreate.test.ts @@ -14,7 +14,7 @@ import { http } from '../../src/clients/transports/http.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' import { parseEther } from '../../src/utils/unit/parseEther.js' -import { toSmartAccount8130 } from '../../src/experimental/eip8130/accounts/toSmartAccount8130.js' +import { toSmartAccount } from '../../src/experimental/eip8130/accounts/toSmartAccount.js' import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' import { key } from '../../src/experimental/eip8130/keys.js' @@ -36,7 +36,7 @@ describe.runIf(PRIVATE_KEY)( const deployment = getEip8130Deployment(baseSepolia.id)! const userSalt = keccak256(stringToHex(`viem-8130-self-${Date.now()}`)) - const account = await toSmartAccount8130({ + const account = await toSmartAccount({ client, owner, userSalt, diff --git a/scripts/eip8130/selfBundleRotateP256.test.ts b/scripts/eip8130/selfBundleRotateP256.test.ts index 881f6b6f78..01f0dbcabe 100644 --- a/scripts/eip8130/selfBundleRotateP256.test.ts +++ b/scripts/eip8130/selfBundleRotateP256.test.ts @@ -13,7 +13,7 @@ import { baseSepolia } from '../../src/chains/index.js' import { createClient } from '../../src/clients/createClient.js' import { http } from '../../src/clients/transports/http.js' import { accountConfigurationAbi } from '../../src/experimental/eip8130/abis.js' -import { toSmartAccount8130 } from '../../src/experimental/eip8130/accounts/toSmartAccount8130.js' +import { toSmartAccount } from '../../src/experimental/eip8130/accounts/toSmartAccount.js' import { actorScope, ecrecoverAuthenticator, @@ -21,7 +21,7 @@ import { import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' import { actorIdFromPublicKey } from '../../src/experimental/eip8130/utils/actorId.js' -import { signActorChanges8130 } from '../../src/experimental/eip8130/utils/signActorChanges.js' +import { signActorChanges } from '../../src/experimental/eip8130/utils/signActorChanges.js' import { encodeSignedActorChangesSignature } from '../../src/experimental/eip8130/utils/signedActorChangesSignature.js' import { concatHex } from '../../src/utils/data/concat.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' @@ -52,7 +52,7 @@ describe.runIf(PRIVATE_KEY)( const deployment = getEip8130Deployment(baseSepolia.id)! const userSalt = keccak256(stringToHex(`viem-8130-rotate-${Date.now()}`)) - const account = await toSmartAccount8130({ + const account = await toSmartAccount({ client, owner, userSalt, @@ -121,7 +121,7 @@ describe.runIf(PRIVATE_KEY)( // Current k1 owner authorizes the new P-256 actor. createAccount() sets // localSequence = 1 (as the initialized flag), so the first // applySignedActorChanges call on a fresh account must sign over sequence 1. - const set = await signActorChanges8130({ + const set = await signActorChanges({ signer: owner, account: account.address, chainId: baseSepolia.id, diff --git a/scripts/eip8130/setup8130Account.test.ts b/scripts/eip8130/setupAccount.test.ts similarity index 95% rename from scripts/eip8130/setup8130Account.test.ts rename to scripts/eip8130/setupAccount.test.ts index 6200532385..2ff1767fa2 100644 --- a/scripts/eip8130/setup8130Account.test.ts +++ b/scripts/eip8130/setupAccount.test.ts @@ -10,7 +10,7 @@ import { http } from '../../src/clients/transports/http.js' import { accountConfigurationAbi } from '../../src/experimental/eip8130/abis.js' import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' import { key } from '../../src/experimental/eip8130/keys.js' -import { computeAddress8130 } from '../../src/experimental/eip8130/utils/computeAddress.js' +import { computeAddress } from '../../src/experimental/eip8130/utils/computeAddress.js' import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' @@ -37,7 +37,7 @@ describe.runIf(PRIVATE_KEY)('setup an EIP-8130 account on Base Sepolia', () => { authenticator: a.authenticator, })) - const local = computeAddress8130({ userSalt, code, initialActors }) + const local = computeAddress({ userSalt, code, initialActors }) const onchain = await readContract(client, { abi: accountConfigurationAbi, address: deployment.accountConfiguration, diff --git a/scripts/eip8130/vibenet6PartTest.test.ts b/scripts/eip8130/vibenet6PartTest.test.ts index f76cc48340..69e6ddadf2 100644 --- a/scripts/eip8130/vibenet6PartTest.test.ts +++ b/scripts/eip8130/vibenet6PartTest.test.ts @@ -21,13 +21,13 @@ import { generatePrivateKey, privateKeyToAccount } from '../../src/accounts/inde import { getBalance } from '../../src/actions/public/getBalance.js' import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' -import { waitForTransactionReceipt8130 } from '../../src/experimental/eip8130/actions/waitForTransactionReceipt8130.js' +import { waitForTransactionReceipt as waitForReceipt8130 } from '../../src/experimental/eip8130/actions/waitForTransactionReceipt.js' import { createClient } from '../../src/clients/createClient.js' import { http } from '../../src/clients/transports/http.js' -import { to8130Account } from '../../src/experimental/eip8130/accounts/to8130Account.js' -import { getConfigSequence8130 } from '../../src/experimental/eip8130/actions/getConfigSequence8130.js' -import { getTransactionCount8130 } from '../../src/experimental/eip8130/actions/getTransactionCount8130.js' -import { sendCalls8130 } from '../../src/experimental/eip8130/actions/sendCalls.js' +import { toAccount } from '../../src/experimental/eip8130/accounts/toAccount.js' +import { getConfigSequence } from '../../src/experimental/eip8130/actions/getConfigSequence.js' +import { getTransactionCount } from '../../src/experimental/eip8130/actions/getTransactionCount.js' +import { sendCalls } from '../../src/experimental/eip8130/actions/sendCalls.js' import { vibenetDevnetDeployment } from '../../src/experimental/eip8130/deployments.js' import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' import { toP256Signer } from '../../src/experimental/eip8130/utils/signers.js' @@ -37,7 +37,8 @@ import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { parseEther } from '../../src/utils/unit/parseEther.js' -import type { Address, Hex } from '../../src/types/index.js' +import type { Address } from '../../src/index.js' +import type { Hex } from '../../src/types/misc.js' // --------------------------------------------------------------------------- // Config — defaults target the local vibenet devnet @@ -80,17 +81,17 @@ async function fund(to: Address, amount = parseEther('0.5')) { log('funded', `${to} ← ${amount} wei`) } -async function send8130( - account: ReturnType, +async function send( + account: ReturnType, calls: AaCalls, accountChanges?: any[], ): Promise { - const nonce = await getTransactionCount8130(client as any, { + const nonce = await getTransactionCount(client as any, { address: account.address as Address, nonceKey: 0n, }) - const hash = await sendCalls8130(client as any, { + const hash = await sendCalls(client as any, { account, calls, accountChanges: accountChanges ?? [], @@ -98,19 +99,19 @@ async function send8130( gas: 500_000n, }) log('tx hash', hash) - const receipt = await waitForTransactionReceipt8130(client as any, { hash, timeout: 30_000 }) - const ok = receipt.status === '0x1' || receipt.status === 'success' + const receipt = await waitForReceipt8130(client as any, { hash, timeout: 30_000 }) + const ok = receipt.status === '0x1' log('status', ok ? '✓ success' : `✗ FAILED (status=${receipt.status}, phases=${JSON.stringify(receipt.eip8130?.phaseStatuses)})`) if (!ok) throw new Error(`Transaction reverted: ${hash}`) return hash } async function sendWithOwnerChange( - account: ReturnType, + account: ReturnType, calls: AaCalls, actorChanges: Parameters[0], ): Promise { - const { local } = await getConfigSequence8130(client as any, { + const { local } = await getConfigSequence(client as any, { accountConfiguration: D.accountConfiguration as Address, account: account.address as Address, }) @@ -118,14 +119,16 @@ async function sendWithOwnerChange( chainId: CHAIN_ID, sequence: Number(local), }) - return send8130(account, calls, [configChange]) + return send(account, calls, [configChange]) } // --------------------------------------------------------------------------- // Test data — fresh keys per run so tests are fully independent // --------------------------------------------------------------------------- -const code = erc1167Bytecode(D.accounts.erc4337) +// Vibenet is native 8130: use the canonical DefaultAccount as the delegate / +// proxy implementation (the deployment carries no erc4337 example wallet). +const code = erc1167Bytecode(D.accounts.default) const RECIPIENT = '0x1111111111111111111111111111111111111111' as Address // EOA account — signer IS the account address @@ -134,7 +137,7 @@ const eoaKey2 = generatePrivateKey() const eoa1 = privateKeyToAccount(eoaKey1) const eoa2 = privateKeyToAccount(eoaKey2) -const eoaAccount1 = to8130Account({ +const eoaAccount1 = toAccount({ signer: eoa1, userSalt: '0x' + '00'.repeat(32) as Hex, code, @@ -144,7 +147,7 @@ const eoaAccount1 = to8130Account({ }) // On first use eoaAccount2 still points at eoa1's address but signs with eoa2 -const eoaAccount2 = to8130Account({ +const eoaAccount2 = toAccount({ signer: eoa2, userSalt: '0x' + '00'.repeat(32) as Hex, code, @@ -158,7 +161,7 @@ const smartKey1 = generatePrivateKey() const smart1 = privateKeyToAccount(smartKey1) const smartSalt = keccak256(stringToHex(`vibe-6part-${Date.now()}`)) -const smartAccount = to8130Account({ +const smartAccount = toAccount({ signer: smart1, userSalt: smartSalt, code, @@ -171,7 +174,7 @@ const p256PrivateKey = P256.randomPrivateKey() const p256Signer = toP256Signer({ privateKey: p256PrivateKey }) const p256Salt = keccak256(stringToHex(`vibe-p256-${Date.now()}`)) -const p256Account = to8130Account({ +const p256Account = toAccount({ signer: p256Signer, authenticator: p256Signer.authenticator, userSalt: p256Salt, @@ -195,7 +198,7 @@ describe.sequential('6-part vibenet EIP-8130 native tx test', () => { // Include a `delegation` account-change so the EOA is backed by DefaultAccount // bytecode before executeBatch is invoked. Without this the EOA has no code // and the executeBatch self-call is a no-op (succeeds silently, value not sent). - await send8130( + await send( eoaAccount1, [[{ to: RECIPIENT, value: parseEther('0.001') }]], [eoaAccount1.delegate(D.accounts.default as Address)], @@ -222,7 +225,7 @@ describe.sequential('6-part vibenet EIP-8130 native tx test', () => { console.log('\n══ Test 3: EOA tx with new key ══') const balBefore = await getBalance(client as any, { address: RECIPIENT }) - await send8130( + await send( eoaAccount2, [[{ to: RECIPIENT, value: parseEther('0.001') }]], ) @@ -239,7 +242,7 @@ describe.sequential('6-part vibenet EIP-8130 native tx test', () => { const balBefore = await getBalance(client as any, { address: RECIPIENT }) // First tx includes the create account-change so the account is deployed - await send8130( + await send( smartAccount, [[{ to: RECIPIENT, value: parseEther('0.001') }]], [smartAccount.create()], @@ -253,7 +256,7 @@ describe.sequential('6-part vibenet EIP-8130 native tx test', () => { console.log('\n══ Test 5: Smart account follow-up tx ══') const balBefore = await getBalance(client as any, { address: RECIPIENT }) - await send8130( + await send( smartAccount, [[{ to: RECIPIENT, value: parseEther('0.001') }]], ) @@ -287,7 +290,7 @@ describe.sequential('6-part vibenet EIP-8130 native tx test', () => { const balBefore = await getBalance(client as any, { address: RECIPIENT }) // First tx: create account change deploys the account, then sends ETH. - await send8130( + await send( p256Account, [[{ to: RECIPIENT, value: parseEther('0.001') }]], [p256Account.create()], @@ -303,7 +306,7 @@ describe.sequential('6-part vibenet EIP-8130 native tx test', () => { const balBefore = await getBalance(client as any, { address: RECIPIENT }) // Subsequent tx: no account changes needed, P-256 signer signs directly. - await send8130( + await send( p256Account, [[{ to: RECIPIENT, value: parseEther('0.001') }]], ) diff --git a/scripts/smoke-estimate-sender-actor.mjs b/scripts/smoke-estimate-sender-actor.mjs index 6e9f9a9905..b01a7a7d78 100644 --- a/scripts/smoke-estimate-sender-actor.mjs +++ b/scripts/smoke-estimate-sender-actor.mjs @@ -1,5 +1,5 @@ /** - * Live smoke: estimateGas8130 with/without senderActorId against vibenet. + * Live smoke: estimateGas with/without senderActorId against vibenet. * * Proves the node (#3892) + viem hint path for policy-gated session keys. * @@ -9,8 +9,8 @@ import { createPublicClient, http, parseEther, zeroAddress } from '../src/index.ts' import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.ts' import { toP256Signer } from '../src/experimental/eip8130/utils/signers.ts' -import { to8130Account } from '../src/experimental/eip8130/accounts/to8130Account.ts' -import { estimateGas8130 } from '../src/experimental/eip8130/actions/estimateGas8130.ts' +import { toAccount } from '../src/experimental/eip8130/accounts/toAccount.ts' +import { estimateGas } from '../src/experimental/eip8130/actions/estimateGas.ts' import { authorizeActor, key, @@ -45,7 +45,7 @@ const initialActors = [ key.trustedExecutor(POLICY_MANAGER), ].sort((a, b) => (a.actorId < b.actorId ? -1 : a.actorId > b.actorId ? 1 : 0)) -const account = to8130Account({ +const account = toAccount({ signer: owner, userSalt, code: erc1167Bytecode(DEFAULT_ACCOUNT), @@ -100,7 +100,7 @@ console.log('chainId', await client.getChainId()) async function tryEstimate(label, params) { try { - const gas = await estimateGas8130(client, params) + const gas = await estimateGas(client, params) console.log(`OK ${label}: gas=${gas}`) return { ok: true, gas } } catch (err) { diff --git a/site/pages/experimental/eip8130.mdx b/site/pages/experimental/eip8130.mdx index 5a8485c61c..8fe7c7d7f8 100644 --- a/site/pages/experimental/eip8130.mdx +++ b/site/pages/experimental/eip8130.mdx @@ -33,9 +33,9 @@ The helpers live under the dedicated entrypoint: ```ts import { - newSmartAccount8130, - sendCalls8130, - estimateGas8130, + newSmartAccount, + sendCalls, + estimateGas, } from 'viem/experimental/eip8130' ``` diff --git a/site/pages/experimental/eip8130/calls-and-batching.mdx b/site/pages/experimental/eip8130/calls-and-batching.mdx index 73bda920b3..4400cf9a42 100644 --- a/site/pages/experimental/eip8130/calls-and-batching.mdx +++ b/site/pages/experimental/eip8130/calls-and-batching.mdx @@ -32,27 +32,27 @@ Each `AaCall` is `{ to, data?, value? }`: ## Flat vs. phased -`sendCalls8130` accepts either shape. A **flat** array is sugar for a single phase; pass a **nested** array to control phases explicitly: +`sendCalls` accepts either shape. A **flat** array is sugar for a single phase; pass a **nested** array to control phases explicitly: ```ts -import { sendCalls8130 } from 'viem/experimental/eip8130' +import { sendCalls } from 'viem/experimental/eip8130' // One atomic phase (flat): -await sendCalls8130(client, { +await sendCalls(client, { account, calls: [{ to: a, data }, { to: b, data }], gas: 300_000n, }) // Two phases (nested): -await sendCalls8130(client, { +await sendCalls(client, { account, calls: [[{ to: a, data }], [{ to: b, data }]], gas: 300_000n, }) ``` -`estimateGas8130` and `prepareTransaction8130` always take the **phased** form (`AaCalls`). +`estimateGas` and `prepareTransaction` always take the **phased** form (`AaCalls`). ## How value-bearing calls execute @@ -70,10 +70,10 @@ const wire = encodeWalletCalls({ ## Custom executors -Wallets whose bytecode does not expose `executeBatch` must supply their own `encodeExecute` — a function mapping a phase's normalized calls to a single wallet call. Pass it to `sendCalls8130` (or `encodeWalletCalls`): +Wallets whose bytecode does not expose `executeBatch` must supply their own `encodeExecute` — a function mapping a phase's normalized calls to a single wallet call. Pass it to `sendCalls` (or `encodeWalletCalls`): ```ts -import { type EncodeExecute, sendCalls8130 } from 'viem/experimental/eip8130' +import { type EncodeExecute, sendCalls } from 'viem/experimental/eip8130' import { encodeFunctionData } from 'viem' const encodeExecute: EncodeExecute = ({ account, calls }) => ({ @@ -85,7 +85,7 @@ const encodeExecute: EncodeExecute = ({ account, calls }) => ({ }), }) -await sendCalls8130(client, { account, calls, gas: 300_000n, encodeExecute }) +await sendCalls(client, { account, calls, gas: 300_000n, encodeExecute }) ``` ## Choosing phases diff --git a/site/pages/experimental/eip8130/creating-an-account.mdx b/site/pages/experimental/eip8130/creating-an-account.mdx index af31ddcdb8..73ccc7dabc 100644 --- a/site/pages/experimental/eip8130/creating-an-account.mdx +++ b/site/pages/experimental/eip8130/creating-an-account.mdx @@ -4,7 +4,7 @@ description: Create an EIP-8130 smart account from a secp256k1, P-256, or WebAut # Creating an Account -`newSmartAccount8130` turns a signer into an EIP-8130 smart account. It auto-derives the controlling actor, the deployment bytecode, and the counterfactual CREATE2 address. The account is **not** deployed yet — it is deployed atomically inside its [first transaction](/experimental/eip8130/sending-a-transaction#deploy-on-first-use). +`newSmartAccount` turns a signer into an EIP-8130 smart account. It auto-derives the controlling actor, the deployment bytecode, and the counterfactual CREATE2 address. The account is **not** deployed yet — it is deployed atomically inside its [first transaction](/experimental/eip8130/sending-a-transaction#deploy-on-first-use). By default the account is an **`UpgradeableAccount`** deployed behind an ERC-1967 `UpgradeableProxy`, so its implementation can later be swapped via a CONFIG-key-signed `upgradeBySignature`. Pass `upgradeable: false` to deploy an immutable `DefaultHighRateAccount` behind a 45-byte ERC-1167 proxy instead. @@ -14,11 +14,11 @@ The signer type (K1 / P-256 / WebAuthn) is detected automatically. ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { newSmartAccount8130 } from 'viem/experimental/eip8130' +import { newSmartAccount } from 'viem/experimental/eip8130' const owner = privateKeyToAccount(generatePrivateKey()) -const account = newSmartAccount8130({ signer: owner }) +const account = newSmartAccount({ signer: owner }) account.address // counterfactual address (deterministic from the salt) account.createChange // include in `accountChanges` to deploy on first use @@ -28,9 +28,9 @@ Pass a fixed `salt` to recover the same address across sessions: ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { newSmartAccount8130 } from 'viem/experimental/eip8130' +import { newSmartAccount } from 'viem/experimental/eip8130' const owner = privateKeyToAccount(generatePrivateKey()) -const account = newSmartAccount8130({ +const account = newSmartAccount({ signer: owner, salt: '0x0000000000000000000000000000000000000000000000000000000000000001', }) @@ -40,23 +40,23 @@ const account = newSmartAccount8130({ ```ts import * as P256 from 'ox/P256' -import { newSmartAccount8130, toP256Signer } from 'viem/experimental/eip8130' +import { newSmartAccount, toP256Signer } from 'viem/experimental/eip8130' const signer = toP256Signer({ privateKey: P256.randomPrivateKey() }) -const account = newSmartAccount8130({ signer }) +const account = newSmartAccount({ signer }) ``` ## WebAuthn / passkey ```ts import { createWebAuthnCredential, toWebAuthnAccount } from 'viem/account-abstraction' -import { newSmartAccount8130, toWebAuthnSigner } from 'viem/experimental/eip8130' +import { newSmartAccount, toWebAuthnSigner } from 'viem/experimental/eip8130' const credential = await createWebAuthnCredential({ name: 'vibes' }) const signer = toWebAuthnSigner(toWebAuthnAccount({ credential })) -const account = newSmartAccount8130({ signer }) +const account = newSmartAccount({ signer }) ``` ## Multiple initial keys @@ -65,13 +65,13 @@ Register additional actors at creation with `extraActors`. Use the [`key`](/expe ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { key, newSmartAccount8130, toP256Signer } from 'viem/experimental/eip8130' +import { key, newSmartAccount, toP256Signer } from 'viem/experimental/eip8130' import * as P256 from 'ox/P256' const owner = privateKeyToAccount(generatePrivateKey()) const p256 = toP256Signer({ privateKey: P256.randomPrivateKey() }) -const account = newSmartAccount8130({ +const account = newSmartAccount({ signer: owner, extraActors: [key.p256(p256.publicKey)], }) @@ -79,13 +79,13 @@ const account = newSmartAccount8130({ ## Plain / delegated EOAs -To use a raw secp256k1 EOA as its own account (cheapest auth path — a bare 65-byte signature, no contract required), use `toEoa8130Account`. The same account can later be upgraded to a smart account via an EIP-7702 delegation in its first transaction. +To use a raw secp256k1 EOA as its own account (cheapest auth path — a bare 65-byte signature, no contract required), use `toEoaAccount`. The same account can later be upgraded to a smart account via an EIP-7702 delegation in its first transaction. ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { toEoa8130Account } from 'viem/experimental/eip8130' +import { toEoaAccount } from 'viem/experimental/eip8130' -const account = toEoa8130Account(privateKeyToAccount(generatePrivateKey())) +const account = toEoaAccount(privateKeyToAccount(generatePrivateKey())) // Later, install smart-account code with `account.delegate(impl)` in the first // transaction's `accountChanges`. @@ -99,9 +99,9 @@ The same EIP-8130 account works on chains **without** native 8130 support throug ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { canonicalEip8130Deployment, toSmartAccount8130 } from 'viem/experimental/eip8130' +import { canonicalEip8130Deployment, toSmartAccount } from 'viem/experimental/eip8130' -const account = await toSmartAccount8130({ +const account = await toSmartAccount({ client, owner: privateKeyToAccount(generatePrivateKey()), userSalt: '0x0000000000000000000000000000000000000000000000000000000000000001', diff --git a/site/pages/experimental/eip8130/metadata.mdx b/site/pages/experimental/eip8130/metadata.mdx index 1d80c0c013..c050456005 100644 --- a/site/pages/experimental/eip8130/metadata.mdx +++ b/site/pages/experimental/eip8130/metadata.mdx @@ -14,14 +14,14 @@ Use it to bind off-chain context to a transaction — a payer `policyId`, a clie ## Setting metadata -`metadata` is a field on the transaction, not a parameter of `sendCalls8130`. Build the transaction with `prepareTransaction8130`, set `metadata`, then sign and submit: +`metadata` is a field on the transaction, not a parameter of `sendCalls`. Build the transaction with `prepareTransaction`, set `metadata`, then sign and submit: ```ts import { sendRawTransaction } from 'viem/actions' -import { prepareTransaction8130 } from 'viem/experimental/eip8130' +import { prepareTransaction } from 'viem/experimental/eip8130' import { stringToHex } from 'viem' -const tx = await prepareTransaction8130(client, { +const tx = await prepareTransaction(client, { account, calls: [[{ to: recipient, data }]], gas: 250_000n, @@ -42,9 +42,9 @@ The value round-trips onto the receipt: ```ts import { hexToString } from 'viem' -import { waitForTransactionReceipt8130 } from 'viem/experimental/eip8130' +import { waitForTransactionReceipt } from 'viem/experimental/eip8130' -const receipt = await waitForTransactionReceipt8130(client, { hash }) +const receipt = await waitForTransactionReceipt(client, { hash }) const raw = receipt.eip8130.metadata // e.g. '0x7b22...' if (raw && raw !== '0x') { const decoded = JSON.parse(hexToString(raw)) diff --git a/site/pages/experimental/eip8130/payer-services.mdx b/site/pages/experimental/eip8130/payer-services.mdx index ecaf0d874e..c3549dc792 100644 --- a/site/pages/experimental/eip8130/payer-services.mdx +++ b/site/pages/experimental/eip8130/payer-services.mdx @@ -134,9 +134,9 @@ Prepare the transaction with the offer's gas, sign `sender_auth` with `payer` na ```ts import { hexToBigInt } from 'viem' -import { prepareTransaction8130 } from 'viem/experimental/eip8130' +import { prepareTransaction } from 'viem/experimental/eip8130' -const tx = await prepareTransaction8130(client, { +const tx = await prepareTransaction(client, { account, calls, gas: hexToBigInt(terms.gasEstimate!.gasLimit), diff --git a/site/pages/experimental/eip8130/receipts.mdx b/site/pages/experimental/eip8130/receipts.mdx index 8b9a8f353e..99ac21ac80 100644 --- a/site/pages/experimental/eip8130/receipts.mdx +++ b/site/pages/experimental/eip8130/receipts.mdx @@ -4,14 +4,14 @@ description: Read EIP-8130 receipt fields — per-phase statuses, payer, and met # Receipts -An `AA_TX_TYPE` (`0x79`) receipt carries extra fields beyond a standard receipt: the resolved gas **payer**, the **per-phase execution statuses**, and the echoed **metadata**. `getTransactionReceipt8130` and `waitForTransactionReceipt8130` surface these under a parsed `eip8130` property while still returning the raw receipt. +An `AA_TX_TYPE` (`0x79`) receipt carries extra fields beyond a standard receipt: the resolved gas **payer**, the **per-phase execution statuses**, and the echoed **metadata**. `getTransactionReceipt` and `waitForTransactionReceipt` surface these under a parsed `eip8130` property while still returning the raw receipt. ## Wait for a receipt ```ts -import { waitForTransactionReceipt8130 } from 'viem/experimental/eip8130' +import { waitForTransactionReceipt } from 'viem/experimental/eip8130' -const receipt = await waitForTransactionReceipt8130(client, { hash }) +const receipt = await waitForTransactionReceipt(client, { hash }) receipt.status // top-level tx status ('0x1' / '0x0') receipt.eip8130.payer // who paid gas (sender for self-pay) @@ -25,17 +25,17 @@ It polls `eth_getTransactionReceipt` until the transaction is mined (default eve Any transaction with a non-zero `expiry` (sequenced or nonce-free) can no longer land once the chain's latest block timestamp passes it. Pass the `expiry` you signed with and the wait rejects with a `TransactionExpiredError` the moment it lapses, instead of silently spinning until the timeout. -Thread the resolved `expiry` out of `sendCalls8130` with `onTransaction` — this is reliable because it's the exact value the tx was signed with (including auto-computed nonce-free expiries), and it doesn't depend on the node returning a pending transaction: +Thread the resolved `expiry` out of `sendCalls` with `onTransaction` — this is reliable because it's the exact value the tx was signed with (including auto-computed nonce-free expiries), and it doesn't depend on the node returning a pending transaction: ```ts import { TransactionExpiredError, - sendCalls8130, - waitForTransactionReceipt8130, + sendCalls, + waitForTransactionReceipt, } from 'viem/experimental/eip8130' let expiry: bigint | undefined -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account, calls, gas, @@ -43,7 +43,7 @@ const hash = await sendCalls8130(client, { }) try { - const receipt = await waitForTransactionReceipt8130(client, { hash, expiry }) + const receipt = await waitForTransactionReceipt(client, { hash, expiry }) } catch (e) { if (e instanceof TransactionExpiredError) { // tx can never land — resubmit with a fresh `expiry` @@ -55,12 +55,12 @@ If you omit `expiry`, the wait still tries to read it off the pending transactio ## Fetch without waiting -`getTransactionReceipt8130` returns `null` if the receipt is not yet available: +`getTransactionReceipt` returns `null` if the receipt is not yet available: ```ts -import { getTransactionReceipt8130 } from 'viem/experimental/eip8130' +import { getTransactionReceipt } from 'viem/experimental/eip8130' -const receipt = await getTransactionReceipt8130(client, { hash }) +const receipt = await getTransactionReceipt(client, { hash }) if (receipt) console.log(receipt.eip8130.phaseStatuses) ``` @@ -73,9 +73,9 @@ The `eip8130` fields are only populated for `AA_TX_TYPE` receipts on a node with `allPhasesSucceeded` collapses this to a single boolean: ```ts -import { allPhasesSucceeded, waitForTransactionReceipt8130 } from 'viem/experimental/eip8130' +import { allPhasesSucceeded, waitForTransactionReceipt } from 'viem/experimental/eip8130' -const receipt = await waitForTransactionReceipt8130(client, { hash }) +const receipt = await waitForTransactionReceipt(client, { hash }) if (!allPhasesSucceeded(receipt.eip8130)) { // The tx was still included and the fee was paid, but a phase reverted. const failedPhase = receipt.eip8130.phaseStatuses?.findIndex((s) => s !== '0x1') diff --git a/site/pages/experimental/eip8130/rotating-owners.mdx b/site/pages/experimental/eip8130/rotating-owners.mdx index 2e8c09c85b..f306046856 100644 --- a/site/pages/experimental/eip8130/rotating-owners.mdx +++ b/site/pages/experimental/eip8130/rotating-owners.mdx @@ -48,10 +48,10 @@ An unrestricted (full-owner) actor uses scope `0`. Every config change is signed against the account's **next** config sequence. Read it first to avoid sequence-mismatch rejections: ```ts -import { getConfigSequence8130, getEip8130Deployment } from 'viem/experimental/eip8130' +import { getConfigSequence, getEip8130Deployment } from 'viem/experimental/eip8130' const { accountConfiguration } = getEip8130Deployment(client.chain.id)! -const { local: sequence } = await getConfigSequence8130(client, { +const { local: sequence } = await getConfigSequence(client, { accountConfiguration, account: account.address, }) @@ -66,7 +66,7 @@ import { actorScope, authorizeActor, key, - sendCalls8130, + sendCalls, } from 'viem/experimental/eip8130' const change = await account.change( @@ -78,7 +78,7 @@ const change = await account.change( { chainId: client.chain.id, sequence: Number(sequence) }, ) -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account, accountChanges: [change], calls: [], // config-only transaction @@ -117,19 +117,19 @@ import { authorizeActor, canonicalEip8130Deployment, key, - sendCalls8130, - toEoa8130Account, + sendCalls, + toEoaAccount, } from 'viem/experimental/eip8130' import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -const account = toEoa8130Account(privateKeyToAccount(generatePrivateKey())) +const account = toEoaAccount(privateKeyToAccount(generatePrivateKey())) const addP256 = await account.change( [authorizeActor(key.p256({ x, y }), { scope: actorScope.sender })], { chainId: client.chain.id, sequence: 0 }, ) -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account, accountChanges: [ account.delegate(canonicalEip8130Deployment.accounts.default), diff --git a/site/pages/experimental/eip8130/sending-a-transaction.mdx b/site/pages/experimental/eip8130/sending-a-transaction.mdx index 1d3691b753..8c605f869c 100644 --- a/site/pages/experimental/eip8130/sending-a-transaction.mdx +++ b/site/pages/experimental/eip8130/sending-a-transaction.mdx @@ -4,17 +4,17 @@ description: Estimate, sign, and submit an EIP-8130 AA_TX_TYPE transaction # Sending a Transaction -An EIP-8130 transaction is an `AA_TX_TYPE` (`0x79`) envelope. `sendCalls8130` handles the full lifecycle: it fills the nonce and fees, encodes the calls, signs `sender_auth` (and `payer_auth` when sponsored), serializes, and submits via `eth_sendRawTransaction`. +An EIP-8130 transaction is an `AA_TX_TYPE` (`0x79`) envelope. `sendCalls` handles the full lifecycle: it fills the nonce and fees, encodes the calls, signs `sender_auth` (and `payer_auth` when sponsored), serializes, and submits via `eth_sendRawTransaction`. ## Estimate gas -Unlike EVM transactions, the gas budget for an `AA_TX_TYPE` transaction is node-computed and required up front. `estimateGas8130` prices authentication gas from the *shape* of the auth blob — never a real signature — so you can estimate before signing. Pass a `senderAuthVerifier` hint matching your signer so the node prices the right authenticator: +Unlike EVM transactions, the gas budget for an `AA_TX_TYPE` transaction is node-computed and required up front. `estimateGas` prices authentication gas from the *shape* of the auth blob — never a real signature — so you can estimate before signing. Pass a `senderAuthVerifier` hint matching your signer so the node prices the right authenticator: ```ts import { parseEther } from 'viem' -import { canonicalAuthenticators, estimateGas8130 } from 'viem/experimental/eip8130' +import { canonicalAuthenticators, estimateGas } from 'viem/experimental/eip8130' -const gas = await estimateGas8130(client, { +const gas = await estimateGas(client, { sender: account.address, // Include `createChange` only on the first (deploying) transaction. accountChanges: [account.createChange], @@ -40,34 +40,34 @@ An EIP-8130 estimate returns the charged gas **even when an inner call reverts** ## Deploy on first use -Deploy the account and run its first calls in a single transaction by including `account.createChange` in `accountChanges`. `waitForTransactionReceipt8130` surfaces the EIP-8130 receipt fields (per-phase statuses, `payer`, `metadata`). +Deploy the account and run its first calls in a single transaction by including `account.createChange` in `accountChanges`. `waitForTransactionReceipt` surfaces the EIP-8130 receipt fields (per-phase statuses, `payer`, `metadata`). ```ts import { parseEther } from 'viem' import { canonicalAuthenticators, - estimateGas8130, - sendCalls8130, - waitForTransactionReceipt8130, + estimateGas, + sendCalls, + waitForTransactionReceipt, } from 'viem/experimental/eip8130' const calls = [{ to: recipient, value: parseEther('0.001') }] -const gas = await estimateGas8130(client, { +const gas = await estimateGas(client, { sender: account.address, accountChanges: [account.createChange], calls: [calls], senderAuthVerifier: canonicalAuthenticators.k1, }) -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account, accountChanges: [account.createChange], // deploy — omit on later txs calls, gas: (gas * 120n) / 100n, }) -const receipt = await waitForTransactionReceipt8130(client, { hash }) +const receipt = await waitForTransactionReceipt(client, { hash }) receipt.eip8130.phaseStatuses // e.g. ['0x1'] ``` @@ -77,14 +77,14 @@ Once the account is deployed, drop `accountChanges` and just pass `calls`. The n ```ts import { encodeFunctionData } from 'viem' -import { estimateGas8130, sendCalls8130 } from 'viem/experimental/eip8130' +import { estimateGas, sendCalls } from 'viem/experimental/eip8130' -const gas = await estimateGas8130(client, { +const gas = await estimateGas(client, { sender: account.address, calls: [[{ to: token, data: transferData }]], }) -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account, calls: [{ to: token, data: transferData }], gas: (gas * 120n) / 100n, @@ -96,9 +96,9 @@ const hash = await sendCalls8130(client, { A flat `calls` array runs as one atomic phase. Pass a nested array to control phases explicitly (each phase is a separate `executeBatch`) — see [Calls & Batching](/experimental/eip8130/calls-and-batching) for the full phased model: ```ts -import { sendCalls8130 } from 'viem/experimental/eip8130' +import { sendCalls } from 'viem/experimental/eip8130' -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account, calls: [ { to: tokenA, data: approveData }, @@ -113,9 +113,9 @@ const hash = await sendCalls8130(client, { A `payer` account can co-sign the transaction and pay its gas — either a key you hold or a payer web service: ```ts -import { sendCalls8130 } from 'viem/experimental/eip8130' +import { sendCalls } from 'viem/experimental/eip8130' -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account, calls: [{ to: recipient, data }], gas: 250_000n, @@ -127,9 +127,9 @@ See [Sponsoring Transactions](/experimental/eip8130/sponsoring-transactions) for ## Lower-level control -`sendCalls8130` composes two primitives you can use directly: +`sendCalls` composes two primitives you can use directly: -- `prepareTransaction8130(client, params)` — fills chain id, nonce sequence, and EIP-1559 fees into a `TransactionSerializable8130`. +- `prepareTransaction(client, params)` — fills chain id, nonce sequence, and EIP-1559 fees into a `TransactionSerializable8130`. - `account.signTransaction(tx, { payer })` — produces the `sender_auth`/`payer_auth` blobs and returns the serialized envelope, which you can submit with `sendRawTransaction`. ## Next diff --git a/site/pages/experimental/eip8130/session-keys.mdx b/site/pages/experimental/eip8130/session-keys.mdx index ecadde2aca..0f5eba7c0b 100644 --- a/site/pages/experimental/eip8130/session-keys.mdx +++ b/site/pages/experimental/eip8130/session-keys.mdx @@ -68,7 +68,7 @@ import { actorScope, authorizeActor, key, - sendCalls8130, + sendCalls, } from 'viem/experimental/eip8130' const sessionKey = key.p256({ x, y }) @@ -83,7 +83,7 @@ const change = await account.change( { chainId: client.chain.id, sequence: Number(sequence) }, ) -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account, accountChanges: [change], // The install call initializes the binding — it must land before first use. @@ -100,13 +100,13 @@ Build the account with the session signer and send an `executeCall`. The manager import { encodeFunctionData, erc20Abi, parseUnits } from 'viem' import { encodeSessionPolicyAction, - newSmartAccount8130, - sendCalls8130, + newSmartAccount, + sendCalls, toP256Signer, } from 'viem/experimental/eip8130' // Same account address, driven by the session key. -const sessionAccount = newSmartAccount8130({ +const sessionAccount = newSmartAccount({ signer: toP256Signer({ privateKey: sessionPrivateKey }), salt: accountSalt, }) @@ -117,7 +117,7 @@ const transfer = encodeFunctionData({ args: [recipient, parseUnits('10', 6)], }) -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account: sessionAccount, calls: [ session.executeCall( diff --git a/site/pages/experimental/eip8130/sponsoring-transactions.mdx b/site/pages/experimental/eip8130/sponsoring-transactions.mdx index 0bb683c841..961852f78d 100644 --- a/site/pages/experimental/eip8130/sponsoring-transactions.mdx +++ b/site/pages/experimental/eip8130/sponsoring-transactions.mdx @@ -18,15 +18,15 @@ There are two ways to obtain `payer_auth`: ## Co-signing locally -Pass a `payer` signer to `sendCalls8130`. It signs `payer_auth` bound to the sender and sets the `payer` wire field. Set `payer.address` when the onchain payer account differs from the signing key. +Pass a `payer` signer to `sendCalls`. It signs `payer_auth` bound to the sender and sets the `payer` wire field. Set `payer.address` when the onchain payer account differs from the signing key. ```ts import { privateKeyToAccount } from 'viem/accounts' -import { sendCalls8130 } from 'viem/experimental/eip8130' +import { sendCalls } from 'viem/experimental/eip8130' const sponsor = privateKeyToAccount(process.env.SPONSOR_KEY as `0x${string}`) -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account, // signs sender_auth calls: [{ to: recipient, data }], gas: 250_000n, @@ -39,9 +39,9 @@ const hash = await sendCalls8130(client, { Pass the `payer` address (and, if the payer uses a non-K1 key, `payerAuthVerifier`) so the node prices the payer authentication too: ```ts -import { canonicalAuthenticators, estimateGas8130 } from 'viem/experimental/eip8130' +import { canonicalAuthenticators, estimateGas } from 'viem/experimental/eip8130' -const gas = await estimateGas8130(client, { +const gas = await estimateGas(client, { sender: account.address, calls: [[{ to: recipient, data }]], payer: sponsor.address, @@ -55,9 +55,9 @@ To charge the sender in an ERC-20 while the payer fronts native gas, run a two-p ```ts import { encodeTokenTransfer } from 'viem/experimental/eip8168' -import { sendCalls8130 } from 'viem/experimental/eip8130' +import { sendCalls } from 'viem/experimental/eip8130' -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account, calls: [ // phase 0 — pay the payer in USDC @@ -74,13 +74,13 @@ In practice the token amount and payer are negotiated with a [payer service](/ex ## Lower-level control -`sendCalls8130` wraps two primitives when you need to inspect or persist the transaction between signing and submitting: +`sendCalls` wraps two primitives when you need to inspect or persist the transaction between signing and submitting: ```ts import { sendRawTransaction } from 'viem/actions' -import { prepareTransaction8130 } from 'viem/experimental/eip8130' +import { prepareTransaction } from 'viem/experimental/eip8130' -const tx = await prepareTransaction8130(client, { +const tx = await prepareTransaction(client, { account, calls: /* AaCalls (phased) */ phases, gas: 250_000n, diff --git a/site/pages/experimental/eip8130/sub-accounts.mdx b/site/pages/experimental/eip8130/sub-accounts.mdx index 6524f8dc08..529401f1d7 100644 --- a/site/pages/experimental/eip8130/sub-accounts.mdx +++ b/site/pages/experimental/eip8130/sub-accounts.mdx @@ -15,14 +15,14 @@ The account address is a deterministic function of the salt (plus the wallet cod ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { newSmartAccount8130 } from 'viem/experimental/eip8130' +import { newSmartAccount } from 'viem/experimental/eip8130' const owner = privateKeyToAccount(generatePrivateKey()) // A stable, human-meaningful salt derivation is convenient here. -const main = newSmartAccount8130({ signer: owner, salt: saltFor('main') }) -const trading = newSmartAccount8130({ signer: owner, salt: saltFor('trading') }) -const savings = newSmartAccount8130({ signer: owner, salt: saltFor('savings') }) +const main = newSmartAccount({ signer: owner, salt: saltFor('main') }) +const trading = newSmartAccount({ signer: owner, salt: saltFor('trading') }) +const savings = newSmartAccount({ signer: owner, salt: saltFor('savings') }) main.address !== trading.address // distinct accounts, same owner key ``` @@ -38,7 +38,7 @@ import { actorScope, authorizeActor, key, - sendCalls8130, + sendCalls, } from 'viem/experimental/eip8130' // On the sub account, authorize the primary account as a delegate. @@ -51,7 +51,7 @@ const link = await subAccount.change( { chainId: client.chain.id, sequence: Number(sequence) }, ) -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account: subAccount, accountChanges: [link], calls: [], @@ -64,18 +64,18 @@ Once linked, build an account handle that signs for the sub account using the ** ```ts import { canonicalAuthenticators, - sendCalls8130, - to8130Account, + sendCalls, + toAccount, } from 'viem/experimental/eip8130' // Drive `subAccount.address` with `main`'s key through the delegate authenticator. -const subAsDelegate = to8130Account({ +const subAsDelegate = toAccount({ signer: main.signer, authenticator: canonicalAuthenticators.delegate, address: subAccount.address, }) -const hash = await sendCalls8130(client, { +const hash = await sendCalls(client, { account: subAsDelegate, calls: [{ to: recipient, value: 1n }], gas: 200_000n, @@ -83,7 +83,7 @@ const hash = await sendCalls8130(client, { ``` :::note -The delegate authenticator validates a signature produced for the linked account, so authentication gas is priced from the delegate blob's shape. Pass `senderAuthVerifier: canonicalAuthenticators.delegate` to [`estimateGas8130`](/experimental/eip8130/sending-a-transaction#estimate-gas) when pricing delegate-signed transactions. +The delegate authenticator validates a signature produced for the linked account, so authentication gas is priced from the delegate blob's shape. Pass `senderAuthVerifier: canonicalAuthenticators.delegate` to [`estimateGas`](/experimental/eip8130/sending-a-transaction#estimate-gas) when pricing delegate-signed transactions. ::: ## Revoking a link diff --git a/src/actions/public/getTransaction.ts b/src/actions/public/getTransaction.ts index 13a6d1b966..a420e12a0f 100644 --- a/src/actions/public/getTransaction.ts +++ b/src/actions/public/getTransaction.ts @@ -186,7 +186,7 @@ export async function getTransaction< to: null, value: '0x0', input: '0x', - // EIP-8130 extra fields (preserved as-is for `getTransaction8130`). + // EIP-8130 extra fields (preserved as-is for the eip8130 `getTransaction`). nonceKey: body.nonceKey, expiry: body.expiry, calls: body.calls, diff --git a/src/experimental/eip8130/abis.ts b/src/experimental/eip8130/abis.ts index 2df93e62f8..585a484eb5 100644 --- a/src/experimental/eip8130/abis.ts +++ b/src/experimental/eip8130/abis.ts @@ -5,14 +5,15 @@ import { parseAbi } from 'abitype' * (`IAccountConfiguration`) at `ACCOUNT_CONFIG_ADDRESS`. */ export const accountConfigurationAbi = parseAbi([ - 'struct InitialActor { bytes32 actorId; address authenticator; uint8 scope; bytes policyData; }', - 'struct ActorConfig { address authenticator; uint8 scope; uint48 expiry; }', + 'struct InitialActor { bytes32 actorId; address authenticator; uint16 scope; bytes policyData; }', + 'struct ActorConfig { address authenticator; uint48 expiry; uint16 scope; }', 'struct Actor { bytes32 actorId; ActorConfig config; bytes policyData; }', - 'struct ActorChange { uint8 changeType; bytes32 actorId; bytes data; }', - 'struct ChangeSequences { uint64 multichain; uint64 local; }', + 'struct AccountChange { uint8 changeType; bytes payload; }', + 'struct SignedAccountChanges { uint8 channel; uint64 sequence; AccountChange[] changes; bytes signature; }', + 'struct ChangeSequences { uint64 multichain; uint32 localEpoch; uint32 localSequence; }', - // `actorData` is tightly packed: authenticator(20) || scope(1) || expiry(6) || - // reserved(5 zero bytes) = 32 bytes, plus manager(20) || commitment(32) when + // `actorData` is tightly packed: authenticator(20) || expiry(6) || scope(2) || + // reserved(4 zero bytes) = 32 bytes, plus manager(20) || commitment(32) when // scope & SCOPE_POLICY != 0 (84 bytes total). Policy presence is the // SCOPE_POLICY bit — there is no `policyType` field. 'event ActorAuthorized(address indexed account, bytes32 indexed actorId, bytes actorData)', @@ -26,10 +27,9 @@ export const accountConfigurationAbi = parseAbi([ 'function createAccount(bytes32 userSalt, bytes bytecode, InitialActor[] initialActors) returns (address)', 'function computeAddress(bytes32 userSalt, bytes bytecode, InitialActor[] initialActors) view returns (address)', 'function importAccount(address account, uint256 chainId, InitialActor[] initialActors, bytes signature)', - 'function applySignedActorChanges(address account, uint256 chainId, ActorChange[] actorChanges, bytes auth)', - 'function applySignedLockChanges(address account, uint8 op, uint16 unlockDelay, bytes auth)', + 'function applySignedAccountChanges(address account, SignedAccountChanges s)', 'function verifySignature(address account, bytes32 hash, bytes signature) view returns (bool verified)', - 'function authenticateActor(address account, bytes32 hash, bytes auth) view returns (bytes32 actorId, uint8 scope, address policyTarget)', + 'function authenticateActor(address account, bytes32 hash, bytes auth) view returns (bytes32 actorId, uint16 scope)', 'function isActor(address account, bytes32 actorId) view returns (bool)', 'function getActorConfig(address account, bytes32 actorId) view returns (ActorConfig)', 'function getPolicy(address account, bytes32 actorId) view returns (address target, bytes32 commitment)', diff --git a/src/experimental/eip8130/accounts/to8130Account.ts b/src/experimental/eip8130/accounts/toAccount.ts similarity index 88% rename from src/experimental/eip8130/accounts/to8130Account.ts rename to src/experimental/eip8130/accounts/toAccount.ts index a7f9ad8bd6..ce2429c8a3 100644 --- a/src/experimental/eip8130/accounts/to8130Account.ts +++ b/src/experimental/eip8130/accounts/toAccount.ts @@ -17,20 +17,21 @@ import type { AaAccountChangeCreate, AaAccountChangeDelegation, AaActor, - AaActorChange, + AaChange, + AaChangeChannel, TransactionSerializable8130, TransactionSerialized8130, } from '../types/transaction.js' -import { computeAddress8130 } from '../utils/computeAddress.js' +import { computeAddress } from '../utils/computeAddress.js' import { erc1167Bytecode, upgradeableProxyBytecode } from '../utils/proxy.js' -import { signActorChanges8130 } from '../utils/signActorChanges.js' -import { type Signer, signTransaction8130 } from '../utils/signTransaction.js' +import { signAccountChanges } from '../utils/signActorChanges.js' +import { type Signer, signTransaction } from '../utils/signTransaction.js' /** - * Common base params shared by both `to8130Account` shapes. + * Common base params shared by both `toAccount` shapes. * @internal */ -type To8130AccountBase = { +type ToAccountBase = { /** Signer that produces `sender_auth` / `auth` blobs for this account. */ signer: Signer /** @@ -42,7 +43,7 @@ type To8130AccountBase = { /** * Scope bitmask of the **signing actor** on this account (see * {@link actorScope}). Prefer omitting this once the actor is on-chain — - * {@link prepareTransaction8130} reads `getActorConfig` and derives nonce + * {@link prepareTransaction} reads `getActorConfig` and derives nonce * mode from chain truth. When set on an already-bound actor, it must match * the on-chain value or prepare throws {@link ScopeMismatchError}. * @@ -59,7 +60,7 @@ type To8130AccountBase = { } /** - * Parameters for `to8130Account` — two mutually exclusive shapes: + * Parameters for `toAccount` — two mutually exclusive shapes: * * **Smart-account shape** (`userSalt` + `code` + `initialActors`): derives the * counterfactual CREATE2 address and exposes `create()` for first-deployment. @@ -69,7 +70,7 @@ type To8130AccountBase = { * in the first transaction's `accountChanges` instead. No `userSalt`, `code`, or * `initialActors` are needed. */ -export type To8130AccountParameters = To8130AccountBase & +export type ToAccountParameters = ToAccountBase & ( | { /** @@ -105,14 +106,14 @@ export type To8130AccountParameters = To8130AccountBase & } ) -export type To8130AccountReturnType = { +export type ToAccountReturnType = { readonly address: Address readonly signer: Signer readonly initialActors: readonly AaActor[] /** * Scope of the signing actor, when known off-chain. Prefer leaving this * unset once the actor is authorized — prepare reads chain truth instead. - * See {@link prepareTransaction8130}. + * See {@link prepareTransaction}. */ readonly scope?: number | undefined /** @@ -128,10 +129,14 @@ export type To8130AccountReturnType = { * (e.g. delegated EOA) — use `delegate(impl)` instead. */ create(): AaAccountChangeCreate - /** Signs an `authorizeActor` / `revokeActor` set into a `config` entry. */ + /** Signs a `SignedAccountChanges` batch into a `config` entry. */ change( - actorChanges: readonly AaActorChange[], - options?: { chainId?: number; sequence?: number }, + changes: readonly AaChange[], + options?: { + channel?: AaChangeChannel + chainId?: number + sequence?: bigint + }, ): Promise /** * Builds an EIP-7702 `delegation` account-change entry. @@ -153,13 +158,13 @@ export type To8130AccountReturnType = { * * **Smart-account** — supply `userSalt + code + initialActors`: * ```ts - * const account = to8130Account({ signer, userSalt, code, initialActors }) + * const account = toAccount({ signer, userSalt, code, initialActors }) * // first tx: accountChanges: [account.create()] * ``` * * **Delegated EOA** — supply `address` only (no salt, no code, no actors): * ```ts - * const account = to8130Account({ signer, address: eoaSigner.address }) + * const account = toAccount({ signer, address: eoaSigner.address }) * // first tx: accountChanges: [account.delegate(deployment.accounts.default)] * // add keys: accountChanges: [account.delegate(...), await account.change([...])] * ``` @@ -167,19 +172,19 @@ export type To8130AccountReturnType = { * For the P256 / WebAuthn actor to drive the EOA after delegation, construct * a second handle with the new signer but the same `address`: * ```ts - * const accountAsP256 = to8130Account({ + * const accountAsP256 = toAccount({ * signer: p256, * authenticator: p256.authenticator, * address: eoaSigner.address, * }) * ``` * - * For pure EOA K1 signing (no contract, raw 65-byte sig) see {@link toEoa8130Account}. - * For a new smart account with auto-derived address see {@link newSmartAccount8130}. + * For pure EOA K1 signing (no contract, raw 65-byte sig) see {@link toEoaAccount}. + * For a new smart account with auto-derived address see {@link newSmartAccount}. */ -export function to8130Account( - parameters: To8130AccountParameters, -): To8130AccountReturnType { +export function toAccount( + parameters: ToAccountParameters, +): ToAccountReturnType { const { signer, authenticator = ecrecoverAuthenticator, scope } = parameters // Address-only mode (delegated EOA): address is fixed, no CREATE2 derivation. @@ -194,7 +199,7 @@ export function to8130Account( throw new BaseError( 'Provide `address` or `userSalt + code + initialActors` to derive the account address.', ) - return computeAddress8130({ + return computeAddress({ userSalt: parameters.userSalt!, code: parameters.code!, initialActors: parameters.initialActors!, @@ -235,13 +240,14 @@ export function to8130Account( } }, - async change(actorChanges, options = {}) { - return signActorChanges8130({ + async change(changes, options = {}) { + return signAccountChanges({ signer, account: address, + channel: options.channel ?? 'local', chainId: options.chainId ?? 0, - sequence: options.sequence ?? 0, - actorChanges, + sequence: options.sequence ?? 0n, + changes, authenticator, }) }, @@ -253,7 +259,7 @@ export function to8130Account( async signTransaction(transaction, options = {}) { if (!signer.sign) throw new BaseError('`signer` does not support raw signing.') - return signTransaction8130({ + return signTransaction({ transaction: { ...transaction, from: transaction.from ?? address }, account: signer, authenticator, @@ -264,10 +270,10 @@ export function to8130Account( } // ───────────────────────────────────────────────────────────────────────────── -// newSmartAccount8130 +// newSmartAccount // ───────────────────────────────────────────────────────────────────────────── -export type NewSmartAccount8130Parameters = { +export type NewSmartAccountParameters = { /** * The signing key for this account's controlling actor. * @@ -312,13 +318,13 @@ export type NewSmartAccount8130Parameters = { accountConfigAddress?: Address | undefined } -export type NewSmartAccount8130ReturnType = To8130AccountReturnType & { +export type NewSmartAccountReturnType = ToAccountReturnType & { /** * The `create` account-change entry — include in `accountChanges` for the * first transaction to deploy this account. * * @example - * const gas = await estimateGas8130(client, { + * const gas = await estimateGas(client, { * from: account.address, * accountChanges: [account.createChange], * calls: [[{ to: recipient, value: parseEther('0.01') }]], @@ -344,21 +350,21 @@ export type NewSmartAccount8130ReturnType = To8130AccountReturnType & { * * @example * // K1 (EOA private key) - * const account = newSmartAccount8130({ signer: privateKeyToAccount(pk) }) + * const account = newSmartAccount({ signer: privateKeyToAccount(pk) }) * * @example * // P-256 * const p256 = toP256Signer({ privateKey: P256.randomPrivateKey() }) - * const account = newSmartAccount8130({ signer: p256 }) + * const account = newSmartAccount({ signer: p256 }) * * @example * // WebAuthn / passkey * const webAuthn = toWebAuthnSigner(toWebAuthnAccount({ credential })) - * const account = newSmartAccount8130({ signer: webAuthn }) + * const account = newSmartAccount({ signer: webAuthn }) * * @example * // First tx: create + call in one shot - * const gas = await estimateGas8130(client, { + * const gas = await estimateGas(client, { * from: account.address, * accountChanges: [account.createChange], * calls: [[{ to: recipient, value }]], @@ -372,9 +378,9 @@ export type NewSmartAccount8130ReturnType = To8130AccountReturnType & { * maxPriorityFeePerGas: 1_000_000n, * }) */ -export function newSmartAccount8130( - parameters: NewSmartAccount8130Parameters, -): NewSmartAccount8130ReturnType { +export function newSmartAccount( + parameters: NewSmartAccountParameters, +): NewSmartAccountReturnType { const { signer, implementation, @@ -421,7 +427,7 @@ export function newSmartAccount8130( } } - const inner = to8130Account({ + const inner = toAccount({ signer, userSalt: salt, code, @@ -439,10 +445,10 @@ export function newSmartAccount8130( } // ───────────────────────────────────────────────────────────────────────────── -// toEoa8130Account +// toEoaAccount // ───────────────────────────────────────────────────────────────────────────── -export type ToEoa8130AccountParameters = { +export type ToEoaAccountParameters = { /** * Scope of the EOA's implicit self-actor. Defaults to admin * ({@link scopeUnrestricted}), which may use ordered *or* nonce-free nonces @@ -451,14 +457,14 @@ export type ToEoa8130AccountParameters = { scope?: number | undefined } -export type ToEoa8130AccountReturnType = { +export type ToEoaAccountReturnType = { /** The EOA address — both the sender identity and the key recovery target. */ readonly address: Address readonly signer: Signer /** * Scope of the implicit self-actor (admin by default). Drives automatic * nonce-mode selection: admin may use ordered *or* nonce-free, so sends - * default to ordered (sequenced) mode. See {@link prepareTransaction8130}. + * default to ordered (sequenced) mode. See {@link prepareTransaction}. */ readonly scope?: number | undefined /** @@ -489,8 +495,12 @@ export type ToEoa8130AccountReturnType = { * }) */ change( - actorChanges: readonly AaActorChange[], - options?: { chainId?: number; sequence?: number }, + changes: readonly AaChange[], + options?: { + channel?: AaChangeChannel + chainId?: number + sequence?: bigint + }, ): Promise /** * Signs an EIP-8130 transaction using the EOA implicit self-actor path. @@ -519,9 +529,9 @@ export type ToEoa8130AccountReturnType = { * the first tx's `accountChanges`). Both cases use the same signing path. * * To drive the same EOA address with a **different** actor (P-256 / WebAuthn) - * after delegation, use {@link to8130Account} with `address`: + * after delegation, use {@link toAccount} with `address`: * ```ts - * const accountAsP256 = to8130Account({ + * const accountAsP256 = toAccount({ * signer: p256, * authenticator: p256.authenticator, * address: eoaSigner.address, @@ -530,22 +540,22 @@ export type ToEoa8130AccountReturnType = { * * @example * // Pure EOA — no contract, payer-sponsored - * const account = toEoa8130Account(privateKeyToAccount(pk)) + * const account = toEoaAccount(privateKeyToAccount(pk)) * const signed = await account.signTransaction({ calls: wire, payer: payerAddr, ... }) * * @example * // Delegated EOA — delegate + add P256 in one shot - * const account = toEoa8130Account(privateKeyToAccount(pk)) + * const account = toEoaAccount(privateKeyToAccount(pk)) * const addP256 = await account.change([authorizeActor(key.p256(...))], { chainId, sequence: 0 }) * const signed = await account.signTransaction({ * accountChanges: [account.delegate(deployment.accounts.default), addP256], * calls: wire, ... * }) */ -export function toEoa8130Account( +export function toEoaAccount( signer: Signer, - parameters: ToEoa8130AccountParameters = {}, -): ToEoa8130AccountReturnType { + parameters: ToEoaAccountParameters = {}, +): ToEoaAccountReturnType { if (!signer.address) throw new BaseError( '`signer.address` is required. Use `privateKeyToAccount(pk)` or equivalent.', @@ -562,13 +572,14 @@ export function toEoa8130Account( return { type: 'delegation', target } }, - async change(actorChanges, options = {}) { - return signActorChanges8130({ + async change(changes, options = {}) { + return signAccountChanges({ signer, account: address, + channel: options.channel ?? 'local', chainId: options.chainId ?? 0, - sequence: options.sequence ?? 0, - actorChanges, + sequence: options.sequence ?? 0n, + changes, authenticator: ecrecoverAuthenticator, }) }, @@ -576,7 +587,7 @@ export function toEoa8130Account( async signTransaction(transaction, options = {}) { if (!signer.sign) throw new BaseError('`signer` does not support raw signing.') - return signTransaction8130({ + return signTransaction({ // Omit `from` → EOA implicit self-actor path: // senderAuth = raw 65-byte sig, sender recovered via ecrecover. transaction, @@ -589,10 +600,10 @@ export function toEoa8130Account( } // ───────────────────────────────────────────────────────────────────────────── -// toDelegate8130Signer +// toDelegateSigner // ───────────────────────────────────────────────────────────────────────────── -export type ToDelegate8130SignerParameters = { +export type ToDelegateSignerParameters = { /** * The delegate (parent) account address that controls the sub-account. The * sub-account must have a `key.delegate(delegateAccount)` actor authorized @@ -620,17 +631,17 @@ export type ToDelegate8130SignerParameters = { * sub-account through the `DelegateAuthenticator` (one delegation hop). The * produced `sign` returns the delegate authenticator's `data` payload — * `delegateAccount(20) ‖ nestedAuthenticator(20) ‖ nestedSignature` — so that - * `to8130Account`'s configured-actor path serializes the full `senderAuth` as + * `toAccount`'s configured-actor path serializes the full `senderAuth` as * `DELEGATE_AUTHENTICATOR ‖ data`. * - * Use it as the `signer` (and pass its `authenticator`) to {@link to8130Account} + * Use it as the `signer` (and pass its `authenticator`) to {@link toAccount} * for an account whose only owner is `key.delegate(parent)`: * ```ts - * const delegateSigner = toDelegate8130Signer({ + * const delegateSigner = toDelegateSigner({ * delegateAccount: parent.address, * nestedSigner: parentAdmin, // an admin (scope 0) owner of the parent * }) - * const sub = to8130Account({ + * const sub = toAccount({ * signer: delegateSigner, * authenticator: delegateSigner.authenticator, // DelegateAuthenticator * userSalt, code, @@ -642,8 +653,8 @@ export type ToDelegate8130SignerParameters = { * The parent account MUST be deployed (its admin actor config must be on-chain) * before the delegate vouch can be validated. */ -export function toDelegate8130Signer( - parameters: ToDelegate8130SignerParameters, +export function toDelegateSigner( + parameters: ToDelegateSignerParameters, ): Signer { const { delegateAccount, diff --git a/src/experimental/eip8130/accounts/toSmartAccount8130.test.ts b/src/experimental/eip8130/accounts/toSmartAccount.test.ts similarity index 88% rename from src/experimental/eip8130/accounts/toSmartAccount8130.test.ts rename to src/experimental/eip8130/accounts/toSmartAccount.test.ts index f627693fcd..3b6730e914 100644 --- a/src/experimental/eip8130/accounts/toSmartAccount8130.test.ts +++ b/src/experimental/eip8130/accounts/toSmartAccount.test.ts @@ -9,9 +9,9 @@ import { recoverMessageAddress } from '../../../utils/signature/recoverMessageAd import { accountConfigurationAbi } from '../abis.js' import { ecrecoverAuthenticator } from '../constants.js' import type { AaActor } from '../types/transaction.js' -import { computeAddress8130 } from '../utils/computeAddress.js' +import { computeAddress } from '../utils/computeAddress.js' import { erc1167Bytecode } from '../utils/proxy.js' -import { toSmartAccount8130 } from './toSmartAccount8130.js' +import { toSmartAccount } from './toSmartAccount.js' // Offline stub transports. `client` reports the account as counterfactual // (not deployed); `deployedClient` reports it as deployed (so signatures are @@ -47,11 +47,11 @@ const base = { implementation, } as const -describe('toSmartAccount8130', () => { - test('getAddress matches computeAddress8130', async () => { - const account = await toSmartAccount8130(base) +describe('toSmartAccount', () => { + test('getAddress matches computeAddress', async () => { + const account = await toSmartAccount(base) expect(await account.getAddress()).toBe( - computeAddress8130({ + computeAddress({ userSalt: base.userSalt, code: erc1167Bytecode(implementation), initialActors: base.initialActors, @@ -60,7 +60,7 @@ describe('toSmartAccount8130', () => { }) test('getFactoryArgs -> AccountConfiguration.createAccount', async () => { - const account = await toSmartAccount8130(base) + const account = await toSmartAccount(base) const { factory, factoryData } = await account.getFactoryArgs() expect(factory).toMatch(/^0x[0-9a-fA-F]{40}$/) const decoded = decodeFunctionData({ @@ -73,7 +73,7 @@ describe('toSmartAccount8130', () => { }) test('encodeCalls/decodeCalls round-trip via executeBatch', async () => { - const account = await toSmartAccount8130(base) + const account = await toSmartAccount(base) const calls = [ { to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', @@ -99,7 +99,7 @@ describe('toSmartAccount8130', () => { }) test('getStubSignature is authenticator-prefixed', async () => { - const account = await toSmartAccount8130(base) + const account = await toSmartAccount(base) const stub = await account.getStubSignature() expect(slice(stub, 0, 20).toLowerCase()).toBe( ecrecoverAuthenticator.toLowerCase(), @@ -107,7 +107,7 @@ describe('toSmartAccount8130', () => { }) test('signMessage = authenticator || recoverable ECDSA', async () => { - const account = await toSmartAccount8130({ + const account = await toSmartAccount({ ...base, client: deployedClient, }) @@ -124,7 +124,7 @@ describe('toSmartAccount8130', () => { }) test('throws without identity inputs when deriving factory args', async () => { - const account = await toSmartAccount8130({ + const account = await toSmartAccount({ client, owner, address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', diff --git a/src/experimental/eip8130/accounts/toSmartAccount8130.ts b/src/experimental/eip8130/accounts/toSmartAccount.ts similarity index 92% rename from src/experimental/eip8130/accounts/toSmartAccount8130.ts rename to src/experimental/eip8130/accounts/toSmartAccount.ts index 3765939dc6..8089793379 100644 --- a/src/experimental/eip8130/accounts/toSmartAccount8130.ts +++ b/src/experimental/eip8130/accounts/toSmartAccount.ts @@ -1,5 +1,5 @@ import type { Abi, Address } from 'abitype' -import { toSmartAccount } from '../../../account-abstraction/accounts/toSmartAccount.js' +import { toSmartAccount as toSmartAccount_ } from '../../../account-abstraction/accounts/toSmartAccount.js' import type { SmartAccount, SmartAccountImplementation, @@ -25,11 +25,11 @@ import { ecrecoverAuthenticator, } from '../constants.js' import type { AaActor } from '../types/transaction.js' -import { toFactoryArgs8130 } from '../utils/accountConfigCalls.js' -import { computeAddress8130 } from '../utils/computeAddress.js' +import { toFactoryArgs } from '../utils/accountConfigCalls.js' +import { computeAddress } from '../utils/computeAddress.js' import { erc1167Bytecode } from '../utils/proxy.js' -export type ToSmartAccount8130Parameters< +export type ToSmartAccountParameters< entryPointAbi extends Abi = Abi, entryPointVersion extends EntryPointVersion = EntryPointVersion, > = { @@ -95,7 +95,7 @@ export type Eip8130SmartAccountImplementation< { abi: typeof erc4337AccountAbi } > -export type ToSmartAccount8130ReturnType< +export type ToSmartAccountReturnType< entryPointAbi extends Abi = Abi, entryPointVersion extends EntryPointVersion = EntryPointVersion, > = Prettify< @@ -115,9 +115,9 @@ export type ToSmartAccount8130ReturnType< * `authenticator || data` auth format. * * @example - * import { toSmartAccount8130 } from 'viem/experimental' + * import { toSmartAccount } from 'viem/experimental/eip8130' * - * const account = await toSmartAccount8130({ + * const account = await toSmartAccount({ * client, * owner, * userSalt: '0x...', @@ -125,12 +125,12 @@ export type ToSmartAccount8130ReturnType< * implementation: '0x...', // ERC4337Account impl * }) */ -export async function toSmartAccount8130< +export async function toSmartAccount< entryPointAbi extends Abi = typeof entryPoint07Abi, entryPointVersion extends EntryPointVersion = '0.7', >( - parameters: ToSmartAccount8130Parameters, -): Promise> { + parameters: ToSmartAccountParameters, +): Promise> { const { client, entryPoint: entryPoint_ = { @@ -191,7 +191,7 @@ export async function toSmartAccount8130< } } - return toSmartAccount({ + return toSmartAccount_({ client, entryPoint, getNonce, @@ -225,11 +225,11 @@ export async function toSmartAccount8130< async getAddress() { if (parameters.address) return parameters.address - return computeAddress8130(getCreateParameters()) + return computeAddress(getCreateParameters()) }, async getFactoryArgs() { - return toFactoryArgs8130(getCreateParameters()) + return toFactoryArgs(getCreateParameters()) }, async getStubSignature() { diff --git a/src/experimental/eip8130/actions/estimateGas8130.test.ts b/src/experimental/eip8130/actions/estimateGas.test.ts similarity index 84% rename from src/experimental/eip8130/actions/estimateGas8130.test.ts rename to src/experimental/eip8130/actions/estimateGas.test.ts index c3ca17fc13..6b49fb3cb5 100644 --- a/src/experimental/eip8130/actions/estimateGas8130.test.ts +++ b/src/experimental/eip8130/actions/estimateGas.test.ts @@ -4,10 +4,10 @@ import { mainnet } from '../../../chains/index.js' import { createClient } from '../../../clients/createClient.js' import { custom } from '../../../clients/transports/custom.js' import { numberToHex } from '../../../utils/encoding/toHex.js' -import { to8130Account } from '../accounts/to8130Account.js' +import { toAccount } from '../accounts/toAccount.js' import { actorScope, canonicalAuthenticators } from '../constants.js' import { authorizeActor, encodePolicyData, key } from '../keys.js' -import { estimateGas8130 } from './estimateGas8130.js' +import { estimateGas } from './estimateGas.js' const owner = privateKeyToAccount( '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', @@ -40,17 +40,17 @@ function recordingClient() { } } -describe('estimateGas8130 — create account-change serialization', () => { +describe('estimateGas — create account-change serialization', () => { test('each initialActor carries scope (number) and policyData (hex)', async () => { const rec = recordingClient() - const account = to8130Account({ + const account = toAccount({ signer: owner, userSalt, code, initialActors: [key.k1(owner.address)], }) - await estimateGas8130(rec.client, { + await estimateGas(rec.client, { sender: account.address, accountChanges: [account.create()], calls: [[{ to: owner.address, value: 1n }]], @@ -82,10 +82,13 @@ describe('estimateGas8130 — create account-change serialization', () => { } as const // Build a policy-gated actor and register it as an initial actor via its // scope/policyData (as computeAddress commits them). - const gated = authorizeActor(key.p256({ - x: '0x1111111111111111111111111111111111111111111111111111111111111111', - y: '0x2222222222222222222222222222222222222222222222222222222222222222', - }), { scope: actorScope.sender, policy }) + const gated = authorizeActor( + key.p256({ + x: '0x1111111111111111111111111111111111111111111111111111111111111111', + y: '0x2222222222222222222222222222222222222222222222222222222222222222', + }), + { scope: actorScope.sender, policy }, + ) const initialActors = [ { @@ -97,14 +100,14 @@ describe('estimateGas8130 — create account-change serialization', () => { key.k1(owner.address), ].sort((a, b) => (BigInt(a.actorId) < BigInt(b.actorId) ? -1 : 1)) - const account = to8130Account({ + const account = toAccount({ signer: owner, userSalt, code, initialActors, }) - await estimateGas8130(rec.client, { + await estimateGas(rec.client, { sender: account.address, accountChanges: [account.create()], calls: [[{ to: owner.address }]], @@ -112,9 +115,7 @@ describe('estimateGas8130 — create account-change serialization', () => { }) const actors = rec.request.accountChanges[0].initialActors - const gatedOut = actors.find( - (a: any) => a.actorId === gated.actorId, - ) + const gatedOut = actors.find((a: any) => a.actorId === gated.actorId) expect(gatedOut.scope).toBe(actorScope.sender | actorScope.policy) expect(typeof gatedOut.scope).toBe('number') expect(gatedOut.policyData?.toLowerCase()).toBe( @@ -123,17 +124,17 @@ describe('estimateGas8130 — create account-change serialization', () => { }) }) -describe('estimateGas8130 — dataSuffix → metadata', () => { +describe('estimateGas — dataSuffix → metadata', () => { test('full-body mode writes dataSuffix to metadata', async () => { const rec = recordingClient() - const account = to8130Account({ + const account = toAccount({ signer: owner, userSalt, code, initialActors: [key.k1(owner.address)], }) - await estimateGas8130(rec.client, { + await estimateGas(rec.client, { sender: account.address, accountChanges: [account.create()], calls: [[{ to: owner.address }]], @@ -146,14 +147,14 @@ describe('estimateGas8130 — dataSuffix → metadata', () => { test('full-body mode defaults metadata to 0x', async () => { const rec = recordingClient() - const account = to8130Account({ + const account = toAccount({ signer: owner, userSalt, code, initialActors: [key.k1(owner.address)], }) - await estimateGas8130(rec.client, { + await estimateGas(rec.client, { sender: account.address, accountChanges: [account.create()], calls: [[{ to: owner.address }]], diff --git a/src/experimental/eip8130/actions/estimateGas8130.ts b/src/experimental/eip8130/actions/estimateGas.ts similarity index 88% rename from src/experimental/eip8130/actions/estimateGas8130.ts rename to src/experimental/eip8130/actions/estimateGas.ts index f0bb01e784..9246aa2199 100644 --- a/src/experimental/eip8130/actions/estimateGas8130.ts +++ b/src/experimental/eip8130/actions/estimateGas.ts @@ -12,15 +12,15 @@ import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import { numberToHex } from '../../../utils/encoding/toHex.js' import { aaTransactionType, - actorChangeType, canonicalAuthDataLength, + changeType, } from '../constants.js' import type { AaAccountChange, AaCalls } from '../types/transaction.js' -import { encodeActorChangeData } from '../utils/actorChangeData.js' +import { encodeChangePayload } from '../utils/actorChangeData.js' -export type EstimateGas8130Parameters = { +export type EstimateGasParameters = { /** - * Sender address. **Required** (or {@link EstimateGas8130Parameters.sender}) + * Sender address. **Required** (or {@link EstimateGasParameters.sender}) * — the sender drives actor/policy resolution, and the node returns * `INVALID_PARAMS` for an EIP-8130 estimate without one. */ @@ -104,7 +104,7 @@ export type EstimateGas8130Parameters = { * policy and revert. Pass the session (or other non-self) actor id you intend * to sign with; the node resolves that actor's policy after applying * `accountChanges`, so an actor authorized in the same estimate request is - * visible. Orthogonal to {@link EstimateGas8130Parameters.senderAuthAuthenticator} + * visible. Orthogonal to {@link EstimateGasParameters.senderAuthAuthenticator} * (auth-gas pricing) — accept both when estimating a session-key send. */ senderActorId?: Hex | undefined @@ -133,7 +133,7 @@ export type EstimateGas8130Parameters = { blockTag?: BlockTag | undefined } -export type EstimateGas8130ReturnType = bigint +export type EstimateGasReturnType = bigint /** Generous cap matching the node's `MAX_AUTH_SIZE`; rejects OOM-sized inputs. */ const maxAuthSize = 8_192 @@ -164,19 +164,19 @@ const maxAuthSize = 8_192 * `senderAuth`) for a configured account, and leave both unset for the * default EOA. For policy-gated actors (session keys), also pass * `senderActorId` so the simulate path publishes the intended acting actor - * instead of the account's self-actor. See {@link EstimateGas8130Parameters}. + * instead of the account's self-actor. See {@link EstimateGasParameters}. * * Note: an EIP-8130 estimate that reverts a phase surfaces the revert (like * standard `eth_estimateGas`), even though a reverted EIP-8130 tx would still * be included onchain (nonce consumed, fee paid). */ -export async function estimateGas8130< +export async function estimateGas< chain extends Chain | undefined, account extends Account | undefined, >( client: Client, - parameters: EstimateGas8130Parameters, -): Promise { + parameters: EstimateGasParameters, +): Promise { const { from, sender, @@ -247,21 +247,21 @@ export async function estimateGas8130< sender: account_, nonceKey: Number(nonceKey), nonceSequence, - expiry: 0, + validAfter: 0, + validBefore: 0, // Reasonable fee defaults — the estimate skips fee validation. maxFeePerGas: numberToHex(1_000_000_000n), maxPriorityFeePerGas: numberToHex(1_000_000n), // Large gas cap so the simulation isn't capped below real execution. gasLimit: 30_000_000, accountChanges: (accountChanges ?? []).map(serializeAccountChange), - calls: ( - calls ?? [[{ to: account_, value: 0n, data: '0x' as Hex }]] - ).map((phase) => - phase.map((c) => ({ - to: c.to, - value: numberToHex(c.value ?? 0n), - data: c.data ?? '0x', - })), + calls: (calls ?? [[{ to: account_, value: 0n, data: '0x' as Hex }]]).map( + (phase) => + phase.map((c) => ({ + to: c.to, + value: numberToHex(c.value ?? 0n), + data: c.data ?? '0x', + })), ), metadata: dataSuffix ?? '0x', payer: payer ?? null, @@ -326,7 +326,8 @@ function buildAuthBlob( if (size === undefined) return undefined return filler(size) } - const dataLength = size ?? canonicalAuthDataLength[authenticator.toLowerCase()] + const dataLength = + size ?? canonicalAuthDataLength[authenticator.toLowerCase()] if (dataLength === undefined) throw new BaseError( `No default auth-payload length is known for authenticator ${authenticator}. Pass an explicit auth size.`, @@ -343,8 +344,9 @@ function filler(length: number): Hex { * `eth_estimateGas` deserialiser expects. For `create` changes, all fields are * passed through directly. For `delegation`, only `target` is required (the * node does not need the proxy bytecode for gas pricing). For `config`, the - * node tags the variant as `configChange` and expects each actor change in - * wire form (`changeType` + opaque `data`), not the typed authorize fields. + * node tags the variant as `configChange` and expects the `SignedAccountChanges` + * batch shape (`channel` / `sequence` / `changes[{changeType, payload}]` / + * `signature`) — the ABI-encoded opaque `payload`, not the typed fields. */ function serializeAccountChange( change: AaAccountChange, @@ -373,20 +375,25 @@ function serializeAccountChange( if (change.type === 'delegation') { return { type: 'delegation', target: change.target } } - // config → RPC tag `configChange`. Simulate does not verify auth, but still - // prices its byte-length into intrinsic gas and applies actorChanges. + // config → RPC tag `configChange`. Simulate does not verify the signature, but + // still prices the batch's byte-length into intrinsic gas and applies it. The + // node deserializes the consensus `SignedAccountChanges` (serde camelCase), + // whose `channel` / `changeType` are the enum variant names. + const changeTypeName: Record = { + [changeType.authorizeActor]: 'AuthorizeActor', + [changeType.revokeActor]: 'RevokeActor', + [changeType.incrementLocalEpoch]: 'IncrementLocalEpoch', + [changeType.lock]: 'Lock', + [changeType.unlock]: 'Unlock', + } return { type: 'configChange', - chainId: change.chainId, - sequence: change.sequence, - actorChanges: change.actorChanges.map((ac) => ({ - changeType: - ac.changeType === actorChangeType.revokeActor - ? 'Revoke' - : 'Authorize', - actorId: ac.actorId, - data: encodeActorChangeData(ac), + channel: change.channel === 'multichain' ? 'Multichain' : 'Local', + sequence: Number(change.sequence), + changes: change.changes.map((c) => ({ + changeType: changeTypeName[c.changeType], + payload: encodeChangePayload(c), })), - auth: change.auth, + signature: change.signature, } } diff --git a/src/experimental/eip8130/actions/getActorConfig8130.ts b/src/experimental/eip8130/actions/getActorConfig.ts similarity index 87% rename from src/experimental/eip8130/actions/getActorConfig8130.ts rename to src/experimental/eip8130/actions/getActorConfig.ts index b6896fa428..be262a5ce1 100644 --- a/src/experimental/eip8130/actions/getActorConfig8130.ts +++ b/src/experimental/eip8130/actions/getActorConfig.ts @@ -8,11 +8,11 @@ import type { Chain } from '../../../types/chain.js' import type { Hex } from '../../../types/misc.js' import { accountConfigurationAbi } from '../abis.js' import { - accountConfigAddress as defaultAccountConfigAddress, actorScope, + accountConfigAddress as defaultAccountConfigAddress, } from '../constants.js' -export type GetActorConfig8130Parameters = { +export type GetActorConfigParameters = { /** The account whose actor to read. */ account: Address /** The 32-byte actor identifier (see `key.*(...).actorId`). */ @@ -24,7 +24,7 @@ export type GetActorConfig8130Parameters = { accountConfiguration?: Address | undefined } -export type GetActorConfig8130ReturnType = { +export type GetActorConfigReturnType = { /** Authenticator contract address (or protocol sentinel) for the actor. */ authenticator: Address /** Permission bitmask (see `actorScope`). `0` = unrestricted. */ @@ -42,9 +42,9 @@ export type GetActorConfig8130ReturnType = { * * @example * ```ts - * import { getActorConfig8130, key } from 'viem/experimental/eip8130' + * import { getActorConfig, key } from 'viem/experimental/eip8130' * - * const config = await getActorConfig8130(client, { + * const config = await getActorConfig(client, { * account: account.address, * actorId: key.p256({ x, y }).actorId, * }) @@ -54,13 +54,13 @@ export type GetActorConfig8130ReturnType = { * @param parameters - Parameters. * @returns The actor's configuration. */ -export async function getActorConfig8130< +export async function getActorConfig< chain extends Chain | undefined, account extends Account | undefined, >( client: Client, - parameters: GetActorConfig8130Parameters, -): Promise { + parameters: GetActorConfigParameters, +): Promise { const { account, actorId, diff --git a/src/experimental/eip8130/actions/getConfigSequence8130.ts b/src/experimental/eip8130/actions/getConfigSequence.ts similarity index 59% rename from src/experimental/eip8130/actions/getConfigSequence8130.ts rename to src/experimental/eip8130/actions/getConfigSequence.ts index 84a775d2a2..5927973399 100644 --- a/src/experimental/eip8130/actions/getConfigSequence8130.ts +++ b/src/experimental/eip8130/actions/getConfigSequence.ts @@ -1,31 +1,34 @@ import type { Address } from 'abitype' - +import { readContract } from '../../../actions/public/readContract.js' import type { Client } from '../../../clients/createClient.js' import type { Transport } from '../../../clients/transports/createTransport.js' import type { Account } from '../../../types/account.js' import type { Chain } from '../../../types/chain.js' -import { readContract } from '../../../actions/public/readContract.js' import { accountConfigurationAbi } from '../abis.js' -export type GetConfigSequence8130Parameters = { +export type GetConfigSequenceParameters = { /** The EIP-8130 AccountConfiguration system contract address. */ accountConfiguration: Address /** The account whose local config sequence to read. */ account: Address } -export type GetConfigSequence8130ReturnType = { +export type GetConfigSequenceReturnType = { /** - * The local (single-chain) change sequence. This is the NEXT sequence - * number to use when signing an `ActorChange` — it equals the count of - * config changes that have been applied to the account on this chain. + * The NEXT local-channel `sequence` word to sign: `localEpoch (high 32) || + * localSequence (low 32)`. Pass it directly as the `sequence` for a `'local'` + * channel `SignedAccountChanges` batch. */ local: bigint /** - * The multi-chain change sequence (cross-chain actor changes via EIP-8130 - * multi-chain signing). + * The multi-chain change sequence (cross-chain changes via EIP-8130 + * multi-chain signing). The NEXT `sequence` for a `'multichain'` batch. */ multichain: bigint + /** The current local epoch (high 32 bits of the local word). */ + localEpoch: number + /** The current local sequence (low 32 bits of the local word). */ + localSequence: number } /** @@ -38,19 +41,19 @@ export type GetConfigSequence8130ReturnType = { * sequence-mismatch rejections caused by a stale local cache. * * @example - * const { local } = await getConfigSequence8130(client, { + * const { local } = await getConfigSequence(client, { * accountConfiguration: deployment.accountConfiguration, * account: accountAddress, * }) * // Use `local` as the sequence for the next AccountChange. */ -export async function getConfigSequence8130< +export async function getConfigSequence< chain extends Chain | undefined, account extends Account | undefined, >( client: Client, - parameters: GetConfigSequence8130Parameters, -): Promise { + parameters: GetConfigSequenceParameters, +): Promise { const { accountConfiguration, account } = parameters const result = await readContract(client, { @@ -60,8 +63,14 @@ export async function getConfigSequence8130< args: [account], }) + // The signed local `sequence` word is `localEpoch (high 32) || localSequence + // (low 32)`; recompose it so callers can pass `local` straight through. + const local = + (BigInt(result.localEpoch) << 32n) | BigInt(result.localSequence) return { - local: result.local, + local, multichain: result.multichain, + localEpoch: result.localEpoch, + localSequence: result.localSequence, } } diff --git a/src/experimental/eip8130/actions/getLockStatus8130.ts b/src/experimental/eip8130/actions/getLockStatus.ts similarity index 85% rename from src/experimental/eip8130/actions/getLockStatus8130.ts rename to src/experimental/eip8130/actions/getLockStatus.ts index 95ea6170fb..fade5f70d9 100644 --- a/src/experimental/eip8130/actions/getLockStatus8130.ts +++ b/src/experimental/eip8130/actions/getLockStatus.ts @@ -8,7 +8,7 @@ import type { Chain } from '../../../types/chain.js' import { accountConfigurationAbi } from '../abis.js' import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' -export type GetLockStatus8130Parameters = { +export type GetLockStatusParameters = { /** The account whose lock status to read. */ account: Address /** @@ -18,7 +18,7 @@ export type GetLockStatus8130Parameters = { accountConfiguration?: Address | undefined } -export type GetLockStatus8130ReturnType = { +export type GetLockStatusReturnType = { /** Whether the account is currently locked. */ locked: boolean /** Whether an unlock has been initiated (the delay is counting down). */ @@ -35,23 +35,23 @@ export type GetLockStatus8130ReturnType = { * * @example * ```ts - * import { getLockStatus8130 } from 'viem/experimental/eip8130' + * import { getLockStatus } from 'viem/experimental/eip8130' * * const { locked, hasInitiatedUnlock, unlocksAt, unlockDelay } = - * await getLockStatus8130(client, { account: account.address }) + * await getLockStatus(client, { account: account.address }) * ``` * * @param client - Client. * @param parameters - Parameters. * @returns The account's lock status. */ -export async function getLockStatus8130< +export async function getLockStatus< chain extends Chain | undefined, account extends Account | undefined, >( client: Client, - parameters: GetLockStatus8130Parameters, -): Promise { + parameters: GetLockStatusParameters, +): Promise { const { account, accountConfiguration = defaultAccountConfigAddress } = parameters diff --git a/src/experimental/eip8130/actions/getPolicy8130.ts b/src/experimental/eip8130/actions/getPolicy.ts similarity index 79% rename from src/experimental/eip8130/actions/getPolicy8130.ts rename to src/experimental/eip8130/actions/getPolicy.ts index d4b33b07de..417c9574bc 100644 --- a/src/experimental/eip8130/actions/getPolicy8130.ts +++ b/src/experimental/eip8130/actions/getPolicy.ts @@ -9,7 +9,7 @@ import type { Hex } from '../../../types/misc.js' import { accountConfigurationAbi } from '../abis.js' import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' -export type GetPolicy8130Parameters = { +export type GetPolicyParameters = { /** The account whose actor policy to read. */ account: Address /** The 32-byte actor identifier (see `key.*(...).actorId`). */ @@ -21,7 +21,7 @@ export type GetPolicy8130Parameters = { accountConfiguration?: Address | undefined } -export type GetPolicy8130ReturnType = { +export type GetPolicyReturnType = { /** Policy manager the actor is gated to (the zero address when unset). */ target: Address /** 32-byte policy commitment stored on the actor. */ @@ -31,30 +31,30 @@ export type GetPolicy8130ReturnType = { /** * Reads the policy binding for an actor (manager, commitment) from * the `AccountConfiguration` system contract (`getPolicy`). Use it to resolve a - * session key's policy commitment for {@link getSessionSpend8130}. + * session key's policy commitment for {@link getSessionSpend}. * * @example * ```ts - * import { getPolicy8130, getSessionSpend8130, key } from 'viem/experimental/eip8130' + * import { getPolicy, getSessionSpend, key } from 'viem/experimental/eip8130' * - * const { commitment } = await getPolicy8130(client, { + * const { commitment } = await getPolicy(client, { * account: account.address, * actorId: key.p256({ x, y }).actorId, * }) - * const spend = await getSessionSpend8130(client, { commitment, token: usdc }) + * const spend = await getSessionSpend(client, { commitment, token: usdc }) * ``` * * @param client - Client. * @param parameters - Parameters. * @returns The actor's policy binding. */ -export async function getPolicy8130< +export async function getPolicy< chain extends Chain | undefined, account extends Account | undefined, >( client: Client, - parameters: GetPolicy8130Parameters, -): Promise { + parameters: GetPolicyParameters, +): Promise { const { account, actorId, diff --git a/src/experimental/eip8130/actions/getSessionSpend8130.ts b/src/experimental/eip8130/actions/getSessionSpend.ts similarity index 91% rename from src/experimental/eip8130/actions/getSessionSpend8130.ts rename to src/experimental/eip8130/actions/getSessionSpend.ts index a624caea38..5cc68da7c9 100644 --- a/src/experimental/eip8130/actions/getSessionSpend8130.ts +++ b/src/experimental/eip8130/actions/getSessionSpend.ts @@ -8,12 +8,12 @@ import type { Chain } from '../../../types/chain.js' import type { Hex } from '../../../types/misc.js' import { getAction } from '../../../utils/getAction.js' import { + type SessionPolicyTokenLimit, sessionPolicyAbi, sessionPolicyAddress, - type SessionPolicyTokenLimit, } from '../policies.js' -export type GetSessionSpend8130Parameters = { +export type GetSessionSpendParameters = { /** The session policy binding commitment (see `defineSessionPolicy().commitment`). */ commitment: Hex /** @@ -31,7 +31,7 @@ export type GetSessionSpend8130Parameters = { sessionPolicy?: Address | undefined } -export type GetSessionSpend8130ReturnType = { +export type GetSessionSpendReturnType = { /** The spend cap per period (atomic units) — echoes the supplied `tokenLimit.limit`. */ allowance: bigint /** Period length in seconds. `0` = one-time (never resets). */ @@ -54,10 +54,10 @@ export type GetSessionSpend8130ReturnType = { * * @example * ```ts - * import { getSessionSpend8130 } from 'viem/experimental/eip8130' + * import { getSessionSpend } from 'viem/experimental/eip8130' * * // Pass the exact token limit from the binding's config. - * const { allowance, spent, remaining, periodEnd } = await getSessionSpend8130( + * const { allowance, spent, remaining, periodEnd } = await getSessionSpend( * client, * { * commitment: session.commitment, @@ -70,13 +70,13 @@ export type GetSessionSpend8130ReturnType = { * @param parameters - Parameters. * @returns The session key's limit and current-period spend for the token. */ -export async function getSessionSpend8130< +export async function getSessionSpend< chain extends Chain | undefined, account extends Account | undefined, >( client: Client, - parameters: GetSessionSpend8130Parameters, -): Promise { + parameters: GetSessionSpendParameters, +): Promise { const { commitment, tokenLimit, diff --git a/src/experimental/eip8130/actions/getTransaction8130.ts b/src/experimental/eip8130/actions/getTransaction.ts similarity index 84% rename from src/experimental/eip8130/actions/getTransaction8130.ts rename to src/experimental/eip8130/actions/getTransaction.ts index 7af1d450de..bb6742c72e 100644 --- a/src/experimental/eip8130/actions/getTransaction8130.ts +++ b/src/experimental/eip8130/actions/getTransaction.ts @@ -12,7 +12,7 @@ import type { AaAccountChange, AaCalls } from '../types/transaction.js' * transaction as returned by `eth_getTransactionByHash` on a node with the * EIP-8130 extension. */ -export type Transaction8130 = { +export type Transaction = { /** EIP-8130 transaction type marker. */ type: typeof aaTransactionType /** Transaction hash (injected from the request — not present in the raw RPC response). */ @@ -25,8 +25,10 @@ export type Transaction8130 = { nonceKey: Hex /** 2D nonce: sequence within the channel. */ nonceSequence: number - /** Expiry timestamp (0 = no expiry). */ - expiry: number + /** Lower validity bound (unix ms; 0 = no lower bound). */ + validAfter: number + /** Upper validity bound (unix ms; 0 = no upper bound). */ + validBefore: number /** Maximum fee per gas (EIP-1559). */ maxFeePerGas: bigint /** Maximum priority fee per gas (EIP-1559). */ @@ -55,22 +57,23 @@ export type Transaction8130 = { transactionIndex: number | null } -export type GetTransaction8130Parameters = { +export type GetTransactionParameters = { /** The hash of the EIP-8130 transaction to fetch. */ hash: Hash } -export type GetTransaction8130ReturnType = Transaction8130 +export type GetTransactionReturnType = Transaction /** Raw RPC response shape for `eth_getTransactionByHash` on an 8130 node. */ -type RawTx8130 = { +type RawTx = { type: typeof aaTransactionType tx: { chainId: number sender: Address nonceKey: Hex nonceSequence: number - expiry: number + validAfter: number + validBefore: number maxFeePerGas: Hex maxPriorityFeePerGas: Hex gasLimit: number @@ -90,7 +93,7 @@ type RawTx8130 = { /** * Fetches an EIP-8130 (`AA_TX_TYPE`) transaction by hash and returns a - * fully-typed `Transaction8130` object. + * fully-typed `Transaction` object. * * Unlike the generic `getTransaction`, this action: * - Understands the nested `tx` body format returned by the EIP-8130 RPC node. @@ -98,30 +101,30 @@ type RawTx8130 = { * - Converts all numeric fields from raw form to `bigint` / `number`. * * @example - * const tx = await getTransaction8130(client, { hash: '0xabc...' }) + * const tx = await getTransaction(client, { hash: '0xabc...' }) * console.log(tx.calls) // AaCalls * console.log(tx.accountChanges) // AaAccountChange[] * console.log(tx.nonceSequence) // number */ -export async function getTransaction8130< +export async function getTransaction< chain extends Chain | undefined, account extends Account | undefined, >( client: Client, - parameters: GetTransaction8130Parameters, -): Promise { + parameters: GetTransactionParameters, +): Promise { const { hash } = parameters const raw = await ( client.request as (args: { method: 'eth_getTransactionByHash' params: [Hash] - }) => Promise + }) => Promise )({ method: 'eth_getTransactionByHash', params: [hash] }) if (!raw || raw.type !== aaTransactionType) throw new Error( - `getTransaction8130: expected type ${aaTransactionType} but got type ${(raw as any)?.type ?? 'null'} for hash ${hash}`, + `getTransaction: expected type ${aaTransactionType} but got type ${(raw as any)?.type ?? 'null'} for hash ${hash}`, ) const body = raw.tx @@ -133,7 +136,8 @@ export async function getTransaction8130< chainId: body.chainId, nonceKey: body.nonceKey, nonceSequence: body.nonceSequence, - expiry: body.expiry, + validAfter: body.validAfter, + validBefore: body.validBefore, maxFeePerGas: BigInt(body.maxFeePerGas), maxPriorityFeePerGas: BigInt(body.maxPriorityFeePerGas), gas: BigInt(body.gasLimit), diff --git a/src/experimental/eip8130/actions/getTransactionCount8130.ts b/src/experimental/eip8130/actions/getTransactionCount.ts similarity index 87% rename from src/experimental/eip8130/actions/getTransactionCount8130.ts rename to src/experimental/eip8130/actions/getTransactionCount.ts index 70133f5505..c5b34402d5 100644 --- a/src/experimental/eip8130/actions/getTransactionCount8130.ts +++ b/src/experimental/eip8130/actions/getTransactionCount.ts @@ -10,7 +10,7 @@ import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import { numberToHex } from '../../../utils/encoding/toHex.js' import { nonceKeyMax } from '../constants.js' -export type GetTransactionCount8130Parameters = { +export type GetTransactionCountParameters = { /** The account address. */ address: Address /** @@ -28,7 +28,7 @@ export type GetTransactionCount8130Parameters = { blockTag?: BlockTag | undefined } -export type GetTransactionCount8130ReturnType = bigint +export type GetTransactionCountReturnType = bigint /** * Reads an EIP-8130 nonce via `eth_getTransactionCount`, including the 2D @@ -44,18 +44,18 @@ export type GetTransactionCount8130ReturnType = bigint * - `nonceKey !== 0n` → 2D channel nonce from the precompile storage. * * @example - * const sequence = await getTransactionCount8130(client, { + * const sequence = await getTransactionCount(client, { * address: account.address, * nonceKey: 0n, * }) */ -export async function getTransactionCount8130< +export async function getTransactionCount< chain extends Chain | undefined, account extends Account | undefined, >( client: Client, - parameters: GetTransactionCount8130Parameters, -): Promise { + parameters: GetTransactionCountParameters, +): Promise { const { address, nonceKey = 0n, @@ -73,9 +73,7 @@ export async function getTransactionCount8130< // Third positional `nonce_key` is the EIP-8130 RPC extension. Omitting it // (or passing 0x0) yields the standard protocol-nonce resolution. const params: [Address, string] | [Address, string, Hex] = - nonceKey === 0n - ? [address, block] - : [address, block, numberToHex(nonceKey)] + nonceKey === 0n ? [address, block] : [address, block, numberToHex(nonceKey)] const count = await ( client.request as (args: { diff --git a/src/experimental/eip8130/actions/getTransactionReceipt8130.ts b/src/experimental/eip8130/actions/getTransactionReceipt.ts similarity index 83% rename from src/experimental/eip8130/actions/getTransactionReceipt8130.ts rename to src/experimental/eip8130/actions/getTransactionReceipt.ts index caacf36b4c..6289a27969 100644 --- a/src/experimental/eip8130/actions/getTransactionReceipt8130.ts +++ b/src/experimental/eip8130/actions/getTransactionReceipt.ts @@ -13,7 +13,7 @@ import type { Hash, Hex } from '../../../types/misc.js' * These are present only for `AA_TX_TYPE` (`0x79`) receipts on a node with the * extension; they are `undefined` for standard receipts or older nodes. */ -export type Eip8130ReceiptFields = { +export type ReceiptFields = { /** * Gas payer: the sender for self-pay, or the named payer for a sponsored tx. */ @@ -36,22 +36,22 @@ type RawReceipt = { [key: string]: unknown } -export type GetTransactionReceipt8130Parameters = { +export type GetTransactionReceiptParameters = { /** Transaction hash to fetch the receipt for. */ hash: Hash } -export type GetTransactionReceipt8130ReturnType = +export type GetTransactionReceiptReturnType = | (RawReceipt & { /** Parsed EIP-8130 receipt fields (decoded from the raw receipt). */ - eip8130: Eip8130ReceiptFields + eip8130: ReceiptFields }) | null /** Reads the EIP-8130 fields off a raw JSON-RPC receipt (graceful if absent). */ -export function parseEip8130ReceiptFields( +export function parseReceiptFields( receipt: RawReceipt | null | undefined, -): Eip8130ReceiptFields { +): ReceiptFields { if (!receipt) return {} return { payer: receipt.payer, @@ -62,7 +62,7 @@ export function parseEip8130ReceiptFields( /** Returns `true` when every reported call phase succeeded. */ export function allPhasesSucceeded( - fields: Pick, + fields: Pick, ): boolean { const phases = fields.phaseStatuses if (!phases) return true @@ -74,13 +74,13 @@ export function allPhasesSucceeded( * `phaseStatuses`, `metadata`) alongside the raw receipt. Returns `null` when * the receipt is not yet available. */ -export async function getTransactionReceipt8130< +export async function getTransactionReceipt< chain extends Chain | undefined, account extends Account | undefined, >( client: Client, - parameters: GetTransactionReceipt8130Parameters, -): Promise { + parameters: GetTransactionReceiptParameters, +): Promise { const { hash } = parameters const receipt = await ( client.request as (args: { @@ -90,5 +90,5 @@ export async function getTransactionReceipt8130< )({ method: 'eth_getTransactionReceipt', params: [hash] }) if (!receipt) return null - return { ...receipt, eip8130: parseEip8130ReceiptFields(receipt) } + return { ...receipt, eip8130: parseReceiptFields(receipt) } } diff --git a/src/experimental/eip8130/actions/isActor8130.ts b/src/experimental/eip8130/actions/isActor.ts similarity index 82% rename from src/experimental/eip8130/actions/isActor8130.ts rename to src/experimental/eip8130/actions/isActor.ts index f7937087a6..7b7040c68e 100644 --- a/src/experimental/eip8130/actions/isActor8130.ts +++ b/src/experimental/eip8130/actions/isActor.ts @@ -9,7 +9,7 @@ import type { Hex } from '../../../types/misc.js' import { accountConfigurationAbi } from '../abis.js' import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' -export type IsActor8130Parameters = { +export type IsActorParameters = { /** The account to check. */ account: Address /** The 32-byte actor identifier (see `key.*(...).actorId`). */ @@ -21,18 +21,18 @@ export type IsActor8130Parameters = { accountConfiguration?: Address | undefined } -export type IsActor8130ReturnType = boolean +export type IsActorReturnType = boolean /** * Reads whether an actor is currently authorized on an EIP-8130 account, from * the `AccountConfiguration` system contract (`isActor`). For the actor's full - * configuration, use {@link getActorConfig8130}. + * configuration, use {@link getActorConfig}. * * @example * ```ts - * import { isActor8130, key } from 'viem/experimental/eip8130' + * import { isActor, key } from 'viem/experimental/eip8130' * - * const authorized = await isActor8130(client, { + * const authorized = await isActor(client, { * account: account.address, * actorId: key.p256({ x, y }).actorId, * }) @@ -42,13 +42,13 @@ export type IsActor8130ReturnType = boolean * @param parameters - Parameters. * @returns Whether the actor is authorized. */ -export async function isActor8130< +export async function isActor< chain extends Chain | undefined, account extends Account | undefined, >( client: Client, - parameters: IsActor8130Parameters, -): Promise { + parameters: IsActorParameters, +): Promise { const { account, actorId, diff --git a/src/experimental/eip8130/actions/isLocked8130.ts b/src/experimental/eip8130/actions/isLocked.ts similarity index 78% rename from src/experimental/eip8130/actions/isLocked8130.ts rename to src/experimental/eip8130/actions/isLocked.ts index eb4038105a..05670264c4 100644 --- a/src/experimental/eip8130/actions/isLocked8130.ts +++ b/src/experimental/eip8130/actions/isLocked.ts @@ -8,7 +8,7 @@ import type { Chain } from '../../../types/chain.js' import { accountConfigurationAbi } from '../abis.js' import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' -export type IsLocked8130Parameters = { +export type IsLockedParameters = { /** The account to check. */ account: Address /** @@ -18,31 +18,31 @@ export type IsLocked8130Parameters = { accountConfiguration?: Address | undefined } -export type IsLocked8130ReturnType = boolean +export type IsLockedReturnType = boolean /** * Reads whether an EIP-8130 account is currently locked, from the * `AccountConfiguration` system contract (`isLocked`). For the full status - * (unlock timing, delay), use {@link getLockStatus8130}. + * (unlock timing, delay), use {@link getLockStatus}. * * @example * ```ts - * import { isLocked8130 } from 'viem/experimental/eip8130' + * import { isLocked } from 'viem/experimental/eip8130' * - * const locked = await isLocked8130(client, { account: account.address }) + * const locked = await isLocked(client, { account: account.address }) * ``` * * @param client - Client. * @param parameters - Parameters. * @returns Whether the account is locked. */ -export async function isLocked8130< +export async function isLocked< chain extends Chain | undefined, account extends Account | undefined, >( client: Client, - parameters: IsLocked8130Parameters, -): Promise { + parameters: IsLockedParameters, +): Promise { const { account, accountConfiguration = defaultAccountConfigAddress } = parameters diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/experimental/eip8130/actions/sendCalls.ts index 40430d6e5f..c7006987de 100644 --- a/src/experimental/eip8130/actions/sendCalls.ts +++ b/src/experimental/eip8130/actions/sendCalls.ts @@ -7,7 +7,7 @@ import type { Account } from '../../../types/account.js' import type { Chain } from '../../../types/chain.js' import type { Hex } from '../../../types/misc.js' import { getAction } from '../../../utils/getAction.js' -import type { To8130AccountReturnType } from '../accounts/to8130Account.js' +import type { ToAccountReturnType } from '../accounts/toAccount.js' import { nonceFreeMaxExpiryWindow, nonceKeyMax } from '../constants.js' import { NonceScopeError, ScopeMismatchError } from '../errors.js' import { isNoncelessOnly } from '../keys.js' @@ -17,19 +17,22 @@ import type { AaCalls, TransactionSerializable8130, } from '../types/transaction.js' -import { type EncodeExecute, encodeWalletCalls } from '../utils/encodeWalletCalls.js' +import { + type EncodeExecute, + encodeWalletCalls, +} from '../utils/encodeWalletCalls.js' import type { Signer } from '../utils/signTransaction.js' -import { getActorConfig8130 } from './getActorConfig8130.js' -import { getTransactionCount8130 } from './getTransactionCount8130.js' -import { isActor8130 } from './isActor8130.js' +import { getActorConfig } from './getActorConfig.js' +import { getTransactionCount } from './getTransactionCount.js' +import { isActor } from './isActor.js' type FeeOverrides = { maxFeePerGas?: bigint | undefined maxPriorityFeePerGas?: bigint | undefined } -export type PrepareTransaction8130Parameters = FeeOverrides & { - account: To8130AccountReturnType +export type PrepareTransactionParameters = FeeOverrides & { + account: ToAccountReturnType /** Ordered call phases. */ calls: AaCalls accountChanges?: readonly AaAccountChange[] | undefined @@ -38,7 +41,10 @@ export type PrepareTransaction8130Parameters = FeeOverrides & { gas: bigint nonceKey?: bigint | undefined nonceSequence?: bigint | undefined - expiry?: bigint | undefined + /** Lower validity bound (unix ms; 0/omitted = none). */ + validAfter?: bigint | undefined + /** Upper validity bound (unix ms; required non-zero in nonce-free mode). */ + validBefore?: bigint | undefined /** * Attribution / opaque suffix. Written to the EIP-8130 `metadata` field * (not appended to call calldata). Takes precedence over `client.dataSuffix`. @@ -53,20 +59,20 @@ export type PrepareTransaction8130Parameters = FeeOverrides & { */ async function resolveSigningScope( client: Client, - account: To8130AccountReturnType, + account: ToAccountReturnType, ): Promise { const { actorId, scope: declared } = account if (!actorId) return declared const accountConfiguration = account.accountConfigAddress - const bound = await isActor8130(client, { + const bound = await isActor(client, { account: account.address, actorId, ...(accountConfiguration ? { accountConfiguration } : {}), }) if (!bound) return declared - const { scope: onChain } = await getActorConfig8130(client, { + const { scope: onChain } = await getActorConfig(client, { account: account.address, actorId, ...(accountConfiguration ? { accountConfiguration } : {}), @@ -82,9 +88,9 @@ async function resolveSigningScope( * `eth_getTransactionCount`'s 2D channel-nonce extension), and EIP-1559 fees * from the client when not provided. */ -export async function prepareTransaction8130( +export async function prepareTransaction( client: Client, - parameters: PrepareTransaction8130Parameters, + parameters: PrepareTransactionParameters, ): Promise { const { account, calls, accountChanges, payer, gas } = parameters @@ -116,7 +122,7 @@ export async function prepareTransaction8130( nonceKey ??= 0n } - let expiry = parameters.expiry + let validBefore = parameters.validBefore let { maxFeePerGas, maxPriorityFeePerGas } = parameters if (maxFeePerGas === undefined || maxPriorityFeePerGas === undefined) { @@ -133,12 +139,12 @@ export async function prepareTransaction8130( let nonceSequence = parameters.nonceSequence if (nonceKey === nonceKeyMax) { // Nonce-free (expiring) mode: there is no per-channel counter to read; - // replay protection relies on `expiry`. Pin the sequence to `0n`. - // Default the expiry to the mempool admission window when the caller did - // not supply one — whether nonce-free was auto-selected (restricted actor) - // or explicitly chosen (admin / `SCOPE_NONCE` actor opting in). - if (!expiry || expiry === 0n) - expiry = BigInt(Math.floor(Date.now() / 1000)) + nonceFreeMaxExpiryWindow + // replay protection relies on `validBefore`. Pin the sequence to `0n`. + // Default `validBefore` to the mempool admission window (unix ms) when the + // caller did not supply one — whether nonce-free was auto-selected + // (restricted actor) or explicitly chosen (admin / `SCOPE_NONCE` opting in). + if (!validBefore || validBefore === 0n) + validBefore = BigInt(Date.now()) + nonceFreeMaxExpiryWindow nonceSequence ??= 0n } else if (nonceSequence === undefined) { // Read the next sequence via `eth_getTransactionCount` (with the 2D @@ -146,8 +152,8 @@ export async function prepareTransaction8130( // `eth_call`, so this RPC path is the correct nonce source. nonceSequence = await getAction( client, - getTransactionCount8130, - 'getTransactionCount8130', + getTransactionCount, + 'getTransactionCount', )({ address: account.address, nonceKey }) } @@ -159,7 +165,7 @@ export async function prepareTransaction8130( maxFeePerGas, maxPriorityFeePerGas, gas, - expiry, + validBefore, accountChanges, calls, ...(dataSuffix ? { metadata: dataSuffix } : {}), @@ -167,8 +173,8 @@ export async function prepareTransaction8130( } } -export type SendCalls8130Parameters = FeeOverrides & { - account: To8130AccountReturnType +export type SendCallsParameters = FeeOverrides & { + account: ToAccountReturnType /** * Calls to execute. A flat list runs as a single atomic phase; pass a nested * array to control phases explicitly. @@ -179,7 +185,10 @@ export type SendCalls8130Parameters = FeeOverrides & { gas: bigint nonceKey?: bigint | undefined nonceSequence?: bigint | undefined - expiry?: bigint | undefined + /** Lower validity bound (unix ms; 0/omitted = none). */ + validAfter?: bigint | undefined + /** Upper validity bound (unix ms; required non-zero in nonce-free mode). */ + validBefore?: bigint | undefined /** * Attribution / opaque suffix. Written to the EIP-8130 `metadata` field * (not appended to call calldata). Takes precedence over `client.dataSuffix`. @@ -193,16 +202,16 @@ export type SendCalls8130Parameters = FeeOverrides & { encodeExecute?: EncodeExecute | undefined /** * Invoked with the fully-resolved transaction just before it is signed and - * sent. Use it to thread the resolved `expiry` (which may be auto-computed for - * nonce-free sends) into `waitForTransactionReceipt8130` without re-preparing: + * sent. Use it to thread the resolved `validBefore` (which may be auto-computed + * for nonce-free sends) into `waitForTransactionReceipt` without re-preparing: * * ```ts - * let expiry: bigint | undefined - * const hash = await sendCalls8130(client, { + * let validBefore: bigint | undefined + * const hash = await sendCalls(client, { * ...params, - * onTransaction: (tx) => { expiry = tx.expiry }, + * onTransaction: (tx) => { validBefore = tx.validBefore }, * }) - * await waitForTransactionReceipt8130(client, { hash, expiry }) + * await waitForTransactionReceipt(client, { hash, validBefore }) * ``` */ onTransaction?: @@ -210,7 +219,7 @@ export type SendCalls8130Parameters = FeeOverrides & { | undefined } -function toPhases(calls: SendCalls8130Parameters['calls']): AaCalls { +function toPhases(calls: SendCallsParameters['calls']): AaCalls { if (calls.length === 0) return [] // Already phased (array of arrays)? if (Array.isArray(calls[0])) return calls as AaCalls @@ -223,19 +232,19 @@ function toPhases(calls: SendCalls8130Parameters['calls']): AaCalls { * serializes, and submits via `eth_sendRawTransaction`. * * @example - * const hash = await sendCalls8130(client, { + * const hash = await sendCalls(client, { * account, * calls: [{ to, data }], * gas: 200_000n, * }) */ -export async function sendCalls8130( +export async function sendCalls( client: Client, - parameters: SendCalls8130Parameters, + parameters: SendCallsParameters, ): Promise { const { account, calls, payer, encodeExecute, onTransaction, ...rest } = parameters - const transaction = await prepareTransaction8130(client, { + const transaction = await prepareTransaction(client, { ...rest, account, calls: encodeWalletCalls({ diff --git a/src/experimental/eip8130/actions/waitForTransactionReceipt.ts b/src/experimental/eip8130/actions/waitForTransactionReceipt.ts new file mode 100644 index 0000000000..17cfb2a480 --- /dev/null +++ b/src/experimental/eip8130/actions/waitForTransactionReceipt.ts @@ -0,0 +1,136 @@ +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import type { Hash, Hex } from '../../../types/misc.js' +import { TransactionExpiredError } from '../errors.js' +import { getTransaction } from './getTransaction.js' +import { + type GetTransactionReceiptReturnType, + getTransactionReceipt, +} from './getTransactionReceipt.js' + +export type WaitForTransactionReceiptParameters = { + /** Transaction hash to wait for. */ + hash: Hash + /** + * `validBefore` (unix **milliseconds**) of the transaction being awaited. When + * provided, the wait fails fast with a {@link TransactionExpiredError} once the + * chain's latest block timestamp passes it. This is the reliable path: it is + * the value the transaction was signed with (thread it out of `sendCalls` via + * `onTransaction`, or read it off `prepareTransaction`'s result). + * + * If omitted, the wait *opportunistically* tries to read `validBefore` off the + * still-pending transaction — but nodes are not obligated to return a pending + * transaction from `eth_getTransactionByHash`, so pass `validBefore` when you + * need a guarantee. Applies to any expiring transaction (sequenced or + * nonce-free). + */ + validBefore?: bigint | number | undefined + /** + * How often to poll for the receipt (ms). + * @default 500 + */ + pollingInterval?: number | undefined + /** + * Maximum time to wait before rejecting (ms). + * @default 60_000 + */ + timeout?: number | undefined +} + +export type WaitForTransactionReceiptReturnType = + NonNullable + +/** + * Polls `eth_getTransactionReceipt` until an EIP-8130 (`AA_TX_TYPE`) transaction + * is included in a block, then returns the receipt with the EIP-8130 fields + * (`payer`, `phaseStatuses`, `metadata`) attached. + * + * Unlike the generic `waitForTransactionReceipt`, this action: + * - Uses `getTransactionReceipt` so EIP-8130 receipt fields are surfaced. + * - Skips the standard replacement-detection path (EIP-8130 uses 2D nonces and + * cannot be replaced via the same-nonce mechanism). + * - Detects expiring transactions that can no longer land: once the chain's + * latest block timestamp passes the tx's `validBefore`, it rejects with a + * {@link TransactionExpiredError} instead of silently waiting for the timeout. + * Any transaction with a non-zero `validBefore` expires — sequenced and + * nonce-free alike — so pass `validBefore` (the value you signed) for reliable + * detection. When omitted, it is read opportunistically off the pending tx, + * which is best-effort only: a node may not serve a pending + * `eth_getTransactionByHash`. + * + * @example + * const receipt = await waitForTransactionReceipt(client, { + * hash: '0xabc...', + * }) + * console.log(receipt.eip8130.phaseStatuses) // ['0x1'] + */ +export async function waitForTransactionReceipt< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: WaitForTransactionReceiptParameters, +): Promise { + const { hash, pollingInterval = 500, timeout = 60_000 } = parameters + + const deadline = Date.now() + timeout + + // The reliable bound is the caller-supplied one (the value the tx was signed + // with). `validBefore === 0` means "no upper bound", so treat it as unknown. + const suppliedValidBefore = + parameters.validBefore !== undefined && BigInt(parameters.validBefore) > 0n + let validBefore = suppliedValidBefore + ? BigInt(parameters.validBefore!) + : undefined + + while (Date.now() < deadline) { + const receipt = await getTransactionReceipt(client, { hash }) + if (receipt !== null) return receipt + + // Opportunistic fallback only: if the caller didn't supply `validBefore`, + // try to read it off the still-pending tx. This is best-effort — a node is + // not obligated to serve a pending `eth_getTransactionByHash`, so it may + // never resolve. Applies to any expiring tx (sequenced or nonce-free). + if (validBefore === undefined) { + try { + const tx = await getTransaction(client, { hash }) + if (tx.validBefore > 0) validBefore = BigInt(tx.validBefore) + } catch {} + } + + if (validBefore !== undefined) { + const blockTimestamp = await getLatestBlockTimestamp(client) + // `validBefore` is unix milliseconds; block timestamps are unix seconds. + if (blockTimestamp !== undefined && blockTimestamp * 1000n > validBefore) + throw new TransactionExpiredError({ + hash, + validBefore, + blockTimestamp, + }) + } + + await new Promise((resolve) => setTimeout(resolve, pollingInterval)) + } + + throw new Error( + `waitForTransactionReceipt: timed out after ${timeout}ms waiting for ${hash}`, + ) +} + +async function getLatestBlockTimestamp( + client: Client, +): Promise { + try { + const block = await ( + client.request as (args: { + method: 'eth_getBlockByNumber' + params: ['latest', false] + }) => Promise<{ timestamp: Hex } | null> + )({ method: 'eth_getBlockByNumber', params: ['latest', false] }) + return block?.timestamp ? BigInt(block.timestamp) : undefined + } catch { + return undefined + } +} diff --git a/src/experimental/eip8130/actions/waitForTransactionReceipt8130.ts b/src/experimental/eip8130/actions/waitForTransactionReceipt8130.ts deleted file mode 100644 index 8e196ad553..0000000000 --- a/src/experimental/eip8130/actions/waitForTransactionReceipt8130.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' -import type { Hash, Hex } from '../../../types/misc.js' -import { TransactionExpiredError } from '../errors.js' -import { getTransaction8130 } from './getTransaction8130.js' -import { - type GetTransactionReceipt8130ReturnType, - getTransactionReceipt8130, -} from './getTransactionReceipt8130.js' - -export type WaitForTransactionReceipt8130Parameters = { - /** Transaction hash to wait for. */ - hash: Hash - /** - * `expiry` (unix seconds) of the transaction being awaited. When provided, - * the wait fails fast with a {@link TransactionExpiredError} once the chain's - * latest block timestamp passes it. This is the reliable path: it is the - * value the transaction was signed with (thread it out of `sendCalls8130` via - * `onTransaction`, or read it off `prepareTransaction8130`'s result). - * - * If omitted, the wait *opportunistically* tries to read the expiry off the - * still-pending transaction — but nodes are not obligated to return a pending - * transaction from `eth_getTransactionByHash`, so pass `expiry` when you need - * a guarantee. Applies to any expiring transaction (sequenced or nonce-free). - */ - expiry?: bigint | number | undefined - /** - * How often to poll for the receipt (ms). - * @default 500 - */ - pollingInterval?: number | undefined - /** - * Maximum time to wait before rejecting (ms). - * @default 60_000 - */ - timeout?: number | undefined -} - -export type WaitForTransactionReceipt8130ReturnType = NonNullable - -/** - * Polls `eth_getTransactionReceipt` until an EIP-8130 (`AA_TX_TYPE`) transaction - * is included in a block, then returns the receipt with the EIP-8130 fields - * (`payer`, `phaseStatuses`, `metadata`) attached. - * - * Unlike the generic `waitForTransactionReceipt`, this action: - * - Uses `getTransactionReceipt8130` so EIP-8130 receipt fields are surfaced. - * - Skips the standard replacement-detection path (EIP-8130 uses 2D nonces and - * cannot be replaced via the same-nonce mechanism). - * - Detects expiring transactions that can no longer land: once the chain's - * latest block timestamp passes the tx's `expiry`, it rejects with a - * {@link TransactionExpiredError} instead of silently waiting for the timeout. - * Any transaction with a non-zero `expiry` expires — sequenced and nonce-free - * alike — so pass `expiry` (the value you signed) for reliable detection. When - * omitted, the expiry is read opportunistically off the pending tx, which is - * best-effort only: a node may not serve a pending `eth_getTransactionByHash`. - * - * @example - * const receipt = await waitForTransactionReceipt8130(client, { - * hash: '0xabc...', - * }) - * console.log(receipt.eip8130.phaseStatuses) // ['0x1'] - */ -export async function waitForTransactionReceipt8130< - chain extends Chain | undefined, - account extends Account | undefined, ->( - client: Client, - parameters: WaitForTransactionReceipt8130Parameters, -): Promise { - const { - hash, - pollingInterval = 500, - timeout = 60_000, - } = parameters - - const deadline = Date.now() + timeout - - // The reliable expiry is the caller-supplied one (the value the tx was signed - // with). `expiry === 0` means "no expiry", so treat it as unknown. - const suppliedExpiry = - parameters.expiry !== undefined && BigInt(parameters.expiry) > 0n - let expiry = suppliedExpiry ? BigInt(parameters.expiry!) : undefined - - while (Date.now() < deadline) { - const receipt = await getTransactionReceipt8130(client, { hash }) - if (receipt !== null) return receipt - - // Opportunistic fallback only: if the caller didn't supply `expiry`, try to - // read it off the still-pending tx. This is best-effort — a node is not - // obligated to serve a pending `eth_getTransactionByHash`, so it may never - // resolve. Applies to any expiring tx (sequenced or nonce-free). - if (expiry === undefined) { - try { - const tx = await getTransaction8130(client, { hash }) - if (tx.expiry > 0) expiry = BigInt(tx.expiry) - } catch {} - } - - if (expiry !== undefined) { - const blockTimestamp = await getLatestBlockTimestamp(client) - if (blockTimestamp !== undefined && blockTimestamp > expiry) - throw new TransactionExpiredError({ hash, expiry, blockTimestamp }) - } - - await new Promise((resolve) => setTimeout(resolve, pollingInterval)) - } - - throw new Error( - `waitForTransactionReceipt8130: timed out after ${timeout}ms waiting for ${hash}`, - ) -} - -async function getLatestBlockTimestamp( - client: Client, -): Promise { - try { - const block = await ( - client.request as (args: { - method: 'eth_getBlockByNumber' - params: ['latest', false] - }) => Promise<{ timestamp: Hex } | null> - )({ method: 'eth_getBlockByNumber', params: ['latest', false] }) - return block?.timestamp ? BigInt(block.timestamp) : undefined - } catch { - return undefined - } -} diff --git a/src/experimental/eip8130/constants.ts b/src/experimental/eip8130/constants.ts index beec92eaeb..b45d3eab45 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/experimental/eip8130/constants.ts @@ -29,22 +29,24 @@ export const nonceKeyExistingCost = 5000n /** * Domain-separation prefix for the nonce-free `replay_id` preimage * (`REPLAY_ID_TYPE`): `keccak256(REPLAY_ID_TYPE || rlp([chain_id, - * resolved_sender, expiry, account_changes, calls, metadata, payer]))`. + * resolved_sender, valid_after, valid_before, account_changes, calls, metadata, + * payer]))`. */ export const replayIdType = '0x7901' satisfies Hex /** - * Consensus/execution replay window (seconds) for nonce-free transactions - * (`NONCE_FREE_EXPIRY_WINDOW`). A nonce-free tx's `expiry` must fall within - * `(now, now + NONCE_FREE_EXPIRY_WINDOW]`. + * Consensus/execution replay window (**milliseconds**) for nonce-free + * transactions (`NONCE_FREE_EXPIRY_WINDOW`). A nonce-free tx's `validBefore` + * must fall within `(now, now + NONCE_FREE_EXPIRY_WINDOW]`, where `now` is + * `block.timestamp * 1000`. */ -export const nonceFreeExpiryWindow = 30n +export const nonceFreeExpiryWindow = 30000n /** - * Mempool-admission cap (seconds) on a nonce-free tx's `expiry` window - * (`NONCE_FREE_MAX_EXPIRY_WINDOW`); tighter than the consensus window. + * Mempool-admission cap (**milliseconds**) on a nonce-free tx's `validBefore` + * window (`NONCE_FREE_MAX_EXPIRY_WINDOW`); tighter than the consensus window. */ -export const nonceFreeMaxExpiryWindow = 10n +export const nonceFreeMaxExpiryWindow = 20000n /** Enshrined nonce-free replay ring-buffer capacity (`REPLAY_BUFFER_CAPACITY`). */ export const replayBufferCapacity = 300000n @@ -63,11 +65,23 @@ export const accountChangeType = { } as const satisfies Record /** - * Actor change operation types used within a config-change entry. - */ -export const actorChangeType = { - authorizeActor: 0x01, - revokeActor: 0x02, + * `ChangeType` operations within a `SignedAccountChanges` batch (the `config` + * account-change entry). Mirrors `Keystore.ChangeType`; the value is the wire op + * byte hashed into the batch digest. + * + * - `authorizeActor` / `revokeActor`: authority ops (mutate who can act). + * - `incrementLocalEpoch`: bump the local epoch (either channel; empty payload). + * - `lock` / `unlock`: environment ops, Local-channel only and must be the + * batch's only op. NOTE: the enshrined node currently defers `lock`/`unlock` + * (a batch carrying one is rejected), so they are contract-accurate but not + * yet accepted on the native path. + */ +export const changeType = { + authorizeActor: 0x00, + revokeActor: 0x01, + incrementLocalEpoch: 0x02, + lock: 0x03, + unlock: 0x04, } as const /** @@ -111,19 +125,13 @@ export const accountStateFlags = { unlockInitiated: 0x04, } as const -/** `applySignedLockChanges` op selecting a hard lock (`LOCK_OP`). */ -export const lockOp = 0x01 - -/** `applySignedLockChanges` op initiating a (delayed) unlock (`UNLOCK_OP`). */ -export const unlockOp = 0x02 - /** Exact `policyData` byte length for a policy-bearing actor: `manager (20) || commitment (32)` (`POLICY_DATA_LEN`). */ export const policyDataLength = 52 /** * Nonce-free mode selector (`NONCE_KEY_MAX`). When `nonceKey` equals this value, * no nonce state is read or incremented and replay protection relies on - * `expiry`. + * `validBefore`. */ export const nonceKeyMax = 2n ** 256n - 1n @@ -171,17 +179,17 @@ export const canonicalAuthenticators = { /** secp256k1 — native sentinel (`ECRECOVER_AUTHENTICATOR` / `K1_AUTHENTICATOR`). */ k1: '0x0000000000000000000000000000000000000001', /** P-256 (raw). Canonical base/eip-8130 deployment. */ - p256: '0xf8847a74F8067CabaE5fe56B70b372A7D670f0f8', + p256: '0x8130C89F65750431b564A4730397552a11CeA256', /** WebAuthn / FIDO2 passkey. Canonical base/eip-8130 deployment. */ - passkey: '0x871c72d3950308A028E9c4917591bcfd3D6a1EF7', + passkey: '0x813007b6b1b48E75D91dEc5927ab515d12a0F1d0', /** Signature delegation (1-hop). Canonical base/eip-8130 deployment. */ - delegate: '0xbb73E3871FBaC8aef1a7Ee8A24E21139916f14C2', + delegate: '0x81302CC9e53aB471abf9c5924aDD6CF0A3eBADE1', } as const satisfies Record /** * Representative authentication-payload byte length (the bytes after a * prefixed blob's 20-byte authenticator selector) for each canonical - * authenticator, keyed by lowercased address. Used by `estimateGas8130` to + * authenticator, keyed by lowercased address. Used by `estimateGas` to * synthesize an auth-blob stub from a verifier-address hint alone, without * requiring the caller to know the exact real signature length. * @@ -212,10 +220,10 @@ export const txContextAddress = * Defaults to the [base/eip-8130](https://github.com/base/eip-8130) deployment * (Base Sepolia). The address may differ per chain — resolve via * {@link getEip8130Deployment}, or override via the `accountConfigAddress` - * parameter of {@link computeAddress8130}. + * parameter of {@link computeAddress}. */ export const accountConfigAddress = - '0x53648Cf00356fbAA1F2B531715c6B64AaBDE1555' satisfies Hex + '0x8130f09E345cE43531DF25966017710030Dc00AC' satisfies Hex /** * Default wallet implementation for EOA auto-delegation @@ -226,7 +234,7 @@ export const accountConfigAddress = * {@link accountConfigAddress}. */ export const defaultAccountAddress = - '0x58da469ef71Dd4B092B010CdA37DE124C926EebD' satisfies Hex + '0x81301D5aFE1DE3B255781876FC07eD45C150AdEF' satisfies Hex /** Size of the deployment header in bytes (`DEPLOYMENT_HEADER_SIZE`). */ export const deploymentHeaderSize = 14 diff --git a/src/experimental/eip8130/deployments.ts b/src/experimental/eip8130/deployments.ts index 56b43a2353..aa1c014326 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/experimental/eip8130/deployments.ts @@ -68,10 +68,12 @@ export type Eip8130Deployment = { /** * Canonical EIP-8130 deployment addresses. Every contract is deployed through - * Nick's deterministic CREATE2 factory with `salt = 0` (see `base/eip-8130` - * `script/Deploy.s.sol`), so each address is a pure function of its compiled - * bytecode — identical on every chain (Base Sepolia, vibenet devnet, mainnet - * when live). + * Nick's deterministic CREATE2 factory with a **per-contract mined salt** (see + * `base/eip-8130` `script/Deploy.s.sol`), so each address is a pure function of + * its compiled bytecode and salt — identical on every chain (Base Sepolia, + * vibenet devnet, mainnet when live). Each salt is mined so the contract shares + * the `0x8130…` vanity prefix (except `alwaysValid`, deployed under the zero + * salt). * * `accountConfiguration` is enshrined in the execution client; using any other * value derives a different account address and the create transaction fails. @@ -80,52 +82,35 @@ export type Eip8130Deployment = { * bytecode change), all addresses must be re-derived and this object updated. */ export const canonicalEip8130Deployment = { - accountConfiguration: '0x53648Cf00356fbAA1F2B531715c6B64AaBDE1555', + accountConfiguration: '0x8130f09E345cE43531DF25966017710030Dc00AC', accounts: { // `upgradeable` / `erc4337` are unaudited example wallets and are not part // of the canonical deployment. Callers must provide those implementations // explicitly if they choose an example-specific path. - default: '0x58da469ef71Dd4B092B010CdA37DE124C926EebD', - defaultHighRate: '0x23Fe6949d6370330Ae32e7c17E1265D65955C92a', + default: '0x81301D5aFE1DE3B255781876FC07eD45C150AdEF', + defaultHighRate: '0x81301B078907cad978E37E8Cf7F91d44f305fA57', }, authenticators: { k1: '0x0000000000000000000000000000000000000001', - p256: '0xf8847a74F8067CabaE5fe56B70b372A7D670f0f8', - webAuthn: '0x871c72d3950308A028E9c4917591bcfd3D6a1EF7', - delegate: '0xbb73E3871FBaC8aef1a7Ee8A24E21139916f14C2', + p256: '0x8130C89F65750431b564A4730397552a11CeA256', + webAuthn: '0x813007b6b1b48E75D91dEc5927ab515d12a0F1d0', + delegate: '0x81302CC9e53aB471abf9c5924aDD6CF0A3eBADE1', alwaysValid: '0xA550545Da91720c23483c5B3493412A02D1cF9F9', }, policies: { - manager: '0x6e9E627770C1c90371A2E4CB9474A7Af577a4306', - sessionPolicy: '0x58ef2d572a1bC528f0B9121d686B2618809604Dc', + manager: '0x8130646ffaB930BEBd601D06315118071d7F0ac1', + sessionPolicy: '0x8130309A18c9923b4523B448325F7e9529695e55', }, } as const satisfies Eip8130Deployment /** - * Current EIP-8130 deployment on Base Sepolia (chain id `84532`). + * EIP-8130 deployment on Base Sepolia (chain id `84532`). * - * Base Sepolia has not yet migrated to the latest canonical AccountConfiguration - * deployment, so keep its live addresses separate until that network upgrades. + * The finalized contracts deploy at deterministic, per-contract-salt addresses + * that are identical on every chain, so this is just the canonical set. */ export const baseSepoliaDeployment = { - accountConfiguration: '0xe7Bb8eF3728ea9f0A8be6D7e9585FeAb12dE086A', - accounts: { - upgradeable: '0xF8dafa4DA35F664cf2CF842f00482ebb68a982b3', - default: '0xDd802113C9FF6964cD2A61A16e075D5271cC82c9', - defaultHighRate: '0xe5edfB7E7365893d685c2FbFBAC3e022f51d942F', - erc4337: '0x8812ee1c9BA2395b5f113412769f22C6e7b89B11', - }, - authenticators: { - k1: '0x0000000000000000000000000000000000000001', - p256: '0xf8847a74F8067CabaE5fe56B70b372A7D670f0f8', - webAuthn: '0x871c72d3950308A028E9c4917591bcfd3D6a1EF7', - delegate: '0x1B0195ba5E3FCdB387DD619816eeF8b510Ed0855', - alwaysValid: '0xA550545Da91720c23483c5B3493412A02D1cF9F9', - }, - policies: { - manager: '0x18B545EfC321644eE2dB9644c8f94f3f3d5e8624', - sessionPolicy: '0x6Ef50425716c134162C5c289E02162dde75b23Ea', - }, + ...canonicalEip8130Deployment, } as const satisfies Eip8130Deployment /** @@ -134,7 +119,6 @@ export const baseSepoliaDeployment = { * The devnet runs EIP-8130 **natively**: the execution client enshrines the * canonical `accountConfiguration`. Using any other value derives a different * account address and create transactions fail with "create address mismatch". - * The example policy contracts use the #43 binding-at-execute ABI. */ export const vibenetDevnetDeployment = { ...canonicalEip8130Deployment, diff --git a/src/experimental/eip8130/devx.test.ts b/src/experimental/eip8130/devx.test.ts index a8ce78ae04..95252b3399 100644 --- a/src/experimental/eip8130/devx.test.ts +++ b/src/experimental/eip8130/devx.test.ts @@ -7,11 +7,11 @@ import type { Hex } from '../../types/misc.js' import { keccak256 } from '../../utils/hash/keccak256.js' import { delegateAuthSize, - newSmartAccount8130, - to8130Account, - toDelegate8130Signer, -} from './accounts/to8130Account.js' -import { sendCalls8130 } from './actions/sendCalls.js' + newSmartAccount, + toAccount, + toDelegateSigner, +} from './accounts/toAccount.js' +import { sendCalls } from './actions/sendCalls.js' import { actorScope, canonicalAuthenticators, @@ -26,7 +26,7 @@ import { toScope, } from './keys.js' import { actorIdFromAddress, actorIdFromPublicKey } from './utils/actorId.js' -import { parseTransaction8130 } from './utils/parseTransaction.js' +import { parseTransaction } from './utils/parseTransaction.js' import { erc1167Bytecode } from './utils/proxy.js' const owner = privateKeyToAccount( @@ -43,7 +43,7 @@ const pubkey = { describe('canonical smart-account deployment', () => { test('defaults to an ERC-1167 proxy to DefaultAccount', () => { - const account = newSmartAccount8130({ signer: owner, salt: userSalt }) + const account = newSmartAccount({ signer: owner, salt: userSalt }) expect(account.createChange.code).toBe( erc1167Bytecode(canonicalEip8130Deployment.accounts.default), @@ -52,7 +52,7 @@ describe('canonical smart-account deployment', () => { test('requires an explicit implementation for upgradeable accounts', () => { expect(() => - newSmartAccount8130({ + newSmartAccount({ signer: owner, salt: userSalt, upgradeable: true, @@ -116,8 +116,8 @@ describe('scope + policy helpers', () => { }) }) -describe('to8130Account', () => { - const account = to8130Account({ +describe('toAccount', () => { + const account = toAccount({ signer: owner, userSalt, code, @@ -146,9 +146,9 @@ describe('to8130Account', () => { revokeActor(key.k1('0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC')), ]) expect(change.type).toBe('config') - expect(change.actorChanges).toHaveLength(2) - // auth = ecrecover authenticator (20 bytes) || 65-byte sig = 85 bytes - expect(change.auth.length).toBe(2 + 85 * 2) + expect(change.changes).toHaveLength(2) + // signature = ecrecover authenticator (20 bytes) || 65-byte sig = 85 bytes + expect(change.signature.length).toBe(2 + 85 * 2) }) test('delegate() entry', () => { @@ -161,7 +161,7 @@ describe('to8130Account', () => { }) }) -describe('sendCalls8130', () => { +describe('sendCalls', () => { let sent: Hex | undefined const client = createClient({ chain: mainnet, @@ -178,7 +178,7 @@ describe('sendCalls8130', () => { }, }), }) - const account = to8130Account({ + const account = toAccount({ signer: owner, userSalt, code, @@ -186,7 +186,7 @@ describe('sendCalls8130', () => { }) test('builds, signs, serializes and submits an AA_TX_TYPE tx', async () => { - const hash = await sendCalls8130(client, { + const hash = await sendCalls(client, { account, calls: [ { to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }, @@ -205,7 +205,7 @@ describe('sendCalls8130', () => { expect(hash).toMatch(/^0x[0-9a-f]{64}$/) expect(sent?.startsWith('0x79')).toBe(true) - const parsed = parseTransaction8130(sent!) + const parsed = parseTransaction(sent!) expect(parsed.from?.toLowerCase()).toBe(account.address.toLowerCase()) expect(parsed.chainId).toBe(1) expect(parsed.calls).toHaveLength(1) // single atomic phase @@ -216,7 +216,7 @@ describe('sendCalls8130', () => { }) test('behavior: dataSuffix is written to metadata', async () => { - await sendCalls8130(client, { + await sendCalls(client, { account, calls: [{ to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }], accountChanges: [account.create()], @@ -227,7 +227,7 @@ describe('sendCalls8130', () => { dataSuffix: '0xdeadbeef', }) - const parsed = parseTransaction8130(sent!) + const parsed = parseTransaction(sent!) expect(parsed.metadata).toBe('0xdeadbeef') // Calldata is untouched — attribution lives in metadata, not call data. expect(parsed.calls?.[0]?.[0]?.data ?? '0x').toBe('0x') @@ -251,7 +251,7 @@ describe('sendCalls8130', () => { }), }) - await sendCalls8130(suffixedClient, { + await sendCalls(suffixedClient, { account, calls: [{ to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }], accountChanges: [account.create()], @@ -261,11 +261,11 @@ describe('sendCalls8130', () => { nonceSequence: 0n, }) - expect(parseTransaction8130(clientSent!).metadata).toBe('0x12345678') + expect(parseTransaction(clientSent!).metadata).toBe('0x12345678') }) }) -describe('toDelegate8130Signer (sub-account via key.delegate)', () => { +describe('toDelegateSigner (sub-account via key.delegate)', () => { const parent = '0x00112233445566778899aabbccddeeff00112233' as const test('delegateAuthSize defaults to a 125-byte K1 blob', () => { @@ -274,7 +274,7 @@ describe('toDelegate8130Signer (sub-account via key.delegate)', () => { }) test('signer wraps the nested sig into the delegate data blob', async () => { - const signer = toDelegate8130Signer({ + const signer = toDelegateSigner({ delegateAccount: parent, nestedSigner: owner, }) @@ -289,12 +289,12 @@ describe('toDelegate8130Signer (sub-account via key.delegate)', () => { ) }) - test('to8130Account serializes the full delegate senderAuth', async () => { - const signer = toDelegate8130Signer({ + test('toAccount serializes the full delegate senderAuth', async () => { + const signer = toDelegateSigner({ delegateAccount: parent, nestedSigner: owner, }) - const sub = to8130Account({ + const sub = toAccount({ signer, authenticator: signer.authenticator, userSalt, @@ -310,7 +310,7 @@ describe('toDelegate8130Signer (sub-account via key.delegate)', () => { maxPriorityFeePerGas: 1_000_000_000n, nonceSequence: 0n, }) - const parsed = parseTransaction8130(serialized) + const parsed = parseTransaction(serialized) // senderAuth = DELEGATE(20) || delegate(20) || nested(20) || sig(65) = 125 bytes expect((parsed.senderAuth!.length - 2) / 2).toBe(delegateAuthSize()) expect(parsed.senderAuth!.slice(0, 42).toLowerCase()).toBe( diff --git a/src/experimental/eip8130/errors.ts b/src/experimental/eip8130/errors.ts index 17ae53c79b..1639829d56 100644 --- a/src/experimental/eip8130/errors.ts +++ b/src/experimental/eip8130/errors.ts @@ -68,30 +68,33 @@ export type TransactionExpiredErrorType = TransactionExpiredError & { /** * Thrown while waiting on a nonce-free (expiring) EIP-8130 transaction when the - * chain's latest block timestamp passes the transaction's `expiry` before a + * chain's latest block timestamp passes the transaction's `validBefore` before a * receipt is observed. The transaction can no longer be included — it has been * (or will be) dropped from the mempool — so waiting further is pointless. * * Distinct from a plain wait timeout: this is a definitive terminal state, not - * "we gave up early". Callers can catch this to resubmit with a fresh `expiry`. + * "we gave up early". Callers can catch this to resubmit with a fresh + * `validBefore`. */ export class TransactionExpiredError extends BaseError { override name = 'TransactionExpiredError' constructor({ hash, - expiry, + validBefore, blockTimestamp, }: { hash: `0x${string}` - expiry: bigint + /** Upper validity bound the tx was signed with (unix ms). */ + validBefore: bigint + /** Latest block timestamp (unix seconds). */ blockTimestamp: bigint }) { super( - `Transaction \`${hash}\` expired before landing: \`expiry\` ${expiry} passed (latest block timestamp ${blockTimestamp}).`, + `Transaction \`${hash}\` expired before landing: \`validBefore\` ${validBefore} (unix ms) passed (latest block timestamp ${blockTimestamp}s).`, { metaMessages: [ - 'Nonce-free (expiring) transactions are only valid until their `expiry`; once the block timestamp passes it the tx is dropped and can never be mined.', - 'Resubmit with a fresh `expiry` (e.g. `nonce.nonceless({ expiresIn })`).', + 'Nonce-free (expiring) transactions are only valid until their `validBefore`; once the block timestamp passes it the tx is dropped and can never be mined.', + 'Resubmit with a fresh `validBefore` (e.g. `nonce.nonceless({ expiresIn })`).', ], }, ) diff --git a/src/experimental/eip8130/index.ts b/src/experimental/eip8130/index.ts index 4c8b3d0ce4..05f8c84803 100644 --- a/src/experimental/eip8130/index.ts +++ b/src/experimental/eip8130/index.ts @@ -7,94 +7,94 @@ export { transactionContextAbi, } from './abis.js' export { - type To8130AccountParameters, - type To8130AccountReturnType, - to8130Account, - type NewSmartAccount8130Parameters, - type NewSmartAccount8130ReturnType, - newSmartAccount8130, - type ToEoa8130AccountReturnType, - toEoa8130Account, - type ToDelegate8130SignerParameters, - toDelegate8130Signer, delegateAuthSize, -} from './accounts/to8130Account.js' + type NewSmartAccountParameters, + type NewSmartAccountReturnType, + newSmartAccount, + type ToAccountParameters, + type ToAccountReturnType, + type ToDelegateSignerParameters, + type ToEoaAccountReturnType, + toAccount, + toDelegateSigner, + toEoaAccount, +} from './accounts/toAccount.js' export { type Eip8130SmartAccountImplementation, - type ToSmartAccount8130Parameters, - type ToSmartAccount8130ReturnType, - toSmartAccount8130, -} from './accounts/toSmartAccount8130.js' -export { - type EstimateGas8130Parameters, - type EstimateGas8130ReturnType, - estimateGas8130, -} from './actions/estimateGas8130.js' -export { - type GetActorConfig8130Parameters, - type GetActorConfig8130ReturnType, - getActorConfig8130, -} from './actions/getActorConfig8130.js' -export { - type GetConfigSequence8130Parameters, - type GetConfigSequence8130ReturnType, - getConfigSequence8130, -} from './actions/getConfigSequence8130.js' -export { - type GetLockStatus8130Parameters, - type GetLockStatus8130ReturnType, - getLockStatus8130, -} from './actions/getLockStatus8130.js' -export { - type GetPolicy8130Parameters, - type GetPolicy8130ReturnType, - getPolicy8130, -} from './actions/getPolicy8130.js' -export { - type GetSessionSpend8130Parameters, - type GetSessionSpend8130ReturnType, - getSessionSpend8130, -} from './actions/getSessionSpend8130.js' -export { - type IsActor8130Parameters, - type IsActor8130ReturnType, - isActor8130, -} from './actions/isActor8130.js' -export { - type IsLocked8130Parameters, - type IsLocked8130ReturnType, - isLocked8130, -} from './actions/isLocked8130.js' -export { - type GetTransactionCount8130Parameters, - type GetTransactionCount8130ReturnType, - getTransactionCount8130, -} from './actions/getTransactionCount8130.js' + type ToSmartAccountParameters, + type ToSmartAccountReturnType, + toSmartAccount, +} from './accounts/toSmartAccount.js' +export { + type EstimateGasParameters, + type EstimateGasReturnType, + estimateGas, +} from './actions/estimateGas.js' +export { + type GetActorConfigParameters, + type GetActorConfigReturnType, + getActorConfig, +} from './actions/getActorConfig.js' +export { + type GetConfigSequenceParameters, + type GetConfigSequenceReturnType, + getConfigSequence, +} from './actions/getConfigSequence.js' +export { + type GetLockStatusParameters, + type GetLockStatusReturnType, + getLockStatus, +} from './actions/getLockStatus.js' +export { + type GetPolicyParameters, + type GetPolicyReturnType, + getPolicy, +} from './actions/getPolicy.js' +export { + type GetSessionSpendParameters, + type GetSessionSpendReturnType, + getSessionSpend, +} from './actions/getSessionSpend.js' +export { + type GetTransactionParameters, + type GetTransactionReturnType, + getTransaction, + type Transaction, +} from './actions/getTransaction.js' +export { + type GetTransactionCountParameters, + type GetTransactionCountReturnType, + getTransactionCount, +} from './actions/getTransactionCount.js' export { allPhasesSucceeded, - type Eip8130ReceiptFields, - type GetTransactionReceipt8130Parameters, - type GetTransactionReceipt8130ReturnType, - getTransactionReceipt8130, - parseEip8130ReceiptFields, -} from './actions/getTransactionReceipt8130.js' -export { - type GetTransaction8130Parameters, - type GetTransaction8130ReturnType, - type Transaction8130, - getTransaction8130, -} from './actions/getTransaction8130.js' -export { - type WaitForTransactionReceipt8130Parameters, - type WaitForTransactionReceipt8130ReturnType, - waitForTransactionReceipt8130, -} from './actions/waitForTransactionReceipt8130.js' -export { - type PrepareTransaction8130Parameters, - prepareTransaction8130, - type SendCalls8130Parameters, - sendCalls8130, + type GetTransactionReceiptParameters, + type GetTransactionReceiptReturnType, + getTransactionReceipt, + parseReceiptFields, + type ReceiptFields, +} from './actions/getTransactionReceipt.js' +export { + type IsActorParameters, + type IsActorReturnType, + isActor, +} from './actions/isActor.js' +export { + type IsLockedParameters, + type IsLockedReturnType, + isLocked, +} from './actions/isLocked.js' +export { + type PrepareTransactionParameters, + prepareTransaction, + type SendCallsParameters, + sendCalls, } from './actions/sendCalls.js' +export { + type WaitForTransactionReceiptParameters, + type WaitForTransactionReceiptReturnType, + waitForTransactionReceipt, +} from './actions/waitForTransactionReceipt.js' export { eip8130ChainIds, type Is8130EnabledParameters, @@ -109,14 +109,13 @@ export { accountChangeType, accountConfigAddress, accountStateFlags, - actorChangeType, actorScope, canonicalAuthDataLength, canonicalAuthenticators, + changeType, defaultAccountAddress, deploymentHeaderSize, ecrecoverAuthenticator, - lockOp, maxCodeSize, nonceFreeCost, nonceFreeExpiryWindow, @@ -132,7 +131,6 @@ export { scopeUnrestricted, trustedExecutorAuthenticator, txContextAddress, - unlockOp, } from './constants.js' export { baseSepoliaDeployment, @@ -143,14 +141,14 @@ export { vibenetDevnetDeployment, } from './deployments.js' export { - type ActorNotBoundErrorType, ActorNotBoundError, - type NonceScopeErrorType, + type ActorNotBoundErrorType, NonceScopeError, - type ScopeMismatchErrorType, + type NonceScopeErrorType, ScopeMismatchError, - type TransactionExpiredErrorType, + type ScopeMismatchErrorType, TransactionExpiredError, + type TransactionExpiredErrorType, } from './errors.js' export { type AuthorizeActorOptions, @@ -164,14 +162,10 @@ export { toScope, } from './keys.js' export { - type HashLockChange8130Parameters, - hashLockChange8130, - type InitiateUnlockCallParameters, - initiateUnlockCall, - type LockCallParameters, - type LockChangeOp, - lockChangeTypehash, - lockCall, + type LockChangeParameters, + lockChange, + maxUnlockDelay, + unlockChange, } from './lock.js' export { type Nonce, nonce } from './nonce.js' export { @@ -201,32 +195,36 @@ export type { AaAccountChangeCreate, AaAccountChangeDelegation, AaActor, - AaActorChange, AaAuthorizeActor, AaCall, AaCalls, + AaChange, + AaChangeChannel, + AaIncrementLocalEpoch, + AaLock, AaRevokeActor, + AaUnlock, TransactionSerializable8130, TransactionSerialized8130, } from './types/transaction.js' export { - type EncodeApplySignedActorChangesDataErrorType, - type EncodeApplySignedActorChangesDataParameters, + type EncodeApplySignedAccountChangesDataErrorType, + type EncodeApplySignedAccountChangesDataParameters, type EncodeCreateAccountDataErrorType, type EncodeCreateAccountDataParameters, - encodeApplySignedActorChangesData, + encodeApplySignedAccountChangesData, encodeCreateAccountData, - type ToFactoryArgs8130ErrorType, - type ToFactoryArgs8130Parameters, - type ToFactoryArgs8130ReturnType, - toFactoryArgs8130, + type ToFactoryArgsErrorType, + type ToFactoryArgsParameters, + type ToFactoryArgsReturnType, + toFactoryArgs, } from './utils/accountConfigCalls.js' export { - type DecodeAuthorizeActorDataErrorType, - type DecodedAuthorizeActorData, - decodeAuthorizeActorData, - type EncodeActorChangeDataErrorType, - encodeActorChangeData, + type DecodeAuthorizeActorPayloadErrorType, + type DecodedAuthorizeActorPayload, + decodeAuthorizeActorPayload, + type EncodeChangePayloadErrorType, + encodeChangePayload, } from './utils/actorChangeData.js' export { type ActorIdFromAddressErrorType, @@ -235,13 +233,13 @@ export { actorIdFromPublicKey, } from './utils/actorId.js' export { - type AssertTransaction8130ErrorType, - assertTransaction8130, + type AssertTransactionErrorType, + assertTransaction, } from './utils/assertTransaction.js' export { - type ComputeAddress8130ErrorType, - type ComputeAddress8130Parameters, - computeAddress8130, + type ComputeAddressErrorType, + type ComputeAddressParameters, + computeAddress, deploymentHeader, } from './utils/computeAddress.js' export { @@ -251,41 +249,41 @@ export { encodeWalletCalls, } from './utils/encodeWalletCalls.js' export { - actorChangeTypehash, - type HashActorChanges8130ErrorType, - type HashActorChanges8130Parameters, - hashActorChanges8130, - signedActorChangesTypehash, + accountChangeTypehash, + type HashAccountChangesErrorType, + type HashAccountChangesParameters, + hashAccountChanges, + signedAccountChangesTypehash, } from './utils/hashActorChanges.js' export { - type GetPayerSignatureHash8130ErrorType, - type GetSenderSignatureHash8130ErrorType, - type GetSignatureHash8130Parameters, - type GetSignatureHash8130ReturnType, - getPayerSignatureHash8130, - getSenderSignatureHash8130, + type GetPayerSignatureHashErrorType, + type GetSenderSignatureHashErrorType, + type GetSignatureHashParameters, + type GetSignatureHashReturnType, + getPayerSignatureHash, + getSenderSignatureHash, } from './utils/hashTransaction.js' export { - type ParseTransaction8130ErrorType, - parseTransaction8130, + type ParseTransactionErrorType, + parseTransaction, } from './utils/parseTransaction.js' export { erc1167Bytecode, upgradeableProxyBytecode } from './utils/proxy.js' export { - type RecoverSenderAddress8130ErrorType, - type RecoverSenderAddress8130Parameters, - recoverSenderAddress8130, + type RecoverSenderAddressErrorType, + type RecoverSenderAddressParameters, + recoverSenderAddress, } from './utils/recoverSender.js' export { - type SerializeTransaction8130ErrorType, - serializeTransaction8130, + type SerializeTransactionErrorType, + serializeTransaction, toAccountChangesList, toCallsList, toTransactionBody, } from './utils/serializeTransaction.js' export { - type SignActorChanges8130ErrorType, - type SignActorChanges8130Parameters, - signActorChanges8130, + type SignAccountChangesErrorType, + type SignAccountChangesParameters, + signAccountChanges, } from './utils/signActorChanges.js' export { type EncodeSignedActorChangesSignatureErrorType, @@ -302,7 +300,7 @@ export { } from './utils/signers.js' export { type Signer, - type SignTransaction8130ErrorType, - type SignTransaction8130Parameters, - signTransaction8130, + type SignTransactionErrorType, + type SignTransactionParameters, + signTransaction, } from './utils/signTransaction.js' diff --git a/src/experimental/eip8130/keys.ts b/src/experimental/eip8130/keys.ts index f1a77ba5a5..94ab401d31 100644 --- a/src/experimental/eip8130/keys.ts +++ b/src/experimental/eip8130/keys.ts @@ -149,8 +149,8 @@ export type AuthorizeActorOptions = { /** * Builds an `authorizeActor` change from a {@link key} actor plus scope, expiry, - * and optional policy. The result can be signed via `signActorChanges8130` / - * `to8130Account#authorize`. + * and optional policy. The result can be signed via `signActorChanges` / + * `toAccount#authorize`. * * A policy-gated actor (session key) should be authorized as POLICY-only * (`scope: actorScope.policy`): `SCOPE_POLICY` grants "gated initiation", so the @@ -171,7 +171,7 @@ export function authorizeActor( options: AuthorizeActorOptions = {}, ): AaAuthorizeActor { const change: AaAuthorizeActor = { - changeType: 0x01, + changeType: 0x00, actorId: actor.actorId, authenticator: actor.authenticator, } @@ -194,5 +194,5 @@ export function authorizeActor( /** Builds a `revokeActor` change for an actor (or raw `actorId`). */ export function revokeActor(actor: AaActor | Hex): AaRevokeActor { const actorId = typeof actor === 'string' ? actor : actor.actorId - return { changeType: 0x02, actorId } + return { changeType: 0x01, actorId } } diff --git a/src/experimental/eip8130/lock.test.ts b/src/experimental/eip8130/lock.test.ts index 9460d2f109..5268aa9689 100644 --- a/src/experimental/eip8130/lock.test.ts +++ b/src/experimental/eip8130/lock.test.ts @@ -2,55 +2,39 @@ import { describe, expect, test } from 'vitest' import { mainnet } from '../../chains/index.js' import { createClient } from '../../clients/createClient.js' import { custom } from '../../clients/transports/custom.js' -import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' +import { encodeAbiParameters } from '../../utils/abi/encodeAbiParameters.js' import { encodeFunctionResult } from '../../utils/abi/encodeFunctionResult.js' import { accountConfigurationAbi } from './abis.js' -import { getLockStatus8130 } from './actions/getLockStatus8130.js' -import { isLocked8130 } from './actions/isLocked8130.js' -import { accountConfigAddress, lockOp, unlockOp } from './constants.js' -import { initiateUnlockCall, lockCall } from './lock.js' +import { getLockStatus } from './actions/getLockStatus.js' +import { isLocked } from './actions/isLocked.js' +import { changeType } from './constants.js' +import { lockChange, unlockChange } from './lock.js' +import { encodeChangePayload } from './utils/actorChangeData.js' const account = '0x0000000000000000000000000000000000000a11' -// Signed-lock-change `auth` blob (authenticator || data); opaque to the call builder. -const auth = `0x${'ab'.repeat(85)}` as const -describe('lockCall', () => { - test('encodes applySignedLockChanges(LOCK_OP) to the canonical AccountConfiguration', () => { - const call = lockCall({ account, unlockDelay: 3600, auth }) - expect(call.to).toBe(accountConfigAddress) - const { functionName, args } = decodeFunctionData({ - abi: accountConfigurationAbi, - data: call.data!, - }) - expect(functionName).toBe('applySignedLockChanges') - expect(args).toEqual([account, lockOp, 3600, auth]) - }) - - test('respects an accountConfiguration override', () => { - const accountConfiguration = '0x00000000000000000000000000000000000000cc' - expect( - lockCall({ account, unlockDelay: 3600, auth, accountConfiguration }).to, - ).toBe(accountConfiguration) +describe('lockChange', () => { + test('builds a Lock op with an abi.encode(uint16 unlockDelay) payload', () => { + const change = lockChange({ unlockDelay: 3600 }) + expect(change).toEqual({ changeType: changeType.lock, unlockDelay: 3600 }) + expect(encodeChangePayload(change)).toBe( + encodeAbiParameters([{ type: 'uint16' }], [3600]), + ) }) test('rejects out-of-range unlockDelay (uint16)', () => { - expect(() => lockCall({ account, unlockDelay: 0, auth })).toThrow() - expect(() => lockCall({ account, unlockDelay: -1, auth })).toThrow() - expect(() => lockCall({ account, unlockDelay: 65_536, auth })).toThrow() - expect(() => lockCall({ account, unlockDelay: 1.5, auth })).toThrow() + expect(() => lockChange({ unlockDelay: 0 })).toThrow() + expect(() => lockChange({ unlockDelay: -1 })).toThrow() + expect(() => lockChange({ unlockDelay: 65_536 })).toThrow() + expect(() => lockChange({ unlockDelay: 1.5 })).toThrow() }) }) -describe('initiateUnlockCall', () => { - test('encodes applySignedLockChanges(UNLOCK_OP) to the canonical AccountConfiguration', () => { - const call = initiateUnlockCall({ account, auth }) - expect(call.to).toBe(accountConfigAddress) - const { functionName, args } = decodeFunctionData({ - abi: accountConfigurationAbi, - data: call.data!, - }) - expect(functionName).toBe('applySignedLockChanges') - expect(args).toEqual([account, unlockOp, 0, auth]) +describe('unlockChange', () => { + test('builds an Unlock op with an empty payload', () => { + const change = unlockChange() + expect(change).toEqual({ changeType: changeType.unlock }) + expect(encodeChangePayload(change)).toBe('0x') }) }) @@ -67,7 +51,7 @@ function lockClient(handlers: Record) { }) } -describe('getLockStatus8130', () => { +describe('getLockStatus', () => { test('decodes the AccountConfiguration.getLockStatus tuple', async () => { const client = lockClient({ eth_call: encodeFunctionResult({ @@ -76,7 +60,7 @@ describe('getLockStatus8130', () => { result: [true, true, 1_800_000_000, 3600], }), }) - const status = await getLockStatus8130(client, { account }) + const status = await getLockStatus(client, { account }) expect(status).toEqual({ locked: true, hasInitiatedUnlock: true, @@ -86,7 +70,7 @@ describe('getLockStatus8130', () => { }) }) -describe('isLocked8130', () => { +describe('isLocked', () => { test('decodes the AccountConfiguration.isLocked bool', async () => { const client = lockClient({ eth_call: encodeFunctionResult({ @@ -95,6 +79,6 @@ describe('isLocked8130', () => { result: true, }), }) - expect(await isLocked8130(client, { account })).toBe(true) + expect(await isLocked(client, { account })).toBe(true) }) }) diff --git a/src/experimental/eip8130/lock.ts b/src/experimental/eip8130/lock.ts index f0653508e0..bacf27adbe 100644 --- a/src/experimental/eip8130/lock.ts +++ b/src/experimental/eip8130/lock.ts @@ -1,147 +1,71 @@ -import type { Address } from 'abitype' import { BaseError } from '../../errors/base.js' -import { encodeAbiParameters } from '../../utils/abi/encodeAbiParameters.js' -import { encodeFunctionData } from '../../utils/abi/encodeFunctionData.js' -import { stringToHex } from '../../utils/encoding/toHex.js' -import { keccak256 } from '../../utils/hash/keccak256.js' -import type { Hex } from '../../types/misc.js' -import { accountConfigurationAbi } from './abis.js' -import { - accountConfigAddress as defaultAccountConfigAddress, - lockOp, - unlockOp, -} from './constants.js' -import type { AaCall } from './types/transaction.js' +import { changeType } from './constants.js' +import type { AaLock, AaUnlock } from './types/transaction.js' /** - * Account locking (EIP-8130 `AccountConfiguration`). + * Account locking (EIP-8130 `Keystore`). * * Locking an account freezes it against a compromised key: it blocks * configuration changes and delegation, and unlocking is time-delayed * (`unlockDelay`), giving the owner a window to react. Locked accounts are * eligible for elevated per-account rate limits. * - * Lock changes are a **signed** operation (like `applySignedActorChanges`): the - * account's admin (`scope == 0`) actor signs the {@link hashLockChange8130} - * digest, and the resulting `authenticator || data` blob is passed to - * `AccountConfiguration.applySignedLockChanges(account, op, unlockDelay, auth)`. - * Lock changes are local-channel only, so the digest binds the current - * `chainId` and consumes the account's local change `sequence`. Read the current - * state with {@link getLockStatus8130} / {@link isLocked8130}. + * Lock/unlock are `ChangeType` ops inside a `SignedAccountChanges` batch: the + * account's admin (`scope == 0`) actor signs a batch carrying a single + * {@link lockChange} / {@link unlockChange} op, on the **Local** channel only + * (they consume the local `sequence` and must be the batch's only op). Build the + * op, sign it via {@link signAccountChanges} (`channel: 'local'`), and apply it + * via {@link encodeApplySignedAccountChangesData} (or include the resulting + * `config` entry in a transaction's `accountChanges`). Read the current state + * with {@link getLockStatus} / {@link isLocked}. + * + * @remarks The enshrined node currently **defers** lock/unlock — a batch + * carrying one is rejected on the native path. These builders are + * contract-accurate (they match the finalized `Keystore`) but are not yet + * accepted by the node. * * @example * ```ts * import { - * hashLockChange8130, - * lockCall, - * getChangeSequences8130, // local sequence source - * sendCalls8130, + * lockChange, + * signAccountChanges, + * encodeApplySignedAccountChangesData, + * getConfigSequence, + * sendCalls, * } from 'viem/experimental/eip8130' * - * // 1) hash + sign the lock (1-hour unlock delay) with an admin key - * const digest = hashLockChange8130({ account, chainId, op: 'lock', unlockDelay: 3600, sequence }) - * const auth = await signDigest(digest) // `authenticator || data` - * - * // 2) submit the signed lock change - * await sendCalls8130(client, { account, calls: [lockCall({ account, unlockDelay: 3600, auth })], gas }) + * const { local } = await getConfigSequence(client, { accountConfiguration, account }) + * const entry = await signAccountChanges({ + * signer: admin, + * account, + * channel: 'local', + * chainId, + * sequence: local, + * changes: [lockChange({ unlockDelay: 3600 })], + * }) + * const data = encodeApplySignedAccountChangesData({ account, ...entry }) + * await sendCalls(client, { account, calls: [{ to: accountConfiguration, data }], gas }) * ``` */ -/** Maximum `unlockDelay` (the ABI field is `uint16`). */ -const maxUnlockDelay = 0xffff - -/** `keccak256("SignedLockChange(address account,uint256 chainId,uint8 op,uint16 unlockDelay,uint64 sequence)")` */ -export const lockChangeTypehash = keccak256( - stringToHex( - 'SignedLockChange(address account,uint256 chainId,uint8 op,uint16 unlockDelay,uint64 sequence)', - ), -) - -export type LockChangeOp = 'lock' | 'unlock' - -function opByte(op: LockChangeOp): number { - return op === 'lock' ? lockOp : unlockOp -} +/** Maximum `unlockDelay` (the payload field is `uint16`). */ +export const maxUnlockDelay = 0xffff -export type HashLockChange8130Parameters = { - /** The account whose lock state is changing. */ - account: Address - /** Chain ID (lock changes are local-channel only; use the current chain). */ - chainId: number - /** `'lock'` (hard-lock) or `'unlock'` (initiate the delayed unlock). */ - op: LockChangeOp +export type LockChangeParameters = { /** - * Unlock delay in seconds (`uint16`, `1 … 65535`). Required and non-zero for - * `'lock'`; MUST be `0` for `'unlock'` (which consumes the stored delay). - */ - unlockDelay: number - /** The account's local change sequence (from `getChangeSequences8130`). */ - sequence: number -} - -/** - * Computes the EIP-8130 `SignedLockChange` signature digest: - * `keccak256(abi.encode(LOCK_CHANGE_TYPEHASH, account, chainId, op, unlockDelay, sequence))`. - * - * Sign it (in `authenticator || data` form, with an admin key) to produce the - * `auth` passed to {@link lockCall} / {@link initiateUnlockCall}. - */ -export function hashLockChange8130( - parameters: HashLockChange8130Parameters, -): Hex { - const { account, chainId, op, unlockDelay, sequence } = parameters - return keccak256( - encodeAbiParameters( - [ - { type: 'bytes32' }, - { type: 'address' }, - { type: 'uint256' }, - { type: 'uint8' }, - { type: 'uint16' }, - { type: 'uint64' }, - ], - [ - lockChangeTypehash, - account, - BigInt(chainId), - opByte(op), - unlockDelay, - BigInt(sequence), - ], - ), - ) -} - -export type LockCallParameters = { - /** The account being locked (bound into the signed digest and the call). */ - account: Address - /** - * Delay in seconds between {@link initiateUnlockCall} and the account becoming + * Delay in seconds between the unlock being initiated and the account becoming * unlocked (`uint16`, `1 … 65535`). A larger delay gives more time to respond * to a compromised key. */ unlockDelay: number - /** Admin signature over {@link hashLockChange8130} (`authenticator || data`). */ - auth: Hex - /** - * `AccountConfiguration` system contract. Defaults to the canonical - * (enshrined) address, which is identical on every supported chain. - */ - accountConfiguration?: Address | undefined } /** - * Builds the account call that hard-locks the account: - * `AccountConfiguration.applySignedLockChanges(account, LOCK_OP, unlockDelay, auth)`. - * Include it in a {@link sendCalls8130} phase. + * Builds a `lock` ({@link AaLock}) op that hard-locks the account with a delayed + * unlock. Local channel only; must be the batch's only op. */ -export function lockCall(parameters: LockCallParameters): AaCall { - const { - account, - unlockDelay, - auth, - accountConfiguration = defaultAccountConfigAddress, - } = parameters +export function lockChange(parameters: LockChangeParameters): AaLock { + const { unlockDelay } = parameters if ( !Number.isInteger(unlockDelay) || unlockDelay < 1 || @@ -150,48 +74,14 @@ export function lockCall(parameters: LockCallParameters): AaCall { throw new BaseError( `\`unlockDelay\` must be an integer in \`1 … ${maxUnlockDelay}\` (uint16 seconds). Received ${unlockDelay}.`, ) - return { - to: accountConfiguration, - data: encodeFunctionData({ - abi: accountConfigurationAbi, - functionName: 'applySignedLockChanges', - args: [account, lockOp, unlockDelay, auth], - }), - } -} - -export type InitiateUnlockCallParameters = { - /** The account being unlocked (bound into the signed digest and the call). */ - account: Address - /** Admin signature over {@link hashLockChange8130} (`authenticator || data`). */ - auth: Hex - /** - * `AccountConfiguration` system contract. Defaults to the canonical - * (enshrined) address, which is identical on every supported chain. - */ - accountConfiguration?: Address | undefined + return { changeType: changeType.lock, unlockDelay } } /** - * Builds the account call that begins the (time-delayed) unlock: - * `AccountConfiguration.applySignedLockChanges(account, UNLOCK_OP, 0, auth)`. - * Include it in a {@link sendCalls8130} phase. The account becomes unlocked - * `unlockDelay` seconds later (see {@link getLockStatus8130}). + * Builds an `unlock` ({@link AaUnlock}) op that initiates the (time-delayed) + * unlock (consuming the stored `unlockDelay`). Local channel only; must be the + * batch's only op. The account becomes unlocked `unlockDelay` seconds later. */ -export function initiateUnlockCall( - parameters: InitiateUnlockCallParameters, -): AaCall { - const { - account, - auth, - accountConfiguration = defaultAccountConfigAddress, - } = parameters - return { - to: accountConfiguration, - data: encodeFunctionData({ - abi: accountConfigurationAbi, - functionName: 'applySignedLockChanges', - args: [account, unlockOp, 0, auth], - }), - } +export function unlockChange(): AaUnlock { + return { changeType: changeType.unlock } } diff --git a/src/experimental/eip8130/nonce.test.ts b/src/experimental/eip8130/nonce.test.ts index 1da83b3ad7..22254d728b 100644 --- a/src/experimental/eip8130/nonce.test.ts +++ b/src/experimental/eip8130/nonce.test.ts @@ -5,12 +5,12 @@ import { createClient } from '../../clients/createClient.js' import { custom } from '../../clients/transports/custom.js' import type { Hex } from '../../types/misc.js' import { keccak256 } from '../../utils/hash/keccak256.js' -import { to8130Account } from './accounts/to8130Account.js' -import { sendCalls8130 } from './actions/sendCalls.js' +import { toAccount } from './accounts/toAccount.js' +import { sendCalls } from './actions/sendCalls.js' import { nonceKeyMax } from './constants.js' import { key } from './keys.js' import { nonce } from './nonce.js' -import { parseTransaction8130 } from './utils/parseTransaction.js' +import { parseTransaction } from './utils/parseTransaction.js' import { erc1167Bytecode } from './utils/proxy.js' const owner = privateKeyToAccount( @@ -20,7 +20,7 @@ const code = erc1167Bytecode('0x00000000000000000000000000000000000000Ec') const userSalt = '0x0000000000000000000000000000000000000000000000000000000000000001' -const account = to8130Account({ +const account = toAccount({ signer: owner, userSalt, code, @@ -55,30 +55,30 @@ describe('nonce builders', () => { ) }) - test('nonceless with absolute expiry', () => { - expect(nonce.nonceless({ expiry: 1_800_000_000n })).toEqual({ + test('nonceless with absolute validBefore (unix ms)', () => { + expect(nonce.nonceless({ validBefore: 1_800_000_000_000n })).toEqual({ nonceKey: nonceKeyMax, nonceSequence: 0n, - expiry: 1_800_000_000n, + validBefore: 1_800_000_000_000n, }) }) - test('nonceless with relative expiresIn', () => { - const now = Math.floor(Date.now() / 1000) + test('nonceless with relative expiresIn (seconds → ms validBefore)', () => { + const nowMs = Date.now() const result = nonce.nonceless({ expiresIn: 600 }) expect(result.nonceKey).toBe(nonceKeyMax) expect(result.nonceSequence).toBe(0n) - expect(Number(result.expiry)).toBeGreaterThanOrEqual(now + 600) - expect(Number(result.expiry)).toBeLessThanOrEqual(now + 601) + expect(Number(result.validBefore)).toBeGreaterThanOrEqual(nowMs + 600_000) + expect(Number(result.validBefore)).toBeLessThanOrEqual(nowMs + 601_000) }) - test('nonceless requires an expiry', () => { + test('nonceless requires a validBefore', () => { expect(() => nonce.nonceless({})).toThrow() - expect(() => nonce.nonceless({ expiry: 0n })).toThrow() + expect(() => nonce.nonceless({ validBefore: 0n })).toThrow() }) }) -describe('sendCalls8130 nonce integration', () => { +describe('sendCalls nonce integration', () => { function makeClient() { const methods: string[] = [] let sent: Hex | undefined @@ -121,27 +121,29 @@ describe('sendCalls8130 nonce integration', () => { maxFeePerGas: 2_000_000_000n, maxPriorityFeePerGas: 1_000_000_000n, } - const calls = [{ to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }] + const calls = [ + { to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }, + ] - test('nonceless: no nonce read, tx carries NONCE_KEY_MAX + expiry', async () => { + test('nonceless: no nonce read, tx carries NONCE_KEY_MAX + validBefore', async () => { const ctx = makeClient() - await sendCalls8130(ctx.client, { + await sendCalls(ctx.client, { account, calls, ...fees, - ...nonce.nonceless({ expiry: 1_800_000_000n }), + ...nonce.nonceless({ validBefore: 1_800_000_000_000n }), }) expect(ctx.methods).not.toContain('eth_getTransactionCount') - const parsed = parseTransaction8130(ctx.sent!) + const parsed = parseTransaction(ctx.sent!) expect(parsed.nonceKey).toBe(nonceKeyMax) // `0n` sequence RLP-encodes as empty and parses back as `undefined`/`0n`. expect(parsed.nonceSequence ?? 0n).toBe(0n) - expect(parsed.expiry).toBe(1_800_000_000n) + expect(parsed.validBefore).toBe(1_800_000_000_000n) }) test('channel: reads the sequence with the 2D nonce_key param', async () => { const ctx = makeClient() - await sendCalls8130(ctx.client, { + await sendCalls(ctx.client, { account, calls, ...fees, @@ -151,7 +153,7 @@ describe('sendCalls8130 nonce integration', () => { // [address, blockTag, nonce_key] expect(ctx.lastGetCountParams).toHaveLength(3) expect(ctx.lastGetCountParams?.[2]).toBe('0x5') - const parsed = parseTransaction8130(ctx.sent!) + const parsed = parseTransaction(ctx.sent!) expect(parsed.nonceKey).toBe(5n) expect(parsed.nonceSequence).toBe(3n) }) diff --git a/src/experimental/eip8130/nonce.ts b/src/experimental/eip8130/nonce.ts index 054d51b245..706378ad51 100644 --- a/src/experimental/eip8130/nonce.ts +++ b/src/experimental/eip8130/nonce.ts @@ -7,7 +7,7 @@ import { isNoncelessOnly } from './keys.js' /** * A resolved EIP-8130 nonce selection: the channel key and (for nonce-free * mode) the fixed sequence and required expiry. Spread the result directly into - * {@link sendCalls8130} / {@link prepareTransaction8130} parameters. + * {@link sendCalls} / {@link prepareTransaction} parameters. */ export type Nonce = { /** 2D nonce channel selector (`uint256`). `0` = standard sequential ordering. */ @@ -18,17 +18,18 @@ export type Nonce = { */ nonceSequence?: bigint | undefined /** - * Unix timestamp (seconds) after which the transaction is invalid. Required - * for nonce-free mode (it is the sole replay protection there). + * Unix timestamp (**milliseconds**) at/after which the transaction is invalid + * (the tx `validBefore`). Required (non-zero) for nonce-free mode — it is the + * sole replay protection there. */ - expiry?: bigint | undefined + validBefore?: bigint | undefined } /** * Builders for EIP-8130 nonce selection. EIP-8130 accounts support three * distinct nonce strategies; these helpers produce the correct * `nonceKey` / `nonceSequence` / `expiry` fields for each, ready to spread into - * {@link sendCalls8130} / {@link prepareTransaction8130}. + * {@link sendCalls} / {@link prepareTransaction}. * * - {@link nonce.sequential} — the classic single-file nonce (channel `0`). * - {@link nonce.channel} / {@link nonce.randomChannel} — independent 2D nonce @@ -39,17 +40,17 @@ export type Nonce = { * * @example * ```ts - * import { nonce, sendCalls8130 } from 'viem/experimental/eip8130' + * import { nonce, sendCalls } from 'viem/experimental/eip8130' * * // Two independent channels → can be mined in either order. - * await sendCalls8130(client, { account, calls: a, gas, ...nonce.channel(1n) }) - * await sendCalls8130(client, { account, calls: b, gas, ...nonce.channel(2n) }) + * await sendCalls(client, { account, calls: a, gas, ...nonce.channel(1n) }) + * await sendCalls(client, { account, calls: b, gas, ...nonce.channel(2n) }) * * // Fire-and-forget parallel txs on random channels. - * await sendCalls8130(client, { account, calls, gas, ...nonce.randomChannel() }) + * await sendCalls(client, { account, calls, gas, ...nonce.randomChannel() }) * * // Nonce-free: valid for the next 10 minutes, no sequencing. - * await sendCalls8130(client, { account, calls, gas, ...nonce.nonceless({ expiresIn: 600 }) }) + * await sendCalls(client, { account, calls, gas, ...nonce.nonceless({ expiresIn: 600 }) }) * ``` */ export const nonce = { @@ -78,7 +79,7 @@ export const nonce = { ) if (key === nonceKeyMax) throw new BaseError( - '`NONCE_KEY_MAX` selects nonce-free mode, which has no counter. Use `nonce.nonceless({ expiry })` instead.', + '`NONCE_KEY_MAX` selects nonce-free mode, which has no counter. Use `nonce.nonceless({ validBefore })` instead.', ) return { nonceKey: key } }, @@ -102,22 +103,28 @@ export const nonce = { * any other; replay protection is provided solely by `expiry`. Ideal for * fully parallel, retry-safe sends. * - * @param parameters.expiry - Absolute expiry (unix seconds). - * @param parameters.expiresIn - Relative expiry (seconds from now). Ignored - * when `expiry` is provided. One of `expiry` / `expiresIn` is required. + * @param parameters.validBefore - Absolute upper validity bound (unix ms). + * @param parameters.expiresIn - Relative validity window (**seconds** from + * now). Ignored when `validBefore` is provided. One of `validBefore` / + * `expiresIn` is required. */ - nonceless(parameters: { expiry?: bigint; expiresIn?: number }): Nonce { - const { expiry, expiresIn } = parameters - const resolvedExpiry = - expiry ?? + nonceless(parameters: { validBefore?: bigint; expiresIn?: number }): Nonce { + const { validBefore, expiresIn } = parameters + // `validBefore` is unix milliseconds; `expiresIn` is a seconds duration. + const resolvedValidBefore = + validBefore ?? (expiresIn !== undefined - ? BigInt(Math.floor(Date.now() / 1000) + expiresIn) + ? BigInt(Date.now() + expiresIn * 1000) : undefined) - if (resolvedExpiry === undefined || resolvedExpiry <= 0n) + if (resolvedValidBefore === undefined || resolvedValidBefore <= 0n) throw new BaseError( - 'Nonce-free mode requires a non-zero `expiry` (or `expiresIn`).', + 'Nonce-free mode requires a non-zero `validBefore` (or `expiresIn`).', ) - return { nonceKey: nonceKeyMax, nonceSequence: 0n, expiry: resolvedExpiry } + return { + nonceKey: nonceKeyMax, + nonceSequence: 0n, + validBefore: resolvedValidBefore, + } }, /** @@ -129,31 +136,32 @@ export const nonce = { * - Admin (`scope == 0`) **or** `SCOPE_NONCE` set → sequenced * {@link nonce.channel} (default channel `0`, i.e. {@link nonce.sequential}). * - Restricted actor **without** `SCOPE_NONCE` → {@link nonce.nonceless}, - * defaulting the expiry to `NONCE_FREE_MAX_EXPIRY_WINDOW` seconds from now. + * defaulting the window to `NONCE_FREE_MAX_EXPIRY_WINDOW` from now. * * @param scope - The signing actor's scope bitmask. * @param parameters.key - Sequenced channel selector (ignored in nonce-free * mode). @default 0n - * @param parameters.expiry - Absolute expiry (unix seconds) for nonce-free - * mode. Overrides `expiresIn`. - * @param parameters.expiresIn - Relative expiry (seconds) for nonce-free - * mode. @default Number(NONCE_FREE_MAX_EXPIRY_WINDOW) + * @param parameters.validBefore - Absolute upper validity bound (unix ms) for + * nonce-free mode. Overrides `expiresIn`. + * @param parameters.expiresIn - Relative validity window (seconds) for + * nonce-free mode. @default Number(NONCE_FREE_MAX_EXPIRY_WINDOW) / 1000 */ forScope( scope: number, parameters: { key?: bigint | undefined - expiry?: bigint | undefined + validBefore?: bigint | undefined expiresIn?: number | undefined } = {}, ): Nonce { if (isNoncelessOnly(scope)) return nonce.nonceless( - parameters.expiry !== undefined - ? { expiry: parameters.expiry } + parameters.validBefore !== undefined + ? { validBefore: parameters.validBefore } : { + // `nonceFreeMaxExpiryWindow` is milliseconds; `expiresIn` seconds. expiresIn: - parameters.expiresIn ?? Number(nonceFreeMaxExpiryWindow), + parameters.expiresIn ?? Number(nonceFreeMaxExpiryWindow) / 1000, }, ) return nonce.channel(parameters.key ?? 0n) diff --git a/src/experimental/eip8130/policies.test.ts b/src/experimental/eip8130/policies.test.ts index 80bef22337..004f5b4af1 100644 --- a/src/experimental/eip8130/policies.test.ts +++ b/src/experimental/eip8130/policies.test.ts @@ -50,7 +50,7 @@ describe('encoders', () => { describe('commitmentOf', () => { test('matches PolicyManager.commitmentOf reference vector', () => { expect(commitmentOf(binding)).toBe( - '0x8dd99af2418214f90f5f77acda96c564ef33f54f714cb5df58c39f33ba1c820d', + '0x99f5258c7da5ed6dcc01fcc552cdfc1a69369487bcfaa4f623e6e37f6780e8e7', ) }) diff --git a/src/experimental/eip8130/queries.test.ts b/src/experimental/eip8130/queries.test.ts index 38dbd64cda..9597f00c65 100644 --- a/src/experimental/eip8130/queries.test.ts +++ b/src/experimental/eip8130/queries.test.ts @@ -1,15 +1,15 @@ -import { describe, expect, test } from 'vitest' import type { Abi } from 'abitype' +import { describe, expect, test } from 'vitest' import { mainnet } from '../../chains/index.js' import { createClient } from '../../clients/createClient.js' import { custom } from '../../clients/transports/custom.js' import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' import { encodeFunctionResult } from '../../utils/abi/encodeFunctionResult.js' import { accountConfigurationAbi } from './abis.js' -import { getActorConfig8130 } from './actions/getActorConfig8130.js' -import { getPolicy8130 } from './actions/getPolicy8130.js' -import { getSessionSpend8130 } from './actions/getSessionSpend8130.js' -import { isActor8130 } from './actions/isActor8130.js' +import { getActorConfig } from './actions/getActorConfig.js' +import { getPolicy } from './actions/getPolicy.js' +import { getSessionSpend } from './actions/getSessionSpend.js' +import { isActor } from './actions/isActor.js' import { canonicalAuthenticators } from './constants.js' import { sessionPolicyAbi } from './policies.js' @@ -45,13 +45,13 @@ function readClient(abi: Abi, results: Record) { }) } -describe('getSessionSpend8130', () => { +describe('getSessionSpend', () => { test('reads getCurrentSpend into a budget view (#43: limit supplied)', async () => { const client = readClient(sessionPolicyAbi, { // PeriodUsage { start, end, spend } getCurrentSpend: { start: 1_000, end: 605_800, spend: 40_000_000n }, }) - const spend = await getSessionSpend8130(client, { + const spend = await getSessionSpend(client, { commitment, tokenLimit: { token, limit: 100_000_000n, period: 604_800n }, }) @@ -69,7 +69,7 @@ describe('getSessionSpend8130', () => { const client = readClient(sessionPolicyAbi, { getCurrentSpend: { start: 1_000, end: 605_800, spend: 150_000_000n }, }) - const spend = await getSessionSpend8130(client, { + const spend = await getSessionSpend(client, { commitment, tokenLimit: { token, limit: 100_000_000n, period: 604_800n }, }) @@ -77,7 +77,7 @@ describe('getSessionSpend8130', () => { }) }) -describe('getActorConfig8130', () => { +describe('getActorConfig', () => { test('decodes the ActorConfig struct', async () => { const client = readClient(accountConfigurationAbi, { getActorConfig: { @@ -86,7 +86,7 @@ describe('getActorConfig8130', () => { expiry: 1_800_000_000, }, }) - expect(await getActorConfig8130(client, { account, actorId })).toEqual({ + expect(await getActorConfig(client, { account, actorId })).toEqual({ authenticator: canonicalAuthenticators.p256, scope: 2, expiry: 1_800_000_000, @@ -96,20 +96,20 @@ describe('getActorConfig8130', () => { }) }) -describe('isActor8130', () => { +describe('isActor', () => { test('decodes the isActor bool', async () => { const client = readClient(accountConfigurationAbi, { isActor: true }) - expect(await isActor8130(client, { account, actorId })).toBe(true) + expect(await isActor(client, { account, actorId })).toBe(true) }) }) -describe('getPolicy8130', () => { +describe('getPolicy', () => { test('decodes (target, commitment)', async () => { const manager = '0x00000000000000000000000000000000000000dd' const client = readClient(accountConfigurationAbi, { getPolicy: [manager, commitment], }) - expect(await getPolicy8130(client, { account, actorId })).toEqual({ + expect(await getPolicy(client, { account, actorId })).toEqual({ target: manager, commitment, }) diff --git a/src/experimental/eip8130/types/transaction.ts b/src/experimental/eip8130/types/transaction.ts index 4413f739b4..6d027e4a3d 100644 --- a/src/experimental/eip8130/types/transaction.ts +++ b/src/experimental/eip8130/types/transaction.ts @@ -6,7 +6,7 @@ import type { Hex } from '../../../types/misc.js' * * On the wire, EIP-8130 calls carry no ETH value — each call executes with * `msg.value == 0`. A `value` MAY be supplied as ERC-5792-style intent: actions - * that build the wire (e.g. {@link sendCalls8130}) realize any non-zero `value` + * that build the wire (e.g. {@link sendCalls}) realize any non-zero `value` * by routing the phase through the account's wallet bytecode (`executeBatch`), * collapsing it back into a value-less `[to, data]`. A call whose `value` is `0` * (or omitted) is encoded directly as `[to, data]`. A non-zero `value` that @@ -43,18 +43,19 @@ export type AaCalls = readonly (readonly AaCall[])[] * An initial actor for a `create` entry, and the identity returned by the * {@link key} builders. * - * Initial actors carry their `scope` and (when `scope & SCOPE_POLICY`) their - * `policyData`; `expiry` is always `0` at creation. The address-derivation - * commitment is `actorId || authenticator || scope || policyData` per actor - * (`policyData` is empty unless the `SCOPE_POLICY` bit is set, then exactly - * 52 bytes). + * Initial actors carry their `scope` (a `uint16` bitmask) and (when + * `scope & SCOPE_POLICY`) their `policyData`; `expiry` is always `0` at creation. + * The address-derivation commitment hashes each actor into a leaf + * `keccak256(actorId(32) || authenticator(20) || scope(2, big-endian) || policyData)` + * then hashes the concatenated leaves (`policyData` is empty unless the + * `SCOPE_POLICY` bit is set, then exactly 52 bytes). */ export type AaActor = { /** 32-byte actor identifier. */ actorId: Hex /** Authenticator contract address. */ authenticator: Address - /** Scope bitmask committed at creation. `0` (or omitted) = unrestricted admin. */ + /** Scope bitmask (`uint16`) committed at creation. `0` (or omitted) = unrestricted admin. */ scope?: number | undefined /** Policy data (`manager || commitment`) — required iff `scope & SCOPE_POLICY`, else empty/omitted. */ policyData?: Hex | undefined @@ -74,41 +75,96 @@ export type AaAccountChangeCreate = { initialActors: readonly AaActor[] } -/** `authorizeActor` (change type `0x01`) operation within a config-change entry. */ +/** + * `authorizeActor` (`ChangeType` `0x00`) op within a `SignedAccountChanges` + * batch. Payload: `abi.encode(bytes32 actorId, (address authenticator, uint48 + * expiry, uint16 scope) config, bytes policyData)`. + */ export type AaAuthorizeActor = { - changeType: 0x01 + changeType: 0x00 /** 32-byte actor identifier. */ actorId: Hex /** Authenticator contract address. */ authenticator: Address - /** Permission bitmask. `0` (or omitted) = unrestricted admin. Set the `SCOPE_POLICY` bit for a policy-gated actor. */ + /** Permission bitmask (`uint16`). `0` (or omitted) = unrestricted admin. Set the `SCOPE_POLICY` bit for a policy-gated actor. */ scope?: number | undefined - /** Actor expiry (unix seconds). `0` (or omitted) = no expiry. */ + /** Actor expiry (unix seconds, `uint48`). `0` (or omitted) = no expiry. */ expiry?: bigint | undefined /** Policy data (`manager || commitment`) — required iff `scope & SCOPE_POLICY`, else empty/omitted. */ policyData?: Hex | undefined } -/** `revokeActor` (change type `0x02`) operation within a config-change entry. */ +/** `revokeActor` (`ChangeType` `0x01`) op. Payload: `abi.encode(bytes32 actorId)`. */ export type AaRevokeActor = { - changeType: 0x02 + changeType: 0x01 /** 32-byte actor identifier. */ actorId: Hex } -export type AaActorChange = AaAuthorizeActor | AaRevokeActor +/** + * `incrementLocalEpoch` (`ChangeType` `0x02`) op: bumps the account's local + * epoch, invalidating every unlanded local-channel signature at a prior epoch. + * Valid on either channel; empty payload. + */ +export type AaIncrementLocalEpoch = { + changeType: 0x02 +} + +/** + * `lock` (`ChangeType` `0x03`) op: hard-locks the account with a delayed unlock. + * Local channel only and MUST be the batch's only op. Payload: + * `abi.encode(uint16 unlockDelay)`. + * + * @remarks The enshrined node currently defers lock/unlock (a batch carrying one + * is rejected on the native path); it is contract-accurate but not yet accepted. + */ +export type AaLock = { + changeType: 0x03 + /** Unlock delay in seconds (`uint16`, `1 … 65535`). */ + unlockDelay: number +} + +/** + * `unlock` (`ChangeType` `0x04`) op: initiates the delayed unlock. Local channel + * only and MUST be the batch's only op; empty payload. See {@link AaLock}. + */ +export type AaUnlock = { + changeType: 0x04 +} + +/** A single operation within a `SignedAccountChanges` batch. */ +export type AaChange = + | AaAuthorizeActor + | AaRevokeActor + | AaIncrementLocalEpoch + | AaLock + | AaUnlock + +/** The replay domain a `SignedAccountChanges` batch binds to. */ +export type AaChangeChannel = 'local' | 'multichain' -/** `config` (type `0x01`) account-change entry: actor management. */ +/** + * `config` (type `0x01`) account-change entry: a signed `SignedAccountChanges` + * batch (`applySignedAccountChanges`). + */ export type AaAccountChangeConfig = { type: 'config' - /** Chain ID scope. `0` = valid on any chain (multichain channel). */ - chainId: number - /** Monotonic ordering sequence within the channel. */ - sequence: number - /** Actor change operations. */ - actorChanges: readonly AaActorChange[] - /** Authorization signature (`authenticator || data`). */ - auth: Hex + /** + * Replay channel. `'local'` binds `block.chainid` and carries the epoch + + * sequence machinery; `'multichain'` binds chain id `0` (a plain monotonic + * counter). + */ + channel: AaChangeChannel + /** + * Channel sequence word (`uint64`). On the `'local'` channel this is + * `localEpoch (high 32) || localSequence (low 32)`; on `'multichain'` it is a + * plain monotonic counter. Source it from `getConfigSequence`. + */ + sequence: bigint + /** The ordered ops, applied all-or-nothing. */ + changes: readonly AaChange[] + /** Authorization signature over the batch digest (`authenticator || data`). */ + signature: Hex } /** `delegation` (type `0x02`) account-change entry: code delegation. */ @@ -130,7 +186,7 @@ export type AaAccountChange = * * ``` * AA_TX_TYPE || rlp([ - * chain_id, sender, nonce_key, nonce_sequence, expiry, + * chain_id, sender, nonce_key, nonce_sequence, valid_after, valid_before, * max_priority_fee_per_gas, max_fee_per_gas, gas_limit, * account_changes, calls, metadata, payer, sender_auth, payer_auth * ]) @@ -149,8 +205,19 @@ export type TransactionSerializable8130 = { nonceKey?: bigint | undefined /** Expected sequence number within `nonceKey` (`uint64`). */ nonceSequence?: bigint | undefined - /** Unix timestamp (seconds) after which the transaction is invalid. `0` = no expiry. */ - expiry?: bigint | undefined + /** + * Unix timestamp (**milliseconds**, `uint64`) before which the transaction is + * invalid. `0` (or omitted) = no lower bound. Evaluated against + * `block.timestamp * 1000`. + */ + validAfter?: bigint | undefined + /** + * Unix timestamp (**milliseconds**, `uint64`) at/after which the transaction + * is invalid. `0` (or omitted) = no upper bound, but MUST be non-zero in + * nonce-free mode (`nonceKey == nonceKeyMax`). Evaluated against + * `block.timestamp * 1000`. + */ + validBefore?: bigint | undefined /** Max priority fee per gas (EIP-1559). */ maxPriorityFeePerGas?: bigint | undefined /** Max fee per gas (EIP-1559). */ @@ -167,7 +234,7 @@ export type TransactionSerializable8130 = { * is authenticated by both the sender and (when present) the payer. Omit or * `'0x'` for none. * - * High-level helpers (`prepareTransaction8130` / `sendCalls8130`) populate + * High-level helpers (`prepareTransaction` / `sendCalls`) populate * this from `dataSuffix` / `client.dataSuffix` (EIP-8130 has no calldata * suffix; attribution lands here instead). */ diff --git a/src/experimental/eip8130/utils/accountConfigCalls.test.ts b/src/experimental/eip8130/utils/accountConfigCalls.test.ts index bc8ab89c03..28d3c29391 100644 --- a/src/experimental/eip8130/utils/accountConfigCalls.test.ts +++ b/src/experimental/eip8130/utils/accountConfigCalls.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'vitest' +import type { Hex } from '../../../types/misc.js' import { decodeFunctionData } from '../../../utils/abi/decodeFunctionData.js' import { accountConfigurationAbi } from '../abis.js' import { @@ -8,13 +9,13 @@ import { unregister8130Chains, } from '../chains.js' import { accountConfigAddress } from '../constants.js' -import type { AaActor, AaActorChange } from '../types/transaction.js' +import type { AaActor, AaChange } from '../types/transaction.js' import { - encodeApplySignedActorChangesData, + encodeApplySignedAccountChangesData, encodeCreateAccountData, - toFactoryArgs8130, + toFactoryArgs, } from './accountConfigCalls.js' -import { computeAddress8130 } from './computeAddress.js' +import { computeAddress } from './computeAddress.js' const actor: AaActor = { actorId: '0x0000000000000000000000000000000000000000000000000000000000000001', @@ -39,7 +40,7 @@ describe('is8130Enabled (routing)', () => { }) }) -describe('toFactoryArgs8130 (ERC-4337 factory)', () => { +describe('toFactoryArgs (ERC-4337 factory)', () => { const params = { userSalt: '0x0000000000000000000000000000000000000000000000000000000000000001', @@ -48,7 +49,7 @@ describe('toFactoryArgs8130 (ERC-4337 factory)', () => { } as const test('factory is the account config contract; factoryData is createAccount', () => { - const { factory, factoryData } = toFactoryArgs8130(params) + const { factory, factoryData } = toFactoryArgs(params) expect(factory).toBe(accountConfigAddress) expect(factoryData).toBe(encodeCreateAccountData(params)) @@ -71,51 +72,57 @@ describe('toFactoryArgs8130 (ERC-4337 factory)', () => { test('custom factory address', () => { const factoryAddress = '0x00000000000000000000000000000000000000aa' as const - const { factory } = toFactoryArgs8130({ + const { factory } = toFactoryArgs({ ...params, accountConfigAddress: factoryAddress, }) expect(factory).toBe(factoryAddress) }) - test('factory deploys to the computeAddress8130 address', () => { + test('factory deploys to the computeAddress address', () => { // both derive from the same inputs/config address -> portable address - const address = computeAddress8130(params) + const address = computeAddress(params) expect(address).toMatch(/^0x[0-9a-fA-F]{40}$/) }) }) -describe('encodeApplySignedActorChangesData (portable path)', () => { - test('encodes account, chainId, actorChanges, auth', () => { - const actorChanges: readonly AaActorChange[] = [ +describe('encodeApplySignedAccountChangesData (portable path)', () => { + test('encodes account + SignedAccountChanges(channel, sequence, changes, signature)', () => { + const changes: readonly AaChange[] = [ { - changeType: 0x01, + changeType: 0x00, actorId: '0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc', authenticator: '0x0000000000000000000000000000000000000001', scope: 0x04, }, { - changeType: 0x02, + changeType: 0x01, actorId: '0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266', }, ] - const data = encodeApplySignedActorChangesData({ + const data = encodeApplySignedAccountChangesData({ account: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', - chainId: 0, - actorChanges, - auth: '0xfeed', + channel: 'local', + sequence: 1n, + changes, + signature: '0xfeed', }) const decoded = decodeFunctionData({ abi: accountConfigurationAbi, data, }) - expect(decoded.functionName).toBe('applySignedActorChanges') - expect(decoded.args[1]).toBe(0n) - expect((decoded.args[2] as readonly { changeType: number }[]).length).toBe( - 2, - ) - expect(decoded.args[3]).toBe('0xfeed') + expect(decoded.functionName).toBe('applySignedAccountChanges') + const s = decoded.args[1] as { + channel: number + sequence: bigint + changes: readonly { changeType: number; payload: Hex }[] + signature: Hex + } + expect(s.channel).toBe(0) + expect(s.sequence).toBe(1n) + expect(s.changes.length).toBe(2) + expect(s.signature).toBe('0xfeed') }) }) diff --git a/src/experimental/eip8130/utils/accountConfigCalls.ts b/src/experimental/eip8130/utils/accountConfigCalls.ts index 482702c704..95b6d86188 100644 --- a/src/experimental/eip8130/utils/accountConfigCalls.ts +++ b/src/experimental/eip8130/utils/accountConfigCalls.ts @@ -7,8 +7,12 @@ import { } from '../../../utils/abi/encodeFunctionData.js' import { accountConfigurationAbi } from '../abis.js' import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' -import type { AaActor, AaActorChange } from '../types/transaction.js' -import { encodeActorChangeData } from './actorChangeData.js' +import type { + AaActor, + AaChange, + AaChangeChannel, +} from '../types/transaction.js' +import { encodeChangePayload } from './actorChangeData.js' function toInitialActors(actors: readonly AaActor[]) { return actors.map((actor) => ({ @@ -19,11 +23,10 @@ function toInitialActors(actors: readonly AaActor[]) { })) } -function toAbiActorChanges(changes: readonly AaActorChange[]) { +function toAbiChanges(changes: readonly AaChange[]) { return changes.map((change) => ({ changeType: change.changeType, - actorId: change.actorId, - data: encodeActorChangeData(change), + payload: encodeChangePayload(change), })) } @@ -43,7 +46,7 @@ export type EncodeCreateAccountDataErrorType = /** * Encodes calldata for `AccountConfiguration.createAccount` — the ERC-4337 * factory call that deploys an EIP-8130 account on a non-8130 chain (and is the - * `factoryData` returned by {@link toFactoryArgs8130}). + * `factoryData` returned by {@link toFactoryArgs}). */ export function encodeCreateAccountData( parameters: EncodeCreateAccountDataParameters, @@ -56,7 +59,7 @@ export function encodeCreateAccountData( }) } -export type ToFactoryArgs8130Parameters = EncodeCreateAccountDataParameters & { +export type ToFactoryArgsParameters = EncodeCreateAccountDataParameters & { /** * Account Configuration contract address (the ERC-4337 factory). Defaults to * the placeholder {@link accountConfigAddress} constant. @@ -64,23 +67,23 @@ export type ToFactoryArgs8130Parameters = EncodeCreateAccountDataParameters & { accountConfigAddress?: Address | undefined } -export type ToFactoryArgs8130ReturnType = { +export type ToFactoryArgsReturnType = { factory: Address factoryData: Hex } -export type ToFactoryArgs8130ErrorType = +export type ToFactoryArgsErrorType = | EncodeCreateAccountDataErrorType | ErrorType /** * Returns the ERC-4337 `{ factory, factoryData }` for deploying an EIP-8130 * account through the `AccountConfiguration` contract on a non-8130 chain. The - * resulting account address matches {@link computeAddress8130}. + * resulting account address matches {@link computeAddress}. */ -export function toFactoryArgs8130( - parameters: ToFactoryArgs8130Parameters, -): ToFactoryArgs8130ReturnType { +export function toFactoryArgs( + parameters: ToFactoryArgsParameters, +): ToFactoryArgsReturnType { const { accountConfigAddress = defaultAccountConfigAddress, ...createParameters @@ -91,33 +94,43 @@ export function toFactoryArgs8130( } } -export type EncodeApplySignedActorChangesDataParameters = { - /** The account whose actor configuration is changing. */ +export type EncodeApplySignedAccountChangesDataParameters = { + /** The account whose configuration is changing. */ account: Address - /** Chain ID scope. `0` = valid on any chain (multichain channel). */ - chainId: number - /** Actor change operations. */ - actorChanges: readonly AaActorChange[] - /** Authorization signature (`authenticator || data`). */ - auth: Hex + /** Replay channel (`'local'` binds `block.chainid`; `'multichain'` binds `0`). */ + channel: AaChangeChannel + /** The channel sequence word (`uint64`). */ + sequence: bigint + /** The ordered ops in the batch. */ + changes: readonly AaChange[] + /** Authorization signature over the batch digest (`authenticator || data`). */ + signature: Hex } -export type EncodeApplySignedActorChangesDataErrorType = +export type EncodeApplySignedAccountChangesDataErrorType = | EncodeFunctionDataErrorType | ErrorType /** - * Encodes calldata for `AccountConfiguration.applySignedActorChanges` — the - * portable (any-chain) path to apply signed actor changes via plain EVM - * execution. Pair with {@link signActorChanges8130} to produce the `auth`. + * Encodes calldata for `AccountConfiguration.applySignedAccountChanges` — the + * portable (any-chain) path to apply a signed batch via plain EVM execution. + * Pair with {@link signAccountChanges} to produce the `signature`. */ -export function encodeApplySignedActorChangesData( - parameters: EncodeApplySignedActorChangesDataParameters, +export function encodeApplySignedAccountChangesData( + parameters: EncodeApplySignedAccountChangesDataParameters, ): Hex { - const { account, chainId, actorChanges, auth } = parameters + const { account, channel, sequence, changes, signature } = parameters return encodeFunctionData({ abi: accountConfigurationAbi, - functionName: 'applySignedActorChanges', - args: [account, BigInt(chainId), toAbiActorChanges(actorChanges), auth], + functionName: 'applySignedAccountChanges', + args: [ + account, + { + channel: channel === 'multichain' ? 1 : 0, + sequence, + changes: toAbiChanges(changes), + signature, + }, + ], }) } diff --git a/src/experimental/eip8130/utils/actorChangeData.ts b/src/experimental/eip8130/utils/actorChangeData.ts index 13b20201bb..abe7bef4ea 100644 --- a/src/experimental/eip8130/utils/actorChangeData.ts +++ b/src/experimental/eip8130/utils/actorChangeData.ts @@ -9,72 +9,100 @@ import { type EncodeAbiParametersErrorType, encodeAbiParameters, } from '../../../utils/abi/encodeAbiParameters.js' -import { actorChangeType } from '../constants.js' -import type { AaActorChange } from '../types/transaction.js' +import { changeType } from '../constants.js' +import type { AaChange } from '../types/transaction.js' -const authorizeDataParameters = [ +/** + * ABI parameters for an `authorizeActor` payload: + * `abi.encode(bytes32 actorId, (address authenticator, uint48 expiry, uint16 + * scope) config, bytes policyData)`. The `config` tuple field order and widths + * mirror `Keystore.ActorConfig`. + */ +const authorizePayloadParameters = [ + { name: 'actorId', type: 'bytes32' }, { + name: 'config', type: 'tuple', components: [ { name: 'authenticator', type: 'address' }, - { name: 'scope', type: 'uint8' }, { name: 'expiry', type: 'uint48' }, + { name: 'scope', type: 'uint16' }, ], }, - { type: 'bytes' }, + { name: 'policyData', type: 'bytes' }, ] as const -export type EncodeActorChangeDataErrorType = +/** ABI parameters for a `revokeActor` payload: `abi.encode(bytes32 actorId)`. */ +const revokePayloadParameters = [{ name: 'actorId', type: 'bytes32' }] as const + +/** ABI parameters for a `lock` payload: `abi.encode(uint16 unlockDelay)`. */ +const lockPayloadParameters = [{ name: 'unlockDelay', type: 'uint16' }] as const + +export type EncodeChangePayloadErrorType = | EncodeAbiParametersErrorType | ErrorType /** - * Encodes the operation-specific `data` of an `actor_change`: + * Encodes the operation-specific `payload` of a `SignedAccountChanges` change + * (mirrors `Keystore.AccountChange.payload`): * - * - `authorizeActor` -> `abi.encode((address,uint8,uint48) config, bytes policyData)` - * - `revokeActor` -> empty bytes (`0x`) + * - `authorizeActor` -> `abi.encode(bytes32 actorId, (address,uint48,uint16) config, bytes policyData)` + * - `revokeActor` -> `abi.encode(bytes32 actorId)` + * - `lock` -> `abi.encode(uint16 unlockDelay)` + * - `incrementLocalEpoch` / `unlock` -> empty bytes (`0x`) * * @remarks - * The `data` is ABI-encoded (not RLP) so the same blob is decoded identically by - * the native protocol and by `AccountConfiguration.applySignedActorChanges` - * (`abi.decode(data, (ActorConfig, bytes))`). It is also the value hashed in the - * config-change signature digest (see {@link hashActorChanges8130}). Policy - * presence is the `SCOPE_POLICY` bit in `scope`; `policyData` is empty unless - * that bit is set (then `manager (20) || commitment (32)`). + * The `payload` is ABI-encoded (not RLP) so the same blob is decoded + * identically by the native protocol and by + * `AccountConfiguration.applySignedAccountChanges`. It is also the value hashed + * (`keccak256(payload)`) into the batch signature digest (see + * {@link hashAccountChanges}). Policy presence is the `SCOPE_POLICY` bit in + * `scope`; `policyData` is empty unless that bit is set (then `manager (20) || + * commitment (32)`). */ -export function encodeActorChangeData(change: AaActorChange): Hex { - if (change.changeType === actorChangeType.authorizeActor) - return encodeAbiParameters(authorizeDataParameters, [ +export function encodeChangePayload(change: AaChange): Hex { + if (change.changeType === changeType.authorizeActor) + return encodeAbiParameters(authorizePayloadParameters, [ + change.actorId, { authenticator: change.authenticator, - scope: change.scope ?? 0, // `uint48` maps to `number` in viem's ABI encoder; expiry (unix seconds) // fits comfortably. expiry: Number(change.expiry ?? 0n), + scope: change.scope ?? 0, }, change.policyData ?? '0x', ]) + if (change.changeType === changeType.revokeActor) + return encodeAbiParameters(revokePayloadParameters, [change.actorId]) + if (change.changeType === changeType.lock) + return encodeAbiParameters(lockPayloadParameters, [change.unlockDelay]) + // incrementLocalEpoch / unlock: empty payload. return '0x' } -export type DecodedAuthorizeActorData = { +export type DecodedAuthorizeActorPayload = { + actorId: Hex authenticator: Address scope: number expiry: bigint policyData: Hex } -export type DecodeAuthorizeActorDataErrorType = +export type DecodeAuthorizeActorPayloadErrorType = | DecodeAbiParametersErrorType | ErrorType -/** Decodes the `authorizeActor` `data` produced by {@link encodeActorChangeData}. */ -export function decodeAuthorizeActorData(data: Hex): DecodedAuthorizeActorData { - const [config, policyData] = decodeAbiParameters( - authorizeDataParameters, - data, +/** Decodes an `authorizeActor` `payload` produced by {@link encodeChangePayload}. */ +export function decodeAuthorizeActorPayload( + payload: Hex, +): DecodedAuthorizeActorPayload { + const [actorId, config, policyData] = decodeAbiParameters( + authorizePayloadParameters, + payload, ) return { + actorId, authenticator: config.authenticator, scope: config.scope, expiry: BigInt(config.expiry), diff --git a/src/experimental/eip8130/utils/assertTransaction.ts b/src/experimental/eip8130/utils/assertTransaction.ts index 8e328dc446..31bbd254f5 100644 --- a/src/experimental/eip8130/utils/assertTransaction.ts +++ b/src/experimental/eip8130/utils/assertTransaction.ts @@ -4,7 +4,7 @@ import type { ErrorType } from '../../../errors/utils.js' import { nonceKeyMax } from '../constants.js' import type { TransactionSerializable8130 } from '../types/transaction.js' -export type AssertTransaction8130ErrorType = +export type AssertTransactionErrorType = | InvalidChainIdError | BaseError | ErrorType @@ -13,11 +13,18 @@ export type AssertTransaction8130ErrorType = * Validates the structural invariants of an EIP-8130 transaction prior to * serialization or hashing. */ -export function assertTransaction8130( +export function assertTransaction( transaction: TransactionSerializable8130, ): void { - const { chainId, nonceKey, nonceSequence, expiry, payer, payerAuth, calls } = - transaction + const { + chainId, + nonceKey, + nonceSequence, + validBefore, + payer, + payerAuth, + calls, + } = transaction if (chainId <= 0) throw new InvalidChainIdError({ chainId }) @@ -29,18 +36,18 @@ export function assertTransaction8130( for (const call of phase) if (call.value && call.value !== 0n) throw new BaseError( - 'EIP-8130 calls cannot carry `value` on the wire. Route value-bearing calls through the account wallet (e.g. `encodeWalletCalls` / `sendCalls8130`).', + 'EIP-8130 calls cannot carry `value` on the wire. Route value-bearing calls through the account wallet (e.g. `encodeWalletCalls` / `sendCalls`).', ) - // Nonce-free mode (`NONCE_KEY_MAX`): sequence must be 0 and expiry non-zero. + // Nonce-free mode (`NONCE_KEY_MAX`): sequence must be 0 and validBefore non-zero. if (typeof nonceKey === 'bigint' && nonceKey === nonceKeyMax) { if (nonceSequence !== undefined && nonceSequence !== 0n) throw new BaseError( '`nonceSequence` must be `0n` when `nonceKey` is `nonceKeyMax` (nonce-free mode).', ) - if (!expiry || expiry === 0n) + if (!validBefore || validBefore === 0n) throw new BaseError( - '`expiry` must be non-zero when `nonceKey` is `nonceKeyMax` (nonce-free mode).', + '`validBefore` must be non-zero when `nonceKey` is `nonceKeyMax` (nonce-free mode).', ) } diff --git a/src/experimental/eip8130/utils/computeAddress.test.ts b/src/experimental/eip8130/utils/computeAddress.test.ts index 3fcecb88de..e64d96327b 100644 --- a/src/experimental/eip8130/utils/computeAddress.test.ts +++ b/src/experimental/eip8130/utils/computeAddress.test.ts @@ -6,7 +6,7 @@ import { toHex } from '../../../utils/encoding/toHex.js' import { keccak256 } from '../../../utils/hash/keccak256.js' import { accountConfigAddress } from '../constants.js' import type { AaActor } from '../types/transaction.js' -import { computeAddress8130, deploymentHeader } from './computeAddress.js' +import { computeAddress, deploymentHeader } from './computeAddress.js' const actorA: AaActor = { actorId: '0x0000000000000000000000000000000000000000000000000000000000000001', @@ -25,27 +25,28 @@ describe('computeAddress (EIP-8130)', () => { code: '0x6080604052', initialActors: [actorA, actorB], } as const - const address = computeAddress8130(params) + const address = computeAddress(params) expect(isAddress(address)).toBe(true) - expect(computeAddress8130(params)).toBe(address) + expect(computeAddress(params)).toBe(address) }) test('matches manual CREATE2 derivation', () => { const userSalt = '0x00000000000000000000000000000000000000000000000000000000000000aa' as const const code = '0x6080' as const - const actorsCommitment = keccak256( - concatHex([ - actorA.actorId, - actorA.authenticator, - toHex(actorA.scope ?? 0, { size: 1 }), - actorA.policyData ?? '0x', - actorB.actorId, - actorB.authenticator, - toHex(actorB.scope ?? 0, { size: 1 }), - actorB.policyData ?? '0x', - ]), - ) + // Leaves-then-list commitment (EIP-8130 `_computeActorsCommitment`): each + // actor hashes into a leaf `keccak256(actorId || authenticator || + // scope(2 BE) || policyData)`, then the packed leaves are hashed once. + const leaf = (actor: AaActor) => + keccak256( + concatHex([ + actor.actorId, + actor.authenticator, + toHex(actor.scope ?? 0, { size: 2 }), + actor.policyData ?? '0x', + ]), + ) + const actorsCommitment = keccak256(concatHex([leaf(actorA), leaf(actorB)])) const effectiveSalt = keccak256(concatHex([userSalt, actorsCommitment])) const deploymentCode = concatHex([deploymentHeader(2), code]) const expected = getCreate2Address({ @@ -54,18 +55,18 @@ describe('computeAddress (EIP-8130)', () => { bytecode: deploymentCode, }) expect( - computeAddress8130({ userSalt, code, initialActors: [actorA, actorB] }), + computeAddress({ userSalt, code, initialActors: [actorA, actorB] }), ).toBe(expected) }) test('different salt yields different address', () => { const base = { code: '0x6080', initialActors: [actorA] } as const - const a = computeAddress8130({ + const a = computeAddress({ ...base, userSalt: '0x0000000000000000000000000000000000000000000000000000000000000001', }) - const b = computeAddress8130({ + const b = computeAddress({ ...base, userSalt: '0x0000000000000000000000000000000000000000000000000000000000000002', @@ -80,8 +81,8 @@ describe('computeAddress (EIP-8130)', () => { code: '0x6080', initialActors: [actorA], } as const - const a = computeAddress8130(base) - const b = computeAddress8130({ + const a = computeAddress(base) + const b = computeAddress({ ...base, accountConfigAddress: '0x00000000000000000000000000000000000000ff', }) @@ -95,7 +96,7 @@ describe('computeAddress (EIP-8130)', () => { test('rejects unsorted / duplicate actors', () => { expect(() => - computeAddress8130({ + computeAddress({ userSalt: '0x0000000000000000000000000000000000000000000000000000000000000001', code: '0x6080', @@ -103,7 +104,7 @@ describe('computeAddress (EIP-8130)', () => { }), ).toThrowError() expect(() => - computeAddress8130({ + computeAddress({ userSalt: '0x0000000000000000000000000000000000000000000000000000000000000001', code: '0x6080', @@ -114,7 +115,7 @@ describe('computeAddress (EIP-8130)', () => { test('rejects empty code', () => { expect(() => - computeAddress8130({ + computeAddress({ userSalt: '0x0000000000000000000000000000000000000000000000000000000000000001', code: '0x', diff --git a/src/experimental/eip8130/utils/computeAddress.ts b/src/experimental/eip8130/utils/computeAddress.ts index 6417e3f09e..1f2979c5be 100644 --- a/src/experimental/eip8130/utils/computeAddress.ts +++ b/src/experimental/eip8130/utils/computeAddress.ts @@ -50,7 +50,7 @@ export function deploymentHeader(codeSize: number): Hex { ) } -export type ComputeAddress8130Parameters = { +export type ComputeAddressParameters = { /** User-chosen uniqueness factor (bytes32). */ userSalt: Hex /** Runtime bytecode to be placed at the account address. */ @@ -67,7 +67,7 @@ export type ComputeAddress8130Parameters = { accountConfigAddress?: Address | undefined } -export type ComputeAddress8130ErrorType = +export type ComputeAddressErrorType = | GetCreate2AddressErrorType | ConcatHexErrorType | Keccak256ErrorType @@ -79,16 +79,18 @@ export type ComputeAddress8130ErrorType = * CREATE2 derivation: * * ``` - * // per actor: actorId(32) || authenticator(20) || scope(1) || policyData(0|52) - * actors_commitment = keccak256(actorId_0 || authenticator_0 || scope_0 || policyData_0 || ...) + * // per actor leaf: keccak256(actorId(32) || authenticator(20) || scope(2 BE) || policyData(0|52)) + * actors_commitment = keccak256(leaf_0 || leaf_1 || ... || leaf_{n-1}) * effective_salt = keccak256(user_salt || actors_commitment) * deployment_code = DEPLOYMENT_HEADER(len(code)) || code * address = keccak256(0xff || ACCOUNT_CONFIG_ADDRESS || effective_salt || keccak256(deployment_code))[12:] * ``` + * + * The commitment is a "hash-the-leaves-then-hash-the-list" scheme (EIP-8130 + * `_computeActorsCommitment`): each actor is hashed into a 32-byte leaf, then + * the packed leaves are hashed once. */ -export function computeAddress8130( - parameters: ComputeAddress8130Parameters, -): Address { +export function computeAddress(parameters: ComputeAddressParameters): Address { const { userSalt, code, @@ -118,12 +120,16 @@ export function computeAddress8130( const actorsCommitment = keccak256( concatHex( - initialActors.flatMap((actor) => [ - actor.actorId, - actor.authenticator, - toHex(actor.scope ?? 0, { size: 1 }), - actor.policyData ?? '0x', - ]), + initialActors.map((actor) => + keccak256( + concatHex([ + actor.actorId, + actor.authenticator, + toHex(actor.scope ?? 0, { size: 2 }), + actor.policyData ?? '0x', + ]), + ), + ), ), ) const effectiveSalt = keccak256(concatHex([userSalt, actorsCommitment])) diff --git a/src/experimental/eip8130/utils/encodeWalletCalls.test.ts b/src/experimental/eip8130/utils/encodeWalletCalls.test.ts index 0c6ae5a580..1bdafe1034 100644 --- a/src/experimental/eip8130/utils/encodeWalletCalls.test.ts +++ b/src/experimental/eip8130/utils/encodeWalletCalls.test.ts @@ -37,9 +37,7 @@ describe('encodeWalletCalls', () => { data: phase[0].data!, }) expect(decoded.functionName).toBe('executeBatch') - expect(decoded.args).toEqual([ - [{ target: to, value: 1n, data: '0xbeef' }], - ]) + expect(decoded.args).toEqual([[{ target: to, value: 1n, data: '0xbeef' }]]) }) test('collapses every call in a value-bearing phase (incl. value-less ones)', () => { diff --git a/src/experimental/eip8130/utils/hashActorChanges.ts b/src/experimental/eip8130/utils/hashActorChanges.ts index 4df595d2a8..6ec552a077 100644 --- a/src/experimental/eip8130/utils/hashActorChanges.ts +++ b/src/experimental/eip8130/utils/hashActorChanges.ts @@ -17,35 +17,38 @@ import { type Keccak256ErrorType, keccak256, } from '../../../utils/hash/keccak256.js' -import type { AaActorChange } from '../types/transaction.js' -import { encodeActorChangeData } from './actorChangeData.js' +import type { AaChange } from '../types/transaction.js' +import { encodeChangePayload } from './actorChangeData.js' -/** `keccak256("ActorChange(uint8 changeType,bytes32 actorId,bytes data)")` */ -export const actorChangeTypehash = keccak256( - stringToHex('ActorChange(uint8 changeType,bytes32 actorId,bytes data)'), +/** `keccak256("AccountChange(uint8 changeType,bytes payload)")` */ +export const accountChangeTypehash = keccak256( + stringToHex('AccountChange(uint8 changeType,bytes payload)'), ) /** - * `keccak256("SignedActorChanges(address account,uint256 chainId,uint64 sequence,ActorChange[] actorChanges)ActorChange(uint8 changeType,bytes32 actorId,bytes data)")` + * `keccak256("SignedAccountChanges(address account,uint256 chainId,uint64 sequence,AccountChange[] changes)AccountChange(uint8 changeType,bytes payload)")` */ -export const signedActorChangesTypehash = keccak256( +export const signedAccountChangesTypehash = keccak256( stringToHex( - 'SignedActorChanges(address account,uint256 chainId,uint64 sequence,ActorChange[] actorChanges)ActorChange(uint8 changeType,bytes32 actorId,bytes data)', + 'SignedAccountChanges(address account,uint256 chainId,uint64 sequence,AccountChange[] changes)AccountChange(uint8 changeType,bytes payload)', ), ) -export type HashActorChanges8130Parameters = { - /** The account whose actor configuration is changing. */ +export type HashAccountChangesParameters = { + /** The account whose configuration is changing. */ account: Address - /** Chain ID scope. `0` = valid on any chain (multichain channel). */ - chainId: number - /** Monotonic ordering sequence within the channel. */ - sequence: number - /** Actor change operations. */ - actorChanges: readonly AaActorChange[] + /** + * The replay chain id bound into the digest: `block.chainid` for the `'local'` + * channel, `0` for `'multichain'`. + */ + chainId: number | bigint + /** The channel sequence word (`uint64`). */ + sequence: number | bigint + /** The ordered ops in the batch. */ + changes: readonly AaChange[] } -export type HashActorChanges8130ErrorType = +export type HashAccountChangesErrorType = | EncodeAbiParametersErrorType | ConcatHexErrorType | Keccak256ErrorType @@ -53,41 +56,36 @@ export type HashActorChanges8130ErrorType = | ErrorType /** - * Computes the EIP-8130 config-change (`SignedActorChanges`) signature digest: + * Computes the EIP-8130 `SignedAccountChanges` batch signature digest + * (`Keystore._changesDigest`): * * ``` - * actorChangeHashes = [keccak256(abi.encode(ACTORCHANGE_TYPEHASH, changeType, actorId, keccak256(data)))] - * actorChangesHash = keccak256(abi.encodePacked(actorChangeHashes)) - * digest = keccak256(abi.encode(TYPEHASH, account, chainId, sequence, actorChangesHash)) + * changeHashes = [keccak256(abi.encode(ACCOUNT_CHANGE_TYPEHASH, changeType, keccak256(payload)))] + * changesHash = keccak256(abi.encodePacked(changeHashes)) + * digest = keccak256(abi.encode(SIGNED_ACCOUNT_CHANGES_TYPEHASH, account, chainId, sequence, changesHash)) * ``` * * The resulting digest is signed (in `authenticator || data` form) to produce - * the config-change entry's `auth`. + * the config entry's `signature`. */ -export function hashActorChanges8130( - parameters: HashActorChanges8130Parameters, +export function hashAccountChanges( + parameters: HashAccountChangesParameters, ): Hex { - const { account, chainId, sequence, actorChanges } = parameters + const { account, chainId, sequence, changes } = parameters - const actorChangeHashes = actorChanges.map((change) => + const changeHashes = changes.map((change) => keccak256( encodeAbiParameters( + [{ type: 'bytes32' }, { type: 'uint8' }, { type: 'bytes32' }], [ - { type: 'bytes32' }, - { type: 'uint8' }, - { type: 'bytes32' }, - { type: 'bytes32' }, - ], - [ - actorChangeTypehash, + accountChangeTypehash, change.changeType, - change.actorId, - keccak256(encodeActorChangeData(change)), + keccak256(encodeChangePayload(change)), ], ), ), ) - const actorChangesHash = keccak256(concatHex(actorChangeHashes)) + const changesHash = keccak256(concatHex(changeHashes)) return keccak256( encodeAbiParameters( @@ -99,11 +97,11 @@ export function hashActorChanges8130( { type: 'bytes32' }, ], [ - signedActorChangesTypehash, + signedAccountChangesTypehash, account, BigInt(chainId), BigInt(sequence), - actorChangesHash, + changesHash, ], ), ) diff --git a/src/experimental/eip8130/utils/hashTransaction.ts b/src/experimental/eip8130/utils/hashTransaction.ts index a7847b9341..579e251dfa 100644 --- a/src/experimental/eip8130/utils/hashTransaction.ts +++ b/src/experimental/eip8130/utils/hashTransaction.ts @@ -19,17 +19,17 @@ import { toTransactionBody } from './serializeTransaction.js' type To = 'hex' | 'bytes' -export type GetSignatureHash8130Parameters = +export type GetSignatureHashParameters = TransactionSerializable8130 & { /** Output format. @default 'hex' */ to?: to | To | undefined } -export type GetSignatureHash8130ReturnType = +export type GetSignatureHashReturnType = | (to extends 'bytes' ? ByteArray : never) | (to extends 'hex' ? Hex : never) -export type GetSenderSignatureHash8130ErrorType = +export type GetSenderSignatureHashErrorType = | Keccak256ErrorType | ConcatHexErrorType | HexToBytesErrorType @@ -41,15 +41,15 @@ export type GetSenderSignatureHash8130ErrorType = * * ``` * keccak256(AA_TX_TYPE || rlp([ - * chain_id, from, nonce_key, nonce_sequence, expiry, + * chain_id, from, nonce_key, nonce_sequence, valid_after, valid_before, * max_priority_fee_per_gas, max_fee_per_gas, gas_limit, - * account_changes, calls, payer + * account_changes, calls, metadata, payer * ])) * ``` */ -export function getSenderSignatureHash8130( - parameters: GetSignatureHash8130Parameters, -): GetSignatureHash8130ReturnType { +export function getSenderSignatureHash( + parameters: GetSignatureHashParameters, +): GetSignatureHashReturnType { const { to = 'hex', payer } = parameters const hash = keccak256( concatHex([ @@ -57,12 +57,11 @@ export function getSenderSignatureHash8130( toRlp([...toTransactionBody(parameters), payer ?? '0x']), ]), ) - if (to === 'bytes') - return hexToBytes(hash) as GetSignatureHash8130ReturnType - return hash as GetSignatureHash8130ReturnType + if (to === 'bytes') return hexToBytes(hash) as GetSignatureHashReturnType + return hash as GetSignatureHashReturnType } -export type GetPayerSignatureHash8130ErrorType = +export type GetPayerSignatureHashErrorType = | Keccak256ErrorType | ConcatHexErrorType | HexToBytesErrorType @@ -74,7 +73,7 @@ export type GetPayerSignatureHash8130ErrorType = * * ``` * keccak256(AA_PAYER_TYPE || rlp([ - * chain_id, from, nonce_key, nonce_sequence, expiry, + * chain_id, from, nonce_key, nonce_sequence, valid_after, valid_before, * max_priority_fee_per_gas, max_fee_per_gas, gas_limit, * account_changes, calls, metadata, payer * ])) @@ -90,9 +89,9 @@ export type GetPayerSignatureHash8130ErrorType = * `parameters.from` before computing this hash, to bind the payer's signature to * the specific sender and prevent cross-sender replay. */ -export function getPayerSignatureHash8130( - parameters: GetSignatureHash8130Parameters, -): GetSignatureHash8130ReturnType { +export function getPayerSignatureHash( + parameters: GetSignatureHashParameters, +): GetSignatureHashReturnType { const { to = 'hex', payer } = parameters const hash = keccak256( concatHex([ @@ -100,7 +99,6 @@ export function getPayerSignatureHash8130( toRlp([...toTransactionBody(parameters), payer ?? '0x']), ]), ) - if (to === 'bytes') - return hexToBytes(hash) as GetSignatureHash8130ReturnType - return hash as GetSignatureHash8130ReturnType + if (to === 'bytes') return hexToBytes(hash) as GetSignatureHashReturnType + return hash as GetSignatureHashReturnType } diff --git a/src/experimental/eip8130/utils/parseTransaction.ts b/src/experimental/eip8130/utils/parseTransaction.ts index 8c8efc760c..73d12ba813 100644 --- a/src/experimental/eip8130/utils/parseTransaction.ts +++ b/src/experimental/eip8130/utils/parseTransaction.ts @@ -2,6 +2,7 @@ import type { Address } from 'abitype' import { BaseError } from '../../../errors/base.js' import type { ErrorType } from '../../../errors/utils.js' import type { Hex } from '../../../types/misc.js' +import { decodeAbiParameters } from '../../../utils/abi/decodeAbiParameters.js' import { type SliceErrorType, sliceHex } from '../../../utils/data/slice.js' import { type HexToBigIntErrorType, @@ -17,18 +18,18 @@ import type { RecursiveArray } from '../../../utils/encoding/toRlp.js' import { aaTransactionType, accountChangeType, - actorChangeType, + changeType, } from '../constants.js' import type { AaAccountChange, AaActor, - AaActorChange, AaCalls, + AaChange, TransactionSerializable8130, } from '../types/transaction.js' -import { decodeAuthorizeActorData } from './actorChangeData.js' +import { decodeAuthorizeActorPayload } from './actorChangeData.js' -export type ParseTransaction8130ErrorType = +export type ParseTransactionErrorType = | SliceErrorType | FromRlpErrorType | HexToBigIntErrorType @@ -64,14 +65,14 @@ function parseActor(value: RlpHex): AaActor { return actor } -function parseActorChange(value: RlpHex): AaActorChange { - const [changeType, actorId, data] = value as [Hex, Hex, Hex] - const type = changeType === '0x' ? 0 : hexToNumber(changeType) - if (type === actorChangeType.authorizeActor) { - const { authenticator, scope, expiry, policyData } = - decodeAuthorizeActorData(data) - const change: AaActorChange = { - changeType: actorChangeType.authorizeActor, +function parseChange(value: RlpHex): AaChange { + const [opByte, payload] = value as [Hex, Hex] + const type = opByte === '0x' ? 0 : hexToNumber(opByte) + if (type === changeType.authorizeActor) { + const { actorId, authenticator, scope, expiry, policyData } = + decodeAuthorizeActorPayload(payload) + const change: AaChange = { + changeType: changeType.authorizeActor, actorId, authenticator, } @@ -80,7 +81,16 @@ function parseActorChange(value: RlpHex): AaActorChange { if (policyData !== '0x') change.policyData = policyData return change } - return { changeType: actorChangeType.revokeActor, actorId } + if (type === changeType.revokeActor) { + const [actorId] = decodeAbiParameters([{ type: 'bytes32' }], payload) + return { changeType: changeType.revokeActor, actorId } + } + if (type === changeType.lock) { + const [unlockDelay] = decodeAbiParameters([{ type: 'uint16' }], payload) + return { changeType: changeType.lock, unlockDelay } + } + if (type === changeType.unlock) return { changeType: changeType.unlock } + return { changeType: changeType.incrementLocalEpoch } } function parseAccountChanges(value: RlpHex): readonly AaAccountChange[] { @@ -103,13 +113,14 @@ function parseAccountChanges(value: RlpHex): readonly AaAccountChange[] { continue } if (type === accountChangeType.config) { - const [chainId, sequence, actorChanges, auth] = body + const [channel, sequence, changes, signature] = body result.push({ type: 'config', - chainId: chainId === '0x' ? 0 : hexToNumber(chainId as Hex), - sequence: sequence === '0x' ? 0 : hexToNumber(sequence as Hex), - actorChanges: (actorChanges as RlpHex[]).map(parseActorChange), - auth: auth as Hex, + channel: (channel as Hex) === '0x01' ? 'multichain' : 'local', + sequence: + (sequence as Hex) === '0x' ? 0n : hexToBigInt(sequence as Hex), + changes: (changes as RlpHex[]).map(parseChange), + signature: signature as Hex, }) continue } @@ -127,9 +138,7 @@ function parseAccountChanges(value: RlpHex): readonly AaAccountChange[] { * Parses a serialized EIP-8130 (`AA_TX_TYPE`) transaction back into a * {@link TransactionSerializable8130}. */ -export function parseTransaction8130( - serialized: Hex, -): TransactionSerializable8130 { +export function parseTransaction(serialized: Hex): TransactionSerializable8130 { const type = sliceHex(serialized, 0, 1) if (type !== aaTransactionType) throw new BaseError( @@ -142,7 +151,8 @@ export function parseTransaction8130( from, nonceKey, nonceSequence, - expiry, + validAfter, + validBefore, maxPriorityFeePerGas, maxFeePerGas, gas, @@ -165,8 +175,10 @@ export function parseTransaction8130( const nonceSequenceValue = toOptionalBigInt(nonceSequence as Hex) if (nonceSequenceValue !== undefined) transaction.nonceSequence = nonceSequenceValue - const expiryValue = toOptionalBigInt(expiry as Hex) - if (expiryValue !== undefined) transaction.expiry = expiryValue + const validAfterValue = toOptionalBigInt(validAfter as Hex) + if (validAfterValue !== undefined) transaction.validAfter = validAfterValue + const validBeforeValue = toOptionalBigInt(validBefore as Hex) + if (validBeforeValue !== undefined) transaction.validBefore = validBeforeValue const maxPriorityFeePerGasValue = toOptionalBigInt( maxPriorityFeePerGas as Hex, ) diff --git a/src/experimental/eip8130/utils/proxy.ts b/src/experimental/eip8130/utils/proxy.ts index 3b3f49a708..828af4c0b2 100644 --- a/src/experimental/eip8130/utils/proxy.ts +++ b/src/experimental/eip8130/utils/proxy.ts @@ -5,8 +5,8 @@ import { concatHex } from '../../../utils/data/concat.js' /** * Builds the 45-byte ERC-1167 minimal proxy runtime bytecode that delegates to * `implementation`. This is the `code` deployed at an **immutable** EIP-8130 - * account address (e.g. `DefaultHighRateAccount`). See {@link computeAddress8130} - * and {@link toFactoryArgs8130}. + * account address (e.g. `DefaultHighRateAccount`). See {@link computeAddress} + * and {@link toFactoryArgs}. */ export function erc1167Bytecode(implementation: Address): Hex { return concatHex([ diff --git a/src/experimental/eip8130/utils/recoverSender.ts b/src/experimental/eip8130/utils/recoverSender.ts index 0cb72d9917..fd54479ac8 100644 --- a/src/experimental/eip8130/utils/recoverSender.ts +++ b/src/experimental/eip8130/utils/recoverSender.ts @@ -2,16 +2,16 @@ import type { Address } from 'abitype' import type { ErrorType } from '../../../errors/utils.js' import { recoverAddress } from '../../../utils/signature/recoverAddress.js' import type { TransactionSerializable8130 } from '../types/transaction.js' -import { getSenderSignatureHash8130 } from './hashTransaction.js' +import { getSenderSignatureHash } from './hashTransaction.js' -export type RecoverSenderAddress8130Parameters = { +export type RecoverSenderAddressParameters = { /** * A parsed / serializable EIP-8130 transaction. Must carry `senderAuth`. */ transaction: TransactionSerializable8130 } -export type RecoverSenderAddress8130ErrorType = ErrorType +export type RecoverSenderAddressErrorType = ErrorType /** * Resolves the sender (`from`) address of an EIP-8130 transaction. @@ -28,10 +28,10 @@ export type RecoverSenderAddress8130ErrorType = ErrorType * the wire format omits `from` in that case. * * @example - * const from = await recoverSenderAddress8130({ transaction: parsed }) + * const from = await recoverSenderAddress({ transaction: parsed }) */ -export async function recoverSenderAddress8130( - parameters: RecoverSenderAddress8130Parameters, +export async function recoverSenderAddress( + parameters: RecoverSenderAddressParameters, ): Promise
{ const { transaction } = parameters if (transaction.from) return transaction.from @@ -40,6 +40,6 @@ export async function recoverSenderAddress8130( 'Cannot recover sender: transaction has neither `from` nor `senderAuth`.', ) // EOA path: sender hash is computed with `from` empty (the wire form). - const hash = getSenderSignatureHash8130({ ...transaction, from: undefined }) + const hash = getSenderSignatureHash({ ...transaction, from: undefined }) return recoverAddress({ hash, signature: transaction.senderAuth }) } diff --git a/src/experimental/eip8130/utils/serializeTransaction.test.ts b/src/experimental/eip8130/utils/serializeTransaction.test.ts index 2c23881755..17cdddf306 100644 --- a/src/experimental/eip8130/utils/serializeTransaction.test.ts +++ b/src/experimental/eip8130/utils/serializeTransaction.test.ts @@ -3,11 +3,11 @@ import { keccak256 } from '../../../utils/hash/keccak256.js' import { aaPayerType, aaTransactionType, nonceKeyMax } from '../constants.js' import type { TransactionSerializable8130 } from '../types/transaction.js' import { - getPayerSignatureHash8130, - getSenderSignatureHash8130, + getPayerSignatureHash, + getSenderSignatureHash, } from './hashTransaction.js' -import { parseTransaction8130 } from './parseTransaction.js' -import { serializeTransaction8130 } from './serializeTransaction.js' +import { parseTransaction } from './parseTransaction.js' +import { serializeTransaction } from './serializeTransaction.js' const alice = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const const bob = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as const @@ -31,13 +31,13 @@ describe('serializeTransaction (EIP-8130)', () => { calls: [[{ to: bob, data: '0xdeadbeef' }]], senderAuth, } - const serialized = serializeTransaction8130(transaction) + const serialized = serializeTransaction(transaction) expect(serialized.startsWith(aaTransactionType)).toBe(true) // canonical codec round-trip (addresses are returned lowercase, matching viem) - expect(serializeTransaction8130(parseTransaction8130(serialized))).toEqual( + expect(serializeTransaction(parseTransaction(serialized))).toEqual( serialized, ) - expect(parseTransaction8130(serialized)).toMatchObject({ + expect(parseTransaction(serialized)).toMatchObject({ chainId: 8453, nonceSequence: 3n, senderAuth, @@ -50,7 +50,8 @@ describe('serializeTransaction (EIP-8130)', () => { from: alice, nonceKey: 7n, nonceSequence: 1n, - expiry: 1_900_000_000n, + validAfter: 1_800_000_000_000n, + validBefore: 1_900_000_000_000n, maxPriorityFeePerGas: 1n, maxFeePerGas: 2n, gas: 50_000n, @@ -59,8 +60,8 @@ describe('serializeTransaction (EIP-8130)', () => { senderAuth, payerAuth, } - const serialized = serializeTransaction8130(transaction) - expect(serializeTransaction8130(parseTransaction8130(serialized))).toEqual( + const serialized = serializeTransaction(transaction) + expect(serializeTransaction(parseTransaction(serialized))).toEqual( serialized, ) }) @@ -72,11 +73,11 @@ describe('serializeTransaction (EIP-8130)', () => { calls: [[{ to: bob, data: '0x' }]], senderAuth, } - const serialized = serializeTransaction8130(transaction) - const parsed = parseTransaction8130(serialized) + const serialized = serializeTransaction(transaction) + const parsed = parseTransaction(serialized) expect(parsed.from).toBeUndefined() // re-serialization is stable - expect(serializeTransaction8130(parsed)).toEqual(serialized) + expect(serializeTransaction(parsed)).toEqual(serialized) }) test('account changes: create + delegation', () => { @@ -103,8 +104,8 @@ describe('serializeTransaction (EIP-8130)', () => { calls: [[{ to: alice, data: '0x' }]], senderAuth, } - const serialized = serializeTransaction8130(transaction) - expect(serializeTransaction8130(parseTransaction8130(serialized))).toEqual( + const serialized = serializeTransaction(transaction) + expect(serializeTransaction(parseTransaction(serialized))).toEqual( serialized, ) }) @@ -117,11 +118,11 @@ describe('serializeTransaction (EIP-8130)', () => { accountChanges: [ { type: 'config', - chainId: 0, - sequence: 5, - actorChanges: [ + channel: 'local', + sequence: 5n, + changes: [ { - changeType: 0x01, + changeType: 0x00, actorId: '0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc', authenticator: '0x0000000000000000000000000000000000000001', @@ -132,24 +133,28 @@ describe('serializeTransaction (EIP-8130)', () => { policyData: '0xc0ffee', }, { - changeType: 0x02, + changeType: 0x01, actorId: '0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266', }, ], - auth: '0xfeed', + signature: '0xfeed', }, ], senderAuth, } - const serialized = serializeTransaction8130(transaction) - expect(serializeTransaction8130(parseTransaction8130(serialized))).toEqual( + const serialized = serializeTransaction(transaction) + expect(serializeTransaction(parseTransaction(serialized))).toEqual( serialized, ) - // structural round-trip on the parsed actor changes - const parsed = parseTransaction8130(serialized) + // structural round-trip on the parsed changes + const parsed = parseTransaction(serialized) const config = parsed.accountChanges?.[0] - expect(config).toMatchObject({ type: 'config', chainId: 0, sequence: 5 }) + expect(config).toMatchObject({ + type: 'config', + channel: 'local', + sequence: 5n, + }) }) test('nonce-free mode (nonceKeyMax)', () => { @@ -157,13 +162,13 @@ describe('serializeTransaction (EIP-8130)', () => { chainId: 8453, from: alice, nonceKey: nonceKeyMax, - expiry: 1_900_000_000n, + validBefore: 1_900_000_000_000n, maxFeePerGas: 2n, calls: [[{ to: bob }]], senderAuth, } - const serialized = serializeTransaction8130(transaction) - expect(serializeTransaction8130(parseTransaction8130(serialized))).toEqual( + const serialized = serializeTransaction(transaction) + expect(serializeTransaction(parseTransaction(serialized))).toEqual( serialized, ) }) @@ -172,13 +177,13 @@ describe('serializeTransaction (EIP-8130)', () => { describe('assertions', () => { test('rejects invalid chainId', () => { expect(() => - serializeTransaction8130({ chainId: 0, senderAuth }), + serializeTransaction({ chainId: 0, senderAuth }), ).toThrowError() }) - test('nonce-free mode requires expiry', () => { + test('nonce-free mode requires validBefore', () => { expect(() => - serializeTransaction8130({ + serializeTransaction({ chainId: 1, nonceKey: nonceKeyMax, senderAuth, @@ -188,11 +193,11 @@ describe('assertions', () => { test('nonce-free mode rejects non-zero sequence', () => { expect(() => - serializeTransaction8130({ + serializeTransaction({ chainId: 1, nonceKey: nonceKeyMax, nonceSequence: 1n, - expiry: 1_900_000_000n, + validBefore: 1_900_000_000_000n, senderAuth, }), ).toThrowError() @@ -200,7 +205,7 @@ describe('assertions', () => { test('self-pay rejects payerAuth', () => { expect(() => - serializeTransaction8130({ chainId: 1, senderAuth, payerAuth }), + serializeTransaction({ chainId: 1, senderAuth, payerAuth }), ).toThrowError() }) }) @@ -217,16 +222,16 @@ describe('signature hashes', () => { } test('sender hash is domain-separated from payer hash', () => { - const senderHash = getSenderSignatureHash8130(transaction) - const payerHash = getPayerSignatureHash8130(transaction) + const senderHash = getSenderSignatureHash(transaction) + const payerHash = getPayerSignatureHash(transaction) expect(senderHash).not.toEqual(payerHash) expect(senderHash).toMatch(/^0x[0-9a-f]{64}$/) expect(payerHash).toMatch(/^0x[0-9a-f]{64}$/) }) test('sender hash binds the payer field', () => { - const withPayer = getSenderSignatureHash8130(transaction) - const withoutPayer = getSenderSignatureHash8130({ + const withPayer = getSenderSignatureHash(transaction) + const withoutPayer = getSenderSignatureHash({ ...transaction, payer: undefined, }) @@ -236,15 +241,15 @@ describe('signature hashes', () => { test('payer hash binds the payer field', () => { // The payer signature commits to the full body INCLUDING the `payer` slot // (matches the Rust node's `payer_signature_hash`). - const a = getPayerSignatureHash8130(transaction) - const b = getPayerSignatureHash8130({ ...transaction, payer: undefined }) + const a = getPayerSignatureHash(transaction) + const b = getPayerSignatureHash({ ...transaction, payer: undefined }) expect(a).not.toEqual(b) }) test('uses the correct domain-separation type bytes', () => { expect(aaTransactionType).not.toEqual(aaPayerType) // bytes output supported - const bytes = getSenderSignatureHash8130({ ...transaction, to: 'bytes' }) + const bytes = getSenderSignatureHash({ ...transaction, to: 'bytes' }) expect(bytes).toBeInstanceOf(Uint8Array) expect(keccak256(bytes)).toMatch(/^0x/) }) diff --git a/src/experimental/eip8130/utils/serializeTransaction.ts b/src/experimental/eip8130/utils/serializeTransaction.ts index bc361ceab3..513f2f317a 100644 --- a/src/experimental/eip8130/utils/serializeTransaction.ts +++ b/src/experimental/eip8130/utils/serializeTransaction.ts @@ -16,15 +16,15 @@ import { import { aaTransactionType, accountChangeType } from '../constants.js' import type { AaAccountChange, - AaActorChange, AaCalls, + AaChange, TransactionSerializable8130, TransactionSerialized8130, } from '../types/transaction.js' -import { encodeActorChangeData } from './actorChangeData.js' +import { encodeChangePayload } from './actorChangeData.js' import { - type AssertTransaction8130ErrorType, - assertTransaction8130, + type AssertTransactionErrorType, + assertTransaction, } from './assertTransaction.js' /** Encodes the `calls` field into a nested RLP-ready array. */ @@ -34,12 +34,15 @@ export function toCallsList(calls: AaCalls | undefined): RecursiveArray[] { ) } -/** Encodes a single `actor_change` operation into a nested RLP-ready array. */ -function toActorChange(change: AaActorChange): RecursiveArray { +/** + * Encodes a single `SignedAccountChanges` op into its `rlp([op_byte, payload])` + * pair. `op_byte` is the `ChangeType` discriminant (`authorizeActor` = `0` → + * RLP `0x80`). + */ +function toChange(change: AaChange): RecursiveArray { return [ - numberToHex(change.changeType), - change.actorId, - encodeActorChangeData(change), + change.changeType ? numberToHex(change.changeType) : '0x', + encodeChangePayload(change), ] } @@ -74,10 +77,11 @@ export function toAccountChangesList( if (entry.type === 'config') return [ accountChangeType.config, - entry.chainId ? numberToHex(entry.chainId) : '0x', + // channel byte: local = 0x00 (RLP '0x'), multichain = 0x01. + entry.channel === 'multichain' ? '0x01' : '0x', entry.sequence ? numberToHex(entry.sequence) : '0x', - entry.actorChanges.map(toActorChange), - entry.auth, + entry.changes.map(toChange), + entry.signature, ] return [accountChangeType.delegation, entry.target] }) @@ -97,7 +101,8 @@ export function toTransactionBody( from, nonceKey, nonceSequence, - expiry, + validAfter, + validBefore, maxPriorityFeePerGas, maxFeePerGas, gas, @@ -110,7 +115,8 @@ export function toTransactionBody( from ?? '0x', nonceKey ? numberToHex(nonceKey) : '0x', nonceSequence ? numberToHex(nonceSequence) : '0x', - expiry ? numberToHex(expiry) : '0x', + validAfter ? numberToHex(validAfter) : '0x', + validBefore ? numberToHex(validBefore) : '0x', maxPriorityFeePerGas ? numberToHex(maxPriorityFeePerGas) : '0x', maxFeePerGas ? numberToHex(maxFeePerGas) : '0x', gas ? numberToHex(gas) : '0x', @@ -120,8 +126,8 @@ export function toTransactionBody( ] } -export type SerializeTransaction8130ErrorType = - | AssertTransaction8130ErrorType +export type SerializeTransactionErrorType = + | AssertTransactionErrorType | ConcatHexErrorType | NumberToHexErrorType | ToRlpErrorType @@ -133,10 +139,10 @@ export type SerializeTransaction8130ErrorType = * Requires `senderAuth`. For sponsored transactions, also provide `payer` and * `payerAuth`; omit both for self-pay. */ -export function serializeTransaction8130( +export function serializeTransaction( transaction: TransactionSerializable8130, ): TransactionSerialized8130 { - assertTransaction8130(transaction) + assertTransaction(transaction) const { payer, senderAuth, payerAuth } = transaction diff --git a/src/experimental/eip8130/utils/signActorChanges.test.ts b/src/experimental/eip8130/utils/signActorChanges.test.ts index 8bfbea42f1..f3b1b8bb8c 100644 --- a/src/experimental/eip8130/utils/signActorChanges.test.ts +++ b/src/experimental/eip8130/utils/signActorChanges.test.ts @@ -4,23 +4,23 @@ import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' import { sliceHex } from '../../../utils/data/slice.js' import { recoverAddress } from '../../../utils/signature/recoverAddress.js' import { ecrecoverAuthenticator } from '../constants.js' -import type { AaActorChange } from '../types/transaction.js' +import type { AaChange } from '../types/transaction.js' import { actorIdFromAddress } from './actorId.js' -import { hashActorChanges8130 } from './hashActorChanges.js' -import { signActorChanges8130 } from './signActorChanges.js' +import { hashAccountChanges } from './hashActorChanges.js' +import { signAccountChanges } from './signActorChanges.js' const signer = privateKeyToAccount(accounts[0].privateKey) const account = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const -const authorize: AaActorChange = { - changeType: 0x01, +const authorize: AaChange = { + changeType: 0x00, actorId: '0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc', authenticator: '0x0000000000000000000000000000000000000001', scope: 0x04, expiry: 1_900_000_000n, } -const revoke: AaActorChange = { - changeType: 0x02, +const revoke: AaChange = { + changeType: 0x01, actorId: '0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266', } @@ -35,85 +35,88 @@ describe('actorIdFromAddress', () => { }) }) -describe('hashActorChanges (EIP-8130)', () => { +describe('hashAccountChanges (EIP-8130)', () => { test('deterministic 32-byte digest', () => { - const digest = hashActorChanges8130({ + const digest = hashAccountChanges({ account, chainId: 0, sequence: 1, - actorChanges: [authorize, revoke], + changes: [authorize, revoke], }) expect(digest).toMatch(/^0x[0-9a-f]{64}$/) expect( - hashActorChanges8130({ + hashAccountChanges({ account, chainId: 0, sequence: 1, - actorChanges: [authorize, revoke], + changes: [authorize, revoke], }), ).toBe(digest) }) test('sequence and account are bound', () => { - const base = { account, chainId: 0, actorChanges: [authorize] } as const - expect(hashActorChanges8130({ ...base, sequence: 1 })).not.toBe( - hashActorChanges8130({ ...base, sequence: 2 }), + const base = { account, chainId: 0, changes: [authorize] } as const + expect(hashAccountChanges({ ...base, sequence: 1 })).not.toBe( + hashAccountChanges({ ...base, sequence: 2 }), ) - expect(hashActorChanges8130({ ...base, sequence: 1 })).not.toBe( - hashActorChanges8130({ + expect(hashAccountChanges({ ...base, sequence: 1 })).not.toBe( + hashAccountChanges({ account: '0x0000000000000000000000000000000000000009', chainId: 0, sequence: 1, - actorChanges: [authorize], + changes: [authorize], }), ) }) }) -describe('signActorChanges (EIP-8130)', () => { - test('returns a config entry whose auth recovers the signer', async () => { - const entry = await signActorChanges8130({ +describe('signAccountChanges (EIP-8130)', () => { + test('returns a config entry whose signature recovers the signer', async () => { + const entry = await signAccountChanges({ signer, account, + channel: 'multichain', chainId: 0, - sequence: 1, - actorChanges: [authorize, revoke], + sequence: 1n, + changes: [authorize, revoke], }) expect(entry.type).toBe('config') - expect(entry.chainId).toBe(0) - expect(entry.sequence).toBe(1) - expect(sliceHex(entry.auth, 0, 20)).toBe(ecrecoverAuthenticator) + expect(entry.channel).toBe('multichain') + expect(entry.sequence).toBe(1n) + expect(sliceHex(entry.signature, 0, 20)).toBe(ecrecoverAuthenticator) - const digest = hashActorChanges8130({ + // 'multichain' binds chainId 0 in the digest. + const digest = hashAccountChanges({ account, chainId: 0, sequence: 1, - actorChanges: [authorize, revoke], + changes: [authorize, revoke], }) const recovered = await recoverAddress({ hash: digest, - signature: sliceHex(entry.auth, 20), + signature: sliceHex(entry.signature, 20), }) expect(recovered.toLowerCase()).toBe(signer.address.toLowerCase()) }) - test('defaults account to signer address', async () => { - const entry = await signActorChanges8130({ + test('defaults account to signer address, local channel binds chainId', async () => { + const entry = await signAccountChanges({ signer, - chainId: 0, - sequence: 3, - actorChanges: [revoke], + channel: 'local', + chainId: 8453, + sequence: 3n, + changes: [revoke], }) - const digest = hashActorChanges8130({ + const digest = hashAccountChanges({ account: signer.address, - chainId: 0, + chainId: 8453, sequence: 3, - actorChanges: [revoke], + changes: [revoke], }) const recovered = await recoverAddress({ hash: digest, - signature: sliceHex(entry.auth, 20), + signature: sliceHex(entry.signature, 20), }) expect(recovered.toLowerCase()).toBe(signer.address.toLowerCase()) }) diff --git a/src/experimental/eip8130/utils/signActorChanges.ts b/src/experimental/eip8130/utils/signActorChanges.ts index 3542e30ff4..2774abc18e 100644 --- a/src/experimental/eip8130/utils/signActorChanges.ts +++ b/src/experimental/eip8130/utils/signActorChanges.ts @@ -8,51 +8,61 @@ import { import { ecrecoverAuthenticator } from '../constants.js' import type { AaAccountChangeConfig, - AaActorChange, + AaChange, + AaChangeChannel, } from '../types/transaction.js' import { - type HashActorChanges8130ErrorType, - hashActorChanges8130, + type HashAccountChangesErrorType, + hashAccountChanges, } from './hashActorChanges.js' import type { Signer } from './signTransaction.js' -export type SignActorChanges8130Parameters = { - /** Signer producing the config-change `auth` (the authorizing actor's key). */ +export type SignAccountChangesParameters = { + /** Signer producing the batch `signature` (the authorizing admin actor's key). */ signer: Signer /** - * The account whose actor configuration is changing. Defaults to the signer's + * The account whose configuration is changing. Defaults to the signer's * address (an account authorizing its own changes). */ account?: Address | undefined - /** Chain ID scope. `0` = valid on any chain (multichain channel). */ + /** + * Replay channel. `'local'` binds `chainId`; `'multichain'` binds chain id + * `0`. @default 'local' + */ + channel?: AaChangeChannel | undefined + /** + * The local chain id, bound into the digest on the `'local'` channel (ignored + * for `'multichain'`, which binds `0`). + */ chainId: number - /** Monotonic ordering sequence within the channel. */ - sequence: number - /** Actor change operations. */ - actorChanges: readonly AaActorChange[] + /** The channel sequence word (`uint64`; source from `getConfigSequence`). */ + sequence: bigint + /** The ordered ops in the batch. */ + changes: readonly AaChange[] /** - * Authenticator address for the `auth` blob. Defaults to + * Authenticator address for the `signature` blob. Defaults to * `signer.authenticator`, then `ECRECOVER_AUTHENTICATOR` (native secp256k1). */ authenticator?: Address | undefined } -export type SignActorChanges8130ErrorType = - | HashActorChanges8130ErrorType +export type SignAccountChangesErrorType = + | HashAccountChangesErrorType | ConcatHexErrorType | BaseError | ErrorType /** - * Signs a set of EIP-8130 actor changes and returns a ready-to-use `config` - * account-change entry (with `auth` in `authenticator || data` form) that can be - * placed in a transaction's `accountChanges` or submitted via - * `applySignedActorChanges`. + * Signs an EIP-8130 `SignedAccountChanges` batch and returns a ready-to-use + * `config` account-change entry (with `signature` in `authenticator || data` + * form) that can be placed in a transaction's `accountChanges` or submitted via + * `applySignedAccountChanges`. */ -export async function signActorChanges8130( - parameters: SignActorChanges8130Parameters, +export async function signAccountChanges( + parameters: SignAccountChangesParameters, ): Promise { - const { signer, chainId, sequence, actorChanges } = parameters + const { signer, chainId, sequence, changes } = parameters + const channel = parameters.channel ?? 'local' const authenticator = parameters.authenticator ?? signer.authenticator ?? ecrecoverAuthenticator const account = parameters.account ?? signer.address @@ -60,14 +70,16 @@ export async function signActorChanges8130( if (!signer.sign) throw new BaseError('`signer` does not support raw signing.') - const digest = hashActorChanges8130({ + const digest = hashAccountChanges({ account, - chainId, + chainId: channel === 'local' ? chainId : 0, sequence, - actorChanges, + changes, }) - const signature = await signer.sign({ hash: digest }) - const auth = concatHex([authenticator, signature]) + const signature = concatHex([ + authenticator, + await signer.sign({ hash: digest }), + ]) - return { type: 'config', chainId, sequence, actorChanges, auth } + return { type: 'config', channel, sequence, changes, signature } } diff --git a/src/experimental/eip8130/utils/signTransaction.test.ts b/src/experimental/eip8130/utils/signTransaction.test.ts index 14b471b71f..10bf87f11d 100644 --- a/src/experimental/eip8130/utils/signTransaction.test.ts +++ b/src/experimental/eip8130/utils/signTransaction.test.ts @@ -4,14 +4,17 @@ import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' import type { Hex } from '../../../types/misc.js' import { sliceHex } from '../../../utils/data/slice.js' import { recoverAddress } from '../../../utils/signature/recoverAddress.js' -import { canonicalAuthenticators, ecrecoverAuthenticator } from '../constants.js' +import { + canonicalAuthenticators, + ecrecoverAuthenticator, +} from '../constants.js' import type { TransactionSerializable8130 } from '../types/transaction.js' import { - getPayerSignatureHash8130, - getSenderSignatureHash8130, + getPayerSignatureHash, + getSenderSignatureHash, } from './hashTransaction.js' -import { parseTransaction8130 } from './parseTransaction.js' -import { type Signer, signTransaction8130 } from './signTransaction.js' +import { parseTransaction } from './parseTransaction.js' +import { type Signer, signTransaction } from './signTransaction.js' const sender = privateKeyToAccount(accounts[0].privateKey) const sponsor = privateKeyToAccount(accounts[1].privateKey) @@ -42,18 +45,18 @@ describe('signTransaction (EIP-8130)', () => { gas: 100_000n, calls: [[{ to: bob, data: '0xdeadbeef' }]], } - const serialized = await signTransaction8130({ + const serialized = await signTransaction({ transaction, account: sender, }) - const parsed = parseTransaction8130(serialized) + const parsed = parseTransaction(serialized) expect(parsed.from).toBeUndefined() expect(parsed.senderAuth).toBeDefined() // raw 65-byte signature expect(sliceHex(parsed.senderAuth!).length).toBe(2 + 65 * 2) - const hash = getSenderSignatureHash8130(parsed) + const hash = getSenderSignatureHash(parsed) const recovered = await recoverAddress({ hash, signature: parsed.senderAuth!, @@ -69,17 +72,17 @@ describe('signTransaction (EIP-8130)', () => { maxFeePerGas: 2n, calls: [[{ to: bob }]], } - const serialized = await signTransaction8130({ + const serialized = await signTransaction({ transaction, account: sender, }) - const parsed = parseTransaction8130(serialized) + const parsed = parseTransaction(serialized) expect(parsed.from?.toLowerCase()).toBe(sender.address.toLowerCase()) // first 20 bytes are the authenticator expect(sliceHex(parsed.senderAuth!, 0, 20)).toBe(ecrecoverAuthenticator) - const hash = getSenderSignatureHash8130(parsed) + const hash = getSenderSignatureHash(parsed) const recovered = await recoverAddress({ hash, signature: sliceHex(parsed.senderAuth!, 20), @@ -95,18 +98,18 @@ describe('signTransaction (EIP-8130)', () => { gas: 50_000n, calls: [[{ to: bob }]], } - const serialized = await signTransaction8130({ + const serialized = await signTransaction({ transaction, account: sender, payer: { account: sponsor }, }) - const parsed = parseTransaction8130(serialized) + const parsed = parseTransaction(serialized) expect(parsed.payer?.toLowerCase()).toBe(sponsor.address.toLowerCase()) expect(sliceHex(parsed.payerAuth!, 0, 20)).toBe(ecrecoverAuthenticator) // payer hash binds the resolved (recovered) sender address - const payerHash = getPayerSignatureHash8130({ + const payerHash = getPayerSignatureHash({ ...parsed, from: sender.address, }) @@ -124,12 +127,12 @@ describe('signTransaction (EIP-8130)', () => { maxFeePerGas: 2n, calls: [[{ to: bob }]], } - const serialized = await signTransaction8130({ + const serialized = await signTransaction({ transaction, account: sender, payer: { account: sponsor, address: sponsor.address }, }) - const parsed = parseTransaction8130(serialized) + const parsed = parseTransaction(serialized) expect(parsed.payer?.toLowerCase()).toBe(sponsor.address.toLowerCase()) }) @@ -141,12 +144,12 @@ describe('signTransaction (EIP-8130)', () => { maxFeePerGas: 2n, calls: [[{ to: bob }]], } - const serialized = await signTransaction8130({ + const serialized = await signTransaction({ transaction, account: toMockP256Signer(), authenticator: canonicalAuthenticators.p256, }) - const parsed = parseTransaction8130(serialized) + const parsed = parseTransaction(serialized) expect(parsed.from?.toLowerCase()).toBe(sender.address.toLowerCase()) // first 20 bytes are the P-256 authenticator, remainder is the raw data blob @@ -163,11 +166,13 @@ describe('signTransaction (EIP-8130)', () => { maxFeePerGas: 2n, calls: [[{ to: bob }]], } - const serialized = await signTransaction8130({ + const serialized = await signTransaction({ transaction, - account: toMockP256Signer({ authenticator: canonicalAuthenticators.p256 }), + account: toMockP256Signer({ + authenticator: canonicalAuthenticators.p256, + }), }) - const parsed = parseTransaction8130(serialized) + const parsed = parseTransaction(serialized) expect(sliceHex(parsed.senderAuth!, 0, 20).toLowerCase()).toBe( canonicalAuthenticators.p256.toLowerCase(), ) @@ -180,14 +185,14 @@ describe('signTransaction (EIP-8130)', () => { maxFeePerGas: 2n, calls: [[{ to: bob }]], } - const serialized = await signTransaction8130({ + const serialized = await signTransaction({ transaction, account: toMockP256Signer({ authenticator: canonicalAuthenticators.passkey, }), authenticator: canonicalAuthenticators.p256, }) - const parsed = parseTransaction8130(serialized) + const parsed = parseTransaction(serialized) expect(sliceHex(parsed.senderAuth!, 0, 20).toLowerCase()).toBe( canonicalAuthenticators.p256.toLowerCase(), ) @@ -201,7 +206,7 @@ describe('signTransaction (EIP-8130)', () => { gas: 50_000n, calls: [[{ to: bob }]], } - const serialized = await signTransaction8130({ + const serialized = await signTransaction({ transaction, account: sender, payer: { @@ -210,7 +215,7 @@ describe('signTransaction (EIP-8130)', () => { authenticator: canonicalAuthenticators.passkey, }, }) - const parsed = parseTransaction8130(serialized) + const parsed = parseTransaction(serialized) expect(parsed.payer?.toLowerCase()).toBe(bob.toLowerCase()) expect(sliceHex(parsed.payerAuth!, 0, 20).toLowerCase()).toBe( canonicalAuthenticators.passkey.toLowerCase(), @@ -220,7 +225,7 @@ describe('signTransaction (EIP-8130)', () => { test('throws without sender account or preset senderAuth', async () => { await expect( - signTransaction8130({ + signTransaction({ transaction: { chainId: 1, maxFeePerGas: 2n }, }), ).rejects.toThrowError() @@ -228,10 +233,10 @@ describe('signTransaction (EIP-8130)', () => { test('preset senderAuth skips signing', async () => { const senderAuth = `0x${'11'.repeat(65)}` as const - const serialized = await signTransaction8130({ + const serialized = await signTransaction({ transaction: { chainId: 1, maxFeePerGas: 2n, senderAuth }, }) - const parsed = parseTransaction8130(serialized) + const parsed = parseTransaction(serialized) expect(parsed.senderAuth).toBe(senderAuth) }) }) diff --git a/src/experimental/eip8130/utils/signTransaction.ts b/src/experimental/eip8130/utils/signTransaction.ts index 1787e06e48..64ee808685 100644 --- a/src/experimental/eip8130/utils/signTransaction.ts +++ b/src/experimental/eip8130/utils/signTransaction.ts @@ -12,14 +12,14 @@ import type { TransactionSerialized8130, } from '../types/transaction.js' import { - type GetPayerSignatureHash8130ErrorType, - type GetSenderSignatureHash8130ErrorType, - getPayerSignatureHash8130, - getSenderSignatureHash8130, + type GetPayerSignatureHashErrorType, + type GetSenderSignatureHashErrorType, + getPayerSignatureHash, + getSenderSignatureHash, } from './hashTransaction.js' import { - type SerializeTransaction8130ErrorType, - serializeTransaction8130, + type SerializeTransactionErrorType, + serializeTransaction, } from './serializeTransaction.js' /** @@ -43,7 +43,7 @@ export type Signer = Pick & { authenticator?: Address | undefined } -export type SignTransaction8130Parameters = { +export type SignTransactionParameters = { transaction: TransactionSerializable8130 /** * Sender signer (secp256k1). Used to produce `sender_auth` over the sender @@ -80,10 +80,10 @@ export type SignTransaction8130Parameters = { | undefined } -export type SignTransaction8130ErrorType = - | GetSenderSignatureHash8130ErrorType - | GetPayerSignatureHash8130ErrorType - | SerializeTransaction8130ErrorType +export type SignTransactionErrorType = + | GetSenderSignatureHashErrorType + | GetPayerSignatureHashErrorType + | SerializeTransactionErrorType | ConcatHexErrorType | ErrorType @@ -98,8 +98,8 @@ export type SignTransaction8130ErrorType = * authenticator-specific `data`. Presetting `transaction.senderAuth` / * `transaction.payerAuth` skips the corresponding signer entirely. */ -export async function signTransaction8130( - parameters: SignTransaction8130Parameters, +export async function signTransaction( + parameters: SignTransactionParameters, ): Promise { const { account, payer } = parameters const transaction: TransactionSerializable8130 = { ...parameters.transaction } @@ -111,8 +111,10 @@ export async function signTransaction8130( 'A sender `account` with raw signing support, or a preset `transaction.senderAuth`, is required.', ) const authenticator = - parameters.authenticator ?? account.authenticator ?? ecrecoverAuthenticator - const senderHash = getSenderSignatureHash8130(transaction) + parameters.authenticator ?? + account.authenticator ?? + ecrecoverAuthenticator + const senderHash = getSenderSignatureHash(transaction) const signature = await account.sign({ hash: senderHash }) transaction.senderAuth = transaction.from ? // Configured actor: AUTHENTICATOR || data @@ -137,10 +139,10 @@ export async function signTransaction8130( ecrecoverAuthenticator // The payer hash MUST bind to the resolved sender address. const from = transaction.from ?? account?.address - const payerHash = getPayerSignatureHash8130({ ...transaction, from }) + const payerHash = getPayerSignatureHash({ ...transaction, from }) const signature = await payer.account.sign({ hash: payerHash }) transaction.payerAuth = concatHex([authenticator, signature]) } - return serializeTransaction8130(transaction) + return serializeTransaction(transaction) } diff --git a/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts b/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts index d6e008abf0..718609f839 100644 --- a/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts +++ b/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts @@ -5,7 +5,7 @@ import { stringToHex } from '../../../utils/encoding/toHex.js' import { keccak256 } from '../../../utils/hash/keccak256.js' import { actorScope, canonicalAuthenticators } from '../constants.js' import { authorizeActor, key, revokeActor } from '../keys.js' -import { encodeActorChangeData } from './actorChangeData.js' +import { encodeChangePayload } from './actorChangeData.js' import { encodeSignedActorChangesSignature, signedActorChangesMagic, @@ -26,8 +26,7 @@ const decodeParameters = [ type: 'tuple[]', components: [ { name: 'changeType', type: 'uint8' }, - { name: 'actorId', type: 'bytes32' }, - { name: 'data', type: 'bytes' }, + { name: 'payload', type: 'bytes' }, ], }, { name: 'auth', type: 'bytes' }, @@ -49,7 +48,7 @@ describe('encodeSignedActorChangesSignature', () => { const signature = encodeSignedActorChangesSignature( [ { - actorChanges: [authorizeActor(key.p256(pubKey))], + changes: [authorizeActor(key.p256(pubKey))], auth: '0xdeadbeef', }, ], @@ -65,7 +64,7 @@ describe('encodeSignedActorChangesSignature', () => { const auth = '0xc0ffee' const opAuth = '0xdeadbeef' const signature = encodeSignedActorChangesSignature( - [{ actorChanges: [change], auth }], + [{ changes: [change], auth }], opAuth, ) @@ -79,19 +78,19 @@ describe('encodeSignedActorChangesSignature', () => { expect(changeSets[0].auth).toBe(auth) expect(changeSets[0].changes).toHaveLength(1) expect(changeSets[0].changes[0].changeType).toBe(change.changeType) - expect(changeSets[0].changes[0].actorId).toBe(change.actorId) - expect(changeSets[0].changes[0].data).toBe(encodeActorChangeData(change)) + expect(changeSets[0].changes[0].payload).toBe(encodeChangePayload(change)) expect(decodedOpAuth).toBe(opAuth) }) test('encodes multiple sets in order (chained rotations)', () => { const setA = { - actorChanges: [authorizeActor(key.p256(pubKey))], + changes: [authorizeActor(key.p256(pubKey))], auth: '0xaaaa', } as const + const revoke = revokeActor(key.p256(pubKey)) const setB = { - actorChanges: [ - revokeActor(key.p256(pubKey)), + changes: [ + revoke, authorizeActor(key.k1('0x0000000000000000000000000000000000000abc')), ], auth: '0xbbbb', @@ -103,18 +102,21 @@ describe('encodeSignedActorChangesSignature', () => { expect(changeSets).toHaveLength(2) expect(changeSets[0].auth).toBe(setA.auth) expect(changeSets[1].auth).toBe(setB.auth) - expect(changeSets[1].changes[0].data).toBe('0x') - expect(changeSets[1].changes[1].changeType).toBe(0x01) + // revokeActor payload is `abi.encode(bytes32 actorId)`. + expect(changeSets[1].changes[0].payload).toBe(encodeChangePayload(revoke)) + expect(changeSets[1].changes[0].changeType).toBe(0x01) + // authorizeActor is ChangeType 0x00. + expect(changeSets[1].changes[1].changeType).toBe(0x00) }) - test('p256 authorize data carries the canonical authenticator', () => { + test('p256 authorize payload carries the canonical authenticator', () => { const change = authorizeActor(key.p256(pubKey)) const signature = encodeSignedActorChangesSignature( - [{ actorChanges: [change], auth: '0x' }], + [{ changes: [change], auth: '0x' }], '0x', ) const [, changeSets] = decodeAbiParameters(decodeParameters, signature) - expect(changeSets[0].changes[0].data.toLowerCase()).toContain( + expect(changeSets[0].changes[0].payload.toLowerCase()).toContain( canonicalAuthenticators.p256.slice(2).toLowerCase(), ) }) diff --git a/src/experimental/eip8130/utils/signedActorChangesSignature.ts b/src/experimental/eip8130/utils/signedActorChangesSignature.ts index 1a2614da68..eab607937d 100644 --- a/src/experimental/eip8130/utils/signedActorChangesSignature.ts +++ b/src/experimental/eip8130/utils/signedActorChangesSignature.ts @@ -12,8 +12,8 @@ import { type Keccak256ErrorType, keccak256, } from '../../../utils/hash/keccak256.js' -import type { AaActorChange } from '../types/transaction.js' -import { encodeActorChangeData } from './actorChangeData.js' +import type { AaChange } from '../types/transaction.js' +import { encodeChangePayload } from './actorChangeData.js' /** * `keccak256("ERC4337Account.signedActorChanges.v1")` — the 32-byte discriminator @@ -26,13 +26,13 @@ export const signedActorChangesMagic = keccak256( export type SignedActorChangeSet = { /** - * Actor change operations applied as one batch (consuming one sequence). Use a - * {@link key} builder + {@link authorizeActor}/{@link revokeActor} to construct. + * Ops applied as one batch (consuming one sequence). Use a {@link key} builder + * + {@link authorizeActor}/{@link revokeActor} to construct. */ - actorChanges: readonly AaActorChange[] + changes: readonly AaChange[] /** * Authorization over the batch digest in `authenticator || data` form, as - * produced by {@link signActorChanges8130} (its `auth` field). + * produced by {@link signAccountChanges} (its `signature` field). */ auth: Hex } @@ -47,8 +47,7 @@ const signatureParameters = [ type: 'tuple[]', components: [ { name: 'changeType', type: 'uint8' }, - { name: 'actorId', type: 'bytes32' }, - { name: 'data', type: 'bytes' }, + { name: 'payload', type: 'bytes' }, ], }, { name: 'auth', type: 'bytes' }, @@ -83,12 +82,12 @@ export type EncodeSignedActorChangesSignatureErrorType = * * @example * ```ts - * const set = await signActorChanges8130({ + * const set = await signAccountChanges({ * signer: owner, * account: smartAccount, * chainId: baseSepolia.id, * sequence, - * actorChanges: [authorizeActor(key.p256({ x, y }), { scope: actorScope.sender })], + * changes: [authorizeActor(key.p256({ x, y }), { scope: actorScope.sender })], * }) * // opAuth: authenticator-prefixed signature over the userOpHash by any authorized actor * const opAuth = concatHex([ecrecoverAuthenticator, await owner.sign({ hash: userOpHash })]) @@ -102,10 +101,9 @@ export function encodeSignedActorChangesSignature( return encodeAbiParameters(signatureParameters, [ signedActorChangesMagic, changeSets.map((set) => ({ - changes: set.actorChanges.map((change) => ({ + changes: set.changes.map((change) => ({ changeType: change.changeType, - actorId: change.actorId, - data: encodeActorChangeData(change), + payload: encodeChangePayload(change), })), auth: set.auth, })), diff --git a/src/experimental/eip8130/utils/signers.test.ts b/src/experimental/eip8130/utils/signers.test.ts index 5993977af6..a8e9b8b0d8 100644 --- a/src/experimental/eip8130/utils/signers.test.ts +++ b/src/experimental/eip8130/utils/signers.test.ts @@ -6,9 +6,9 @@ import { sliceHex } from '../../../utils/data/slice.js' import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import { canonicalAuthenticators } from '../constants.js' import type { TransactionSerializable8130 } from '../types/transaction.js' -import { parseTransaction8130 } from './parseTransaction.js' +import { parseTransaction } from './parseTransaction.js' import { toP256Signer, toWebAuthnSigner } from './signers.js' -import { signTransaction8130 } from './signTransaction.js' +import { signTransaction } from './signTransaction.js' const bob = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const const privateKey = `0x${'a'.repeat(64)}` as const @@ -49,11 +49,11 @@ describe('toP256Signer', () => { maxFeePerGas: 2n, calls: [[{ to: bob }]], } - const serialized = await signTransaction8130({ + const serialized = await signTransaction({ transaction, account: signer, }) - const parsed = parseTransaction8130(serialized) + const parsed = parseTransaction(serialized) expect(sliceHex(parsed.senderAuth!, 0, 20).toLowerCase()).toBe( canonicalAuthenticators.p256.toLowerCase(), diff --git a/src/experimental/eip8130/utils/signers.ts b/src/experimental/eip8130/utils/signers.ts index 177edc734d..452a841ea0 100644 --- a/src/experimental/eip8130/utils/signers.ts +++ b/src/experimental/eip8130/utils/signers.ts @@ -41,14 +41,14 @@ export type ToP256SignerParameters = { * Builds a {@link Signer} for a raw P-256 (secp256r1) configured actor. The * signer's `sign` returns the P-256 authenticator `data` * (`r || s || x || y || preHash`, 129 bytes) and carries the P-256 - * `authenticator`, so it can be passed straight to `to8130Account` / - * `signTransaction8130` to sign as a non-ECDSA actor. + * `authenticator`, so it can be passed straight to `toAccount` / + * `signTransaction` to sign as a non-ECDSA actor. * * @example - * import { key, to8130Account, toP256Signer } from 'viem/experimental' + * import { key, toAccount, toP256Signer } from 'viem/experimental' * * const signer = toP256Signer({ privateKey }) - * const account = to8130Account({ + * const account = toAccount({ * signer, * authenticator: signer.authenticator, * userSalt, @@ -137,11 +137,11 @@ export type ToWebAuthnSignerParameters = { * * @example * import { createWebAuthnCredential, toWebAuthnAccount } from 'viem/account-abstraction' - * import { key, to8130Account, toWebAuthnSigner } from 'viem/experimental' + * import { key, toAccount, toWebAuthnSigner } from 'viem/experimental' * * const credential = await createWebAuthnCredential({ name: 'vibes' }) * const signer = toWebAuthnSigner(toWebAuthnAccount({ credential })) - * const account = to8130Account({ + * const account = toAccount({ * signer, * authenticator: signer.authenticator, * userSalt, diff --git a/src/experimental/eip8168/actions/sendSponsoredCalls.ts b/src/experimental/eip8168/actions/sendSponsoredCalls.ts index 49d8e268e0..e2aae09a3a 100644 --- a/src/experimental/eip8168/actions/sendSponsoredCalls.ts +++ b/src/experimental/eip8168/actions/sendSponsoredCalls.ts @@ -5,8 +5,8 @@ import type { Account } from '../../../types/account.js' import type { Chain } from '../../../types/chain.js' import { hexToBigInt } from '../../../utils/encoding/fromHex.js' import { numberToHex } from '../../../utils/encoding/toHex.js' -import type { To8130AccountReturnType } from '../../eip8130/accounts/to8130Account.js' -import { prepareTransaction8130 } from '../../eip8130/actions/sendCalls.js' +import type { ToAccountReturnType } from '../../eip8130/accounts/toAccount.js' +import { prepareTransaction } from '../../eip8130/actions/sendCalls.js' import { nonceFreeMaxExpiryWindow } from '../../eip8130/constants.js' import { isNoncelessOnly } from '../../eip8130/keys.js' import type { Address } from 'abitype' @@ -59,7 +59,7 @@ export type ResignRequest = { export type SendSponsoredCallsParameters = { /** The sending account (signs `sender_auth`). */ - account: To8130AccountReturnType + account: ToAccountReturnType /** Payer service client (ERC-8168). */ payerClient: PayerClient /** User's intended calls (run in the final phase). */ @@ -86,12 +86,15 @@ export type SendSponsoredCallsParameters = { /** Opaque app context forwarded to `payer_*` calls (e.g. `policyId`). */ context?: Record | undefined /** - * Override transaction expiry as an absolute Unix timestamp (seconds). When - * omitted, the selected offer's `conditions.maxExpiry` (a relative duration) - * is applied: `current_time + maxExpiry`. With no `maxExpiry`, expiry is `0` - * (no protocol-enforced lifetime) unless overridden here. + * Override the transaction's upper validity bound (`validBefore`) as an + * absolute Unix timestamp in **milliseconds**. When omitted, the selected + * offer's `conditions.maxExpiry` (a relative duration in seconds) is applied: + * `now + maxExpiry * 1000`. With no `maxExpiry`, it is `0` (no + * protocol-enforced lifetime) — unless the sending actor is nonce-free-only, + * in which case the mempool admission window is used (a nonce-free tx MUST + * carry a non-zero `validBefore`). */ - expiry?: bigint | undefined + validBefore?: bigint | undefined /** Override gas (defaults to the terms' top-level `gasEstimate.gasLimit`). */ gas?: bigint | undefined maxFeePerGas?: bigint | undefined @@ -119,15 +122,15 @@ export type SendSponsoredCallsParameters = { /** * Invoked with the fully-resolved transaction just before each submit attempt * (before `payer_sendTransaction` / `payer_signTransaction`). Use it to thread - * the resolved `expiry` — which is auto-computed here from the offer's + * the resolved `validBefore` — which is auto-computed here from the offer's * `maxExpiry` (or the nonce-free window) when not overridden — into - * `waitForTransactionReceipt8130` without re-deriving it: + * `waitForTransactionReceipt` without re-deriving it: * * ```ts - * let expiry: bigint | undefined + * let validBefore: bigint | undefined * await sendSponsoredCalls(client, { * ...params, - * onTransaction: (tx) => { expiry = tx.expiry }, + * onTransaction: (tx) => { validBefore = tx.validBefore }, * }) * ``` * @@ -224,39 +227,40 @@ export async function sendSponsoredCalls( : undefined // A nonce-free-only sending actor (admin or no `SCOPE_NONCE`) MUST carry a - // non-zero, in-window expiry — it is the sole replay protection. When the - // payer's offer has no `maxExpiry`, fall back to the mempool admission window - // instead of `0` (which the node would reject as a nonce-free tx). + // non-zero, in-window `validBefore` — it is the sole replay protection. When + // the payer's offer has no `maxExpiry`, fall back to the mempool admission + // window instead of `0` (which the node would reject as a nonce-free tx). const noncelessOnly = account.scope !== undefined && isNoncelessOnly(account.scope) // `conditions.maxExpiry` is a relative duration (seconds from now). The wallet - // sets the onchain expiry to `now + maxExpiry`; the payer keeps `maxExpiry` - // short. Recomputed per attempt so a retry doesn't inherit a near-expiry - // window. A caller-supplied absolute `expiry` is used as-is. - const computeExpiry = (): bigint => { - if (parameters.expiry !== undefined) return parameters.expiry - // Nonce-free-only actor: expiry is the sole replay protection and must fall - // within the protocol's tight replay window, so pin it to the mempool - // admission window regardless of the payer's (possibly larger) `maxExpiry`. - if (noncelessOnly) - return BigInt(Math.floor(Date.now() / 1000)) + nonceFreeMaxExpiryWindow + // sets the onchain `validBefore` (unix ms) to `now + maxExpiry * 1000`; the + // payer keeps `maxExpiry` short. Recomputed per attempt so a retry doesn't + // inherit a near-expiry window. A caller-supplied absolute `validBefore` (unix + // ms) is used as-is. + const computeValidBefore = (): bigint => { + if (parameters.validBefore !== undefined) return parameters.validBefore + // Nonce-free-only actor: `validBefore` is the sole replay protection and + // must fall within the protocol's tight replay window, so pin it to the + // mempool admission window (unix ms) regardless of the payer's (possibly + // larger) `maxExpiry`. + if (noncelessOnly) return BigInt(Date.now()) + nonceFreeMaxExpiryWindow const maxExpiry = option.conditions?.maxExpiry return maxExpiry !== undefined - ? BigInt(Math.floor(Date.now() / 1000)) + BigInt(maxExpiry) + ? BigInt(Date.now()) + BigInt(maxExpiry) * 1000n : 0n } // Prepared once so the nonce stays stable across re-signs; only the phase-0 // transfer (on `requote`) and `gas` (on `minGasLimit`) change between attempts. - const transaction = await prepareTransaction8130(client, { + const transaction = await prepareTransaction(client, { account, calls: built.calls, accountChanges, gas: initialGas, maxFeePerGas, maxPriorityFeePerGas, - expiry: computeExpiry(), + validBefore: computeValidBefore(), nonceKey: parameters.nonceKey, nonceSequence: parameters.nonceSequence, }) @@ -273,7 +277,7 @@ export async function sendSponsoredCalls( for (let attempt = 0; ; attempt++) { transaction.calls = phases transaction.gas = gas - transaction.expiry = computeExpiry() + transaction.validBefore = computeValidBefore() transaction.payerAuth = '0x' onTransaction?.(transaction) diff --git a/src/experimental/eip8168/eip8168.test.ts b/src/experimental/eip8168/eip8168.test.ts index 238052ff2d..eb033f323f 100644 --- a/src/experimental/eip8168/eip8168.test.ts +++ b/src/experimental/eip8168/eip8168.test.ts @@ -7,9 +7,9 @@ import { erc20Abi } from '../../constants/abis.js' import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' import { hexToBigInt } from '../../utils/encoding/fromHex.js' import { keccak256 } from '../../utils/hash/keccak256.js' -import { to8130Account } from '../eip8130/accounts/to8130Account.js' +import { toAccount } from '../eip8130/accounts/toAccount.js' import { key } from '../eip8130/keys.js' -import { parseTransaction8130 } from '../eip8130/utils/parseTransaction.js' +import { parseTransaction } from '../eip8130/utils/parseTransaction.js' import { erc1167Bytecode } from '../eip8130/utils/proxy.js' import { sendSponsoredCalls } from './actions/sendSponsoredCalls.js' import { createPayerClient } from './client.js' @@ -22,7 +22,7 @@ import { const owner = privateKeyToAccount( '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', ) -const account = to8130Account({ +const account = toAccount({ signer: owner, userSalt: `0x${'01'.padStart(64, '0')}`, code: erc1167Bytecode('0x00000000000000000000000000000000000000Ec'), @@ -243,7 +243,7 @@ describe('sendSponsoredCalls (end-to-end)', () => { }) expect(result).toHaveProperty('transactionHash') - const parsed = parseTransaction8130(relayed!) + const parsed = parseTransaction(relayed!) expect(parsed.payer?.toLowerCase()).toBe(PAYER.toLowerCase()) expect(parsed.payerAuth ?? '0x').toBe('0x') // payer fills this in expect(parsed.senderAuth).toBeDefined() @@ -275,7 +275,7 @@ describe('sendSponsoredCalls (end-to-end)', () => { nonceSequence: 0n, }) expect(result).toHaveProperty('signedTransaction') - const parsed = parseTransaction8130( + const parsed = parseTransaction( (result as { signedTransaction: `0x${string}` }).signedTransaction, ) expect(parsed.calls).toHaveLength(2) From 934ab72364c34c3b3706cb30a1468f8a5f34444c Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Mon, 10 Aug 2026 20:25:58 -0400 Subject: [PATCH 59/96] fix(eip8130): align actor IDs + reads with finalized Keystore - actorId: right-align address-derived IDs to bytes32(uint256(uint160)) to match the finalized Keystore (was left-aligned bytes32(bytes20)), which unblocks the EVM applySignedAccountChanges path. - isActor: derive from getActorConfig (authenticator != zero) since the contract no longer exposes isActor. - getPolicy: read via the combined getActor accessor. - abis: drop removed isActor/getPolicy, add getActor. - sendCalls: thread validAfter through prepareTransaction. --- src/experimental/eip8130/abis.ts | 3 +- src/experimental/eip8130/actions/getPolicy.ts | 17 +++++++---- src/experimental/eip8130/actions/isActor.ts | 30 +++++++++---------- src/experimental/eip8130/actions/sendCalls.ts | 3 ++ src/experimental/eip8130/utils/actorId.ts | 10 ++++--- 5 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/experimental/eip8130/abis.ts b/src/experimental/eip8130/abis.ts index 585a484eb5..deae7b0505 100644 --- a/src/experimental/eip8130/abis.ts +++ b/src/experimental/eip8130/abis.ts @@ -30,9 +30,8 @@ export const accountConfigurationAbi = parseAbi([ 'function applySignedAccountChanges(address account, SignedAccountChanges s)', 'function verifySignature(address account, bytes32 hash, bytes signature) view returns (bool verified)', 'function authenticateActor(address account, bytes32 hash, bytes auth) view returns (bytes32 actorId, uint16 scope)', - 'function isActor(address account, bytes32 actorId) view returns (bool)', 'function getActorConfig(address account, bytes32 actorId) view returns (ActorConfig)', - 'function getPolicy(address account, bytes32 actorId) view returns (address target, bytes32 commitment)', + 'function getActor(address account, bytes32 actorId) view returns (ActorConfig config, address policyManager, bytes32 policyCommitment)', 'function getPolicyCommitment(address account, bytes32 actorId) view returns (bytes32)', 'function getPolicyManager(address account, bytes32 actorId) view returns (address)', 'function getChangeSequences(address account) view returns (ChangeSequences)', diff --git a/src/experimental/eip8130/actions/getPolicy.ts b/src/experimental/eip8130/actions/getPolicy.ts index 417c9574bc..850e4623de 100644 --- a/src/experimental/eip8130/actions/getPolicy.ts +++ b/src/experimental/eip8130/actions/getPolicy.ts @@ -29,9 +29,11 @@ export type GetPolicyReturnType = { } /** - * Reads the policy binding for an actor (manager, commitment) from - * the `AccountConfiguration` system contract (`getPolicy`). Use it to resolve a - * session key's policy commitment for {@link getSessionSpend}. + * Reads the policy binding for an actor (manager, commitment) from the finalized + * Keystore system contract via its combined `getActor` read (one call returns + * the actor config plus its policy manager and commitment). Use it to resolve a + * session key's policy commitment for {@link getSessionSpend}. The manager and + * commitment are non-zero only for a live, policy-gated actor. * * @example * ```ts @@ -61,12 +63,15 @@ export async function getPolicy< accountConfiguration = defaultAccountConfigAddress, } = parameters - const [target, commitment] = await readContract(client, { + // The finalized Keystore exposes a single combined read that returns the actor + // config plus its policy manager and commitment; `policyManager` and + // `policyCommitment` are zero for a non-live / ungated actor. + const [, policyManager, policyCommitment] = await readContract(client, { address: accountConfiguration, abi: accountConfigurationAbi, - functionName: 'getPolicy', + functionName: 'getActor', args: [account, actorId], }) - return { target, commitment } + return { target: policyManager, commitment: policyCommitment } } diff --git a/src/experimental/eip8130/actions/isActor.ts b/src/experimental/eip8130/actions/isActor.ts index 7b7040c68e..1bf591a405 100644 --- a/src/experimental/eip8130/actions/isActor.ts +++ b/src/experimental/eip8130/actions/isActor.ts @@ -1,13 +1,12 @@ import type { Address } from 'abitype' -import { readContract } from '../../../actions/public/readContract.js' +import { zeroAddress } from '../../../constants/address.js' import type { Client } from '../../../clients/createClient.js' import type { Transport } from '../../../clients/transports/createTransport.js' import type { Account } from '../../../types/account.js' import type { Chain } from '../../../types/chain.js' import type { Hex } from '../../../types/misc.js' -import { accountConfigurationAbi } from '../abis.js' -import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' +import { getActorConfig } from './getActorConfig.js' export type IsActorParameters = { /** The account to check. */ @@ -24,9 +23,14 @@ export type IsActorParameters = { export type IsActorReturnType = boolean /** - * Reads whether an actor is currently authorized on an EIP-8130 account, from - * the `AccountConfiguration` system contract (`isActor`). For the actor's full - * configuration, use {@link getActorConfig}. + * Reads whether an actor is currently authorized on an EIP-8130 account. + * + * The finalized Keystore system contract has no dedicated `isActor` view; actor + * liveness is derived from {@link getActorConfig}, whose single resolver returns + * the all-zero config (authenticator `0x0`) for any actor that is unknown, + * revoked, disabled, or expired — and never reverts (including for an account + * that has not been created yet). "Bound" therefore means a non-zero + * authenticator. For the actor's full configuration, use {@link getActorConfig}. * * @example * ```ts @@ -49,16 +53,12 @@ export async function isActor< client: Client, parameters: IsActorParameters, ): Promise { - const { + const { account, actorId, accountConfiguration } = parameters + + const { authenticator } = await getActorConfig(client, { account, actorId, - accountConfiguration = defaultAccountConfigAddress, - } = parameters - - return readContract(client, { - address: accountConfiguration, - abi: accountConfigurationAbi, - functionName: 'isActor', - args: [account, actorId], + ...(accountConfiguration ? { accountConfiguration } : {}), }) + return authenticator !== zeroAddress } diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/experimental/eip8130/actions/sendCalls.ts index c7006987de..a0320f06aa 100644 --- a/src/experimental/eip8130/actions/sendCalls.ts +++ b/src/experimental/eip8130/actions/sendCalls.ts @@ -166,6 +166,9 @@ export async function prepareTransaction( maxPriorityFeePerGas, gas, validBefore, + ...(parameters.validAfter !== undefined + ? { validAfter: parameters.validAfter } + : {}), accountChanges, calls, ...(dataSuffix ? { metadata: dataSuffix } : {}), diff --git a/src/experimental/eip8130/utils/actorId.ts b/src/experimental/eip8130/utils/actorId.ts index 55255d7d22..f68f8c1e11 100644 --- a/src/experimental/eip8130/utils/actorId.ts +++ b/src/experimental/eip8130/utils/actorId.ts @@ -10,14 +10,16 @@ import { keccak256 } from '../../../utils/hash/keccak256.js' export type ActorIdFromAddressErrorType = PadErrorType | ErrorType /** - * Derives the `actorId` for an address-based actor: `bytes32(bytes20(address))`. + * Derives the `actorId` for an address-based actor: + * `bytes32(uint256(uint160(address)))`. * * Used for the implicit EOA actor, `k1` (`ECRECOVER_AUTHENTICATOR`), and - * `delegate` actors. `bytesN` widening is left-aligned, so the 20-byte address - * occupies the high-order bytes and the remaining 12 bytes are zero. + * `delegate` actors. The 20-byte address is right-aligned into the low-order + * bytes and the high 12 bytes are zero (matches `Keystore.ActorId.fromAddress` + * and enables the standard `address(uint160(uint256(id)))` round-trip). */ export function actorIdFromAddress(address: Address): Hex { - return pad(address, { dir: 'right', size: 32 }) + return pad(address, { dir: 'left', size: 32 }) } export type ActorIdFromPublicKeyErrorType = BaseError | ErrorType From 6a8f6957c131e4234794983268a3bfb2d76a3a32 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 08:15:53 -0400 Subject: [PATCH 60/96] refactor(eip8130): graduate eip8130 + eip8168 out of experimental Promote EIP-8130 (native account abstraction) and ERC-8168 (payer sponsorship) from `viem/experimental/*` to top-level `viem/eip8130` / `viem/eip8168` for a production-ready, upstream-submittable surface. - move src/experimental/eip813{0,8}* -> src/{eip8130,eip8168} and rewrite every internal import specifier - add ./eip8130 + ./eip8168 export and typesVersions maps (drop the experimental entries); update knip entry globs - repoint scripts, the eip8130 vitest config, and site docs/sidebar to the new paths - refresh unit tests for the finalized Keystore reads (getActorConfig / getActor), right-aligned actorId, and validBefore (unix ms) - biome format + organize-imports across both modules; drop dead noExplicitAny suppressions Validation: full build, publint --strict + attw (viem/eip8130, viem/eip8168 all green), and the eip8130/eip8168 unit suite (123 tests) all pass. --- package.json | 2 +- scripts/eip8130/README.md | 4 +- scripts/eip8130/authorizeSessionKey.test.ts | 16 +++---- scripts/eip8130/baseSepolia4337E2E.test.ts | 16 +++---- scripts/eip8130/buildTransaction.test.ts | 16 +++---- .../eip8130/bundlerCreateAndExecute.test.ts | 6 +-- scripts/eip8130/bundlerProbeDeployed.test.ts | 6 +-- scripts/eip8130/policySmoke.test.ts | 28 +++++------ scripts/eip8130/selfBundleCreate.test.ts | 6 +-- scripts/eip8130/selfBundleRotateP256.test.ts | 16 +++---- scripts/eip8130/setupAccount.test.ts | 10 ++-- scripts/eip8130/vibenet6PartTest.test.ts | 20 ++++---- scripts/smoke-estimate-sender-actor.mjs | 14 +++--- site/pages/{experimental => }/eip8130.mdx | 30 ++++++------ .../eip8130/calls-and-batching.mdx | 14 +++--- .../eip8130/creating-an-account.mdx | 20 ++++---- .../{experimental => }/eip8130/metadata.mdx | 8 ++-- .../eip8130/payer-services.mdx | 18 +++---- .../{experimental => }/eip8130/receipts.mdx | 14 +++--- .../eip8130/rotating-owners.mdx | 14 +++--- .../eip8130/sending-a-transaction.mdx | 20 ++++---- .../eip8130/session-keys.mdx | 8 ++-- .../eip8130/sponsoring-transactions.mdx | 16 +++---- .../eip8130/sub-accounts.mdx | 16 +++---- site/vocs.config.ts | 22 ++++----- src/{experimental => }/eip8130/abis.ts | 0 .../eip8130/accounts/toAccount.ts | 10 ++-- .../eip8130/accounts/toSmartAccount.test.ts | 14 +++--- .../eip8130/accounts/toSmartAccount.ts | 36 +++++++------- .../eip8130/actions/estimateGas.test.ts | 10 ++-- .../eip8130/actions/estimateGas.ts | 20 ++++---- .../eip8130/actions/getActorConfig.ts | 14 +++--- .../eip8130/actions/getConfigSequence.ts | 10 ++-- .../eip8130/actions/getLockStatus.ts | 12 ++--- .../eip8130/actions/getPolicy.ts | 14 +++--- .../eip8130/actions/getSessionSpend.ts | 16 +++---- .../eip8130/actions/getTransaction.ts | 10 ++-- .../eip8130/actions/getTransactionCount.ts | 16 +++---- .../eip8130/actions/getTransactionReceipt.ts | 10 ++-- .../eip8130/actions/isActor.ts | 15 +++--- .../eip8130/actions/isLocked.ts | 12 ++--- .../eip8130/actions/sendCalls.ts | 18 +++---- .../actions/waitForTransactionReceipt.ts | 10 ++-- src/{experimental => }/eip8130/chains.ts | 0 src/{experimental => }/eip8130/constants.ts | 2 +- src/{experimental => }/eip8130/deployments.ts | 2 +- src/{experimental => }/eip8130/devx.test.ts | 20 ++++---- src/{experimental => }/eip8130/errors.ts | 2 +- src/{experimental => }/eip8130/index.ts | 0 src/{experimental => }/eip8130/keys.ts | 10 ++-- src/{experimental => }/eip8130/lock.test.ts | 10 ++-- src/{experimental => }/eip8130/lock.ts | 4 +- src/{experimental => }/eip8130/nonce.test.ts | 17 +++---- src/{experimental => }/eip8130/nonce.ts | 8 ++-- src/eip8130/package.json | 6 +++ .../eip8130/policies.test.ts | 2 +- src/{experimental => }/eip8130/policies.ts | 12 ++--- .../eip8130/queries.test.ts | 47 +++++++++++++++---- .../eip8130/types/transaction.ts | 2 +- .../eip8130/utils/accountConfigCalls.test.ts | 4 +- .../eip8130/utils/accountConfigCalls.ts | 6 +-- .../eip8130/utils/actorChangeData.ts | 8 ++-- .../eip8130/utils/actorId.ts | 14 +++--- .../eip8130/utils/assertTransaction.ts | 6 +-- .../eip8130/utils/computeAddress.test.ts | 10 ++-- .../eip8130/utils/computeAddress.ts | 21 ++++----- .../eip8130/utils/encodeWalletCalls.test.ts | 2 +- .../eip8130/utils/encodeWalletCalls.ts | 4 +- .../eip8130/utils/hashActorChanges.ts | 18 +++---- .../eip8130/utils/hashTransaction.ts | 15 +++--- .../eip8130/utils/parseTransaction.ts | 19 ++++---- src/{experimental => }/eip8130/utils/proxy.ts | 4 +- .../eip8130/utils/recoverSender.ts | 4 +- .../utils/serializeTransaction.test.ts | 2 +- .../eip8130/utils/serializeTransaction.ts | 13 ++--- .../eip8130/utils/signActorChanges.test.ts | 14 +++--- .../eip8130/utils/signActorChanges.ts | 9 ++-- .../eip8130/utils/signTransaction.test.ts | 8 ++-- .../eip8130/utils/signTransaction.ts | 11 ++--- .../utils/signedActorChangesSignature.test.ts | 8 ++-- .../utils/signedActorChangesSignature.ts | 13 ++--- .../eip8130/utils/signers.test.ts | 8 ++-- .../eip8130/utils/signers.ts | 14 +++--- .../eip8168/actions/sendSponsoredCalls.ts | 16 +++---- src/{experimental => }/eip8168/client.ts | 6 +-- src/{experimental => }/eip8168/constants.ts | 1 - .../eip8168/eip8168.test.ts | 34 +++++++++----- src/{experimental => }/eip8168/index.ts | 4 +- src/eip8168/package.json | 6 +++ src/{experimental => }/eip8168/types.ts | 3 +- .../eip8168/utils/buildSponsoredCalls.ts | 16 +++---- .../eip8168/utils/parsePayerError.ts | 4 +- src/experimental/eip8130/package.json | 6 --- src/package.json | 28 +++++------ test/vitest.eip8130.config.ts | 4 +- 95 files changed, 565 insertions(+), 549 deletions(-) rename site/pages/{experimental => }/eip8130.mdx (75%) rename site/pages/{experimental => }/eip8130/calls-and-batching.mdx (82%) rename site/pages/{experimental => }/eip8130/creating-an-account.mdx (85%) rename site/pages/{experimental => }/eip8130/metadata.mdx (80%) rename site/pages/{experimental => }/eip8130/payer-services.mdx (91%) rename site/pages/{experimental => }/eip8130/receipts.mdx (82%) rename site/pages/{experimental => }/eip8130/rotating-owners.mdx (91%) rename site/pages/{experimental => }/eip8130/sending-a-transaction.mdx (82%) rename site/pages/{experimental => }/eip8130/session-keys.mdx (95%) rename site/pages/{experimental => }/eip8130/sponsoring-transactions.mdx (84%) rename site/pages/{experimental => }/eip8130/sub-accounts.mdx (85%) rename src/{experimental => }/eip8130/abis.ts (100%) rename src/{experimental => }/eip8130/accounts/toAccount.ts (98%) rename src/{experimental => }/eip8130/accounts/toSmartAccount.test.ts (90%) rename src/{experimental => }/eip8130/accounts/toSmartAccount.ts (85%) rename src/{experimental => }/eip8130/actions/estimateGas.test.ts (94%) rename src/{experimental => }/eip8130/actions/estimateGas.ts (96%) rename src/{experimental => }/eip8130/actions/getActorConfig.ts (83%) rename src/{experimental => }/eip8130/actions/getConfigSequence.ts (88%) rename src/{experimental => }/eip8130/actions/getLockStatus.ts (83%) rename src/{experimental => }/eip8130/actions/getPolicy.ts (83%) rename src/{experimental => }/eip8130/actions/getSessionSpend.ts (86%) rename src/{experimental => }/eip8130/actions/getTransaction.ts (94%) rename src/{experimental => }/eip8130/actions/getTransactionCount.ts (85%) rename src/{experimental => }/eip8130/actions/getTransactionReceipt.ts (89%) rename src/{experimental => }/eip8130/actions/isActor.ts (80%) rename src/{experimental => }/eip8130/actions/isLocked.ts (78%) rename src/{experimental => }/eip8130/actions/sendCalls.ts (94%) rename src/{experimental => }/eip8130/actions/waitForTransactionReceipt.ts (94%) rename src/{experimental => }/eip8130/chains.ts (100%) rename src/{experimental => }/eip8130/constants.ts (99%) rename src/{experimental => }/eip8130/deployments.ts (98%) rename src/{experimental => }/eip8130/devx.test.ts (93%) rename src/{experimental => }/eip8130/errors.ts (98%) rename src/{experimental => }/eip8130/index.ts (100%) rename src/{experimental => }/eip8130/keys.ts (96%) rename src/{experimental => }/eip8130/lock.test.ts (89%) rename src/{experimental => }/eip8130/lock.ts (97%) rename src/{experimental => }/eip8130/nonce.test.ts (89%) rename src/{experimental => }/eip8130/nonce.ts (96%) create mode 100644 src/eip8130/package.json rename src/{experimental => }/eip8130/policies.test.ts (98%) rename src/{experimental => }/eip8130/policies.ts (98%) rename src/{experimental => }/eip8130/queries.test.ts (70%) rename src/{experimental => }/eip8130/types/transaction.ts (99%) rename src/{experimental => }/eip8130/utils/accountConfigCalls.test.ts (96%) rename src/{experimental => }/eip8130/utils/accountConfigCalls.ts (96%) rename src/{experimental => }/eip8130/utils/actorChangeData.ts (94%) rename src/{experimental => }/eip8130/utils/actorId.ts (78%) rename src/{experimental => }/eip8130/utils/assertTransaction.ts (91%) rename src/{experimental => }/eip8130/utils/computeAddress.test.ts (92%) rename src/{experimental => }/eip8130/utils/computeAddress.ts (87%) rename src/{experimental => }/eip8130/utils/encodeWalletCalls.test.ts (97%) rename src/{experimental => }/eip8130/utils/encodeWalletCalls.ts (95%) rename src/{experimental => }/eip8130/utils/hashActorChanges.ts (88%) rename src/{experimental => }/eip8130/utils/hashTransaction.ts (89%) rename src/{experimental => }/eip8130/utils/parseTransaction.ts (92%) rename src/{experimental => }/eip8130/utils/proxy.ts (94%) rename src/{experimental => }/eip8130/utils/recoverSender.ts (93%) rename src/{experimental => }/eip8130/utils/serializeTransaction.test.ts (99%) rename src/{experimental => }/eip8130/utils/serializeTransaction.ts (94%) rename src/{experimental => }/eip8130/utils/signActorChanges.test.ts (86%) rename src/{experimental => }/eip8130/utils/signActorChanges.ts (92%) rename src/{experimental => }/eip8130/utils/signTransaction.test.ts (96%) rename src/{experimental => }/eip8130/utils/signTransaction.ts (95%) rename src/{experimental => }/eip8130/utils/signedActorChangesSignature.test.ts (93%) rename src/{experimental => }/eip8130/utils/signedActorChangesSignature.ts (91%) rename src/{experimental => }/eip8130/utils/signers.test.ts (94%) rename src/{experimental => }/eip8130/utils/signers.ts (93%) rename src/{experimental => }/eip8168/actions/sendSponsoredCalls.ts (96%) rename src/{experimental => }/eip8168/client.ts (95%) rename src/{experimental => }/eip8168/constants.ts (96%) rename src/{experimental => }/eip8168/eip8168.test.ts (88%) rename src/{experimental => }/eip8168/index.ts (100%) create mode 100644 src/eip8168/package.json rename src/{experimental => }/eip8168/types.ts (98%) rename src/{experimental => }/eip8168/utils/buildSponsoredCalls.ts (93%) rename src/{experimental => }/eip8168/utils/parsePayerError.ts (96%) delete mode 100644 src/experimental/eip8130/package.json diff --git a/package.json b/package.json index 426a9bfcac..74cdd88d97 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "src": { "entry": [ "index.ts!", - "{account-abstraction,accounts,actions,celo,chains,ens,experimental,experimental/eip8130,experimental/eip8168,experimental/erc7739,experimental/erc7821,experimental/erc7811,experimental/erc7846,experimental/erc7895,linea,node,nonce,op-stack,siwe,tempo,tempo/actions,tempo/chains,tempo/zones,utils,window,zksync}/index.ts!", + "{account-abstraction,accounts,actions,celo,chains,ens,eip8130,eip8168,experimental,experimental/erc7739,experimental/erc7821,experimental/erc7811,experimental/erc7846,experimental/erc7895,linea,node,nonce,op-stack,siwe,tempo,tempo/actions,tempo/chains,tempo/zones,utils,window,zksync}/index.ts!", "chains/utils.ts!" ], "ignore": [ diff --git a/scripts/eip8130/README.md b/scripts/eip8130/README.md index 8eaadc504a..553b621d20 100644 --- a/scripts/eip8130/README.md +++ b/scripts/eip8130/README.md @@ -1,7 +1,7 @@ # EIP-8130 manual scripts Temporary dev/integration scripts for the experimental EIP-8130 work in -`src/experimental/eip8130`. These are **not** public examples (see top-level +`src/eip8130`. These are **not** public examples (see top-level `examples/` for those) — they import local source (`../../src`) and most hit a live testnet. Keep them here until EIP-8130 graduates from experimental. @@ -48,4 +48,4 @@ scripts. op over `userOpHash`. See `encodeSignedActorChangesSignature`. - `createAccount` seeds `localSequence = 1`, so the first `applySignedActorChanges` on a fresh account signs over sequence `1`. -- Deployment addresses live in `src/experimental/eip8130/deployments.ts`. +- Deployment addresses live in `src/eip8130/deployments.ts`. diff --git a/scripts/eip8130/authorizeSessionKey.test.ts b/scripts/eip8130/authorizeSessionKey.test.ts index a99582cbf7..6019676964 100644 --- a/scripts/eip8130/authorizeSessionKey.test.ts +++ b/scripts/eip8130/authorizeSessionKey.test.ts @@ -7,14 +7,14 @@ import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' import { baseSepolia } from '../../src/chains/index.js' import { createClient } from '../../src/clients/createClient.js' import { http } from '../../src/clients/transports/http.js' -import { accountConfigurationAbi } from '../../src/experimental/eip8130/abis.js' -import { actorScope } from '../../src/experimental/eip8130/constants.js' -import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' -import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' -import { encodeApplySignedActorChangesData } from '../../src/experimental/eip8130/utils/accountConfigCalls.js' -import { computeAddress } from '../../src/experimental/eip8130/utils/computeAddress.js' -import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' -import { signActorChanges } from '../../src/experimental/eip8130/utils/signActorChanges.js' +import { accountConfigurationAbi } from '../../src/eip8130/abis.js' +import { actorScope } from '../../src/eip8130/constants.js' +import { getEip8130Deployment } from '../../src/eip8130/deployments.js' +import { authorizeActor, key } from '../../src/eip8130/keys.js' +import { encodeApplySignedActorChangesData } from '../../src/eip8130/utils/accountConfigCalls.js' +import { computeAddress } from '../../src/eip8130/utils/computeAddress.js' +import { erc1167Bytecode } from '../../src/eip8130/utils/proxy.js' +import { signActorChanges } from '../../src/eip8130/utils/signActorChanges.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' diff --git a/scripts/eip8130/baseSepolia4337E2E.test.ts b/scripts/eip8130/baseSepolia4337E2E.test.ts index 355b7aa2c1..05a9a94bda 100644 --- a/scripts/eip8130/baseSepolia4337E2E.test.ts +++ b/scripts/eip8130/baseSepolia4337E2E.test.ts @@ -36,14 +36,14 @@ import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' import { baseSepolia } from '../../src/chains/index.js' import { createClient } from '../../src/clients/createClient.js' import { http } from '../../src/clients/transports/http.js' -import { toSmartAccount } from '../../src/experimental/eip8130/accounts/toSmartAccount.js' -import { getConfigSequence } from '../../src/experimental/eip8130/actions/getConfigSequence.js' -import { isActor } from '../../src/experimental/eip8130/actions/isActor.js' -import { actorScope } from '../../src/experimental/eip8130/constants.js' -import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' -import { authorizeActor, key, revokeActor } from '../../src/experimental/eip8130/keys.js' -import { encodeApplySignedActorChangesData } from '../../src/experimental/eip8130/utils/accountConfigCalls.js' -import { signActorChanges } from '../../src/experimental/eip8130/utils/signActorChanges.js' +import { toSmartAccount } from '../../src/eip8130/accounts/toSmartAccount.js' +import { getConfigSequence } from '../../src/eip8130/actions/getConfigSequence.js' +import { isActor } from '../../src/eip8130/actions/isActor.js' +import { actorScope } from '../../src/eip8130/constants.js' +import { getEip8130Deployment } from '../../src/eip8130/deployments.js' +import { authorizeActor, key, revokeActor } from '../../src/eip8130/keys.js' +import { encodeApplySignedActorChangesData } from '../../src/eip8130/utils/accountConfigCalls.js' +import { signActorChanges } from '../../src/eip8130/utils/signActorChanges.js' import { parseEther } from '../../src/utils/unit/parseEther.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' diff --git a/scripts/eip8130/buildTransaction.test.ts b/scripts/eip8130/buildTransaction.test.ts index abe177e4ab..2db00d95d6 100644 --- a/scripts/eip8130/buildTransaction.test.ts +++ b/scripts/eip8130/buildTransaction.test.ts @@ -4,17 +4,17 @@ import { baseSepolia } from '../../src/chains/index.js' import { actorScope, canonicalAuthenticators, -} from '../../src/experimental/eip8130/constants.js' -import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' -import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' +} from '../../src/eip8130/constants.js' +import { getEip8130Deployment } from '../../src/eip8130/deployments.js' +import { authorizeActor, key } from '../../src/eip8130/keys.js' import type { AaCalls, TransactionSerializable8130, -} from '../../src/experimental/eip8130/types/transaction.js' -import { parseTransaction } from '../../src/experimental/eip8130/utils/parseTransaction.js' -import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' -import { serializeTransaction } from '../../src/experimental/eip8130/utils/serializeTransaction.js' -import { signTransaction } from '../../src/experimental/eip8130/utils/signTransaction.js' +} from '../../src/eip8130/types/transaction.js' +import { parseTransaction } from '../../src/eip8130/utils/parseTransaction.js' +import { erc1167Bytecode } from '../../src/eip8130/utils/proxy.js' +import { serializeTransaction } from '../../src/eip8130/utils/serializeTransaction.js' +import { signTransaction } from '../../src/eip8130/utils/signTransaction.js' import { sliceHex } from '../../src/utils/data/slice.js' import { fromRlp } from '../../src/utils/encoding/fromRlp.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' diff --git a/scripts/eip8130/bundlerCreateAndExecute.test.ts b/scripts/eip8130/bundlerCreateAndExecute.test.ts index ddab338df1..e57185eb5a 100644 --- a/scripts/eip8130/bundlerCreateAndExecute.test.ts +++ b/scripts/eip8130/bundlerCreateAndExecute.test.ts @@ -12,9 +12,9 @@ import { http } from '../../src/clients/transports/http.js' import { parseEther } from '../../src/utils/unit/parseEther.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' -import { toSmartAccount } from '../../src/experimental/eip8130/accounts/toSmartAccount.js' -import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' -import { key } from '../../src/experimental/eip8130/keys.js' +import { toSmartAccount } from '../../src/eip8130/accounts/toSmartAccount.js' +import { getEip8130Deployment } from '../../src/eip8130/deployments.js' +import { key } from '../../src/eip8130/keys.js' const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' diff --git a/scripts/eip8130/bundlerProbeDeployed.test.ts b/scripts/eip8130/bundlerProbeDeployed.test.ts index 6055957906..0d77cfef32 100644 --- a/scripts/eip8130/bundlerProbeDeployed.test.ts +++ b/scripts/eip8130/bundlerProbeDeployed.test.ts @@ -11,9 +11,9 @@ import { http } from '../../src/clients/transports/http.js' import { parseEther } from '../../src/utils/unit/parseEther.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' -import { toSmartAccount } from '../../src/experimental/eip8130/accounts/toSmartAccount.js' -import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' -import { key } from '../../src/experimental/eip8130/keys.js' +import { toSmartAccount } from '../../src/eip8130/accounts/toSmartAccount.js' +import { getEip8130Deployment } from '../../src/eip8130/deployments.js' +import { key } from '../../src/eip8130/keys.js' const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' diff --git a/scripts/eip8130/policySmoke.test.ts b/scripts/eip8130/policySmoke.test.ts index 3c60075d0b..fb55e9ea0c 100644 --- a/scripts/eip8130/policySmoke.test.ts +++ b/scripts/eip8130/policySmoke.test.ts @@ -47,27 +47,27 @@ import { describe, expect, test } from 'vitest' import { generatePrivateKey, privateKeyToAccount } from '../../src/accounts/index.js' import { createPublicClient } from '../../src/clients/createPublicClient.js' import { http } from '../../src/clients/transports/http.js' -import { createPayerClient } from '../../src/experimental/eip8168/client.js' -import { sendSponsoredCalls } from '../../src/experimental/eip8168/actions/sendSponsoredCalls.js' -import { toAccount } from '../../src/experimental/eip8130/accounts/toAccount.js' -import { getActorConfig } from '../../src/experimental/eip8130/actions/getActorConfig.js' -import { getConfigSequence } from '../../src/experimental/eip8130/actions/getConfigSequence.js' -import { isActor } from '../../src/experimental/eip8130/actions/isActor.js' -import { allPhasesSucceeded } from '../../src/experimental/eip8130/actions/getTransactionReceipt.js' -import { waitForTransactionReceipt } from '../../src/experimental/eip8130/actions/waitForTransactionReceipt.js' +import { createPayerClient } from '../../src/eip8168/client.js' +import { sendSponsoredCalls } from '../../src/eip8168/actions/sendSponsoredCalls.js' +import { toAccount } from '../../src/eip8130/accounts/toAccount.js' +import { getActorConfig } from '../../src/eip8130/actions/getActorConfig.js' +import { getConfigSequence } from '../../src/eip8130/actions/getConfigSequence.js' +import { isActor } from '../../src/eip8130/actions/isActor.js' +import { allPhasesSucceeded } from '../../src/eip8130/actions/getTransactionReceipt.js' +import { waitForTransactionReceipt } from '../../src/eip8130/actions/waitForTransactionReceipt.js' import { actorScope, canonicalAuthenticators, scopeUnrestricted, -} from '../../src/experimental/eip8130/constants.js' -import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' -import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' +} from '../../src/eip8130/constants.js' +import { getEip8130Deployment } from '../../src/eip8130/deployments.js' +import { authorizeActor, key } from '../../src/eip8130/keys.js' import { defineSessionPolicy, encodeSessionPolicyConfig, -} from '../../src/experimental/eip8130/policies.js' -import type { AaAccountChange, AaCall } from '../../src/experimental/eip8130/types/transaction.js' -import { upgradeableProxyBytecode } from '../../src/experimental/eip8130/utils/proxy.js' +} from '../../src/eip8130/policies.js' +import type { AaAccountChange, AaCall } from '../../src/eip8130/types/transaction.js' +import { upgradeableProxyBytecode } from '../../src/eip8130/utils/proxy.js' import type { Hex } from '../../src/types/misc.js' import { hexToBigInt } from '../../src/utils/encoding/fromHex.js' diff --git a/scripts/eip8130/selfBundleCreate.test.ts b/scripts/eip8130/selfBundleCreate.test.ts index 49aa78c42b..ac88c1c18a 100644 --- a/scripts/eip8130/selfBundleCreate.test.ts +++ b/scripts/eip8130/selfBundleCreate.test.ts @@ -14,9 +14,9 @@ import { http } from '../../src/clients/transports/http.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' import { parseEther } from '../../src/utils/unit/parseEther.js' -import { toSmartAccount } from '../../src/experimental/eip8130/accounts/toSmartAccount.js' -import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' -import { key } from '../../src/experimental/eip8130/keys.js' +import { toSmartAccount } from '../../src/eip8130/accounts/toSmartAccount.js' +import { getEip8130Deployment } from '../../src/eip8130/deployments.js' +import { key } from '../../src/eip8130/keys.js' const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' diff --git a/scripts/eip8130/selfBundleRotateP256.test.ts b/scripts/eip8130/selfBundleRotateP256.test.ts index 01f0dbcabe..62310f068b 100644 --- a/scripts/eip8130/selfBundleRotateP256.test.ts +++ b/scripts/eip8130/selfBundleRotateP256.test.ts @@ -12,17 +12,17 @@ import { writeContract } from '../../src/actions/wallet/writeContract.js' import { baseSepolia } from '../../src/chains/index.js' import { createClient } from '../../src/clients/createClient.js' import { http } from '../../src/clients/transports/http.js' -import { accountConfigurationAbi } from '../../src/experimental/eip8130/abis.js' -import { toSmartAccount } from '../../src/experimental/eip8130/accounts/toSmartAccount.js' +import { accountConfigurationAbi } from '../../src/eip8130/abis.js' +import { toSmartAccount } from '../../src/eip8130/accounts/toSmartAccount.js' import { actorScope, ecrecoverAuthenticator, -} from '../../src/experimental/eip8130/constants.js' -import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' -import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' -import { actorIdFromPublicKey } from '../../src/experimental/eip8130/utils/actorId.js' -import { signActorChanges } from '../../src/experimental/eip8130/utils/signActorChanges.js' -import { encodeSignedActorChangesSignature } from '../../src/experimental/eip8130/utils/signedActorChangesSignature.js' +} from '../../src/eip8130/constants.js' +import { getEip8130Deployment } from '../../src/eip8130/deployments.js' +import { authorizeActor, key } from '../../src/eip8130/keys.js' +import { actorIdFromPublicKey } from '../../src/eip8130/utils/actorId.js' +import { signActorChanges } from '../../src/eip8130/utils/signActorChanges.js' +import { encodeSignedActorChangesSignature } from '../../src/eip8130/utils/signedActorChangesSignature.js' import { concatHex } from '../../src/utils/data/concat.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' diff --git a/scripts/eip8130/setupAccount.test.ts b/scripts/eip8130/setupAccount.test.ts index 2ff1767fa2..58c7ef8ebb 100644 --- a/scripts/eip8130/setupAccount.test.ts +++ b/scripts/eip8130/setupAccount.test.ts @@ -7,11 +7,11 @@ import { writeContract } from '../../src/actions/wallet/writeContract.js' import { baseSepolia } from '../../src/chains/index.js' import { createClient } from '../../src/clients/createClient.js' import { http } from '../../src/clients/transports/http.js' -import { accountConfigurationAbi } from '../../src/experimental/eip8130/abis.js' -import { getEip8130Deployment } from '../../src/experimental/eip8130/deployments.js' -import { key } from '../../src/experimental/eip8130/keys.js' -import { computeAddress } from '../../src/experimental/eip8130/utils/computeAddress.js' -import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' +import { accountConfigurationAbi } from '../../src/eip8130/abis.js' +import { getEip8130Deployment } from '../../src/eip8130/deployments.js' +import { key } from '../../src/eip8130/keys.js' +import { computeAddress } from '../../src/eip8130/utils/computeAddress.js' +import { erc1167Bytecode } from '../../src/eip8130/utils/proxy.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' diff --git a/scripts/eip8130/vibenet6PartTest.test.ts b/scripts/eip8130/vibenet6PartTest.test.ts index 69e6ddadf2..45e675c3a7 100644 --- a/scripts/eip8130/vibenet6PartTest.test.ts +++ b/scripts/eip8130/vibenet6PartTest.test.ts @@ -21,19 +21,19 @@ import { generatePrivateKey, privateKeyToAccount } from '../../src/accounts/inde import { getBalance } from '../../src/actions/public/getBalance.js' import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' -import { waitForTransactionReceipt as waitForReceipt8130 } from '../../src/experimental/eip8130/actions/waitForTransactionReceipt.js' +import { waitForTransactionReceipt as waitForReceipt8130 } from '../../src/eip8130/actions/waitForTransactionReceipt.js' import { createClient } from '../../src/clients/createClient.js' import { http } from '../../src/clients/transports/http.js' -import { toAccount } from '../../src/experimental/eip8130/accounts/toAccount.js' -import { getConfigSequence } from '../../src/experimental/eip8130/actions/getConfigSequence.js' -import { getTransactionCount } from '../../src/experimental/eip8130/actions/getTransactionCount.js' -import { sendCalls } from '../../src/experimental/eip8130/actions/sendCalls.js' -import { vibenetDevnetDeployment } from '../../src/experimental/eip8130/deployments.js' -import { authorizeActor, key } from '../../src/experimental/eip8130/keys.js' -import { toP256Signer } from '../../src/experimental/eip8130/utils/signers.js' +import { toAccount } from '../../src/eip8130/accounts/toAccount.js' +import { getConfigSequence } from '../../src/eip8130/actions/getConfigSequence.js' +import { getTransactionCount } from '../../src/eip8130/actions/getTransactionCount.js' +import { sendCalls } from '../../src/eip8130/actions/sendCalls.js' +import { vibenetDevnetDeployment } from '../../src/eip8130/deployments.js' +import { authorizeActor, key } from '../../src/eip8130/keys.js' +import { toP256Signer } from '../../src/eip8130/utils/signers.js' import * as P256 from 'ox/P256' -import type { AaCalls } from '../../src/experimental/eip8130/types/transaction.js' -import { erc1167Bytecode } from '../../src/experimental/eip8130/utils/proxy.js' +import type { AaCalls } from '../../src/eip8130/types/transaction.js' +import { erc1167Bytecode } from '../../src/eip8130/utils/proxy.js' import { keccak256 } from '../../src/utils/hash/keccak256.js' import { stringToHex } from '../../src/utils/encoding/toHex.js' import { parseEther } from '../../src/utils/unit/parseEther.js' diff --git a/scripts/smoke-estimate-sender-actor.mjs b/scripts/smoke-estimate-sender-actor.mjs index b01a7a7d78..30d0683aff 100644 --- a/scripts/smoke-estimate-sender-actor.mjs +++ b/scripts/smoke-estimate-sender-actor.mjs @@ -8,20 +8,20 @@ */ import { createPublicClient, http, parseEther, zeroAddress } from '../src/index.ts' import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.ts' -import { toP256Signer } from '../src/experimental/eip8130/utils/signers.ts' -import { toAccount } from '../src/experimental/eip8130/accounts/toAccount.ts' -import { estimateGas } from '../src/experimental/eip8130/actions/estimateGas.ts' +import { toP256Signer } from '../src/eip8130/utils/signers.ts' +import { toAccount } from '../src/eip8130/accounts/toAccount.ts' +import { estimateGas } from '../src/eip8130/actions/estimateGas.ts' import { authorizeActor, key, -} from '../src/experimental/eip8130/keys.ts' -import { actorScope, canonicalAuthenticators } from '../src/experimental/eip8130/constants.ts' +} from '../src/eip8130/keys.ts' +import { actorScope, canonicalAuthenticators } from '../src/eip8130/constants.ts' import { defineSessionPolicy, encodeSessionPolicyAction, encodeSessionPolicyConfig, -} from '../src/experimental/eip8130/policies.ts' -import { erc1167Bytecode } from '../src/experimental/eip8130/utils/proxy.ts' +} from '../src/eip8130/policies.ts' +import { erc1167Bytecode } from '../src/eip8130/utils/proxy.ts' import * as P256 from 'ox/P256' const RPC = process.env.VIBENET_RPC ?? 'https://rpc.vibes.base.org' diff --git a/site/pages/experimental/eip8130.mdx b/site/pages/eip8130.mdx similarity index 75% rename from site/pages/experimental/eip8130.mdx rename to site/pages/eip8130.mdx index 8fe7c7d7f8..b2a6c11f68 100644 --- a/site/pages/experimental/eip8130.mdx +++ b/site/pages/eip8130.mdx @@ -4,7 +4,7 @@ description: Getting started with EIP-8130 native account abstraction in Viem # EIP-8130 (Native Account Abstraction) [Overview] -[EIP-8130](https://github.com/base/eip-8130) is a native account-abstraction transaction type (`AA_TX_TYPE`, `0x79`). Every account is a smart account governed by an onchain **Account Configuration** contract, and transactions are sent directly to the chain — no bundler, no EntryPoint. Viem exposes the full flow through the `viem/experimental/eip8130` entrypoint. +[EIP-8130](https://github.com/base/eip-8130) is a native account-abstraction transaction type (`AA_TX_TYPE`, `0x79`). Every account is a smart account governed by an onchain **Account Configuration** contract, and transactions are sent directly to the chain — no bundler, no EntryPoint. Viem exposes the full flow through the `viem/eip8130` entrypoint. :::warning[Warning] EIP-8130 is experimental and enabled per-chain. It is not yet live on mainnet. Do not solely rely on experimental features in production. @@ -23,7 +23,7 @@ EIP-8130 is experimental and enabled per-chain. It is not yet live on mainnet. D | Concept | Meaning | | --- | --- | | **Account** | Smart account at a CREATE2 address derived from `userSalt`, wallet `code`, and `initialActors`. | -| **Actor** | A key authorized on the account (`{ actorId, authenticator }`), built via the [`key`](/experimental/eip8130/rotating-owners#actors-and-keys) helpers. | +| **Actor** | A key authorized on the account (`{ actorId, authenticator }`), built via the [`key`](/eip8130/rotating-owners#actors-and-keys) helpers. | | **Account change** | A `create`, `config` (actor changes), or `delegation` operation applied atomically within a transaction. | | **Auth blob** | `sender_auth` / `payer_auth` bytes: a bare 65-byte signature for the implicit EOA path, or `authenticator(20) || data` for a configured actor. | @@ -36,7 +36,7 @@ import { newSmartAccount, sendCalls, estimateGas, -} from 'viem/experimental/eip8130' +} from 'viem/eip8130' ``` ## Setup @@ -45,7 +45,7 @@ EIP-8130 actions are standalone — they take a Viem `Client` as their first arg ```ts import { createClient, http, defineChain } from 'viem' -import { register8130Chains } from 'viem/experimental/eip8130' +import { register8130Chains } from 'viem/eip8130' export const vibenet = defineChain({ id: 84_538_453, @@ -65,7 +65,7 @@ register8130Chains(vibenet.id) The protocol contracts (Account Configuration, wallet implementations, authenticators, example policies) are deployed deterministically and are identical on every chain running the same bytecode. Resolve them per chain: ```ts -import { getEip8130Deployment, canonicalEip8130Deployment } from 'viem/experimental/eip8130' +import { getEip8130Deployment, canonicalEip8130Deployment } from 'viem/eip8130' const deployment = getEip8130Deployment(84_538_453) ?? canonicalEip8130Deployment deployment.accountConfiguration // factory + actor-config registry @@ -81,13 +81,13 @@ Account implementations: **`UpgradeableAccount`** (the default for smart account ## Guides -- [Creating an Account](/experimental/eip8130/creating-an-account) — K1, P-256, and passkey accounts. -- [Sending a Transaction](/experimental/eip8130/sending-a-transaction) — estimate, deploy-on-first-use, sponsor gas, batch calls. -- [Calls & Batching](/experimental/eip8130/calls-and-batching) — atomic phases and value-bearing calls. -- [Receipts](/experimental/eip8130/receipts) — per-phase statuses, payer, and metadata. -- [Metadata](/experimental/eip8130/metadata) — attach opaque, authenticated application data. -- [Rotating Owners](/experimental/eip8130/rotating-owners) — authorize and revoke actors. -- [Session Keys](/experimental/eip8130/session-keys) — policy-gated, scoped signing keys. -- [Sub Accounts](/experimental/eip8130/sub-accounts) — many accounts per owner, linked via delegate actors. -- [Sponsoring Transactions](/experimental/eip8130/sponsoring-transactions) — pay another account's gas with a co-signing payer. -- [Payer Services (ERC-8168)](/experimental/eip8130/payer-services) — negotiate sponsorship / token payment with a web service. +- [Creating an Account](/eip8130/creating-an-account) — K1, P-256, and passkey accounts. +- [Sending a Transaction](/eip8130/sending-a-transaction) — estimate, deploy-on-first-use, sponsor gas, batch calls. +- [Calls & Batching](/eip8130/calls-and-batching) — atomic phases and value-bearing calls. +- [Receipts](/eip8130/receipts) — per-phase statuses, payer, and metadata. +- [Metadata](/eip8130/metadata) — attach opaque, authenticated application data. +- [Rotating Owners](/eip8130/rotating-owners) — authorize and revoke actors. +- [Session Keys](/eip8130/session-keys) — policy-gated, scoped signing keys. +- [Sub Accounts](/eip8130/sub-accounts) — many accounts per owner, linked via delegate actors. +- [Sponsoring Transactions](/eip8130/sponsoring-transactions) — pay another account's gas with a co-signing payer. +- [Payer Services (ERC-8168)](/eip8130/payer-services) — negotiate sponsorship / token payment with a web service. diff --git a/site/pages/experimental/eip8130/calls-and-batching.mdx b/site/pages/eip8130/calls-and-batching.mdx similarity index 82% rename from site/pages/experimental/eip8130/calls-and-batching.mdx rename to site/pages/eip8130/calls-and-batching.mdx index 4400cf9a42..d3f8356abd 100644 --- a/site/pages/experimental/eip8130/calls-and-batching.mdx +++ b/site/pages/eip8130/calls-and-batching.mdx @@ -11,7 +11,7 @@ Every EIP-8130 transaction carries a list of **calls** grouped into ordered **ph `calls` is a nested array — an array of phases, each an array of `AaCall`: ```ts -import { type AaCalls, parseEther } from 'viem/experimental/eip8130' +import { type AaCalls, parseEther } from 'viem/eip8130' const calls: AaCalls = [ // phase 0 — runs first, atomically @@ -35,7 +35,7 @@ Each `AaCall` is `{ to, data?, value? }`: `sendCalls` accepts either shape. A **flat** array is sugar for a single phase; pass a **nested** array to control phases explicitly: ```ts -import { sendCalls } from 'viem/experimental/eip8130' +import { sendCalls } from 'viem/eip8130' // One atomic phase (flat): await sendCalls(client, { @@ -59,7 +59,7 @@ await sendCalls(client, { A phase with no `value` passes each call straight to the wire as `[to, data]`. As soon as a phase contains any value-bearing call, the whole phase is collapsed into a single wallet-routed call — by default a self-call to the account's `executeBatch(Call[])`, which performs each value-bearing `CALL` while preserving the phase's atomicity. `encodeWalletCalls` implements this normalization: ```ts -import { encodeWalletCalls, parseEther } from 'viem/experimental/eip8130' +import { encodeWalletCalls, parseEther } from 'viem/eip8130' const wire = encodeWalletCalls({ account: account.address, @@ -73,7 +73,7 @@ const wire = encodeWalletCalls({ Wallets whose bytecode does not expose `executeBatch` must supply their own `encodeExecute` — a function mapping a phase's normalized calls to a single wallet call. Pass it to `sendCalls` (or `encodeWalletCalls`): ```ts -import { type EncodeExecute, sendCalls } from 'viem/experimental/eip8130' +import { type EncodeExecute, sendCalls } from 'viem/eip8130' import { encodeFunctionData } from 'viem' const encodeExecute: EncodeExecute = ({ account, calls }) => ({ @@ -91,9 +91,9 @@ await sendCalls(client, { account, calls, gas: 300_000n, encodeExecute }) ## Choosing phases - **Single atomic batch** → one phase. Everything succeeds together or nothing does. -- **Ordered, independently-committing steps** → multiple phases. Use this for pay-then-act (a [sponsored](/experimental/eip8130/sponsoring-transactions) phase-0 token transfer followed by the user's calls), or any flow where an early step must persist even if a later one reverts. +- **Ordered, independently-committing steps** → multiple phases. Use this for pay-then-act (a [sponsored](/eip8130/sponsoring-transactions) phase-0 token transfer followed by the user's calls), or any flow where an early step must persist even if a later one reverts. ## Next -- Inspect per-phase outcomes on the [receipt](/experimental/eip8130/receipts). -- Attach application data with [metadata](/experimental/eip8130/metadata). +- Inspect per-phase outcomes on the [receipt](/eip8130/receipts). +- Attach application data with [metadata](/eip8130/metadata). diff --git a/site/pages/experimental/eip8130/creating-an-account.mdx b/site/pages/eip8130/creating-an-account.mdx similarity index 85% rename from site/pages/experimental/eip8130/creating-an-account.mdx rename to site/pages/eip8130/creating-an-account.mdx index 73ccc7dabc..1efb976af0 100644 --- a/site/pages/experimental/eip8130/creating-an-account.mdx +++ b/site/pages/eip8130/creating-an-account.mdx @@ -4,7 +4,7 @@ description: Create an EIP-8130 smart account from a secp256k1, P-256, or WebAut # Creating an Account -`newSmartAccount` turns a signer into an EIP-8130 smart account. It auto-derives the controlling actor, the deployment bytecode, and the counterfactual CREATE2 address. The account is **not** deployed yet — it is deployed atomically inside its [first transaction](/experimental/eip8130/sending-a-transaction#deploy-on-first-use). +`newSmartAccount` turns a signer into an EIP-8130 smart account. It auto-derives the controlling actor, the deployment bytecode, and the counterfactual CREATE2 address. The account is **not** deployed yet — it is deployed atomically inside its [first transaction](/eip8130/sending-a-transaction#deploy-on-first-use). By default the account is an **`UpgradeableAccount`** deployed behind an ERC-1967 `UpgradeableProxy`, so its implementation can later be swapped via a CONFIG-key-signed `upgradeBySignature`. Pass `upgradeable: false` to deploy an immutable `DefaultHighRateAccount` behind a 45-byte ERC-1167 proxy instead. @@ -14,7 +14,7 @@ The signer type (K1 / P-256 / WebAuthn) is detected automatically. ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { newSmartAccount } from 'viem/experimental/eip8130' +import { newSmartAccount } from 'viem/eip8130' const owner = privateKeyToAccount(generatePrivateKey()) @@ -28,7 +28,7 @@ Pass a fixed `salt` to recover the same address across sessions: ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { newSmartAccount } from 'viem/experimental/eip8130' +import { newSmartAccount } from 'viem/eip8130' const owner = privateKeyToAccount(generatePrivateKey()) const account = newSmartAccount({ signer: owner, @@ -40,7 +40,7 @@ const account = newSmartAccount({ ```ts import * as P256 from 'ox/P256' -import { newSmartAccount, toP256Signer } from 'viem/experimental/eip8130' +import { newSmartAccount, toP256Signer } from 'viem/eip8130' const signer = toP256Signer({ privateKey: P256.randomPrivateKey() }) @@ -51,7 +51,7 @@ const account = newSmartAccount({ signer }) ```ts import { createWebAuthnCredential, toWebAuthnAccount } from 'viem/account-abstraction' -import { newSmartAccount, toWebAuthnSigner } from 'viem/experimental/eip8130' +import { newSmartAccount, toWebAuthnSigner } from 'viem/eip8130' const credential = await createWebAuthnCredential({ name: 'vibes' }) const signer = toWebAuthnSigner(toWebAuthnAccount({ credential })) @@ -61,11 +61,11 @@ const account = newSmartAccount({ signer }) ## Multiple initial keys -Register additional actors at creation with `extraActors`. Use the [`key`](/experimental/eip8130/rotating-owners#actors-and-keys) builders — the library sorts actors by `actorId` for you (a protocol requirement). +Register additional actors at creation with `extraActors`. Use the [`key`](/eip8130/rotating-owners#actors-and-keys) builders — the library sorts actors by `actorId` for you (a protocol requirement). ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { key, newSmartAccount, toP256Signer } from 'viem/experimental/eip8130' +import { key, newSmartAccount, toP256Signer } from 'viem/eip8130' import * as P256 from 'ox/P256' const owner = privateKeyToAccount(generatePrivateKey()) @@ -83,7 +83,7 @@ To use a raw secp256k1 EOA as its own account (cheapest auth path — a bare 65- ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { toEoaAccount } from 'viem/experimental/eip8130' +import { toEoaAccount } from 'viem/eip8130' const account = toEoaAccount(privateKeyToAccount(generatePrivateKey())) @@ -99,7 +99,7 @@ The same EIP-8130 account works on chains **without** native 8130 support throug ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { canonicalEip8130Deployment, toSmartAccount } from 'viem/experimental/eip8130' +import { canonicalEip8130Deployment, toSmartAccount } from 'viem/eip8130' const account = await toSmartAccount({ client, @@ -114,4 +114,4 @@ On a chain without native EIP-8130, the account must delegate to (or be deployed ## Next -Now [send a transaction](/experimental/eip8130/sending-a-transaction) with your account. +Now [send a transaction](/eip8130/sending-a-transaction) with your account. diff --git a/site/pages/experimental/eip8130/metadata.mdx b/site/pages/eip8130/metadata.mdx similarity index 80% rename from site/pages/experimental/eip8130/metadata.mdx rename to site/pages/eip8130/metadata.mdx index c050456005..c567bd4927 100644 --- a/site/pages/experimental/eip8130/metadata.mdx +++ b/site/pages/eip8130/metadata.mdx @@ -4,7 +4,7 @@ description: Attach opaque, authenticated application data to an EIP-8130 transa # Metadata -An EIP-8130 transaction can carry an opaque, application-defined `metadata` blob — arbitrary bytes with no protocol meaning. It is appended **after** `calls` in the signed body, so it is authenticated by the sender (and, when present, the [payer](/experimental/eip8130/sponsoring-transactions)): it cannot be altered in flight without invalidating the signature. The node echoes it back on the [receipt](/experimental/eip8130/receipts). +An EIP-8130 transaction can carry an opaque, application-defined `metadata` blob — arbitrary bytes with no protocol meaning. It is appended **after** `calls` in the signed body, so it is authenticated by the sender (and, when present, the [payer](/eip8130/sponsoring-transactions)): it cannot be altered in flight without invalidating the signature. The node echoes it back on the [receipt](/eip8130/receipts). Use it to bind off-chain context to a transaction — a payer `policyId`, a client request id, an app tag — where you want the value tamper-evident but don't want to spend calldata inside a call. @@ -18,7 +18,7 @@ Use it to bind off-chain context to a transaction — a payer `policyId`, a clie ```ts import { sendRawTransaction } from 'viem/actions' -import { prepareTransaction } from 'viem/experimental/eip8130' +import { prepareTransaction } from 'viem/eip8130' import { stringToHex } from 'viem' const tx = await prepareTransaction(client, { @@ -42,7 +42,7 @@ The value round-trips onto the receipt: ```ts import { hexToString } from 'viem' -import { waitForTransactionReceipt } from 'viem/experimental/eip8130' +import { waitForTransactionReceipt } from 'viem/eip8130' const receipt = await waitForTransactionReceipt(client, { hash }) const raw = receipt.eip8130.metadata // e.g. '0x7b22...' @@ -55,4 +55,4 @@ Keep it small — every byte is signed and stored onchain. For large payloads, s ## Next -- Read per-phase outcomes and the payer on the [receipt](/experimental/eip8130/receipts). +- Read per-phase outcomes and the payer on the [receipt](/eip8130/receipts). diff --git a/site/pages/experimental/eip8130/payer-services.mdx b/site/pages/eip8130/payer-services.mdx similarity index 91% rename from site/pages/experimental/eip8130/payer-services.mdx rename to site/pages/eip8130/payer-services.mdx index c3549dc792..ca243d122f 100644 --- a/site/pages/experimental/eip8130/payer-services.mdx +++ b/site/pages/eip8130/payer-services.mdx @@ -4,9 +4,9 @@ description: Negotiate gas sponsorship and token payment with an ERC-8168 payer # Payer Services (ERC-8168) -[ERC-8168](https://github.com/base/eip-8130) standardizes a **payer web service**: a JSON-RPC endpoint a wallet queries to sponsor an EIP-8130 transaction or accept an ERC-20 as gas payment. The service holds the payer key and co-signs `payer_auth` (see [Sponsoring Transactions](/experimental/eip8130/sponsoring-transactions) for the underlying mechanism). +[ERC-8168](https://github.com/base/eip-8130) standardizes a **payer web service**: a JSON-RPC endpoint a wallet queries to sponsor an EIP-8130 transaction or accept an ERC-20 as gas payment. The service holds the payer key and co-signs `payer_auth` (see [Sponsoring Transactions](/eip8130/sponsoring-transactions) for the underlying mechanism). -Viem exposes the client and helpers under `viem/experimental/eip8168`. +Viem exposes the client and helpers under `viem/eip8168`. :::warning[Warning] ERC-8168 is experimental. A payer's offers are trust-based — always preflight caps (`conditions`) and confirm token charges with the user before re-signing. @@ -26,7 +26,7 @@ A payer implements four `payer_*` methods (two required, two optional): Create a client for the endpoint: ```ts -import { createPayerClient } from 'viem/experimental/eip8168' +import { createPayerClient } from 'viem/eip8168' const payerClient = createPayerClient({ url: 'https://payer.example.com/v1' }) ``` @@ -36,7 +36,7 @@ const payerClient = createPayerClient({ url: 'https://payer.example.com/v1' }) `sendSponsoredCalls` runs the whole flow: fetch terms, select an offer, build the phases (including any phase-0 token transfer), sign `sender_auth` with the payer named and `payer_auth` empty, then hand off to the payer to co-sign and submit. ```ts -import { sendSponsoredCalls } from 'viem/experimental/eip8168' +import { sendSponsoredCalls } from 'viem/eip8168' const { transactionHash, tokenCharged } = await sendSponsoredCalls(client, { account, // signs sender_auth @@ -55,7 +55,7 @@ const { transactionHash, tokenCharged } = await sendSponsoredCalls(client, { If the payer rejects with a recoverable reason (`PAYMENT_INSUFFICIENT` → a higher token charge; `GAS_TOO_LOW` → a higher gas limit), `sendSponsoredCalls` can re-sign — but only when you approve each attempt via `confirmRetry`. Re-signing mints a **new** `sender_auth` (and, for a passkey, a fresh user gesture), so there is no silent retry: with no callback, the rejection is thrown for you to handle. ```ts -import { sendSponsoredCalls } from 'viem/experimental/eip8168' +import { sendSponsoredCalls } from 'viem/eip8168' import { formatUnits } from 'viem' const result = await sendSponsoredCalls(client, { @@ -103,7 +103,7 @@ import { isSponsoredOffer, isTokenOffer, selectPaymentOption, -} from 'viem/experimental/eip8168' +} from 'viem/eip8168' for (const option of terms.options) { if (isSponsoredOffer(option)) console.log('free via', option.payer) @@ -119,7 +119,7 @@ const { option, tokenChoice } = selectPaymentOption(terms, { token: usdc }) `buildSponsoredCalls` turns the selected offer into the EIP-8130 `payer` + phased `calls` (adding the phase-0 token transfer when paying with a token). ```ts -import { buildSponsoredCalls } from 'viem/experimental/eip8168' +import { buildSponsoredCalls } from 'viem/eip8168' const { payer, calls, paymentAmount } = buildSponsoredCalls({ terms, @@ -134,7 +134,7 @@ Prepare the transaction with the offer's gas, sign `sender_auth` with `payer` na ```ts import { hexToBigInt } from 'viem' -import { prepareTransaction } from 'viem/experimental/eip8130' +import { prepareTransaction } from 'viem/eip8130' const tx = await prepareTransaction(client, { account, @@ -155,7 +155,7 @@ const { transactionHash } = await payerClient.sendTransaction({ signedTransactio `payer_sendTransaction` / `payer_signTransaction` fail with a `PAYER_REJECTED` envelope. Decode it with `parsePayerError` to branch on the stable string `code`. ```ts -import { parsePayerError } from 'viem/experimental/eip8168' +import { parsePayerError } from 'viem/eip8168' try { await payerClient.sendTransaction({ signedTransaction }) diff --git a/site/pages/experimental/eip8130/receipts.mdx b/site/pages/eip8130/receipts.mdx similarity index 82% rename from site/pages/experimental/eip8130/receipts.mdx rename to site/pages/eip8130/receipts.mdx index 99ac21ac80..a267ffb61e 100644 --- a/site/pages/experimental/eip8130/receipts.mdx +++ b/site/pages/eip8130/receipts.mdx @@ -9,7 +9,7 @@ An `AA_TX_TYPE` (`0x79`) receipt carries extra fields beyond a standard receipt: ## Wait for a receipt ```ts -import { waitForTransactionReceipt } from 'viem/experimental/eip8130' +import { waitForTransactionReceipt } from 'viem/eip8130' const receipt = await waitForTransactionReceipt(client, { hash }) @@ -32,7 +32,7 @@ import { TransactionExpiredError, sendCalls, waitForTransactionReceipt, -} from 'viem/experimental/eip8130' +} from 'viem/eip8130' let expiry: bigint | undefined const hash = await sendCalls(client, { @@ -58,7 +58,7 @@ If you omit `expiry`, the wait still tries to read it off the pending transactio `getTransactionReceipt` returns `null` if the receipt is not yet available: ```ts -import { getTransactionReceipt } from 'viem/experimental/eip8130' +import { getTransactionReceipt } from 'viem/eip8130' const receipt = await getTransactionReceipt(client, { hash }) if (receipt) console.log(receipt.eip8130.phaseStatuses) @@ -68,12 +68,12 @@ The `eip8130` fields are only populated for `AA_TX_TYPE` receipts on a node with ## Per-phase statuses -`phaseStatuses` reports each [call phase](/experimental/eip8130/calls-and-batching) in order — `0x1` for success, `0x0` for revert. Because a reverted phase halts execution, every phase after the first failure is reported `0x0`. The array is empty when the transaction has no calls (e.g. a config-only change). +`phaseStatuses` reports each [call phase](/eip8130/calls-and-batching) in order — `0x1` for success, `0x0` for revert. Because a reverted phase halts execution, every phase after the first failure is reported `0x0`. The array is empty when the transaction has no calls (e.g. a config-only change). `allPhasesSucceeded` collapses this to a single boolean: ```ts -import { allPhasesSucceeded, waitForTransactionReceipt } from 'viem/experimental/eip8130' +import { allPhasesSucceeded, waitForTransactionReceipt } from 'viem/eip8130' const receipt = await waitForTransactionReceipt(client, { hash }) if (!allPhasesSucceeded(receipt.eip8130)) { @@ -88,8 +88,8 @@ Unlike a failed EVM call, a reverted `AA_TX_TYPE` transaction is mined: its nonc ## The payer field -`eip8130.payer` is the account that paid the gas: the sender for a self-paid transaction, or the named [payer](/experimental/eip8130/sponsoring-transactions) for a sponsored one. Use it to confirm sponsorship landed as expected. +`eip8130.payer` is the account that paid the gas: the sender for a self-paid transaction, or the named [payer](/eip8130/sponsoring-transactions) for a sponsored one. Use it to confirm sponsorship landed as expected. ## Next -- Attach and read application data with [metadata](/experimental/eip8130/metadata). +- Attach and read application data with [metadata](/eip8130/metadata). diff --git a/site/pages/experimental/eip8130/rotating-owners.mdx b/site/pages/eip8130/rotating-owners.mdx similarity index 91% rename from site/pages/experimental/eip8130/rotating-owners.mdx rename to site/pages/eip8130/rotating-owners.mdx index f306046856..944f86bbc0 100644 --- a/site/pages/experimental/eip8130/rotating-owners.mdx +++ b/site/pages/eip8130/rotating-owners.mdx @@ -11,7 +11,7 @@ An EIP-8130 account is controlled by a set of **actors**. You rotate ownership b Each actor is `{ actorId, authenticator }`. Build them with the `key` helpers: ```ts -import { key } from 'viem/experimental/eip8130' +import { key } from 'viem/eip8130' key.k1('0xowner...') // secp256k1 (native ecrecover) key.p256({ x, y }) // P-256 public key @@ -24,7 +24,7 @@ key.delegate('0xotherAccount') // signatures for another account act for this `authorizeActor` attaches a permission scope and optional expiry to an actor. Combine scope flags with `toScope`: ```ts -import { actorScope, authorizeActor, key, toScope } from 'viem/experimental/eip8130' +import { actorScope, authorizeActor, key, toScope } from 'viem/eip8130' authorizeActor(key.p256({ x, y }), { // What the actor may do: sign / act as sender / act as payer / change config. @@ -48,7 +48,7 @@ An unrestricted (full-owner) actor uses scope `0`. Every config change is signed against the account's **next** config sequence. Read it first to avoid sequence-mismatch rejections: ```ts -import { getConfigSequence, getEip8130Deployment } from 'viem/experimental/eip8130' +import { getConfigSequence, getEip8130Deployment } from 'viem/eip8130' const { accountConfiguration } = getEip8130Deployment(client.chain.id)! const { local: sequence } = await getConfigSequence(client, { @@ -67,7 +67,7 @@ import { authorizeActor, key, sendCalls, -} from 'viem/experimental/eip8130' +} from 'viem/eip8130' const change = await account.change( [ @@ -96,7 +96,7 @@ import { authorizeActor, key, revokeActor, -} from 'viem/experimental/eip8130' +} from 'viem/eip8130' const rotate = await account.change( [ @@ -119,7 +119,7 @@ import { key, sendCalls, toEoaAccount, -} from 'viem/experimental/eip8130' +} from 'viem/eip8130' import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' const account = toEoaAccount(privateKeyToAccount(generatePrivateKey())) @@ -142,4 +142,4 @@ const hash = await sendCalls(client, { ## Next -Authorize a scoped, policy-gated [session key](/experimental/eip8130/session-keys). +Authorize a scoped, policy-gated [session key](/eip8130/session-keys). diff --git a/site/pages/experimental/eip8130/sending-a-transaction.mdx b/site/pages/eip8130/sending-a-transaction.mdx similarity index 82% rename from site/pages/experimental/eip8130/sending-a-transaction.mdx rename to site/pages/eip8130/sending-a-transaction.mdx index 8c605f869c..f617eb0b68 100644 --- a/site/pages/experimental/eip8130/sending-a-transaction.mdx +++ b/site/pages/eip8130/sending-a-transaction.mdx @@ -12,7 +12,7 @@ Unlike EVM transactions, the gas budget for an `AA_TX_TYPE` transaction is node- ```ts import { parseEther } from 'viem' -import { canonicalAuthenticators, estimateGas } from 'viem/experimental/eip8130' +import { canonicalAuthenticators, estimateGas } from 'viem/eip8130' const gas = await estimateGas(client, { sender: account.address, @@ -27,7 +27,7 @@ const gas = await estimateGas(client, { Pick the authenticator from the signer kind: ```ts -import { canonicalAuthenticators } from 'viem/experimental/eip8130' +import { canonicalAuthenticators } from 'viem/eip8130' const senderAuthVerifier = kind === 'p256' ? canonicalAuthenticators.p256 : kind === 'passkey' ? canonicalAuthenticators.passkey @@ -49,7 +49,7 @@ import { estimateGas, sendCalls, waitForTransactionReceipt, -} from 'viem/experimental/eip8130' +} from 'viem/eip8130' const calls = [{ to: recipient, value: parseEther('0.001') }] @@ -77,7 +77,7 @@ Once the account is deployed, drop `accountChanges` and just pass `calls`. The n ```ts import { encodeFunctionData } from 'viem' -import { estimateGas, sendCalls } from 'viem/experimental/eip8130' +import { estimateGas, sendCalls } from 'viem/eip8130' const gas = await estimateGas(client, { sender: account.address, @@ -93,10 +93,10 @@ const hash = await sendCalls(client, { ## Batching calls -A flat `calls` array runs as one atomic phase. Pass a nested array to control phases explicitly (each phase is a separate `executeBatch`) — see [Calls & Batching](/experimental/eip8130/calls-and-batching) for the full phased model: +A flat `calls` array runs as one atomic phase. Pass a nested array to control phases explicitly (each phase is a separate `executeBatch`) — see [Calls & Batching](/eip8130/calls-and-batching) for the full phased model: ```ts -import { sendCalls } from 'viem/experimental/eip8130' +import { sendCalls } from 'viem/eip8130' const hash = await sendCalls(client, { account, @@ -113,7 +113,7 @@ const hash = await sendCalls(client, { A `payer` account can co-sign the transaction and pay its gas — either a key you hold or a payer web service: ```ts -import { sendCalls } from 'viem/experimental/eip8130' +import { sendCalls } from 'viem/eip8130' const hash = await sendCalls(client, { account, @@ -123,7 +123,7 @@ const hash = await sendCalls(client, { }) ``` -See [Sponsoring Transactions](/experimental/eip8130/sponsoring-transactions) for the co-signing mechanism (and token payment), and [Payer Services (ERC-8168)](/experimental/eip8130/payer-services) to negotiate sponsorship with a service. +See [Sponsoring Transactions](/eip8130/sponsoring-transactions) for the co-signing mechanism (and token payment), and [Payer Services (ERC-8168)](/eip8130/payer-services) to negotiate sponsorship with a service. ## Lower-level control @@ -134,5 +134,5 @@ See [Sponsoring Transactions](/experimental/eip8130/sponsoring-transactions) for ## Next -- [Rotate owners](/experimental/eip8130/rotating-owners) by authorizing and revoking actors. -- Add scoped [session keys](/experimental/eip8130/session-keys). +- [Rotate owners](/eip8130/rotating-owners) by authorizing and revoking actors. +- Add scoped [session keys](/eip8130/session-keys). diff --git a/site/pages/experimental/eip8130/session-keys.mdx b/site/pages/eip8130/session-keys.mdx similarity index 95% rename from site/pages/experimental/eip8130/session-keys.mdx rename to site/pages/eip8130/session-keys.mdx index 0f5eba7c0b..21d737029e 100644 --- a/site/pages/experimental/eip8130/session-keys.mdx +++ b/site/pages/eip8130/session-keys.mdx @@ -24,7 +24,7 @@ import { parseUnits } from 'viem' import { defineSessionPolicy, encodeSessionPolicyConfig, -} from 'viem/experimental/eip8130' +} from 'viem/eip8130' const session = defineSessionPolicy({ account: account.address, @@ -47,7 +47,7 @@ session.actorPolicy // pass to `authorizeActor(key, { scope, policy })` `selectorRules` may bind recipients for the standard ERC-20 selectors (`transfer`, `transferFrom`, `approve`): ```ts -import { encodeSessionPolicyConfig } from 'viem/experimental/eip8130' +import { encodeSessionPolicyConfig } from 'viem/eip8130' encodeSessionPolicyConfig({ callScopes: [ @@ -69,7 +69,7 @@ import { authorizeActor, key, sendCalls, -} from 'viem/experimental/eip8130' +} from 'viem/eip8130' const sessionKey = key.p256({ x, y }) @@ -103,7 +103,7 @@ import { newSmartAccount, sendCalls, toP256Signer, -} from 'viem/experimental/eip8130' +} from 'viem/eip8130' // Same account address, driven by the session key. const sessionAccount = newSmartAccount({ diff --git a/site/pages/experimental/eip8130/sponsoring-transactions.mdx b/site/pages/eip8130/sponsoring-transactions.mdx similarity index 84% rename from site/pages/experimental/eip8130/sponsoring-transactions.mdx rename to site/pages/eip8130/sponsoring-transactions.mdx index 961852f78d..4998b9f3aa 100644 --- a/site/pages/experimental/eip8130/sponsoring-transactions.mdx +++ b/site/pages/eip8130/sponsoring-transactions.mdx @@ -14,7 +14,7 @@ Two auth blobs are produced: There are two ways to obtain `payer_auth`: - **You hold the payer key** (self-sponsor, or your own backend) → co-sign locally. Covered here. -- **A service holds the payer key** → negotiate terms and have it co-sign over RPC. See [Payer Services (ERC-8168)](/experimental/eip8130/payer-services). +- **A service holds the payer key** → negotiate terms and have it co-sign over RPC. See [Payer Services (ERC-8168)](/eip8130/payer-services). ## Co-signing locally @@ -22,7 +22,7 @@ Pass a `payer` signer to `sendCalls`. It signs `payer_auth` bound to the sender ```ts import { privateKeyToAccount } from 'viem/accounts' -import { sendCalls } from 'viem/experimental/eip8130' +import { sendCalls } from 'viem/eip8130' const sponsor = privateKeyToAccount(process.env.SPONSOR_KEY as `0x${string}`) @@ -39,7 +39,7 @@ const hash = await sendCalls(client, { Pass the `payer` address (and, if the payer uses a non-K1 key, `payerAuthVerifier`) so the node prices the payer authentication too: ```ts -import { canonicalAuthenticators, estimateGas } from 'viem/experimental/eip8130' +import { canonicalAuthenticators, estimateGas } from 'viem/eip8130' const gas = await estimateGas(client, { sender: account.address, @@ -54,8 +54,8 @@ const gas = await estimateGas(client, { To charge the sender in an ERC-20 while the payer fronts native gas, run a two-phase transaction: phase 0 transfers the token to the payer, phase 1 runs the user's calls. The payer only co-signs because it can see its fee is paid in phase 0. ```ts -import { encodeTokenTransfer } from 'viem/experimental/eip8168' -import { sendCalls } from 'viem/experimental/eip8130' +import { encodeTokenTransfer } from 'viem/eip8168' +import { sendCalls } from 'viem/eip8130' const hash = await sendCalls(client, { account, @@ -70,7 +70,7 @@ const hash = await sendCalls(client, { }) ``` -In practice the token amount and payer are negotiated with a [payer service](/experimental/eip8130/payer-services), which quotes the fee and builds these phases for you. +In practice the token amount and payer are negotiated with a [payer service](/eip8130/payer-services), which quotes the fee and builds these phases for you. ## Lower-level control @@ -78,7 +78,7 @@ In practice the token amount and payer are negotiated with a [payer service](/ex ```ts import { sendRawTransaction } from 'viem/actions' -import { prepareTransaction } from 'viem/experimental/eip8130' +import { prepareTransaction } from 'viem/eip8130' const tx = await prepareTransaction(client, { account, @@ -97,4 +97,4 @@ If you already have a `payer_auth` blob (e.g. returned by a service), preset `tr ## Next -Negotiate sponsorship or token payment with a web service — [Payer Services (ERC-8168)](/experimental/eip8130/payer-services). +Negotiate sponsorship or token payment with a web service — [Payer Services (ERC-8168)](/eip8130/payer-services). diff --git a/site/pages/experimental/eip8130/sub-accounts.mdx b/site/pages/eip8130/sub-accounts.mdx similarity index 85% rename from site/pages/experimental/eip8130/sub-accounts.mdx rename to site/pages/eip8130/sub-accounts.mdx index 529401f1d7..93cdea3d79 100644 --- a/site/pages/experimental/eip8130/sub-accounts.mdx +++ b/site/pages/eip8130/sub-accounts.mdx @@ -15,7 +15,7 @@ The account address is a deterministic function of the salt (plus the wallet cod ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { newSmartAccount } from 'viem/experimental/eip8130' +import { newSmartAccount } from 'viem/eip8130' const owner = privateKeyToAccount(generatePrivateKey()) @@ -27,7 +27,7 @@ const savings = newSmartAccount({ signer: owner, salt: saltFor('savings') }) main.address !== trading.address // distinct accounts, same owner key ``` -Each sub account is deployed independently on its first transaction (see [Sending a Transaction](/experimental/eip8130/sending-a-transaction#deploy-on-first-use)). +Each sub account is deployed independently on its first transaction (see [Sending a Transaction](/eip8130/sending-a-transaction#deploy-on-first-use)). ## Linking with delegate actors @@ -39,7 +39,7 @@ import { authorizeActor, key, sendCalls, -} from 'viem/experimental/eip8130' +} from 'viem/eip8130' // On the sub account, authorize the primary account as a delegate. const link = await subAccount.change( @@ -66,7 +66,7 @@ import { canonicalAuthenticators, sendCalls, toAccount, -} from 'viem/experimental/eip8130' +} from 'viem/eip8130' // Drive `subAccount.address` with `main`'s key through the delegate authenticator. const subAsDelegate = toAccount({ @@ -83,7 +83,7 @@ const hash = await sendCalls(client, { ``` :::note -The delegate authenticator validates a signature produced for the linked account, so authentication gas is priced from the delegate blob's shape. Pass `senderAuthVerifier: canonicalAuthenticators.delegate` to [`estimateGas`](/experimental/eip8130/sending-a-transaction#estimate-gas) when pricing delegate-signed transactions. +The delegate authenticator validates a signature produced for the linked account, so authentication gas is priced from the delegate blob's shape. Pass `senderAuthVerifier: canonicalAuthenticators.delegate` to [`estimateGas`](/eip8130/sending-a-transaction#estimate-gas) when pricing delegate-signed transactions. ::: ## Revoking a link @@ -91,7 +91,7 @@ The delegate authenticator validates a signature produced for the linked account Revoke the delegate actor to unlink a sub account at any time: ```ts -import { key, revokeActor } from 'viem/experimental/eip8130' +import { key, revokeActor } from 'viem/eip8130' const unlink = await subAccount.change( [revokeActor(key.delegate(main.address))], @@ -101,5 +101,5 @@ const unlink = await subAccount.change( ## Next -- Let a third party pay for a sub account's gas — [Sponsoring Transactions](/experimental/eip8130/sponsoring-transactions). -- Negotiate sponsorship with a service — [Payer Services (ERC-8168)](/experimental/eip8130/payer-services). +- Let a third party pay for a sub account's gas — [Sponsoring Transactions](/eip8130/sponsoring-transactions). +- Negotiate sponsorship with a service — [Payer Services (ERC-8168)](/eip8130/payer-services). diff --git a/site/vocs.config.ts b/site/vocs.config.ts index f20072de88..3d238f397e 100644 --- a/site/vocs.config.ts +++ b/site/vocs.config.ts @@ -1572,47 +1572,47 @@ export default defineConfig({ items: [ { text: 'Overview', - link: '/experimental/eip8130', + link: '/eip8130', }, { text: 'Creating an Account', - link: '/experimental/eip8130/creating-an-account', + link: '/eip8130/creating-an-account', }, { text: 'Sending a Transaction', - link: '/experimental/eip8130/sending-a-transaction', + link: '/eip8130/sending-a-transaction', }, { text: 'Calls & Batching', - link: '/experimental/eip8130/calls-and-batching', + link: '/eip8130/calls-and-batching', }, { text: 'Receipts', - link: '/experimental/eip8130/receipts', + link: '/eip8130/receipts', }, { text: 'Metadata', - link: '/experimental/eip8130/metadata', + link: '/eip8130/metadata', }, { text: 'Rotating Owners', - link: '/experimental/eip8130/rotating-owners', + link: '/eip8130/rotating-owners', }, { text: 'Session Keys', - link: '/experimental/eip8130/session-keys', + link: '/eip8130/session-keys', }, { text: 'Sub Accounts', - link: '/experimental/eip8130/sub-accounts', + link: '/eip8130/sub-accounts', }, { text: 'Sponsoring Transactions', - link: '/experimental/eip8130/sponsoring-transactions', + link: '/eip8130/sponsoring-transactions', }, { text: 'Payer Services (ERC-8168)', - link: '/experimental/eip8130/payer-services', + link: '/eip8130/payer-services', }, ], }, diff --git a/src/experimental/eip8130/abis.ts b/src/eip8130/abis.ts similarity index 100% rename from src/experimental/eip8130/abis.ts rename to src/eip8130/abis.ts diff --git a/src/experimental/eip8130/accounts/toAccount.ts b/src/eip8130/accounts/toAccount.ts similarity index 98% rename from src/experimental/eip8130/accounts/toAccount.ts rename to src/eip8130/accounts/toAccount.ts index ce2429c8a3..4df53bd40d 100644 --- a/src/experimental/eip8130/accounts/toAccount.ts +++ b/src/eip8130/accounts/toAccount.ts @@ -1,9 +1,9 @@ import type { Address } from 'abitype' -import { BaseError } from '../../../errors/base.js' -import type { Hex } from '../../../types/misc.js' -import { concatHex } from '../../../utils/data/concat.js' -import { hexToBigInt } from '../../../utils/encoding/fromHex.js' -import { bytesToHex } from '../../../utils/encoding/toHex.js' +import { BaseError } from '../../errors/base.js' +import type { Hex } from '../../types/misc.js' +import { concatHex } from '../../utils/data/concat.js' +import { hexToBigInt } from '../../utils/encoding/fromHex.js' +import { bytesToHex } from '../../utils/encoding/toHex.js' import { canonicalAuthenticators, accountConfigAddress as defaultAccountConfigAddress, diff --git a/src/experimental/eip8130/accounts/toSmartAccount.test.ts b/src/eip8130/accounts/toSmartAccount.test.ts similarity index 90% rename from src/experimental/eip8130/accounts/toSmartAccount.test.ts rename to src/eip8130/accounts/toSmartAccount.test.ts index 3b6730e914..566d34102a 100644 --- a/src/experimental/eip8130/accounts/toSmartAccount.test.ts +++ b/src/eip8130/accounts/toSmartAccount.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' -import { mainnet } from '../../../chains/index.js' -import { createClient } from '../../../clients/createClient.js' -import { custom } from '../../../clients/transports/custom.js' -import { decodeFunctionData } from '../../../utils/abi/decodeFunctionData.js' -import { slice } from '../../../utils/data/slice.js' -import { recoverMessageAddress } from '../../../utils/signature/recoverMessageAddress.js' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { mainnet } from '../../chains/index.js' +import { createClient } from '../../clients/createClient.js' +import { custom } from '../../clients/transports/custom.js' +import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' +import { slice } from '../../utils/data/slice.js' +import { recoverMessageAddress } from '../../utils/signature/recoverMessageAddress.js' import { accountConfigurationAbi } from '../abis.js' import { ecrecoverAuthenticator } from '../constants.js' import type { AaActor } from '../types/transaction.js' diff --git a/src/experimental/eip8130/accounts/toSmartAccount.ts b/src/eip8130/accounts/toSmartAccount.ts similarity index 85% rename from src/experimental/eip8130/accounts/toSmartAccount.ts rename to src/eip8130/accounts/toSmartAccount.ts index 8089793379..1108a8955f 100644 --- a/src/experimental/eip8130/accounts/toSmartAccount.ts +++ b/src/eip8130/accounts/toSmartAccount.ts @@ -1,24 +1,24 @@ import type { Abi, Address } from 'abitype' -import { toSmartAccount as toSmartAccount_ } from '../../../account-abstraction/accounts/toSmartAccount.js' +import { toSmartAccount as toSmartAccount_ } from '../../account-abstraction/accounts/toSmartAccount.js' import type { SmartAccount, SmartAccountImplementation, -} from '../../../account-abstraction/accounts/types.js' -import { entryPoint07Abi } from '../../../account-abstraction/constants/abis.js' -import { entryPoint07Address } from '../../../account-abstraction/constants/address.js' -import type { EntryPointVersion } from '../../../account-abstraction/types/entryPointVersion.js' -import { getUserOperationHash } from '../../../account-abstraction/utils/userOperation/getUserOperationHash.js' -import { parseAccount } from '../../../accounts/utils/parseAccount.js' -import { signMessage as signMessage_ } from '../../../actions/wallet/signMessage.js' -import { signTypedData as signTypedData_ } from '../../../actions/wallet/signTypedData.js' -import { BaseError } from '../../../errors/base.js' -import type { Account } from '../../../types/account.js' -import type { Hex } from '../../../types/misc.js' -import type { Prettify } from '../../../types/utils.js' -import { decodeFunctionData } from '../../../utils/abi/decodeFunctionData.js' -import { encodeFunctionData } from '../../../utils/abi/encodeFunctionData.js' -import { concatHex } from '../../../utils/data/concat.js' -import { getAction } from '../../../utils/getAction.js' +} from '../../account-abstraction/accounts/types.js' +import { entryPoint07Abi } from '../../account-abstraction/constants/abis.js' +import { entryPoint07Address } from '../../account-abstraction/constants/address.js' +import type { EntryPointVersion } from '../../account-abstraction/types/entryPointVersion.js' +import { getUserOperationHash } from '../../account-abstraction/utils/userOperation/getUserOperationHash.js' +import { parseAccount } from '../../accounts/utils/parseAccount.js' +import { signMessage as signMessage_ } from '../../actions/wallet/signMessage.js' +import { signTypedData as signTypedData_ } from '../../actions/wallet/signTypedData.js' +import { BaseError } from '../../errors/base.js' +import type { Account } from '../../types/account.js' +import type { Hex } from '../../types/misc.js' +import type { Prettify } from '../../types/utils.js' +import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' +import { encodeFunctionData } from '../../utils/abi/encodeFunctionData.js' +import { concatHex } from '../../utils/data/concat.js' +import { getAction } from '../../utils/getAction.js' import { erc4337AccountAbi } from '../abis.js' import { accountConfigAddress as defaultAccountConfigAddress, @@ -115,7 +115,7 @@ export type ToSmartAccountReturnType< * `authenticator || data` auth format. * * @example - * import { toSmartAccount } from 'viem/experimental/eip8130' + * import { toSmartAccount } from 'viem/eip8130' * * const account = await toSmartAccount({ * client, diff --git a/src/experimental/eip8130/actions/estimateGas.test.ts b/src/eip8130/actions/estimateGas.test.ts similarity index 94% rename from src/experimental/eip8130/actions/estimateGas.test.ts rename to src/eip8130/actions/estimateGas.test.ts index 6b49fb3cb5..32abf57d2d 100644 --- a/src/experimental/eip8130/actions/estimateGas.test.ts +++ b/src/eip8130/actions/estimateGas.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' -import { mainnet } from '../../../chains/index.js' -import { createClient } from '../../../clients/createClient.js' -import { custom } from '../../../clients/transports/custom.js' -import { numberToHex } from '../../../utils/encoding/toHex.js' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { mainnet } from '../../chains/index.js' +import { createClient } from '../../clients/createClient.js' +import { custom } from '../../clients/transports/custom.js' +import { numberToHex } from '../../utils/encoding/toHex.js' import { toAccount } from '../accounts/toAccount.js' import { actorScope, canonicalAuthenticators } from '../constants.js' import { authorizeActor, encodePolicyData, key } from '../keys.js' diff --git a/src/experimental/eip8130/actions/estimateGas.ts b/src/eip8130/actions/estimateGas.ts similarity index 96% rename from src/experimental/eip8130/actions/estimateGas.ts rename to src/eip8130/actions/estimateGas.ts index 9246aa2199..d6655f40df 100644 --- a/src/experimental/eip8130/actions/estimateGas.ts +++ b/src/eip8130/actions/estimateGas.ts @@ -1,15 +1,15 @@ import type { Address } from 'abitype' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import { BaseError } from '../../../errors/base.js' -import type { Account } from '../../../types/account.js' -import type { BlockTag } from '../../../types/block.js' -import type { Chain } from '../../../types/chain.js' -import type { Hex } from '../../../types/misc.js' -import { concatHex } from '../../../utils/data/concat.js' -import { hexToBigInt } from '../../../utils/encoding/fromHex.js' -import { numberToHex } from '../../../utils/encoding/toHex.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import { BaseError } from '../../errors/base.js' +import type { Account } from '../../types/account.js' +import type { BlockTag } from '../../types/block.js' +import type { Chain } from '../../types/chain.js' +import type { Hex } from '../../types/misc.js' +import { concatHex } from '../../utils/data/concat.js' +import { hexToBigInt } from '../../utils/encoding/fromHex.js' +import { numberToHex } from '../../utils/encoding/toHex.js' import { aaTransactionType, canonicalAuthDataLength, diff --git a/src/experimental/eip8130/actions/getActorConfig.ts b/src/eip8130/actions/getActorConfig.ts similarity index 83% rename from src/experimental/eip8130/actions/getActorConfig.ts rename to src/eip8130/actions/getActorConfig.ts index be262a5ce1..d004cb39a6 100644 --- a/src/experimental/eip8130/actions/getActorConfig.ts +++ b/src/eip8130/actions/getActorConfig.ts @@ -1,11 +1,11 @@ import type { Address } from 'abitype' -import { readContract } from '../../../actions/public/readContract.js' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' -import type { Hex } from '../../../types/misc.js' +import { readContract } from '../../actions/public/readContract.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import type { Hex } from '../../types/misc.js' import { accountConfigurationAbi } from '../abis.js' import { actorScope, @@ -42,7 +42,7 @@ export type GetActorConfigReturnType = { * * @example * ```ts - * import { getActorConfig, key } from 'viem/experimental/eip8130' + * import { getActorConfig, key } from 'viem/eip8130' * * const config = await getActorConfig(client, { * account: account.address, diff --git a/src/experimental/eip8130/actions/getConfigSequence.ts b/src/eip8130/actions/getConfigSequence.ts similarity index 88% rename from src/experimental/eip8130/actions/getConfigSequence.ts rename to src/eip8130/actions/getConfigSequence.ts index 5927973399..6c318f8dd9 100644 --- a/src/experimental/eip8130/actions/getConfigSequence.ts +++ b/src/eip8130/actions/getConfigSequence.ts @@ -1,9 +1,9 @@ import type { Address } from 'abitype' -import { readContract } from '../../../actions/public/readContract.js' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' +import { readContract } from '../../actions/public/readContract.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' import { accountConfigurationAbi } from '../abis.js' export type GetConfigSequenceParameters = { diff --git a/src/experimental/eip8130/actions/getLockStatus.ts b/src/eip8130/actions/getLockStatus.ts similarity index 83% rename from src/experimental/eip8130/actions/getLockStatus.ts rename to src/eip8130/actions/getLockStatus.ts index fade5f70d9..091345688f 100644 --- a/src/experimental/eip8130/actions/getLockStatus.ts +++ b/src/eip8130/actions/getLockStatus.ts @@ -1,10 +1,10 @@ import type { Address } from 'abitype' -import { readContract } from '../../../actions/public/readContract.js' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' +import { readContract } from '../../actions/public/readContract.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' import { accountConfigurationAbi } from '../abis.js' import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' @@ -35,7 +35,7 @@ export type GetLockStatusReturnType = { * * @example * ```ts - * import { getLockStatus } from 'viem/experimental/eip8130' + * import { getLockStatus } from 'viem/eip8130' * * const { locked, hasInitiatedUnlock, unlocksAt, unlockDelay } = * await getLockStatus(client, { account: account.address }) diff --git a/src/experimental/eip8130/actions/getPolicy.ts b/src/eip8130/actions/getPolicy.ts similarity index 83% rename from src/experimental/eip8130/actions/getPolicy.ts rename to src/eip8130/actions/getPolicy.ts index 850e4623de..91c9468fb8 100644 --- a/src/experimental/eip8130/actions/getPolicy.ts +++ b/src/eip8130/actions/getPolicy.ts @@ -1,11 +1,11 @@ import type { Address } from 'abitype' -import { readContract } from '../../../actions/public/readContract.js' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' -import type { Hex } from '../../../types/misc.js' +import { readContract } from '../../actions/public/readContract.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import type { Hex } from '../../types/misc.js' import { accountConfigurationAbi } from '../abis.js' import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' @@ -37,7 +37,7 @@ export type GetPolicyReturnType = { * * @example * ```ts - * import { getPolicy, getSessionSpend, key } from 'viem/experimental/eip8130' + * import { getPolicy, getSessionSpend, key } from 'viem/eip8130' * * const { commitment } = await getPolicy(client, { * account: account.address, diff --git a/src/experimental/eip8130/actions/getSessionSpend.ts b/src/eip8130/actions/getSessionSpend.ts similarity index 86% rename from src/experimental/eip8130/actions/getSessionSpend.ts rename to src/eip8130/actions/getSessionSpend.ts index 5cc68da7c9..fadbe13da0 100644 --- a/src/experimental/eip8130/actions/getSessionSpend.ts +++ b/src/eip8130/actions/getSessionSpend.ts @@ -1,12 +1,12 @@ import type { Address } from 'abitype' -import { readContract } from '../../../actions/public/readContract.js' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' -import type { Hex } from '../../../types/misc.js' -import { getAction } from '../../../utils/getAction.js' +import { readContract } from '../../actions/public/readContract.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import type { Hex } from '../../types/misc.js' +import { getAction } from '../../utils/getAction.js' import { type SessionPolicyTokenLimit, sessionPolicyAbi, @@ -54,7 +54,7 @@ export type GetSessionSpendReturnType = { * * @example * ```ts - * import { getSessionSpend } from 'viem/experimental/eip8130' + * import { getSessionSpend } from 'viem/eip8130' * * // Pass the exact token limit from the binding's config. * const { allowance, spent, remaining, periodEnd } = await getSessionSpend( diff --git a/src/experimental/eip8130/actions/getTransaction.ts b/src/eip8130/actions/getTransaction.ts similarity index 94% rename from src/experimental/eip8130/actions/getTransaction.ts rename to src/eip8130/actions/getTransaction.ts index bb6742c72e..94cdf89424 100644 --- a/src/experimental/eip8130/actions/getTransaction.ts +++ b/src/eip8130/actions/getTransaction.ts @@ -1,9 +1,9 @@ import type { Address } from 'abitype' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' -import type { Hash, Hex } from '../../../types/misc.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import type { Hash, Hex } from '../../types/misc.js' import { aaTransactionType } from '../constants.js' import type { AaAccountChange, AaCalls } from '../types/transaction.js' diff --git a/src/experimental/eip8130/actions/getTransactionCount.ts b/src/eip8130/actions/getTransactionCount.ts similarity index 85% rename from src/experimental/eip8130/actions/getTransactionCount.ts rename to src/eip8130/actions/getTransactionCount.ts index c5b34402d5..bff686afa5 100644 --- a/src/experimental/eip8130/actions/getTransactionCount.ts +++ b/src/eip8130/actions/getTransactionCount.ts @@ -1,13 +1,13 @@ import type { Address } from 'abitype' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { Account } from '../../../types/account.js' -import type { BlockTag } from '../../../types/block.js' -import type { Chain } from '../../../types/chain.js' -import type { Hex } from '../../../types/misc.js' -import { hexToBigInt } from '../../../utils/encoding/fromHex.js' -import { numberToHex } from '../../../utils/encoding/toHex.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { Account } from '../../types/account.js' +import type { BlockTag } from '../../types/block.js' +import type { Chain } from '../../types/chain.js' +import type { Hex } from '../../types/misc.js' +import { hexToBigInt } from '../../utils/encoding/fromHex.js' +import { numberToHex } from '../../utils/encoding/toHex.js' import { nonceKeyMax } from '../constants.js' export type GetTransactionCountParameters = { diff --git a/src/experimental/eip8130/actions/getTransactionReceipt.ts b/src/eip8130/actions/getTransactionReceipt.ts similarity index 89% rename from src/experimental/eip8130/actions/getTransactionReceipt.ts rename to src/eip8130/actions/getTransactionReceipt.ts index 6289a27969..c2644e7ce7 100644 --- a/src/experimental/eip8130/actions/getTransactionReceipt.ts +++ b/src/eip8130/actions/getTransactionReceipt.ts @@ -1,10 +1,10 @@ import type { Address } from 'abitype' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' -import type { Hash, Hex } from '../../../types/misc.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import type { Hash, Hex } from '../../types/misc.js' /** * EIP-8130 receipt fields surfaced by `eth_getTransactionReceipt` diff --git a/src/experimental/eip8130/actions/isActor.ts b/src/eip8130/actions/isActor.ts similarity index 80% rename from src/experimental/eip8130/actions/isActor.ts rename to src/eip8130/actions/isActor.ts index 1bf591a405..95acb4c51d 100644 --- a/src/experimental/eip8130/actions/isActor.ts +++ b/src/eip8130/actions/isActor.ts @@ -1,11 +1,10 @@ import type { Address } from 'abitype' - -import { zeroAddress } from '../../../constants/address.js' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' -import type { Hex } from '../../../types/misc.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import { zeroAddress } from '../../constants/address.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import type { Hex } from '../../types/misc.js' import { getActorConfig } from './getActorConfig.js' export type IsActorParameters = { @@ -34,7 +33,7 @@ export type IsActorReturnType = boolean * * @example * ```ts - * import { isActor, key } from 'viem/experimental/eip8130' + * import { isActor, key } from 'viem/eip8130' * * const authorized = await isActor(client, { * account: account.address, diff --git a/src/experimental/eip8130/actions/isLocked.ts b/src/eip8130/actions/isLocked.ts similarity index 78% rename from src/experimental/eip8130/actions/isLocked.ts rename to src/eip8130/actions/isLocked.ts index 05670264c4..472f835757 100644 --- a/src/experimental/eip8130/actions/isLocked.ts +++ b/src/eip8130/actions/isLocked.ts @@ -1,10 +1,10 @@ import type { Address } from 'abitype' -import { readContract } from '../../../actions/public/readContract.js' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' +import { readContract } from '../../actions/public/readContract.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' import { accountConfigurationAbi } from '../abis.js' import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' @@ -27,7 +27,7 @@ export type IsLockedReturnType = boolean * * @example * ```ts - * import { isLocked } from 'viem/experimental/eip8130' + * import { isLocked } from 'viem/eip8130' * * const locked = await isLocked(client, { account: account.address }) * ``` diff --git a/src/experimental/eip8130/actions/sendCalls.ts b/src/eip8130/actions/sendCalls.ts similarity index 94% rename from src/experimental/eip8130/actions/sendCalls.ts rename to src/eip8130/actions/sendCalls.ts index a0320f06aa..93f13ed9c0 100644 --- a/src/experimental/eip8130/actions/sendCalls.ts +++ b/src/eip8130/actions/sendCalls.ts @@ -1,12 +1,12 @@ -import { estimateFeesPerGas } from '../../../actions/public/estimateFeesPerGas.js' -import { sendRawTransaction } from '../../../actions/wallet/sendRawTransaction.js' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import { BaseError } from '../../../errors/base.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' -import type { Hex } from '../../../types/misc.js' -import { getAction } from '../../../utils/getAction.js' +import { estimateFeesPerGas } from '../../actions/public/estimateFeesPerGas.js' +import { sendRawTransaction } from '../../actions/wallet/sendRawTransaction.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import { BaseError } from '../../errors/base.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import type { Hex } from '../../types/misc.js' +import { getAction } from '../../utils/getAction.js' import type { ToAccountReturnType } from '../accounts/toAccount.js' import { nonceFreeMaxExpiryWindow, nonceKeyMax } from '../constants.js' import { NonceScopeError, ScopeMismatchError } from '../errors.js' diff --git a/src/experimental/eip8130/actions/waitForTransactionReceipt.ts b/src/eip8130/actions/waitForTransactionReceipt.ts similarity index 94% rename from src/experimental/eip8130/actions/waitForTransactionReceipt.ts rename to src/eip8130/actions/waitForTransactionReceipt.ts index 17cfb2a480..8236fcb1f7 100644 --- a/src/experimental/eip8130/actions/waitForTransactionReceipt.ts +++ b/src/eip8130/actions/waitForTransactionReceipt.ts @@ -1,8 +1,8 @@ -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' -import type { Hash, Hex } from '../../../types/misc.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import type { Hash, Hex } from '../../types/misc.js' import { TransactionExpiredError } from '../errors.js' import { getTransaction } from './getTransaction.js' import { diff --git a/src/experimental/eip8130/chains.ts b/src/eip8130/chains.ts similarity index 100% rename from src/experimental/eip8130/chains.ts rename to src/eip8130/chains.ts diff --git a/src/experimental/eip8130/constants.ts b/src/eip8130/constants.ts similarity index 99% rename from src/experimental/eip8130/constants.ts rename to src/eip8130/constants.ts index b45d3eab45..ae93883099 100644 --- a/src/experimental/eip8130/constants.ts +++ b/src/eip8130/constants.ts @@ -1,4 +1,4 @@ -import type { Hex } from '../../types/misc.js' +import type { Hex } from '../types/misc.js' /** * EIP-2718 transaction type for EIP-8130 AA transactions (`AA_TX_TYPE`). diff --git a/src/experimental/eip8130/deployments.ts b/src/eip8130/deployments.ts similarity index 98% rename from src/experimental/eip8130/deployments.ts rename to src/eip8130/deployments.ts index aa1c014326..13b8511a52 100644 --- a/src/experimental/eip8130/deployments.ts +++ b/src/eip8130/deployments.ts @@ -53,7 +53,7 @@ export type Eip8130Deployment = { /** * Example actor-policy contracts (unaudited reference). A restricted actor is * gated to the `manager`; the manager forwards committed call plans built by - * the policy. See `viem/experimental/eip8130` policy helpers. + * the policy. See `viem/eip8130` policy helpers. */ policies?: { /** PolicyManager — the single target a policy-gated actor may call. */ diff --git a/src/experimental/eip8130/devx.test.ts b/src/eip8130/devx.test.ts similarity index 93% rename from src/experimental/eip8130/devx.test.ts rename to src/eip8130/devx.test.ts index 95252b3399..4217456412 100644 --- a/src/experimental/eip8130/devx.test.ts +++ b/src/eip8130/devx.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' -import { mainnet } from '../../chains/index.js' -import { createClient } from '../../clients/createClient.js' -import { custom } from '../../clients/transports/custom.js' -import type { Hex } from '../../types/misc.js' -import { keccak256 } from '../../utils/hash/keccak256.js' +import { privateKeyToAccount } from '../accounts/privateKeyToAccount.js' +import { mainnet } from '../chains/index.js' +import { createClient } from '../clients/createClient.js' +import { custom } from '../clients/transports/custom.js' +import type { Hex } from '../types/misc.js' +import { keccak256 } from '../utils/hash/keccak256.js' import { delegateAuthSize, newSmartAccount, @@ -168,8 +168,10 @@ describe('sendCalls', () => { transport: custom({ async request({ method, params }: { method: string; params: any }) { if (method === 'eth_chainId') return '0x1' - // Offline: actor is not yet bound — fall back to declared handle scope. - if (method === 'eth_call') return `0x${'0'.repeat(64)}` + // Offline: actor is not yet bound — `getActorConfig` returns an all-zero + // 3-word ActorConfig struct (authenticator 0x0), so `resolveSigningScope` + // falls back to the declared handle scope. + if (method === 'eth_call') return `0x${'0'.repeat(192)}` if (method === 'eth_sendRawTransaction') { sent = params[0] return keccak256(params[0]) @@ -241,7 +243,7 @@ describe('sendCalls', () => { transport: custom({ async request({ method, params }: { method: string; params: any }) { if (method === 'eth_chainId') return '0x1' - if (method === 'eth_call') return `0x${'0'.repeat(64)}` + if (method === 'eth_call') return `0x${'0'.repeat(192)}` if (method === 'eth_sendRawTransaction') { clientSent = params[0] return keccak256(params[0]) diff --git a/src/experimental/eip8130/errors.ts b/src/eip8130/errors.ts similarity index 98% rename from src/experimental/eip8130/errors.ts rename to src/eip8130/errors.ts index 1639829d56..337dd6965e 100644 --- a/src/experimental/eip8130/errors.ts +++ b/src/eip8130/errors.ts @@ -1,4 +1,4 @@ -import { BaseError } from '../../errors/base.js' +import { BaseError } from '../errors/base.js' export type NonceScopeErrorType = NonceScopeError & { name: 'NonceScopeError' } diff --git a/src/experimental/eip8130/index.ts b/src/eip8130/index.ts similarity index 100% rename from src/experimental/eip8130/index.ts rename to src/eip8130/index.ts diff --git a/src/experimental/eip8130/keys.ts b/src/eip8130/keys.ts similarity index 96% rename from src/experimental/eip8130/keys.ts rename to src/eip8130/keys.ts index 94ab401d31..004b50fc39 100644 --- a/src/experimental/eip8130/keys.ts +++ b/src/eip8130/keys.ts @@ -1,9 +1,9 @@ import type { Address } from 'abitype' -import { BaseError } from '../../errors/base.js' -import type { Hex } from '../../types/misc.js' -import { concatHex } from '../../utils/data/concat.js' -import { pad } from '../../utils/data/pad.js' -import { size } from '../../utils/data/size.js' +import { BaseError } from '../errors/base.js' +import type { Hex } from '../types/misc.js' +import { concatHex } from '../utils/data/concat.js' +import { pad } from '../utils/data/pad.js' +import { size } from '../utils/data/size.js' import { actorScope, canonicalAuthenticators, diff --git a/src/experimental/eip8130/lock.test.ts b/src/eip8130/lock.test.ts similarity index 89% rename from src/experimental/eip8130/lock.test.ts rename to src/eip8130/lock.test.ts index 5268aa9689..4e3b248969 100644 --- a/src/experimental/eip8130/lock.test.ts +++ b/src/eip8130/lock.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'vitest' -import { mainnet } from '../../chains/index.js' -import { createClient } from '../../clients/createClient.js' -import { custom } from '../../clients/transports/custom.js' -import { encodeAbiParameters } from '../../utils/abi/encodeAbiParameters.js' -import { encodeFunctionResult } from '../../utils/abi/encodeFunctionResult.js' +import { mainnet } from '../chains/index.js' +import { createClient } from '../clients/createClient.js' +import { custom } from '../clients/transports/custom.js' +import { encodeAbiParameters } from '../utils/abi/encodeAbiParameters.js' +import { encodeFunctionResult } from '../utils/abi/encodeFunctionResult.js' import { accountConfigurationAbi } from './abis.js' import { getLockStatus } from './actions/getLockStatus.js' import { isLocked } from './actions/isLocked.js' diff --git a/src/experimental/eip8130/lock.ts b/src/eip8130/lock.ts similarity index 97% rename from src/experimental/eip8130/lock.ts rename to src/eip8130/lock.ts index bacf27adbe..cfa7e7ac17 100644 --- a/src/experimental/eip8130/lock.ts +++ b/src/eip8130/lock.ts @@ -1,4 +1,4 @@ -import { BaseError } from '../../errors/base.js' +import { BaseError } from '../errors/base.js' import { changeType } from './constants.js' import type { AaLock, AaUnlock } from './types/transaction.js' @@ -32,7 +32,7 @@ import type { AaLock, AaUnlock } from './types/transaction.js' * encodeApplySignedAccountChangesData, * getConfigSequence, * sendCalls, - * } from 'viem/experimental/eip8130' + * } from 'viem/eip8130' * * const { local } = await getConfigSequence(client, { accountConfiguration, account }) * const entry = await signAccountChanges({ diff --git a/src/experimental/eip8130/nonce.test.ts b/src/eip8130/nonce.test.ts similarity index 89% rename from src/experimental/eip8130/nonce.test.ts rename to src/eip8130/nonce.test.ts index 22254d728b..62dca2abf2 100644 --- a/src/experimental/eip8130/nonce.test.ts +++ b/src/eip8130/nonce.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' -import { mainnet } from '../../chains/index.js' -import { createClient } from '../../clients/createClient.js' -import { custom } from '../../clients/transports/custom.js' -import type { Hex } from '../../types/misc.js' -import { keccak256 } from '../../utils/hash/keccak256.js' +import { privateKeyToAccount } from '../accounts/privateKeyToAccount.js' +import { mainnet } from '../chains/index.js' +import { createClient } from '../clients/createClient.js' +import { custom } from '../clients/transports/custom.js' +import type { Hex } from '../types/misc.js' +import { keccak256 } from '../utils/hash/keccak256.js' import { toAccount } from './accounts/toAccount.js' import { sendCalls } from './actions/sendCalls.js' import { nonceKeyMax } from './constants.js' @@ -89,9 +89,10 @@ describe('sendCalls nonce integration', () => { async request({ method, params }: { method: string; params: any }) { methods.push(method) if (method === 'eth_chainId') return '0x1' - // Actor not yet bound on-chain → resolveSigningScope falls back to the + // Actor not yet bound on-chain → `getActorConfig` returns an all-zero + // 3-word ActorConfig struct, so resolveSigningScope falls back to the // declared handle scope (offline nonce-mode selection). - if (method === 'eth_call') return `0x${'0'.repeat(64)}` + if (method === 'eth_call') return `0x${'0'.repeat(192)}` if (method === 'eth_getTransactionCount') { lastGetCountParams = params return '0x3' diff --git a/src/experimental/eip8130/nonce.ts b/src/eip8130/nonce.ts similarity index 96% rename from src/experimental/eip8130/nonce.ts rename to src/eip8130/nonce.ts index 706378ad51..476847f690 100644 --- a/src/experimental/eip8130/nonce.ts +++ b/src/eip8130/nonce.ts @@ -1,6 +1,6 @@ -import { BaseError } from '../../errors/base.js' -import { hexToBigInt } from '../../utils/encoding/fromHex.js' -import { bytesToHex } from '../../utils/encoding/toHex.js' +import { BaseError } from '../errors/base.js' +import { hexToBigInt } from '../utils/encoding/fromHex.js' +import { bytesToHex } from '../utils/encoding/toHex.js' import { nonceFreeMaxExpiryWindow, nonceKeyMax } from './constants.js' import { isNoncelessOnly } from './keys.js' @@ -40,7 +40,7 @@ export type Nonce = { * * @example * ```ts - * import { nonce, sendCalls } from 'viem/experimental/eip8130' + * import { nonce, sendCalls } from 'viem/eip8130' * * // Two independent channels → can be mined in either order. * await sendCalls(client, { account, calls: a, gas, ...nonce.channel(1n) }) diff --git a/src/eip8130/package.json b/src/eip8130/package.json new file mode 100644 index 0000000000..7befbdad41 --- /dev/null +++ b/src/eip8130/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "types": "../_types/eip8130/index.d.ts", + "module": "../_esm/eip8130/index.js", + "main": "../_cjs/eip8130/index.js" +} diff --git a/src/experimental/eip8130/policies.test.ts b/src/eip8130/policies.test.ts similarity index 98% rename from src/experimental/eip8130/policies.test.ts rename to src/eip8130/policies.test.ts index 004f5b4af1..1eb20b16fe 100644 --- a/src/experimental/eip8130/policies.test.ts +++ b/src/eip8130/policies.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' +import { decodeFunctionData } from '../utils/abi/decodeFunctionData.js' import { baseSepoliaDeployment } from './deployments.js' import { commitmentOf, diff --git a/src/experimental/eip8130/policies.ts b/src/eip8130/policies.ts similarity index 98% rename from src/experimental/eip8130/policies.ts rename to src/eip8130/policies.ts index feaf232b96..7667cdd420 100644 --- a/src/experimental/eip8130/policies.ts +++ b/src/eip8130/policies.ts @@ -1,13 +1,13 @@ import type { Address } from 'abitype' import { parseAbi } from 'abitype' -import { BaseError } from '../../errors/base.js' -import type { Hex } from '../../types/misc.js' +import { BaseError } from '../errors/base.js' +import type { Hex } from '../types/misc.js' import { type EncodeAbiParametersErrorType, encodeAbiParameters, -} from '../../utils/abi/encodeAbiParameters.js' -import { encodeFunctionData } from '../../utils/abi/encodeFunctionData.js' -import { keccak256 } from '../../utils/hash/keccak256.js' +} from '../utils/abi/encodeAbiParameters.js' +import { encodeFunctionData } from '../utils/abi/encodeFunctionData.js' +import { keccak256 } from '../utils/hash/keccak256.js' import { baseSepoliaDeployment } from './deployments.js' import type { Policy } from './keys.js' import type { AaCall } from './types/transaction.js' @@ -215,7 +215,7 @@ export type DefineSessionPolicyErrorType = CommitmentOfErrorType * authorizeActor, * actorScope, * key, - * } from 'viem/experimental/eip8130' + * } from 'viem/eip8130' * * const session = defineSessionPolicy({ * account: account.address, diff --git a/src/experimental/eip8130/queries.test.ts b/src/eip8130/queries.test.ts similarity index 70% rename from src/experimental/eip8130/queries.test.ts rename to src/eip8130/queries.test.ts index 9597f00c65..324affb4af 100644 --- a/src/experimental/eip8130/queries.test.ts +++ b/src/eip8130/queries.test.ts @@ -1,10 +1,10 @@ import type { Abi } from 'abitype' import { describe, expect, test } from 'vitest' -import { mainnet } from '../../chains/index.js' -import { createClient } from '../../clients/createClient.js' -import { custom } from '../../clients/transports/custom.js' -import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' -import { encodeFunctionResult } from '../../utils/abi/encodeFunctionResult.js' +import { mainnet } from '../chains/index.js' +import { createClient } from '../clients/createClient.js' +import { custom } from '../clients/transports/custom.js' +import { decodeFunctionData } from '../utils/abi/decodeFunctionData.js' +import { encodeFunctionResult } from '../utils/abi/encodeFunctionResult.js' import { accountConfigurationAbi } from './abis.js' import { getActorConfig } from './actions/getActorConfig.js' import { getPolicy } from './actions/getPolicy.js' @@ -97,17 +97,46 @@ describe('getActorConfig', () => { }) describe('isActor', () => { - test('decodes the isActor bool', async () => { - const client = readClient(accountConfigurationAbi, { isActor: true }) + // The finalized Keystore has no `isActor` view; liveness is derived from + // `getActorConfig` (a non-zero authenticator ⇒ bound). + test('derives liveness from a non-zero authenticator', async () => { + const client = readClient(accountConfigurationAbi, { + getActorConfig: { + authenticator: canonicalAuthenticators.p256, + scope: 2, + expiry: 0, + }, + }) expect(await isActor(client, { account, actorId })).toBe(true) }) + + test('returns false for an all-zero (unbound) config', async () => { + const client = readClient(accountConfigurationAbi, { + getActorConfig: { + authenticator: '0x0000000000000000000000000000000000000000', + scope: 0, + expiry: 0, + }, + }) + expect(await isActor(client, { account, actorId })).toBe(false) + }) }) describe('getPolicy', () => { - test('decodes (target, commitment)', async () => { + // The finalized Keystore exposes a single combined `getActor` read + // returning (config, policyManager, policyCommitment). + test('decodes (manager, commitment) from the combined getActor read', async () => { const manager = '0x00000000000000000000000000000000000000dd' const client = readClient(accountConfigurationAbi, { - getPolicy: [manager, commitment], + getActor: [ + { + authenticator: canonicalAuthenticators.p256, + scope: 2, + expiry: 0, + }, + manager, + commitment, + ], }) expect(await getPolicy(client, { account, actorId })).toEqual({ target: manager, diff --git a/src/experimental/eip8130/types/transaction.ts b/src/eip8130/types/transaction.ts similarity index 99% rename from src/experimental/eip8130/types/transaction.ts rename to src/eip8130/types/transaction.ts index 6d027e4a3d..b9649df302 100644 --- a/src/experimental/eip8130/types/transaction.ts +++ b/src/eip8130/types/transaction.ts @@ -1,5 +1,5 @@ import type { Address } from 'abitype' -import type { Hex } from '../../../types/misc.js' +import type { Hex } from '../../types/misc.js' /** * A single call within a phase. diff --git a/src/experimental/eip8130/utils/accountConfigCalls.test.ts b/src/eip8130/utils/accountConfigCalls.test.ts similarity index 96% rename from src/experimental/eip8130/utils/accountConfigCalls.test.ts rename to src/eip8130/utils/accountConfigCalls.test.ts index 28d3c29391..9f70790164 100644 --- a/src/experimental/eip8130/utils/accountConfigCalls.test.ts +++ b/src/eip8130/utils/accountConfigCalls.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest' -import type { Hex } from '../../../types/misc.js' -import { decodeFunctionData } from '../../../utils/abi/decodeFunctionData.js' +import type { Hex } from '../../types/misc.js' +import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' import { accountConfigurationAbi } from '../abis.js' import { eip8130ChainIds, diff --git a/src/experimental/eip8130/utils/accountConfigCalls.ts b/src/eip8130/utils/accountConfigCalls.ts similarity index 96% rename from src/experimental/eip8130/utils/accountConfigCalls.ts rename to src/eip8130/utils/accountConfigCalls.ts index 95b6d86188..a6fb17012c 100644 --- a/src/experimental/eip8130/utils/accountConfigCalls.ts +++ b/src/eip8130/utils/accountConfigCalls.ts @@ -1,10 +1,10 @@ import type { Address } from 'abitype' -import type { ErrorType } from '../../../errors/utils.js' -import type { Hex } from '../../../types/misc.js' +import type { ErrorType } from '../../errors/utils.js' +import type { Hex } from '../../types/misc.js' import { type EncodeFunctionDataErrorType, encodeFunctionData, -} from '../../../utils/abi/encodeFunctionData.js' +} from '../../utils/abi/encodeFunctionData.js' import { accountConfigurationAbi } from '../abis.js' import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' import type { diff --git a/src/experimental/eip8130/utils/actorChangeData.ts b/src/eip8130/utils/actorChangeData.ts similarity index 94% rename from src/experimental/eip8130/utils/actorChangeData.ts rename to src/eip8130/utils/actorChangeData.ts index abe7bef4ea..1479e49b05 100644 --- a/src/experimental/eip8130/utils/actorChangeData.ts +++ b/src/eip8130/utils/actorChangeData.ts @@ -1,14 +1,14 @@ import type { Address } from 'abitype' -import type { ErrorType } from '../../../errors/utils.js' -import type { Hex } from '../../../types/misc.js' +import type { ErrorType } from '../../errors/utils.js' +import type { Hex } from '../../types/misc.js' import { type DecodeAbiParametersErrorType, decodeAbiParameters, -} from '../../../utils/abi/decodeAbiParameters.js' +} from '../../utils/abi/decodeAbiParameters.js' import { type EncodeAbiParametersErrorType, encodeAbiParameters, -} from '../../../utils/abi/encodeAbiParameters.js' +} from '../../utils/abi/encodeAbiParameters.js' import { changeType } from '../constants.js' import type { AaChange } from '../types/transaction.js' diff --git a/src/experimental/eip8130/utils/actorId.ts b/src/eip8130/utils/actorId.ts similarity index 78% rename from src/experimental/eip8130/utils/actorId.ts rename to src/eip8130/utils/actorId.ts index f68f8c1e11..fe96b076e8 100644 --- a/src/experimental/eip8130/utils/actorId.ts +++ b/src/eip8130/utils/actorId.ts @@ -1,11 +1,11 @@ import type { Address } from 'abitype' -import { BaseError } from '../../../errors/base.js' -import type { ErrorType } from '../../../errors/utils.js' -import type { Hex } from '../../../types/misc.js' -import { concatHex } from '../../../utils/data/concat.js' -import { type PadErrorType, pad } from '../../../utils/data/pad.js' -import { size } from '../../../utils/data/size.js' -import { keccak256 } from '../../../utils/hash/keccak256.js' +import { BaseError } from '../../errors/base.js' +import type { ErrorType } from '../../errors/utils.js' +import type { Hex } from '../../types/misc.js' +import { concatHex } from '../../utils/data/concat.js' +import { type PadErrorType, pad } from '../../utils/data/pad.js' +import { size } from '../../utils/data/size.js' +import { keccak256 } from '../../utils/hash/keccak256.js' export type ActorIdFromAddressErrorType = PadErrorType | ErrorType diff --git a/src/experimental/eip8130/utils/assertTransaction.ts b/src/eip8130/utils/assertTransaction.ts similarity index 91% rename from src/experimental/eip8130/utils/assertTransaction.ts rename to src/eip8130/utils/assertTransaction.ts index 31bbd254f5..8cdafc8d6b 100644 --- a/src/experimental/eip8130/utils/assertTransaction.ts +++ b/src/eip8130/utils/assertTransaction.ts @@ -1,6 +1,6 @@ -import { BaseError } from '../../../errors/base.js' -import { InvalidChainIdError } from '../../../errors/chain.js' -import type { ErrorType } from '../../../errors/utils.js' +import { BaseError } from '../../errors/base.js' +import { InvalidChainIdError } from '../../errors/chain.js' +import type { ErrorType } from '../../errors/utils.js' import { nonceKeyMax } from '../constants.js' import type { TransactionSerializable8130 } from '../types/transaction.js' diff --git a/src/experimental/eip8130/utils/computeAddress.test.ts b/src/eip8130/utils/computeAddress.test.ts similarity index 92% rename from src/experimental/eip8130/utils/computeAddress.test.ts rename to src/eip8130/utils/computeAddress.test.ts index e64d96327b..e5485a418b 100644 --- a/src/experimental/eip8130/utils/computeAddress.test.ts +++ b/src/eip8130/utils/computeAddress.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'vitest' -import { getCreate2Address } from '../../../utils/address/getContractAddress.js' -import { isAddress } from '../../../utils/address/isAddress.js' -import { concatHex } from '../../../utils/data/concat.js' -import { toHex } from '../../../utils/encoding/toHex.js' -import { keccak256 } from '../../../utils/hash/keccak256.js' +import { getCreate2Address } from '../../utils/address/getContractAddress.js' +import { isAddress } from '../../utils/address/isAddress.js' +import { concatHex } from '../../utils/data/concat.js' +import { toHex } from '../../utils/encoding/toHex.js' +import { keccak256 } from '../../utils/hash/keccak256.js' import { accountConfigAddress } from '../constants.js' import type { AaActor } from '../types/transaction.js' import { computeAddress, deploymentHeader } from './computeAddress.js' diff --git a/src/experimental/eip8130/utils/computeAddress.ts b/src/eip8130/utils/computeAddress.ts similarity index 87% rename from src/experimental/eip8130/utils/computeAddress.ts rename to src/eip8130/utils/computeAddress.ts index 1f2979c5be..f9e7ab2ae6 100644 --- a/src/experimental/eip8130/utils/computeAddress.ts +++ b/src/eip8130/utils/computeAddress.ts @@ -1,22 +1,19 @@ import type { Address } from 'abitype' -import { BaseError } from '../../../errors/base.js' -import type { ErrorType } from '../../../errors/utils.js' -import type { Hex } from '../../../types/misc.js' +import { BaseError } from '../../errors/base.js' +import type { ErrorType } from '../../errors/utils.js' +import type { Hex } from '../../types/misc.js' import { type GetCreate2AddressErrorType, getCreate2Address, -} from '../../../utils/address/getContractAddress.js' -import { - type ConcatHexErrorType, - concatHex, -} from '../../../utils/data/concat.js' -import { size } from '../../../utils/data/size.js' -import { hexToBigInt } from '../../../utils/encoding/fromHex.js' -import { bytesToHex, toHex } from '../../../utils/encoding/toHex.js' +} from '../../utils/address/getContractAddress.js' +import { type ConcatHexErrorType, concatHex } from '../../utils/data/concat.js' +import { size } from '../../utils/data/size.js' +import { hexToBigInt } from '../../utils/encoding/fromHex.js' +import { bytesToHex, toHex } from '../../utils/encoding/toHex.js' import { type Keccak256ErrorType, keccak256, -} from '../../../utils/hash/keccak256.js' +} from '../../utils/hash/keccak256.js' import { accountConfigAddress as defaultAccountConfigAddress, maxCodeSize, diff --git a/src/experimental/eip8130/utils/encodeWalletCalls.test.ts b/src/eip8130/utils/encodeWalletCalls.test.ts similarity index 97% rename from src/experimental/eip8130/utils/encodeWalletCalls.test.ts rename to src/eip8130/utils/encodeWalletCalls.test.ts index 1bdafe1034..001c9f1286 100644 --- a/src/experimental/eip8130/utils/encodeWalletCalls.test.ts +++ b/src/eip8130/utils/encodeWalletCalls.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { decodeFunctionData } from '../../../utils/abi/decodeFunctionData.js' +import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' import { erc4337AccountAbi } from '../abis.js' import type { AaCalls } from '../types/transaction.js' import { encodeWalletCalls } from './encodeWalletCalls.js' diff --git a/src/experimental/eip8130/utils/encodeWalletCalls.ts b/src/eip8130/utils/encodeWalletCalls.ts similarity index 95% rename from src/experimental/eip8130/utils/encodeWalletCalls.ts rename to src/eip8130/utils/encodeWalletCalls.ts index 1ef94d4c75..41b894afa5 100644 --- a/src/experimental/eip8130/utils/encodeWalletCalls.ts +++ b/src/eip8130/utils/encodeWalletCalls.ts @@ -1,6 +1,6 @@ import type { Address } from 'abitype' -import type { Hex } from '../../../types/misc.js' -import { encodeFunctionData } from '../../../utils/abi/encodeFunctionData.js' +import type { Hex } from '../../types/misc.js' +import { encodeFunctionData } from '../../utils/abi/encodeFunctionData.js' import { erc4337AccountAbi } from '../abis.js' import type { AaCall, AaCalls } from '../types/transaction.js' diff --git a/src/experimental/eip8130/utils/hashActorChanges.ts b/src/eip8130/utils/hashActorChanges.ts similarity index 88% rename from src/experimental/eip8130/utils/hashActorChanges.ts rename to src/eip8130/utils/hashActorChanges.ts index 6ec552a077..fdd01a603c 100644 --- a/src/experimental/eip8130/utils/hashActorChanges.ts +++ b/src/eip8130/utils/hashActorChanges.ts @@ -1,22 +1,16 @@ import type { Address } from 'abitype' -import type { ErrorType } from '../../../errors/utils.js' -import type { Hex } from '../../../types/misc.js' +import type { ErrorType } from '../../errors/utils.js' +import type { Hex } from '../../types/misc.js' import { type EncodeAbiParametersErrorType, encodeAbiParameters, -} from '../../../utils/abi/encodeAbiParameters.js' -import { - type ConcatHexErrorType, - concatHex, -} from '../../../utils/data/concat.js' -import { - stringToHex, - type ToHexErrorType, -} from '../../../utils/encoding/toHex.js' +} from '../../utils/abi/encodeAbiParameters.js' +import { type ConcatHexErrorType, concatHex } from '../../utils/data/concat.js' +import { stringToHex, type ToHexErrorType } from '../../utils/encoding/toHex.js' import { type Keccak256ErrorType, keccak256, -} from '../../../utils/hash/keccak256.js' +} from '../../utils/hash/keccak256.js' import type { AaChange } from '../types/transaction.js' import { encodeChangePayload } from './actorChangeData.js' diff --git a/src/experimental/eip8130/utils/hashTransaction.ts b/src/eip8130/utils/hashTransaction.ts similarity index 89% rename from src/experimental/eip8130/utils/hashTransaction.ts rename to src/eip8130/utils/hashTransaction.ts index 579e251dfa..7b2e125343 100644 --- a/src/experimental/eip8130/utils/hashTransaction.ts +++ b/src/eip8130/utils/hashTransaction.ts @@ -1,18 +1,15 @@ -import type { ErrorType } from '../../../errors/utils.js' -import type { ByteArray, Hex } from '../../../types/misc.js' -import { - type ConcatHexErrorType, - concatHex, -} from '../../../utils/data/concat.js' +import type { ErrorType } from '../../errors/utils.js' +import type { ByteArray, Hex } from '../../types/misc.js' +import { type ConcatHexErrorType, concatHex } from '../../utils/data/concat.js' import { type HexToBytesErrorType, hexToBytes, -} from '../../../utils/encoding/toBytes.js' -import { toRlp } from '../../../utils/encoding/toRlp.js' +} from '../../utils/encoding/toBytes.js' +import { toRlp } from '../../utils/encoding/toRlp.js' import { type Keccak256ErrorType, keccak256, -} from '../../../utils/hash/keccak256.js' +} from '../../utils/hash/keccak256.js' import { aaPayerType, aaTransactionType } from '../constants.js' import type { TransactionSerializable8130 } from '../types/transaction.js' import { toTransactionBody } from './serializeTransaction.js' diff --git a/src/experimental/eip8130/utils/parseTransaction.ts b/src/eip8130/utils/parseTransaction.ts similarity index 92% rename from src/experimental/eip8130/utils/parseTransaction.ts rename to src/eip8130/utils/parseTransaction.ts index 73d12ba813..98f544b853 100644 --- a/src/experimental/eip8130/utils/parseTransaction.ts +++ b/src/eip8130/utils/parseTransaction.ts @@ -1,20 +1,17 @@ import type { Address } from 'abitype' -import { BaseError } from '../../../errors/base.js' -import type { ErrorType } from '../../../errors/utils.js' -import type { Hex } from '../../../types/misc.js' -import { decodeAbiParameters } from '../../../utils/abi/decodeAbiParameters.js' -import { type SliceErrorType, sliceHex } from '../../../utils/data/slice.js' +import { BaseError } from '../../errors/base.js' +import type { ErrorType } from '../../errors/utils.js' +import type { Hex } from '../../types/misc.js' +import { decodeAbiParameters } from '../../utils/abi/decodeAbiParameters.js' +import { type SliceErrorType, sliceHex } from '../../utils/data/slice.js' import { type HexToBigIntErrorType, type HexToNumberErrorType, hexToBigInt, hexToNumber, -} from '../../../utils/encoding/fromHex.js' -import { - type FromRlpErrorType, - fromRlp, -} from '../../../utils/encoding/fromRlp.js' -import type { RecursiveArray } from '../../../utils/encoding/toRlp.js' +} from '../../utils/encoding/fromHex.js' +import { type FromRlpErrorType, fromRlp } from '../../utils/encoding/fromRlp.js' +import type { RecursiveArray } from '../../utils/encoding/toRlp.js' import { aaTransactionType, accountChangeType, diff --git a/src/experimental/eip8130/utils/proxy.ts b/src/eip8130/utils/proxy.ts similarity index 94% rename from src/experimental/eip8130/utils/proxy.ts rename to src/eip8130/utils/proxy.ts index 828af4c0b2..6b52476b1c 100644 --- a/src/experimental/eip8130/utils/proxy.ts +++ b/src/eip8130/utils/proxy.ts @@ -1,6 +1,6 @@ import type { Address } from 'abitype' -import type { Hex } from '../../../types/misc.js' -import { concatHex } from '../../../utils/data/concat.js' +import type { Hex } from '../../types/misc.js' +import { concatHex } from '../../utils/data/concat.js' /** * Builds the 45-byte ERC-1167 minimal proxy runtime bytecode that delegates to diff --git a/src/experimental/eip8130/utils/recoverSender.ts b/src/eip8130/utils/recoverSender.ts similarity index 93% rename from src/experimental/eip8130/utils/recoverSender.ts rename to src/eip8130/utils/recoverSender.ts index fd54479ac8..304e0d25c5 100644 --- a/src/experimental/eip8130/utils/recoverSender.ts +++ b/src/eip8130/utils/recoverSender.ts @@ -1,6 +1,6 @@ import type { Address } from 'abitype' -import type { ErrorType } from '../../../errors/utils.js' -import { recoverAddress } from '../../../utils/signature/recoverAddress.js' +import type { ErrorType } from '../../errors/utils.js' +import { recoverAddress } from '../../utils/signature/recoverAddress.js' import type { TransactionSerializable8130 } from '../types/transaction.js' import { getSenderSignatureHash } from './hashTransaction.js' diff --git a/src/experimental/eip8130/utils/serializeTransaction.test.ts b/src/eip8130/utils/serializeTransaction.test.ts similarity index 99% rename from src/experimental/eip8130/utils/serializeTransaction.test.ts rename to src/eip8130/utils/serializeTransaction.test.ts index 17cdddf306..c4c3d902c1 100644 --- a/src/experimental/eip8130/utils/serializeTransaction.test.ts +++ b/src/eip8130/utils/serializeTransaction.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { keccak256 } from '../../../utils/hash/keccak256.js' +import { keccak256 } from '../../utils/hash/keccak256.js' import { aaPayerType, aaTransactionType, nonceKeyMax } from '../constants.js' import type { TransactionSerializable8130 } from '../types/transaction.js' import { diff --git a/src/experimental/eip8130/utils/serializeTransaction.ts b/src/eip8130/utils/serializeTransaction.ts similarity index 94% rename from src/experimental/eip8130/utils/serializeTransaction.ts rename to src/eip8130/utils/serializeTransaction.ts index 513f2f317a..5fe3e698d2 100644 --- a/src/experimental/eip8130/utils/serializeTransaction.ts +++ b/src/eip8130/utils/serializeTransaction.ts @@ -1,18 +1,15 @@ -import type { ErrorType } from '../../../errors/utils.js' -import type { Hex } from '../../../types/misc.js' -import { - type ConcatHexErrorType, - concatHex, -} from '../../../utils/data/concat.js' +import type { ErrorType } from '../../errors/utils.js' +import type { Hex } from '../../types/misc.js' +import { type ConcatHexErrorType, concatHex } from '../../utils/data/concat.js' import { type NumberToHexErrorType, numberToHex, -} from '../../../utils/encoding/toHex.js' +} from '../../utils/encoding/toHex.js' import { type RecursiveArray, type ToRlpErrorType, toRlp, -} from '../../../utils/encoding/toRlp.js' +} from '../../utils/encoding/toRlp.js' import { aaTransactionType, accountChangeType } from '../constants.js' import type { AaAccountChange, diff --git a/src/experimental/eip8130/utils/signActorChanges.test.ts b/src/eip8130/utils/signActorChanges.test.ts similarity index 86% rename from src/experimental/eip8130/utils/signActorChanges.test.ts rename to src/eip8130/utils/signActorChanges.test.ts index f3b1b8bb8c..23f00ac491 100644 --- a/src/experimental/eip8130/utils/signActorChanges.test.ts +++ b/src/eip8130/utils/signActorChanges.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'vitest' import { accounts } from '~test/constants.js' -import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' -import { sliceHex } from '../../../utils/data/slice.js' -import { recoverAddress } from '../../../utils/signature/recoverAddress.js' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { sliceHex } from '../../utils/data/slice.js' +import { recoverAddress } from '../../utils/signature/recoverAddress.js' import { ecrecoverAuthenticator } from '../constants.js' import type { AaChange } from '../types/transaction.js' import { actorIdFromAddress } from './actorId.js' @@ -25,12 +25,14 @@ const revoke: AaChange = { } describe('actorIdFromAddress', () => { - test('left-aligned bytes32(bytes20(address))', () => { + // The finalized Keystore derives address-based actor IDs RIGHT-aligned: + // `bytes32(uint256(uint160(address)))`. + test('right-aligned bytes32(uint256(uint160(address)))', () => { expect( actorIdFromAddress('0x0000000000000000000000000000000000000001'), - ).toBe('0x0000000000000000000000000000000000000001000000000000000000000000') + ).toBe('0x0000000000000000000000000000000000000000000000000000000000000001') expect(actorIdFromAddress(account).toLowerCase()).toBe( - `0x${account.slice(2).toLowerCase()}000000000000000000000000`, + `0x000000000000000000000000${account.slice(2).toLowerCase()}`, ) }) }) diff --git a/src/experimental/eip8130/utils/signActorChanges.ts b/src/eip8130/utils/signActorChanges.ts similarity index 92% rename from src/experimental/eip8130/utils/signActorChanges.ts rename to src/eip8130/utils/signActorChanges.ts index 2774abc18e..0dd2c5f8e7 100644 --- a/src/experimental/eip8130/utils/signActorChanges.ts +++ b/src/eip8130/utils/signActorChanges.ts @@ -1,10 +1,7 @@ import type { Address } from 'abitype' -import { BaseError } from '../../../errors/base.js' -import type { ErrorType } from '../../../errors/utils.js' -import { - type ConcatHexErrorType, - concatHex, -} from '../../../utils/data/concat.js' +import { BaseError } from '../../errors/base.js' +import type { ErrorType } from '../../errors/utils.js' +import { type ConcatHexErrorType, concatHex } from '../../utils/data/concat.js' import { ecrecoverAuthenticator } from '../constants.js' import type { AaAccountChangeConfig, diff --git a/src/experimental/eip8130/utils/signTransaction.test.ts b/src/eip8130/utils/signTransaction.test.ts similarity index 96% rename from src/experimental/eip8130/utils/signTransaction.test.ts rename to src/eip8130/utils/signTransaction.test.ts index 10bf87f11d..91a52beff8 100644 --- a/src/experimental/eip8130/utils/signTransaction.test.ts +++ b/src/eip8130/utils/signTransaction.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'vitest' import { accounts } from '~test/constants.js' -import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' -import type { Hex } from '../../../types/misc.js' -import { sliceHex } from '../../../utils/data/slice.js' -import { recoverAddress } from '../../../utils/signature/recoverAddress.js' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import type { Hex } from '../../types/misc.js' +import { sliceHex } from '../../utils/data/slice.js' +import { recoverAddress } from '../../utils/signature/recoverAddress.js' import { canonicalAuthenticators, ecrecoverAuthenticator, diff --git a/src/experimental/eip8130/utils/signTransaction.ts b/src/eip8130/utils/signTransaction.ts similarity index 95% rename from src/experimental/eip8130/utils/signTransaction.ts rename to src/eip8130/utils/signTransaction.ts index 64ee808685..1cbdb0ba0f 100644 --- a/src/experimental/eip8130/utils/signTransaction.ts +++ b/src/eip8130/utils/signTransaction.ts @@ -1,11 +1,8 @@ import type { Address } from 'abitype' -import type { LocalAccount } from '../../../accounts/types.js' -import { BaseError } from '../../../errors/base.js' -import type { ErrorType } from '../../../errors/utils.js' -import { - type ConcatHexErrorType, - concatHex, -} from '../../../utils/data/concat.js' +import type { LocalAccount } from '../../accounts/types.js' +import { BaseError } from '../../errors/base.js' +import type { ErrorType } from '../../errors/utils.js' +import { type ConcatHexErrorType, concatHex } from '../../utils/data/concat.js' import { ecrecoverAuthenticator } from '../constants.js' import type { TransactionSerializable8130, diff --git a/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts b/src/eip8130/utils/signedActorChangesSignature.test.ts similarity index 93% rename from src/experimental/eip8130/utils/signedActorChangesSignature.test.ts rename to src/eip8130/utils/signedActorChangesSignature.test.ts index 718609f839..68c60285d4 100644 --- a/src/experimental/eip8130/utils/signedActorChangesSignature.test.ts +++ b/src/eip8130/utils/signedActorChangesSignature.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'vitest' -import { decodeAbiParameters } from '../../../utils/abi/decodeAbiParameters.js' -import { slice } from '../../../utils/data/slice.js' -import { stringToHex } from '../../../utils/encoding/toHex.js' -import { keccak256 } from '../../../utils/hash/keccak256.js' +import { decodeAbiParameters } from '../../utils/abi/decodeAbiParameters.js' +import { slice } from '../../utils/data/slice.js' +import { stringToHex } from '../../utils/encoding/toHex.js' +import { keccak256 } from '../../utils/hash/keccak256.js' import { actorScope, canonicalAuthenticators } from '../constants.js' import { authorizeActor, key, revokeActor } from '../keys.js' import { encodeChangePayload } from './actorChangeData.js' diff --git a/src/experimental/eip8130/utils/signedActorChangesSignature.ts b/src/eip8130/utils/signedActorChangesSignature.ts similarity index 91% rename from src/experimental/eip8130/utils/signedActorChangesSignature.ts rename to src/eip8130/utils/signedActorChangesSignature.ts index eab607937d..6886e6156c 100644 --- a/src/experimental/eip8130/utils/signedActorChangesSignature.ts +++ b/src/eip8130/utils/signedActorChangesSignature.ts @@ -1,17 +1,14 @@ -import type { ErrorType } from '../../../errors/utils.js' -import type { Hex } from '../../../types/misc.js' +import type { ErrorType } from '../../errors/utils.js' +import type { Hex } from '../../types/misc.js' import { type EncodeAbiParametersErrorType, encodeAbiParameters, -} from '../../../utils/abi/encodeAbiParameters.js' -import { - stringToHex, - type ToHexErrorType, -} from '../../../utils/encoding/toHex.js' +} from '../../utils/abi/encodeAbiParameters.js' +import { stringToHex, type ToHexErrorType } from '../../utils/encoding/toHex.js' import { type Keccak256ErrorType, keccak256, -} from '../../../utils/hash/keccak256.js' +} from '../../utils/hash/keccak256.js' import type { AaChange } from '../types/transaction.js' import { encodeChangePayload } from './actorChangeData.js' diff --git a/src/experimental/eip8130/utils/signers.test.ts b/src/eip8130/utils/signers.test.ts similarity index 94% rename from src/experimental/eip8130/utils/signers.test.ts rename to src/eip8130/utils/signers.test.ts index a8e9b8b0d8..b216cbf974 100644 --- a/src/experimental/eip8130/utils/signers.test.ts +++ b/src/eip8130/utils/signers.test.ts @@ -1,9 +1,9 @@ import * as P256 from 'ox/P256' import { describe, expect, test } from 'vitest' -import { decodeAbiParameters } from '../../../utils/abi/decodeAbiParameters.js' -import { size } from '../../../utils/data/size.js' -import { sliceHex } from '../../../utils/data/slice.js' -import { hexToBigInt } from '../../../utils/encoding/fromHex.js' +import { decodeAbiParameters } from '../../utils/abi/decodeAbiParameters.js' +import { size } from '../../utils/data/size.js' +import { sliceHex } from '../../utils/data/slice.js' +import { hexToBigInt } from '../../utils/encoding/fromHex.js' import { canonicalAuthenticators } from '../constants.js' import type { TransactionSerializable8130 } from '../types/transaction.js' import { parseTransaction } from './parseTransaction.js' diff --git a/src/experimental/eip8130/utils/signers.ts b/src/eip8130/utils/signers.ts similarity index 93% rename from src/experimental/eip8130/utils/signers.ts rename to src/eip8130/utils/signers.ts index 452a841ea0..03d8359f3e 100644 --- a/src/experimental/eip8130/utils/signers.ts +++ b/src/eip8130/utils/signers.ts @@ -1,12 +1,12 @@ import type { Address } from 'abitype' import * as P256 from 'ox/P256' -import { zeroAddress } from '../../../constants/address.js' -import type { Hex } from '../../../types/misc.js' -import { encodeAbiParameters } from '../../../utils/abi/encodeAbiParameters.js' -import { concatHex } from '../../../utils/data/concat.js' -import { size } from '../../../utils/data/size.js' -import { sliceHex } from '../../../utils/data/slice.js' -import { numberToHex } from '../../../utils/encoding/toHex.js' +import { zeroAddress } from '../../constants/address.js' +import type { Hex } from '../../types/misc.js' +import { encodeAbiParameters } from '../../utils/abi/encodeAbiParameters.js' +import { concatHex } from '../../utils/data/concat.js' +import { size } from '../../utils/data/size.js' +import { sliceHex } from '../../utils/data/slice.js' +import { numberToHex } from '../../utils/encoding/toHex.js' import { canonicalAuthenticators } from '../constants.js' import type { Signer } from './signTransaction.js' diff --git a/src/experimental/eip8168/actions/sendSponsoredCalls.ts b/src/eip8168/actions/sendSponsoredCalls.ts similarity index 96% rename from src/experimental/eip8168/actions/sendSponsoredCalls.ts rename to src/eip8168/actions/sendSponsoredCalls.ts index e2aae09a3a..6c30e15f3f 100644 --- a/src/experimental/eip8168/actions/sendSponsoredCalls.ts +++ b/src/eip8168/actions/sendSponsoredCalls.ts @@ -1,21 +1,21 @@ -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import { BaseError } from '../../../errors/base.js' -import type { Account } from '../../../types/account.js' -import type { Chain } from '../../../types/chain.js' -import { hexToBigInt } from '../../../utils/encoding/fromHex.js' -import { numberToHex } from '../../../utils/encoding/toHex.js' +import type { Address } from 'abitype' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' import type { ToAccountReturnType } from '../../eip8130/accounts/toAccount.js' import { prepareTransaction } from '../../eip8130/actions/sendCalls.js' import { nonceFreeMaxExpiryWindow } from '../../eip8130/constants.js' import { isNoncelessOnly } from '../../eip8130/keys.js' -import type { Address } from 'abitype' import type { AaAccountChange, AaCall, AaCalls, TransactionSerializable8130, } from '../../eip8130/types/transaction.js' +import { BaseError } from '../../errors/base.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import { hexToBigInt } from '../../utils/encoding/fromHex.js' +import { numberToHex } from '../../utils/encoding/toHex.js' import type { PayerClient } from '../client.js' import type { GetTermsReturnType, diff --git a/src/experimental/eip8168/client.ts b/src/eip8168/client.ts similarity index 95% rename from src/experimental/eip8168/client.ts rename to src/eip8168/client.ts index 062ba15860..379881787f 100644 --- a/src/experimental/eip8168/client.ts +++ b/src/eip8168/client.ts @@ -1,6 +1,6 @@ -import { createClient } from '../../clients/createClient.js' -import type { Transport } from '../../clients/transports/createTransport.js' -import { http } from '../../clients/transports/http.js' +import { createClient } from '../clients/createClient.js' +import type { Transport } from '../clients/transports/createTransport.js' +import { http } from '../clients/transports/http.js' import type { GetSponsorshipBalanceParameters, GetSponsorshipBalanceReturnType, diff --git a/src/experimental/eip8168/constants.ts b/src/eip8168/constants.ts similarity index 96% rename from src/experimental/eip8168/constants.ts rename to src/eip8168/constants.ts index ea59231d2c..949423a948 100644 --- a/src/experimental/eip8168/constants.ts +++ b/src/eip8168/constants.ts @@ -49,7 +49,6 @@ export const payerErrorCode = { export type PayerErrorCode = | (typeof payerErrorCode)[keyof typeof payerErrorCode] - // biome-ignore lint/suspicious/noExplicitAny: keep the well-known union while staying open to custom strings | (string & {}) /** diff --git a/src/experimental/eip8168/eip8168.test.ts b/src/eip8168/eip8168.test.ts similarity index 88% rename from src/experimental/eip8168/eip8168.test.ts rename to src/eip8168/eip8168.test.ts index eb033f323f..66152e90a4 100644 --- a/src/experimental/eip8168/eip8168.test.ts +++ b/src/eip8168/eip8168.test.ts @@ -1,16 +1,16 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' -import { mainnet } from '../../chains/index.js' -import { createClient } from '../../clients/createClient.js' -import { custom } from '../../clients/transports/custom.js' -import { erc20Abi } from '../../constants/abis.js' -import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' -import { hexToBigInt } from '../../utils/encoding/fromHex.js' -import { keccak256 } from '../../utils/hash/keccak256.js' +import { privateKeyToAccount } from '../accounts/privateKeyToAccount.js' +import { mainnet } from '../chains/index.js' +import { createClient } from '../clients/createClient.js' +import { custom } from '../clients/transports/custom.js' +import { erc20Abi } from '../constants/abis.js' import { toAccount } from '../eip8130/accounts/toAccount.js' import { key } from '../eip8130/keys.js' import { parseTransaction } from '../eip8130/utils/parseTransaction.js' import { erc1167Bytecode } from '../eip8130/utils/proxy.js' +import { decodeFunctionData } from '../utils/abi/decodeFunctionData.js' +import { hexToBigInt } from '../utils/encoding/fromHex.js' +import { keccak256 } from '../utils/hash/keccak256.js' import { sendSponsoredCalls } from './actions/sendSponsoredCalls.js' import { createPayerClient } from './client.js' import type { GetTermsReturnType } from './types.js' @@ -80,9 +80,8 @@ const tokenTerms: GetTermsReturnType = { ], } -// Freeze time so expiry assertions are deterministic. +// Freeze time so validBefore assertions are deterministic. const FROZEN_NOW_MS = 1_700_000_000_000 // arbitrary fixed epoch -const FROZEN_NOW_S = Math.floor(FROZEN_NOW_MS / 1000) beforeEach(() => { vi.useFakeTimers() @@ -158,7 +157,9 @@ describe('buildSponsoredCalls', () => { options: [ { ...tokenTerms.options[0], - tokens: [{ ...tokenTerms.options[0].tokens[0], feeRecipient: undefined }], + tokens: [ + { ...tokenTerms.options[0].tokens[0], feeRecipient: undefined }, + ], } as (typeof tokenTerms.options)[number], ], }, @@ -214,6 +215,11 @@ describe('sendSponsoredCalls (end-to-end)', () => { transport: custom({ async request({ method }: { method: string }) { if (method === 'eth_chainId') return '0x1' + // Actor not yet bound → `getActorConfig` returns an all-zero 3-word + // ActorConfig struct, so `resolveSigningScope` falls back to the + // declared handle scope (offline). + if (method === 'eth_call') return `0x${'0'.repeat(192)}` + if (method === 'eth_getTransactionCount') return '0x0' throw new Error(`unexpected chain RPC: ${method}`) }, }), @@ -250,8 +256,10 @@ describe('sendSponsoredCalls (end-to-end)', () => { expect(parsed.calls).toHaveLength(1) // single phase, full sponsorship expect(parsed.calls?.[0]?.[0]?.to).toBe(userCalls[0].to.toLowerCase()) expect(parsed.gas).toBe(hexToBigInt(gasEstimate.gasLimit)) - // expiry = now + maxExpiry (relative) - expect(parsed.expiry).toBe(BigInt(FROZEN_NOW_S + MAX_EXPIRY_REL)) + // validBefore = now + maxExpiry (unix ms); frozen clock ⇒ deterministic. + expect(parsed.validBefore).toBe( + BigInt(FROZEN_NOW_MS) + BigInt(MAX_EXPIRY_REL) * 1000n, + ) }) test('token payment: phase 0 transfer present; co-sign mode returns signed tx', async () => { diff --git a/src/experimental/eip8168/index.ts b/src/eip8168/index.ts similarity index 100% rename from src/experimental/eip8168/index.ts rename to src/eip8168/index.ts index 77f950690c..2dfdb773cd 100644 --- a/src/experimental/eip8168/index.ts +++ b/src/eip8168/index.ts @@ -24,7 +24,6 @@ export type { GetSponsorshipBalanceReturnType, GetTermsParameters, GetTermsReturnType, - PaymentOption, PayerBalance, PayerConditions, PayerGasEstimate, @@ -32,6 +31,7 @@ export type { PayerRejectedData, PayerRequote, PayerRpcCall, + PaymentOption, RefundPolicy, SendTransactionParameters, SendTransactionReturnType, @@ -41,8 +41,8 @@ export type { SponsoredOfferDeclined, SponsoredOfferSelectable, SponsorshipDeclineCode, - TokenChoice, TokenCharged, + TokenChoice, TokenPaymentOffer, } from './types.js' export { diff --git a/src/eip8168/package.json b/src/eip8168/package.json new file mode 100644 index 0000000000..82c579d95c --- /dev/null +++ b/src/eip8168/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "types": "../_types/eip8168/index.d.ts", + "module": "../_esm/eip8168/index.js", + "main": "../_cjs/eip8168/index.js" +} diff --git a/src/experimental/eip8168/types.ts b/src/eip8168/types.ts similarity index 98% rename from src/experimental/eip8168/types.ts rename to src/eip8168/types.ts index e7e540724c..60f81850f0 100644 --- a/src/experimental/eip8168/types.ts +++ b/src/eip8168/types.ts @@ -1,5 +1,5 @@ import type { Address } from 'abitype' -import type { Hex } from '../../types/misc.js' +import type { Hex } from '../types/misc.js' import type { PayerErrorCode } from './constants.js' /** A call in a payer RPC request (`value`/`data` optional). */ @@ -114,7 +114,6 @@ export type SponsorshipDeclineCode = | 'SENDER_LIMIT_REACHED' | 'GAS_EXCEEDS_LIMIT' | 'TEMPORARILY_UNAVAILABLE' - // biome-ignore lint/suspicious/noExplicitAny: keep the well-known union while staying open to custom strings | (string & {}) /** diff --git a/src/experimental/eip8168/utils/buildSponsoredCalls.ts b/src/eip8168/utils/buildSponsoredCalls.ts similarity index 93% rename from src/experimental/eip8168/utils/buildSponsoredCalls.ts rename to src/eip8168/utils/buildSponsoredCalls.ts index 489d21909d..270374cd85 100644 --- a/src/experimental/eip8168/utils/buildSponsoredCalls.ts +++ b/src/eip8168/utils/buildSponsoredCalls.ts @@ -1,9 +1,9 @@ import type { Address } from 'abitype' -import { erc20Abi } from '../../../constants/abis.js' -import { BaseError } from '../../../errors/base.js' -import { encodeFunctionData } from '../../../utils/abi/encodeFunctionData.js' -import { hexToBigInt } from '../../../utils/encoding/fromHex.js' +import { erc20Abi } from '../../constants/abis.js' import type { AaCall, AaCalls } from '../../eip8130/types/transaction.js' +import { BaseError } from '../../errors/base.js' +import { encodeFunctionData } from '../../utils/abi/encodeFunctionData.js' +import { hexToBigInt } from '../../utils/encoding/fromHex.js' import type { GetTermsReturnType, PaymentOption, @@ -30,7 +30,9 @@ export function encodeTokenTransfer(parameters: { } /** A `token` offer. */ -export function isTokenOffer(option: PaymentOption): option is TokenPaymentOffer { +export function isTokenOffer( + option: PaymentOption, +): option is TokenPaymentOffer { return option.kind === 'token' } @@ -87,9 +89,7 @@ export function selectPaymentOption( const matchChoice = (offer: TokenPaymentOffer): TokenChoice | undefined => { const choices = offer.tokens ?? [] if (token) - return choices.find( - (c) => c.token.toLowerCase() === token.toLowerCase(), - ) + return choices.find((c) => c.token.toLowerCase() === token.toLowerCase()) return choices[0] } diff --git a/src/experimental/eip8168/utils/parsePayerError.ts b/src/eip8168/utils/parsePayerError.ts similarity index 96% rename from src/experimental/eip8168/utils/parsePayerError.ts rename to src/eip8168/utils/parsePayerError.ts index 42d7b61029..327f89c856 100644 --- a/src/experimental/eip8168/utils/parsePayerError.ts +++ b/src/eip8168/utils/parsePayerError.ts @@ -26,9 +26,7 @@ import type { PayerRejectedData } from '../types.js' * } * } */ -export function parsePayerError( - error: unknown, -): PayerRejectedData | undefined { +export function parsePayerError(error: unknown): PayerRejectedData | undefined { const seen = new Set() let current: unknown = error diff --git a/src/experimental/eip8130/package.json b/src/experimental/eip8130/package.json deleted file mode 100644 index cef396dc06..0000000000 --- a/src/experimental/eip8130/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "module", - "types": "../../_types/experimental/eip8130/index.d.ts", - "module": "../../_esm/experimental/eip8130/index.js", - "main": "../../_cjs/experimental/eip8130/index.js" -} diff --git a/src/package.json b/src/package.json index 729f56b521..02452041c2 100644 --- a/src/package.json +++ b/src/package.json @@ -54,6 +54,16 @@ "import": "./_esm/chains/utils.js", "default": "./_cjs/chains/utils.js" }, + "./eip8130": { + "types": "./_types/eip8130/index.d.ts", + "import": "./_esm/eip8130/index.js", + "default": "./_cjs/eip8130/index.js" + }, + "./eip8168": { + "types": "./_types/eip8168/index.d.ts", + "import": "./_esm/eip8168/index.js", + "default": "./_cjs/eip8168/index.js" + }, "./ens": { "types": "./_types/ens/index.d.ts", "import": "./_esm/ens/index.js", @@ -64,16 +74,6 @@ "import": "./_esm/experimental/index.js", "default": "./_cjs/experimental/index.js" }, - "./experimental/eip8130": { - "types": "./_types/experimental/eip8130/index.d.ts", - "import": "./_esm/experimental/eip8130/index.js", - "default": "./_cjs/experimental/eip8130/index.js" - }, - "./experimental/eip8168": { - "types": "./_types/experimental/eip8168/index.d.ts", - "import": "./_esm/experimental/eip8168/index.js", - "default": "./_cjs/experimental/eip8168/index.js" - }, "./experimental/erc7739": { "types": "./_types/experimental/erc7739/index.d.ts", "import": "./_esm/experimental/erc7739/index.js", @@ -184,11 +184,11 @@ "experimental": [ "./_types/experimental/index.d.ts" ], - "experimental/eip8130": [ - "./_types/experimental/eip8130/index.d.ts" + "eip8130": [ + "./_types/eip8130/index.d.ts" ], - "experimental/eip8168": [ - "./_types/experimental/eip8168/index.d.ts" + "eip8168": [ + "./_types/eip8168/index.d.ts" ], "experimental/erc7739": [ "./_types/experimental/erc7739/index.d.ts" diff --git a/test/vitest.eip8130.config.ts b/test/vitest.eip8130.config.ts index 2db92e58b5..7ed572dd53 100644 --- a/test/vitest.eip8130.config.ts +++ b/test/vitest.eip8130.config.ts @@ -11,8 +11,8 @@ export default defineConfig({ { find: /^viem\/(.*)/, replacement: join(__dirname, '../src/$1') }, ], include: [ - 'src/experimental/eip813*/**/*.test.ts', - 'src/experimental/eip8168/**/*.test.ts', + 'src/eip8130/**/*.test.ts', + 'src/eip8168/**/*.test.ts', // Manual / integration demo scripts (most require PRIVATE_KEY + network). 'scripts/eip8130/**/*.test.ts', ], From dc810220f9979c4aaf0b15f90567f3dd6e02f65f Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 09:10:58 -0400 Subject: [PATCH 61/96] docs(eip8130): correctness pass on demos + guides post-graduation - scripts README: drop "experimental/temporary" framing; point at the docs - buildTransaction demo: print the real 15-field wire layout (valid_after/valid_before/metadata) instead of the stale 13-field/expiry list - receipts guide: use `validBefore` (unix ms), matching waitForTransactionReceipt (the tx has no `expiry` field) - fix stale `viem/experimental` doc-imports -> `viem/eip8130` --- scripts/eip8130/README.md | 9 +++++---- scripts/eip8130/buildTransaction.test.ts | 11 ++++++++--- site/pages/eip8130/receipts.mdx | 14 +++++++------- src/eip8130/utils/signers.ts | 4 ++-- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/scripts/eip8130/README.md b/scripts/eip8130/README.md index 553b621d20..237514732c 100644 --- a/scripts/eip8130/README.md +++ b/scripts/eip8130/README.md @@ -1,9 +1,10 @@ # EIP-8130 manual scripts -Temporary dev/integration scripts for the experimental EIP-8130 work in -`src/eip8130`. These are **not** public examples (see top-level -`examples/` for those) — they import local source (`../../src`) and most hit a -live testnet. Keep them here until EIP-8130 graduates from experimental. +Runnable dev/integration demonstrations for the EIP-8130 (`viem/eip8130`) and +ERC-8168 (`viem/eip8168`) modules. They import local source +(`../../src/eip8130`, `../../src/eip8168`) so they exercise the code in this +repo directly; most hit a live testnet (skipped unless `PRIVATE_KEY` is set). +For copy-paste usage docs see `site/pages/eip8130/`. ## Run diff --git a/scripts/eip8130/buildTransaction.test.ts b/scripts/eip8130/buildTransaction.test.ts index 2db00d95d6..c882fdb56c 100644 --- a/scripts/eip8130/buildTransaction.test.ts +++ b/scripts/eip8130/buildTransaction.test.ts @@ -127,24 +127,29 @@ describe('build an EIP-8130 transaction (offline demo)', () => { console.log('\nrlp envelope:') console.log(serialized) - // ── decode the raw RLP to show the 13-field wire layout ────────────────── + // ── decode the raw RLP to show the 15-field wire layout ────────────────── + // Replay protection is a nonce (nonce_key + nonce_sequence) AND/OR an + // absolute validity window (valid_after / valid_before, unix ms). A + // nonce-free tx sets nonce_key = NONCE_KEY_MAX and relies on the window. const fields = fromRlp(sliceHex(serialized, 1), 'hex') as unknown[] const fieldNames = [ 'chain_id', 'sender', 'nonce_key', 'nonce_sequence', - 'expiry', + 'valid_after', + 'valid_before', 'max_priority_fee_per_gas', 'max_fee_per_gas', 'gas_limit', 'account_changes', 'calls', + 'metadata', 'payer', 'sender_auth', 'payer_auth', ] - console.log('\n— raw RLP fields (13 elements) —') + console.log('\n— raw RLP fields (15 elements) —') fields.forEach((value, i) => { const rendered = Array.isArray(value) ? jsonify(value) diff --git a/site/pages/eip8130/receipts.mdx b/site/pages/eip8130/receipts.mdx index a267ffb61e..36e52816aa 100644 --- a/site/pages/eip8130/receipts.mdx +++ b/site/pages/eip8130/receipts.mdx @@ -23,9 +23,9 @@ It polls `eth_getTransactionReceipt` until the transaction is mined (default eve ### Expiring transactions -Any transaction with a non-zero `expiry` (sequenced or nonce-free) can no longer land once the chain's latest block timestamp passes it. Pass the `expiry` you signed with and the wait rejects with a `TransactionExpiredError` the moment it lapses, instead of silently spinning until the timeout. +Any transaction with a non-zero `validBefore` (sequenced or nonce-free) can no longer land once the chain's latest block timestamp passes it. Pass the `validBefore` (unix **milliseconds**) you signed with and the wait rejects with a `TransactionExpiredError` the moment it lapses, instead of silently spinning until the timeout. -Thread the resolved `expiry` out of `sendCalls` with `onTransaction` — this is reliable because it's the exact value the tx was signed with (including auto-computed nonce-free expiries), and it doesn't depend on the node returning a pending transaction: +Thread the resolved `validBefore` out of `sendCalls` with `onTransaction` — this is reliable because it's the exact value the tx was signed with (including auto-computed nonce-free windows), and it doesn't depend on the node returning a pending transaction: ```ts import { @@ -34,24 +34,24 @@ import { waitForTransactionReceipt, } from 'viem/eip8130' -let expiry: bigint | undefined +let validBefore: bigint | undefined const hash = await sendCalls(client, { account, calls, gas, - onTransaction: (tx) => { expiry = tx.expiry }, + onTransaction: (tx) => { validBefore = tx.validBefore }, }) try { - const receipt = await waitForTransactionReceipt(client, { hash, expiry }) + const receipt = await waitForTransactionReceipt(client, { hash, validBefore }) } catch (e) { if (e instanceof TransactionExpiredError) { - // tx can never land — resubmit with a fresh `expiry` + // tx can never land — resubmit with a fresh `validBefore` } } ``` -If you omit `expiry`, the wait still tries to read it off the pending transaction, but that is best-effort only — a node is not obligated to serve a pending `eth_getTransactionByHash`. +If you omit `validBefore`, the wait still tries to read it off the pending transaction, but that is best-effort only — a node is not obligated to serve a pending `eth_getTransactionByHash`. ## Fetch without waiting diff --git a/src/eip8130/utils/signers.ts b/src/eip8130/utils/signers.ts index 03d8359f3e..b28f24df10 100644 --- a/src/eip8130/utils/signers.ts +++ b/src/eip8130/utils/signers.ts @@ -45,7 +45,7 @@ export type ToP256SignerParameters = { * `signTransaction` to sign as a non-ECDSA actor. * * @example - * import { key, toAccount, toP256Signer } from 'viem/experimental' + * import { key, toAccount, toP256Signer } from 'viem/eip8130' * * const signer = toP256Signer({ privateKey }) * const account = toAccount({ @@ -137,7 +137,7 @@ export type ToWebAuthnSignerParameters = { * * @example * import { createWebAuthnCredential, toWebAuthnAccount } from 'viem/account-abstraction' - * import { key, toAccount, toWebAuthnSigner } from 'viem/experimental' + * import { key, toAccount, toWebAuthnSigner } from 'viem/eip8130' * * const credential = await createWebAuthnCredential({ name: 'vibes' }) * const signer = toWebAuthnSigner(toWebAuthnAccount({ credential })) From 4466f3e3c66a9f31149250d8b9a1342df78216a0 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 09:11:10 -0400 Subject: [PATCH 62/96] feat(eip8168): discover and aggregate multiple payer sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wallet may reach a payer several ways at once — the chain's node RPC, a block builder integrated with the sequencer (e.g. flashblocks), an app endpoint, or a wallet-injected payer. Add a discovery surface that queries them all in parallel and presents one PayerClient. - createAggregatePayerClient({ payers }): fans `getTerms` out with Promise.allSettled (a slow/failing source never blocks the rest), merges offers best-first in source order, and routes `payer_sendTransaction` / `payer_signTransaction` back to the source that offered the tx's `payer`. It is itself a PayerClient, so it drops into sendSponsoredCalls unchanged. - toChainPayerClient(client): adapt the wallet's execution client into a PayerClient when the chain/builder serves `payer_*` natively. - hasChainPayerService + payerServiceChainIds registry (mirrors is8130Enabled), no core Chain type change. - unit tests (parallel merge, source-order, failure skip, tx.payer routing, balance concat) + a "Discovering payers" docs section. --- site/pages/eip8130/payer-services.mdx | 46 +++++- src/eip8168/aggregate.test.ts | 228 ++++++++++++++++++++++++++ src/eip8168/aggregate.ts | 176 ++++++++++++++++++++ src/eip8168/chains.ts | 57 +++++++ src/eip8168/client.ts | 87 +++++++--- src/eip8168/index.ts | 12 ++ 6 files changed, 580 insertions(+), 26 deletions(-) create mode 100644 src/eip8168/aggregate.test.ts create mode 100644 src/eip8168/aggregate.ts create mode 100644 src/eip8168/chains.ts diff --git a/site/pages/eip8130/payer-services.mdx b/site/pages/eip8130/payer-services.mdx index ca243d122f..35b7d8b27f 100644 --- a/site/pages/eip8130/payer-services.mdx +++ b/site/pages/eip8130/payer-services.mdx @@ -9,7 +9,7 @@ description: Negotiate gas sponsorship and token payment with an ERC-8168 payer Viem exposes the client and helpers under `viem/eip8168`. :::warning[Warning] -ERC-8168 is experimental. A payer's offers are trust-based — always preflight caps (`conditions`) and confirm token charges with the user before re-signing. +ERC-8168 is a draft standard. A payer's offers are trust-based — always preflight caps (`conditions`) and confirm token charges with the user before re-signing. ::: ## The RPC surface @@ -31,6 +31,50 @@ import { createPayerClient } from 'viem/eip8168' const payerClient = createPayerClient({ url: 'https://payer.example.com/v1' }) ``` +## Discovering payers + +A wallet often has more than one place to get sponsorship: the chain's node RPC may serve `payer_*` itself, a **block builder** integrated with the sequencer (e.g. flashblocks) may expose it, the app may run its own payer, and the wallet may inject one. `createAggregatePayerClient` queries them all **in parallel** and presents the result as a single `PayerClient`, so it drops straight into `sendSponsoredCalls`. + +- `getTerms` fans out with `Promise.allSettled` (a slow or failing source never blocks the others) and merges every source's offers best-first, in source order. +- `sendTransaction` / `signTransaction` read the signed tx's `payer` and route the co-sign back to the source that offered it — so let the aggregate fetch terms (don't pre-pass `terms`). + +Use `hasChainPayerService` to decide whether to add the node itself (via `toChainPayerClient`) as a source; register the chains you know serve it with `registerPayerServiceChains`. + +```ts +import { + createAggregatePayerClient, + createPayerClient, + hasChainPayerService, + registerPayerServiceChains, + sendSponsoredCalls, + toChainPayerClient, +} from 'viem/eip8168' + +// Chains whose node RPC (or integrated builder, e.g. flashblocks) serves payer_*. +registerPayerServiceChains(client.chain.id) + +const payerClient = createAggregatePayerClient({ + payers: [ + // 1. the chain / block builder, over the wallet's own RPC + ...(hasChainPayerService(client.chain) ? [toChainPayerClient(client)] : []), + // 2. the app's payer service + createPayerClient({ url: 'https://payer.myapp.com/v1' }), + // 3. a wallet-injected payer (any PayerClient) + walletPayer, + ], + onError: (error, index) => console.warn(`payer #${index} unavailable`, error), +}) + +// Unchanged: the aggregate is just a PayerClient. +const { transactionHash } = await sendSponsoredCalls(client, { + account, + payerClient, + calls: [{ to: recipient, data }], +}) +``` + +The merged `gasEstimate` is the worst-case (largest `gasLimit`) across sources; a source with a tighter cap re-quotes via `GAS_TOO_LOW`, which `sendSponsoredCalls` handles. + ## End-to-end: `sendSponsoredCalls` `sendSponsoredCalls` runs the whole flow: fetch terms, select an offer, build the phases (including any phase-0 token transfer), sign `sender_auth` with the payer named and `payer_auth` empty, then hand off to the payer to co-sign and submit. diff --git a/src/eip8168/aggregate.test.ts b/src/eip8168/aggregate.test.ts new file mode 100644 index 0000000000..53ec8c40a8 --- /dev/null +++ b/src/eip8168/aggregate.test.ts @@ -0,0 +1,228 @@ +import type { Address } from 'abitype' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { mainnet } from '../chains/index.js' +import { createClient } from '../clients/createClient.js' +import { custom } from '../clients/transports/custom.js' +import { serializeTransaction } from '../eip8130/utils/serializeTransaction.js' +import { createAggregatePayerClient } from './aggregate.js' +import { + hasChainPayerService, + payerServiceChainIds, + registerPayerServiceChains, + unregisterPayerServiceChains, +} from './chains.js' +import type { PayerClient } from './client.js' +import { toChainPayerClient } from './client.js' +import type { GetTermsReturnType } from './types.js' + +const PAYER_A = '0x1111111111111111111111111111111111111111' as const +const PAYER_B = '0x2222222222222222222222222222222222222222' as const + +// A serialized 8130 tx naming `payer` — used to drive routing. +function signedFor(payer: Address) { + return serializeTransaction({ + chainId: 1, + nonceSequence: 0n, + maxFeePerGas: 1n, + maxPriorityFeePerGas: 1n, + gas: 21_000n, + calls: [[{ to: '0x0000000000000000000000000000000000000001', data: '0x' }]], + payer, + senderAuth: '0x1234', + }) +} + +/** A minimal in-memory {@link PayerClient} with call spies. */ +function fakePayer( + terms: GetTermsReturnType, + options: { + fail?: boolean + balance?: { balances: any[]; ttl: number } + hash?: `0x${string}` + } = {}, +): { client: PayerClient; getTerms: any; send: any; sign: any } { + const getTerms = vi.fn(async () => { + if (options.fail) throw new Error('source down') + return terms + }) + const send = vi.fn(async () => ({ + transactionHash: options.hash ?? (`0x${'ab'.repeat(32)}` as const), + })) + const sign = vi.fn(async (p: { signedTransaction: `0x${string}` }) => ({ + signedTransaction: p.signedTransaction, + })) + const getSponsorshipBalance = vi.fn( + async () => options.balance ?? { balances: [], ttl: 30 }, + ) + return { + client: { + getTerms, + sendTransaction: send, + signTransaction: sign, + getSponsorshipBalance, + }, + getTerms, + send, + sign, + } +} + +const gas = (gasLimit: `0x${string}`) => ({ + gasLimit, + maxFeePerGas: '0x1' as const, + maxPriorityFeePerGas: '0x1' as const, +}) + +describe('hasChainPayerService', () => { + const TEST_ID = 999_001 + + afterEach(() => unregisterPayerServiceChains(TEST_ID)) + + test('false for an unregistered chain, id, undefined', () => { + expect(hasChainPayerService(TEST_ID)).toBe(false) + expect(hasChainPayerService({ id: TEST_ID })).toBe(false) + expect(hasChainPayerService(undefined)).toBe(false) + }) + + test('true after register; false after unregister', () => { + registerPayerServiceChains(TEST_ID) + expect(payerServiceChainIds.has(TEST_ID)).toBe(true) + expect(hasChainPayerService(TEST_ID)).toBe(true) + expect(hasChainPayerService({ id: TEST_ID })).toBe(true) + unregisterPayerServiceChains(TEST_ID) + expect(hasChainPayerService(TEST_ID)).toBe(false) + }) + + test('honors an explicit chainIds set without touching the registry', () => { + expect(hasChainPayerService(TEST_ID, { chainIds: [TEST_ID] })).toBe(true) + expect(payerServiceChainIds.has(TEST_ID)).toBe(false) + }) +}) + +describe('toChainPayerClient', () => { + test('routes payer_* over the client transport', async () => { + const seen: string[] = [] + const terms: GetTermsReturnType = { + options: [{ kind: 'sponsored', payer: PAYER_A, ttl: 300 }], + } + const client = createClient({ + chain: mainnet, + transport: custom({ + async request({ method }: { method: string }) { + seen.push(method) + if (method === 'payer_getTerms') return terms + throw new Error(`unexpected ${method}`) + }, + }), + }) + const payer = toChainPayerClient(client) + expect( + await payer.getTerms({ chainId: '0x1', from: PAYER_A, calls: [] }), + ).toEqual(terms) + expect(seen).toContain('payer_getTerms') + }) +}) + +describe('createAggregatePayerClient', () => { + test('throws with no payers', () => { + expect(() => createAggregatePayerClient({ payers: [] })).toThrow() + }) + + test('getTerms queries every source in parallel and merges best-first', async () => { + const a = fakePayer({ + gasEstimate: gas('0x5208'), + fiatCurrency: 'USD', + options: [{ kind: 'sponsored', payer: PAYER_A, ttl: 300 }], + }) + const b = fakePayer({ + gasEstimate: gas('0xC350'), // larger → worst-case wins + options: [{ kind: 'sponsored', payer: PAYER_B, ttl: 60 }], + }) + const agg = createAggregatePayerClient({ payers: [a.client, b.client] }) + + const terms = await agg.getTerms({ + chainId: '0x1', + from: PAYER_A, + calls: [], + }) + + expect(a.getTerms).toHaveBeenCalledTimes(1) + expect(b.getTerms).toHaveBeenCalledTimes(1) + // Source order preserved; both offers present. + expect(terms.options.map((o) => (o as any).payer)).toEqual([ + PAYER_A, + PAYER_B, + ]) + // Worst-case (largest) gasLimit across responses. + expect(terms.gasEstimate?.gasLimit).toBe('0xC350') + // First defined fiatCurrency. + expect(terms.fiatCurrency).toBe('USD') + }) + + test('a failing source is skipped (onError) and never fatal', async () => { + const a = fakePayer( + { options: [{ kind: 'sponsored', payer: PAYER_A, ttl: 300 }] }, + { fail: true }, + ) + const b = fakePayer({ + options: [{ kind: 'sponsored', payer: PAYER_B, ttl: 60 }], + }) + const onError = vi.fn() + const agg = createAggregatePayerClient({ + payers: [a.client, b.client], + onError, + }) + + const terms = await agg.getTerms({ + chainId: '0x1', + from: PAYER_A, + calls: [], + }) + + expect(onError).toHaveBeenCalledTimes(1) + expect(terms.options.map((o) => (o as any).payer)).toEqual([PAYER_B]) + }) + + test('sendTransaction routes to the source that offered the tx `payer`', async () => { + const a = fakePayer({ + options: [{ kind: 'sponsored', payer: PAYER_A, ttl: 300 }], + }) + const b = fakePayer({ + options: [{ kind: 'sponsored', payer: PAYER_B, ttl: 60 }], + }) + const agg = createAggregatePayerClient({ payers: [a.client, b.client] }) + + await agg.getTerms({ chainId: '0x1', from: PAYER_A, calls: [] }) // populate routes + + await agg.sendTransaction({ signedTransaction: signedFor(PAYER_B) }) + expect(b.send).toHaveBeenCalledTimes(1) + expect(a.send).not.toHaveBeenCalled() + + await agg.signTransaction({ signedTransaction: signedFor(PAYER_A) }) + expect(a.sign).toHaveBeenCalledTimes(1) + expect(b.sign).not.toHaveBeenCalled() + }) + + test('sendTransaction throws for an unknown / unrouted payer', async () => { + const a = fakePayer({ + options: [{ kind: 'sponsored', payer: PAYER_A, ttl: 300 }], + }) + const agg = createAggregatePayerClient({ payers: [a.client] }) + await agg.getTerms({ chainId: '0x1', from: PAYER_A, calls: [] }) + await expect( + agg.sendTransaction({ signedTransaction: signedFor(PAYER_B) }), + ).rejects.toThrow() + }) + + test('getSponsorshipBalance concatenates balances and takes the min ttl', async () => { + const balA = { balances: [{ kind: 'sponsorship', limits: [] }], ttl: 30 } + const balB = { balances: [{ kind: 'credit', limits: [] }], ttl: 10 } + const a = fakePayer({ options: [] }, { balance: balA as any }) + const b = fakePayer({ options: [] }, { balance: balB as any }) + const agg = createAggregatePayerClient({ payers: [a.client, b.client] }) + + const { balances, ttl } = await agg.getSponsorshipBalance({ from: PAYER_A }) + expect(balances).toHaveLength(2) + expect(ttl).toBe(10) + }) +}) diff --git a/src/eip8168/aggregate.ts b/src/eip8168/aggregate.ts new file mode 100644 index 0000000000..07ca6bd48f --- /dev/null +++ b/src/eip8168/aggregate.ts @@ -0,0 +1,176 @@ +import { parseTransaction } from '../eip8130/utils/parseTransaction.js' +import { BaseError } from '../errors/base.js' +import { hexToBigInt } from '../utils/encoding/fromHex.js' +import type { PayerClient } from './client.js' +import type { + GetSponsorshipBalanceParameters, + GetSponsorshipBalanceReturnType, + GetTermsParameters, + GetTermsReturnType, + PayerBalance, + PaymentOption, + SendTransactionParameters, + SendTransactionReturnType, + SignTransactionParameters, + SignTransactionReturnType, +} from './types.js' +import { isSelectableOffer } from './utils/buildSponsoredCalls.js' + +export type CreateAggregatePayerClientParameters = { + /** + * Payer sources to query in parallel, in preference order (earlier wins ties + * when two sources advertise the same `payer` address). Each is a + * {@link PayerClient} — an external service (`createPayerClient`), the chain / + * block-builder node (`toChainPayerClient`), or a wallet-injected payer. + */ + payers: readonly PayerClient[] + /** + * Invoked when an individual source rejects (during `getTerms` / + * `getSponsorshipBalance` fan-out). A rejecting source is skipped, never fatal + * — this hook lets a wallet log or surface a degraded source without failing + * the whole negotiation. + */ + onError?: ((error: unknown, index: number) => void) | undefined +} + +/** + * Fans an ERC-8168 negotiation out across several payer sources and presents + * them to the wallet as a single {@link PayerClient} — so it drops straight into + * {@link sendSponsoredCalls} with no other change. + * + * - `getTerms` queries every source **in parallel** (`Promise.allSettled`, so a + * slow or failing source never blocks the rest) and merges their `options` + * best-first in source order. Offers stay self-describing via their `payer` + * (and optional `endpoint`), and the aggregate remembers which source produced + * each `payer` so it can route the co-sign back. + * - `sendTransaction` / `signTransaction` read the signed transaction's `payer` + * field and dispatch to the source that offered it (populated by the preceding + * `getTerms`). Let the aggregate fetch terms — don't pre-pass `terms` to + * `sendSponsoredCalls` — so routing is populated. + * - `getSponsorshipBalance` concatenates every source's balances. + * + * The merged `gasEstimate` is the worst-case (largest `gasLimit`) across + * responses: the same calls are quoted by every source, and a source with a + * tighter cap re-quotes via `GAS_TOO_LOW`, which `sendSponsoredCalls` handles. + * + * @example + * import { + * createAggregatePayerClient, + * createPayerClient, + * hasChainPayerService, + * sendSponsoredCalls, + * toChainPayerClient, + * } from 'viem/eip8168' + * + * const payerClient = createAggregatePayerClient({ + * payers: [ + * ...(hasChainPayerService(client.chain) ? [toChainPayerClient(client)] : []), + * createPayerClient({ url: appPayerUrl }), + * createPayerClient({ url: flashblocksBuilderUrl }), + * ], + * }) + * await sendSponsoredCalls(client, { account, payerClient, calls }) + */ +export function createAggregatePayerClient( + parameters: CreateAggregatePayerClientParameters, +): PayerClient { + const { payers, onError } = parameters + if (payers.length === 0) + throw new BaseError('`createAggregatePayerClient` requires ≥1 payer.') + + // payer address -> the source that offered it, populated on every `getTerms` + // so `sendTransaction` / `signTransaction` can route the co-sign back. + const routes = new Map() + + const settle = async ( + fn: (payer: PayerClient) => Promise, + ): Promise => { + const results = await Promise.allSettled(payers.map(fn)) + const values: T[] = [] + results.forEach((result, index) => { + if (result.status === 'fulfilled') values.push(result.value) + else onError?.(result.reason, index) + }) + return values + } + + const route = (signedTransaction: `0x${string}`): PayerClient => { + const { payer } = parseTransaction(signedTransaction) + if (!payer) + throw new BaseError( + 'Signed transaction names no `payer`; nothing to route to a payer source.', + ) + const source = routes.get(payer.toLowerCase()) + if (!source) + throw new BaseError( + `No payer source produced an offer for payer "${payer}". Let the aggregate client fetch terms (do not pre-pass \`terms\`) so routing is populated.`, + ) + return source + } + + return { + async getTerms(params: GetTermsParameters): Promise { + const responses = await settle((payer) => + payer.getTerms(params).then((terms) => ({ payer, terms })), + ) + + const options: PaymentOption[] = [] + let gasEstimate: GetTermsReturnType['gasEstimate'] + let fiatCurrency: string | undefined + + for (const { payer, terms } of responses) { + for (const option of terms.options ?? []) { + options.push(option) + // Only selectable offers carry a routable `payer`; earlier sources win. + if (isSelectableOffer(option)) { + const key = option.payer.toLowerCase() + if (!routes.has(key)) routes.set(key, payer) + } + } + // Worst-case sizing: keep the largest gasLimit across responses. + if ( + terms.gasEstimate && + (!gasEstimate || + hexToBigInt(terms.gasEstimate.gasLimit) > + hexToBigInt(gasEstimate.gasLimit)) + ) + gasEstimate = terms.gasEstimate + fiatCurrency ??= terms.fiatCurrency + } + + return { + options, + ...(gasEstimate ? { gasEstimate } : {}), + ...(fiatCurrency ? { fiatCurrency } : {}), + } + }, + + async sendTransaction( + params: SendTransactionParameters, + ): Promise { + return route(params.signedTransaction).sendTransaction(params) + }, + + async signTransaction( + params: SignTransactionParameters, + ): Promise { + return route(params.signedTransaction).signTransaction(params) + }, + + async getSponsorshipBalance( + params: GetSponsorshipBalanceParameters, + ): Promise { + const responses = await settle((payer) => + payer.getSponsorshipBalance(params), + ) + const balances: PayerBalance[] = [] + let ttl: number | undefined + for (const response of responses) { + balances.push(...response.balances) + // Cache only as long as the shortest-lived snapshot stays valid. + ttl = ttl === undefined ? response.ttl : Math.min(ttl, response.ttl) + } + return { balances, ttl: ttl ?? 0 } + }, + } +} diff --git a/src/eip8168/chains.ts b/src/eip8168/chains.ts new file mode 100644 index 0000000000..4d20142dba --- /dev/null +++ b/src/eip8168/chains.ts @@ -0,0 +1,57 @@ +/** + * Registry of chain IDs whose node RPC serves the ERC-8168 `payer_*` methods + * natively — i.e. the wallet can add the execution client itself as a payer + * source (see {@link toChainPayerClient}) instead of, or alongside, an external + * payer web service. + * + * @remarks + * A payer service can be exposed by several parties: the chain/node itself, a + * block builder integrated with the sequencer (e.g. flashblocks), an app's own + * endpoint, or the wallet. Only the node-native case is discoverable from the + * chain, so it is tracked here; the other sources are supplied explicitly to + * {@link createAggregatePayerClient}. + * + * This set is empty by default. Populate it with {@link registerPayerServiceChains}, + * or pass an explicit `chainIds` set to {@link hasChainPayerService}. + */ +export const payerServiceChainIds: Set = new Set() + +/** Registers one or more chain IDs as serving `payer_*` on their node RPC. */ +export function registerPayerServiceChains(...chainIds: number[]): void { + for (const id of chainIds) payerServiceChainIds.add(id) +} + +/** Unregisters one or more chain IDs. */ +export function unregisterPayerServiceChains(...chainIds: number[]): void { + for (const id of chainIds) payerServiceChainIds.delete(id) +} + +export type HasChainPayerServiceParameters = { + /** + * Explicit set of chain IDs to check against. Defaults to the shared + * {@link payerServiceChainIds} registry. + */ + chainIds?: Iterable | undefined +} + +/** + * Returns whether a chain serves the ERC-8168 payer service on its node RPC. A + * wallet uses this to decide whether to include {@link toChainPayerClient} among + * the payer sources it queries in parallel. + * + * Accepts a chain ID, a chain object, or `undefined` (returns `false`, so it can + * be called directly with `client.chain`). + */ +export function hasChainPayerService( + chain: number | { id: number } | undefined, + parameters: HasChainPayerServiceParameters = {}, +): boolean { + if (chain === undefined) return false + const id = typeof chain === 'number' ? chain : chain.id + const set = parameters.chainIds + ? parameters.chainIds instanceof Set + ? parameters.chainIds + : new Set(parameters.chainIds) + : payerServiceChainIds + return set.has(id) +} diff --git a/src/eip8168/client.ts b/src/eip8168/client.ts index 379881787f..32d6994eb6 100644 --- a/src/eip8168/client.ts +++ b/src/eip8168/client.ts @@ -1,6 +1,9 @@ +import type { Client } from '../clients/createClient.js' import { createClient } from '../clients/createClient.js' import type { Transport } from '../clients/transports/createTransport.js' import { http } from '../clients/transports/http.js' +import type { Account } from '../types/account.js' +import type { Chain } from '../types/chain.js' import type { GetSponsorshipBalanceParameters, GetSponsorshipBalanceReturnType, @@ -76,33 +79,18 @@ export type PayerClient = { ): Promise } +/** Minimal JSON-RPC request function shape a {@link PayerClient} wraps. */ +type PayerRequestFn = (args: { + method: string + params: readonly unknown[] +}) => Promise + /** - * Creates a client for an ERC-8168 payer web service. Wraps the `payer_*` - * JSON-RPC methods used by wallets to negotiate and obtain gas sponsorship / - * token payment for EIP-8130 transactions. - * - * @example - * import { createPayerClient } from 'viem/experimental' - * - * const payer = createPayerClient({ url: 'https://payer.example.com/v1' }) - * const { options } = await payer.getTerms({ chainId: '0x2105', from, calls }) + * Builds a {@link PayerClient} over any JSON-RPC `request` function that speaks + * the `payer_*` methods — shared by {@link createPayerClient} (a standalone HTTP + * endpoint) and {@link toChainPayerClient} (the wallet's own execution client). */ -export function createPayerClient( - parameters: CreatePayerClientParameters, -): PayerClient { - const { url, transport = http(url) } = parameters - if (!url && !parameters.transport) - throw new Error('`url` or `transport` is required.') - - const { request } = createClient< - Transport, - undefined, - undefined, - PayerRpcSchema - >({ - transport, - }) - +function payerClientFromRequest(request: PayerRequestFn): PayerClient { return { getTerms(params) { return request({ @@ -130,3 +118,52 @@ export function createPayerClient( }, } } + +/** + * Creates a client for an ERC-8168 payer web service. Wraps the `payer_*` + * JSON-RPC methods used by wallets to negotiate and obtain gas sponsorship / + * token payment for EIP-8130 transactions. + * + * @example + * import { createPayerClient } from 'viem/eip8168' + * + * const payer = createPayerClient({ url: 'https://payer.example.com/v1' }) + * const { options } = await payer.getTerms({ chainId: '0x2105', from, calls }) + */ +export function createPayerClient( + parameters: CreatePayerClientParameters, +): PayerClient { + const { url, transport = http(url) } = parameters + if (!url && !parameters.transport) + throw new Error('`url` or `transport` is required.') + + const { request } = createClient< + Transport, + undefined, + undefined, + PayerRpcSchema + >({ + transport, + }) + + return payerClientFromRequest(request as PayerRequestFn) +} + +/** + * Adapts an existing viem client into a {@link PayerClient} by routing the + * `payer_*` methods over its transport. Use it when the chain (or block builder, + * e.g. a flashblocks endpoint) serves the payer service natively on the same RPC + * the wallet already talks to — check {@link hasChainPayerService} first, then + * fold the result into {@link createAggregatePayerClient}'s `payers`. + * + * @example + * import { hasChainPayerService, toChainPayerClient } from 'viem/eip8168' + * + * if (hasChainPayerService(client.chain)) + * payers.push(toChainPayerClient(client)) + */ +export function toChainPayerClient( + client: Client, +): PayerClient { + return payerClientFromRequest(client.request as unknown as PayerRequestFn) +} diff --git a/src/eip8168/index.ts b/src/eip8168/index.ts index 2dfdb773cd..14e003abe8 100644 --- a/src/eip8168/index.ts +++ b/src/eip8168/index.ts @@ -5,11 +5,23 @@ export { type SendSponsoredCallsReturnType, sendSponsoredCalls, } from './actions/sendSponsoredCalls.js' +export { + type CreateAggregatePayerClientParameters, + createAggregatePayerClient, +} from './aggregate.js' +export { + type HasChainPayerServiceParameters, + hasChainPayerService, + payerServiceChainIds, + registerPayerServiceChains, + unregisterPayerServiceChains, +} from './chains.js' export { type CreatePayerClientParameters, createPayerClient, type PayerClient, type PayerRpcSchema, + toChainPayerClient, } from './client.js' export { type PayerErrorCode, From 100aeb6b991316ee73efc9382463821e56e0d79f Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 13:23:34 -0400 Subject: [PATCH 63/96] feat(eip8130): finalize newSmartAccount API, canonical addresses, and production typecheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - newSmartAccount: `proxy: 'erc1167' | 'upgradeable'` (default `upgradeable`, which requires an explicit UUPS implementation until one is enshrined) plus `admins`/`extraActors` for the initial actor set. Widen the `signer` type so a standard K1 `privateKeyToAccount` (SEC1 hex `publicKey`) typechecks — the primary documented usage previously did not compile. - Update canonical EIP-8130 addresses to the regenerated vanity set (Keystore, DefaultAccount, high-rate account, authenticators, policy manager + session). - Docs: document the proxy/admins API and correct the ERC-4337 portable-path section (`accounts.erc4337` is intentionally out of scope / not enshrined; supply your own deployed implementation). - Typecheck: exclude the manual, network-gated `scripts/eip8130` demos from `check:types` (they run via their own vitest config); fix test type looseness (call `to` literals typed as `0x${string}`, PaymentOption token-variant cast). eip8130 + eip8168 sources and unit tests (137) are type-clean; publint reports no problems and attw is green for `viem/eip8130` and `viem/eip8168`. --- scripts/tsconfig.json | 4 + site/pages/eip8130.mdx | 69 +++++++--- site/pages/eip8130/creating-an-account.mdx | 50 ++++++-- site/pages/eip8130/session-keys.mdx | 3 +- site/pages/eip8130/sub-accounts.mdx | 6 +- src/eip8130/accounts/toAccount.ts | 141 +++++++++++++++------ src/eip8130/constants.ts | 6 +- src/eip8130/deployments.ts | 35 +++-- src/eip8130/devx.test.ts | 79 +++++++++++- src/eip8130/nonce.test.ts | 5 +- src/eip8130/policies.test.ts | 2 +- src/eip8168/eip8168.test.ts | 15 ++- 12 files changed, 317 insertions(+), 98 deletions(-) diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 8fe5a5d85e..1de82d40a4 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -1,6 +1,10 @@ { "extends": "../tsconfig.base.json", "include": ["."], + // The `eip8130/` demos are manual, network-gated harnesses that import local + // source directly and run via `test/vitest.eip8130.config.ts` — they are not + // part of the library and are intentionally excluded from `check:types`. + "exclude": ["./eip8130"], "compilerOptions": { "composite": true, "noEmit": true, diff --git a/site/pages/eip8130.mdx b/site/pages/eip8130.mdx index b2a6c11f68..f9d44459ab 100644 --- a/site/pages/eip8130.mdx +++ b/site/pages/eip8130.mdx @@ -4,19 +4,32 @@ description: Getting started with EIP-8130 native account abstraction in Viem # EIP-8130 (Native Account Abstraction) [Overview] -[EIP-8130](https://github.com/base/eip-8130) is a native account-abstraction transaction type (`AA_TX_TYPE`, `0x79`). Every account is a smart account governed by an onchain **Account Configuration** contract, and transactions are sent directly to the chain — no bundler, no EntryPoint. Viem exposes the full flow through the `viem/eip8130` entrypoint. +[EIP-8130](https://github.com/base/eip-8130) is a native account-abstraction transaction type (`AA_TX_TYPE`, `0x79`). Every account is a smart account governed by an onchain **Keystore** contract, and transactions are sent directly to the chain — no bundler, no EntryPoint. Viem exposes the full flow through the `viem/eip8130` entrypoint. :::warning[Warning] -EIP-8130 is experimental and enabled per-chain. It is not yet live on mainnet. Do not solely rely on experimental features in production. +EIP-8130 is not yet enabled on mainnet and is currently in audit. Do not rely on it in production yet. ::: ## What you get -- **One account, many keys** — a single account address controlled by a set of *actors* (secp256k1 / P-256 / WebAuthn passkeys / delegates), each with its own scope and expiry. -- **Deploy-on-first-use** — the counterfactual address is known up front; the account is deployed atomically inside its first transaction. -- **Native transactions** — sign an `AA_TX_TYPE` transaction and submit it with `eth_sendRawTransaction`. Gas is priced by the node via an EIP-8130-aware `eth_estimateGas`. -- **Sponsored gas** — an optional `payer` co-signs the transaction so a third party pays the fee. -- **Session keys** — policy-gated actors (spend limits, target/selector allowlists) for scoped, delegated signing. +**Auth** +- **Multiple key types** — sign for the account with secp256k1, P-256, or WebAuthn passkeys, and **rotate keys** at any time. +- **Sub-accounts** — many accounts per owner, linked via delegate actors. +- **Session keys & scoped permissions** — policy-gated actors (spend limits, target/selector allowlists). +- **Account recovery & multisig** — available today (recover via actor sets, require multiple actors to authorize). + +**Gas abstraction** +- **Sponsorship** and **ERC-20 gas payment**. + +**Account** +- **Backwards compatible** with existing ERC-4337 accounts — every account works. +- **Portable** — one account for everything: an EOA, an existing smart account, or a new one — the same on **any EVM chain**. + +**Execution** +- **Guaranteed atomic batching**. +- **Call phases** — sequentially-committed atomic call groups. +- **Expiring transactions**. +- **High-throughput accounts** — 100s of tps. ## Mental model @@ -24,9 +37,18 @@ EIP-8130 is experimental and enabled per-chain. It is not yet live on mainnet. D | --- | --- | | **Account** | Smart account at a CREATE2 address derived from `userSalt`, wallet `code`, and `initialActors`. | | **Actor** | A key authorized on the account (`{ actorId, authenticator }`), built via the [`key`](/eip8130/rotating-owners#actors-and-keys) helpers. | +| **Authenticator** | A contract that validates an auth blob and returns the `actorId` it authenticates (`bytes32(0)` if invalid). Secp256k1 is built in (`address(1)`); P-256, WebAuthn, and delegate authenticators are contracts. | | **Account change** | A `create`, `config` (actor changes), or `delegation` operation applied atomically within a transaction. | | **Auth blob** | `sender_auth` / `payer_auth` bytes: a bare 65-byte signature for the implicit EOA path, or `authenticator(20) || data` for a configured actor. | +Every authenticator implements a single view function — given a hash and the auth `data`, it returns the actor it proves (or `bytes32(0)`): + +```solidity +interface IAuthenticator { + function authenticate(bytes32 hash, bytes data) external view returns (bytes32 actorId); +} +``` + ## Installation The helpers live under the dedicated entrypoint: @@ -62,22 +84,35 @@ register8130Chains(vibenet.id) ## Deployment addresses -The protocol contracts (Account Configuration, wallet implementations, authenticators, example policies) are deployed deterministically and are identical on every chain running the same bytecode. Resolve them per chain: +Every protocol contract is deployed through a deterministic CREATE2 factory with a per-contract mined salt, so each address is a pure function of its bytecode — **identical on every chain**. The `viem/eip8130` actions already default to this canonical set (`accountConfiguration` is enshrined in the execution client), so you normally don't pass any addresses at all. + +The default is implied but extensible — fall back to `canonicalEip8130Deployment`, and override only if a chain ever pins a different set: ```ts import { getEip8130Deployment, canonicalEip8130Deployment } from 'viem/eip8130' -const deployment = getEip8130Deployment(84_538_453) ?? canonicalEip8130Deployment -deployment.accountConfiguration // factory + actor-config registry -deployment.accounts.upgradeable // UpgradeableAccount — default smart-account impl -deployment.accounts.default // DefaultAccount — default EOA (EIP-7702) delegate -deployment.accounts.defaultHighRate // DefaultHighRateAccount — immutable smart-account impl -deployment.accounts.erc4337 // BackwardsCompatible4337Account — cross-chain (ERC-4337) -deployment.authenticators.p256 // P-256 authenticator -deployment.policies?.manager // example PolicyManager (session keys) +const deployment = + getEip8130Deployment(chainId) ?? // per-chain override, if one is ever registered + canonicalEip8130Deployment // canonical default (every chain today) + +deployment.accountConfiguration // Keystore — factory + actor-config registry (enshrined) +deployment.accounts.default // DefaultAccount — EIP-7702 delegate / proxy impl +deployment.accounts.defaultHighRate // CanonicalHighRatePayerAccount — immutable (ERC-1167) +deployment.authenticators.p256 // P-256 (webAuthn, delegate, k1, alwaysValid alongside) +``` + +The canonical set is **`DefaultAccount`** (the bare building block and the direct EIP-7702 delegation target for EOAs) and **`CanonicalHighRatePayerAccount`** (immutable, behind a 45-byte ERC-1167 proxy). **`UpgradeableAccount`** (ERC-1967 upgradeable proxy) and **`BackwardsCompatible4337Account`** (the ERC-4337 portable implementation for non-native chains) are optional, unaudited example wallets — supply them explicitly if you choose those paths. + +### Policies are app-level, not protocol + +Policy contracts are **not** part of the EIP-8130 protocol. A policy-gated actor is simply gated to a `manager` address that forwards committed call plans — and both the manager and the policies it enforces are **extensible**: add a new policy under the same manager, or deploy a whole new manager. Base ships one **audited** `PolicyManager` and one `SessionPolicy` in use today: + +```ts +deployment.policies?.manager // PolicyManager (audited) +deployment.policies?.sessionPolicy // SessionPolicy — the policy in use today ``` -Account implementations: **`UpgradeableAccount`** (the default for smart accounts — behind an ERC-1967 `UpgradeableProxy`, upgradeable via `upgradeBySignature`), **`DefaultAccount`** (the bare building block, deployed standalone as the direct EIP-7702 delegation target for EOAs), **`DefaultHighRateAccount`** (immutable — behind a 45-byte ERC-1167 proxy), and **`BackwardsCompatible4337Account`** (the ERC-4337 portable implementation for non-native chains). All four are deployed as singletons by base's canonical `Deploy.s.sol` at canonical CREATE2 addresses. +To use your own, point `authorizeActor`'s `policy.manager` at your contract — see [Session Keys](/eip8130/session-keys). ## Guides diff --git a/site/pages/eip8130/creating-an-account.mdx b/site/pages/eip8130/creating-an-account.mdx index 1efb976af0..c3ecea9c13 100644 --- a/site/pages/eip8130/creating-an-account.mdx +++ b/site/pages/eip8130/creating-an-account.mdx @@ -6,7 +6,19 @@ description: Create an EIP-8130 smart account from a secp256k1, P-256, or WebAut `newSmartAccount` turns a signer into an EIP-8130 smart account. It auto-derives the controlling actor, the deployment bytecode, and the counterfactual CREATE2 address. The account is **not** deployed yet — it is deployed atomically inside its [first transaction](/eip8130/sending-a-transaction#deploy-on-first-use). -By default the account is an **`UpgradeableAccount`** deployed behind an ERC-1967 `UpgradeableProxy`, so its implementation can later be swapped via a CONFIG-key-signed `upgradeBySignature`. Pass `upgradeable: false` to deploy an immutable `DefaultHighRateAccount` behind a 45-byte ERC-1167 proxy instead. +There are two proxy shapes: + +- **`proxy: 'upgradeable'` (default)** — a 93-byte ERC-1967 `UpgradeableProxy` delegating to a real UUPS `UpgradeableAccount`, so the account is genuinely upgradeable via an owner-signed, multichain-safe `upgradeBySignature`. Pass the UUPS `implementation` you deployed. +- **`proxy: 'erc1167'`** — an immutable 45-byte minimal proxy to the canonical `DefaultAccount`. Smallest attack surface (no upgrade slot), but not multichain-upgrade-safe. + +```ts +newSmartAccount({ signer, implementation: upgradeableImpl }) // upgradeable (default) +newSmartAccount({ signer, proxy: 'erc1167' }) // immutable → DefaultAccount +``` + +:::warning +**Pending final implementation.** No canonical UUPS `UpgradeableAccount` is enshrined yet (the expected long-term implementation is Coinbase Smart Wallet v2). Until one is, the default `proxy: 'upgradeable'` **requires an explicit `implementation`** — a UUPS `UpgradeableAccount` you deployed (see [base/eip-8130-examples](https://github.com/base/eip-8130-examples)); it never silently falls back to the non-UUPS `DefaultAccount`. Once the address is enshrined the default goes live with no code change. +::: The signer type (K1 / P-256 / WebAuthn) is detected automatically. @@ -18,7 +30,8 @@ import { newSmartAccount } from 'viem/eip8130' const owner = privateKeyToAccount(generatePrivateKey()) -const account = newSmartAccount({ signer: owner }) +// Immutable DefaultAccount-backed account (no upgrade slot). +const account = newSmartAccount({ signer: owner, proxy: 'erc1167' }) account.address // counterfactual address (deterministic from the salt) account.createChange // include in `accountChanges` to deploy on first use @@ -32,6 +45,7 @@ import { newSmartAccount } from 'viem/eip8130' const owner = privateKeyToAccount(generatePrivateKey()) const account = newSmartAccount({ signer: owner, + proxy: 'erc1167', salt: '0x0000000000000000000000000000000000000000000000000000000000000001', }) ``` @@ -44,7 +58,7 @@ import { newSmartAccount, toP256Signer } from 'viem/eip8130' const signer = toP256Signer({ privateKey: P256.randomPrivateKey() }) -const account = newSmartAccount({ signer }) +const account = newSmartAccount({ signer, proxy: 'erc1167' }) ``` ## WebAuthn / passkey @@ -56,24 +70,32 @@ import { newSmartAccount, toWebAuthnSigner } from 'viem/eip8130' const credential = await createWebAuthnCredential({ name: 'vibes' }) const signer = toWebAuthnSigner(toWebAuthnAccount({ credential })) -const account = newSmartAccount({ signer }) +const account = newSmartAccount({ signer, proxy: 'erc1167' }) ``` ## Multiple initial keys -Register additional actors at creation with `extraActors`. Use the [`key`](/eip8130/rotating-owners#actors-and-keys) builders — the library sorts actors by `actorId` for you (a protocol requirement). +Register additional actors at creation: + +- **`admins`** — extra **unrestricted** (scope `0`) co-owners: multisig owners, recovery keys, a second device. Any scope you pass is ignored (forced to admin). +- **`extraActors`** — **scoped** actors: session keys, delegates, trusted executors, each carrying its own `scope`/`policyData` (see [`authorizeActor`](/eip8130/rotating-owners)). + +Use the [`key`](/eip8130/rotating-owners#actors-and-keys) builders — the library merges the signer, admins, and extra actors and sorts them by `actorId` for you (a protocol requirement), rejecting duplicates. ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { key, newSmartAccount, toP256Signer } from 'viem/eip8130' +import { actorScope, authorizeActor, key, newSmartAccount, toP256Signer } from 'viem/eip8130' import * as P256 from 'ox/P256' const owner = privateKeyToAccount(generatePrivateKey()) -const p256 = toP256Signer({ privateKey: P256.randomPrivateKey() }) +const recovery = toP256Signer({ privateKey: P256.randomPrivateKey() }) +const session = toP256Signer({ privateKey: P256.randomPrivateKey() }) const account = newSmartAccount({ signer: owner, - extraActors: [key.p256(p256.publicKey)], + proxy: 'erc1167', + admins: [key.p256(recovery.publicKey)], // co-owner / recovery + extraActors: [authorizeActor(key.p256(session.publicKey), { scope: actorScope.sender })], // session key }) ``` @@ -93,20 +115,24 @@ const account = toEoaAccount(privateKeyToAccount(generatePrivateKey())) ## Cross-chain (ERC-4337) portability -The same EIP-8130 account works on chains **without** native 8130 support through a bundler, by wrapping it as a standard Viem Smart Account. Execution routes through `executeBatch` on the `BackwardsCompatible4337Account` implementation, and deployment uses the Account Configuration contract as the ERC-4337 factory. +The same EIP-8130 account works on chains **without** native 8130 support through a bundler, by wrapping it as a standard Viem Smart Account. Execution routes through `executeBatch` on the `BackwardsCompatible4337Account` implementation, and deployment uses the Keystore contract as the ERC-4337 factory. -`BackwardsCompatible4337Account` is deployed as a fourth singleton by base's canonical `Deploy.s.sol` at `canonicalEip8130Deployment.accounts.erc4337` (a canonical CREATE2 address). Pass it as `implementation`: +`BackwardsCompatible4337Account` is **not** part of the canonical deployment yet — the ERC-4337 portable path is out of scope for the initial release, so `canonicalEip8130Deployment.accounts.erc4337` is intentionally unset. Deploy the implementation yourself (see [`base/eip-8130-examples`](https://github.com/base/eip-8130-examples)) and pass its address as `implementation`: ```ts import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' -import { canonicalEip8130Deployment, toSmartAccount } from 'viem/eip8130' +import { toSmartAccount } from 'viem/eip8130' + +// Address of your deployed `BackwardsCompatible4337Account` (same CREATE2 +// address on every chain you deploy it to). +const erc4337Implementation = '0x0000000000000000000000000000000000000000' const account = await toSmartAccount({ client, owner: privateKeyToAccount(generatePrivateKey()), userSalt: '0x0000000000000000000000000000000000000000000000000000000000000001', initialActors: [/* key.k1(owner.address), ... */], - implementation: canonicalEip8130Deployment.accounts.erc4337, + implementation: erc4337Implementation, }) ``` diff --git a/site/pages/eip8130/session-keys.mdx b/site/pages/eip8130/session-keys.mdx index 21d737029e..0366dadebe 100644 --- a/site/pages/eip8130/session-keys.mdx +++ b/site/pages/eip8130/session-keys.mdx @@ -105,9 +105,10 @@ import { toP256Signer, } from 'viem/eip8130' -// Same account address, driven by the session key. +// Same account address, driven by the session key (match the account's proxy). const sessionAccount = newSmartAccount({ signer: toP256Signer({ privateKey: sessionPrivateKey }), + proxy: 'erc1167', salt: accountSalt, }) diff --git a/site/pages/eip8130/sub-accounts.mdx b/site/pages/eip8130/sub-accounts.mdx index 93cdea3d79..d8a76b0518 100644 --- a/site/pages/eip8130/sub-accounts.mdx +++ b/site/pages/eip8130/sub-accounts.mdx @@ -20,9 +20,9 @@ import { newSmartAccount } from 'viem/eip8130' const owner = privateKeyToAccount(generatePrivateKey()) // A stable, human-meaningful salt derivation is convenient here. -const main = newSmartAccount({ signer: owner, salt: saltFor('main') }) -const trading = newSmartAccount({ signer: owner, salt: saltFor('trading') }) -const savings = newSmartAccount({ signer: owner, salt: saltFor('savings') }) +const main = newSmartAccount({ signer: owner, proxy: 'erc1167', salt: saltFor('main') }) +const trading = newSmartAccount({ signer: owner, proxy: 'erc1167', salt: saltFor('trading') }) +const savings = newSmartAccount({ signer: owner, proxy: 'erc1167', salt: saltFor('savings') }) main.address !== trading.address // distinct accounts, same owner key ``` diff --git a/src/eip8130/accounts/toAccount.ts b/src/eip8130/accounts/toAccount.ts index 4df53bd40d..ea6202cb56 100644 --- a/src/eip8130/accounts/toAccount.ts +++ b/src/eip8130/accounts/toAccount.ts @@ -281,37 +281,61 @@ export type NewSmartAccountParameters = { * - **P-256** — from `toP256Signer({ privateKey })` * - **WebAuthn / passkey** — from `toWebAuthnSigner(toWebAuthnAccount({ credential }))` * - * The signer's type is detected automatically: K1 signers expose `.address`; - * P-256 / WebAuthn signers expose `.publicKey` and `.authenticator`. + * The signer's type is detected automatically: K1 signers expose `.address` + * (and, for a `LocalAccount`, a SEC1 hex `.publicKey`); P-256 / WebAuthn + * signers expose an `{ x, y }` `.publicKey` and `.authenticator`. */ - signer: Signer & { publicKey?: { x: Hex; y: Hex } } + signer: Signer & { publicKey?: { x: Hex; y: Hex } | Hex | undefined } /** * Uniqueness factor for CREATE2 (bytes32). Randomly generated if omitted. * Pass the same salt across sessions to recover a deterministic address. */ salt?: Hex | undefined /** - * When `false` (default), the account is deployed as an ERC-1167 proxy to the - * canonical `DefaultAccount`. When `true`, `implementation` is required and - * is deployed behind an ERC-1967 `UpgradeableProxy`. Ignored if `code` is - * provided. + * Per-account proxy placed at the account address. + * + * - `'upgradeable'` (default) — 93-byte ERC-1967 {@link upgradeableProxyBytecode} + * delegating to a real UUPS `UpgradeableAccount`, so the account is genuinely + * upgradeable (owner-signed, multichain-safe `upgradeBySignature`). Uses + * `implementation` if given, else the enshrined `accounts.upgradeable`. It + * never falls back to the non-UUPS `DefaultAccount`. **PENDING FINAL + * IMPLEMENTATION**: no canonical UUPS impl is enshrined yet (expected: + * Coinbase Smart Wallet v2), so until then this path needs an explicit + * `implementation` (e.g. the `UpgradeableAccount` example from + * [base/eip-8130-examples](https://github.com/base/eip-8130-examples)). + * - `'erc1167'` — immutable 45-byte {@link erc1167Bytecode} minimal proxy to the + * canonical `DefaultAccount`. No upgrade slot (smallest attack surface), but + * not multichain-upgrade-safe. + * + * Ignored if `code` is provided. */ - upgradeable?: boolean | undefined + proxy?: 'erc1167' | 'upgradeable' | undefined /** - * Wallet implementation address the account proxies to. Defaults to the - * canonical `DefaultAccount` when `upgradeable` is `false`. Required when - * `upgradeable` is `true`. Ignored if `code` is provided. + * Implementation address the proxy delegates to. For `proxy: 'erc1167'` + * defaults to the canonical `DefaultAccount`; for `proxy: 'upgradeable'` + * defaults to the enshrined `accounts.upgradeable` (a UUPS `UpgradeableAccount` + * — required explicitly until one is enshrined). Swap it to back the account + * with a different wallet implementation you deployed. Ignored if `code` is + * provided. */ implementation?: Address | undefined /** - * Deployment bytecode override. Defaults to `upgradeableProxyBytecode(implementation)` - * (or `erc1167Bytecode(implementation)` when `upgradeable` is `false`). + * Deployment bytecode override. Bypasses `proxy` / `implementation` — supply + * the full runtime bytecode placed at the account address. */ code?: Hex | undefined /** - * Additional actors to include at account creation alongside the signer's own - * actor. All actors are sorted by `actorId` in strictly ascending order (as - * required by the protocol). + * Additional **admin** (unrestricted, scope `0`) actors registered at creation + * alongside the signer's own actor — co-owners, multisig owners, or recovery + * keys. Any `scope`/`policyData` on these is ignored (forced to admin). Build + * them with {@link key} (e.g. `key.p256(pubkey)`, `key.k1(addr)`). + */ + admins?: readonly AaActor[] | undefined + /** + * Additional **scoped** actors registered at creation — session keys, + * delegates, or trusted executors. Each carries its own `scope`/`policyData` + * (see {@link authorizeActor}). All actors (signer + admins + extras) are + * sorted by `actorId` in strictly ascending order (protocol requirement). */ extraActors?: readonly AaActor[] | undefined /** AccountConfiguration contract override (advanced). Defaults to canonical. */ @@ -349,18 +373,21 @@ export type NewSmartAccountReturnType = ToAccountReturnType & { * automatically from the signer object. * * @example - * // K1 (EOA private key) - * const account = newSmartAccount({ signer: privateKeyToAccount(pk) }) + * // Immutable ERC-1167 proxy to the canonical DefaultAccount + * const account = newSmartAccount({ signer: privateKeyToAccount(pk), proxy: 'erc1167' }) * * @example - * // P-256 - * const p256 = toP256Signer({ privateKey: P256.randomPrivateKey() }) - * const account = newSmartAccount({ signer: p256 }) + * // Upgradeable (default): supply a UUPS UpgradeableAccount you deployed — + * // required until a canonical upgradeable impl is enshrined. + * const account = newSmartAccount({ signer, implementation: upgradeableAccountImpl }) * * @example - * // WebAuthn / passkey - * const webAuthn = toWebAuthnSigner(toWebAuthnAccount({ credential })) - * const account = newSmartAccount({ signer: webAuthn }) + * // Multisig / co-owners + a session key at creation + * const account = newSmartAccount({ + * signer, + * admins: [key.p256(recoveryPubkey)], // extra unrestricted owners + * extraActors: [authorizeActor(key.p256(sessionPubkey), { scope: actorScope.sender })], + * }) * * @example * // First tx: create + call in one shot @@ -384,7 +411,8 @@ export function newSmartAccount( const { signer, implementation, - upgradeable = false, + proxy = 'upgradeable', + admins = [], extraActors = [], accountConfigAddress, } = parameters @@ -401,31 +429,64 @@ export function newSmartAccount( : key.p256(signer.publicKey) : key.k1(signer.address) + // Admins are unrestricted co-owners: strip any scope/policyData so they are + // registered as admin (scope 0) actors regardless of what was passed in. + const adminActors: AaActor[] = admins.map((a) => ({ + actorId: a.actorId, + authenticator: a.authenticator, + })) + // Sort all actors by actorId (strictly ascending — protocol requirement). - const allActors: AaActor[] = [primaryActor, ...extraActors].sort((a, b) => { + const allActors: AaActor[] = [ + primaryActor, + ...adminActors, + ...extraActors, + ].sort((a, b) => { const ai = hexToBigInt(a.actorId as Hex) const bi = hexToBigInt(b.actorId as Hex) return ai < bi ? -1 : ai > bi ? 1 : 0 }) + // Reject duplicate actor ids: the protocol requires *strictly* ascending + // order, and a repeated id would otherwise corrupt address derivation. + for (let i = 1; i < allActors.length; i++) + if (allActors[i]!.actorId === allActors[i - 1]!.actorId) + throw new BaseError( + `Duplicate initial actor id \`${allActors[i]!.actorId}\` (signer, admins, and extraActors must be distinct).`, + ) + const salt = parameters.salt ?? randomBytes32() - // Canonical accounts use an ERC-1167 proxy to DefaultAccount. Upgradeability - // remains available only when the caller explicitly supplies an implementation. - let code = parameters.code - if (!code) { - if (upgradeable) { - if (!implementation) + // Proxy selection. + // - 'erc1167' — immutable minimal proxy → `implementation` ?? DefaultAccount. + // - 'upgradeable' — ERC-1967 proxy → a real UUPS `UpgradeableAccount`, so the + // account is *actually* upgradeable (owner-signed `upgradeBySignature`, + // multichain-safe). It must NOT silently fall back to the non-UUPS + // DefaultAccount, so we require an explicit `implementation` or an enshrined + // `accounts.upgradeable`. + // + // PENDING FINAL IMPLEMENTATION: `accounts.upgradeable` is not enshrined yet + // (expected long-term impl: Coinbase Smart Wallet v2). Until it is, the default + // `proxy: 'upgradeable'` path needs an explicit `implementation`. + const code = + parameters.code ?? + (() => { + if (proxy === 'erc1167') + return erc1167Bytecode( + implementation ?? canonicalEip8130Deployment.accounts.default, + ) + const impl = + implementation ?? canonicalEip8130Deployment.accounts.upgradeable + if (!impl) throw new BaseError( - '`implementation` is required for `upgradeable: true`; the canonical deployment does not include the unaudited UpgradeableAccount example.', + 'No canonical `UpgradeableAccount` is enshrined yet (pending final ' + + 'implementation), so `proxy: "upgradeable"` requires an explicit ' + + '`implementation` — a UUPS UpgradeableAccount you deployed (see ' + + 'https://github.com/base/eip-8130-examples). Alternatively pass ' + + '`proxy: "erc1167"` for an immutable DefaultAccount-backed account.', ) - code = upgradeableProxyBytecode(implementation) - } else { - code = erc1167Bytecode( - implementation ?? canonicalEip8130Deployment.accounts.default, - ) - } - } + return upgradeableProxyBytecode(impl) + })() const inner = toAccount({ signer, diff --git a/src/eip8130/constants.ts b/src/eip8130/constants.ts index ae93883099..e69c0cab9b 100644 --- a/src/eip8130/constants.ts +++ b/src/eip8130/constants.ts @@ -183,7 +183,7 @@ export const canonicalAuthenticators = { /** WebAuthn / FIDO2 passkey. Canonical base/eip-8130 deployment. */ passkey: '0x813007b6b1b48E75D91dEc5927ab515d12a0F1d0', /** Signature delegation (1-hop). Canonical base/eip-8130 deployment. */ - delegate: '0x81302CC9e53aB471abf9c5924aDD6CF0A3eBADE1', + delegate: '0x813077C6d0931a8FD93e59dB9e2E7b56364AaDe1', } as const satisfies Record /** @@ -223,7 +223,7 @@ export const txContextAddress = * parameter of {@link computeAddress}. */ export const accountConfigAddress = - '0x8130f09E345cE43531DF25966017710030Dc00AC' satisfies Hex + '0x8130b291585518d44a6250952b7385d00DB900ac' satisfies Hex /** * Default wallet implementation for EOA auto-delegation @@ -234,7 +234,7 @@ export const accountConfigAddress = * {@link accountConfigAddress}. */ export const defaultAccountAddress = - '0x81301D5aFE1DE3B255781876FC07eD45C150AdEF' satisfies Hex + '0x8130920597E715374C513C0b77D1E2bD0A7AAdef' satisfies Hex /** Size of the deployment header in bytes (`DEPLOYMENT_HEADER_SIZE`). */ export const deploymentHeaderSize = 14 diff --git a/src/eip8130/deployments.ts b/src/eip8130/deployments.ts index 13b8511a52..d14c60f326 100644 --- a/src/eip8130/deployments.ts +++ b/src/eip8130/deployments.ts @@ -51,9 +51,12 @@ export type Eip8130Deployment = { alwaysValid: Address } /** - * Example actor-policy contracts (unaudited reference). A restricted actor is - * gated to the `manager`; the manager forwards committed call plans built by - * the policy. See `viem/eip8130` policy helpers. + * Actor-policy contracts. Policies are app-level, not part of the EIP-8130 + * protocol: a restricted actor is gated to the `manager`, which forwards + * committed call plans built by the policy. Base ships one audited + * `PolicyManager` and one `SessionPolicy` in use today; both are extensible — + * add a new policy under the same manager, or deploy a new manager. See + * `viem/eip8130` policy helpers. */ policies?: { /** PolicyManager — the single target a policy-gated actor may call. */ @@ -82,24 +85,32 @@ export type Eip8130Deployment = { * bytecode change), all addresses must be re-derived and this object updated. */ export const canonicalEip8130Deployment = { - accountConfiguration: '0x8130f09E345cE43531DF25966017710030Dc00AC', + accountConfiguration: '0x8130b291585518d44a6250952b7385d00DB900ac', accounts: { - // `upgradeable` / `erc4337` are unaudited example wallets and are not part - // of the canonical deployment. Callers must provide those implementations - // explicitly if they choose an example-specific path. - default: '0x81301D5aFE1DE3B255781876FC07eD45C150AdEF', - defaultHighRate: '0x81301B078907cad978E37E8Cf7F91d44f305fA57', + // PENDING FINAL IMPLEMENTATION: the default `newSmartAccount` proxy is + // `'upgradeable'`, which must delegate to a real UUPS `UpgradeableAccount` + // (see base/eip-8130-examples) so accounts are genuinely upgradeable and + // multichain-safe. No such implementation is enshrined against the canonical + // Keystore yet — its address is keystore-dependent (constructor arg), and + // the expected long-term implementation is Coinbase Smart Wallet v2. Until an + // address is set here, `proxy: 'upgradeable'` requires an explicit + // `implementation`. Set `upgradeable` once deployed and the default goes live. + upgradeable: undefined, + default: '0x8130920597E715374C513C0b77D1E2bD0A7AAdef', + defaultHighRate: '0x8130f457Acda6659911897fd1514f235eA4dFA57', + // `erc4337` (BackwardsCompatible4337Account) is intentionally out of scope + // for now — supply it explicitly if you choose the ERC-4337 portable path. }, authenticators: { k1: '0x0000000000000000000000000000000000000001', p256: '0x8130C89F65750431b564A4730397552a11CeA256', webAuthn: '0x813007b6b1b48E75D91dEc5927ab515d12a0F1d0', - delegate: '0x81302CC9e53aB471abf9c5924aDD6CF0A3eBADE1', + delegate: '0x813077C6d0931a8FD93e59dB9e2E7b56364AaDe1', alwaysValid: '0xA550545Da91720c23483c5B3493412A02D1cF9F9', }, policies: { - manager: '0x8130646ffaB930BEBd601D06315118071d7F0ac1', - sessionPolicy: '0x8130309A18c9923b4523B448325F7e9529695e55', + manager: '0x8130427F403f58513d09DD8CDc57f919c3a40ac1', + sessionPolicy: '0x813058cC4b7a0248274BBd6DACcA825237735E55', }, } as const satisfies Eip8130Deployment diff --git a/src/eip8130/devx.test.ts b/src/eip8130/devx.test.ts index 4217456412..fbcae60fbd 100644 --- a/src/eip8130/devx.test.ts +++ b/src/eip8130/devx.test.ts @@ -27,7 +27,7 @@ import { } from './keys.js' import { actorIdFromAddress, actorIdFromPublicKey } from './utils/actorId.js' import { parseTransaction } from './utils/parseTransaction.js' -import { erc1167Bytecode } from './utils/proxy.js' +import { erc1167Bytecode, upgradeableProxyBytecode } from './utils/proxy.js' const owner = privateKeyToAccount( '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', @@ -42,22 +42,89 @@ const pubkey = { } as const describe('canonical smart-account deployment', () => { - test('defaults to an ERC-1167 proxy to DefaultAccount', () => { - const account = newSmartAccount({ signer: owner, salt: userSalt }) + test('default upgradeable proxy requires an implementation until enshrined', () => { + // PENDING FINAL IMPLEMENTATION: no canonical UUPS UpgradeableAccount is + // enshrined yet, so the default `proxy: 'upgradeable'` needs an explicit impl. + expect(() => newSmartAccount({ signer: owner, salt: userSalt })).toThrow( + 'No canonical `UpgradeableAccount` is enshrined yet', + ) + }) + + test('upgradeable proxy to an explicit UUPS implementation', () => { + const impl = '0x00000000000000000000000000000000000000Ec' as const + const account = newSmartAccount({ + signer: owner, + salt: userSalt, + implementation: impl, + }) + + expect(account.createChange.code).toBe(upgradeableProxyBytecode(impl)) + }) + + test('proxy: "erc1167" deploys an immutable minimal proxy to DefaultAccount', () => { + const account = newSmartAccount({ + signer: owner, + salt: userSalt, + proxy: 'erc1167', + }) expect(account.createChange.code).toBe( erc1167Bytecode(canonicalEip8130Deployment.accounts.default), ) }) - test('requires an explicit implementation for upgradeable accounts', () => { + test('swaps the implementation the proxy delegates to', () => { + const impl = '0x00000000000000000000000000000000000000Ec' as const + expect( + newSmartAccount({ signer: owner, salt: userSalt, implementation: impl }) + .createChange.code, + ).toBe(upgradeableProxyBytecode(impl)) + expect( + newSmartAccount({ + signer: owner, + salt: userSalt, + proxy: 'erc1167', + implementation: impl, + }).createChange.code, + ).toBe(erc1167Bytecode(impl)) + }) + + test('merges admins + extraActors, sorted by actorId', () => { + const co = privateKeyToAccount( + '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', + ) + const account = newSmartAccount({ + signer: owner, + salt: userSalt, + proxy: 'erc1167', + admins: [key.k1(co.address)], + extraActors: [ + authorizeActor(key.p256(pubkey), { scope: actorScope.sender }), + ], + }) + + const ids = account.createChange.initialActors.map((a) => a.actorId) + // strictly ascending + expect([...ids].sort()).toStrictEqual(ids) + expect(ids).toContain(actorIdFromAddress(owner.address)) + expect(ids).toContain(actorIdFromAddress(co.address)) + expect(ids).toContain(actorIdFromPublicKey(pubkey)) + // admins are forced to unrestricted (no scope) + const coActor = account.createChange.initialActors.find( + (a) => a.actorId === actorIdFromAddress(co.address), + ) + expect(coActor?.scope).toBeUndefined() + }) + + test('rejects duplicate initial actor ids', () => { expect(() => newSmartAccount({ signer: owner, salt: userSalt, - upgradeable: true, + proxy: 'erc1167', + admins: [key.k1(owner.address)], }), - ).toThrow('`implementation` is required for `upgradeable: true`') + ).toThrow('Duplicate initial actor id') }) }) diff --git a/src/eip8130/nonce.test.ts b/src/eip8130/nonce.test.ts index 62dca2abf2..7f3e3a68d7 100644 --- a/src/eip8130/nonce.test.ts +++ b/src/eip8130/nonce.test.ts @@ -123,7 +123,10 @@ describe('sendCalls nonce integration', () => { maxPriorityFeePerGas: 1_000_000_000n, } const calls = [ - { to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }, + { + to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const, + data: '0x' as const, + }, ] test('nonceless: no nonce read, tx carries NONCE_KEY_MAX + validBefore', async () => { diff --git a/src/eip8130/policies.test.ts b/src/eip8130/policies.test.ts index 1eb20b16fe..c0fd4a092c 100644 --- a/src/eip8130/policies.test.ts +++ b/src/eip8130/policies.test.ts @@ -50,7 +50,7 @@ describe('encoders', () => { describe('commitmentOf', () => { test('matches PolicyManager.commitmentOf reference vector', () => { expect(commitmentOf(binding)).toBe( - '0x99f5258c7da5ed6dcc01fcc552cdfc1a69369487bcfaa4f623e6e37f6780e8e7', + '0x75038539ec7d5b1284b0ee42ebef12db17a334d5fe73189d8449881a3bdd68c3', ) }) diff --git a/src/eip8168/eip8168.test.ts b/src/eip8168/eip8168.test.ts index 66152e90a4..3adee24d4f 100644 --- a/src/eip8168/eip8168.test.ts +++ b/src/eip8168/eip8168.test.ts @@ -32,7 +32,10 @@ const PAYER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' as const const FEE_RECIPIENT = '0x90F79bf6EB2c4f870365E785982E1f101E93b906' as const const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const const userCalls = [ - { to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' as const }, + { + to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const, + data: '0x' as const, + }, ] const gasEstimate = { @@ -158,7 +161,15 @@ describe('buildSponsoredCalls', () => { { ...tokenTerms.options[0], tokens: [ - { ...tokenTerms.options[0].tokens[0], feeRecipient: undefined }, + { + ...( + tokenTerms.options[0] as Extract< + GetTermsReturnType['options'][number], + { kind: 'token' } + > + ).tokens[0], + feeRecipient: undefined, + }, ], } as (typeof tokenTerms.options)[number], ], From bdcdf0f9bab2c6a137e297bf9bf6fb1bad0288bb Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 14:44:39 -0400 Subject: [PATCH 64/96] chore(eip8130): sync canonical addresses with base/eip-8130#79 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit base/eip-8130 #79 skips expired unsequenced (JIT) `AuthorizeActor` grants instead of reverting and removes the `ExpiredChange` error. That is a contract-side behavior change with no viem surface — viem never documented the revert path — so no client logic changes. The bytecode change regenerated the `0x8130` vanity CREATE2 addresses (verified via `forge script Deploy.s.sol --sig addresses()`): - Keystore, DefaultAccount, CanonicalHighRatePayerAccount - DelegateAuthenticator, PolicyManager, SessionPolicy - P256 / WebAuthn authenticators unchanged Updates `deployments.ts` + `constants.ts` to the new set (all valid EIP-55) and refreshes the `commitmentOf` reference vector (depends on the SessionPolicy address). eip8130 + eip8168 unit tests (137) pass. --- src/eip8130/constants.ts | 6 +++--- src/eip8130/deployments.ts | 12 ++++++------ src/eip8130/policies.test.ts | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/eip8130/constants.ts b/src/eip8130/constants.ts index e69c0cab9b..af09a1eb81 100644 --- a/src/eip8130/constants.ts +++ b/src/eip8130/constants.ts @@ -183,7 +183,7 @@ export const canonicalAuthenticators = { /** WebAuthn / FIDO2 passkey. Canonical base/eip-8130 deployment. */ passkey: '0x813007b6b1b48E75D91dEc5927ab515d12a0F1d0', /** Signature delegation (1-hop). Canonical base/eip-8130 deployment. */ - delegate: '0x813077C6d0931a8FD93e59dB9e2E7b56364AaDe1', + delegate: '0x8130b7D430D041ED4050935814D493299980aDE1', } as const satisfies Record /** @@ -223,7 +223,7 @@ export const txContextAddress = * parameter of {@link computeAddress}. */ export const accountConfigAddress = - '0x8130b291585518d44a6250952b7385d00DB900ac' satisfies Hex + '0x81305d4f4976220D2af17E5Dc246848E235600AC' satisfies Hex /** * Default wallet implementation for EOA auto-delegation @@ -234,7 +234,7 @@ export const accountConfigAddress = * {@link accountConfigAddress}. */ export const defaultAccountAddress = - '0x8130920597E715374C513C0b77D1E2bD0A7AAdef' satisfies Hex + '0x813078f98b3eb214046C8Dc93A771ac9de5AaDEf' satisfies Hex /** Size of the deployment header in bytes (`DEPLOYMENT_HEADER_SIZE`). */ export const deploymentHeaderSize = 14 diff --git a/src/eip8130/deployments.ts b/src/eip8130/deployments.ts index d14c60f326..b2db5e2568 100644 --- a/src/eip8130/deployments.ts +++ b/src/eip8130/deployments.ts @@ -85,7 +85,7 @@ export type Eip8130Deployment = { * bytecode change), all addresses must be re-derived and this object updated. */ export const canonicalEip8130Deployment = { - accountConfiguration: '0x8130b291585518d44a6250952b7385d00DB900ac', + accountConfiguration: '0x81305d4f4976220D2af17E5Dc246848E235600AC', accounts: { // PENDING FINAL IMPLEMENTATION: the default `newSmartAccount` proxy is // `'upgradeable'`, which must delegate to a real UUPS `UpgradeableAccount` @@ -96,8 +96,8 @@ export const canonicalEip8130Deployment = { // address is set here, `proxy: 'upgradeable'` requires an explicit // `implementation`. Set `upgradeable` once deployed and the default goes live. upgradeable: undefined, - default: '0x8130920597E715374C513C0b77D1E2bD0A7AAdef', - defaultHighRate: '0x8130f457Acda6659911897fd1514f235eA4dFA57', + default: '0x813078f98b3eb214046C8Dc93A771ac9de5AaDEf', + defaultHighRate: '0x8130931874c894aC4963e128D6273AE520dAFa57', // `erc4337` (BackwardsCompatible4337Account) is intentionally out of scope // for now — supply it explicitly if you choose the ERC-4337 portable path. }, @@ -105,12 +105,12 @@ export const canonicalEip8130Deployment = { k1: '0x0000000000000000000000000000000000000001', p256: '0x8130C89F65750431b564A4730397552a11CeA256', webAuthn: '0x813007b6b1b48E75D91dEc5927ab515d12a0F1d0', - delegate: '0x813077C6d0931a8FD93e59dB9e2E7b56364AaDe1', + delegate: '0x8130b7D430D041ED4050935814D493299980aDE1', alwaysValid: '0xA550545Da91720c23483c5B3493412A02D1cF9F9', }, policies: { - manager: '0x8130427F403f58513d09DD8CDc57f919c3a40ac1', - sessionPolicy: '0x813058cC4b7a0248274BBd6DACcA825237735E55', + manager: '0x813077055d1110F92191ccE13018f51820B40ac1', + sessionPolicy: '0x81300Fd9bCa7DC7982474d0eaB0d936FF1C25E55', }, } as const satisfies Eip8130Deployment diff --git a/src/eip8130/policies.test.ts b/src/eip8130/policies.test.ts index c0fd4a092c..39c624f32e 100644 --- a/src/eip8130/policies.test.ts +++ b/src/eip8130/policies.test.ts @@ -50,7 +50,7 @@ describe('encoders', () => { describe('commitmentOf', () => { test('matches PolicyManager.commitmentOf reference vector', () => { expect(commitmentOf(binding)).toBe( - '0x75038539ec7d5b1284b0ee42ebef12db17a334d5fe73189d8449881a3bdd68c3', + '0x76958bee732b160cb2b85c73c153db765cf10892871632afd5746cbba149bf33', ) }) From be2bdf9ee64f62f399d97130f1854a100cb584ec Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 14:54:04 -0400 Subject: [PATCH 65/96] feat(eip8130): add incrementLocalEpoch change builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `incrementLocalEpoch` (ChangeType 0x02) was fully plumbed (type, wire byte, encode/parse/gas) and already accepted by `account.change`, but had no ergonomic builder like `authorizeActor` / `revokeActor` — callers had to hand-write `{ changeType: 0x02 }`. Add a named, discoverable, typed builder plus a serialize<->parse round-trip test, and document it in the owner-rotation guide as a "revoke all pending" epoch kill-switch. Reads already expose the epoch via `getConfigSequence` (`localEpoch`); lock / unlock already ship `lockChange` / `unlockChange` builders in `lock.ts`. --- site/pages/eip8130/rotating-owners.mdx | 27 +++++++++++++ src/eip8130/index.ts | 1 + src/eip8130/keys.ts | 18 +++++++++ .../utils/serializeTransaction.test.ts | 39 ++++++++++++++++++- 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/site/pages/eip8130/rotating-owners.mdx b/site/pages/eip8130/rotating-owners.mdx index 944f86bbc0..7efdcb800e 100644 --- a/site/pages/eip8130/rotating-owners.mdx +++ b/site/pages/eip8130/rotating-owners.mdx @@ -107,6 +107,33 @@ const rotate = await account.change( ) ``` +## Invalidate pending changes (epoch bump) + +`incrementLocalEpoch()` bumps the account's local epoch, invalidating **every** unlanded local-channel signature signed at a prior epoch in one shot — an instant "revoke all pending" kill-switch (e.g. after a suspected key compromise, or to void outstanding just-in-time session-key grants that haven't landed). It takes no payload; sign it at the current `local` sequence like any other change: + +```ts +import { getConfigSequence, incrementLocalEpoch, sendCalls } from 'viem/eip8130' + +const { local: sequence } = await getConfigSequence(client, { + accountConfiguration, + account: account.address, +}) + +const bump = await account.change([incrementLocalEpoch()], { + chainId: client.chain.id, + sequence, +}) + +const hash = await sendCalls(client, { + account, + accountChanges: [bump], + calls: [], + gas: 200_000n, +}) +``` + +The new epoch is reflected in `getConfigSequence`'s `localEpoch` once it lands. + ## Rotate during deployment For a delegated EOA, you can delegate *and* install new keys in the very first transaction — no separate account handle required: diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index 05f8c84803..b7c23257e1 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -155,6 +155,7 @@ export { authorizeActor, canUseSequencedNonce, encodePolicyData, + incrementLocalEpoch, isNoncelessOnly, key, type Policy, diff --git a/src/eip8130/keys.ts b/src/eip8130/keys.ts index 004b50fc39..8bd8bcf661 100644 --- a/src/eip8130/keys.ts +++ b/src/eip8130/keys.ts @@ -14,6 +14,7 @@ import { import type { AaActor, AaAuthorizeActor, + AaIncrementLocalEpoch, AaRevokeActor, } from './types/transaction.js' import { actorIdFromAddress, actorIdFromPublicKey } from './utils/actorId.js' @@ -196,3 +197,20 @@ export function revokeActor(actor: AaActor | Hex): AaRevokeActor { const actorId = typeof actor === 'string' ? actor : actor.actorId return { changeType: 0x01, actorId } } + +/** + * Builds an `incrementLocalEpoch` change: bumps the account's local epoch, + * invalidating **every** unlanded local-channel signature signed at a prior + * epoch in one shot — an instant "revoke all pending" / rotation kill-switch + * (e.g. after a suspected key compromise, or to void outstanding JIT grants). + * + * Sign it like any other change via `account.change` at the current `local` + * sequence from {@link getConfigSequence}; it takes no payload. + * + * @example + * const { local } = await getConfigSequence(client, { accountConfiguration, account }) + * const bump = await account.change([incrementLocalEpoch()], { chainId, sequence: local }) + */ +export function incrementLocalEpoch(): AaIncrementLocalEpoch { + return { changeType: 0x02 } +} diff --git a/src/eip8130/utils/serializeTransaction.test.ts b/src/eip8130/utils/serializeTransaction.test.ts index c4c3d902c1..a2565fe8d1 100644 --- a/src/eip8130/utils/serializeTransaction.test.ts +++ b/src/eip8130/utils/serializeTransaction.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from 'vitest' import { keccak256 } from '../../utils/hash/keccak256.js' -import { aaPayerType, aaTransactionType, nonceKeyMax } from '../constants.js' +import { + aaPayerType, + aaTransactionType, + changeType, + nonceKeyMax, +} from '../constants.js' +import { incrementLocalEpoch } from '../keys.js' import type { TransactionSerializable8130 } from '../types/transaction.js' import { getPayerSignatureHash, @@ -157,6 +163,37 @@ describe('serializeTransaction (EIP-8130)', () => { }) }) + test('account changes: incrementLocalEpoch (empty-payload op)', () => { + const change = incrementLocalEpoch() + expect(change).toEqual({ changeType: changeType.incrementLocalEpoch }) + + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: alice, + maxFeePerGas: 2n, + accountChanges: [ + { + type: 'config', + channel: 'local', + sequence: 5n, + changes: [change], + signature: '0xfeed', + }, + ], + senderAuth, + } + const serialized = serializeTransaction(transaction) + expect(serializeTransaction(parseTransaction(serialized))).toEqual( + serialized, + ) + const parsed = parseTransaction(serialized) + const config = parsed.accountChanges?.[0] + expect(config).toMatchObject({ type: 'config', channel: 'local' }) + expect(config && 'changes' in config && config.changes?.[0]).toEqual({ + changeType: changeType.incrementLocalEpoch, + }) + }) + test('nonce-free mode (nonceKeyMax)', () => { const transaction: TransactionSerializable8130 = { chainId: 8453, From fc485a7bfa5799f533aea0f98ad086593d8a7fc2 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 14:58:27 -0400 Subject: [PATCH 66/96] docs(eip8130): pass config sequence as bigint, not Number() The local config sequence is a packed `localEpoch << 32 | localSequence` word that can exceed Number.MAX_SAFE_INTEGER, and `account.change` takes a `bigint`. Drop the lossy `Number(sequence)` in the rotate-owners examples. --- site/pages/eip8130/rotating-owners.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/pages/eip8130/rotating-owners.mdx b/site/pages/eip8130/rotating-owners.mdx index 7efdcb800e..ccc2bd82cb 100644 --- a/site/pages/eip8130/rotating-owners.mdx +++ b/site/pages/eip8130/rotating-owners.mdx @@ -75,7 +75,7 @@ const change = await account.change( scope: actorScope.sender, // full owner? use scope 0 }), ], - { chainId: client.chain.id, sequence: Number(sequence) }, + { chainId: client.chain.id, sequence }, // bigint, straight from getConfigSequence ) const hash = await sendCalls(client, { @@ -103,7 +103,7 @@ const rotate = await account.change( authorizeActor(key.k1('0xnewOwner...'), { scope: actorScope.sender }), revokeActor(key.k1('0xoldOwner...')), ], - { chainId: client.chain.id, sequence: Number(sequence) }, + { chainId: client.chain.id, sequence }, ) ``` From 0dcaaa9a44d0a5245566a809250da1856c598a60 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 16:53:01 -0400 Subject: [PATCH 67/96] =?UTF-8?q?feat(eip8130):=20add=20ERC-7715=20?= =?UTF-8?q?=E2=86=92=20SessionPolicy=20adapter=20(toSessionPolicy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lowers a `wallet_grantPermissions` request (7715 permissions + expiry) onto EIP-8130 session-key primitives: - `toSessionPolicyConfig(permissions)`: pure mapping of native/erc20 transfer and contract-call permissions (+ token-allowance / rate-limit policies) to a `SessionPolicyConfig`. rate-limit `interval` becomes the cap's reset `period` (recurring allowance); gas-limit is left to the payer/8168 layer; unbounded transfers and custom permissions/policies throw. - `toSessionPolicy(params)`: binds the lowered config to an account via `defineSessionPolicy`, mapping the 7715 `expiry` to `validUntil`. Exported from viem/eip8130, unit-tested, and documented in session-keys. --- site/pages/eip8130/session-keys.mdx | 51 ++++++ src/eip8130/index.ts | 7 + src/eip8130/permissions.test.ts | 173 ++++++++++++++++++++ src/eip8130/permissions.ts | 245 ++++++++++++++++++++++++++++ 4 files changed, 476 insertions(+) create mode 100644 src/eip8130/permissions.test.ts create mode 100644 src/eip8130/permissions.ts diff --git a/site/pages/eip8130/session-keys.mdx b/site/pages/eip8130/session-keys.mdx index 0366dadebe..e6d7861ece 100644 --- a/site/pages/eip8130/session-keys.mdx +++ b/site/pages/eip8130/session-keys.mdx @@ -130,3 +130,54 @@ const hash = await sendCalls(client, { ``` A transfer that exceeds the weekly limit, or targets a contract/selector outside the allowlist, reverts inside the manager — so the key can only ever act within its committed policy. + +## Fulfilling an ERC-7715 request + +When a dApp asks a wallet for a session key via [ERC-7715](https://eips.ethereum.org/EIPS/eip-7715) `wallet_grantPermissions`, the request arrives as a set of `permissions` (+ policies) and a top-level `expiry`. `toSessionPolicy` lowers that request directly onto the EIP-8130 primitives above — no need to hand-author the `SessionPolicyConfig`: + +```ts +import { parseUnits } from 'viem' +import { + actorScope, + authorizeActor, + key, + toSessionPolicy, +} from 'viem/eip8130' + +// The `permissions` + `expiry` a dApp sent via `wallet_grantPermissions`. +const session = toSessionPolicy({ + account: account.address, + expiry: Math.floor(Date.now() / 1000) + 7 * 86_400, + permissions: [ + { + // ≤ 100 USDC / week (a recurring "subscription") + type: 'erc20-token-transfer', + data: { address: usdc, ticker: 'USDC' }, + policies: [ + { type: 'token-allowance', data: { allowance: parseUnits('100', 6) } }, + { type: 'rate-limit', data: { count: 1, interval: 7 * 86_400 } }, + ], + }, + ], +}) + +// Authorize the requested key — its signed commitment *is* the grant. +const change = await account.change([ + authorizeActor(key.p256({ x, y }), { + scope: actorScope.sender, + policy: session.actorPolicy, + }), +]) +``` + +The mapping is: + +| ERC-7715 | EIP-8130 `SessionPolicy` | +| --- | --- | +| `native-token-transfer` + `token-allowance` | native-ETH `tokenLimit` (gated on each call's `value`) | +| `erc20-token-transfer` + `token-allowance` | ERC-20 `tokenLimit` **and** a `callScope` for `transfer`/`transferFrom` on the token | +| `contract-call` `{ address, calls }` | a `callScope` restricting the key to those selectors on the target | +| `rate-limit` `interval` (on a transfer) | the cap's reset `period` (a recurring allowance) | +| `gas-limit` | ignored — gas is settled by the payer / [ERC-8168](/eip8168) layer | + +A transfer permission without a `token-allowance` throws (an unbounded spend can't be safely granted), as do `custom` permissions/policies — build the `SessionPolicyConfig` explicitly for those. Use `toSessionPolicyConfig` on its own if you only need the lowered config (e.g. to inspect or extend it before binding). diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index b7c23257e1..29ce099a40 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -169,6 +169,13 @@ export { unlockChange, } from './lock.js' export { type Nonce, nonce } from './nonce.js' +export { + type ToSessionPolicyConfigErrorType, + type ToSessionPolicyErrorType, + type ToSessionPolicyParameters, + toSessionPolicy, + toSessionPolicyConfig, +} from './permissions.js' export { type CommitmentOfErrorType, commitmentOf, diff --git a/src/eip8130/permissions.test.ts b/src/eip8130/permissions.test.ts new file mode 100644 index 0000000000..b54515b12f --- /dev/null +++ b/src/eip8130/permissions.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from 'vitest' +import { zeroAddress } from '../constants/address.js' +import type { Permission } from '../experimental/erc7715/types/permission.js' +import { toSessionPolicy, toSessionPolicyConfig } from './permissions.js' +import { defineSessionPolicy, encodeSessionPolicyConfig } from './policies.js' + +const account = '0x0000000000000000000000000000000000000a11' +const usdc = '0x0000000000000000000000000000000000000a22' +const nft = '0x0000000000000000000000000000000000000b33' + +const transfer = '0xa9059cbb' +const transferFrom = '0x23b872dd' + +describe('toSessionPolicyConfig', () => { + test('erc20-token-transfer → tokenLimit + transfer call scope', () => { + const config = toSessionPolicyConfig([ + { + type: 'erc20-token-transfer', + data: { address: usdc, ticker: 'USDC' }, + policies: [ + { type: 'token-allowance', data: { allowance: 100_000_000n } }, + ], + }, + ]) + expect(config).toEqual({ + tokenLimits: [{ token: usdc, limit: 100_000_000n, period: undefined }], + callScopes: [ + { + target: usdc, + selectorRules: [{ selector: transfer }, { selector: transferFrom }], + }, + ], + }) + }) + + test('rate-limit interval → recurring period (subscription)', () => { + const config = toSessionPolicyConfig([ + { + type: 'erc20-token-transfer', + data: { address: usdc, ticker: 'USDC' }, + policies: [ + { type: 'token-allowance', data: { allowance: 100_000_000n } }, + { type: 'rate-limit', data: { count: 1, interval: 7 * 86400 } }, + ], + }, + ]) + expect(config.tokenLimits?.[0]).toEqual({ + token: usdc, + limit: 100_000_000n, + period: 604_800n, + }) + }) + + test('native-token-transfer → native (zero address) tokenLimit', () => { + const config = toSessionPolicyConfig([ + { + type: 'native-token-transfer', + data: { ticker: 'ETH' }, + policies: [ + { type: 'token-allowance', data: { allowance: 1_000_000_000n } }, + ], + }, + ]) + expect(config.tokenLimits).toEqual([ + { token: zeroAddress, limit: 1_000_000_000n, period: undefined }, + ]) + expect(config.callScopes).toBeUndefined() + }) + + test('contract-call → call scope from signatures and raw selectors', () => { + const config = toSessionPolicyConfig([ + { + type: 'contract-call', + data: { address: nft, calls: ['mint(address)', '0xDEADBEEF'] }, + policies: [], + }, + ]) + expect(config).toEqual({ + tokenLimits: undefined, + callScopes: [ + { + target: nft, + selectorRules: [ + { selector: '0x6a627842' }, + { selector: '0xdeadbeef' }, + ], + }, + ], + }) + }) + + test('gas-limit policy is ignored (settled by payer layer)', () => { + const config = toSessionPolicyConfig([ + { + type: 'erc20-token-transfer', + data: { address: usdc, ticker: 'USDC' }, + policies: [ + { type: 'token-allowance', data: { allowance: 5n } }, + { type: 'gas-limit', data: { limit: 21_000n } }, + ], + }, + ]) + expect(config.tokenLimits).toEqual([ + { token: usdc, limit: 5n, period: undefined }, + ]) + }) + + test('throws when a transfer permission has no allowance', () => { + expect(() => + toSessionPolicyConfig([ + { + type: 'erc20-token-transfer', + data: { address: usdc, ticker: 'USDC' }, + policies: [], + }, + ]), + ).toThrow(/token-allowance/) + }) + + test('throws on a custom permission', () => { + expect(() => + toSessionPolicyConfig([ + { + type: { custom: 'anything' }, + data: {}, + policies: [], + } as unknown as Permission, + ]), + ).toThrow(/custom ERC-7715 permission/) + }) + + test('throws on a custom policy', () => { + expect(() => + toSessionPolicyConfig([ + { + type: 'native-token-transfer', + data: { ticker: 'ETH' }, + policies: [{ type: { custom: 'x' }, data: {} }], + } as unknown as Permission, + ]), + ).toThrow(/custom ERC-7715 policy/) + }) +}) + +describe('toSessionPolicy', () => { + test('binds permissions + expiry → SessionPolicy commitment', () => { + const permissions: readonly Permission[] = [ + { + type: 'erc20-token-transfer', + data: { address: usdc, ticker: 'USDC' }, + policies: [ + { type: 'token-allowance', data: { allowance: 100_000_000n } }, + ], + }, + ] + const expiry = 1_800_000_000 + + const session = toSessionPolicy({ account, permissions, expiry }) + + // Equivalent to hand-binding the lowered config with validUntil = expiry. + const expected = defineSessionPolicy({ + account, + policyConfig: encodeSessionPolicyConfig( + toSessionPolicyConfig(permissions), + ), + validUntil: BigInt(expiry), + }) + + expect(session.commitment).toBe(expected.commitment) + expect(session.binding.validUntil).toBe(BigInt(expiry)) + expect(session.actorPolicy).toEqual(expected.actorPolicy) + }) +}) diff --git a/src/eip8130/permissions.ts b/src/eip8130/permissions.ts new file mode 100644 index 0000000000..71d9a68a9e --- /dev/null +++ b/src/eip8130/permissions.ts @@ -0,0 +1,245 @@ +import type { Address } from 'abitype' +import { zeroAddress } from '../constants/address.js' +import { BaseError } from '../errors/base.js' +import type { Permission } from '../experimental/erc7715/types/permission.js' +import type { Policy as GrantedPolicy } from '../experimental/erc7715/types/policy.js' +import type { Hex } from '../types/misc.js' +import { toFunctionSelector } from '../utils/hash/toFunctionSelector.js' +import { + type DefineSessionPolicyParameters, + defineSessionPolicy, + encodeSessionPolicyConfig, + type SessionPolicy, + type SessionPolicyCallScope, + type SessionPolicyConfig, + type SessionPolicyTokenLimit, +} from './policies.js' + +/** + * The ERC-20 selectors a `SessionPolicy` gates on for token spend + recipient + * checks. An `erc20-token-transfer` permission is lowered to a call scope + * allowing exactly these on the token contract. + */ +const erc20TransferSelectors = [ + toFunctionSelector('transfer(address,uint256)'), + toFunctionSelector('transferFrom(address,address,uint256)'), +] as const + +/** Matches a bare 4-byte selector (`0x` + 8 hex chars). */ +const selectorRegex = /^0x[0-9a-fA-F]{8}$/ + +/** + * Lower a single call signature/selector to its 4-byte selector. + * + * Accepts a human-readable signature (`"transfer(address,uint256)"`) or an + * already-computed selector (`"0xa9059cbb"`). + */ +function toSelector(signatureOrSelector: string): Hex { + if (selectorRegex.test(signatureOrSelector)) + return signatureOrSelector.toLowerCase() as Hex + return toFunctionSelector(signatureOrSelector) +} + +/** + * Fold an ERC-7715 permission's policies into a `SessionPolicy` spend cap. + * + * - `token-allowance` → the per-period (or one-time) `limit`. + * - `rate-limit` → the reset `period` (its `interval`, in seconds). The per- + * interval `count` has no `SessionPolicy` equivalent (the policy caps spend, + * not call frequency) and is ignored. + * - `gas-limit` → ignored here (gas is settled by the payer / ERC-8168 layer, + * not the session policy). + * - `custom` → rejected (cannot be safely lowered). + */ +function spendFromPolicies(policies: readonly GrantedPolicy[]): { + limit?: bigint | undefined + period?: bigint | undefined +} { + let limit: bigint | undefined + let period: bigint | undefined + for (const policy of policies) { + if (typeof policy.type === 'object') + throw new BaseError( + 'Cannot lower a custom ERC-7715 policy to a SessionPolicy. Build the `SessionPolicyConfig` explicitly.', + ) + const type = policy.type + switch (policy.type) { + case 'token-allowance': + limit = policy.data.allowance + break + case 'rate-limit': + period = BigInt(policy.data.interval) + break + case 'gas-limit': + break + default: + throw new BaseError(`Unsupported ERC-7715 policy type: "${type}".`) + } + } + return { limit, period } +} + +export type ToSessionPolicyConfigErrorType = BaseError + +/** + * Lower a set of ERC-7715 `permissions` (a `wallet_grantPermissions` request) + * to an EIP-8130 {@link SessionPolicyConfig}. + * + * This is the fulfillment glue a wallet runs to satisfy a dApp permission + * request with a policy-gated EIP-8130 session key: + * + * - `native-token-transfer` → a native-ETH `tokenLimit` (gated on each call's + * `value`). Requires a `token-allowance` policy for the cap. + * - `erc20-token-transfer` → an ERC-20 `tokenLimit` **and** a `callScope` + * restricting the key to `transfer` / `transferFrom` on the token. Requires a + * `token-allowance` policy for the cap. + * - `contract-call` → a `callScope` restricting the key to the given selectors + * on the target contract. + * + * A `rate-limit` policy on a transfer permission sets the cap's reset `period` + * (a recurring allowance / "subscription"); without one the cap is one-time. + * + * @throws if a transfer permission has no `token-allowance` (an unbounded spend + * cannot be safely expressed), or if a `custom` permission/policy is present. + * + * @example + * import { toSessionPolicyConfig, encodeSessionPolicyConfig } from 'viem/eip8130' + * + * // "≤ 100 USDC / week" subscription for a session key + * const config = toSessionPolicyConfig([ + * { + * type: 'erc20-token-transfer', + * data: { address: usdc, ticker: 'USDC' }, + * policies: [ + * { type: 'token-allowance', data: { allowance: parseUnits('100', 6) } }, + * { type: 'rate-limit', data: { count: 1, interval: 7 * 86400 } }, + * ], + * }, + * ]) + * const policyConfig = encodeSessionPolicyConfig(config) + */ +export function toSessionPolicyConfig( + permissions: readonly Permission[], +): SessionPolicyConfig { + const tokenLimits: SessionPolicyTokenLimit[] = [] + const callScopes: SessionPolicyCallScope[] = [] + + for (const permission of permissions) { + if (typeof permission.type === 'object') + throw new BaseError( + 'Cannot lower a custom ERC-7715 permission to a SessionPolicy. Build the `SessionPolicyConfig` explicitly.', + ) + + const type = permission.type + switch (permission.type) { + case 'native-token-transfer': { + const { limit, period } = spendFromPolicies(permission.policies) + if (limit === undefined) + throw new BaseError( + 'A `native-token-transfer` permission must include a `token-allowance` policy (an unbounded spend cannot be granted).', + ) + tokenLimits.push({ token: zeroAddress, limit, period }) + break + } + case 'erc20-token-transfer': { + const token = permission.data.address as Address + const { limit, period } = spendFromPolicies(permission.policies) + if (limit === undefined) + throw new BaseError( + 'An `erc20-token-transfer` permission must include a `token-allowance` policy (an unbounded spend cannot be granted).', + ) + tokenLimits.push({ token, limit, period }) + callScopes.push({ + target: token, + selectorRules: erc20TransferSelectors.map((selector) => ({ + selector, + })), + }) + break + } + case 'contract-call': { + callScopes.push({ + target: permission.data.address as Address, + selectorRules: permission.data.calls.map((call) => ({ + selector: toSelector(call), + })), + }) + break + } + default: + throw new BaseError(`Unsupported ERC-7715 permission type: "${type}".`) + } + } + + return { + tokenLimits: tokenLimits.length > 0 ? tokenLimits : undefined, + callScopes: callScopes.length > 0 ? callScopes : undefined, + } +} + +export type ToSessionPolicyParameters = Omit< + DefineSessionPolicyParameters, + 'policyConfig' | 'validUntil' +> & { + /** The ERC-7715 permissions to grant the session key. */ + permissions: readonly Permission[] + /** + * Top-level ERC-7715 `expiry` (unix **seconds**) after which the policy is no + * longer valid. Maps to the binding's `validUntil`. @default 0n (no expiry) + */ + expiry?: number | bigint | undefined +} + +export type ToSessionPolicyErrorType = ToSessionPolicyConfigErrorType + +/** + * Fulfill an ERC-7715 `wallet_grantPermissions` request as an EIP-8130 + * policy-gated session key: lowers `permissions` to a {@link SessionPolicyConfig} + * and binds it to `account` via {@link defineSessionPolicy}. + * + * The returned {@link SessionPolicy} carries the `commitment`, the `actorPolicy` + * to authorize the key, and `executeCall` for later spends. + * + * @example + * import { + * toSessionPolicy, + * authorizeActor, + * actorScope, + * key, + * } from 'viem/eip8130' + * + * const session = toSessionPolicy({ + * account: account.address, + * expiry: Math.floor(Date.now() / 1000) + 7 * 86400, + * permissions: [ + * { + * type: 'erc20-token-transfer', + * data: { address: usdc, ticker: 'USDC' }, + * policies: [ + * { type: 'token-allowance', data: { allowance: parseUnits('100', 6) } }, + * ], + * }, + * ], + * }) + * + * // authorize the session key (its signed commitment IS the grant) + * await account.change([ + * authorizeActor(key.p256(pub), { + * scope: actorScope.sender, + * policy: session.actorPolicy, + * }), + * ]) + * + * // later: the key spends within its limit, routed through the manager + * const spend = session.executeCall({ target: usdc, data: transferCalldata }) + */ +export function toSessionPolicy( + parameters: ToSessionPolicyParameters, +): SessionPolicy { + const { permissions, expiry, ...rest } = parameters + return defineSessionPolicy({ + ...rest, + policyConfig: encodeSessionPolicyConfig(toSessionPolicyConfig(permissions)), + validUntil: expiry === undefined ? undefined : BigInt(expiry), + }) +} From 94e1fb072367dedbf4e196246ecb67cb215b891a Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 17:08:34 -0400 Subject: [PATCH 68/96] =?UTF-8?q?feat(eip8130):=20fulfillGrantPermissions?= =?UTF-8?q?=20=E2=80=94=20session=20+=20external-pull=20roles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the ERC-7715 adapter into a full wallet-side fulfillment that returns the ready-to-sign authorizeActor change, always POLICY-gated: - `role: 'session'` (default) → k1 actor, acts via PolicyManager.execute. - `role: 'pull'` → external-pull sentinel actor (EXTERNAL_POLICY_AUTHENTICATOR), draws via PolicyManager.executeFor from its own address. A single ERC-7715 `expiry` drives both the actor authorization and the policy binding's validUntil so they can't drift. Supporting additions: - `externalPolicyAuthenticator` constant (keccak256("externalPolicyCaller")). - `key.externalPull(address)` actor builder. - `SessionPolicy.executeForCall` (PolicyManager.executeFor encoder). Exported from viem/eip8130, unit-tested (session + pull), and documented. --- site/pages/eip8130/session-keys.mdx | 49 ++++++---- src/eip8130/constants.ts | 17 ++++ src/eip8130/index.ts | 6 ++ src/eip8130/keys.ts | 16 ++++ src/eip8130/permissions.test.ts | 81 +++++++++++++++- src/eip8130/permissions.ts | 138 ++++++++++++++++++++++++++++ src/eip8130/policies.ts | 27 ++++++ 7 files changed, 316 insertions(+), 18 deletions(-) diff --git a/site/pages/eip8130/session-keys.mdx b/site/pages/eip8130/session-keys.mdx index e6d7861ece..02d3d9f153 100644 --- a/site/pages/eip8130/session-keys.mdx +++ b/site/pages/eip8130/session-keys.mdx @@ -133,20 +133,21 @@ A transfer that exceeds the weekly limit, or targets a contract/selector outside ## Fulfilling an ERC-7715 request -When a dApp asks a wallet for a session key via [ERC-7715](https://eips.ethereum.org/EIPS/eip-7715) `wallet_grantPermissions`, the request arrives as a set of `permissions` (+ policies) and a top-level `expiry`. `toSessionPolicy` lowers that request directly onto the EIP-8130 primitives above — no need to hand-author the `SessionPolicyConfig`: +When a dApp asks a wallet for permissions via [ERC-7715](https://eips.ethereum.org/EIPS/eip-7715) `wallet_grantPermissions`, the request arrives as a set of `permissions` (+ policies) and a top-level `expiry`. `fulfillGrantPermissions` lowers that request directly onto the EIP-8130 primitives above — building the `SessionPolicyConfig`, binding it, and returning the ready-to-sign `authorizeActor` change. It supports two roles, both **POLICY-gated** to the same committed policy: + +- `role: 'session'` (default) — a **key on the account**. The protocol dispatches its calls as the account (`PolicyManager.execute`). Uses a secp256k1 (`k1`) actor. +- `role: 'pull'` — an **external caller** (e.g. a subscription provider) that draws against the policy via `PolicyManager.executeFor` from its own address. Uses the `externalPolicyAuthenticator` sentinel actor, which can *only* act through the external-pull path. + +The single `expiry` drives **both** the actor authorization and the policy binding's `validUntil`, so they can't drift. ```ts import { parseUnits } from 'viem' -import { - actorScope, - authorizeActor, - key, - toSessionPolicy, -} from 'viem/eip8130' +import { fulfillGrantPermissions } from 'viem/eip8130' // The `permissions` + `expiry` a dApp sent via `wallet_grantPermissions`. -const session = toSessionPolicy({ +const { change, session } = fulfillGrantPermissions({ account: account.address, + grantee: sessionKeyAddress, // the key to authorize (k1) expiry: Math.floor(Date.now() / 1000) + 7 * 86_400, permissions: [ { @@ -162,15 +163,31 @@ const session = toSessionPolicy({ }) // Authorize the requested key — its signed commitment *is* the grant. -const change = await account.change([ - authorizeActor(key.p256({ x, y }), { - scope: actorScope.sender, - policy: session.actorPolicy, - }), -]) +await account.change([change]) + +// Later, the session key spends within its limit (routed through the manager): +// session.executeCall({ target: usdc, data: transferCalldata }) ``` -The mapping is: +For an **external pull** subscription, pass `role: 'pull'` and the provider's address. The provider later submits `session.executeForCall(action)` as an ordinary transaction from its own address: + +```ts +const pull = fulfillGrantPermissions({ + account: account.address, + grantee: providerAddress, + role: 'pull', + expiry, + permissions, +}) +await account.change([pull.change]) +// provider draws (from its own address): pull.session.executeForCall({ target: usdc, data }) +``` + +:::info +For the manager's forwarded `executeBatch` to land, the account must register the manager as a trusted-executor actor once: `authorizeActor(key.trustedExecutor(manager), { scope: actorScope.sender })`. +::: + +The permission → policy mapping is: | ERC-7715 | EIP-8130 `SessionPolicy` | | --- | --- | @@ -180,4 +197,4 @@ The mapping is: | `rate-limit` `interval` (on a transfer) | the cap's reset `period` (a recurring allowance) | | `gas-limit` | ignored — gas is settled by the payer / [ERC-8168](/eip8168) layer | -A transfer permission without a `token-allowance` throws (an unbounded spend can't be safely granted), as do `custom` permissions/policies — build the `SessionPolicyConfig` explicitly for those. Use `toSessionPolicyConfig` on its own if you only need the lowered config (e.g. to inspect or extend it before binding). +A transfer permission without a `token-allowance` throws (an unbounded spend can't be safely granted), as do `custom` permissions/policies — build the `SessionPolicyConfig` explicitly for those. Use `toSessionPolicyConfig` (the pure mapping) or `toSessionPolicy` (mapping + binding) directly if you only need the lowered config or the bound policy without the `authorizeActor` change. diff --git a/src/eip8130/constants.ts b/src/eip8130/constants.ts index af09a1eb81..208272a7b8 100644 --- a/src/eip8130/constants.ts +++ b/src/eip8130/constants.ts @@ -165,6 +165,23 @@ export const revokedAuthenticator = export const trustedExecutorAuthenticator = '0xbe114b191a3ac7519670cac0c5e74aac1d819a13' satisfies Hex +/** + * Sentinel authenticator for external-pull policy actors + * (`EXTERNAL_POLICY_AUTHENTICATOR = address(uint160(uint256(keccak256("externalPolicyCaller"))))`, + * as defined in `base/eip-8130`'s `PolicyManager`). + * + * No contract is deployed here. An actor whose `authenticator` is this sentinel + * represents an *external caller* governed by a policy (e.g. a subscription + * provider): it may act ONLY through the manager's external entrypoints + * (`executeFor` / `executeForMany`), never directly. Because the address is + * no-code, the actor cannot authenticate an EIP-8130 transaction or drive + * `executeBatch` — the manager requires `authenticator == this` on the external + * path, and the acting `actorId` is `actorIdFromAddress(msg.sender)` (the pull + * caller's own address). See {@link key.externalPull}. + */ +export const externalPolicyAuthenticator = + '0x8a22e6B3c724A7D0C3aCA1f7EbD089CfbD96B392' satisfies Hex + /** * Canonical authenticator set (the signature algorithms compliant nodes MUST * accept). `k1` is the native `ECRECOVER_AUTHENTICATOR` sentinel; the others are diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index 29ce099a40..3abde0dc4b 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -116,6 +116,7 @@ export { defaultAccountAddress, deploymentHeaderSize, ecrecoverAuthenticator, + externalPolicyAuthenticator, maxCodeSize, nonceFreeCost, nonceFreeExpiryWindow, @@ -170,6 +171,11 @@ export { } from './lock.js' export { type Nonce, nonce } from './nonce.js' export { + type FulfillGrantPermissionsErrorType, + type FulfillGrantPermissionsParameters, + type FulfillGrantPermissionsReturnType, + fulfillGrantPermissions, + type GrantRole, type ToSessionPolicyConfigErrorType, type ToSessionPolicyErrorType, type ToSessionPolicyParameters, diff --git a/src/eip8130/keys.ts b/src/eip8130/keys.ts index 8bd8bcf661..0209742b67 100644 --- a/src/eip8130/keys.ts +++ b/src/eip8130/keys.ts @@ -8,6 +8,7 @@ import { actorScope, canonicalAuthenticators, ecrecoverAuthenticator, + externalPolicyAuthenticator, scopeUnrestricted, trustedExecutorAuthenticator, } from './constants.js' @@ -85,6 +86,21 @@ export const key = { authenticator: trustedExecutorAuthenticator, } }, + /** + * External-pull ("subscription provider") actor for `caller` — an address the + * account authorizes to draw against a policy via the manager's `executeFor` / + * `executeForMany` entrypoints. Uses the {@link externalPolicyAuthenticator} + * sentinel, so the actor can *only* act through the external policy path (never + * `executeBatch`, and it cannot sign an 8130 transaction). Pair with + * `authorizeActor(..., { scope: actorScope.policy, policy })`: the manager + * resolves the acting id as `actorIdFromAddress(caller)`. + */ + externalPull(caller: Address): AaActor { + return { + actorId: actorIdFromAddress(caller), + authenticator: externalPolicyAuthenticator, + } + }, } as const /** Combines {@link actorScope} flags into a single scope bitmask. */ diff --git a/src/eip8130/permissions.test.ts b/src/eip8130/permissions.test.ts index b54515b12f..9732eab081 100644 --- a/src/eip8130/permissions.test.ts +++ b/src/eip8130/permissions.test.ts @@ -1,8 +1,24 @@ import { describe, expect, test } from 'vitest' import { zeroAddress } from '../constants/address.js' import type { Permission } from '../experimental/erc7715/types/permission.js' -import { toSessionPolicy, toSessionPolicyConfig } from './permissions.js' -import { defineSessionPolicy, encodeSessionPolicyConfig } from './policies.js' +import { decodeFunctionData } from '../utils/abi/decodeFunctionData.js' +import { + actorScope, + ecrecoverAuthenticator, + externalPolicyAuthenticator, +} from './constants.js' +import { encodePolicyData, key } from './keys.js' +import { + fulfillGrantPermissions, + toSessionPolicy, + toSessionPolicyConfig, +} from './permissions.js' +import { + defineSessionPolicy, + encodeSessionPolicyConfig, + policyManagerAbi, +} from './policies.js' +import { actorIdFromAddress } from './utils/actorId.js' const account = '0x0000000000000000000000000000000000000a11' const usdc = '0x0000000000000000000000000000000000000a22' @@ -171,3 +187,64 @@ describe('toSessionPolicy', () => { expect(session.actorPolicy).toEqual(expected.actorPolicy) }) }) + +describe('fulfillGrantPermissions', () => { + const permissions: readonly Permission[] = [ + { + type: 'erc20-token-transfer', + data: { address: usdc, ticker: 'USDC' }, + policies: [ + { type: 'token-allowance', data: { allowance: 100_000_000n } }, + ], + }, + ] + const grantee = '0x00000000000000000000000000000000000acce5' + const expiry = 1_800_000_000 + + test('session role → POLICY-only k1 actor, one expiry drives both surfaces', () => { + const { actor, change, session } = fulfillGrantPermissions({ + account, + grantee, + permissions, + expiry, + }) + + // k1 actor for the session key address. + expect(actor).toEqual(key.k1(grantee)) + expect(change.authenticator).toBe(ecrecoverAuthenticator) + expect(change.actorId).toBe(actorIdFromAddress(grantee)) + + // POLICY-only, gated to the bound policy. + expect(change.scope).toBe(actorScope.policy) + expect(change.policyData).toBe(encodePolicyData(session.actorPolicy)) + + // Single expiry → both the actor change and the policy binding. + expect(change.expiry).toBe(BigInt(expiry)) + expect(session.binding.validUntil).toBe(BigInt(expiry)) + }) + + test('pull role → external-pull sentinel actor + executeFor call', () => { + const { actor, change, session } = fulfillGrantPermissions({ + account, + grantee, + role: 'pull', + permissions, + expiry, + }) + + // External-pull sentinel; actorId derived from the caller address. + expect(actor).toEqual(key.externalPull(grantee)) + expect(change.authenticator).toBe(externalPolicyAuthenticator) + expect(change.actorId).toBe(actorIdFromAddress(grantee)) + expect(change.scope).toBe(actorScope.policy) + + // The pull call targets the manager's `executeFor` entrypoint. + const call = session.executeForCall({ target: usdc, data: '0x' }) + expect(call.to).toBe(session.manager) + const decoded = decodeFunctionData({ + abi: policyManagerAbi, + data: call.data, + }) + expect(decoded.functionName).toBe('executeFor') + }) +}) diff --git a/src/eip8130/permissions.ts b/src/eip8130/permissions.ts index 71d9a68a9e..1e5bf0327a 100644 --- a/src/eip8130/permissions.ts +++ b/src/eip8130/permissions.ts @@ -5,6 +5,8 @@ import type { Permission } from '../experimental/erc7715/types/permission.js' import type { Policy as GrantedPolicy } from '../experimental/erc7715/types/policy.js' import type { Hex } from '../types/misc.js' import { toFunctionSelector } from '../utils/hash/toFunctionSelector.js' +import { actorScope } from './constants.js' +import { authorizeActor, key } from './keys.js' import { type DefineSessionPolicyParameters, defineSessionPolicy, @@ -14,6 +16,7 @@ import { type SessionPolicyConfig, type SessionPolicyTokenLimit, } from './policies.js' +import type { AaActor, AaAuthorizeActor } from './types/transaction.js' /** * The ERC-20 selectors a `SessionPolicy` gates on for token spend + recipient @@ -243,3 +246,138 @@ export function toSessionPolicy( validUntil: expiry === undefined ? undefined : BigInt(expiry), }) } + +/** + * How the granted key acts on the account: + * + * - `'session'`: a key **on** the account. The EIP-8130 protocol dispatches its + * calls as the account (`PolicyManager.execute`), gated by the policy. Uses a + * secp256k1 (k1) actor for the given key address. + * - `'pull'`: an **external** caller (e.g. a subscription provider) that draws + * against the policy via `PolicyManager.executeFor` from its *own* address. + * Uses the {@link externalPolicyAuthenticator} sentinel actor, which can only + * act through the external-pull path. + */ +export type GrantRole = 'session' | 'pull' + +export type FulfillGrantPermissionsParameters = Omit< + DefineSessionPolicyParameters, + 'account' | 'policyConfig' | 'validUntil' | 'policyType' +> & { + /** The smart account granting the permissions (the execution target). */ + account: Address + /** + * The key/caller to authorize: + * - `role: 'session'` → the session key's secp256k1 address. + * - `role: 'pull'` → the external caller's (subscription provider's) address. + */ + grantee: Address + /** How the grantee acts on the account. @default 'session' */ + role?: GrantRole | undefined + /** The ERC-7715 permissions to grant. */ + permissions: readonly Permission[] + /** + * Top-level ERC-7715 `expiry` (unix **seconds**). Drives **both** the policy + * binding's `validUntil` and the actor authorization's `expiry`, so they can't + * drift. @default 0n (no expiry) + */ + expiry?: number | bigint | undefined +} + +export type FulfillGrantPermissionsReturnType = { + /** The authorized actor (`key.k1` for `session`, `key.externalPull` for `pull`). */ + actor: AaActor + /** + * The `authorizeActor` change the account must sign + land. Its signed + * commitment *is* the authorization (no separate install). Always POLICY-gated + * ({@link actorScope}.policy). + */ + change: AaAuthorizeActor + /** + * The bound {@link SessionPolicy}: `commitment`, `actorPolicy`, and the call + * builders (`executeCall` for `session`, `executeForCall` for `pull`). + */ + session: SessionPolicy +} + +export type FulfillGrantPermissionsErrorType = ToSessionPolicyConfigErrorType + +/** + * Fulfill an ERC-7715 `wallet_grantPermissions` request as an EIP-8130 + * **policy-gated** actor — the wallet-side glue for both flows we support: + * + * - a **session key** that takes actions on the account (`role: 'session'`), or + * - an **external pull** subscription that batches draws (`role: 'pull'`). + * + * Both are authorized POLICY-only ({@link actorScope}.policy) against the same + * committed {@link SessionPolicy}; the `role` only changes the actor's + * authenticator (a k1 key vs. the external-pull sentinel) and which manager + * entrypoint is used at execution (`execute` vs. `executeFor`). The single + * ERC-7715 `expiry` drives both the binding `validUntil` and the actor `expiry`. + * + * @remarks For the policy-manager's forwarded `executeBatch` to land, the + * account must (once) register the `manager` as a trusted-executor actor: + * `authorizeActor(key.trustedExecutor(manager), { scope: actorScope.sender })`. + * + * @example + * import { fulfillGrantPermissions, sendCalls } from 'viem/eip8130' + * + * // session key: "≤ 100 USDC / week", expires in 7 days + * const { change, session } = fulfillGrantPermissions({ + * account: account.address, + * grantee: sessionKeyAddress, + * expiry: Math.floor(Date.now() / 1000) + 7 * 86_400, + * permissions: [ + * { + * type: 'erc20-token-transfer', + * data: { address: usdc, ticker: 'USDC' }, + * policies: [ + * { type: 'token-allowance', data: { allowance: parseUnits('100', 6) } }, + * { type: 'rate-limit', data: { count: 1, interval: 7 * 86_400 } }, + * ], + * }, + * ], + * }) + * await account.change([change]) + * // later: session.executeCall({ target: usdc, data: transferCalldata }) + * + * // external pull: the same policy, drawn by a provider via executeFor + * const pull = fulfillGrantPermissions({ + * account: account.address, + * grantee: providerAddress, + * role: 'pull', + * expiry, + * permissions, + * }) + * // provider later submits (from its own address): pull.session.executeForCall(action) + */ +export function fulfillGrantPermissions( + parameters: FulfillGrantPermissionsParameters, +): FulfillGrantPermissionsReturnType { + const { + account, + grantee, + role = 'session', + permissions, + expiry, + ...rest + } = parameters + const expiryBig = expiry === undefined ? undefined : BigInt(expiry) + + const session = defineSessionPolicy({ + ...rest, + account, + policyConfig: encodeSessionPolicyConfig(toSessionPolicyConfig(permissions)), + validUntil: expiryBig, + }) + + const actor = role === 'pull' ? key.externalPull(grantee) : key.k1(grantee) + + const change = authorizeActor(actor, { + scope: actorScope.policy, + policy: session.actorPolicy, + expiry: expiryBig, + }) + + return { actor, change, session } +} diff --git a/src/eip8130/policies.ts b/src/eip8130/policies.ts index 7667cdd420..b7c2e14f85 100644 --- a/src/eip8130/policies.ts +++ b/src/eip8130/policies.ts @@ -199,6 +199,18 @@ export type SessionPolicy = { * action fields `{ target, value?, data? }` and the encoding is done for you. */ executeCall(executionData: Hex | SessionPolicyAction): AaCall + /** + * External-pull call: `PolicyManager.executeFor(binding, executionData)`, + * sent by an *external caller* (e.g. a subscription provider) from its own + * address — not routed through the account. The account must have authorized + * that caller as an external-pull actor (see `key.externalPull`); the manager + * resolves the acting id from `msg.sender`. Returns the raw call to submit as + * an ordinary transaction from the caller. + * + * Pass either raw `executionData` ({@link encodeSessionPolicyAction}) or the + * action fields `{ target, value?, data? }`. + */ + executeForCall(executionData: Hex | SessionPolicyAction): AaCall } export type DefineSessionPolicyErrorType = CommitmentOfErrorType @@ -283,6 +295,21 @@ export function defineSessionPolicy( }), } }, + executeForCall(executionDataOrAction) { + const executionData = + typeof executionDataOrAction === 'string' + ? executionDataOrAction + : encodeSessionPolicyAction(executionDataOrAction) + return { + to: manager, + value: 0n, + data: encodeFunctionData({ + abi: policyManagerAbi, + functionName: 'executeFor', + args: [toBindingArgs(binding), executionData], + }), + } + }, } } From c0f9e49c2bca7458b8d5d3a4dcf8260d95b5c3cb Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 17:22:39 -0400 Subject: [PATCH 69/96] feat(eip8130): auto-register manager in fulfillGrantPermissions fulfillGrantPermissions is now an action (client, parameters): it reads the account and, if the policy manager isn't yet a trusted-executor actor, folds that one-time registration into the returned `changes` batch (managerChange first, then the grantee change). One `account.change(changes)` now both provisions the manager and authorizes the grantee. - adds `managerChange` + `changes` to the return; `assumeManagerRegistered` option skips the on-chain read. - tests cover registered / unregistered / skip paths for session + pull. --- site/pages/eip8130/session-keys.mdx | 17 ++--- src/eip8130/permissions.test.ts | 88 ++++++++++++++++++++++-- src/eip8130/permissions.ts | 100 ++++++++++++++++++++++------ 3 files changed, 171 insertions(+), 34 deletions(-) diff --git a/site/pages/eip8130/session-keys.mdx b/site/pages/eip8130/session-keys.mdx index 02d3d9f153..dea7bf4e05 100644 --- a/site/pages/eip8130/session-keys.mdx +++ b/site/pages/eip8130/session-keys.mdx @@ -133,19 +133,19 @@ A transfer that exceeds the weekly limit, or targets a contract/selector outside ## Fulfilling an ERC-7715 request -When a dApp asks a wallet for permissions via [ERC-7715](https://eips.ethereum.org/EIPS/eip-7715) `wallet_grantPermissions`, the request arrives as a set of `permissions` (+ policies) and a top-level `expiry`. `fulfillGrantPermissions` lowers that request directly onto the EIP-8130 primitives above — building the `SessionPolicyConfig`, binding it, and returning the ready-to-sign `authorizeActor` change. It supports two roles, both **POLICY-gated** to the same committed policy: +When a dApp asks a wallet for permissions via [ERC-7715](https://eips.ethereum.org/EIPS/eip-7715) `wallet_grantPermissions`, the request arrives as a set of `permissions` (+ policies) and a top-level `expiry`. `fulfillGrantPermissions` lowers that request directly onto the EIP-8130 primitives above — building the `SessionPolicyConfig`, binding it, and returning the ready-to-sign `changes`. It supports two roles, both **POLICY-gated** to the same committed policy: - `role: 'session'` (default) — a **key on the account**. The protocol dispatches its calls as the account (`PolicyManager.execute`). Uses a secp256k1 (`k1`) actor. - `role: 'pull'` — an **external caller** (e.g. a subscription provider) that draws against the policy via `PolicyManager.executeFor` from its own address. Uses the `externalPolicyAuthenticator` sentinel actor, which can *only* act through the external-pull path. -The single `expiry` drives **both** the actor authorization and the policy binding's `validUntil`, so they can't drift. +The single `expiry` drives **both** the actor authorization and the policy binding's `validUntil`, so they can't drift. The action also reads the account and, if the policy manager isn't yet a trusted-executor actor (required so its forwarded `executeBatch` can land), folds that one-time registration into `changes` — so a single `account.change(changes)` provisions the manager *and* authorizes the grantee: ```ts import { parseUnits } from 'viem' import { fulfillGrantPermissions } from 'viem/eip8130' // The `permissions` + `expiry` a dApp sent via `wallet_grantPermissions`. -const { change, session } = fulfillGrantPermissions({ +const { changes, session } = await fulfillGrantPermissions(client, { account: account.address, grantee: sessionKeyAddress, // the key to authorize (k1) expiry: Math.floor(Date.now() / 1000) + 7 * 86_400, @@ -162,8 +162,9 @@ const { change, session } = fulfillGrantPermissions({ ], }) -// Authorize the requested key — its signed commitment *is* the grant. -await account.change([change]) +// Grant the key (+ register the manager if needed) — the signed commitment *is* +// the authorization. +await account.change(changes) // Later, the session key spends within its limit (routed through the manager): // session.executeCall({ target: usdc, data: transferCalldata }) @@ -172,19 +173,19 @@ await account.change([change]) For an **external pull** subscription, pass `role: 'pull'` and the provider's address. The provider later submits `session.executeForCall(action)` as an ordinary transaction from its own address: ```ts -const pull = fulfillGrantPermissions({ +const pull = await fulfillGrantPermissions(client, { account: account.address, grantee: providerAddress, role: 'pull', expiry, permissions, }) -await account.change([pull.change]) +await account.change(pull.changes) // provider draws (from its own address): pull.session.executeForCall({ target: usdc, data }) ``` :::info -For the manager's forwarded `executeBatch` to land, the account must register the manager as a trusted-executor actor once: `authorizeActor(key.trustedExecutor(manager), { scope: actorScope.sender })`. +The manager registration is checked on-chain and included automatically only when missing. Pass `assumeManagerRegistered: true` to skip the read when you already know it's provisioned. ::: The permission → policy mapping is: diff --git a/src/eip8130/permissions.test.ts b/src/eip8130/permissions.test.ts index 9732eab081..fde7282c4c 100644 --- a/src/eip8130/permissions.test.ts +++ b/src/eip8130/permissions.test.ts @@ -1,11 +1,18 @@ +import type { Abi } from 'abitype' import { describe, expect, test } from 'vitest' +import { mainnet } from '../chains/index.js' +import { createClient } from '../clients/createClient.js' +import { custom } from '../clients/transports/custom.js' import { zeroAddress } from '../constants/address.js' import type { Permission } from '../experimental/erc7715/types/permission.js' import { decodeFunctionData } from '../utils/abi/decodeFunctionData.js' +import { encodeFunctionResult } from '../utils/abi/encodeFunctionResult.js' +import { accountConfigurationAbi } from './abis.js' import { actorScope, ecrecoverAuthenticator, externalPolicyAuthenticator, + trustedExecutorAuthenticator, } from './constants.js' import { encodePolicyData, key } from './keys.js' import { @@ -20,6 +27,43 @@ import { } from './policies.js' import { actorIdFromAddress } from './utils/actorId.js' +/** + * A client whose `eth_call` decodes `getActorConfig` and returns an actor with + * the given `authenticator` (zero = unregistered). + */ +function actorConfigClient(authenticator: string) { + return createClient({ + chain: mainnet, + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_call') { + const { functionName } = decodeFunctionData({ + abi: accountConfigurationAbi as Abi, + data: params[0].data, + }) + return encodeFunctionResult({ + abi: accountConfigurationAbi as Abi, + functionName, + result: { authenticator, expiry: 0, scope: 0 } as never, + }) + } + throw new Error(`unexpected RPC: ${method}`) + }, + }), + }) +} + +/** A client that throws on any request — proves no RPC was made. */ +const throwingClient = createClient({ + chain: mainnet, + transport: custom({ + async request() { + throw new Error('no RPC expected') + }, + }), +}) + const account = '0x0000000000000000000000000000000000000a11' const usdc = '0x0000000000000000000000000000000000000a22' const nft = '0x0000000000000000000000000000000000000b33' @@ -201,8 +245,9 @@ describe('fulfillGrantPermissions', () => { const grantee = '0x00000000000000000000000000000000000acce5' const expiry = 1_800_000_000 - test('session role → POLICY-only k1 actor, one expiry drives both surfaces', () => { - const { actor, change, session } = fulfillGrantPermissions({ + test('session role → POLICY-only k1 actor, one expiry drives both surfaces', async () => { + const client = actorConfigClient(trustedExecutorAuthenticator) + const { actor, change, session } = await fulfillGrantPermissions(client, { account, grantee, permissions, @@ -223,8 +268,43 @@ describe('fulfillGrantPermissions', () => { expect(session.binding.validUntil).toBe(BigInt(expiry)) }) - test('pull role → external-pull sentinel actor + executeFor call', () => { - const { actor, change, session } = fulfillGrantPermissions({ + test('manager not registered → managerChange folded into the batch', async () => { + const client = actorConfigClient(zeroAddress) + const { change, managerChange, changes, session } = + await fulfillGrantPermissions(client, { account, grantee, permissions }) + + // A trusted-executor registration for the manager is included first. + expect(managerChange).toBeDefined() + expect(managerChange?.authenticator).toBe(trustedExecutorAuthenticator) + expect(managerChange?.actorId).toBe(actorIdFromAddress(session.manager)) + expect(managerChange?.scope).toBe(actorScope.sender) + expect(managerChange?.policyData).toBeUndefined() + + expect(changes).toEqual([managerChange, change]) + }) + + test('manager already registered → no managerChange', async () => { + const client = actorConfigClient(trustedExecutorAuthenticator) + const { change, managerChange, changes } = await fulfillGrantPermissions( + client, + { account, grantee, permissions }, + ) + expect(managerChange).toBeUndefined() + expect(changes).toEqual([change]) + }) + + test('assumeManagerRegistered skips the on-chain read', async () => { + const { managerChange, changes, change } = await fulfillGrantPermissions( + throwingClient, + { account, grantee, permissions, assumeManagerRegistered: true }, + ) + expect(managerChange).toBeUndefined() + expect(changes).toEqual([change]) + }) + + test('pull role → external-pull sentinel actor + executeFor call', async () => { + const client = actorConfigClient(trustedExecutorAuthenticator) + const { actor, change, session } = await fulfillGrantPermissions(client, { account, grantee, role: 'pull', diff --git a/src/eip8130/permissions.ts b/src/eip8130/permissions.ts index 1e5bf0327a..3dee689106 100644 --- a/src/eip8130/permissions.ts +++ b/src/eip8130/permissions.ts @@ -1,11 +1,16 @@ import type { Address } from 'abitype' +import type { Client } from '../clients/createClient.js' +import type { Transport } from '../clients/transports/createTransport.js' import { zeroAddress } from '../constants/address.js' import { BaseError } from '../errors/base.js' import type { Permission } from '../experimental/erc7715/types/permission.js' import type { Policy as GrantedPolicy } from '../experimental/erc7715/types/policy.js' +import type { Account } from '../types/account.js' +import type { Chain } from '../types/chain.js' import type { Hex } from '../types/misc.js' import { toFunctionSelector } from '../utils/hash/toFunctionSelector.js' -import { actorScope } from './constants.js' +import { getActorConfig } from './actions/getActorConfig.js' +import { actorScope, trustedExecutorAuthenticator } from './constants.js' import { authorizeActor, key } from './keys.js' import { type DefineSessionPolicyParameters, @@ -16,7 +21,11 @@ import { type SessionPolicyConfig, type SessionPolicyTokenLimit, } from './policies.js' -import type { AaActor, AaAuthorizeActor } from './types/transaction.js' +import type { + AaActor, + AaAuthorizeActor, + AaChange, +} from './types/transaction.js' /** * The ERC-20 selectors a `SessionPolicy` gates on for token spend + recipient @@ -282,17 +291,41 @@ export type FulfillGrantPermissionsParameters = Omit< * drift. @default 0n (no expiry) */ expiry?: number | bigint | undefined + /** + * Skip the on-chain check for whether `manager` is registered as a + * trusted-executor actor on the account (assume it already is). When `false` + * (default), the account is read and a `managerChange` is included in `changes` + * if the manager still needs registering. @default false + */ + assumeManagerRegistered?: boolean | undefined + /** + * `AccountConfiguration` system contract used for the manager check. Defaults + * to the canonical (enshrined) address. + */ + accountConfiguration?: Address | undefined } export type FulfillGrantPermissionsReturnType = { /** The authorized actor (`key.k1` for `session`, `key.externalPull` for `pull`). */ actor: AaActor /** - * The `authorizeActor` change the account must sign + land. Its signed - * commitment *is* the authorization (no separate install). Always POLICY-gated + * The `authorizeActor` change authorizing the grantee. Its signed commitment + * *is* the authorization (no separate install). Always POLICY-gated * ({@link actorScope}.policy). */ change: AaAuthorizeActor + /** + * Present iff the `manager` was not yet registered as a trusted-executor actor + * on the account: the one-time `authorizeActor(key.trustedExecutor(manager), + * { scope: sender })` change the account needs so the manager's forwarded + * `executeBatch` can land. Already included in {@link changes}. + */ + managerChange?: AaAuthorizeActor | undefined + /** + * The full batch of account changes to sign + land in one transaction: the + * `managerChange` (if needed) followed by the grantee `change`. + */ + changes: readonly AaChange[] /** * The bound {@link SessionPolicy}: `commitment`, `actorPolicy`, and the call * builders (`executeCall` for `session`, `executeForCall` for `pull`). @@ -315,15 +348,18 @@ export type FulfillGrantPermissionsErrorType = ToSessionPolicyConfigErrorType * entrypoint is used at execution (`execute` vs. `executeFor`). The single * ERC-7715 `expiry` drives both the binding `validUntil` and the actor `expiry`. * - * @remarks For the policy-manager's forwarded `executeBatch` to land, the - * account must (once) register the `manager` as a trusted-executor actor: - * `authorizeActor(key.trustedExecutor(manager), { scope: actorScope.sender })`. + * For the manager's forwarded `executeBatch` to land, the account must register + * the `manager` as a trusted-executor actor. This action reads the account and, + * if that registration is missing, includes it as `managerChange` at the front + * of `changes` — so a single `account.change(changes)` both provisions the + * manager (once) and authorizes the grantee. Pass `assumeManagerRegistered: + * true` to skip the read. * * @example - * import { fulfillGrantPermissions, sendCalls } from 'viem/eip8130' + * import { fulfillGrantPermissions } from 'viem/eip8130' * * // session key: "≤ 100 USDC / week", expires in 7 days - * const { change, session } = fulfillGrantPermissions({ + * const { changes, session } = await fulfillGrantPermissions(client, { * account: account.address, * grantee: sessionKeyAddress, * expiry: Math.floor(Date.now() / 1000) + 7 * 86_400, @@ -338,28 +374,28 @@ export type FulfillGrantPermissionsErrorType = ToSessionPolicyConfigErrorType * }, * ], * }) - * await account.change([change]) + * // provisions the manager (if needed) + authorizes the key, in one batch + * await account.change(changes) * // later: session.executeCall({ target: usdc, data: transferCalldata }) * - * // external pull: the same policy, drawn by a provider via executeFor - * const pull = fulfillGrantPermissions({ - * account: account.address, - * grantee: providerAddress, - * role: 'pull', - * expiry, - * permissions, - * }) - * // provider later submits (from its own address): pull.session.executeForCall(action) + * @param client - Client. + * @param parameters - Parameters. */ -export function fulfillGrantPermissions( +export async function fulfillGrantPermissions< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, parameters: FulfillGrantPermissionsParameters, -): FulfillGrantPermissionsReturnType { +): Promise { const { account, grantee, role = 'session', permissions, expiry, + assumeManagerRegistered = false, + accountConfiguration, ...rest } = parameters const expiryBig = expiry === undefined ? undefined : BigInt(expiry) @@ -379,5 +415,25 @@ export function fulfillGrantPermissions( expiry: expiryBig, }) - return { actor, change, session } + // Ensure the manager is a trusted-executor actor so its forwarded + // `executeBatch` can drive the account; register it in the same batch if not. + let managerChange: AaAuthorizeActor | undefined + if (!assumeManagerRegistered) { + const managerActor = key.trustedExecutor(session.manager) + const { authenticator } = await getActorConfig(client, { + account, + actorId: managerActor.actorId, + ...(accountConfiguration ? { accountConfiguration } : {}), + }) + const registered = + authenticator.toLowerCase() === trustedExecutorAuthenticator.toLowerCase() + if (!registered) + managerChange = authorizeActor(managerActor, { + scope: actorScope.sender, + }) + } + + const changes = managerChange ? [managerChange, change] : [change] + + return { actor, change, managerChange, changes, session } } From 566687c1a46bd4417c58fe86c22dc3c437d40a11 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 20:48:51 -0400 Subject: [PATCH 70/96] =?UTF-8?q?feat(eip8130):=20fulfillAddSubAccount=20?= =?UTF-8?q?=E2=80=94=20ERC-7895=20=E2=86=92=20delegate-linked=20sub-accoun?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fulfills a wallet_addSubAccount (type: 'create') request as a distinct 8130 smart account (own address, asset isolation) whose initial owner set is the requested keys plus key.delegate(parent). The parent-control link is installed at creation (in createChange) — no separate change tx — and the returned handle signs for the sub-account with the parent's signer via the delegate authenticator. - maps ERC-7895 key types → owner actors (address→k1, p256/webcrypto→p256, webauthn→webAuthn); rejects duplicate/colliding actor ids. - returns the account handle + createChange + parentActor + ERC-7895 response. - unit-tested and documented in sub-accounts. --- site/pages/eip8130/sub-accounts.mdx | 29 ++++ src/eip8130/index.ts | 7 + src/eip8130/subAccounts.test.ts | 100 ++++++++++++++ src/eip8130/subAccounts.ts | 196 ++++++++++++++++++++++++++++ 4 files changed, 332 insertions(+) create mode 100644 src/eip8130/subAccounts.test.ts create mode 100644 src/eip8130/subAccounts.ts diff --git a/site/pages/eip8130/sub-accounts.mdx b/site/pages/eip8130/sub-accounts.mdx index d8a76b0518..ed1c4d4f76 100644 --- a/site/pages/eip8130/sub-accounts.mdx +++ b/site/pages/eip8130/sub-accounts.mdx @@ -99,6 +99,35 @@ const unlink = await subAccount.change( ) ``` +## Fulfilling an ERC-7895 request + +When a dApp asks a wallet to create a sub account via [ERC-7895](https://eips.ethereum.org/EIPS/eip-7895) `wallet_addSubAccount` (`type: 'create'`), `fulfillAddSubAccount` builds the two primitives above in one step: a **distinct** account whose owner set is the requested `keys` **plus** `key.delegate(parent)` — the link installed at creation, so no separate change transaction is needed. + +```ts +import { fulfillAddSubAccount, sendCalls } from 'viem/eip8130' + +// `keys` are the owner keys the dApp requested (wallet_addSubAccount). +const sub = fulfillAddSubAccount({ + parent: parent.address, + signer: parent.signer, // signs for the sub account via the delegate authenticator + proxy: 'erc1167', + keys: [{ publicKey: dappKeyAddress, type: 'address' }], +}) + +// ERC-7895 response shape: +sub.response.address + +// Deploy + first call in one shot — the parent drives it as a delegate: +const hash = await sendCalls(client, { + account: sub, + accountChanges: [sub.createChange], + calls: [{ to: recipient, value: 1n }], + gas: 300_000n, +}) +``` + +The returned handle is a full account (its own `address`, `createChange`, and `signTransaction`) driven by the parent's `signer` through the delegate authenticator. Requested `keys` map to owner actors by `type`: `'address'` → `key.k1`, `'p256'` / `'webcrypto-p256'` → `key.p256`, `'webauthn-p256'` → `key.webAuthn`. Pass a stable `salt` to derive a deterministic sub-account address. + ## Next - Let a third party pay for a sub account's gas — [Sponsoring Transactions](/eip8130/sponsoring-transactions). diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index 3abde0dc4b..746e9f35bf 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -203,6 +203,13 @@ export { sessionPolicyAbi, sessionPolicyAddress, } from './policies.js' +export { + type FulfillAddSubAccountErrorType, + type FulfillAddSubAccountParameters, + type FulfillAddSubAccountReturnType, + fulfillAddSubAccount, + type SubAccountKey, +} from './subAccounts.js' export type { AaAccountChange, AaAccountChangeConfig, diff --git a/src/eip8130/subAccounts.test.ts b/src/eip8130/subAccounts.test.ts new file mode 100644 index 0000000000..7fabce386d --- /dev/null +++ b/src/eip8130/subAccounts.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../accounts/privateKeyToAccount.js' +import { canonicalAuthenticators, scopeUnrestricted } from './constants.js' +import { key } from './keys.js' +import { fulfillAddSubAccount } from './subAccounts.js' + +// Deterministic parent signer for stable addresses. +const parentSigner = privateKeyToAccount(`0x${'11'.repeat(32)}`) +const parent = parentSigner.address + +const dappKey = '0x00000000000000000000000000000000000da990' +const salt = `0x${'aa'.repeat(32)}` as const + +describe('fulfillAddSubAccount', () => { + test('creates a distinct account controlled by the parent delegate', () => { + const sub = fulfillAddSubAccount({ + parent, + signer: parentSigner, + proxy: 'erc1167', + salt, + keys: [{ publicKey: dappKey, type: 'address' }], + }) + + // Its own address (asset isolation), and the ERC-7895 response mirrors it. + expect(sub.address).toMatch(/^0x[0-9a-fA-F]{40}$/) + expect(sub.response.address).toBe(sub.address) + + // Parent is an unrestricted delegate actor; sub-account signs via delegate. + expect(sub.parentActor).toEqual(key.delegate(parent)) + expect(sub.parentActor.authenticator).toBe(canonicalAuthenticators.delegate) + expect(sub.actorId).toBe(key.delegate(parent).actorId) + expect(sub.scope).toBe(scopeUnrestricted) + + // Owner set = parent delegate + requested key, and deployable via createChange. + expect(sub.initialActors).toContainEqual(key.delegate(parent)) + expect(sub.initialActors).toContainEqual(key.k1(dappKey)) + expect(sub.createChange).toBeDefined() + }) + + test('initial actors are sorted by actorId ascending', () => { + const sub = fulfillAddSubAccount({ + parent, + signer: parentSigner, + proxy: 'erc1167', + salt, + keys: [ + { + publicKey: '0x00000000000000000000000000000000000000ff', + type: 'address', + }, + { + publicKey: '0x0000000000000000000000000000000000000011', + type: 'address', + }, + ], + }) + const ids = sub.initialActors.map((a) => BigInt(a.actorId as `0x${string}`)) + const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + expect(ids).toEqual(sorted) + }) + + test('distinct salts yield distinct sub-accounts', () => { + const a = fulfillAddSubAccount({ + parent, + signer: parentSigner, + proxy: 'erc1167', + salt: `0x${'01'.repeat(32)}`, + }) + const b = fulfillAddSubAccount({ + parent, + signer: parentSigner, + proxy: 'erc1167', + salt: `0x${'02'.repeat(32)}`, + }) + expect(a.address).not.toBe(b.address) + }) + + test('rejects a requested key that collides with the parent delegate', () => { + expect(() => + fulfillAddSubAccount({ + parent, + signer: parentSigner, + proxy: 'erc1167', + salt, + // Same actorId as key.delegate(parent) (both derive from the parent address). + keys: [{ publicKey: parent, type: 'address' }], + }), + ).toThrow(/Duplicate initial actor id/) + }) + + test('upgradeable proxy without an implementation throws (pending enshrinement)', () => { + expect(() => + fulfillAddSubAccount({ + parent, + signer: parentSigner, + salt, + }), + ).toThrow(/UpgradeableAccount/) + }) +}) diff --git a/src/eip8130/subAccounts.ts b/src/eip8130/subAccounts.ts new file mode 100644 index 0000000000..34c8f4dfa7 --- /dev/null +++ b/src/eip8130/subAccounts.ts @@ -0,0 +1,196 @@ +import type { Address } from 'abitype' +import { BaseError } from '../errors/base.js' +import type { Hex } from '../types/misc.js' +import { hexToBigInt } from '../utils/encoding/fromHex.js' +import { bytesToHex } from '../utils/encoding/toHex.js' +import { type ToAccountReturnType, toAccount } from './accounts/toAccount.js' +import { canonicalAuthenticators, scopeUnrestricted } from './constants.js' +import { canonicalEip8130Deployment } from './deployments.js' +import { key } from './keys.js' +import type { AaAccountChangeCreate, AaActor } from './types/transaction.js' +import { erc1167Bytecode, upgradeableProxyBytecode } from './utils/proxy.js' +import type { Signer } from './utils/signTransaction.js' + +/** + * An ERC-7895 requested owner key for a `type: 'create'` sub-account. `type` + * selects the EIP-8130 authenticator the key is registered under. + */ +export type SubAccountKey = { + /** Owner public key: an address (`type: 'address'`) or a P-256 public key. */ + publicKey: Hex + /** Key scheme. */ + type: 'address' | 'p256' | 'webcrypto-p256' | 'webauthn-p256' +} + +/** Maps an ERC-7895 requested key to its EIP-8130 owner actor. */ +function toKeyActor(k: SubAccountKey): AaActor { + switch (k.type) { + case 'address': + return key.k1(k.publicKey as Address) + case 'p256': + case 'webcrypto-p256': + return key.p256(k.publicKey) + case 'webauthn-p256': + return key.webAuthn(k.publicKey) + default: + throw new BaseError(`Unsupported sub-account key type: "${k.type}".`) + } +} + +function randomBytes32(): Hex { + const buf = new Uint8Array(32) + globalThis.crypto.getRandomValues(buf) + return bytesToHex(buf) +} + +export type FulfillAddSubAccountParameters = { + /** + * The parent account that controls the sub-account. Registered on the + * sub-account as an unrestricted **delegate** actor (`key.delegate(parent)`), + * so anyone who can act on the parent can drive the sub-account — a + * "controlled by" link, without sharing raw keys or reusing key material. + */ + parent: Address + /** + * The parent's controlling signer. Produces the sub-account's `senderAuth` + * through the delegate authenticator (the signature is validated for `parent`). + */ + signer: Signer + /** + * ERC-7895 requested owner keys (from a `type: 'create'` request) registered + * as additional unrestricted co-owners of the sub-account. + */ + keys?: readonly SubAccountKey[] | undefined + /** + * CREATE2 uniqueness factor (bytes32). Randomly generated if omitted; pass a + * stable salt to derive a deterministic sub-account address. + */ + salt?: Hex | undefined + /** Per-account proxy. @default 'upgradeable' (see {@link newSmartAccount}). */ + proxy?: 'erc1167' | 'upgradeable' | undefined + /** Implementation the proxy delegates to. */ + implementation?: Address | undefined + /** Deployment bytecode override (bypasses `proxy`/`implementation`). */ + code?: Hex | undefined + /** AccountConfiguration contract override. Defaults to canonical. */ + accountConfigAddress?: Address | undefined +} + +export type FulfillAddSubAccountReturnType = ToAccountReturnType & { + /** + * The `create` account-change entry — include in the sub-account's first + * transaction's `accountChanges` to deploy + link it in one shot. + */ + readonly createChange: AaAccountChangeCreate + /** The delegate actor linking the sub-account to its `parent`. */ + readonly parentActor: AaActor + /** ERC-7895 `wallet_addSubAccount` response. */ + readonly response: { address: Address } +} + +export type FulfillAddSubAccountErrorType = BaseError + +/** + * Fulfill an ERC-7895 `wallet_addSubAccount` (`type: 'create'`) request as a + * **distinct** EIP-8130 smart account controlled by the `parent` via a delegate + * actor. + * + * The sub-account is its own address (asset isolation) whose initial owner set + * is the requested `keys` plus `key.delegate(parent)` — so the parent can co- + * sign / recover it while the requested keys operate it. The link is installed + * at creation (in `createChange`), so no separate change transaction is needed. + * + * The returned handle signs for the sub-account with the parent's `signer` + * through the delegate authenticator. Deploy on first use by including + * `createChange` in the first transaction's `accountChanges`. + * + * @example + * import { fulfillAddSubAccount, sendCalls } from 'viem/eip8130' + * + * const sub = fulfillAddSubAccount({ + * parent: parent.address, + * signer: parent.signer, // signs via the delegate authenticator + * proxy: 'erc1167', + * keys: [{ publicKey: dappKeyAddress, type: 'address' }], + * }) + * + * // ERC-7895 response: { address } + * sub.response.address + * + * // deploy + first call in one shot (parent drives it as a delegate) + * await sendCalls(client, { + * account: sub, + * accountChanges: [sub.createChange], + * calls: [{ to: recipient, value: 1n }], + * gas: 300_000n, + * }) + */ +export function fulfillAddSubAccount( + parameters: FulfillAddSubAccountParameters, +): FulfillAddSubAccountReturnType { + const { + parent, + signer, + keys = [], + proxy = 'upgradeable', + implementation, + accountConfigAddress, + } = parameters + + const parentActor = key.delegate(parent) + + // Owner set: the parent delegate + the requested keys, sorted by actorId in + // strictly ascending order (protocol requirement), rejecting duplicates. + const initialActors: AaActor[] = [parentActor, ...keys.map(toKeyActor)].sort( + (a, b) => { + const ai = hexToBigInt(a.actorId as Hex) + const bi = hexToBigInt(b.actorId as Hex) + return ai < bi ? -1 : ai > bi ? 1 : 0 + }, + ) + for (let i = 1; i < initialActors.length; i++) + if (initialActors[i]!.actorId === initialActors[i - 1]!.actorId) + throw new BaseError( + `Duplicate initial actor id \`${initialActors[i]!.actorId}\` (parent delegate and requested keys must be distinct).`, + ) + + const salt = parameters.salt ?? randomBytes32() + + const code = + parameters.code ?? + (() => { + if (proxy === 'erc1167') + return erc1167Bytecode( + implementation ?? canonicalEip8130Deployment.accounts.default, + ) + const impl = + implementation ?? canonicalEip8130Deployment.accounts.upgradeable + if (!impl) + throw new BaseError( + 'No canonical `UpgradeableAccount` is enshrined yet (pending final ' + + 'implementation), so `proxy: "upgradeable"` requires an explicit ' + + '`implementation`. Pass `proxy: "erc1167"` for an immutable ' + + 'DefaultAccount-backed sub-account.', + ) + return upgradeableProxyBytecode(impl) + })() + + const inner = toAccount({ + signer, + userSalt: salt, + code, + initialActors, + // The sub-account is driven by the parent through the delegate authenticator. + authenticator: canonicalAuthenticators.delegate, + actorId: parentActor.actorId, + scope: scopeUnrestricted, + accountConfigAddress, + }) + + return { + ...inner, + createChange: inner.create(), + parentActor, + response: { address: inner.address }, + } +} From 3d54e17d93a1cc831a9cd71f0d4b9ea0ba5003b4 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 21:10:07 -0400 Subject: [PATCH 71/96] feat(eip8130): EIP-5792 capabilities advertisement + scoped sub-account keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eip8130Capabilities / eip8130CapabilitiesByChain: wallet-side descriptor for wallet_getCapabilities advertising exactly what the adapters support — atomic batches, ERC-7715 grants (permissions/policy/signer types), and ERC-7895 sub-accounts (key types); optional paymasterService (ERC-8168). custom and gas-limit are deliberately not advertised (can't be lowered to a SessionPolicy). - fulfillAddSubAccount: add keyScope / keyPolicy so requested keys can be registered as scoped (optionally policy-gated) session-key actors instead of full co-owners, keeping the parent the sole unrestricted owner. Both exported from viem/eip8130, unit-tested, and documented. --- site/pages/eip8130/session-keys.mdx | 24 +++++ site/pages/eip8130/sub-accounts.mdx | 22 +++++ src/eip8130/capabilities.test.ts | 61 +++++++++++++ src/eip8130/capabilities.ts | 132 ++++++++++++++++++++++++++++ src/eip8130/index.ts | 10 +++ src/eip8130/subAccounts.test.ts | 53 ++++++++++- src/eip8130/subAccounts.ts | 48 +++++++--- 7 files changed, 338 insertions(+), 12 deletions(-) create mode 100644 src/eip8130/capabilities.test.ts create mode 100644 src/eip8130/capabilities.ts diff --git a/site/pages/eip8130/session-keys.mdx b/site/pages/eip8130/session-keys.mdx index dea7bf4e05..692183ff74 100644 --- a/site/pages/eip8130/session-keys.mdx +++ b/site/pages/eip8130/session-keys.mdx @@ -199,3 +199,27 @@ The permission → policy mapping is: | `gas-limit` | ignored — gas is settled by the payer / [ERC-8168](/eip8168) layer | A transfer permission without a `token-allowance` throws (an unbounded spend can't be safely granted), as do `custom` permissions/policies — build the `SessionPolicyConfig` explicitly for those. Use `toSessionPolicyConfig` (the pure mapping) or `toSessionPolicy` (mapping + binding) directly if you only need the lowered config or the bound policy without the `authorizeActor` change. + +## Advertising support (EIP-5792) + +So a dApp can discover — before requesting — which permission, policy, and key types it can rely on, a wallet returns `eip8130Capabilities` from its [EIP-5792](https://eips.ethereum.org/EIPS/eip-5792) `wallet_getCapabilities` handler. It describes exactly what the adapters here support: atomic batches, ERC-7715 grants (`fulfillGrantPermissions`), and ERC-7895 sub-accounts (`fulfillAddSubAccount`). + +```ts +import { eip8130CapabilitiesByChain } from 'viem/eip8130' + +// In the wallet's `wallet_getCapabilities` handler (per chain): +const capabilities = eip8130CapabilitiesByChain([8453, 84532], { + paymasterService: true, // if the wallet exposes an ERC-8168 payer +}) +// { +// '0x2105': { +// atomic: { status: 'supported' }, +// permissions: { supported: true, signerTypes, permissionTypes, policyTypes }, +// unstable_addSubAccount: { supported: true, keyTypes }, +// paymasterService: { supported: true }, +// }, +// ... +// } +``` + +The advertised lists (`supportedPermissionTypes`, `supportedPolicyTypes`, `supportedSubAccountKeyTypes`) mirror what the adapters actually accept — `custom` and `gas-limit` are deliberately excluded, since they can't be lowered to a `SessionPolicy`. diff --git a/site/pages/eip8130/sub-accounts.mdx b/site/pages/eip8130/sub-accounts.mdx index ed1c4d4f76..b9c7b99215 100644 --- a/site/pages/eip8130/sub-accounts.mdx +++ b/site/pages/eip8130/sub-accounts.mdx @@ -128,6 +128,28 @@ const hash = await sendCalls(client, { The returned handle is a full account (its own `address`, `createChange`, and `signTransaction`) driven by the parent's `signer` through the delegate authenticator. Requested `keys` map to owner actors by `type`: `'address'` → `key.k1`, `'p256'` / `'webcrypto-p256'` → `key.p256`, `'webauthn-p256'` → `key.webAuthn`. Pass a stable `salt` to derive a deterministic sub-account address. +By default the requested `keys` are unrestricted **co-owners**. Pass `keyScope` (and optionally `keyPolicy`) to register them as **scoped** actors instead — so the parent stays the sole full owner while the dApp key is a bounded session key: + +```ts +import { actorScope, defineSessionPolicy, encodeSessionPolicyConfig } from 'viem/eip8130' + +const session = defineSessionPolicy({ + account: subAddress, // the sub-account address you're creating + policyConfig: encodeSessionPolicyConfig({ + tokenLimits: [{ token: usdc, limit: 100_000_000n, period: 604_800n }], + }), +}) + +const sub = fulfillAddSubAccount({ + parent: parent.address, + signer: parent.signer, + proxy: 'erc1167', + keys: [{ publicKey: dappKeyAddress, type: 'address' }], + keyScope: actorScope.policy, // policy-gated session key (not a full owner) + keyPolicy: session.actorPolicy, +}) +``` + ## Next - Let a third party pay for a sub account's gas — [Sponsoring Transactions](/eip8130/sponsoring-transactions). diff --git a/src/eip8130/capabilities.test.ts b/src/eip8130/capabilities.test.ts new file mode 100644 index 0000000000..324a2f1545 --- /dev/null +++ b/src/eip8130/capabilities.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'vitest' +import { + eip8130Capabilities, + eip8130CapabilitiesByChain, + supportedPermissionTypes, + supportedPolicyTypes, + supportedSubAccountKeyTypes, +} from './capabilities.js' + +describe('eip8130Capabilities', () => { + test('advertises exactly what the adapters support', () => { + expect(eip8130Capabilities()).toEqual({ + atomic: { status: 'supported' }, + permissions: { + supported: true, + signerTypes: ['account', 'key'], + permissionTypes: supportedPermissionTypes, + policyTypes: supportedPolicyTypes, + }, + unstable_addSubAccount: { + supported: true, + keyTypes: supportedSubAccountKeyTypes, + }, + }) + }) + + test('does not advertise custom / gas-limit (not safely lowerable)', () => { + const { permissions } = eip8130Capabilities() + expect(permissions.permissionTypes).not.toContain('custom') + expect(permissions.policyTypes).not.toContain('custom') + expect(permissions.policyTypes).not.toContain('gas-limit') + }) + + test('paymasterService advertised only when set', () => { + expect(eip8130Capabilities().paymasterService).toBeUndefined() + expect(eip8130Capabilities({ paymasterService: true })).toMatchObject({ + paymasterService: { supported: true }, + }) + expect( + eip8130Capabilities({ paymasterService: false }).paymasterService, + ).toEqual({ supported: false }) + }) + + test('overrides are respected', () => { + const caps = eip8130Capabilities({ + permissionTypes: ['erc20-token-transfer'], + subAccountKeyTypes: ['address'], + }) + expect(caps.permissions.permissionTypes).toEqual(['erc20-token-transfer']) + expect(caps.unstable_addSubAccount.keyTypes).toEqual(['address']) + }) +}) + +describe('eip8130CapabilitiesByChain', () => { + test('keys the descriptor by hex chain id', () => { + const record = eip8130CapabilitiesByChain([8453, '0x14a34']) + expect(Object.keys(record)).toEqual(['0x2105', '0x14a34']) + expect(record['0x2105']).toEqual(eip8130Capabilities()) + expect(record['0x14a34']).toEqual(record['0x2105']) + }) +}) diff --git a/src/eip8130/capabilities.ts b/src/eip8130/capabilities.ts new file mode 100644 index 0000000000..948af7e3c1 --- /dev/null +++ b/src/eip8130/capabilities.ts @@ -0,0 +1,132 @@ +import type { Hex } from '../types/misc.js' +import { numberToHex } from '../utils/encoding/toHex.js' + +/** + * ERC-7715 permission types `fulfillGrantPermissions` can lower to a + * `SessionPolicy`. `custom` is intentionally excluded (it cannot be safely + * lowered), and gas is settled by the payer layer, not the session policy. + */ +export const supportedPermissionTypes = [ + 'native-token-transfer', + 'erc20-token-transfer', + 'contract-call', +] as const + +/** ERC-7715 policy types the session-policy mapping enforces. */ +export const supportedPolicyTypes = ['token-allowance', 'rate-limit'] as const + +/** ERC-7715 signer types accepted for a grant (a session key address). */ +export const supportedSignerTypes = ['account', 'key'] as const + +/** ERC-7895 sub-account owner key types `fulfillAddSubAccount` accepts. */ +export const supportedSubAccountKeyTypes = [ + 'address', + 'p256', + 'webcrypto-p256', + 'webauthn-p256', +] as const + +export type Eip8130Capabilities = { + /** EIP-8130 batches account changes + calls atomically in one transaction. */ + atomic: { status: 'supported' } + /** ERC-7715 `wallet_grantPermissions` support (session keys / subscriptions). */ + permissions: { + supported: true + signerTypes: readonly string[] + permissionTypes: readonly string[] + policyTypes: readonly string[] + } + /** ERC-7895 `wallet_addSubAccount` support. */ + unstable_addSubAccount: { + supported: true + keyTypes: readonly string[] + } + /** ERC-8168 payer / paymaster sponsorship, when the wallet exposes it. */ + paymasterService?: { supported: boolean } | undefined +} + +export type Eip8130CapabilitiesParameters = { + /** + * Advertise ERC-8168 payer / paymaster sponsorship (`paymasterService`). + * @default false + */ + paymasterService?: boolean | undefined + /** Override the advertised ERC-7715 signer types. */ + signerTypes?: readonly string[] | undefined + /** Override the advertised ERC-7715 permission types. */ + permissionTypes?: readonly string[] | undefined + /** Override the advertised ERC-7715 policy types. */ + policyTypes?: readonly string[] | undefined + /** Override the advertised ERC-7895 sub-account key types. */ + subAccountKeyTypes?: readonly string[] | undefined +} + +/** + * Builds the EIP-8130 wallet capabilities advertised via EIP-5792 + * `wallet_getCapabilities`, describing exactly what the adapters in this module + * support: atomic batches, ERC-7715 grants (`fulfillGrantPermissions`), and + * ERC-7895 sub-accounts (`fulfillAddSubAccount`). + * + * A wallet returns this (per chain — see {@link eip8130CapabilitiesByChain}) so + * a dApp can discover, before requesting, which permission / policy / key types + * it can rely on. + * + * @example + * import { eip8130Capabilities } from 'viem/eip8130' + * + * // In a wallet's `wallet_getCapabilities` handler: + * const capabilities = eip8130Capabilities({ paymasterService: true }) + */ +export function eip8130Capabilities( + parameters: Eip8130CapabilitiesParameters = {}, +): Eip8130Capabilities { + const { + paymasterService, + signerTypes = supportedSignerTypes, + permissionTypes = supportedPermissionTypes, + policyTypes = supportedPolicyTypes, + subAccountKeyTypes = supportedSubAccountKeyTypes, + } = parameters + + return { + atomic: { status: 'supported' }, + permissions: { + supported: true, + signerTypes, + permissionTypes, + policyTypes, + }, + unstable_addSubAccount: { + supported: true, + keyTypes: subAccountKeyTypes, + }, + ...(paymasterService !== undefined + ? { paymasterService: { supported: paymasterService } } + : {}), + } +} + +/** + * The same {@link eip8130Capabilities} keyed by chain id (hex), matching the + * `wallet_getCapabilities` return shape (a per-chain record). EIP-8130 + * capabilities are identical across supported chains, so every chain maps to + * the same descriptor. + * + * @example + * import { eip8130CapabilitiesByChain } from 'viem/eip8130' + * + * const capabilities = eip8130CapabilitiesByChain([8453, 84532]) + * // { '0x2105': { ... }, '0x14a34': { ... } } + */ +export function eip8130CapabilitiesByChain( + chainIds: readonly (number | Hex)[], + parameters: Eip8130CapabilitiesParameters = {}, +): Record { + const capabilities = eip8130Capabilities(parameters) + const record: Record = {} + for (const chainId of chainIds) { + const key = typeof chainId === 'number' ? numberToHex(chainId) : chainId + record[key] = capabilities + } + return record +} diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index 746e9f35bf..f2cf7594dd 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -95,6 +95,16 @@ export { type WaitForTransactionReceiptReturnType, waitForTransactionReceipt, } from './actions/waitForTransactionReceipt.js' +export { + type Eip8130Capabilities, + type Eip8130CapabilitiesParameters, + eip8130Capabilities, + eip8130CapabilitiesByChain, + supportedPermissionTypes, + supportedPolicyTypes, + supportedSignerTypes, + supportedSubAccountKeyTypes, +} from './capabilities.js' export { eip8130ChainIds, type Is8130EnabledParameters, diff --git a/src/eip8130/subAccounts.test.ts b/src/eip8130/subAccounts.test.ts index 7fabce386d..0d829ea3a7 100644 --- a/src/eip8130/subAccounts.test.ts +++ b/src/eip8130/subAccounts.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from 'vitest' import { privateKeyToAccount } from '../accounts/privateKeyToAccount.js' -import { canonicalAuthenticators, scopeUnrestricted } from './constants.js' -import { key } from './keys.js' +import { + actorScope, + canonicalAuthenticators, + scopeUnrestricted, +} from './constants.js' +import { encodePolicyData, key } from './keys.js' import { fulfillAddSubAccount } from './subAccounts.js' // Deterministic parent signer for stable addresses. @@ -88,6 +92,51 @@ describe('fulfillAddSubAccount', () => { ).toThrow(/Duplicate initial actor id/) }) + test('keyScope registers requested keys as scoped actors (parent stays sole owner)', () => { + const sub = fulfillAddSubAccount({ + parent, + signer: parentSigner, + proxy: 'erc1167', + salt, + keys: [{ publicKey: dappKey, type: 'address' }], + keyScope: actorScope.sender, + }) + + const keyActor = sub.initialActors.find( + (a) => a.actorId === key.k1(dappKey).actorId, + ) + expect(keyActor?.scope).toBe(actorScope.sender) + + // The parent delegate remains an unrestricted admin. + const parentActor = sub.initialActors.find( + (a) => a.actorId === key.delegate(parent).actorId, + ) + expect(parentActor?.scope ?? scopeUnrestricted).toBe(scopeUnrestricted) + }) + + test('keyPolicy gates requested keys (policy-scoped session key)', () => { + const policy = { + type: 1, + manager: '0x00000000000000000000000000000000000ma9a5', + commitment: `0x${'cc'.repeat(32)}`, + } as const + const sub = fulfillAddSubAccount({ + parent, + signer: parentSigner, + proxy: 'erc1167', + salt, + keys: [{ publicKey: dappKey, type: 'address' }], + keyScope: actorScope.policy, + keyPolicy: policy, + }) + + const keyActor = sub.initialActors.find( + (a) => a.actorId === key.k1(dappKey).actorId, + ) + expect(keyActor?.scope).toBe(actorScope.policy) + expect(keyActor?.policyData).toBe(encodePolicyData(policy)) + }) + test('upgradeable proxy without an implementation throws (pending enshrinement)', () => { expect(() => fulfillAddSubAccount({ diff --git a/src/eip8130/subAccounts.ts b/src/eip8130/subAccounts.ts index 34c8f4dfa7..0cfa28f46c 100644 --- a/src/eip8130/subAccounts.ts +++ b/src/eip8130/subAccounts.ts @@ -6,7 +6,7 @@ import { bytesToHex } from '../utils/encoding/toHex.js' import { type ToAccountReturnType, toAccount } from './accounts/toAccount.js' import { canonicalAuthenticators, scopeUnrestricted } from './constants.js' import { canonicalEip8130Deployment } from './deployments.js' -import { key } from './keys.js' +import { authorizeActor, key, type Policy } from './keys.js' import type { AaAccountChangeCreate, AaActor } from './types/transaction.js' import { erc1167Bytecode, upgradeableProxyBytecode } from './utils/proxy.js' import type { Signer } from './utils/signTransaction.js' @@ -57,10 +57,26 @@ export type FulfillAddSubAccountParameters = { */ signer: Signer /** - * ERC-7895 requested owner keys (from a `type: 'create'` request) registered - * as additional unrestricted co-owners of the sub-account. + * ERC-7895 requested keys (from a `type: 'create'` request). By default they + * are registered as additional unrestricted **co-owners**; pass `keyScope` + * (and optionally `keyPolicy`) to register them as **scoped** actors instead, + * keeping the parent the sole full owner. */ keys?: readonly SubAccountKey[] | undefined + /** + * Scope applied to the requested `keys`. Omitted or {@link scopeUnrestricted} + * (`0`, default) registers them as unrestricted co-owners (admins). Set a + * restricted scope (e.g. `actorScope.sender`, or `actorScope.policy` with + * `keyPolicy`) to register them as scoped session-key actors — so the parent + * remains the only unrestricted owner. + */ + keyScope?: number | undefined + /** + * Policy gate applied to the requested `keys` (a `SessionPolicy` binding — see + * `defineSessionPolicy`). Requires a restricted `keyScope` (the `SCOPE_POLICY` + * bit is added automatically). Ignored when `keyScope` is unrestricted. + */ + keyPolicy?: Policy | undefined /** * CREATE2 uniqueness factor (bytes32). Randomly generated if omitted; pass a * stable salt to derive a deterministic sub-account address. @@ -132,6 +148,8 @@ export function fulfillAddSubAccount( parent, signer, keys = [], + keyScope, + keyPolicy, proxy = 'upgradeable', implementation, accountConfigAddress, @@ -139,15 +157,25 @@ export function fulfillAddSubAccount( const parentActor = key.delegate(parent) + // Map each requested key to an owner actor: an unrestricted admin by default, + // or a scoped (optionally policy-gated) actor when `keyScope` is set. + const restricted = keyScope !== undefined && keyScope !== scopeUnrestricted + const keyActors: AaActor[] = keys.map((k) => { + const actor = toKeyActor(k) + if (!restricted) return actor + return authorizeActor(actor, { + scope: keyScope, + ...(keyPolicy ? { policy: keyPolicy } : {}), + }) + }) + // Owner set: the parent delegate + the requested keys, sorted by actorId in // strictly ascending order (protocol requirement), rejecting duplicates. - const initialActors: AaActor[] = [parentActor, ...keys.map(toKeyActor)].sort( - (a, b) => { - const ai = hexToBigInt(a.actorId as Hex) - const bi = hexToBigInt(b.actorId as Hex) - return ai < bi ? -1 : ai > bi ? 1 : 0 - }, - ) + const initialActors: AaActor[] = [parentActor, ...keyActors].sort((a, b) => { + const ai = hexToBigInt(a.actorId as Hex) + const bi = hexToBigInt(b.actorId as Hex) + return ai < bi ? -1 : ai > bi ? 1 : 0 + }) for (let i = 1; i < initialActors.length; i++) if (initialActors[i]!.actorId === initialActors[i - 1]!.actorId) throw new BaseError( From df44fa5a673afd18e05c761854dfe4355b789781 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Tue, 11 Aug 2026 21:19:29 -0400 Subject: [PATCH 72/96] feat(eip8130): permissionsContext routing (sendCalls-level grant redemption) Adds the ERC-7715 redemption path so a granted key's calls can be routed through the policy manager with no wallet-side storage: - toPermissionsContext / parsePermissionsContext: encode a grant into an opaque, self-describing context (account, role, actor, full policy binding) and decode it back into a rebound SessionPolicy (recomputes the same commitment). - routePermissionedCalls({ context, calls }): wraps each action as session.executeCall (session key, dispatched as the account) or executeForCall (external pull actor). - fulfillGrantPermissions now also returns `permissionsContext`. Exported from viem/eip8130, round-trip + routing tests, and documented. --- site/pages/eip8130/session-keys.mdx | 33 ++++++ src/eip8130/index.ts | 9 ++ src/eip8130/permissions.test.ts | 77 ++++++++++++ src/eip8130/permissions.ts | 178 +++++++++++++++++++++++++++- 4 files changed, 296 insertions(+), 1 deletion(-) diff --git a/site/pages/eip8130/session-keys.mdx b/site/pages/eip8130/session-keys.mdx index 692183ff74..27a550d2ab 100644 --- a/site/pages/eip8130/session-keys.mdx +++ b/site/pages/eip8130/session-keys.mdx @@ -188,6 +188,39 @@ await account.change(pull.changes) The manager registration is checked on-chain and included automatically only when missing. Pass `assumeManagerRegistered: true` to skip the read when you already know it's provisioned. ::: +### Redeeming a grant + +`fulfillGrantPermissions` also returns a `permissionsContext` — an opaque, **self-describing** string (per [ERC-7715](https://eips.ethereum.org/EIPS/eip-7715)) that encodes everything needed to redeem the grant later: the account, role, actor identity, and full policy binding. No wallet-side storage is required. + +When the granted key wants to act, `routePermissionedCalls` decodes it and wraps each action so it lands on the policy manager — `execute` for a session key (dispatched as the account) or `executeFor` for an external pull actor: + +```ts +import { + actorScope, + routePermissionedCalls, + sendCalls, + toAccount, +} from 'viem/eip8130' + +const { account, actor, calls } = routePermissionedCalls({ + context: permissionsContext, // returned at grant time + calls: [{ target: usdc, data: transferCalldata }], +}) + +// A session key signs and sends the routed calls AS the account: +const handle = toAccount({ + signer: sessionSigner, + address: account, + authenticator: actor.authenticator, + actorId: actor.actorId, + scope: actorScope.policy, +}) + +const hash = await sendCalls(client, { account: handle, calls, gas: 250_000n }) +``` + +For a `pull` grant the routed calls target `executeFor`, and the external provider submits them from **its own** address (not through the account). Use `parsePermissionsContext` if you only need to inspect a context (account, role, actor, rebound `SessionPolicy`) without routing calls. + The permission → policy mapping is: | ERC-7715 | EIP-8130 `SessionPolicy` | diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index f2cf7594dd..1ab0d1c36d 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -186,9 +186,18 @@ export { type FulfillGrantPermissionsReturnType, fulfillGrantPermissions, type GrantRole, + type ParsePermissionsContextErrorType, + type ParsePermissionsContextReturnType, + parsePermissionsContext, + type RoutePermissionedCallsErrorType, + type RoutePermissionedCallsParameters, + type RoutePermissionedCallsReturnType, + routePermissionedCalls, + type ToPermissionsContextParameters, type ToSessionPolicyConfigErrorType, type ToSessionPolicyErrorType, type ToSessionPolicyParameters, + toPermissionsContext, toSessionPolicy, toSessionPolicyConfig, } from './permissions.js' diff --git a/src/eip8130/permissions.test.ts b/src/eip8130/permissions.test.ts index fde7282c4c..c4d87f4876 100644 --- a/src/eip8130/permissions.test.ts +++ b/src/eip8130/permissions.test.ts @@ -17,6 +17,8 @@ import { import { encodePolicyData, key } from './keys.js' import { fulfillGrantPermissions, + parsePermissionsContext, + routePermissionedCalls, toSessionPolicy, toSessionPolicyConfig, } from './permissions.js' @@ -327,4 +329,79 @@ describe('fulfillGrantPermissions', () => { }) expect(decoded.functionName).toBe('executeFor') }) + + test('returns a permissionsContext that round-trips', async () => { + const client = actorConfigClient(trustedExecutorAuthenticator) + const { permissionsContext, session, actor } = + await fulfillGrantPermissions(client, { + account, + grantee, + permissions, + expiry, + }) + + const parsed = parsePermissionsContext(permissionsContext) + expect(parsed.account).toBe(account) + expect(parsed.role).toBe('session') + expect(parsed.actor).toEqual(actor) + // Rebound policy recomputes the identical commitment. + expect(parsed.session.commitment).toBe(session.commitment) + expect(parsed.session.binding.validUntil).toBe(BigInt(expiry)) + }) +}) + +describe('routePermissionedCalls', () => { + const permissions: readonly Permission[] = [ + { + type: 'erc20-token-transfer', + data: { address: usdc, ticker: 'USDC' }, + policies: [ + { type: 'token-allowance', data: { allowance: 100_000_000n } }, + ], + }, + ] + + test('session context → calls routed through PolicyManager.execute', async () => { + const client = actorConfigClient(trustedExecutorAuthenticator) + const { permissionsContext, session } = await fulfillGrantPermissions( + client, + { + account, + grantee: '0x00000000000000000000000000000000000acce5', + permissions, + }, + ) + + const routed = routePermissionedCalls({ + context: permissionsContext, + calls: [{ target: usdc, data: '0x' }], + }) + expect(routed.role).toBe('session') + expect(routed.calls).toHaveLength(1) + expect(routed.calls[0]!.to).toBe(session.manager) + expect( + decodeFunctionData({ abi: policyManagerAbi, data: routed.calls[0]!.data }) + .functionName, + ).toBe('execute') + }) + + test('pull context → calls routed through PolicyManager.executeFor', async () => { + const client = actorConfigClient(trustedExecutorAuthenticator) + const { permissionsContext } = await fulfillGrantPermissions(client, { + account, + grantee: '0x00000000000000000000000000000000000acce5', + role: 'pull', + permissions, + }) + + const routed = routePermissionedCalls({ + context: permissionsContext, + calls: [{ target: usdc, data: '0x' }], + }) + expect(routed.role).toBe('pull') + expect( + decodeFunctionData({ abi: policyManagerAbi, data: routed.calls[0]!.data }) + .functionName, + ).toBe('executeFor') + }) }) diff --git a/src/eip8130/permissions.ts b/src/eip8130/permissions.ts index 3dee689106..339d18c579 100644 --- a/src/eip8130/permissions.ts +++ b/src/eip8130/permissions.ts @@ -8,6 +8,8 @@ import type { Policy as GrantedPolicy } from '../experimental/erc7715/types/poli import type { Account } from '../types/account.js' import type { Chain } from '../types/chain.js' import type { Hex } from '../types/misc.js' +import { decodeAbiParameters } from '../utils/abi/decodeAbiParameters.js' +import { encodeAbiParameters } from '../utils/abi/encodeAbiParameters.js' import { toFunctionSelector } from '../utils/hash/toFunctionSelector.js' import { getActorConfig } from './actions/getActorConfig.js' import { actorScope, trustedExecutorAuthenticator } from './constants.js' @@ -17,6 +19,7 @@ import { defineSessionPolicy, encodeSessionPolicyConfig, type SessionPolicy, + type SessionPolicyAction, type SessionPolicyCallScope, type SessionPolicyConfig, type SessionPolicyTokenLimit, @@ -24,6 +27,7 @@ import { import type { AaActor, AaAuthorizeActor, + AaCall, AaChange, } from './types/transaction.js' @@ -331,6 +335,13 @@ export type FulfillGrantPermissionsReturnType = { * builders (`executeCall` for `session`, `executeForCall` for `pull`). */ session: SessionPolicy + /** + * Opaque, self-describing ERC-7715 `permissionsContext` for this grant. + * Return it to the dApp; later `routePermissionedCalls` / `sendPermissionedCalls` + * decode it to route the granted key's calls through the manager — no wallet- + * side storage needed (see {@link parsePermissionsContext}). + */ + permissionsContext: Hex } export type FulfillGrantPermissionsErrorType = ToSessionPolicyConfigErrorType @@ -435,5 +446,170 @@ export async function fulfillGrantPermissions< const changes = managerChange ? [managerChange, change] : [change] - return { actor, change, managerChange, changes, session } + const permissionsContext = toPermissionsContext({ role, actor, session }) + + return { actor, change, managerChange, changes, session, permissionsContext } +} + +// ───────────────────────────────────────────────────────────────────────────── +// permissionsContext — encode / decode / route +// ───────────────────────────────────────────────────────────────────────────── + +const grantRoleCode = { session: 0, pull: 1 } as const +const grantRoleName = [ + 'session', + 'pull', +] as const satisfies readonly GrantRole[] + +const permissionsContextParameters = [ + { type: 'address' }, // account + { type: 'uint8' }, // role + { type: 'bytes32' }, // actorId + { type: 'address' }, // authenticator + { type: 'address' }, // manager + { type: 'address' }, // policy + { type: 'bytes' }, // policyConfig + { type: 'uint40' }, // validAfter + { type: 'uint40' }, // validUntil + { type: 'uint256' }, // salt +] as const + +export type ToPermissionsContextParameters = { + /** The granted role (`session` or `pull`). */ + role: GrantRole + /** The authorized actor (`{ actorId, authenticator }`). */ + actor: AaActor + /** The bound {@link SessionPolicy} (carries the binding + manager + policy). */ + session: SessionPolicy +} + +export type ParsePermissionsContextReturnType = { + /** The account the permission was granted on. */ + account: Address + /** The granted role. */ + role: GrantRole + /** The authorized actor. */ + actor: AaActor + /** The rebound {@link SessionPolicy} (recomputes the same `commitment`). */ + session: SessionPolicy +} + +/** + * Encodes a grant into an opaque, **self-describing** ERC-7715 + * `permissionsContext` — everything needed to later route the granted key's + * calls (account, role, actor identity, and the full policy binding), so no + * wallet-side storage is required. Round-trips with {@link parsePermissionsContext}. + */ +export function toPermissionsContext( + parameters: ToPermissionsContextParameters, +): Hex { + const { role, actor, session } = parameters + const { binding } = session + return encodeAbiParameters(permissionsContextParameters, [ + binding.account, + grantRoleCode[role], + actor.actorId, + actor.authenticator, + session.manager, + binding.policy, + binding.policyConfig, + Number(binding.validAfter), + Number(binding.validUntil), + binding.salt, + ]) +} + +export type ParsePermissionsContextErrorType = BaseError + +/** + * Decodes an opaque {@link toPermissionsContext} `permissionsContext` back into + * the account, role, actor, and a rebound {@link SessionPolicy} (which recomputes + * the same `commitment` and exposes `executeCall` / `executeForCall`). + */ +export function parsePermissionsContext( + context: Hex, +): ParsePermissionsContextReturnType { + const [ + account, + roleCode, + actorId, + authenticator, + manager, + policy, + policyConfig, + validAfter, + validUntil, + salt, + ] = decodeAbiParameters(permissionsContextParameters, context) + + const role = grantRoleName[roleCode] + if (!role) + throw new BaseError(`Unknown permissionsContext role code: ${roleCode}.`) + + const session = defineSessionPolicy({ + account, + policy, + manager, + policyConfig, + validAfter: BigInt(validAfter), + validUntil: BigInt(validUntil), + salt, + }) + + return { account, role, actor: { actorId, authenticator }, session } +} + +export type RoutePermissionedCallsParameters = { + /** The ERC-7715 `permissionsContext` returned at grant time. */ + context: Hex + /** The actions the granted key wants to perform (target/value/data each). */ + calls: readonly SessionPolicyAction[] +} + +export type RoutePermissionedCallsReturnType = + ParsePermissionsContextReturnType & { + /** + * The calls to submit, each wrapped for the policy manager: + * `execute` for a `session` key (sent as the account) or `executeFor` for a + * `pull` actor (sent by the external caller from its own address). + */ + calls: readonly AaCall[] + } + +export type RoutePermissionedCallsErrorType = ParsePermissionsContextErrorType + +/** + * The `sendCalls`-level routing step: decodes a `permissionsContext` and wraps + * each user action so it lands on the policy manager under the granted key — + * `session.executeCall` for a session key (dispatched as the account) or + * `session.executeForCall` for an external pull actor. + * + * @example + * import { routePermissionedCalls, sendCalls, toAccount, actorScope } from 'viem/eip8130' + * + * const { account, actor, calls } = routePermissionedCalls({ + * context: permissionsContext, // from the grant + * calls: [{ target: usdc, data: transferCalldata }], + * }) + * + * // session key: send the routed calls AS the account, signed by the session key + * const handle = toAccount({ + * signer: sessionSigner, + * address: account, + * authenticator: actor.authenticator, + * actorId: actor.actorId, + * scope: actorScope.policy, + * }) + * await sendCalls(client, { account: handle, calls, gas }) + */ +export function routePermissionedCalls( + parameters: RoutePermissionedCallsParameters, +): RoutePermissionedCallsReturnType { + const { context, calls } = parameters + const parsed = parsePermissionsContext(context) + const wrap = + parsed.role === 'pull' + ? parsed.session.executeForCall + : parsed.session.executeCall + return { ...parsed, calls: calls.map((action) => wrap(action)) } } From 338286dd54e2ac477daec8ea8c631bb9d7f52675 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 12 Aug 2026 19:33:27 -0400 Subject: [PATCH 73/96] feat(eip8130): repin SessionPolicy address + document native-ETH fail-closed Update canonical SessionPolicy to 0x813070914C530d030f4Efd8Fa99C18e836435e55 (re-mined after base/eip-8130#80; PolicyManager unchanged). Document that native ETH is fail-closed: a session key can only move value with a zero-address tokenLimit; absent means no ETH, not unlimited. --- site/pages/eip8130/session-keys.mdx | 18 ++++++++++++++++++ src/eip8130/deployments.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/site/pages/eip8130/session-keys.mdx b/site/pages/eip8130/session-keys.mdx index 27a550d2ab..31230f326b 100644 --- a/site/pages/eip8130/session-keys.mdx +++ b/site/pages/eip8130/session-keys.mdx @@ -44,6 +44,24 @@ session.commitment // the policy commitment stored on the actor session.actorPolicy // pass to `authorizeActor(key, { scope, policy })` ``` +:::warning[Native ETH is fail-closed] +A session key can move ETH (a call with `value > 0`) **only** when the config has a `tokenLimits` entry for the **zero address** (`0x0000000000000000000000000000000000000000`); its `limit` caps cumulative native spend per `period`. With no native limit, any call carrying `value` reverts with `NativeValueNotAllowed` — an absent native limit means **no ETH**, not unlimited. (ERC-20s differ: an absent token limit is unbounded, because moving a token still requires an allowlisted `callScope` to its contract, which acts as the gate.) +::: + +To let a key spend ETH, add a native `tokenLimits` entry: + +```ts +import { parseEther, zeroAddress } from 'viem' +import { encodeSessionPolicyConfig } from 'viem/eip8130' + +encodeSessionPolicyConfig({ + // ≤ 0.1 ETH per day. + tokenLimits: [ + { token: zeroAddress, limit: parseEther('0.1'), period: 86_400n }, + ], +}) +``` + `selectorRules` may bind recipients for the standard ERC-20 selectors (`transfer`, `transferFrom`, `approve`): ```ts diff --git a/src/eip8130/deployments.ts b/src/eip8130/deployments.ts index b2db5e2568..720d6a2dd2 100644 --- a/src/eip8130/deployments.ts +++ b/src/eip8130/deployments.ts @@ -110,7 +110,7 @@ export const canonicalEip8130Deployment = { }, policies: { manager: '0x813077055d1110F92191ccE13018f51820B40ac1', - sessionPolicy: '0x81300Fd9bCa7DC7982474d0eaB0d936FF1C25E55', + sessionPolicy: '0x813070914C530d030f4Efd8Fa99C18e836435e55', }, } as const satisfies Eip8130Deployment From 3d72534d48ebccd190a3b4df9c76129d4900ac01 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 13 Aug 2026 13:42:50 -0400 Subject: [PATCH 74/96] chore: drop stray unrelated docs/test wording edits (tempo, circle-usdc) Revert incidental `on-chain` -> `onchain` wording changes in tempo and circle-usdc pages/tests that were unrelated to the EIP-8130 work, so the upstream PR stays scoped to the new eip8130/eip8168 modules. --- site/pages/circle-usdc/guides/integrating.mdx | 2 +- site/pages/tempo/actions/zone.encryptedDeposit.mdx | 2 +- site/pages/tempo/guides/multisig-transactions.mdx | 2 +- src/CHANGELOG.md | 2 +- src/tempo/actions/simulate.test.ts | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/site/pages/circle-usdc/guides/integrating.mdx b/site/pages/circle-usdc/guides/integrating.mdx index 82bc3fc140..2bc5fd59c3 100644 --- a/site/pages/circle-usdc/guides/integrating.mdx +++ b/site/pages/circle-usdc/guides/integrating.mdx @@ -20,7 +20,7 @@ By the end of this guide, you'll know how to: * Read and display USDC balances * Send USDC between wallets * Approve contracts (e.g., Uniswap) to spend USDC on your behalf -* Monitor onchain Transfer events in real-time +* Monitor on-chain Transfer events in real-time * Optimize data loading with batched readContract calls Each step is self-contained and modular — designed to be easily copied into your own project, whether you're building a wallet, a dashboard, a DeFi tool, or anything else powered by stable digital dollars. diff --git a/site/pages/tempo/actions/zone.encryptedDeposit.mdx b/site/pages/tempo/actions/zone.encryptedDeposit.mdx index c9e3f9a994..6e9ad75c6e 100644 --- a/site/pages/tempo/actions/zone.encryptedDeposit.mdx +++ b/site/pages/tempo/actions/zone.encryptedDeposit.mdx @@ -136,6 +136,6 @@ Optional deposit memo. Encrypted along with the recipient. - **Type:** `Address` - **Default:** `account.address` -Recipient address in the zone. Encrypted in the onchain payload. +Recipient address in the zone. Encrypted in the on-chain payload. diff --git a/site/pages/tempo/guides/multisig-transactions.mdx b/site/pages/tempo/guides/multisig-transactions.mdx index f4aa4072cb..3bc74745f5 100644 --- a/site/pages/tempo/guides/multisig-transactions.mdx +++ b/site/pages/tempo/guides/multisig-transactions.mdx @@ -28,7 +28,7 @@ With Viem, the flow is: 4. Broadcast the transaction with the collected `signatures` (the prepared request already carries the multisig account as sender). -The first transaction from a multisig account **auto-bootstraps** (registers) it onchain – +The first transaction from a multisig account **auto-bootstraps** (registers) it on-chain – you don't need to pass an explicit `init` flag. Subsequent transactions are sent normally. [See the Tempo Transactions specification](https://docs.tempo.xyz/protocol/transactions) diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index e1a45e4f92..93e801736f 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -447,7 +447,7 @@ - [#4621](https://github.com/wevm/viem/pull/4621) [`6d80eaeea315c552a57e9683607ed36f7d219a9e`](https://github.com/wevm/viem/commit/6d80eaeea315c552a57e9683607ed36f7d219a9e) Thanks [@Blessing-Circle](https://github.com/Blessing-Circle)! - Added Arc chain. -- [#4622](https://github.com/wevm/viem/pull/4622) [`c5dc4d63506f787e92417eff77dd0ef84e3a2c8c`](https://github.com/wevm/viem/commit/c5dc4d63506f787e92417eff77dd0ef84e3a2c8c) Thanks [@struong](https://github.com/struong)! - `viem/tempo`: Preserved `feeToken` on broadcast envelope when `feePayerSignature` is present. Previously stripped unconditionally when `feePayer === true`, breaking fee payer signature verification onchain. +- [#4622](https://github.com/wevm/viem/pull/4622) [`c5dc4d63506f787e92417eff77dd0ef84e3a2c8c`](https://github.com/wevm/viem/commit/c5dc4d63506f787e92417eff77dd0ef84e3a2c8c) Thanks [@struong](https://github.com/struong)! - `viem/tempo`: Preserved `feeToken` on broadcast envelope when `feePayerSignature` is present. Previously stripped unconditionally when `feePayer === true`, breaking fee payer signature verification on-chain. ## 2.49.2 diff --git a/src/tempo/actions/simulate.test.ts b/src/tempo/actions/simulate.test.ts index 77ff0dda9b..256c3c2c4a 100644 --- a/src/tempo/actions/simulate.test.ts +++ b/src/tempo/actions/simulate.test.ts @@ -391,7 +391,7 @@ describe('simulateCalls', () => { }) test('behavior: approve + dex swap + transfer', async () => { - // Set up token pair + liquidity onchain + // Set up token pair + liquidity on-chain const { base, quote } = await setupTokenPair(client as never) // Place sell order so there's liquidity to buy against @@ -459,7 +459,7 @@ describe('simulateCalls', () => { // Fund seller with fee tokens for gas await setupFeeToken(client, { account: seller }) - // Set up token pair + liquidity onchain + // Set up token pair + liquidity on-chain const { base, quote } = await setupTokenPair(client as never) // Fund seller with base tokens and approve DEX From 24aa695819c535ca4eac941c34cf8614cc331b05 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 13 Aug 2026 13:48:43 -0400 Subject: [PATCH 75/96] chore(eip8130): drop manual network-gated script harnesses from PR Remove `scripts/eip8130/**`, `scripts/smoke-estimate-sender-actor.mjs`, and the dedicated `test/vitest.eip8130.config.ts`, and revert `scripts/tsconfig.json` to upstream. These were deep-relative-import, credential/testnet-gated dev harnesses excluded from CI and `check:types`, with no upstream precedent under `scripts/`. CI-covered behavior remains fully tested by the colocated `src/eip8130/**/*.test.ts` suites. Harnesses remain recoverable in git history. --- scripts/eip8130/README.md | 52 --- scripts/eip8130/authorizeSessionKey.test.ts | 125 ------ scripts/eip8130/baseSepolia4337E2E.test.ts | 253 ----------- scripts/eip8130/buildTransaction.test.ts | 186 -------- .../eip8130/bundlerCreateAndExecute.test.ts | 115 ----- scripts/eip8130/bundlerProbeDeployed.test.ts | 87 ---- scripts/eip8130/policySmoke.test.ts | 418 ------------------ scripts/eip8130/selfBundleCreate.test.ts | 134 ------ scripts/eip8130/selfBundleRotateP256.test.ts | 184 -------- scripts/eip8130/setupAccount.test.ts | 89 ---- scripts/eip8130/vibenet6PartTest.test.ts | 318 ------------- scripts/smoke-estimate-sender-actor.mjs | 145 ------ scripts/tsconfig.json | 4 - test/vitest.eip8130.config.ts | 21 - 14 files changed, 2131 deletions(-) delete mode 100644 scripts/eip8130/README.md delete mode 100644 scripts/eip8130/authorizeSessionKey.test.ts delete mode 100644 scripts/eip8130/baseSepolia4337E2E.test.ts delete mode 100644 scripts/eip8130/buildTransaction.test.ts delete mode 100644 scripts/eip8130/bundlerCreateAndExecute.test.ts delete mode 100644 scripts/eip8130/bundlerProbeDeployed.test.ts delete mode 100644 scripts/eip8130/policySmoke.test.ts delete mode 100644 scripts/eip8130/selfBundleCreate.test.ts delete mode 100644 scripts/eip8130/selfBundleRotateP256.test.ts delete mode 100644 scripts/eip8130/setupAccount.test.ts delete mode 100644 scripts/eip8130/vibenet6PartTest.test.ts delete mode 100644 scripts/smoke-estimate-sender-actor.mjs delete mode 100644 test/vitest.eip8130.config.ts diff --git a/scripts/eip8130/README.md b/scripts/eip8130/README.md deleted file mode 100644 index 237514732c..0000000000 --- a/scripts/eip8130/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# EIP-8130 manual scripts - -Runnable dev/integration demonstrations for the EIP-8130 (`viem/eip8130`) and -ERC-8168 (`viem/eip8168`) modules. They import local source -(`../../src/eip8130`, `../../src/eip8168`) so they exercise the code in this -repo directly; most hit a live testnet (skipped unless `PRIVATE_KEY` is set). -For copy-paste usage docs see `site/pages/eip8130/`. - -## Run - -```bash -# offline, no setup -npx vitest run --config test/vitest.eip8130.config.ts scripts/eip8130/buildTransaction.test.ts - -# network scripts (skipped unless PRIVATE_KEY is set; funded Base Sepolia EOA) -PRIVATE_KEY=0x... npx vitest run --config test/vitest.eip8130.config.ts scripts/eip8130/.test.ts - -# hosted vibenet policy smoke (sponsored; no PRIVATE_KEY required) -npx vitest run --config test/vitest.eip8130.config.ts scripts/eip8130/policySmoke.test.ts -``` - -All scripts are auto-included via the `scripts/eip8130/**/*.test.ts` glob in -`test/vitest.eip8130.config.ts` — just drop a new `*.test.ts` here. - -Env: `PRIVATE_KEY` (required for network scripts), `BASE_SEPOLIA_RPC`, -`BUNDLER_URL`, `SALT_LABEL`. `baseSepolia4337E2E` requires `BUNDLER_URL` with no -default so no bundler credential is committed; do not hardcode API keys in these -scripts. - -## Scripts - -| Script | Network? | What it does | -| --- | --- | --- | -| `buildTransaction` | no | Build/sign/serialize/parse an 8130 tx; prints JSON, RLP envelope, and the 13-field wire layout. | -| `setupAccount` | yes | Create an 8130 account on Base Sepolia. | -| `authorizeSessionKey` | yes | Authorize a session-key actor on an existing account. | -| `selfBundleCreate` | yes | Deploy + execute in one self-bundled userOp via `EntryPoint.handleOps` (no staking). | -| `selfBundleRotateP256` | yes | Create + validation-phase P-256 key rotation + execute in one userOp. | -| `bundlerCreateAndExecute` | yes | Create + execute through a real ERC-4337 bundler (`BUNDLER_URL`). | -| `bundlerProbeDeployed` | yes | Send a userOp to an already-deployed account (no factory phase). | -| `baseSepolia4337E2E` | yes | Full ERC-4337 e2e on Base Sepolia: create + execute, a follow-up userOp, then authorize/revoke an actor via `applySignedActorChanges`. Requires `PRIVATE_KEY` **and** `BUNDLER_URL`. | -| `policySmoke` | yes (hosted vibenet) | Sponsored session-key + policy smoke: create → authorize manager+session → `Counter.increment` via PolicyManager. No `PRIVATE_KEY` needed. | - -## Notes - -- An 8130 call is only `{ to, data }` — there is no per-call `value`. -- Validation-phase signature (key rotation) is - `abi.encode(magic, SignedActorChanges[], bytes opAuth)`; `opAuth` authorizes the - op over `userOpHash`. See `encodeSignedActorChangesSignature`. -- `createAccount` seeds `localSequence = 1`, so the first `applySignedActorChanges` - on a fresh account signs over sequence `1`. -- Deployment addresses live in `src/eip8130/deployments.ts`. diff --git a/scripts/eip8130/authorizeSessionKey.test.ts b/scripts/eip8130/authorizeSessionKey.test.ts deleted file mode 100644 index 6019676964..0000000000 --- a/scripts/eip8130/authorizeSessionKey.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' -import { getCode } from '../../src/actions/public/getCode.js' -import { readContract } from '../../src/actions/public/readContract.js' -import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' -import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' -import { baseSepolia } from '../../src/chains/index.js' -import { createClient } from '../../src/clients/createClient.js' -import { http } from '../../src/clients/transports/http.js' -import { accountConfigurationAbi } from '../../src/eip8130/abis.js' -import { actorScope } from '../../src/eip8130/constants.js' -import { getEip8130Deployment } from '../../src/eip8130/deployments.js' -import { authorizeActor, key } from '../../src/eip8130/keys.js' -import { encodeApplySignedActorChangesData } from '../../src/eip8130/utils/accountConfigCalls.js' -import { computeAddress } from '../../src/eip8130/utils/computeAddress.js' -import { erc1167Bytecode } from '../../src/eip8130/utils/proxy.js' -import { signActorChanges } from '../../src/eip8130/utils/signActorChanges.js' -import { stringToHex } from '../../src/utils/encoding/toHex.js' -import { keccak256 } from '../../src/utils/hash/keccak256.js' - -const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined -const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' -const SALT_LABEL = process.env.SALT_LABEL ?? 'viem-eip8130-demo-1' - -describe.runIf(PRIVATE_KEY)( - 'authorize a P-256 session key on Base Sepolia', - () => { - test('applySignedActorChanges authorizes a scoped P-256 actor', async () => { - const owner = privateKeyToAccount(PRIVATE_KEY!) - const client = createClient({ - account: owner, - chain: baseSepolia, - transport: http(RPC_URL), - }) - - const deployment = getEip8130Deployment(baseSepolia.id)! - - // Re-derive the account deployed by setupAccount.test.ts. - const code = erc1167Bytecode(deployment.accounts.erc4337) - const initialActors = [key.k1(owner.address)] - const userSalt = keccak256(stringToHex(SALT_LABEL)) - const account = computeAddress({ userSalt, code, initialActors }) - - const deployed = await getCode(client, { address: account }) - if (!deployed || deployed === '0x') - throw new Error('account not deployed; run setupAccount first') - - // A new P-256 session key (any 32-byte x/y; on-curve validity is only - // checked by the authenticator at use-time, not at authorization). - const x = keccak256(stringToHex('viem-eip8130-p256-x')) - const y = keccak256(stringToHex('viem-eip8130-p256-y')) - const sessionKey = key.p256({ x, y }) - - const change = authorizeActor(sessionKey, { scope: actorScope.sender }) - - // applySignedActorChanges consumes the current channel sequence (the - // post-increment read). createAccount sets the local channel to 1. - const seq = await readContract(client, { - abi: accountConfigurationAbi, - address: deployment.accountConfiguration, - functionName: 'getChangeSequences', - args: [account], - }) - - const chainId = baseSepolia.id - const signed = await signActorChanges({ - signer: owner, - account, - chainId, - sequence: Number(seq.local), - actorChanges: [change], - }) - - console.log('\n— EIP-8130 authorize session key (Base Sepolia) —') - console.log('account: ', account) - console.log('session actorId: ', sessionKey.actorId) - console.log('p256 authenticator:', sessionKey.authenticator) - console.log('local sequence: ', seq.local.toString()) - - const data = encodeApplySignedActorChangesData({ - account, - chainId, - actorChanges: [change], - auth: signed.auth, - }) - - const hash = await sendTransaction(client, { - to: deployment.accountConfiguration, - data, - chain: baseSepolia, - account: owner, - }) - console.log( - 'tx: ', - `https://sepolia.basescan.org/tx/${hash}`, - ) - - const receipt = await waitForTransactionReceipt(client, { hash }) - console.log('status: ', receipt.status) - expect(receipt.status).toBe('success') - - // Verify the actor was written with the expected authenticator + scope. - let config: { authenticator: string; scope: number } | undefined - for (let i = 0; i < 10; i++) { - config = (await readContract(client, { - abi: accountConfigurationAbi, - address: deployment.accountConfiguration, - functionName: 'getActorConfig', - args: [account, sessionKey.actorId], - })) as { authenticator: string; scope: number } - if ( - config.authenticator.toLowerCase() === - sessionKey.authenticator.toLowerCase() - ) - break - await new Promise((r) => setTimeout(r, 1500)) - } - console.log('actor config: ', config) - expect(config?.authenticator.toLowerCase()).toBe( - sessionKey.authenticator.toLowerCase(), - ) - expect(Number(config?.scope)).toBe(actorScope.sender) - }, 120_000) - }, -) diff --git a/scripts/eip8130/baseSepolia4337E2E.test.ts b/scripts/eip8130/baseSepolia4337E2E.test.ts deleted file mode 100644 index 05a9a94bda..0000000000 --- a/scripts/eip8130/baseSepolia4337E2E.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Base Sepolia ERC-4337 end-to-end for EIP-8130. - * - * Base Sepolia is NOT a native EIP-8130 chain, so accounts run through the - * portable ERC-4337 path: the `AccountConfiguration` contract is the factory, - * the `BackwardsCompatible4337Account` is the wallet implementation, and a real - * bundler + EntryPoint drive execution. This is the flow every non-vibenet chain - * uses until native 8130 ships. - * - * This single test proves the three things that must hold before merge: - * 1. CREATE — deploy-on-first-use: a counterfactual account is deployed inside - * its first userOp and executes a call in the same op. - * 2. USER OPS — a second userOp runs against the now-deployed account. - * 3. CHANGE ACTORS — authorize (then revoke) a new actor via a userOp that - * calls `AccountConfiguration.applySignedActorChanges`, signed by the owner - * over the live on-chain config sequence, verified by read-back. - * - * Run (requires a funded Base Sepolia EOA + an ERC-4337 bundler; skipped in CI): - * PRIVATE_KEY=0x... BUNDLER_URL=https://... \ - * npx vitest run --config test/vitest.eip8130.config.ts \ - * scripts/eip8130/baseSepolia4337E2E.test.ts - * - * Env: PRIVATE_KEY (required), BUNDLER_URL (required — no default, so no bundler - * credential is ever committed), BASE_SEPOLIA_RPC (optional). - */ - -import { describe, expect, test } from 'vitest' -import { createBundlerClient } from '../../src/account-abstraction/clients/createBundlerClient.js' -import { entryPoint07Address } from '../../src/account-abstraction/constants/address.js' -import { generatePrivateKey, privateKeyToAccount } from '../../src/accounts/index.js' -import { estimateFeesPerGas } from '../../src/actions/public/estimateFeesPerGas.js' -import { getBalance } from '../../src/actions/public/getBalance.js' -import { getCode } from '../../src/actions/public/getCode.js' -import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' -import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' -import { baseSepolia } from '../../src/chains/index.js' -import { createClient } from '../../src/clients/createClient.js' -import { http } from '../../src/clients/transports/http.js' -import { toSmartAccount } from '../../src/eip8130/accounts/toSmartAccount.js' -import { getConfigSequence } from '../../src/eip8130/actions/getConfigSequence.js' -import { isActor } from '../../src/eip8130/actions/isActor.js' -import { actorScope } from '../../src/eip8130/constants.js' -import { getEip8130Deployment } from '../../src/eip8130/deployments.js' -import { authorizeActor, key, revokeActor } from '../../src/eip8130/keys.js' -import { encodeApplySignedActorChangesData } from '../../src/eip8130/utils/accountConfigCalls.js' -import { signActorChanges } from '../../src/eip8130/utils/signActorChanges.js' -import { parseEther } from '../../src/utils/unit/parseEther.js' -import { keccak256 } from '../../src/utils/hash/keccak256.js' -import { stringToHex } from '../../src/utils/encoding/toHex.js' - -const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined -const BUNDLER_URL = process.env.BUNDLER_URL -const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) - -// Explicit gas limits so the bundler skips `eth_estimateUserOperationGas` (which -// validates signatures a counterfactual account can't satisfy with a stub). -const gasLimits = { - callGasLimit: 500_000n, - verificationGasLimit: 1_500_000n, - preVerificationGas: 500_000n, -} as const - -describe.runIf(PRIVATE_KEY && BUNDLER_URL)( - 'Base Sepolia ERC-4337 e2e: create → userOp → change actors', - () => { - test( - 'create + execute, a follow-up userOp, and authorize/revoke an actor', - async () => { - const owner = privateKeyToAccount(PRIVATE_KEY!) - const client = createClient({ - account: owner, - chain: baseSepolia, - transport: http(RPC_URL), - }) - const bundlerClient = createBundlerClient({ - client, - transport: http(BUNDLER_URL!), - }) - - const deployment = getEip8130Deployment(baseSepolia.id) - if (!deployment?.accounts.erc4337) - throw new Error( - 'Base Sepolia deployment is missing the erc4337 wallet implementation.', - ) - const accountConfiguration = deployment.accountConfiguration - - const userSalt = keccak256(stringToHex(`viem-8130-e2e-${Date.now()}`)) - // A 4337-compat account must register the EntryPoint as a trusted-executor - // actor so it may drive `executeBatch`; without it `validateUserOp` reverts - // (AA23). Actors must be sorted by `actorId`, strictly ascending. - const initialActors = [ - key.k1(owner.address), - key.trustedExecutor(entryPoint07Address), - ].sort((a, b) => (a.actorId < b.actorId ? -1 : a.actorId > b.actorId ? 1 : 0)) - const account = await toSmartAccount({ - client, - owner, - userSalt, - initialActors, - implementation: deployment.accounts.erc4337, - accountConfigAddress: accountConfiguration, - }) - - console.log('\n— Base Sepolia ERC-4337 e2e —') - console.log('owner (EOA): ', owner.address) - console.log('smart account: ', account.address) - console.log('factory (config):', accountConfiguration) - - const fees = await estimateFeesPerGas(client) - const feeParams = { - maxFeePerGas: fees.maxFeePerGas, - maxPriorityFeePerGas: fees.maxPriorityFeePerGas, - } - - // Wait for a userOp receipt and assert it succeeded. - async function runUserOp( - label: string, - calls: readonly { to: `0x${string}`; value?: bigint; data?: `0x${string}` }[], - ) { - // The bundler maintains its own state view that can lag the RPC node - // right after a funding tx. Retry the transient prefund-precheck race. - let hash: `0x${string}` | undefined - for (let attempt = 0; ; attempt++) { - try { - hash = await bundlerClient.sendUserOperation({ - account, - calls, - ...gasLimits, - ...feeParams, - }) - break - } catch (err) { - const msg = (err as Error).message ?? '' - const transient = /precheck failed|balance.*is 0|deposit/i.test(msg) - if (!transient || attempt >= 8) throw err - await sleep(3000) - } - } - const receipt = await bundlerClient.waitForUserOperationReceipt({ hash }) - console.log( - `${label}:`.padEnd(18), - `https://sepolia.basescan.org/tx/${receipt.receipt.transactionHash}`, - `(success=${receipt.success})`, - ) - expect(receipt.success, `${label} userOp must succeed`).toBe(true) - return receipt - } - - // Poll a read-back predicate to defeat public-RPC replica lag. - async function pollUntil( - fn: () => Promise, - { tries = 12, delay = 1500 } = {}, - ) { - for (let i = 0; i < tries; i++) { - if (await fn()) return true - await sleep(delay) - } - return false - } - - // === 1. CREATE — deploy-on-first-use ============================== - expect((await getCode(client, { address: account.address })) ?? '0x').toBe( - '0x', - ) - // Prefund the counterfactual sender so the EntryPoint can pull prefund. - // Each userOp needs ~1.75e13 wei of gas; 0.005 ETH covers the whole run - // with headroom while keeping unrecoverable testnet spend low. - const prefund = parseEther('0.005') - const fundHash = await sendTransaction(client, { - account: owner, - to: account.address, - value: prefund, - chain: baseSepolia, - }) - await waitForTransactionReceipt(client, { hash: fundHash }) - // Confirm the RPC node reflects the prefund before touching the bundler. - await pollUntil(async () => { - const bal = await getBalance(client, { address: account.address }) - return bal >= prefund - }) - - await runUserOp('create+execute', [{ to: owner.address, value: 1n }]) - const deployed = await pollUntil(async () => { - const code = await getCode(client, { address: account.address }) - return !!(code && code !== '0x') - }) - expect(deployed, 'account must be deployed after the first userOp').toBe( - true, - ) - - // === 2. USER OPS — a follow-up op on the deployed account ========= - await runUserOp('follow-up op', [{ to: owner.address, value: 1n }]) - - // === 3. CHANGE ACTORS — authorize then revoke a fresh k1 actor ==== - const newActor = key.k1(privateKeyToAccount(generatePrivateKey()).address) - - async function applyActorChange( - label: string, - change: ReturnType | ReturnType, - ) { - // Sign over the LIVE local sequence — never hardcode it. - const { local } = await getConfigSequence(client, { - accountConfiguration, - account: account.address, - }) - const changes = [change] - const set = await signActorChanges({ - signer: owner, - account: account.address, - chainId: baseSepolia.id, - sequence: Number(local), - actorChanges: changes, - }) - const data = encodeApplySignedActorChangesData({ - account: account.address, - chainId: baseSepolia.id, - actorChanges: changes, - auth: set.auth, - }) - await runUserOp(label, [{ to: accountConfiguration, data }]) - } - - await applyActorChange( - 'authorize actor', - authorizeActor(newActor, { scope: actorScope.sender }), - ) - const authorized = await pollUntil(() => - isActor(client, { - account: account.address, - actorId: newActor.actorId, - accountConfiguration, - }), - ) - console.log('actor authorized:', authorized) - expect(authorized, 'new actor must be bound after authorize').toBe(true) - - await applyActorChange('revoke actor', revokeActor(newActor)) - const revoked = await pollUntil(async () => - !(await isActor(client, { - account: account.address, - actorId: newActor.actorId, - accountConfiguration, - })), - ) - console.log('actor revoked: ', revoked) - expect(revoked, 'new actor must be unbound after revoke').toBe(true) - }, - 240_000, - ) - }, -) diff --git a/scripts/eip8130/buildTransaction.test.ts b/scripts/eip8130/buildTransaction.test.ts deleted file mode 100644 index c882fdb56c..0000000000 --- a/scripts/eip8130/buildTransaction.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' -import { baseSepolia } from '../../src/chains/index.js' -import { - actorScope, - canonicalAuthenticators, -} from '../../src/eip8130/constants.js' -import { getEip8130Deployment } from '../../src/eip8130/deployments.js' -import { authorizeActor, key } from '../../src/eip8130/keys.js' -import type { - AaCalls, - TransactionSerializable8130, -} from '../../src/eip8130/types/transaction.js' -import { parseTransaction } from '../../src/eip8130/utils/parseTransaction.js' -import { erc1167Bytecode } from '../../src/eip8130/utils/proxy.js' -import { serializeTransaction } from '../../src/eip8130/utils/serializeTransaction.js' -import { signTransaction } from '../../src/eip8130/utils/signTransaction.js' -import { sliceHex } from '../../src/utils/data/slice.js' -import { fromRlp } from '../../src/utils/encoding/fromRlp.js' -import { stringToHex } from '../../src/utils/encoding/toHex.js' -import { keccak256 } from '../../src/utils/hash/keccak256.js' - -// Publicly-known Hardhat test key #0 — NOT a secret, used only to make the -// demo's signature/output deterministic. -const DEMO_KEY = - '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as const - -// Pretty-prints an object whose leaves may be bigint (JSON can't serialize those). -function jsonify(value: unknown): string { - return JSON.stringify( - value, - (_, v) => (typeof v === 'bigint' ? `${v.toString()} (bigint)` : v), - 2, - ) -} - -describe('build an EIP-8130 transaction (offline demo)', () => { - test('serialize → JSON + RLP envelope, then parse back', async () => { - const owner = privateKeyToAccount(DEMO_KEY) - const deployment = getEip8130Deployment(baseSepolia.id)! - - const p256PubKey = { - x: '0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296', - y: '0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5', - } as const - - // ── calls ────────────────────────────────────────────────────────────── - // Calls are grouped into ORDERED PHASES. Each phase is its own atomic batch. - // NOTE on `value`: an EIP-8130 call is ONLY `{ to, data }` — there is no - // per-call `value` field at all (a call is RLP `[to, data]`). ETH value - // movement is driven by the account's wallet bytecode via `data`, not by a - // value on each call. (The TS type `AaCall` reflects this: it has no `value`.) - const calls: AaCalls = [ - // Phase 0 — e.g. an ERC-20 approve. - [ - { - to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', - data: '0x095ea7b3', // approve(...) selector (args elided for brevity) - }, - ], - // Phase 1 — two calls executed atomically after phase 0 succeeds. - [ - { - to: '0x2626664c2603336E57B271c5C0b26F421741e481', - data: '0x3593564c', // execute(...) selector - }, - { - to: owner.address, - data: '0x', - }, - ], - ] - - // ── the transaction ────────────────────────────────────────────────────── - // A self-paid tx that ALSO deploys the account (a `create` account change) - // and registers a P-256 session key (a `config` change is shown via the - // higher-level helpers elsewhere; here we keep the create + calls focused). - const transaction: TransactionSerializable8130 = { - chainId: baseSepolia.id, - // EOA path: omit `from` and let the sender be recovered from senderAuth. - nonceSequence: 0n, - maxPriorityFeePerGas: 1_000_000n, // 0.001 gwei - maxFeePerGas: 100_000_000n, // 0.1 gwei - gas: 500_000n, - accountChanges: [ - { - type: 'create', - userSalt: keccak256(stringToHex('viem-8130-demo')), - // ERC-1167 minimal proxy → the canonical wallet implementation. - code: erc1167Bytecode(deployment.accounts.default), - // Initial actors MUST be sorted by actorId ascending. One owner here. - initialActors: [key.k1(owner.address)], - }, - ], - calls, - // self-pay → no payer / payerAuth - } - - console.log('\n══════════════════════════════════════════════════════════') - console.log(' EIP-8130 transaction (unsigned, serializable form)') - console.log('══════════════════════════════════════════════════════════') - console.log(jsonify(transaction)) - console.log( - '\nnote: each call is only { to, data } — EIP-8130 calls carry no `value`.', - ) - - // Show that an authorizeActor change (a P-256 session key) is built the same - // way — for reference, not added to the tx above. - const sessionKeyChange = authorizeActor(key.p256(p256PubKey), { - scope: actorScope.sender, - }) - console.log('\n— example authorizeActor change (P-256 session key) —') - console.log(jsonify(sessionKeyChange)) - console.log(' p256 authenticator:', canonicalAuthenticators.p256) - - // ── sign + serialize ───────────────────────────────────────────────────── - const serialized = await signTransaction({ - transaction, - account: owner, - }) - - console.log('\n══════════════════════════════════════════════════════════') - console.log(' Serialized envelope (EIP-2718: AA_TX_TYPE || rlp(body))') - console.log('══════════════════════════════════════════════════════════') - console.log('type byte:', sliceHex(serialized, 0, 1), '(AA_TX_TYPE = 0x7b)') - console.log('byte length:', (serialized.length - 2) / 2) - console.log('\nrlp envelope:') - console.log(serialized) - - // ── decode the raw RLP to show the 15-field wire layout ────────────────── - // Replay protection is a nonce (nonce_key + nonce_sequence) AND/OR an - // absolute validity window (valid_after / valid_before, unix ms). A - // nonce-free tx sets nonce_key = NONCE_KEY_MAX and relies on the window. - const fields = fromRlp(sliceHex(serialized, 1), 'hex') as unknown[] - const fieldNames = [ - 'chain_id', - 'sender', - 'nonce_key', - 'nonce_sequence', - 'valid_after', - 'valid_before', - 'max_priority_fee_per_gas', - 'max_fee_per_gas', - 'gas_limit', - 'account_changes', - 'calls', - 'metadata', - 'payer', - 'sender_auth', - 'payer_auth', - ] - console.log('\n— raw RLP fields (15 elements) —') - fields.forEach((value, i) => { - const rendered = Array.isArray(value) - ? jsonify(value) - : (value as string) - console.log(` [${i}] ${fieldNames[i]}: ${rendered}`) - }) - - // ── round-trip: parse the envelope back to a structured tx ─────────────── - const parsed = parseTransaction(serialized) - console.log('\n══════════════════════════════════════════════════════════') - console.log(' Parsed back from the envelope') - console.log('══════════════════════════════════════════════════════════') - console.log(jsonify(parsed)) - - // The round-trip must reproduce the input (sans the bogus `value`). - expect(parsed.chainId).toBe(baseSepolia.id) - expect(parsed.calls).toHaveLength(2) - expect(parsed.calls?.[1]).toHaveLength(2) - // A call is only `{ to, data }` — there is never a per-call `value`. - expect(parsed.calls?.[1][0].to.toLowerCase()).toBe( - '0x2626664c2603336e57b271c5c0b26f421741e481', - ) - expect(parsed.calls?.[1][0].data).toBe('0x3593564c') - expect((parsed.calls?.[1][0] as { value?: unknown }).value).toBeUndefined() - expect(parsed.accountChanges?.[0].type).toBe('create') - expect(parsed.senderAuth).toBeDefined() - // Self-pay: no payer / payerAuth. - expect(parsed.payer).toBeUndefined() - expect(parsed.payerAuth).toBeUndefined() - - // Re-serializing the parsed tx yields the identical envelope. - expect(serializeTransaction(parsed)).toBe(serialized) - }) -}) diff --git a/scripts/eip8130/bundlerCreateAndExecute.test.ts b/scripts/eip8130/bundlerCreateAndExecute.test.ts deleted file mode 100644 index e57185eb5a..0000000000 --- a/scripts/eip8130/bundlerCreateAndExecute.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' -import { createBundlerClient } from '../../src/account-abstraction/clients/createBundlerClient.js' -import { estimateFeesPerGas } from '../../src/actions/public/estimateFeesPerGas.js' -import { getBalance } from '../../src/actions/public/getBalance.js' -import { getCode } from '../../src/actions/public/getCode.js' -import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' -import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' -import { baseSepolia } from '../../src/chains/index.js' -import { createClient } from '../../src/clients/createClient.js' -import { http } from '../../src/clients/transports/http.js' -import { parseEther } from '../../src/utils/unit/parseEther.js' -import { stringToHex } from '../../src/utils/encoding/toHex.js' -import { keccak256 } from '../../src/utils/hash/keccak256.js' -import { toSmartAccount } from '../../src/eip8130/accounts/toSmartAccount.js' -import { getEip8130Deployment } from '../../src/eip8130/deployments.js' -import { key } from '../../src/eip8130/keys.js' - -const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined -const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' -const BUNDLER_URL = - process.env.BUNDLER_URL ?? - 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/7YlYO9viupy6QeNdPG4bzerRepbnoPQT' - -describe.runIf(PRIVATE_KEY)( - 'bundler: create EIP-8130 account + execute in a single user op', - () => { - test( - 'AccountConfiguration is the factory; deploy + action in one userOp', - async () => { - const owner = privateKeyToAccount(PRIVATE_KEY!) - const client = createClient({ - account: owner, - chain: baseSepolia, - transport: http(RPC_URL), - }) - const bundlerClient = createBundlerClient({ - client, - transport: http(BUNDLER_URL), - }) - const deployment = getEip8130Deployment(baseSepolia.id)! - - // Fresh salt so the account is purely counterfactual (not pre-created). - const userSalt = keccak256(stringToHex(`viem-8130-bundler-${Date.now()}`)) - const account = await toSmartAccount({ - client, - owner, - userSalt, - initialActors: [key.k1(owner.address)], - implementation: deployment.accounts.erc4337, - }) - - console.log('\n— bundler create + execute (Base Sepolia) —') - console.log('owner (EOA): ', owner.address) - console.log('smart account: ', account.address) - console.log('factory (config):', deployment.accountConfiguration) - - const codeBefore = await getCode(client, { address: account.address }) - expect(codeBefore ?? '0x').toBe('0x') - - // Prefund the counterfactual sender so the EntryPoint can pull its prefund. - const fundHash = await sendTransaction(client, { - account: owner, - to: account.address, - value: parseEther('0.01'), - chain: baseSepolia, - }) - await waitForTransactionReceipt(client, { hash: fundHash }) - console.log('funded sender: ', '0.01 ETH') - - // CDP validates signatures during eth_estimateUserOperationGas, which a - // counterfactual account cannot satisfy with a stub. Provide explicit gas - // limits so viem skips estimation and submits with the real signature. - const fees = await estimateFeesPerGas(client) - const ownerBalanceBefore = await getBalance(client, { - address: owner.address, - }) - const userOpHash = await bundlerClient.sendUserOperation({ - account, - calls: [{ to: owner.address, value: 1n }], - callGasLimit: 500_000n, - verificationGasLimit: 1_500_000n, - preVerificationGas: 500_000n, - maxFeePerGas: fees.maxFeePerGas, - maxPriorityFeePerGas: fees.maxPriorityFeePerGas, - }) - console.log('userOp: ', userOpHash) - - const receipt = await bundlerClient.waitForUserOperationReceipt({ - hash: userOpHash, - }) - console.log( - 'tx: ', - `https://sepolia.basescan.org/tx/${receipt.receipt.transactionHash}`, - ) - console.log('success: ', receipt.success) - expect(receipt.success).toBe(true) - - // Account is now deployed and the action ran (1 wei returned to owner). - const codeAfter = await getCode(client, { address: account.address }) - expect(codeAfter && codeAfter !== '0x').toBeTruthy() - const ownerBalanceAfter = await getBalance(client, { - address: owner.address, - }) - // owner received 1 wei from the account's executeBatch (net of gas it paid - // to fund — we only assert the account executed by checking it deployed). - console.log( - 'owner +1 wei? ', - ownerBalanceAfter > ownerBalanceBefore - parseEther('0.001'), - ) - }, - 180_000, - ) - }, -) diff --git a/scripts/eip8130/bundlerProbeDeployed.test.ts b/scripts/eip8130/bundlerProbeDeployed.test.ts deleted file mode 100644 index 0d77cfef32..0000000000 --- a/scripts/eip8130/bundlerProbeDeployed.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' -import { createBundlerClient } from '../../src/account-abstraction/clients/createBundlerClient.js' -import { estimateFeesPerGas } from '../../src/actions/public/estimateFeesPerGas.js' -import { getBalance } from '../../src/actions/public/getBalance.js' -import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' -import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' -import { baseSepolia } from '../../src/chains/index.js' -import { createClient } from '../../src/clients/createClient.js' -import { http } from '../../src/clients/transports/http.js' -import { parseEther } from '../../src/utils/unit/parseEther.js' -import { stringToHex } from '../../src/utils/encoding/toHex.js' -import { keccak256 } from '../../src/utils/hash/keccak256.js' -import { toSmartAccount } from '../../src/eip8130/accounts/toSmartAccount.js' -import { getEip8130Deployment } from '../../src/eip8130/deployments.js' -import { key } from '../../src/eip8130/keys.js' - -const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined -const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' -const BUNDLER_URL = - process.env.BUNDLER_URL ?? - 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/7YlYO9viupy6QeNdPG4bzerRepbnoPQT' - -describe.runIf(PRIVATE_KEY)('bundler probe: transact on a pre-deployed account', () => { - test( - 'userOp on already-deployed account (no factory phase)', - async () => { - const owner = privateKeyToAccount(PRIVATE_KEY!) - const client = createClient({ - account: owner, - chain: baseSepolia, - transport: http(RPC_URL), - }) - const bundlerClient = createBundlerClient({ - client, - transport: http(BUNDLER_URL), - }) - const deployment = getEip8130Deployment(baseSepolia.id)! - - const account = await toSmartAccount({ - client, - owner, - address: '0x64609Df27EFb3ecB241B349a3985DFdE2B98dc6b', - userSalt: keccak256(stringToHex('viem-eip8130-demo-1')), - initialActors: [key.k1(owner.address)], - accountConfigAddress: deployment.accountConfiguration, - implementation: deployment.accounts.erc4337, - }) - - console.log('\n— bundler probe (deployed account) —') - console.log('account: ', account.address) - - const bal = await getBalance(client, { address: account.address }) - if (bal < parseEther('0.002')) { - const fundHash = await sendTransaction(client, { - account: owner, - to: account.address, - value: parseEther('0.005'), - chain: baseSepolia, - }) - await waitForTransactionReceipt(client, { hash: fundHash }) - } - - const fees = await estimateFeesPerGas(client) - const userOpHash = await bundlerClient.sendUserOperation({ - account, - calls: [{ to: owner.address, value: 1n }], - callGasLimit: 300_000n, - verificationGasLimit: 600_000n, - preVerificationGas: 500_000n, - maxFeePerGas: fees.maxFeePerGas, - maxPriorityFeePerGas: fees.maxPriorityFeePerGas, - }) - console.log('userOp: ', userOpHash) - const receipt = await bundlerClient.waitForUserOperationReceipt({ - hash: userOpHash, - }) - console.log( - 'tx: ', - `https://sepolia.basescan.org/tx/${receipt.receipt.transactionHash}`, - ) - console.log('success: ', receipt.success) - expect(receipt.success).toBe(true) - }, - 180_000, - ) -}) diff --git a/scripts/eip8130/policySmoke.test.ts b/scripts/eip8130/policySmoke.test.ts deleted file mode 100644 index fb55e9ea0c..0000000000 --- a/scripts/eip8130/policySmoke.test.ts +++ /dev/null @@ -1,418 +0,0 @@ -/** - * EIP-8130 session-key + policy smoke test — base/eip-8130 #43. - * - * Runs LIVE against the hosted vibenet devnet; gas is sponsored by the vibenet - * payer, so you don't need to fund anything. It proves the full session-key - * flow and pins down two things that make a working authorize LOOK broken. - * - * Run: - * npx vitest run --config test/vitest.eip8130.config.ts \ - * scripts/eip8130/policySmoke.test.ts - * - * Env (optional): RPC_URL, PAYER_URL, BROADCAST_URL. - * - * Flow: - * 1. Create a fresh smart account (sponsored). - * 2. Register the PolicyManager (trusted-executor) + a session key (policy) - * in ONE config change, on the LOCAL channel, at the LIVE sequence. - * 3. Use the session key to drive the ONE permitted policy-gated call — - * Counter.increment() — via PolicyManager.execute(binding, action). - * (#43: no install step; the full PolicyBinding is passed at execute.) - * - * TWO GOTCHAS currently broken in this VIBENET build: - * - * (a) NO EVENTS IN THE RECEIPT. A *successful* authorize emits ZERO - * `receipt.logs` — `ActorAuthorized` is not surfaced as a normal EVM log. - * "No events" is NOT a failure signal. The reliable success check is a - * READ-BACK: isActor / getActorConfig / a bumped getConfigSequence. - * - * (b) READ-BACK LAG. State reads trail the receipt by ~1 block (~2s). Reading - * isActor/sequence right after the receipt returns STALE values — poll. - * - * Sequence correctness: the digest binds (account, chainId, sequence). Sign - * with the LIVE on-chain counter for the channel (LOCAL for session keys); - * never hardcode it. First-authorize local seq is 1 for a created smart wallet - * (create bumps local 0->1) or 0 for a bare 7702-delegated EOA — so read it. - * A stale sequence is rejected loudly at broadcast ("config change sequence - * mismatch") or mines as a silent no-op. - * - * NONCE MODE: the session key is authorized with POLICY | SCOPE_NONCE, so - * prepare uses sequenced nonces (`getTransactionCount`, channel 0). - * Prepare reads on-chain scope via getActorConfig when the actor is bound — - * do NOT redeclare scope on the session account handle. Owner (admin, - * scope 0) remains nonce-free — admin cannot hold SCOPE_NONCE. - */ - -import { describe, expect, test } from 'vitest' -import { generatePrivateKey, privateKeyToAccount } from '../../src/accounts/index.js' -import { createPublicClient } from '../../src/clients/createPublicClient.js' -import { http } from '../../src/clients/transports/http.js' -import { createPayerClient } from '../../src/eip8168/client.js' -import { sendSponsoredCalls } from '../../src/eip8168/actions/sendSponsoredCalls.js' -import { toAccount } from '../../src/eip8130/accounts/toAccount.js' -import { getActorConfig } from '../../src/eip8130/actions/getActorConfig.js' -import { getConfigSequence } from '../../src/eip8130/actions/getConfigSequence.js' -import { isActor } from '../../src/eip8130/actions/isActor.js' -import { allPhasesSucceeded } from '../../src/eip8130/actions/getTransactionReceipt.js' -import { waitForTransactionReceipt } from '../../src/eip8130/actions/waitForTransactionReceipt.js' -import { - actorScope, - canonicalAuthenticators, - scopeUnrestricted, -} from '../../src/eip8130/constants.js' -import { getEip8130Deployment } from '../../src/eip8130/deployments.js' -import { authorizeActor, key } from '../../src/eip8130/keys.js' -import { - defineSessionPolicy, - encodeSessionPolicyConfig, -} from '../../src/eip8130/policies.js' -import type { AaAccountChange, AaCall } from '../../src/eip8130/types/transaction.js' -import { upgradeableProxyBytecode } from '../../src/eip8130/utils/proxy.js' -import type { Hex } from '../../src/types/misc.js' -import { hexToBigInt } from '../../src/utils/encoding/fromHex.js' - -const RPC_URL = process.env.RPC_URL ?? 'https://vibes.base.org/api/vibenet/account/rpc' -const PAYER_URL = - process.env.PAYER_URL ?? 'https://vibes.base.org/api/vibenet/account/payer' -const BROADCAST_URL = - process.env.BROADCAST_URL ?? 'https://vibes.base.org/api/vibenet/account/rpc' - -// The ONE contract this session key is allowed to touch, and the ONE selector. -// Counter.increment() @ vibenet devnet — a real, verifiable on-chain effect. -const COUNTER = '0x7ec1445f7019949B1A1d85e49d29a2ae5dEcF9B0' as const -const INCREMENT = '0xd09de08a' as const // increment() -const COUNT = '0x06661abd' as const // count() (public getter) - -describe('EIP-8130 policy smoke (hosted vibenet)', () => { - test( - 'create → authorize manager+session → session Counter.increment', - async () => { - // --- client + chain ------------------------------------------------- - const bootstrap = createPublicClient({ transport: http(RPC_URL) }) - const chainId = Number( - await bootstrap.request({ method: 'eth_chainId' }), - ) - const chain = { - id: chainId, - name: 'vibenet', - nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, - rpcUrls: { default: { http: [RPC_URL] } }, - } as const - const client = createPublicClient({ chain, transport: http(RPC_URL) }) - const bclient = createPublicClient({ - chain, - transport: http(BROADCAST_URL), - }) - const payer = createPayerClient({ url: PAYER_URL }) - - const deployment = getEip8130Deployment(chainId) - if (!deployment?.policies) { - throw new Error( - `No EIP-8130 policy addresses for chain ${chainId}. Refusing to borrow another chain's deployment.`, - ) - } - const { policies, accountConfiguration } = deployment - console.log('chainId:', chainId) - console.log('policyManager:', policies.manager) - console.log('sessionPolicy:', policies.sessionPolicy, '\n') - - // --- helpers -------------------------------------------------------- - const now = () => BigInt(Math.floor(Date.now() / 1000)) - const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) - - /** - * Send a sponsored EIP-8130 tx that may carry BOTH account changes and calls. - * Hosted vibenet payer only CO-SIGNS (`mode: "sign"`); we self-broadcast. - */ - async function sponsor(parameters: { - account: ReturnType - accountChanges?: readonly AaAccountChange[] - calls?: readonly AaCall[] - }) { - const cosigned = await sendSponsoredCalls(client, { - account: parameters.account, - payerClient: payer, - mode: 'sign', - accountChanges: parameters.accountChanges, - calls: parameters.calls ?? [], - // Payer estimates from `calls` only; floor covers account-change application. - gas: 2_000_000n, - context: { flow: 'transact' }, - }) - const finalTx = - 'signedTransaction' in cosigned - ? cosigned.signedTransaction - : (cosigned as Hex) - const hash = await bclient.request({ - method: 'eth_sendRawTransaction', - params: [finalTx], - }) - const receipt = await waitForTransactionReceipt(client, { hash }) - return { hash, receipt } - } - - const readSeq = () => - getConfigSequence(client, { - accountConfiguration, - account: account.address, - }) - const readIsActor = (actorId: Hex) => - isActor(client, { - account: account.address, - actorId, - accountConfiguration, - }) - - /** Poll a read-back predicate to defeat recall/state lag (~1 block). */ - async function pollUntil( - fn: () => Promise, - { tries = 20, delay = 1500 } = {}, - ): Promise { - let last: T | null | undefined | false - for (let i = 0; i < tries; i++) { - last = await fn() - if (last) return last - await sleep(delay) - } - return last - } - - let ok = true - function report(name: string, pass: boolean, extra = '') { - if (!pass) ok = false - console.log( - `${pass ? 'PASS' : 'FAIL'} ${name}${extra ? ` :: ${extra}` : ''}`, - ) - } - - // --- actors --------------------------------------------------------- - const owner = privateKeyToAccount(generatePrivateKey()) - const account = toAccount({ - signer: owner, - userSalt: generatePrivateKey(), - code: upgradeableProxyBytecode(deployment.accounts.default), - initialActors: [key.k1(owner.address)], - authenticator: canonicalAuthenticators.k1, - accountConfigAddress: accountConfiguration, - // Owner is admin (scope 0) and not yet on-chain at create time, so prepare - // cannot read scope from getActorConfig — declare admin here once. After - // create, prepare prefers on-chain scope when the actor is bound. - scope: scopeUnrestricted, - }) - console.log('owner: ', owner.address) - console.log('account:', account.address, '\n') - - // Session key (a k1 EOA so we can sign its execute tx) + its policy. - const sessionSigner = privateKeyToAccount(generatePrivateKey()) - const sessionActor = key.k1(sessionSigner.address) - const managerActor = key.trustedExecutor(policies.manager) - - // ── Session-key scope (declared ONCE — at authorize) ──────────────── - // POLICY | NONCE → sequenced nonces via getTransactionCount. - // Do NOT also pass this scope into toAccount for the session handle — - // prepare reads getActorConfig and selects nonce mode from chain truth. - const SESSION_SELF_PAY = false - const SESSION_USE_NONCE = true - const sessionScope = - actorScope.policy | - (SESSION_USE_NONCE ? actorScope.nonce : 0) | - (SESSION_SELF_PAY ? actorScope.selfPayer : 0) - - const policyConfig = encodeSessionPolicyConfig({ - tokenLimits: [], - callScopes: [ - { target: COUNTER, selectorRules: [{ selector: INCREMENT }] }, - ], - }) - const expiry = now() + 86_400n - const session = defineSessionPolicy({ - account: account.address, - policy: policies.sessionPolicy, - policyConfig, - manager: policies.manager, - validUntil: expiry, - }) - - // ===================================================================== - // STEP 1 — create the smart account (sponsored). `create` bumps local -> 1. - // ===================================================================== - console.log('── STEP 1: create smart account ──') - try { - const { hash, receipt } = await sponsor({ - account, - accountChanges: [account.create()], - calls: [{ to: account.address, data: '0x' }], - }) - console.log( - 'tx:', - hash, - '| status:', - receipt.status, - '| allPhases:', - allPhasesSucceeded(receipt.eip8130), - ) - const seq = await pollUntil(async () => { - const s = await readSeq() - return s.local >= 1n ? s : null - }) - const local = - seq && typeof seq === 'object' && 'local' in seq ? seq.local : 0n - report( - 'account created (local sequence bumped to 1)', - local >= 1n, - `local=${local}`, - ) - } catch (e) { - report('create', false, (e as Error)?.message ?? String(e)) - } - - // ===================================================================== - // STEP 2 — register PolicyManager (trusted-executor) + session key (policy) - // in ONE config change, LOCAL channel, at the LIVE sequence. - // ===================================================================== - console.log('\n── STEP 2: register PolicyManager + session key ──') - try { - const live = await readSeq() // read live local counter — never guess - const configChanges = [ - authorizeActor(managerActor, { scope: actorScope.sender }), - authorizeActor(sessionActor, { - scope: sessionScope, - expiry, - policy: session.actorPolicy, - }), - ] - const authChange = await account.change(configChanges, { - chainId, // LOCAL channel - sequence: Number(live.local), - }) - const { hash, receipt } = await sponsor({ - account, - accountChanges: [authChange], - calls: [{ to: account.address, data: '0x' }], - }) - console.log( - 'tx:', - hash, - '| status:', - receipt.status, - '| allPhases:', - allPhasesSucceeded(receipt.eip8130), - '| receipt.logs:', - Array.isArray(receipt.logs) ? receipt.logs.length : 0, - ) - console.log( - ' NOTE: receipt.logs is 0 even on success — ActorAuthorized is not a', - '\n normal EVM log here. Verify via read-back, not logs.', - ) - - const mgrBound = await pollUntil(async () => - (await readIsActor(managerActor.actorId)) ? true : null, - ) - report('PolicyManager bound as trusted-executor actor', mgrBound === true) - - const skBound = await pollUntil(async () => - (await readIsActor(sessionActor.actorId)) ? true : null, - ) - report('session key bound as actor', skBound === true) - - if (skBound) { - const cfg = await getActorConfig(client, { - account: account.address, - actorId: sessionActor.actorId, - accountConfiguration, - }) - report( - 'session key scope is POLICY | NONCE (sequenced)', - cfg.hasPolicy === true && (cfg.scope & actorScope.nonce) !== 0, - `scope=0x${cfg.scope.toString(16)}`, - ) - } else { - console.log( - ' ↳ If this ever FAILS: the authorize was skipped fail-closed. The tx', - '\n still reports success with no failed phase — check the signed', - '\n sequence against the LIVE on-chain counter for this channel.', - ) - } - } catch (e) { - report( - 'register manager + session key', - false, - (e as Error)?.message ?? String(e), - ) - } - - // ===================================================================== - // STEP 3 — use the session key: execute the ONE permitted policy-gated call. - // ===================================================================== - console.log( - '\n── STEP 3: use the session key (Counter.increment via PolicyManager) ──', - ) - try { - const readCount = async () => - hexToBigInt( - await client.request({ - method: 'eth_call', - params: [{ to: COUNTER, data: COUNT }, 'latest'], - }), - ) - const before = await readCount() - - // Signer + address only — no redeclared `scope`. prepare reads on-chain - // getActorConfig (POLICY|NONCE) and fills nonceKey=0 + nonceSequence - // via getTransactionCount. - const sessionAccount = toAccount({ - signer: sessionSigner, - address: account.address, - authenticator: canonicalAuthenticators.k1, - accountConfigAddress: accountConfiguration, - }) - const executeCall = session.executeCall({ - target: COUNTER, - value: 0n, - data: INCREMENT, - }) - - const { hash, receipt } = await sponsor({ - account: sessionAccount, - calls: [executeCall], - }) - console.log( - 'tx:', - hash, - '| status:', - receipt.status, - '| allPhases:', - allPhasesSucceeded(receipt.eip8130), - '| phaseStatuses:', - JSON.stringify(receipt.eip8130.phaseStatuses), - ) - report( - 'session-key execute landed (all call phases succeeded)', - allPhasesSucceeded(receipt.eip8130), - ) - - const bumped = await pollUntil(async () => { - const c = await readCount() - return c === before + 1n ? c : null - }) - report( - 'Counter.increment ran via the session key', - bumped === before + 1n, - `count ${before} -> ${bumped ?? '?'}`, - ) - } catch (e) { - report( - 'session-key execute send', - false, - (e as Error)?.message ?? String(e), - ) - } - - console.log('') - expect(ok, 'one or more policy-smoke checks failed — see PASS/FAIL above').toBe( - true, - ) - }, - 180_000, - ) -}) diff --git a/scripts/eip8130/selfBundleCreate.test.ts b/scripts/eip8130/selfBundleCreate.test.ts deleted file mode 100644 index ac88c1c18a..0000000000 --- a/scripts/eip8130/selfBundleCreate.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' -import { entryPoint07Abi } from '../../src/account-abstraction/constants/abis.js' -import { entryPoint07Address } from '../../src/account-abstraction/constants/address.js' -import { toPackedUserOperation } from '../../src/account-abstraction/utils/userOperation/toPackedUserOperation.js' -import { estimateFeesPerGas } from '../../src/actions/public/estimateFeesPerGas.js' -import { getCode } from '../../src/actions/public/getCode.js' -import { readContract } from '../../src/actions/public/readContract.js' -import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' -import { writeContract } from '../../src/actions/wallet/writeContract.js' -import { baseSepolia } from '../../src/chains/index.js' -import { createClient } from '../../src/clients/createClient.js' -import { http } from '../../src/clients/transports/http.js' -import { stringToHex } from '../../src/utils/encoding/toHex.js' -import { keccak256 } from '../../src/utils/hash/keccak256.js' -import { parseEther } from '../../src/utils/unit/parseEther.js' -import { toSmartAccount } from '../../src/eip8130/accounts/toSmartAccount.js' -import { getEip8130Deployment } from '../../src/eip8130/deployments.js' -import { key } from '../../src/eip8130/keys.js' - -const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined -const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' - -describe.runIf(PRIVATE_KEY)( - 'self-bundle: create EIP-8130 account + execute via EntryPoint.handleOps', - () => { - test( - 'AccountConfiguration is the factory; deploy + action in a single userOp (no staking)', - async () => { - const owner = privateKeyToAccount(PRIVATE_KEY!) - const client = createClient({ - account: owner, - chain: baseSepolia, - transport: http(RPC_URL), - }) - const deployment = getEip8130Deployment(baseSepolia.id)! - - const userSalt = keccak256(stringToHex(`viem-8130-self-${Date.now()}`)) - const account = await toSmartAccount({ - client, - owner, - userSalt, - initialActors: [key.k1(owner.address)], - implementation: deployment.accounts.erc4337, - accountConfigAddress: deployment.accountConfiguration, - }) - - console.log('\n— self-bundled create + execute (Base Sepolia) —') - console.log('owner / bundler: ', owner.address) - console.log('smart account: ', account.address) - console.log('factory (config):', deployment.accountConfiguration) - - const codeBefore = await getCode(client, { address: account.address }) - expect(codeBefore ?? '0x').toBe('0x') - - // Pre-fund the account's EntryPoint *deposit* so missingAccountFunds = 0. - // (Avoids the account having to repay prefund mid-validation, which public - // RPCs mis-simulate during eth_estimateGas.) - const depositHash = await writeContract(client, { - abi: entryPoint07Abi, - address: entryPoint07Address, - functionName: 'depositTo', - args: [account.address], - value: parseEther('0.003'), - account: owner, - chain: baseSepolia, - }) - await waitForTransactionReceipt(client, { hash: depositHash }) - - // Build the user operation by hand. - const { factory, factoryData } = await account.getFactoryArgs() - const callData = await account.encodeCalls([ - { to: owner.address, value: 0n, data: '0x' }, - ]) - const nonce = await readContract(client, { - abi: entryPoint07Abi, - address: entryPoint07Address, - functionName: 'getNonce', - args: [account.address, 0n], - }) - const fees = await estimateFeesPerGas(client) - - const userOperation = { - sender: account.address, - nonce, - factory, - factoryData, - callData, - callGasLimit: 200_000n, - verificationGasLimit: 1_000_000n, - preVerificationGas: 100_000n, - maxFeePerGas: fees.maxFeePerGas, - maxPriorityFeePerGas: fees.maxPriorityFeePerGas, - } as const - - const signature = await account.signUserOperation({ - ...userOperation, - chainId: baseSepolia.id, - }) - const packed = toPackedUserOperation({ ...userOperation, signature }) - - // We are the bundler: submit handleOps directly, collecting the refund. - const hash = await writeContract(client, { - abi: entryPoint07Abi, - address: entryPoint07Address, - functionName: 'handleOps', - args: [[packed], owner.address], - account: owner, - chain: baseSepolia, - // Public RPC eth_estimateGas mis-simulates handleOps prefund; set manually. - gas: 2_000_000n, - }) - const receipt = await waitForTransactionReceipt(client, { hash }) - console.log( - 'tx: ', - `https://sepolia.basescan.org/tx/${receipt.transactionHash}`, - ) - console.log('status: ', receipt.status) - expect(receipt.status).toBe('success') - - // Public RPCs are load-balanced; poll to avoid reading a lagging replica. - let codeAfter: `0x${string}` | undefined - for (let i = 0; i < 10; i++) { - codeAfter = await getCode(client, { address: account.address }) - if (codeAfter && codeAfter !== '0x') break - await new Promise((r) => setTimeout(r, 1500)) - } - expect(codeAfter && codeAfter !== '0x').toBeTruthy() - console.log('account deployed:', !!(codeAfter && codeAfter !== '0x')) - }, - 180_000, - ) - }, -) diff --git a/scripts/eip8130/selfBundleRotateP256.test.ts b/scripts/eip8130/selfBundleRotateP256.test.ts deleted file mode 100644 index 62310f068b..0000000000 --- a/scripts/eip8130/selfBundleRotateP256.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { entryPoint07Abi } from '../../src/account-abstraction/constants/abis.js' -import { entryPoint07Address } from '../../src/account-abstraction/constants/address.js' -import { getUserOperationHash } from '../../src/account-abstraction/utils/userOperation/getUserOperationHash.js' -import { toPackedUserOperation } from '../../src/account-abstraction/utils/userOperation/toPackedUserOperation.js' -import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' -import { estimateFeesPerGas } from '../../src/actions/public/estimateFeesPerGas.js' -import { getCode } from '../../src/actions/public/getCode.js' -import { readContract } from '../../src/actions/public/readContract.js' -import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' -import { writeContract } from '../../src/actions/wallet/writeContract.js' -import { baseSepolia } from '../../src/chains/index.js' -import { createClient } from '../../src/clients/createClient.js' -import { http } from '../../src/clients/transports/http.js' -import { accountConfigurationAbi } from '../../src/eip8130/abis.js' -import { toSmartAccount } from '../../src/eip8130/accounts/toSmartAccount.js' -import { - actorScope, - ecrecoverAuthenticator, -} from '../../src/eip8130/constants.js' -import { getEip8130Deployment } from '../../src/eip8130/deployments.js' -import { authorizeActor, key } from '../../src/eip8130/keys.js' -import { actorIdFromPublicKey } from '../../src/eip8130/utils/actorId.js' -import { signActorChanges } from '../../src/eip8130/utils/signActorChanges.js' -import { encodeSignedActorChangesSignature } from '../../src/eip8130/utils/signedActorChangesSignature.js' -import { concatHex } from '../../src/utils/data/concat.js' -import { stringToHex } from '../../src/utils/encoding/toHex.js' -import { keccak256 } from '../../src/utils/hash/keccak256.js' -import { parseEther } from '../../src/utils/unit/parseEther.js' - -const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined -const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' - -// P-256 generator point (Gx, Gy) — a valid, well-known public key used purely -// to prove the actor is registered onchain during validateUserOp. The op is -// authorized by the k1 owner signing the actor change, not by this key. -const p256PubKey = { - x: '0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296', - y: '0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5', -} as const - -describe.runIf(PRIVATE_KEY)( - 'self-bundle: create + rotate to P-256 in validation phase + execute', - () => { - test('create, authorize a P-256 actor during validateUserOp, and execute — single userOp (no staking)', async () => { - const owner = privateKeyToAccount(PRIVATE_KEY!) - const client = createClient({ - account: owner, - chain: baseSepolia, - transport: http(RPC_URL), - }) - const deployment = getEip8130Deployment(baseSepolia.id)! - - const userSalt = keccak256(stringToHex(`viem-8130-rotate-${Date.now()}`)) - const account = await toSmartAccount({ - client, - owner, - userSalt, - initialActors: [key.k1(owner.address)], - implementation: deployment.accounts.erc4337, - accountConfigAddress: deployment.accountConfiguration, - }) - - const p256Actor = key.p256(p256PubKey) - const p256ActorId = actorIdFromPublicKey(p256PubKey) - - console.log('\n— self-bundled create + rotate-to-P256 (Base Sepolia) —') - console.log('owner / bundler: ', owner.address) - console.log('smart account: ', account.address) - console.log('factory (config):', deployment.accountConfiguration) - console.log('new p256 actorId:', p256ActorId) - - const codeBefore = await getCode(client, { address: account.address }) - expect(codeBefore ?? '0x').toBe('0x') - - // Pre-fund the account's EntryPoint deposit so missingAccountFunds = 0. - const depositHash = await writeContract(client, { - abi: entryPoint07Abi, - address: entryPoint07Address, - functionName: 'depositTo', - args: [account.address], - value: parseEther('0.003'), - account: owner, - chain: baseSepolia, - }) - await waitForTransactionReceipt(client, { hash: depositHash }) - - const { factory, factoryData } = await account.getFactoryArgs() - const callData = await account.encodeCalls([ - { to: owner.address, value: 0n, data: '0x' }, - ]) - const nonce = await readContract(client, { - abi: entryPoint07Abi, - address: entryPoint07Address, - functionName: 'getNonce', - args: [account.address, 0n], - }) - const fees = await estimateFeesPerGas(client) - - const userOperation = { - sender: account.address, - nonce, - factory, - factoryData, - callData, - callGasLimit: 200_000n, - verificationGasLimit: 1_500_000n, - preVerificationGas: 100_000n, - maxFeePerGas: fees.maxFeePerGas, - maxPriorityFeePerGas: fees.maxPriorityFeePerGas, - } as const - - // Compute the userOpHash so we can produce opAuth before finalizing the signature. - const userOpHash = getUserOperationHash({ - chainId: baseSepolia.id, - entryPointAddress: entryPoint07Address, - entryPointVersion: '0.7', - userOperation: { ...userOperation, sender: account.address }, - }) - - // Current k1 owner authorizes the new P-256 actor. createAccount() sets - // localSequence = 1 (as the initialized flag), so the first - // applySignedActorChanges call on a fresh account must sign over sequence 1. - const set = await signActorChanges({ - signer: owner, - account: account.address, - chainId: baseSepolia.id, - sequence: 1, - actorChanges: [authorizeActor(p256Actor, { scope: actorScope.sender })], - }) - - // opAuth: k1 owner signs the userOpHash in authenticator || data format. - // The owner is the initial actor so this always passes. A rotate-only op - // could use the newly added P-256 key here instead. - const opAuth = concatHex([ - ecrecoverAuthenticator, - await owner.sign({ hash: userOpHash }), - ]) - const signature = encodeSignedActorChangesSignature([set], opAuth) - - const packed = toPackedUserOperation({ ...userOperation, signature }) - - const hash = await writeContract(client, { - abi: entryPoint07Abi, - address: entryPoint07Address, - functionName: 'handleOps', - args: [[packed], owner.address], - account: owner, - chain: baseSepolia, - gas: 2_500_000n, - }) - const receipt = await waitForTransactionReceipt(client, { hash }) - console.log( - 'tx: ', - `https://sepolia.basescan.org/tx/${receipt.transactionHash}`, - ) - console.log('status: ', receipt.status) - expect(receipt.status).toBe('success') - - // Public RPCs are load-balanced; poll to avoid reading a lagging replica. - let deployed = false - let isP256Actor = false - for (let i = 0; i < 10; i++) { - const code = await getCode(client, { address: account.address }) - deployed = !!(code && code !== '0x') - if (deployed) { - isP256Actor = await readContract(client, { - abi: accountConfigurationAbi, - address: deployment.accountConfiguration, - functionName: 'isActor', - args: [account.address, p256ActorId], - }) - } - if (deployed && isP256Actor) break - await new Promise((r) => setTimeout(r, 1500)) - } - - console.log('account deployed:', deployed) - console.log('p256 is actor: ', isP256Actor) - expect(deployed).toBeTruthy() - expect(isP256Actor).toBeTruthy() - }, 180_000) - }, -) diff --git a/scripts/eip8130/setupAccount.test.ts b/scripts/eip8130/setupAccount.test.ts deleted file mode 100644 index 58c7ef8ebb..0000000000 --- a/scripts/eip8130/setupAccount.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { privateKeyToAccount } from '../../src/accounts/privateKeyToAccount.js' -import { getCode } from '../../src/actions/public/getCode.js' -import { readContract } from '../../src/actions/public/readContract.js' -import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' -import { writeContract } from '../../src/actions/wallet/writeContract.js' -import { baseSepolia } from '../../src/chains/index.js' -import { createClient } from '../../src/clients/createClient.js' -import { http } from '../../src/clients/transports/http.js' -import { accountConfigurationAbi } from '../../src/eip8130/abis.js' -import { getEip8130Deployment } from '../../src/eip8130/deployments.js' -import { key } from '../../src/eip8130/keys.js' -import { computeAddress } from '../../src/eip8130/utils/computeAddress.js' -import { erc1167Bytecode } from '../../src/eip8130/utils/proxy.js' -import { stringToHex } from '../../src/utils/encoding/toHex.js' -import { keccak256 } from '../../src/utils/hash/keccak256.js' - -const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}` | undefined -const RPC_URL = process.env.BASE_SEPOLIA_RPC ?? 'https://sepolia.base.org' -const SALT_LABEL = process.env.SALT_LABEL ?? 'viem-eip8130-demo-1' - -describe.runIf(PRIVATE_KEY)('setup an EIP-8130 account on Base Sepolia', () => { - test('computeAddress matches onchain and createAccount lands', async () => { - const owner = privateKeyToAccount(PRIVATE_KEY!) - const client = createClient({ - account: owner, - chain: baseSepolia, - transport: http(RPC_URL), - }) - - const deployment = getEip8130Deployment(baseSepolia.id)! - const code = erc1167Bytecode(deployment.accounts.erc4337) - const initialActors = [key.k1(owner.address)] - const userSalt = keccak256(stringToHex(SALT_LABEL)) - const initialActorsArg = initialActors.map((a) => ({ - actorId: a.actorId, - authenticator: a.authenticator, - })) - - const local = computeAddress({ userSalt, code, initialActors }) - const onchain = await readContract(client, { - abi: accountConfigurationAbi, - address: deployment.accountConfiguration, - functionName: 'computeAddress', - args: [userSalt, code, initialActorsArg], - }) - - console.log('\n— EIP-8130 account setup (Base Sepolia) —') - console.log('owner (EOA): ', owner.address) - console.log('account (local): ', local) - console.log('account (onchain):', onchain) - expect(local.toLowerCase()).toBe(onchain.toLowerCase()) - - const existing = await getCode(client, { address: local }) - if (existing && existing !== '0x') { - console.log('already deployed; skipping createAccount.') - return - } - - console.log('sending createAccount...') - const hash = await writeContract(client, { - abi: accountConfigurationAbi, - address: deployment.accountConfiguration, - functionName: 'createAccount', - args: [userSalt, code, initialActorsArg], - chain: baseSepolia, - account: owner, - }) - console.log('tx: ', `https://sepolia.basescan.org/tx/${hash}`) - - const receipt = await waitForTransactionReceipt(client, { hash }) - console.log('status: ', receipt.status) - console.log( - 'account: ', - `https://sepolia.basescan.org/address/${local}`, - ) - expect(receipt.status).toBe('success') - - // Public RPCs are load-balanced; poll to avoid reading a lagging replica. - let deployed: `0x${string}` | undefined - for (let i = 0; i < 10; i++) { - deployed = await getCode(client, { address: local }) - if (deployed && deployed !== '0x') break - await new Promise((r) => setTimeout(r, 1500)) - } - console.log('code: ', deployed) - expect(deployed && deployed !== '0x').toBeTruthy() - }, 120_000) -}) diff --git a/scripts/eip8130/vibenet6PartTest.test.ts b/scripts/eip8130/vibenet6PartTest.test.ts deleted file mode 100644 index 45e675c3a7..0000000000 --- a/scripts/eip8130/vibenet6PartTest.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * 8-part EIP-8130 native transaction test against the local vibenet devnet. - * - * Tests (in order): - * 1. EOA plain tx — delegate + send ETH (EIP-7702 style) - * 2. EOA owner change — authorize a second K1 key - * 3. EOA with new key — send tx signed by the newly authorized key - * 4. Smart account create — createAccount + send ETH (K1 signer) - * 5. Smart account tx — follow-up send using same account - * 6. Smart account rotate — authorize a new K1 key - * 7. P256 smart account create — createAccount + send ETH (P-256 signer) - * 8. P256 smart account tx — follow-up send signed by P-256 key - * - * Run: - * npx vitest run --config test/vitest.eip8130.config.ts \ - * scripts/eip8130/vibenet6PartTest.test.ts - */ - -import { describe, expect, test } from 'vitest' -import { generatePrivateKey, privateKeyToAccount } from '../../src/accounts/index.js' -import { getBalance } from '../../src/actions/public/getBalance.js' -import { waitForTransactionReceipt } from '../../src/actions/public/waitForTransactionReceipt.js' -import { sendTransaction } from '../../src/actions/wallet/sendTransaction.js' -import { waitForTransactionReceipt as waitForReceipt8130 } from '../../src/eip8130/actions/waitForTransactionReceipt.js' -import { createClient } from '../../src/clients/createClient.js' -import { http } from '../../src/clients/transports/http.js' -import { toAccount } from '../../src/eip8130/accounts/toAccount.js' -import { getConfigSequence } from '../../src/eip8130/actions/getConfigSequence.js' -import { getTransactionCount } from '../../src/eip8130/actions/getTransactionCount.js' -import { sendCalls } from '../../src/eip8130/actions/sendCalls.js' -import { vibenetDevnetDeployment } from '../../src/eip8130/deployments.js' -import { authorizeActor, key } from '../../src/eip8130/keys.js' -import { toP256Signer } from '../../src/eip8130/utils/signers.js' -import * as P256 from 'ox/P256' -import type { AaCalls } from '../../src/eip8130/types/transaction.js' -import { erc1167Bytecode } from '../../src/eip8130/utils/proxy.js' -import { keccak256 } from '../../src/utils/hash/keccak256.js' -import { stringToHex } from '../../src/utils/encoding/toHex.js' -import { parseEther } from '../../src/utils/unit/parseEther.js' -import type { Address } from '../../src/index.js' -import type { Hex } from '../../src/types/misc.js' - -// --------------------------------------------------------------------------- -// Config — defaults target the local vibenet devnet -// --------------------------------------------------------------------------- - -const RPC = process.env.VIBENET_RPC ?? 'http://localhost:8645' -// Anvil account 0 — vibenet-setup sweeps all anvil balances into this address -// on both L1 and L2, so it becomes the rich faucet after setup completes. -const FAUCET_KEY = (process.env.FAUCET_KEY ?? - '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') as Hex -const CHAIN_ID = 84538453 -const D = vibenetDevnetDeployment - -const vibenetChain = { - id: CHAIN_ID, - name: 'vibenet', - nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, - rpcUrls: { default: { http: [RPC] } }, -} as const - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function log(label: string, value: string) { - console.log(` ${label.padEnd(22)} ${value}`) -} - -const client = createClient({ transport: http(RPC), chain: vibenetChain }) - -async function fund(to: Address, amount = parseEther('0.5')) { - const faucet = privateKeyToAccount(FAUCET_KEY) - const hash = await sendTransaction(client as any, { - account: faucet, - to, - value: amount, - chain: vibenetChain, - }) - await waitForTransactionReceipt(client as any, { hash }) - log('funded', `${to} ← ${amount} wei`) -} - -async function send( - account: ReturnType, - calls: AaCalls, - accountChanges?: any[], -): Promise { - const nonce = await getTransactionCount(client as any, { - address: account.address as Address, - nonceKey: 0n, - }) - - const hash = await sendCalls(client as any, { - account, - calls, - accountChanges: accountChanges ?? [], - nonceSequence: nonce, - gas: 500_000n, - }) - log('tx hash', hash) - const receipt = await waitForReceipt8130(client as any, { hash, timeout: 30_000 }) - const ok = receipt.status === '0x1' - log('status', ok ? '✓ success' : `✗ FAILED (status=${receipt.status}, phases=${JSON.stringify(receipt.eip8130?.phaseStatuses)})`) - if (!ok) throw new Error(`Transaction reverted: ${hash}`) - return hash -} - -async function sendWithOwnerChange( - account: ReturnType, - calls: AaCalls, - actorChanges: Parameters[0], -): Promise { - const { local } = await getConfigSequence(client as any, { - accountConfiguration: D.accountConfiguration as Address, - account: account.address as Address, - }) - const configChange = await account.change(actorChanges, { - chainId: CHAIN_ID, - sequence: Number(local), - }) - return send(account, calls, [configChange]) -} - -// --------------------------------------------------------------------------- -// Test data — fresh keys per run so tests are fully independent -// --------------------------------------------------------------------------- - -// Vibenet is native 8130: use the canonical DefaultAccount as the delegate / -// proxy implementation (the deployment carries no erc4337 example wallet). -const code = erc1167Bytecode(D.accounts.default) -const RECIPIENT = '0x1111111111111111111111111111111111111111' as Address - -// EOA account — signer IS the account address -const eoaKey1 = generatePrivateKey() -const eoaKey2 = generatePrivateKey() -const eoa1 = privateKeyToAccount(eoaKey1) -const eoa2 = privateKeyToAccount(eoaKey2) - -const eoaAccount1 = toAccount({ - signer: eoa1, - userSalt: '0x' + '00'.repeat(32) as Hex, - code, - initialActors: [key.k1(eoa1.address)], - accountConfigAddress: D.accountConfiguration as Address, - address: eoa1.address, // EOA: address == signer address -}) - -// On first use eoaAccount2 still points at eoa1's address but signs with eoa2 -const eoaAccount2 = toAccount({ - signer: eoa2, - userSalt: '0x' + '00'.repeat(32) as Hex, - code, - initialActors: [key.k1(eoa1.address)], - accountConfigAddress: D.accountConfiguration as Address, - address: eoa1.address, -}) - -// Smart account — address derived from salt + initialActors -const smartKey1 = generatePrivateKey() -const smart1 = privateKeyToAccount(smartKey1) -const smartSalt = keccak256(stringToHex(`vibe-6part-${Date.now()}`)) - -const smartAccount = toAccount({ - signer: smart1, - userSalt: smartSalt, - code, - initialActors: [key.k1(smart1.address)], - accountConfigAddress: D.accountConfiguration as Address, -}) - -// P256 smart account — address derived from P-256 public key -const p256PrivateKey = P256.randomPrivateKey() -const p256Signer = toP256Signer({ privateKey: p256PrivateKey }) -const p256Salt = keccak256(stringToHex(`vibe-p256-${Date.now()}`)) - -const p256Account = toAccount({ - signer: p256Signer, - authenticator: p256Signer.authenticator, - userSalt: p256Salt, - code, - initialActors: [key.p256(p256Signer.publicKey)], - accountConfigAddress: D.accountConfiguration as Address, -}) - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe.sequential('6-part vibenet EIP-8130 native tx test', () => { - test('1. EOA — plain tx (delegate + ETH send)', async () => { - console.log('\n══ Test 1: EOA plain tx ══') - log('EOA address', eoa1.address) - - await fund(eoa1.address) - const balBefore = await getBalance(client as any, { address: RECIPIENT }) - - // Include a `delegation` account-change so the EOA is backed by DefaultAccount - // bytecode before executeBatch is invoked. Without this the EOA has no code - // and the executeBatch self-call is a no-op (succeeds silently, value not sent). - await send( - eoaAccount1, - [[{ to: RECIPIENT, value: parseEther('0.001') }]], - [eoaAccount1.delegate(D.accounts.default as Address)], - ) - - const balAfter = await getBalance(client as any, { address: RECIPIENT }) - expect(balAfter).toBeGreaterThan(balBefore) - log('recipient Δ', `+${(Number(balAfter - balBefore) / 1e15).toFixed(3)} mETH`) - }, 60_000) - - test('2. EOA — owner change (authorize second K1 key)', async () => { - console.log('\n══ Test 2: EOA owner change ══') - log('new key', eoa2.address) - - await sendWithOwnerChange( - eoaAccount1, - [[{ to: RECIPIENT, value: 0n }]], - [authorizeActor({ actorId: key.k1(eoa2.address).actorId, authenticator: D.authenticators.k1 as Address })], - ) - log('authorized', eoa2.address) - }, 60_000) - - test('3. EOA — tx signed by newly authorized key', async () => { - console.log('\n══ Test 3: EOA tx with new key ══') - const balBefore = await getBalance(client as any, { address: RECIPIENT }) - - await send( - eoaAccount2, - [[{ to: RECIPIENT, value: parseEther('0.001') }]], - ) - - const balAfter = await getBalance(client as any, { address: RECIPIENT }) - expect(balAfter).toBeGreaterThan(balBefore) - }, 60_000) - - test('4. Smart account — createAccount + ETH send', async () => { - console.log('\n══ Test 4: Smart account create ══') - log('smart account', smartAccount.address) - await fund(smartAccount.address) - - const balBefore = await getBalance(client as any, { address: RECIPIENT }) - - // First tx includes the create account-change so the account is deployed - await send( - smartAccount, - [[{ to: RECIPIENT, value: parseEther('0.001') }]], - [smartAccount.create()], - ) - - const balAfter = await getBalance(client as any, { address: RECIPIENT }) - expect(balAfter).toBeGreaterThan(balBefore) - }, 60_000) - - test('5. Smart account — follow-up tx (no redeploy)', async () => { - console.log('\n══ Test 5: Smart account follow-up tx ══') - const balBefore = await getBalance(client as any, { address: RECIPIENT }) - - await send( - smartAccount, - [[{ to: RECIPIENT, value: parseEther('0.001') }]], - ) - - const balAfter = await getBalance(client as any, { address: RECIPIENT }) - expect(balAfter).toBeGreaterThan(balBefore) - }, 60_000) - - test('6. Smart account — owner change (add new K1 key)', async () => { - console.log('\n══ Test 6: Smart account owner change ══') - const newKey = privateKeyToAccount(generatePrivateKey()) - log('new owner key', newKey.address) - - await sendWithOwnerChange( - smartAccount, - [[{ to: RECIPIENT, value: 0n }]], - [authorizeActor({ actorId: key.k1(newKey.address).actorId, authenticator: D.authenticators.k1 as Address })], - ) - log('authorized', newKey.address) - }, 60_000) - - test('7. P256 smart account — createAccount + ETH send', async () => { - console.log('\n══ Test 7: P256 smart account create ══') - log('p256 account', p256Account.address) - log('p256 pubkey x', p256Signer.publicKey.x) - log('p256 pubkey y', p256Signer.publicKey.y) - log('authenticator', p256Signer.authenticator!) - - await fund(p256Account.address) - - const balBefore = await getBalance(client as any, { address: RECIPIENT }) - - // First tx: create account change deploys the account, then sends ETH. - await send( - p256Account, - [[{ to: RECIPIENT, value: parseEther('0.001') }]], - [p256Account.create()], - ) - - const balAfter = await getBalance(client as any, { address: RECIPIENT }) - expect(balAfter).toBeGreaterThan(balBefore) - log('recipient Δ', `+${(Number(balAfter - balBefore) / 1e15).toFixed(3)} mETH`) - }, 60_000) - - test('8. P256 smart account — follow-up tx (no redeploy)', async () => { - console.log('\n══ Test 8: P256 smart account follow-up tx ══') - const balBefore = await getBalance(client as any, { address: RECIPIENT }) - - // Subsequent tx: no account changes needed, P-256 signer signs directly. - await send( - p256Account, - [[{ to: RECIPIENT, value: parseEther('0.001') }]], - ) - - const balAfter = await getBalance(client as any, { address: RECIPIENT }) - expect(balAfter).toBeGreaterThan(balBefore) - log('recipient Δ', `+${(Number(balAfter - balBefore) / 1e15).toFixed(3)} mETH`) - }, 60_000) -}) diff --git a/scripts/smoke-estimate-sender-actor.mjs b/scripts/smoke-estimate-sender-actor.mjs deleted file mode 100644 index 30d0683aff..0000000000 --- a/scripts/smoke-estimate-sender-actor.mjs +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Live smoke: estimateGas with/without senderActorId against vibenet. - * - * Proves the node (#3892) + viem hint path for policy-gated session keys. - * - * node --experimental-vm-modules scripts/smoke-estimate-sender-actor.mjs - * # or: bun scripts/smoke-estimate-sender-actor.mjs - */ -import { createPublicClient, http, parseEther, zeroAddress } from '../src/index.ts' -import { privateKeyToAccount } from '../src/accounts/privateKeyToAccount.ts' -import { toP256Signer } from '../src/eip8130/utils/signers.ts' -import { toAccount } from '../src/eip8130/accounts/toAccount.ts' -import { estimateGas } from '../src/eip8130/actions/estimateGas.ts' -import { - authorizeActor, - key, -} from '../src/eip8130/keys.ts' -import { actorScope, canonicalAuthenticators } from '../src/eip8130/constants.ts' -import { - defineSessionPolicy, - encodeSessionPolicyAction, - encodeSessionPolicyConfig, -} from '../src/eip8130/policies.ts' -import { erc1167Bytecode } from '../src/eip8130/utils/proxy.ts' -import * as P256 from 'ox/P256' - -const RPC = process.env.VIBENET_RPC ?? 'https://rpc.vibes.base.org' -const ACCOUNT_CONFIG = '0x2403408177dB7F8512a9593343a7C80371D8f2dF' -const DEFAULT_ACCOUNT = '0xaF0973bbebe12BDaE6B61c96019dc0DcA554b67c' -const POLICY_MANAGER = '0x5E5c3D54078d1000309233fEc116A83Df5a07E67' -const SESSION_POLICY = '0xbd26BdA18Ee35F767ef03fD72356ae598ed6f793' - -const client = createPublicClient({ transport: http(RPC) }) - -const owner = privateKeyToAccount( - '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', -) -const p256 = toP256Signer({ privateKey: P256.randomPrivateKey() }) -const sessionActor = key.p256(p256.publicKey) - -const userSalt = - '0x00000000000000000000000000000000000000000000000000000000000000a1' -const initialActors = [ - key.k1(owner.address), - key.trustedExecutor(POLICY_MANAGER), -].sort((a, b) => (a.actorId < b.actorId ? -1 : a.actorId > b.actorId ? 1 : 0)) - -const account = toAccount({ - signer: owner, - userSalt, - code: erc1167Bytecode(DEFAULT_ACCOUNT), - initialActors, - accountConfigAddress: ACCOUNT_CONFIG, -}) -const createChange = account.create() - -const session = defineSessionPolicy({ - account: account.address, - manager: POLICY_MANAGER, - policy: SESSION_POLICY, - policyConfig: encodeSessionPolicyConfig({ - tokenLimits: [ - { token: zeroAddress, limit: parseEther('1'), period: 0n }, - ], - callScopes: [{ target: account.address }], - }), -}) - -const authChange = await account.change( - [ - authorizeActor(sessionActor, { - scope: actorScope.sender, - policy: session.actorPolicy, - }), - ], - { chainId: Number(await client.getChainId()), sequence: 1 }, -) - -const install = session.installCall(sessionActor.actorId) -const spend = session.executeCall( - encodeSessionPolicyAction({ - target: account.address, - value: 0n, - data: '0x', - }), -) - -const baseParams = { - sender: account.address, - accountChanges: [createChange, authChange], - calls: [[install], [spend]], - nonceSequence: 0, - senderAuthVerifier: canonicalAuthenticators.p256, -} - -console.log('rpc', RPC) -console.log('account', account.address) -console.log('sessionActorId', sessionActor.actorId) -console.log('chainId', await client.getChainId()) - -async function tryEstimate(label, params) { - try { - const gas = await estimateGas(client, params) - console.log(`OK ${label}: gas=${gas}`) - return { ok: true, gas } - } catch (err) { - const msg = err?.shortMessage ?? err?.message ?? String(err) - const details = err?.details ?? '' - console.log(`FAIL ${label}: ${msg}${details ? ` | ${details}` : ''}`) - return { ok: false, err } - } -} - -// Owner create-only estimate (sanity — self actor, no session). -await tryEstimate('owner create+noop', { - sender: account.address, - accountChanges: [createChange], - calls: [[{ to: account.address, value: 0n, data: '0x' }]], - nonceSequence: 0, - senderAuthVerifier: canonicalAuthenticators.k1, -}) - -// Session-key estimate WITHOUT actor hint — historically NoActivePolicy. -const without = await tryEstimate('session WITHOUT senderActorId', baseParams) - -// Session-key estimate WITH actor hint — should succeed after #3892. -const withHint = await tryEstimate('session WITH senderActorId', { - ...baseParams, - senderActorId: sessionActor.actorId, -}) - -if (!withHint.ok) { - console.error('\nSMOKE FAILED: senderActorId estimate still throws') - process.exit(1) -} -if (without.ok) { - console.log( - '\nNOTE: estimate without senderActorId also succeeded (self-actor path may not hit policy gate for this shape).', - ) -} else { - console.log( - '\nExpected: without hint fails, with hint succeeds — confirms the fix.', - ) -} -console.log('\nSMOKE PASSED') diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index fdbb4747b4..f75d038685 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -1,11 +1,7 @@ { "extends": "../tsconfig.base.json", "include": [".", "../src"], - // The `eip8130/` demos are manual, network-gated harnesses that import local - // source directly and run via `test/vitest.eip8130.config.ts` — they are not - // part of the library and are intentionally excluded from `check:types`. "exclude": [ - "./eip8130", "../src/**/*.test.ts", "../src/**/*.test-d.ts", "../src/**/*.bench.ts", diff --git a/test/vitest.eip8130.config.ts b/test/vitest.eip8130.config.ts deleted file mode 100644 index 7ed572dd53..0000000000 --- a/test/vitest.eip8130.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { join } from 'node:path' -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - disableConsoleIntercept: true, - alias: [ - { find: '~contracts', replacement: join(__dirname, '../contracts') }, - { find: '~test', replacement: join(__dirname, './src') }, - { find: /^viem$/, replacement: join(__dirname, '../src/index.ts') }, - { find: /^viem\/(.*)/, replacement: join(__dirname, '../src/$1') }, - ], - include: [ - 'src/eip8130/**/*.test.ts', - 'src/eip8168/**/*.test.ts', - // Manual / integration demo scripts (most require PRIVATE_KEY + network). - 'scripts/eip8130/**/*.test.ts', - ], - testTimeout: 120_000, - }, -}) From 25847b821c1c441a3cc8f84fddf6ec15b2cde6a7 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Mon, 24 Aug 2026 18:46:52 -0400 Subject: [PATCH 76/96] feat(eip8130): default keystore on getConfigSequence + unsequenced local changes - getConfigSequence now defaults `accountConfiguration` to the canonical (enshrined) keystore, matching every sibling read (getLockStatus, isLocked, getActorConfig, getPolicy, isActor). There is a single keystore, so callers no longer pass it. - Add first-class support for unsequenced (JIT) local changes: the `unsequencedLocalHalf` sentinel (type(uint32).max, mirrors Keystore.UNSEQUENCED) and `unsequencedLocalSequence(localEpoch)` helper that packs `localEpoch << 32 | UNSEQUENCED` for signing epoch-bound, sequence-less local changes. Exported from viem/eip8130 with a unit test. - Docs: drop the now-redundant keystore address from getConfigSequence examples. --- site/pages/eip8130/rotating-owners.mdx | 5 +- src/eip8130/actions/getConfigSequence.test.ts | 21 ++++++++ src/eip8130/actions/getConfigSequence.ts | 49 ++++++++++++++++--- src/eip8130/constants.ts | 13 +++++ src/eip8130/index.ts | 2 + src/eip8130/keys.ts | 2 +- src/eip8130/lock.ts | 5 +- 7 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 src/eip8130/actions/getConfigSequence.test.ts diff --git a/site/pages/eip8130/rotating-owners.mdx b/site/pages/eip8130/rotating-owners.mdx index ccc2bd82cb..39f38b2ada 100644 --- a/site/pages/eip8130/rotating-owners.mdx +++ b/site/pages/eip8130/rotating-owners.mdx @@ -48,11 +48,9 @@ An unrestricted (full-owner) actor uses scope `0`. Every config change is signed against the account's **next** config sequence. Read it first to avoid sequence-mismatch rejections: ```ts -import { getConfigSequence, getEip8130Deployment } from 'viem/eip8130' +import { getConfigSequence } from 'viem/eip8130' -const { accountConfiguration } = getEip8130Deployment(client.chain.id)! const { local: sequence } = await getConfigSequence(client, { - accountConfiguration, account: account.address, }) ``` @@ -115,7 +113,6 @@ const rotate = await account.change( import { getConfigSequence, incrementLocalEpoch, sendCalls } from 'viem/eip8130' const { local: sequence } = await getConfigSequence(client, { - accountConfiguration, account: account.address, }) diff --git a/src/eip8130/actions/getConfigSequence.test.ts b/src/eip8130/actions/getConfigSequence.test.ts new file mode 100644 index 0000000000..333ffe70a3 --- /dev/null +++ b/src/eip8130/actions/getConfigSequence.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'vitest' +import { unsequencedLocalHalf } from '../constants.js' +import { unsequencedLocalSequence } from './getConfigSequence.js' + +test('unsequencedLocalHalf is type(uint32).max', () => { + expect(unsequencedLocalHalf).toBe(2n ** 32n - 1n) + expect(unsequencedLocalHalf).toBe(0xffff_ffffn) +}) + +test('unsequencedLocalSequence packs epoch (high 32) || UNSEQUENCED (low 32)', () => { + // epoch 0 → just the sentinel in the low half. + expect(unsequencedLocalSequence(0)).toBe(0xffff_ffffn) + // epoch 1 → 0x1_ffff_ffff. + expect(unsequencedLocalSequence(1)).toBe((1n << 32n) | 0xffff_ffffn) + + // For an arbitrary epoch: high 32 bits are the epoch, low 32 the sentinel. + const epoch = 42 + const word = unsequencedLocalSequence(epoch) + expect(word >> 32n).toBe(BigInt(epoch)) + expect(word & 0xffff_ffffn).toBe(unsequencedLocalHalf) +}) diff --git a/src/eip8130/actions/getConfigSequence.ts b/src/eip8130/actions/getConfigSequence.ts index 6c318f8dd9..ef3875b298 100644 --- a/src/eip8130/actions/getConfigSequence.ts +++ b/src/eip8130/actions/getConfigSequence.ts @@ -5,12 +5,19 @@ import type { Transport } from '../../clients/transports/createTransport.js' import type { Account } from '../../types/account.js' import type { Chain } from '../../types/chain.js' import { accountConfigurationAbi } from '../abis.js' +import { + accountConfigAddress as defaultAccountConfigAddress, + unsequencedLocalHalf, +} from '../constants.js' export type GetConfigSequenceParameters = { - /** The EIP-8130 AccountConfiguration system contract address. */ - accountConfiguration: Address /** The account whose local config sequence to read. */ account: Address + /** + * `AccountConfiguration` (keystore) system contract. Defaults to the canonical + * (enshrined) address, which is identical on every supported chain. + */ + accountConfiguration?: Address | undefined } export type GetConfigSequenceReturnType = { @@ -40,11 +47,11 @@ export type GetConfigSequenceReturnType = { * Calling this before signing any owner change (authorize / revoke) prevents * sequence-mismatch rejections caused by a stale local cache. * + * For an *unsequenced* (JIT) local change, don't use `local` — build the word + * from `localEpoch` via {@link unsequencedLocalSequence} instead. + * * @example - * const { local } = await getConfigSequence(client, { - * accountConfiguration: deployment.accountConfiguration, - * account: accountAddress, - * }) + * const { local } = await getConfigSequence(client, { account: accountAddress }) * // Use `local` as the sequence for the next AccountChange. */ export async function getConfigSequence< @@ -54,7 +61,8 @@ export async function getConfigSequence< client: Client, parameters: GetConfigSequenceParameters, ): Promise { - const { accountConfiguration, account } = parameters + const { account, accountConfiguration = defaultAccountConfigAddress } = + parameters const result = await readContract(client, { address: accountConfiguration, @@ -74,3 +82,30 @@ export async function getConfigSequence< localSequence: result.localSequence, } } + +/** + * Builds an *unsequenced* (JIT) `'local'` channel sequence word from the current + * `localEpoch`: `localEpoch (high 32) || UNSEQUENCED (low 32)`, where + * `UNSEQUENCED` is {@link unsequencedLocalHalf} (`type(uint32).max`). + * + * A change signed at this word is bound to the current epoch but not pinned to a + * monotonic `localSequence`: it may land at any position within the epoch and is + * invalidated by an `incrementLocalEpoch` bump. Pass it as the `sequence` to + * `account.change(...)` / `signAccountChanges(...)`. + * + * @example + * import { getConfigSequence, unsequencedLocalSequence } from 'viem/eip8130' + * + * const { localEpoch } = await getConfigSequence(client, { account: account.address }) + * const change = await account.change(changes, { + * channel: 'local', + * chainId: client.chain.id, + * sequence: unsequencedLocalSequence(localEpoch), + * }) + * + * @param localEpoch - The current local epoch (from {@link getConfigSequence}). + * @returns The packed `uint64` local sequence word with the unsequenced sentinel. + */ +export function unsequencedLocalSequence(localEpoch: number): bigint { + return (BigInt(localEpoch) << 32n) | unsequencedLocalHalf +} diff --git a/src/eip8130/constants.ts b/src/eip8130/constants.ts index 208272a7b8..996b885695 100644 --- a/src/eip8130/constants.ts +++ b/src/eip8130/constants.ts @@ -84,6 +84,19 @@ export const changeType = { unlock: 0x04, } as const +/** + * JIT sentinel for the **local** channel's low 32 bits (`localSequence`): + * `type(uint32).max`. When a `'local'` sequence word's low half equals this, the + * signed change is *unsequenced* — bound to the current `localEpoch` (the high 32 + * bits) but not pinned to a monotonic `localSequence`, so it may land at any + * position within the epoch and is invalidated by an `incrementLocalEpoch` bump. + * Mirrors `Keystore.UNSEQUENCED`. + * + * Build the full `uint64` sequence word from the current epoch with + * {@link unsequencedLocalSequence}. + */ +export const unsequencedLocalHalf = 0xffff_ffffn + /** * Actor scope permission bitmask values (base/eip-8130 `AccountConfiguration`). * diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index 1ab0d1c36d..7fbd6b31eb 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -39,6 +39,7 @@ export { type GetConfigSequenceParameters, type GetConfigSequenceReturnType, getConfigSequence, + unsequencedLocalSequence, } from './actions/getConfigSequence.js' export { type GetLockStatusParameters, @@ -142,6 +143,7 @@ export { scopeUnrestricted, trustedExecutorAuthenticator, txContextAddress, + unsequencedLocalHalf, } from './constants.js' export { baseSepoliaDeployment, diff --git a/src/eip8130/keys.ts b/src/eip8130/keys.ts index 0209742b67..278763d05b 100644 --- a/src/eip8130/keys.ts +++ b/src/eip8130/keys.ts @@ -224,7 +224,7 @@ export function revokeActor(actor: AaActor | Hex): AaRevokeActor { * sequence from {@link getConfigSequence}; it takes no payload. * * @example - * const { local } = await getConfigSequence(client, { accountConfiguration, account }) + * const { local } = await getConfigSequence(client, { account }) * const bump = await account.change([incrementLocalEpoch()], { chainId, sequence: local }) */ export function incrementLocalEpoch(): AaIncrementLocalEpoch { diff --git a/src/eip8130/lock.ts b/src/eip8130/lock.ts index cfa7e7ac17..1dfd88a4d7 100644 --- a/src/eip8130/lock.ts +++ b/src/eip8130/lock.ts @@ -27,6 +27,7 @@ import type { AaLock, AaUnlock } from './types/transaction.js' * @example * ```ts * import { + * accountConfigAddress, * lockChange, * signAccountChanges, * encodeApplySignedAccountChangesData, @@ -34,7 +35,7 @@ import type { AaLock, AaUnlock } from './types/transaction.js' * sendCalls, * } from 'viem/eip8130' * - * const { local } = await getConfigSequence(client, { accountConfiguration, account }) + * const { local } = await getConfigSequence(client, { account }) * const entry = await signAccountChanges({ * signer: admin, * account, @@ -44,7 +45,7 @@ import type { AaLock, AaUnlock } from './types/transaction.js' * changes: [lockChange({ unlockDelay: 3600 })], * }) * const data = encodeApplySignedAccountChangesData({ account, ...entry }) - * await sendCalls(client, { account, calls: [{ to: accountConfiguration, data }], gas }) + * await sendCalls(client, { account, calls: [{ to: accountConfigAddress, data }], gas }) * ``` */ From 4282bb27ba2d4e469af55db934a31382e212a2ad Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Mon, 24 Aug 2026 19:57:22 -0400 Subject: [PATCH 77/96] refactor(eip8130): single, unchangeable keystore (drop config-address overrides) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keystore (AccountConfiguration) is enshrined in the execution client and identical on every chain — using any other address derives a different account address and fails the create tx. So it is not configurable. - Rename the `accountConfigAddress` constant to `keystoreAddress` and document it as the fixed, enshrined keystore. - Drop the `accountConfiguration` / `accountConfigAddress` override params from every read (getLockStatus, isLocked, getActorConfig, getPolicy, isActor, getConfigSequence), the permissions helper, the account builders (toAccount/newSmartAccount/toSmartAccount/subAccounts), and the encoders (computeAddress, toFactoryArgs). All now reference `keystoreAddress` directly. - Remove the `accountConfigAddress` property from the account object; sendCalls reads the keystore constant instead. - Remove `accountConfiguration` from the `Eip8130Deployment` record (it is the fixed `keystoreAddress`, not a per-chain address). - Update tests + docs; drop the now-impossible override cases. --- site/pages/eip8130.mdx | 9 ++++---- src/eip8130/accounts/toAccount.ts | 16 -------------- src/eip8130/accounts/toSmartAccount.ts | 9 +------- src/eip8130/actions/getActorConfig.ts | 18 +++------------ src/eip8130/actions/getConfigSequence.ts | 15 +++---------- src/eip8130/actions/getLockStatus.ts | 12 +++------- src/eip8130/actions/getPolicy.ts | 15 +++---------- src/eip8130/actions/isActor.ts | 8 +------ src/eip8130/actions/isLocked.ts | 12 +++------- src/eip8130/actions/sendCalls.ts | 3 --- src/eip8130/constants.ts | 19 +++++++++------- src/eip8130/deployments.ts | 18 +++++++-------- src/eip8130/index.ts | 2 +- src/eip8130/lock.ts | 4 ++-- src/eip8130/permissions.ts | 7 ------ src/eip8130/subAccounts.ts | 4 ---- src/eip8130/utils/accountConfigCalls.test.ts | 15 +++---------- src/eip8130/utils/accountConfigCalls.ts | 23 ++++++-------------- src/eip8130/utils/computeAddress.test.ts | 19 ++-------------- src/eip8130/utils/computeAddress.ts | 19 +++------------- 20 files changed, 59 insertions(+), 188 deletions(-) diff --git a/site/pages/eip8130.mdx b/site/pages/eip8130.mdx index f9d44459ab..6f19683cd6 100644 --- a/site/pages/eip8130.mdx +++ b/site/pages/eip8130.mdx @@ -84,18 +84,19 @@ register8130Chains(vibenet.id) ## Deployment addresses -Every protocol contract is deployed through a deterministic CREATE2 factory with a per-contract mined salt, so each address is a pure function of its bytecode — **identical on every chain**. The `viem/eip8130` actions already default to this canonical set (`accountConfiguration` is enshrined in the execution client), so you normally don't pass any addresses at all. +Every protocol contract is deployed through a deterministic CREATE2 factory with a per-contract mined salt, so each address is a pure function of its bytecode — **identical on every chain**. The `viem/eip8130` actions already default to this canonical set, so you normally don't pass any addresses at all. -The default is implied but extensible — fall back to `canonicalEip8130Deployment`, and override only if a chain ever pins a different set: +The keystore itself is enshrined in the execution client and is not configurable: it lives at the fixed `keystoreAddress` constant, so it is not part of the per-chain deployment record. The rest of the addresses fall back to `canonicalEip8130Deployment`, and can be overridden only if a chain ever pins a different set: ```ts -import { getEip8130Deployment, canonicalEip8130Deployment } from 'viem/eip8130' +import { keystoreAddress, getEip8130Deployment, canonicalEip8130Deployment } from 'viem/eip8130' + +keystoreAddress // Keystore — factory + actor-config registry (enshrined, fixed) const deployment = getEip8130Deployment(chainId) ?? // per-chain override, if one is ever registered canonicalEip8130Deployment // canonical default (every chain today) -deployment.accountConfiguration // Keystore — factory + actor-config registry (enshrined) deployment.accounts.default // DefaultAccount — EIP-7702 delegate / proxy impl deployment.accounts.defaultHighRate // CanonicalHighRatePayerAccount — immutable (ERC-1167) deployment.authenticators.p256 // P-256 (webAuthn, delegate, k1, alwaysValid alongside) diff --git a/src/eip8130/accounts/toAccount.ts b/src/eip8130/accounts/toAccount.ts index ea6202cb56..529da813bb 100644 --- a/src/eip8130/accounts/toAccount.ts +++ b/src/eip8130/accounts/toAccount.ts @@ -6,7 +6,6 @@ import { hexToBigInt } from '../../utils/encoding/fromHex.js' import { bytesToHex } from '../../utils/encoding/toHex.js' import { canonicalAuthenticators, - accountConfigAddress as defaultAccountConfigAddress, ecrecoverAuthenticator, scopeUnrestricted, } from '../constants.js' @@ -85,8 +84,6 @@ export type ToAccountParameters = ToAccountBase & * ascending order. */ initialActors: readonly AaActor[] - /** Account Configuration contract (CREATE2 deployer). Defaults to canonical. */ - accountConfigAddress?: Address | undefined /** Override the derived address (advanced). */ address?: Address | undefined } @@ -101,8 +98,6 @@ export type ToAccountParameters = ToAccountBase & userSalt?: undefined code?: undefined initialActors?: undefined - /** AccountConfiguration for on-chain actor reads. Defaults to canonical. */ - accountConfigAddress?: Address | undefined } ) @@ -121,8 +116,6 @@ export type ToAccountReturnType = { * set explicitly for non-K1 authenticators. */ readonly actorId?: Hex | undefined - /** AccountConfiguration address used for on-chain actor reads (when known). */ - readonly accountConfigAddress?: Address | undefined /** * Builds the `create` account-change entry (include in the first tx for * smart accounts). Throws if the account was constructed with a known `address` @@ -190,9 +183,6 @@ export function toAccount( // Address-only mode (delegated EOA): address is fixed, no CREATE2 derivation. const isAddressOnly = parameters.userSalt === undefined - const accountConfigAddress = - parameters.accountConfigAddress ?? defaultAccountConfigAddress - const address: Address = (() => { if (parameters.address) return parameters.address if (isAddressOnly) @@ -203,7 +193,6 @@ export function toAccount( userSalt: parameters.userSalt!, code: parameters.code!, initialActors: parameters.initialActors!, - accountConfigAddress, }) })() @@ -224,7 +213,6 @@ export function toAccount( initialActors, scope, actorId, - accountConfigAddress, create() { if (isAddressOnly) @@ -338,8 +326,6 @@ export type NewSmartAccountParameters = { * sorted by `actorId` in strictly ascending order (protocol requirement). */ extraActors?: readonly AaActor[] | undefined - /** AccountConfiguration contract override (advanced). Defaults to canonical. */ - accountConfigAddress?: Address | undefined } export type NewSmartAccountReturnType = ToAccountReturnType & { @@ -414,7 +400,6 @@ export function newSmartAccount( proxy = 'upgradeable', admins = [], extraActors = [], - accountConfigAddress, } = parameters // Detect signer type and derive the primary actor. @@ -494,7 +479,6 @@ export function newSmartAccount( code, initialActors: allActors, authenticator: signer.authenticator, - accountConfigAddress, // The primary (controlling) actor is registered without a scope, i.e. as an // admin actor (`scopeUnrestricted`). Admin actors may use ordered *or* // nonce-free nonces, so sends default to ordered (sequenced) mode — surface diff --git a/src/eip8130/accounts/toSmartAccount.ts b/src/eip8130/accounts/toSmartAccount.ts index 1108a8955f..6649a572ba 100644 --- a/src/eip8130/accounts/toSmartAccount.ts +++ b/src/eip8130/accounts/toSmartAccount.ts @@ -20,10 +20,7 @@ import { encodeFunctionData } from '../../utils/abi/encodeFunctionData.js' import { concatHex } from '../../utils/data/concat.js' import { getAction } from '../../utils/getAction.js' import { erc4337AccountAbi } from '../abis.js' -import { - accountConfigAddress as defaultAccountConfigAddress, - ecrecoverAuthenticator, -} from '../constants.js' +import { ecrecoverAuthenticator } from '../constants.js' import type { AaActor } from '../types/transaction.js' import { toFactoryArgs } from '../utils/accountConfigCalls.js' import { computeAddress } from '../utils/computeAddress.js' @@ -62,8 +59,6 @@ export type ToSmartAccountParameters< * {@link sign} (e.g. 129 bytes for P-256). */ stubData?: Hex | undefined - /** Account Configuration contract (the ERC-4337 factory). */ - accountConfigAddress?: Address | undefined } & ( | { /** Pre-deployed / known account address. */ @@ -140,7 +135,6 @@ export async function toSmartAccount< }, getNonce, authenticator = ecrecoverAuthenticator, - accountConfigAddress = defaultAccountConfigAddress, } = parameters const entryPoint = { @@ -187,7 +181,6 @@ export async function toSmartAccount< userSalt: parameters.userSalt, code, initialActors: parameters.initialActors, - accountConfigAddress, } } diff --git a/src/eip8130/actions/getActorConfig.ts b/src/eip8130/actions/getActorConfig.ts index d004cb39a6..f23de50a24 100644 --- a/src/eip8130/actions/getActorConfig.ts +++ b/src/eip8130/actions/getActorConfig.ts @@ -7,21 +7,13 @@ import type { Account } from '../../types/account.js' import type { Chain } from '../../types/chain.js' import type { Hex } from '../../types/misc.js' import { accountConfigurationAbi } from '../abis.js' -import { - actorScope, - accountConfigAddress as defaultAccountConfigAddress, -} from '../constants.js' +import { actorScope, keystoreAddress } from '../constants.js' export type GetActorConfigParameters = { /** The account whose actor to read. */ account: Address /** The 32-byte actor identifier (see `key.*(...).actorId`). */ actorId: Hex - /** - * `AccountConfiguration` system contract. Defaults to the canonical - * (enshrined) address, which is identical on every supported chain. - */ - accountConfiguration?: Address | undefined } export type GetActorConfigReturnType = { @@ -61,14 +53,10 @@ export async function getActorConfig< client: Client, parameters: GetActorConfigParameters, ): Promise { - const { - account, - actorId, - accountConfiguration = defaultAccountConfigAddress, - } = parameters + const { account, actorId } = parameters const config = await readContract(client, { - address: accountConfiguration, + address: keystoreAddress, abi: accountConfigurationAbi, functionName: 'getActorConfig', args: [account, actorId], diff --git a/src/eip8130/actions/getConfigSequence.ts b/src/eip8130/actions/getConfigSequence.ts index ef3875b298..3b78291864 100644 --- a/src/eip8130/actions/getConfigSequence.ts +++ b/src/eip8130/actions/getConfigSequence.ts @@ -5,19 +5,11 @@ import type { Transport } from '../../clients/transports/createTransport.js' import type { Account } from '../../types/account.js' import type { Chain } from '../../types/chain.js' import { accountConfigurationAbi } from '../abis.js' -import { - accountConfigAddress as defaultAccountConfigAddress, - unsequencedLocalHalf, -} from '../constants.js' +import { keystoreAddress, unsequencedLocalHalf } from '../constants.js' export type GetConfigSequenceParameters = { /** The account whose local config sequence to read. */ account: Address - /** - * `AccountConfiguration` (keystore) system contract. Defaults to the canonical - * (enshrined) address, which is identical on every supported chain. - */ - accountConfiguration?: Address | undefined } export type GetConfigSequenceReturnType = { @@ -61,11 +53,10 @@ export async function getConfigSequence< client: Client, parameters: GetConfigSequenceParameters, ): Promise { - const { account, accountConfiguration = defaultAccountConfigAddress } = - parameters + const { account } = parameters const result = await readContract(client, { - address: accountConfiguration, + address: keystoreAddress, abi: accountConfigurationAbi, functionName: 'getChangeSequences', args: [account], diff --git a/src/eip8130/actions/getLockStatus.ts b/src/eip8130/actions/getLockStatus.ts index 091345688f..31ea484dda 100644 --- a/src/eip8130/actions/getLockStatus.ts +++ b/src/eip8130/actions/getLockStatus.ts @@ -6,16 +6,11 @@ import type { Transport } from '../../clients/transports/createTransport.js' import type { Account } from '../../types/account.js' import type { Chain } from '../../types/chain.js' import { accountConfigurationAbi } from '../abis.js' -import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' +import { keystoreAddress } from '../constants.js' export type GetLockStatusParameters = { /** The account whose lock status to read. */ account: Address - /** - * `AccountConfiguration` system contract. Defaults to the canonical - * (enshrined) address, which is identical on every supported chain. - */ - accountConfiguration?: Address | undefined } export type GetLockStatusReturnType = { @@ -52,12 +47,11 @@ export async function getLockStatus< client: Client, parameters: GetLockStatusParameters, ): Promise { - const { account, accountConfiguration = defaultAccountConfigAddress } = - parameters + const { account } = parameters const [locked, hasInitiatedUnlock, unlocksAt, unlockDelay] = await readContract(client, { - address: accountConfiguration, + address: keystoreAddress, abi: accountConfigurationAbi, functionName: 'getLockStatus', args: [account], diff --git a/src/eip8130/actions/getPolicy.ts b/src/eip8130/actions/getPolicy.ts index 91c9468fb8..78cdcfc184 100644 --- a/src/eip8130/actions/getPolicy.ts +++ b/src/eip8130/actions/getPolicy.ts @@ -7,18 +7,13 @@ import type { Account } from '../../types/account.js' import type { Chain } from '../../types/chain.js' import type { Hex } from '../../types/misc.js' import { accountConfigurationAbi } from '../abis.js' -import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' +import { keystoreAddress } from '../constants.js' export type GetPolicyParameters = { /** The account whose actor policy to read. */ account: Address /** The 32-byte actor identifier (see `key.*(...).actorId`). */ actorId: Hex - /** - * `AccountConfiguration` system contract. Defaults to the canonical - * (enshrined) address, which is identical on every supported chain. - */ - accountConfiguration?: Address | undefined } export type GetPolicyReturnType = { @@ -57,17 +52,13 @@ export async function getPolicy< client: Client, parameters: GetPolicyParameters, ): Promise { - const { - account, - actorId, - accountConfiguration = defaultAccountConfigAddress, - } = parameters + const { account, actorId } = parameters // The finalized Keystore exposes a single combined read that returns the actor // config plus its policy manager and commitment; `policyManager` and // `policyCommitment` are zero for a non-live / ungated actor. const [, policyManager, policyCommitment] = await readContract(client, { - address: accountConfiguration, + address: keystoreAddress, abi: accountConfigurationAbi, functionName: 'getActor', args: [account, actorId], diff --git a/src/eip8130/actions/isActor.ts b/src/eip8130/actions/isActor.ts index 95acb4c51d..6eefe994e5 100644 --- a/src/eip8130/actions/isActor.ts +++ b/src/eip8130/actions/isActor.ts @@ -12,11 +12,6 @@ export type IsActorParameters = { account: Address /** The 32-byte actor identifier (see `key.*(...).actorId`). */ actorId: Hex - /** - * `AccountConfiguration` system contract. Defaults to the canonical - * (enshrined) address, which is identical on every supported chain. - */ - accountConfiguration?: Address | undefined } export type IsActorReturnType = boolean @@ -52,12 +47,11 @@ export async function isActor< client: Client, parameters: IsActorParameters, ): Promise { - const { account, actorId, accountConfiguration } = parameters + const { account, actorId } = parameters const { authenticator } = await getActorConfig(client, { account, actorId, - ...(accountConfiguration ? { accountConfiguration } : {}), }) return authenticator !== zeroAddress } diff --git a/src/eip8130/actions/isLocked.ts b/src/eip8130/actions/isLocked.ts index 472f835757..6b15d3d3fe 100644 --- a/src/eip8130/actions/isLocked.ts +++ b/src/eip8130/actions/isLocked.ts @@ -6,16 +6,11 @@ import type { Transport } from '../../clients/transports/createTransport.js' import type { Account } from '../../types/account.js' import type { Chain } from '../../types/chain.js' import { accountConfigurationAbi } from '../abis.js' -import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' +import { keystoreAddress } from '../constants.js' export type IsLockedParameters = { /** The account to check. */ account: Address - /** - * `AccountConfiguration` system contract. Defaults to the canonical - * (enshrined) address, which is identical on every supported chain. - */ - accountConfiguration?: Address | undefined } export type IsLockedReturnType = boolean @@ -43,11 +38,10 @@ export async function isLocked< client: Client, parameters: IsLockedParameters, ): Promise { - const { account, accountConfiguration = defaultAccountConfigAddress } = - parameters + const { account } = parameters return readContract(client, { - address: accountConfiguration, + address: keystoreAddress, abi: accountConfigurationAbi, functionName: 'isLocked', args: [account], diff --git a/src/eip8130/actions/sendCalls.ts b/src/eip8130/actions/sendCalls.ts index 93f13ed9c0..d089b40f21 100644 --- a/src/eip8130/actions/sendCalls.ts +++ b/src/eip8130/actions/sendCalls.ts @@ -64,18 +64,15 @@ async function resolveSigningScope( const { actorId, scope: declared } = account if (!actorId) return declared - const accountConfiguration = account.accountConfigAddress const bound = await isActor(client, { account: account.address, actorId, - ...(accountConfiguration ? { accountConfiguration } : {}), }) if (!bound) return declared const { scope: onChain } = await getActorConfig(client, { account: account.address, actorId, - ...(accountConfiguration ? { accountConfiguration } : {}), }) if (declared !== undefined && declared !== onChain) throw new ScopeMismatchError({ declared, onChain }) diff --git a/src/eip8130/constants.ts b/src/eip8130/constants.ts index 996b885695..3d20fa5f83 100644 --- a/src/eip8130/constants.ts +++ b/src/eip8130/constants.ts @@ -243,16 +243,19 @@ export const txContextAddress = '0x813000000000000000000000000000000000aa02' satisfies Hex /** - * Account Configuration system contract address (`ACCOUNT_CONFIG_ADDRESS`), - * used as the CREATE2 deployer for account address derivation. + * The EIP-8130 keystore (`AccountConfiguration`) system contract address + * (`ACCOUNT_CONFIG_ADDRESS`), also used as the CREATE2 deployer for account + * address derivation. * * @remarks - * Defaults to the [base/eip-8130](https://github.com/base/eip-8130) deployment - * (Base Sepolia). The address may differ per chain — resolve via - * {@link getEip8130Deployment}, or override via the `accountConfigAddress` - * parameter of {@link computeAddress}. + * There is a single keystore: it is **enshrined** in the execution client and is + * identical on every supported chain (Base Sepolia, vibenet devnet, mainnet when + * live). It is deployed via [base/eip-8130](https://github.com/base/eip-8130) + * through Nick's deterministic CREATE2 factory with a mined salt. This value is + * not configurable — using any other address derives a different account address + * and the create transaction fails. */ -export const accountConfigAddress = +export const keystoreAddress = '0x81305d4f4976220D2af17E5Dc246848E235600AC' satisfies Hex /** @@ -261,7 +264,7 @@ export const accountConfigAddress = * * @remarks * Defaults to the base/eip-8130 deployment (Base Sepolia); see - * {@link accountConfigAddress}. + * {@link keystoreAddress}. */ export const defaultAccountAddress = '0x813078f98b3eb214046C8Dc93A771ac9de5AaDEf' satisfies Hex diff --git a/src/eip8130/deployments.ts b/src/eip8130/deployments.ts index 720d6a2dd2..ed996b4ac1 100644 --- a/src/eip8130/deployments.ts +++ b/src/eip8130/deployments.ts @@ -3,15 +3,14 @@ import type { Address } from 'abitype' /** * Onchain addresses for an EIP-8130 deployment ([base/eip-8130](https://github.com/base/eip-8130)). * - * On chains **without** native EIP-8130 support, these contracts provide the - * portable path: `accountConfiguration` is the ERC-4337 factory / config - * registry, `accounts` are the wallet implementations (proxied via ERC-1167), - * and `authenticators` are the deployed authenticator contracts used during EVM + * The keystore itself is enshrined and identical on every chain (see + * {@link keystoreAddress}); it is not part of this per-chain record. On chains + * **without** native EIP-8130 support, these contracts provide the portable + * path: `accounts` are the wallet implementations (proxied via ERC-1167), and + * `authenticators` are the deployed authenticator contracts used during EVM * execution (native chains use the protocol sentinels instead). */ export type Eip8130Deployment = { - /** AccountConfiguration system contract (factory + actor-config registry). */ - accountConfiguration: Address /** Deployed wallet implementation contracts (the singletons account proxies delegate to). */ accounts: { /** @@ -78,14 +77,13 @@ export type Eip8130Deployment = { * the `0x8130…` vanity prefix (except `alwaysValid`, deployed under the zero * salt). * - * `accountConfiguration` is enshrined in the execution client; using any other - * value derives a different account address and the create transaction fails. + * The keystore is enshrined at {@link keystoreAddress} (not listed here, since it + * is fixed and not configurable). * * When the `base/eip-8130` contracts are recompiled (Solidity upgrade or * bytecode change), all addresses must be re-derived and this object updated. */ export const canonicalEip8130Deployment = { - accountConfiguration: '0x81305d4f4976220D2af17E5Dc246848E235600AC', accounts: { // PENDING FINAL IMPLEMENTATION: the default `newSmartAccount` proxy is // `'upgradeable'`, which must delegate to a real UUPS `UpgradeableAccount` @@ -128,7 +126,7 @@ export const baseSepoliaDeployment = { * EIP-8130 deployment for the Base "vibenet" devnet (chain id `84538453`). * * The devnet runs EIP-8130 **natively**: the execution client enshrines the - * canonical `accountConfiguration`. Using any other value derives a different + * keystore at {@link keystoreAddress}. Using any other value derives a different * account address and create transactions fail with "create address mismatch". */ export const vibenetDevnetDeployment = { diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index 7fbd6b31eb..3562e61f85 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -118,7 +118,6 @@ export { aaPayerType, aaTransactionType, accountChangeType, - accountConfigAddress, accountStateFlags, actorScope, canonicalAuthDataLength, @@ -128,6 +127,7 @@ export { deploymentHeaderSize, ecrecoverAuthenticator, externalPolicyAuthenticator, + keystoreAddress, maxCodeSize, nonceFreeCost, nonceFreeExpiryWindow, diff --git a/src/eip8130/lock.ts b/src/eip8130/lock.ts index 1dfd88a4d7..8807749625 100644 --- a/src/eip8130/lock.ts +++ b/src/eip8130/lock.ts @@ -27,7 +27,7 @@ import type { AaLock, AaUnlock } from './types/transaction.js' * @example * ```ts * import { - * accountConfigAddress, + * keystoreAddress, * lockChange, * signAccountChanges, * encodeApplySignedAccountChangesData, @@ -45,7 +45,7 @@ import type { AaLock, AaUnlock } from './types/transaction.js' * changes: [lockChange({ unlockDelay: 3600 })], * }) * const data = encodeApplySignedAccountChangesData({ account, ...entry }) - * await sendCalls(client, { account, calls: [{ to: accountConfigAddress, data }], gas }) + * await sendCalls(client, { account, calls: [{ to: keystoreAddress, data }], gas }) * ``` */ diff --git a/src/eip8130/permissions.ts b/src/eip8130/permissions.ts index 339d18c579..eec1d99dd4 100644 --- a/src/eip8130/permissions.ts +++ b/src/eip8130/permissions.ts @@ -302,11 +302,6 @@ export type FulfillGrantPermissionsParameters = Omit< * if the manager still needs registering. @default false */ assumeManagerRegistered?: boolean | undefined - /** - * `AccountConfiguration` system contract used for the manager check. Defaults - * to the canonical (enshrined) address. - */ - accountConfiguration?: Address | undefined } export type FulfillGrantPermissionsReturnType = { @@ -406,7 +401,6 @@ export async function fulfillGrantPermissions< permissions, expiry, assumeManagerRegistered = false, - accountConfiguration, ...rest } = parameters const expiryBig = expiry === undefined ? undefined : BigInt(expiry) @@ -434,7 +428,6 @@ export async function fulfillGrantPermissions< const { authenticator } = await getActorConfig(client, { account, actorId: managerActor.actorId, - ...(accountConfiguration ? { accountConfiguration } : {}), }) const registered = authenticator.toLowerCase() === trustedExecutorAuthenticator.toLowerCase() diff --git a/src/eip8130/subAccounts.ts b/src/eip8130/subAccounts.ts index 0cfa28f46c..cdd32d766c 100644 --- a/src/eip8130/subAccounts.ts +++ b/src/eip8130/subAccounts.ts @@ -88,8 +88,6 @@ export type FulfillAddSubAccountParameters = { implementation?: Address | undefined /** Deployment bytecode override (bypasses `proxy`/`implementation`). */ code?: Hex | undefined - /** AccountConfiguration contract override. Defaults to canonical. */ - accountConfigAddress?: Address | undefined } export type FulfillAddSubAccountReturnType = ToAccountReturnType & { @@ -152,7 +150,6 @@ export function fulfillAddSubAccount( keyPolicy, proxy = 'upgradeable', implementation, - accountConfigAddress, } = parameters const parentActor = key.delegate(parent) @@ -212,7 +209,6 @@ export function fulfillAddSubAccount( authenticator: canonicalAuthenticators.delegate, actorId: parentActor.actorId, scope: scopeUnrestricted, - accountConfigAddress, }) return { diff --git a/src/eip8130/utils/accountConfigCalls.test.ts b/src/eip8130/utils/accountConfigCalls.test.ts index 9f70790164..e38061f3b6 100644 --- a/src/eip8130/utils/accountConfigCalls.test.ts +++ b/src/eip8130/utils/accountConfigCalls.test.ts @@ -8,7 +8,7 @@ import { register8130Chains, unregister8130Chains, } from '../chains.js' -import { accountConfigAddress } from '../constants.js' +import { keystoreAddress } from '../constants.js' import type { AaActor, AaChange } from '../types/transaction.js' import { encodeApplySignedAccountChangesData, @@ -48,9 +48,9 @@ describe('toFactoryArgs (ERC-4337 factory)', () => { initialActors: [actor], } as const - test('factory is the account config contract; factoryData is createAccount', () => { + test('factory is the keystore; factoryData is createAccount', () => { const { factory, factoryData } = toFactoryArgs(params) - expect(factory).toBe(accountConfigAddress) + expect(factory).toBe(keystoreAddress) expect(factoryData).toBe(encodeCreateAccountData(params)) const { functionName, args } = decodeFunctionData({ @@ -70,15 +70,6 @@ describe('toFactoryArgs (ERC-4337 factory)', () => { ]) }) - test('custom factory address', () => { - const factoryAddress = '0x00000000000000000000000000000000000000aa' as const - const { factory } = toFactoryArgs({ - ...params, - accountConfigAddress: factoryAddress, - }) - expect(factory).toBe(factoryAddress) - }) - test('factory deploys to the computeAddress address', () => { // both derive from the same inputs/config address -> portable address const address = computeAddress(params) diff --git a/src/eip8130/utils/accountConfigCalls.ts b/src/eip8130/utils/accountConfigCalls.ts index a6fb17012c..49a93498cb 100644 --- a/src/eip8130/utils/accountConfigCalls.ts +++ b/src/eip8130/utils/accountConfigCalls.ts @@ -6,7 +6,7 @@ import { encodeFunctionData, } from '../../utils/abi/encodeFunctionData.js' import { accountConfigurationAbi } from '../abis.js' -import { accountConfigAddress as defaultAccountConfigAddress } from '../constants.js' +import { keystoreAddress } from '../constants.js' import type { AaActor, AaChange, @@ -59,13 +59,7 @@ export function encodeCreateAccountData( }) } -export type ToFactoryArgsParameters = EncodeCreateAccountDataParameters & { - /** - * Account Configuration contract address (the ERC-4337 factory). Defaults to - * the placeholder {@link accountConfigAddress} constant. - */ - accountConfigAddress?: Address | undefined -} +export type ToFactoryArgsParameters = EncodeCreateAccountDataParameters export type ToFactoryArgsReturnType = { factory: Address @@ -78,19 +72,16 @@ export type ToFactoryArgsErrorType = /** * Returns the ERC-4337 `{ factory, factoryData }` for deploying an EIP-8130 - * account through the `AccountConfiguration` contract on a non-8130 chain. The - * resulting account address matches {@link computeAddress}. + * account through the keystore on a non-8130 chain. The resulting account + * address matches {@link computeAddress}. The factory is the enshrined + * {@link keystoreAddress}. */ export function toFactoryArgs( parameters: ToFactoryArgsParameters, ): ToFactoryArgsReturnType { - const { - accountConfigAddress = defaultAccountConfigAddress, - ...createParameters - } = parameters return { - factory: accountConfigAddress, - factoryData: encodeCreateAccountData(createParameters), + factory: keystoreAddress, + factoryData: encodeCreateAccountData(parameters), } } diff --git a/src/eip8130/utils/computeAddress.test.ts b/src/eip8130/utils/computeAddress.test.ts index e5485a418b..51ed491f2a 100644 --- a/src/eip8130/utils/computeAddress.test.ts +++ b/src/eip8130/utils/computeAddress.test.ts @@ -4,7 +4,7 @@ import { isAddress } from '../../utils/address/isAddress.js' import { concatHex } from '../../utils/data/concat.js' import { toHex } from '../../utils/encoding/toHex.js' import { keccak256 } from '../../utils/hash/keccak256.js' -import { accountConfigAddress } from '../constants.js' +import { keystoreAddress } from '../constants.js' import type { AaActor } from '../types/transaction.js' import { computeAddress, deploymentHeader } from './computeAddress.js' @@ -50,7 +50,7 @@ describe('computeAddress (EIP-8130)', () => { const effectiveSalt = keccak256(concatHex([userSalt, actorsCommitment])) const deploymentCode = concatHex([deploymentHeader(2), code]) const expected = getCreate2Address({ - from: accountConfigAddress, + from: keystoreAddress, salt: effectiveSalt, bytecode: deploymentCode, }) @@ -74,21 +74,6 @@ describe('computeAddress (EIP-8130)', () => { expect(a).not.toBe(b) }) - test('custom accountConfigAddress changes the address', () => { - const base = { - userSalt: - '0x0000000000000000000000000000000000000000000000000000000000000001', - code: '0x6080', - initialActors: [actorA], - } as const - const a = computeAddress(base) - const b = computeAddress({ - ...base, - accountConfigAddress: '0x00000000000000000000000000000000000000ff', - }) - expect(a).not.toBe(b) - }) - test('deploymentHeader encodes code length into PUSH2 operands', () => { expect(deploymentHeader(2)).toBe('0x610002600e60003961000260' + '00f3') expect(deploymentHeader(0x1234)).toBe('0x611234600e6000396112346000f3') diff --git a/src/eip8130/utils/computeAddress.ts b/src/eip8130/utils/computeAddress.ts index f9e7ab2ae6..3406728d44 100644 --- a/src/eip8130/utils/computeAddress.ts +++ b/src/eip8130/utils/computeAddress.ts @@ -14,10 +14,7 @@ import { type Keccak256ErrorType, keccak256, } from '../../utils/hash/keccak256.js' -import { - accountConfigAddress as defaultAccountConfigAddress, - maxCodeSize, -} from '../constants.js' +import { keystoreAddress, maxCodeSize } from '../constants.js' import type { AaActor } from '../types/transaction.js' /** @@ -57,11 +54,6 @@ export type ComputeAddressParameters = { * (this also rejects duplicate `actorId`s). */ initialActors: readonly AaActor[] - /** - * Account Configuration contract address (CREATE2 deployer). Defaults to the - * placeholder {@link accountConfigAddress} constant. - */ - accountConfigAddress?: Address | undefined } export type ComputeAddressErrorType = @@ -88,12 +80,7 @@ export type ComputeAddressErrorType = * the packed leaves are hashed once. */ export function computeAddress(parameters: ComputeAddressParameters): Address { - const { - userSalt, - code, - initialActors, - accountConfigAddress = defaultAccountConfigAddress, - } = parameters + const { userSalt, code, initialActors } = parameters const codeSize = size(code) if (codeSize === 0) throw new BaseError('`code` must not be empty.') @@ -133,7 +120,7 @@ export function computeAddress(parameters: ComputeAddressParameters): Address { const deploymentCode = concatHex([deploymentHeader(codeSize), code]) return getCreate2Address({ - from: accountConfigAddress, + from: keystoreAddress, salt: effectiveSalt, bytecode: deploymentCode, }) From 21cd31eae6fe82a2f1a42b8004ce70e61ba76127 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 26 Aug 2026 14:39:41 -0400 Subject: [PATCH 78/96] chore(eip8130): update canonical deployment addresses Point the SDK at the freshly deployed base/eip-8130 contracts: - keystore: 0x813011b7a5f25f8433Ac1E0993DE06CB2d1500Ac - DefaultAccount: 0x813035E3fc4a102CE2b4a73D78a25D1Ea5AFadEf - HighRatePayer: 0x8130D6819734515f958965eFd2d212541d44FA57 - DelegateAuthenticator:0x8130015119757e0b1F9985F723091a851598Ade1 - PolicyManager: 0x8130fAA29D2675D05d01D387C576c6525F280ac1 - SessionPolicy: 0x81306283dfD94FcDe1a3aD4b7beDF1c5cD0f5e55 P256/WebAuthn authenticators are unchanged. Regenerate the commitmentOf reference vector for the new SessionPolicy address. --- src/eip8130/constants.ts | 6 +++--- src/eip8130/deployments.ts | 10 +++++----- src/eip8130/policies.test.ts | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/eip8130/constants.ts b/src/eip8130/constants.ts index 3d20fa5f83..04caf90f46 100644 --- a/src/eip8130/constants.ts +++ b/src/eip8130/constants.ts @@ -213,7 +213,7 @@ export const canonicalAuthenticators = { /** WebAuthn / FIDO2 passkey. Canonical base/eip-8130 deployment. */ passkey: '0x813007b6b1b48E75D91dEc5927ab515d12a0F1d0', /** Signature delegation (1-hop). Canonical base/eip-8130 deployment. */ - delegate: '0x8130b7D430D041ED4050935814D493299980aDE1', + delegate: '0x8130015119757e0b1F9985F723091a851598Ade1', } as const satisfies Record /** @@ -256,7 +256,7 @@ export const txContextAddress = * and the create transaction fails. */ export const keystoreAddress = - '0x81305d4f4976220D2af17E5Dc246848E235600AC' satisfies Hex + '0x813011b7a5f25f8433Ac1E0993DE06CB2d1500Ac' satisfies Hex /** * Default wallet implementation for EOA auto-delegation @@ -267,7 +267,7 @@ export const keystoreAddress = * {@link keystoreAddress}. */ export const defaultAccountAddress = - '0x813078f98b3eb214046C8Dc93A771ac9de5AaDEf' satisfies Hex + '0x813035E3fc4a102CE2b4a73D78a25D1Ea5AFadEf' satisfies Hex /** Size of the deployment header in bytes (`DEPLOYMENT_HEADER_SIZE`). */ export const deploymentHeaderSize = 14 diff --git a/src/eip8130/deployments.ts b/src/eip8130/deployments.ts index ed996b4ac1..f06d65a211 100644 --- a/src/eip8130/deployments.ts +++ b/src/eip8130/deployments.ts @@ -94,8 +94,8 @@ export const canonicalEip8130Deployment = { // address is set here, `proxy: 'upgradeable'` requires an explicit // `implementation`. Set `upgradeable` once deployed and the default goes live. upgradeable: undefined, - default: '0x813078f98b3eb214046C8Dc93A771ac9de5AaDEf', - defaultHighRate: '0x8130931874c894aC4963e128D6273AE520dAFa57', + default: '0x813035E3fc4a102CE2b4a73D78a25D1Ea5AFadEf', + defaultHighRate: '0x8130D6819734515f958965eFd2d212541d44FA57', // `erc4337` (BackwardsCompatible4337Account) is intentionally out of scope // for now — supply it explicitly if you choose the ERC-4337 portable path. }, @@ -103,12 +103,12 @@ export const canonicalEip8130Deployment = { k1: '0x0000000000000000000000000000000000000001', p256: '0x8130C89F65750431b564A4730397552a11CeA256', webAuthn: '0x813007b6b1b48E75D91dEc5927ab515d12a0F1d0', - delegate: '0x8130b7D430D041ED4050935814D493299980aDE1', + delegate: '0x8130015119757e0b1F9985F723091a851598Ade1', alwaysValid: '0xA550545Da91720c23483c5B3493412A02D1cF9F9', }, policies: { - manager: '0x813077055d1110F92191ccE13018f51820B40ac1', - sessionPolicy: '0x813070914C530d030f4Efd8Fa99C18e836435e55', + manager: '0x8130fAA29D2675D05d01D387C576c6525F280ac1', + sessionPolicy: '0x81306283dfD94FcDe1a3aD4b7beDF1c5cD0f5e55', }, } as const satisfies Eip8130Deployment diff --git a/src/eip8130/policies.test.ts b/src/eip8130/policies.test.ts index 39c624f32e..2bafabd0c5 100644 --- a/src/eip8130/policies.test.ts +++ b/src/eip8130/policies.test.ts @@ -50,7 +50,7 @@ describe('encoders', () => { describe('commitmentOf', () => { test('matches PolicyManager.commitmentOf reference vector', () => { expect(commitmentOf(binding)).toBe( - '0x76958bee732b160cb2b85c73c153db765cf10892871632afd5746cbba149bf33', + '0x512b8e70d95c8ff3410a0fbbf9bfe0d41c3b608bb36c20f407991870200a3f5b', ) }) From ebedf5c92e92c159a863ae37bc6210c71e266734 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 26 Aug 2026 14:49:39 -0400 Subject: [PATCH 79/96] docs(eip8130): correct per-chain framing; clarify keystore vs policy overrides All protocol contracts are deterministic CREATE2 (identical on every chain), so the "defaults to Base Sepolia / may differ per chain" wording on the authenticators, DefaultAccount, and SessionPolicy addresses was misleading. Also document why the enshrined keystore is not overridable while the unaudited, extensible policy contracts (PolicyManager / SessionPolicy) still accept an override. --- src/eip8130/actions/getSessionSpend.ts | 5 ++++- src/eip8130/constants.ts | 10 ++++++---- src/eip8130/policies.ts | 12 +++++++++--- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/eip8130/actions/getSessionSpend.ts b/src/eip8130/actions/getSessionSpend.ts index fadbe13da0..95cab76daf 100644 --- a/src/eip8130/actions/getSessionSpend.ts +++ b/src/eip8130/actions/getSessionSpend.ts @@ -26,7 +26,10 @@ export type GetSessionSpendParameters = { */ tokenLimit: SessionPolicyTokenLimit /** - * `SessionPolicy` contract. Defaults to the reference Base Sepolia deployment. + * `SessionPolicy` contract. Defaults to the canonical (deterministic, same on + * every chain) deployment. Remains overridable because `SessionPolicy` is an + * unaudited, extensible example contract (unlike the enshrined keystore) — pass + * your own if you deployed a custom policy. */ sessionPolicy?: Address | undefined } diff --git a/src/eip8130/constants.ts b/src/eip8130/constants.ts index 04caf90f46..5b72416274 100644 --- a/src/eip8130/constants.ts +++ b/src/eip8130/constants.ts @@ -202,8 +202,10 @@ export const externalPolicyAuthenticator = * * @remarks * The non-native addresses below are the [base/eip-8130](https://github.com/base/eip-8130) - * deployment (Base Sepolia). They may differ per chain — resolve via - * {@link eip8130Deployments} / {@link getEip8130Deployment}, or override per call. + * deployment. Each is deployed through Nick's deterministic CREATE2 factory with + * a mined salt, so the address is a pure function of its bytecode and is + * identical on every supported chain. Pass a different authenticator per account + * via the `authenticator` parameter when using a custom verifier. */ export const canonicalAuthenticators = { /** secp256k1 — native sentinel (`ECRECOVER_AUTHENTICATOR` / `K1_AUTHENTICATOR`). */ @@ -263,8 +265,8 @@ export const keystoreAddress = * (`DEFAULT_ACCOUNT_ADDRESS`). * * @remarks - * Defaults to the base/eip-8130 deployment (Base Sepolia); see - * {@link keystoreAddress}. + * Deployed through the deterministic CREATE2 factory, so this address is + * identical on every supported chain; see {@link keystoreAddress}. */ export const defaultAccountAddress = '0x813035E3fc4a102CE2b4a73D78a25D1Ea5AFadEf' satisfies Hex diff --git a/src/eip8130/policies.ts b/src/eip8130/policies.ts index b7c2e14f85..a7c06af31a 100644 --- a/src/eip8130/policies.ts +++ b/src/eip8130/policies.ts @@ -37,8 +37,11 @@ import type { AaCall } from './types/transaction.js' * on-chain at execute; the manager recomputes its commitment and requires it * to equal the account's live signed commitment. * - * @remarks These contracts are an unaudited reference. Addresses default to the - * Base Sepolia deployment; override `manager` / `policy` for other chains. + * @remarks Unlike the enshrined {@link keystoreAddress}, these are unaudited + * *example* contracts (extensible: deploy your own manager/policy), so the + * `manager` / `policy` addresses remain overridable. The defaults are the + * canonical base/eip-8130 deployment, which — being deterministic CREATE2 — is + * identical on every supported chain. */ // ───────────────────────────────────────────────────────────────────────────── @@ -317,7 +320,10 @@ export function defineSessionPolicy( // SessionPolicy config + action encoders // ───────────────────────────────────────────────────────────────────────────── -/** Reference `SessionPolicy` deployment address (Base Sepolia). */ +/** + * Reference `SessionPolicy` deployment address. Deterministic CREATE2, so it is + * identical on every supported chain. + */ export const sessionPolicyAddress = baseSepoliaDeployment.policies .sessionPolicy as Address From dc9785c7b0d17bd8ac7f35369a2053d5c640295e Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 26 Aug 2026 19:24:21 -0400 Subject: [PATCH 80/96] refactor(eip8130)!: sendCalls -> sendTransaction, prepareTransaction -> prepareTransactionRequest; add sendTransactionSync Aligns the EIP-8130 send surface with viem-native naming. Drops the ERC-5792/4337-flavored `sendCalls` / `prepareTransaction` names (and the collision with core `sendCalls`) now that this path is native `AA_TX_TYPE` submission rather than a bundler flow. - `sendCalls` -> `sendTransaction`, `SendCallsParameters` -> `SendTransactionParameters` - `prepareTransaction` -> `prepareTransactionRequest` (+ Parameters) - add `sendTransactionSync` via core `eth_sendRawTransactionSync`, returning the AA receipt with `eip8130` fields (payer/phaseStatuses/metadata) Fill/sign/submit behavior is unchanged. Updates the eip8168 caller, all JSDoc references, unit tests, and docs. Hard rename, no aliases (module is experimental, pre-merge). --- site/pages/eip8130.mdx | 2 +- site/pages/eip8130/calls-and-batching.mdx | 16 +-- site/pages/eip8130/metadata.mdx | 6 +- site/pages/eip8130/payer-services.mdx | 4 +- site/pages/eip8130/receipts.mdx | 6 +- site/pages/eip8130/rotating-owners.mdx | 12 +- site/pages/eip8130/sending-a-transaction.mdx | 22 ++-- site/pages/eip8130/session-keys.mdx | 12 +- .../pages/eip8130/sponsoring-transactions.mdx | 16 +-- site/pages/eip8130/sub-accounts.mdx | 12 +- src/eip8130/accounts/toAccount.ts | 6 +- .../{sendCalls.ts => sendTransaction.ts} | 109 +++++++++++++----- .../actions/waitForTransactionReceipt.ts | 4 +- src/eip8130/devx.test.ts | 10 +- src/eip8130/index.ts | 14 ++- src/eip8130/lock.ts | 4 +- src/eip8130/nonce.test.ts | 8 +- src/eip8130/nonce.ts | 14 +-- src/eip8130/permissions.ts | 6 +- src/eip8130/subAccounts.ts | 4 +- src/eip8130/types/transaction.ts | 4 +- src/eip8130/utils/assertTransaction.ts | 2 +- src/eip8168/actions/sendSponsoredCalls.ts | 4 +- 23 files changed, 174 insertions(+), 123 deletions(-) rename src/eip8130/actions/{sendCalls.ts => sendTransaction.ts} (76%) diff --git a/site/pages/eip8130.mdx b/site/pages/eip8130.mdx index 6f19683cd6..8ff19782c6 100644 --- a/site/pages/eip8130.mdx +++ b/site/pages/eip8130.mdx @@ -56,7 +56,7 @@ The helpers live under the dedicated entrypoint: ```ts import { newSmartAccount, - sendCalls, + sendTransaction, estimateGas, } from 'viem/eip8130' ``` diff --git a/site/pages/eip8130/calls-and-batching.mdx b/site/pages/eip8130/calls-and-batching.mdx index d3f8356abd..e497bfdbce 100644 --- a/site/pages/eip8130/calls-and-batching.mdx +++ b/site/pages/eip8130/calls-and-batching.mdx @@ -32,27 +32,27 @@ Each `AaCall` is `{ to, data?, value? }`: ## Flat vs. phased -`sendCalls` accepts either shape. A **flat** array is sugar for a single phase; pass a **nested** array to control phases explicitly: +`sendTransaction` accepts either shape. A **flat** array is sugar for a single phase; pass a **nested** array to control phases explicitly: ```ts -import { sendCalls } from 'viem/eip8130' +import { sendTransaction } from 'viem/eip8130' // One atomic phase (flat): -await sendCalls(client, { +await sendTransaction(client, { account, calls: [{ to: a, data }, { to: b, data }], gas: 300_000n, }) // Two phases (nested): -await sendCalls(client, { +await sendTransaction(client, { account, calls: [[{ to: a, data }], [{ to: b, data }]], gas: 300_000n, }) ``` -`estimateGas` and `prepareTransaction` always take the **phased** form (`AaCalls`). +`estimateGas` and `prepareTransactionRequest` always take the **phased** form (`AaCalls`). ## How value-bearing calls execute @@ -70,10 +70,10 @@ const wire = encodeWalletCalls({ ## Custom executors -Wallets whose bytecode does not expose `executeBatch` must supply their own `encodeExecute` — a function mapping a phase's normalized calls to a single wallet call. Pass it to `sendCalls` (or `encodeWalletCalls`): +Wallets whose bytecode does not expose `executeBatch` must supply their own `encodeExecute` — a function mapping a phase's normalized calls to a single wallet call. Pass it to `sendTransaction` (or `encodeWalletCalls`): ```ts -import { type EncodeExecute, sendCalls } from 'viem/eip8130' +import { type EncodeExecute, sendTransaction } from 'viem/eip8130' import { encodeFunctionData } from 'viem' const encodeExecute: EncodeExecute = ({ account, calls }) => ({ @@ -85,7 +85,7 @@ const encodeExecute: EncodeExecute = ({ account, calls }) => ({ }), }) -await sendCalls(client, { account, calls, gas: 300_000n, encodeExecute }) +await sendTransaction(client, { account, calls, gas: 300_000n, encodeExecute }) ``` ## Choosing phases diff --git a/site/pages/eip8130/metadata.mdx b/site/pages/eip8130/metadata.mdx index c567bd4927..1959604d7c 100644 --- a/site/pages/eip8130/metadata.mdx +++ b/site/pages/eip8130/metadata.mdx @@ -14,14 +14,14 @@ Use it to bind off-chain context to a transaction — a payer `policyId`, a clie ## Setting metadata -`metadata` is a field on the transaction, not a parameter of `sendCalls`. Build the transaction with `prepareTransaction`, set `metadata`, then sign and submit: +`metadata` is a field on the transaction, not a parameter of `sendTransaction`. Build the transaction with `prepareTransactionRequest`, set `metadata`, then sign and submit: ```ts import { sendRawTransaction } from 'viem/actions' -import { prepareTransaction } from 'viem/eip8130' +import { prepareTransactionRequest } from 'viem/eip8130' import { stringToHex } from 'viem' -const tx = await prepareTransaction(client, { +const tx = await prepareTransactionRequest(client, { account, calls: [[{ to: recipient, data }]], gas: 250_000n, diff --git a/site/pages/eip8130/payer-services.mdx b/site/pages/eip8130/payer-services.mdx index 35b7d8b27f..7d991b9e53 100644 --- a/site/pages/eip8130/payer-services.mdx +++ b/site/pages/eip8130/payer-services.mdx @@ -178,9 +178,9 @@ Prepare the transaction with the offer's gas, sign `sender_auth` with `payer` na ```ts import { hexToBigInt } from 'viem' -import { prepareTransaction } from 'viem/eip8130' +import { prepareTransactionRequest } from 'viem/eip8130' -const tx = await prepareTransaction(client, { +const tx = await prepareTransactionRequest(client, { account, calls, gas: hexToBigInt(terms.gasEstimate!.gasLimit), diff --git a/site/pages/eip8130/receipts.mdx b/site/pages/eip8130/receipts.mdx index 36e52816aa..1c93ebd337 100644 --- a/site/pages/eip8130/receipts.mdx +++ b/site/pages/eip8130/receipts.mdx @@ -25,17 +25,17 @@ It polls `eth_getTransactionReceipt` until the transaction is mined (default eve Any transaction with a non-zero `validBefore` (sequenced or nonce-free) can no longer land once the chain's latest block timestamp passes it. Pass the `validBefore` (unix **milliseconds**) you signed with and the wait rejects with a `TransactionExpiredError` the moment it lapses, instead of silently spinning until the timeout. -Thread the resolved `validBefore` out of `sendCalls` with `onTransaction` — this is reliable because it's the exact value the tx was signed with (including auto-computed nonce-free windows), and it doesn't depend on the node returning a pending transaction: +Thread the resolved `validBefore` out of `sendTransaction` with `onTransaction` — this is reliable because it's the exact value the tx was signed with (including auto-computed nonce-free windows), and it doesn't depend on the node returning a pending transaction: ```ts import { TransactionExpiredError, - sendCalls, + sendTransaction, waitForTransactionReceipt, } from 'viem/eip8130' let validBefore: bigint | undefined -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account, calls, gas, diff --git a/site/pages/eip8130/rotating-owners.mdx b/site/pages/eip8130/rotating-owners.mdx index 39f38b2ada..8b230481b2 100644 --- a/site/pages/eip8130/rotating-owners.mdx +++ b/site/pages/eip8130/rotating-owners.mdx @@ -64,7 +64,7 @@ import { actorScope, authorizeActor, key, - sendCalls, + sendTransaction, } from 'viem/eip8130' const change = await account.change( @@ -76,7 +76,7 @@ const change = await account.change( { chainId: client.chain.id, sequence }, // bigint, straight from getConfigSequence ) -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account, accountChanges: [change], calls: [], // config-only transaction @@ -110,7 +110,7 @@ const rotate = await account.change( `incrementLocalEpoch()` bumps the account's local epoch, invalidating **every** unlanded local-channel signature signed at a prior epoch in one shot — an instant "revoke all pending" kill-switch (e.g. after a suspected key compromise, or to void outstanding just-in-time session-key grants that haven't landed). It takes no payload; sign it at the current `local` sequence like any other change: ```ts -import { getConfigSequence, incrementLocalEpoch, sendCalls } from 'viem/eip8130' +import { getConfigSequence, incrementLocalEpoch, sendTransaction } from 'viem/eip8130' const { local: sequence } = await getConfigSequence(client, { account: account.address, @@ -121,7 +121,7 @@ const bump = await account.change([incrementLocalEpoch()], { sequence, }) -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account, accountChanges: [bump], calls: [], @@ -141,7 +141,7 @@ import { authorizeActor, canonicalEip8130Deployment, key, - sendCalls, + sendTransaction, toEoaAccount, } from 'viem/eip8130' import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' @@ -153,7 +153,7 @@ const addP256 = await account.change( { chainId: client.chain.id, sequence: 0 }, ) -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account, accountChanges: [ account.delegate(canonicalEip8130Deployment.accounts.default), diff --git a/site/pages/eip8130/sending-a-transaction.mdx b/site/pages/eip8130/sending-a-transaction.mdx index f617eb0b68..f709c9bba9 100644 --- a/site/pages/eip8130/sending-a-transaction.mdx +++ b/site/pages/eip8130/sending-a-transaction.mdx @@ -4,7 +4,7 @@ description: Estimate, sign, and submit an EIP-8130 AA_TX_TYPE transaction # Sending a Transaction -An EIP-8130 transaction is an `AA_TX_TYPE` (`0x79`) envelope. `sendCalls` handles the full lifecycle: it fills the nonce and fees, encodes the calls, signs `sender_auth` (and `payer_auth` when sponsored), serializes, and submits via `eth_sendRawTransaction`. +An EIP-8130 transaction is an `AA_TX_TYPE` (`0x79`) envelope. `sendTransaction` handles the full lifecycle: it fills the nonce and fees, encodes the calls, signs `sender_auth` (and `payer_auth` when sponsored), serializes, and submits via `eth_sendRawTransaction`. ## Estimate gas @@ -47,7 +47,7 @@ import { parseEther } from 'viem' import { canonicalAuthenticators, estimateGas, - sendCalls, + sendTransaction, waitForTransactionReceipt, } from 'viem/eip8130' @@ -60,7 +60,7 @@ const gas = await estimateGas(client, { senderAuthVerifier: canonicalAuthenticators.k1, }) -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account, accountChanges: [account.createChange], // deploy — omit on later txs calls, @@ -77,14 +77,14 @@ Once the account is deployed, drop `accountChanges` and just pass `calls`. The n ```ts import { encodeFunctionData } from 'viem' -import { estimateGas, sendCalls } from 'viem/eip8130' +import { estimateGas, sendTransaction } from 'viem/eip8130' const gas = await estimateGas(client, { sender: account.address, calls: [[{ to: token, data: transferData }]], }) -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account, calls: [{ to: token, data: transferData }], gas: (gas * 120n) / 100n, @@ -96,9 +96,9 @@ const hash = await sendCalls(client, { A flat `calls` array runs as one atomic phase. Pass a nested array to control phases explicitly (each phase is a separate `executeBatch`) — see [Calls & Batching](/eip8130/calls-and-batching) for the full phased model: ```ts -import { sendCalls } from 'viem/eip8130' +import { sendTransaction } from 'viem/eip8130' -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account, calls: [ { to: tokenA, data: approveData }, @@ -113,9 +113,9 @@ const hash = await sendCalls(client, { A `payer` account can co-sign the transaction and pay its gas — either a key you hold or a payer web service: ```ts -import { sendCalls } from 'viem/eip8130' +import { sendTransaction } from 'viem/eip8130' -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account, calls: [{ to: recipient, data }], gas: 250_000n, @@ -127,9 +127,9 @@ See [Sponsoring Transactions](/eip8130/sponsoring-transactions) for the co-signi ## Lower-level control -`sendCalls` composes two primitives you can use directly: +`sendTransaction` composes two primitives you can use directly: -- `prepareTransaction(client, params)` — fills chain id, nonce sequence, and EIP-1559 fees into a `TransactionSerializable8130`. +- `prepareTransactionRequest(client, params)` — fills chain id, nonce sequence, and EIP-1559 fees into a `TransactionSerializable8130`. - `account.signTransaction(tx, { payer })` — produces the `sender_auth`/`payer_auth` blobs and returns the serialized envelope, which you can submit with `sendRawTransaction`. ## Next diff --git a/site/pages/eip8130/session-keys.mdx b/site/pages/eip8130/session-keys.mdx index 31230f326b..ad521dc6eb 100644 --- a/site/pages/eip8130/session-keys.mdx +++ b/site/pages/eip8130/session-keys.mdx @@ -86,7 +86,7 @@ import { actorScope, authorizeActor, key, - sendCalls, + sendTransaction, } from 'viem/eip8130' const sessionKey = key.p256({ x, y }) @@ -101,7 +101,7 @@ const change = await account.change( { chainId: client.chain.id, sequence: Number(sequence) }, ) -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account, accountChanges: [change], // The install call initializes the binding — it must land before first use. @@ -119,7 +119,7 @@ import { encodeFunctionData, erc20Abi, parseUnits } from 'viem' import { encodeSessionPolicyAction, newSmartAccount, - sendCalls, + sendTransaction, toP256Signer, } from 'viem/eip8130' @@ -136,7 +136,7 @@ const transfer = encodeFunctionData({ args: [recipient, parseUnits('10', 6)], }) -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account: sessionAccount, calls: [ session.executeCall( @@ -216,7 +216,7 @@ When the granted key wants to act, `routePermissionedCalls` decodes it and wraps import { actorScope, routePermissionedCalls, - sendCalls, + sendTransaction, toAccount, } from 'viem/eip8130' @@ -234,7 +234,7 @@ const handle = toAccount({ scope: actorScope.policy, }) -const hash = await sendCalls(client, { account: handle, calls, gas: 250_000n }) +const hash = await sendTransaction(client, { account: handle, calls, gas: 250_000n }) ``` For a `pull` grant the routed calls target `executeFor`, and the external provider submits them from **its own** address (not through the account). Use `parsePermissionsContext` if you only need to inspect a context (account, role, actor, rebound `SessionPolicy`) without routing calls. diff --git a/site/pages/eip8130/sponsoring-transactions.mdx b/site/pages/eip8130/sponsoring-transactions.mdx index 4998b9f3aa..82da5b98e4 100644 --- a/site/pages/eip8130/sponsoring-transactions.mdx +++ b/site/pages/eip8130/sponsoring-transactions.mdx @@ -18,15 +18,15 @@ There are two ways to obtain `payer_auth`: ## Co-signing locally -Pass a `payer` signer to `sendCalls`. It signs `payer_auth` bound to the sender and sets the `payer` wire field. Set `payer.address` when the onchain payer account differs from the signing key. +Pass a `payer` signer to `sendTransaction`. It signs `payer_auth` bound to the sender and sets the `payer` wire field. Set `payer.address` when the onchain payer account differs from the signing key. ```ts import { privateKeyToAccount } from 'viem/accounts' -import { sendCalls } from 'viem/eip8130' +import { sendTransaction } from 'viem/eip8130' const sponsor = privateKeyToAccount(process.env.SPONSOR_KEY as `0x${string}`) -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account, // signs sender_auth calls: [{ to: recipient, data }], gas: 250_000n, @@ -55,9 +55,9 @@ To charge the sender in an ERC-20 while the payer fronts native gas, run a two-p ```ts import { encodeTokenTransfer } from 'viem/eip8168' -import { sendCalls } from 'viem/eip8130' +import { sendTransaction } from 'viem/eip8130' -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account, calls: [ // phase 0 — pay the payer in USDC @@ -74,13 +74,13 @@ In practice the token amount and payer are negotiated with a [payer service](/ei ## Lower-level control -`sendCalls` wraps two primitives when you need to inspect or persist the transaction between signing and submitting: +`sendTransaction` wraps two primitives when you need to inspect or persist the transaction between signing and submitting: ```ts import { sendRawTransaction } from 'viem/actions' -import { prepareTransaction } from 'viem/eip8130' +import { prepareTransactionRequest } from 'viem/eip8130' -const tx = await prepareTransaction(client, { +const tx = await prepareTransactionRequest(client, { account, calls: /* AaCalls (phased) */ phases, gas: 250_000n, diff --git a/site/pages/eip8130/sub-accounts.mdx b/site/pages/eip8130/sub-accounts.mdx index b9c7b99215..23790eb571 100644 --- a/site/pages/eip8130/sub-accounts.mdx +++ b/site/pages/eip8130/sub-accounts.mdx @@ -38,7 +38,7 @@ import { actorScope, authorizeActor, key, - sendCalls, + sendTransaction, } from 'viem/eip8130' // On the sub account, authorize the primary account as a delegate. @@ -51,7 +51,7 @@ const link = await subAccount.change( { chainId: client.chain.id, sequence: Number(sequence) }, ) -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account: subAccount, accountChanges: [link], calls: [], @@ -64,7 +64,7 @@ Once linked, build an account handle that signs for the sub account using the ** ```ts import { canonicalAuthenticators, - sendCalls, + sendTransaction, toAccount, } from 'viem/eip8130' @@ -75,7 +75,7 @@ const subAsDelegate = toAccount({ address: subAccount.address, }) -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account: subAsDelegate, calls: [{ to: recipient, value: 1n }], gas: 200_000n, @@ -104,7 +104,7 @@ const unlink = await subAccount.change( When a dApp asks a wallet to create a sub account via [ERC-7895](https://eips.ethereum.org/EIPS/eip-7895) `wallet_addSubAccount` (`type: 'create'`), `fulfillAddSubAccount` builds the two primitives above in one step: a **distinct** account whose owner set is the requested `keys` **plus** `key.delegate(parent)` — the link installed at creation, so no separate change transaction is needed. ```ts -import { fulfillAddSubAccount, sendCalls } from 'viem/eip8130' +import { fulfillAddSubAccount, sendTransaction } from 'viem/eip8130' // `keys` are the owner keys the dApp requested (wallet_addSubAccount). const sub = fulfillAddSubAccount({ @@ -118,7 +118,7 @@ const sub = fulfillAddSubAccount({ sub.response.address // Deploy + first call in one shot — the parent drives it as a delegate: -const hash = await sendCalls(client, { +const hash = await sendTransaction(client, { account: sub, accountChanges: [sub.createChange], calls: [{ to: recipient, value: 1n }], diff --git a/src/eip8130/accounts/toAccount.ts b/src/eip8130/accounts/toAccount.ts index 529da813bb..72ee0522b5 100644 --- a/src/eip8130/accounts/toAccount.ts +++ b/src/eip8130/accounts/toAccount.ts @@ -42,7 +42,7 @@ type ToAccountBase = { /** * Scope bitmask of the **signing actor** on this account (see * {@link actorScope}). Prefer omitting this once the actor is on-chain — - * {@link prepareTransaction} reads `getActorConfig` and derives nonce + * {@link prepareTransactionRequest} reads `getActorConfig` and derives nonce * mode from chain truth. When set on an already-bound actor, it must match * the on-chain value or prepare throws {@link ScopeMismatchError}. * @@ -108,7 +108,7 @@ export type ToAccountReturnType = { /** * Scope of the signing actor, when known off-chain. Prefer leaving this * unset once the actor is authorized — prepare reads chain truth instead. - * See {@link prepareTransaction}. + * See {@link prepareTransactionRequest}. */ readonly scope?: number | undefined /** @@ -509,7 +509,7 @@ export type ToEoaAccountReturnType = { /** * Scope of the implicit self-actor (admin by default). Drives automatic * nonce-mode selection: admin may use ordered *or* nonce-free, so sends - * default to ordered (sequenced) mode. See {@link prepareTransaction}. + * default to ordered (sequenced) mode. See {@link prepareTransactionRequest}. */ readonly scope?: number | undefined /** diff --git a/src/eip8130/actions/sendCalls.ts b/src/eip8130/actions/sendTransaction.ts similarity index 76% rename from src/eip8130/actions/sendCalls.ts rename to src/eip8130/actions/sendTransaction.ts index d089b40f21..a236a27ffc 100644 --- a/src/eip8130/actions/sendCalls.ts +++ b/src/eip8130/actions/sendTransaction.ts @@ -1,5 +1,6 @@ import { estimateFeesPerGas } from '../../actions/public/estimateFeesPerGas.js' import { sendRawTransaction } from '../../actions/wallet/sendRawTransaction.js' +import { sendRawTransactionSync } from '../../actions/wallet/sendRawTransactionSync.js' import type { Client } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' import { BaseError } from '../../errors/base.js' @@ -24,6 +25,10 @@ import { import type { Signer } from '../utils/signTransaction.js' import { getActorConfig } from './getActorConfig.js' import { getTransactionCount } from './getTransactionCount.js' +import { + type GetTransactionReceiptReturnType, + parseReceiptFields, +} from './getTransactionReceipt.js' import { isActor } from './isActor.js' type FeeOverrides = { @@ -31,7 +36,7 @@ type FeeOverrides = { maxPriorityFeePerGas?: bigint | undefined } -export type PrepareTransactionParameters = FeeOverrides & { +export type PrepareTransactionRequestParameters = FeeOverrides & { account: ToAccountReturnType /** Ordered call phases. */ calls: AaCalls @@ -85,9 +90,9 @@ async function resolveSigningScope( * `eth_getTransactionCount`'s 2D channel-nonce extension), and EIP-1559 fees * from the client when not provided. */ -export async function prepareTransaction( +export async function prepareTransactionRequest( client: Client, - parameters: PrepareTransactionParameters, + parameters: PrepareTransactionRequestParameters, ): Promise { const { account, calls, accountChanges, payer, gas } = parameters @@ -173,7 +178,7 @@ export async function prepareTransaction( } } -export type SendCallsParameters = FeeOverrides & { +type SendTransactionBaseParameters = FeeOverrides & { account: ToAccountReturnType /** * Calls to execute. A flat list runs as a single atomic phase; pass a nested @@ -203,23 +208,18 @@ export type SendCallsParameters = FeeOverrides & { /** * Invoked with the fully-resolved transaction just before it is signed and * sent. Use it to thread the resolved `validBefore` (which may be auto-computed - * for nonce-free sends) into `waitForTransactionReceipt` without re-preparing: - * - * ```ts - * let validBefore: bigint | undefined - * const hash = await sendCalls(client, { - * ...params, - * onTransaction: (tx) => { validBefore = tx.validBefore }, - * }) - * await waitForTransactionReceipt(client, { hash, validBefore }) - * ``` + * for nonce-free sends) into `waitForTransactionReceipt` without re-preparing. */ onTransaction?: | ((transaction: TransactionSerializable8130) => void) | undefined } -function toPhases(calls: SendCallsParameters['calls']): AaCalls { +export type SendTransactionParameters = SendTransactionBaseParameters + +export type SendTransactionReturnType = Hex + +function toPhases(calls: SendTransactionBaseParameters['calls']): AaCalls { if (calls.length === 0) return [] // Already phased (array of arrays)? if (Array.isArray(calls[0])) return calls as AaCalls @@ -227,24 +227,16 @@ function toPhases(calls: SendCallsParameters['calls']): AaCalls { } /** - * Sends an EIP-8130 (`AA_TX_TYPE`) transaction for an account: prepares the - * transaction body, signs `sender_auth` (and `payer_auth` when sponsored), - * serializes, and submits via `eth_sendRawTransaction`. - * - * @example - * const hash = await sendCalls(client, { - * account, - * calls: [{ to, data }], - * gas: 200_000n, - * }) + * Prepares, signs, and serializes an EIP-8130 (`AA_TX_TYPE`) transaction. + * Shared by {@link sendTransaction} and {@link sendTransactionSync}. */ -export async function sendCalls( +async function prepareAndSign( client: Client, - parameters: SendCallsParameters, + parameters: SendTransactionBaseParameters, ): Promise { const { account, calls, payer, encodeExecute, onTransaction, ...rest } = parameters - const transaction = await prepareTransaction(client, { + const transaction = await prepareTransactionRequest(client, { ...rest, account, calls: encodeWalletCalls({ @@ -255,12 +247,67 @@ export async function sendCalls( payer, }) onTransaction?.(transaction) - const serializedTransaction = await account.signTransaction(transaction, { - payer, - }) + return account.signTransaction(transaction, { payer }) +} + +/** + * Sends an EIP-8130 (`AA_TX_TYPE`) transaction for an account: prepares the + * transaction body, signs `sender_auth` (and `payer_auth` when sponsored), + * serializes, and submits via `eth_sendRawTransaction`. + * + * @example + * const hash = await sendTransaction(client, { + * account, + * calls: [{ to, data }], + * gas: 200_000n, + * }) + */ +export async function sendTransaction( + client: Client, + parameters: SendTransactionParameters, +): Promise { + const serializedTransaction = await prepareAndSign(client, parameters) return getAction( client, sendRawTransaction, 'sendRawTransaction', )({ serializedTransaction }) } + +export type SendTransactionSyncParameters = SendTransactionBaseParameters & { + /** Whether to throw if the transaction reverted. @default true */ + throwOnReceiptRevert?: boolean | undefined + /** Timeout for the synchronous send (ms). */ + timeout?: number | undefined +} + +export type SendTransactionSyncReturnType = + NonNullable + +/** + * Sends an EIP-8130 (`AA_TX_TYPE`) transaction and waits for its receipt in a + * single round-trip via `eth_sendRawTransactionSync` (EIP-7966). Returns the + * receipt with the EIP-8130 fields (`payer`, `phaseStatuses`, `metadata`) + * attached. Requires a node that supports synchronous sends. + * + * @example + * const receipt = await sendTransactionSync(client, { + * account, + * calls: [{ to, data }], + * gas: 200_000n, + * }) + * console.log(receipt.eip8130.phaseStatuses) // ['0x1'] + */ +export async function sendTransactionSync( + client: Client, + parameters: SendTransactionSyncParameters, +): Promise { + const { throwOnReceiptRevert, timeout, ...rest } = parameters + const serializedTransaction = await prepareAndSign(client, rest) + const receipt = await getAction( + client, + sendRawTransactionSync, + 'sendRawTransactionSync', + )({ serializedTransaction, throwOnReceiptRevert, timeout }) + return { ...receipt, eip8130: parseReceiptFields(receipt as never) } as never +} diff --git a/src/eip8130/actions/waitForTransactionReceipt.ts b/src/eip8130/actions/waitForTransactionReceipt.ts index 8236fcb1f7..4189117dae 100644 --- a/src/eip8130/actions/waitForTransactionReceipt.ts +++ b/src/eip8130/actions/waitForTransactionReceipt.ts @@ -17,8 +17,8 @@ export type WaitForTransactionReceiptParameters = { * `validBefore` (unix **milliseconds**) of the transaction being awaited. When * provided, the wait fails fast with a {@link TransactionExpiredError} once the * chain's latest block timestamp passes it. This is the reliable path: it is - * the value the transaction was signed with (thread it out of `sendCalls` via - * `onTransaction`, or read it off `prepareTransaction`'s result). + * the value the transaction was signed with (thread it out of `sendTransaction` + * via `onTransaction`, or read it off `prepareTransactionRequest`'s result). * * If omitted, the wait *opportunistically* tries to read `validBefore` off the * still-pending transaction — but nodes are not obligated to return a pending diff --git a/src/eip8130/devx.test.ts b/src/eip8130/devx.test.ts index fbcae60fbd..ce87c90ae8 100644 --- a/src/eip8130/devx.test.ts +++ b/src/eip8130/devx.test.ts @@ -11,7 +11,7 @@ import { toAccount, toDelegateSigner, } from './accounts/toAccount.js' -import { sendCalls } from './actions/sendCalls.js' +import { sendTransaction } from './actions/sendTransaction.js' import { actorScope, canonicalAuthenticators, @@ -228,7 +228,7 @@ describe('toAccount', () => { }) }) -describe('sendCalls', () => { +describe('sendTransaction', () => { let sent: Hex | undefined const client = createClient({ chain: mainnet, @@ -255,7 +255,7 @@ describe('sendCalls', () => { }) test('builds, signs, serializes and submits an AA_TX_TYPE tx', async () => { - const hash = await sendCalls(client, { + const hash = await sendTransaction(client, { account, calls: [ { to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }, @@ -285,7 +285,7 @@ describe('sendCalls', () => { }) test('behavior: dataSuffix is written to metadata', async () => { - await sendCalls(client, { + await sendTransaction(client, { account, calls: [{ to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }], accountChanges: [account.create()], @@ -320,7 +320,7 @@ describe('sendCalls', () => { }), }) - await sendCalls(suffixedClient, { + await sendTransaction(suffixedClient, { account, calls: [{ to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', data: '0x' }], accountChanges: [account.create()], diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index 3562e61f85..fc38116181 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -86,11 +86,15 @@ export { isLocked, } from './actions/isLocked.js' export { - type PrepareTransactionParameters, - prepareTransaction, - type SendCallsParameters, - sendCalls, -} from './actions/sendCalls.js' + type PrepareTransactionRequestParameters, + prepareTransactionRequest, + type SendTransactionParameters, + type SendTransactionReturnType, + type SendTransactionSyncParameters, + type SendTransactionSyncReturnType, + sendTransaction, + sendTransactionSync, +} from './actions/sendTransaction.js' export { type WaitForTransactionReceiptParameters, type WaitForTransactionReceiptReturnType, diff --git a/src/eip8130/lock.ts b/src/eip8130/lock.ts index 8807749625..f6a486bb0f 100644 --- a/src/eip8130/lock.ts +++ b/src/eip8130/lock.ts @@ -32,7 +32,7 @@ import type { AaLock, AaUnlock } from './types/transaction.js' * signAccountChanges, * encodeApplySignedAccountChangesData, * getConfigSequence, - * sendCalls, + * sendTransaction, * } from 'viem/eip8130' * * const { local } = await getConfigSequence(client, { account }) @@ -45,7 +45,7 @@ import type { AaLock, AaUnlock } from './types/transaction.js' * changes: [lockChange({ unlockDelay: 3600 })], * }) * const data = encodeApplySignedAccountChangesData({ account, ...entry }) - * await sendCalls(client, { account, calls: [{ to: keystoreAddress, data }], gas }) + * await sendTransaction(client, { account, calls: [{ to: keystoreAddress, data }], gas }) * ``` */ diff --git a/src/eip8130/nonce.test.ts b/src/eip8130/nonce.test.ts index 7f3e3a68d7..b81c9900ef 100644 --- a/src/eip8130/nonce.test.ts +++ b/src/eip8130/nonce.test.ts @@ -6,7 +6,7 @@ import { custom } from '../clients/transports/custom.js' import type { Hex } from '../types/misc.js' import { keccak256 } from '../utils/hash/keccak256.js' import { toAccount } from './accounts/toAccount.js' -import { sendCalls } from './actions/sendCalls.js' +import { sendTransaction } from './actions/sendTransaction.js' import { nonceKeyMax } from './constants.js' import { key } from './keys.js' import { nonce } from './nonce.js' @@ -78,7 +78,7 @@ describe('nonce builders', () => { }) }) -describe('sendCalls nonce integration', () => { +describe('sendTransaction nonce integration', () => { function makeClient() { const methods: string[] = [] let sent: Hex | undefined @@ -131,7 +131,7 @@ describe('sendCalls nonce integration', () => { test('nonceless: no nonce read, tx carries NONCE_KEY_MAX + validBefore', async () => { const ctx = makeClient() - await sendCalls(ctx.client, { + await sendTransaction(ctx.client, { account, calls, ...fees, @@ -147,7 +147,7 @@ describe('sendCalls nonce integration', () => { test('channel: reads the sequence with the 2D nonce_key param', async () => { const ctx = makeClient() - await sendCalls(ctx.client, { + await sendTransaction(ctx.client, { account, calls, ...fees, diff --git a/src/eip8130/nonce.ts b/src/eip8130/nonce.ts index 476847f690..73c5884914 100644 --- a/src/eip8130/nonce.ts +++ b/src/eip8130/nonce.ts @@ -7,7 +7,7 @@ import { isNoncelessOnly } from './keys.js' /** * A resolved EIP-8130 nonce selection: the channel key and (for nonce-free * mode) the fixed sequence and required expiry. Spread the result directly into - * {@link sendCalls} / {@link prepareTransaction} parameters. + * {@link sendTransaction} / {@link prepareTransactionRequest} parameters. */ export type Nonce = { /** 2D nonce channel selector (`uint256`). `0` = standard sequential ordering. */ @@ -29,7 +29,7 @@ export type Nonce = { * Builders for EIP-8130 nonce selection. EIP-8130 accounts support three * distinct nonce strategies; these helpers produce the correct * `nonceKey` / `nonceSequence` / `expiry` fields for each, ready to spread into - * {@link sendCalls} / {@link prepareTransaction}. + * {@link sendTransaction} / {@link prepareTransactionRequest}. * * - {@link nonce.sequential} — the classic single-file nonce (channel `0`). * - {@link nonce.channel} / {@link nonce.randomChannel} — independent 2D nonce @@ -40,17 +40,17 @@ export type Nonce = { * * @example * ```ts - * import { nonce, sendCalls } from 'viem/eip8130' + * import { nonce, sendTransaction } from 'viem/eip8130' * * // Two independent channels → can be mined in either order. - * await sendCalls(client, { account, calls: a, gas, ...nonce.channel(1n) }) - * await sendCalls(client, { account, calls: b, gas, ...nonce.channel(2n) }) + * await sendTransaction(client, { account, calls: a, gas, ...nonce.channel(1n) }) + * await sendTransaction(client, { account, calls: b, gas, ...nonce.channel(2n) }) * * // Fire-and-forget parallel txs on random channels. - * await sendCalls(client, { account, calls, gas, ...nonce.randomChannel() }) + * await sendTransaction(client, { account, calls, gas, ...nonce.randomChannel() }) * * // Nonce-free: valid for the next 10 minutes, no sequencing. - * await sendCalls(client, { account, calls, gas, ...nonce.nonceless({ expiresIn: 600 }) }) + * await sendTransaction(client, { account, calls, gas, ...nonce.nonceless({ expiresIn: 600 }) }) * ``` */ export const nonce = { diff --git a/src/eip8130/permissions.ts b/src/eip8130/permissions.ts index eec1d99dd4..b4322f62ed 100644 --- a/src/eip8130/permissions.ts +++ b/src/eip8130/permissions.ts @@ -572,13 +572,13 @@ export type RoutePermissionedCallsReturnType = export type RoutePermissionedCallsErrorType = ParsePermissionsContextErrorType /** - * The `sendCalls`-level routing step: decodes a `permissionsContext` and wraps + * The `sendTransaction`-level routing step: decodes a `permissionsContext` and wraps * each user action so it lands on the policy manager under the granted key — * `session.executeCall` for a session key (dispatched as the account) or * `session.executeForCall` for an external pull actor. * * @example - * import { routePermissionedCalls, sendCalls, toAccount, actorScope } from 'viem/eip8130' + * import { routePermissionedCalls, sendTransaction, toAccount, actorScope } from 'viem/eip8130' * * const { account, actor, calls } = routePermissionedCalls({ * context: permissionsContext, // from the grant @@ -593,7 +593,7 @@ export type RoutePermissionedCallsErrorType = ParsePermissionsContextErrorType * actorId: actor.actorId, * scope: actorScope.policy, * }) - * await sendCalls(client, { account: handle, calls, gas }) + * await sendTransaction(client, { account: handle, calls, gas }) */ export function routePermissionedCalls( parameters: RoutePermissionedCallsParameters, diff --git a/src/eip8130/subAccounts.ts b/src/eip8130/subAccounts.ts index cdd32d766c..4590fbcfe7 100644 --- a/src/eip8130/subAccounts.ts +++ b/src/eip8130/subAccounts.ts @@ -119,7 +119,7 @@ export type FulfillAddSubAccountErrorType = BaseError * `createChange` in the first transaction's `accountChanges`. * * @example - * import { fulfillAddSubAccount, sendCalls } from 'viem/eip8130' + * import { fulfillAddSubAccount, sendTransaction } from 'viem/eip8130' * * const sub = fulfillAddSubAccount({ * parent: parent.address, @@ -132,7 +132,7 @@ export type FulfillAddSubAccountErrorType = BaseError * sub.response.address * * // deploy + first call in one shot (parent drives it as a delegate) - * await sendCalls(client, { + * await sendTransaction(client, { * account: sub, * accountChanges: [sub.createChange], * calls: [{ to: recipient, value: 1n }], diff --git a/src/eip8130/types/transaction.ts b/src/eip8130/types/transaction.ts index b9649df302..b9a60e13e8 100644 --- a/src/eip8130/types/transaction.ts +++ b/src/eip8130/types/transaction.ts @@ -6,7 +6,7 @@ import type { Hex } from '../../types/misc.js' * * On the wire, EIP-8130 calls carry no ETH value — each call executes with * `msg.value == 0`. A `value` MAY be supplied as ERC-5792-style intent: actions - * that build the wire (e.g. {@link sendCalls}) realize any non-zero `value` + * that build the wire (e.g. {@link sendTransaction}) realize any non-zero `value` * by routing the phase through the account's wallet bytecode (`executeBatch`), * collapsing it back into a value-less `[to, data]`. A call whose `value` is `0` * (or omitted) is encoded directly as `[to, data]`. A non-zero `value` that @@ -234,7 +234,7 @@ export type TransactionSerializable8130 = { * is authenticated by both the sender and (when present) the payer. Omit or * `'0x'` for none. * - * High-level helpers (`prepareTransaction` / `sendCalls`) populate + * High-level helpers (`prepareTransactionRequest` / `sendTransaction`) populate * this from `dataSuffix` / `client.dataSuffix` (EIP-8130 has no calldata * suffix; attribution lands here instead). */ diff --git a/src/eip8130/utils/assertTransaction.ts b/src/eip8130/utils/assertTransaction.ts index 8cdafc8d6b..7b48a10e60 100644 --- a/src/eip8130/utils/assertTransaction.ts +++ b/src/eip8130/utils/assertTransaction.ts @@ -36,7 +36,7 @@ export function assertTransaction( for (const call of phase) if (call.value && call.value !== 0n) throw new BaseError( - 'EIP-8130 calls cannot carry `value` on the wire. Route value-bearing calls through the account wallet (e.g. `encodeWalletCalls` / `sendCalls`).', + 'EIP-8130 calls cannot carry `value` on the wire. Route value-bearing calls through the account wallet (e.g. `encodeWalletCalls` / `sendTransaction`).', ) // Nonce-free mode (`NONCE_KEY_MAX`): sequence must be 0 and validBefore non-zero. diff --git a/src/eip8168/actions/sendSponsoredCalls.ts b/src/eip8168/actions/sendSponsoredCalls.ts index 6c30e15f3f..e337a8b310 100644 --- a/src/eip8168/actions/sendSponsoredCalls.ts +++ b/src/eip8168/actions/sendSponsoredCalls.ts @@ -2,7 +2,7 @@ import type { Address } from 'abitype' import type { Client } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' import type { ToAccountReturnType } from '../../eip8130/accounts/toAccount.js' -import { prepareTransaction } from '../../eip8130/actions/sendCalls.js' +import { prepareTransactionRequest } from '../../eip8130/actions/sendTransaction.js' import { nonceFreeMaxExpiryWindow } from '../../eip8130/constants.js' import { isNoncelessOnly } from '../../eip8130/keys.js' import type { @@ -253,7 +253,7 @@ export async function sendSponsoredCalls( // Prepared once so the nonce stays stable across re-signs; only the phase-0 // transfer (on `requote`) and `gas` (on `minGasLimit`) change between attempts. - const transaction = await prepareTransaction(client, { + const transaction = await prepareTransactionRequest(client, { account, calls: built.calls, accountChanges, From 68f6db64a13151e843cbbbfa91f2550e31dd78a8 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 26 Aug 2026 19:36:13 -0400 Subject: [PATCH 81/96] feat(eip8168): payment as a capability of the fill (prepareTransactionRequest/sendTransaction) Folds the ERC-8168 payer surface behind the native fill -> send verbs, so "choosing terms" is a component of filling a transaction rather than a standalone endpoint: - add `prepareTransactionRequest`: fills the tx and returns solicited offers as `capabilities.paymentOptions` (via `capabilities.paymasterService`) - add `sendTransaction`: submits the chosen `capabilities.paymentOption`, delegating to the tested `sendSponsoredCalls` engine (offer selection, phase-0 build, re-quote/re-sign) unchanged - rename the low-level `payer_*` RPC types to `Payer*` (SendTransaction*/SignTransaction* -> Payer*) to free the native names and clarify they are the payer RPC layer `sendSponsoredCalls` stays as the reusable engine. Adds capability-face unit tests; existing payer tests unchanged. --- src/eip8168/actions/sendSponsoredCalls.ts | 8 +- src/eip8168/actions/sendTransaction.test.ts | 229 +++++++++++++++++ src/eip8168/actions/sendTransaction.ts | 262 ++++++++++++++++++++ src/eip8168/aggregate.ts | 16 +- src/eip8168/client.ts | 28 +-- src/eip8168/index.ts | 19 +- src/eip8168/types.ts | 12 +- 7 files changed, 540 insertions(+), 34 deletions(-) create mode 100644 src/eip8168/actions/sendTransaction.test.ts create mode 100644 src/eip8168/actions/sendTransaction.ts diff --git a/src/eip8168/actions/sendSponsoredCalls.ts b/src/eip8168/actions/sendSponsoredCalls.ts index e337a8b310..2be8319d7d 100644 --- a/src/eip8168/actions/sendSponsoredCalls.ts +++ b/src/eip8168/actions/sendSponsoredCalls.ts @@ -20,8 +20,8 @@ import type { PayerClient } from '../client.js' import type { GetTermsReturnType, PayerRejectedData, - SendTransactionReturnType, - SignTransactionReturnType, + PayerSendTransactionReturnType, + PayerSignTransactionReturnType, } from '../types.js' import { buildSponsoredCalls, @@ -143,8 +143,8 @@ export type SendSponsoredCallsParameters = { } export type SendSponsoredCallsReturnType = - | SendTransactionReturnType - | SignTransactionReturnType + | PayerSendTransactionReturnType + | PayerSignTransactionReturnType /** * End-to-end ERC-8168 sponsored-transaction flow: diff --git a/src/eip8168/actions/sendTransaction.test.ts b/src/eip8168/actions/sendTransaction.test.ts new file mode 100644 index 0000000000..966e9445ea --- /dev/null +++ b/src/eip8168/actions/sendTransaction.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { mainnet } from '../../chains/index.js' +import { createClient } from '../../clients/createClient.js' +import { custom } from '../../clients/transports/custom.js' +import { toAccount } from '../../eip8130/accounts/toAccount.js' +import { key } from '../../eip8130/keys.js' +import { parseTransaction } from '../../eip8130/utils/parseTransaction.js' +import { erc1167Bytecode } from '../../eip8130/utils/proxy.js' +import { hexToBigInt } from '../../utils/encoding/fromHex.js' +import { keccak256 } from '../../utils/hash/keccak256.js' +import { createPayerClient } from '../client.js' +import type { GetTermsReturnType } from '../types.js' +import { + prepareTransactionRequest, + sendTransaction, +} from './sendTransaction.js' + +const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const account = toAccount({ + signer: owner, + userSalt: `0x${'01'.padStart(64, '0')}`, + code: erc1167Bytecode('0x00000000000000000000000000000000000000Ec'), + initialActors: [key.k1(owner.address)], +}) +const PAYER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' as const +const FEE_RECIPIENT = '0x90F79bf6EB2c4f870365E785982E1f101E93b906' as const +const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const +const userCalls = [ + { + to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const, + data: '0x' as const, + }, +] + +const gasEstimate = { + gasLimit: '0xC350', + maxFeePerGas: '0x59682F00', + maxPriorityFeePerGas: '0x59682F00', +} as const + +const sponsoredTerms: GetTermsReturnType = { + gasEstimate, + options: [ + { + kind: 'sponsored', + payer: PAYER, + ttl: 300, + conditions: { maxExpiry: 60 }, + provider: { name: 'My App' }, + }, + ], +} + +const tokenTerms: GetTermsReturnType = { + gasEstimate, + options: [ + { + kind: 'token', + payer: PAYER, + methods: ['payer_signTransaction'], + ttl: 60, + conditions: { maxExpiry: 60 }, + tokens: [ + { + token: USDC, + symbol: 'USDC', + decimals: 6, + paymentAmount: '0x30D40', + feeRecipient: FEE_RECIPIENT, + rate: { numerator: '0x7A308480', denominator: '0xDE0B6B3A7640000' }, + }, + ], + }, + ], +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) +}) +afterEach(() => { + vi.useRealTimers() +}) + +function makeClient() { + return createClient({ + chain: mainnet, + transport: custom({ + async request({ method }: { method: string }) { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_call') return `0x${'0'.repeat(192)}` + if (method === 'eth_getTransactionCount') return '0x0' + throw new Error(`unexpected chain RPC: ${method}`) + }, + }), + }) +} + +describe('prepareTransactionRequest', () => { + test('surfaces payment offers as a component of the fill', async () => { + const payer = createPayerClient({ + transport: custom({ + async request({ method }: { method: string }) { + if (method === 'payer_getTerms') return sponsoredTerms + throw new Error(`unexpected ${method}`) + }, + }), + }) + + const { request, capabilities } = await prepareTransactionRequest( + makeClient(), + { + account, + payerClient: payer, + calls: userCalls, + capabilities: { paymasterService: {} }, + nonceSequence: 0n, + }, + ) + + // Payment terms are a component of the fill. + expect(capabilities.paymentOptions).toHaveLength(1) + expect(capabilities.paymentOptions[0].kind).toBe('sponsored') + expect(capabilities.gasEstimate).toEqual(gasEstimate) + + // The base fill is sized from the payer's recommended gas. + expect(request.gas).toBe(hexToBigInt(gasEstimate.gasLimit)) + expect(request.maxFeePerGas).toBe(hexToBigInt(gasEstimate.maxFeePerGas)) + expect(request.from).toBe(account.address) + }) + + test('forwards preferredTokens / context to payer_getTerms', async () => { + const seen: { params: any }[] = [] + const payer = createPayerClient({ + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'payer_getTerms') { + seen.push({ params }) + return tokenTerms + } + throw new Error(`unexpected ${method}`) + }, + }), + }) + + await prepareTransactionRequest(makeClient(), { + account, + payerClient: payer, + calls: userCalls, + capabilities: { + paymasterService: { + preferredTokens: [USDC], + context: { policyId: 'x' }, + }, + }, + nonceSequence: 0n, + }) + expect(seen[0].params[0].preferredTokens).toEqual([USDC]) + expect(seen[0].params[0].context).toEqual({ policyId: 'x' }) + }) +}) + +describe('sendTransaction', () => { + test('submits with the chosen sponsored offer (single phase)', async () => { + let relayed: `0x${string}` | undefined + const payer = createPayerClient({ + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'payer_sendTransaction') { + relayed = params[0].signedTransaction + return { transactionHash: keccak256(params[0].signedTransaction) } + } + throw new Error(`unexpected ${method}`) + }, + }), + }) + + const result = await sendTransaction(makeClient(), { + account, + payerClient: payer, + calls: userCalls, + capabilities: { + paymentOption: sponsoredTerms.options[0], + gasEstimate, + }, + nonceSequence: 0n, + }) + expect(result).toHaveProperty('transactionHash') + + const parsed = parseTransaction(relayed!) + expect(parsed.payer?.toLowerCase()).toBe(PAYER.toLowerCase()) + expect(parsed.calls).toHaveLength(1) + expect(parsed.gas).toBe(hexToBigInt(gasEstimate.gasLimit)) + }) + + test('token offer: phase-0 transfer present; co-sign mode returns signed tx', async () => { + const payer = createPayerClient({ + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'payer_signTransaction') + return { signedTransaction: params[0].signedTransaction } + throw new Error(`unexpected ${method}`) + }, + }), + }) + + const result = await sendTransaction(makeClient(), { + account, + payerClient: payer, + calls: userCalls, + mode: 'sign', + token: USDC, + capabilities: { + paymentOption: tokenTerms.options[0], + gasEstimate, + }, + nonceSequence: 0n, + }) + expect(result).toHaveProperty('signedTransaction') + const parsed = parseTransaction( + (result as { signedTransaction: `0x${string}` }).signedTransaction, + ) + expect(parsed.calls).toHaveLength(2) + expect(parsed.calls?.[0]?.[0]?.to).toBe(USDC.toLowerCase()) + }) +}) diff --git a/src/eip8168/actions/sendTransaction.ts b/src/eip8168/actions/sendTransaction.ts new file mode 100644 index 0000000000..5d59da4fde --- /dev/null +++ b/src/eip8168/actions/sendTransaction.ts @@ -0,0 +1,262 @@ +import type { Address } from 'abitype' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { ToAccountReturnType } from '../../eip8130/accounts/toAccount.js' +import { prepareTransactionRequest as prepareEip8130Request } from '../../eip8130/actions/sendTransaction.js' +import type { + AaAccountChange, + AaCall, + TransactionSerializable8130, +} from '../../eip8130/types/transaction.js' +import { BaseError } from '../../errors/base.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import { hexToBigInt } from '../../utils/encoding/fromHex.js' +import { numberToHex } from '../../utils/encoding/toHex.js' +import type { PayerClient } from '../client.js' +import type { + GetTermsReturnType, + PayerGasEstimate, + PaymentOption, +} from '../types.js' +import { + type ResignRequest, + type SendSponsoredCallsReturnType, + sendSponsoredCalls, +} from './sendSponsoredCalls.js' + +/** + * ERC-8168 payment as a **capability of the fill**, not a separate "choose + * terms" endpoint. The flow mirrors the native fill → send pattern: + * + * 1. {@link prepareTransactionRequest} fills the transaction and returns the + * solicited payment offers (`capabilities.paymentOptions`) as a *component* + * of that fill. + * 2. The wallet picks one and threads it back into {@link sendTransaction} via + * `capabilities.paymentOption`. + * + * This folds the `payer_*` RPC surface behind the same verbs a chain-brokered + * payer would ride on native `eth_` methods, so app code speaks one shape + * whether the payer is the wallet's own service, a chain builder, or a + * third-party sponsor. The tested {@link sendSponsoredCalls} engine (offer + * selection, phase-0 construction, re-quote/re-sign) is reused underneath. + */ + +/** Request capability: solicit payment offers from a payer service. */ +export type PaymasterServiceCapability = { + /** + * Opaque app context (e.g. `policyId`) forwarded to `payer_getTerms`. + */ + context?: Record | undefined + /** Prefer these payment tokens when the payer offers token payment. */ + preferredTokens?: readonly Address[] | undefined + /** ISO-4217 code the wallet would like `fiatRate`s quoted in (e.g. `"USD"`). */ + fiatCurrency?: string | undefined +} + +export type PrepareTransactionRequestParameters = { + /** The sending account (drives actor/nonce resolution). */ + account: ToAccountReturnType + /** Payer service client (single service or an aggregate). */ + payerClient: PayerClient + /** The user's intended calls (run in the final phase). */ + calls: readonly AaCall[] + /** Account changes applied atomically before the calls (e.g. sponsored deploy). */ + accountChanges?: readonly AaAccountChange[] | undefined + /** + * Fill capabilities. `paymasterService` opts the fill into soliciting payment + * offers; the resulting offers come back on the return's + * `capabilities.paymentOptions`. + */ + capabilities?: + | { paymasterService?: PaymasterServiceCapability | undefined } + | undefined + /** + * Gas budget. Falls back to the terms' top-level `gasEstimate.gasLimit`. The + * fill throws if neither is available. + */ + gas?: bigint | undefined + nonceKey?: bigint | undefined + nonceSequence?: bigint | undefined +} + +/** Return capability: the payment offers surfaced by the fill. */ +export type PrepareTransactionCapabilities = { + /** Payment offers for this intent, best-first (may be empty). */ + paymentOptions: readonly PaymentOption[] + /** Recommended gas params shared by every offer (used to size the tx). */ + gasEstimate?: PayerGasEstimate | undefined + /** ISO-4217 code every offer's `fiatRate` is quoted in. */ + fiatCurrency?: string | undefined +} + +export type PrepareTransactionRequestReturnType = { + /** The filled EIP-8130 transaction (base, self-pay shape). */ + request: TransactionSerializable8130 + /** Payment terms surfaced as a component of the fill. */ + capabilities: PrepareTransactionCapabilities +} + +/** + * Fills an EIP-8130 transaction and, when `capabilities.paymasterService` is + * set, solicits payment offers (`payer_getTerms`) and returns them as + * `capabilities.paymentOptions` — terms as a *component* of the fill. Pick an + * offer and pass it to {@link sendTransaction} via `capabilities.paymentOption`. + * + * @example + * import { createPayerClient, prepareTransactionRequest, sendTransaction } from 'viem/eip8168' + * + * const payerClient = createPayerClient({ url: 'https://payer.example.com/v1' }) + * const { request, capabilities } = await prepareTransactionRequest(client, { + * account, + * payerClient, + * calls: [{ to, data }], + * capabilities: { paymasterService: { preferredTokens: [usdc] } }, + * }) + * const { transactionHash } = await sendTransaction(client, { + * account, + * payerClient, + * calls: [{ to, data }], + * capabilities: { paymentOption: capabilities.paymentOptions[0], gasEstimate: capabilities.gasEstimate }, + * }) + */ +export async function prepareTransactionRequest( + client: Client, + parameters: PrepareTransactionRequestParameters, +): Promise { + const { account, payerClient, calls, accountChanges, capabilities } = + parameters + + const chainId = client.chain?.id + if (!chainId) + throw new BaseError('`client` must be configured with a `chain`.') + + const paymasterService = capabilities?.paymasterService + const terms: GetTermsReturnType = await payerClient.getTerms({ + chainId: numberToHex(chainId), + from: account.address, + calls: calls.map((call) => ({ to: call.to, data: call.data ?? '0x' })), + ...(paymasterService?.preferredTokens + ? { preferredTokens: paymasterService.preferredTokens } + : {}), + ...(paymasterService?.fiatCurrency + ? { fiatCurrency: paymasterService.fiatCurrency } + : {}), + ...(paymasterService?.context ? { context: paymasterService.context } : {}), + }) + + const gas = + parameters.gas ?? + (terms.gasEstimate ? hexToBigInt(terms.gasEstimate.gasLimit) : undefined) + if (gas === undefined) + throw new BaseError( + 'Unable to determine `gas`: the payer returned no top-level `gasEstimate.gasLimit` and no `gas` override was provided.', + ) + + const request = await prepareEip8130Request(client, { + account, + calls: [calls], + accountChanges, + gas, + ...(terms.gasEstimate + ? { + maxFeePerGas: hexToBigInt(terms.gasEstimate.maxFeePerGas), + maxPriorityFeePerGas: hexToBigInt( + terms.gasEstimate.maxPriorityFeePerGas, + ), + } + : {}), + nonceKey: parameters.nonceKey, + nonceSequence: parameters.nonceSequence, + }) + + return { + request, + capabilities: { + paymentOptions: terms.options, + gasEstimate: terms.gasEstimate, + fiatCurrency: terms.fiatCurrency, + }, + } +} + +/** Request capability: the payment offer chosen from the fill. */ +export type SendTransactionCapabilities = { + /** The offer selected from `prepareTransactionRequest`'s `paymentOptions`. */ + paymentOption: PaymentOption + /** The fill's recommended gas params (thread through from the fill). */ + gasEstimate?: PayerGasEstimate | undefined + /** The fill's `fiatCurrency` (thread through from the fill). */ + fiatCurrency?: string | undefined +} + +export type SendTransactionParameters = { + /** The sending account (signs `sender_auth`). */ + account: ToAccountReturnType + /** Payer service client (ERC-8168). */ + payerClient: PayerClient + /** The user's intended calls (run in the final phase). */ + calls: readonly AaCall[] + /** Account changes applied atomically before the calls. */ + accountChanges?: readonly AaAccountChange[] | undefined + /** Send capabilities: the chosen payment offer (see {@link SendTransactionCapabilities}). */ + capabilities: SendTransactionCapabilities + /** + * `"send"` (default) asks the payer to co-sign and submit; `"sign"` asks the + * payer to co-sign and return the transaction for the wallet to submit. + */ + mode?: 'send' | 'sign' | undefined + /** + * For a token offer with multiple accepted tokens, which token to pay in. + * Ignored for a sponsored offer. + */ + token?: `0x${string}` | undefined + /** Opaque app context forwarded to `payer_*` calls. */ + context?: Record | undefined + /** Override the transaction's `validBefore` (absolute unix ms). */ + validBefore?: bigint | undefined + /** Override gas (defaults to the fill's `gasEstimate.gasLimit`). */ + gas?: bigint | undefined + maxFeePerGas?: bigint | undefined + maxPriorityFeePerGas?: bigint | undefined + nonceKey?: bigint | undefined + nonceSequence?: bigint | undefined + /** Upper bound on re-signs after a recoverable payer rejection. Default `2`. */ + retries?: number | undefined + /** Gate for every re-sign (see {@link ResignRequest}). No callback ⇒ no retry. */ + confirmRetry?: (request: ResignRequest) => boolean | Promise + /** Invoked with the resolved transaction just before each submit attempt. */ + onTransaction?: + | ((transaction: TransactionSerializable8130) => void) + | undefined +} + +export type SendTransactionReturnType = SendSponsoredCallsReturnType + +/** + * Submits an EIP-8130 transaction paid for by the chosen payment offer + * (`capabilities.paymentOption`, from {@link prepareTransactionRequest}). Folds + * `payer_sendTransaction` / `payer_signTransaction` behind the native send verb; + * offer selection, phase-0 construction, and re-quote/re-sign are handled by the + * {@link sendSponsoredCalls} engine. + */ +export async function sendTransaction( + client: Client, + parameters: SendTransactionParameters, +): Promise { + const { capabilities, ...rest } = parameters + + // A single-offer `terms` reuses the sponsored-calls engine's selection, + // phase-0 build, and re-quote/re-sign machinery on the pre-chosen offer. + const terms: GetTermsReturnType = { + options: [capabilities.paymentOption], + ...(capabilities.gasEstimate + ? { gasEstimate: capabilities.gasEstimate } + : {}), + ...(capabilities.fiatCurrency + ? { fiatCurrency: capabilities.fiatCurrency } + : {}), + } + + return sendSponsoredCalls(client, { ...rest, terms }) +} diff --git a/src/eip8168/aggregate.ts b/src/eip8168/aggregate.ts index 07ca6bd48f..6d07e092ee 100644 --- a/src/eip8168/aggregate.ts +++ b/src/eip8168/aggregate.ts @@ -8,11 +8,11 @@ import type { GetTermsParameters, GetTermsReturnType, PayerBalance, + PayerSendTransactionParameters, + PayerSendTransactionReturnType, + PayerSignTransactionParameters, + PayerSignTransactionReturnType, PaymentOption, - SendTransactionParameters, - SendTransactionReturnType, - SignTransactionParameters, - SignTransactionReturnType, } from './types.js' import { isSelectableOffer } from './utils/buildSponsoredCalls.js' @@ -146,14 +146,14 @@ export function createAggregatePayerClient( }, async sendTransaction( - params: SendTransactionParameters, - ): Promise { + params: PayerSendTransactionParameters, + ): Promise { return route(params.signedTransaction).sendTransaction(params) }, async signTransaction( - params: SignTransactionParameters, - ): Promise { + params: PayerSignTransactionParameters, + ): Promise { return route(params.signedTransaction).signTransaction(params) }, diff --git a/src/eip8168/client.ts b/src/eip8168/client.ts index 32d6994eb6..5d46125210 100644 --- a/src/eip8168/client.ts +++ b/src/eip8168/client.ts @@ -9,10 +9,10 @@ import type { GetSponsorshipBalanceReturnType, GetTermsParameters, GetTermsReturnType, - SendTransactionParameters, - SendTransactionReturnType, - SignTransactionParameters, - SignTransactionReturnType, + PayerSendTransactionParameters, + PayerSendTransactionReturnType, + PayerSignTransactionParameters, + PayerSignTransactionReturnType, } from './types.js' /** JSON-RPC schema for the ERC-8168 `payer_*` methods. */ @@ -24,13 +24,13 @@ export type PayerRpcSchema = [ }, { Method: 'payer_sendTransaction' - Parameters: [SendTransactionParameters] - ReturnType: SendTransactionReturnType + Parameters: [PayerSendTransactionParameters] + ReturnType: PayerSendTransactionReturnType }, { Method: 'payer_signTransaction' - Parameters: [SignTransactionParameters] - ReturnType: SignTransactionReturnType + Parameters: [PayerSignTransactionParameters] + ReturnType: PayerSignTransactionReturnType }, { Method: 'payer_getSponsorshipBalance' @@ -60,16 +60,16 @@ export type PayerClient = { * hash). REQUIRED on every payer. */ sendTransaction( - parameters: SendTransactionParameters, - ): Promise + parameters: PayerSendTransactionParameters, + ): Promise /** * Co-sign a sender-signed EIP-8130 transaction and return the bytes without * submitting. OPTIONAL — only call when the picked offer advertises it via * `methods`. Returns JSON-RPC `-32601` when unimplemented. */ signTransaction( - parameters: SignTransactionParameters, - ): Promise + parameters: PayerSignTransactionParameters, + ): Promise /** * Standing, intent-free balances (sponsorship allowance / prepaid credit). * OPTIONAL. @@ -102,13 +102,13 @@ function payerClientFromRequest(request: PayerRequestFn): PayerClient { return request({ method: 'payer_sendTransaction', params: [params], - }) as Promise + }) as Promise }, signTransaction(params) { return request({ method: 'payer_signTransaction', params: [params], - }) as Promise + }) as Promise }, getSponsorshipBalance(params) { return request({ diff --git a/src/eip8168/index.ts b/src/eip8168/index.ts index 14e003abe8..cc0be7da8b 100644 --- a/src/eip8168/index.ts +++ b/src/eip8168/index.ts @@ -5,6 +5,17 @@ export { type SendSponsoredCallsReturnType, sendSponsoredCalls, } from './actions/sendSponsoredCalls.js' +export { + type PaymasterServiceCapability, + type PrepareTransactionCapabilities, + type PrepareTransactionRequestParameters, + type PrepareTransactionRequestReturnType, + prepareTransactionRequest, + type SendTransactionCapabilities, + type SendTransactionParameters, + type SendTransactionReturnType, + sendTransaction, +} from './actions/sendTransaction.js' export { type CreateAggregatePayerClientParameters, createAggregatePayerClient, @@ -43,12 +54,12 @@ export type { PayerRejectedData, PayerRequote, PayerRpcCall, + PayerSendTransactionParameters, + PayerSendTransactionReturnType, + PayerSignTransactionParameters, + PayerSignTransactionReturnType, PaymentOption, RefundPolicy, - SendTransactionParameters, - SendTransactionReturnType, - SignTransactionParameters, - SignTransactionReturnType, SponsoredOffer, SponsoredOfferDeclined, SponsoredOfferSelectable, diff --git a/src/eip8168/types.ts b/src/eip8168/types.ts index 60f81850f0..5e375a29c5 100644 --- a/src/eip8168/types.ts +++ b/src/eip8168/types.ts @@ -277,22 +277,26 @@ export type TokenCharged = { estimatedRefund?: Hex | undefined } -export type SendTransactionParameters = { +/** Params for the `payer_sendTransaction` RPC method (payer co-signs + submits). */ +export type PayerSendTransactionParameters = { signedTransaction: Hex context?: Record | undefined } -export type SendTransactionReturnType = { +/** Return of the `payer_sendTransaction` RPC method. */ +export type PayerSendTransactionReturnType = { transactionHash: Hex tokenCharged?: TokenCharged | undefined } -export type SignTransactionParameters = { +/** Params for the `payer_signTransaction` RPC method (payer co-signs only). */ +export type PayerSignTransactionParameters = { signedTransaction: Hex context?: Record | undefined } -export type SignTransactionReturnType = { +/** Return of the `payer_signTransaction` RPC method. */ +export type PayerSignTransactionReturnType = { signedTransaction: Hex tokenCharged?: TokenCharged | undefined } From c8b2127b1fe6bf7121d3c02193493737c17a6d67 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 26 Aug 2026 19:41:42 -0400 Subject: [PATCH 82/96] feat(eip8168): add sendTransactionSync for the sponsored path Sync variant of the capability-centric sponsored send: submits via the payer (`payer_sendTransaction`, which returns the hash) and awaits the EIP-8130 receipt, threading the resolved `validBefore` for fast expiry detection. Always `mode: "send"` (co-sign-only `"sign"` has nothing to await). Returns the payer send result plus the `eip8130` receipt. --- src/eip8168/actions/sendTransaction.test.ts | 52 +++++++++++++++ src/eip8168/actions/sendTransaction.ts | 70 +++++++++++++++++++++ src/eip8168/index.ts | 3 + 3 files changed, 125 insertions(+) diff --git a/src/eip8168/actions/sendTransaction.test.ts b/src/eip8168/actions/sendTransaction.test.ts index 966e9445ea..ec625eb82f 100644 --- a/src/eip8168/actions/sendTransaction.test.ts +++ b/src/eip8168/actions/sendTransaction.test.ts @@ -14,6 +14,7 @@ import type { GetTermsReturnType } from '../types.js' import { prepareTransactionRequest, sendTransaction, + sendTransactionSync, } from './sendTransaction.js' const owner = privateKeyToAccount( @@ -227,3 +228,54 @@ describe('sendTransaction', () => { expect(parsed.calls?.[0]?.[0]?.to).toBe(USDC.toLowerCase()) }) }) + +describe('sendTransactionSync', () => { + test('submits via payer then returns the awaited EIP-8130 receipt', async () => { + const RECEIPT = { + transactionHash: `0x${'ab'.repeat(32)}` as const, + status: '0x1' as const, + payer: PAYER, + phaseStatuses: ['0x1'] as const, + } + // A sync send drives payer_sendTransaction, then polls the chain for the + // receipt; the mock chain resolves the receipt immediately. + const client = createClient({ + chain: mainnet, + transport: custom({ + async request({ method }: { method: string }) { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_call') return `0x${'0'.repeat(192)}` + if (method === 'eth_getTransactionCount') return '0x0' + if (method === 'eth_getTransactionReceipt') return RECEIPT + throw new Error(`unexpected chain RPC: ${method}`) + }, + }), + }) + const payer = createPayerClient({ + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'payer_sendTransaction') + return { + transactionHash: RECEIPT.transactionHash, + tokenCharged: { token: USDC, amount: '0x30D40' }, + } + throw new Error(`unexpected ${method}: ${JSON.stringify(params)}`) + }, + }), + }) + + const { transactionHash, tokenCharged, receipt } = + await sendTransactionSync(client, { + account, + payerClient: payer, + calls: userCalls, + capabilities: { paymentOption: sponsoredTerms.options[0], gasEstimate }, + nonceSequence: 0n, + }) + + expect(transactionHash).toBe(RECEIPT.transactionHash) + expect(tokenCharged?.token).toBe(USDC) + expect(receipt.eip8130.phaseStatuses).toEqual(['0x1']) + expect(receipt.eip8130.payer).toBe(PAYER) + }) +}) diff --git a/src/eip8168/actions/sendTransaction.ts b/src/eip8168/actions/sendTransaction.ts index 5d59da4fde..e4eabb38a3 100644 --- a/src/eip8168/actions/sendTransaction.ts +++ b/src/eip8168/actions/sendTransaction.ts @@ -3,6 +3,10 @@ import type { Client } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' import type { ToAccountReturnType } from '../../eip8130/accounts/toAccount.js' import { prepareTransactionRequest as prepareEip8130Request } from '../../eip8130/actions/sendTransaction.js' +import { + type WaitForTransactionReceiptReturnType, + waitForTransactionReceipt, +} from '../../eip8130/actions/waitForTransactionReceipt.js' import type { AaAccountChange, AaCall, @@ -17,6 +21,7 @@ import type { PayerClient } from '../client.js' import type { GetTermsReturnType, PayerGasEstimate, + PayerSendTransactionReturnType, PaymentOption, } from '../types.js' import { @@ -260,3 +265,68 @@ export async function sendTransaction( return sendSponsoredCalls(client, { ...rest, terms }) } + +export type SendTransactionSyncParameters = Omit< + SendTransactionParameters, + 'mode' | 'onTransaction' +> & { + /** How often to poll for the receipt (ms). @default 500 */ + pollingInterval?: number | undefined + /** Maximum time to wait for the receipt before rejecting (ms). @default 60_000 */ + timeout?: number | undefined +} + +export type SendTransactionSyncReturnType = PayerSendTransactionReturnType & { + /** The awaited EIP-8130 receipt (with `eip8130` fields). */ + receipt: WaitForTransactionReceiptReturnType +} + +/** + * Sponsored send that waits for the receipt. Submits via the payer + * (`payer_sendTransaction`, which returns the hash) and then awaits the + * EIP-8130 receipt, threading the resolved `validBefore` for fast expiry + * detection. The payer is the submitter, so this is always `mode: "send"` + * (there is nothing to await in co-sign-only `"sign"` mode). + * + * @example + * const { transactionHash, tokenCharged, receipt } = await sendTransactionSync( + * client, + * { + * account, + * payerClient, + * calls: [{ to, data }], + * capabilities: { paymentOption, gasEstimate }, + * }, + * ) + * console.log(receipt.eip8130.phaseStatuses) + */ +export async function sendTransactionSync( + client: Client, + parameters: SendTransactionSyncParameters, +): Promise { + const { pollingInterval, timeout, ...rest } = parameters + + let validBefore: bigint | undefined + const result = await sendTransaction(client, { + ...rest, + mode: 'send', + onTransaction: (tx) => { + validBefore = tx.validBefore + }, + }) + + // `mode: 'send'` always resolves to the submit variant (carries a hash). + if (!('transactionHash' in result)) + throw new BaseError( + 'Payer did not return a transaction hash for a `send`-mode sponsored transaction.', + ) + + const receipt = await waitForTransactionReceipt(client, { + hash: result.transactionHash, + ...(validBefore !== undefined ? { validBefore } : {}), + ...(pollingInterval !== undefined ? { pollingInterval } : {}), + ...(timeout !== undefined ? { timeout } : {}), + }) + + return { ...result, receipt } +} diff --git a/src/eip8168/index.ts b/src/eip8168/index.ts index cc0be7da8b..7f4557438b 100644 --- a/src/eip8168/index.ts +++ b/src/eip8168/index.ts @@ -14,7 +14,10 @@ export { type SendTransactionCapabilities, type SendTransactionParameters, type SendTransactionReturnType, + type SendTransactionSyncParameters, + type SendTransactionSyncReturnType, sendTransaction, + sendTransactionSync, } from './actions/sendTransaction.js' export { type CreateAggregatePayerClientParameters, From 4ceb45c07dcfce8d42b8a75a3e44f99e9399a80d Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 26 Aug 2026 19:49:09 -0400 Subject: [PATCH 83/96] feat(eip8130,eip8168): add namespaced client decorators Add `eip8130Actions()` (client.eip8130.*) and `eip8168Actions({ payerClient? })` (client.payer.*) so the native AA_TX_TYPE and payer flows ride an extended client. Namespaced (mirroring the tempo decorator) because viem's `.extend` requires protected core actions (sendTransaction, estimateGas, etc.) to conform to core signatures, which the EIP-8130 variants intentionally do not. The payer decorator can bind a default payerClient so callers don't repeat it. --- src/eip8130/decorators/eip8130Actions.test.ts | 55 ++++++++ src/eip8130/decorators/eip8130Actions.ts | 120 +++++++++++++++++ src/eip8130/index.ts | 4 + src/eip8168/decorators/eip8168Actions.test.ts | 97 ++++++++++++++ src/eip8168/decorators/eip8168Actions.ts | 122 ++++++++++++++++++ src/eip8168/index.ts | 5 + 6 files changed, 403 insertions(+) create mode 100644 src/eip8130/decorators/eip8130Actions.test.ts create mode 100644 src/eip8130/decorators/eip8130Actions.ts create mode 100644 src/eip8168/decorators/eip8168Actions.test.ts create mode 100644 src/eip8168/decorators/eip8168Actions.ts diff --git a/src/eip8130/decorators/eip8130Actions.test.ts b/src/eip8130/decorators/eip8130Actions.test.ts new file mode 100644 index 0000000000..5d5468be2b --- /dev/null +++ b/src/eip8130/decorators/eip8130Actions.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from 'vitest' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { mainnet } from '../../chains/index.js' +import { createClient } from '../../clients/createClient.js' +import { custom } from '../../clients/transports/custom.js' +import { toAccount } from '../accounts/toAccount.js' +import { key } from '../keys.js' +import { erc1167Bytecode } from '../utils/proxy.js' +import { eip8130Actions } from './eip8130Actions.js' + +const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const account = toAccount({ + signer: owner, + userSalt: `0x${'01'.padStart(64, '0')}`, + code: erc1167Bytecode('0x00000000000000000000000000000000000000Ec'), + initialActors: [key.k1(owner.address)], +}) + +function makeClient() { + return createClient({ + chain: mainnet, + transport: custom({ + async request({ method }: { method: string }) { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_call') return `0x${'0'.repeat(192)}` + if (method === 'eth_getTransactionCount') return '0x0' + throw new Error(`unexpected chain RPC: ${method}`) + }, + }), + }).extend(eip8130Actions()) +} + +test('exposes actions under the eip8130 namespace', () => { + const client = makeClient() + expect(typeof client.eip8130.sendTransaction).toBe('function') + expect(typeof client.eip8130.prepareTransactionRequest).toBe('function') + expect(typeof client.eip8130.getConfigSequence).toBe('function') +}) + +test('client.eip8130.prepareTransactionRequest fills the transaction', async () => { + const client = makeClient() + const request = await client.eip8130.prepareTransactionRequest({ + account, + calls: [[{ to: account.address, data: '0x' }]], + gas: 200_000n, + maxFeePerGas: 1_000_000_000n, + maxPriorityFeePerGas: 1_000_000n, + nonceSequence: 0n, + }) + expect(request.from).toBe(account.address) + expect(request.gas).toBe(200_000n) + expect(request.chainId).toBe(1) +}) diff --git a/src/eip8130/decorators/eip8130Actions.ts b/src/eip8130/decorators/eip8130Actions.ts new file mode 100644 index 0000000000..96e4eb72e0 --- /dev/null +++ b/src/eip8130/decorators/eip8130Actions.ts @@ -0,0 +1,120 @@ +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import { estimateGas } from '../actions/estimateGas.js' +import { getActorConfig } from '../actions/getActorConfig.js' +import { getConfigSequence } from '../actions/getConfigSequence.js' +import { getLockStatus } from '../actions/getLockStatus.js' +import { getPolicy } from '../actions/getPolicy.js' +import { getSessionSpend } from '../actions/getSessionSpend.js' +import { getTransaction } from '../actions/getTransaction.js' +import { getTransactionCount } from '../actions/getTransactionCount.js' +import { getTransactionReceipt } from '../actions/getTransactionReceipt.js' +import { isActor } from '../actions/isActor.js' +import { isLocked } from '../actions/isLocked.js' +import { + prepareTransactionRequest, + sendTransaction, + sendTransactionSync, +} from '../actions/sendTransaction.js' +import { waitForTransactionReceipt } from '../actions/waitForTransactionReceipt.js' + +type Action = (client: any, parameters: any) => any +/** Binds an `(client, parameters)` action to a client-bound `(parameters)` method. */ +type Bound = ( + parameters: Parameters[1], +) => ReturnType + +/** + * EIP-8130 actions, exposed under a `eip8130` namespace so they don't shadow + * core client actions with incompatible signatures (viem's `.extend` requires + * protected actions like `sendTransaction` / `estimateGas` to conform to core + * shapes). Mirrors the tempo decorator pattern. + */ +export type Eip8130Actions = { + eip8130: { + /** Send an EIP-8130 (`AA_TX_TYPE`) transaction; returns the hash. */ + sendTransaction: Bound + /** Send an EIP-8130 transaction and await its receipt (`eth_sendRawTransactionSync`). */ + sendTransactionSync: Bound + /** Fill an EIP-8130 transaction body (chain id, nonce, fees). */ + prepareTransactionRequest: Bound + /** Estimate gas for an EIP-8130 call/transaction. */ + estimateGas: Bound + /** Await an EIP-8130 receipt (with `eip8130` fields). */ + waitForTransactionReceipt: Bound + /** Read a pending/mined EIP-8130 transaction. */ + getTransaction: Bound + /** Read an EIP-8130 receipt (with `eip8130` fields). */ + getTransactionReceipt: Bound + /** Read the next 2D channel-nonce sequence. */ + getTransactionCount: Bound + /** Read an account's local/multichain config sequences. */ + getConfigSequence: Bound + /** Read an actor's on-chain config. */ + getActorConfig: Bound + /** Read an actor's policy binding. */ + getPolicy: Bound + /** Whether an actor is authorized. */ + isActor: Bound + /** Whether an account is locked. */ + isLocked: Bound + /** Full lock status of an account. */ + getLockStatus: Bound + /** Current spend against a session key. */ + getSessionSpend: Bound + } +} + +/** + * A suite of EIP-8130 actions, added to a client under `client.eip8130`. + * + * @example + * import { createClient, http } from 'viem' + * import { baseSepolia } from 'viem/chains' + * import { eip8130Actions } from 'viem/eip8130' + * + * const client = createClient({ + * chain: baseSepolia, + * transport: http(), + * }).extend(eip8130Actions()) + * + * const hash = await client.eip8130.sendTransaction({ + * account, + * calls: [{ to, data }], + * gas: 200_000n, + * }) + */ +export function eip8130Actions() { + return < + transport extends Transport, + chain extends Chain | undefined = Chain | undefined, + account extends Account | undefined = Account | undefined, + >( + client: Client, + ): Eip8130Actions => ({ + eip8130: { + sendTransaction: (parameters) => sendTransaction(client, parameters), + sendTransactionSync: (parameters) => + sendTransactionSync(client, parameters), + prepareTransactionRequest: (parameters) => + prepareTransactionRequest(client, parameters), + estimateGas: (parameters) => estimateGas(client, parameters), + waitForTransactionReceipt: (parameters) => + waitForTransactionReceipt(client, parameters), + getTransaction: (parameters) => getTransaction(client, parameters), + getTransactionReceipt: (parameters) => + getTransactionReceipt(client, parameters), + getTransactionCount: (parameters) => + getTransactionCount(client, parameters), + getConfigSequence: (parameters) => getConfigSequence(client, parameters), + getActorConfig: (parameters) => getActorConfig(client, parameters), + getPolicy: (parameters) => getPolicy(client, parameters), + isActor: (parameters) => isActor(client, parameters), + isLocked: (parameters) => isLocked(client, parameters), + getLockStatus: (parameters) => getLockStatus(client, parameters), + getSessionSpend: (parameters) => getSessionSpend(client, parameters), + }, + }) +} diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index fc38116181..d83f423b25 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -149,6 +149,10 @@ export { txContextAddress, unsequencedLocalHalf, } from './constants.js' +export { + type Eip8130Actions, + eip8130Actions, +} from './decorators/eip8130Actions.js' export { baseSepoliaDeployment, canonicalEip8130Deployment, diff --git a/src/eip8168/decorators/eip8168Actions.test.ts b/src/eip8168/decorators/eip8168Actions.test.ts new file mode 100644 index 0000000000..26feabffd1 --- /dev/null +++ b/src/eip8168/decorators/eip8168Actions.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from 'vitest' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { mainnet } from '../../chains/index.js' +import { createClient } from '../../clients/createClient.js' +import { custom } from '../../clients/transports/custom.js' +import { toAccount } from '../../eip8130/accounts/toAccount.js' +import { key } from '../../eip8130/keys.js' +import { erc1167Bytecode } from '../../eip8130/utils/proxy.js' +import { keccak256 } from '../../utils/hash/keccak256.js' +import { createPayerClient } from '../client.js' +import type { GetTermsReturnType } from '../types.js' +import { eip8168Actions } from './eip8168Actions.js' + +const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const account = toAccount({ + signer: owner, + userSalt: `0x${'01'.padStart(64, '0')}`, + code: erc1167Bytecode('0x00000000000000000000000000000000000000Ec'), + initialActors: [key.k1(owner.address)], +}) +const PAYER = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' as const +const userCalls = [ + { + to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const, + data: '0x' as const, + }, +] +const gasEstimate = { + gasLimit: '0xC350', + maxFeePerGas: '0x59682F00', + maxPriorityFeePerGas: '0x59682F00', +} as const +const sponsoredTerms: GetTermsReturnType = { + gasEstimate, + options: [{ kind: 'sponsored', payer: PAYER, ttl: 300 }], +} + +function makeChain() { + return createClient({ + chain: mainnet, + transport: custom({ + async request({ method }: { method: string }) { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_call') return `0x${'0'.repeat(192)}` + if (method === 'eth_getTransactionCount') return '0x0' + throw new Error(`unexpected chain RPC: ${method}`) + }, + }), + }) +} + +test('binds a default payerClient and exposes client.payer.*', async () => { + const payerClient = createPayerClient({ + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'payer_getTerms') return sponsoredTerms + if (method === 'payer_sendTransaction') + return { transactionHash: keccak256(params[0].signedTransaction) } + throw new Error(`unexpected ${method}`) + }, + }), + }) + const client = makeChain().extend(eip8168Actions({ payerClient })) + + const { capabilities } = await client.payer.prepareTransactionRequest({ + account, + calls: userCalls, + capabilities: { paymasterService: {} }, + nonceSequence: 0n, + }) + expect(capabilities.paymentOptions[0].kind).toBe('sponsored') + + const result = await client.payer.sendTransaction({ + account, + calls: userCalls, + capabilities: { + paymentOption: capabilities.paymentOptions[0], + gasEstimate, + }, + nonceSequence: 0n, + }) + expect(result).toHaveProperty('transactionHash') +}) + +test('throws when no payerClient is bound or provided', () => { + const client = makeChain().extend(eip8168Actions()) + expect(() => + client.payer.sendTransaction({ + account, + calls: userCalls, + capabilities: { paymentOption: sponsoredTerms.options[0], gasEstimate }, + nonceSequence: 0n, + } as never), + ).toThrow('`payerClient` is required') +}) diff --git a/src/eip8168/decorators/eip8168Actions.ts b/src/eip8168/decorators/eip8168Actions.ts new file mode 100644 index 0000000000..7191964a40 --- /dev/null +++ b/src/eip8168/decorators/eip8168Actions.ts @@ -0,0 +1,122 @@ +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import { BaseError } from '../../errors/base.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import { + type SendSponsoredCallsParameters, + type SendSponsoredCallsReturnType, + sendSponsoredCalls, +} from '../actions/sendSponsoredCalls.js' +import { + type PrepareTransactionRequestParameters, + type PrepareTransactionRequestReturnType, + prepareTransactionRequest, + type SendTransactionParameters, + type SendTransactionReturnType, + type SendTransactionSyncParameters, + type SendTransactionSyncReturnType, + sendTransaction, + sendTransactionSync, +} from '../actions/sendTransaction.js' +import type { PayerClient } from '../client.js' + +/** Makes `payerClient` optional (a bound default from the decorator fills it). */ +type WithOptionalPayer = Omit< + parameters, + 'payerClient' +> & { payerClient?: PayerClient | undefined } + +export type Eip8168Actions = { + payer: { + /** + * Fill an EIP-8130 transaction and surface payment offers as a component of + * the fill (`capabilities.paymentOptions`). + */ + prepareTransactionRequest: ( + parameters: WithOptionalPayer, + ) => Promise + /** Submit an EIP-8130 transaction paid for by the chosen `capabilities.paymentOption`. */ + sendTransaction: ( + parameters: WithOptionalPayer, + ) => Promise + /** Sponsored send that awaits the EIP-8130 receipt. */ + sendTransactionSync: ( + parameters: WithOptionalPayer, + ) => Promise + /** End-to-end ERC-8168 sponsored-calls flow (fetch terms, select, submit). */ + sendSponsoredCalls: ( + parameters: WithOptionalPayer, + ) => Promise + } +} + +export type Eip8168ActionsParameters = { + /** + * Default payer service client used by every `client.payer.*` call. Individual + * calls may still pass their own `payerClient` to override it. Omit to require + * `payerClient` on each call. + */ + payerClient?: PayerClient | undefined +} + +/** + * A suite of ERC-8168 payer actions, added to a client under `client.payer`. + * Payment is a capability of the fill: `prepareTransactionRequest` surfaces + * offers, and `sendTransaction` submits the chosen one. + * + * @example + * import { createClient, http } from 'viem' + * import { baseSepolia } from 'viem/chains' + * import { createPayerClient, eip8168Actions } from 'viem/eip8168' + * + * const payerClient = createPayerClient({ url: 'https://payer.example.com/v1' }) + * const client = createClient({ + * chain: baseSepolia, + * transport: http(), + * }).extend(eip8168Actions({ payerClient })) + * + * const { capabilities } = await client.payer.prepareTransactionRequest({ + * account, + * calls: [{ to, data }], + * capabilities: { paymasterService: {} }, + * }) + * const { transactionHash } = await client.payer.sendTransaction({ + * account, + * calls: [{ to, data }], + * capabilities: { paymentOption: capabilities.paymentOptions[0], gasEstimate: capabilities.gasEstimate }, + * }) + */ +export function eip8168Actions(parameters: Eip8168ActionsParameters = {}) { + const { payerClient: defaultPayerClient } = parameters + + const withPayer =

( + p: p, + ): p & { payerClient: PayerClient } => { + const payerClient = p.payerClient ?? defaultPayerClient + if (!payerClient) + throw new BaseError( + '`payerClient` is required: pass it to `eip8168Actions({ payerClient })` or on the call.', + ) + return { ...p, payerClient } + } + + return < + transport extends Transport, + chain extends Chain | undefined = Chain | undefined, + account extends Account | undefined = Account | undefined, + >( + client: Client, + ): Eip8168Actions => ({ + payer: { + prepareTransactionRequest: (parameters) => + prepareTransactionRequest(client, withPayer(parameters)), + sendTransaction: (parameters) => + sendTransaction(client, withPayer(parameters)), + sendTransactionSync: (parameters) => + sendTransactionSync(client, withPayer(parameters)), + sendSponsoredCalls: (parameters) => + sendSponsoredCalls(client, withPayer(parameters)), + }, + }) +} diff --git a/src/eip8168/index.ts b/src/eip8168/index.ts index 7f4557438b..fb7ac92e86 100644 --- a/src/eip8168/index.ts +++ b/src/eip8168/index.ts @@ -43,6 +43,11 @@ export { payerRejectedCode, sponsorshipDeclineCode, } from './constants.js' +export { + type Eip8168Actions, + type Eip8168ActionsParameters, + eip8168Actions, +} from './decorators/eip8168Actions.js' export type { BalanceLimit, BaseOffer, From 0768b7cfb7a50d94f4d6ca1afab7ce507da5ce36 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 27 Aug 2026 08:47:12 -0400 Subject: [PATCH 84/96] feat(eip8130): add eip8130ChainConfig to fold receipts into core Add a tempo-style `chainConfig` that registers a `transactionReceipt` formatter, so on an EIP-8130-enabled chain core `client.getTransactionReceipt` and `client.waitForTransactionReceipt` natively return the AA fields (`payer`, `phaseStatuses`, `metadata`) under `receipt.eip8130`. Only the receipt is folded. The send path (phased calls, required gas, 2D nonce, actor-auth envelopes, payer auth) does not map onto core `client.sendTransaction`, and `eth_getTransactionByHash` returns a non-standard nested body, so those stay on the `client.eip8130.*` decorator. --- src/eip8130/chainConfig.test.ts | 58 ++++++++++++++++++++++++++++++ src/eip8130/chainConfig.ts | 64 +++++++++++++++++++++++++++++++++ src/eip8130/index.ts | 1 + 3 files changed, 123 insertions(+) create mode 100644 src/eip8130/chainConfig.test.ts create mode 100644 src/eip8130/chainConfig.ts diff --git a/src/eip8130/chainConfig.test.ts b/src/eip8130/chainConfig.test.ts new file mode 100644 index 0000000000..ee4ca0841c --- /dev/null +++ b/src/eip8130/chainConfig.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from 'vitest' +import { getTransactionReceipt } from '../actions/public/getTransactionReceipt.js' +import { createClient } from '../clients/createClient.js' +import { custom } from '../clients/transports/custom.js' +import { defineChain } from '../utils/chain/defineChain.js' +import { eip8130ChainConfig } from './chainConfig.js' + +const rawReceipt = { + blockHash: `0x${'ab'.repeat(32)}`, + blockNumber: '0x1', + contractAddress: null, + cumulativeGasUsed: '0x5208', + effectiveGasPrice: '0x3b9aca00', + from: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + gasUsed: '0x5208', + logs: [], + logsBloom: `0x${'00'.repeat(256)}`, + status: '0x1', + to: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', + transactionHash: `0x${'cd'.repeat(32)}`, + transactionIndex: '0x0', + type: '0x79', + // EIP-8130 extension fields: + payer: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + phaseStatuses: ['0x1'], + metadata: '0xdeadbeef', +} + +const chain = defineChain({ + ...eip8130ChainConfig, + id: 84_532, + name: 'Test 8130', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['https://example.com'] } }, +}) + +test('core getTransactionReceipt surfaces eip8130 fields natively', async () => { + const client = createClient({ + chain, + transport: custom({ + async request({ method }: { method: string }) { + if (method === 'eth_getTransactionReceipt') return rawReceipt + throw new Error(`unexpected ${method}`) + }, + }), + }) + + const receipt = await getTransactionReceipt(client, { + hash: `0x${'cd'.repeat(32)}`, + }) + + expect(receipt.status).toBe('success') + expect(receipt.eip8130).toEqual({ + payer: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + phaseStatuses: ['0x1'], + metadata: '0xdeadbeef', + }) +}) diff --git a/src/eip8130/chainConfig.ts b/src/eip8130/chainConfig.ts new file mode 100644 index 0000000000..86d3f9af98 --- /dev/null +++ b/src/eip8130/chainConfig.ts @@ -0,0 +1,64 @@ +import type { ChainConfig } from '../types/chain.js' +import type { RpcTransactionReceipt } from '../types/rpc.js' +import type { TransactionReceipt } from '../types/transaction.js' +import type { ExactPartial } from '../types/utils.js' +import { + defineTransactionReceipt, + formatTransactionReceipt, +} from '../utils/formatters/transactionReceipt.js' +import { + parseReceiptFields, + type ReceiptFields, +} from './actions/getTransactionReceipt.js' + +type RawReceipt8130 = ExactPartial & { + payer?: ReceiptFields['payer'] + phaseStatuses?: ReceiptFields['phaseStatuses'] + metadata?: ReceiptFields['metadata'] +} + +/** + * Chain config that folds EIP-8130 (`AA_TX_TYPE`, `0x79`) receipt fields into + * core viem, so `client.getTransactionReceipt` and + * `client.waitForTransactionReceipt` natively return the EIP-8130 fields + * (`payer`, `phaseStatuses`, `metadata`) under a `eip8130` key. Mirrors the + * tempo `chainConfig` fold. + * + * Spread it into an EIP-8130-enabled chain: + * + * @example + * import { defineChain } from 'viem' + * import { eip8130ChainConfig } from 'viem/eip8130' + * + * export const myChain = defineChain({ + * ...eip8130ChainConfig, + * id: 1234, + * name: 'My Chain', + * nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + * rpcUrls: { default: { http: ['https://rpc.example.com'] } }, + * }) + * + * // then, on a client for `myChain`: + * const receipt = await client.getTransactionReceipt({ hash }) + * receipt.eip8130.phaseStatuses // ['0x1'] + * + * @remarks + * Only the receipt is folded. The `AA_TX_TYPE` send path (phased `calls`, + * required `gas`, 2D nonce, actor-auth envelopes, payer auth) does not map onto + * core `client.sendTransaction`, and `eth_getTransactionByHash` returns a + * non-standard nested body: use the `client.eip8130.*` decorator for those. + */ +export const eip8130ChainConfig = { + formatters: { + transactionReceipt: defineTransactionReceipt({ + format( + receipt: RawReceipt8130, + ): TransactionReceipt & { eip8130: ReceiptFields } { + return { + ...formatTransactionReceipt(receipt), + eip8130: parseReceiptFields(receipt as never), + } + }, + }), + }, +} as const satisfies ChainConfig diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index d83f423b25..280c2da26c 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -110,6 +110,7 @@ export { supportedSignerTypes, supportedSubAccountKeyTypes, } from './capabilities.js' +export { eip8130ChainConfig } from './chainConfig.js' export { eip8130ChainIds, type Is8130EnabledParameters, From 4f4ee78c02a1c4a2c6cad94d4d208dabbcf6f696 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 27 Aug 2026 09:03:14 -0400 Subject: [PATCH 85/96] refactor(eip8130): drop getTransactionReceipt from the decorator Receipts now fold into core via eip8130ChainConfig, so remove the redundant client.eip8130.getTransactionReceipt method and point users to the chain config. --- src/eip8130/decorators/eip8130Actions.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/eip8130/decorators/eip8130Actions.ts b/src/eip8130/decorators/eip8130Actions.ts index 96e4eb72e0..8104454456 100644 --- a/src/eip8130/decorators/eip8130Actions.ts +++ b/src/eip8130/decorators/eip8130Actions.ts @@ -10,7 +10,6 @@ import { getPolicy } from '../actions/getPolicy.js' import { getSessionSpend } from '../actions/getSessionSpend.js' import { getTransaction } from '../actions/getTransaction.js' import { getTransactionCount } from '../actions/getTransactionCount.js' -import { getTransactionReceipt } from '../actions/getTransactionReceipt.js' import { isActor } from '../actions/isActor.js' import { isLocked } from '../actions/isLocked.js' import { @@ -44,10 +43,8 @@ export type Eip8130Actions = { estimateGas: Bound /** Await an EIP-8130 receipt (with `eip8130` fields). */ waitForTransactionReceipt: Bound - /** Read a pending/mined EIP-8130 transaction. */ + /** Read a pending/mined EIP-8130 transaction (non-standard nested body). */ getTransaction: Bound - /** Read an EIP-8130 receipt (with `eip8130` fields). */ - getTransactionReceipt: Bound /** Read the next 2D channel-nonce sequence. */ getTransactionCount: Bound /** Read an account's local/multichain config sequences. */ @@ -70,6 +67,10 @@ export type Eip8130Actions = { /** * A suite of EIP-8130 actions, added to a client under `client.eip8130`. * + * Transaction receipts are not included here: spread {@link eip8130ChainConfig} + * into your chain and core `client.getTransactionReceipt` / + * `client.waitForTransactionReceipt` return the EIP-8130 fields natively. + * * @example * import { createClient, http } from 'viem' * import { baseSepolia } from 'viem/chains' @@ -104,8 +105,6 @@ export function eip8130Actions() { waitForTransactionReceipt: (parameters) => waitForTransactionReceipt(client, parameters), getTransaction: (parameters) => getTransaction(client, parameters), - getTransactionReceipt: (parameters) => - getTransactionReceipt(client, parameters), getTransactionCount: (parameters) => getTransactionCount(client, parameters), getConfigSequence: (parameters) => getConfigSequence(client, parameters), From f4e2ab68cdfc67ec1e1d1a8a51224540d1f35b47 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 27 Aug 2026 09:13:37 -0400 Subject: [PATCH 86/96] feat(eip8130): fold native AA sends into core client.sendTransaction Make the EIP-8130 account a viem local account (type 'local', source 'eip8130') and add a `prepareTransactionRequest` hook to eip8130ChainConfig. On a chain that spreads the config, core `client.sendTransaction({ account, calls, gas })` now resolves the AA body (2D nonce, fees, scope-driven nonce mode, phased-call encoding) via the hook and lets the account serialize + sign the AA_TX_TYPE (0x79) envelope, so no eth_fillTransaction/getTransactionCount fallback runs. Covers the self-pay path; sponsored sends stay on client.eip8168.* (the payer signer is not part of a core request). --- src/eip8130/accounts/toAccount.ts | 32 +++++++++++++++ src/eip8130/chainConfig.test.ts | 48 ++++++++++++++++++++++ src/eip8130/chainConfig.ts | 68 ++++++++++++++++++++++++++++--- 3 files changed, 143 insertions(+), 5 deletions(-) diff --git a/src/eip8130/accounts/toAccount.ts b/src/eip8130/accounts/toAccount.ts index 72ee0522b5..9ab3e59943 100644 --- a/src/eip8130/accounts/toAccount.ts +++ b/src/eip8130/accounts/toAccount.ts @@ -116,6 +116,16 @@ export type ToAccountReturnType = { * set explicitly for non-K1 authenticators. */ readonly actorId?: Hex | undefined + /** viem account kind (`'local'`), so core `sendTransaction` takes the local-account path. */ + readonly type: 'local' + /** viem account source marker used by `eip8130ChainConfig` to detect AA sends. */ + readonly source: 'eip8130' + /** SEC1 hex public key of a K1 signer, or `'0x'` for non-K1 signers. */ + readonly publicKey: Hex + /** Not supported for EIP-8130 accounts. */ + signMessage(): never + /** Not supported for EIP-8130 accounts. */ + signTypedData(): never /** * Builds the `create` account-change entry (include in the first tx for * smart accounts). Throws if the account was constructed with a known `address` @@ -207,6 +217,10 @@ export function toAccount( parameters.actorId ?? (isK1Authenticator ? key.k1(signer.address).actorId : undefined) + const signerPublicKey = (signer as { publicKey?: unknown }).publicKey + const publicKey: Hex = + typeof signerPublicKey === 'string' ? (signerPublicKey as Hex) : '0x' + return { address, signer, @@ -214,6 +228,24 @@ export function toAccount( scope, actorId, + // viem `LocalAccount` conformance, so the account can drive core + // `client.sendTransaction` on a chain that spreads `eip8130ChainConfig`. + // The `beforeFillTransaction` hook resolves the AA body; `signTransaction` + // (below) serializes + signs it, ignoring the core-supplied `serializer`. + type: 'local', + source: 'eip8130', + publicKey, + signMessage(): never { + throw new BaseError( + '`signMessage` is not supported for EIP-8130 accounts.', + ) + }, + signTypedData(): never { + throw new BaseError( + '`signTypedData` is not supported for EIP-8130 accounts.', + ) + }, + create() { if (isAddressOnly) throw new BaseError( diff --git a/src/eip8130/chainConfig.test.ts b/src/eip8130/chainConfig.test.ts index ee4ca0841c..bbed8d810b 100644 --- a/src/eip8130/chainConfig.test.ts +++ b/src/eip8130/chainConfig.test.ts @@ -1,9 +1,15 @@ import { expect, test } from 'vitest' +import { privateKeyToAccount } from '../accounts/privateKeyToAccount.js' import { getTransactionReceipt } from '../actions/public/getTransactionReceipt.js' +import { sendTransaction } from '../actions/wallet/sendTransaction.js' import { createClient } from '../clients/createClient.js' import { custom } from '../clients/transports/custom.js' import { defineChain } from '../utils/chain/defineChain.js' +import { toAccount } from './accounts/toAccount.js' import { eip8130ChainConfig } from './chainConfig.js' +import { aaTransactionType } from './constants.js' +import { key } from './keys.js' +import { erc1167Bytecode } from './utils/proxy.js' const rawReceipt = { blockHash: `0x${'ab'.repeat(32)}`, @@ -56,3 +62,45 @@ test('core getTransactionReceipt surfaces eip8130 fields natively', async () => metadata: '0xdeadbeef', }) }) + +test('core sendTransaction submits a native AA_TX_TYPE (0x79) transaction', async () => { + const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + ) + const account = toAccount({ + signer: owner, + userSalt: `0x${'01'.padStart(64, '0')}`, + code: erc1167Bytecode('0x00000000000000000000000000000000000000Ec'), + initialActors: [key.k1(owner.address)], + }) + + let submitted: `0x${string}` | undefined + const client = createClient({ + chain, + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + // actor-config read (actor not yet bound → zeroed config) + if (method === 'eth_call') return `0x${'0'.repeat(192)}` + // 2D channel-nonce read + if (method === 'eth_getTransactionCount') return '0x0' + if (method === 'eth_sendRawTransaction') { + submitted = params[0] + return `0x${'11'.repeat(32)}` + } + throw new Error(`unexpected ${method}`) + }, + }), + }) + + const hash = await sendTransaction(client, { + account, + calls: [{ to: account.address, data: '0x' }], + accountChanges: [account.create()], + gas: 200_000n, + maxFeePerGas: 1_000_000_000n, + maxPriorityFeePerGas: 1_000_000n, + } as never) + + expect(hash).toBe(`0x${'11'.repeat(32)}`) + expect(submitted?.startsWith(aaTransactionType)).toBe(true) +}) diff --git a/src/eip8130/chainConfig.ts b/src/eip8130/chainConfig.ts index 86d3f9af98..cef7c3abf8 100644 --- a/src/eip8130/chainConfig.ts +++ b/src/eip8130/chainConfig.ts @@ -6,10 +6,14 @@ import { defineTransactionReceipt, formatTransactionReceipt, } from '../utils/formatters/transactionReceipt.js' +import type { ToAccountReturnType } from './accounts/toAccount.js' import { parseReceiptFields, type ReceiptFields, } from './actions/getTransactionReceipt.js' +import { prepareTransactionRequest as fillEip8130Body } from './actions/sendTransaction.js' +import type { AaCall, AaCalls } from './types/transaction.js' +import { encodeWalletCalls } from './utils/encodeWalletCalls.js' type RawReceipt8130 = ExactPartial & { payer?: ReceiptFields['payer'] @@ -17,6 +21,13 @@ type RawReceipt8130 = ExactPartial & { metadata?: ReceiptFields['metadata'] } +/** Normalizes a flat call list into a single phase (nested lists pass through). */ +function toPhases(calls: readonly AaCall[] | AaCalls): AaCalls { + if (calls.length === 0) return [] + if (Array.isArray(calls[0])) return calls as AaCalls + return [calls as readonly AaCall[]] +} + /** * Chain config that folds EIP-8130 (`AA_TX_TYPE`, `0x79`) receipt fields into * core viem, so `client.getTransactionReceipt` and @@ -38,15 +49,24 @@ type RawReceipt8130 = ExactPartial & { * rpcUrls: { default: { http: ['https://rpc.example.com'] } }, * }) * - * // then, on a client for `myChain`: + * // then, on a client for `myChain` with an EIP-8130 account, core + * // `client.sendTransaction` submits a native `AA_TX_TYPE` transaction: + * const hash = await client.sendTransaction({ + * account, // toAccount(...) / newSmartAccount(...) + * calls: [{ to, data }], + * gas: 200_000n, + * }) * const receipt = await client.getTransactionReceipt({ hash }) * receipt.eip8130.phaseStatuses // ['0x1'] * * @remarks - * Only the receipt is folded. The `AA_TX_TYPE` send path (phased `calls`, - * required `gas`, 2D nonce, actor-auth envelopes, payer auth) does not map onto - * core `client.sendTransaction`, and `eth_getTransactionByHash` returns a - * non-standard nested body: use the `client.eip8130.*` decorator for those. + * The `prepareTransactionRequest` hook resolves the AA body (2D nonce, fees, + * scope-driven nonce mode, phased-call encoding) so core skips its standard + * fills; the account's `signTransaction` then serializes + signs the + * `AA_TX_TYPE` envelope. This covers the self-pay path; sponsored (payer) sends + * still use `client.eip8168.*` (the payer signer is not part of a core request). + * `eth_getTransactionByHash` returns a non-standard nested body, so + * `client.eip8130.getTransaction` remains the reader for AA transactions. */ export const eip8130ChainConfig = { formatters: { @@ -61,4 +81,42 @@ export const eip8130ChainConfig = { }, }), }, + prepareTransactionRequest: [ + async (request, { client }) => { + const req = request as Record + // Only handle EIP-8130 AA sends: an `eip8130` account with phased calls. + // Everything else (plain EOA txs on this chain) passes through untouched. + if (req.account?.source !== 'eip8130' || !req.calls) return request + + const account = req.account as ToAccountReturnType + const body = await fillEip8130Body(client, { + account, + calls: encodeWalletCalls({ + account: account.address, + calls: toPhases(req.calls), + encodeExecute: req.encodeExecute, + }), + accountChanges: req.accountChanges, + payer: req.payer, + gas: req.gas, + nonceKey: req.nonceKey, + nonceSequence: req.nonceSequence, + validAfter: req.validAfter, + validBefore: req.validBefore, + maxFeePerGas: req.maxFeePerGas, + maxPriorityFeePerGas: req.maxPriorityFeePerGas, + dataSuffix: req.metadata, + }) + + return { + ...request, + ...body, + // Scalar-nonce shim: satisfies core's nonce gate so it neither calls + // `eth_fillTransaction` nor `getTransactionCount`. The real 2D nonce is + // carried by `nonceKey`/`nonceSequence`, which `signTransaction` reads. + nonce: Number(body.nonceSequence), + } as typeof request + }, + { runAt: ['beforeFillTransaction'] }, + ], } as const satisfies ChainConfig From cddb35b118fa84e04d12338705431614694462e0 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 27 Aug 2026 09:28:33 -0400 Subject: [PATCH 87/96] feat(eip8130): estimate gas in the send fold + guard sponsored sends The core sendTransaction fold now auto-estimates gas via the EIP-8130 eth_estimateGas extension when `gas` is omitted (threading senderActorId / senderAuthAuthenticator hints), instead of requiring it. Passing a `payer` through core sendTransaction now throws a redirect to client.eip8130.sendTransaction (local payer) / client.eip8168.sendTransaction (payer service), since sponsored settlement is off the raw-submit path. --- src/eip8130/chainConfig.test.ts | 36 ++++++++++++++++- src/eip8130/chainConfig.ts | 70 +++++++++++++++++++++++++-------- 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src/eip8130/chainConfig.test.ts b/src/eip8130/chainConfig.test.ts index bbed8d810b..c8e52a0510 100644 --- a/src/eip8130/chainConfig.test.ts +++ b/src/eip8130/chainConfig.test.ts @@ -75,6 +75,7 @@ test('core sendTransaction submits a native AA_TX_TYPE (0x79) transaction', asyn }) let submitted: `0x${string}` | undefined + let estimated = false const client = createClient({ chain, transport: custom({ @@ -83,6 +84,11 @@ test('core sendTransaction submits a native AA_TX_TYPE (0x79) transaction', asyn if (method === 'eth_call') return `0x${'0'.repeat(192)}` // 2D channel-nonce read if (method === 'eth_getTransactionCount') return '0x0' + // AA gas estimation (no `gas` was pinned) + if (method === 'eth_estimateGas') { + estimated = true + return '0x30d40' // 200_000 + } if (method === 'eth_sendRawTransaction') { submitted = params[0] return `0x${'11'.repeat(32)}` @@ -96,11 +102,39 @@ test('core sendTransaction submits a native AA_TX_TYPE (0x79) transaction', asyn account, calls: [{ to: account.address, data: '0x' }], accountChanges: [account.create()], - gas: 200_000n, maxFeePerGas: 1_000_000_000n, maxPriorityFeePerGas: 1_000_000n, } as never) + expect(estimated).toBe(true) expect(hash).toBe(`0x${'11'.repeat(32)}`) expect(submitted?.startsWith(aaTransactionType)).toBe(true) }) + +test('core sendTransaction rejects sponsored (payer) sends with a redirect', async () => { + const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + ) + const account = toAccount({ + signer: owner, + userSalt: `0x${'01'.padStart(64, '0')}`, + code: erc1167Bytecode('0x00000000000000000000000000000000000000Ec'), + initialActors: [key.k1(owner.address)], + }) + const client = createClient({ + chain, + transport: custom({ + async request() { + throw new Error('no RPC expected') + }, + }), + }) + + await expect( + sendTransaction(client, { + account, + calls: [{ to: account.address, data: '0x' }], + payer: { account: owner }, + } as never), + ).rejects.toThrow('Sponsored EIP-8130 transactions are not supported') +}) diff --git a/src/eip8130/chainConfig.ts b/src/eip8130/chainConfig.ts index cef7c3abf8..b4e5a357cb 100644 --- a/src/eip8130/chainConfig.ts +++ b/src/eip8130/chainConfig.ts @@ -1,3 +1,4 @@ +import { BaseError } from '../errors/base.js' import type { ChainConfig } from '../types/chain.js' import type { RpcTransactionReceipt } from '../types/rpc.js' import type { TransactionReceipt } from '../types/transaction.js' @@ -7,6 +8,7 @@ import { formatTransactionReceipt, } from '../utils/formatters/transactionReceipt.js' import type { ToAccountReturnType } from './accounts/toAccount.js' +import { estimateGas } from './actions/estimateGas.js' import { parseReceiptFields, type ReceiptFields, @@ -50,23 +52,30 @@ function toPhases(calls: readonly AaCall[] | AaCalls): AaCalls { * }) * * // then, on a client for `myChain` with an EIP-8130 account, core - * // `client.sendTransaction` submits a native `AA_TX_TYPE` transaction: + * // `client.sendTransaction` submits a native `AA_TX_TYPE` transaction + * // (gas is estimated via the EIP-8130 `eth_estimateGas` extension when omitted): * const hash = await client.sendTransaction({ * account, // toAccount(...) / newSmartAccount(...) * calls: [{ to, data }], - * gas: 200_000n, * }) * const receipt = await client.getTransactionReceipt({ hash }) * receipt.eip8130.phaseStatuses // ['0x1'] * * @remarks - * The `prepareTransactionRequest` hook resolves the AA body (2D nonce, fees, - * scope-driven nonce mode, phased-call encoding) so core skips its standard - * fills; the account's `signTransaction` then serializes + signs the - * `AA_TX_TYPE` envelope. This covers the self-pay path; sponsored (payer) sends - * still use `client.eip8168.*` (the payer signer is not part of a core request). - * `eth_getTransactionByHash` returns a non-standard nested body, so - * `client.eip8130.getTransaction` remains the reader for AA transactions. + * The `prepareTransactionRequest` hook resolves the AA body (2D nonce, EIP-1559 + * fees, gas estimation, scope-driven nonce mode, phased-call encoding) so core + * skips its standard fills; the account's `signTransaction` then serializes + + * signs the `AA_TX_TYPE` envelope. Pass `gas` to skip estimation, and + * `senderActorId` / `senderAuthAuthenticator` to refine estimation for + * session-key or non-K1 sends. + * + * This covers the self-pay path only. Sponsored sends are not routed through + * core `sendTransaction` (the payer signer isn't part of a core request, and + * settlement happens off the raw-submit path); use + * `client.eip8130.sendTransaction` (local payer signer) or + * `client.eip8168.sendTransaction` (ERC-8168 payer service). Passing `payer` + * here throws. `eth_getTransactionByHash` returns a non-standard nested body, + * so `client.eip8130.getTransaction` remains the reader for AA transactions. */ export const eip8130ChainConfig = { formatters: { @@ -88,17 +97,46 @@ export const eip8130ChainConfig = { // Everything else (plain EOA txs on this chain) passes through untouched. if (req.account?.source !== 'eip8130' || !req.calls) return request + // Sponsored sends can't ride core `sendTransaction`: the sender's + // `signTransaction` has no payer context, and payment is settled off the + // raw-submit path (a local payer signer co-signs `payer_auth`, or an + // ERC-8168 payer service submits via `payer_sendTransaction`). Redirect + // to the dedicated actions instead of emitting an unsigned-payer tx. + if (req.payer) + throw new BaseError( + 'Sponsored EIP-8130 transactions are not supported through core `sendTransaction`. ' + + 'Use `client.eip8130.sendTransaction` (local payer signer) or ' + + '`client.eip8168.sendTransaction` (ERC-8168 payer service).', + ) + const account = req.account as ToAccountReturnType + const calls = encodeWalletCalls({ + account: account.address, + calls: toPhases(req.calls), + encodeExecute: req.encodeExecute, + }) + + // Price the AA transaction via the EIP-8130 `eth_estimateGas` extension + // when the caller didn't pin `gas`. Thread the acting-actor hint so + // policy-gated (session-key) sends resolve the right policy, and let the + // caller override auth-gas pricing via `senderAuthAuthenticator`. + const gas = + req.gas ?? + (await estimateGas(client, { + sender: account.address, + accountChanges: req.accountChanges, + calls, + nonceKey: req.nonceKey, + senderActorId: req.senderActorId ?? account.actorId, + senderAuthAuthenticator: req.senderAuthAuthenticator, + dataSuffix: req.metadata, + })) + const body = await fillEip8130Body(client, { account, - calls: encodeWalletCalls({ - account: account.address, - calls: toPhases(req.calls), - encodeExecute: req.encodeExecute, - }), + calls, accountChanges: req.accountChanges, - payer: req.payer, - gas: req.gas, + gas, nonceKey: req.nonceKey, nonceSequence: req.nonceSequence, validAfter: req.validAfter, From 711e728972acc2d76583e25b62715fbd21b70366 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 27 Aug 2026 09:44:21 -0400 Subject: [PATCH 88/96] feat(eip8168): estimate gas ourselves by default, payer as opt-in oracle The sponsored fill now defaults to our own node estimate (gasEstimator: 'self') and uses max(ours, payer) when the payer also quotes, so an added payment phase never under-sizes the tx. gasEstimator: 'payer' trusts the service outright; explicit `gas` overrides both. --- src/eip8168/actions/sendTransaction.test.ts | 54 ++++++++++++++++++ src/eip8168/actions/sendTransaction.ts | 55 ++++++++++++++++--- src/eip8168/decorators/eip8168Actions.test.ts | 1 + 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/src/eip8168/actions/sendTransaction.test.ts b/src/eip8168/actions/sendTransaction.test.ts index ec625eb82f..b93121d20a 100644 --- a/src/eip8168/actions/sendTransaction.test.ts +++ b/src/eip8168/actions/sendTransaction.test.ts @@ -118,6 +118,8 @@ describe('prepareTransactionRequest', () => { payerClient: payer, calls: userCalls, capabilities: { paymasterService: {} }, + // Trust the payer's quote as the gas oracle for this assertion. + gasEstimator: 'payer', nonceSequence: 0n, }, ) @@ -157,11 +159,63 @@ describe('prepareTransactionRequest', () => { context: { policyId: 'x' }, }, }, + gasEstimator: 'payer', nonceSequence: 0n, }) expect(seen[0].params[0].preferredTokens).toEqual([USDC]) expect(seen[0].params[0].context).toEqual({ policyId: 'x' }) }) + + test('defaults to our own node gas estimate (gasEstimator: "self")', async () => { + // Real timers: 'self' also estimates EIP-1559 fees via the node. + vi.useRealTimers() + let estimated = false + const client = createClient({ + chain: mainnet, + transport: custom({ + async request({ method }: { method: string }) { + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_call') return `0x${'0'.repeat(192)}` + if (method === 'eth_getTransactionCount') return '0x0' + if (method === 'eth_estimateGas') { + estimated = true + return '0x30d40' // 200_000 + } + if (method === 'eth_getBlockByNumber') + return { + baseFeePerGas: '0x3b9aca00', + number: '0x1', + hash: `0x${'00'.repeat(32)}`, + transactions: [], + } + if (method === 'eth_maxPriorityFeePerGas') return '0xf4240' + throw new Error(`unexpected chain RPC: ${method}`) + }, + }), + }) + const payer = createPayerClient({ + transport: custom({ + async request({ method }: { method: string }) { + if (method === 'payer_getTerms') return sponsoredTerms + throw new Error(`unexpected ${method}`) + }, + }), + }) + + const { request } = await prepareTransactionRequest(client, { + account, + payerClient: payer, + calls: userCalls, + capabilities: { paymasterService: {} }, + nonceSequence: 0n, + }) + + expect(estimated).toBe(true) + // Sized from our node estimate, not the payer's 0xC350 quote. + expect(request.gas).toBe(200_000n) + // Fees came from the node, not the payer's 0x59682F00 quote. + expect(request.maxPriorityFeePerGas).toBe(1_000_000n) + }) }) describe('sendTransaction', () => { diff --git a/src/eip8168/actions/sendTransaction.ts b/src/eip8168/actions/sendTransaction.ts index e4eabb38a3..c03c4258db 100644 --- a/src/eip8168/actions/sendTransaction.ts +++ b/src/eip8168/actions/sendTransaction.ts @@ -2,6 +2,7 @@ import type { Address } from 'abitype' import type { Client } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' import type { ToAccountReturnType } from '../../eip8130/accounts/toAccount.js' +import { estimateGas } from '../../eip8130/actions/estimateGas.js' import { prepareTransactionRequest as prepareEip8130Request } from '../../eip8130/actions/sendTransaction.js' import { type WaitForTransactionReceiptReturnType, @@ -77,10 +78,27 @@ export type PrepareTransactionRequestParameters = { | { paymasterService?: PaymasterServiceCapability | undefined } | undefined /** - * Gas budget. Falls back to the terms' top-level `gasEstimate.gasLimit`. The - * fill throws if neither is available. + * Explicit gas budget. Overrides {@link gasEstimator}. */ gas?: bigint | undefined + /** + * Where to source the gas limit when `gas` is not given: + * + * - `'self'` (default): our own node estimate via the EIP-8130 + * `eth_estimateGas` extension, consistent with the native self-pay path. + * When the payer also returns a quote, the fill uses `max(ours, payer)` — + * a payer only *adds* work (e.g. a token-payment phase), so its quote is + * never below ours and the max gives natural headroom without under-sizing. + * - `'payer'`: trust the payer service's `payer_getTerms` quote + * (`gasEstimate.gasLimit`) outright as the gas oracle (like trusting the + * node). + * + * Fees: `'payer'` adopts the terms' fee quote; `'self'` lets the node + * estimate EIP-1559 fees. + * + * @default 'self' + */ + gasEstimator?: 'self' | 'payer' | undefined nonceKey?: bigint | undefined nonceSequence?: bigint | undefined } @@ -150,12 +168,33 @@ export async function prepareTransactionRequest( ...(paymasterService?.context ? { context: paymasterService.context } : {}), }) - const gas = - parameters.gas ?? - (terms.gasEstimate ? hexToBigInt(terms.gasEstimate.gasLimit) : undefined) + // Default to our own node estimate; trust the payer's quote only when asked. + const usePayerGas = (parameters.gasEstimator ?? 'self') === 'payer' + const payerGas = terms.gasEstimate + ? hexToBigInt(terms.gasEstimate.gasLimit) + : undefined + let gas = parameters.gas + if (gas === undefined) { + if (usePayerGas) gas = payerGas + else { + const ownGas = await estimateGas(client, { + sender: account.address, + accountChanges, + calls: [calls], + nonceKey: parameters.nonceKey, + senderActorId: account.actorId, + }) + // Take the larger of our estimate and the payer's quote. A payer only + // *adds* work (e.g. a token-payment phase), so its quote should never be + // below ours; the max gives natural headroom without ever under-sizing. + gas = payerGas !== undefined && payerGas > ownGas ? payerGas : ownGas + } + } if (gas === undefined) throw new BaseError( - 'Unable to determine `gas`: the payer returned no top-level `gasEstimate.gasLimit` and no `gas` override was provided.', + 'Unable to determine `gas`: with `gasEstimator: "payer"` the payer ' + + 'returned no top-level `gasEstimate.gasLimit`, and no `gas` override ' + + 'was provided. Use `gasEstimator: "self"` for a node estimate.', ) const request = await prepareEip8130Request(client, { @@ -163,7 +202,9 @@ export async function prepareTransactionRequest( calls: [calls], accountChanges, gas, - ...(terms.gasEstimate + // Fees follow the gas source: adopt the payer's quote only for `'payer'`; + // otherwise let our node estimate EIP-1559 fees. + ...(usePayerGas && terms.gasEstimate ? { maxFeePerGas: hexToBigInt(terms.gasEstimate.maxFeePerGas), maxPriorityFeePerGas: hexToBigInt( diff --git a/src/eip8168/decorators/eip8168Actions.test.ts b/src/eip8168/decorators/eip8168Actions.test.ts index 26feabffd1..da5d0c491b 100644 --- a/src/eip8168/decorators/eip8168Actions.test.ts +++ b/src/eip8168/decorators/eip8168Actions.test.ts @@ -68,6 +68,7 @@ test('binds a default payerClient and exposes client.payer.*', async () => { account, calls: userCalls, capabilities: { paymasterService: {} }, + gasEstimator: 'payer', nonceSequence: 0n, }) expect(capabilities.paymentOptions[0].kind).toBe('sponsored') From 413df23c843322fbe814d4c9e12220a6fb7a8b5a Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 27 Aug 2026 09:49:23 -0400 Subject: [PATCH 89/96] test(eip8130): assert non-null call data when decoding routed calls AaCall.data is optional (defaults to '0x'), but the permission call builders always populate it. Assert non-null at the decode sites so check:types is green. --- src/eip8130/permissions.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/eip8130/permissions.test.ts b/src/eip8130/permissions.test.ts index c4d87f4876..7acd4bb22f 100644 --- a/src/eip8130/permissions.test.ts +++ b/src/eip8130/permissions.test.ts @@ -325,7 +325,7 @@ describe('fulfillGrantPermissions', () => { expect(call.to).toBe(session.manager) const decoded = decodeFunctionData({ abi: policyManagerAbi, - data: call.data, + data: call.data!, }) expect(decoded.functionName).toBe('executeFor') }) @@ -380,8 +380,10 @@ describe('routePermissionedCalls', () => { expect(routed.calls).toHaveLength(1) expect(routed.calls[0]!.to).toBe(session.manager) expect( - decodeFunctionData({ abi: policyManagerAbi, data: routed.calls[0]!.data }) - .functionName, + decodeFunctionData({ + abi: policyManagerAbi, + data: routed.calls[0]!.data!, + }).functionName, ).toBe('execute') }) @@ -400,8 +402,10 @@ describe('routePermissionedCalls', () => { }) expect(routed.role).toBe('pull') expect( - decodeFunctionData({ abi: policyManagerAbi, data: routed.calls[0]!.data }) - .functionName, + decodeFunctionData({ + abi: policyManagerAbi, + data: routed.calls[0]!.data!, + }).functionName, ).toBe('executeFor') }) }) From c7856db811a3447cae2cd666fa19f4af36070b8f Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 27 Aug 2026 09:51:46 -0400 Subject: [PATCH 90/96] docs(eip8130): fix stale estimateGas hint name (senderAuthVerifier -> senderAuthAuthenticator) The estimateGas action never exposed `senderAuthVerifier`; the hint is `senderAuthAuthenticator`. Copy-pasted doc examples would have thrown. --- site/pages/eip8130/sending-a-transaction.mdx | 8 ++++---- site/pages/eip8130/sub-accounts.mdx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/site/pages/eip8130/sending-a-transaction.mdx b/site/pages/eip8130/sending-a-transaction.mdx index f709c9bba9..c40934a932 100644 --- a/site/pages/eip8130/sending-a-transaction.mdx +++ b/site/pages/eip8130/sending-a-transaction.mdx @@ -8,7 +8,7 @@ An EIP-8130 transaction is an `AA_TX_TYPE` (`0x79`) envelope. `sendTransaction` ## Estimate gas -Unlike EVM transactions, the gas budget for an `AA_TX_TYPE` transaction is node-computed and required up front. `estimateGas` prices authentication gas from the *shape* of the auth blob — never a real signature — so you can estimate before signing. Pass a `senderAuthVerifier` hint matching your signer so the node prices the right authenticator: +Unlike EVM transactions, the gas budget for an `AA_TX_TYPE` transaction is node-computed and required up front. `estimateGas` prices authentication gas from the *shape* of the auth blob — never a real signature — so you can estimate before signing. Pass a `senderAuthAuthenticator` hint matching your signer so the node prices the right authenticator: ```ts import { parseEther } from 'viem' @@ -20,7 +20,7 @@ const gas = await estimateGas(client, { accountChanges: [account.createChange], // Phased calls: each inner array is one atomic `executeBatch` phase. calls: [[{ to: recipient, value: parseEther('0.001') }]], - senderAuthVerifier: canonicalAuthenticators.k1, // .p256 / .passkey for other signers + senderAuthAuthenticator: canonicalAuthenticators.k1, // .p256 / .passkey for other signers }) ``` @@ -28,7 +28,7 @@ Pick the authenticator from the signer kind: ```ts import { canonicalAuthenticators } from 'viem/eip8130' -const senderAuthVerifier = +const senderAuthAuthenticator = kind === 'p256' ? canonicalAuthenticators.p256 : kind === 'passkey' ? canonicalAuthenticators.passkey : canonicalAuthenticators.k1 @@ -57,7 +57,7 @@ const gas = await estimateGas(client, { sender: account.address, accountChanges: [account.createChange], calls: [calls], - senderAuthVerifier: canonicalAuthenticators.k1, + senderAuthAuthenticator: canonicalAuthenticators.k1, }) const hash = await sendTransaction(client, { diff --git a/site/pages/eip8130/sub-accounts.mdx b/site/pages/eip8130/sub-accounts.mdx index 23790eb571..cbd0ce0a28 100644 --- a/site/pages/eip8130/sub-accounts.mdx +++ b/site/pages/eip8130/sub-accounts.mdx @@ -83,7 +83,7 @@ const hash = await sendTransaction(client, { ``` :::note -The delegate authenticator validates a signature produced for the linked account, so authentication gas is priced from the delegate blob's shape. Pass `senderAuthVerifier: canonicalAuthenticators.delegate` to [`estimateGas`](/eip8130/sending-a-transaction#estimate-gas) when pricing delegate-signed transactions. +The delegate authenticator validates a signature produced for the linked account, so authentication gas is priced from the delegate blob's shape. Pass `senderAuthAuthenticator: canonicalAuthenticators.delegate` to [`estimateGas`](/eip8130/sending-a-transaction#estimate-gas) when pricing delegate-signed transactions. ::: ## Revoking a link From add52b31c1f4bb3b814d57f2ffa3f7302f5f1047 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 27 Aug 2026 09:59:27 -0400 Subject: [PATCH 91/96] refactor(eip8130): rename Account Configuration references to Keystore The system contract is now the Keystore. Renames the exported ABI `accountConfigurationAbi` -> `keystoreAbi`, the `accountConfigCalls` module -> `keystoreCalls`, and all prose/docstrings referencing "Account Configuration"/`ACCOUNT_CONFIG_ADDRESS` to the Keystore. On-chain function names and `keystoreAddress` are unchanged. --- src/eip8130/abis.ts | 7 +++-- src/eip8130/accounts/toSmartAccount.test.ts | 6 ++--- src/eip8130/accounts/toSmartAccount.ts | 8 +++--- src/eip8130/actions/getActorConfig.ts | 6 ++--- src/eip8130/actions/getConfigSequence.ts | 6 ++--- src/eip8130/actions/getLockStatus.ts | 6 ++--- src/eip8130/actions/getPolicy.ts | 4 +-- src/eip8130/actions/getTransaction.ts | 2 +- src/eip8130/actions/isLocked.ts | 6 ++--- src/eip8130/chains.ts | 2 +- src/eip8130/constants.ts | 9 +++---- src/eip8130/index.ts | 26 +++++++++---------- src/eip8130/lock.test.ts | 10 +++---- src/eip8130/permissions.test.ts | 6 ++--- src/eip8130/policies.ts | 2 +- src/eip8130/queries.test.ts | 10 +++---- src/eip8130/utils/actorChangeData.ts | 2 +- src/eip8130/utils/computeAddress.ts | 2 +- ...figCalls.test.ts => keystoreCalls.test.ts} | 10 +++---- ...accountConfigCalls.ts => keystoreCalls.ts} | 10 +++---- .../utils/signedActorChangesSignature.ts | 2 +- 21 files changed, 70 insertions(+), 72 deletions(-) rename src/eip8130/utils/{accountConfigCalls.test.ts => keystoreCalls.test.ts} (95%) rename src/eip8130/utils/{accountConfigCalls.ts => keystoreCalls.ts} (92%) diff --git a/src/eip8130/abis.ts b/src/eip8130/abis.ts index deae7b0505..6e79066cb7 100644 --- a/src/eip8130/abis.ts +++ b/src/eip8130/abis.ts @@ -1,10 +1,9 @@ import { parseAbi } from 'abitype' /** - * ABI for the EIP-8130 Account Configuration system contract - * (`IAccountConfiguration`) at `ACCOUNT_CONFIG_ADDRESS`. + * ABI for the EIP-8130 Keystore system contract at `keystoreAddress`. */ -export const accountConfigurationAbi = parseAbi([ +export const keystoreAbi = parseAbi([ 'struct InitialActor { bytes32 actorId; address authenticator; uint16 scope; bytes policyData; }', 'struct ActorConfig { address authenticator; uint48 expiry; uint16 scope; }', 'struct Actor { bytes32 actorId; ActorConfig config; bytes policyData; }', @@ -43,7 +42,7 @@ export const accountConfigurationAbi = parseAbi([ * ABI for the canonical EIP-8130 wallet implementation * (`BackwardCompatibleERC4337Account`) — the account behind the ERC-1167 proxy. * Used for ERC-4337 execution on non-8130 chains. Validation is delegated to the - * Account Configuration contract via `authenticateActor`. + * Keystore contract via `authenticateActor`. */ export const erc4337AccountAbi = parseAbi([ 'struct Call { address target; uint256 value; bytes data; }', diff --git a/src/eip8130/accounts/toSmartAccount.test.ts b/src/eip8130/accounts/toSmartAccount.test.ts index 566d34102a..a8d61f918f 100644 --- a/src/eip8130/accounts/toSmartAccount.test.ts +++ b/src/eip8130/accounts/toSmartAccount.test.ts @@ -6,7 +6,7 @@ import { custom } from '../../clients/transports/custom.js' import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' import { slice } from '../../utils/data/slice.js' import { recoverMessageAddress } from '../../utils/signature/recoverMessageAddress.js' -import { accountConfigurationAbi } from '../abis.js' +import { keystoreAbi } from '../abis.js' import { ecrecoverAuthenticator } from '../constants.js' import type { AaActor } from '../types/transaction.js' import { computeAddress } from '../utils/computeAddress.js' @@ -59,12 +59,12 @@ describe('toSmartAccount', () => { ) }) - test('getFactoryArgs -> AccountConfiguration.createAccount', async () => { + test('getFactoryArgs -> Keystore.createAccount', async () => { const account = await toSmartAccount(base) const { factory, factoryData } = await account.getFactoryArgs() expect(factory).toMatch(/^0x[0-9a-fA-F]{40}$/) const decoded = decodeFunctionData({ - abi: accountConfigurationAbi, + abi: keystoreAbi, data: factoryData!, }) expect(decoded.functionName).toBe('createAccount') diff --git a/src/eip8130/accounts/toSmartAccount.ts b/src/eip8130/accounts/toSmartAccount.ts index 6649a572ba..a98689d6df 100644 --- a/src/eip8130/accounts/toSmartAccount.ts +++ b/src/eip8130/accounts/toSmartAccount.ts @@ -22,8 +22,8 @@ import { getAction } from '../../utils/getAction.js' import { erc4337AccountAbi } from '../abis.js' import { ecrecoverAuthenticator } from '../constants.js' import type { AaActor } from '../types/transaction.js' -import { toFactoryArgs } from '../utils/accountConfigCalls.js' import { computeAddress } from '../utils/computeAddress.js' +import { toFactoryArgs } from '../utils/keystoreCalls.js' import { erc1167Bytecode } from '../utils/proxy.js' export type ToSmartAccountParameters< @@ -104,9 +104,9 @@ export type ToSmartAccountReturnType< * account can be used on non-8130 chains through a `bundlerClient`. * * Execution goes through `executeBatch(Call[])` on the canonical - * `BackwardCompatibleERC4337Account` wallet; deployment uses the Account - * Configuration contract as the ERC-4337 factory (`createAccount`); and - * signature validation is delegated to the Account Configuration system via the + * `BackwardCompatibleERC4337Account` wallet; deployment uses the Keystore + * contract as the ERC-4337 factory (`createAccount`); and + * signature validation is delegated to the Keystore system via the * `authenticator || data` auth format. * * @example diff --git a/src/eip8130/actions/getActorConfig.ts b/src/eip8130/actions/getActorConfig.ts index f23de50a24..c211a9b24e 100644 --- a/src/eip8130/actions/getActorConfig.ts +++ b/src/eip8130/actions/getActorConfig.ts @@ -6,7 +6,7 @@ import type { Transport } from '../../clients/transports/createTransport.js' import type { Account } from '../../types/account.js' import type { Chain } from '../../types/chain.js' import type { Hex } from '../../types/misc.js' -import { accountConfigurationAbi } from '../abis.js' +import { keystoreAbi } from '../abis.js' import { actorScope, keystoreAddress } from '../constants.js' export type GetActorConfigParameters = { @@ -29,7 +29,7 @@ export type GetActorConfigReturnType = { /** * Reads an actor's configuration (authenticator, scope, expiry, policy type) - * from the `AccountConfiguration` system contract (`getActorConfig`). Use it to + * from the `Keystore` system contract (`getActorConfig`). Use it to * inspect owners / session keys, e.g. to enrich a "sign with" picker. * * @example @@ -57,7 +57,7 @@ export async function getActorConfig< const config = await readContract(client, { address: keystoreAddress, - abi: accountConfigurationAbi, + abi: keystoreAbi, functionName: 'getActorConfig', args: [account, actorId], }) diff --git a/src/eip8130/actions/getConfigSequence.ts b/src/eip8130/actions/getConfigSequence.ts index 3b78291864..332c6b9f5a 100644 --- a/src/eip8130/actions/getConfigSequence.ts +++ b/src/eip8130/actions/getConfigSequence.ts @@ -4,7 +4,7 @@ import type { Client } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' import type { Account } from '../../types/account.js' import type { Chain } from '../../types/chain.js' -import { accountConfigurationAbi } from '../abis.js' +import { keystoreAbi } from '../abis.js' import { keystoreAddress, unsequencedLocalHalf } from '../constants.js' export type GetConfigSequenceParameters = { @@ -32,7 +32,7 @@ export type GetConfigSequenceReturnType = { /** * Reads the current config-change sequences for an EIP-8130 account from the - * `AccountConfiguration` system contract. Use the returned `local` value as + * `Keystore` system contract. Use the returned `local` value as * the `sequence` parameter when building an `AccountChange` — it is the NEXT * expected sequence, not the last one used. * @@ -57,7 +57,7 @@ export async function getConfigSequence< const result = await readContract(client, { address: keystoreAddress, - abi: accountConfigurationAbi, + abi: keystoreAbi, functionName: 'getChangeSequences', args: [account], }) diff --git a/src/eip8130/actions/getLockStatus.ts b/src/eip8130/actions/getLockStatus.ts index 31ea484dda..621a264bd7 100644 --- a/src/eip8130/actions/getLockStatus.ts +++ b/src/eip8130/actions/getLockStatus.ts @@ -5,7 +5,7 @@ import type { Client } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' import type { Account } from '../../types/account.js' import type { Chain } from '../../types/chain.js' -import { accountConfigurationAbi } from '../abis.js' +import { keystoreAbi } from '../abis.js' import { keystoreAddress } from '../constants.js' export type GetLockStatusParameters = { @@ -26,7 +26,7 @@ export type GetLockStatusReturnType = { /** * Reads the full lock status of an EIP-8130 account from the - * `AccountConfiguration` system contract (`getLockStatus`). + * `Keystore` system contract (`getLockStatus`). * * @example * ```ts @@ -52,7 +52,7 @@ export async function getLockStatus< const [locked, hasInitiatedUnlock, unlocksAt, unlockDelay] = await readContract(client, { address: keystoreAddress, - abi: accountConfigurationAbi, + abi: keystoreAbi, functionName: 'getLockStatus', args: [account], }) diff --git a/src/eip8130/actions/getPolicy.ts b/src/eip8130/actions/getPolicy.ts index 78cdcfc184..84088edd6a 100644 --- a/src/eip8130/actions/getPolicy.ts +++ b/src/eip8130/actions/getPolicy.ts @@ -6,7 +6,7 @@ import type { Transport } from '../../clients/transports/createTransport.js' import type { Account } from '../../types/account.js' import type { Chain } from '../../types/chain.js' import type { Hex } from '../../types/misc.js' -import { accountConfigurationAbi } from '../abis.js' +import { keystoreAbi } from '../abis.js' import { keystoreAddress } from '../constants.js' export type GetPolicyParameters = { @@ -59,7 +59,7 @@ export async function getPolicy< // `policyCommitment` are zero for a non-live / ungated actor. const [, policyManager, policyCommitment] = await readContract(client, { address: keystoreAddress, - abi: accountConfigurationAbi, + abi: keystoreAbi, functionName: 'getActor', args: [account, actorId], }) diff --git a/src/eip8130/actions/getTransaction.ts b/src/eip8130/actions/getTransaction.ts index 94cdf89424..946c17c583 100644 --- a/src/eip8130/actions/getTransaction.ts +++ b/src/eip8130/actions/getTransaction.ts @@ -37,7 +37,7 @@ export type Transaction = { gas: bigint /** Ordered list of call phases. */ calls: AaCalls - /** Account-configuration changes bundled in this transaction. */ + /** Account changes bundled in this transaction. */ accountChanges: readonly AaAccountChange[] /** Opaque tx metadata (echoed in the receipt). */ metadata: Hex diff --git a/src/eip8130/actions/isLocked.ts b/src/eip8130/actions/isLocked.ts index 6b15d3d3fe..1b39e1ea6b 100644 --- a/src/eip8130/actions/isLocked.ts +++ b/src/eip8130/actions/isLocked.ts @@ -5,7 +5,7 @@ import type { Client } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' import type { Account } from '../../types/account.js' import type { Chain } from '../../types/chain.js' -import { accountConfigurationAbi } from '../abis.js' +import { keystoreAbi } from '../abis.js' import { keystoreAddress } from '../constants.js' export type IsLockedParameters = { @@ -17,7 +17,7 @@ export type IsLockedReturnType = boolean /** * Reads whether an EIP-8130 account is currently locked, from the - * `AccountConfiguration` system contract (`isLocked`). For the full status + * `Keystore` system contract (`isLocked`). For the full status * (unlock timing, delay), use {@link getLockStatus}. * * @example @@ -42,7 +42,7 @@ export async function isLocked< return readContract(client, { address: keystoreAddress, - abi: accountConfigurationAbi, + abi: keystoreAbi, functionName: 'isLocked', args: [account], }) diff --git a/src/eip8130/chains.ts b/src/eip8130/chains.ts index 863849e46f..339bc30bfd 100644 --- a/src/eip8130/chains.ts +++ b/src/eip8130/chains.ts @@ -5,7 +5,7 @@ * @remarks * EIP-8130 is enabled per-chain. Accounts are portable: on an 8130-enabled chain * use the `AA_TX_TYPE` flow; on other chains the same account operates through an - * alternative AA mechanism (e.g. ERC-4337) with the `AccountConfiguration` + * alternative AA mechanism (e.g. ERC-4337) with the `Keystore` * contract as its factory. * * This set is empty by default (no networks have shipped 8130 yet). Populate it diff --git a/src/eip8130/constants.ts b/src/eip8130/constants.ts index 5b72416274..4810164791 100644 --- a/src/eip8130/constants.ts +++ b/src/eip8130/constants.ts @@ -98,7 +98,7 @@ export const changeType = { export const unsequencedLocalHalf = 0xffff_ffffn /** - * Actor scope permission bitmask values (base/eip-8130 `AccountConfiguration`). + * Actor scope permission bitmask values (base/eip-8130 `Keystore`). * * `0x00` (unrestricted) is admin: an actor is admin iff `scope == 0`. There is * no `SCOPE_SIGNATURE` / `SCOPE_CONFIG` bit — ERC-1271 signing and config rights @@ -122,7 +122,7 @@ export const actorScope = { export const scopeUnrestricted = 0x00 /** - * `AccountState.flags` bits (base/eip-8130 `AccountConfiguration`). + * `AccountState.flags` bits (base/eip-8130 `Keystore`). * * The lock state is derived from these flags plus the `lockUnion` field: while * `unlockInitiated` is clear, `lockUnion` holds the configured unlock delay @@ -245,9 +245,8 @@ export const txContextAddress = '0x813000000000000000000000000000000000aa02' satisfies Hex /** - * The EIP-8130 keystore (`AccountConfiguration`) system contract address - * (`ACCOUNT_CONFIG_ADDRESS`), also used as the CREATE2 deployer for account - * address derivation. + * The EIP-8130 Keystore system contract address, also used as the CREATE2 + * deployer for account address derivation. * * @remarks * There is a single keystore: it is **enshrined** in the execution client and is diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index 280c2da26c..fcf3bbd9d5 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -1,8 +1,8 @@ // biome-ignore lint/performance/noBarrelFile: entrypoint export { - accountConfigurationAbi, authenticatorAbi, erc4337AccountAbi, + keystoreAbi, nonceManagerAbi, transactionContextAbi, } from './abis.js' @@ -258,18 +258,6 @@ export type { TransactionSerializable8130, TransactionSerialized8130, } from './types/transaction.js' -export { - type EncodeApplySignedAccountChangesDataErrorType, - type EncodeApplySignedAccountChangesDataParameters, - type EncodeCreateAccountDataErrorType, - type EncodeCreateAccountDataParameters, - encodeApplySignedAccountChangesData, - encodeCreateAccountData, - type ToFactoryArgsErrorType, - type ToFactoryArgsParameters, - type ToFactoryArgsReturnType, - toFactoryArgs, -} from './utils/accountConfigCalls.js' export { type DecodeAuthorizeActorPayloadErrorType, type DecodedAuthorizeActorPayload, @@ -314,6 +302,18 @@ export { getPayerSignatureHash, getSenderSignatureHash, } from './utils/hashTransaction.js' +export { + type EncodeApplySignedAccountChangesDataErrorType, + type EncodeApplySignedAccountChangesDataParameters, + type EncodeCreateAccountDataErrorType, + type EncodeCreateAccountDataParameters, + encodeApplySignedAccountChangesData, + encodeCreateAccountData, + type ToFactoryArgsErrorType, + type ToFactoryArgsParameters, + type ToFactoryArgsReturnType, + toFactoryArgs, +} from './utils/keystoreCalls.js' export { type ParseTransactionErrorType, parseTransaction, diff --git a/src/eip8130/lock.test.ts b/src/eip8130/lock.test.ts index 4e3b248969..d7dbe9929c 100644 --- a/src/eip8130/lock.test.ts +++ b/src/eip8130/lock.test.ts @@ -4,7 +4,7 @@ import { createClient } from '../clients/createClient.js' import { custom } from '../clients/transports/custom.js' import { encodeAbiParameters } from '../utils/abi/encodeAbiParameters.js' import { encodeFunctionResult } from '../utils/abi/encodeFunctionResult.js' -import { accountConfigurationAbi } from './abis.js' +import { keystoreAbi } from './abis.js' import { getLockStatus } from './actions/getLockStatus.js' import { isLocked } from './actions/isLocked.js' import { changeType } from './constants.js' @@ -52,10 +52,10 @@ function lockClient(handlers: Record) { } describe('getLockStatus', () => { - test('decodes the AccountConfiguration.getLockStatus tuple', async () => { + test('decodes the Keystore.getLockStatus tuple', async () => { const client = lockClient({ eth_call: encodeFunctionResult({ - abi: accountConfigurationAbi, + abi: keystoreAbi, functionName: 'getLockStatus', result: [true, true, 1_800_000_000, 3600], }), @@ -71,10 +71,10 @@ describe('getLockStatus', () => { }) describe('isLocked', () => { - test('decodes the AccountConfiguration.isLocked bool', async () => { + test('decodes the Keystore.isLocked bool', async () => { const client = lockClient({ eth_call: encodeFunctionResult({ - abi: accountConfigurationAbi, + abi: keystoreAbi, functionName: 'isLocked', result: true, }), diff --git a/src/eip8130/permissions.test.ts b/src/eip8130/permissions.test.ts index 7acd4bb22f..08b8e30238 100644 --- a/src/eip8130/permissions.test.ts +++ b/src/eip8130/permissions.test.ts @@ -7,7 +7,7 @@ import { zeroAddress } from '../constants/address.js' import type { Permission } from '../experimental/erc7715/types/permission.js' import { decodeFunctionData } from '../utils/abi/decodeFunctionData.js' import { encodeFunctionResult } from '../utils/abi/encodeFunctionResult.js' -import { accountConfigurationAbi } from './abis.js' +import { keystoreAbi } from './abis.js' import { actorScope, ecrecoverAuthenticator, @@ -41,11 +41,11 @@ function actorConfigClient(authenticator: string) { if (method === 'eth_chainId') return '0x1' if (method === 'eth_call') { const { functionName } = decodeFunctionData({ - abi: accountConfigurationAbi as Abi, + abi: keystoreAbi as Abi, data: params[0].data, }) return encodeFunctionResult({ - abi: accountConfigurationAbi as Abi, + abi: keystoreAbi as Abi, functionName, result: { authenticator, expiry: 0, scope: 0 } as never, }) diff --git a/src/eip8130/policies.ts b/src/eip8130/policies.ts index a7c06af31a..f019c7bd97 100644 --- a/src/eip8130/policies.ts +++ b/src/eip8130/policies.ts @@ -52,7 +52,7 @@ import type { AaCall } from './types/transaction.js' * ABI for the example `PolicyManager` reference contract (base/eip-8130#43). * * There is no `install` step: the account's signed actor change (which stores - * `policy_manager` + `policy_commitment` in AccountConfiguration) *is* the + * `policy_manager` + `policy_commitment` in Keystore) *is* the * authorization. Every execute path takes the full {@link PolicyBinding}; the * manager recomputes its commitment and compares it to the live signed * commitment, authenticating config, validity window, and owning account in one diff --git a/src/eip8130/queries.test.ts b/src/eip8130/queries.test.ts index 324affb4af..ca4b3f1f50 100644 --- a/src/eip8130/queries.test.ts +++ b/src/eip8130/queries.test.ts @@ -5,7 +5,7 @@ import { createClient } from '../clients/createClient.js' import { custom } from '../clients/transports/custom.js' import { decodeFunctionData } from '../utils/abi/decodeFunctionData.js' import { encodeFunctionResult } from '../utils/abi/encodeFunctionResult.js' -import { accountConfigurationAbi } from './abis.js' +import { keystoreAbi } from './abis.js' import { getActorConfig } from './actions/getActorConfig.js' import { getPolicy } from './actions/getPolicy.js' import { getSessionSpend } from './actions/getSessionSpend.js' @@ -79,7 +79,7 @@ describe('getSessionSpend', () => { describe('getActorConfig', () => { test('decodes the ActorConfig struct', async () => { - const client = readClient(accountConfigurationAbi, { + const client = readClient(keystoreAbi, { getActorConfig: { authenticator: canonicalAuthenticators.p256, scope: 2, @@ -100,7 +100,7 @@ describe('isActor', () => { // The finalized Keystore has no `isActor` view; liveness is derived from // `getActorConfig` (a non-zero authenticator ⇒ bound). test('derives liveness from a non-zero authenticator', async () => { - const client = readClient(accountConfigurationAbi, { + const client = readClient(keystoreAbi, { getActorConfig: { authenticator: canonicalAuthenticators.p256, scope: 2, @@ -111,7 +111,7 @@ describe('isActor', () => { }) test('returns false for an all-zero (unbound) config', async () => { - const client = readClient(accountConfigurationAbi, { + const client = readClient(keystoreAbi, { getActorConfig: { authenticator: '0x0000000000000000000000000000000000000000', scope: 0, @@ -127,7 +127,7 @@ describe('getPolicy', () => { // returning (config, policyManager, policyCommitment). test('decodes (manager, commitment) from the combined getActor read', async () => { const manager = '0x00000000000000000000000000000000000000dd' - const client = readClient(accountConfigurationAbi, { + const client = readClient(keystoreAbi, { getActor: [ { authenticator: canonicalAuthenticators.p256, diff --git a/src/eip8130/utils/actorChangeData.ts b/src/eip8130/utils/actorChangeData.ts index 1479e49b05..16630a0170 100644 --- a/src/eip8130/utils/actorChangeData.ts +++ b/src/eip8130/utils/actorChangeData.ts @@ -54,7 +54,7 @@ export type EncodeChangePayloadErrorType = * @remarks * The `payload` is ABI-encoded (not RLP) so the same blob is decoded * identically by the native protocol and by - * `AccountConfiguration.applySignedAccountChanges`. It is also the value hashed + * `Keystore.applySignedAccountChanges`. It is also the value hashed * (`keccak256(payload)`) into the batch signature digest (see * {@link hashAccountChanges}). Policy presence is the `SCOPE_POLICY` bit in * `scope`; `policyData` is empty unless that bit is set (then `manager (20) || diff --git a/src/eip8130/utils/computeAddress.ts b/src/eip8130/utils/computeAddress.ts index 3406728d44..d17ff15969 100644 --- a/src/eip8130/utils/computeAddress.ts +++ b/src/eip8130/utils/computeAddress.ts @@ -72,7 +72,7 @@ export type ComputeAddressErrorType = * actors_commitment = keccak256(leaf_0 || leaf_1 || ... || leaf_{n-1}) * effective_salt = keccak256(user_salt || actors_commitment) * deployment_code = DEPLOYMENT_HEADER(len(code)) || code - * address = keccak256(0xff || ACCOUNT_CONFIG_ADDRESS || effective_salt || keccak256(deployment_code))[12:] + * address = keccak256(0xff || KEYSTORE_ADDRESS || effective_salt || keccak256(deployment_code))[12:] * ``` * * The commitment is a "hash-the-leaves-then-hash-the-list" scheme (EIP-8130 diff --git a/src/eip8130/utils/accountConfigCalls.test.ts b/src/eip8130/utils/keystoreCalls.test.ts similarity index 95% rename from src/eip8130/utils/accountConfigCalls.test.ts rename to src/eip8130/utils/keystoreCalls.test.ts index e38061f3b6..64f48c686b 100644 --- a/src/eip8130/utils/accountConfigCalls.test.ts +++ b/src/eip8130/utils/keystoreCalls.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest' import type { Hex } from '../../types/misc.js' import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' -import { accountConfigurationAbi } from '../abis.js' +import { keystoreAbi } from '../abis.js' import { eip8130ChainIds, is8130Enabled, @@ -10,12 +10,12 @@ import { } from '../chains.js' import { keystoreAddress } from '../constants.js' import type { AaActor, AaChange } from '../types/transaction.js' +import { computeAddress } from './computeAddress.js' import { encodeApplySignedAccountChangesData, encodeCreateAccountData, toFactoryArgs, -} from './accountConfigCalls.js' -import { computeAddress } from './computeAddress.js' +} from './keystoreCalls.js' const actor: AaActor = { actorId: '0x0000000000000000000000000000000000000000000000000000000000000001', @@ -54,7 +54,7 @@ describe('toFactoryArgs (ERC-4337 factory)', () => { expect(factoryData).toBe(encodeCreateAccountData(params)) const { functionName, args } = decodeFunctionData({ - abi: accountConfigurationAbi, + abi: keystoreAbi, data: factoryData, }) expect(functionName).toBe('createAccount') @@ -101,7 +101,7 @@ describe('encodeApplySignedAccountChangesData (portable path)', () => { signature: '0xfeed', }) const decoded = decodeFunctionData({ - abi: accountConfigurationAbi, + abi: keystoreAbi, data, }) expect(decoded.functionName).toBe('applySignedAccountChanges') diff --git a/src/eip8130/utils/accountConfigCalls.ts b/src/eip8130/utils/keystoreCalls.ts similarity index 92% rename from src/eip8130/utils/accountConfigCalls.ts rename to src/eip8130/utils/keystoreCalls.ts index 49a93498cb..ff47da4008 100644 --- a/src/eip8130/utils/accountConfigCalls.ts +++ b/src/eip8130/utils/keystoreCalls.ts @@ -5,7 +5,7 @@ import { type EncodeFunctionDataErrorType, encodeFunctionData, } from '../../utils/abi/encodeFunctionData.js' -import { accountConfigurationAbi } from '../abis.js' +import { keystoreAbi } from '../abis.js' import { keystoreAddress } from '../constants.js' import type { AaActor, @@ -44,7 +44,7 @@ export type EncodeCreateAccountDataErrorType = | ErrorType /** - * Encodes calldata for `AccountConfiguration.createAccount` — the ERC-4337 + * Encodes calldata for `Keystore.createAccount` — the ERC-4337 * factory call that deploys an EIP-8130 account on a non-8130 chain (and is the * `factoryData` returned by {@link toFactoryArgs}). */ @@ -53,7 +53,7 @@ export function encodeCreateAccountData( ): Hex { const { userSalt, code, initialActors } = parameters return encodeFunctionData({ - abi: accountConfigurationAbi, + abi: keystoreAbi, functionName: 'createAccount', args: [userSalt, code, toInitialActors(initialActors)], }) @@ -103,7 +103,7 @@ export type EncodeApplySignedAccountChangesDataErrorType = | ErrorType /** - * Encodes calldata for `AccountConfiguration.applySignedAccountChanges` — the + * Encodes calldata for `Keystore.applySignedAccountChanges` — the * portable (any-chain) path to apply a signed batch via plain EVM execution. * Pair with {@link signAccountChanges} to produce the `signature`. */ @@ -112,7 +112,7 @@ export function encodeApplySignedAccountChangesData( ): Hex { const { account, channel, sequence, changes, signature } = parameters return encodeFunctionData({ - abi: accountConfigurationAbi, + abi: keystoreAbi, functionName: 'applySignedAccountChanges', args: [ account, diff --git a/src/eip8130/utils/signedActorChangesSignature.ts b/src/eip8130/utils/signedActorChangesSignature.ts index 6886e6156c..ad9db4dbe3 100644 --- a/src/eip8130/utils/signedActorChangesSignature.ts +++ b/src/eip8130/utils/signedActorChangesSignature.ts @@ -72,7 +72,7 @@ export type EncodeSignedActorChangesSignatureErrorType = * `userOpHash` (may be produced by a key the changes just added/rotated to). * * Use the result as a UserOperation's `signature`. During `validateUserOp` the - * account first applies each set in order via `AccountConfiguration.applySignedActorChanges`, + * account first applies each set in order via `Keystore.applySignedActorChanges`, * then authenticates the op via `opAuth`. Sets are applied in array order, so a * later set may rely on an actor authorized by an earlier one (e.g. owner → key B, * then key B → key C). From 7a07a585ce0079f566bb3fb93c158468c749c9d2 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 27 Aug 2026 10:52:13 -0400 Subject: [PATCH 92/96] docs(eip8130): showcase native core actions, decorators, and capability payer API - Overview: replace the "no .extend() decorator" note with the three integration styles (eip8130ChainConfig, eip8130Actions/eip8168Actions decorators, standalone actions). - Sending: document core client.sendTransaction via eip8130ChainConfig (gas auto-estimated) alongside the standalone actions. - Payer services: lead with the capability API (client.payer.prepare/ send, gasEstimator self|payer max-of, sendTransactionSync); frame sendSponsoredCalls as the underlying engine. - Receipts: note core receipts surface eip8130 fields via the chain config. --- site/pages/eip8130.mdx | 41 +++++++++++- site/pages/eip8130/payer-services.mdx | 64 ++++++++++++++++++- site/pages/eip8130/receipts.mdx | 4 ++ site/pages/eip8130/sending-a-transaction.mdx | 21 ++++++ .../pages/eip8130/sponsoring-transactions.mdx | 8 ++- 5 files changed, 133 insertions(+), 5 deletions(-) diff --git a/site/pages/eip8130.mdx b/site/pages/eip8130.mdx index 8ff19782c6..84be10405e 100644 --- a/site/pages/eip8130.mdx +++ b/site/pages/eip8130.mdx @@ -63,13 +63,18 @@ import { ## Setup -EIP-8130 actions are standalone — they take a Viem `Client` as their first argument (there is no `.extend()` client decorator). Configure a client for your 8130-enabled chain and register the chain id so `is8130Enabled` can route correctly: +There are three ways to use EIP-8130, from most native to most explicit. They interoperate: pick per call site. + +### 1. Native Core Actions (`eip8130ChainConfig`) + +Spread [`eip8130ChainConfig`](/eip8130/sending-a-transaction#native-core-actions) into your chain and EIP-8130 flows through the standard viem actions you already know. Core `client.sendTransaction` submits a native `AA_TX_TYPE` for an EIP-8130 account (gas is estimated for you), and core `client.getTransactionReceipt` / `client.waitForTransactionReceipt` return the `eip8130` receipt fields. ```ts import { createClient, http, defineChain } from 'viem' -import { register8130Chains } from 'viem/eip8130' +import { eip8130ChainConfig, register8130Chains } from 'viem/eip8130' export const vibenet = defineChain({ + ...eip8130ChainConfig, id: 84_538_453, name: 'Vibenet Devnet', nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, @@ -82,6 +87,38 @@ export const client = createClient({ chain: vibenet, transport: http() }) register8130Chains(vibenet.id) ``` +### 2. Namespaced Client Decorators + +`.extend(eip8130Actions())` adds the full read/write suite under `client.eip8130.*`, and `.extend(eip8168Actions())` adds the [payer service](/eip8130/payer-services) flow under `client.payer.*`. These are namespaced (rather than folded into core) because viem's protected actions like `sendTransaction` require core-conforming signatures. + +```ts +import { createClient, http } from 'viem' +import { eip8130Actions } from 'viem/eip8130' +import { eip8168Actions, createPayerClient } from 'viem/eip8168' +import { vibenet } from './viem.config' + +const payerClient = createPayerClient({ url: 'https://payer.example.com/v1' }) + +export const client = createClient({ chain: vibenet, transport: http() }) + .extend(eip8130Actions()) // client.eip8130.* + .extend(eip8168Actions({ payerClient })) // client.payer.* + +const hash = await client.eip8130.sendTransaction({ account, calls, gas: 200_000n }) +``` + +### 3. Standalone Actions + +Every action is also importable directly and takes a `Client` as its first argument. Handy when you don't want to extend the client. + +```ts +import { createClient, http } from 'viem' +import { sendTransaction } from 'viem/eip8130' +import { vibenet } from './viem.config' + +const client = createClient({ chain: vibenet, transport: http() }) +const hash = await sendTransaction(client, { account, calls, gas: 200_000n }) +``` + ## Deployment addresses Every protocol contract is deployed through a deterministic CREATE2 factory with a per-contract mined salt, so each address is a pure function of its bytecode — **identical on every chain**. The `viem/eip8130` actions already default to this canonical set, so you normally don't pass any addresses at all. diff --git a/site/pages/eip8130/payer-services.mdx b/site/pages/eip8130/payer-services.mdx index 7d991b9e53..8686c1e74d 100644 --- a/site/pages/eip8130/payer-services.mdx +++ b/site/pages/eip8130/payer-services.mdx @@ -31,6 +31,68 @@ import { createPayerClient } from 'viem/eip8168' const payerClient = createPayerClient({ url: 'https://payer.example.com/v1' }) ``` +## Fill, Then Choose Terms + +The recommended flow treats payment as a **capability of filling a transaction**, mirroring the native fill then send pattern. Extend a client with [`eip8168Actions`](/eip8130#2-namespaced-client-decorators) to get `client.payer.*`: + +1. `prepareTransactionRequest` fills the transaction and returns the payer's offers on `capabilities.paymentOptions` (a component of the fill). +2. Pick one and thread it into `sendTransaction` via `capabilities.paymentOption`. + +```ts +import { createClient, http } from 'viem' +import { createPayerClient, eip8168Actions } from 'viem/eip8168' +import { vibenet } from './viem.config' + +const payerClient = createPayerClient({ url: 'https://payer.example.com/v1' }) +const client = createClient({ chain: vibenet, transport: http() }) + .extend(eip8168Actions({ payerClient })) + +// 1. Fill; offers come back as a component of the fill. +const { capabilities } = await client.payer.prepareTransactionRequest({ + account, + calls: [{ to: recipient, data }], + capabilities: { paymasterService: { preferredTokens: [usdc] } }, +}) + +// 2. Choose an offer and send. +const { transactionHash, tokenCharged } = await client.payer.sendTransaction({ + account, + calls: [{ to: recipient, data }], + capabilities: { + paymentOption: capabilities.paymentOptions[0], + gasEstimate: capabilities.gasEstimate, + }, +}) +``` + +These are also importable standalone from `viem/eip8168` (`prepareTransactionRequest`, `sendTransaction`), taking the `client` as the first argument and `payerClient` in the parameters. + +### Who Estimates Gas + +By default the fill estimates gas with **our own node** (`gasEstimator: 'self'`), and when the payer also quotes it uses `max(ours, payer)`, so a payer that adds a token-payment phase never under-sizes the transaction. Pass `gasEstimator: 'payer'` to trust the service's quote outright, or pin `gas` to skip estimation entirely. + +```ts +const { capabilities } = await client.payer.prepareTransactionRequest({ + account, + calls: [{ to: recipient, data }], + capabilities: { paymasterService: {} }, + gasEstimator: 'payer', // trust the service (default is 'self') +}) +``` + +### Awaiting the Receipt + +`sendTransactionSync` submits through the payer and then awaits the EIP-8130 receipt (threading the signed `validBefore` for fast expiry detection): + +```ts +const { transactionHash, receipt } = await client.payer.sendTransactionSync({ + account, + calls: [{ to: recipient, data }], + capabilities: { paymentOption: capabilities.paymentOptions[0], gasEstimate: capabilities.gasEstimate }, +}) +receipt.eip8130.phaseStatuses // ['0x1'] +``` + ## Discovering payers A wallet often has more than one place to get sponsorship: the chain's node RPC may serve `payer_*` itself, a **block builder** integrated with the sequencer (e.g. flashblocks) may expose it, the app may run its own payer, and the wallet may inject one. `createAggregatePayerClient` queries them all **in parallel** and presents the result as a single `PayerClient`, so it drops straight into `sendSponsoredCalls`. @@ -77,7 +139,7 @@ The merged `gasEstimate` is the worst-case (largest `gasLimit`) across sources; ## End-to-end: `sendSponsoredCalls` -`sendSponsoredCalls` runs the whole flow: fetch terms, select an offer, build the phases (including any phase-0 token transfer), sign `sender_auth` with the payer named and `payer_auth` empty, then hand off to the payer to co-sign and submit. +`sendSponsoredCalls` is the engine underneath `client.payer.sendTransaction`: it runs the whole flow in one call, fetching terms itself rather than taking a pre-selected offer. Reach for it when you want the library to fetch and select in a single step; use the capability API above when you want to surface offers to the user between fill and send. It fetches terms, selects an offer, builds the phases (including any phase-0 token transfer), signs `sender_auth` with the payer named and `payer_auth` empty, then hands off to the payer to co-sign and submit. ```ts import { sendSponsoredCalls } from 'viem/eip8168' diff --git a/site/pages/eip8130/receipts.mdx b/site/pages/eip8130/receipts.mdx index 1c93ebd337..f682a32d4f 100644 --- a/site/pages/eip8130/receipts.mdx +++ b/site/pages/eip8130/receipts.mdx @@ -6,6 +6,10 @@ description: Read EIP-8130 receipt fields — per-phase statuses, payer, and met An `AA_TX_TYPE` (`0x79`) receipt carries extra fields beyond a standard receipt: the resolved gas **payer**, the **per-phase execution statuses**, and the echoed **metadata**. `getTransactionReceipt` and `waitForTransactionReceipt` surface these under a parsed `eip8130` property while still returning the raw receipt. +:::tip +If your chain is defined with [`eip8130ChainConfig`](/eip8130#1-native-core-actions-eip8130chainconfig), core `client.getTransactionReceipt` and `client.waitForTransactionReceipt` return the same `eip8130` fields natively, so you don't need the `viem/eip8130` variants shown below. +::: + ## Wait for a receipt ```ts diff --git a/site/pages/eip8130/sending-a-transaction.mdx b/site/pages/eip8130/sending-a-transaction.mdx index c40934a932..9b50cb3ae7 100644 --- a/site/pages/eip8130/sending-a-transaction.mdx +++ b/site/pages/eip8130/sending-a-transaction.mdx @@ -6,6 +6,27 @@ description: Estimate, sign, and submit an EIP-8130 AA_TX_TYPE transaction An EIP-8130 transaction is an `AA_TX_TYPE` (`0x79`) envelope. `sendTransaction` handles the full lifecycle: it fills the nonce and fees, encodes the calls, signs `sender_auth` (and `payer_auth` when sponsored), serializes, and submits via `eth_sendRawTransaction`. +## Native Core Actions + +If your chain is defined with [`eip8130ChainConfig`](/eip8130#1-native-core-actions-eip8130chainconfig), core `client.sendTransaction` submits an `AA_TX_TYPE` for an EIP-8130 account. Gas is estimated for you (the config prices it via the EIP-8130 `eth_estimateGas` extension), so a send is just an account and calls: + +```ts +import { client } from './viem.config' // chain includes ...eip8130ChainConfig + +const hash = await client.sendTransaction({ + account, // toAccount(...) / newSmartAccount(...) + calls: [{ to: recipient, data }], + // gas is optional here — omit to auto-estimate, or pin it to skip the call +}) + +const receipt = await client.getTransactionReceipt({ hash }) +receipt.eip8130.phaseStatuses // ['0x1'] +``` + +For session-key or non-K1 sends, pass `senderActorId` / `senderAuthAuthenticator` to refine the auto-estimate. Sponsored (`payer`) sends do not ride core `sendTransaction`: use `client.eip8130.sendTransaction` (local payer) or `client.eip8168.sendTransaction` (payer service). See [Sponsoring Transactions](/eip8130/sponsoring-transactions). + +The rest of this guide uses the standalone `viem/eip8130` actions, which are explicit about each step and require a `gas` budget. They are equivalent to the `client.eip8130.*` decorator methods. + ## Estimate gas Unlike EVM transactions, the gas budget for an `AA_TX_TYPE` transaction is node-computed and required up front. `estimateGas` prices authentication gas from the *shape* of the auth blob — never a real signature — so you can estimate before signing. Pass a `senderAuthAuthenticator` hint matching your signer so the node prices the right authenticator: diff --git a/site/pages/eip8130/sponsoring-transactions.mdx b/site/pages/eip8130/sponsoring-transactions.mdx index 82da5b98e4..d6e929ff46 100644 --- a/site/pages/eip8130/sponsoring-transactions.mdx +++ b/site/pages/eip8130/sponsoring-transactions.mdx @@ -34,9 +34,13 @@ const hash = await sendTransaction(client, { }) ``` +:::info +This is also available as `client.eip8130.sendTransaction` via the [`eip8130Actions`](/eip8130#2-namespaced-client-decorators) decorator. Sponsored sends deliberately do not ride core `client.sendTransaction` (which throws on `payer`), since the payer co-signs off the raw-submit path. +::: + ## Estimating a sponsored transaction -Pass the `payer` address (and, if the payer uses a non-K1 key, `payerAuthVerifier`) so the node prices the payer authentication too: +Pass the `payer` address (and, if the payer uses a non-K1 key, `payerAuthAuthenticator`) so the node prices the payer authentication too: ```ts import { canonicalAuthenticators, estimateGas } from 'viem/eip8130' @@ -45,7 +49,7 @@ const gas = await estimateGas(client, { sender: account.address, calls: [[{ to: recipient, data }]], payer: sponsor.address, - payerAuthVerifier: canonicalAuthenticators.k1, + payerAuthAuthenticator: canonicalAuthenticators.k1, }) ``` From fdd2a0a1d138ca4a48d7091b07473561f057ae95 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 27 Aug 2026 11:59:29 -0400 Subject: [PATCH 93/96] feat(eip8130): make nonce-free validBefore time source configurable Nonce-free sends auto-compute validBefore from the client wall clock (Date.now()) plus a 20s window. On chains whose block time is skewed from the client, that can produce a born-expired tx. Add optional `now` (anchor to e.g. the block timestamp) and `expiryWindow` overrides; Date.now() + nonceFreeMaxExpiryWindow remains the default. --- site/pages/eip8130/receipts.mdx | 14 +++++++ src/eip8130/actions/sendTransaction.ts | 32 +++++++++++++++- src/eip8130/nonce.test.ts | 52 +++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/site/pages/eip8130/receipts.mdx b/site/pages/eip8130/receipts.mdx index f682a32d4f..3c546cd37c 100644 --- a/site/pages/eip8130/receipts.mdx +++ b/site/pages/eip8130/receipts.mdx @@ -29,6 +29,20 @@ It polls `eth_getTransactionReceipt` until the transaction is mined (default eve Any transaction with a non-zero `validBefore` (sequenced or nonce-free) can no longer land once the chain's latest block timestamp passes it. Pass the `validBefore` (unix **milliseconds**) you signed with and the wait rejects with a `TransactionExpiredError` the moment it lapses, instead of silently spinning until the timeout. +For a nonce-free send, `sendTransaction` auto-computes `validBefore` as `Date.now()` plus a 20s window when you don't pass one. Because the node enforces the deadline against **block time**, a client clock that runs behind the chain can make a send arrive already-expired. Pass `now` (e.g. the chain's latest block timestamp in ms) to anchor the deadline to block time, or `expiryWindow` to widen it: + +```ts +const { timestamp } = await client.getBlock() // seconds +const hash = await sendTransaction(client, { + account, + calls, + gas, + nonceKey: nonceKeyMax, // nonce-free (expiring) mode + now: timestamp * 1000n, // anchor to block time, immune to skew + expiryWindow: 60_000n, // optional: widen the 20s default +}) +``` + Thread the resolved `validBefore` out of `sendTransaction` with `onTransaction` — this is reliable because it's the exact value the tx was signed with (including auto-computed nonce-free windows), and it doesn't depend on the node returning a pending transaction: ```ts diff --git a/src/eip8130/actions/sendTransaction.ts b/src/eip8130/actions/sendTransaction.ts index a236a27ffc..4db2685b21 100644 --- a/src/eip8130/actions/sendTransaction.ts +++ b/src/eip8130/actions/sendTransaction.ts @@ -50,6 +50,20 @@ export type PrepareTransactionRequestParameters = FeeOverrides & { validAfter?: bigint | undefined /** Upper validity bound (unix ms; required non-zero in nonce-free mode). */ validBefore?: bigint | undefined + /** + * "Now" (unix ms) used to auto-compute `validBefore` for a nonce-free + * (expiring) send. Defaults to the client's wall clock (`Date.now()`). Pass + * the chain's latest block timestamp (ms) to anchor the deadline to block + * time when the client and chain clocks may differ. Ignored when + * `validBefore` is set. + */ + now?: bigint | undefined + /** + * Window (ms) added to `now` for the auto-computed `validBefore` on a + * nonce-free send. Defaults to `nonceFreeMaxExpiryWindow` (20s). Ignored when + * `validBefore` is set. + */ + expiryWindow?: bigint | undefined /** * Attribution / opaque suffix. Written to the EIP-8130 `metadata` field * (not appended to call calldata). Takes precedence over `client.dataSuffix`. @@ -146,7 +160,9 @@ export async function prepareTransactionRequest( // caller did not supply one — whether nonce-free was auto-selected // (restricted actor) or explicitly chosen (admin / `SCOPE_NONCE` opting in). if (!validBefore || validBefore === 0n) - validBefore = BigInt(Date.now()) + nonceFreeMaxExpiryWindow + validBefore = + (parameters.now ?? BigInt(Date.now())) + + (parameters.expiryWindow ?? nonceFreeMaxExpiryWindow) nonceSequence ??= 0n } else if (nonceSequence === undefined) { // Read the next sequence via `eth_getTransactionCount` (with the 2D @@ -194,6 +210,20 @@ type SendTransactionBaseParameters = FeeOverrides & { validAfter?: bigint | undefined /** Upper validity bound (unix ms; required non-zero in nonce-free mode). */ validBefore?: bigint | undefined + /** + * "Now" (unix ms) used to auto-compute `validBefore` for a nonce-free + * (expiring) send. Defaults to the client's wall clock (`Date.now()`). Pass + * the chain's latest block timestamp (ms) to anchor the deadline to block + * time when the client and chain clocks may differ. Ignored when + * `validBefore` is set. + */ + now?: bigint | undefined + /** + * Window (ms) added to `now` for the auto-computed `validBefore` on a + * nonce-free send. Defaults to `nonceFreeMaxExpiryWindow` (20s). Ignored when + * `validBefore` is set. + */ + expiryWindow?: bigint | undefined /** * Attribution / opaque suffix. Written to the EIP-8130 `metadata` field * (not appended to call calldata). Takes precedence over `client.dataSuffix`. diff --git a/src/eip8130/nonce.test.ts b/src/eip8130/nonce.test.ts index b81c9900ef..730d2841c0 100644 --- a/src/eip8130/nonce.test.ts +++ b/src/eip8130/nonce.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { privateKeyToAccount } from '../accounts/privateKeyToAccount.js' import { mainnet } from '../chains/index.js' import { createClient } from '../clients/createClient.js' @@ -7,7 +7,7 @@ import type { Hex } from '../types/misc.js' import { keccak256 } from '../utils/hash/keccak256.js' import { toAccount } from './accounts/toAccount.js' import { sendTransaction } from './actions/sendTransaction.js' -import { nonceKeyMax } from './constants.js' +import { nonceFreeMaxExpiryWindow, nonceKeyMax } from './constants.js' import { key } from './keys.js' import { nonce } from './nonce.js' import { parseTransaction } from './utils/parseTransaction.js' @@ -129,6 +129,54 @@ describe('sendTransaction nonce integration', () => { }, ] + afterEach(() => { + vi.useRealTimers() + }) + + test('nonce-free: validBefore defaults to Date.now() + window, overridable via now/expiryWindow', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) + + // Default: local wall clock + the 20s window. + const a = makeClient() + await sendTransaction(a.client, { + account, + calls, + ...fees, + nonceKey: nonceKeyMax, // opt into nonce-free without an explicit validBefore + }) + expect(parseTransaction(a.sent!).validBefore).toBe( + 1_700_000_000_000n + nonceFreeMaxExpiryWindow, + ) + + // `now` anchors the deadline to a supplied (e.g. block) timestamp, immune + // to client/chain clock skew. + const b = makeClient() + await sendTransaction(b.client, { + account, + calls, + ...fees, + nonceKey: nonceKeyMax, + now: 1_700_000_060_000n, // chain head 60s "ahead" of the local clock + }) + expect(parseTransaction(b.sent!).validBefore).toBe( + 1_700_000_060_000n + nonceFreeMaxExpiryWindow, + ) + + // `expiryWindow` widens/narrows the window off the same "now". + const c = makeClient() + await sendTransaction(c.client, { + account, + calls, + ...fees, + nonceKey: nonceKeyMax, + expiryWindow: 5_000n, + }) + expect(parseTransaction(c.sent!).validBefore).toBe( + 1_700_000_000_000n + 5_000n, + ) + }) + test('nonceless: no nonce read, tx carries NONCE_KEY_MAX + validBefore', async () => { const ctx = makeClient() await sendTransaction(ctx.client, { From 09646045ec61409e014d35000a60e3c8acf97e84 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Thu, 27 Aug 2026 12:06:58 -0400 Subject: [PATCH 94/96] fix(eip8130): accept flat calls in estimateGas, matching sendTransaction estimateGas accepted nested-only (AaCalls) while sendTransaction accepts a flat AaCall[] OR nested phases. Extract a shared toPhases normalizer and use it in estimateGas, sendTransaction, and the chainConfig hook so every entry point takes the same calls shape. --- src/eip8130/actions/estimateGas.test.ts | 54 +++++++++++++++++++++++++ src/eip8130/actions/estimateGas.ts | 26 +++++++----- src/eip8130/actions/sendTransaction.ts | 8 +--- src/eip8130/chainConfig.ts | 9 +---- src/eip8130/utils/toPhases.ts | 21 ++++++++++ 5 files changed, 92 insertions(+), 26 deletions(-) create mode 100644 src/eip8130/utils/toPhases.ts diff --git a/src/eip8130/actions/estimateGas.test.ts b/src/eip8130/actions/estimateGas.test.ts index 32abf57d2d..8a68cb90c7 100644 --- a/src/eip8130/actions/estimateGas.test.ts +++ b/src/eip8130/actions/estimateGas.test.ts @@ -163,4 +163,58 @@ describe('estimateGas — dataSuffix → metadata', () => { expect(rec.request.metadata).toBe('0x') }) + + test('accepts a flat calls list and normalizes it into a single phase', async () => { + const rec = recordingClient() + const account = toAccount({ + signer: owner, + userSalt, + code, + initialActors: [key.k1(owner.address)], + }) + + // Flat `AaCall[]` — matches the shape `sendTransaction` accepts. + await estimateGas(rec.client, { + sender: account.address, + calls: [ + { to: owner.address, value: 1n }, + { to: owner.address, data: '0x1234' }, + ], + senderAuthAuthenticator: canonicalAuthenticators.k1, + }) + + // Serialized as a single phase (one inner array) with both calls. + expect(rec.request.calls).toHaveLength(1) + expect(rec.request.calls[0]).toHaveLength(2) + expect(rec.request.calls[0][0]).toMatchObject({ + to: owner.address, + value: numberToHex(1n), + data: '0x', + }) + expect(rec.request.calls[0][1]).toMatchObject({ + to: owner.address, + value: numberToHex(0n), + data: '0x1234', + }) + }) + + test('nested phased calls pass through unchanged', async () => { + const rec = recordingClient() + const account = toAccount({ + signer: owner, + userSalt, + code, + initialActors: [key.k1(owner.address)], + }) + + await estimateGas(rec.client, { + sender: account.address, + calls: [[{ to: owner.address }], [{ to: owner.address, data: '0xab' }]], + senderAuthAuthenticator: canonicalAuthenticators.k1, + }) + + expect(rec.request.calls).toHaveLength(2) + expect(rec.request.calls[0]).toHaveLength(1) + expect(rec.request.calls[1][0]).toMatchObject({ data: '0xab' }) + }) }) diff --git a/src/eip8130/actions/estimateGas.ts b/src/eip8130/actions/estimateGas.ts index d6655f40df..caffaeecc8 100644 --- a/src/eip8130/actions/estimateGas.ts +++ b/src/eip8130/actions/estimateGas.ts @@ -15,8 +15,9 @@ import { canonicalAuthDataLength, changeType, } from '../constants.js' -import type { AaAccountChange, AaCalls } from '../types/transaction.js' +import type { AaAccountChange, AaCall, AaCalls } from '../types/transaction.js' import { encodeChangePayload } from '../utils/actorChangeData.js' +import { toPhases } from '../utils/toPhases.js' export type EstimateGasParameters = { /** @@ -57,10 +58,11 @@ export type EstimateGasParameters = { */ accountChanges?: readonly AaAccountChange[] | undefined /** - * Phased calls array (each inner array is one phase / `executeBatch` call). - * Typically a single phase: `[[{ to, value, data }]]`. + * Calls to price. A flat list (`[{ to, value, data }]`) is treated as a + * single atomic phase; pass a nested array (`[[…], […]]`) to price explicit + * phases. Matches the `calls` shape accepted by `sendTransaction`. */ - calls?: AaCalls | undefined + calls?: readonly AaCall[] | AaCalls | undefined /** * 2-D nonce key. Defaults to `0n`. Only relevant when the account has * multiple open channels; leave as default for single-channel accounts. @@ -255,13 +257,15 @@ export async function estimateGas< // Large gas cap so the simulation isn't capped below real execution. gasLimit: 30_000_000, accountChanges: (accountChanges ?? []).map(serializeAccountChange), - calls: (calls ?? [[{ to: account_, value: 0n, data: '0x' as Hex }]]).map( - (phase) => - phase.map((c) => ({ - to: c.to, - value: numberToHex(c.value ?? 0n), - data: c.data ?? '0x', - })), + calls: (calls !== undefined + ? toPhases(calls) + : [[{ to: account_, value: 0n, data: '0x' as Hex }]] + ).map((phase) => + phase.map((c) => ({ + to: c.to, + value: numberToHex(c.value ?? 0n), + data: c.data ?? '0x', + })), ), metadata: dataSuffix ?? '0x', payer: payer ?? null, diff --git a/src/eip8130/actions/sendTransaction.ts b/src/eip8130/actions/sendTransaction.ts index 4db2685b21..344aca5242 100644 --- a/src/eip8130/actions/sendTransaction.ts +++ b/src/eip8130/actions/sendTransaction.ts @@ -23,6 +23,7 @@ import { encodeWalletCalls, } from '../utils/encodeWalletCalls.js' import type { Signer } from '../utils/signTransaction.js' +import { toPhases } from '../utils/toPhases.js' import { getActorConfig } from './getActorConfig.js' import { getTransactionCount } from './getTransactionCount.js' import { @@ -249,13 +250,6 @@ export type SendTransactionParameters = SendTransactionBaseParameters export type SendTransactionReturnType = Hex -function toPhases(calls: SendTransactionBaseParameters['calls']): AaCalls { - if (calls.length === 0) return [] - // Already phased (array of arrays)? - if (Array.isArray(calls[0])) return calls as AaCalls - return [calls as readonly AaCall[]] -} - /** * Prepares, signs, and serializes an EIP-8130 (`AA_TX_TYPE`) transaction. * Shared by {@link sendTransaction} and {@link sendTransactionSync}. diff --git a/src/eip8130/chainConfig.ts b/src/eip8130/chainConfig.ts index b4e5a357cb..732312bc7d 100644 --- a/src/eip8130/chainConfig.ts +++ b/src/eip8130/chainConfig.ts @@ -14,8 +14,8 @@ import { type ReceiptFields, } from './actions/getTransactionReceipt.js' import { prepareTransactionRequest as fillEip8130Body } from './actions/sendTransaction.js' -import type { AaCall, AaCalls } from './types/transaction.js' import { encodeWalletCalls } from './utils/encodeWalletCalls.js' +import { toPhases } from './utils/toPhases.js' type RawReceipt8130 = ExactPartial & { payer?: ReceiptFields['payer'] @@ -23,13 +23,6 @@ type RawReceipt8130 = ExactPartial & { metadata?: ReceiptFields['metadata'] } -/** Normalizes a flat call list into a single phase (nested lists pass through). */ -function toPhases(calls: readonly AaCall[] | AaCalls): AaCalls { - if (calls.length === 0) return [] - if (Array.isArray(calls[0])) return calls as AaCalls - return [calls as readonly AaCall[]] -} - /** * Chain config that folds EIP-8130 (`AA_TX_TYPE`, `0x79`) receipt fields into * core viem, so `client.getTransactionReceipt` and diff --git a/src/eip8130/utils/toPhases.ts b/src/eip8130/utils/toPhases.ts new file mode 100644 index 0000000000..2a5e0739a4 --- /dev/null +++ b/src/eip8130/utils/toPhases.ts @@ -0,0 +1,21 @@ +import type { AaCall, AaCalls } from '../types/transaction.js' + +/** + * Normalizes a `calls` input into ordered phases ({@link AaCalls}). + * + * Accepts either a flat list of calls (`readonly AaCall[]`) — run as a single + * atomic phase — or an already-phased nested array (`AaCalls`), which is passed + * through unchanged. A flat call is an object (`{ to, ... }`) while a phase is + * an array, so the two shapes are unambiguous. + * + * Shared by `sendTransaction`, `estimateGas`, and the `eip8130ChainConfig` + * request hook so every entry point accepts the same `calls` shape. + */ +export function toPhases( + calls: readonly AaCall[] | AaCalls | undefined, +): AaCalls { + if (!calls || calls.length === 0) return [] + // Already phased (array of arrays)? + if (Array.isArray(calls[0])) return calls as AaCalls + return [calls as readonly AaCall[]] +} From 47710b6edfda57dd434ec81318ae71caf08008fc Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 2 Sep 2026 15:50:38 -0400 Subject: [PATCH 95/96] =?UTF-8?q?feat(eip8130):=20message-signing=20envelo?= =?UTF-8?q?pe,=20dataSuffix=E2=86=92metadata,=20contract=20address=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SignedMessageEnvelope signing path: replaySafeHash / envelope digest utils, signMessageEnvelope / signTypedDataEnvelope, ERC-6492 counterfactual wrap, and a validateSignature read action. Wire toAccount / newSmartAccount / toSmartAccount signMessage & signTypedData to emit ERC-1271 envelopes so core client.verifyMessage works. Docs (signing-messages) + tests. - abis: replace stale verifySignature with validateSignature (matches Keystore). - Fold dataSuffix (+ client.dataSuffix) into top-level, signed `metadata` on the core eip8130ChainConfig send path; document metadata as a top-level tx field. - Sync base/eip-8130 contract changes (PR #95-100): actorScope reorder + SENDER→OPERATOR, getActor→getActorWithPolicy, importAccount()/computeImportDigest, SignedAccountChangeBatch typehash, CoinbaseSmartWalletV2 proxy docs. - Update canonical contract addresses (keystore, default/highRate accounts, delegate authenticator, policy manager/session policy) and regenerate the commitmentOf reference vector. --- site/pages/eip8130.mdx | 2 +- site/pages/eip8130/creating-an-account.mdx | 10 +- site/pages/eip8130/rotating-owners.mdx | 21 +- site/pages/eip8130/sending-a-transaction.mdx | 16 + site/pages/eip8130/session-keys.mdx | 2 +- site/pages/eip8130/signing-messages.mdx | 126 ++++++ site/pages/eip8130/sub-accounts.mdx | 2 +- site/vocs.config.ts | 4 + src/eip8130/abis.ts | 20 +- src/eip8130/accounts/toAccount.ts | 105 +++-- src/eip8130/accounts/toSmartAccount.test.ts | 25 +- src/eip8130/accounts/toSmartAccount.ts | 46 ++- src/eip8130/actions/estimateGas.test.ts | 4 +- src/eip8130/actions/getPolicy.ts | 12 +- src/eip8130/actions/validateSignature.ts | 103 +++++ src/eip8130/chainConfig.test.ts | 60 +++ src/eip8130/chainConfig.ts | 17 +- src/eip8130/constants.ts | 38 +- src/eip8130/decorators/eip8130Actions.ts | 4 + src/eip8130/deployments.ts | 36 +- src/eip8130/devx.test.ts | 56 ++- src/eip8130/index.ts | 28 ++ src/eip8130/keys.ts | 13 +- src/eip8130/permissions.test.ts | 2 +- src/eip8130/permissions.ts | 4 +- src/eip8130/policies.test.ts | 2 +- src/eip8130/policies.ts | 2 +- src/eip8130/queries.test.ts | 14 +- src/eip8130/subAccounts.test.ts | 8 +- src/eip8130/subAccounts.ts | 10 +- src/eip8130/utils/hashActorChanges.ts | 10 +- src/eip8130/utils/proxy.ts | 15 +- src/eip8130/utils/signMessage.test.ts | 235 +++++++++++ src/eip8130/utils/signMessage.ts | 378 ++++++++++++++++++ .../utils/signedActorChangesSignature.test.ts | 2 +- .../utils/signedActorChangesSignature.ts | 2 +- 36 files changed, 1258 insertions(+), 176 deletions(-) create mode 100644 site/pages/eip8130/signing-messages.mdx create mode 100644 src/eip8130/actions/validateSignature.ts create mode 100644 src/eip8130/utils/signMessage.test.ts create mode 100644 src/eip8130/utils/signMessage.ts diff --git a/site/pages/eip8130.mdx b/site/pages/eip8130.mdx index 84be10405e..b59d809c4b 100644 --- a/site/pages/eip8130.mdx +++ b/site/pages/eip8130.mdx @@ -139,7 +139,7 @@ deployment.accounts.defaultHighRate // CanonicalHighRatePayerAccount — immutab deployment.authenticators.p256 // P-256 (webAuthn, delegate, k1, alwaysValid alongside) ``` -The canonical set is **`DefaultAccount`** (the bare building block and the direct EIP-7702 delegation target for EOAs) and **`CanonicalHighRatePayerAccount`** (immutable, behind a 45-byte ERC-1167 proxy). **`UpgradeableAccount`** (ERC-1967 upgradeable proxy) and **`BackwardsCompatible4337Account`** (the ERC-4337 portable implementation for non-native chains) are optional, unaudited example wallets — supply them explicitly if you choose those paths. +The canonical set is **`DefaultAccount`** (the bare building block and the direct EIP-7702 delegation target for EOAs) and **`CanonicalHighRatePayerAccount`** (immutable, behind a 45-byte ERC-1167 proxy). **`CoinbaseSmartWalletV2`** (the upgradeable implementation, behind an ERC-1967 [`UpgradeableProxy`](https://github.com/base/smart-wallet-v2/blob/master/src/proxy/UpgradeableProxy.sol); 7702 EOAs delegate via [`EIP7702ProxyForEIP8130`](https://github.com/base/smart-wallet-v2/blob/master/src/proxy/EIP7702ProxyForEIP8130.sol)) and **`BackwardsCompatible4337Account`** (the ERC-4337 portable implementation for non-native chains) are supplied explicitly if you choose those paths. ### Policies are app-level, not protocol diff --git a/site/pages/eip8130/creating-an-account.mdx b/site/pages/eip8130/creating-an-account.mdx index c3ecea9c13..556fc85343 100644 --- a/site/pages/eip8130/creating-an-account.mdx +++ b/site/pages/eip8130/creating-an-account.mdx @@ -8,16 +8,16 @@ description: Create an EIP-8130 smart account from a secp256k1, P-256, or WebAut There are two proxy shapes: -- **`proxy: 'upgradeable'` (default)** — a 93-byte ERC-1967 `UpgradeableProxy` delegating to a real UUPS `UpgradeableAccount`, so the account is genuinely upgradeable via an owner-signed, multichain-safe `upgradeBySignature`. Pass the UUPS `implementation` you deployed. +- **`proxy: 'upgradeable'` (default)** — a 93-byte ERC-1967 [`UpgradeableProxy`](https://github.com/base/smart-wallet-v2/blob/master/src/proxy/UpgradeableProxy.sol) delegating to [`CoinbaseSmartWalletV2`](https://github.com/base/smart-wallet-v2) (a UUPS implementation), so the account is genuinely upgradeable via an admin-signed, multichain-safe `upgrade`. Pass the CBSW v2 `implementation` you deployed. A 7702-delegated EOA uses the [`EIP7702ProxyForEIP8130`](https://github.com/base/smart-wallet-v2/blob/master/src/proxy/EIP7702ProxyForEIP8130.sol) singleton instead. - **`proxy: 'erc1167'`** — an immutable 45-byte minimal proxy to the canonical `DefaultAccount`. Smallest attack surface (no upgrade slot), but not multichain-upgrade-safe. ```ts -newSmartAccount({ signer, implementation: upgradeableImpl }) // upgradeable (default) -newSmartAccount({ signer, proxy: 'erc1167' }) // immutable → DefaultAccount +newSmartAccount({ signer, implementation: coinbaseSmartWalletV2Impl }) // upgradeable (default) +newSmartAccount({ signer, proxy: 'erc1167' }) // immutable → DefaultAccount ``` :::warning -**Pending final implementation.** No canonical UUPS `UpgradeableAccount` is enshrined yet (the expected long-term implementation is Coinbase Smart Wallet v2). Until one is, the default `proxy: 'upgradeable'` **requires an explicit `implementation`** — a UUPS `UpgradeableAccount` you deployed (see [base/eip-8130-examples](https://github.com/base/eip-8130-examples)); it never silently falls back to the non-UUPS `DefaultAccount`. Once the address is enshrined the default goes live with no code change. +**Pending deployment.** `CoinbaseSmartWalletV2` is not yet deployed against the canonical Keystore (its address depends on the Keystore, which was regenerated). Until it is, the default `proxy: 'upgradeable'` **requires an explicit `implementation`** — a `CoinbaseSmartWalletV2` you deployed (see [base/smart-wallet-v2](https://github.com/base/smart-wallet-v2)); it never silently falls back to the non-UUPS `DefaultAccount`. Once the address is set the default goes live with no code change. ::: The signer type (K1 / P-256 / WebAuthn) is detected automatically. @@ -95,7 +95,7 @@ const account = newSmartAccount({ signer: owner, proxy: 'erc1167', admins: [key.p256(recovery.publicKey)], // co-owner / recovery - extraActors: [authorizeActor(key.p256(session.publicKey), { scope: actorScope.sender })], // session key + extraActors: [authorizeActor(key.p256(session.publicKey), { scope: actorScope.operator })], // scoped operator key }) ``` diff --git a/site/pages/eip8130/rotating-owners.mdx b/site/pages/eip8130/rotating-owners.mdx index 8b230481b2..57e845bcaa 100644 --- a/site/pages/eip8130/rotating-owners.mdx +++ b/site/pages/eip8130/rotating-owners.mdx @@ -27,8 +27,8 @@ key.delegate('0xotherAccount') // signatures for another account act for this import { actorScope, authorizeActor, key, toScope } from 'viem/eip8130' authorizeActor(key.p256({ x, y }), { - // What the actor may do: sign / act as sender / act as payer / change config. - scope: toScope(actorScope.sender, actorScope.signature), + // What the actor may do: originate transactions and pay its own gas. + scope: toScope(actorScope.operator, actorScope.selfPayer), // Optional expiry (unix seconds); 0/omitted = no expiry. expiry: BigInt(Math.floor(Date.now() / 1000) + 86_400), }) @@ -36,12 +36,13 @@ authorizeActor(key.p256({ x, y }), { | Flag | Grants | | --- | --- | -| `actorScope.signature` | Producing signatures for the account (e.g. ERC-1271). | -| `actorScope.sender` | Sending transactions (the `sender` auth path). | -| `actorScope.payer` | Sponsoring transactions as the `payer`. | -| `actorScope.config` | Changing the account's actor configuration. | +| `actorScope.operator` | Ungated initiation: originate transactions to any target (also grants execution + ERC-1271 signing authority). | +| `actorScope.selfPayer` | Pay for its own transactions (`payer == sender`). | +| `actorScope.sponsorPayer` | Sponsor others (`payer != sender`). | +| `actorScope.policy` | Gated initiation: originate only through the actor's policy manager. Does **not** combine with `operator`. | +| `actorScope.nonce` | Use sequenced nonce keys (without it, restricted to nonce-free). | -An unrestricted (full-owner) actor uses scope `0`. +An unrestricted (full-owner) actor uses scope `0` and needs no flags: admin (`scope == 0`) already carries every authority. ## Read the config sequence @@ -70,7 +71,7 @@ import { const change = await account.change( [ authorizeActor(key.k1('0xnewOwner...'), { - scope: actorScope.sender, // full owner? use scope 0 + scope: actorScope.operator, // full owner? use scope 0 }), ], { chainId: client.chain.id, sequence }, // bigint, straight from getConfigSequence @@ -98,7 +99,7 @@ import { const rotate = await account.change( [ - authorizeActor(key.k1('0xnewOwner...'), { scope: actorScope.sender }), + authorizeActor(key.k1('0xnewOwner...'), { scope: actorScope.operator }), revokeActor(key.k1('0xoldOwner...')), ], { chainId: client.chain.id, sequence }, @@ -149,7 +150,7 @@ import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' const account = toEoaAccount(privateKeyToAccount(generatePrivateKey())) const addP256 = await account.change( - [authorizeActor(key.p256({ x, y }), { scope: actorScope.sender })], + [authorizeActor(key.p256({ x, y }), { scope: actorScope.operator })], { chainId: client.chain.id, sequence: 0 }, ) diff --git a/site/pages/eip8130/sending-a-transaction.mdx b/site/pages/eip8130/sending-a-transaction.mdx index 9b50cb3ae7..77be5e55b4 100644 --- a/site/pages/eip8130/sending-a-transaction.mdx +++ b/site/pages/eip8130/sending-a-transaction.mdx @@ -25,6 +25,22 @@ receipt.eip8130.phaseStatuses // ['0x1'] For session-key or non-K1 sends, pass `senderActorId` / `senderAuthAuthenticator` to refine the auto-estimate. Sponsored (`payer`) sends do not ride core `sendTransaction`: use `client.eip8130.sendTransaction` (local payer) or `client.eip8168.sendTransaction` (payer service). See [Sponsoring Transactions](/eip8130/sponsoring-transactions). +### Attribution and Metadata + +Unlike other transaction types, EIP-8130 carries an opaque `metadata` field at the **top level** of the signed transaction (it is authenticated with the body, not appended to calldata). Set it directly for attribution, a memo, or any application-defined bytes: + +```ts +const hash = await client.sendTransaction({ + account, + calls: [{ to: recipient, data }], + metadata: '0xc0ffee', // top-level, opaque, signed with the tx +}) +``` + +`metadata` takes precedence over a client-wide `dataSuffix`, so an app can set `client.dataSuffix` as a default attribution tag and override it per transaction with `metadata`. (Core `sendTransaction` consumes its own request-level `dataSuffix` param before the EIP-8130 hook runs, so use `metadata` for per-transaction attribution on this path; the standalone `viem/eip8130` and `client.eip8130.*` actions also accept `dataSuffix` directly.) + +The field is opaque bytes: pass whatever your indexer expects. (If you want a shared structure, [ERC-1883](https://github.com/ethereum/ERCs/pull/1883) proposes an optional CBOR encoding, but it is not required to use `metadata`.) + The rest of this guide uses the standalone `viem/eip8130` actions, which are explicit about each step and require a `gas` budget. They are equivalent to the `client.eip8130.*` decorator methods. ## Estimate gas diff --git a/site/pages/eip8130/session-keys.mdx b/site/pages/eip8130/session-keys.mdx index ad521dc6eb..c9132b73b6 100644 --- a/site/pages/eip8130/session-keys.mdx +++ b/site/pages/eip8130/session-keys.mdx @@ -94,7 +94,7 @@ const sessionKey = key.p256({ x, y }) const change = await account.change( [ authorizeActor(sessionKey, { - scope: actorScope.sender, + scope: actorScope.policy, // POLICY-only; OPERATOR would override the gate policy: session.actorPolicy, }), ], diff --git a/site/pages/eip8130/signing-messages.mdx b/site/pages/eip8130/signing-messages.mdx new file mode 100644 index 0000000000..96716d9b57 --- /dev/null +++ b/site/pages/eip8130/signing-messages.mdx @@ -0,0 +1,126 @@ +--- +description: Sign and verify ERC-1271 messages for an EIP-8130 account using the SignedMessageEnvelope path +--- + +# Signing Messages + +An EIP-8130 account signs off-chain messages (personal-sign or EIP-712) as an **ERC-1271** signature, so apps verify them the same way they verify any smart-account signature: `client.verifyMessage`, Sign-In With Ethereum, etc. + +Under the hood a signature is a typed **envelope**: + +``` +sigType(1) || authenticator(20) || data +``` + +The signer signs a **replay-safe digest** that binds the raw app hash to the account and a chain: `keccak256(abi.encode(SIGNED_MESSAGE_TYPEHASH, account, chainId, hash))`. The leading `sigType` byte selects the channel: + +- **`multichain`** (`0x02`): binds `chainId = 0`, so the signature is valid for the account on **every** chain. This is the default, matching the account's chain-agnostic address. +- **`local`** (`0x01`): binds `block.chainid`, so the signature is valid on **one** chain. + +On-chain, an account's `isValidSignature` delegates to `Keystore.validateSignature`, which resolves the same digest and returns the verified `actorId` and `scope`. + +## Sign a Message + +`account.signMessage` and `account.signTypedData` produce a ready-to-verify `multichain` envelope. + +```ts +import { toAccount } from 'viem/eip8130' + +const account = toAccount({ signer: owner, address }) + +const signature = await account.signMessage({ message: 'hello world' }) + +const typedSignature = await account.signTypedData({ + domain: { name: 'App', version: '1', chainId: 8453 }, + types: { Mail: [{ name: 'contents', type: 'string' }] }, + primaryType: 'Mail', + message: { contents: 'gm' }, +}) +``` + +## Verify a Signature + +Because the account implements ERC-1271, core viem verifies the signature with no EIP-8130-specific code: + +```ts +import { verifyMessage } from 'viem/actions' + +const valid = await verifyMessage(client, { + address: account.address, + message: 'hello world', + signature, +}) +``` + +## Chain-Bound (`local`) Signatures + +`account.signMessage` is always `multichain` because viem's account interface can't pass a chain id. For a chain-bound signature, use `signMessageEnvelope` (or `signTypedDataEnvelope`) directly with `sigType: 'local'` and the `chainId`: + +```ts +import { signMessageEnvelope } from 'viem/eip8130' + +const signature = await signMessageEnvelope({ + signer: owner, + account: account.address, + message: 'hello world', + sigType: 'local', + chainId: 8453, + // authenticator defaults to the signer's, then native ecrecover +}) +``` + +## Who Signed: `validateSignature` + +`verifyMessage` only tells you pass/fail. When you need to know **which** actor signed (e.g. to apply your own scope-based authorization for a session key), read `validateSignature`: + +```ts +import { validateSignature } from 'viem/eip8130' + +const { valid, actorId, scope } = await validateSignature(client, { + account: account.address, + message: 'hello world', + signature, +}) +``` + +`scope` is the actor's capability bitmask (`0` = unrestricted admin). An account's ERC-1271 gate accepts any operational actor, i.e. `scope === 0 || (scope & actorScope.operator)`; apply the same check if you verify via `validateSignature` yourself. + +## Counterfactual Accounts (ERC-6492) + +A plain envelope needs code at the account address (`isValidSignature`). To verify a signature **before** the account is deployed, wrap the envelope with the keystore's deploy call using [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492). `verifyMessage` / `verifyHash` then deploy-and-verify through the universal validator, exactly as viem does for other counterfactual smart accounts. + +```ts +import { + newSmartAccount, + signMessageEnvelope, + wrapCounterfactualSignature, +} from 'viem/eip8130' +import { verifyMessage } from 'viem/actions' + +const account = newSmartAccount({ signer, userSalt, code, initialActors }) + +const envelope = await signMessageEnvelope({ + signer, + account: account.address, + message: 'gm', +}) + +const signature = wrapCounterfactualSignature({ + signature: envelope, + userSalt, + code, + initialActors, +}) + +// Verifies even though `account.address` has no code yet. +const valid = await verifyMessage(client, { + address: account.address, + message: 'gm', + signature, +}) +``` + +## See More + +- [Rotating Owners](/eip8130/rotating-owners): actors, scopes, and authenticators. +- [Session Keys](/eip8130/session-keys): policy-gated actors. diff --git a/site/pages/eip8130/sub-accounts.mdx b/site/pages/eip8130/sub-accounts.mdx index cbd0ce0a28..61b62c4183 100644 --- a/site/pages/eip8130/sub-accounts.mdx +++ b/site/pages/eip8130/sub-accounts.mdx @@ -45,7 +45,7 @@ import { const link = await subAccount.change( [ authorizeActor(key.delegate(main.address), { - scope: actorScope.sender, + scope: actorScope.operator, }), ], { chainId: client.chain.id, sequence: Number(sequence) }, diff --git a/site/vocs.config.ts b/site/vocs.config.ts index b5ba9bb439..97969e36fc 100644 --- a/site/vocs.config.ts +++ b/site/vocs.config.ts @@ -1684,6 +1684,10 @@ export default defineConfig({ text: 'Sub Accounts', link: '/eip8130/sub-accounts', }, + { + text: 'Signing Messages', + link: '/eip8130/signing-messages', + }, { text: 'Sponsoring Transactions', link: '/eip8130/sponsoring-transactions', diff --git a/src/eip8130/abis.ts b/src/eip8130/abis.ts index 6e79066cb7..15f8335425 100644 --- a/src/eip8130/abis.ts +++ b/src/eip8130/abis.ts @@ -13,8 +13,10 @@ export const keystoreAbi = parseAbi([ // `actorData` is tightly packed: authenticator(20) || expiry(6) || scope(2) || // reserved(4 zero bytes) = 32 bytes, plus manager(20) || commitment(32) when - // scope & SCOPE_POLICY != 0 (84 bytes total). Policy presence is the - // SCOPE_POLICY bit — there is no `policyType` field. + // policy is attached (84 bytes total). Policy attachment is decided by payload + // length (empty vs 52 bytes), not by any scope bit — there is no `policyType` + // field; the co-located `ActorRecord` stores `config`, then `policyManager`, + // then `policyCommitment` in consecutive slots. '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)', @@ -25,12 +27,20 @@ export const keystoreAbi = parseAbi([ 'function createAccount(bytes32 userSalt, bytes bytecode, InitialActor[] initialActors) returns (address)', 'function computeAddress(bytes32 userSalt, bytes bytecode, InitialActor[] initialActors) view returns (address)', - 'function importAccount(address account, uint256 chainId, InitialActor[] initialActors, bytes signature)', + // Import is authorized by the account's own code, not by a signature: the + // account itself must call this (`msg.sender`), and its code returns the actor + // set + `computeImportDigest` from `IKeystoreImport.confirmKeystoreImport()`. + 'function importAccount()', + 'function computeImportDigest(address account, InitialActor[] initialActors) pure returns (bytes32)', 'function applySignedAccountChanges(address account, SignedAccountChanges s)', - 'function verifySignature(address account, bytes32 hash, bytes signature) view returns (bool verified)', + // Canonical validation of a typed-envelope user signature (`sigType(1) || + // authenticator(20) || data`) over an app `hash`. Reverts on failure; returns + // the verified actor + its scope. Supersedes ERC-1271 for 8130 accounts (an + // account's `isValidSignature` is built on this + `Scopes.isOperator`). + 'function validateSignature(address account, bytes32 hash, bytes auth) view returns (bytes32 actorId, uint16 scope)', 'function authenticateActor(address account, bytes32 hash, bytes auth) view returns (bytes32 actorId, uint16 scope)', 'function getActorConfig(address account, bytes32 actorId) view returns (ActorConfig)', - 'function getActor(address account, bytes32 actorId) view returns (ActorConfig config, address policyManager, bytes32 policyCommitment)', + 'function getActorWithPolicy(address account, bytes32 actorId) view returns (ActorConfig config, address policyManager, bytes32 policyCommitment)', 'function getPolicyCommitment(address account, bytes32 actorId) view returns (bytes32)', 'function getPolicyManager(address account, bytes32 actorId) view returns (address)', 'function getChangeSequences(address account) view returns (ChangeSequences)', diff --git a/src/eip8130/accounts/toAccount.ts b/src/eip8130/accounts/toAccount.ts index 9ab3e59943..f8d97176c6 100644 --- a/src/eip8130/accounts/toAccount.ts +++ b/src/eip8130/accounts/toAccount.ts @@ -1,6 +1,7 @@ -import type { Address } from 'abitype' +import type { Address, TypedData } from 'abitype' import { BaseError } from '../../errors/base.js' -import type { Hex } from '../../types/misc.js' +import type { Hex, SignableMessage } from '../../types/misc.js' +import type { TypedDataDefinition } from '../../types/typedData.js' import { concatHex } from '../../utils/data/concat.js' import { hexToBigInt } from '../../utils/encoding/fromHex.js' import { bytesToHex } from '../../utils/encoding/toHex.js' @@ -24,6 +25,10 @@ import type { import { computeAddress } from '../utils/computeAddress.js' import { erc1167Bytecode, upgradeableProxyBytecode } from '../utils/proxy.js' import { signAccountChanges } from '../utils/signActorChanges.js' +import { + signMessageEnvelope, + signTypedDataEnvelope, +} from '../utils/signMessage.js' import { type Signer, signTransaction } from '../utils/signTransaction.js' /** @@ -122,10 +127,22 @@ export type ToAccountReturnType = { readonly source: 'eip8130' /** SEC1 hex public key of a K1 signer, or `'0x'` for non-K1 signers. */ readonly publicKey: Hex - /** Not supported for EIP-8130 accounts. */ - signMessage(): never - /** Not supported for EIP-8130 accounts. */ - signTypedData(): never + /** + * Signs `message` (personal-sign) as an EIP-8130 ERC-1271 signature envelope + * (`sigType || authenticator || data`) using a **multichain** (`chainId = 0`) + * scope, so the signature verifies for this account on every chain via + * `isValidSignature` / core `client.verifyMessage`. For a chain-bound (`local`) + * signature use {@link signMessageEnvelope} with `sigType: 'local'` + `chainId`. + */ + signMessage(parameters: { message: SignableMessage }): Promise + /** + * Signs EIP-712 `typedData` as an EIP-8130 ERC-1271 signature envelope + * (multichain scope). See {@link signMessage}. + */ + signTypedData< + const typedData extends TypedData | Record, + primaryType extends keyof typedData | 'EIP712Domain' = keyof typedData, + >(parameters: TypedDataDefinition): Promise /** * Builds the `create` account-change entry (include in the first tx for * smart accounts). Throws if the account was constructed with a known `address` @@ -235,15 +252,21 @@ export function toAccount( type: 'local', source: 'eip8130', publicKey, - signMessage(): never { - throw new BaseError( - '`signMessage` is not supported for EIP-8130 accounts.', - ) + async signMessage({ message }) { + return signMessageEnvelope({ + signer, + account: address, + authenticator, + message, + }) }, - signTypedData(): never { - throw new BaseError( - '`signTypedData` is not supported for EIP-8130 accounts.', - ) + async signTypedData(typedData) { + return signTypedDataEnvelope({ + signer, + account: address, + authenticator, + ...(typedData as object), + } as never) }, create() { @@ -315,14 +338,13 @@ export type NewSmartAccountParameters = { * Per-account proxy placed at the account address. * * - `'upgradeable'` (default) — 93-byte ERC-1967 {@link upgradeableProxyBytecode} - * delegating to a real UUPS `UpgradeableAccount`, so the account is genuinely - * upgradeable (owner-signed, multichain-safe `upgradeBySignature`). Uses - * `implementation` if given, else the enshrined `accounts.upgradeable`. It - * never falls back to the non-UUPS `DefaultAccount`. **PENDING FINAL - * IMPLEMENTATION**: no canonical UUPS impl is enshrined yet (expected: - * Coinbase Smart Wallet v2), so until then this path needs an explicit - * `implementation` (e.g. the `UpgradeableAccount` example from - * [base/eip-8130-examples](https://github.com/base/eip-8130-examples)). + * delegating to `CoinbaseSmartWalletV2` (a UUPS implementation), so the account + * is genuinely upgradeable (admin-signed, multichain-safe `upgrade`). Uses + * `implementation` if given, else the canonical `accounts.upgradeable`. It + * never falls back to the non-UUPS `DefaultAccount`. **PENDING DEPLOYMENT**: + * CBSW v2 is not yet deployed against the canonical Keystore, so until then + * this path needs an explicit `implementation` (see + * [base/smart-wallet-v2](https://github.com/base/smart-wallet-v2)). * - `'erc1167'` — immutable 45-byte {@link erc1167Bytecode} minimal proxy to the * canonical `DefaultAccount`. No upgrade slot (smallest attack surface), but * not multichain-upgrade-safe. @@ -333,10 +355,10 @@ export type NewSmartAccountParameters = { /** * Implementation address the proxy delegates to. For `proxy: 'erc1167'` * defaults to the canonical `DefaultAccount`; for `proxy: 'upgradeable'` - * defaults to the enshrined `accounts.upgradeable` (a UUPS `UpgradeableAccount` - * — required explicitly until one is enshrined). Swap it to back the account - * with a different wallet implementation you deployed. Ignored if `code` is - * provided. + * defaults to the canonical `accounts.upgradeable` (`CoinbaseSmartWalletV2` — a + * UUPS impl, required explicitly until CBSW v2 is deployed). Swap it to back the + * account with a different wallet implementation you deployed. Ignored if `code` + * is provided. */ implementation?: Address | undefined /** @@ -395,16 +417,16 @@ export type NewSmartAccountReturnType = ToAccountReturnType & { * const account = newSmartAccount({ signer: privateKeyToAccount(pk), proxy: 'erc1167' }) * * @example - * // Upgradeable (default): supply a UUPS UpgradeableAccount you deployed — - * // required until a canonical upgradeable impl is enshrined. - * const account = newSmartAccount({ signer, implementation: upgradeableAccountImpl }) + * // Upgradeable (default): supply a CoinbaseSmartWalletV2 implementation — + * // required until a canonical CBSW v2 is deployed against the Keystore. + * const account = newSmartAccount({ signer, implementation: coinbaseSmartWalletV2Impl }) * * @example * // Multisig / co-owners + a session key at creation * const account = newSmartAccount({ * signer, * admins: [key.p256(recoveryPubkey)], // extra unrestricted owners - * extraActors: [authorizeActor(key.p256(sessionPubkey), { scope: actorScope.sender })], + * extraActors: [authorizeActor(key.p256(sessionPubkey), { scope: actorScope.operator })], * }) * * @example @@ -476,14 +498,13 @@ export function newSmartAccount( // Proxy selection. // - 'erc1167' — immutable minimal proxy → `implementation` ?? DefaultAccount. - // - 'upgradeable' — ERC-1967 proxy → a real UUPS `UpgradeableAccount`, so the - // account is *actually* upgradeable (owner-signed `upgradeBySignature`, - // multichain-safe). It must NOT silently fall back to the non-UUPS - // DefaultAccount, so we require an explicit `implementation` or an enshrined - // `accounts.upgradeable`. + // - 'upgradeable' — ERC-1967 proxy → `CoinbaseSmartWalletV2` (a UUPS impl), so + // the account is *actually* upgradeable (admin-signed `upgrade`, multichain- + // safe). It must NOT silently fall back to the non-UUPS DefaultAccount, so we + // require an explicit `implementation` or a canonical `accounts.upgradeable`. // - // PENDING FINAL IMPLEMENTATION: `accounts.upgradeable` is not enshrined yet - // (expected long-term impl: Coinbase Smart Wallet v2). Until it is, the default + // PENDING DEPLOYMENT: `accounts.upgradeable` (CoinbaseSmartWalletV2) is not + // deployed against the canonical Keystore yet. Until it is, the default // `proxy: 'upgradeable'` path needs an explicit `implementation`. const code = parameters.code ?? @@ -496,10 +517,10 @@ export function newSmartAccount( implementation ?? canonicalEip8130Deployment.accounts.upgradeable if (!impl) throw new BaseError( - 'No canonical `UpgradeableAccount` is enshrined yet (pending final ' + - 'implementation), so `proxy: "upgradeable"` requires an explicit ' + - '`implementation` — a UUPS UpgradeableAccount you deployed (see ' + - 'https://github.com/base/eip-8130-examples). Alternatively pass ' + + 'No canonical `CoinbaseSmartWalletV2` is deployed against the Keystore ' + + 'yet, so `proxy: "upgradeable"` requires an explicit `implementation` ' + + '— a CoinbaseSmartWalletV2 you deployed (see ' + + 'https://github.com/base/smart-wallet-v2). Alternatively pass ' + '`proxy: "erc1167"` for an immutable DefaultAccount-backed account.', ) return upgradeableProxyBytecode(impl) @@ -564,7 +585,7 @@ export type ToEoaAccountReturnType = { * @example * // Atomically delegate + add a P256 key in the first tx: * const addP256 = await account.change([ - * authorizeActor(key.p256(p256.publicKey), { scope: actorScope.sender }), + * authorizeActor(key.p256(p256.publicKey), { scope: actorScope.operator }), * ], { chainId, sequence: 0 }) * await account.signTransaction({ * accountChanges: [account.delegate(impl), addP256], diff --git a/src/eip8130/accounts/toSmartAccount.test.ts b/src/eip8130/accounts/toSmartAccount.test.ts index a8d61f918f..b2966ba60b 100644 --- a/src/eip8130/accounts/toSmartAccount.test.ts +++ b/src/eip8130/accounts/toSmartAccount.test.ts @@ -5,12 +5,14 @@ import { createClient } from '../../clients/createClient.js' import { custom } from '../../clients/transports/custom.js' import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' import { slice } from '../../utils/data/slice.js' -import { recoverMessageAddress } from '../../utils/signature/recoverMessageAddress.js' +import { hashMessage } from '../../utils/signature/hashMessage.js' +import { recoverAddress } from '../../utils/signature/recoverAddress.js' import { keystoreAbi } from '../abis.js' import { ecrecoverAuthenticator } from '../constants.js' import type { AaActor } from '../types/transaction.js' import { computeAddress } from '../utils/computeAddress.js' import { erc1167Bytecode } from '../utils/proxy.js' +import { replaySafeHash } from '../utils/signMessage.js' import { toSmartAccount } from './toSmartAccount.js' // Offline stub transports. `client` reports the account as counterfactual @@ -106,19 +108,30 @@ describe('toSmartAccount', () => { ) }) - test('signMessage = authenticator || recoverable ECDSA', async () => { + test('signMessage = SignedMessageEnvelope (sigType || authenticator || sig)', async () => { const account = await toSmartAccount({ ...base, client: deployedClient, }) const message = 'hello 8130' const sig = await account.signMessage({ message }) - expect(slice(sig, 0, 20).toLowerCase()).toBe( + + // multichain sigType byte, then the authenticator. + expect(slice(sig, 0, 1)).toBe('0x02') + expect(slice(sig, 1, 21).toLowerCase()).toBe( ecrecoverAuthenticator.toLowerCase(), ) - const recovered = await recoverMessageAddress({ - message, - signature: slice(sig, 20), + + // The ECDSA `data` signs the account/chain-scoped replay-safe digest + // (multichain => chainId 0), not the raw message hash. + const digest = replaySafeHash({ + account: await account.getAddress(), + chainId: 0n, + hash: hashMessage(message), + }) + const recovered = await recoverAddress({ + hash: digest, + signature: slice(sig, 21), }) expect(recovered).toBe(owner.address) }) diff --git a/src/eip8130/accounts/toSmartAccount.ts b/src/eip8130/accounts/toSmartAccount.ts index a98689d6df..89bacd82ff 100644 --- a/src/eip8130/accounts/toSmartAccount.ts +++ b/src/eip8130/accounts/toSmartAccount.ts @@ -9,8 +9,6 @@ import { entryPoint07Address } from '../../account-abstraction/constants/address import type { EntryPointVersion } from '../../account-abstraction/types/entryPointVersion.js' import { getUserOperationHash } from '../../account-abstraction/utils/userOperation/getUserOperationHash.js' import { parseAccount } from '../../accounts/utils/parseAccount.js' -import { signMessage as signMessage_ } from '../../actions/wallet/signMessage.js' -import { signTypedData as signTypedData_ } from '../../actions/wallet/signTypedData.js' import { BaseError } from '../../errors/base.js' import type { Account } from '../../types/account.js' import type { Hex } from '../../types/misc.js' @@ -18,13 +16,18 @@ import type { Prettify } from '../../types/utils.js' import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' import { encodeFunctionData } from '../../utils/abi/encodeFunctionData.js' import { concatHex } from '../../utils/data/concat.js' -import { getAction } from '../../utils/getAction.js' +import { hashMessage } from '../../utils/signature/hashMessage.js' +import { hashTypedData } from '../../utils/signature/hashTypedData.js' import { erc4337AccountAbi } from '../abis.js' import { ecrecoverAuthenticator } from '../constants.js' import type { AaActor } from '../types/transaction.js' import { computeAddress } from '../utils/computeAddress.js' import { toFactoryArgs } from '../utils/keystoreCalls.js' import { erc1167Bytecode } from '../utils/proxy.js' +import { + getSignatureEnvelopeHash, + wrapSignatureEnvelope, +} from '../utils/signMessage.js' export type ToSmartAccountParameters< entryPointAbi extends Abi = Abi, @@ -229,22 +232,35 @@ export async function toSmartAccount< return concatHex([authenticator, stubData]) }, + // ERC-1271 message signatures use the EIP-8130 `SignedMessageEnvelope` + // (`sigType || authenticator || data` over `replaySafeHash`), which is what + // the account's `isValidSignature` -> `Keystore.validateSignature` expects. + // A multichain envelope (chainId 0) is used, matching the chain-agnostic + // account address; `sign` produces the authenticator `data` over the digest. async signMessage(parameters_) { - const signature = await getAction( - client, - signMessage_, - 'signMessage', - )({ account: owner, message: parameters_.message }) - return concatHex([authenticator, signature]) + const address = await this.getAddress() + const digest = getSignatureEnvelopeHash({ + account: address, + hash: hashMessage(parameters_.message), + }) + return wrapSignatureEnvelope({ + sigType: 'multichain', + authenticator, + signature: await sign(digest), + }) }, async signTypedData(parameters_) { - const signature = await getAction( - client, - signTypedData_, - 'signTypedData', - )({ account: owner, ...(parameters_ as any) }) - return concatHex([authenticator, signature]) + const address = await this.getAddress() + const digest = getSignatureEnvelopeHash({ + account: address, + hash: hashTypedData(parameters_ as never), + }) + return wrapSignatureEnvelope({ + sigType: 'multichain', + authenticator, + signature: await sign(digest), + }) }, async signUserOperation(parameters_) { diff --git a/src/eip8130/actions/estimateGas.test.ts b/src/eip8130/actions/estimateGas.test.ts index 8a68cb90c7..d0757482bb 100644 --- a/src/eip8130/actions/estimateGas.test.ts +++ b/src/eip8130/actions/estimateGas.test.ts @@ -87,7 +87,7 @@ describe('estimateGas — create account-change serialization', () => { x: '0x1111111111111111111111111111111111111111111111111111111111111111', y: '0x2222222222222222222222222222222222222222222222222222222222222222', }), - { scope: actorScope.sender, policy }, + { scope: actorScope.policy, policy }, ) const initialActors = [ @@ -116,7 +116,7 @@ describe('estimateGas — create account-change serialization', () => { const actors = rec.request.accountChanges[0].initialActors const gatedOut = actors.find((a: any) => a.actorId === gated.actorId) - expect(gatedOut.scope).toBe(actorScope.sender | actorScope.policy) + expect(gatedOut.scope).toBe(actorScope.policy) expect(typeof gatedOut.scope).toBe('number') expect(gatedOut.policyData?.toLowerCase()).toBe( encodePolicyData(policy).toLowerCase(), diff --git a/src/eip8130/actions/getPolicy.ts b/src/eip8130/actions/getPolicy.ts index 84088edd6a..a9cff77731 100644 --- a/src/eip8130/actions/getPolicy.ts +++ b/src/eip8130/actions/getPolicy.ts @@ -25,8 +25,8 @@ export type GetPolicyReturnType = { /** * Reads the policy binding for an actor (manager, commitment) from the finalized - * Keystore system contract via its combined `getActor` read (one call returns - * the actor config plus its policy manager and commitment). Use it to resolve a + * Keystore system contract via its combined `getActorWithPolicy` read (one call + * returns the actor config plus its policy manager and commitment). Use it to resolve a * session key's policy commitment for {@link getSessionSpend}. The manager and * commitment are non-zero only for a live, policy-gated actor. * @@ -54,13 +54,13 @@ export async function getPolicy< ): Promise { const { account, actorId } = parameters - // The finalized Keystore exposes a single combined read that returns the actor - // config plus its policy manager and commitment; `policyManager` and - // `policyCommitment` are zero for a non-live / ungated actor. + // The finalized Keystore exposes a single combined read (`getActorWithPolicy`) + // that returns the actor config plus its policy manager and commitment; + // `policyManager` and `policyCommitment` are zero for a non-live / ungated actor. const [, policyManager, policyCommitment] = await readContract(client, { address: keystoreAddress, abi: keystoreAbi, - functionName: 'getActor', + functionName: 'getActorWithPolicy', args: [account, actorId], }) diff --git a/src/eip8130/actions/validateSignature.ts b/src/eip8130/actions/validateSignature.ts new file mode 100644 index 0000000000..5c8e38b5ca --- /dev/null +++ b/src/eip8130/actions/validateSignature.ts @@ -0,0 +1,103 @@ +import type { Address, TypedData } from 'abitype' + +import { readContract } from '../../actions/public/readContract.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { Account } from '../../types/account.js' +import type { Chain } from '../../types/chain.js' +import type { Hex, SignableMessage } from '../../types/misc.js' +import type { TypedDataDefinition } from '../../types/typedData.js' +import { hashMessage } from '../../utils/signature/hashMessage.js' +import { hashTypedData } from '../../utils/signature/hashTypedData.js' +import { keystoreAbi } from '../abis.js' +import { keystoreAddress } from '../constants.js' + +export type ValidateSignatureParameters = { + /** The account the signature is validated against. */ + account: Address + /** + * The EIP-8130 signature envelope (`sigType || authenticator || data`), e.g. + * from `account.signMessage(...)` or `signMessageEnvelope(...)`. + */ + signature: Hex +} & ( + | { message: SignableMessage; hash?: undefined; typedData?: undefined } + | { hash: Hex; message?: undefined; typedData?: undefined } + | { + typedData: TypedDataDefinition< + TypedData | Record, + string + > + message?: undefined + hash?: undefined + } +) + +export type ValidateSignatureReturnType = { + /** Whether the signature authenticated a live actor of the account. */ + valid: boolean + /** The verified actor's identifier (zero-ish when invalid). */ + actorId: Hex + /** The verified actor's scope bitmask (`0` = unrestricted admin). */ + scope: number +} + +/** + * Verifies an EIP-8130 signature envelope against the `Keystore` + * (`validateSignature`), returning the resolved `actorId` and `scope`. + * + * This is the finer-grained counterpart to core `client.verifyMessage` (which + * only tells you pass/fail via ERC-1271): use it when you need to know *which* + * actor signed, or to apply your own scope-based authorization (e.g. gate on + * `Scopes.isOperator`, i.e. `scope === 0 || (scope & actorScope.operator)`). + * The keystore reverts when the actor cannot be authenticated, which is surfaced + * here as `valid: false`. + * + * @example + * ```ts + * import { validateSignature } from 'viem/eip8130' + * + * const { valid, actorId, scope } = await validateSignature(client, { + * account: account.address, + * message: 'hello world', + * signature, // from account.signMessage({ message: 'hello world' }) + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The verification result plus the resolved actor id and scope. + */ +export async function validateSignature< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: ValidateSignatureParameters, +): Promise { + const { account, signature } = parameters + const hash = + parameters.hash ?? + (parameters.message !== undefined + ? hashMessage(parameters.message) + : hashTypedData(parameters.typedData as never)) + + try { + const [actorId, scope] = await readContract(client, { + address: keystoreAddress, + abi: keystoreAbi, + functionName: 'validateSignature', + args: [account, hash, signature], + }) + return { valid: true, actorId, scope } + } catch { + // The keystore reverts (UnknownSignatureType / AuthenticationFailed / …) + // when the envelope does not authenticate a live actor. + return { + valid: false, + actorId: + '0x0000000000000000000000000000000000000000000000000000000000000000', + scope: 0, + } + } +} diff --git a/src/eip8130/chainConfig.test.ts b/src/eip8130/chainConfig.test.ts index c8e52a0510..9f1ee12fc4 100644 --- a/src/eip8130/chainConfig.test.ts +++ b/src/eip8130/chainConfig.test.ts @@ -9,6 +9,7 @@ import { toAccount } from './accounts/toAccount.js' import { eip8130ChainConfig } from './chainConfig.js' import { aaTransactionType } from './constants.js' import { key } from './keys.js' +import { parseTransaction } from './utils/parseTransaction.js' import { erc1167Bytecode } from './utils/proxy.js' const rawReceipt = { @@ -111,6 +112,65 @@ test('core sendTransaction submits a native AA_TX_TYPE (0x79) transaction', asyn expect(submitted?.startsWith(aaTransactionType)).toBe(true) }) +test('core sendTransaction wires dataSuffix (and client.dataSuffix) into metadata', async () => { + const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + ) + const account = toAccount({ + signer: owner, + userSalt: `0x${'01'.padStart(64, '0')}`, + code: erc1167Bytecode('0x00000000000000000000000000000000000000Ec'), + initialActors: [key.k1(owner.address)], + }) + + function makeClient(dataSuffix?: `0x${string}`) { + let submitted: `0x${string}` | undefined + const client = createClient({ + chain, + // client-level attribution suffix (the common wallet pattern) + dataSuffix, + transport: custom({ + async request({ method, params }: { method: string; params: any }) { + if (method === 'eth_call') return `0x${'0'.repeat(192)}` + if (method === 'eth_getTransactionCount') return '0x0' + if (method === 'eth_estimateGas') return '0x30d40' + if (method === 'eth_sendRawTransaction') { + submitted = params[0] + return `0x${'11'.repeat(32)}` + } + throw new Error(`unexpected ${method}`) + }, + }), + }) + return { client, getSubmitted: () => submitted } + } + + // client-level dataSuffix → metadata + const a = makeClient('0xc0ffee') + await sendTransaction(a.client, { + account, + calls: [{ to: account.address, data: '0x' }], + accountChanges: [account.create()], + maxFeePerGas: 1_000_000_000n, + maxPriorityFeePerGas: 1_000_000n, + } as never) + expect(parseTransaction(a.getSubmitted()!).metadata).toBe('0xc0ffee') + + // per-tx `metadata` (the 8130-native alias) overrides client-level dataSuffix. + // (Core consumes its own request-level `dataSuffix` param before this hook, so + // per-tx attribution on the native path uses `metadata`.) + const b = makeClient('0xc0ffee') + await sendTransaction(b.client, { + account, + calls: [{ to: account.address, data: '0x' }], + accountChanges: [account.create()], + maxFeePerGas: 1_000_000_000n, + maxPriorityFeePerGas: 1_000_000n, + metadata: '0xbeef', + } as never) + expect(parseTransaction(b.getSubmitted()!).metadata).toBe('0xbeef') +}) + test('core sendTransaction rejects sponsored (payer) sends with a redirect', async () => { const owner = privateKeyToAccount( '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', diff --git a/src/eip8130/chainConfig.ts b/src/eip8130/chainConfig.ts index 732312bc7d..4578df779f 100644 --- a/src/eip8130/chainConfig.ts +++ b/src/eip8130/chainConfig.ts @@ -109,6 +109,19 @@ export const eip8130ChainConfig = { encodeExecute: req.encodeExecute, }) + // Attribution suffix → top-level (signed, authenticated) `metadata`, since + // EIP-8130 has no calldata to concatenate onto. Precedence mirrors core: + // a per-tx value wins over the client-wide `client.dataSuffix`. Core + // `sendTransaction` consumes its own `dataSuffix` param before this hook, + // so per-tx attribution on the native path uses the 8130 `metadata` field; + // `req.dataSuffix` is kept as a fallback for direct request builders. + const dataSuffix = + req.metadata ?? + req.dataSuffix ?? + (typeof client.dataSuffix === 'string' + ? client.dataSuffix + : client.dataSuffix?.value) + // Price the AA transaction via the EIP-8130 `eth_estimateGas` extension // when the caller didn't pin `gas`. Thread the acting-actor hint so // policy-gated (session-key) sends resolve the right policy, and let the @@ -122,7 +135,7 @@ export const eip8130ChainConfig = { nonceKey: req.nonceKey, senderActorId: req.senderActorId ?? account.actorId, senderAuthAuthenticator: req.senderAuthAuthenticator, - dataSuffix: req.metadata, + dataSuffix, })) const body = await fillEip8130Body(client, { @@ -136,7 +149,7 @@ export const eip8130ChainConfig = { validBefore: req.validBefore, maxFeePerGas: req.maxFeePerGas, maxPriorityFeePerGas: req.maxPriorityFeePerGas, - dataSuffix: req.metadata, + dataSuffix, }) return { diff --git a/src/eip8130/constants.ts b/src/eip8130/constants.ts index 4810164791..db6806a14f 100644 --- a/src/eip8130/constants.ts +++ b/src/eip8130/constants.ts @@ -98,24 +98,30 @@ export const changeType = { export const unsequencedLocalHalf = 0xffff_ffffn /** - * Actor scope permission bitmask values (base/eip-8130 `Keystore`). + * Actor scope permission bitmask values (base/eip-8130 `Scopes`, a `uint16`). + * + * Core grants occupy bits 0–2 (`OPERATOR`, `SELF_PAYER`, `SPONSOR_PAYER`); the + * optional `POLICY` and `NONCE` grants trail them. Bits `0x20`..`0x8000` are + * spare, reserved for future pure grants. * * `0x00` (unrestricted) is admin: an actor is admin iff `scope == 0`. There is * no `SCOPE_SIGNATURE` / `SCOPE_CONFIG` bit — ERC-1271 signing and config rights - * ride on admin scope (or a SENDER actor without POLICY). Bits `0x20`, `0x40`, - * `0x80` are spare. + * ride on operational authority (`isOperator`: `scope == 0 || OPERATOR set`). + * `OPERATOR` and `POLICY` do **not** combine: `OPERATOR` may originate to any + * `call.to` and overrides `POLICY`, whereas a `POLICY`-only actor is gated to its + * manager. A policy-gated session key must therefore be `POLICY`-only. */ export const actorScope = { - /** `SCOPE_SENDER` — may originate transactions with the account as sender. */ - sender: 0x01, - /** `SCOPE_POLICY` — actor is gated to its policy manager; a bit in the scope word (replaces the old `policyType` field). */ - policy: 0x02, - /** `SCOPE_NONCE` — may use sequenced nonce keys; without it, restricted to nonce-free (`NONCE_KEY_MAX`). */ - nonce: 0x04, - /** `SCOPE_SELF_PAYER` — may pay for its own transactions (`payer == sender`). */ - selfPayer: 0x08, - /** `SCOPE_SPONSOR_PAYER` — may sponsor others (`payer != sender`). */ - sponsorPayer: 0x10, + /** `OPERATOR` — ungated initiation: may originate transactions to any `call.to`. Formerly `SCOPE_SENDER`. */ + operator: 0x01, + /** `SELF_PAYER` — may pay for its own transactions (`payer == sender`). */ + selfPayer: 0x02, + /** `SPONSOR_PAYER` — may sponsor others (`payer != sender`). */ + sponsorPayer: 0x04, + /** `POLICY` — gated initiation: actor is gated to its policy manager. Attachment is by payload length; this bit gates `sender_auth`. */ + policy: 0x08, + /** `NONCE` — may use sequenced nonce keys; without it, restricted to nonce-free (`NONCE_KEY_MAX`). */ + nonce: 0x10, } as const /** Unrestricted (admin) scope value (`SCOPE_UNRESTRICTED`). An actor is admin iff `scope == 0`. */ @@ -215,7 +221,7 @@ export const canonicalAuthenticators = { /** WebAuthn / FIDO2 passkey. Canonical base/eip-8130 deployment. */ passkey: '0x813007b6b1b48E75D91dEc5927ab515d12a0F1d0', /** Signature delegation (1-hop). Canonical base/eip-8130 deployment. */ - delegate: '0x8130015119757e0b1F9985F723091a851598Ade1', + delegate: '0x81301AA52202f8C6b79Cde660440E3c6A7c5ade1', } as const satisfies Record /** @@ -257,7 +263,7 @@ export const txContextAddress = * and the create transaction fails. */ export const keystoreAddress = - '0x813011b7a5f25f8433Ac1E0993DE06CB2d1500Ac' satisfies Hex + '0x813012Bd8D971928475235BBac6F0488c4A100AC' satisfies Hex /** * Default wallet implementation for EOA auto-delegation @@ -268,7 +274,7 @@ export const keystoreAddress = * identical on every supported chain; see {@link keystoreAddress}. */ export const defaultAccountAddress = - '0x813035E3fc4a102CE2b4a73D78a25D1Ea5AFadEf' satisfies Hex + '0x81309c54D6Bc190FbBc0FA9f296ea4C6A539ADEf' satisfies Hex /** Size of the deployment header in bytes (`DEPLOYMENT_HEADER_SIZE`). */ export const deploymentHeaderSize = 14 diff --git a/src/eip8130/decorators/eip8130Actions.ts b/src/eip8130/decorators/eip8130Actions.ts index 8104454456..66cebc7a7e 100644 --- a/src/eip8130/decorators/eip8130Actions.ts +++ b/src/eip8130/decorators/eip8130Actions.ts @@ -17,6 +17,7 @@ import { sendTransaction, sendTransactionSync, } from '../actions/sendTransaction.js' +import { validateSignature } from '../actions/validateSignature.js' import { waitForTransactionReceipt } from '../actions/waitForTransactionReceipt.js' type Action = (client: any, parameters: any) => any @@ -61,6 +62,8 @@ export type Eip8130Actions = { getLockStatus: Bound /** Current spend against a session key. */ getSessionSpend: Bound + /** Verify an EIP-8130 signature envelope; returns the resolved actor + scope. */ + validateSignature: Bound } } @@ -114,6 +117,7 @@ export function eip8130Actions() { isLocked: (parameters) => isLocked(client, parameters), getLockStatus: (parameters) => getLockStatus(client, parameters), getSessionSpend: (parameters) => getSessionSpend(client, parameters), + validateSignature: (parameters) => validateSignature(client, parameters), }, }) } diff --git a/src/eip8130/deployments.ts b/src/eip8130/deployments.ts index f06d65a211..33b19a707f 100644 --- a/src/eip8130/deployments.ts +++ b/src/eip8130/deployments.ts @@ -14,10 +14,12 @@ export type Eip8130Deployment = { /** Deployed wallet implementation contracts (the singletons account proxies delegate to). */ accounts: { /** - * Optional unaudited UpgradeableAccount example implementation. - * Accounts are deployed behind an ERC-1967 `UpgradeableProxy` (see - * {@link upgradeableProxyBytecode}) so they can be upgraded via - * `upgradeBySignature`. + * `CoinbaseSmartWalletV2` implementation — the canonical upgradeable account + * (see [base/smart-wallet-v2](https://github.com/base/smart-wallet-v2)). + * CREATE2 accounts are deployed behind an ERC-1967 `UpgradeableProxy` (see + * {@link upgradeableProxyBytecode}); a 7702-delegated EOA uses the + * `EIP7702ProxyForEIP8130` singleton with CBSW v2 as its default. Upgrades go + * through CBSW v2's admin-gated (scope-0) `upgrade`. */ upgradeable?: Address | undefined /** @@ -85,17 +87,17 @@ export type Eip8130Deployment = { */ export const canonicalEip8130Deployment = { accounts: { - // PENDING FINAL IMPLEMENTATION: the default `newSmartAccount` proxy is - // `'upgradeable'`, which must delegate to a real UUPS `UpgradeableAccount` - // (see base/eip-8130-examples) so accounts are genuinely upgradeable and - // multichain-safe. No such implementation is enshrined against the canonical - // Keystore yet — its address is keystore-dependent (constructor arg), and - // the expected long-term implementation is Coinbase Smart Wallet v2. Until an - // address is set here, `proxy: 'upgradeable'` requires an explicit - // `implementation`. Set `upgradeable` once deployed and the default goes live. + // PENDING DEPLOYMENT: the default `newSmartAccount` proxy is `'upgradeable'`, + // which delegates to `CoinbaseSmartWalletV2` (base/smart-wallet-v2) behind the + // ERC-1967 `UpgradeableProxy` so accounts are genuinely upgradeable and + // multichain-safe. CBSW v2 is not yet deployed against the canonical Keystore — + // its address is keystore-dependent (constructor arg), and the Keystore address + // itself was regenerated (see `keystoreAddress`). Until CBSW v2 is deployed and + // set here, `proxy: 'upgradeable'` requires an explicit `implementation`. Set + // `upgradeable` to the deployed CBSW v2 address and the default goes live. upgradeable: undefined, - default: '0x813035E3fc4a102CE2b4a73D78a25D1Ea5AFadEf', - defaultHighRate: '0x8130D6819734515f958965eFd2d212541d44FA57', + default: '0x81309c54D6Bc190FbBc0FA9f296ea4C6A539ADEf', + defaultHighRate: '0x813002fFdd25C81CeF79781702176D453AF0Fa57', // `erc4337` (BackwardsCompatible4337Account) is intentionally out of scope // for now — supply it explicitly if you choose the ERC-4337 portable path. }, @@ -103,12 +105,12 @@ export const canonicalEip8130Deployment = { k1: '0x0000000000000000000000000000000000000001', p256: '0x8130C89F65750431b564A4730397552a11CeA256', webAuthn: '0x813007b6b1b48E75D91dEc5927ab515d12a0F1d0', - delegate: '0x8130015119757e0b1F9985F723091a851598Ade1', + delegate: '0x81301AA52202f8C6b79Cde660440E3c6A7c5ade1', alwaysValid: '0xA550545Da91720c23483c5B3493412A02D1cF9F9', }, policies: { - manager: '0x8130fAA29D2675D05d01D387C576c6525F280ac1', - sessionPolicy: '0x81306283dfD94FcDe1a3aD4b7beDF1c5cD0f5e55', + manager: '0x8130E47Bc12CfDD6d2d2178B35Def9A51cae0aC1', + sessionPolicy: '0x8130A0D85473CeF9e888B4228F729b48F0c45E55', }, } as const satisfies Eip8130Deployment diff --git a/src/eip8130/devx.test.ts b/src/eip8130/devx.test.ts index ce87c90ae8..3b119fb993 100644 --- a/src/eip8130/devx.test.ts +++ b/src/eip8130/devx.test.ts @@ -5,6 +5,8 @@ import { createClient } from '../clients/createClient.js' import { custom } from '../clients/transports/custom.js' import type { Hex } from '../types/misc.js' import { keccak256 } from '../utils/hash/keccak256.js' +import { hashMessage } from '../utils/signature/hashMessage.js' +import { recoverAddress } from '../utils/signature/recoverAddress.js' import { delegateAuthSize, newSmartAccount, @@ -28,6 +30,7 @@ import { import { actorIdFromAddress, actorIdFromPublicKey } from './utils/actorId.js' import { parseTransaction } from './utils/parseTransaction.js' import { erc1167Bytecode, upgradeableProxyBytecode } from './utils/proxy.js' +import { parseSignatureEnvelope, replaySafeHash } from './utils/signMessage.js' const owner = privateKeyToAccount( '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', @@ -42,11 +45,12 @@ const pubkey = { } as const describe('canonical smart-account deployment', () => { - test('default upgradeable proxy requires an implementation until enshrined', () => { - // PENDING FINAL IMPLEMENTATION: no canonical UUPS UpgradeableAccount is - // enshrined yet, so the default `proxy: 'upgradeable'` needs an explicit impl. + test('default upgradeable proxy requires an implementation until deployed', () => { + // PENDING DEPLOYMENT: CoinbaseSmartWalletV2 is not deployed against the + // canonical Keystore yet, so the default `proxy: 'upgradeable'` needs an + // explicit impl. expect(() => newSmartAccount({ signer: owner, salt: userSalt })).toThrow( - 'No canonical `UpgradeableAccount` is enshrined yet', + 'No canonical `CoinbaseSmartWalletV2` is deployed against the Keystore', ) }) @@ -99,7 +103,7 @@ describe('canonical smart-account deployment', () => { proxy: 'erc1167', admins: [key.k1(co.address)], extraActors: [ - authorizeActor(key.p256(pubkey), { scope: actorScope.sender }), + authorizeActor(key.p256(pubkey), { scope: actorScope.operator }), ], }) @@ -146,7 +150,7 @@ describe('key builders + actorId derivation', () => { describe('scope + policy helpers', () => { test('toScope combines flags', () => { - expect(toScope(actorScope.sender, actorScope.selfPayer)).toBe(0x09) + expect(toScope(actorScope.operator, actorScope.selfPayer)).toBe(0x03) }) test('encodePolicyData = manager || commitment', () => { @@ -171,12 +175,12 @@ describe('scope + policy helpers', () => { expect(() => authorizeActor(key.p256(pubkey), { scope: 0, policy }), ).toThrow() - // sender-scoped policy actor: SCOPE_POLICY bit is set, policyData populated. + // policy-scoped actor: POLICY bit is set, policyData populated. const change = authorizeActor(key.p256(pubkey), { - scope: actorScope.sender, + scope: actorScope.policy, policy, }) - expect(change.scope).toBe(actorScope.sender | actorScope.policy) + expect(change.scope).toBe(actorScope.policy) expect(change.policyData?.toLowerCase()).toBe( `${policy.manager.toLowerCase()}${commitment.slice(2)}`, ) @@ -203,7 +207,7 @@ describe('toAccount', () => { test('change() produces a signed config entry (add p256 session key)', async () => { const change = await account.change([ authorizeActor(key.p256(pubkey), { - scope: actorScope.sender, + scope: actorScope.policy, policy: { type: 1, manager: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', @@ -218,6 +222,38 @@ describe('toAccount', () => { expect(change.signature.length).toBe(2 + 85 * 2) }) + test('signMessage() produces a multichain ERC-1271 envelope', async () => { + const signature = await account.signMessage({ message: 'gm' }) + const { + sigType, + authenticator, + signature: data, + } = parseSignatureEnvelope(signature) + expect(sigType).toBe('multichain') + expect(authenticator).toBe(canonicalAuthenticators.k1) + // k1 data recovers to the owner over the account/chain-scoped digest. + const digest = replaySafeHash({ + account: account.address, + chainId: 0n, + hash: hashMessage('gm'), + }) + expect( + (await recoverAddress({ hash: digest, signature: data })).toLowerCase(), + ).toBe(owner.address.toLowerCase()) + }) + + test('signTypedData() produces an ERC-1271 envelope', async () => { + const signature = await account.signTypedData({ + domain: { name: 'App', version: '1' }, + types: { Mail: [{ name: 'contents', type: 'string' }] }, + primaryType: 'Mail', + message: { contents: 'gm' }, + }) + expect(parseSignatureEnvelope(signature).authenticator).toBe( + canonicalAuthenticators.k1, + ) + }) + test('delegate() entry', () => { expect( account.delegate('0x0000000000000000000000000000000000000000'), diff --git a/src/eip8130/index.ts b/src/eip8130/index.ts index fcf3bbd9d5..18e35911f4 100644 --- a/src/eip8130/index.ts +++ b/src/eip8130/index.ts @@ -95,6 +95,11 @@ export { sendTransaction, sendTransactionSync, } from './actions/sendTransaction.js' +export { + type ValidateSignatureParameters, + type ValidateSignatureReturnType, + validateSignature, +} from './actions/validateSignature.js' export { type WaitForTransactionReceiptParameters, type WaitForTransactionReceiptReturnType, @@ -349,6 +354,29 @@ export { toWebAuthnSigner, type WebAuthnSignSource, } from './utils/signers.js' +export { + type GetSignatureEnvelopeHashParameters, + getSignatureEnvelopeHash, + multichainId, + type ParsedSignatureEnvelope, + parseSignatureEnvelope, + type ReplaySafeHashErrorType, + type ReplaySafeHashParameters, + replaySafeHash, + type SignatureType, + type SignMessageEnvelopeErrorType, + type SignMessageEnvelopeParameters, + type SignTypedDataEnvelopeParameters, + signatureType, + signedMessageTypehash, + signMessageEnvelope, + signTypedDataEnvelope, + type WrapCounterfactualSignatureErrorType, + type WrapCounterfactualSignatureParameters, + type WrapSignatureEnvelopeParameters, + wrapCounterfactualSignature, + wrapSignatureEnvelope, +} from './utils/signMessage.js' export { type Signer, type SignTransactionErrorType, diff --git a/src/eip8130/keys.ts b/src/eip8130/keys.ts index 278763d05b..753c58338f 100644 --- a/src/eip8130/keys.ts +++ b/src/eip8130/keys.ts @@ -77,7 +77,7 @@ export const key = { * Trusted-executor ("external caller") actor for `caller` — an address (e.g. a * PolicyManager or ERC-4337 EntryPoint) authorized to drive the account via * `executeBatch` by matching `msg.sender`, not by producing a signature. Pair - * with `authorizeActor(..., { scope: actorScope.sender })` and no policy. A + * with `authorizeActor(..., { scope: actorScope.operator })` and no policy. A * policy-gated session key needs its `manager` registered this way. */ trustedExecutor(caller: Address): AaActor { @@ -170,12 +170,13 @@ export type AuthorizeActorOptions = { * `toAccount#authorize`. * * A policy-gated actor (session key) should be authorized as POLICY-only - * (`scope: actorScope.policy`): `SCOPE_POLICY` grants "gated initiation", so the + * (`scope: actorScope.policy`): `POLICY` grants "gated initiation", so the * key can originate a transaction but every call is routed through — and - * validated by — its manager. Per base/eip-8130, do NOT add `SCOPE_SENDER` (the - * policy gate governs regardless). OR in `actorScope.selfPayer` for self-pay or - * `actorScope.nonce` to allow sequenced nonces (without it the key is - * nonce-free-only). + * validated by — its manager. Per base/eip-8130, do NOT add `OPERATOR`: + * `OPERATOR` and `POLICY` do not combine — `OPERATOR` overrides the gate and + * lets the key originate to any target, defeating the policy. OR in + * `actorScope.selfPayer` for self-pay or `actorScope.nonce` to allow sequenced + * nonces (without it the key is nonce-free-only). * * @example * authorizeActor(key.p256({ x, y }), { diff --git a/src/eip8130/permissions.test.ts b/src/eip8130/permissions.test.ts index 08b8e30238..47049aaf11 100644 --- a/src/eip8130/permissions.test.ts +++ b/src/eip8130/permissions.test.ts @@ -279,7 +279,7 @@ describe('fulfillGrantPermissions', () => { expect(managerChange).toBeDefined() expect(managerChange?.authenticator).toBe(trustedExecutorAuthenticator) expect(managerChange?.actorId).toBe(actorIdFromAddress(session.manager)) - expect(managerChange?.scope).toBe(actorScope.sender) + expect(managerChange?.scope).toBe(actorScope.operator) expect(managerChange?.policyData).toBeUndefined() expect(changes).toEqual([managerChange, change]) diff --git a/src/eip8130/permissions.ts b/src/eip8130/permissions.ts index b4322f62ed..b4596a5cf7 100644 --- a/src/eip8130/permissions.ts +++ b/src/eip8130/permissions.ts @@ -241,7 +241,7 @@ export type ToSessionPolicyErrorType = ToSessionPolicyConfigErrorType * // authorize the session key (its signed commitment IS the grant) * await account.change([ * authorizeActor(key.p256(pub), { - * scope: actorScope.sender, + * scope: actorScope.policy, // POLICY-only; OPERATOR would override the gate * policy: session.actorPolicy, * }), * ]) @@ -433,7 +433,7 @@ export async function fulfillGrantPermissions< authenticator.toLowerCase() === trustedExecutorAuthenticator.toLowerCase() if (!registered) managerChange = authorizeActor(managerActor, { - scope: actorScope.sender, + scope: actorScope.operator, }) } diff --git a/src/eip8130/policies.test.ts b/src/eip8130/policies.test.ts index 2bafabd0c5..02786148b3 100644 --- a/src/eip8130/policies.test.ts +++ b/src/eip8130/policies.test.ts @@ -50,7 +50,7 @@ describe('encoders', () => { describe('commitmentOf', () => { test('matches PolicyManager.commitmentOf reference vector', () => { expect(commitmentOf(binding)).toBe( - '0x512b8e70d95c8ff3410a0fbbf9bfe0d41c3b608bb36c20f407991870200a3f5b', + '0xf728b71109fd552c8ded7e5d780d0eb68afcf0c8a4c9c294ca2ab24fd288b851', ) }) diff --git a/src/eip8130/policies.ts b/src/eip8130/policies.ts index f019c7bd97..cc802a788a 100644 --- a/src/eip8130/policies.ts +++ b/src/eip8130/policies.ts @@ -244,7 +244,7 @@ export type DefineSessionPolicyErrorType = CommitmentOfErrorType * // 1) authorize in one transaction (sent by the account). The signed actor * // change stores the commitment — that IS the authorization (no install). * const change = await account.change([ - * authorizeActor(key.p256(pub), { scope: actorScope.sender, policy: session.actorPolicy }), + * authorizeActor(key.p256(pub), { scope: actorScope.policy, policy: session.actorPolicy }), * ]) * * // 2) later, the session key spends within its limit diff --git a/src/eip8130/queries.test.ts b/src/eip8130/queries.test.ts index ca4b3f1f50..2160bb2862 100644 --- a/src/eip8130/queries.test.ts +++ b/src/eip8130/queries.test.ts @@ -10,7 +10,7 @@ import { getActorConfig } from './actions/getActorConfig.js' import { getPolicy } from './actions/getPolicy.js' import { getSessionSpend } from './actions/getSessionSpend.js' import { isActor } from './actions/isActor.js' -import { canonicalAuthenticators } from './constants.js' +import { actorScope, canonicalAuthenticators } from './constants.js' import { sessionPolicyAbi } from './policies.js' const account = '0x0000000000000000000000000000000000000a11' @@ -82,15 +82,15 @@ describe('getActorConfig', () => { const client = readClient(keystoreAbi, { getActorConfig: { authenticator: canonicalAuthenticators.p256, - scope: 2, + scope: actorScope.policy, expiry: 1_800_000_000, }, }) expect(await getActorConfig(client, { account, actorId })).toEqual({ authenticator: canonicalAuthenticators.p256, - scope: 2, + scope: actorScope.policy, expiry: 1_800_000_000, - // SCOPE_POLICY (0x02) is set → hasPolicy. + // POLICY (0x08) bit is set → hasPolicy. hasPolicy: true, }) }) @@ -123,12 +123,12 @@ describe('isActor', () => { }) describe('getPolicy', () => { - // The finalized Keystore exposes a single combined `getActor` read + // The finalized Keystore exposes a single combined `getActorWithPolicy` read // returning (config, policyManager, policyCommitment). - test('decodes (manager, commitment) from the combined getActor read', async () => { + test('decodes (manager, commitment) from the combined getActorWithPolicy read', async () => { const manager = '0x00000000000000000000000000000000000000dd' const client = readClient(keystoreAbi, { - getActor: [ + getActorWithPolicy: [ { authenticator: canonicalAuthenticators.p256, scope: 2, diff --git a/src/eip8130/subAccounts.test.ts b/src/eip8130/subAccounts.test.ts index 0d829ea3a7..348af6ab17 100644 --- a/src/eip8130/subAccounts.test.ts +++ b/src/eip8130/subAccounts.test.ts @@ -99,13 +99,13 @@ describe('fulfillAddSubAccount', () => { proxy: 'erc1167', salt, keys: [{ publicKey: dappKey, type: 'address' }], - keyScope: actorScope.sender, + keyScope: actorScope.operator, }) const keyActor = sub.initialActors.find( (a) => a.actorId === key.k1(dappKey).actorId, ) - expect(keyActor?.scope).toBe(actorScope.sender) + expect(keyActor?.scope).toBe(actorScope.operator) // The parent delegate remains an unrestricted admin. const parentActor = sub.initialActors.find( @@ -137,13 +137,13 @@ describe('fulfillAddSubAccount', () => { expect(keyActor?.policyData).toBe(encodePolicyData(policy)) }) - test('upgradeable proxy without an implementation throws (pending enshrinement)', () => { + test('upgradeable proxy without an implementation throws (pending deployment)', () => { expect(() => fulfillAddSubAccount({ parent, signer: parentSigner, salt, }), - ).toThrow(/UpgradeableAccount/) + ).toThrow(/CoinbaseSmartWalletV2/) }) }) diff --git a/src/eip8130/subAccounts.ts b/src/eip8130/subAccounts.ts index 4590fbcfe7..2c32b51f44 100644 --- a/src/eip8130/subAccounts.ts +++ b/src/eip8130/subAccounts.ts @@ -66,7 +66,7 @@ export type FulfillAddSubAccountParameters = { /** * Scope applied to the requested `keys`. Omitted or {@link scopeUnrestricted} * (`0`, default) registers them as unrestricted co-owners (admins). Set a - * restricted scope (e.g. `actorScope.sender`, or `actorScope.policy` with + * restricted scope (e.g. `actorScope.operator`, or `actorScope.policy` with * `keyPolicy`) to register them as scoped session-key actors — so the parent * remains the only unrestricted owner. */ @@ -192,10 +192,10 @@ export function fulfillAddSubAccount( implementation ?? canonicalEip8130Deployment.accounts.upgradeable if (!impl) throw new BaseError( - 'No canonical `UpgradeableAccount` is enshrined yet (pending final ' + - 'implementation), so `proxy: "upgradeable"` requires an explicit ' + - '`implementation`. Pass `proxy: "erc1167"` for an immutable ' + - 'DefaultAccount-backed sub-account.', + 'No canonical `CoinbaseSmartWalletV2` is deployed against the Keystore ' + + 'yet, so `proxy: "upgradeable"` requires an explicit `implementation`. ' + + 'Pass `proxy: "erc1167"` for an immutable DefaultAccount-backed ' + + 'sub-account.', ) return upgradeableProxyBytecode(impl) })() diff --git a/src/eip8130/utils/hashActorChanges.ts b/src/eip8130/utils/hashActorChanges.ts index fdd01a603c..70169a0eb5 100644 --- a/src/eip8130/utils/hashActorChanges.ts +++ b/src/eip8130/utils/hashActorChanges.ts @@ -20,11 +20,17 @@ export const accountChangeTypehash = keccak256( ) /** - * `keccak256("SignedAccountChanges(address account,uint256 chainId,uint64 sequence,AccountChange[] changes)AccountChange(uint8 changeType,bytes payload)")` + * `keccak256("SignedAccountChangeBatch(address account,uint256 chainId,uint64 sequence,AccountChange[] changes)AccountChange(uint8 changeType,bytes payload)")` + * + * Mirrors `Keystore.SIGNED_ACCOUNT_CHANGES_TYPEHASH`. The struct name is + * `SignedAccountChangeBatch` (the wire struct passed to + * `applySignedAccountChanges` is still named `SignedAccountChanges`, but the + * signing typehash uses the `Batch` name — they must match the contract byte + * for byte or the node rejects the signature). */ export const signedAccountChangesTypehash = keccak256( stringToHex( - 'SignedAccountChanges(address account,uint256 chainId,uint64 sequence,AccountChange[] changes)AccountChange(uint8 changeType,bytes payload)', + 'SignedAccountChangeBatch(address account,uint256 chainId,uint64 sequence,AccountChange[] changes)AccountChange(uint8 changeType,bytes payload)', ), ) diff --git a/src/eip8130/utils/proxy.ts b/src/eip8130/utils/proxy.ts index 6b52476b1c..4df83ef476 100644 --- a/src/eip8130/utils/proxy.ts +++ b/src/eip8130/utils/proxy.ts @@ -19,17 +19,20 @@ export function erc1167Bytecode(implementation: Address): Hex { /** * Builds the 93-byte `UpgradeableProxy` runtime bytecode: an ERC-1967 proxy with * a hardcoded default `implementation`. This is the `code` deployed at an - * **upgradeable** EIP-8130 account address (an `UpgradeableAccount`), and the - * per-account counterpart to the singleton implementation it delegates to. + * **upgradeable** EIP-8130 account address (a {@link https://github.com/base/smart-wallet-v2/blob/master/src/CoinbaseSmartWalletV2.sol CoinbaseSmartWalletV2} + * account), and the per-account counterpart to the singleton implementation it + * delegates to. * - * Proxy logic (see [base/eip-8130 `UpgradeableProxy`](https://github.com/base/eip-8130/blob/main/src/accounts/UpgradeableProxy.sol)): + * Proxy logic (see [base/smart-wallet-v2 `UpgradeableProxy`](https://github.com/base/smart-wallet-v2/blob/master/src/proxy/UpgradeableProxy.sol)): * 1. `SLOAD` the ERC-1967 implementation slot. * 2. If non-zero, `delegatecall` to that address (the upgraded path). * 3. If zero, `delegatecall` to the hardcoded default (a fresh account). * - * Pass an `UpgradeableAccount` implementation — only a UUPS-capable - * implementation can ever write the slot this proxy reads. Immutable accounts - * use {@link erc1167Bytecode} instead. + * Pass a `CoinbaseSmartWalletV2` implementation — only a UUPS-capable + * implementation can ever write the slot this proxy reads (via CBSW v2's + * admin-gated `upgrade`). Immutable accounts use {@link erc1167Bytecode} instead. + * For a 7702-delegated EOA, the singleton {@link https://github.com/base/smart-wallet-v2/blob/master/src/proxy/EIP7702ProxyForEIP8130.sol EIP7702ProxyForEIP8130} + * is the delegation target (CBSW v2 as its default implementation). */ export function upgradeableProxyBytecode(implementation: Address): Hex { return concatHex([ diff --git a/src/eip8130/utils/signMessage.test.ts b/src/eip8130/utils/signMessage.test.ts new file mode 100644 index 0000000000..8840f620b6 --- /dev/null +++ b/src/eip8130/utils/signMessage.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { encodeAbiParameters } from '../../utils/abi/encodeAbiParameters.js' +import { size } from '../../utils/data/size.js' +import { slice } from '../../utils/data/slice.js' +import { stringToHex } from '../../utils/encoding/toHex.js' +import { keccak256 } from '../../utils/hash/keccak256.js' +import { hashMessage } from '../../utils/signature/hashMessage.js' +import { isErc6492Signature } from '../../utils/signature/isErc6492Signature.js' +import { parseErc6492Signature } from '../../utils/signature/parseErc6492Signature.js' +import { recoverAddress } from '../../utils/signature/recoverAddress.js' +import { + canonicalAuthenticators, + ecrecoverAuthenticator, + keystoreAddress, +} from '../constants.js' +import { key } from '../keys.js' +import { erc1167Bytecode } from './proxy.js' +import { + getSignatureEnvelopeHash, + multichainId, + parseSignatureEnvelope, + replaySafeHash, + signatureType, + signedMessageTypehash, + signMessageEnvelope, + signTypedDataEnvelope, + wrapCounterfactualSignature, + wrapSignatureEnvelope, +} from './signMessage.js' + +const account = '0x000000000000000000000000000000000000a130' as const +const owner = privateKeyToAccount(`0x${'11'.repeat(32)}`) +const hash = keccak256(stringToHex('app hash')) + +describe('signedMessageTypehash', () => { + test('matches keccak256 of the SignedMessageEnvelope type string', () => { + expect(signedMessageTypehash).toBe( + keccak256( + stringToHex( + 'SignedMessageEnvelope(address account,uint256 chainId,bytes32 hash)', + ), + ), + ) + }) +}) + +describe('replaySafeHash', () => { + test('equals keccak256(abi.encode(typehash, account, chainId, hash))', () => { + const chainId = 8453n + expect(replaySafeHash({ account, chainId, hash })).toBe( + keccak256( + encodeAbiParameters( + [ + { type: 'bytes32' }, + { type: 'address' }, + { type: 'uint256' }, + { type: 'bytes32' }, + ], + [signedMessageTypehash, account, chainId, hash], + ), + ), + ) + }) + + test('local vs multichain bind different chain ids', () => { + expect(replaySafeHash({ account, chainId: 0n, hash })).not.toBe( + replaySafeHash({ account, chainId: 8453n, hash }), + ) + }) +}) + +describe('getSignatureEnvelopeHash', () => { + test('multichain binds chainId 0', () => { + expect(getSignatureEnvelopeHash({ account, hash })).toBe( + replaySafeHash({ account, chainId: multichainId, hash }), + ) + }) + + test('local binds the supplied chainId', () => { + expect( + getSignatureEnvelopeHash({ + account, + hash, + sigType: 'local', + chainId: 10n, + }), + ).toBe(replaySafeHash({ account, chainId: 10n, hash })) + }) + + test('local requires a chainId', () => { + expect(() => + getSignatureEnvelopeHash({ account, hash, sigType: 'local' }), + ).toThrow('`chainId` is required') + }) +}) + +describe('wrap / parse signature envelope', () => { + test('round-trips sigType || authenticator || data', () => { + const signature = `0x${'ab'.repeat(65)}` as const + const envelope = wrapSignatureEnvelope({ + sigType: 'local', + authenticator: ecrecoverAuthenticator, + signature, + }) + // 1 (sigType) + 20 (authenticator) + 65 (sig) = 86 bytes + expect(size(envelope)).toBe(86) + expect(slice(envelope, 0, 1)).toBe('0x01') + expect(parseSignatureEnvelope(envelope)).toEqual({ + sigType: 'local', + authenticator: ecrecoverAuthenticator, + signature, + }) + }) + + test('multichain leading byte is 0x02', () => { + const envelope = wrapSignatureEnvelope({ + sigType: 'multichain', + authenticator: ecrecoverAuthenticator, + signature: `0x${'cd'.repeat(65)}`, + }) + expect(slice(envelope, 0, 1)).toBe('0x02') + expect(parseSignatureEnvelope(envelope).sigType).toBe('multichain') + }) + + test('rejects an unknown type byte', () => { + expect(() => parseSignatureEnvelope(`0x00${'ab'.repeat(20)}`)).toThrow( + 'Unknown signature envelope type byte', + ) + }) +}) + +describe('signMessageEnvelope', () => { + test('default multichain: signs the replay-safe digest, recovers the k1 signer', async () => { + const envelope = await signMessageEnvelope({ + signer: owner, + account, + message: 'hello world', + }) + const { sigType, authenticator, signature } = + parseSignatureEnvelope(envelope) + expect(sigType).toBe('multichain') + expect(authenticator).toBe(ecrecoverAuthenticator) + + // The k1 authenticator data is a raw 65-byte ECDSA signature over the + // account/chain-scoped digest — recoverable back to the owner. + const digest = replaySafeHash({ + account, + chainId: multichainId, + hash: hashMessage('hello world'), + }) + expect( + (await recoverAddress({ hash: digest, signature })).toLowerCase(), + ).toBe(owner.address.toLowerCase()) + }) + + test('local envelope binds the chain id', async () => { + const envelope = await signMessageEnvelope({ + signer: owner, + account, + message: 'hi', + sigType: 'local', + chainId: 8453n, + }) + const { signature } = parseSignatureEnvelope(envelope) + const digest = replaySafeHash({ + account, + chainId: 8453n, + hash: hashMessage('hi'), + }) + expect( + (await recoverAddress({ hash: digest, signature })).toLowerCase(), + ).toBe(owner.address.toLowerCase()) + }) + + test('accepts a pre-computed hash', async () => { + const a = await signMessageEnvelope({ signer: owner, account, hash }) + const b = await signMessageEnvelope({ + signer: owner, + account, + hash, + sigType: 'multichain', + }) + expect(a).toBe(b) + }) +}) + +describe('wrapCounterfactualSignature', () => { + test('wraps the envelope in an ERC-6492 sig with the keystore deploy call', async () => { + const userSalt = `0x${'01'.padStart(64, '0')}` as const + const code = erc1167Bytecode('0x00000000000000000000000000000000000000Ec') + const initialActors = [ + { ...key.k1(owner.address), authenticator: canonicalAuthenticators.k1 }, + ] + const envelope = await signMessageEnvelope({ + signer: owner, + account, + message: 'gm', + }) + const wrapped = wrapCounterfactualSignature({ + signature: envelope, + userSalt, + code, + initialActors, + }) + + expect(isErc6492Signature(wrapped)).toBe(true) + const { address, signature } = parseErc6492Signature(wrapped) + // Factory is the enshrined keystore; inner sig is the original envelope. + expect(address).toBe(keystoreAddress) + expect(signature).toBe(envelope) + }) +}) + +describe('signTypedDataEnvelope', () => { + test('wraps hashTypedData into the same envelope', async () => { + const typedData = { + domain: { name: 'App', version: '1', chainId: 8453 }, + types: { Mail: [{ name: 'contents', type: 'string' }] }, + primaryType: 'Mail', + message: { contents: 'gm' }, + } as const + + const envelope = await signTypedDataEnvelope({ + signer: owner, + account, + ...typedData, + }) + expect(parseSignatureEnvelope(envelope).sigType).toBe('multichain') + expect(parseSignatureEnvelope(envelope).authenticator).toBe( + ecrecoverAuthenticator, + ) + expect(signatureType.multichain).toBe(0x02) + }) +}) diff --git a/src/eip8130/utils/signMessage.ts b/src/eip8130/utils/signMessage.ts new file mode 100644 index 0000000000..c97860b80d --- /dev/null +++ b/src/eip8130/utils/signMessage.ts @@ -0,0 +1,378 @@ +import type { Address, TypedData } from 'abitype' +import type { ErrorType } from '../../errors/utils.js' +import type { Hex, SignableMessage } from '../../types/misc.js' +import type { TypedDataDefinition } from '../../types/typedData.js' +import { + type EncodeAbiParametersErrorType, + encodeAbiParameters, +} from '../../utils/abi/encodeAbiParameters.js' +import { type ConcatHexErrorType, concatHex } from '../../utils/data/concat.js' +import { pad } from '../../utils/data/pad.js' +import { size } from '../../utils/data/size.js' +import { slice } from '../../utils/data/slice.js' +import { hexToNumber } from '../../utils/encoding/fromHex.js' +import { numberToHex, stringToHex } from '../../utils/encoding/toHex.js' +import { keccak256 } from '../../utils/hash/keccak256.js' +import { + type HashMessageErrorType, + hashMessage, +} from '../../utils/signature/hashMessage.js' +import { hashTypedData } from '../../utils/signature/hashTypedData.js' +import { + type SerializeErc6492SignatureErrorType, + serializeErc6492Signature, +} from '../../utils/signature/serializeErc6492Signature.js' +import { ecrecoverAuthenticator } from '../constants.js' +import type { AaActor } from '../types/transaction.js' +import { type ToFactoryArgsErrorType, toFactoryArgs } from './keystoreCalls.js' +import type { Signer } from './signTransaction.js' + +/** + * `keccak256("SignedMessageEnvelope(address account,uint256 chainId,bytes32 hash)")` + * + * Mirrors `Keystore.SIGNED_MESSAGE_TYPEHASH`. Deliberately **not** EIP-712 (no + * domain separator) so a wallet's `eth_signTypedData` cannot be phished into + * producing one; the account + chainId scoping lives inside the struct. + */ +export const signedMessageTypehash = keccak256( + stringToHex( + 'SignedMessageEnvelope(address account,uint256 chainId,bytes32 hash)', + ), +) + +/** + * Signature-envelope channel byte (`Keystore.SignatureType`), the leading byte + * of an EIP-8130 signature (`sigType || authenticator || data`). + * + * - `local` (`0x01`) — binds `block.chainid`; the signature is valid on one chain. + * - `multichain` (`0x02`) — binds `chainId = 0`; the signature is valid on every + * chain (mirrors the `applySignedAccountChanges` multichain channel). + * + * `0x00` is the reserved `Invalid` value and is rejected on-chain. + */ +export const signatureType = { + local: 0x01, + multichain: 0x02, +} as const + +export type SignatureType = keyof typeof signatureType + +/** + * The chain a `multichain` envelope binds to (`0`, i.e. every chain). + */ +export const multichainId = 0n + +export type ReplaySafeHashParameters = { + /** The account the signature is bound to. */ + account: Address + /** + * The chain the signature is bound to. `block.chainid` for a `local` + * signature, `0` for a `multichain` (all-chains) signature. + */ + chainId: number | bigint + /** The raw app digest (e.g. `hashMessage(message)` / `hashTypedData(...)`). */ + hash: Hex +} + +export type ReplaySafeHashErrorType = EncodeAbiParametersErrorType | ErrorType + +/** + * Computes the EIP-8130 replay-safe message digest — the value an actor actually + * signs for `hash` to be accepted for `account` on `chainId` + * (`Keystore.replaySafeHash`): + * + * ``` + * keccak256(abi.encode(SIGNED_MESSAGE_TYPEHASH, account, chainId, hash)) + * ``` + */ +export function replaySafeHash(parameters: ReplaySafeHashParameters): Hex { + const { account, chainId, hash } = parameters + return keccak256( + encodeAbiParameters( + [ + { type: 'bytes32' }, + { type: 'address' }, + { type: 'uint256' }, + { type: 'bytes32' }, + ], + [signedMessageTypehash, account, BigInt(chainId), hash], + ), + ) +} + +export type GetSignatureEnvelopeHashParameters = { + /** The account the signature is bound to. */ + account: Address + /** The raw app digest to wrap. */ + hash: Hex + /** Envelope channel. @default 'multichain' */ + sigType?: SignatureType | undefined + /** + * Chain to bind a `local` envelope to (`block.chainid`). Required for + * `sigType: 'local'`; ignored for `multichain` (always `0`). + */ + chainId?: number | bigint | undefined +} + +/** + * Resolves the `sigType` to its bound chain id and returns the + * {@link replaySafeHash} digest to sign (`Keystore.envelopeDigest`). This is the + * digest an actor's authenticator signs; prepend `sigType || authenticator` to + * the resulting signature to form the on-chain envelope (see + * {@link wrapSignatureEnvelope}). + */ +export function getSignatureEnvelopeHash( + parameters: GetSignatureEnvelopeHashParameters, +): Hex { + const { account, hash, sigType = 'multichain', chainId } = parameters + if (sigType === 'local' && chainId === undefined) + throw new Error('`chainId` is required for a `local` signature envelope.') + return replaySafeHash({ + account, + chainId: sigType === 'multichain' ? multichainId : chainId!, + hash, + }) +} + +export type WrapSignatureEnvelopeParameters = { + /** Envelope channel. */ + sigType: SignatureType + /** Authenticator address that validates `signature`. */ + authenticator: Address + /** + * Authenticator-specific `data`: a raw 65-byte ECDSA signature for the native + * secp256k1 authenticator, or the authenticator's blob otherwise. + */ + signature: Hex +} + +/** + * Wraps an authenticator signature into the on-chain EIP-8130 signature envelope + * consumed by `Keystore.validateSignature` / an account's ERC-1271 + * `isValidSignature`: + * + * ``` + * sigType(1) || authenticator(20) || data + * ``` + */ +export function wrapSignatureEnvelope( + parameters: WrapSignatureEnvelopeParameters, +): Hex { + const { sigType, authenticator, signature } = parameters + return concatHex([ + numberToHex(signatureType[sigType], { size: 1 }), + pad(authenticator, { size: 20 }), + signature, + ]) +} + +export type ParsedSignatureEnvelope = { + /** Envelope channel resolved from the leading byte. */ + sigType: SignatureType + /** Authenticator address (bytes 1..21). */ + authenticator: Address + /** Authenticator-specific `data` (the remaining bytes). */ + signature: Hex +} + +/** + * Decodes an EIP-8130 signature envelope (`sigType || authenticator || data`) + * back into its parts. Inverse of {@link wrapSignatureEnvelope}. + */ +export function parseSignatureEnvelope(envelope: Hex): ParsedSignatureEnvelope { + if (size(envelope) < 21) + throw new Error( + 'Signature envelope must be at least 21 bytes (`sigType || authenticator`).', + ) + const typeByte = hexToNumber(slice(envelope, 0, 1)) + const sigType = (Object.keys(signatureType) as SignatureType[]).find( + (k) => signatureType[k] === typeByte, + ) + if (!sigType) + throw new Error(`Unknown signature envelope type byte: ${typeByte}.`) + return { + sigType, + authenticator: slice(envelope, 1, 21), + signature: slice(envelope, 21), + } +} + +export type SignMessageEnvelopeParameters = { + /** + * Signer producing the authenticator `data` over the envelope digest. For the + * native secp256k1 path this is a `LocalAccount` (its `sign` returns a raw + * 65-byte ECDSA signature); for a P-256 / WebAuthn / delegate actor it returns + * the authenticator-specific blob. + */ + signer: Signer + /** The account the signature is bound to. */ + account: Address + /** + * Authenticator address written into the envelope. Defaults to the signer's + * `authenticator`, then the native `ECRECOVER_AUTHENTICATOR`. + */ + authenticator?: Address | undefined + /** Envelope channel. @default 'multichain' */ + sigType?: SignatureType | undefined + /** Chain to bind a `local` envelope to. Required for `sigType: 'local'`. */ + chainId?: number | bigint | undefined +} & ( + | { message: SignableMessage; hash?: undefined; typedData?: undefined } + | { hash: Hex; message?: undefined; typedData?: undefined } +) + +export type SignMessageEnvelopeErrorType = + | HashMessageErrorType + | ReplaySafeHashErrorType + | ConcatHexErrorType + | ErrorType + +/** + * Produces an EIP-8130 ERC-1271 signature for `message` (or a pre-computed + * `hash`), ready to pass to `Keystore.validateSignature` or an account's + * `isValidSignature`. + * + * It hashes the message (`hashMessage`), wraps it in the chain/account-scoped + * {@link getSignatureEnvelopeHash replay-safe digest}, signs that with `signer`, + * and returns the `sigType || authenticator || data` envelope. + * + * @example + * ```ts + * import { signMessageEnvelope } from 'viem/eip8130' + * import { privateKeyToAccount } from 'viem/accounts' + * + * const owner = privateKeyToAccount('0x…') + * const signature = await signMessageEnvelope({ + * signer: owner, + * account: account.address, + * message: 'hello world', + * // sigType: 'local', chainId: 8453, // chain-bound; default is multichain + * }) + * // verify via core ERC-1271: client.verifyMessage({ address, message, signature }) + * ``` + */ +export async function signMessageEnvelope( + parameters: SignMessageEnvelopeParameters, +): Promise { + const { + signer, + account, + sigType = 'multichain', + chainId, + message, + hash: hash_, + } = parameters + if (!signer.sign) + throw new Error('`signer` does not support raw signing (`sign`).') + const authenticator = + parameters.authenticator ?? signer.authenticator ?? ecrecoverAuthenticator + const hash = hash_ ?? hashMessage(message!) + const digest = getSignatureEnvelopeHash({ account, hash, sigType, chainId }) + const signature = await signer.sign({ hash: digest }) + return wrapSignatureEnvelope({ sigType, authenticator, signature }) +} + +export type SignTypedDataEnvelopeParameters< + typedData extends TypedData | Record = TypedData, + primaryType extends keyof typedData | 'EIP712Domain' = keyof typedData, +> = TypedDataDefinition & { + /** Signer producing the authenticator `data`. */ + signer: Signer + /** The account the signature is bound to. */ + account: Address + /** Authenticator address. Defaults to the signer's, then native ecrecover. */ + authenticator?: Address | undefined + /** Envelope channel. @default 'multichain' */ + sigType?: SignatureType | undefined + /** Chain to bind a `local` envelope to. Required for `sigType: 'local'`. */ + chainId?: number | bigint | undefined +} + +/** + * EIP-712 variant of {@link signMessageEnvelope}: hashes `typedData` with + * `hashTypedData`, then produces the same account/chain-scoped envelope. The + * inner EIP-712 domain is the app's; the outer `SignedMessageEnvelope` scoping + * (which is not itself EIP-712) binds the signature to the 8130 account. + */ +export async function signTypedDataEnvelope< + const typedData extends TypedData | Record, + primaryType extends keyof typedData | 'EIP712Domain', +>( + parameters: SignTypedDataEnvelopeParameters, +): Promise { + const { + signer, + account, + sigType = 'multichain', + chainId, + ...typedData + } = parameters as SignTypedDataEnvelopeParameters + const authenticator = + parameters.authenticator ?? signer.authenticator ?? ecrecoverAuthenticator + const hash = hashTypedData(typedData as never) + return signMessageEnvelope({ + signer, + account, + authenticator, + sigType, + chainId, + hash, + }) +} + +export type WrapCounterfactualSignatureParameters = { + /** + * The EIP-8130 signature envelope to wrap (e.g. from {@link signMessageEnvelope} + * or `account.signMessage(...)`). + */ + signature: Hex + /** User-chosen uniqueness factor (bytes32) — as passed to `newSmartAccount`. */ + userSalt: Hex + /** Runtime bytecode placed at the account address. */ + code: Hex + /** Initial actors (sorted by `actorId`, strictly ascending). */ + initialActors: readonly AaActor[] +} + +export type WrapCounterfactualSignatureErrorType = + | ToFactoryArgsErrorType + | SerializeErc6492SignatureErrorType + | ErrorType + +/** + * Wraps an EIP-8130 signature envelope in an [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492) + * signature so it verifies for a **counterfactual** (not-yet-deployed) account. + * + * A plain envelope is validated via the account's `isValidSignature`, which + * requires code at the address. Before the account is deployed, wrap the + * envelope with the keystore `createAccount` deploy call: `client.verifyMessage` + * / `verifyHash` then deploy-and-verify through the ERC-6492 universal validator, + * exactly as viem does for other counterfactual smart accounts. + * + * @example + * ```ts + * import { newSmartAccount, signMessageEnvelope, wrapCounterfactualSignature } from 'viem/eip8130' + * + * const account = newSmartAccount({ signer, userSalt, code, initialActors }) + * const envelope = await signMessageEnvelope({ signer, account: account.address, message: 'gm' }) + * const signature = wrapCounterfactualSignature({ signature: envelope, userSalt, code, initialActors }) + * + * // Verifies even though `account.address` has no code yet. + * const valid = await client.verifyMessage({ address: account.address, message: 'gm', signature }) + * ``` + */ +export function wrapCounterfactualSignature( + parameters: WrapCounterfactualSignatureParameters, +): Hex { + const { signature, userSalt, code, initialActors } = parameters + const { factory, factoryData } = toFactoryArgs({ + userSalt, + code, + initialActors, + }) + return serializeErc6492Signature({ + address: factory, + data: factoryData, + signature, + }) +} diff --git a/src/eip8130/utils/signedActorChangesSignature.test.ts b/src/eip8130/utils/signedActorChangesSignature.test.ts index 68c60285d4..0760b66bba 100644 --- a/src/eip8130/utils/signedActorChangesSignature.test.ts +++ b/src/eip8130/utils/signedActorChangesSignature.test.ts @@ -59,7 +59,7 @@ describe('encodeSignedActorChangesSignature', () => { test('round-trips a single set through abi.decode', () => { const change = authorizeActor(key.p256(pubKey), { - scope: actorScope.sender, + scope: actorScope.operator, }) const auth = '0xc0ffee' const opAuth = '0xdeadbeef' diff --git a/src/eip8130/utils/signedActorChangesSignature.ts b/src/eip8130/utils/signedActorChangesSignature.ts index ad9db4dbe3..db18b6c196 100644 --- a/src/eip8130/utils/signedActorChangesSignature.ts +++ b/src/eip8130/utils/signedActorChangesSignature.ts @@ -84,7 +84,7 @@ export type EncodeSignedActorChangesSignatureErrorType = * account: smartAccount, * chainId: baseSepolia.id, * sequence, - * changes: [authorizeActor(key.p256({ x, y }), { scope: actorScope.sender })], + * changes: [authorizeActor(key.p256({ x, y }), { scope: actorScope.operator })], * }) * // opAuth: authenticator-prefixed signature over the userOpHash by any authorized actor * const opAuth = concatHex([ecrecoverAuthenticator, await owner.sign({ hash: userOpHash })]) From ad18b2494d4af54c98454f2b35306e02e094b304 Mon Sep 17 00:00:00 2001 From: Chris Hunter Date: Wed, 2 Sep 2026 19:20:17 -0400 Subject: [PATCH 96/96] feat(eip8130): register drive-only contracts as k1 operators TRUSTED_EXECUTOR is gone after base/eip-8130 #101. key.trustedExecutor now aliases key.k1 so PolicyManager and EntryPoint authorize as live k1 operational actors. --- site/pages/eip8130/creating-an-account.mdx | 2 +- site/pages/eip8130/session-keys.mdx | 2 +- src/eip8130/constants.ts | 16 ++++------- src/eip8130/deployments.ts | 3 +- src/eip8130/keys.ts | 18 ++++++------ src/eip8130/permissions.test.ts | 17 ++++++------ src/eip8130/permissions.ts | 32 +++++++++++----------- 7 files changed, 41 insertions(+), 49 deletions(-) diff --git a/site/pages/eip8130/creating-an-account.mdx b/site/pages/eip8130/creating-an-account.mdx index 556fc85343..104fe6b62d 100644 --- a/site/pages/eip8130/creating-an-account.mdx +++ b/site/pages/eip8130/creating-an-account.mdx @@ -136,7 +136,7 @@ const account = await toSmartAccount({ }) ``` -On a chain without native EIP-8130, the account must delegate to (or be deployed as) this ERC-4337 implementation, and its EntryPoint must be registered as a trusted-executor actor (`key.trustedExecutor(entryPoint)`) so it can drive `executeBatch`. +On a chain without native EIP-8130, the account must delegate to (or be deployed as) this ERC-4337 implementation, and its EntryPoint must be registered as a k1 operational actor (`key.k1(entryPoint)` or `key.trustedExecutor(entryPoint)`, scope `actorScope.operator`) so it can drive `executeBatch`. ## Next diff --git a/site/pages/eip8130/session-keys.mdx b/site/pages/eip8130/session-keys.mdx index c9132b73b6..a00e052a37 100644 --- a/site/pages/eip8130/session-keys.mdx +++ b/site/pages/eip8130/session-keys.mdx @@ -156,7 +156,7 @@ When a dApp asks a wallet for permissions via [ERC-7715](https://eips.ethereum.o - `role: 'session'` (default) — a **key on the account**. The protocol dispatches its calls as the account (`PolicyManager.execute`). Uses a secp256k1 (`k1`) actor. - `role: 'pull'` — an **external caller** (e.g. a subscription provider) that draws against the policy via `PolicyManager.executeFor` from its own address. Uses the `externalPolicyAuthenticator` sentinel actor, which can *only* act through the external-pull path. -The single `expiry` drives **both** the actor authorization and the policy binding's `validUntil`, so they can't drift. The action also reads the account and, if the policy manager isn't yet a trusted-executor actor (required so its forwarded `executeBatch` can land), folds that one-time registration into `changes` — so a single `account.change(changes)` provisions the manager *and* authorizes the grantee: +The single `expiry` drives **both** the actor authorization and the policy binding's `validUntil`, so they can't drift. The action also reads the account and, if the policy manager isn't yet a k1 operational actor (`key.k1(manager)`, required so its forwarded `executeBatch` can land), folds that one-time registration into `changes` — so a single `account.change(changes)` provisions the manager *and* authorizes the grantee: ```ts import { parseUnits } from 'viem' diff --git a/src/eip8130/constants.ts b/src/eip8130/constants.ts index db6806a14f..26aba18f26 100644 --- a/src/eip8130/constants.ts +++ b/src/eip8130/constants.ts @@ -169,17 +169,11 @@ export const revokedAuthenticator = '0xffffffffffffffffffffffffffffffffffffffff' satisfies Hex /** - * Sentinel authenticator for execution-enabled "trusted executor" actors - * (`TRUSTED_EXECUTOR = address(uint160(uint256(keccak256("trustedExecutor"))))`, - * as defined in `base/eip-8130`'s `DefaultAccount`). - * - * No contract is deployed here. An actor whose `authenticator` is this sentinel - * is authorized to drive the account via `executeBatch` when it is the - * `msg.sender` (e.g. an ERC-4337 EntryPoint or a {@link policyManagerAbi} - * PolicyManager). It cannot produce signatures — only direct calls. A - * policy-gated session key therefore requires its `manager` to also be - * registered as a trusted-executor actor (see {@link key.trustedExecutor}), - * otherwise the manager's forwarded `executeBatch` reverts. + * @deprecated The `TRUSTED_EXECUTOR` sentinel was removed in base/eip-8130 #101. + * Drive-only contracts (PolicyManager, EntryPoint) are now k1 operational + * actors: use {@link ecrecoverAuthenticator} / {@link key.trustedExecutor} + * (an alias of {@link key.k1}). This address is the former keccak256 + * `"trustedExecutor"` sentinel and is no longer recognized on-chain. */ export const trustedExecutorAuthenticator = '0xbe114b191a3ac7519670cac0c5e74aac1d819a13' satisfies Hex diff --git a/src/eip8130/deployments.ts b/src/eip8130/deployments.ts index 33b19a707f..7318af7aa6 100644 --- a/src/eip8130/deployments.ts +++ b/src/eip8130/deployments.ts @@ -37,7 +37,8 @@ export type Eip8130Deployment = { * Optional unaudited BackwardsCompatible4337Account example implementation * (`DefaultAccount` + `validateUserOp`). Lets an account run on non-native * chains via a bundler + EntryPoint at the same address; the EntryPoint is - * registered as a trusted-executor actor (see {@link key.trustedExecutor}). + * registered as a k1 operational actor (see {@link key.k1} / + * {@link key.trustedExecutor}). * This is not deployed by base's canonical `Deploy.s.sol`. */ erc4337?: Address | undefined diff --git a/src/eip8130/keys.ts b/src/eip8130/keys.ts index 753c58338f..14144cbd82 100644 --- a/src/eip8130/keys.ts +++ b/src/eip8130/keys.ts @@ -10,7 +10,6 @@ import { ecrecoverAuthenticator, externalPolicyAuthenticator, scopeUnrestricted, - trustedExecutorAuthenticator, } from './constants.js' import type { AaActor, @@ -74,17 +73,16 @@ export const key = { } }, /** - * Trusted-executor ("external caller") actor for `caller` — an address (e.g. a - * PolicyManager or ERC-4337 EntryPoint) authorized to drive the account via - * `executeBatch` by matching `msg.sender`, not by producing a signature. Pair - * with `authorizeActor(..., { scope: actorScope.operator })` and no policy. A - * policy-gated session key needs its `manager` registered this way. + * Drive-only contract actor for `caller` (PolicyManager, ERC-4337 EntryPoint). + * + * After base/eip-8130 #101, `DefaultAccount` authorizes `executeBatch` for a + * live **k1** operational actor (`authenticator == address(1)`, scope admin + * or `OPERATOR`). This is therefore the same identity as {@link key.k1}: + * `actorId = pad(caller)`, authenticator = ecrecover. Pair with + * `authorizeActor(..., { scope: actorScope.operator })` and no policy. */ trustedExecutor(caller: Address): AaActor { - return { - actorId: actorIdFromAddress(caller), - authenticator: trustedExecutorAuthenticator, - } + return key.k1(caller) }, /** * External-pull ("subscription provider") actor for `caller` — an address the diff --git a/src/eip8130/permissions.test.ts b/src/eip8130/permissions.test.ts index 47049aaf11..d1f84725ad 100644 --- a/src/eip8130/permissions.test.ts +++ b/src/eip8130/permissions.test.ts @@ -12,7 +12,6 @@ import { actorScope, ecrecoverAuthenticator, externalPolicyAuthenticator, - trustedExecutorAuthenticator, } from './constants.js' import { encodePolicyData, key } from './keys.js' import { @@ -248,7 +247,7 @@ describe('fulfillGrantPermissions', () => { const expiry = 1_800_000_000 test('session role → POLICY-only k1 actor, one expiry drives both surfaces', async () => { - const client = actorConfigClient(trustedExecutorAuthenticator) + const client = actorConfigClient(ecrecoverAuthenticator) const { actor, change, session } = await fulfillGrantPermissions(client, { account, grantee, @@ -275,9 +274,9 @@ describe('fulfillGrantPermissions', () => { const { change, managerChange, changes, session } = await fulfillGrantPermissions(client, { account, grantee, permissions }) - // A trusted-executor registration for the manager is included first. + // A k1-operator registration for the manager is included first. expect(managerChange).toBeDefined() - expect(managerChange?.authenticator).toBe(trustedExecutorAuthenticator) + expect(managerChange?.authenticator).toBe(ecrecoverAuthenticator) expect(managerChange?.actorId).toBe(actorIdFromAddress(session.manager)) expect(managerChange?.scope).toBe(actorScope.operator) expect(managerChange?.policyData).toBeUndefined() @@ -286,7 +285,7 @@ describe('fulfillGrantPermissions', () => { }) test('manager already registered → no managerChange', async () => { - const client = actorConfigClient(trustedExecutorAuthenticator) + const client = actorConfigClient(ecrecoverAuthenticator) const { change, managerChange, changes } = await fulfillGrantPermissions( client, { account, grantee, permissions }, @@ -305,7 +304,7 @@ describe('fulfillGrantPermissions', () => { }) test('pull role → external-pull sentinel actor + executeFor call', async () => { - const client = actorConfigClient(trustedExecutorAuthenticator) + const client = actorConfigClient(ecrecoverAuthenticator) const { actor, change, session } = await fulfillGrantPermissions(client, { account, grantee, @@ -331,7 +330,7 @@ describe('fulfillGrantPermissions', () => { }) test('returns a permissionsContext that round-trips', async () => { - const client = actorConfigClient(trustedExecutorAuthenticator) + const client = actorConfigClient(ecrecoverAuthenticator) const { permissionsContext, session, actor } = await fulfillGrantPermissions(client, { account, @@ -362,7 +361,7 @@ describe('routePermissionedCalls', () => { ] test('session context → calls routed through PolicyManager.execute', async () => { - const client = actorConfigClient(trustedExecutorAuthenticator) + const client = actorConfigClient(ecrecoverAuthenticator) const { permissionsContext, session } = await fulfillGrantPermissions( client, { @@ -388,7 +387,7 @@ describe('routePermissionedCalls', () => { }) test('pull context → calls routed through PolicyManager.executeFor', async () => { - const client = actorConfigClient(trustedExecutorAuthenticator) + const client = actorConfigClient(ecrecoverAuthenticator) const { permissionsContext } = await fulfillGrantPermissions(client, { account, grantee: '0x00000000000000000000000000000000000acce5', diff --git a/src/eip8130/permissions.ts b/src/eip8130/permissions.ts index b4596a5cf7..072cb1aac1 100644 --- a/src/eip8130/permissions.ts +++ b/src/eip8130/permissions.ts @@ -12,7 +12,7 @@ import { decodeAbiParameters } from '../utils/abi/decodeAbiParameters.js' import { encodeAbiParameters } from '../utils/abi/encodeAbiParameters.js' import { toFunctionSelector } from '../utils/hash/toFunctionSelector.js' import { getActorConfig } from './actions/getActorConfig.js' -import { actorScope, trustedExecutorAuthenticator } from './constants.js' +import { actorScope, ecrecoverAuthenticator } from './constants.js' import { authorizeActor, key } from './keys.js' import { type DefineSessionPolicyParameters, @@ -296,8 +296,8 @@ export type FulfillGrantPermissionsParameters = Omit< */ expiry?: number | bigint | undefined /** - * Skip the on-chain check for whether `manager` is registered as a - * trusted-executor actor on the account (assume it already is). When `false` + * Skip the on-chain check for whether `manager` is registered as a k1 + * operational actor on the account (assume it already is). When `false` * (default), the account is read and a `managerChange` is included in `changes` * if the manager still needs registering. @default false */ @@ -314,10 +314,10 @@ export type FulfillGrantPermissionsReturnType = { */ change: AaAuthorizeActor /** - * Present iff the `manager` was not yet registered as a trusted-executor actor - * on the account: the one-time `authorizeActor(key.trustedExecutor(manager), - * { scope: sender })` change the account needs so the manager's forwarded - * `executeBatch` can land. Already included in {@link changes}. + * Present iff the `manager` was not yet registered as a k1 operational actor + * on the account: the one-time `authorizeActor(key.k1(manager), + * { scope: actorScope.operator })` change the account needs so the manager's + * forwarded `executeBatch` can land. Already included in {@link changes}. */ managerChange?: AaAuthorizeActor | undefined /** @@ -355,11 +355,11 @@ export type FulfillGrantPermissionsErrorType = ToSessionPolicyConfigErrorType * ERC-7715 `expiry` drives both the binding `validUntil` and the actor `expiry`. * * For the manager's forwarded `executeBatch` to land, the account must register - * the `manager` as a trusted-executor actor. This action reads the account and, - * if that registration is missing, includes it as `managerChange` at the front - * of `changes` — so a single `account.change(changes)` both provisions the - * manager (once) and authorizes the grantee. Pass `assumeManagerRegistered: - * true` to skip the read. + * the `manager` as a k1 operational actor (`key.k1(manager)` / `key.trustedExecutor`). + * This action reads the account and, if that registration is missing, includes + * it as `managerChange` at the front of `changes` — so a single + * `account.change(changes)` both provisions the manager (once) and authorizes + * the grantee. Pass `assumeManagerRegistered: true` to skip the read. * * @example * import { fulfillGrantPermissions } from 'viem/eip8130' @@ -420,17 +420,17 @@ export async function fulfillGrantPermissions< expiry: expiryBig, }) - // Ensure the manager is a trusted-executor actor so its forwarded - // `executeBatch` can drive the account; register it in the same batch if not. + // Ensure the manager is a live k1 operator so its forwarded `executeBatch` + // can drive the account (DefaultAccount after #101); register it if not. let managerChange: AaAuthorizeActor | undefined if (!assumeManagerRegistered) { - const managerActor = key.trustedExecutor(session.manager) + const managerActor = key.k1(session.manager) const { authenticator } = await getActorConfig(client, { account, actorId: managerActor.actorId, }) const registered = - authenticator.toLowerCase() === trustedExecutorAuthenticator.toLowerCase() + authenticator.toLowerCase() === ecrecoverAuthenticator.toLowerCase() if (!registered) managerChange = authorizeActor(managerActor, { scope: actorScope.operator,