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,
})
```
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Infer `eip8141` from either defined `frames` or `nonceKeys`.
33 changes: 33 additions & 0 deletions site/src/pages/guides/transactions/eip-8141.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 * as Quantity from './internal/quantity.js'
import type {
Compute,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -413,6 +426,7 @@ export declare namespace fromRpc {

type ErrorType =
| Signature.extract.ErrorType
| FrameNonce.InvalidError
| Frame.fromRpc.ErrorType
| FrameSignature.fromRpc.ErrorType
| Errors.GlobalErrorType
Expand Down Expand Up @@ -480,6 +494,8 @@ export function toRpc<pending extends boolean = false>(
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)
Expand Down Expand Up @@ -527,6 +543,7 @@ export declare namespace toRpc {

type ErrorType =
| Signature.extract.ErrorType
| FrameNonce.InvalidError
| Frame.toRpc.ErrorType
| FrameSignature.toRpc.ErrorType
| Errors.GlobalErrorType
Expand Down
12 changes: 12 additions & 0 deletions src/core/TransactionRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Comment on lines +62 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Infer EIP-8141 when nonceKeys is present

When an incomplete request supplies nonceKeys but omits both type and frames, TransactionRequest.toEnvelope delegates to TxEnvelope.getType, which only recognizes frames as the EIP-8141 discriminator. It therefore defaults to EIP-1559, interprets the shared sequence as a legacy nonce, and silently drops the keys instead of selecting EIP-8250 and reporting the missing required frames. Add nonceKeys to both the runtime and type-level getType inference so this input follows the EIP-8141 validation path.

AGENTS.md reference: AGENTS.md:L150-L150

Useful? React with 👍 / 👎.

/** Frame signature entries for EIP-8141 transactions. */
signatures?: readonly signature[] | undefined
/** Transaction recipient */
Expand Down Expand Up @@ -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[
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions src/core/TxEnvelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,10 @@ export function getType<const envelope extends Typeable>(
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 &&
Expand Down Expand Up @@ -446,7 +449,9 @@ export declare namespace getType {
type: infer type extends string
}
? type
: HasDefined<envelope, 'frames'> extends true
: true extends
| HasDefined<envelope, 'frames'>
| HasDefined<envelope, 'nonceKeys'>
Comment on lines +452 to +454

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve optional explicit types during nonce-key inference

When a caller has an optional discriminator such as { type?: 'eip1559'; nonceKeys: readonly bigint[] }, the first conditional does not match because type is optional, so this branch declares the result as only eip8141. At runtime, however, a defined type wins and returns eip1559, making getType and dependent serialize/toRpc return types unsound. Preserve the possible explicit type alongside the inferred fallback and add a type test for this case.

AGENTS.md reference: AGENTS.md:L45-L45

Useful? React with 👍 / 👎.

? TxEnvelopeEip8141.Type
: HasDefined<envelope, 'authorizationList'> extends true
? TxEnvelopeEip7702.Type
Expand Down
Loading
Loading