From 362ddd96a57cc722118e00134e7d4e53f5f0293b Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:36:10 +1000 Subject: [PATCH 1/4] feat: add keyed frame nonces --- .changeset/frame-keyed-nonces.md | 17 + site/pages/guides/frame-transactions.md | 33 ++ src/core/Transaction.ts | 17 + src/core/TransactionRequest.ts | 9 + src/core/TxEnvelopeEip8141.ts | 74 ++- .../_test/TxEnvelopeEip8141.nonce.test.ts | 445 ++++++++++++++++++ src/core/_test/TxEnvelopeEip8141.test-d.ts | 27 ++ src/core/_test/TxEnvelopeEip8141.test.ts | 4 +- src/core/internal/frameNonce.ts | 70 +++ 9 files changed, 681 insertions(+), 15 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 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/site/pages/guides/frame-transactions.md b/site/pages/guides/frame-transactions.md index dc0bf0bb6..284fcc4cb 100644 --- a/site/pages/guides/frame-transactions.md +++ b/site/pages/guides/frame-transactions.md @@ -113,6 +113,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 2726b2ade..864d80ac9 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 type { Compute, OneOf, UnionCompute } from './internal/types.js' import * as Signature from './Signature.js' @@ -214,6 +215,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 @@ -354,6 +357,16 @@ export function fromRpc< : null transaction_.value = BigInt(transaction.value ?? 0n) + 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.frames) transaction_.frames = transaction.frames.map(Frame.fromRpc) if (transaction.signatures) @@ -389,6 +402,7 @@ export declare namespace fromRpc { } type ErrorType = + | FrameNonce.InvalidError | Frame.fromRpc.ErrorType | FrameSignature.fromRpc.ErrorType | Signature.extract.ErrorType @@ -456,6 +470,8 @@ export function toRpc( rpc.type = (toRpcType as any)[transaction.type] ?? transaction.type rpc.value = Hex.fromNumber(transaction.value ?? 0n) + if (transaction.type === 'eip8141' && transaction.nonceKeys !== undefined) + rpc.nonceKeys = FrameNonce.toRpc(transaction.nonceKeys, transaction.nonce) if (transaction.frames) rpc.frames = transaction.frames.map(Frame.toRpc) if (transaction.signatures) rpc.signatures = transaction.signatures.map(FrameSignature.toRpc) @@ -493,6 +509,7 @@ export declare namespace toRpc { } type ErrorType = + | FrameNonce.InvalidError | Frame.toRpc.ErrorType | FrameSignature.toRpc.ErrorType | Signature.extract.ErrorType diff --git a/src/core/TransactionRequest.ts b/src/core/TransactionRequest.ts index 0f699c5a7..18c15558a 100644 --- a/src/core/TransactionRequest.ts +++ b/src/core/TransactionRequest.ts @@ -5,6 +5,7 @@ import type * 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 type { Compute } from './internal/types.js' import * as Transaction from './Transaction.js' @@ -48,6 +49,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 */ @@ -107,6 +110,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[ @@ -120,6 +125,7 @@ export function fromRpc(request: Rpc): TransactionRequest { export declare namespace fromRpc { export type ErrorType = + | FrameNonce.InvalidError | FrameRequest.fromRpc.ErrorType | FrameSignature.fromRpc.ErrorType | Authorization.fromRpcList.ErrorType @@ -211,6 +217,8 @@ export function toRpc(request: TransactionRequest): Rpc { ) if (typeof request.nonce !== 'undefined') request_rpc.nonce = Hex.fromNumber(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 = @@ -225,6 +233,7 @@ export function toRpc(request: TransactionRequest): Rpc { export declare namespace toRpc { export type ErrorType = + | FrameNonce.InvalidError | FrameRequest.toRpc.ErrorType | FrameSignature.toRpc.ErrorType | Authorization.toRpcList.ErrorType diff --git a/src/core/TxEnvelopeEip8141.ts b/src/core/TxEnvelopeEip8141.ts index 8baffe2b0..c64df9d0a 100644 --- a/src/core/TxEnvelopeEip8141.ts +++ b/src/core/TxEnvelopeEip8141.ts @@ -6,11 +6,17 @@ 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' -/** 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 @@ -24,8 +30,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. */ @@ -58,8 +66,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. */ @@ -111,6 +121,7 @@ export function assert(envelope: PartialBy): void { maxFeePerGas = 0n, maxPriorityFeePerGas = 0n, nonce = 0n, + nonceKeys, sender, signatures = [], } = envelope @@ -123,6 +134,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], @@ -162,6 +174,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 = @@ -226,6 +250,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 @@ -263,9 +288,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) || @@ -293,6 +323,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), @@ -408,6 +439,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), }) @@ -415,6 +449,7 @@ export function fromRpc(envelope: Rpc): TxEnvelopeEip8141 { export declare namespace fromRpc { type ErrorType = + | FrameNonce.InvalidError | from.ErrorType | Frame.fromRpc.ErrorType | FrameSignature.fromRpc.ErrorType @@ -453,6 +488,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', } @@ -467,6 +505,7 @@ export declare namespace toRpc { | 'maxFeePerGas' | 'maxPriorityFeePerGas' | 'nonce' + | 'nonceKeys' | 'type' > & { chainId: Hex.Hex | bigint | number @@ -475,9 +514,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 @@ -565,10 +606,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 { @@ -692,8 +735,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)), @@ -705,7 +747,6 @@ function toTuple(envelope: PartialBy) { ], envelope.blobVersionedHashes ?? [], ] satisfies [ - Hex.Hex, Hex.Hex, Address.Address, Frame.Tuple[], @@ -713,4 +754,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..9fb2c5afe --- /dev/null +++ b/src/core/_test/TxEnvelopeEip8141.nonce.test.ts @@ -0,0 +1,445 @@ +import { encodeRlp, keccak256 } from 'ethers' +import { + Blobs, + Hex, + Secp256k1, + Transaction, + TransactionRequest, + TxEnvelopeEip8141, +} from 'ox' +import { describe, expect, test } from 'vitest' + +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) + const decoded = TxEnvelopeEip8141.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 }), + ).toThrow('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, + ) + }) + + 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, + }), + gas: '0x0' as const, + input: '0x' as const, + to: null, + value: '0x0' as const, + 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 634415f0d..6eb4b1c3e 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 808c0ac98..556251a92 100644 --- a/src/core/_test/TxEnvelopeEip8141.test.ts +++ b/src/core/_test/TxEnvelopeEip8141.test.ts @@ -400,7 +400,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.]`, ) }) @@ -424,7 +424,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..80b2b2dae --- /dev/null +++ b/src/core/internal/frameNonce.ts @@ -0,0 +1,70 @@ +import * as Errors from '../Errors.js' +import * as Hex from '../Hex.js' +import * as Quantity from './quantity.js' + +/** + * Asserts keyed nonce constraints. + * @internal + */ +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.') +} + +/** + * Decodes and validates RPC nonce keys and their shared sequence. + * @internal + */ +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 +} + +/** + * Encodes and validates nonce keys and their shared sequence. + * @internal + */ +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' +} From 255b4e0be9de50e6197cff9bc1386346f2d9f768 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:37:50 +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 bb2166515a903f6d4f5030af5048093eacbbdada Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:39:29 +1000 Subject: [PATCH 3/4] test: consolidate frame nonce tests --- .../_test/TxEnvelopeEip8141.nonce.test.ts | 445 ----------------- src/core/_test/TxEnvelopeEip8141.test.ts | 449 ++++++++++++++++++ 2 files changed, 449 insertions(+), 445 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 9fb2c5afe..000000000 --- a/src/core/_test/TxEnvelopeEip8141.nonce.test.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { encodeRlp, keccak256 } from 'ethers' -import { - Blobs, - Hex, - Secp256k1, - Transaction, - TransactionRequest, - TxEnvelopeEip8141, -} from 'ox' -import { describe, expect, test } from 'vitest' - -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) - const decoded = TxEnvelopeEip8141.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 }), - ).toThrow('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, - ) - }) - - 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, - }), - gas: '0x0' as const, - input: '0x' as const, - to: null, - value: '0x0' as const, - 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 556251a92..cef14a60e 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, @@ -1772,3 +1773,451 @@ 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) + const decoded = TxEnvelopeEip8141.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 }), + ).toThrow('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) + }, + ) + + 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, + }), + gas: '0x0' as const, + input: '0x' as const, + to: null, + value: '0x0' as const, + 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 1830cdafda100f26fda86b2a07ad5d60ad64b611 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:10:05 +1000 Subject: [PATCH 4/4] test: preserve incomplete keyed nonce requests --- src/core/_test/TransactionRequest.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/core/_test/TransactionRequest.test.ts b/src/core/_test/TransactionRequest.test.ts index d6d67c5df..beef7dafe 100644 --- a/src/core/_test/TransactionRequest.test.ts +++ b/src/core/_test/TransactionRequest.test.ts @@ -3,6 +3,21 @@ import { describe, expect, test } from 'vitest' 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',