Skip to content

Frame Transactions (EIP-8141)

Overview

Use TxEnvelopeEip8141 to build a transaction containing multiple Frame calls. Each frame has its own execution mode, target, calldata, value, and gas budgets. The transaction carries a shared FrameSignature list rather than an outer ECDSA signature.

Build, Sign & Send

Create a verification frame that approves execution and payment, followed by a sender frame that transfers value. Omitting a frame's to selects the transaction sender.

The example assumes a chain ID, sender nonce, and configured RPC transport. Choose fees and per-frame budgets for your network; the values below illustrate the flow on a development network.

import {
  Address,
  Hex,
  RpcTransport,
  Secp256k1,
  TxEnvelopeEip8141,
  Value,
} from 'ox'
 
declare const chainId: number
declare const nonce: bigint
declare const privateKey: Hex.Hex
declare const recipient: Address.Address
declare const rpc: RpcTransport.Http
 
const sender = Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey }))
 
const envelope = TxEnvelopeEip8141.from({
  chainId,
  frames: [
    {
      executionGas: 50_000n,
      flags: 'approveExecutionAndPayment',
      mode: 'verify',
    },
    {
      executionGas: 50_000n,
      mode: 'sender',
      to: recipient,
      value: Value.fromEther('0.001'),
    },
  ],
  maxFeePerGas: Value.fromGwei('10'),
  maxPriorityFeePerGas: Value.fromGwei('1'),
  nonce,
  sender,
  signatures: [{ scheme: 'secp256k1' }],
})
 
const signature = Secp256k1.sign({
  payload: TxEnvelopeEip8141.getSignPayload(envelope),
  privateKey,
})
const signed = TxEnvelopeEip8141.from({
  ...envelope,
  signatures: [{ scheme: 'secp256k1', signature }],
})
 
const hash = await rpc.request({
  method: 'eth_sendRawTransaction',
  params: [TxEnvelopeEip8141.serialize(signed)],
})

Choose Frame Modes and Budgets

Frame.from accepts named modes or their numeric equivalents:

ModeValuePurpose
default0Execute a call as the protocol entry point.
verify1Validate the transaction.
sender2Execute a call as the transaction sender.

Use executionGas for execution and stateGas for state creation. Both default to zero; provide budgets appropriate for each call. A transfer to an existing account avoids new-account state costs. Contract calls that create storage or accounts can need stateGas as well.

Additional sender frames can carry contract calldata in data. Calls do not become an atomic batch merely by sharing a transaction. Use flags: 'atomicBatch' on each frame that joins the following frame to the batch, leaving the final frame unflagged. Verification frames cannot be part of an atomic batch.

Sign the Complete Envelope

Create the signature entries before calling getSignPayload, including their schemes and any explicit signers. An empty payload, the default, selects the canonical transaction signing hash. Attaching the resulting signature bytes preserves that hash. Changing frames, fees, nonce, or signature metadata requires signing again.

Signature entries do not correspond to frames by array position. They form a shared 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, supply nonceKeys to select nonce domains. nonce is the single sequence shared by every selected domain. Each domain must have that sequence before execution.

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 converts sender to from, frame to to target, and signature payload to msg. Frame gas fields remain executionGas and stateGas. RPC frame modes, flags, signature schemes, and gas and fee quantities are hex strings. Protocol signature placeholders omit signature.

import { TxEnvelopeEip8141 } from 'ox'
 
declare const envelope: TxEnvelopeEip8141.TxEnvelopeEip8141
 
const request = TxEnvelopeEip8141.toRpc(envelope)
const restored = TxEnvelopeEip8141.fromRpc(request)
const serialized = TxEnvelopeEip8141.serialize(restored)

Inspect Frame Receipts

Convert the transaction receipt with TransactionReceipt.fromRpc to read the payer and individual frame results. Each frame receipt exposes executionGasUsed, stateGasUsed, total gasUsed, logs, and a status of 'success', 'reverted', or 'skipped'.

import { Hex, RpcTransport, TransactionReceipt } from 'ox'
 
declare const hash: Hex.Hex
declare const rpc: RpcTransport.Http
 
const receipt = TransactionReceipt.fromRpc(
  await rpc.request({
    method: 'eth_getTransactionReceipt',
    params: [hash],
  }),
)
 
const payer = receipt?.payer
const frames = receipt?.frameReceipts

Use FrameReceipt.fromRpc when converting an individual frame receipt separately.

See Also