diff --git a/README.md b/README.md index 686f473..d852e0d 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), }) ``` @@ -140,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 ``` @@ -159,15 +184,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), }) @@ -182,11 +207,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 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. +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 6d4193a..2919e3c 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,40 @@ 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. When `delegateAddress` is +provided, construction verifies that `eth_getCode(address)` is exactly +`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 +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`. 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. `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..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: @@ -31,7 +32,7 @@ import { parseRpcFrameReceipt, privateKeyToP256Account, sendFrameTransaction, - toP256FrameAccount, + toFrameAccount, type FrameRpcClient, type RpcFrameReceiptJson, } from '../src/index.js' @@ -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,10 +111,6 @@ async function main(): Promise { chain: hegotaTestnet, transport: http(rpcUrl), }) - const code = await client.getCode({ address: sender }) - if (code === undefined || !code.toLowerCase().startsWith('0xef0100')) - throw new Error(`sender ${sender} does not have an EIP-7702 delegation`) - const signerCall = await client.call({ to: sender, data: encodeFunctionData({ abi: P256_ACCOUNT_ABI, functionName: 'p256Signer' }), @@ -129,7 +127,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..793ffb3 --- /dev/null +++ b/src/accounts/defaults.ts @@ -0,0 +1,75 @@ +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' + +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, + 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 { + 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', + 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) + + 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..b034799 100644 --- a/src/accounts/toFrameAccount.ts +++ b/src/accounts/toFrameAccount.ts @@ -1,31 +1,229 @@ +import { + type Address, + type Chain, + type Client, + type JsonRpcAccount, + type LocalAccount, + type Transport, + type Hex, + 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' +type FrameAccountBaseParameters< + chain extends Chain | undefined = Chain | undefined, + owner extends FrameAccountOwner | undefined = FrameAccountOwner | undefined, + extend extends object = object, +> = { + client: Client + owner?: owner | undefined + /** 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 +} + +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< - implementation extends - FrameAccountImplementation = FrameAccountImplementation, -> = implementation + chain extends Chain | undefined = Chain | undefined, + owner extends FrameAccountOwner | undefined = FrameAccountOwner, + extend extends object = object, +> = owner extends FrameAccountOwner + ? DefaultFrameAccountParameters + : CustomFrameAccountParameters export type ToFrameAccountReturnType< - implementation extends - FrameAccountImplementation = FrameAccountImplementation, -> = FrameAccount + chain extends Chain | undefined = Chain | undefined, + owner extends FrameAccountOwner | undefined = 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 | undefined, + extend extends object = object, >( - implementation: ToFrameAccountParameters, -): Promise> { - const { extend, ...rest } = implementation - const address = await implementation.getAddress() + 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( + '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.length > 48 ? `${code.slice(0, 48)}...` : 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, + ...nonceParameters, + })) + + const getValidationData = + parameters.getValidationData ?? (async () => '0x' as const) + + const getSignatureEntries = + parameters.getSignatureEntries ?? + (async () => { + const useImplicitSigner = + parameters.delegateAddress === undefined && + ownerSignatureScheme(owner) === 1 && + isAddressEqual(address, owner.address) + return [ownerSignatureEntry(owner, useImplicitSigner ? null : owner.address)] + }) + + const signFrameTransaction = + parameters.signFrameTransaction ?? + (async (transaction: FrameTransaction) => { + if (!isAddressEqual(transaction.sender, address)) + throw new FrameEncodeError( + `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, + signatures: [...entries, ...transaction.signatures], + } + signatureIndex = findOwnerSignatureIndex(prepared, owner, 'first') + } + if (signatureIndex === -1) + throw new FrameEncodeError( + `frame account ${address} has no signable entry for owner ${owner.address}`, + ) + + return signFrameSignature(prepared, signatureIndex, signingOwner) + }) + const { extend, sign: _sign, address: _address, ...rest } = parameters return { ...extend, ...rest, + owner, + execution: parameters.execution ?? { type: 'direct' }, + getAddress, + getNonce, + getValidationData, + getSignatureEntries, + signFrameTransaction, address, type: 'frame', - } as ToFrameAccountReturnType + } as unknown as ToFrameAccountReturnType } diff --git a/src/accounts/toFramePayerAccount.ts b/src/accounts/toFramePayerAccount.ts index e9938df..edbd293 100644 --- a/src/accounts/toFramePayerAccount.ts +++ b/src/accounts/toFramePayerAccount.ts @@ -1,31 +1,187 @@ +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' +type FramePayerAccountBaseParameters< + owner extends FrameAccountOwner | undefined, + extend extends object = object, +> = { + owner?: owner | undefined + /** Payer address. Defaults to `owner.address`. */ + address?: 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 +} + +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< - implementation extends - FramePayerAccountImplementation = FramePayerAccountImplementation, -> = implementation + owner extends FrameAccountOwner | undefined = FrameAccountOwner, + extend extends object = object, +> = owner extends FrameAccountOwner + ? DefaultFramePayerAccountParameters + : CustomFramePayerAccountParameters export type ToFramePayerAccountReturnType< - implementation extends - FramePayerAccountImplementation = FramePayerAccountImplementation, -> = FramePayerAccount + owner extends FrameAccountOwner | undefined = 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 | undefined, + extend extends object = object, >( - implementation: ToFramePayerAccountParameters, -): Promise> { - const { extend, ...rest } = implementation - const address = await implementation.getAddress() + 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( + '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 { + 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 && + frame.flags === 0x1 && + frame.target !== null && + isAddressEqual(frame.target, address), + ) + if (!hasPayFrame) + throw new FrameEncodeError( + `transaction has no payment-only VERIFY frame targeting payer ${address}`, + ) + } + + 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', { + 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', { + explicitSigner: true, + }) + } + if (signatureIndex === -1) + throw new FrameEncodeError( + `frame payer ${address} has no signable entry for owner ${owner.address}`, + ) + + return signFrameSignature(prepared, signatureIndex, signingOwner) + }) + const { extend, sign: _sign, address: _address, ...rest } = parameters return { ...extend, ...rest, + owner, + getAddress, + getPayData, + getSignatureEntries, + signFrameTransaction, address, type: 'framePayer', - } as ToFramePayerAccountReturnType + } as unknown 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..19b0286 100644 --- a/src/accounts/types.ts +++ b/src/accounts/types.ts @@ -87,6 +87,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..b10cacd 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -1,17 +1,19 @@ 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 { 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 { 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' 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 () => { @@ -66,13 +68,75 @@ 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('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, + 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('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 +169,78 @@ 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('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('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 = { + ...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 }, + ], + } + + 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/, + ) }) 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 +248,68 @@ describe('toEoaFramePayerAccount', () => { payer.signFrameTransaction({ ...GOLDEN_TX, signatures: [] }), ).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 } + 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]!, 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) + + expect(await recoverFrameSigner(signed, 1)).toBe(owner.address) + }) }) describe('toFramePayerAccount', () => { @@ -156,35 +331,115 @@ 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, address: payerAddress }) + const transaction = { + ...GOLDEN_TX, + 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(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 () => { const payer = await toFramePayerAccount({ - owner, - getAddress: async () => payerAddress, - getPayData: async () => '0x', - getSignatureEntries: async () => [ - { scheme: 2, signer: owner.address, msg: '0x', signature: '0x' }, + 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), ], - signFrameTransaction: async (transaction) => - signFrameSignature(transaction, 1, owner), + } + 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: [ - GOLDEN_TX.signatures[0]!, - { scheme: 2 as const, signer: owner.address, msg: '0x' as const, 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) + await payer.getSignatureEntries(transaction) + await payer.signFrameTransaction(transaction) + expect(resolutions).toBe(1) + }) - expect(payer.owner).toBe(owner) - expect(size(signed.signatures[1]!.signature)).toBe(128) + 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('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 +447,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 +458,176 @@ 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('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 requests: { method: string; params?: unknown }[] = [] + const delegatedClient = createClient({ + chain: mainnet, + transport: custom({ + async request(request) { + requests.push(request) + const { method } = request + 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) + expect(requests).toEqual([ + { method: 'eth_getCode', params: [owner.address, 'latest'] }, + ]) + }) + + 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('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 }[] = [] + 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 }[] = [] @@ -216,7 +641,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 +662,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 +686,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..bf3e87a 100644 --- a/test/p256.test.ts +++ b/test/p256.test.ts @@ -5,11 +5,12 @@ import { custom, getAddress, numberToHex, + size, sliceHex, } 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 +27,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 +150,26 @@ 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 requests: { method: string; params?: unknown }[] = [] const client = createClient({ chain: mainnet, - transport: custom({ async request() {} }), + transport: custom({ + async request(request) { + requests.push(request) + const { method } = request + if (method === 'eth_getCode') return `0xef0100${DELEGATE.slice(2)}` + throw new Error(`unexpected ${method}`) + }, + }), }) - 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 = { @@ -181,22 +192,79 @@ describe('toP256FrameAccount', () => { sliceHex(signer.publicKey, 1), ]), ) + expect(requests).toEqual([ + { method: 'eth_getCode', params: [DELEGATED_EOA, 'latest'] }, + ]) }) - test('rejects a transaction for a different sender', async () => { + 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 toP256FrameAccount({ + 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 () => { + const signer = privateKeyToP256Account(PRIVATE_KEY) + const client = createClient({ + 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, address: DELEGATED_EOA, owner: signer, + delegateAddress: DELEGATE, }) await expect(account.signFrameTransaction(GOLDEN_TX)).rejects.toThrow( 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) + }) }) 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..54b05a2 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, @@ -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) + }) })