Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/frame-keyed-nonces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"ox": patch
---

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,
})
```
33 changes: 33 additions & 0 deletions site/pages/guides/frame-transactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/core/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -389,6 +402,7 @@ export declare namespace fromRpc {
}

type ErrorType =
| FrameNonce.InvalidError
| Frame.fromRpc.ErrorType
| FrameSignature.fromRpc.ErrorType
| Signature.extract.ErrorType
Expand Down Expand Up @@ -456,6 +470,8 @@ export function toRpc<pending extends boolean = false>(
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)
Expand Down Expand Up @@ -493,6 +509,7 @@ export declare namespace toRpc {
}

type ErrorType =
| FrameNonce.InvalidError
| Frame.toRpc.ErrorType
| FrameSignature.toRpc.ErrorType
| Signature.extract.ErrorType
Expand Down
9 changes: 9 additions & 0 deletions src/core/TransactionRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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[
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand All @@ -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
Expand Down
74 changes: 61 additions & 13 deletions src/core/TxEnvelopeEip8141.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -111,6 +121,7 @@ export function assert(envelope: PartialBy<TxEnvelopeEip8141, 'type'>): void {
maxFeePerGas = 0n,
maxPriorityFeePerGas = 0n,
nonce = 0n,
nonceKeys,
sender,
signatures = [],
} = envelope
Expand All @@ -123,6 +134,7 @@ export function assert(envelope: PartialBy<TxEnvelopeEip8141, 'type'>): 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],
Expand Down Expand Up @@ -162,6 +174,18 @@ export function assert(envelope: PartialBy<TxEnvelopeEip8141, 'type'>): 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 =
Expand Down Expand Up @@ -226,6 +250,7 @@ export function assert(envelope: PartialBy<TxEnvelopeEip8141, 'type'>): void {
export declare namespace assert {
type ErrorType =
| InvalidError
| FrameNonce.InvalidError
| Frame.assert.ErrorType
| FrameSignature.toTuple.ErrorType
| Address.assert.ErrorType
Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -408,13 +439,17 @@ 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),
})
}

export declare namespace fromRpc {
type ErrorType =
| FrameNonce.InvalidError
| from.ErrorType
| Frame.fromRpc.ErrorType
| FrameSignature.fromRpc.ErrorType
Expand Down Expand Up @@ -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',
}
Expand All @@ -467,6 +505,7 @@ export declare namespace toRpc {
| 'maxFeePerGas'
| 'maxPriorityFeePerGas'
| 'nonce'
| 'nonceKeys'
| 'type'
> & {
chainId: Hex.Hex | bigint | number
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -692,8 +735,7 @@ export class InvalidError extends Errors.BaseError {
function toTuple(envelope: PartialBy<TxEnvelopeEip8141, 'type'>) {
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)),
Expand All @@ -705,12 +747,18 @@ function toTuple(envelope: PartialBy<TxEnvelopeEip8141, 'type'>) {
],
envelope.blobVersionedHashes ?? [],
] satisfies [
Hex.Hex,
Hex.Hex,
Address.Address,
Frame.Tuple[],
FrameSignature.Tuple[],
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)
}
15 changes: 15 additions & 0 deletions src/core/_test/TransactionRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading