diff --git a/package.json b/package.json index ee28f8a2e4..088c9b3e7c 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,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,tokens,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,tokens,utils,window,zksync}/index.ts!", "chains/utils.ts!" ], "ignore": [ diff --git a/site/pages/eip8130.mdx b/site/pages/eip8130.mdx new file mode 100644 index 0000000000..b59d809c4b --- /dev/null +++ b/site/pages/eip8130.mdx @@ -0,0 +1,166 @@ +--- +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 **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 not yet enabled on mainnet and is currently in audit. Do not rely on it in production yet. +::: + +## What you get + +**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 + +| 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`](/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: + +```ts +import { + newSmartAccount, + sendTransaction, + estimateGas, +} from 'viem/eip8130' +``` + +## Setup + +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 { eip8130ChainConfig, register8130Chains } from 'viem/eip8130' + +export const vibenet = defineChain({ + ...eip8130ChainConfig, + 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) +``` + +### 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. + +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 { 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.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). **`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 + +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 +``` + +To use your own, point `authorizeActor`'s `policy.manager` at your contract — see [Session Keys](/eip8130/session-keys). + +## Guides + +- [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/eip8130/calls-and-batching.mdx b/site/pages/eip8130/calls-and-batching.mdx new file mode 100644 index 0000000000..e497bfdbce --- /dev/null +++ b/site/pages/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/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 + +`sendTransaction` accepts either shape. A **flat** array is sugar for a single phase; pass a **nested** array to control phases explicitly: + +```ts +import { sendTransaction } from 'viem/eip8130' + +// One atomic phase (flat): +await sendTransaction(client, { + account, + calls: [{ to: a, data }, { to: b, data }], + gas: 300_000n, +}) + +// Two phases (nested): +await sendTransaction(client, { + account, + calls: [[{ to: a, data }], [{ to: b, data }]], + gas: 300_000n, +}) +``` + +`estimateGas` and `prepareTransactionRequest` 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/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 `sendTransaction` (or `encodeWalletCalls`): + +```ts +import { type EncodeExecute, sendTransaction } from 'viem/eip8130' +import { encodeFunctionData } from 'viem' + +const encodeExecute: EncodeExecute = ({ account, calls }) => ({ + to: account, + data: encodeFunctionData({ + abi: myWalletAbi, + functionName: 'execute', + args: [calls], + }), +}) + +await sendTransaction(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](/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](/eip8130/receipts). +- Attach application data with [metadata](/eip8130/metadata). diff --git a/site/pages/eip8130/creating-an-account.mdx b/site/pages/eip8130/creating-an-account.mdx new file mode 100644 index 0000000000..104fe6b62d --- /dev/null +++ b/site/pages/eip8130/creating-an-account.mdx @@ -0,0 +1,143 @@ +--- +description: Create an EIP-8130 smart account from a secp256k1, P-256, or WebAuthn passkey signer +--- + +# 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](/eip8130/sending-a-transaction#deploy-on-first-use). + +There are two proxy shapes: + +- **`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: coinbaseSmartWalletV2Impl }) // upgradeable (default) +newSmartAccount({ signer, proxy: 'erc1167' }) // immutable → DefaultAccount +``` + +:::warning +**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. + +## secp256k1 (EOA key) + +```ts +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' +import { newSmartAccount } from 'viem/eip8130' + +const owner = privateKeyToAccount(generatePrivateKey()) + +// 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 +``` + +Pass a fixed `salt` to recover the same address across sessions: + +```ts +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' +import { newSmartAccount } from 'viem/eip8130' +const owner = privateKeyToAccount(generatePrivateKey()) +const account = newSmartAccount({ + signer: owner, + proxy: 'erc1167', + salt: '0x0000000000000000000000000000000000000000000000000000000000000001', +}) +``` + +## P-256 + +```ts +import * as P256 from 'ox/P256' +import { newSmartAccount, toP256Signer } from 'viem/eip8130' + +const signer = toP256Signer({ privateKey: P256.randomPrivateKey() }) + +const account = newSmartAccount({ signer, proxy: 'erc1167' }) +``` + +## WebAuthn / passkey + +```ts +import { createWebAuthnCredential, toWebAuthnAccount } from 'viem/account-abstraction' +import { newSmartAccount, toWebAuthnSigner } from 'viem/eip8130' + +const credential = await createWebAuthnCredential({ name: 'vibes' }) +const signer = toWebAuthnSigner(toWebAuthnAccount({ credential })) + +const account = newSmartAccount({ signer, proxy: 'erc1167' }) +``` + +## Multiple initial keys + +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 { actorScope, authorizeActor, key, newSmartAccount, toP256Signer } from 'viem/eip8130' +import * as P256 from 'ox/P256' + +const owner = privateKeyToAccount(generatePrivateKey()) +const recovery = toP256Signer({ privateKey: P256.randomPrivateKey() }) +const session = toP256Signer({ privateKey: P256.randomPrivateKey() }) + +const account = newSmartAccount({ + signer: owner, + proxy: 'erc1167', + admins: [key.p256(recovery.publicKey)], // co-owner / recovery + extraActors: [authorizeActor(key.p256(session.publicKey), { scope: actorScope.operator })], // scoped operator key +}) +``` + +## 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 `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 { toEoaAccount } from 'viem/eip8130' + +const account = toEoaAccount(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 Keystore contract as the ERC-4337 factory. + +`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 { 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: erc4337Implementation, +}) +``` + +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 + +Now [send a transaction](/eip8130/sending-a-transaction) with your account. diff --git a/site/pages/eip8130/metadata.mdx b/site/pages/eip8130/metadata.mdx new file mode 100644 index 0000000000..1959604d7c --- /dev/null +++ b/site/pages/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](/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. + +:::info +`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 + +`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 { prepareTransactionRequest } from 'viem/eip8130' +import { stringToHex } from 'viem' + +const tx = await prepareTransactionRequest(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 { waitForTransactionReceipt } from 'viem/eip8130' + +const receipt = await waitForTransactionReceipt(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 onchain. 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](/eip8130/receipts). diff --git a/site/pages/eip8130/payer-services.mdx b/site/pages/eip8130/payer-services.mdx new file mode 100644 index 0000000000..8686c1e74d --- /dev/null +++ b/site/pages/eip8130/payer-services.mdx @@ -0,0 +1,291 @@ +--- +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](/eip8130/sponsoring-transactions) for the underlying mechanism). + +Viem exposes the client and helpers under `viem/eip8168`. + +:::warning[Warning] +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 + +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/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`. + +- `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` 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' + +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/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/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/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 { prepareTransactionRequest } from 'viem/eip8130' + +const tx = await prepareTransactionRequest(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/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/eip8130/receipts.mdx b/site/pages/eip8130/receipts.mdx new file mode 100644 index 0000000000..3c546cd37c --- /dev/null +++ b/site/pages/eip8130/receipts.mdx @@ -0,0 +1,113 @@ +--- +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**. `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 +import { waitForTransactionReceipt } from 'viem/eip8130' + +const receipt = await waitForTransactionReceipt(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. + +### Expiring transactions + +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 +import { + TransactionExpiredError, + sendTransaction, + waitForTransactionReceipt, +} from 'viem/eip8130' + +let validBefore: bigint | undefined +const hash = await sendTransaction(client, { + account, + calls, + gas, + onTransaction: (tx) => { validBefore = tx.validBefore }, +}) + +try { + const receipt = await waitForTransactionReceipt(client, { hash, validBefore }) +} catch (e) { + if (e instanceof TransactionExpiredError) { + // tx can never land — resubmit with a fresh `validBefore` + } +} +``` + +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 + +`getTransactionReceipt` returns `null` if the receipt is not yet available: + +```ts +import { getTransactionReceipt } from 'viem/eip8130' + +const receipt = await getTransactionReceipt(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](/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/eip8130' + +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') +} +``` + +:::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](/eip8130/sponsoring-transactions) for a sponsored one. Use it to confirm sponsorship landed as expected. + +## Next + +- Attach and read application data with [metadata](/eip8130/metadata). diff --git a/site/pages/eip8130/rotating-owners.mdx b/site/pages/eip8130/rotating-owners.mdx new file mode 100644 index 0000000000..57e845bcaa --- /dev/null +++ b/site/pages/eip8130/rotating-owners.mdx @@ -0,0 +1,170 @@ +--- +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/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/eip8130' + +authorizeActor(key.p256({ x, y }), { + // 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), +}) +``` + +| Flag | Grants | +| --- | --- | +| `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` and needs no flags: admin (`scope == 0`) already carries every authority. + +## 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 { getConfigSequence } from 'viem/eip8130' + +const { local: sequence } = await getConfigSequence(client, { + 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, + sendTransaction, +} from 'viem/eip8130' + +const change = await account.change( + [ + authorizeActor(key.k1('0xnewOwner...'), { + scope: actorScope.operator, // full owner? use scope 0 + }), + ], + { chainId: client.chain.id, sequence }, // bigint, straight from getConfigSequence +) + +const hash = await sendTransaction(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/eip8130' + +const rotate = await account.change( + [ + authorizeActor(key.k1('0xnewOwner...'), { scope: actorScope.operator }), + revokeActor(key.k1('0xoldOwner...')), + ], + { chainId: client.chain.id, sequence }, +) +``` + +## 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, sendTransaction } from 'viem/eip8130' + +const { local: sequence } = await getConfigSequence(client, { + account: account.address, +}) + +const bump = await account.change([incrementLocalEpoch()], { + chainId: client.chain.id, + sequence, +}) + +const hash = await sendTransaction(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: + +```ts +import { + actorScope, + authorizeActor, + canonicalEip8130Deployment, + key, + sendTransaction, + toEoaAccount, +} from 'viem/eip8130' +import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' + +const account = toEoaAccount(privateKeyToAccount(generatePrivateKey())) + +const addP256 = await account.change( + [authorizeActor(key.p256({ x, y }), { scope: actorScope.operator })], + { chainId: client.chain.id, sequence: 0 }, +) + +const hash = await sendTransaction(client, { + account, + accountChanges: [ + account.delegate(canonicalEip8130Deployment.accounts.default), + addP256, + ], + calls: [], + gas: 300_000n, +}) +``` + +## Next + +Authorize a scoped, policy-gated [session key](/eip8130/session-keys). diff --git a/site/pages/eip8130/sending-a-transaction.mdx b/site/pages/eip8130/sending-a-transaction.mdx new file mode 100644 index 0000000000..77be5e55b4 --- /dev/null +++ b/site/pages/eip8130/sending-a-transaction.mdx @@ -0,0 +1,175 @@ +--- +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. `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). + +### 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 + +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' +import { canonicalAuthenticators, estimateGas } from 'viem/eip8130' + +const gas = await estimateGas(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') }]], + senderAuthAuthenticator: canonicalAuthenticators.k1, // .p256 / .passkey for other signers +}) +``` + +Pick the authenticator from the signer kind: + +```ts +import { canonicalAuthenticators } from 'viem/eip8130' +const senderAuthAuthenticator = + 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`. `waitForTransactionReceipt` surfaces the EIP-8130 receipt fields (per-phase statuses, `payer`, `metadata`). + +```ts +import { parseEther } from 'viem' +import { + canonicalAuthenticators, + estimateGas, + sendTransaction, + waitForTransactionReceipt, +} from 'viem/eip8130' + +const calls = [{ to: recipient, value: parseEther('0.001') }] + +const gas = await estimateGas(client, { + sender: account.address, + accountChanges: [account.createChange], + calls: [calls], + senderAuthAuthenticator: canonicalAuthenticators.k1, +}) + +const hash = await sendTransaction(client, { + account, + accountChanges: [account.createChange], // deploy — omit on later txs + calls, + gas: (gas * 120n) / 100n, +}) + +const receipt = await waitForTransactionReceipt(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 { estimateGas, sendTransaction } from 'viem/eip8130' + +const gas = await estimateGas(client, { + sender: account.address, + calls: [[{ to: token, data: transferData }]], +}) + +const hash = await sendTransaction(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](/eip8130/calls-and-batching) for the full phased model: + +```ts +import { sendTransaction } from 'viem/eip8130' + +const hash = await sendTransaction(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 { sendTransaction } from 'viem/eip8130' + +const hash = await sendTransaction(client, { + account, + calls: [{ to: recipient, data }], + gas: 250_000n, + payer: { account: sponsor }, +}) +``` + +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 + +`sendTransaction` composes two primitives you can use directly: + +- `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 + +- [Rotate owners](/eip8130/rotating-owners) by authorizing and revoking actors. +- Add scoped [session keys](/eip8130/session-keys). diff --git a/site/pages/eip8130/session-keys.mdx b/site/pages/eip8130/session-keys.mdx new file mode 100644 index 0000000000..a00e052a37 --- /dev/null +++ b/site/pages/eip8130/session-keys.mdx @@ -0,0 +1,276 @@ +--- +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/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 })` +``` + +:::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 +import { encodeSessionPolicyConfig } from 'viem/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, + sendTransaction, +} from 'viem/eip8130' + +const sessionKey = key.p256({ x, y }) + +const change = await account.change( + [ + authorizeActor(sessionKey, { + scope: actorScope.policy, // POLICY-only; OPERATOR would override the gate + policy: session.actorPolicy, + }), + ], + { chainId: client.chain.id, sequence: Number(sequence) }, +) + +const hash = await sendTransaction(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, + newSmartAccount, + sendTransaction, + toP256Signer, +} from 'viem/eip8130' + +// Same account address, driven by the session key (match the account's proxy). +const sessionAccount = newSmartAccount({ + signer: toP256Signer({ privateKey: sessionPrivateKey }), + proxy: 'erc1167', + salt: accountSalt, +}) + +const transfer = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [recipient, parseUnits('10', 6)], +}) + +const hash = await sendTransaction(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. + +## 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 `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 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' +import { fulfillGrantPermissions } from 'viem/eip8130' + +// The `permissions` + `expiry` a dApp sent via `wallet_grantPermissions`. +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, + 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 } }, + ], + }, + ], +}) + +// 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 }) +``` + +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 = await fulfillGrantPermissions(client, { + account: account.address, + grantee: providerAddress, + role: 'pull', + expiry, + permissions, +}) +await account.change(pull.changes) +// provider draws (from its own address): pull.session.executeForCall({ target: usdc, data }) +``` + +:::info +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, + sendTransaction, + 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 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. + +The permission → policy 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` (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/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/sponsoring-transactions.mdx b/site/pages/eip8130/sponsoring-transactions.mdx new file mode 100644 index 0000000000..d6e929ff46 --- /dev/null +++ b/site/pages/eip8130/sponsoring-transactions.mdx @@ -0,0 +1,104 @@ +--- +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)](/eip8130/payer-services). + +## Co-signing locally + +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 { sendTransaction } from 'viem/eip8130' + +const sponsor = privateKeyToAccount(process.env.SPONSOR_KEY as `0x${string}`) + +const hash = await sendTransaction(client, { + account, // signs sender_auth + calls: [{ to: recipient, data }], + gas: 250_000n, + payer: { account: sponsor }, // signs payer_auth, pays the fee +}) +``` + +:::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, `payerAuthAuthenticator`) so the node prices the payer authentication too: + +```ts +import { canonicalAuthenticators, estimateGas } from 'viem/eip8130' + +const gas = await estimateGas(client, { + sender: account.address, + calls: [[{ to: recipient, data }]], + payer: sponsor.address, + payerAuthAuthenticator: 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/eip8168' +import { sendTransaction } from 'viem/eip8130' + +const hash = await sendTransaction(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](/eip8130/payer-services), which quotes the fee and builds these phases for you. + +## Lower-level control + +`sendTransaction` wraps two primitives when you need to inspect or persist the transaction between signing and submitting: + +```ts +import { sendRawTransaction } from 'viem/actions' +import { prepareTransactionRequest } from 'viem/eip8130' + +const tx = await prepareTransactionRequest(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)](/eip8130/payer-services). diff --git a/site/pages/eip8130/sub-accounts.mdx b/site/pages/eip8130/sub-accounts.mdx new file mode 100644 index 0000000000..61b62c4183 --- /dev/null +++ b/site/pages/eip8130/sub-accounts.mdx @@ -0,0 +1,156 @@ +--- +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 { newSmartAccount } from 'viem/eip8130' + +const owner = privateKeyToAccount(generatePrivateKey()) + +// A stable, human-meaningful salt derivation is convenient here. +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 +``` + +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 + +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, + sendTransaction, +} from 'viem/eip8130' + +// On the sub account, authorize the primary account as a delegate. +const link = await subAccount.change( + [ + authorizeActor(key.delegate(main.address), { + scope: actorScope.operator, + }), + ], + { chainId: client.chain.id, sequence: Number(sequence) }, +) + +const hash = await sendTransaction(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, + sendTransaction, + toAccount, +} from 'viem/eip8130' + +// Drive `subAccount.address` with `main`'s key through the delegate authenticator. +const subAsDelegate = toAccount({ + signer: main.signer, + authenticator: canonicalAuthenticators.delegate, + address: subAccount.address, +}) + +const hash = await sendTransaction(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 `senderAuthAuthenticator: canonicalAuthenticators.delegate` to [`estimateGas`](/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/eip8130' + +const unlink = await subAccount.change( + [revokeActor(key.delegate(main.address))], + { chainId: client.chain.id, sequence: Number(sequence) }, +) +``` + +## 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, sendTransaction } 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 sendTransaction(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. + +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). +- 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 85a3e27ad2..97969e36fc 100644 --- a/site/vocs.config.ts +++ b/site/vocs.config.ts @@ -1645,6 +1645,59 @@ export default defineConfig({ }, ], }, + { + text: 'EIP-8130', + items: [ + { + text: 'Overview', + link: '/eip8130', + }, + { + text: 'Creating an Account', + link: '/eip8130/creating-an-account', + }, + { + text: 'Sending a Transaction', + link: '/eip8130/sending-a-transaction', + }, + { + text: 'Calls & Batching', + link: '/eip8130/calls-and-batching', + }, + { + text: 'Receipts', + link: '/eip8130/receipts', + }, + { + text: 'Metadata', + link: '/eip8130/metadata', + }, + { + text: 'Rotating Owners', + link: '/eip8130/rotating-owners', + }, + { + text: 'Session Keys', + link: '/eip8130/session-keys', + }, + { + text: 'Sub Accounts', + link: '/eip8130/sub-accounts', + }, + { + text: 'Signing Messages', + link: '/eip8130/signing-messages', + }, + { + text: 'Sponsoring Transactions', + link: '/eip8130/sponsoring-transactions', + }, + { + text: 'Payer Services (ERC-8168)', + link: '/eip8130/payer-services', + }, + ], + }, { text: 'ERC-7715', items: [ diff --git a/src/actions/public/getTransaction.ts b/src/actions/public/getTransaction.ts index fe6cf8b79f..a420e12a0f 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 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 === '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: '0x79', + // 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 the eip8130 `getTransaction`). + 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 31a7394b97..1d2a75c804 100644 --- a/src/actions/public/waitForTransactionReceipt.ts +++ b/src/actions/public/waitForTransactionReceipt.ts @@ -320,7 +320,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 0x79), + // 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/eip8130/abis.ts b/src/eip8130/abis.ts new file mode 100644 index 0000000000..15f8335425 --- /dev/null +++ b/src/eip8130/abis.ts @@ -0,0 +1,85 @@ +import { parseAbi } from 'abitype' + +/** + * ABI for the EIP-8130 Keystore system contract at `keystoreAddress`. + */ +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; }', + '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) || expiry(6) || scope(2) || + // reserved(4 zero bytes) = 32 bytes, plus manager(20) || commitment(32) when + // 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)', + 'event AccountImported(address indexed account)', + 'event DelegationApplied(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)', + // 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)', + // 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 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)', + 'function isLocked(address account) view returns (bool)', + '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 + * Keystore 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)', +]) + +/** 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/eip8130/accounts/toAccount.ts b/src/eip8130/accounts/toAccount.ts new file mode 100644 index 0000000000..f8d97176c6 --- /dev/null +++ b/src/eip8130/accounts/toAccount.ts @@ -0,0 +1,794 @@ +import type { Address, TypedData } from 'abitype' +import { BaseError } from '../../errors/base.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' +import { + canonicalAuthenticators, + ecrecoverAuthenticator, + scopeUnrestricted, +} from '../constants.js' +import { canonicalEip8130Deployment } from '../deployments.js' +import { key } from '../keys.js' +import type { + AaAccountChangeConfig, + AaAccountChangeCreate, + AaAccountChangeDelegation, + AaActor, + AaChange, + AaChangeChannel, + TransactionSerializable8130, + TransactionSerialized8130, +} from '../types/transaction.js' +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' + +/** + * Common base params shared by both `toAccount` shapes. + * @internal + */ +type ToAccountBase = { + /** Signer that produces `sender_auth` / `auth` blobs for this account. */ + signer: Signer + /** + * 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 + /** + * Scope bitmask of the **signing actor** on this account (see + * {@link actorScope}). Prefer omitting this once the actor is on-chain — + * {@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}. + * + * 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 +} + +/** + * Parameters for `toAccount` — 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 ToAccountParameters = ToAccountBase & + ( + | { + /** + * 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[] + /** 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 + } + ) + +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 prepareTransactionRequest}. + */ + 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 + /** 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 + /** + * 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` + * (e.g. delegated EOA) — use `delegate(impl)` instead. + */ + create(): AaAccountChangeCreate + /** Signs a `SignedAccountChanges` batch into a `config` entry. */ + change( + changes: readonly AaChange[], + options?: { + channel?: AaChangeChannel + chainId?: number + sequence?: bigint + }, + ): Promise + /** + * 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( + transaction: TransactionSerializable8130, + options?: { + payer?: { account: Signer; address?: Address } | undefined + }, + ): Promise +} + +/** + * Creates a local EIP-8130 account helper for the **configured-actor** signing + * path (authenticator-prefixed `senderAuth`). Two shapes: + * + * **Smart-account** — supply `userSalt + code + initialActors`: + * ```ts + * 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 = toAccount({ signer, address: eoaSigner.address }) + * // first tx: accountChanges: [account.delegate(deployment.accounts.default)] + * // add keys: accountChanges: [account.delegate(...), await account.change([...])] + * ``` + * + * 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 = toAccount({ + * signer: p256, + * authenticator: p256.authenticator, + * address: eoaSigner.address, + * }) + * ``` + * + * 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 toAccount( + parameters: ToAccountParameters, +): ToAccountReturnType { + const { signer, authenticator = ecrecoverAuthenticator, scope } = parameters + + // 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 computeAddress({ + userSalt: parameters.userSalt!, + code: parameters.code!, + initialActors: parameters.initialActors!, + }) + })() + + 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) + + const signerPublicKey = (signer as { publicKey?: unknown }).publicKey + const publicKey: Hex = + typeof signerPublicKey === 'string' ? (signerPublicKey as Hex) : '0x' + + return { + address, + signer, + initialActors, + 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, + async signMessage({ message }) { + return signMessageEnvelope({ + signer, + account: address, + authenticator, + message, + }) + }, + async signTypedData(typedData) { + return signTypedDataEnvelope({ + signer, + account: address, + authenticator, + ...(typedData as object), + } as never) + }, + + create() { + 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(changes, options = {}) { + return signAccountChanges({ + signer, + account: address, + channel: options.channel ?? 'local', + chainId: options.chainId ?? 0, + sequence: options.sequence ?? 0n, + changes, + authenticator, + }) + }, + + delegate(target) { + return { type: 'delegation', target } + }, + + async signTransaction(transaction, options = {}) { + if (!signer.sign) + throw new BaseError('`signer` does not support raw signing.') + return signTransaction({ + transaction: { ...transaction, from: transaction.from ?? address }, + account: signer, + authenticator, + payer: options.payer, + }) + }, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// newSmartAccount +// ───────────────────────────────────────────────────────────────────────────── + +export type NewSmartAccountParameters = { + /** + * 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` + * (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 } | 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 + /** + * Per-account proxy placed at the account address. + * + * - `'upgradeable'` (default) — 93-byte ERC-1967 {@link upgradeableProxyBytecode} + * 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. + * + * Ignored if `code` is provided. + */ + proxy?: 'erc1167' | 'upgradeable' | undefined + /** + * Implementation address the proxy delegates to. For `proxy: 'erc1167'` + * defaults to the canonical `DefaultAccount`; for `proxy: 'upgradeable'` + * 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 + /** + * Deployment bytecode override. Bypasses `proxy` / `implementation` — supply + * the full runtime bytecode placed at the account address. + */ + code?: Hex | undefined + /** + * 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 +} + +export type NewSmartAccountReturnType = ToAccountReturnType & { + /** + * The `create` account-change entry — include in `accountChanges` for the + * first transaction to deploy this account. + * + * @example + * const gas = await estimateGas(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 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 + * automatically from the signer object. + * + * @example + * // Immutable ERC-1167 proxy to the canonical DefaultAccount + * const account = newSmartAccount({ signer: privateKeyToAccount(pk), proxy: 'erc1167' }) + * + * @example + * // 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.operator })], + * }) + * + * @example + * // First tx: create + call in one shot + * const gas = await estimateGas(client, { + * from: account.address, + * accountChanges: [account.createChange], + * calls: [[{ to: recipient, value }]], + * }) + * 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 newSmartAccount( + parameters: NewSmartAccountParameters, +): NewSmartAccountReturnType { + const { + signer, + implementation, + proxy = 'upgradeable', + admins = [], + extraActors = [], + } = parameters + + // Detect signer type and derive the primary actor. + // 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 && + typeof signer.publicKey !== 'string' + ? signer.authenticator === canonicalAuthenticators.passkey + ? key.webAuthn(signer.publicKey) + : 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, + ...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() + + // Proxy selection. + // - 'erc1167' — immutable minimal proxy → `implementation` ?? DefaultAccount. + // - '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 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 ?? + (() => { + if (proxy === 'erc1167') + return erc1167Bytecode( + implementation ?? canonicalEip8130Deployment.accounts.default, + ) + const impl = + implementation ?? canonicalEip8130Deployment.accounts.upgradeable + if (!impl) + throw new BaseError( + '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) + })() + + const inner = toAccount({ + signer, + userSalt: salt, + code, + initialActors: allActors, + authenticator: signer.authenticator, + // 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 + // the scope so nonce mode is selected automatically. + scope: primaryActor.scope ?? scopeUnrestricted, + }) + + return { ...inner, createChange: inner.create() } +} + +// ───────────────────────────────────────────────────────────────────────────── +// toEoaAccount +// ───────────────────────────────────────────────────────────────────────────── + +export type ToEoaAccountParameters = { + /** + * Scope of the EOA's implicit self-actor. Defaults to admin + * ({@link scopeUnrestricted}), which may use ordered *or* nonce-free nonces + * (sends default to ordered). Override for a restricted self-actor. + */ + scope?: number | undefined +} + +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 prepareTransactionRequest}. + */ + 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` + * to enable smart-account execution (e.g. `executeBatch`, multi-actor auth). + * + * @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.operator }), + * ], { chainId, sequence: 0 }) + * await account.signTransaction({ + * accountChanges: [account.delegate(impl), addP256], + * calls: wire, ... + * }) + */ + change( + changes: readonly AaChange[], + options?: { + channel?: AaChangeChannel + chainId?: number + sequence?: bigint + }, + ): 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, + options?: { + payer?: { account: Signer; address?: Address } | undefined + }, + ): Promise +} + +/** + * 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 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. + * + * To drive the same EOA address with a **different** actor (P-256 / WebAuthn) + * after delegation, use {@link toAccount} with `address`: + * ```ts + * const accountAsP256 = toAccount({ + * signer: p256, + * authenticator: p256.authenticator, + * address: eoaSigner.address, + * }) + * ``` + * + * @example + * // Pure EOA — no contract, payer-sponsored + * 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 = 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 toEoaAccount( + signer: Signer, + parameters: ToEoaAccountParameters = {}, +): ToEoaAccountReturnType { + 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 } + }, + + async change(changes, options = {}) { + return signAccountChanges({ + signer, + account: address, + channel: options.channel ?? 'local', + chainId: options.chainId ?? 0, + sequence: options.sequence ?? 0n, + changes, + authenticator: ecrecoverAuthenticator, + }) + }, + + async signTransaction(transaction, options = {}) { + if (!signer.sign) + throw new BaseError('`signer` does not support raw signing.') + return signTransaction({ + // Omit `from` → EOA implicit self-actor path: + // senderAuth = raw 65-byte sig, sender recovered via ecrecover. + transaction, + account: signer, + authenticator: ecrecoverAuthenticator, + payer: options.payer, + }) + }, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// toDelegateSigner +// ───────────────────────────────────────────────────────────────────────────── + +export type ToDelegateSignerParameters = { + /** + * 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 + * `toAccount`'s configured-actor path serializes the full `senderAuth` as + * `DELEGATE_AUTHENTICATOR ‖ data`. + * + * 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 = toDelegateSigner({ + * delegateAccount: parent.address, + * nestedSigner: parentAdmin, // an admin (scope 0) owner of the parent + * }) + * const sub = toAccount({ + * 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 toDelegateSigner( + parameters: ToDelegateSignerParameters, +): 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) + globalThis.crypto.getRandomValues(buf) + return bytesToHex(buf) +} diff --git a/src/eip8130/accounts/toSmartAccount.test.ts b/src/eip8130/accounts/toSmartAccount.test.ts new file mode 100644 index 0000000000..b2966ba60b --- /dev/null +++ b/src/eip8130/accounts/toSmartAccount.test.ts @@ -0,0 +1,150 @@ +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 { 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 +// (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('toSmartAccount', () => { + test('getAddress matches computeAddress', async () => { + const account = await toSmartAccount(base) + expect(await account.getAddress()).toBe( + computeAddress({ + userSalt: base.userSalt, + code: erc1167Bytecode(implementation), + initialActors: base.initialActors, + }), + ) + }) + + 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: keystoreAbi, + 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 toSmartAccount(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 toSmartAccount(base) + const stub = await account.getStubSignature() + expect(slice(stub, 0, 20).toLowerCase()).toBe( + ecrecoverAuthenticator.toLowerCase(), + ) + }) + + test('signMessage = SignedMessageEnvelope (sigType || authenticator || sig)', async () => { + const account = await toSmartAccount({ + ...base, + client: deployedClient, + }) + const message = 'hello 8130' + const sig = await account.signMessage({ message }) + + // multichain sigType byte, then the authenticator. + expect(slice(sig, 0, 1)).toBe('0x02') + expect(slice(sig, 1, 21).toLowerCase()).toBe( + ecrecoverAuthenticator.toLowerCase(), + ) + + // 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) + }) + + test('throws without identity inputs when deriving factory args', async () => { + const account = await toSmartAccount({ + client, + owner, + address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + }) + expect(await account.getAddress()).toBe( + '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + ) + await expect(account.getFactoryArgs()).rejects.toThrow() + }) +}) diff --git a/src/eip8130/accounts/toSmartAccount.ts b/src/eip8130/accounts/toSmartAccount.ts new file mode 100644 index 0000000000..89bacd82ff --- /dev/null +++ b/src/eip8130/accounts/toSmartAccount.ts @@ -0,0 +1,281 @@ +import type { Abi, Address } from 'abitype' +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 { 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 { 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, + 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 + /** + * 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 +} & ( + | { + /** 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 ToSmartAccountReturnType< + 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 Keystore + * contract as the ERC-4337 factory (`createAccount`); and + * signature validation is delegated to the Keystore system via the + * `authenticator || data` auth format. + * + * @example + * import { toSmartAccount } from 'viem/eip8130' + * + * const account = await toSmartAccount({ + * client, + * owner, + * userSalt: '0x...', + * initialActors: [{ actorId, authenticator }], + * implementation: '0x...', // ERC4337Account impl + * }) + */ +export async function toSmartAccount< + entryPointAbi extends Abi = typeof entryPoint07Abi, + entryPointVersion extends EntryPointVersion = '0.7', +>( + parameters: ToSmartAccountParameters, +): Promise> { + const { + client, + entryPoint: entryPoint_ = { + abi: entryPoint07Abi, + address: entryPoint07Address, + version: '0.7', + }, + getNonce, + authenticator = ecrecoverAuthenticator, + } = parameters + + const entryPoint = { + abi: entryPoint_.abi as entryPointAbi, + address: entryPoint_.address, + version: entryPoint_.version as entryPointVersion, + } 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 ?? + (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, + } + } + + 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 computeAddress(getCreateParameters()) + }, + + async getFactoryArgs() { + return toFactoryArgs(getCreateParameters()) + }, + + async getStubSignature() { + 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 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 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_) { + 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, + }, + }) + return concatHex([authenticator, await sign(userOpHash)]) + }, + }) +} diff --git a/src/eip8130/actions/estimateGas.test.ts b/src/eip8130/actions/estimateGas.test.ts new file mode 100644 index 0000000000..d0757482bb --- /dev/null +++ b/src/eip8130/actions/estimateGas.test.ts @@ -0,0 +1,220 @@ +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 { toAccount } from '../accounts/toAccount.js' +import { actorScope, canonicalAuthenticators } from '../constants.js' +import { authorizeActor, encodePolicyData, key } from '../keys.js' +import { estimateGas } from './estimateGas.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('estimateGas — create account-change serialization', () => { + test('each initialActor carries scope (number) and policyData (hex)', async () => { + const rec = recordingClient() + const account = toAccount({ + signer: owner, + userSalt, + code, + initialActors: [key.k1(owner.address)], + }) + + await estimateGas(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.policy, 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 = toAccount({ + signer: owner, + userSalt, + code, + initialActors, + }) + + await estimateGas(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.policy) + expect(typeof gatedOut.scope).toBe('number') + expect(gatedOut.policyData?.toLowerCase()).toBe( + encodePolicyData(policy).toLowerCase(), + ) + }) +}) + +describe('estimateGas — dataSuffix → metadata', () => { + test('full-body mode writes dataSuffix to metadata', async () => { + const rec = recordingClient() + const account = toAccount({ + signer: owner, + userSalt, + code, + initialActors: [key.k1(owner.address)], + }) + + await estimateGas(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 = toAccount({ + signer: owner, + userSalt, + code, + initialActors: [key.k1(owner.address)], + }) + + await estimateGas(rec.client, { + sender: account.address, + accountChanges: [account.create()], + calls: [[{ to: owner.address }]], + senderAuthAuthenticator: canonicalAuthenticators.k1, + }) + + 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 new file mode 100644 index 0000000000..caffaeecc8 --- /dev/null +++ b/src/eip8130/actions/estimateGas.ts @@ -0,0 +1,403 @@ +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 { + aaTransactionType, + canonicalAuthDataLength, + changeType, +} from '../constants.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 = { + /** + * 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. + */ + 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. + */ + sender?: Address | undefined + + // ── Simplified mode (no accountChanges/calls) ───────────────────────────── + // 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 + + // ── 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` 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 + /** + * 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?: 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. + */ + 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 + + // ── 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 + /** + * 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 + * authenticators ({@link canonicalAuthenticators}) — pass `senderAuthSize` + * explicitly for a custom authenticator with no known default. + */ + senderAuthAuthenticator?: Address | undefined + /** + * 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 + /** + * 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 EstimateGasParameters.senderAuthAuthenticator} + * (auth-gas pricing) — accept both when estimating a session-key send. + */ + senderActorId?: Hex | undefined + + // ── Common ──────────────────────────────────────────────────────────────── + /** Optional sponsoring payer; priced into the estimate when set. */ + payer?: Address | undefined + /** + * The full raw `payerAuth` blob to price verbatim (always the prefixed + * `authenticator(20) || data` form). See `senderAuth`. + */ + payerAuth?: Hex | undefined + /** Payer authenticator address hint. See `senderAuthAuthenticator`. */ + 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'`. */ + blockTag?: BlockTag | undefined +} + +export type EstimateGasReturnType = 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): 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`. 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 `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 + * 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 estimateGas< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: EstimateGasParameters, +): Promise { + const { + from, + sender, + to, + data, + value, + senderAuth: senderAuthExplicit, + senderAuthAuthenticator, + senderAuthSize, + senderActorId, + accountChanges, + calls, + nonceKey = 0n, + nonceSequence = 0, + payer, + 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( + '`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]) + if (size !== undefined && (size < 0 || size > maxAuthSize)) + throw new BaseError( + `auth size ${size} out of range (0..=${maxAuthSize}).`, + ) + + const senderAuth = buildAuthBlob( + senderAuthExplicit, + senderAuthAuthenticator, + senderAuthSize, + ) + const payerAuth = buildAuthBlob( + payerAuthExplicit, + payerAuthAuthenticator, + payerAuthSize, + ) + + 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, + sender: account_, + nonceKey: Number(nonceKey), + nonceSequence, + 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 !== 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, + } + 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 + // (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, + sender: account_, + } + if (to !== undefined) request.to = to + if (data !== undefined) request.data = data + if (value !== undefined) request.value = numberToHex(value) + 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 + + const gas = await ( + client.request as (args: { + method: 'eth_estimateGas' + params: [Record, string] + }) => Promise + )({ method: 'eth_estimateGas', params: [request, block] }) + + 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. `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 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). + * + * 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, + authenticator: Address | undefined, + size: number | undefined, +): Hex | undefined { + if (explicit !== undefined) return explicit + if (authenticator === undefined) { + if (size === undefined) return undefined + return filler(size) + } + 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.`, + ) + return concatHex([authenticator, 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 + * 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 the `SignedAccountChanges` + * batch shape (`channel` / `sequence` / `changes[{changeType, payload}]` / + * `signature`) — the ABI-encoded opaque `payload`, not the typed fields. + */ +function serializeAccountChange( + change: AaAccountChange, +): Record { + if (change.type === 'create') { + return { + 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', + })), + } + } + if (change.type === 'delegation') { + return { type: 'delegation', target: change.target } + } + // 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', + channel: change.channel === 'multichain' ? 'Multichain' : 'Local', + sequence: Number(change.sequence), + changes: change.changes.map((c) => ({ + changeType: changeTypeName[c.changeType], + payload: encodeChangePayload(c), + })), + signature: change.signature, + } +} diff --git a/src/eip8130/actions/getActorConfig.ts b/src/eip8130/actions/getActorConfig.ts new file mode 100644 index 0000000000..c211a9b24e --- /dev/null +++ b/src/eip8130/actions/getActorConfig.ts @@ -0,0 +1,71 @@ +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 { keystoreAbi } from '../abis.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 +} + +export type GetActorConfigReturnType = { + /** 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 `Keystore` system contract (`getActorConfig`). Use it to + * inspect owners / session keys, e.g. to enrich a "sign with" picker. + * + * @example + * ```ts + * import { getActorConfig, key } from 'viem/eip8130' + * + * const config = await getActorConfig(client, { + * account: account.address, + * actorId: key.p256({ x, y }).actorId, + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The actor's configuration. + */ +export async function getActorConfig< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetActorConfigParameters, +): Promise { + const { account, actorId } = parameters + + const config = await readContract(client, { + address: keystoreAddress, + abi: keystoreAbi, + 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/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 new file mode 100644 index 0000000000..332c6b9f5a --- /dev/null +++ b/src/eip8130/actions/getConfigSequence.ts @@ -0,0 +1,102 @@ +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 { keystoreAbi } from '../abis.js' +import { keystoreAddress, unsequencedLocalHalf } from '../constants.js' + +export type GetConfigSequenceParameters = { + /** The account whose local config sequence to read. */ + account: Address +} + +export type GetConfigSequenceReturnType = { + /** + * 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 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 +} + +/** + * Reads the current config-change sequences for an EIP-8130 account from the + * `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. + * + * 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, { account: accountAddress }) + * // Use `local` as the sequence for the next AccountChange. + */ +export async function getConfigSequence< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetConfigSequenceParameters, +): Promise { + const { account } = parameters + + const result = await readContract(client, { + address: keystoreAddress, + abi: keystoreAbi, + functionName: 'getChangeSequences', + 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, + multichain: result.multichain, + localEpoch: result.localEpoch, + 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/actions/getLockStatus.ts b/src/eip8130/actions/getLockStatus.ts new file mode 100644 index 0000000000..621a264bd7 --- /dev/null +++ b/src/eip8130/actions/getLockStatus.ts @@ -0,0 +1,61 @@ +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 { keystoreAbi } from '../abis.js' +import { keystoreAddress } from '../constants.js' + +export type GetLockStatusParameters = { + /** The account whose lock status to read. */ + account: Address +} + +export type GetLockStatusReturnType = { + /** 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 + * `Keystore` system contract (`getLockStatus`). + * + * @example + * ```ts + * import { getLockStatus } from 'viem/eip8130' + * + * const { locked, hasInitiatedUnlock, unlocksAt, unlockDelay } = + * await getLockStatus(client, { account: account.address }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The account's lock status. + */ +export async function getLockStatus< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetLockStatusParameters, +): Promise { + const { account } = parameters + + const [locked, hasInitiatedUnlock, unlocksAt, unlockDelay] = + await readContract(client, { + address: keystoreAddress, + abi: keystoreAbi, + functionName: 'getLockStatus', + args: [account], + }) + + return { locked, hasInitiatedUnlock, unlocksAt, unlockDelay } +} diff --git a/src/eip8130/actions/getPolicy.ts b/src/eip8130/actions/getPolicy.ts new file mode 100644 index 0000000000..a9cff77731 --- /dev/null +++ b/src/eip8130/actions/getPolicy.ts @@ -0,0 +1,68 @@ +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 { keystoreAbi } from '../abis.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 +} + +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. */ + commitment: Hex +} + +/** + * Reads the policy binding for an actor (manager, commitment) from the finalized + * 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. + * + * @example + * ```ts + * import { getPolicy, getSessionSpend, key } from 'viem/eip8130' + * + * const { commitment } = await getPolicy(client, { + * account: account.address, + * actorId: key.p256({ x, y }).actorId, + * }) + * const spend = await getSessionSpend(client, { commitment, token: usdc }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The actor's policy binding. + */ +export async function getPolicy< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetPolicyParameters, +): Promise { + const { account, actorId } = parameters + + // 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: 'getActorWithPolicy', + args: [account, actorId], + }) + + return { target: policyManager, commitment: policyCommitment } +} diff --git a/src/eip8130/actions/getSessionSpend.ts b/src/eip8130/actions/getSessionSpend.ts new file mode 100644 index 0000000000..95cab76daf --- /dev/null +++ b/src/eip8130/actions/getSessionSpend.ts @@ -0,0 +1,119 @@ +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 { + type SessionPolicyTokenLimit, + sessionPolicyAbi, + sessionPolicyAddress, +} from '../policies.js' + +export type GetSessionSpendParameters = { + /** The session policy binding commitment (see `defineSessionPolicy().commitment`). */ + commitment: Hex + /** + * 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 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 +} + +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). */ + 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 + * 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 { getSessionSpend } from 'viem/eip8130' + * + * // Pass the exact token limit from the binding's config. + * const { allowance, spent, remaining, periodEnd } = await getSessionSpend( + * client, + * { + * commitment: session.commitment, + * tokenLimit: { token: usdc, limit: parseUnits('100', 6), period: 604800n }, + * }, + * ) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The session key's limit and current-period spend for the token. + */ +export async function getSessionSpend< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetSessionSpendParameters, +): Promise { + const { + commitment, + tokenLimit, + sessionPolicy = sessionPolicyAddress, + } = parameters + + const read = getAction(client, readContract, 'readContract') + + 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 { + allowance, + period, + spent, + remaining, + periodStart: usage.start, + periodEnd: usage.end, + } +} diff --git a/src/eip8130/actions/getTransaction.ts b/src/eip8130/actions/getTransaction.ts new file mode 100644 index 0000000000..946c17c583 --- /dev/null +++ b/src/eip8130/actions/getTransaction.ts @@ -0,0 +1,157 @@ +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 { aaTransactionType } from '../constants.js' +import type { AaAccountChange, AaCalls } from '../types/transaction.js' + +/** + * 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 Transaction = { + /** EIP-8130 transaction type marker. */ + 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). */ + from: Address + /** Chain id. */ + chainId: number + /** 2D nonce: channel key. */ + nonceKey: Hex + /** 2D nonce: sequence within the channel. */ + nonceSequence: 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). */ + maxPriorityFeePerGas: bigint + /** Gas limit. */ + gas: bigint + /** Ordered list of call phases. */ + calls: AaCalls + /** Account 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 GetTransactionParameters = { + /** The hash of the EIP-8130 transaction to fetch. */ + hash: Hash +} + +export type GetTransactionReturnType = Transaction + +/** Raw RPC response shape for `eth_getTransactionByHash` on an 8130 node. */ +type RawTx = { + type: typeof aaTransactionType + tx: { + chainId: number + sender: Address + nonceKey: Hex + nonceSequence: number + validAfter: number + validBefore: 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 `Transaction` 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 getTransaction(client, { hash: '0xabc...' }) + * console.log(tx.calls) // AaCalls + * console.log(tx.accountChanges) // AaAccountChange[] + * console.log(tx.nonceSequence) // number + */ +export async function getTransaction< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetTransactionParameters, +): 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 !== aaTransactionType) + throw new Error( + `getTransaction: expected type ${aaTransactionType} but got type ${(raw as any)?.type ?? 'null'} for hash ${hash}`, + ) + + const body = raw.tx + + return { + type: aaTransactionType, + hash, + from: raw.from ?? body.sender, + chainId: body.chainId, + nonceKey: body.nonceKey, + nonceSequence: body.nonceSequence, + validAfter: body.validAfter, + validBefore: body.validBefore, + 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/eip8130/actions/getTransactionCount.ts b/src/eip8130/actions/getTransactionCount.ts new file mode 100644 index 0000000000..bff686afa5 --- /dev/null +++ b/src/eip8130/actions/getTransactionCount.ts @@ -0,0 +1,86 @@ +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 GetTransactionCountParameters = { + /** 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 GetTransactionCountReturnType = 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 getTransactionCount(client, { + * address: account.address, + * nonceKey: 0n, + * }) + */ +export async function getTransactionCount< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetTransactionCountParameters, +): 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/eip8130/actions/getTransactionReceipt.ts b/src/eip8130/actions/getTransactionReceipt.ts new file mode 100644 index 0000000000..c2644e7ce7 --- /dev/null +++ b/src/eip8130/actions/getTransactionReceipt.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` (`0x79`) receipts on a node with the + * extension; they are `undefined` for standard receipts or older nodes. + */ +export type ReceiptFields = { + /** + * 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 GetTransactionReceiptParameters = { + /** Transaction hash to fetch the receipt for. */ + hash: Hash +} + +export type GetTransactionReceiptReturnType = + | (RawReceipt & { + /** Parsed EIP-8130 receipt fields (decoded from the raw receipt). */ + eip8130: ReceiptFields + }) + | null + +/** Reads the EIP-8130 fields off a raw JSON-RPC receipt (graceful if absent). */ +export function parseReceiptFields( + receipt: RawReceipt | null | undefined, +): ReceiptFields { + 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 getTransactionReceipt< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: GetTransactionReceiptParameters, +): 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: parseReceiptFields(receipt) } +} diff --git a/src/eip8130/actions/isActor.ts b/src/eip8130/actions/isActor.ts new file mode 100644 index 0000000000..6eefe994e5 --- /dev/null +++ b/src/eip8130/actions/isActor.ts @@ -0,0 +1,57 @@ +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 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 = { + /** The account to check. */ + account: Address + /** The 32-byte actor identifier (see `key.*(...).actorId`). */ + actorId: Hex +} + +export type IsActorReturnType = boolean + +/** + * 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 + * import { isActor, key } from 'viem/eip8130' + * + * const authorized = await isActor(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 isActor< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: IsActorParameters, +): Promise { + const { account, actorId } = parameters + + const { authenticator } = await getActorConfig(client, { + account, + actorId, + }) + return authenticator !== zeroAddress +} diff --git a/src/eip8130/actions/isLocked.ts b/src/eip8130/actions/isLocked.ts new file mode 100644 index 0000000000..1b39e1ea6b --- /dev/null +++ b/src/eip8130/actions/isLocked.ts @@ -0,0 +1,49 @@ +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 { keystoreAbi } from '../abis.js' +import { keystoreAddress } from '../constants.js' + +export type IsLockedParameters = { + /** The account to check. */ + account: Address +} + +export type IsLockedReturnType = boolean + +/** + * Reads whether an EIP-8130 account is currently locked, from the + * `Keystore` system contract (`isLocked`). For the full status + * (unlock timing, delay), use {@link getLockStatus}. + * + * @example + * ```ts + * import { isLocked } from 'viem/eip8130' + * + * const locked = await isLocked(client, { account: account.address }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns Whether the account is locked. + */ +export async function isLocked< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: IsLockedParameters, +): Promise { + const { account } = parameters + + return readContract(client, { + address: keystoreAddress, + abi: keystoreAbi, + functionName: 'isLocked', + args: [account], + }) +} diff --git a/src/eip8130/actions/sendTransaction.ts b/src/eip8130/actions/sendTransaction.ts new file mode 100644 index 0000000000..344aca5242 --- /dev/null +++ b/src/eip8130/actions/sendTransaction.ts @@ -0,0 +1,337 @@ +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' +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' +import { isNoncelessOnly } from '../keys.js' +import type { + AaAccountChange, + AaCall, + AaCalls, + TransactionSerializable8130, +} from '../types/transaction.js' +import { + type EncodeExecute, + 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 { + type GetTransactionReceiptReturnType, + parseReceiptFields, +} from './getTransactionReceipt.js' +import { isActor } from './isActor.js' + +type FeeOverrides = { + maxFeePerGas?: bigint | undefined + maxPriorityFeePerGas?: bigint | undefined +} + +export type PrepareTransactionRequestParameters = FeeOverrides & { + account: ToAccountReturnType + /** 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 + /** 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 + /** + * "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`. + */ + dataSuffix?: Hex | 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: ToAccountReturnType, +): Promise { + const { actorId, scope: declared } = account + if (!actorId) return declared + + const bound = await isActor(client, { + account: account.address, + actorId, + }) + if (!bound) return declared + + const { scope: onChain } = await getActorConfig(client, { + account: account.address, + actorId, + }) + 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 + * `eth_getTransactionCount`'s 2D channel-nonce extension), and EIP-1559 fees + * from the client when not provided. + */ +export async function prepareTransactionRequest( + client: Client, + parameters: PrepareTransactionRequestParameters, +): Promise { + const { account, calls, accountChanges, payer, gas } = parameters + + const chainId = client.chain?.id + 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 + // 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: scope!, nonceKey }) + nonceKey = nonceKeyMax + } else { + nonceKey ??= 0n + } + + let validBefore = parameters.validBefore + + let { maxFeePerGas, maxPriorityFeePerGas } = parameters + if (maxFeePerGas === undefined || maxPriorityFeePerGas === undefined) { + const fees = await getAction( + client, + estimateFeesPerGas, + 'estimateFeesPerGas', + )({ chain: client.chain }) + maxFeePerGas ??= fees.maxFeePerGas + maxPriorityFeePerGas ??= fees.maxPriorityFeePerGas + } + + // Resolve the sequence for the selected nonce channel. + let nonceSequence = parameters.nonceSequence + if (nonceKey === nonceKeyMax) { + // Nonce-free (expiring) mode: there is no per-channel counter to read; + // 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 = + (parameters.now ?? BigInt(Date.now())) + + (parameters.expiryWindow ?? nonceFreeMaxExpiryWindow) + 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, + getTransactionCount, + 'getTransactionCount', + )({ address: account.address, nonceKey }) + } + + return { + chainId, + from: account.address, + nonceKey, + nonceSequence, + maxFeePerGas, + maxPriorityFeePerGas, + gas, + validBefore, + ...(parameters.validAfter !== undefined + ? { validAfter: parameters.validAfter } + : {}), + accountChanges, + calls, + ...(dataSuffix ? { metadata: dataSuffix } : {}), + payer: payer?.address ?? payer?.account.address, + } +} + +type SendTransactionBaseParameters = FeeOverrides & { + account: ToAccountReturnType + /** + * 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 + /** 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 + /** + * "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`. + */ + 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 + * 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 `validBefore` (which may be auto-computed + * for nonce-free sends) into `waitForTransactionReceipt` without re-preparing. + */ + onTransaction?: + | ((transaction: TransactionSerializable8130) => void) + | undefined +} + +export type SendTransactionParameters = SendTransactionBaseParameters + +export type SendTransactionReturnType = Hex + +/** + * Prepares, signs, and serializes an EIP-8130 (`AA_TX_TYPE`) transaction. + * Shared by {@link sendTransaction} and {@link sendTransactionSync}. + */ +async function prepareAndSign( + client: Client, + parameters: SendTransactionBaseParameters, +): Promise { + const { account, calls, payer, encodeExecute, onTransaction, ...rest } = + parameters + const transaction = await prepareTransactionRequest(client, { + ...rest, + account, + calls: encodeWalletCalls({ + account: account.address, + calls: toPhases(calls), + encodeExecute, + }), + payer, + }) + onTransaction?.(transaction) + 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/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/actions/waitForTransactionReceipt.ts b/src/eip8130/actions/waitForTransactionReceipt.ts new file mode 100644 index 0000000000..4189117dae --- /dev/null +++ b/src/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 `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 + * 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/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/chainConfig.test.ts b/src/eip8130/chainConfig.test.ts new file mode 100644 index 0000000000..9f1ee12fc4 --- /dev/null +++ b/src/eip8130/chainConfig.test.ts @@ -0,0 +1,200 @@ +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 { parseTransaction } from './utils/parseTransaction.js' +import { erc1167Bytecode } from './utils/proxy.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', + }) +}) + +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 + let estimated = false + 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' + // 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)}` + } + throw new Error(`unexpected ${method}`) + }, + }), + }) + + const hash = await sendTransaction(client, { + account, + calls: [{ to: account.address, data: '0x' }], + accountChanges: [account.create()], + 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 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', + ) + 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 new file mode 100644 index 0000000000..4578df779f --- /dev/null +++ b/src/eip8130/chainConfig.ts @@ -0,0 +1,166 @@ +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' +import type { ExactPartial } from '../types/utils.js' +import { + defineTransactionReceipt, + formatTransactionReceipt, +} from '../utils/formatters/transactionReceipt.js' +import type { ToAccountReturnType } from './accounts/toAccount.js' +import { estimateGas } from './actions/estimateGas.js' +import { + parseReceiptFields, + type ReceiptFields, +} from './actions/getTransactionReceipt.js' +import { prepareTransactionRequest as fillEip8130Body } from './actions/sendTransaction.js' +import { encodeWalletCalls } from './utils/encodeWalletCalls.js' +import { toPhases } from './utils/toPhases.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` with an EIP-8130 account, core + * // `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 }], + * }) + * const receipt = await client.getTransactionReceipt({ hash }) + * receipt.eip8130.phaseStatuses // ['0x1'] + * + * @remarks + * 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: { + transactionReceipt: defineTransactionReceipt({ + format( + receipt: RawReceipt8130, + ): TransactionReceipt & { eip8130: ReceiptFields } { + return { + ...formatTransactionReceipt(receipt), + eip8130: parseReceiptFields(receipt as never), + } + }, + }), + }, + 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 + + // 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, + }) + + // 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 + // 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, + })) + + const body = await fillEip8130Body(client, { + account, + calls, + accountChanges: req.accountChanges, + gas, + nonceKey: req.nonceKey, + nonceSequence: req.nonceSequence, + validAfter: req.validAfter, + validBefore: req.validBefore, + maxFeePerGas: req.maxFeePerGas, + maxPriorityFeePerGas: req.maxPriorityFeePerGas, + dataSuffix, + }) + + 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 diff --git a/src/eip8130/chains.ts b/src/eip8130/chains.ts new file mode 100644 index 0000000000..339bc30bfd --- /dev/null +++ b/src/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 `Keystore` + * 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/eip8130/constants.ts b/src/eip8130/constants.ts new file mode 100644 index 0000000000..26aba18f26 --- /dev/null +++ b/src/eip8130/constants.ts @@ -0,0 +1,277 @@ +import type { Hex } from '../types/misc.js' + +/** + * EIP-2718 transaction type for EIP-8130 AA transactions (`AA_TX_TYPE`). + */ +export const aaTransactionType = '0x79' satisfies Hex + +/** + * Magic byte for payer signature domain separation (`AA_PAYER_TYPE`). + */ +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, valid_after, valid_before, account_changes, calls, metadata, + * payer]))`. + */ +export const replayIdType = '0x7901' satisfies Hex + +/** + * 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 = 30000n + +/** + * 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 = 20000n + +/** Enshrined nonce-free replay ring-buffer capacity (`REPLAY_BUFFER_CAPACITY`). */ +export const replayBufferCapacity = 300000n + +/** + * 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: '0x', + config: '0x01', + delegation: '0x02', +} as const satisfies Record + +/** + * `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 + +/** + * 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 `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 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 = { + /** `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`. */ +export const scopeUnrestricted = 0x00 + +/** + * `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 + * (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 + +/** 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 + * `validBefore`. + */ +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 + +/** + * @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 + +/** + * 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 + * onchain contracts. + * + * @remarks + * The non-native addresses below are the [base/eip-8130](https://github.com/base/eip-8130) + * 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`). */ + k1: '0x0000000000000000000000000000000000000001', + /** P-256 (raw). Canonical base/eip-8130 deployment. */ + p256: '0x8130C89F65750431b564A4730397552a11CeA256', + /** WebAuthn / FIDO2 passkey. Canonical base/eip-8130 deployment. */ + passkey: '0x813007b6b1b48E75D91dEc5927ab515d12a0F1d0', + /** Signature delegation (1-hop). Canonical base/eip-8130 deployment. */ + delegate: '0x81301AA52202f8C6b79Cde660440E3c6A7c5ade1', +} 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 `estimateGas` 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 + +/** Transaction Context precompile address (`TX_CONTEXT_ADDRESS`). */ +export const txContextAddress = + '0x813000000000000000000000000000000000aa02' satisfies Hex + +/** + * 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 + * 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 keystoreAddress = + '0x813012Bd8D971928475235BBac6F0488c4A100AC' satisfies Hex + +/** + * Default wallet implementation for EOA auto-delegation + * (`DEFAULT_ACCOUNT_ADDRESS`). + * + * @remarks + * Deployed through the deterministic CREATE2 factory, so this address is + * identical on every supported chain; see {@link keystoreAddress}. + */ +export const defaultAccountAddress = + '0x81309c54D6Bc190FbBc0FA9f296ea4C6A539ADEf' 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/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..66cebc7a7e --- /dev/null +++ b/src/eip8130/decorators/eip8130Actions.ts @@ -0,0 +1,123 @@ +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 { isActor } from '../actions/isActor.js' +import { isLocked } from '../actions/isLocked.js' +import { + prepareTransactionRequest, + 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 +/** 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 (non-standard nested body). */ + getTransaction: 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 + /** Verify an EIP-8130 signature envelope; returns the resolved actor + scope. */ + validateSignature: Bound + } +} + +/** + * 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' + * 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), + 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), + validateSignature: (parameters) => validateSignature(client, parameters), + }, + }) +} diff --git a/src/eip8130/deployments.ts b/src/eip8130/deployments.ts new file mode 100644 index 0000000000..7318af7aa6 --- /dev/null +++ b/src/eip8130/deployments.ts @@ -0,0 +1,150 @@ +import type { Address } from 'abitype' + +/** + * Onchain addresses for an EIP-8130 deployment ([base/eip-8130](https://github.com/base/eip-8130)). + * + * 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 = { + /** Deployed wallet implementation contracts (the singletons account proxies delegate to). */ + accounts: { + /** + * `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 + /** + * 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 + /** + * CanonicalHighRatePayerAccount implementation. Deployed behind a 45-byte + * ERC-1167 proxy (see {@link erc1167Bytecode}). + */ + defaultHighRate: Address + /** + * 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 k1 operational actor (see {@link key.k1} / + * {@link key.trustedExecutor}). + * This is not deployed by base's canonical `Deploy.s.sol`. + */ + erc4337?: Address | undefined + } + /** 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 + } + /** + * 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. */ + manager: Address + /** + * SessionPolicy — unified session-key policy (target allowlist + selector + * rules + recipient allowlists + per-token/native spend limits). + */ + sessionPolicy: Address + } +} + +/** + * Canonical EIP-8130 deployment addresses. Every contract is deployed through + * 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). + * + * 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 = { + accounts: { + // 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: '0x81309c54D6Bc190FbBc0FA9f296ea4C6A539ADEf', + defaultHighRate: '0x813002fFdd25C81CeF79781702176D453AF0Fa57', + // `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: '0x81301AA52202f8C6b79Cde660440E3c6A7c5ade1', + alwaysValid: '0xA550545Da91720c23483c5B3493412A02D1cF9F9', + }, + policies: { + manager: '0x8130E47Bc12CfDD6d2d2178B35Def9A51cae0aC1', + sessionPolicy: '0x8130A0D85473CeF9e888B4228F729b48F0c45E55', + }, +} as const satisfies Eip8130Deployment + +/** + * EIP-8130 deployment on Base Sepolia (chain id `84532`). + * + * 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 = { + ...canonicalEip8130Deployment, +} as const satisfies Eip8130Deployment + +/** + * EIP-8130 deployment for the Base "vibenet" devnet (chain id `84538453`). + * + * The devnet runs EIP-8130 **natively**: the execution client enshrines the + * keystore at {@link keystoreAddress}. Using any other value derives a different + * account address and create transactions fail with "create address mismatch". + */ +export const vibenetDevnetDeployment = { + ...canonicalEip8130Deployment, +} 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. */ +export function getEip8130Deployment( + chainId: number, +): Eip8130Deployment | undefined { + return eip8130Deployments[chainId] +} diff --git a/src/eip8130/devx.test.ts b/src/eip8130/devx.test.ts new file mode 100644 index 0000000000..3b119fb993 --- /dev/null +++ b/src/eip8130/devx.test.ts @@ -0,0 +1,425 @@ +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 { hashMessage } from '../utils/signature/hashMessage.js' +import { recoverAddress } from '../utils/signature/recoverAddress.js' +import { + delegateAuthSize, + newSmartAccount, + toAccount, + toDelegateSigner, +} from './accounts/toAccount.js' +import { sendTransaction } from './actions/sendTransaction.js' +import { + actorScope, + canonicalAuthenticators, + ecrecoverAuthenticator, +} from './constants.js' +import { canonicalEip8130Deployment } from './deployments.js' +import { + authorizeActor, + encodePolicyData, + key, + revokeActor, + toScope, +} from './keys.js' +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', +) +const code = erc1167Bytecode('0x00000000000000000000000000000000000000Ec') +const userSalt = + '0x0000000000000000000000000000000000000000000000000000000000000001' + +const pubkey = { + x: '0x1111111111111111111111111111111111111111111111111111111111111111', + y: '0x2222222222222222222222222222222222222222222222222222222222222222', +} as const + +describe('canonical smart-account deployment', () => { + 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 `CoinbaseSmartWalletV2` is deployed against the Keystore', + ) + }) + + 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('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.operator }), + ], + }) + + 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, + proxy: 'erc1167', + admins: [key.k1(owner.address)], + }), + ).toThrow('Duplicate initial actor id') + }) +}) + +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.operator, actorScope.selfPayer)).toBe(0x03) + }) + + 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 (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: 0, policy }), + ).toThrow() + // policy-scoped actor: POLICY bit is set, policyData populated. + const change = authorizeActor(key.p256(pubkey), { + scope: actorScope.policy, + policy, + }) + expect(change.scope).toBe(actorScope.policy) + expect(change.policyData?.toLowerCase()).toBe( + `${policy.manager.toLowerCase()}${commitment.slice(2)}`, + ) + }) +}) + +describe('toAccount', () => { + const account = toAccount({ + 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.policy, + policy: { + type: 1, + manager: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + commitment: `0x${'aa'.repeat(32)}`, + }, + }), + revokeActor(key.k1('0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC')), + ]) + expect(change.type).toBe('config') + expect(change.changes).toHaveLength(2) + // signature = ecrecover authenticator (20 bytes) || 65-byte sig = 85 bytes + 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'), + ).toEqual({ + type: 'delegation', + target: '0x0000000000000000000000000000000000000000', + }) + }) +}) + +describe('sendTransaction', () => { + 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' + // 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]) + } + throw new Error(`unexpected RPC: ${method}`) + }, + }), + }) + const account = toAccount({ + signer: owner, + userSalt, + code, + initialActors: [key.k1(owner.address)], + }) + + test('builds, signs, serializes and submits an AA_TX_TYPE tx', async () => { + const hash = await sendTransaction(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('0x79')).toBe(true) + + 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 + 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 sendTransaction(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 = 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') + }) + + 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(192)}` + if (method === 'eth_sendRawTransaction') { + clientSent = params[0] + return keccak256(params[0]) + } + throw new Error(`unexpected RPC: ${method}`) + }, + }), + }) + + await sendTransaction(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(parseTransaction(clientSent!).metadata).toBe('0x12345678') + }) +}) + +describe('toDelegateSigner (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 = toDelegateSigner({ + 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('toAccount serializes the full delegate senderAuth', async () => { + const signer = toDelegateSigner({ + delegateAccount: parent, + nestedSigner: owner, + }) + const sub = toAccount({ + 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 = 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( + canonicalAuthenticators.delegate.toLowerCase(), + ) + }) +}) diff --git a/src/eip8130/errors.ts b/src/eip8130/errors.ts new file mode 100644 index 0000000000..337dd6965e --- /dev/null +++ b/src/eip8130/errors.ts @@ -0,0 +1,133 @@ +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 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' + constructor({ + scope, + nonceKey, + }: { + scope: number + nonceKey?: bigint | undefined + }) { + super( + `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: [ + '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.', + ], + }, + ) + } +} + +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 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 `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 + * `validBefore`. + */ +export class TransactionExpiredError extends BaseError { + override name = 'TransactionExpiredError' + constructor({ + hash, + validBefore, + blockTimestamp, + }: { + hash: `0x${string}` + /** 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: \`validBefore\` ${validBefore} (unix ms) passed (latest block timestamp ${blockTimestamp}s).`, + { + metaMessages: [ + '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 })`).', + ], + }, + ) + } +} + +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/eip8130/index.ts b/src/eip8130/index.ts new file mode 100644 index 0000000000..18e35911f4 --- /dev/null +++ b/src/eip8130/index.ts @@ -0,0 +1,385 @@ +// biome-ignore lint/performance/noBarrelFile: entrypoint +export { + authenticatorAbi, + erc4337AccountAbi, + keystoreAbi, + nonceManagerAbi, + transactionContextAbi, +} from './abis.js' +export { + delegateAuthSize, + type NewSmartAccountParameters, + type NewSmartAccountReturnType, + newSmartAccount, + type ToAccountParameters, + type ToAccountReturnType, + type ToDelegateSignerParameters, + type ToEoaAccountReturnType, + toAccount, + toDelegateSigner, + toEoaAccount, +} from './accounts/toAccount.js' +export { + type Eip8130SmartAccountImplementation, + 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, + unsequencedLocalSequence, +} 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 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 PrepareTransactionRequestParameters, + prepareTransactionRequest, + type SendTransactionParameters, + type SendTransactionReturnType, + type SendTransactionSyncParameters, + type SendTransactionSyncReturnType, + sendTransaction, + sendTransactionSync, +} from './actions/sendTransaction.js' +export { + type ValidateSignatureParameters, + type ValidateSignatureReturnType, + validateSignature, +} from './actions/validateSignature.js' +export { + type WaitForTransactionReceiptParameters, + type WaitForTransactionReceiptReturnType, + waitForTransactionReceipt, +} from './actions/waitForTransactionReceipt.js' +export { + type Eip8130Capabilities, + type Eip8130CapabilitiesParameters, + eip8130Capabilities, + eip8130CapabilitiesByChain, + supportedPermissionTypes, + supportedPolicyTypes, + supportedSignerTypes, + supportedSubAccountKeyTypes, +} from './capabilities.js' +export { eip8130ChainConfig } from './chainConfig.js' +export { + eip8130ChainIds, + type Is8130EnabledParameters, + is8130Enabled, + register8130Chains, + unregister8130Chains, +} from './chains.js' +export { + aaBaseCost, + aaPayerType, + aaTransactionType, + accountChangeType, + accountStateFlags, + actorScope, + canonicalAuthDataLength, + canonicalAuthenticators, + changeType, + defaultAccountAddress, + deploymentHeaderSize, + ecrecoverAuthenticator, + externalPolicyAuthenticator, + keystoreAddress, + maxCodeSize, + nonceFreeCost, + nonceFreeExpiryWindow, + nonceFreeMaxExpiryWindow, + nonceKeyExistingCost, + nonceKeyFirstUseCost, + nonceKeyMax, + nonceManagerAddress, + policyDataLength, + replayBufferCapacity, + replayIdType, + revokedAuthenticator, + scopeUnrestricted, + trustedExecutorAuthenticator, + txContextAddress, + unsequencedLocalHalf, +} from './constants.js' +export { + type Eip8130Actions, + eip8130Actions, +} from './decorators/eip8130Actions.js' +export { + baseSepoliaDeployment, + canonicalEip8130Deployment, + type Eip8130Deployment, + eip8130Deployments, + getEip8130Deployment, + vibenetDevnetDeployment, +} from './deployments.js' +export { + ActorNotBoundError, + type ActorNotBoundErrorType, + NonceScopeError, + type NonceScopeErrorType, + ScopeMismatchError, + type ScopeMismatchErrorType, + TransactionExpiredError, + type TransactionExpiredErrorType, +} from './errors.js' +export { + type AuthorizeActorOptions, + authorizeActor, + canUseSequencedNonce, + encodePolicyData, + incrementLocalEpoch, + isNoncelessOnly, + key, + type Policy, + revokeActor, + toScope, +} from './keys.js' +export { + type LockChangeParameters, + lockChange, + maxUnlockDelay, + unlockChange, +} from './lock.js' +export { type Nonce, nonce } from './nonce.js' +export { + type FulfillGrantPermissionsErrorType, + type FulfillGrantPermissionsParameters, + 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' +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 FulfillAddSubAccountErrorType, + type FulfillAddSubAccountParameters, + type FulfillAddSubAccountReturnType, + fulfillAddSubAccount, + type SubAccountKey, +} from './subAccounts.js' +export type { + AaAccountChange, + AaAccountChangeConfig, + AaAccountChangeCreate, + AaAccountChangeDelegation, + AaActor, + AaAuthorizeActor, + AaCall, + AaCalls, + AaChange, + AaChangeChannel, + AaIncrementLocalEpoch, + AaLock, + AaRevokeActor, + AaUnlock, + TransactionSerializable8130, + TransactionSerialized8130, +} from './types/transaction.js' +export { + type DecodeAuthorizeActorPayloadErrorType, + type DecodedAuthorizeActorPayload, + decodeAuthorizeActorPayload, + type EncodeChangePayloadErrorType, + encodeChangePayload, +} from './utils/actorChangeData.js' +export { + type ActorIdFromAddressErrorType, + type ActorIdFromPublicKeyErrorType, + actorIdFromAddress, + actorIdFromPublicKey, +} from './utils/actorId.js' +export { + type AssertTransactionErrorType, + assertTransaction, +} from './utils/assertTransaction.js' +export { + type ComputeAddressErrorType, + type ComputeAddressParameters, + computeAddress, + deploymentHeader, +} from './utils/computeAddress.js' +export { + defaultEncodeExecute, + type EncodeExecute, + type EncodeExecuteParameters, + encodeWalletCalls, +} from './utils/encodeWalletCalls.js' +export { + accountChangeTypehash, + type HashAccountChangesErrorType, + type HashAccountChangesParameters, + hashAccountChanges, + signedAccountChangesTypehash, +} from './utils/hashActorChanges.js' +export { + type GetPayerSignatureHashErrorType, + type GetSenderSignatureHashErrorType, + type GetSignatureHashParameters, + type GetSignatureHashReturnType, + 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, +} from './utils/parseTransaction.js' +export { erc1167Bytecode, upgradeableProxyBytecode } from './utils/proxy.js' +export { + type RecoverSenderAddressErrorType, + type RecoverSenderAddressParameters, + recoverSenderAddress, +} from './utils/recoverSender.js' +export { + type SerializeTransactionErrorType, + serializeTransaction, + toAccountChangesList, + toCallsList, + toTransactionBody, +} from './utils/serializeTransaction.js' +export { + type SignAccountChangesErrorType, + type SignAccountChangesParameters, + signAccountChanges, +} from './utils/signActorChanges.js' +export { + type EncodeSignedActorChangesSignatureErrorType, + encodeSignedActorChangesSignature, + type SignedActorChangeSet, + signedActorChangesMagic, +} from './utils/signedActorChangesSignature.js' +export { + type ToP256SignerParameters, + type ToWebAuthnSignerParameters, + toP256Signer, + 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, + type SignTransactionParameters, + signTransaction, +} from './utils/signTransaction.js' diff --git a/src/eip8130/keys.ts b/src/eip8130/keys.ts new file mode 100644 index 0000000000..14144cbd82 --- /dev/null +++ b/src/eip8130/keys.ts @@ -0,0 +1,231 @@ +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, + externalPolicyAuthenticator, + scopeUnrestricted, +} from './constants.js' +import type { + AaActor, + AaAuthorizeActor, + AaIncrementLocalEpoch, + 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. 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 } = {}, + ): 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, + } + }, + /** + * 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 key.k1(caller) + }, + /** + * 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. */ +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. + * + * 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 { + 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`). + * + * 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) +} + +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 `signActorChanges` / + * `toAccount#authorize`. + * + * A policy-gated actor (session key) should be authorized as POLICY-only + * (`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 `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 }), { + * scope: actorScope.policy, // POLICY-only; optionally | actorScope.selfPayer | actorScope.nonce + * policy: { type: 1, manager, commitment }, + * }) + */ +export function authorizeActor( + actor: AaActor, + options: AuthorizeActorOptions = {}, +): AaAuthorizeActor { + const change: AaAuthorizeActor = { + changeType: 0x00, + actorId: actor.actorId, + authenticator: actor.authenticator, + } + let scope = options.scope ?? 0 + if (options.expiry) change.expiry = options.expiry + if (options.policy) { + // 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.policy`).', + ) + // 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 +} + +/** 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: 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, { account }) + * const bump = await account.change([incrementLocalEpoch()], { chainId, sequence: local }) + */ +export function incrementLocalEpoch(): AaIncrementLocalEpoch { + return { changeType: 0x02 } +} diff --git a/src/eip8130/lock.test.ts b/src/eip8130/lock.test.ts new file mode 100644 index 0000000000..d7dbe9929c --- /dev/null +++ b/src/eip8130/lock.test.ts @@ -0,0 +1,84 @@ +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 { keystoreAbi } from './abis.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' + +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(() => lockChange({ unlockDelay: 0 })).toThrow() + expect(() => lockChange({ unlockDelay: -1 })).toThrow() + expect(() => lockChange({ unlockDelay: 65_536 })).toThrow() + expect(() => lockChange({ unlockDelay: 1.5 })).toThrow() + }) +}) + +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') + }) +}) + +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('getLockStatus', () => { + test('decodes the Keystore.getLockStatus tuple', async () => { + const client = lockClient({ + eth_call: encodeFunctionResult({ + abi: keystoreAbi, + functionName: 'getLockStatus', + result: [true, true, 1_800_000_000, 3600], + }), + }) + const status = await getLockStatus(client, { account }) + expect(status).toEqual({ + locked: true, + hasInitiatedUnlock: true, + unlocksAt: 1_800_000_000, + unlockDelay: 3600, + }) + }) +}) + +describe('isLocked', () => { + test('decodes the Keystore.isLocked bool', async () => { + const client = lockClient({ + eth_call: encodeFunctionResult({ + abi: keystoreAbi, + functionName: 'isLocked', + result: true, + }), + }) + expect(await isLocked(client, { account })).toBe(true) + }) +}) diff --git a/src/eip8130/lock.ts b/src/eip8130/lock.ts new file mode 100644 index 0000000000..f6a486bb0f --- /dev/null +++ b/src/eip8130/lock.ts @@ -0,0 +1,88 @@ +import { BaseError } from '../errors/base.js' +import { changeType } from './constants.js' +import type { AaLock, AaUnlock } from './types/transaction.js' + +/** + * 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/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 { + * keystoreAddress, + * lockChange, + * signAccountChanges, + * encodeApplySignedAccountChangesData, + * getConfigSequence, + * sendTransaction, + * } from 'viem/eip8130' + * + * const { local } = await getConfigSequence(client, { account }) + * const entry = await signAccountChanges({ + * signer: admin, + * account, + * channel: 'local', + * chainId, + * sequence: local, + * changes: [lockChange({ unlockDelay: 3600 })], + * }) + * const data = encodeApplySignedAccountChangesData({ account, ...entry }) + * await sendTransaction(client, { account, calls: [{ to: keystoreAddress, data }], gas }) + * ``` + */ + +/** Maximum `unlockDelay` (the payload field is `uint16`). */ +export const maxUnlockDelay = 0xffff + +export type LockChangeParameters = { + /** + * 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 +} + +/** + * 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 lockChange(parameters: LockChangeParameters): AaLock { + const { unlockDelay } = 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 { changeType: changeType.lock, unlockDelay } +} + +/** + * 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 unlockChange(): AaUnlock { + return { changeType: changeType.unlock } +} diff --git a/src/eip8130/nonce.test.ts b/src/eip8130/nonce.test.ts new file mode 100644 index 0000000000..730d2841c0 --- /dev/null +++ b/src/eip8130/nonce.test.ts @@ -0,0 +1,212 @@ +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' +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 { sendTransaction } from './actions/sendTransaction.js' +import { nonceFreeMaxExpiryWindow, nonceKeyMax } from './constants.js' +import { key } from './keys.js' +import { nonce } from './nonce.js' +import { parseTransaction } from './utils/parseTransaction.js' +import { erc1167Bytecode } from './utils/proxy.js' + +const owner = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', +) +const code = erc1167Bytecode('0x00000000000000000000000000000000000000Ec') +const userSalt = + '0x0000000000000000000000000000000000000000000000000000000000000001' + +const account = toAccount({ + 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 validBefore (unix ms)', () => { + expect(nonce.nonceless({ validBefore: 1_800_000_000_000n })).toEqual({ + nonceKey: nonceKeyMax, + nonceSequence: 0n, + validBefore: 1_800_000_000_000n, + }) + }) + + 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.validBefore)).toBeGreaterThanOrEqual(nowMs + 600_000) + expect(Number(result.validBefore)).toBeLessThanOrEqual(nowMs + 601_000) + }) + + test('nonceless requires a validBefore', () => { + expect(() => nonce.nonceless({})).toThrow() + expect(() => nonce.nonceless({ validBefore: 0n })).toThrow() + }) +}) + +describe('sendTransaction 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' + // 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(192)}` + 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' as const, + data: '0x' as const, + }, + ] + + 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, { + account, + calls, + ...fees, + ...nonce.nonceless({ validBefore: 1_800_000_000_000n }), + }) + expect(ctx.methods).not.toContain('eth_getTransactionCount') + 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.validBefore).toBe(1_800_000_000_000n) + }) + + test('channel: reads the sequence with the 2D nonce_key param', async () => { + const ctx = makeClient() + await sendTransaction(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 = parseTransaction(ctx.sent!) + expect(parsed.nonceKey).toBe(5n) + expect(parsed.nonceSequence).toBe(3n) + }) +}) diff --git a/src/eip8130/nonce.ts b/src/eip8130/nonce.ts new file mode 100644 index 0000000000..73c5884914 --- /dev/null +++ b/src/eip8130/nonce.ts @@ -0,0 +1,169 @@ +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' + +/** + * 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 sendTransaction} / {@link prepareTransactionRequest} 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 (**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. + */ + 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 sendTransaction} / {@link prepareTransactionRequest}. + * + * - {@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, sendTransaction } from 'viem/eip8130' + * + * // Two independent channels → can be mined in either order. + * 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 sendTransaction(client, { account, calls, gas, ...nonce.randomChannel() }) + * + * // Nonce-free: valid for the next 10 minutes, no sequencing. + * await sendTransaction(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({ validBefore })` 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.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: { validBefore?: bigint; expiresIn?: number }): Nonce { + const { validBefore, expiresIn } = parameters + // `validBefore` is unix milliseconds; `expiresIn` is a seconds duration. + const resolvedValidBefore = + validBefore ?? + (expiresIn !== undefined + ? BigInt(Date.now() + expiresIn * 1000) + : undefined) + if (resolvedValidBefore === undefined || resolvedValidBefore <= 0n) + throw new BaseError( + 'Nonce-free mode requires a non-zero `validBefore` (or `expiresIn`).', + ) + return { + nonceKey: nonceKeyMax, + nonceSequence: 0n, + validBefore: resolvedValidBefore, + } + }, + + /** + * 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. + * + * - 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 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.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 + validBefore?: bigint | undefined + expiresIn?: number | undefined + } = {}, + ): Nonce { + if (isNoncelessOnly(scope)) + return nonce.nonceless( + parameters.validBefore !== undefined + ? { validBefore: parameters.validBefore } + : { + // `nonceFreeMaxExpiryWindow` is milliseconds; `expiresIn` seconds. + expiresIn: + parameters.expiresIn ?? Number(nonceFreeMaxExpiryWindow) / 1000, + }, + ) + return nonce.channel(parameters.key ?? 0n) + }, +} as const 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/eip8130/permissions.test.ts b/src/eip8130/permissions.test.ts new file mode 100644 index 0000000000..d1f84725ad --- /dev/null +++ b/src/eip8130/permissions.test.ts @@ -0,0 +1,410 @@ +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 { keystoreAbi } from './abis.js' +import { + actorScope, + ecrecoverAuthenticator, + externalPolicyAuthenticator, +} from './constants.js' +import { encodePolicyData, key } from './keys.js' +import { + fulfillGrantPermissions, + parsePermissionsContext, + routePermissionedCalls, + toSessionPolicy, + toSessionPolicyConfig, +} from './permissions.js' +import { + defineSessionPolicy, + encodeSessionPolicyConfig, + policyManagerAbi, +} 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: keystoreAbi as Abi, + data: params[0].data, + }) + return encodeFunctionResult({ + abi: keystoreAbi 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' + +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) + }) +}) + +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', async () => { + const client = actorConfigClient(ecrecoverAuthenticator) + const { actor, change, session } = await fulfillGrantPermissions(client, { + 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('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 k1-operator registration for the manager is included first. + expect(managerChange).toBeDefined() + expect(managerChange?.authenticator).toBe(ecrecoverAuthenticator) + expect(managerChange?.actorId).toBe(actorIdFromAddress(session.manager)) + expect(managerChange?.scope).toBe(actorScope.operator) + expect(managerChange?.policyData).toBeUndefined() + + expect(changes).toEqual([managerChange, change]) + }) + + test('manager already registered → no managerChange', async () => { + const client = actorConfigClient(ecrecoverAuthenticator) + 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(ecrecoverAuthenticator) + const { actor, change, session } = await fulfillGrantPermissions(client, { + 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') + }) + + test('returns a permissionsContext that round-trips', async () => { + const client = actorConfigClient(ecrecoverAuthenticator) + 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(ecrecoverAuthenticator) + 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(ecrecoverAuthenticator) + 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 new file mode 100644 index 0000000000..072cb1aac1 --- /dev/null +++ b/src/eip8130/permissions.ts @@ -0,0 +1,608 @@ +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 { 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, ecrecoverAuthenticator } from './constants.js' +import { authorizeActor, key } from './keys.js' +import { + type DefineSessionPolicyParameters, + defineSessionPolicy, + encodeSessionPolicyConfig, + type SessionPolicy, + type SessionPolicyAction, + type SessionPolicyCallScope, + type SessionPolicyConfig, + type SessionPolicyTokenLimit, +} from './policies.js' +import type { + AaActor, + AaAuthorizeActor, + AaCall, + AaChange, +} from './types/transaction.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.policy, // POLICY-only; OPERATOR would override the gate + * 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), + }) +} + +/** + * 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 + /** + * 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 + */ + assumeManagerRegistered?: boolean | undefined +} + +export type FulfillGrantPermissionsReturnType = { + /** The authorized actor (`key.k1` for `session`, `key.externalPull` for `pull`). */ + actor: AaActor + /** + * 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 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 + /** + * 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`). + */ + 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 + +/** + * 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`. + * + * For the manager's forwarded `executeBatch` to land, the account must register + * 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' + * + * // session key: "≤ 100 USDC / week", expires in 7 days + * const { changes, session } = await fulfillGrantPermissions(client, { + * 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 } }, + * ], + * }, + * ], + * }) + * // provisions the manager (if needed) + authorizes the key, in one batch + * await account.change(changes) + * // later: session.executeCall({ target: usdc, data: transferCalldata }) + * + * @param client - Client. + * @param parameters - Parameters. + */ +export async function fulfillGrantPermissions< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: FulfillGrantPermissionsParameters, +): Promise { + const { + account, + grantee, + role = 'session', + permissions, + expiry, + assumeManagerRegistered = false, + ...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, + }) + + // 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.k1(session.manager) + const { authenticator } = await getActorConfig(client, { + account, + actorId: managerActor.actorId, + }) + const registered = + authenticator.toLowerCase() === ecrecoverAuthenticator.toLowerCase() + if (!registered) + managerChange = authorizeActor(managerActor, { + scope: actorScope.operator, + }) + } + + const changes = managerChange ? [managerChange, change] : [change] + + 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 `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, sendTransaction, 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 sendTransaction(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)) } +} diff --git a/src/eip8130/policies.test.ts b/src/eip8130/policies.test.ts new file mode 100644 index 0000000000..02786148b3 --- /dev/null +++ b/src/eip8130/policies.test.ts @@ -0,0 +1,103 @@ +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( + '0xf728b71109fd552c8ded7e5d780d0eb68afcf0c8a4c9c294ca2ab24fd288b851', + ) + }) + + 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('executeCall encodes PolicyManager.execute(binding, 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') + // #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/eip8130/policies.ts b/src/eip8130/policies.ts new file mode 100644 index 0000000000..cc802a788a --- /dev/null +++ b/src/eip8130/policies.ts @@ -0,0 +1,454 @@ +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/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` 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 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. + */ + +// ───────────────────────────────────────────────────────────────────────────── +// ABIs +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 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 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 + * 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; }', + '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 execute(PolicyBinding binding, bytes executionData)', + 'function executeFor(PolicyBinding binding, bytes executionData)', + 'function executeForMany(PolicyBinding[] bindings, bytes[] executionData) returns (bool[] results)', +]) + +/** + * 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; }', + // #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)', +]) + +// ───────────────────────────────────────────────────────────────────────────── +// 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 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). + * + * Pass either raw `executionData` ({@link encodeSessionPolicyAction}) or the + * 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 + +/** + * Binds a committed policy config to an account and returns everything needed to + * authorize and use a policy-gated session key (base/eip-8130#43: no install). + * + * @example + * import { + * defineSessionPolicy, + * encodeSessionPolicyConfig, + * encodeSessionPolicyAction, + * authorizeActor, + * actorScope, + * key, + * } from 'viem/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 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.policy, policy: session.actorPolicy }), + * ]) + * + * // 2) later, the session key spends within its limit + * const spend = session.executeCall({ 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 }, + executeCall(executionDataOrAction) { + const executionData = + typeof executionDataOrAction === 'string' + ? executionDataOrAction + : encodeSessionPolicyAction(executionDataOrAction) + return { + to: manager, + value: 0n, + data: encodeFunctionData({ + abi: policyManagerAbi, + functionName: 'execute', + args: [toBindingArgs(binding), executionData], + }), + } + }, + 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], + }), + } + }, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// SessionPolicy config + action encoders +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Reference `SessionPolicy` deployment address. Deterministic CREATE2, so it is + * identical on every supported chain. + */ +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/eip8130/queries.test.ts b/src/eip8130/queries.test.ts new file mode 100644 index 0000000000..2160bb2862 --- /dev/null +++ b/src/eip8130/queries.test.ts @@ -0,0 +1,146 @@ +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 { keystoreAbi } from './abis.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 { actorScope, 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('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 getSessionSpend(client, { + commitment, + tokenLimit: { token, limit: 100_000_000n, period: 604_800n }, + }) + expect(spend).toEqual({ + 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', async () => { + const client = readClient(sessionPolicyAbi, { + getCurrentSpend: { start: 1_000, end: 605_800, spend: 150_000_000n }, + }) + const spend = await getSessionSpend(client, { + commitment, + tokenLimit: { token, limit: 100_000_000n, period: 604_800n }, + }) + expect(spend.remaining).toBe(0n) + }) +}) + +describe('getActorConfig', () => { + test('decodes the ActorConfig struct', async () => { + const client = readClient(keystoreAbi, { + getActorConfig: { + authenticator: canonicalAuthenticators.p256, + scope: actorScope.policy, + expiry: 1_800_000_000, + }, + }) + expect(await getActorConfig(client, { account, actorId })).toEqual({ + authenticator: canonicalAuthenticators.p256, + scope: actorScope.policy, + expiry: 1_800_000_000, + // POLICY (0x08) bit is set → hasPolicy. + hasPolicy: true, + }) + }) +}) + +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(keystoreAbi, { + 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(keystoreAbi, { + getActorConfig: { + authenticator: '0x0000000000000000000000000000000000000000', + scope: 0, + expiry: 0, + }, + }) + expect(await isActor(client, { account, actorId })).toBe(false) + }) +}) + +describe('getPolicy', () => { + // The finalized Keystore exposes a single combined `getActorWithPolicy` read + // returning (config, policyManager, policyCommitment). + test('decodes (manager, commitment) from the combined getActorWithPolicy read', async () => { + const manager = '0x00000000000000000000000000000000000000dd' + const client = readClient(keystoreAbi, { + getActorWithPolicy: [ + { + authenticator: canonicalAuthenticators.p256, + scope: 2, + expiry: 0, + }, + manager, + commitment, + ], + }) + expect(await getPolicy(client, { account, actorId })).toEqual({ + target: manager, + commitment, + }) + }) +}) diff --git a/src/eip8130/subAccounts.test.ts b/src/eip8130/subAccounts.test.ts new file mode 100644 index 0000000000..348af6ab17 --- /dev/null +++ b/src/eip8130/subAccounts.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from 'vitest' +import { privateKeyToAccount } from '../accounts/privateKeyToAccount.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. +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('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.operator, + }) + + const keyActor = sub.initialActors.find( + (a) => a.actorId === key.k1(dappKey).actorId, + ) + expect(keyActor?.scope).toBe(actorScope.operator) + + // 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 deployment)', () => { + expect(() => + fulfillAddSubAccount({ + parent, + signer: parentSigner, + salt, + }), + ).toThrow(/CoinbaseSmartWalletV2/) + }) +}) diff --git a/src/eip8130/subAccounts.ts b/src/eip8130/subAccounts.ts new file mode 100644 index 0000000000..2c32b51f44 --- /dev/null +++ b/src/eip8130/subAccounts.ts @@ -0,0 +1,220 @@ +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 { 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' + +/** + * 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 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.operator`, 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. + */ + 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 +} + +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, sendTransaction } 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 sendTransaction(client, { + * account: sub, + * accountChanges: [sub.createChange], + * calls: [{ to: recipient, value: 1n }], + * gas: 300_000n, + * }) + */ +export function fulfillAddSubAccount( + parameters: FulfillAddSubAccountParameters, +): FulfillAddSubAccountReturnType { + const { + parent, + signer, + keys = [], + keyScope, + keyPolicy, + proxy = 'upgradeable', + implementation, + } = parameters + + 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, ...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( + `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 `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) + })() + + 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, + }) + + return { + ...inner, + createChange: inner.create(), + parentActor, + response: { address: inner.address }, + } +} diff --git a/src/eip8130/types/transaction.ts b/src/eip8130/types/transaction.ts new file mode 100644 index 0000000000..b9a60e13e8 --- /dev/null +++ b/src/eip8130/types/transaction.ts @@ -0,0 +1,257 @@ +import type { Address } from 'abitype' +import type { Hex } from '../../types/misc.js' + +/** + * 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 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 + * 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 +} + +/** + * 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, and the identity returned by the + * {@link key} builders. + * + * 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 (`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 +} + +/** `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` (`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: 0x00 + /** 32-byte actor identifier. */ + actorId: Hex + /** Authenticator contract address. */ + authenticator: Address + /** 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, `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` (`ChangeType` `0x01`) op. Payload: `abi.encode(bytes32 actorId)`. */ +export type AaRevokeActor = { + changeType: 0x01 + /** 32-byte actor identifier. */ + actorId: Hex +} + +/** + * `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: a signed `SignedAccountChanges` + * batch (`applySignedAccountChanges`). + */ +export type AaAccountChangeConfig = { + type: 'config' + /** + * 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. */ +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, valid_after, valid_before, + * max_priority_fee_per_gas, max_fee_per_gas, gas_limit, + * account_changes, calls, metadata, 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 (**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). */ + 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 + /** + * 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. + * + * High-level helpers (`prepareTransactionRequest` / `sendTransaction`) 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. */ + 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/eip8130/utils/actorChangeData.ts b/src/eip8130/utils/actorChangeData.ts new file mode 100644 index 0000000000..16630a0170 --- /dev/null +++ b/src/eip8130/utils/actorChangeData.ts @@ -0,0 +1,111 @@ +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 { changeType } from '../constants.js' +import type { AaChange } from '../types/transaction.js' + +/** + * 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: 'expiry', type: 'uint48' }, + { name: 'scope', type: 'uint16' }, + ], + }, + { name: 'policyData', type: 'bytes' }, +] as const + +/** 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 `payload` of a `SignedAccountChanges` change + * (mirrors `Keystore.AccountChange.payload`): + * + * - `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 `payload` is ABI-encoded (not RLP) so the same blob is decoded + * identically by the native protocol and by + * `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) || + * commitment (32)`). + */ +export function encodeChangePayload(change: AaChange): Hex { + if (change.changeType === changeType.authorizeActor) + return encodeAbiParameters(authorizePayloadParameters, [ + change.actorId, + { + authenticator: change.authenticator, + // `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 DecodedAuthorizeActorPayload = { + actorId: Hex + authenticator: Address + scope: number + expiry: bigint + policyData: Hex +} + +export type DecodeAuthorizeActorPayloadErrorType = + | DecodeAbiParametersErrorType + | ErrorType + +/** 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), + policyData, + } +} diff --git a/src/eip8130/utils/actorId.ts b/src/eip8130/utils/actorId.ts new file mode 100644 index 0000000000..fe96b076e8 --- /dev/null +++ b/src/eip8130/utils/actorId.ts @@ -0,0 +1,46 @@ +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 + +/** + * Derives the `actorId` for an address-based actor: + * `bytes32(uint256(uint160(address)))`. + * + * Used for the implicit EOA actor, `k1` (`ECRECOVER_AUTHENTICATOR`), and + * `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: 'left', 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])) +} diff --git a/src/eip8130/utils/assertTransaction.ts b/src/eip8130/utils/assertTransaction.ts new file mode 100644 index 0000000000..7b48a10e60 --- /dev/null +++ b/src/eip8130/utils/assertTransaction.ts @@ -0,0 +1,59 @@ +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 AssertTransactionErrorType = + | InvalidChainIdError + | BaseError + | ErrorType + +/** + * Validates the structural invariants of an EIP-8130 transaction prior to + * serialization or hashing. + */ +export function assertTransaction( + transaction: TransactionSerializable8130, +): void { + const { + chainId, + nonceKey, + nonceSequence, + validBefore, + 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` / `sendTransaction`).', + ) + + // 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 (!validBefore || validBefore === 0n) + throw new BaseError( + '`validBefore` 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/eip8130/utils/computeAddress.test.ts b/src/eip8130/utils/computeAddress.test.ts new file mode 100644 index 0000000000..51ed491f2a --- /dev/null +++ b/src/eip8130/utils/computeAddress.test.ts @@ -0,0 +1,111 @@ +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 { keystoreAddress } from '../constants.js' +import type { AaActor } from '../types/transaction.js' +import { computeAddress, 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 = computeAddress(params) + expect(isAddress(address)).toBe(true) + expect(computeAddress(params)).toBe(address) + }) + + test('matches manual CREATE2 derivation', () => { + const userSalt = + '0x00000000000000000000000000000000000000000000000000000000000000aa' as const + const code = '0x6080' as const + // 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({ + from: keystoreAddress, + salt: effectiveSalt, + bytecode: deploymentCode, + }) + expect( + computeAddress({ userSalt, code, initialActors: [actorA, actorB] }), + ).toBe(expected) + }) + + test('different salt yields different address', () => { + const base = { code: '0x6080', initialActors: [actorA] } as const + const a = computeAddress({ + ...base, + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }) + const b = computeAddress({ + ...base, + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000002', + }) + 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(() => + computeAddress({ + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + code: '0x6080', + initialActors: [actorB, actorA], + }), + ).toThrowError() + expect(() => + computeAddress({ + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + code: '0x6080', + initialActors: [actorA, actorA], + }), + ).toThrowError() + }) + + test('rejects empty code', () => { + expect(() => + computeAddress({ + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + code: '0x', + initialActors: [actorA], + }), + ).toThrowError() + }) +}) diff --git a/src/eip8130/utils/computeAddress.ts b/src/eip8130/utils/computeAddress.ts new file mode 100644 index 0000000000..d17ff15969 --- /dev/null +++ b/src/eip8130/utils/computeAddress.ts @@ -0,0 +1,127 @@ +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, toHex } from '../../utils/encoding/toHex.js' +import { + type Keccak256ErrorType, + keccak256, +} from '../../utils/hash/keccak256.js' +import { keystoreAddress, 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 ComputeAddressParameters = { + /** 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[] +} + +export type ComputeAddressErrorType = + | GetCreate2AddressErrorType + | ConcatHexErrorType + | Keccak256ErrorType + | BaseError + | ErrorType + +/** + * Computes the counterfactual address for an EIP-8130 `create` entry using the + * CREATE2 derivation: + * + * ``` + * // 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 || KEYSTORE_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 computeAddress(parameters: ComputeAddressParameters): Address { + const { userSalt, code, initialActors } = 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.map((actor) => + keccak256( + concatHex([ + actor.actorId, + actor.authenticator, + toHex(actor.scope ?? 0, { size: 2 }), + actor.policyData ?? '0x', + ]), + ), + ), + ), + ) + const effectiveSalt = keccak256(concatHex([userSalt, actorsCommitment])) + const deploymentCode = concatHex([deploymentHeader(codeSize), code]) + + return getCreate2Address({ + from: keystoreAddress, + salt: effectiveSalt, + bytecode: deploymentCode, + }) +} diff --git a/src/eip8130/utils/encodeWalletCalls.test.ts b/src/eip8130/utils/encodeWalletCalls.test.ts new file mode 100644 index 0000000000..001c9f1286 --- /dev/null +++ b/src/eip8130/utils/encodeWalletCalls.test.ts @@ -0,0 +1,92 @@ +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/eip8130/utils/encodeWalletCalls.ts b/src/eip8130/utils/encodeWalletCalls.ts new file mode 100644 index 0000000000..41b894afa5 --- /dev/null +++ b/src/eip8130/utils/encodeWalletCalls.ts @@ -0,0 +1,90 @@ +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' + +/** 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. + */ +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 NormalizedAaCall[] +} + +/** + * 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): NormalizedAaCall => ({ + to: call.to, + value: call.value ?? 0n, + data: call.data ?? '0x', + }), + ) + return [encodeExecute({ account, calls: normalized })] + }) +} diff --git a/src/eip8130/utils/hashActorChanges.ts b/src/eip8130/utils/hashActorChanges.ts new file mode 100644 index 0000000000..70169a0eb5 --- /dev/null +++ b/src/eip8130/utils/hashActorChanges.ts @@ -0,0 +1,108 @@ +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 { AaChange } from '../types/transaction.js' +import { encodeChangePayload } from './actorChangeData.js' + +/** `keccak256("AccountChange(uint8 changeType,bytes payload)")` */ +export const accountChangeTypehash = keccak256( + stringToHex('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( + 'SignedAccountChangeBatch(address account,uint256 chainId,uint64 sequence,AccountChange[] changes)AccountChange(uint8 changeType,bytes payload)', + ), +) + +export type HashAccountChangesParameters = { + /** The account whose configuration is changing. */ + account: Address + /** + * 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 HashAccountChangesErrorType = + | EncodeAbiParametersErrorType + | ConcatHexErrorType + | Keccak256ErrorType + | ToHexErrorType + | ErrorType + +/** + * Computes the EIP-8130 `SignedAccountChanges` batch signature digest + * (`Keystore._changesDigest`): + * + * ``` + * 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 entry's `signature`. + */ +export function hashAccountChanges( + parameters: HashAccountChangesParameters, +): Hex { + const { account, chainId, sequence, changes } = parameters + + const changeHashes = changes.map((change) => + keccak256( + encodeAbiParameters( + [{ type: 'bytes32' }, { type: 'uint8' }, { type: 'bytes32' }], + [ + accountChangeTypehash, + change.changeType, + keccak256(encodeChangePayload(change)), + ], + ), + ), + ) + const changesHash = keccak256(concatHex(changeHashes)) + + return keccak256( + encodeAbiParameters( + [ + { type: 'bytes32' }, + { type: 'address' }, + { type: 'uint256' }, + { type: 'uint64' }, + { type: 'bytes32' }, + ], + [ + signedAccountChangesTypehash, + account, + BigInt(chainId), + BigInt(sequence), + changesHash, + ], + ), + ) +} diff --git a/src/eip8130/utils/hashTransaction.ts b/src/eip8130/utils/hashTransaction.ts new file mode 100644 index 0000000000..7b2e125343 --- /dev/null +++ b/src/eip8130/utils/hashTransaction.ts @@ -0,0 +1,101 @@ +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 GetSignatureHashParameters = + TransactionSerializable8130 & { + /** Output format. @default 'hex' */ + to?: to | To | undefined + } + +export type GetSignatureHashReturnType = + | (to extends 'bytes' ? ByteArray : never) + | (to extends 'hex' ? Hex : never) + +export type GetSenderSignatureHashErrorType = + | 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, valid_after, valid_before, + * max_priority_fee_per_gas, max_fee_per_gas, gas_limit, + * account_changes, calls, metadata, payer + * ])) + * ``` + */ +export function getSenderSignatureHash( + parameters: GetSignatureHashParameters, +): GetSignatureHashReturnType { + const { to = 'hex', payer } = parameters + const hash = keccak256( + concatHex([ + aaTransactionType, + toRlp([...toTransactionBody(parameters), payer ?? '0x']), + ]), + ) + if (to === 'bytes') return hexToBytes(hash) as GetSignatureHashReturnType + return hash as GetSignatureHashReturnType +} + +export type GetPayerSignatureHashErrorType = + | Keccak256ErrorType + | ConcatHexErrorType + | HexToBytesErrorType + | ErrorType + +/** + * 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, valid_after, valid_before, + * max_priority_fee_per_gas, max_fee_per_gas, gas_limit, + * 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 + * `parameters.from` before computing this hash, to bind the payer's signature to + * the specific sender and prevent cross-sender replay. + */ +export function getPayerSignatureHash( + parameters: GetSignatureHashParameters, +): GetSignatureHashReturnType { + const { to = 'hex', payer } = parameters + const hash = keccak256( + concatHex([ + aaPayerType, + toRlp([...toTransactionBody(parameters), payer ?? '0x']), + ]), + ) + if (to === 'bytes') return hexToBytes(hash) as GetSignatureHashReturnType + return hash as GetSignatureHashReturnType +} diff --git a/src/eip8130/utils/keystoreCalls.test.ts b/src/eip8130/utils/keystoreCalls.test.ts new file mode 100644 index 0000000000..64f48c686b --- /dev/null +++ b/src/eip8130/utils/keystoreCalls.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from 'vitest' +import type { Hex } from '../../types/misc.js' +import { decodeFunctionData } from '../../utils/abi/decodeFunctionData.js' +import { keystoreAbi } from '../abis.js' +import { + eip8130ChainIds, + is8130Enabled, + register8130Chains, + unregister8130Chains, +} 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 './keystoreCalls.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('toFactoryArgs (ERC-4337 factory)', () => { + const params = { + userSalt: + '0x0000000000000000000000000000000000000000000000000000000000000001', + code: '0x6080', + initialActors: [actor], + } as const + + test('factory is the keystore; factoryData is createAccount', () => { + const { factory, factoryData } = toFactoryArgs(params) + expect(factory).toBe(keystoreAddress) + expect(factoryData).toBe(encodeCreateAccountData(params)) + + const { functionName, args } = decodeFunctionData({ + abi: keystoreAbi, + 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, + scope: 0, + policyData: '0x', + }, + ]) + }) + + test('factory deploys to the computeAddress address', () => { + // both derive from the same inputs/config address -> portable address + const address = computeAddress(params) + expect(address).toMatch(/^0x[0-9a-fA-F]{40}$/) + }) +}) + +describe('encodeApplySignedAccountChangesData (portable path)', () => { + test('encodes account + SignedAccountChanges(channel, sequence, changes, signature)', () => { + const changes: readonly AaChange[] = [ + { + changeType: 0x00, + actorId: + '0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc', + authenticator: '0x0000000000000000000000000000000000000001', + scope: 0x04, + }, + { + changeType: 0x01, + actorId: + '0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266', + }, + ] + const data = encodeApplySignedAccountChangesData({ + account: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + channel: 'local', + sequence: 1n, + changes, + signature: '0xfeed', + }) + const decoded = decodeFunctionData({ + abi: keystoreAbi, + data, + }) + 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/eip8130/utils/keystoreCalls.ts b/src/eip8130/utils/keystoreCalls.ts new file mode 100644 index 0000000000..ff47da4008 --- /dev/null +++ b/src/eip8130/utils/keystoreCalls.ts @@ -0,0 +1,127 @@ +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 { keystoreAbi } from '../abis.js' +import { keystoreAddress } from '../constants.js' +import type { + AaActor, + AaChange, + AaChangeChannel, +} from '../types/transaction.js' +import { encodeChangePayload } from './actorChangeData.js' + +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), + })) +} + +function toAbiChanges(changes: readonly AaChange[]) { + return changes.map((change) => ({ + changeType: change.changeType, + payload: encodeChangePayload(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 `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}). + */ +export function encodeCreateAccountData( + parameters: EncodeCreateAccountDataParameters, +): Hex { + const { userSalt, code, initialActors } = parameters + return encodeFunctionData({ + abi: keystoreAbi, + functionName: 'createAccount', + args: [userSalt, code, toInitialActors(initialActors)], + }) +} + +export type ToFactoryArgsParameters = EncodeCreateAccountDataParameters + +export type ToFactoryArgsReturnType = { + factory: Address + factoryData: Hex +} + +export type ToFactoryArgsErrorType = + | EncodeCreateAccountDataErrorType + | ErrorType + +/** + * Returns the ERC-4337 `{ factory, factoryData }` for deploying an EIP-8130 + * 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 { + return { + factory: keystoreAddress, + factoryData: encodeCreateAccountData(parameters), + } +} + +export type EncodeApplySignedAccountChangesDataParameters = { + /** The account whose configuration is changing. */ + account: Address + /** 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 EncodeApplySignedAccountChangesDataErrorType = + | EncodeFunctionDataErrorType + | ErrorType + +/** + * 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`. + */ +export function encodeApplySignedAccountChangesData( + parameters: EncodeApplySignedAccountChangesDataParameters, +): Hex { + const { account, channel, sequence, changes, signature } = parameters + return encodeFunctionData({ + abi: keystoreAbi, + functionName: 'applySignedAccountChanges', + args: [ + account, + { + channel: channel === 'multichain' ? 1 : 0, + sequence, + changes: toAbiChanges(changes), + signature, + }, + ], + }) +} diff --git a/src/eip8130/utils/parseTransaction.ts b/src/eip8130/utils/parseTransaction.ts new file mode 100644 index 0000000000..98f544b853 --- /dev/null +++ b/src/eip8130/utils/parseTransaction.ts @@ -0,0 +1,201 @@ +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, + 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, + changeType, +} from '../constants.js' +import type { + AaAccountChange, + AaActor, + AaCalls, + AaChange, + TransactionSerializable8130, +} from '../types/transaction.js' +import { decodeAuthorizeActorPayload } from './actorChangeData.js' + +export type ParseTransactionErrorType = + | 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, 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 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, + } + if (scope !== 0) change.scope = scope + if (expiry !== 0n) change.expiry = expiry + if (policyData !== '0x') change.policyData = policyData + return change + } + 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[] { + // 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 (const entry of entries) { + const [type, ...body] = entry as RlpHex[] + if (type === accountChangeType.create) { + 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 [channel, sequence, changes, signature] = body + result.push({ + type: 'config', + 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 + } + if (type === accountChangeType.delegation) { + const [target] = body + result.push({ type: 'delegation', target: target as Address }) + continue + } + throw new BaseError(`Unknown account change entry type: "${type as Hex}".`) + } + return result +} + +/** + * Parses a serialized EIP-8130 (`AA_TX_TYPE`) transaction back into a + * {@link TransactionSerializable8130}. + */ +export function parseTransaction(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, + validAfter, + validBefore, + maxPriorityFeePerGas, + maxFeePerGas, + gas, + accountChanges, + calls, + metadata, + 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 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, + ) + 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) + if ((metadata as Hex) !== '0x') transaction.metadata = metadata as Hex + + 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/eip8130/utils/proxy.ts b/src/eip8130/utils/proxy.ts new file mode 100644 index 0000000000..4df83ef476 --- /dev/null +++ b/src/eip8130/utils/proxy.ts @@ -0,0 +1,52 @@ +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 **immutable** EIP-8130 + * account address (e.g. `DefaultHighRateAccount`). See {@link computeAddress} + * and {@link toFactoryArgs}. + */ +export function erc1167Bytecode(implementation: Address): Hex { + return concatHex([ + '0x363d3d373d3d3d363d73', + implementation, + '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 (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/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 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([ + // 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', + ]) +} diff --git a/src/eip8130/utils/recoverSender.ts b/src/eip8130/utils/recoverSender.ts new file mode 100644 index 0000000000..304e0d25c5 --- /dev/null +++ b/src/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 { getSenderSignatureHash } from './hashTransaction.js' + +export type RecoverSenderAddressParameters = { + /** + * A parsed / serializable EIP-8130 transaction. Must carry `senderAuth`. + */ + transaction: TransactionSerializable8130 +} + +export type RecoverSenderAddressErrorType = 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 recoverSenderAddress({ transaction: parsed }) + */ +export async function recoverSenderAddress( + parameters: RecoverSenderAddressParameters, +): 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 = getSenderSignatureHash({ ...transaction, from: undefined }) + return recoverAddress({ hash, signature: transaction.senderAuth }) +} diff --git a/src/eip8130/utils/serializeTransaction.test.ts b/src/eip8130/utils/serializeTransaction.test.ts new file mode 100644 index 0000000000..a2565fe8d1 --- /dev/null +++ b/src/eip8130/utils/serializeTransaction.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, test } from 'vitest' +import { keccak256 } from '../../utils/hash/keccak256.js' +import { + aaPayerType, + aaTransactionType, + changeType, + nonceKeyMax, +} from '../constants.js' +import { incrementLocalEpoch } from '../keys.js' +import type { TransactionSerializable8130 } from '../types/transaction.js' +import { + getPayerSignatureHash, + getSenderSignatureHash, +} from './hashTransaction.js' +import { parseTransaction } from './parseTransaction.js' +import { serializeTransaction } 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 = serializeTransaction(transaction) + expect(serialized.startsWith(aaTransactionType)).toBe(true) + // canonical codec round-trip (addresses are returned lowercase, matching viem) + expect(serializeTransaction(parseTransaction(serialized))).toEqual( + serialized, + ) + expect(parseTransaction(serialized)).toMatchObject({ + chainId: 8453, + nonceSequence: 3n, + senderAuth, + }) + }) + + test('sponsored: payer + payerAuth', () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: alice, + nonceKey: 7n, + nonceSequence: 1n, + validAfter: 1_800_000_000_000n, + validBefore: 1_900_000_000_000n, + maxPriorityFeePerGas: 1n, + maxFeePerGas: 2n, + gas: 50_000n, + calls: [[{ to: token, data: '0xabcd' }], [{ to: bob }]], + payer, + senderAuth, + payerAuth, + } + const serialized = serializeTransaction(transaction) + expect(serializeTransaction(parseTransaction(serialized))).toEqual( + serialized, + ) + }) + + test('EOA path: no from', () => { + const transaction: TransactionSerializable8130 = { + chainId: 1, + maxFeePerGas: 2n, + calls: [[{ to: bob, data: '0x' }]], + senderAuth, + } + const serialized = serializeTransaction(transaction) + const parsed = parseTransaction(serialized) + expect(parsed.from).toBeUndefined() + // re-serialization is stable + expect(serializeTransaction(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 = serializeTransaction(transaction) + expect(serializeTransaction(parseTransaction(serialized))).toEqual( + serialized, + ) + }) + + test('account changes: config (actor management)', () => { + const transaction: TransactionSerializable8130 = { + chainId: 8453, + from: alice, + maxFeePerGas: 2n, + accountChanges: [ + { + type: 'config', + channel: 'local', + sequence: 5n, + changes: [ + { + changeType: 0x00, + actorId: + '0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc', + authenticator: '0x0000000000000000000000000000000000000001', + // SCOPE_NONCE (0x04) | SCOPE_POLICY (0x02); policy presence is a + // scope bit now (no standalone policyType field). + scope: 0x06, + expiry: 1_900_000_000n, + policyData: '0xc0ffee', + }, + { + changeType: 0x01, + actorId: + '0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266', + }, + ], + signature: '0xfeed', + }, + ], + senderAuth, + } + const serialized = serializeTransaction(transaction) + expect(serializeTransaction(parseTransaction(serialized))).toEqual( + serialized, + ) + // structural round-trip on the parsed changes + const parsed = parseTransaction(serialized) + const config = parsed.accountChanges?.[0] + expect(config).toMatchObject({ + type: 'config', + channel: 'local', + sequence: 5n, + }) + }) + + 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: alice, + nonceKey: nonceKeyMax, + validBefore: 1_900_000_000_000n, + maxFeePerGas: 2n, + calls: [[{ to: bob }]], + senderAuth, + } + const serialized = serializeTransaction(transaction) + expect(serializeTransaction(parseTransaction(serialized))).toEqual( + serialized, + ) + }) +}) + +describe('assertions', () => { + test('rejects invalid chainId', () => { + expect(() => + serializeTransaction({ chainId: 0, senderAuth }), + ).toThrowError() + }) + + test('nonce-free mode requires validBefore', () => { + expect(() => + serializeTransaction({ + chainId: 1, + nonceKey: nonceKeyMax, + senderAuth, + }), + ).toThrowError() + }) + + test('nonce-free mode rejects non-zero sequence', () => { + expect(() => + serializeTransaction({ + chainId: 1, + nonceKey: nonceKeyMax, + nonceSequence: 1n, + validBefore: 1_900_000_000_000n, + senderAuth, + }), + ).toThrowError() + }) + + test('self-pay rejects payerAuth', () => { + expect(() => + serializeTransaction({ 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 = 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 = getSenderSignatureHash(transaction) + const withoutPayer = getSenderSignatureHash({ + ...transaction, + payer: undefined, + }) + expect(withPayer).not.toEqual(withoutPayer) + }) + + 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 = 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 = getSenderSignatureHash({ ...transaction, to: 'bytes' }) + expect(bytes).toBeInstanceOf(Uint8Array) + expect(keccak256(bytes)).toMatch(/^0x/) + }) +}) diff --git a/src/eip8130/utils/serializeTransaction.ts b/src/eip8130/utils/serializeTransaction.ts new file mode 100644 index 0000000000..5fe3e698d2 --- /dev/null +++ b/src/eip8130/utils/serializeTransaction.ts @@ -0,0 +1,155 @@ +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 } from '../constants.js' +import type { + AaAccountChange, + AaCalls, + AaChange, + TransactionSerializable8130, + TransactionSerialized8130, +} from '../types/transaction.js' +import { encodeChangePayload } from './actorChangeData.js' +import { + type AssertTransactionErrorType, + assertTransaction, +} 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 `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 [ + change.changeType ? numberToHex(change.changeType) : '0x', + encodeChangePayload(change), + ] +} + +/** + * Encodes the `account_changes` field into a nested RLP-ready array. + * + * 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 ?? []).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', + ]), + ] + if (entry.type === 'config') + return [ + accountChangeType.config, + // channel byte: local = 0x00 (RLP '0x'), multichain = 0x01. + entry.channel === 'multichain' ? '0x01' : '0x', + entry.sequence ? numberToHex(entry.sequence) : '0x', + entry.changes.map(toChange), + entry.signature, + ] + 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, + validAfter, + validBefore, + maxPriorityFeePerGas, + maxFeePerGas, + gas, + accountChanges, + calls, + metadata, + } = transaction + return [ + numberToHex(chainId), + from ?? '0x', + nonceKey ? numberToHex(nonceKey) : '0x', + nonceSequence ? numberToHex(nonceSequence) : '0x', + validAfter ? numberToHex(validAfter) : '0x', + validBefore ? numberToHex(validBefore) : '0x', + maxPriorityFeePerGas ? numberToHex(maxPriorityFeePerGas) : '0x', + maxFeePerGas ? numberToHex(maxFeePerGas) : '0x', + gas ? numberToHex(gas) : '0x', + toAccountChangesList(accountChanges), + toCallsList(calls), + metadata ?? '0x', + ] +} + +export type SerializeTransactionErrorType = + | AssertTransactionErrorType + | 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 serializeTransaction( + transaction: TransactionSerializable8130, +): TransactionSerialized8130 { + assertTransaction(transaction) + + const { payer, senderAuth, payerAuth } = transaction + + return concatHex([ + aaTransactionType, + toRlp([ + ...toTransactionBody(transaction), + payer ?? '0x', + senderAuth ?? '0x', + payerAuth ?? '0x', + ]), + ]) as TransactionSerialized8130 +} diff --git a/src/eip8130/utils/signActorChanges.test.ts b/src/eip8130/utils/signActorChanges.test.ts new file mode 100644 index 0000000000..23f00ac491 --- /dev/null +++ b/src/eip8130/utils/signActorChanges.test.ts @@ -0,0 +1,125 @@ +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 { AaChange } from '../types/transaction.js' +import { actorIdFromAddress } from './actorId.js' +import { hashAccountChanges } from './hashActorChanges.js' +import { signAccountChanges } from './signActorChanges.js' + +const signer = privateKeyToAccount(accounts[0].privateKey) +const account = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' as const + +const authorize: AaChange = { + changeType: 0x00, + actorId: '0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc', + authenticator: '0x0000000000000000000000000000000000000001', + scope: 0x04, + expiry: 1_900_000_000n, +} +const revoke: AaChange = { + changeType: 0x01, + actorId: '0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266', +} + +describe('actorIdFromAddress', () => { + // 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('0x0000000000000000000000000000000000000000000000000000000000000001') + expect(actorIdFromAddress(account).toLowerCase()).toBe( + `0x000000000000000000000000${account.slice(2).toLowerCase()}`, + ) + }) +}) + +describe('hashAccountChanges (EIP-8130)', () => { + test('deterministic 32-byte digest', () => { + const digest = hashAccountChanges({ + account, + chainId: 0, + sequence: 1, + changes: [authorize, revoke], + }) + expect(digest).toMatch(/^0x[0-9a-f]{64}$/) + expect( + hashAccountChanges({ + account, + chainId: 0, + sequence: 1, + changes: [authorize, revoke], + }), + ).toBe(digest) + }) + + test('sequence and account are bound', () => { + const base = { account, chainId: 0, changes: [authorize] } as const + expect(hashAccountChanges({ ...base, sequence: 1 })).not.toBe( + hashAccountChanges({ ...base, sequence: 2 }), + ) + expect(hashAccountChanges({ ...base, sequence: 1 })).not.toBe( + hashAccountChanges({ + account: '0x0000000000000000000000000000000000000009', + chainId: 0, + sequence: 1, + changes: [authorize], + }), + ) + }) +}) + +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: 1n, + changes: [authorize, revoke], + }) + + expect(entry.type).toBe('config') + expect(entry.channel).toBe('multichain') + expect(entry.sequence).toBe(1n) + expect(sliceHex(entry.signature, 0, 20)).toBe(ecrecoverAuthenticator) + + // 'multichain' binds chainId 0 in the digest. + const digest = hashAccountChanges({ + account, + chainId: 0, + sequence: 1, + changes: [authorize, revoke], + }) + const recovered = await recoverAddress({ + hash: digest, + signature: sliceHex(entry.signature, 20), + }) + expect(recovered.toLowerCase()).toBe(signer.address.toLowerCase()) + }) + + test('defaults account to signer address, local channel binds chainId', async () => { + const entry = await signAccountChanges({ + signer, + channel: 'local', + chainId: 8453, + sequence: 3n, + changes: [revoke], + }) + const digest = hashAccountChanges({ + account: signer.address, + chainId: 8453, + sequence: 3, + changes: [revoke], + }) + const recovered = await recoverAddress({ + hash: digest, + signature: sliceHex(entry.signature, 20), + }) + expect(recovered.toLowerCase()).toBe(signer.address.toLowerCase()) + }) +}) diff --git a/src/eip8130/utils/signActorChanges.ts b/src/eip8130/utils/signActorChanges.ts new file mode 100644 index 0000000000..0dd2c5f8e7 --- /dev/null +++ b/src/eip8130/utils/signActorChanges.ts @@ -0,0 +1,82 @@ +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, + AaChange, + AaChangeChannel, +} from '../types/transaction.js' +import { + type HashAccountChangesErrorType, + hashAccountChanges, +} from './hashActorChanges.js' +import type { Signer } from './signTransaction.js' + +export type SignAccountChangesParameters = { + /** Signer producing the batch `signature` (the authorizing admin actor's key). */ + signer: Signer + /** + * The account whose configuration is changing. Defaults to the signer's + * address (an account authorizing its own changes). + */ + account?: Address | undefined + /** + * 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 + /** The channel sequence word (`uint64`; source from `getConfigSequence`). */ + sequence: bigint + /** The ordered ops in the batch. */ + changes: readonly AaChange[] + /** + * Authenticator address for the `signature` blob. Defaults to + * `signer.authenticator`, then `ECRECOVER_AUTHENTICATOR` (native secp256k1). + */ + authenticator?: Address | undefined +} + +export type SignAccountChangesErrorType = + | HashAccountChangesErrorType + | ConcatHexErrorType + | BaseError + | ErrorType + +/** + * 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 signAccountChanges( + parameters: SignAccountChangesParameters, +): Promise { + const { signer, chainId, sequence, changes } = parameters + const channel = parameters.channel ?? 'local' + const authenticator = + parameters.authenticator ?? signer.authenticator ?? ecrecoverAuthenticator + const account = parameters.account ?? signer.address + + if (!signer.sign) + throw new BaseError('`signer` does not support raw signing.') + + const digest = hashAccountChanges({ + account, + chainId: channel === 'local' ? chainId : 0, + sequence, + changes, + }) + const signature = concatHex([ + authenticator, + await signer.sign({ hash: digest }), + ]) + + return { type: 'config', channel, sequence, changes, signature } +} 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/signTransaction.test.ts b/src/eip8130/utils/signTransaction.test.ts new file mode 100644 index 0000000000..91a52beff8 --- /dev/null +++ b/src/eip8130/utils/signTransaction.test.ts @@ -0,0 +1,242 @@ +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 { + canonicalAuthenticators, + ecrecoverAuthenticator, +} from '../constants.js' +import type { TransactionSerializable8130 } from '../types/transaction.js' +import { + getPayerSignatureHash, + getSenderSignatureHash, +} from './hashTransaction.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) +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 = { + chainId: 8453, + nonceSequence: 1n, + maxFeePerGas: 2n, + gas: 100_000n, + calls: [[{ to: bob, data: '0xdeadbeef' }]], + } + const serialized = await signTransaction({ + transaction, + account: sender, + }) + 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 = getSenderSignatureHash(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 signTransaction({ + transaction, + account: sender, + }) + 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 = getSenderSignatureHash(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 signTransaction({ + transaction, + account: sender, + payer: { account: sponsor }, + }) + 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 = getPayerSignatureHash({ + ...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 signTransaction({ + transaction, + account: sender, + payer: { account: sponsor, address: sponsor.address }, + }) + const parsed = parseTransaction(serialized) + 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 signTransaction({ + transaction, + account: toMockP256Signer(), + authenticator: canonicalAuthenticators.p256, + }) + 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 + 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 signTransaction({ + transaction, + account: toMockP256Signer({ + authenticator: canonicalAuthenticators.p256, + }), + }) + const parsed = parseTransaction(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 signTransaction({ + transaction, + account: toMockP256Signer({ + authenticator: canonicalAuthenticators.passkey, + }), + authenticator: canonicalAuthenticators.p256, + }) + const parsed = parseTransaction(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 signTransaction({ + transaction, + account: sender, + payer: { + account: toMockP256Signer(), + address: bob, + authenticator: canonicalAuthenticators.passkey, + }, + }) + const parsed = parseTransaction(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( + signTransaction({ + transaction: { chainId: 1, maxFeePerGas: 2n }, + }), + ).rejects.toThrowError() + }) + + test('preset senderAuth skips signing', async () => { + const senderAuth = `0x${'11'.repeat(65)}` as const + const serialized = await signTransaction({ + transaction: { chainId: 1, maxFeePerGas: 2n, senderAuth }, + }) + const parsed = parseTransaction(serialized) + expect(parsed.senderAuth).toBe(senderAuth) + }) +}) diff --git a/src/eip8130/utils/signTransaction.ts b/src/eip8130/utils/signTransaction.ts new file mode 100644 index 0000000000..1cbdb0ba0f --- /dev/null +++ b/src/eip8130/utils/signTransaction.ts @@ -0,0 +1,145 @@ +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 GetPayerSignatureHashErrorType, + type GetSenderSignatureHashErrorType, + getPayerSignatureHash, + getSenderSignatureHash, +} from './hashTransaction.js' +import { + type SerializeTransactionErrorType, + serializeTransaction, +} from './serializeTransaction.js' + +/** + * 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 SignTransactionParameters = { + 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 + /** + * 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 +} + +export type SignTransactionErrorType = + | GetSenderSignatureHashErrorType + | GetPayerSignatureHashErrorType + | SerializeTransactionErrorType + | ConcatHexErrorType + | ErrorType + +/** + * Signs an EIP-8130 (`AA_TX_TYPE`) transaction. + * + * 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 signTransaction( + parameters: SignTransactionParameters, +): 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 authenticator = + parameters.authenticator ?? + account.authenticator ?? + ecrecoverAuthenticator + const senderHash = getSenderSignatureHash(transaction) + const signature = await account.sign({ hash: senderHash }) + transaction.senderAuth = transaction.from + ? // Configured actor: AUTHENTICATOR || data + // (ecrecover: r || s || v; P-256/WebAuthn: authenticator-specific blob) + concatHex([authenticator, 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.', + ) + 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 = getPayerSignatureHash({ ...transaction, from }) + const signature = await payer.account.sign({ hash: payerHash }) + transaction.payerAuth = concatHex([authenticator, signature]) + } + + return serializeTransaction(transaction) +} diff --git a/src/eip8130/utils/signedActorChangesSignature.test.ts b/src/eip8130/utils/signedActorChangesSignature.test.ts new file mode 100644 index 0000000000..0760b66bba --- /dev/null +++ b/src/eip8130/utils/signedActorChangesSignature.test.ts @@ -0,0 +1,123 @@ +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 { encodeChangePayload } from './actorChangeData.js' +import { + encodeSignedActorChangesSignature, + signedActorChangesMagic, +} from './signedActorChangesSignature.js' + +const pubKey = { + x: '0x1111111111111111111111111111111111111111111111111111111111111111', + y: '0x2222222222222222222222222222222222222222222222222222222222222222', +} as const + +const decodeParameters = [ + { type: 'bytes32' }, + { + type: 'tuple[]', + components: [ + { + name: 'changes', + type: 'tuple[]', + components: [ + { name: 'changeType', type: 'uint8' }, + { name: 'payload', type: 'bytes' }, + ], + }, + { name: 'auth', type: 'bytes' }, + ], + }, + { name: 'opAuth', type: 'bytes' }, +] 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( + [ + { + changes: [authorizeActor(key.p256(pubKey))], + auth: '0xdeadbeef', + }, + ], + '0x', + ) + expect(slice(signature, 0, 32)).toBe(signedActorChangesMagic) + }) + + test('round-trips a single set through abi.decode', () => { + const change = authorizeActor(key.p256(pubKey), { + scope: actorScope.operator, + }) + const auth = '0xc0ffee' + const opAuth = '0xdeadbeef' + const signature = encodeSignedActorChangesSignature( + [{ changes: [change], auth }], + opAuth, + ) + + const [magic, changeSets, decodedOpAuth] = decodeAbiParameters( + decodeParameters, + 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].payload).toBe(encodeChangePayload(change)) + expect(decodedOpAuth).toBe(opAuth) + }) + + test('encodes multiple sets in order (chained rotations)', () => { + const setA = { + changes: [authorizeActor(key.p256(pubKey))], + auth: '0xaaaa', + } as const + const revoke = revokeActor(key.p256(pubKey)) + const setB = { + changes: [ + revoke, + authorizeActor(key.k1('0x0000000000000000000000000000000000000abc')), + ], + auth: '0xbbbb', + } as const + + 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 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 payload carries the canonical authenticator', () => { + const change = authorizeActor(key.p256(pubKey)) + const signature = encodeSignedActorChangesSignature( + [{ changes: [change], auth: '0x' }], + '0x', + ) + const [, changeSets] = decodeAbiParameters(decodeParameters, signature) + expect(changeSets[0].changes[0].payload.toLowerCase()).toContain( + canonicalAuthenticators.p256.slice(2).toLowerCase(), + ) + }) +}) diff --git a/src/eip8130/utils/signedActorChangesSignature.ts b/src/eip8130/utils/signedActorChangesSignature.ts new file mode 100644 index 0000000000..db18b6c196 --- /dev/null +++ b/src/eip8130/utils/signedActorChangesSignature.ts @@ -0,0 +1,109 @@ +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 { AaChange } from '../types/transaction.js' +import { encodeChangePayload } 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 = { + /** + * Ops applied as one batch (consuming one sequence). Use a {@link key} builder + * + {@link authorizeActor}/{@link revokeActor} to construct. + */ + changes: readonly AaChange[] + /** + * Authorization over the batch digest in `authenticator || data` form, as + * produced by {@link signAccountChanges} (its `signature` field). + */ + auth: Hex +} + +const signatureParameters = [ + { type: 'bytes32' }, + { + type: 'tuple[]', + components: [ + { + name: 'changes', + type: 'tuple[]', + components: [ + { name: 'changeType', type: 'uint8' }, + { name: 'payload', type: 'bytes' }, + ], + }, + { name: 'auth', type: 'bytes' }, + ], + }, + { name: 'opAuth', 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, bytes opAuth) + * ``` + * + * 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 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). + * + * @example + * ```ts + * const set = await signAccountChanges({ + * signer: owner, + * account: smartAccount, + * chainId: baseSepolia.id, + * sequence, + * 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 })]) + * const signature = encodeSignedActorChangesSignature([set], opAuth) + * ``` + */ +export function encodeSignedActorChangesSignature( + changeSets: readonly SignedActorChangeSet[], + opAuth: Hex, +): Hex { + return encodeAbiParameters(signatureParameters, [ + signedActorChangesMagic, + changeSets.map((set) => ({ + changes: set.changes.map((change) => ({ + changeType: change.changeType, + payload: encodeChangePayload(change), + })), + auth: set.auth, + })), + opAuth, + ]) +} diff --git a/src/eip8130/utils/signers.test.ts b/src/eip8130/utils/signers.test.ts new file mode 100644 index 0000000000..b216cbf974 --- /dev/null +++ b/src/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 { parseTransaction } from './parseTransaction.js' +import { toP256Signer, toWebAuthnSigner } from './signers.js' +import { signTransaction } 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 signTransaction({ + transaction, + account: signer, + }) + const parsed = parseTransaction(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/eip8130/utils/signers.ts b/src/eip8130/utils/signers.ts new file mode 100644 index 0000000000..b28f24df10 --- /dev/null +++ b/src/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 `toAccount` / + * `signTransaction` to sign as a non-ECDSA actor. + * + * @example + * import { key, toAccount, toP256Signer } from 'viem/eip8130' + * + * const signer = toP256Signer({ privateKey }) + * const account = toAccount({ + * 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, toAccount, toWebAuthnSigner } from 'viem/eip8130' + * + * const credential = await createWebAuthnCredential({ name: 'vibes' }) + * const signer = toWebAuthnSigner(toWebAuthnAccount({ credential })) + * const account = toAccount({ + * 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, + ]) + }, + } +} 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[]] +} diff --git a/src/eip8168/actions/sendSponsoredCalls.ts b/src/eip8168/actions/sendSponsoredCalls.ts new file mode 100644 index 0000000000..2be8319d7d --- /dev/null +++ b/src/eip8168/actions/sendSponsoredCalls.ts @@ -0,0 +1,334 @@ +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 } from '../../eip8130/actions/sendTransaction.js' +import { nonceFreeMaxExpiryWindow } from '../../eip8130/constants.js' +import { isNoncelessOnly } from '../../eip8130/keys.js' +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, + PayerRejectedData, + PayerSendTransactionReturnType, + PayerSignTransactionReturnType, +} from '../types.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`). */ + account: ToAccountReturnType + /** Payer service client (ERC-8168). */ + 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 + * `"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 + /** 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 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`). + */ + validBefore?: bigint | undefined + /** 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 + /** + * Invoked with the fully-resolved transaction just before each submit attempt + * (before `payer_sendTransaction` / `payer_signTransaction`). Use it to thread + * the resolved `validBefore` — which is auto-computed here from the offer's + * `maxExpiry` (or the nonce-free window) when not overridden — into + * `waitForTransactionReceipt` without re-deriving it: + * + * ```ts + * let validBefore: bigint | undefined + * await sendSponsoredCalls(client, { + * ...params, + * onTransaction: (tx) => { validBefore = tx.validBefore }, + * }) + * ``` + * + * 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 = + | PayerSendTransactionReturnType + | PayerSignTransactionReturnType + +/** + * End-to-end ERC-8168 sponsored-transaction flow: + * + * 1. Fetch terms (`payer_getTerms`) unless provided. + * 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). + * + * @example + * const { transactionHash } = await sendSponsoredCalls(client, { + * account, + * payerClient, + * calls: [{ to, data }], + * }) + */ +export async function sendSponsoredCalls( + client: Client, + parameters: SendSponsoredCallsParameters, +): Promise { + const { + account, + payerClient, + calls, + accountChanges, + mode = 'send', + token, + context, + retries = 2, + confirmRetry, + onTransaction, + } = 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 { 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.', + ) + + // Recommended gas params are shared at the top level of the terms response. + const gasEstimate = terms.gasEstimate + const initialGas = + parameters.gas ?? + (gasEstimate ? hexToBigInt(gasEstimate.gasLimit) : undefined) + if (initialGas === undefined) + throw new BaseError( + 'Unable to determine `gas`: the terms carry no top-level `gasEstimate.gasLimit` and no `gas` override was provided.', + ) + + const maxFeePerGas = + parameters.maxFeePerGas ?? + (gasEstimate ? hexToBigInt(gasEstimate.maxFeePerGas) : undefined) + const maxPriorityFeePerGas = + parameters.maxPriorityFeePerGas ?? + (gasEstimate ? hexToBigInt(gasEstimate.maxPriorityFeePerGas) : undefined) + + const maxGasLimit = option.conditions?.maxGasLimit + ? hexToBigInt(option.conditions.maxGasLimit) + : undefined + + // A nonce-free-only sending actor (admin or no `SCOPE_NONCE`) MUST carry a + // 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 `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(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 prepareTransactionRequest(client, { + account, + calls: built.calls, + accountChanges, + gas: initialGas, + maxFeePerGas, + maxPriorityFeePerGas, + validBefore: computeValidBefore(), + nonceKey: parameters.nonceKey, + nonceSequence: parameters.nonceSequence, + }) + transaction.payer = built.payer + + 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.validBefore = computeValidBefore() + transaction.payerAuth = '0x' + + onTransaction?.(transaction) + 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 + + // 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/eip8168/actions/sendTransaction.test.ts b/src/eip8168/actions/sendTransaction.test.ts new file mode 100644 index 0000000000..b93121d20a --- /dev/null +++ b/src/eip8168/actions/sendTransaction.test.ts @@ -0,0 +1,335 @@ +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, + sendTransactionSync, +} 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: {} }, + // Trust the payer's quote as the gas oracle for this assertion. + gasEstimator: 'payer', + 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' }, + }, + }, + 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', () => { + 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()) + }) +}) + +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 new file mode 100644 index 0000000000..c03c4258db --- /dev/null +++ b/src/eip8168/actions/sendTransaction.ts @@ -0,0 +1,373 @@ +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, + waitForTransactionReceipt, +} from '../../eip8130/actions/waitForTransactionReceipt.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, + PayerSendTransactionReturnType, + 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 + /** + * 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 +} + +/** 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 } : {}), + }) + + // 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`: 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, { + account, + calls: [calls], + accountChanges, + gas, + // 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( + 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 }) +} + +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/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..6d07e092ee --- /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, + PayerSendTransactionParameters, + PayerSendTransactionReturnType, + PayerSignTransactionParameters, + PayerSignTransactionReturnType, + PaymentOption, +} 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: PayerSendTransactionParameters, + ): Promise { + return route(params.signedTransaction).sendTransaction(params) + }, + + async signTransaction( + params: PayerSignTransactionParameters, + ): 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 new file mode 100644 index 0000000000..5d46125210 --- /dev/null +++ b/src/eip8168/client.ts @@ -0,0 +1,169 @@ +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, + GetTermsParameters, + GetTermsReturnType, + PayerSendTransactionParameters, + PayerSendTransactionReturnType, + PayerSignTransactionParameters, + PayerSignTransactionReturnType, +} 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: [PayerSendTransactionParameters] + ReturnType: PayerSendTransactionReturnType + }, + { + Method: 'payer_signTransaction' + Parameters: [PayerSignTransactionParameters] + ReturnType: PayerSignTransactionReturnType + }, + { + 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 + /** + * 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 = { + /** + * 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 (returns the tx + * hash). REQUIRED on every payer. + */ + sendTransaction( + 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: PayerSignTransactionParameters, + ): Promise + /** + * Standing, intent-free balances (sponsorship allowance / prepaid credit). + * OPTIONAL. + */ + getSponsorshipBalance( + parameters: GetSponsorshipBalanceParameters, + ): Promise +} + +/** Minimal JSON-RPC request function shape a {@link PayerClient} wraps. */ +type PayerRequestFn = (args: { + method: string + params: readonly unknown[] +}) => Promise + +/** + * 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). + */ +function payerClientFromRequest(request: PayerRequestFn): PayerClient { + 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 + }, + getSponsorshipBalance(params) { + return request({ + method: 'payer_getSponsorshipBalance', + params: [params], + }) as 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/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/constants.ts b/src/eip8168/constants.ts new file mode 100644 index 0000000000..949423a948 --- /dev/null +++ b/src/eip8168/constants.ts @@ -0,0 +1,66 @@ +/** + * 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). + * + * 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: 'INVALID_TRANSACTION', + /** Token in the phase-0 transfer not accepted by this payer. */ + 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] + | (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/eip8168/decorators/eip8168Actions.test.ts b/src/eip8168/decorators/eip8168Actions.test.ts new file mode 100644 index 0000000000..da5d0c491b --- /dev/null +++ b/src/eip8168/decorators/eip8168Actions.test.ts @@ -0,0 +1,98 @@ +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: {} }, + gasEstimator: 'payer', + 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/eip8168.test.ts b/src/eip8168/eip8168.test.ts new file mode 100644 index 0000000000..3adee24d4f --- /dev/null +++ b/src/eip8168/eip8168.test.ts @@ -0,0 +1,323 @@ +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 { 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' +import { + buildSponsoredCalls, + selectPaymentOption, +} from './utils/buildSponsoredCalls.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 MAX_EXPIRY_REL = 60 // upper bound from conditions (relative seconds) + +const sponsoredTerms: GetTermsReturnType = { + gasEstimate, + 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 = { + gasEstimate, + options: [ + { + 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' }, + }, + ], + }, + ], +} + +// Freeze time so validBefore assertions are deterministic. +const FROZEN_NOW_MS = 1_700_000_000_000 // arbitrary fixed epoch + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(FROZEN_NOW_MS) +}) +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({ + terms: sponsoredTerms, + calls: userCalls, + }) + expect(built.payer).toBe(PAYER) + expect(built.calls).toEqual([userCalls]) + expect(built.paymentAmount).toBeUndefined() + }) + + 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(FEE_RECIPIENT) + expect(decoded.args[1]).toBe(hexToBigInt('0x30D40')) + expect(built.calls[1]).toEqual(userCalls) + }) + + test('token payment defaults destination to offer payer when feeRecipient absent', () => { + const built = buildSponsoredCalls({ + terms: { + options: [ + { + ...tokenTerms.options[0], + tokens: [ + { + ...( + tokenTerms.options[0] as Extract< + GetTermsReturnType['options'][number], + { kind: 'token' } + > + ).tokens[0], + feeRecipient: undefined, + }, + ], + } as (typeof tokenTerms.options)[number], + ], + }, + calls: userCalls, + }) + 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 there is no selectable offer', () => { + expect(() => + buildSponsoredCalls({ terms: { options: [] }, 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_getSponsorshipBalance') + return { balances: [], ttl: 30 } + throw new Error(`unexpected ${method}`) + }, + }), + }) + const terms = await payer.getTerms({ + chainId: '0x1', + from: owner.address, + calls: userCalls, + }) + 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.getSponsorshipBalance({ from: owner.address, kind: ['credit'] }) + expect(seen[1].method).toBe('payer_getSponsorshipBalance') + }) +}) + +describe('sendSponsoredCalls (end-to-end)', () => { + function makeClient() { + return createClient({ + chain: mainnet, + 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}`) + }, + }), + }) + } + + test('full sponsorship: sender-signs, payer relays; expiry = now + maxExpiry', 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 = parseTransaction(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)) + // 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 () => { + 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', + token: USDC, + 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()) + }) + + 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/eip8168/index.ts b/src/eip8168/index.ts new file mode 100644 index 0000000000..fb7ac92e86 --- /dev/null +++ b/src/eip8168/index.ts @@ -0,0 +1,92 @@ +// biome-ignore lint/performance/noBarrelFile: entrypoint +export { + type ResignRequest, + type SendSponsoredCallsParameters, + type SendSponsoredCallsReturnType, + sendSponsoredCalls, +} from './actions/sendSponsoredCalls.js' +export { + type PaymasterServiceCapability, + type PrepareTransactionCapabilities, + type PrepareTransactionRequestParameters, + type PrepareTransactionRequestReturnType, + prepareTransactionRequest, + type SendTransactionCapabilities, + type SendTransactionParameters, + type SendTransactionReturnType, + type SendTransactionSyncParameters, + type SendTransactionSyncReturnType, + sendTransaction, + sendTransactionSync, +} from './actions/sendTransaction.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, + payerErrorCode, + payerRejectedCode, + sponsorshipDeclineCode, +} from './constants.js' +export { + type Eip8168Actions, + type Eip8168ActionsParameters, + eip8168Actions, +} from './decorators/eip8168Actions.js' +export type { + BalanceLimit, + BaseOffer, + GetSponsorshipBalanceParameters, + GetSponsorshipBalanceReturnType, + GetTermsParameters, + GetTermsReturnType, + PayerBalance, + PayerConditions, + PayerGasEstimate, + PayerProvider, + PayerRejectedData, + PayerRequote, + PayerRpcCall, + PayerSendTransactionParameters, + PayerSendTransactionReturnType, + PayerSignTransactionParameters, + PayerSignTransactionReturnType, + PaymentOption, + RefundPolicy, + SponsoredOffer, + SponsoredOfferDeclined, + SponsoredOfferSelectable, + SponsorshipDeclineCode, + TokenCharged, + TokenChoice, + 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/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/eip8168/types.ts b/src/eip8168/types.ts new file mode 100644 index 0000000000..5e375a29c5 --- /dev/null +++ b/src/eip8168/types.ts @@ -0,0 +1,370 @@ +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 = { + to: Address + value?: Hex | undefined + data?: Hex | undefined +} + +/** + * 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 +} + +/** + * 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 +} + +/** + * 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 = { + /** 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 + /** + * 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. + */ + maxCost?: Hex | undefined +} + +/** + * 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' + | (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 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 + /** ...per this many native wei. */ + denominator: Hex + } + /** + * 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`. + */ + fiatRate?: Hex | undefined + refund?: RefundPolicy | 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[] + /** + * 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. */ +export type PaymentOption = SponsoredOffer | TokenPaymentOffer + +export type GetTermsParameters = { + chainId: Hex + from: Address + 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 = { + /** + * 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 + /** + * 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 +} + +/** Params for the `payer_sendTransaction` RPC method (payer co-signs + submits). */ +export type PayerSendTransactionParameters = { + signedTransaction: Hex + context?: Record | undefined +} + +/** Return of the `payer_sendTransaction` RPC method. */ +export type PayerSendTransactionReturnType = { + transactionHash: Hex + tokenCharged?: TokenCharged | undefined +} + +/** Params for the `payer_signTransaction` RPC method (payer co-signs only). */ +export type PayerSignTransactionParameters = { + signedTransaction: Hex + context?: Record | undefined +} + +/** Return of the `payer_signTransaction` RPC method. */ +export type PayerSignTransactionReturnType = { + signedTransaction: Hex + tokenCharged?: TokenCharged | undefined +} + +export type GetSponsorshipBalanceParameters = { + from: Address + /** Optional executionchain filter. */ + 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 GetSponsorshipBalanceReturnType = { + balances: readonly PayerBalance[] + /** How long this snapshot may be cached, in seconds. */ + ttl: number +} + +/** + * 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?: + | { + /** Wei cost the payer estimated for this intent at current gas prices. */ + estimatedCost: Hex + /** Per-tx ceiling in wei. */ + maxCost: Hex + } + | undefined + /** + * 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`). + */ + minGasLimit?: Hex | undefined + /** Present (SHOULD) for `PAYMENT_INSUFFICIENT`: the corrected phase-0 transfer. */ + requote?: PayerRequote | undefined +} diff --git a/src/eip8168/utils/buildSponsoredCalls.ts b/src/eip8168/utils/buildSponsoredCalls.ts new file mode 100644 index 0000000000..270374cd85 --- /dev/null +++ b/src/eip8168/utils/buildSponsoredCalls.ts @@ -0,0 +1,194 @@ +import type { Address } from 'abitype' +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, + SponsoredOfferDeclined, + SponsoredOfferSelectable, + TokenChoice, + TokenPaymentOffer, +} 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], + }), + } +} + +/** 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 = { + /** Terms returned by `payer_getTerms`. */ + terms: GetTermsReturnType + /** The user's intended calls (placed in the final phase). */ + calls: readonly AaCall[] + /** Prefer token payment with this token over sponsorship. */ + token?: Address | undefined +} + +export type BuildSponsoredCallsReturnType = { + /** Payer address (the selected offer's `payer`) to set on the transaction. */ + payer: Address + /** Ordered call phases (phase 0 = token transfer when paying, last = user calls). */ + calls: AaCalls + /** 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 +} + +/** + * Constructs the EIP-8130 `calls` phases and `payer` from `payer_getTerms` + * output, per the ERC-8168 phase table: + * + * | Offer `kind` | Phase 0 | Phase 1 | + * |---|---|---| + * | `sponsored` | — | user calls | + * | `token` | `transfer(choice.feeRecipient ?? offer.payer, choice.paymentAmount)` | user calls | + * + * 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, token } = parameters + + const { option, tokenChoice } = selectPaymentOption(terms, { token }) + + if (option.kind === 'sponsored') + return { payer: option.payer, calls: [calls], option } + + if (!tokenChoice) + throw new BaseError('Selected token offer resolved no `TokenChoice`.') + + const feeRecipient = tokenChoice.feeRecipient ?? option.payer + const paymentAmount = hexToBigInt(tokenChoice.paymentAmount) + const phase0: AaCall[] = [ + encodeTokenTransfer({ + token: tokenChoice.token, + to: feeRecipient, + amount: paymentAmount, + }), + ] + + return { + payer: option.payer, + calls: [phase0, calls], + option, + tokenChoice, + feeRecipient, + paymentAmount, + } +} diff --git a/src/eip8168/utils/parsePayerError.ts b/src/eip8168/utils/parsePayerError.ts new file mode 100644 index 0000000000..327f89c856 --- /dev/null +++ b/src/eip8168/utils/parsePayerError.ts @@ -0,0 +1,62 @@ +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 } diff --git a/src/package.json b/src/package.json index 7cbe11751f..2fb862b620 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", @@ -174,6 +184,12 @@ "experimental": [ "./_types/experimental/index.d.ts" ], + "eip8130": [ + "./_types/eip8130/index.d.ts" + ], + "eip8168": [ + "./_types/eip8168/index.d.ts" + ], "experimental/erc7739": [ "./_types/experimental/erc7739/index.d.ts" ], diff --git a/src/utils/formatters/transaction.ts b/src/utils/formatters/transaction.ts index 931a17ce77..c9f98666b6 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', + '0x79': 'eip8130', } as const satisfies Record export type FormatTransactionErrorType = ErrorType