From 2c4db192f8dfbf8f64f753531413f6074d89f450 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:32:01 +1000 Subject: [PATCH 1/4] feat: add keyed frame nonces --- .changeset/frame-keyed-nonces.md | 17 + AGENTS.md | 2 + .../pages/guides/transactions/eip-8141.mdx | 33 ++ src/core/Transaction.ts | 17 + src/core/TransactionRequest.ts | 12 + src/core/TxEnvelopeEip8141.ts | 74 ++- .../_test/TxEnvelopeEip8141.nonce.test.ts | 451 ++++++++++++++++++ src/core/_test/TxEnvelopeEip8141.test-d.ts | 27 ++ src/core/_test/TxEnvelopeEip8141.test.ts | 4 +- src/core/internal/frameNonce.ts | 61 +++ src/zod/Transaction.ts | 65 ++- src/zod/TransactionRequest.ts | 49 +- src/zod/TxEnvelopeEip8141.ts | 2 + src/zod/_test/TxEnvelopeEip8141.nonce.test.ts | 174 +++++++ src/zod/_test/TxEnvelopeEip8141.test-d.ts | 29 ++ src/zod/internal/Frame.ts | 45 ++ 16 files changed, 1007 insertions(+), 55 deletions(-) create mode 100644 .changeset/frame-keyed-nonces.md create mode 100644 src/core/_test/TxEnvelopeEip8141.nonce.test.ts create mode 100644 src/core/internal/frameNonce.ts create mode 100644 src/zod/_test/TxEnvelopeEip8141.nonce.test.ts create mode 100644 src/zod/_test/TxEnvelopeEip8141.test-d.ts diff --git a/.changeset/frame-keyed-nonces.md b/.changeset/frame-keyed-nonces.md new file mode 100644 index 000000000..37af5af05 --- /dev/null +++ b/.changeset/frame-keyed-nonces.md @@ -0,0 +1,17 @@ +--- +"ox": minor +--- + +Added EIP-8250 keyed frame nonces with shared `nonce` sequences while preserving the original EIP-8141 encoding when `nonceKeys` is omitted. + +```ts +import { TxEnvelopeEip8141 } from 'ox' + +const envelope = TxEnvelopeEip8141.from({ + chainId: 1, + sender: '0x1111111111111111111111111111111111111111', + frames: [{ mode: 'sender', executionGas: 50_000n }], + nonceKeys: [1n, 2n], + nonce: 0n, +}) +``` diff --git a/AGENTS.md b/AGENTS.md index 91b58c8b7..12e237ecf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,3 +146,5 @@ - **Docgen export comments** -- `extractNamespaceDocComments` should read the nearest JSDoc on an export declaration. ts-morph can include earlier file-level JSDoc descendants on the first export. - **Type snapshot config** -- `@ark/attest` 0.16 does not follow root project references. Point `ATTEST_CONFIG.tsconfig` at `test/tsconfig.json`. - **Node local storage** -- Node 24+ needs a valid `--localstorage-file` for the `@typescript/vfs` dependency used by attest. + +- **Frame nonce encodings** -- omitted `nonceKeys` preserves seven-field EIP-8141 encoding; explicit keys, including `[0n]`, select eight-field EIP-8250 encoding. RPC keeps `nonce` as the shared sequence. diff --git a/site/src/pages/guides/transactions/eip-8141.mdx b/site/src/pages/guides/transactions/eip-8141.mdx index 5cf36051b..e64a36db5 100644 --- a/site/src/pages/guides/transactions/eip-8141.mdx +++ b/site/src/pages/guides/transactions/eip-8141.mdx @@ -115,6 +115,39 @@ list available to account verification logic. `FrameSignature.from` supports `'arbitrary'`, `'secp256k1'`, and `'p256'`; omitting `scheme` selects `'arbitrary'`. Use an explicit 32-byte `payload` only when the account expects a separate digest. +## Select Nonce Domains + +On networks supporting [EIP-8250](https://eips.ethereum.org/EIPS/eip-8250), supply +`nonceKeys` to select nonce domains. `nonce` is the single sequence shared by every +selected domain. Each domain must have that sequence before execution. + +```ts twoslash +import { TxEnvelopeEip8141 } from 'ox' + +const envelope = TxEnvelopeEip8141.from({ + chainId: 1, + sender: '0x1111111111111111111111111111111111111111', + frames: [{ mode: 'sender', executionGas: 50_000n }], + nonceKeys: [1n, 2n], + nonce: 0n, +}) +``` + +Keys must be 1–16 strictly increasing uint256 values. `[0n]` selects the sender's +legacy account nonce; zero cannot appear with another key. Sequences must be +nonnegative and less than `2n ** 64n - 1n`. Ox validates these constraints locally; +the execution client checks each domain's current sequence and account authorization. + +Omitting `nonceKeys` preserves the original seven-field EIP-8141 encoding. Explicit +keys, including `[0n]`, select the eight-field EIP-8250 encoding. Use `[0n]` for the +legacy nonce domain after activation. Ox does not infer network activation or add +keys automatically. Both keys and sequence affect transaction and signing hashes. + +RPC uses `nonceKeys` and `nonce`. RLP encodes the keys followed by the sequence at +the old nonce position. An incomplete request can omit `nonce` for client filling; +an envelope defaults it to zero. First use of nonzero domains also requires state +gas for nonce storage; supply budgets appropriate for the network. + ## Convert RPC Data [`TxEnvelopeEip8141.toRpc`](/api/TxEnvelopeEip8141/toRpc) converts `sender` to diff --git a/src/core/Transaction.ts b/src/core/Transaction.ts index 43e349c38..5ab48aed3 100644 --- a/src/core/Transaction.ts +++ b/src/core/Transaction.ts @@ -5,6 +5,7 @@ import type * as Errors from './Errors.js' import * as Frame from './Frame.js' import * as FrameSignature from './FrameSignature.js' import * as Hex from './Hex.js' +import * as FrameNonce from './internal/frameNonce.js' import * as Quantity from './internal/quantity.js' import type { Compute, @@ -226,6 +227,8 @@ export type Eip8141< maxFeePerGas: bigintType /** Maximum priority fee per gas. */ maxPriorityFeePerGas: bigintType + /** EIP-8250 domains sharing the transaction's `nonce` sequence. */ + nonceKeys?: readonly bigintType[] | undefined /** Frame signature entries. */ signatures: readonly ([bigintType] extends [Hex.Hex] ? FrameSignature.Rpc @@ -377,6 +380,16 @@ export function fromRpc< ? chainId : Number(chainId) } + if (transaction.type === '0x6' && transaction.nonceKeys !== undefined) { + if (transaction.nonce === undefined) + throw new FrameNonce.InvalidError( + 'A keyed transaction result requires nonce.', + ) + transaction_.nonceKeys = FrameNonce.fromRpc( + transaction.nonceKeys, + transaction.nonce, + ) + } if (transaction.type === '0x6' && transaction.frames) { transaction_.frames = transaction.frames.map(Frame.fromRpc) transaction_.signatures = (transaction.signatures ?? []).map( @@ -413,6 +426,7 @@ export declare namespace fromRpc { type ErrorType = | Signature.extract.ErrorType + | FrameNonce.InvalidError | Frame.fromRpc.ErrorType | FrameSignature.fromRpc.ErrorType | Errors.GlobalErrorType @@ -480,6 +494,8 @@ export function toRpc( rpc.type = (toRpcType as any)[transaction.type] ?? transaction.type rpc.value = Quantity.fromNumberish(transaction.value ?? 0n) + if (transaction.type === 'eip8141' && transaction.nonceKeys !== undefined) + rpc.nonceKeys = FrameNonce.toRpc(transaction.nonceKeys, transaction.nonce) if (transaction.type === 'eip8141' && transaction.frames) { rpc.frames = transaction.frames.map(Frame.toRpc) rpc.signatures = transaction.signatures.map(FrameSignature.toRpc) @@ -527,6 +543,7 @@ export declare namespace toRpc { type ErrorType = | Signature.extract.ErrorType + | FrameNonce.InvalidError | Frame.toRpc.ErrorType | FrameSignature.toRpc.ErrorType | Errors.GlobalErrorType diff --git a/src/core/TransactionRequest.ts b/src/core/TransactionRequest.ts index 4f553b66a..7e1189afc 100644 --- a/src/core/TransactionRequest.ts +++ b/src/core/TransactionRequest.ts @@ -6,6 +6,7 @@ import * as Errors from './Errors.js' import * as FrameRequest from './FrameRequest.js' import * as FrameSignature from './FrameSignature.js' import * as Hex from './Hex.js' +import * as FrameNonce from './internal/frameNonce.js' import * as Quantity from './internal/quantity.js' import type { Compute } from './internal/types.js' import type * as Kzg from './Kzg.js' @@ -58,6 +59,8 @@ export type TransactionRequest< maxPriorityFeePerGas?: bigintType | undefined /** Unique number identifying this transaction */ nonce?: bigintType | undefined + /** EIP-8250 nonce domains. `nonce` is their shared sequence; omitted keys preserve EIP-8141 encoding. */ + nonceKeys?: readonly bigintType[] | undefined /** Frame signature entries for EIP-8141 transactions. */ signatures?: readonly signature[] | undefined /** Transaction recipient */ @@ -126,6 +129,8 @@ export function fromRpc(request: Rpc): TransactionRequest { request_.maxPriorityFeePerGas = Hex.toBigInt(request.maxPriorityFeePerGas) if (typeof request.nonce !== 'undefined') request_.nonce = Hex.toBigInt(request.nonce) + if (request.nonceKeys !== undefined) + request_.nonceKeys = FrameNonce.fromRpc(request.nonceKeys, request.nonce) if (typeof request.type !== 'undefined') request_.type = Transaction.fromRpcType[ @@ -143,6 +148,7 @@ export function fromRpc(request: Rpc): TransactionRequest { export declare namespace fromRpc { export type ErrorType = | Authorization.fromRpcList.ErrorType + | FrameNonce.InvalidError | FrameRequest.fromRpc.ErrorType | FrameSignature.fromRpc.ErrorType | Hex.toNumber.ErrorType @@ -232,6 +238,8 @@ export function toRpc(request: toRpc.Input): Rpc { ) if (typeof request.nonce !== 'undefined') request_rpc.nonce = Quantity.fromNumberish(request.nonce) + if (request.nonceKeys !== undefined) + request_rpc.nonceKeys = FrameNonce.toRpc(request.nonceKeys, request.nonce) if (typeof request.to !== 'undefined') request_rpc.to = request.to if (typeof request.type !== 'undefined') request_rpc.type = @@ -260,6 +268,7 @@ export declare namespace toRpc { export type ErrorType = | Authorization.toRpcList.ErrorType + | FrameNonce.InvalidError | FrameRequest.toRpc.ErrorType | FrameSignature.toRpc.ErrorType | Hex.fromNumber.ErrorType @@ -402,6 +411,9 @@ export function toEnvelope( maxFeePerGas: request.maxFeePerGas, maxPriorityFeePerGas: request.maxPriorityFeePerGas, nonce: request.nonce, + ...(request.nonceKeys !== undefined + ? { nonceKeys: request.nonceKeys } + : {}), sender: request.from, sidecars, signatures: request.signatures, diff --git a/src/core/TxEnvelopeEip8141.ts b/src/core/TxEnvelopeEip8141.ts index e731c0f48..b469ded3a 100644 --- a/src/core/TxEnvelopeEip8141.ts +++ b/src/core/TxEnvelopeEip8141.ts @@ -6,12 +6,18 @@ import * as Frame from './Frame.js' import * as FrameSignature from './FrameSignature.js' import * as Hash from './Hash.js' import * as Hex from './Hex.js' +import * as FrameNonce from './internal/frameNonce.js' import * as Quantity from './internal/quantity.js' import type { Assign, Compute, PartialBy } from './internal/types.js' import * as Rlp from './Rlp.js' import type * as TxEnvelopeEip4844 from './TxEnvelopeEip4844.js' -/** An EIP-8141 transaction containing independently budgeted call frames. */ +/** + * An EIP-8141 transaction containing independently budgeted call frames. + * + * Omitted `nonceKeys` preserves the original EIP-8141 encoding. Supplying keys, + * including `[0n]`, selects EIP-8250 encoding and changes the signing hash. + */ export type TxEnvelopeEip8141 = { /** Versioned blob hashes. @default [] */ blobVersionedHashes?: readonly Hex.Hex[] | undefined @@ -25,8 +31,10 @@ export type TxEnvelopeEip8141 = { maxFeePerGas?: bigint | undefined /** Maximum priority fee per gas, in wei. @default 0n */ maxPriorityFeePerGas?: bigint | undefined - /** Sender nonce. @default 0n */ + /** Sender nonce or shared sequence. With `nonceKeys`, the sequence must be less than 2^64 - 1. @default 0n */ nonce?: bigint | undefined + /** EIP-8250 domains: 1–16 strictly increasing uint256 keys. `[0n]` selects the legacy account nonce; zero cannot accompany other keys. */ + nonceKeys?: readonly bigint[] | undefined /** Account authorizing execution. */ sender: Address.Address /** PeerDAS sidecars. Excluded from transaction and signing hashes. */ @@ -53,8 +61,10 @@ export type Rpc = { maxFeePerGas: Hex.Hex /** Maximum priority fee per gas. */ maxPriorityFeePerGas: Hex.Hex - /** Sender nonce. */ + /** Sender nonce or shared EIP-8250 sequence. */ nonce: Hex.Hex + /** EIP-8250 domains. Omitted for the original EIP-8141 encoding. */ + nonceKeys?: readonly Hex.Hex[] | undefined /** Frame signature entries. */ signatures: readonly FrameSignature.Rpc[] /** RPC transaction type. */ @@ -106,6 +116,7 @@ export function assert(envelope: PartialBy): void { maxFeePerGas = 0n, maxPriorityFeePerGas = 0n, nonce = 0n, + nonceKeys, sender, signatures = [], } = envelope @@ -118,6 +129,7 @@ export function assert(envelope: PartialBy): void { throw new InvalidError( 'chainId must be an unsigned 256-bit integer; use bigint for large IDs.', ) + if (nonceKeys !== undefined) FrameNonce.assert(nonceKeys, nonce) Address.assert(sender, { strict: false }) for (const [field, value, bits] of [ ['maxFeePerBlobGas', maxFeePerBlobGas, 256n], @@ -157,6 +169,18 @@ export function assert(envelope: PartialBy): void { for (const byte of Hex.toBytes(data)) tokens += byte === 0 ? 1n : 4n size += BigInt(Hex.size(data)) } + if (nonceKeys !== undefined) { + count( + Rlp.fromHex( + nonceKeys.map((key) => + key === 0n ? '0x' : Hex.fromBytes(Bytes.fromNumber(key)), + ), + ), + ) + count( + Rlp.fromHex(nonce === 0n ? '0x' : Hex.fromBytes(Bytes.fromNumber(nonce))), + ) + } for (const [index, frame] of (frames as readonly Frame.Frame[]).entries()) { Frame.assert(frame) const flags = @@ -221,6 +245,7 @@ export function assert(envelope: PartialBy): void { export declare namespace assert { type ErrorType = | InvalidError + | FrameNonce.InvalidError | Frame.assert.ErrorType | FrameSignature.toTuple.ErrorType | Address.assert.ErrorType @@ -258,9 +283,14 @@ export function deserialize(serialized: Serialized): TxEnvelopeEip8141 { throw new InvalidError('Expected a transaction list.') const wrapped = Array.isArray(decoded[0]) const body = wrapped ? decoded[0] : decoded - if (!Array.isArray(body) || body.length !== 7) - throw new InvalidError('Expected seven transaction fields.') - const [chainId, nonce, sender, frames, signatures, fees, hashes] = body + if (!Array.isArray(body) || (body.length !== 7 && body.length !== 8)) + throw new InvalidError('Expected seven or eight transaction fields.') + const keyed = body.length === 8 + const [chainId, ...fields] = body + const keys = keyed ? fields.shift() : undefined + if (keyed && !Array.isArray(keys)) + throw new InvalidError('Expected a nonce key list.') + const [nonce, sender, frames, signatures, fees, hashes] = fields if ( typeof sender !== 'string' || !Array.isArray(frames) || @@ -288,6 +318,7 @@ export function deserialize(serialized: Serialized): TxEnvelopeEip8141 { maxFeePerGas: integer(fees[1]), maxPriorityFeePerGas: integer(fees[0]), nonce: integer(nonce), + ...(Array.isArray(keys) ? { nonceKeys: keys.map(integer) } : {}), sender: sender as Address.Address, signatures: signatures.map((entry) => FrameSignature.fromTuple(entry as FrameSignature.Tuple), @@ -403,6 +434,9 @@ export function fromRpc(envelope: Rpc): TxEnvelopeEip8141 { maxFeePerGas: Hex.toBigInt(envelope.maxFeePerGas), maxPriorityFeePerGas: Hex.toBigInt(envelope.maxPriorityFeePerGas), nonce: Hex.toBigInt(envelope.nonce), + ...(envelope.nonceKeys !== undefined + ? { nonceKeys: FrameNonce.fromRpc(envelope.nonceKeys, envelope.nonce) } + : {}), sender: envelope.from, signatures: envelope.signatures.map(FrameSignature.fromRpc), }) @@ -410,6 +444,7 @@ export function fromRpc(envelope: Rpc): TxEnvelopeEip8141 { export declare namespace fromRpc { type ErrorType = + | FrameNonce.InvalidError | from.ErrorType | Frame.fromRpc.ErrorType | FrameSignature.fromRpc.ErrorType @@ -448,6 +483,9 @@ export function toRpc(envelope: toRpc.Input): Rpc { envelope.maxPriorityFeePerGas ?? 0n, ), nonce: Quantity.fromNumberish(envelope.nonce ?? 0n), + ...(envelope.nonceKeys !== undefined + ? { nonceKeys: FrameNonce.toRpc(envelope.nonceKeys, envelope.nonce) } + : {}), signatures: (envelope.signatures ?? []).map(FrameSignature.toRpc), type: '0x6', } @@ -462,6 +500,7 @@ export declare namespace toRpc { | 'maxFeePerGas' | 'maxPriorityFeePerGas' | 'nonce' + | 'nonceKeys' | 'type' > & { chainId: Hex.Hex | bigint | number @@ -470,9 +509,11 @@ export declare namespace toRpc { maxFeePerGas?: Hex.Hex | bigint | number | undefined maxPriorityFeePerGas?: Hex.Hex | bigint | number | undefined nonce?: Hex.Hex | bigint | number | undefined + nonceKeys?: readonly (Hex.Hex | bigint | number)[] | undefined type?: Type | undefined } type ErrorType = + | FrameNonce.InvalidError | Frame.toRpc.ErrorType | FrameSignature.toRpc.ErrorType | Hex.fromNumber.ErrorType @@ -560,10 +601,12 @@ export function hash( ): Hex.Hex { assert(envelope) const body = toTuple(envelope) - if (options.presign) - body[4] = body[4].map((entry) => - entry[2] === '0x' ? [entry[0], entry[1], entry[2], '0x'] : entry, - ) + if (options.presign) { + const signatures = body.length === 8 ? body[5] : body[4] + for (const [index, entry] of signatures.entries()) + if (entry[2] === '0x') + signatures[index] = [entry[0], entry[1], entry[2], '0x'] + } return Hash.keccak256(Hex.concat(serializedType, Rlp.fromHex(body))) } export declare namespace hash { @@ -690,8 +733,7 @@ export class InvalidError extends Errors.BaseError { function toTuple(envelope: PartialBy) { const quantity = (value: bigint | number | undefined): Hex.Hex => value ? Hex.fromBytes(Bytes.fromNumber(value)) : '0x' - return [ - quantity(envelope.chainId), + const fields = [ quantity(envelope.nonce), envelope.sender, envelope.frames.map((frame) => Frame.toTuple(frame)), @@ -703,7 +745,6 @@ function toTuple(envelope: PartialBy) { ], envelope.blobVersionedHashes ?? [], ] satisfies [ - Hex.Hex, Hex.Hex, Address.Address, Frame.Tuple[], @@ -711,4 +752,11 @@ function toTuple(envelope: PartialBy) { Hex.Hex[], readonly Hex.Hex[], ] + return envelope.nonceKeys === undefined + ? ([quantity(envelope.chainId), ...fields] as const) + : ([ + quantity(envelope.chainId), + envelope.nonceKeys.map(quantity), + ...fields, + ] as const) } diff --git a/src/core/_test/TxEnvelopeEip8141.nonce.test.ts b/src/core/_test/TxEnvelopeEip8141.nonce.test.ts new file mode 100644 index 000000000..d78ce989b --- /dev/null +++ b/src/core/_test/TxEnvelopeEip8141.nonce.test.ts @@ -0,0 +1,451 @@ +import { + Blobs, + Hex, + Secp256k1, + Transaction, + TransactionEnvelope, + TransactionRequest, + TxEnvelopeEip8141, +} from 'ox' +import { encodeRlp, keccak256 } from 'ethers' +import { describe, expect, test } from 'vp/test' + +const sender = '0x1111111111111111111111111111111111111111' +const envelope = TxEnvelopeEip8141.from({ + chainId: 1, + frames: [{ mode: 'sender' }], + sender, + signatures: [{ scheme: 'arbitrary', signature: '0xdeadbeef' }], +}) + +// Generated independently with ethers 6.17 from the literal EIP-8250 RLP layout. +const vectors = [ + { + nonceKeys: undefined, + nonce: 0n, + serialized: + '0x06f00180941111111111111111111111111111111111111111c9c8028080c280808080c9c880808084deadbeefc3808080c0', + hash: '0xa14c90dee4c545369e201e4975ee2ef415357df20a0a183d1be9f602bac9a47d', + signHash: + '0x5682abf965767351637964eed1fe3f775ab45de426def7da68736cf2ae0a43b5', + }, + { + nonceKeys: [0n], + nonce: 0n, + serialized: + '0x06f201c18080941111111111111111111111111111111111111111c9c8028080c280808080c9c880808084deadbeefc3808080c0', + hash: '0x069a1528377db98ed06429aab0dad8043315859ac454ce3e1f5a250e365d072d', + signHash: + '0xd573654ae8d57432b3856b0078fb98f2f3e4d3f73a023d7ba8a952a16d0f4726', + }, + { + nonceKeys: [1n, 128n, 2n ** 256n - 1n], + nonce: 2n ** 64n - 2n, + serialized: + '0x06f85d01e4018180a0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff88fffffffffffffffe941111111111111111111111111111111111111111c9c8028080c280808080c9c880808084deadbeefc3808080c0', + hash: '0x620915f063bdd017a09ae856d0043d064568b3455aba741d7ebc748731fc503f', + signHash: + '0x3d28f5f76f353611385be1512c2832220686b27cad3c53d5b07941de1515319b', + }, +] as const + +describe('serialize', () => { + test.each(vectors)('independent encoding and signing vector %#', (vector) => { + const value = { + ...envelope, + nonce: vector.nonce, + nonceKeys: vector.nonceKeys, + } + expect(TxEnvelopeEip8141.serialize(value)).toBe(vector.serialized) + expect(TxEnvelopeEip8141.hash(value)).toBe(vector.hash) + expect(TxEnvelopeEip8141.getSignPayload(value)).toBe(vector.signHash) + expect(TransactionEnvelope.serialize(value)).toBe(vector.serialized) + expect(TransactionEnvelope.getSignPayload(value)).toBe(vector.signHash) + const decoded = TransactionEnvelope.deserialize(vector.serialized) + expect(TxEnvelopeEip8141.serialize(decoded)).toBe(vector.serialized) + expect(decoded.nonceKeys).toEqual(vector.nonceKeys) + expect(decoded.nonce).toBe(vector.nonce) + expect(TxEnvelopeEip8141.getSignPayload(decoded)).toBe(vector.signHash) + }) + + test('matches independent literal RLP layout', () => { + const body = [ + '0x01', + ['0x01', '0x8180'], + '0x05', + sender, + [['0x02', '0x', '0x', ['0x', '0x'], '0x', '0x']], + [['0x', '0x', '0x', '0xdeadbeef']], + ['0x', '0x', '0x'], + [], + ] + const serialized = `0x06${encodeRlp(body).slice(2)}` + const value = { ...envelope, nonceKeys: [1n, 33152n], nonce: 5n } + expect(TxEnvelopeEip8141.serialize(value)).toBe(serialized) + expect(TxEnvelopeEip8141.hash(value)).toBe(keccak256(serialized)) + }) + + test('keyed PeerDAS wrapper preserves keys and excludes sidecars from hashes', () => { + const commitment = `0x${'00'.repeat(48)}` as const + const value = { + ...envelope, + nonceKeys: [1n], + blobVersionedHashes: [Blobs.commitmentToVersionedHash(commitment)], + sidecars: { + blobs: [`0x${'00'.repeat(Blobs.bytesPerBlob)}` as const], + commitments: [commitment], + cellProofs: Array.from({ length: 128 }, () => commitment), + }, + } + const serialized = TxEnvelopeEip8141.serialize(value) + const decoded = TxEnvelopeEip8141.deserialize(serialized) + expect(decoded.nonceKeys).toMatchInlineSnapshot(` + [ + 1n, + ] + `) + expect(TxEnvelopeEip8141.serialize(decoded)).toBe(serialized) + expect(TxEnvelopeEip8141.hash(value)).toBe( + TxEnvelopeEip8141.hash({ ...value, sidecars: undefined }), + ) + expect(TxEnvelopeEip8141.getSignPayload(value)).toBe( + TxEnvelopeEip8141.getSignPayload({ ...value, sidecars: undefined }), + ) + }) +}) + +describe('assert', () => { + test.each([ + [], + [0n, 1n], + [1n, 1n], + [2n, 1n], + [-1n], + [2n ** 256n], + Array.from({ length: 17 }, (_, i) => BigInt(i + 1)), + ])('rejects invalid keys %#', (...nonceKeys) => { + expect(TxEnvelopeEip8141.validate({ ...envelope, nonceKeys })).toBe(false) + expect(() => + TxEnvelopeEip8141.serialize({ ...envelope, nonceKeys }), + ).toThrow() + }) + + test('accepts 16 keys and the maximum key', () => { + expect( + TxEnvelopeEip8141.validate({ + ...envelope, + nonceKeys: Array.from({ length: 16 }, (_, i) => BigInt(i + 1)), + }), + ).toBe(true) + expect( + TxEnvelopeEip8141.validate({ ...envelope, nonceKeys: [2n ** 256n - 1n] }), + ).toBe(true) + }) + + test.each([-1n, 2n ** 64n - 1n, 2n ** 64n])( + 'rejects invalid shared sequence %s', + (nonce) => { + expect(() => + TxEnvelopeEip8141.assert({ ...envelope, nonceKeys: [1n], nonce }), + ).toThrowErrorMatchingInlineSnapshot( + `[FrameNonce.InvalidError: Nonce must be less than 2^64 - 1 and nonnegative.]`, + ) + }, + ) + + test('rejects a numeric key or null list', () => { + // @ts-expect-error invalid runtime input + expect(TxEnvelopeEip8141.validate({ ...envelope, nonceKeys: [1] })).toBe( + false, + ) + // @ts-expect-error invalid runtime input + expect(TxEnvelopeEip8141.validate({ ...envelope, nonceKeys: null })).toBe( + false, + ) + }) + + test('charges encoded nonce calldata against the transaction gas cap', () => { + const value = { + ...envelope, + signatures: [], + frames: [{ executionGas: 16_764_741n }], + } + expect(TxEnvelopeEip8141.validate(value)).toBe(true) + expect(TxEnvelopeEip8141.validate({ ...value, nonceKeys: [0n] })).toBe( + false, + ) + // rlp([0]) || rlp(0) = c1 80 80, three nonzero bytes at 16 gas each. + expect( + TxEnvelopeEip8141.validate({ + ...value, + nonceKeys: [0n], + frames: [{ executionGas: 16_764_693n }], + }), + ).toBe(true) + }) +}) + +describe('deserialize', () => { + test.each([ + ['0x', '0x'], + [[], '0x'], + [['0x00'], '0x'], + [['0x0001'], '0x'], + [[['0x01']], '0x'], + [['0x01'], '0x00'], + [['0x01'], '0x0001'], + [['0x01'], []], + [['0x01', '0x01'], '0x'], + [['0x02', '0x01'], '0x'], + [['0x', '0x01'], '0x'], + [[`0x01${'00'.repeat(32)}`], '0x'], + [['0x01'], '0x010000000000000000'], + ])('rejects malformed nonce fields %#', (keys, nonce) => { + const serialized = + `0x06${encodeRlp(['0x01', keys, nonce, sender, [['0x02', '0x', '0x', ['0x', '0x'], '0x', '0x']], [], ['0x', '0x', '0x'], []]).slice(2)}` as const + expect(() => TxEnvelopeEip8141.deserialize(serialized)).toThrow() + }) + + test('rejects nonminimal RLP string and list lengths', () => { + const canonical = vectors[1].serialized + expect(() => + TxEnvelopeEip8141.deserialize( + canonical.replace( + 'f201c180', + 'f301c28100', + ) as TxEnvelopeEip8141.Serialized, + ), + ).toThrow() + expect(() => + TxEnvelopeEip8141.deserialize( + canonical.replace( + 'f201c180', + 'f301f80180', + ) as TxEnvelopeEip8141.Serialized, + ), + ).toThrow() + expect(() => + TxEnvelopeEip8141.deserialize( + canonical.replace('f201', 'f83201') as TxEnvelopeEip8141.Serialized, + ), + ).toThrow() + }) +}) + +describe('getSignPayload', () => { + test('signatures bind the complete key set and sequence', () => { + const value = { + ...envelope, + nonceKeys: [1n, 2n], + nonce: 0n, + signatures: [{ scheme: 'secp256k1' } as const], + } + const privateKey = + '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + const publicKey = Secp256k1.getPublicKey({ privateKey }) + const signature = Secp256k1.sign({ + privateKey, + payload: TxEnvelopeEip8141.getSignPayload(value), + }) + const signed = { + ...value, + signatures: [{ scheme: 'secp256k1', signature } as const], + } + expect( + Secp256k1.verify({ + publicKey, + signature, + payload: TxEnvelopeEip8141.getSignPayload(signed), + }), + ).toBe(true) + expect( + Secp256k1.verify({ + publicKey, + signature, + payload: TxEnvelopeEip8141.getSignPayload({ + ...signed, + nonceKeys: [1n, 3n], + }), + }), + ).toBe(false) + expect( + Secp256k1.verify({ + publicKey, + signature, + payload: TxEnvelopeEip8141.getSignPayload({ ...signed, nonce: 1n }), + }), + ).toBe(false) + }) + + test('commits to keys and shared sequence without mutating signatures', () => { + const value = { ...envelope, nonceKeys: [1n, 2n], nonce: 3n } + const hash = TxEnvelopeEip8141.getSignPayload(value) + expect( + TxEnvelopeEip8141.getSignPayload({ ...value, nonceKeys: [1n, 3n] }), + ).not.toBe(hash) + expect(TxEnvelopeEip8141.getSignPayload({ ...value, nonce: 4n })).not.toBe( + hash, + ) + expect( + TxEnvelopeEip8141.getSignPayload({ ...value, nonceKeys: [1n] }), + ).not.toBe(hash) + expect( + TxEnvelopeEip8141.getSignPayload({ + ...value, + signatures: [{ scheme: 'arbitrary', signature: '0xabcdef' }], + }), + ).toBe(hash) + expect(value.signatures[0].signature).toBe('0xdeadbeef') + }) + + test('retains explicit-message signatures', () => { + const value = { + ...envelope, + nonceKeys: [1n], + signatures: [ + { + scheme: 'arbitrary', + payload: `0x${'11'.repeat(32)}`, + signature: '0xdeadbeef', + } as const, + ], + } + expect(TxEnvelopeEip8141.getSignPayload(value)).toBe( + TxEnvelopeEip8141.hash(value), + ) + expect( + TxEnvelopeEip8141.getSignPayload({ + ...value, + signatures: [{ ...value.signatures[0]!, signature: '0xabcdef' }], + }), + ).not.toBe(TxEnvelopeEip8141.getSignPayload(value)) + }) +}) + +describe('toRpc', () => { + test.each(vectors)('preserves absent or explicit nonce keys %#', (vector) => { + const value = { + ...envelope, + nonceKeys: vector.nonceKeys, + nonce: vector.nonce, + } + const rpc = TxEnvelopeEip8141.toRpc(value) + expect('nonceSeq' in rpc).toBe(false) + expect('nonceKeys' in rpc).toBe(vector.nonceKeys !== undefined) + expect(rpc.nonceKeys).toEqual( + vector.nonceKeys?.map((key) => Hex.fromNumber(key)), + ) + expect(rpc.nonce).toBe(Hex.fromNumber(vector.nonce)) + expect(TxEnvelopeEip8141.serialize(TxEnvelopeEip8141.fromRpc(rpc))).toBe( + vector.serialized, + ) + const request = TransactionEnvelope.toTransactionRequest(value) + expect(request.nonceKeys).toEqual(vector.nonceKeys) + expect( + TransactionEnvelope.serialize(TransactionRequest.toEnvelope(request)), + ).toBe(vector.serialized) + }) + + test('request conversion preserves omitted sequence and frame gas', () => { + const request = { + type: 'eip8141', + nonceKeys: [1n, 2n], + frames: [{ mode: 'sender' }, { mode: 'sender', executionGas: 0n }], + } as const + const rpc = TransactionRequest.toRpc(request) + expect(rpc.nonce).toBeUndefined() + expect(rpc.nonceKeys).toMatchInlineSnapshot(` + [ + "0x1", + "0x2", + ] + `) + expect(rpc.frames?.map((frame) => frame.executionGas)).toEqual([ + undefined, + '0x0', + ]) + expect(TransactionRequest.fromRpc(rpc).nonceKeys).toEqual(request.nonceKeys) + expect(TransactionRequest.toRpc({ ...request, nonce: 0n }).nonce).toBe( + '0x0', + ) + }) + + test('transaction result conversion retains keyed nonces', () => { + const rpc = { + ...TxEnvelopeEip8141.toRpc({ + ...envelope, + nonceKeys: [1n, 2n], + nonce: 3n, + }), + blockHash: null, + blockNumber: null, + transactionIndex: null, + hash: `0x${'11'.repeat(32)}` as const, + } + const value = Transaction.fromRpc(rpc, { pending: true })! + expect(value.nonceKeys).toMatchInlineSnapshot(` + [ + 1n, + 2n, + ] + `) + expect(Transaction.toRpc(value, { pending: true }).nonceKeys).toEqual( + rpc.nonceKeys, + ) + expect(value.nonce).toBe(3n) + }) + + test('accepts numberish keys without losing uint256 precision', () => { + expect( + TxEnvelopeEip8141.toRpc({ + ...envelope, + nonceKeys: ['0x1', 128, 2n ** 256n - 1n], + nonce: '0x0', + }).nonceKeys, + ).toEqual(['0x1', '0x80', `0x${'ff'.repeat(32)}`]) + }) +}) + +describe('fromRpc', () => { + test('requires the sequence in keyed transaction results', () => { + const value = { + ...TxEnvelopeEip8141.toRpc({ ...envelope, nonceKeys: [1n] }), + nonce: undefined, + } + expect(() => + Transaction.fromRpc(value as never), + ).toThrowErrorMatchingInlineSnapshot( + `[FrameNonce.InvalidError: A keyed transaction result requires nonce.]`, + ) + }) + + test.each([ + '0x00', + '0x01', + '0x', + '0x10000000000000000', + '0xffffffffffffffff', + ])('rejects invalid keyed sequence %s', (nonce) => { + expect(() => + TxEnvelopeEip8141.fromRpc({ + ...TxEnvelopeEip8141.toRpc(envelope), + nonceKeys: ['0x1'], + nonce: nonce as Hex.Hex, + }), + ).toThrow() + expect(() => + TransactionRequest.fromRpc({ + nonceKeys: ['0x1'], + nonce: nonce as Hex.Hex, + }), + ).toThrow() + }) + test.each([['0x01'], ['0x'], ['0x0', '0x1'], ['0x2', '0x1']])( + 'rejects invalid RPC keys %j', + (...nonceKeys) => { + expect(() => + TransactionRequest.fromRpc({ nonceKeys: nonceKeys as Hex.Hex[] }), + ).toThrow() + }, + ) +}) diff --git a/src/core/_test/TxEnvelopeEip8141.test-d.ts b/src/core/_test/TxEnvelopeEip8141.test-d.ts index ebb430d69..708b7b23e 100644 --- a/src/core/_test/TxEnvelopeEip8141.test-d.ts +++ b/src/core/_test/TxEnvelopeEip8141.test-d.ts @@ -42,3 +42,30 @@ test('unsigned signature', () => { }>() expectTypeOf(envelope).toMatchTypeOf() }) + +test('keyed nonce literals and numberish RPC input', () => { + const envelope = TxEnvelopeEip8141.from({ + chainId: 1, + frames: [{}], + sender: '0x1111111111111111111111111111111111111111', + nonceKeys: [1n, 2n], + nonce: 3n, + }) + expectTypeOf(envelope.nonceKeys).toEqualTypeOf() + expectTypeOf(envelope.nonce).toEqualTypeOf<3n>() + expectTypeOf(TxEnvelopeEip8141.toRpc(envelope).nonceKeys).toEqualTypeOf< + readonly `0x${string}`[] | undefined + >() + expectTypeOf( + TxEnvelopeEip8141.toRpc({ + ...envelope, + nonceKeys: ['0x1', 2], + nonce: '0x3', + }), + ).toEqualTypeOf() + // @ts-expect-error decoded nonce keys are bigint + TxEnvelopeEip8141.from({ ...envelope, nonceKeys: [1] }) + expectTypeOf() + .exclude<'nonceSeq'>() + .toEqualTypeOf() +}) diff --git a/src/core/_test/TxEnvelopeEip8141.test.ts b/src/core/_test/TxEnvelopeEip8141.test.ts index 033f22315..d6da3fb22 100644 --- a/src/core/_test/TxEnvelopeEip8141.test.ts +++ b/src/core/_test/TxEnvelopeEip8141.test.ts @@ -402,7 +402,7 @@ describe('deserialize', () => { expect(() => TxEnvelopeEip8141.deserialize('0x06c0' as TxEnvelopeEip8141.Serialized), ).toThrowErrorMatchingInlineSnapshot( - `[TxEnvelopeEip8141.InvalidError: Expected seven transaction fields.]`, + `[TxEnvelopeEip8141.InvalidError: Expected seven or eight transaction fields.]`, ) }) @@ -426,7 +426,7 @@ describe('deserialize', () => { expect(() => TxEnvelopeEip8141.deserialize('0x06c180' as TxEnvelopeEip8141.Serialized), ).toThrowErrorMatchingInlineSnapshot( - `[TxEnvelopeEip8141.InvalidError: Expected seven transaction fields.]`, + `[TxEnvelopeEip8141.InvalidError: Expected seven or eight transaction fields.]`, ) }) diff --git a/src/core/internal/frameNonce.ts b/src/core/internal/frameNonce.ts new file mode 100644 index 000000000..bb17d3ffa --- /dev/null +++ b/src/core/internal/frameNonce.ts @@ -0,0 +1,61 @@ +import * as Errors from '../Errors.js' +import * as Hex from '../Hex.js' +import * as Quantity from './quantity.js' + +export function assert(keys: readonly bigint[], nonce?: bigint): void { + if (!Array.isArray(keys) || keys.length < 1 || keys.length > 16) + throw new InvalidError('Expected between 1 and 16 nonce keys.') + for (const [index, key] of keys.entries()) { + if (typeof key !== 'bigint' || key < 0n || key >= 2n ** 256n) + throw new InvalidError('Nonce keys must be unsigned 256-bit integers.') + if (index > 0 && key <= keys[index - 1]!) + throw new InvalidError('Nonce keys must be strictly increasing.') + } + if (keys.length > 1 && keys[0] === 0n) + throw new InvalidError( + 'The zero nonce key cannot be combined with other keys.', + ) + if ( + nonce !== undefined && + (typeof nonce !== 'bigint' || nonce < 0n || nonce >= 2n ** 64n - 1n) + ) + throw new InvalidError('Nonce must be less than 2^64 - 1 and nonnegative.') +} + +export function fromRpc( + keys: readonly Hex.Hex[], + nonce?: Hex.Hex, +): readonly bigint[] { + if (!Array.isArray(keys)) throw new InvalidError('Expected a nonce key list.') + const integer = (value: Hex.Hex): bigint => { + if ( + typeof value !== 'string' || + !/^0x(?:0|[1-9a-fA-F][0-9a-fA-F]*)$/.test(value) + ) + throw new InvalidError('Expected a canonical RPC nonce quantity.') + return Hex.toBigInt(value) + } + const decoded = keys.map(integer) + assert(decoded, nonce === undefined ? undefined : integer(nonce)) + return decoded +} + +export function toRpc( + keys: readonly (Hex.Hex | bigint | number)[], + nonce?: Hex.Hex | bigint | number, +): readonly Hex.Hex[] { + if (!Array.isArray(keys)) throw new InvalidError('Expected a nonce key list.') + const encoded = keys.map((key) => Quantity.fromNumberish(key)) + fromRpc( + encoded, + nonce === undefined ? undefined : Quantity.fromNumberish(nonce), + ) + return encoded +} + +export class InvalidError extends Errors.BaseError { + override readonly name = 'FrameNonce.InvalidError' + constructor(reason: string) { + super(reason) + } +} diff --git a/src/zod/Transaction.ts b/src/zod/Transaction.ts index c2d95ce72..afeacb464 100644 --- a/src/zod/Transaction.ts +++ b/src/zod/Transaction.ts @@ -4,7 +4,13 @@ import * as z_Address from './Address.js' import * as z_Authorization from './Authorization.js' import * as z_Frame from './Frame.js' import * as z_FrameSignature from './FrameSignature.js' -import { chainId, chainIdToRpc } from './internal/Frame.js' +import { + chainId, + chainIdToRpc, + nonceKeys, + nonceKeysToRpc, + validNonce, +} from './internal/Frame.js' import * as z_Hex from './Hex.js' import * as z_Number from './Number.js' import * as z_Uint from './Uint.js' @@ -142,37 +148,49 @@ export const PendingEip7702 = z.object( const Eip8141Type = type('0x6', 'eip8141') /** EIP-8141 transaction schema. */ -export const Eip8141 = z.object({ - ...eip8141Fields(z_Uint.Uint, chainId, z_Frame.Frame), - blockHash: z_Hex.Hex, - blockNumber: z_Uint.Uint, - blockTimestamp: z.optional(z_Uint.Uint), - transactionIndex: z_Number.Number, -}) +export const Eip8141 = z + .object({ + ...eip8141Fields(z_Uint.Uint, chainId, z_Frame.Frame, nonceKeys), + blockHash: z_Hex.Hex, + blockNumber: z_Uint.Uint, + blockTimestamp: z.optional(z_Uint.Uint), + transactionIndex: z_Number.Number, + }) + .check(z.refine(validNonce, 'Invalid keyed nonce')) /** Encode-only EIP-8141 transaction schema accepting numberish values. */ -export const Eip8141ToRpc = z.object({ - ...eip8141Fields(z_Uint.UintToRpc, chainIdToRpc, z_Frame.FrameToRpc), - blockHash: z_Hex.Hex, - blockNumber: z_Uint.UintToRpc, - blockTimestamp: z.optional(z_Uint.UintToRpc), - transactionIndex: z_Number.NumberToRpc, -}) +export const Eip8141ToRpc = z + .object({ + ...eip8141Fields( + z_Uint.UintToRpc, + chainIdToRpc, + z_Frame.FrameToRpc, + nonceKeysToRpc, + ), + blockHash: z_Hex.Hex, + blockNumber: z_Uint.UintToRpc, + blockTimestamp: z.optional(z_Uint.UintToRpc), + transactionIndex: z_Number.NumberToRpc, + }) + .check(z.refine(validNonce, 'Invalid keyed nonce')) /** Pending EIP-8141 transaction schema. */ -export const PendingEip8141 = z.object({ - ...eip8141Fields(z_Uint.Uint, chainId, z_Frame.Frame), - blockHash: z.null(), - blockNumber: z.null(), - blockTimestamp: z.optional(z.null()), - transactionIndex: z.null(), -}) +export const PendingEip8141 = z + .object({ + ...eip8141Fields(z_Uint.Uint, chainId, z_Frame.Frame, nonceKeys), + blockHash: z.null(), + blockNumber: z.null(), + blockTimestamp: z.optional(z.null()), + transactionIndex: z.null(), + }) + .check(z.refine(validNonce, 'Invalid keyed nonce')) function eip8141Fields< uint extends z.ZodMiniType, num extends z.ZodMiniType, frame extends z.ZodMiniType, ->(uint: uint, num: num, frame: frame) { + keys extends z.ZodMiniType, +>(uint: uint, num: num, frame: frame, keys: keys) { return { blobVersionedHashes: z.readonly(z.array(z_Hex.Hex)), chainId: num, @@ -187,6 +205,7 @@ function eip8141Fields< maxFeePerGas: uint, maxPriorityFeePerGas: uint, nonce: uint, + nonceKeys: z.optional(keys), signatures: z.readonly(z.array(z_FrameSignature.FrameSignature)), to: z.optional(z.nullable(z_Address.Address)), type: Eip8141Type, diff --git a/src/zod/TransactionRequest.ts b/src/zod/TransactionRequest.ts index d0101b0dd..7215f19b0 100644 --- a/src/zod/TransactionRequest.ts +++ b/src/zod/TransactionRequest.ts @@ -5,6 +5,7 @@ import * as z_Authorization from './Authorization.js' import * as z_FrameRequest from './FrameRequest.js' import * as z_FrameSignature from './FrameSignature.js' import * as z_Hex from './Hex.js' +import { nonceKeys, nonceKeysToRpc, validNonce } from './internal/Frame.js' import * as z_Number from './Number.js' import * as z_Uint from './Uint.js' import * as z from 'zod/mini' @@ -33,31 +34,44 @@ const type = z.codec(z.string(), z.string(), { }) /** Transaction request schema. */ -export const TransactionRequest = z.object( - fields( - z_Uint.Uint, - z_Number.Number, - z_Authorization.ListSigned, - z_FrameRequest.FrameRequest, - ), -) +export const TransactionRequest = z + .object( + fields( + z_Uint.Uint, + z_Number.Number, + z_Authorization.ListSigned, + z_FrameRequest.FrameRequest, + nonceKeys, + ), + ) + .check(z.refine(validNonce, 'Invalid keyed nonce')) /** Encode-only transaction request schema accepting numberish `toRpc` inputs. */ -export const TransactionRequestToRpc = z.object( - fields( - z_Uint.UintToRpc, - z_Number.NumberToRpc, - z_Authorization.ListSignedToRpc, - z_FrameRequest.FrameRequestToRpc, - ), -) +export const TransactionRequestToRpc = z + .object( + fields( + z_Uint.UintToRpc, + z_Number.NumberToRpc, + z_Authorization.ListSignedToRpc, + z_FrameRequest.FrameRequestToRpc, + nonceKeysToRpc, + ), + ) + .check(z.refine(validNonce, 'Invalid keyed nonce')) function fields< uint extends z.ZodMiniType, num extends z.ZodMiniType, authorizationList extends z.ZodMiniType, frame extends z.ZodMiniType, ->(uint: uint, num: num, authorizationList: authorizationList, frame: frame) { + keys extends z.ZodMiniType, +>( + uint: uint, + num: num, + authorizationList: authorizationList, + frame: frame, + keys: keys, +) { return { accessList: z.optional(z_AccessList.AccessList), authorizationList: z.optional(authorizationList), @@ -74,6 +88,7 @@ function fields< maxFeePerGas: z.optional(uint), maxPriorityFeePerGas: z.optional(uint), nonce: z.optional(uint), + nonceKeys: z.optional(keys), signatures: z.optional( z.readonly(z.array(z_FrameSignature.FrameSignature)), ), diff --git a/src/zod/TxEnvelopeEip8141.ts b/src/zod/TxEnvelopeEip8141.ts index 63fafe876..efafb6b5d 100644 --- a/src/zod/TxEnvelopeEip8141.ts +++ b/src/zod/TxEnvelopeEip8141.ts @@ -25,6 +25,7 @@ export const Rpc = z maxFeePerGas: z_Hex.Hex, maxPriorityFeePerGas: z_Hex.Hex, nonce: z_Hex.Hex, + nonceKeys: z.optional(z.readonly(z.array(z_Hex.Hex))), signatures: z.readonly(z.array(z_FrameSignature.Rpc)), type: z.literal('0x6'), }) @@ -94,6 +95,7 @@ function fields< maxFeePerGas: z.optional(uint), maxPriorityFeePerGas: z.optional(uint), nonce: z.optional(uint), + nonceKeys: z.optional(z.readonly(z.array(uint))), sender: z_Address.Address, sidecars: z.optional(z_TxEnvelopeEip4844.Sidecars), signatures: z.optional(z.readonly(z.array(z_FrameSignature.Decoded))), diff --git a/src/zod/_test/TxEnvelopeEip8141.nonce.test.ts b/src/zod/_test/TxEnvelopeEip8141.nonce.test.ts new file mode 100644 index 000000000..2d21185ee --- /dev/null +++ b/src/zod/_test/TxEnvelopeEip8141.nonce.test.ts @@ -0,0 +1,174 @@ +import { TxEnvelopeEip8141 } from 'ox' +import { z } from 'ox/zod' +import { describe, expect, test } from 'vp/test' + +const envelope = TxEnvelopeEip8141.from({ + chainId: 1, + frames: [{}], + sender: '0x1111111111111111111111111111111111111111', + nonceKeys: [1n, 2n ** 256n - 1n], + nonce: 2n ** 64n - 2n, +}) +const rpc = TxEnvelopeEip8141.toRpc(envelope) + +describe('TxEnvelopeEip8141', () => { + test('retains keyed nonces through envelope codecs', () => { + const decoded = z.decode(z.TransactionEnvelope.TransactionEnvelope, rpc) + expect(decoded).toEqual(TxEnvelopeEip8141.fromRpc(rpc)) + expect( + z.encode(z.TransactionEnvelope.TransactionEnvelope, envelope), + ).toEqual(rpc) + expect( + z.encode(z.TransactionEnvelope.TransactionEnvelopeToRpc, { + ...envelope, + nonceKeys: ['0x1', 2n ** 256n - 1n], + nonce: '0xfffffffffffffffe', + }), + ).toEqual(rpc) + const serialized = z.encode(z.TransactionEnvelope.serialized, envelope) + expect(z.decode(z.TransactionEnvelope.serialized, serialized)).toEqual( + decoded, + ) + expect(z.encode(z.TransactionEnvelope.Signed, envelope)).toEqual(rpc) + }) + + test.each([ + { nonceKeys: [] }, + { nonceKeys: ['0x0', '0x1'] }, + { nonceKeys: ['0x2', '0x1'] }, + { nonceKeys: ['0x1', '0x1'] }, + { nonceKeys: ['0x01'] }, + { nonceKeys: ['0x0'], nonce: '0xffffffffffffffff' }, + { nonce: '0x10000000000000000' }, + { nonce: '0x00' }, + { nonce: undefined }, + { nonceKeys: null }, + ])('rejects invalid keyed RPC envelope %#', (fields) => { + expect( + z.safeDecode(z.TxEnvelopeEip8141.TxEnvelopeEip8141, { + ...rpc, + ...fields, + } as never).success, + ).toBe(false) + }) + + test('rejects invalid decoded and numberish keys', () => { + for (const keys of [[], [0n, 1n], [2n, 1n], [1n, 1n], [2n ** 256n]]) { + expect( + z.safeEncode(z.TxEnvelopeEip8141.TxEnvelopeEip8141, { + ...envelope, + nonceKeys: keys, + }).success, + ).toBe(false) + expect( + z.safeEncode(z.TxEnvelopeEip8141.TxEnvelopeEip8141ToRpc, { + ...envelope, + nonceKeys: keys, + }).success, + ).toBe(false) + } + }) +}) + +describe('TransactionRequest', () => { + test('retains keys while leaving sequence and frame limits unassigned', () => { + const input = { + type: '0x6', + nonceKeys: ['0x1', '0x2'], + frames: [{ mode: '0x2' }, { mode: '0x2', executionGas: '0x0' }], + } as const + const decoded = z.decode(z.TransactionRequest.TransactionRequest, input) + expect(decoded.nonceKeys).toMatchInlineSnapshot(` + [ + 1n, + 2n, + ] + `) + expect(decoded.nonce).toBeUndefined() + expect(decoded.frames?.map((frame) => frame.executionGas)).toEqual([ + undefined, + 0n, + ]) + const encoded = z.encode(z.TransactionRequest.TransactionRequest, decoded) + expect(encoded.nonceKeys).toEqual(input.nonceKeys) + expect(encoded.nonce).toBeUndefined() + expect( + z.encode(z.TransactionRequest.TransactionRequestToRpc, { + nonceKeys: [1, '0x2'], + nonce: 0, + }).nonceKeys, + ).toEqual(input.nonceKeys) + }) + test.each([[], ['0x0', '0x1'], ['0x2', '0x1'], ['0x01'], ['0x1', '0x1']])( + 'rejects invalid keys %#', + (...nonceKeys) => { + expect( + z.safeDecode(z.TransactionRequest.TransactionRequest, { + nonceKeys, + } as never).success, + ).toBe(false) + }, + ) + test('rejects an exhausted sequence', () => { + expect( + z.safeDecode(z.TransactionRequest.TransactionRequest, { + nonceKeys: ['0x1'], + nonce: '0xffffffffffffffff', + }).success, + ).toBe(false) + expect( + z.safeEncode(z.TransactionRequest.TransactionRequestToRpc, { + nonceKeys: [1], + nonce: '0xffffffffffffffff', + }).success, + ).toBe(false) + }) +}) + +describe('Transaction', () => { + const pending = { + ...rpc, + blockHash: null, + blockNumber: null, + transactionIndex: null, + hash: `0x${'11'.repeat(32)}`, + } as const + const mined = { + ...pending, + blockHash: `0x${'22'.repeat(32)}`, + blockNumber: '0x1', + transactionIndex: '0x0', + } as const + test('preserves pending and mined keyed transaction results', () => { + const decoded = z.decode(z.Transaction.PendingEip8141, pending) + expect(decoded.nonceKeys).toEqual(envelope.nonceKeys) + expect(z.encode(z.Transaction.PendingEip8141, decoded)).toEqual(pending) + const result = z.decode(z.Transaction.Transaction, mined) + expect('nonceKeys' in result && result.nonceKeys).toEqual( + envelope.nonceKeys, + ) + expect(z.encode(z.Transaction.Transaction, result)).toEqual(mined) + expect(z.encode(z.Transaction.TransactionToRpc, result)).toEqual(mined) + }) + test('rejects invalid keyed transaction results', () => { + for (const fields of [ + { nonceKeys: ['0x2', '0x1'] }, + { nonceKeys: ['0x0', '0x1'] }, + { nonce: '0xffffffffffffffff' }, + { nonce: undefined }, + ]) { + expect( + z.safeDecode(z.Transaction.Transaction, { + ...mined, + ...fields, + } as never).success, + ).toBe(false) + expect( + z.safeDecode(z.Transaction.PendingEip8141, { + ...pending, + ...fields, + } as never).success, + ).toBe(false) + } + }) +}) diff --git a/src/zod/_test/TxEnvelopeEip8141.test-d.ts b/src/zod/_test/TxEnvelopeEip8141.test-d.ts new file mode 100644 index 000000000..6d3829290 --- /dev/null +++ b/src/zod/_test/TxEnvelopeEip8141.test-d.ts @@ -0,0 +1,29 @@ +import type { Transaction, TransactionRequest, TxEnvelopeEip8141 } from 'ox' +import { z } from 'ox/zod' +import { describe, expectTypeOf, test } from 'vp/test' + +describe('TxEnvelopeEip8141', () => { + test('nonce keys preserve decoded, RPC, and numberish types', () => { + expectTypeOf< + z.output + >().toEqualTypeOf() + expectTypeOf< + z.input + >().toEqualTypeOf() + expectTypeOf< + z.output + >().toMatchTypeOf() + expectTypeOf< + z.output['nonceKeys'] + >().toEqualTypeOf() + expectTypeOf< + z.input['nonceKeys'] + >().toEqualTypeOf() + expectTypeOf< + z.output['nonceKeys'] + >().toEqualTypeOf() + expectTypeOf< + z.input['nonceKeys'] + >().toEqualTypeOf() + }) +}) diff --git a/src/zod/internal/Frame.ts b/src/zod/internal/Frame.ts index 7e7d69b4f..1235a0442 100644 --- a/src/zod/internal/Frame.ts +++ b/src/zod/internal/Frame.ts @@ -1,5 +1,6 @@ /* eslint-disable jsdoc-js/require-jsdoc, jsdoc-js/require-description, jsdoc-js/require-example */ import * as Hex from '../../core/Hex.js' +import * as FrameNonce from '../../core/internal/frameNonce.js' import * as Quantity from '../../core/internal/quantity.js' import { quantityHex, uintBigint, uintNumber } from './Integer.js' import * as z from 'zod/mini' @@ -24,3 +25,47 @@ export const chainIdToRpc = z.codec( encode: (value) => Quantity.fromNumberish(value), }, ) + +export const nonceKeysRpc = z.readonly(z.array(quantityHex(256))).check( + z.refine((keys) => { + try { + FrameNonce.fromRpc(keys) + return true + } catch { + return false + } + }, 'Invalid nonce keys'), +) + +export const nonceKeys = z.codec( + nonceKeysRpc, + z.readonly(z.array(uintBigint(256))), + { + decode: (keys) => FrameNonce.fromRpc(keys), + encode: (keys) => FrameNonce.toRpc(keys), + }, +) + +export const nonceKeysToRpc = z.codec( + nonceKeysRpc, + z.readonly( + z.array(z.union([quantityHex(256), uintBigint(256), uintNumber()])), + ), + { + decode: (keys) => FrameNonce.fromRpc(keys), + encode: (keys) => FrameNonce.toRpc(keys), + }, +) + +export function validNonce(value: { + nonceKeys?: readonly (Hex.Hex | bigint | number)[] | undefined + nonce?: Hex.Hex | bigint | number | undefined +}): boolean { + if (value.nonceKeys === undefined) return true + try { + FrameNonce.toRpc(value.nonceKeys, value.nonce) + return true + } catch { + return false + } +} From 8107ee7064bb19780dc1f80a2641ff1d850a9c28 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:37:49 +1000 Subject: [PATCH 2/4] chore: use patch changeset for keyed nonces --- .changeset/frame-keyed-nonces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/frame-keyed-nonces.md b/.changeset/frame-keyed-nonces.md index 37af5af05..2e4f5ef6a 100644 --- a/.changeset/frame-keyed-nonces.md +++ b/.changeset/frame-keyed-nonces.md @@ -1,5 +1,5 @@ --- -"ox": minor +"ox": patch --- Added EIP-8250 keyed frame nonces with shared `nonce` sequences while preserving the original EIP-8141 encoding when `nonceKeys` is omitted. From ad543ee55f78e6f22c96a424e316eca7cabdc100 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:39:28 +1000 Subject: [PATCH 3/4] test: consolidate frame nonce tests --- .../_test/TxEnvelopeEip8141.nonce.test.ts | 451 ----------------- src/core/_test/TxEnvelopeEip8141.test.ts | 454 ++++++++++++++++++ 2 files changed, 454 insertions(+), 451 deletions(-) delete mode 100644 src/core/_test/TxEnvelopeEip8141.nonce.test.ts diff --git a/src/core/_test/TxEnvelopeEip8141.nonce.test.ts b/src/core/_test/TxEnvelopeEip8141.nonce.test.ts deleted file mode 100644 index d78ce989b..000000000 --- a/src/core/_test/TxEnvelopeEip8141.nonce.test.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { - Blobs, - Hex, - Secp256k1, - Transaction, - TransactionEnvelope, - TransactionRequest, - TxEnvelopeEip8141, -} from 'ox' -import { encodeRlp, keccak256 } from 'ethers' -import { describe, expect, test } from 'vp/test' - -const sender = '0x1111111111111111111111111111111111111111' -const envelope = TxEnvelopeEip8141.from({ - chainId: 1, - frames: [{ mode: 'sender' }], - sender, - signatures: [{ scheme: 'arbitrary', signature: '0xdeadbeef' }], -}) - -// Generated independently with ethers 6.17 from the literal EIP-8250 RLP layout. -const vectors = [ - { - nonceKeys: undefined, - nonce: 0n, - serialized: - '0x06f00180941111111111111111111111111111111111111111c9c8028080c280808080c9c880808084deadbeefc3808080c0', - hash: '0xa14c90dee4c545369e201e4975ee2ef415357df20a0a183d1be9f602bac9a47d', - signHash: - '0x5682abf965767351637964eed1fe3f775ab45de426def7da68736cf2ae0a43b5', - }, - { - nonceKeys: [0n], - nonce: 0n, - serialized: - '0x06f201c18080941111111111111111111111111111111111111111c9c8028080c280808080c9c880808084deadbeefc3808080c0', - hash: '0x069a1528377db98ed06429aab0dad8043315859ac454ce3e1f5a250e365d072d', - signHash: - '0xd573654ae8d57432b3856b0078fb98f2f3e4d3f73a023d7ba8a952a16d0f4726', - }, - { - nonceKeys: [1n, 128n, 2n ** 256n - 1n], - nonce: 2n ** 64n - 2n, - serialized: - '0x06f85d01e4018180a0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff88fffffffffffffffe941111111111111111111111111111111111111111c9c8028080c280808080c9c880808084deadbeefc3808080c0', - hash: '0x620915f063bdd017a09ae856d0043d064568b3455aba741d7ebc748731fc503f', - signHash: - '0x3d28f5f76f353611385be1512c2832220686b27cad3c53d5b07941de1515319b', - }, -] as const - -describe('serialize', () => { - test.each(vectors)('independent encoding and signing vector %#', (vector) => { - const value = { - ...envelope, - nonce: vector.nonce, - nonceKeys: vector.nonceKeys, - } - expect(TxEnvelopeEip8141.serialize(value)).toBe(vector.serialized) - expect(TxEnvelopeEip8141.hash(value)).toBe(vector.hash) - expect(TxEnvelopeEip8141.getSignPayload(value)).toBe(vector.signHash) - expect(TransactionEnvelope.serialize(value)).toBe(vector.serialized) - expect(TransactionEnvelope.getSignPayload(value)).toBe(vector.signHash) - const decoded = TransactionEnvelope.deserialize(vector.serialized) - expect(TxEnvelopeEip8141.serialize(decoded)).toBe(vector.serialized) - expect(decoded.nonceKeys).toEqual(vector.nonceKeys) - expect(decoded.nonce).toBe(vector.nonce) - expect(TxEnvelopeEip8141.getSignPayload(decoded)).toBe(vector.signHash) - }) - - test('matches independent literal RLP layout', () => { - const body = [ - '0x01', - ['0x01', '0x8180'], - '0x05', - sender, - [['0x02', '0x', '0x', ['0x', '0x'], '0x', '0x']], - [['0x', '0x', '0x', '0xdeadbeef']], - ['0x', '0x', '0x'], - [], - ] - const serialized = `0x06${encodeRlp(body).slice(2)}` - const value = { ...envelope, nonceKeys: [1n, 33152n], nonce: 5n } - expect(TxEnvelopeEip8141.serialize(value)).toBe(serialized) - expect(TxEnvelopeEip8141.hash(value)).toBe(keccak256(serialized)) - }) - - test('keyed PeerDAS wrapper preserves keys and excludes sidecars from hashes', () => { - const commitment = `0x${'00'.repeat(48)}` as const - const value = { - ...envelope, - nonceKeys: [1n], - blobVersionedHashes: [Blobs.commitmentToVersionedHash(commitment)], - sidecars: { - blobs: [`0x${'00'.repeat(Blobs.bytesPerBlob)}` as const], - commitments: [commitment], - cellProofs: Array.from({ length: 128 }, () => commitment), - }, - } - const serialized = TxEnvelopeEip8141.serialize(value) - const decoded = TxEnvelopeEip8141.deserialize(serialized) - expect(decoded.nonceKeys).toMatchInlineSnapshot(` - [ - 1n, - ] - `) - expect(TxEnvelopeEip8141.serialize(decoded)).toBe(serialized) - expect(TxEnvelopeEip8141.hash(value)).toBe( - TxEnvelopeEip8141.hash({ ...value, sidecars: undefined }), - ) - expect(TxEnvelopeEip8141.getSignPayload(value)).toBe( - TxEnvelopeEip8141.getSignPayload({ ...value, sidecars: undefined }), - ) - }) -}) - -describe('assert', () => { - test.each([ - [], - [0n, 1n], - [1n, 1n], - [2n, 1n], - [-1n], - [2n ** 256n], - Array.from({ length: 17 }, (_, i) => BigInt(i + 1)), - ])('rejects invalid keys %#', (...nonceKeys) => { - expect(TxEnvelopeEip8141.validate({ ...envelope, nonceKeys })).toBe(false) - expect(() => - TxEnvelopeEip8141.serialize({ ...envelope, nonceKeys }), - ).toThrow() - }) - - test('accepts 16 keys and the maximum key', () => { - expect( - TxEnvelopeEip8141.validate({ - ...envelope, - nonceKeys: Array.from({ length: 16 }, (_, i) => BigInt(i + 1)), - }), - ).toBe(true) - expect( - TxEnvelopeEip8141.validate({ ...envelope, nonceKeys: [2n ** 256n - 1n] }), - ).toBe(true) - }) - - test.each([-1n, 2n ** 64n - 1n, 2n ** 64n])( - 'rejects invalid shared sequence %s', - (nonce) => { - expect(() => - TxEnvelopeEip8141.assert({ ...envelope, nonceKeys: [1n], nonce }), - ).toThrowErrorMatchingInlineSnapshot( - `[FrameNonce.InvalidError: Nonce must be less than 2^64 - 1 and nonnegative.]`, - ) - }, - ) - - test('rejects a numeric key or null list', () => { - // @ts-expect-error invalid runtime input - expect(TxEnvelopeEip8141.validate({ ...envelope, nonceKeys: [1] })).toBe( - false, - ) - // @ts-expect-error invalid runtime input - expect(TxEnvelopeEip8141.validate({ ...envelope, nonceKeys: null })).toBe( - false, - ) - }) - - test('charges encoded nonce calldata against the transaction gas cap', () => { - const value = { - ...envelope, - signatures: [], - frames: [{ executionGas: 16_764_741n }], - } - expect(TxEnvelopeEip8141.validate(value)).toBe(true) - expect(TxEnvelopeEip8141.validate({ ...value, nonceKeys: [0n] })).toBe( - false, - ) - // rlp([0]) || rlp(0) = c1 80 80, three nonzero bytes at 16 gas each. - expect( - TxEnvelopeEip8141.validate({ - ...value, - nonceKeys: [0n], - frames: [{ executionGas: 16_764_693n }], - }), - ).toBe(true) - }) -}) - -describe('deserialize', () => { - test.each([ - ['0x', '0x'], - [[], '0x'], - [['0x00'], '0x'], - [['0x0001'], '0x'], - [[['0x01']], '0x'], - [['0x01'], '0x00'], - [['0x01'], '0x0001'], - [['0x01'], []], - [['0x01', '0x01'], '0x'], - [['0x02', '0x01'], '0x'], - [['0x', '0x01'], '0x'], - [[`0x01${'00'.repeat(32)}`], '0x'], - [['0x01'], '0x010000000000000000'], - ])('rejects malformed nonce fields %#', (keys, nonce) => { - const serialized = - `0x06${encodeRlp(['0x01', keys, nonce, sender, [['0x02', '0x', '0x', ['0x', '0x'], '0x', '0x']], [], ['0x', '0x', '0x'], []]).slice(2)}` as const - expect(() => TxEnvelopeEip8141.deserialize(serialized)).toThrow() - }) - - test('rejects nonminimal RLP string and list lengths', () => { - const canonical = vectors[1].serialized - expect(() => - TxEnvelopeEip8141.deserialize( - canonical.replace( - 'f201c180', - 'f301c28100', - ) as TxEnvelopeEip8141.Serialized, - ), - ).toThrow() - expect(() => - TxEnvelopeEip8141.deserialize( - canonical.replace( - 'f201c180', - 'f301f80180', - ) as TxEnvelopeEip8141.Serialized, - ), - ).toThrow() - expect(() => - TxEnvelopeEip8141.deserialize( - canonical.replace('f201', 'f83201') as TxEnvelopeEip8141.Serialized, - ), - ).toThrow() - }) -}) - -describe('getSignPayload', () => { - test('signatures bind the complete key set and sequence', () => { - const value = { - ...envelope, - nonceKeys: [1n, 2n], - nonce: 0n, - signatures: [{ scheme: 'secp256k1' } as const], - } - const privateKey = - '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' - const publicKey = Secp256k1.getPublicKey({ privateKey }) - const signature = Secp256k1.sign({ - privateKey, - payload: TxEnvelopeEip8141.getSignPayload(value), - }) - const signed = { - ...value, - signatures: [{ scheme: 'secp256k1', signature } as const], - } - expect( - Secp256k1.verify({ - publicKey, - signature, - payload: TxEnvelopeEip8141.getSignPayload(signed), - }), - ).toBe(true) - expect( - Secp256k1.verify({ - publicKey, - signature, - payload: TxEnvelopeEip8141.getSignPayload({ - ...signed, - nonceKeys: [1n, 3n], - }), - }), - ).toBe(false) - expect( - Secp256k1.verify({ - publicKey, - signature, - payload: TxEnvelopeEip8141.getSignPayload({ ...signed, nonce: 1n }), - }), - ).toBe(false) - }) - - test('commits to keys and shared sequence without mutating signatures', () => { - const value = { ...envelope, nonceKeys: [1n, 2n], nonce: 3n } - const hash = TxEnvelopeEip8141.getSignPayload(value) - expect( - TxEnvelopeEip8141.getSignPayload({ ...value, nonceKeys: [1n, 3n] }), - ).not.toBe(hash) - expect(TxEnvelopeEip8141.getSignPayload({ ...value, nonce: 4n })).not.toBe( - hash, - ) - expect( - TxEnvelopeEip8141.getSignPayload({ ...value, nonceKeys: [1n] }), - ).not.toBe(hash) - expect( - TxEnvelopeEip8141.getSignPayload({ - ...value, - signatures: [{ scheme: 'arbitrary', signature: '0xabcdef' }], - }), - ).toBe(hash) - expect(value.signatures[0].signature).toBe('0xdeadbeef') - }) - - test('retains explicit-message signatures', () => { - const value = { - ...envelope, - nonceKeys: [1n], - signatures: [ - { - scheme: 'arbitrary', - payload: `0x${'11'.repeat(32)}`, - signature: '0xdeadbeef', - } as const, - ], - } - expect(TxEnvelopeEip8141.getSignPayload(value)).toBe( - TxEnvelopeEip8141.hash(value), - ) - expect( - TxEnvelopeEip8141.getSignPayload({ - ...value, - signatures: [{ ...value.signatures[0]!, signature: '0xabcdef' }], - }), - ).not.toBe(TxEnvelopeEip8141.getSignPayload(value)) - }) -}) - -describe('toRpc', () => { - test.each(vectors)('preserves absent or explicit nonce keys %#', (vector) => { - const value = { - ...envelope, - nonceKeys: vector.nonceKeys, - nonce: vector.nonce, - } - const rpc = TxEnvelopeEip8141.toRpc(value) - expect('nonceSeq' in rpc).toBe(false) - expect('nonceKeys' in rpc).toBe(vector.nonceKeys !== undefined) - expect(rpc.nonceKeys).toEqual( - vector.nonceKeys?.map((key) => Hex.fromNumber(key)), - ) - expect(rpc.nonce).toBe(Hex.fromNumber(vector.nonce)) - expect(TxEnvelopeEip8141.serialize(TxEnvelopeEip8141.fromRpc(rpc))).toBe( - vector.serialized, - ) - const request = TransactionEnvelope.toTransactionRequest(value) - expect(request.nonceKeys).toEqual(vector.nonceKeys) - expect( - TransactionEnvelope.serialize(TransactionRequest.toEnvelope(request)), - ).toBe(vector.serialized) - }) - - test('request conversion preserves omitted sequence and frame gas', () => { - const request = { - type: 'eip8141', - nonceKeys: [1n, 2n], - frames: [{ mode: 'sender' }, { mode: 'sender', executionGas: 0n }], - } as const - const rpc = TransactionRequest.toRpc(request) - expect(rpc.nonce).toBeUndefined() - expect(rpc.nonceKeys).toMatchInlineSnapshot(` - [ - "0x1", - "0x2", - ] - `) - expect(rpc.frames?.map((frame) => frame.executionGas)).toEqual([ - undefined, - '0x0', - ]) - expect(TransactionRequest.fromRpc(rpc).nonceKeys).toEqual(request.nonceKeys) - expect(TransactionRequest.toRpc({ ...request, nonce: 0n }).nonce).toBe( - '0x0', - ) - }) - - test('transaction result conversion retains keyed nonces', () => { - const rpc = { - ...TxEnvelopeEip8141.toRpc({ - ...envelope, - nonceKeys: [1n, 2n], - nonce: 3n, - }), - blockHash: null, - blockNumber: null, - transactionIndex: null, - hash: `0x${'11'.repeat(32)}` as const, - } - const value = Transaction.fromRpc(rpc, { pending: true })! - expect(value.nonceKeys).toMatchInlineSnapshot(` - [ - 1n, - 2n, - ] - `) - expect(Transaction.toRpc(value, { pending: true }).nonceKeys).toEqual( - rpc.nonceKeys, - ) - expect(value.nonce).toBe(3n) - }) - - test('accepts numberish keys without losing uint256 precision', () => { - expect( - TxEnvelopeEip8141.toRpc({ - ...envelope, - nonceKeys: ['0x1', 128, 2n ** 256n - 1n], - nonce: '0x0', - }).nonceKeys, - ).toEqual(['0x1', '0x80', `0x${'ff'.repeat(32)}`]) - }) -}) - -describe('fromRpc', () => { - test('requires the sequence in keyed transaction results', () => { - const value = { - ...TxEnvelopeEip8141.toRpc({ ...envelope, nonceKeys: [1n] }), - nonce: undefined, - } - expect(() => - Transaction.fromRpc(value as never), - ).toThrowErrorMatchingInlineSnapshot( - `[FrameNonce.InvalidError: A keyed transaction result requires nonce.]`, - ) - }) - - test.each([ - '0x00', - '0x01', - '0x', - '0x10000000000000000', - '0xffffffffffffffff', - ])('rejects invalid keyed sequence %s', (nonce) => { - expect(() => - TxEnvelopeEip8141.fromRpc({ - ...TxEnvelopeEip8141.toRpc(envelope), - nonceKeys: ['0x1'], - nonce: nonce as Hex.Hex, - }), - ).toThrow() - expect(() => - TransactionRequest.fromRpc({ - nonceKeys: ['0x1'], - nonce: nonce as Hex.Hex, - }), - ).toThrow() - }) - test.each([['0x01'], ['0x'], ['0x0', '0x1'], ['0x2', '0x1']])( - 'rejects invalid RPC keys %j', - (...nonceKeys) => { - expect(() => - TransactionRequest.fromRpc({ nonceKeys: nonceKeys as Hex.Hex[] }), - ).toThrow() - }, - ) -}) diff --git a/src/core/_test/TxEnvelopeEip8141.test.ts b/src/core/_test/TxEnvelopeEip8141.test.ts index d6da3fb22..4dfb45748 100644 --- a/src/core/_test/TxEnvelopeEip8141.test.ts +++ b/src/core/_test/TxEnvelopeEip8141.test.ts @@ -1,3 +1,4 @@ +import { encodeRlp, keccak256 } from 'ethers' import { Address, Blobs, @@ -1812,3 +1813,456 @@ describe('fromRpc', () => { ) }) }) + +describe('keyed nonces', () => { + const sender = '0x1111111111111111111111111111111111111111' + const envelope = TxEnvelopeEip8141.from({ + chainId: 1, + frames: [{ mode: 'sender' }], + sender, + signatures: [{ scheme: 'arbitrary', signature: '0xdeadbeef' }], + }) + + // Generated independently with ethers 6.17 from the literal EIP-8250 RLP layout. + const vectors = [ + { + nonceKeys: undefined, + nonce: 0n, + serialized: + '0x06f00180941111111111111111111111111111111111111111c9c8028080c280808080c9c880808084deadbeefc3808080c0', + hash: '0xa14c90dee4c545369e201e4975ee2ef415357df20a0a183d1be9f602bac9a47d', + signHash: + '0x5682abf965767351637964eed1fe3f775ab45de426def7da68736cf2ae0a43b5', + }, + { + nonceKeys: [0n], + nonce: 0n, + serialized: + '0x06f201c18080941111111111111111111111111111111111111111c9c8028080c280808080c9c880808084deadbeefc3808080c0', + hash: '0x069a1528377db98ed06429aab0dad8043315859ac454ce3e1f5a250e365d072d', + signHash: + '0xd573654ae8d57432b3856b0078fb98f2f3e4d3f73a023d7ba8a952a16d0f4726', + }, + { + nonceKeys: [1n, 128n, 2n ** 256n - 1n], + nonce: 2n ** 64n - 2n, + serialized: + '0x06f85d01e4018180a0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff88fffffffffffffffe941111111111111111111111111111111111111111c9c8028080c280808080c9c880808084deadbeefc3808080c0', + hash: '0x620915f063bdd017a09ae856d0043d064568b3455aba741d7ebc748731fc503f', + signHash: + '0x3d28f5f76f353611385be1512c2832220686b27cad3c53d5b07941de1515319b', + }, + ] as const + + describe('serialize', () => { + test.each(vectors)( + 'independent encoding and signing vector %#', + (vector) => { + const value = { + ...envelope, + nonce: vector.nonce, + nonceKeys: vector.nonceKeys, + } + expect(TxEnvelopeEip8141.serialize(value)).toBe(vector.serialized) + expect(TxEnvelopeEip8141.hash(value)).toBe(vector.hash) + expect(TxEnvelopeEip8141.getSignPayload(value)).toBe(vector.signHash) + expect(TransactionEnvelope.serialize(value)).toBe(vector.serialized) + expect(TransactionEnvelope.getSignPayload(value)).toBe(vector.signHash) + const decoded = TransactionEnvelope.deserialize(vector.serialized) + expect(TxEnvelopeEip8141.serialize(decoded)).toBe(vector.serialized) + expect(decoded.nonceKeys).toEqual(vector.nonceKeys) + expect(decoded.nonce).toBe(vector.nonce) + expect(TxEnvelopeEip8141.getSignPayload(decoded)).toBe(vector.signHash) + }, + ) + + test('matches independent literal RLP layout', () => { + const body = [ + '0x01', + ['0x01', '0x8180'], + '0x05', + sender, + [['0x02', '0x', '0x', ['0x', '0x'], '0x', '0x']], + [['0x', '0x', '0x', '0xdeadbeef']], + ['0x', '0x', '0x'], + [], + ] + const serialized = `0x06${encodeRlp(body).slice(2)}` + const value = { ...envelope, nonceKeys: [1n, 33152n], nonce: 5n } + expect(TxEnvelopeEip8141.serialize(value)).toBe(serialized) + expect(TxEnvelopeEip8141.hash(value)).toBe(keccak256(serialized)) + }) + + test('keyed PeerDAS wrapper preserves keys and excludes sidecars from hashes', () => { + const commitment = `0x${'00'.repeat(48)}` as const + const value = { + ...envelope, + nonceKeys: [1n], + blobVersionedHashes: [Blobs.commitmentToVersionedHash(commitment)], + sidecars: { + blobs: [`0x${'00'.repeat(Blobs.bytesPerBlob)}` as const], + commitments: [commitment], + cellProofs: Array.from({ length: 128 }, () => commitment), + }, + } + const serialized = TxEnvelopeEip8141.serialize(value) + const decoded = TxEnvelopeEip8141.deserialize(serialized) + expect(decoded.nonceKeys).toMatchInlineSnapshot(` + [ + 1n, + ] + `) + expect(TxEnvelopeEip8141.serialize(decoded)).toBe(serialized) + expect(TxEnvelopeEip8141.hash(value)).toBe( + TxEnvelopeEip8141.hash({ ...value, sidecars: undefined }), + ) + expect(TxEnvelopeEip8141.getSignPayload(value)).toBe( + TxEnvelopeEip8141.getSignPayload({ ...value, sidecars: undefined }), + ) + }) + }) + + describe('assert', () => { + test.each([ + [], + [0n, 1n], + [1n, 1n], + [2n, 1n], + [-1n], + [2n ** 256n], + Array.from({ length: 17 }, (_, i) => BigInt(i + 1)), + ])('rejects invalid keys %#', (...nonceKeys) => { + expect(TxEnvelopeEip8141.validate({ ...envelope, nonceKeys })).toBe(false) + expect(() => + TxEnvelopeEip8141.serialize({ ...envelope, nonceKeys }), + ).toThrow() + }) + + test('accepts 16 keys and the maximum key', () => { + expect( + TxEnvelopeEip8141.validate({ + ...envelope, + nonceKeys: Array.from({ length: 16 }, (_, i) => BigInt(i + 1)), + }), + ).toBe(true) + expect( + TxEnvelopeEip8141.validate({ + ...envelope, + nonceKeys: [2n ** 256n - 1n], + }), + ).toBe(true) + }) + + test.each([-1n, 2n ** 64n - 1n, 2n ** 64n])( + 'rejects invalid shared sequence %s', + (nonce) => { + expect(() => + TxEnvelopeEip8141.assert({ ...envelope, nonceKeys: [1n], nonce }), + ).toThrowErrorMatchingInlineSnapshot( + `[FrameNonce.InvalidError: Nonce must be less than 2^64 - 1 and nonnegative.]`, + ) + }, + ) + + test('rejects a numeric key or null list', () => { + // @ts-expect-error invalid runtime input + expect(TxEnvelopeEip8141.validate({ ...envelope, nonceKeys: [1] })).toBe( + false, + ) + // @ts-expect-error invalid runtime input + expect(TxEnvelopeEip8141.validate({ ...envelope, nonceKeys: null })).toBe( + false, + ) + }) + + test('charges encoded nonce calldata against the transaction gas cap', () => { + const value = { + ...envelope, + signatures: [], + frames: [{ executionGas: 16_764_741n }], + } + expect(TxEnvelopeEip8141.validate(value)).toBe(true) + expect(TxEnvelopeEip8141.validate({ ...value, nonceKeys: [0n] })).toBe( + false, + ) + // rlp([0]) || rlp(0) = c1 80 80, three nonzero bytes at 16 gas each. + expect( + TxEnvelopeEip8141.validate({ + ...value, + nonceKeys: [0n], + frames: [{ executionGas: 16_764_693n }], + }), + ).toBe(true) + }) + }) + + describe('deserialize', () => { + test.each([ + ['0x', '0x'], + [[], '0x'], + [['0x00'], '0x'], + [['0x0001'], '0x'], + [[['0x01']], '0x'], + [['0x01'], '0x00'], + [['0x01'], '0x0001'], + [['0x01'], []], + [['0x01', '0x01'], '0x'], + [['0x02', '0x01'], '0x'], + [['0x', '0x01'], '0x'], + [[`0x01${'00'.repeat(32)}`], '0x'], + [['0x01'], '0x010000000000000000'], + ])('rejects malformed nonce fields %#', (keys, nonce) => { + const serialized = + `0x06${encodeRlp(['0x01', keys, nonce, sender, [['0x02', '0x', '0x', ['0x', '0x'], '0x', '0x']], [], ['0x', '0x', '0x'], []]).slice(2)}` as const + expect(() => TxEnvelopeEip8141.deserialize(serialized)).toThrow() + }) + + test('rejects nonminimal RLP string and list lengths', () => { + const canonical = vectors[1].serialized + expect(() => + TxEnvelopeEip8141.deserialize( + canonical.replace( + 'f201c180', + 'f301c28100', + ) as TxEnvelopeEip8141.Serialized, + ), + ).toThrow() + expect(() => + TxEnvelopeEip8141.deserialize( + canonical.replace( + 'f201c180', + 'f301f80180', + ) as TxEnvelopeEip8141.Serialized, + ), + ).toThrow() + expect(() => + TxEnvelopeEip8141.deserialize( + canonical.replace('f201', 'f83201') as TxEnvelopeEip8141.Serialized, + ), + ).toThrow() + }) + }) + + describe('getSignPayload', () => { + test('signatures bind the complete key set and sequence', () => { + const value = { + ...envelope, + nonceKeys: [1n, 2n], + nonce: 0n, + signatures: [{ scheme: 'secp256k1' } as const], + } + const privateKey = + '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + const publicKey = Secp256k1.getPublicKey({ privateKey }) + const signature = Secp256k1.sign({ + privateKey, + payload: TxEnvelopeEip8141.getSignPayload(value), + }) + const signed = { + ...value, + signatures: [{ scheme: 'secp256k1', signature } as const], + } + expect( + Secp256k1.verify({ + publicKey, + signature, + payload: TxEnvelopeEip8141.getSignPayload(signed), + }), + ).toBe(true) + expect( + Secp256k1.verify({ + publicKey, + signature, + payload: TxEnvelopeEip8141.getSignPayload({ + ...signed, + nonceKeys: [1n, 3n], + }), + }), + ).toBe(false) + expect( + Secp256k1.verify({ + publicKey, + signature, + payload: TxEnvelopeEip8141.getSignPayload({ ...signed, nonce: 1n }), + }), + ).toBe(false) + }) + + test('commits to keys and shared sequence without mutating signatures', () => { + const value = { ...envelope, nonceKeys: [1n, 2n], nonce: 3n } + const hash = TxEnvelopeEip8141.getSignPayload(value) + expect( + TxEnvelopeEip8141.getSignPayload({ ...value, nonceKeys: [1n, 3n] }), + ).not.toBe(hash) + expect( + TxEnvelopeEip8141.getSignPayload({ ...value, nonce: 4n }), + ).not.toBe(hash) + expect( + TxEnvelopeEip8141.getSignPayload({ ...value, nonceKeys: [1n] }), + ).not.toBe(hash) + expect( + TxEnvelopeEip8141.getSignPayload({ + ...value, + signatures: [{ scheme: 'arbitrary', signature: '0xabcdef' }], + }), + ).toBe(hash) + expect(value.signatures[0].signature).toBe('0xdeadbeef') + }) + + test('retains explicit-message signatures', () => { + const value = { + ...envelope, + nonceKeys: [1n], + signatures: [ + { + scheme: 'arbitrary', + payload: `0x${'11'.repeat(32)}`, + signature: '0xdeadbeef', + } as const, + ], + } + expect(TxEnvelopeEip8141.getSignPayload(value)).toBe( + TxEnvelopeEip8141.hash(value), + ) + expect( + TxEnvelopeEip8141.getSignPayload({ + ...value, + signatures: [{ ...value.signatures[0]!, signature: '0xabcdef' }], + }), + ).not.toBe(TxEnvelopeEip8141.getSignPayload(value)) + }) + }) + + describe('toRpc', () => { + test.each(vectors)( + 'preserves absent or explicit nonce keys %#', + (vector) => { + const value = { + ...envelope, + nonceKeys: vector.nonceKeys, + nonce: vector.nonce, + } + const rpc = TxEnvelopeEip8141.toRpc(value) + expect('nonceSeq' in rpc).toBe(false) + expect('nonceKeys' in rpc).toBe(vector.nonceKeys !== undefined) + expect(rpc.nonceKeys).toEqual( + vector.nonceKeys?.map((key) => Hex.fromNumber(key)), + ) + expect(rpc.nonce).toBe(Hex.fromNumber(vector.nonce)) + expect( + TxEnvelopeEip8141.serialize(TxEnvelopeEip8141.fromRpc(rpc)), + ).toBe(vector.serialized) + const request = TransactionEnvelope.toTransactionRequest(value) + expect(request.nonceKeys).toEqual(vector.nonceKeys) + expect( + TransactionEnvelope.serialize(TransactionRequest.toEnvelope(request)), + ).toBe(vector.serialized) + }, + ) + + test('request conversion preserves omitted sequence and frame gas', () => { + const request = { + type: 'eip8141', + nonceKeys: [1n, 2n], + frames: [{ mode: 'sender' }, { mode: 'sender', executionGas: 0n }], + } as const + const rpc = TransactionRequest.toRpc(request) + expect(rpc.nonce).toBeUndefined() + expect(rpc.nonceKeys).toMatchInlineSnapshot(` + [ + "0x1", + "0x2", + ] + `) + expect(rpc.frames?.map((frame) => frame.executionGas)).toEqual([ + undefined, + '0x0', + ]) + expect(TransactionRequest.fromRpc(rpc).nonceKeys).toEqual( + request.nonceKeys, + ) + expect(TransactionRequest.toRpc({ ...request, nonce: 0n }).nonce).toBe( + '0x0', + ) + }) + + test('transaction result conversion retains keyed nonces', () => { + const rpc = { + ...TxEnvelopeEip8141.toRpc({ + ...envelope, + nonceKeys: [1n, 2n], + nonce: 3n, + }), + blockHash: null, + blockNumber: null, + transactionIndex: null, + hash: `0x${'11'.repeat(32)}` as const, + } + const value = Transaction.fromRpc(rpc, { pending: true })! + expect(value.nonceKeys).toMatchInlineSnapshot(` + [ + 1n, + 2n, + ] + `) + expect(Transaction.toRpc(value, { pending: true }).nonceKeys).toEqual( + rpc.nonceKeys, + ) + expect(value.nonce).toBe(3n) + }) + + test('accepts numberish keys without losing uint256 precision', () => { + expect( + TxEnvelopeEip8141.toRpc({ + ...envelope, + nonceKeys: ['0x1', 128, 2n ** 256n - 1n], + nonce: '0x0', + }).nonceKeys, + ).toEqual(['0x1', '0x80', `0x${'ff'.repeat(32)}`]) + }) + }) + + describe('fromRpc', () => { + test('requires the sequence in keyed transaction results', () => { + const value = { + ...TxEnvelopeEip8141.toRpc({ ...envelope, nonceKeys: [1n] }), + nonce: undefined, + } + expect(() => + Transaction.fromRpc(value as never), + ).toThrowErrorMatchingInlineSnapshot( + `[FrameNonce.InvalidError: A keyed transaction result requires nonce.]`, + ) + }) + + test.each([ + '0x00', + '0x01', + '0x', + '0x10000000000000000', + '0xffffffffffffffff', + ])('rejects invalid keyed sequence %s', (nonce) => { + expect(() => + TxEnvelopeEip8141.fromRpc({ + ...TxEnvelopeEip8141.toRpc(envelope), + nonceKeys: ['0x1'], + nonce: nonce as Hex.Hex, + }), + ).toThrow() + expect(() => + TransactionRequest.fromRpc({ + nonceKeys: ['0x1'], + nonce: nonce as Hex.Hex, + }), + ).toThrow() + }) + test.each([['0x01'], ['0x'], ['0x0', '0x1'], ['0x2', '0x1']])( + 'rejects invalid RPC keys %j', + (...nonceKeys) => { + expect(() => + TransactionRequest.fromRpc({ nonceKeys: nonceKeys as Hex.Hex[] }), + ).toThrow() + }, + ) + }) +}) From 1bfc8ae75aab155a1bdf81ce4a850df07ebb6d2b Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:10:04 +1000 Subject: [PATCH 4/4] fix: infer frame transactions from nonce keys --- AGENTS.md | 2 +- src/core/TxEnvelope.ts | 9 +++++-- src/core/_test/TransactionEnvelope.test-d.ts | 27 +++++++++++++++++++ src/core/_test/TransactionEnvelope.test.ts | 26 ++++++++++++++++++ src/core/_test/TransactionRequest.test.ts | 28 ++++++++++++++++++++ 5 files changed, 89 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 12e237ecf..e32627891 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,4 +147,4 @@ - **Type snapshot config** -- `@ark/attest` 0.16 does not follow root project references. Point `ATTEST_CONFIG.tsconfig` at `test/tsconfig.json`. - **Node local storage** -- Node 24+ needs a valid `--localstorage-file` for the `@typescript/vfs` dependency used by attest. -- **Frame nonce encodings** -- omitted `nonceKeys` preserves seven-field EIP-8141 encoding; explicit keys, including `[0n]`, select eight-field EIP-8250 encoding. RPC keeps `nonce` as the shared sequence. +- **Frame nonce encodings** -- omitted `nonceKeys` preserves seven-field EIP-8141 encoding; explicit keys, including `[0n]`, select eight-field EIP-8250 encoding. RPC keeps `nonce` as the shared sequence. Infer `eip8141` from either defined `frames` or `nonceKeys`. diff --git a/src/core/TxEnvelope.ts b/src/core/TxEnvelope.ts index 7f93f2f77..1cad2389c 100644 --- a/src/core/TxEnvelope.ts +++ b/src/core/TxEnvelope.ts @@ -412,7 +412,10 @@ export function getType( return type as never } - if ('frames' in envelope && envelope.frames !== undefined) + if ( + ('frames' in envelope && envelope.frames !== undefined) || + ('nonceKeys' in envelope && envelope.nonceKeys !== undefined) + ) return 'eip8141' as never if ( 'authorizationList' in envelope && @@ -446,7 +449,9 @@ export declare namespace getType { type: infer type extends string } ? type - : HasDefined extends true + : true extends + | HasDefined + | HasDefined ? TxEnvelopeEip8141.Type : HasDefined extends true ? TxEnvelopeEip7702.Type diff --git a/src/core/_test/TransactionEnvelope.test-d.ts b/src/core/_test/TransactionEnvelope.test-d.ts index 9cd30657d..14a512ccc 100644 --- a/src/core/_test/TransactionEnvelope.test-d.ts +++ b/src/core/_test/TransactionEnvelope.test-d.ts @@ -10,6 +10,33 @@ import { import { describe, expectTypeOf, test } from 'vp/test' describe('getType', () => { + test('infers frame transactions from nonce keys', () => { + expectTypeOf( + TransactionEnvelope.getType({ nonceKeys: [1n] }), + ).toEqualTypeOf<'eip8141'>() + expectTypeOf( + TransactionEnvelope.getType({ nonceKeys: [0n] }), + ).toEqualTypeOf<'eip8141'>() + expectTypeOf( + TransactionEnvelope.getType({ nonceKeys: [] }), + ).toEqualTypeOf<'eip8141'>() + expectTypeOf( + TransactionEnvelope.getType({ + nonceKeys: [1n], + frames: undefined, + authorizationList: [], + blobVersionedHashes: [], + gasPrice: 1n, + }), + ).toEqualTypeOf<'eip8141'>() + expectTypeOf( + TransactionEnvelope.getType({ nonceKeys: undefined, maxFeePerGas: 1n }), + ).toEqualTypeOf<'eip1559'>() + expectTypeOf( + TransactionEnvelope.getType({ nonceKeys: [1n], type: 'eip1559' }), + ).toEqualTypeOf<'eip1559'>() + }) + test('behavior: infers explicit transaction types', () => { expectTypeOf( TransactionEnvelope.getType({ type: 'legacy' }), diff --git a/src/core/_test/TransactionEnvelope.test.ts b/src/core/_test/TransactionEnvelope.test.ts index c898c6b21..7ced8e9b4 100644 --- a/src/core/_test/TransactionEnvelope.test.ts +++ b/src/core/_test/TransactionEnvelope.test.ts @@ -99,6 +99,32 @@ test('exports', () => { }) describe('getType', () => { + test('infers frame transactions from nonce keys', () => { + expect([ + TransactionEnvelope.getType({ nonceKeys: [1n] }), + TransactionEnvelope.getType({ nonceKeys: [0n] }), + TransactionEnvelope.getType({ nonceKeys: [] }), + TransactionEnvelope.getType({ + nonceKeys: [1n], + frames: undefined, + authorizationList: [], + blobVersionedHashes: [], + gasPrice: 1n, + }), + TransactionEnvelope.getType({ nonceKeys: undefined, maxFeePerGas: 1n }), + TransactionEnvelope.getType({ nonceKeys: [1n], type: 'eip1559' }), + ]).toMatchInlineSnapshot(` + [ + "eip8141", + "eip8141", + "eip8141", + "eip8141", + "eip1559", + "eip1559", + ] + `) + }) + test('frame fields take precedence over blobs and fees', () => { expect( TransactionEnvelope.getType({ diff --git a/src/core/_test/TransactionRequest.test.ts b/src/core/_test/TransactionRequest.test.ts index df9691f99..cc14c20a9 100644 --- a/src/core/_test/TransactionRequest.test.ts +++ b/src/core/_test/TransactionRequest.test.ts @@ -13,6 +13,21 @@ import { kzg } from '../../../test/kzg.js' import { anvilMainnet } from '../../../test/prool.js' describe('toRpc', () => { + test('preserves nonce keys without type or frames', () => { + const request = { nonceKeys: [1n, 2n], nonce: 0n } + const rpc = TransactionRequest.toRpc(request) + expect(rpc).toMatchInlineSnapshot(` + { + "nonce": "0x0", + "nonceKeys": [ + "0x1", + "0x2", + ], + } + `) + expect(TransactionRequest.fromRpc(rpc)).toEqual(request) + }) + test('default', () => { const request = TransactionRequest.toRpc({ to: '0x0000000000000000000000000000000000000000', @@ -439,6 +454,19 @@ test('exports', () => { }) describe('frame transactions', () => { + test('requires frames when nonce keys select a frame envelope', () => { + expect(() => + TransactionRequest.toEnvelope({ + chainId: 1, + from: '0x1111111111111111111111111111111111111111', + nonceKeys: [1n], + nonce: 0n, + }), + ).toThrowErrorMatchingInlineSnapshot( + '[Quantity.MissingFieldError: Missing required field `frames` on `TransactionRequest`.]', + ) + }) + test('derives frame blob sidecars and replaces stale hashes', () => { const blobs = Blobs.from('0xdeadbeef') const request = {