From 651fd9c2d42a39a86d8222dd5d329f353f9e77af Mon Sep 17 00:00:00 2001 From: nikhil kumar Date: Tue, 27 Oct 2026 19:48:10 +0530 Subject: [PATCH 1/3] refactor(accounts): unify frame account and payer constructors --- README.md | 48 ++++++--- docs/DESIGN.md | 43 ++++---- scripts/send-eth-eoa.ts | 4 +- scripts/send-eth-p256.ts | 12 ++- scripts/send-eth-paymaster.ts | 8 +- scripts/send-frame-transaction.ts | 4 +- src/accounts/defaults.ts | 48 +++++++++ src/accounts/toEoaFrameAccount.ts | 109 -------------------- src/accounts/toEoaFramePayerAccount.ts | 101 ------------------ src/accounts/toFrameAccount.ts | 136 ++++++++++++++++++++++--- src/accounts/toFramePayerAccount.ts | 111 +++++++++++++++++--- src/accounts/toP256FrameAccount.ts | 100 ------------------ src/accounts/types.ts | 4 + src/index.ts | 21 +--- test/accounts.test.ts | 105 ++++++++++++++----- test/p256.test.ts | 11 +- test/prepareFrameTransaction.test.ts | 25 +++-- test/sendFrameTransaction.test.ts | 12 +-- test/signFrameTransaction.test.ts | 12 +-- 19 files changed, 461 insertions(+), 453 deletions(-) create mode 100644 src/accounts/defaults.ts delete mode 100644 src/accounts/toEoaFrameAccount.ts delete mode 100644 src/accounts/toEoaFramePayerAccount.ts delete mode 100644 src/accounts/toP256FrameAccount.ts diff --git a/README.md b/README.md index 686f473..9799660 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,11 @@ const signed = await signFrameTx(tx, remoteAccount) ``` A raw-digest `sign` is required. `signMessage` will not do: it EIP-191-prefixes its -argument, so the signature recovers to nothing. A `JsonRpcAccount` (a browser wallet) -cannot sign a raw digest at all — both are refused with an error rather than producing a -transaction the chain silently rejects. +argument, so the signature recovers to nothing. A plain `JsonRpcAccount` does not expose +raw-digest signing, but `toFrameAccount` and `toFramePayerAccount` accept a `sign({ hash })` +override for an injected, hardware, HSM, or remote account whose backend really can sign +that digest. Do not implement the override with ordinary `personal_sign` or a prefixed +`eth_sign` method; those produce a different signature. The same restriction applies to P-256 signers. WebCrypto's ECDSA API hashes its input internally, so passing the frame sig-hash to it signs `SHA-256(frameSigHash)`, not the @@ -123,14 +125,36 @@ as `r || s || qx || qy`. ```ts import { privateKeyToP256Account, - toP256FrameAccount, + toFrameAccount, } from '@jaw.id/frametx-kit' const owner = privateKeyToP256Account(p256PrivateKey) -const account = await toP256FrameAccount({ +const account = await toFrameAccount({ client, address: delegatedEoa, owner, + delegateAddress: p256Implementation, +}) +``` + +The same constructor handles an ordinary local EOA: + +```ts +const account = await toFrameAccount({ + client, + owner: privateKeyToAccount(privateKey), +}) +``` + +For an injected or remote account, supply its viem account object and a raw-digest +adapter. The adapter is explicit because standard browser-wallet signing methods do not +guarantee the semantics required by a frame signature: + +```ts +const account = await toFrameAccount({ + client, + owner: walletClient.account, + sign: ({ hash }) => rawSigner.signDigest(hash), }) ``` @@ -159,15 +183,15 @@ const receipt = await client.waitForFrameTransactionReceipt({ hash }) Passing every check here does not guarantee admission. The VERIFY prefix must `APPROVE` payment, and the payer must hold the transaction's `maxCost`, or the frame reverts. A -code-less EOA gets that from the protocol's default code, which `toEoaFrameAccount` targets; -anything else needs its own validating contract. Dry-run with +code-less EOA gets that from the protocol's default code, which `toFrameAccount` uses by +default; anything else needs its own validating contract. Dry-run with `client.simulateFrameTransaction({ raw })` before spending. To use a separate codeless EOA payer, resolve it from a raw-digest-capable account and pass it through preparation and signing: ```ts -const payer = await toEoaFramePayerAccount({ +const payer = await toFramePayerAccount({ owner: privateKeyToAccount(payerPrivateKey), }) @@ -183,10 +207,10 @@ const signed = await signFrameTransaction(account, transaction, { payer }) This changes the validation prefix from `self_verify` to `only_verify -> pay`. The sender signs outer signature index `0`; the EOA payer signs the protocol-reserved payment -signature at index `1`. Both entries are installed during preparation, before either -signature is produced, so both signatures commit to the same transaction hash. Custom -contract payers can be defined with `toFramePayerAccount` to provide their PAY calldata, -signature entries, and signing behavior. +signature. Both entries are installed during preparation, before either signature is +produced, so both signatures commit to the same transaction hash. Contract payers use the +same `toFramePayerAccount` constructor and can override their PAY calldata, signature +entries, and signing behavior. ## Nonces are keyed diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6d4193a..ba10a6a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -13,8 +13,8 @@ commit of [`lambdaclass/ethrex`](https://github.com/lambdaclass/ethrex/tree/bdfc A TypeScript client for frame transactions: build, validate, encode, decode, hash, sign, price, simulate, broadcast, and read them back with their frame receipts. It ships a small -account abstraction (`toFrameAccount`, `toEoaFrameAccount`, `toFramePayerAccount`, -`toEoaFramePayerAccount`), `prepareFrameTransaction`, and a viem extension +account abstraction (`toFrameAccount`, `toFramePayerAccount`), +`prepareFrameTransaction`, and a viem extension (`client.extend(frameActions)`). It is not a fork of viem core. Not supported: @@ -141,24 +141,31 @@ from a sender that does not exist yet. These are budgeting figures for choosing A `FrameAccountImplementation` supplies `getAddress()`, `getNonce({ key?, blockTag? })` (the current sequence of one nonce key), `getValidationData({ calls, chainId, nonceKeys, nonceSeq })`, `getSignatureEntries(tx)`, `signFrameTransaction(tx)` and an execution -strategy; `toFrameAccount` resolves it into a `FrameAccount`. - -`toEoaFrameAccount` targets a code-less EOA through the protocol's default code: key `0` -from `eth_getTransactionCount`, other keys from `NONCE_MANAGER` via `eth_getStorageAt`, empty -validation data, and one empty-`msg`, empty-`signer` SECP256K1 entry. - -`toP256FrameAccount` targets an EIP-7702 delegated EOA whose validation code authorizes a -P-256 signer. It emits one empty-`msg` P256 entry with the explicit signer identity -`last20Bytes(keccak256(qx || qy))` at index `0`. Its signer signs the already-computed -frame sig-hash directly, without applying SHA-256 a second time, normalizes `s` low, and -encodes `r || s || qx || qy`. +strategy. `toFrameAccount` is the single constructor for both codeless and delegated +senders. It provides overridable defaults: the sender is `owner.address`, nonce key `0` +comes from `eth_getTransactionCount`, other keys come from `NONCE_MANAGER`, validation +data is empty, and the signature scheme is inferred from the owner. + +A viem owner produces a SECP256K1 entry. A P-256 owner produces a P256 entry with the +explicit identity `last20Bytes(keccak256(qx || qy))`; its signer signs the frame sig-hash +directly, normalizes `s` low, and encodes `r || s || qx || qy`. Passing `address` separately +represents a delegated EOA controlled by a different signer, and `delegateAddress` records +the expected EIP-7702 implementation. Account-specific validation or signature policies +override `getValidationData`, `getSignatureEntries`, or `signFrameTransaction` on the same +constructor. + +A plain JSON-RPC/injected account has no raw-digest signing method. The optional +`sign({ hash })` adapter supports an injected, hardware, HSM, or remote backend only when +that backend actually signs the supplied digest. EIP-191 `signMessage`, `personal_sign`, +and prefixed `eth_sign` behavior are not substitutes. A `FramePayerAccountImplementation` supplies `getAddress()`, `getPayData(tx)`, -`getSignatureEntries(tx)` and `signFrameTransaction(tx)`; `toFramePayerAccount` resolves -it into a `FramePayerAccount`. `toEoaFramePayerAccount` implements the protocol's codeless -EOA payer path: empty PAY calldata and an empty-`msg` SECP256K1 entry at index `1` whose -explicit signer is the payer. The explicit address is required because an empty signer -resolves to `tx.sender`. +`getSignatureEntries(tx)` and `signFrameTransaction(tx)`. `toFramePayerAccount` is the +single payer constructor. Its defaults cover a codeless EOA payer: empty PAY calldata and +an empty-`msg` signature entry whose explicit signer is the payer, since an empty signer +would resolve to `tx.sender`. Delegated or contract payers override the same callbacks. +Signing locates the payer-owned entry by scheme and resolved signer rather than assuming a +fixed signature-array index. `prepareFrameTransaction(account, calls, limits, { nonceKeys?, blockTag?, paymaster? })` builds one SENDER frame per call and reads fees from the node (`2 * baseFee + priorityFee`, diff --git a/scripts/send-eth-eoa.ts b/scripts/send-eth-eoa.ts index 790c5ee..5fdc46c 100644 --- a/scripts/send-eth-eoa.ts +++ b/scripts/send-eth-eoa.ts @@ -33,7 +33,7 @@ import { prepareFrameTransaction, simulateFrameTransaction, signFrameTransaction, - toEoaFrameAccount, + toFrameAccount, type FrameRpcClient, type RpcFrameReceiptJson, } from '../src/index.js' @@ -85,7 +85,7 @@ async function main(): Promise { chain: hegotaTestnet, transport: http(rpcUrl), }) - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) const unsigned = await prepareFrameTransaction( account, diff --git a/scripts/send-eth-p256.ts b/scripts/send-eth-p256.ts index ca8356e..ff7e1a3 100644 --- a/scripts/send-eth-p256.ts +++ b/scripts/send-eth-p256.ts @@ -31,7 +31,7 @@ import { parseRpcFrameReceipt, privateKeyToP256Account, sendFrameTransaction, - toP256FrameAccount, + toFrameAccount, type FrameRpcClient, type RpcFrameReceiptJson, } from '../src/index.js' @@ -110,8 +110,9 @@ async function main(): Promise { transport: http(rpcUrl), }) const code = await client.getCode({ address: sender }) - if (code === undefined || !code.toLowerCase().startsWith('0xef0100')) + if (code === undefined || !/^0xef0100[0-9a-fA-F]{40}$/.test(code)) throw new Error(`sender ${sender} does not have an EIP-7702 delegation`) + const delegateAddress = getAddress(`0x${code.slice(8)}`) const signerCall = await client.call({ to: sender, @@ -129,7 +130,12 @@ async function main(): Promise { `delegated account authorizes ${configuredSigner}, but the PEM identity is ${owner.address}`, ) - const account = await toP256FrameAccount({ client, address: sender, owner }) + const account = await toFrameAccount({ + client, + address: sender, + owner, + delegateAddress, + }) const [senderBalanceBefore, recipientBalanceBefore] = await Promise.all([ client.getBalance({ address: sender }), client.getBalance({ address: recipient }), diff --git a/scripts/send-eth-paymaster.ts b/scripts/send-eth-paymaster.ts index c33438a..bdcd17f 100644 --- a/scripts/send-eth-paymaster.ts +++ b/scripts/send-eth-paymaster.ts @@ -35,8 +35,8 @@ import { prepareFrameTransaction, simulateFrameTransaction, signFrameTransaction, - toEoaFrameAccount, - toEoaFramePayerAccount, + toFrameAccount, + toFramePayerAccount, type FrameRpcClient, type RpcFrameReceiptJson, } from '../src/index.js' @@ -132,8 +132,8 @@ async function main(): Promise { chain: hegotaTestnet, transport: http(rpcUrl), }) - const account = await toEoaFrameAccount({ client, owner }) - const payer = await toEoaFramePayerAccount({ owner: payerOwner }) + const account = await toFrameAccount({ client, owner }) + const payer = await toFramePayerAccount({ owner: payerOwner }) const unsigned = await prepareFrameTransaction( account, diff --git a/scripts/send-frame-transaction.ts b/scripts/send-frame-transaction.ts index 2709e0e..49eeb75 100644 --- a/scripts/send-frame-transaction.ts +++ b/scripts/send-frame-transaction.ts @@ -25,7 +25,7 @@ import { privateKeyToAccount } from 'viem/accounts' import { hegotaTestnet, sendFrameTransaction, - toEoaFrameAccount, + toFrameAccount, } from '../src/index.js' import { frameActions } from '../src/viem.js' @@ -51,7 +51,7 @@ async function main(): Promise { chain: hegotaTestnet, transport: http(rpcUrl), }).extend(frameActions) - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) console.log(`sender: ${account.address}`) console.log(`recipient: ${recipient}`) diff --git a/src/accounts/defaults.ts b/src/accounts/defaults.ts new file mode 100644 index 0000000..0c395dd --- /dev/null +++ b/src/accounts/defaults.ts @@ -0,0 +1,48 @@ +import type { Account, Address, Hex } from 'viem' +import { sameAddress } from '../envelope.js' +import type { FrameSignature, FrameTransaction } from '../types.js' +import type { FrameAccountOwner } from './types.js' + +export type RawFrameSigner = (parameters: { hash: Hex }) => Promise + +export function ownerWithSigner( + owner: FrameAccountOwner, + sign?: RawFrameSigner | undefined, +): FrameAccountOwner { + if (sign === undefined) return owner + if (owner.type === 'p256') return { ...owner, sign } + return { ...owner, sign } as Account +} + +export function ownerSignatureScheme(owner: FrameAccountOwner): 1 | 2 { + return owner.type === 'p256' ? 2 : 1 +} + +export function ownerSignatureEntry( + owner: FrameAccountOwner, + signer: Address | null, +): FrameSignature { + return { + scheme: ownerSignatureScheme(owner), + signer, + msg: '0x', + signature: '0x', + } +} + +export function findOwnerSignatureIndex( + transaction: FrameTransaction, + owner: FrameAccountOwner, + direction: 'first' | 'last', +): number { + const scheme = ownerSignatureScheme(owner) + const matches = (signature: FrameSignature): boolean => + signature.scheme === scheme && + signature.msg === '0x' && + sameAddress(signature.signer ?? transaction.sender, owner.address) + + if (direction === 'first') return transaction.signatures.findIndex(matches) + for (let index = transaction.signatures.length - 1; index >= 0; index--) + if (matches(transaction.signatures[index]!)) return index + return -1 +} diff --git a/src/accounts/toEoaFrameAccount.ts b/src/accounts/toEoaFrameAccount.ts deleted file mode 100644 index 352c0c3..0000000 --- a/src/accounts/toEoaFrameAccount.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - type Account, - type Address, - type Chain, - type Client, - type Hash, - type Hex, - type JsonRpcAccount, - type LocalAccount, - type Transport, - isAddressEqual, -} from 'viem' -import { signFrameSignature } from '../signatures.js' -import { FrameEncodeError } from '../errors.js' -import { getFrameNonceSeq } from '../nonce.js' -import type { FrameTransaction } from '../types.js' -import { toFrameAccount } from './toFrameAccount.js' -import type { FrameAccount, FrameAccountImplementation } from './types.js' - -export type EoaFrameOwner = Account & { - address: Address - sign: (parameters: { hash: Hash }) => Promise -} - -export type EoaFrameAccountImplementation< - chain extends Chain | undefined = Chain | undefined, - owner extends EoaFrameOwner = EoaFrameOwner, - extend extends object = object, -> = FrameAccountImplementation - -export type ToEoaFrameAccountParameters< - chain extends Chain | undefined = Chain | undefined, - owner extends EoaFrameOwner = EoaFrameOwner, - extend extends object = object, -> = { - client: Client< - Transport, - chain, - JsonRpcAccount | LocalAccount | undefined - > - owner: owner - extend?: extend | undefined -} - -export type ToEoaFrameAccountReturnType< - chain extends Chain | undefined = Chain | undefined, - owner extends EoaFrameOwner = EoaFrameOwner, - extend extends object = object, -> = FrameAccount> - -/** Create a direct-execution frame account backed by an ordinary EOA. */ -export async function toEoaFrameAccount< - chain extends Chain | undefined, - owner extends EoaFrameOwner, - extend extends object = object, ->( - parameters: ToEoaFrameAccountParameters, -): Promise> { - const { client, owner } = parameters - - return toFrameAccount({ - client, - owner, - execution: { type: 'direct' }, - ...(parameters.extend === undefined ? {} : { extend: parameters.extend }), - async getAddress() { - return owner.address - }, - async getNonce(nonceParameters = {}) { - return getFrameNonceSeq(client, { address: owner.address, ...nonceParameters }) - }, - async getValidationData() { - return '0x' - }, - async getSignatureEntries() { - return [ - { - scheme: 1, - signer: null, - msg: '0x', - signature: '0x', - }, - ] - }, - async signFrameTransaction(transaction: FrameTransaction) { - if (!isAddressEqual(transaction.sender, owner.address)) - throw new FrameEncodeError( - `transaction sender ${transaction.sender} does not match EOA ${owner.address}`, - ) - - const prepared = - transaction.signatures.length === 0 - ? { - ...transaction, - signatures: [ - { - scheme: 1 as const, - signer: null, - msg: '0x' as const, - signature: '0x' as const, - }, - ], - } - : transaction - - return signFrameSignature(prepared, 0, owner) - }, - }) -} diff --git a/src/accounts/toEoaFramePayerAccount.ts b/src/accounts/toEoaFramePayerAccount.ts deleted file mode 100644 index c54f323..0000000 --- a/src/accounts/toEoaFramePayerAccount.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - type Account, - type Address, - type Hash, - type Hex, - isAddressEqual, -} from 'viem' -import { FrameEncodeError } from '../errors.js' -import { signFrameSignature } from '../signatures.js' -import type { FrameTransaction } from '../types.js' -import { toFramePayerAccount } from './toFramePayerAccount.js' -import type { - FramePayerAccount, - FramePayerAccountImplementation, -} from './types.js' - -export type EoaFramePayerOwner = Account & { - address: Address - sign: (parameters: { hash: Hash }) => Promise -} - -export type EoaFramePayerAccountImplementation< - owner extends EoaFramePayerOwner = EoaFramePayerOwner, - extend extends object = object, -> = FramePayerAccountImplementation - -export type ToEoaFramePayerAccountParameters< - owner extends EoaFramePayerOwner = EoaFramePayerOwner, - extend extends object = object, -> = { - owner: owner - extend?: extend | undefined -} - -export type ToEoaFramePayerAccountReturnType< - owner extends EoaFramePayerOwner = EoaFramePayerOwner, - extend extends object = object, -> = FramePayerAccount> - -/** Create a frame payer backed by the protocol's codeless EOA default code. */ -export function toEoaFramePayerAccount< - owner extends EoaFramePayerOwner, - extend extends object = object, ->( - parameters: ToEoaFramePayerAccountParameters, -): Promise> { - const { owner } = parameters - - function assertPayFrame(transaction: FrameTransaction): void { - const hasPayFrame = transaction.frames.some( - (frame) => - frame.mode === 1 && - frame.flags === 0x1 && - frame.target !== null && - isAddressEqual(frame.target, owner.address), - ) - if (!hasPayFrame) - throw new FrameEncodeError( - `transaction has no payment-only VERIFY frame targeting EOA payer ${owner.address}`, - ) - } - - return toFramePayerAccount({ - owner, - ...(parameters.extend === undefined ? {} : { extend: parameters.extend }), - async getAddress() { - return owner.address - }, - async getPayData() { - return '0x' - }, - async getSignatureEntries(transaction) { - assertPayFrame(transaction) - if (transaction.signatures.length !== 1) - throw new FrameEncodeError( - `codeless EOA payer signature must be inserted at index 1; found ${transaction.signatures.length} existing entries`, - ) - - return [ - { - scheme: 1, - signer: owner.address, - msg: '0x', - signature: '0x', - }, - ] - }, - async signFrameTransaction(transaction: FrameTransaction) { - assertPayFrame(transaction) - const signature = transaction.signatures[1] - if (signature === undefined) - throw new FrameEncodeError('codeless EOA payer requires signature index 1') - if (signature.signer === null || !isAddressEqual(signature.signer, owner.address)) - throw new FrameEncodeError( - `signature 1 signer must match EOA payer ${owner.address}`, - ) - - return signFrameSignature(transaction, 1, owner) - }, - }) -} diff --git a/src/accounts/toFrameAccount.ts b/src/accounts/toFrameAccount.ts index db8dc58..ace2546 100644 --- a/src/accounts/toFrameAccount.ts +++ b/src/accounts/toFrameAccount.ts @@ -1,31 +1,141 @@ +import { + type Address, + type Chain, + type Client, + type JsonRpcAccount, + type LocalAccount, + type Transport, + isAddressEqual, +} from 'viem' +import { FrameEncodeError } from '../errors.js' +import { getFrameNonceSeq } from '../nonce.js' +import { signFrameSignature } from '../signatures.js' +import type { FrameTransaction } from '../types.js' +import { + findOwnerSignatureIndex, + ownerSignatureEntry, + ownerSignatureScheme, + ownerWithSigner, + type RawFrameSigner, +} from './defaults.js' import type { + DirectFrameExecution, FrameAccount, FrameAccountImplementation, + FrameAccountOwner, + GetFrameNonceParameters, + GetValidationDataParameters, } from './types.js' export type ToFrameAccountParameters< - implementation extends - FrameAccountImplementation = FrameAccountImplementation, -> = implementation + chain extends Chain | undefined = Chain | undefined, + owner extends FrameAccountOwner = FrameAccountOwner, + extend extends object = object, +> = { + client: Client + owner: owner + /** Sender address. Defaults to `owner.address`. */ + address?: Address | undefined + /** Expected EIP-7702 delegate. Omit for a non-delegated account. */ + delegateAddress?: Address | undefined + /** Raw-digest signer override, primarily for injected or remote accounts. */ + sign?: RawFrameSigner | undefined + execution?: DirectFrameExecution | undefined + extend?: extend | undefined + getAddress?: (() => Promise
) | undefined + getNonce?: ((parameters?: GetFrameNonceParameters) => Promise) | undefined + getValidationData?: + | ((parameters: GetValidationDataParameters) => Promise<`0x${string}`>) + | undefined + getSignatureEntries?: + | FrameAccountImplementation['getSignatureEntries'] + | undefined + signFrameTransaction?: + | ((transaction: FrameTransaction) => Promise) + | undefined +} export type ToFrameAccountReturnType< - implementation extends - FrameAccountImplementation = FrameAccountImplementation, -> = FrameAccount + chain extends Chain | undefined = Chain | undefined, + owner extends FrameAccountOwner = FrameAccountOwner, + extend extends object = object, +> = FrameAccount> -/** Resolve an account implementation into the object consumed by frame actions. */ +/** Create a frame account with overridable defaults for common account policies. */ export async function toFrameAccount< - const implementation extends FrameAccountImplementation, + chain extends Chain | undefined, + owner extends FrameAccountOwner, + extend extends object = object, >( - implementation: ToFrameAccountParameters, -): Promise> { - const { extend, ...rest } = implementation - const address = await implementation.getAddress() + parameters: ToFrameAccountParameters, +): Promise> { + const { client, owner } = parameters + const getAddress = + parameters.getAddress ?? (async () => parameters.address ?? owner.address) + const signingOwner = ownerWithSigner(owner, parameters.sign) + + const getNonce = + parameters.getNonce ?? + (async (nonceParameters: GetFrameNonceParameters = {}) => + getFrameNonceSeq(client, { + address: await getAddress(), + ...nonceParameters, + })) + + const getValidationData = + parameters.getValidationData ?? (async () => '0x' as const) + + const getSignatureEntries = + parameters.getSignatureEntries ?? + (async () => { + const sender = await getAddress() + const useImplicitSigner = + parameters.delegateAddress === undefined && + ownerSignatureScheme(owner) === 1 && + isAddressEqual(sender, owner.address) + return [ownerSignatureEntry(owner, useImplicitSigner ? null : owner.address)] + }) + + const signFrameTransaction = + parameters.signFrameTransaction ?? + (async (transaction: FrameTransaction) => { + const sender = await getAddress() + if (!isAddressEqual(transaction.sender, sender)) + throw new FrameEncodeError( + `transaction sender ${transaction.sender} does not match frame account ${sender}`, + ) + + let prepared = transaction + let signatureIndex = findOwnerSignatureIndex(prepared, owner, 'first') + if (signatureIndex === -1) { + const entries = await getSignatureEntries(transaction) + prepared = { + ...transaction, + signatures: [...entries, ...transaction.signatures], + } + signatureIndex = findOwnerSignatureIndex(prepared, owner, 'first') + } + if (signatureIndex === -1) + throw new FrameEncodeError( + `frame account ${sender} has no signable entry for owner ${owner.address}`, + ) + + return signFrameSignature(prepared, signatureIndex, signingOwner) + }) + const { extend, sign: _sign, address: _address, ...rest } = parameters + const address = await getAddress() return { ...extend, ...rest, + owner, + execution: parameters.execution ?? { type: 'direct' }, + getAddress, + getNonce, + getValidationData, + getSignatureEntries, + signFrameTransaction, address, type: 'frame', - } as ToFrameAccountReturnType + } as ToFrameAccountReturnType } diff --git a/src/accounts/toFramePayerAccount.ts b/src/accounts/toFramePayerAccount.ts index e9938df..9ff0a40 100644 --- a/src/accounts/toFramePayerAccount.ts +++ b/src/accounts/toFramePayerAccount.ts @@ -1,31 +1,116 @@ +import { type Address, isAddressEqual } from 'viem' +import { FrameEncodeError } from '../errors.js' +import { signFrameSignature } from '../signatures.js' +import type { FrameTransaction } from '../types.js' +import { + findOwnerSignatureIndex, + ownerSignatureEntry, + ownerWithSigner, + type RawFrameSigner, +} from './defaults.js' import type { + FrameAccountOwner, FramePayerAccount, FramePayerAccountImplementation, } from './types.js' export type ToFramePayerAccountParameters< - implementation extends - FramePayerAccountImplementation = FramePayerAccountImplementation, -> = implementation + owner extends FrameAccountOwner = FrameAccountOwner, + extend extends object = object, +> = { + owner: owner + /** Payer address. Defaults to `owner.address`. */ + address?: Address | undefined + /** Expected EIP-7702 delegate. Omit for a non-delegated payer. */ + delegateAddress?: Address | undefined + /** Raw-digest signer override, primarily for injected or remote accounts. */ + sign?: RawFrameSigner | undefined + extend?: extend | undefined + getAddress?: (() => Promise
) | undefined + getPayData?: FramePayerAccountImplementation['getPayData'] | undefined + getSignatureEntries?: + | FramePayerAccountImplementation['getSignatureEntries'] + | undefined + signFrameTransaction?: + | ((transaction: FrameTransaction) => Promise) + | undefined +} export type ToFramePayerAccountReturnType< - implementation extends - FramePayerAccountImplementation = FramePayerAccountImplementation, -> = FramePayerAccount + owner extends FrameAccountOwner = FrameAccountOwner, + extend extends object = object, +> = FramePayerAccount> -/** Resolve a payer implementation into the object consumed by frame actions. */ +/** Create a payer account with overridable defaults for common payment policies. */ export async function toFramePayerAccount< - const implementation extends FramePayerAccountImplementation, + owner extends FrameAccountOwner, + extend extends object = object, >( - implementation: ToFramePayerAccountParameters, -): Promise> { - const { extend, ...rest } = implementation - const address = await implementation.getAddress() + parameters: ToFramePayerAccountParameters, +): Promise> { + const { owner } = parameters + const getAddress = + parameters.getAddress ?? (async () => parameters.address ?? owner.address) + const signingOwner = ownerWithSigner(owner, parameters.sign) + + async function assertPayFrame(transaction: FrameTransaction): Promise { + const payer = await getAddress() + const hasPayFrame = transaction.frames.some( + (frame) => + frame.mode === 1 && + frame.flags === 0x1 && + frame.target !== null && + isAddressEqual(frame.target, payer), + ) + if (!hasPayFrame) + throw new FrameEncodeError( + `transaction has no payment-only VERIFY frame targeting payer ${payer}`, + ) + } + + const getPayData = parameters.getPayData ?? (async () => '0x' as const) + + const getSignatureEntries = + parameters.getSignatureEntries ?? + (async (transaction: FrameTransaction) => { + await assertPayFrame(transaction) + return [ownerSignatureEntry(owner, owner.address)] + }) + + const signFrameTransaction = + parameters.signFrameTransaction ?? + (async (transaction: FrameTransaction) => { + await assertPayFrame(transaction) + + let prepared = transaction + let signatureIndex = findOwnerSignatureIndex(prepared, owner, 'last') + if (signatureIndex === -1) { + const entries = await getSignatureEntries(transaction) + prepared = { + ...transaction, + signatures: [...transaction.signatures, ...entries], + } + signatureIndex = findOwnerSignatureIndex(prepared, owner, 'last') + } + if (signatureIndex === -1) + throw new FrameEncodeError( + `frame payer ${await getAddress()} has no signable entry for owner ${owner.address}`, + ) + + return signFrameSignature(prepared, signatureIndex, signingOwner) + }) + const { extend, sign: _sign, address: _address, ...rest } = parameters + const address = await getAddress() return { ...extend, ...rest, + owner, + getAddress, + getPayData, + getSignatureEntries, + signFrameTransaction, address, type: 'framePayer', - } as ToFramePayerAccountReturnType + } as ToFramePayerAccountReturnType } diff --git a/src/accounts/toP256FrameAccount.ts b/src/accounts/toP256FrameAccount.ts deleted file mode 100644 index 785ddb4..0000000 --- a/src/accounts/toP256FrameAccount.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - type Address, - type Chain, - type Client, - type JsonRpcAccount, - type LocalAccount, - type Transport, - isAddressEqual, -} from 'viem' -import { FrameEncodeError } from '../errors.js' -import { getFrameNonceSeq } from '../nonce.js' -import type { P256FrameSigner } from '../p256.js' -import { signFrameSignature } from '../signatures.js' -import type { FrameTransaction } from '../types.js' -import { toFrameAccount } from './toFrameAccount.js' -import type { FrameAccount, FrameAccountImplementation } from './types.js' - -export type P256FrameAccountImplementation< - chain extends Chain | undefined = Chain | undefined, - owner extends P256FrameSigner = P256FrameSigner, - extend extends object = object, -> = FrameAccountImplementation - -export type ToP256FrameAccountParameters< - chain extends Chain | undefined = Chain | undefined, - owner extends P256FrameSigner = P256FrameSigner, - extend extends object = object, -> = { - client: Client - /** EIP-7702 delegated EOA represented by this frame account. */ - address: Address - owner: owner - extend?: extend | undefined -} - -export type ToP256FrameAccountReturnType< - chain extends Chain | undefined = Chain | undefined, - owner extends P256FrameSigner = P256FrameSigner, - extend extends object = object, -> = FrameAccount> - -/** Create a direct-execution frame account authorized by a P-256 signer. */ -export async function toP256FrameAccount< - chain extends Chain | undefined, - owner extends P256FrameSigner, - extend extends object = object, ->( - parameters: ToP256FrameAccountParameters, -): Promise> { - const { address, client, owner } = parameters - - return toFrameAccount({ - client, - owner, - execution: { type: 'direct' }, - ...(parameters.extend === undefined ? {} : { extend: parameters.extend }), - async getAddress() { - return address - }, - async getNonce(nonceParameters = {}) { - return getFrameNonceSeq(client, { address, ...nonceParameters }) - }, - async getValidationData() { - return '0x' - }, - async getSignatureEntries() { - return [ - { - scheme: 2, - signer: owner.address, - msg: '0x', - signature: '0x', - }, - ] - }, - async signFrameTransaction(transaction: FrameTransaction) { - if (!isAddressEqual(transaction.sender, address)) - throw new FrameEncodeError( - `transaction sender ${transaction.sender} does not match P-256 frame account ${address}`, - ) - - const prepared = - transaction.signatures.length === 0 - ? { - ...transaction, - signatures: [ - { - scheme: 2 as const, - signer: owner.address, - msg: '0x' as const, - signature: '0x' as const, - }, - ], - } - : transaction - - return signFrameSignature(prepared, 0, owner) - }, - }) -} diff --git a/src/accounts/types.ts b/src/accounts/types.ts index 87aa75f..6e3ca76 100644 --- a/src/accounts/types.ts +++ b/src/accounts/types.ts @@ -48,6 +48,8 @@ export type FramePayerAccountImplementation< > = { /** Account or signer that controls the payer. */ owner: owner + /** Expected EIP-7702 delegate, when the payer is a delegated EOA. */ + delegateAddress?: Address | undefined /** Add implementation-specific properties to the resolved payer account. */ extend?: extend | undefined /** Resolve the address that calls `APPROVE(0x1)` and pays for the transaction. */ @@ -87,6 +89,8 @@ export type FrameAccountImplementation< > /** Account or signer that controls the frame account. */ owner: owner + /** Expected EIP-7702 delegate, when the sender is a delegated EOA. */ + delegateAddress?: Address | undefined /** How calls are represented by execution frames. */ execution: DirectFrameExecution /** Add implementation-specific properties to the resolved account. */ diff --git a/src/index.ts b/src/index.ts index 4cf2825..345e86a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,31 +18,12 @@ export { type ToFramePayerAccountReturnType, toFramePayerAccount, } from './accounts/toFramePayerAccount.js' -export { - type EoaFramePayerAccountImplementation, - type EoaFramePayerOwner, - type ToEoaFramePayerAccountParameters, - type ToEoaFramePayerAccountReturnType, - toEoaFramePayerAccount, -} from './accounts/toEoaFramePayerAccount.js' +export { type RawFrameSigner } from './accounts/defaults.js' export { type ToFrameAccountParameters, type ToFrameAccountReturnType, toFrameAccount, } from './accounts/toFrameAccount.js' -export { - type EoaFrameAccountImplementation, - type EoaFrameOwner, - type ToEoaFrameAccountParameters, - type ToEoaFrameAccountReturnType, - toEoaFrameAccount, -} from './accounts/toEoaFrameAccount.js' -export { - type P256FrameAccountImplementation, - type ToP256FrameAccountParameters, - type ToP256FrameAccountReturnType, - toP256FrameAccount, -} from './accounts/toP256FrameAccount.js' export { type GetFrameNonceSeqParameters, NONCE_MANAGER, diff --git a/test/accounts.test.ts b/test/accounts.test.ts index fdc2a88..39a04bc 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -3,8 +3,6 @@ import { createClient, custom, getAddress, size } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { mainnet } from 'viem/chains' import { toFrameAccount } from '../src/accounts/toFrameAccount.js' -import { toEoaFrameAccount } from '../src/accounts/toEoaFrameAccount.js' -import { toEoaFramePayerAccount } from '../src/accounts/toEoaFramePayerAccount.js' import { toFramePayerAccount } from '../src/accounts/toFramePayerAccount.js' import { privateKeyToP256Account } from '../src/p256.js' import { recoverFrameSigner, signFrameSignature } from '../src/signatures.js' @@ -68,11 +66,11 @@ describe('toFrameAccount', () => { }) }) -describe('toEoaFramePayerAccount', () => { +describe('toFramePayerAccount defaults', () => { test('uses default-code payment approval and signs index 1 as the payer', async () => { const sender = privateKeyToAccount(OWNER_KEY) const payerOwner = privateKeyToAccount(`0x${'22'.repeat(32)}`) - const payer = await toEoaFramePayerAccount({ owner: payerOwner }) + const payer = await toFramePayerAccount({ owner: payerOwner }) const transaction = { ...GOLDEN_TX, sender: sender.address, @@ -105,29 +103,31 @@ describe('toEoaFramePayerAccount', () => { expect(await recoverFrameSigner(signed, 1)).toBe(payerOwner.address) }) - test('requires its signature metadata at protocol-reserved index 1', async () => { - const payer = await toEoaFramePayerAccount({ + test('does not hardcode the payer signature index', async () => { + const payer = await toFramePayerAccount({ owner: privateKeyToAccount(`0x${'22'.repeat(32)}`), }) + const transaction = { + ...GOLDEN_TX, + frames: [ + { + ...GOLDEN_TX.frames[0]!, + flags: 0x1, + target: payer.address, + }, + ...GOLDEN_TX.frames.slice(1), + ], + signatures: [], + } - await expect( - payer.getSignatureEntries({ - ...GOLDEN_TX, - frames: [ - { - ...GOLDEN_TX.frames[0]!, - flags: 0x1, - target: payer.address, - }, - ...GOLDEN_TX.frames.slice(1), - ], - signatures: [], - }), - ).rejects.toThrow(/inserted at index 1/) + const signed = await payer.signFrameTransaction(transaction) + + expect(signed.signatures).toHaveLength(1) + expect(await recoverFrameSigner(signed, 0)).toBe(payer.owner.address) }) test('refuses to sign without a pay frame targeting the payer', async () => { - const payer = await toEoaFramePayerAccount({ + const payer = await toFramePayerAccount({ owner: privateKeyToAccount(`0x${'22'.repeat(32)}`), }) @@ -135,6 +135,31 @@ describe('toEoaFramePayerAccount', () => { payer.signFrameTransaction({ ...GOLDEN_TX, signatures: [] }), ).rejects.toThrow(/no payment-only VERIFY frame/) }) + + test('signs through a raw-digest override for an injected payer account', async () => { + const signer = privateKeyToAccount(`0x${'22'.repeat(32)}`) + const owner = { address: signer.address, type: 'json-rpc' as const } + const payer = await toFramePayerAccount({ + owner, + sign: ({ hash }) => signer.sign({ hash }), + }) + const transaction = { + ...GOLDEN_TX, + frames: [ + { + ...GOLDEN_TX.frames[0]!, + flags: 0x1, + target: payer.address, + }, + ...GOLDEN_TX.frames.slice(1), + ], + signatures: [GOLDEN_TX.signatures[0]!], + } + + const signed = await payer.signFrameTransaction(transaction) + + expect(await recoverFrameSigner(signed, 1)).toBe(owner.address) + }) }) describe('toFramePayerAccount', () => { @@ -184,7 +209,7 @@ describe('toFramePayerAccount', () => { }) }) -describe('toEoaFrameAccount', () => { +describe('toFrameAccount defaults', () => { test('uses default-code validation and signs as the EOA', async () => { const owner = privateKeyToAccount(OWNER_KEY) const client = createClient({ @@ -192,7 +217,7 @@ describe('toEoaFrameAccount', () => { chain: mainnet, transport: custom({ async request() {} }), }) - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) const unsigned = { ...GOLDEN_TX, sender: owner.address, signatures: [] } expect( @@ -203,6 +228,30 @@ describe('toEoaFrameAccount', () => { expect(await recoverFrameSigner(signed, 0)).toBe(owner.address) }) + test('signs through a raw-digest override for an injected account', async () => { + const signer = privateKeyToAccount(OWNER_KEY) + const owner = { address: signer.address, type: 'json-rpc' as const } + const client = createClient({ + account: owner, + chain: mainnet, + transport: custom({ async request() {} }), + }) + const account = await toFrameAccount({ + client, + owner, + sign: ({ hash }) => signer.sign({ hash }), + }) + const transaction = { + ...GOLDEN_TX, + sender: owner.address, + signatures: [], + } + + const signed = await account.signFrameTransaction(transaction) + + expect(await recoverFrameSigner(signed, 0)).toBe(owner.address) + }) + test('reads key 0 from the pending account nonce', async () => { const owner = privateKeyToAccount(OWNER_KEY) const requests: { method: string; params?: unknown }[] = [] @@ -216,7 +265,7 @@ describe('toEoaFrameAccount', () => { }, }), }) - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) expect(await account.getNonce()).toBe(7n) expect(requests).toEqual([ @@ -237,7 +286,7 @@ describe('toEoaFrameAccount', () => { }, }), }) - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) expect(await account.getNonce({ key: 5n, blockTag: 'latest' })).toBe(3n) // Slot computed independently: `cast keccak` over @@ -261,7 +310,9 @@ describe('toEoaFrameAccount', () => { chain: mainnet, transport: custom({ async request() {} }), }) - const account = await toEoaFrameAccount({ client, owner }) - await expect(account.signFrameTransaction(GOLDEN_TX)).rejects.toThrow(/does not match EOA/) + const account = await toFrameAccount({ client, owner }) + await expect(account.signFrameTransaction(GOLDEN_TX)).rejects.toThrow( + /does not match frame account/, + ) }) }) diff --git a/test/p256.test.ts b/test/p256.test.ts index 76c44a7..618b094 100644 --- a/test/p256.test.ts +++ b/test/p256.test.ts @@ -9,7 +9,7 @@ import { } from 'viem' import { mainnet } from 'viem/chains' import * as P256 from 'ox/P256' -import { toP256FrameAccount } from '../src/accounts/toP256FrameAccount.js' +import { toFrameAccount } from '../src/accounts/toFrameAccount.js' import { FrameEncodeError } from '../src/errors.js' import { type P256FrameSigner, @@ -26,6 +26,7 @@ import { GOLDEN_TX } from './fixtures/golden.js' const PRIVATE_KEY = numberToHex(1n, { size: 32 }) const DELEGATED_EOA = getAddress('0x0000000000000000000000000000000000001234') +const DELEGATE = getAddress('0x0000000000000000000000000000000000005678') describe('P-256 signer identity', () => { test('private scalar 1 derives the published P-256 generator coordinates', () => { @@ -148,17 +149,18 @@ describe('signFrameSignature with a P-256 owner', () => { }) }) -describe('toP256FrameAccount', () => { +describe('toFrameAccount with a P-256 owner', () => { test('prepares and signs scheme-2 entry zero for a delegated EOA', async () => { const signer = privateKeyToP256Account(PRIVATE_KEY) const client = createClient({ chain: mainnet, transport: custom({ async request() {} }), }) - const account = await toP256FrameAccount({ + const account = await toFrameAccount({ client, address: DELEGATED_EOA, owner: signer, + delegateAddress: DELEGATE, }) const entries = await account.getSignatureEntries(GOLDEN_TX) const unsigned = { @@ -189,10 +191,11 @@ describe('toP256FrameAccount', () => { chain: mainnet, transport: custom({ async request() {} }), }) - const account = await toP256FrameAccount({ + const account = await toFrameAccount({ client, address: DELEGATED_EOA, owner: signer, + delegateAddress: DELEGATE, }) await expect(account.signFrameTransaction(GOLDEN_TX)).rejects.toThrow( diff --git a/test/prepareFrameTransaction.test.ts b/test/prepareFrameTransaction.test.ts index 1238d81..04f9792 100644 --- a/test/prepareFrameTransaction.test.ts +++ b/test/prepareFrameTransaction.test.ts @@ -2,8 +2,7 @@ import { describe, expect, test } from 'vitest' import { createClient, custom, getAddress } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { mainnet } from 'viem/chains' -import { toEoaFrameAccount } from '../src/accounts/toEoaFrameAccount.js' -import { toEoaFramePayerAccount } from '../src/accounts/toEoaFramePayerAccount.js' +import { toFrameAccount } from '../src/accounts/toFrameAccount.js' import { toFramePayerAccount } from '../src/accounts/toFramePayerAccount.js' import { keyedNonceSlot } from '../src/nonce.js' import { prepareFrameTransaction } from '../src/prepareFrameTransaction.js' @@ -56,7 +55,7 @@ function testClient(storage: Record = {}) { describe('prepareFrameTransaction', () => { test('builds a VERIFY frame and one SENDER frame per call', async () => { const { client, owner, requests } = testClient() - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) const first = getAddress('0x0000000000000000000000000000000000001234') const second = getAddress('0x0000000000000000000000000000000000005678') @@ -122,7 +121,7 @@ describe('prepareFrameTransaction', () => { test('builds only_verify and pay frames before sponsored calls', async () => { const { client, owner } = testClient() - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) const paymaster = getAddress('0x0000000000000000000000000000000000009999') const payer = await contractPayer(owner, paymaster, '0xabcdef') const recipient = getAddress('0x0000000000000000000000000000000000001234') @@ -172,9 +171,9 @@ describe('prepareFrameTransaction', () => { test('prepares and signs with a distinct codeless EOA payer', async () => { const { client, owner } = testClient() - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) const payerOwner = privateKeyToAccount(`0x${'22'.repeat(32)}`) - const payer = await toEoaFramePayerAccount({ owner: payerOwner }) + const payer = await toFramePayerAccount({ owner: payerOwner }) const transaction = await prepareFrameTransaction( account, @@ -204,7 +203,7 @@ describe('prepareFrameTransaction', () => { test('rejects negative paymaster limits before making RPC requests', async () => { const { client, owner, requests } = testClient() - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) const payer = await contractPayer(owner, owner.address) await expect( @@ -225,7 +224,7 @@ describe('prepareFrameTransaction', () => { test('accounts for the pay frame in the transaction frame limit', async () => { const { client, owner, requests } = testClient() - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) const payer = await contractPayer(owner, owner.address) const calls = Array.from({ length: 63 }, () => ({ to: owner.address, @@ -256,7 +255,7 @@ describe('prepareFrameTransaction', () => { const owner = privateKeyToAccount(OWNER_KEY) const [one, nine] = [1n, 9n].map((key) => keyedNonceSlot(owner.address, key)) const { client } = testClient({ [one!]: 4n, [nine!]: 4n }) - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) const transaction = await prepareFrameTransaction( account, @@ -271,7 +270,7 @@ describe('prepareFrameTransaction', () => { test('refuses keys that sit at different sequences', async () => { const owner = privateKeyToAccount(OWNER_KEY) const { client } = testClient({ [keyedNonceSlot(owner.address, 2n)]: 1n }) - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) await expect( prepareFrameTransaction( @@ -292,7 +291,7 @@ describe('prepareFrameTransaction', () => { ['negative', [-1n], /nonceKeys\[0\]/], ])('refuses nonceKeys (%s) before making RPC requests', async (_, nonceKeys, message) => { const { client, owner, requests } = testClient() - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) await expect( prepareFrameTransaction( @@ -307,7 +306,7 @@ describe('prepareFrameTransaction', () => { test('requires one call-limit entry per call', async () => { const { client, owner, requests } = testClient() - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) await expect( prepareFrameTransaction( @@ -321,7 +320,7 @@ describe('prepareFrameTransaction', () => { test('rejects negative frame limits before making RPC requests', async () => { const { client, owner, requests } = testClient() - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) await expect( prepareFrameTransaction( diff --git a/test/sendFrameTransaction.test.ts b/test/sendFrameTransaction.test.ts index cea7d19..233e1e9 100644 --- a/test/sendFrameTransaction.test.ts +++ b/test/sendFrameTransaction.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from 'vitest' import { createClient, custom, getAddress, keccak256, type Hex } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { mainnet } from 'viem/chains' -import { toEoaFrameAccount } from '../src/accounts/toEoaFrameAccount.js' -import { toEoaFramePayerAccount } from '../src/accounts/toEoaFramePayerAccount.js' +import { toFrameAccount } from '../src/accounts/toFrameAccount.js' +import { toFramePayerAccount } from '../src/accounts/toFramePayerAccount.js' import { decodeFrameTx } from '../src/envelope.js' import { sendFrameTransaction } from '../src/sendFrameTransaction.js' @@ -40,7 +40,7 @@ function testClient() { describe('sendFrameTransaction', () => { test('defaults optional call fields and returns the node hash', async () => { const { client, owner, getSubmittedRaw } = testClient() - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) const recipient = getAddress('0x0000000000000000000000000000000000001234') const limits = { @@ -66,8 +66,8 @@ describe('sendFrameTransaction', () => { test('applies overrides and signs with a distinct EOA payer', async () => { const { client, owner, getSubmittedRaw } = testClient() - const account = await toEoaFrameAccount({ client, owner }) - const payer = await toEoaFramePayerAccount({ + const account = await toFrameAccount({ client, owner }) + const payer = await toFramePayerAccount({ owner: privateKeyToAccount(PAYER_KEY), }) @@ -97,7 +97,7 @@ describe('sendFrameTransaction', () => { test('requires one per-call override for every call', async () => { const { client, owner, requests } = testClient() - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) await expect( sendFrameTransaction( diff --git a/test/signFrameTransaction.test.ts b/test/signFrameTransaction.test.ts index fd8b334..d4c04e3 100644 --- a/test/signFrameTransaction.test.ts +++ b/test/signFrameTransaction.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test, vi } from 'vitest' import { createClient, custom, getAddress } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { mainnet } from 'viem/chains' -import { toEoaFrameAccount } from '../src/accounts/toEoaFrameAccount.js' -import { toEoaFramePayerAccount } from '../src/accounts/toEoaFramePayerAccount.js' +import { toFrameAccount } from '../src/accounts/toFrameAccount.js' +import { toFramePayerAccount } from '../src/accounts/toFramePayerAccount.js' import { signFrameTransaction } from '../src/signFrameTransaction.js' import { recoverFrameSigner } from '../src/signatures.js' import { GOLDEN_TX } from './fixtures/golden.js' @@ -18,7 +18,7 @@ describe('signFrameTransaction', () => { chain: mainnet, transport: custom({ async request() {} }), }) - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) const unsigned = { ...GOLDEN_TX, sender: owner.address, signatures: [] } const spy = vi.spyOn(account, 'signFrameTransaction') @@ -36,7 +36,7 @@ describe('signFrameTransaction', () => { chain: mainnet, transport: custom({ async request() {} }), }) - const account = await toEoaFrameAccount({ client, owner }) + const account = await toFrameAccount({ client, owner }) const spy = vi.spyOn(account, 'signFrameTransaction') await expect( @@ -56,8 +56,8 @@ describe('signFrameTransaction', () => { chain: mainnet, transport: custom({ async request() {} }), }) - const account = await toEoaFrameAccount({ client, owner }) - const payer = await toEoaFramePayerAccount({ owner: payerOwner }) + const account = await toFrameAccount({ client, owner }) + const payer = await toFramePayerAccount({ owner: payerOwner }) const base = { ...GOLDEN_TX, sender: owner.address, From 2b200a05f030add2bb6865ae8d063ba96f9a311d Mon Sep 17 00:00:00 2001 From: nikhil kumar Date: Tue, 27 Oct 2026 20:28:42 +0530 Subject: [PATCH 2/3] refactor(accounts)!: harden unified account and payer policies BREAKING CHANGE: Specialized EOA, P-256, and EOA payer constructors are replaced by toFrameAccount and toFramePayerAccount. --- README.md | 10 +- docs/DESIGN.md | 15 +- src/accounts/defaults.ts | 31 +++- src/accounts/toFrameAccount.ts | 116 ++++++++++-- src/accounts/toFramePayerAccount.ts | 97 ++++++++-- src/accounts/types.ts | 2 - test/accounts.test.ts | 277 +++++++++++++++++++++++++++- test/p256.test.ts | 42 ++++- 8 files changed, 539 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 9799660..d3af938 100644 --- a/README.md +++ b/README.md @@ -206,11 +206,11 @@ const signed = await signFrameTransaction(account, transaction, { payer }) ``` This changes the validation prefix from `self_verify` to `only_verify -> pay`. The -sender signs outer signature index `0`; the EOA payer signs the protocol-reserved payment -signature. Both entries are installed during preparation, before either signature is -produced, so both signatures commit to the same transaction hash. Contract payers use the -same `toFramePayerAccount` constructor and can override their PAY calldata, signature -entries, and signing behavior. +sender and EOA payer sign their respective prepared entries; the payer entry carries its +address explicitly and is not selected by a fixed array index. Both entries are installed +during preparation, before either signature is produced, so both signatures commit to the +same transaction hash. Contract payers use the same `toFramePayerAccount` constructor and +can override their PAY calldata, signature entries, and signing behavior. ## Nonces are keyed diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ba10a6a..11e2fa3 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -149,10 +149,12 @@ data is empty, and the signature scheme is inferred from the owner. A viem owner produces a SECP256K1 entry. A P-256 owner produces a P256 entry with the explicit identity `last20Bytes(keccak256(qx || qy))`; its signer signs the frame sig-hash directly, normalizes `s` low, and encodes `r || s || qx || qy`. Passing `address` separately -represents a delegated EOA controlled by a different signer, and `delegateAddress` records -the expected EIP-7702 implementation. Account-specific validation or signature policies +represents a delegated EOA controlled by a different signer. When `delegateAddress` is +provided, construction verifies that `eth_getCode(address)` is exactly +`0xef0100 || delegateAddress`. Account-specific validation or signature policies override `getValidationData`, `getSignatureEntries`, or `signFrameTransaction` on the same -constructor. +constructor. A fully custom account may omit `owner` when it supplies an address and all +four account-policy callbacks directly. A plain JSON-RPC/injected account has no raw-digest signing method. The optional `sign({ hash })` adapter supports an injected, hardware, HSM, or remote backend only when @@ -163,9 +165,10 @@ A `FramePayerAccountImplementation` supplies `getAddress()`, `getPayData(tx)`, `getSignatureEntries(tx)` and `signFrameTransaction(tx)`. `toFramePayerAccount` is the single payer constructor. Its defaults cover a codeless EOA payer: empty PAY calldata and an empty-`msg` signature entry whose explicit signer is the payer, since an empty signer -would resolve to `tx.sender`. Delegated or contract payers override the same callbacks. -Signing locates the payer-owned entry by scheme and resolved signer rather than assuming a -fixed signature-array index. +would resolve to `tx.sender`. A contract payer with no signing owner supplies `address` +(or `getAddress`) and all three policy callbacks directly. Delegated or contract payers +override the same callbacks. Signing locates the payer-owned entry by scheme and explicit +signer rather than assuming a fixed signature-array index. `prepareFrameTransaction(account, calls, limits, { nonceKeys?, blockTag?, paymaster? })` builds one SENDER frame per call and reads fees from the node (`2 * baseFee + priorityFee`, diff --git a/src/accounts/defaults.ts b/src/accounts/defaults.ts index 0c395dd..793ffb3 100644 --- a/src/accounts/defaults.ts +++ b/src/accounts/defaults.ts @@ -1,5 +1,12 @@ -import type { Account, Address, Hex } from 'viem' +import { + type Account, + type Address, + type Hex, + isAddressEqual, + recoverAddress, +} from 'viem' import { sameAddress } from '../envelope.js' +import { FrameEncodeError } from '../errors.js' import type { FrameSignature, FrameTransaction } from '../types.js' import type { FrameAccountOwner } from './types.js' @@ -11,7 +18,25 @@ export function ownerWithSigner( ): FrameAccountOwner { if (sign === undefined) return owner if (owner.type === 'p256') return { ...owner, sign } - return { ...owner, sign } as Account + return { + ...owner, + async sign({ hash }: { hash: Hex }) { + const signature = await sign({ hash }) + let recovered: Address + try { + recovered = await recoverAddress({ hash, signature }) + } catch { + throw new FrameEncodeError( + `raw signer for ${owner.address} returned an invalid secp256k1 signature`, + ) + } + if (!isAddressEqual(recovered, owner.address)) + throw new FrameEncodeError( + `raw signer recovered ${recovered}, expected owner ${owner.address}`, + ) + return signature + }, + } as Account } export function ownerSignatureScheme(owner: FrameAccountOwner): 1 | 2 { @@ -34,9 +59,11 @@ export function findOwnerSignatureIndex( transaction: FrameTransaction, owner: FrameAccountOwner, direction: 'first' | 'last', + options: { explicitSigner?: boolean } = {}, ): number { const scheme = ownerSignatureScheme(owner) const matches = (signature: FrameSignature): boolean => + (!options.explicitSigner || signature.signer !== null) && signature.scheme === scheme && signature.msg === '0x' && sameAddress(signature.signer ?? transaction.sender, owner.address) diff --git a/src/accounts/toFrameAccount.ts b/src/accounts/toFrameAccount.ts index ace2546..46f8f71 100644 --- a/src/accounts/toFrameAccount.ts +++ b/src/accounts/toFrameAccount.ts @@ -5,6 +5,7 @@ import { type JsonRpcAccount, type LocalAccount, type Transport, + type Hex, isAddressEqual, } from 'viem' import { FrameEncodeError } from '../errors.js' @@ -27,13 +28,13 @@ import type { GetValidationDataParameters, } from './types.js' -export type ToFrameAccountParameters< +type FrameAccountBaseParameters< chain extends Chain | undefined = Chain | undefined, - owner extends FrameAccountOwner = FrameAccountOwner, + owner extends FrameAccountOwner | undefined = FrameAccountOwner | undefined, extend extends object = object, > = { client: Client - owner: owner + owner?: owner | undefined /** Sender address. Defaults to `owner.address`. */ address?: Address | undefined /** Expected EIP-7702 delegate. Omit for a non-delegated account. */ @@ -55,30 +56,112 @@ export type ToFrameAccountParameters< | undefined } +type DefaultFrameAccountParameters< + chain extends Chain | undefined, + owner extends FrameAccountOwner, + extend extends object, +> = FrameAccountBaseParameters & { owner: owner } + +type CustomFrameAccountParameters< + chain extends Chain | undefined, + extend extends object, +> = FrameAccountBaseParameters & { + owner?: undefined + sign?: never + getNonce: NonNullable< + FrameAccountImplementation['getNonce'] + > + getValidationData: NonNullable< + FrameAccountImplementation['getValidationData'] + > + getSignatureEntries: NonNullable< + FrameAccountImplementation['getSignatureEntries'] + > + signFrameTransaction: NonNullable< + FrameAccountImplementation['signFrameTransaction'] + > +} & ( + | { address: Address; getAddress?: undefined } + | { address?: Address | undefined; getAddress: () => Promise
} + ) + +export type ToFrameAccountParameters< + chain extends Chain | undefined = Chain | undefined, + owner extends FrameAccountOwner | undefined = FrameAccountOwner, + extend extends object = object, +> = owner extends FrameAccountOwner + ? DefaultFrameAccountParameters + : CustomFrameAccountParameters + export type ToFrameAccountReturnType< chain extends Chain | undefined = Chain | undefined, - owner extends FrameAccountOwner = FrameAccountOwner, + owner extends FrameAccountOwner | undefined = FrameAccountOwner, extend extends object = object, > = FrameAccount> /** Create a frame account with overridable defaults for common account policies. */ export async function toFrameAccount< chain extends Chain | undefined, - owner extends FrameAccountOwner, + owner extends FrameAccountOwner | undefined, extend extends object = object, >( parameters: ToFrameAccountParameters, ): Promise> { const { client, owner } = parameters - const getAddress = - parameters.getAddress ?? (async () => parameters.address ?? owner.address) + const unresolvedAddress = parameters.address ?? owner?.address + if (parameters.getAddress === undefined && unresolvedAddress === undefined) + throw new FrameEncodeError( + 'ownerless frame account requires address or getAddress', + ) + const address = await (parameters.getAddress?.() ?? + Promise.resolve(unresolvedAddress!)) + const getAddress = async () => address + + if (parameters.delegateAddress !== undefined) { + const code = (await client.request({ + method: 'eth_getCode', + params: [address, 'latest'], + })) as Hex + const expected = `0xef0100${parameters.delegateAddress.slice(2)}`.toLowerCase() + if (code.toLowerCase() !== expected) + throw new FrameEncodeError( + `frame account ${address} delegates with ${code}, expected ${expected}`, + ) + } + + if (owner === undefined) { + if ( + parameters.getNonce === undefined || + parameters.getValidationData === undefined || + parameters.getSignatureEntries === undefined || + parameters.signFrameTransaction === undefined + ) + throw new FrameEncodeError( + 'ownerless frame account requires getNonce, getValidationData, getSignatureEntries, and signFrameTransaction', + ) + + const { extend, address: _address, ...rest } = parameters + return { + ...extend, + ...rest, + owner, + execution: parameters.execution ?? { type: 'direct' }, + getAddress, + getNonce: parameters.getNonce, + getValidationData: parameters.getValidationData, + getSignatureEntries: parameters.getSignatureEntries, + signFrameTransaction: parameters.signFrameTransaction, + address, + type: 'frame', + } as unknown as ToFrameAccountReturnType + } const signingOwner = ownerWithSigner(owner, parameters.sign) const getNonce = parameters.getNonce ?? (async (nonceParameters: GetFrameNonceParameters = {}) => getFrameNonceSeq(client, { - address: await getAddress(), + address, ...nonceParameters, })) @@ -88,26 +171,28 @@ export async function toFrameAccount< const getSignatureEntries = parameters.getSignatureEntries ?? (async () => { - const sender = await getAddress() const useImplicitSigner = parameters.delegateAddress === undefined && ownerSignatureScheme(owner) === 1 && - isAddressEqual(sender, owner.address) + isAddressEqual(address, owner.address) return [ownerSignatureEntry(owner, useImplicitSigner ? null : owner.address)] }) const signFrameTransaction = parameters.signFrameTransaction ?? (async (transaction: FrameTransaction) => { - const sender = await getAddress() - if (!isAddressEqual(transaction.sender, sender)) + if (!isAddressEqual(transaction.sender, address)) throw new FrameEncodeError( - `transaction sender ${transaction.sender} does not match frame account ${sender}`, + `transaction sender ${transaction.sender} does not match frame account ${address}`, ) let prepared = transaction let signatureIndex = findOwnerSignatureIndex(prepared, owner, 'first') if (signatureIndex === -1) { + if (transaction.signatures.some((entry) => entry.signature !== '0x')) + throw new FrameEncodeError( + `frame account ${address} has no entry and other entries are already signed; install sender entries during preparation`, + ) const entries = await getSignatureEntries(transaction) prepared = { ...transaction, @@ -117,14 +202,13 @@ export async function toFrameAccount< } if (signatureIndex === -1) throw new FrameEncodeError( - `frame account ${sender} has no signable entry for owner ${owner.address}`, + `frame account ${address} has no signable entry for owner ${owner.address}`, ) return signFrameSignature(prepared, signatureIndex, signingOwner) }) const { extend, sign: _sign, address: _address, ...rest } = parameters - const address = await getAddress() return { ...extend, ...rest, @@ -137,5 +221,5 @@ export async function toFrameAccount< signFrameTransaction, address, type: 'frame', - } as ToFrameAccountReturnType + } as unknown as ToFrameAccountReturnType } diff --git a/src/accounts/toFramePayerAccount.ts b/src/accounts/toFramePayerAccount.ts index 9ff0a40..377c47d 100644 --- a/src/accounts/toFramePayerAccount.ts +++ b/src/accounts/toFramePayerAccount.ts @@ -14,15 +14,13 @@ import type { FramePayerAccountImplementation, } from './types.js' -export type ToFramePayerAccountParameters< - owner extends FrameAccountOwner = FrameAccountOwner, +type FramePayerAccountBaseParameters< + owner extends FrameAccountOwner | undefined, extend extends object = object, > = { - owner: owner + owner?: owner | undefined /** Payer address. Defaults to `owner.address`. */ address?: Address | undefined - /** Expected EIP-7702 delegate. Omit for a non-delegated payer. */ - delegateAddress?: Address | undefined /** Raw-digest signer override, primarily for injected or remote accounts. */ sign?: RawFrameSigner | undefined extend?: extend | undefined @@ -36,35 +34,93 @@ export type ToFramePayerAccountParameters< | undefined } +type DefaultFramePayerAccountParameters< + owner extends FrameAccountOwner, + extend extends object, +> = FramePayerAccountBaseParameters & { owner: owner } + +type CustomFramePayerAccountParameters = + FramePayerAccountBaseParameters & { + owner?: undefined + sign?: never + getPayData: NonNullable['getPayData']> + getSignatureEntries: NonNullable< + FramePayerAccountImplementation['getSignatureEntries'] + > + signFrameTransaction: NonNullable< + FramePayerAccountImplementation['signFrameTransaction'] + > + } & ( + | { address: Address; getAddress?: undefined } + | { address?: Address | undefined; getAddress: () => Promise
} + ) + +export type ToFramePayerAccountParameters< + owner extends FrameAccountOwner | undefined = FrameAccountOwner, + extend extends object = object, +> = owner extends FrameAccountOwner + ? DefaultFramePayerAccountParameters + : CustomFramePayerAccountParameters + export type ToFramePayerAccountReturnType< - owner extends FrameAccountOwner = FrameAccountOwner, + owner extends FrameAccountOwner | undefined = FrameAccountOwner, extend extends object = object, > = FramePayerAccount> /** Create a payer account with overridable defaults for common payment policies. */ export async function toFramePayerAccount< - owner extends FrameAccountOwner, + owner extends FrameAccountOwner | undefined, extend extends object = object, >( parameters: ToFramePayerAccountParameters, ): Promise> { const { owner } = parameters - const getAddress = - parameters.getAddress ?? (async () => parameters.address ?? owner.address) + const unresolvedAddress = parameters.address ?? owner?.address + if (parameters.getAddress === undefined && unresolvedAddress === undefined) + throw new FrameEncodeError( + 'ownerless frame payer requires address or getAddress', + ) + const address = await (parameters.getAddress?.() ?? + Promise.resolve(unresolvedAddress!)) + const getAddress = async () => address + + if (owner === undefined) { + if ( + parameters.getPayData === undefined || + parameters.getSignatureEntries === undefined || + parameters.signFrameTransaction === undefined + ) + throw new FrameEncodeError( + 'ownerless frame payer requires getPayData, getSignatureEntries, and signFrameTransaction', + ) + + const { extend, address: _address, ...rest } = parameters + return { + ...extend, + ...rest, + owner, + getAddress, + getPayData: parameters.getPayData, + getSignatureEntries: parameters.getSignatureEntries, + signFrameTransaction: parameters.signFrameTransaction, + address, + type: 'framePayer', + } as unknown as ToFramePayerAccountReturnType + } + const signingOwner = ownerWithSigner(owner, parameters.sign) async function assertPayFrame(transaction: FrameTransaction): Promise { - const payer = await getAddress() const hasPayFrame = transaction.frames.some( (frame) => frame.mode === 1 && frame.flags === 0x1 && frame.target !== null && - isAddressEqual(frame.target, payer), + isAddressEqual(frame.target, address), ) if (!hasPayFrame) throw new FrameEncodeError( - `transaction has no payment-only VERIFY frame targeting payer ${payer}`, + `transaction has no payment-only VERIFY frame targeting payer ${address}`, ) } @@ -83,25 +139,32 @@ export async function toFramePayerAccount< await assertPayFrame(transaction) let prepared = transaction - let signatureIndex = findOwnerSignatureIndex(prepared, owner, 'last') + let signatureIndex = findOwnerSignatureIndex(prepared, owner, 'last', { + explicitSigner: true, + }) if (signatureIndex === -1) { + if (transaction.signatures.some((entry) => entry.signature !== '0x')) + throw new FrameEncodeError( + `frame payer ${address} has no entry and other entries are already signed; install payer entries during preparation`, + ) const entries = await getSignatureEntries(transaction) prepared = { ...transaction, signatures: [...transaction.signatures, ...entries], } - signatureIndex = findOwnerSignatureIndex(prepared, owner, 'last') + signatureIndex = findOwnerSignatureIndex(prepared, owner, 'last', { + explicitSigner: true, + }) } if (signatureIndex === -1) throw new FrameEncodeError( - `frame payer ${await getAddress()} has no signable entry for owner ${owner.address}`, + `frame payer ${address} has no signable entry for owner ${owner.address}`, ) return signFrameSignature(prepared, signatureIndex, signingOwner) }) const { extend, sign: _sign, address: _address, ...rest } = parameters - const address = await getAddress() return { ...extend, ...rest, @@ -112,5 +175,5 @@ export async function toFramePayerAccount< signFrameTransaction, address, type: 'framePayer', - } as ToFramePayerAccountReturnType + } as unknown as ToFramePayerAccountReturnType } diff --git a/src/accounts/types.ts b/src/accounts/types.ts index 6e3ca76..19b0286 100644 --- a/src/accounts/types.ts +++ b/src/accounts/types.ts @@ -48,8 +48,6 @@ export type FramePayerAccountImplementation< > = { /** Account or signer that controls the payer. */ owner: owner - /** Expected EIP-7702 delegate, when the payer is a delegated EOA. */ - delegateAddress?: Address | undefined /** Add implementation-specific properties to the resolved payer account. */ extend?: extend | undefined /** Resolve the address that calls `APPROVE(0x1)` and pays for the transaction. */ diff --git a/test/accounts.test.ts b/test/accounts.test.ts index 39a04bc..d51a560 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -10,6 +10,8 @@ import type { FrameTransaction } from '../src/types.js' import { GOLDEN_TX } from './fixtures/golden.js' const OWNER_KEY = `0x${'11'.repeat(32)}` as const +const OTHER = getAddress('0x0000000000000000000000000000000000009999') +const DELEGATE = getAddress('0x0000000000000000000000000000000000005678') describe('toFrameAccount', () => { test('resolves methods and extensions', async () => { @@ -64,6 +66,51 @@ describe('toFrameAccount', () => { expect(account.address).toBe(owner.address) expect(account.type).toBe('frame') }) + + test('resolves a custom address once and reuses it in every default', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + let resolutions = 0 + const client = createClient({ + chain: mainnet, + transport: custom({ async request() { return '0x0' } }), + }) + const account = await toFrameAccount({ + client, + owner, + getAddress: async () => { + resolutions++ + return owner.address + }, + }) + + await account.getNonce() + await account.getSignatureEntries(GOLDEN_TX) + await account.signFrameTransaction({ + ...GOLDEN_TX, + sender: owner.address, + signatures: [], + }) + expect(resolutions).toBe(1) + }) + + test('supports an ownerless account when every policy callback is supplied', async () => { + const client = createClient({ + chain: mainnet, + transport: custom({ async request() {} }), + }) + const account = await toFrameAccount({ + client, + address: OTHER, + getNonce: async () => 3n, + getValidationData: async () => '0xabcd', + getSignatureEntries: async () => [], + signFrameTransaction: async (transaction) => transaction, + }) + + expect(account.owner).toBeUndefined() + expect(account.address).toBe(OTHER) + expect(await account.getNonce()).toBe(3n) + }) }) describe('toFramePayerAccount defaults', () => { @@ -126,6 +173,58 @@ describe('toFramePayerAccount defaults', () => { expect(await recoverFrameSigner(signed, 0)).toBe(payer.owner.address) }) + test('does not append its entry after another entry has been signed', async () => { + const sender = privateKeyToAccount(OWNER_KEY) + const payer = await toFramePayerAccount({ + owner: privateKeyToAccount(`0x${'22'.repeat(32)}`), + }) + const unsigned = { + ...GOLDEN_TX, + sender: sender.address, + frames: [ + { ...GOLDEN_TX.frames[0]!, flags: 0x1, target: payer.address }, + ...GOLDEN_TX.frames.slice(1), + ], + signatures: [ + { scheme: 1 as const, signer: null, msg: '0x' as const, signature: '0x' as const }, + ], + } + const senderSigned = await signFrameSignature(unsigned, 0, sender) + + await expect(payer.signFrameTransaction(senderSigned)).rejects.toThrow( + /other entries are already signed/, + ) + expect(await recoverFrameSigner(senderSigned, 0)).toBe(sender.address) + }) + + test('requires an explicit payer entry when sender and payer are the same address', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const payer = await toFramePayerAccount({ owner }) + const transaction = { + ...GOLDEN_TX, + sender: owner.address, + frames: [ + { ...GOLDEN_TX.frames[0]!, flags: 0x1, target: owner.address }, + ...GOLDEN_TX.frames.slice(1), + ], + signatures: [ + { scheme: 1 as const, signer: null, msg: '0x' as const, signature: '0x' as const }, + ], + } + + const signed = await payer.signFrameTransaction(transaction) + + expect(signed.signatures[0]!.signature).toBe('0x') + expect(signed.signatures[1]).toMatchObject({ signer: owner.address }) + expect(await recoverFrameSigner(signed, 1)).toBe(owner.address) + + const senderSigned = await signFrameSignature(transaction, 0, owner) + await expect(payer.signFrameTransaction(senderSigned)).rejects.toThrow( + /other entries are already signed/, + ) + expect(await recoverFrameSigner(senderSigned, 0)).toBe(owner.address) + }) + test('refuses to sign without a pay frame targeting the payer', async () => { const payer = await toFramePayerAccount({ owner: privateKeyToAccount(`0x${'22'.repeat(32)}`), @@ -153,7 +252,10 @@ describe('toFramePayerAccount defaults', () => { }, ...GOLDEN_TX.frames.slice(1), ], - signatures: [GOLDEN_TX.signatures[0]!], + signatures: [ + { ...GOLDEN_TX.signatures[0]!, signature: '0x' as const }, + { scheme: 1 as const, signer: owner.address, msg: '0x' as const, signature: '0x' as const }, + ], } const signed = await payer.signFrameTransaction(transaction) @@ -207,6 +309,68 @@ describe('toFramePayerAccount', () => { expect(payer.owner).toBe(owner) expect(size(signed.signatures[1]!.signature)).toBe(128) }) + + test('supports an ownerless payer when every policy callback is supplied', async () => { + const payer = await toFramePayerAccount({ + address: OTHER, + getPayData: async () => '0xabcd', + getSignatureEntries: async () => [], + signFrameTransaction: async (transaction) => transaction, + }) + + expect(payer.owner).toBeUndefined() + expect(payer.address).toBe(OTHER) + expect(await payer.getPayData(GOLDEN_TX)).toBe('0xabcd') + }) + + test('uses the resolved payer address for pay-frame checks', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const payer = await toFramePayerAccount({ owner, address: OTHER }) + const targeted = { + ...GOLDEN_TX, + frames: [ + { ...GOLDEN_TX.frames[0]!, flags: 0x1, target: OTHER }, + ...GOLDEN_TX.frames.slice(1), + ], + } + expect(await payer.getSignatureEntries(targeted)).toHaveLength(1) + + await expect( + payer.getSignatureEntries({ + ...targeted, + frames: [ + { ...targeted.frames[0]!, target: owner.address }, + ...targeted.frames.slice(1), + ], + }), + ).rejects.toThrow(/no payment-only VERIFY frame/) + }) + + test('resolves a custom payer address once and reuses it in every default', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + let resolutions = 0 + const payer = await toFramePayerAccount({ + owner, + getAddress: async () => { + resolutions++ + return OTHER + }, + }) + const transaction = { + ...GOLDEN_TX, + frames: [ + { ...GOLDEN_TX.frames[0]!, flags: 0x1, target: OTHER }, + ...GOLDEN_TX.frames.slice(1), + ], + signatures: [ + { scheme: 1 as const, signer: owner.address, msg: '0x' as const, signature: '0x' as const }, + ], + } + + await payer.getSignatureEntries(transaction) + await payer.signFrameTransaction(transaction) + expect(resolutions).toBe(1) + }) }) describe('toFrameAccount defaults', () => { @@ -252,6 +416,117 @@ describe('toFrameAccount defaults', () => { expect(await recoverFrameSigner(signed, 0)).toBe(owner.address) }) + test('rejects a raw signer that signs with a different secp256k1 key', async () => { + const owner = { address: privateKeyToAccount(OWNER_KEY).address, type: 'json-rpc' as const } + const wrongSigner = privateKeyToAccount(`0x${'22'.repeat(32)}`) + const client = createClient({ + account: owner, + chain: mainnet, + transport: custom({ async request() {} }), + }) + const account = await toFrameAccount({ + client, + owner, + sign: ({ hash }) => wrongSigner.sign({ hash }), + }) + + await expect( + account.signFrameTransaction({ + ...GOLDEN_TX, + sender: owner.address, + signatures: [], + }), + ).rejects.toThrow(/expected owner/) + }) + + test('uses explicit signer metadata for delegated and distinct-address owners', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const delegationCode = `0xef0100${DELEGATE.slice(2)}` + const delegatedClient = createClient({ + chain: mainnet, + transport: custom({ + async request({ method }) { + if (method === 'eth_getCode') return delegationCode + throw new Error(`unexpected ${method}`) + }, + }), + }) + const delegated = await toFrameAccount({ + client: delegatedClient, + owner, + address: owner.address, + delegateAddress: DELEGATE, + }) + const distinct = await toFrameAccount({ + client: createClient({ chain: mainnet, transport: custom({ async request() {} }) }), + owner, + address: OTHER, + }) + + expect((await delegated.getSignatureEntries(GOLDEN_TX))[0]!.signer).toBe(owner.address) + expect((await distinct.getSignatureEntries(GOLDEN_TX))[0]!.signer).toBe(owner.address) + }) + + test('verifies the expected EIP-7702 delegate against account code', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const client = createClient({ + chain: mainnet, + transport: custom({ async request() { return '0x' } }), + }) + + await expect( + toFrameAccount({ client, owner, delegateAddress: DELEGATE }), + ).rejects.toThrow(/expected 0xef0100/) + }) + + test('reads the nonce from the resolved delegated address', async () => { + const owner = privateKeyToP256Account(`0x${'01'.padStart(64, '0')}`) + const requests: { method: string; params?: unknown }[] = [] + const client = createClient({ + chain: mainnet, + transport: custom({ + async request(request) { + requests.push(request) + return '0x5' + }, + }), + }) + const account = await toFrameAccount({ client, owner, address: OTHER }) + + expect(await account.getNonce()).toBe(5n) + expect(requests).toEqual([ + { method: 'eth_getTransactionCount', params: [OTHER, 'pending'] }, + ]) + }) + + test('does not prepend its entry after another entry has been signed', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const payerOwner = privateKeyToAccount(`0x${'22'.repeat(32)}`) + const client = createClient({ + chain: mainnet, + transport: custom({ async request() {} }), + }) + const account = await toFrameAccount({ client, owner }) + const payerOnly = { + ...GOLDEN_TX, + sender: owner.address, + signatures: [ + { + scheme: 1 as const, + signer: payerOwner.address, + msg: '0x' as const, + signature: '0x' as const, + }, + ], + } + const payerSigned = await signFrameSignature(payerOnly, 0, payerOwner) + + await expect(account.signFrameTransaction(payerSigned)).rejects.toThrow( + /other entries are already signed/, + ) + expect(await recoverFrameSigner(payerSigned, 0)).toBe(payerOwner.address) + }) + test('reads key 0 from the pending account nonce', async () => { const owner = privateKeyToAccount(OWNER_KEY) const requests: { method: string; params?: unknown }[] = [] diff --git a/test/p256.test.ts b/test/p256.test.ts index 618b094..d682834 100644 --- a/test/p256.test.ts +++ b/test/p256.test.ts @@ -5,6 +5,7 @@ import { custom, getAddress, numberToHex, + size, sliceHex, } from 'viem' import { mainnet } from 'viem/chains' @@ -154,7 +155,12 @@ describe('toFrameAccount with a P-256 owner', () => { const signer = privateKeyToP256Account(PRIVATE_KEY) const client = createClient({ chain: mainnet, - transport: custom({ async request() {} }), + transport: custom({ + async request({ method }) { + if (method === 'eth_getCode') return `0xef0100${DELEGATE.slice(2)}` + throw new Error(`unexpected ${method}`) + }, + }), }) const account = await toFrameAccount({ client, @@ -189,7 +195,12 @@ describe('toFrameAccount with a P-256 owner', () => { const signer = privateKeyToP256Account(PRIVATE_KEY) const client = createClient({ chain: mainnet, - transport: custom({ async request() {} }), + transport: custom({ + async request({ method }) { + if (method === 'eth_getCode') return `0xef0100${DELEGATE.slice(2)}` + throw new Error(`unexpected ${method}`) + }, + }), }) const account = await toFrameAccount({ client, @@ -202,4 +213,31 @@ describe('toFrameAccount with a P-256 owner', () => { FrameEncodeError, ) }) + + test('uses a raw-signing override for a P-256 owner', async () => { + const signer = privateKeyToP256Account(PRIVATE_KEY) + let called = false + const client = createClient({ + chain: mainnet, + transport: custom({ async request() {} }), + }) + const account = await toFrameAccount({ + client, + owner: signer, + sign: async ({ hash }) => { + called = true + return signer.sign({ hash }) + }, + }) + const signed = await account.signFrameTransaction({ + ...GOLDEN_TX, + sender: signer.address, + signatures: [ + { scheme: 2, signer: signer.address, msg: '0x', signature: '0x' }, + ], + }) + + expect(called).toBe(true) + expect(size(signed.signatures[0]!.signature)).toBe(128) + }) }) From 7fe7a87fa676ce75cb7284f4f0c0dadb9e5e707e Mon Sep 17 00:00:00 2001 From: nikhil kumar Date: Tue, 27 Oct 2026 22:58:47 +0530 Subject: [PATCH 3/3] fix(accounts): harden default account and payer policies Reject ambiguous addresses and unsafe self-paying payer defaults, strengthen delegation validation, and add regression coverage for P-256 and shared-owner signing --- README.md | 1 + docs/DESIGN.md | 18 ++-- scripts/send-eth-p256.ts | 7 +- src/accounts/toFrameAccount.ts | 6 +- src/accounts/toFramePayerAccount.ts | 8 ++ test/accounts.test.ts | 157 +++++++++++++++++++++++----- test/p256.test.ts | 29 ++++- test/signFrameTransaction.test.ts | 55 ++++++++++ 8 files changed, 240 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index d3af938..d852e0d 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ To exercise a PEM-backed delegated account end to end against the Ethrex testnet RPC_URL=https://rpc1.privacy.ethrex.xyz \ SENDER=0x... \ RECIPIENT=0x... \ +P256_IMPLEMENTATION_ADDRESS=0x... \ P256_PRIVATE_KEY_PATH=/absolute/path/to/p256-private.pem \ bun run scripts/send-eth-p256.ts ``` diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 11e2fa3..2919e3c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -151,10 +151,12 @@ explicit identity `last20Bytes(keccak256(qx || qy))`; its signer signs the frame directly, normalizes `s` low, and encodes `r || s || qx || qy`. Passing `address` separately represents a delegated EOA controlled by a different signer. When `delegateAddress` is provided, construction verifies that `eth_getCode(address)` is exactly -`0xef0100 || delegateAddress`. Account-specific validation or signature policies -override `getValidationData`, `getSignatureEntries`, or `signFrameTransaction` on the same -constructor. A fully custom account may omit `owner` when it supplies an address and all -four account-policy callbacks directly. +`0xef0100 || delegateAddress`. A delegated sender always uses explicit signer metadata, +even when its secp256k1 owner has the same address as the sender. `address` and +`getAddress` are mutually exclusive. Account-specific validation or signature policies +override `getValidationData`, `getSignatureEntries`, or `signFrameTransaction` on the +same constructor. A fully custom account may omit `owner` when it supplies an address and +all four account-policy callbacks directly. A plain JSON-RPC/injected account has no raw-digest signing method. The optional `sign({ hash })` adapter supports an injected, hardware, HSM, or remote backend only when @@ -165,8 +167,12 @@ A `FramePayerAccountImplementation` supplies `getAddress()`, `getPayData(tx)`, `getSignatureEntries(tx)` and `signFrameTransaction(tx)`. `toFramePayerAccount` is the single payer constructor. Its defaults cover a codeless EOA payer: empty PAY calldata and an empty-`msg` signature entry whose explicit signer is the payer, since an empty signer -would resolve to `tx.sender`. A contract payer with no signing owner supplies `address` -(or `getAddress`) and all three policy callbacks directly. Delegated or contract payers +would resolve to `tx.sender`. The default policy refuses a payer that is also `tx.sender`; +self-paying EOAs use sender validation rather than a separate PAY frame. It otherwise +applies no sponsorship policy: it does not cap transaction cost or restrict frame contents, +so a sponsor must override `signFrameTransaction` to enforce those rules. A contract payer +with no signing owner supplies `address` (or `getAddress`) and all three policy callbacks +directly; `address` and `getAddress` are mutually exclusive. Delegated or contract payers override the same callbacks. Signing locates the payer-owned entry by scheme and explicit signer rather than assuming a fixed signature-array index. diff --git a/scripts/send-eth-p256.ts b/scripts/send-eth-p256.ts index ff7e1a3..2785d4c 100644 --- a/scripts/send-eth-p256.ts +++ b/scripts/send-eth-p256.ts @@ -5,6 +5,7 @@ * Required: * SENDER=0x... * RECIPIENT=0x... + * P256_IMPLEMENTATION_ADDRESS=0x... * P256_PRIVATE_KEY_PATH=/absolute/path/to/p256-private.pem * * Optional: @@ -91,6 +92,7 @@ async function main(): Promise { const rpcUrl = process.env.RPC_URL ?? 'https://rpc1.privacy.ethrex.xyz' const sender = getAddress(requiredEnv('SENDER')) const recipient = getAddress(requiredEnv('RECIPIENT')) + const delegateAddress = getAddress(requiredEnv('P256_IMPLEMENTATION_ADDRESS')) const amount = parseEther(process.env.AMOUNT_ETH ?? '0.000001') const owner = privateKeyToP256Account( loadP256PrivateKey(requiredEnv('P256_PRIVATE_KEY_PATH')), @@ -109,11 +111,6 @@ async function main(): Promise { chain: hegotaTestnet, transport: http(rpcUrl), }) - const code = await client.getCode({ address: sender }) - if (code === undefined || !/^0xef0100[0-9a-fA-F]{40}$/.test(code)) - throw new Error(`sender ${sender} does not have an EIP-7702 delegation`) - const delegateAddress = getAddress(`0x${code.slice(8)}`) - const signerCall = await client.call({ to: sender, data: encodeFunctionData({ abi: P256_ACCOUNT_ABI, functionName: 'p256Signer' }), diff --git a/src/accounts/toFrameAccount.ts b/src/accounts/toFrameAccount.ts index 46f8f71..b034799 100644 --- a/src/accounts/toFrameAccount.ts +++ b/src/accounts/toFrameAccount.ts @@ -108,6 +108,10 @@ export async function toFrameAccount< parameters: ToFrameAccountParameters, ): Promise> { const { client, owner } = parameters + if (parameters.address !== undefined && parameters.getAddress !== undefined) + throw new FrameEncodeError( + 'frame account parameters cannot include both address and getAddress', + ) const unresolvedAddress = parameters.address ?? owner?.address if (parameters.getAddress === undefined && unresolvedAddress === undefined) throw new FrameEncodeError( @@ -125,7 +129,7 @@ export async function toFrameAccount< const expected = `0xef0100${parameters.delegateAddress.slice(2)}`.toLowerCase() if (code.toLowerCase() !== expected) throw new FrameEncodeError( - `frame account ${address} delegates with ${code}, expected ${expected}`, + `frame account ${address} delegates with ${code.length > 48 ? `${code.slice(0, 48)}...` : code}, expected ${expected}`, ) } diff --git a/src/accounts/toFramePayerAccount.ts b/src/accounts/toFramePayerAccount.ts index 377c47d..edbd293 100644 --- a/src/accounts/toFramePayerAccount.ts +++ b/src/accounts/toFramePayerAccount.ts @@ -75,6 +75,10 @@ export async function toFramePayerAccount< parameters: ToFramePayerAccountParameters, ): Promise> { const { owner } = parameters + if (parameters.address !== undefined && parameters.getAddress !== undefined) + throw new FrameEncodeError( + 'frame payer parameters cannot include both address and getAddress', + ) const unresolvedAddress = parameters.address ?? owner?.address if (parameters.getAddress === undefined && unresolvedAddress === undefined) throw new FrameEncodeError( @@ -111,6 +115,10 @@ export async function toFramePayerAccount< const signingOwner = ownerWithSigner(owner, parameters.sign) async function assertPayFrame(transaction: FrameTransaction): Promise { + if (isAddressEqual(transaction.sender, address)) + throw new FrameEncodeError( + `default frame payer ${address} cannot also be the transaction sender`, + ) const hasPayFrame = transaction.frames.some( (frame) => frame.mode === 1 && diff --git a/test/accounts.test.ts b/test/accounts.test.ts index d51a560..b10cacd 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from 'vitest' -import { createClient, custom, getAddress, size } from 'viem' +import { createClient, custom, getAddress, size, sliceHex } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { mainnet } from 'viem/chains' +import * as P256 from 'ox/P256' import { toFrameAccount } from '../src/accounts/toFrameAccount.js' import { toFramePayerAccount } from '../src/accounts/toFramePayerAccount.js' import { privateKeyToP256Account } from '../src/p256.js' +import { frameTxSigHash } from '../src/sighash.js' import { recoverFrameSigner, signFrameSignature } from '../src/signatures.js' import type { FrameTransaction } from '../src/types.js' import { GOLDEN_TX } from './fixtures/golden.js' @@ -93,6 +95,23 @@ describe('toFrameAccount', () => { expect(resolutions).toBe(1) }) + test('rejects address together with getAddress', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const client = createClient({ + chain: mainnet, + transport: custom({ async request() {} }), + }) + + await expect( + toFrameAccount({ + client, + owner, + address: owner.address, + getAddress: async () => OTHER, + }), + ).rejects.toThrow(/both address and getAddress/) + }) + test('supports an ownerless account when every policy callback is supplied', async () => { const client = createClient({ chain: mainnet, @@ -197,7 +216,7 @@ describe('toFramePayerAccount defaults', () => { expect(await recoverFrameSigner(senderSigned, 0)).toBe(sender.address) }) - test('requires an explicit payer entry when sender and payer are the same address', async () => { + test('refuses the default payer policy when sender and payer are the same address', async () => { const owner = privateKeyToAccount(OWNER_KEY) const payer = await toFramePayerAccount({ owner }) const transaction = { @@ -212,17 +231,12 @@ describe('toFramePayerAccount defaults', () => { ], } - const signed = await payer.signFrameTransaction(transaction) - - expect(signed.signatures[0]!.signature).toBe('0x') - expect(signed.signatures[1]).toMatchObject({ signer: owner.address }) - expect(await recoverFrameSigner(signed, 1)).toBe(owner.address) - - const senderSigned = await signFrameSignature(transaction, 0, owner) - await expect(payer.signFrameTransaction(senderSigned)).rejects.toThrow( - /other entries are already signed/, + await expect(payer.getSignatureEntries(transaction)).rejects.toThrow( + /cannot also be the transaction sender/, + ) + await expect(payer.signFrameTransaction(transaction)).rejects.toThrow( + /cannot also be the transaction sender/, ) - expect(await recoverFrameSigner(senderSigned, 0)).toBe(owner.address) }) test('refuses to sign without a pay frame targeting the payer', async () => { @@ -235,6 +249,40 @@ describe('toFramePayerAccount defaults', () => { ).rejects.toThrow(/no payment-only VERIFY frame/) }) + test('refuses a payer-targeted VERIFY frame with non-payment approval flags', async () => { + const payer = await toFramePayerAccount({ + owner: privateKeyToAccount(`0x${'22'.repeat(32)}`), + }) + const transaction = { + ...GOLDEN_TX, + frames: [ + { ...GOLDEN_TX.frames[0]!, flags: 0x3, target: payer.address }, + ...GOLDEN_TX.frames.slice(1), + ], + } + + await expect(payer.getSignatureEntries(transaction)).rejects.toThrow( + /no payment-only VERIFY frame/, + ) + }) + + test('refuses a payer-targeted non-VERIFY frame', async () => { + const payer = await toFramePayerAccount({ + owner: privateKeyToAccount(`0x${'22'.repeat(32)}`), + }) + const transaction = { + ...GOLDEN_TX, + frames: [ + { ...GOLDEN_TX.frames[0]!, mode: 2 as const, flags: 0x1, target: payer.address }, + ...GOLDEN_TX.frames.slice(1), + ], + } + + await expect(payer.getSignatureEntries(transaction)).rejects.toThrow( + /no payment-only VERIFY frame/, + ) + }) + test('signs through a raw-digest override for an injected payer account', async () => { const signer = privateKeyToAccount(`0x${'22'.repeat(32)}`) const owner = { address: signer.address, type: 'json-rpc' as const } @@ -283,31 +331,37 @@ describe('toFramePayerAccount', () => { expect(await payer.getPayData(GOLDEN_TX)).toBe('0xabcd') }) - test('accepts a P-256 owner and signs a payer-owned scheme-2 entry', async () => { + test('uses the default P-256 payer entry and signing policy', async () => { const owner = privateKeyToP256Account(`0x${'01'.padStart(64, '0')}`) const payerAddress = getAddress('0x0000000000000000000000000000000000009999') - const payer = await toFramePayerAccount({ - owner, - getAddress: async () => payerAddress, - getPayData: async () => '0x', - getSignatureEntries: async () => [ - { scheme: 2, signer: owner.address, msg: '0x', signature: '0x' }, - ], - signFrameTransaction: async (transaction) => - signFrameSignature(transaction, 1, owner), - }) + const payer = await toFramePayerAccount({ owner, address: payerAddress }) const transaction = { ...GOLDEN_TX, - signatures: [ - GOLDEN_TX.signatures[0]!, - { scheme: 2 as const, signer: owner.address, msg: '0x' as const, signature: '0x' as const }, + frames: [ + { ...GOLDEN_TX.frames[0]!, flags: 0x1, target: payerAddress }, + ...GOLDEN_TX.frames.slice(1), ], + signatures: [], } const signed = await payer.signFrameTransaction(transaction) + const entry = signed.signatures[0]! + const signature = { + r: BigInt(sliceHex(entry.signature, 0, 32)), + s: BigInt(sliceHex(entry.signature, 32, 64)), + } + const publicKey = { + prefix: 4 as const, + x: BigInt(sliceHex(owner.publicKey, 1, 33)), + y: BigInt(sliceHex(owner.publicKey, 33, 65)), + } expect(payer.owner).toBe(owner) - expect(size(signed.signatures[1]!.signature)).toBe(128) + expect(entry).toMatchObject({ scheme: 2, signer: owner.address, msg: '0x' }) + expect(size(entry.signature)).toBe(128) + expect( + P256.verify({ payload: frameTxSigHash(signed), publicKey, signature }), + ).toBe(true) }) test('supports an ownerless payer when every policy callback is supplied', async () => { @@ -371,6 +425,18 @@ describe('toFramePayerAccount', () => { await payer.signFrameTransaction(transaction) expect(resolutions).toBe(1) }) + + test('rejects address together with getAddress', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + + await expect( + toFramePayerAccount({ + owner, + address: owner.address, + getAddress: async () => OTHER, + }), + ).rejects.toThrow(/both address and getAddress/) + }) }) describe('toFrameAccount defaults', () => { @@ -442,10 +508,13 @@ describe('toFrameAccount defaults', () => { test('uses explicit signer metadata for delegated and distinct-address owners', async () => { const owner = privateKeyToAccount(OWNER_KEY) const delegationCode = `0xef0100${DELEGATE.slice(2)}` + const requests: { method: string; params?: unknown }[] = [] const delegatedClient = createClient({ chain: mainnet, transport: custom({ - async request({ method }) { + async request(request) { + requests.push(request) + const { method } = request if (method === 'eth_getCode') return delegationCode throw new Error(`unexpected ${method}`) }, @@ -465,6 +534,9 @@ describe('toFrameAccount defaults', () => { expect((await delegated.getSignatureEntries(GOLDEN_TX))[0]!.signer).toBe(owner.address) expect((await distinct.getSignatureEntries(GOLDEN_TX))[0]!.signer).toBe(owner.address) + expect(requests).toEqual([ + { method: 'eth_getCode', params: [owner.address, 'latest'] }, + ]) }) test('verifies the expected EIP-7702 delegate against account code', async () => { @@ -479,6 +551,35 @@ describe('toFrameAccount defaults', () => { ).rejects.toThrow(/expected 0xef0100/) }) + test('rejects code ending in the delegate without the EIP-7702 prefix', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const client = createClient({ + chain: mainnet, + transport: custom({ + async request() { + return `0x000000${DELEGATE.slice(2)}` + }, + }), + }) + + await expect( + toFrameAccount({ client, owner, delegateAddress: DELEGATE }), + ).rejects.toThrow(/expected 0xef0100/) + }) + + test('truncates long contract code in delegate mismatch errors', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const longCode = `0x${'12'.repeat(100)}` as const + const client = createClient({ + chain: mainnet, + transport: custom({ async request() { return longCode } }), + }) + + await expect( + toFrameAccount({ client, owner, delegateAddress: DELEGATE }), + ).rejects.toThrow(`${longCode.slice(0, 48)}...`) + }) + test('reads the nonce from the resolved delegated address', async () => { const owner = privateKeyToP256Account(`0x${'01'.padStart(64, '0')}`) const requests: { method: string; params?: unknown }[] = [] diff --git a/test/p256.test.ts b/test/p256.test.ts index d682834..bf3e87a 100644 --- a/test/p256.test.ts +++ b/test/p256.test.ts @@ -153,10 +153,13 @@ describe('signFrameSignature with a P-256 owner', () => { describe('toFrameAccount with a P-256 owner', () => { test('prepares and signs scheme-2 entry zero for a delegated EOA', async () => { const signer = privateKeyToP256Account(PRIVATE_KEY) + const requests: { method: string; params?: unknown }[] = [] const client = createClient({ chain: mainnet, transport: custom({ - async request({ method }) { + async request(request) { + requests.push(request) + const { method } = request if (method === 'eth_getCode') return `0xef0100${DELEGATE.slice(2)}` throw new Error(`unexpected ${method}`) }, @@ -189,6 +192,30 @@ describe('toFrameAccount with a P-256 owner', () => { sliceHex(signer.publicKey, 1), ]), ) + expect(requests).toEqual([ + { method: 'eth_getCode', params: [DELEGATED_EOA, 'latest'] }, + ]) + }) + + test('keeps an explicit signer when the P-256 identity equals the sender', async () => { + const signer = privateKeyToP256Account(PRIVATE_KEY) + const client = createClient({ + chain: mainnet, + transport: custom({ async request() {} }), + }) + const account = await toFrameAccount({ client, owner: signer }) + + const signed = await account.signFrameTransaction({ + ...GOLDEN_TX, + sender: signer.address, + signatures: [], + }) + + expect(signed.signatures[0]).toMatchObject({ + scheme: 2, + signer: signer.address, + msg: '0x', + }) }) test('rejects a transaction for a different sender', async () => { diff --git a/test/signFrameTransaction.test.ts b/test/signFrameTransaction.test.ts index d4c04e3..54b05a2 100644 --- a/test/signFrameTransaction.test.ts +++ b/test/signFrameTransaction.test.ts @@ -86,4 +86,59 @@ describe('signFrameTransaction', () => { expect(await recoverFrameSigner(signed, 0)).toBe(owner.address) expect(await recoverFrameSigner(signed, 1)).toBe(payerOwner.address) }) + + test('signs separate first and last entries when sender and payer share an owner', async () => { + const owner = privateKeyToAccount(OWNER_KEY) + const sender = getAddress('0x0000000000000000000000000000000000001234') + const payerAddress = getAddress('0x0000000000000000000000000000000000009999') + const delegate = getAddress('0x0000000000000000000000000000000000005678') + const client = createClient({ + account: owner, + chain: mainnet, + transport: custom({ + async request({ method }) { + if (method === 'eth_getCode') return `0xef0100${delegate.slice(2)}` + throw new Error(`unexpected ${method}`) + }, + }), + }) + const account = await toFrameAccount({ + client, + owner, + address: sender, + delegateAddress: delegate, + }) + const payer = await toFramePayerAccount({ owner, address: payerAddress }) + const base = { + ...GOLDEN_TX, + sender, + frames: [ + { ...GOLDEN_TX.frames[0]!, flags: 0x2, target: null }, + { + ...GOLDEN_TX.frames[0]!, + flags: 0x1, + target: payerAddress, + data: '0x' as const, + }, + ...GOLDEN_TX.frames.slice(1), + ], + signatures: [], + } + const senderEntries = await account.getSignatureEntries(base) + const payerEntries = await payer.getSignatureEntries(base) + const prepared = { + ...base, + signatures: [...senderEntries, ...payerEntries], + } + + expect(prepared.signatures).toEqual([ + { scheme: 1, signer: owner.address, msg: '0x', signature: '0x' }, + { scheme: 1, signer: owner.address, msg: '0x', signature: '0x' }, + ]) + + const signed = await signFrameTransaction(account, prepared, { payer }) + + expect(await recoverFrameSigner(signed, 0)).toBe(owner.address) + expect(await recoverFrameSigner(signed, 1)).toBe(owner.address) + }) })