From 3d418be2d62bb2bbf8bfe4480c476041d74d9fbe Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:34:33 +1000 Subject: [PATCH 1/4] fix: align frame rpc requests and results --- .changeset/frame-rpc-fields.md | 2 +- src/core/FrameSignature.ts | 6 +- src/core/TransactionRequest.ts | 13 +- src/core/TxEnvelope.ts | 6 + src/core/_test/FrameRpc.test.ts | 138 ++++++++++++++++++++ src/core/_test/FrameSignature.test.ts | 2 +- src/core/_test/RpcSchema.test-d.ts | 45 +++++++ src/core/_test/TransactionEnvelope.test.ts | 2 +- src/core/_test/TransactionRequest.test-d.ts | 11 +- src/core/_test/TxEnvelopeEip8141.test.ts | 10 +- src/core/internal/frameRequest.ts | 34 +++++ src/core/internal/rpcSchemas/eth.ts | 17 ++- src/zod/FrameSignature.ts | 2 +- src/zod/TransactionRequest.ts | 6 +- src/zod/_test/FrameSignature.test.ts | 24 ++++ src/zod/_test/RpcSchema.test.ts | 106 +++++++++++++++ src/zod/_test/TransactionRequest.test.ts | 61 +++++++++ src/zod/internal/FrameRequest.ts | 45 +++++++ src/zod/internal/rpcSchemas/Eth.ts | 20 +++ 19 files changed, 529 insertions(+), 21 deletions(-) create mode 100644 src/core/_test/FrameRpc.test.ts create mode 100644 src/core/internal/frameRequest.ts create mode 100644 src/zod/internal/FrameRequest.ts diff --git a/.changeset/frame-rpc-fields.md b/.changeset/frame-rpc-fields.md index d34bf6338..e0a29d30e 100644 --- a/.changeset/frame-rpc-fields.md +++ b/.changeset/frame-rpc-fields.md @@ -2,4 +2,4 @@ "ox": patch --- -Aligned frame RPC quantities, signature placeholders, and receipt gas totals with the execution API definitions. +Aligned frame RPC quantities, request gas estimation, signature signers, fill responses, simulation results, and receipt gas totals with the execution API definitions. diff --git a/src/core/FrameSignature.ts b/src/core/FrameSignature.ts index f3af50648..f85ec50ba 100644 --- a/src/core/FrameSignature.ts +++ b/src/core/FrameSignature.ts @@ -73,7 +73,7 @@ export type Rpc = { /** Encoded signature bytes. */ signature?: Hex.Hex | undefined /** Signer address; absent for the transaction sender. */ - signer?: Address.Address | null | undefined + signer?: Address.Address | '0x' | undefined } /** RLP-ready signature entry. The payload occupies the specification's `msg` field. */ @@ -357,6 +357,10 @@ export declare namespace from { * @returns The converted value. */ export function fromRpc(entry: Rpc): FrameSignature { + if (entry.signer === null) + throw new InvalidError( + 'signer must be omitted, empty bytes, or an address.', + ) return fromTuple([ entry.scheme === '0x0' ? '0x' diff --git a/src/core/TransactionRequest.ts b/src/core/TransactionRequest.ts index 376840d71..ae1434829 100644 --- a/src/core/TransactionRequest.ts +++ b/src/core/TransactionRequest.ts @@ -3,9 +3,10 @@ import type * as Address from './Address.js' import * as Authorization from './Authorization.js' import * as Blobs from './Blobs.js' import * as Errors from './Errors.js' -import * as Frame from './Frame.js' +import type * as Frame from './Frame.js' import * as FrameSignature from './FrameSignature.js' import * as Hex from './Hex.js' +import * as FrameRequest from './internal/frameRequest.js' import * as Quantity from './internal/quantity.js' import type { Compute } from './internal/types.js' import type * as Kzg from './Kzg.js' @@ -19,7 +20,9 @@ export type TransactionRequest< bigintType = bigint, numberType = number, type extends string = string, - frame = [bigintType] extends [Hex.Hex] ? Frame.Rpc : Frame.Frame, + frame = [bigintType] extends [Hex.Hex] + ? FrameRequest.Rpc + : Frame.Frame, signature = [bigintType] extends [Hex.Hex] ? FrameSignature.Rpc : FrameSignature.FrameSignature, @@ -79,7 +82,7 @@ export type Rpc = TransactionRequest< Hex.Hex, Hex.Hex, string, - Frame.Rpc, + FrameRequest.Rpc, FrameSignature.Rpc > @@ -109,7 +112,7 @@ export function fromRpc(request: Rpc): TransactionRequest { if (typeof request.chainId !== 'undefined') request_.chainId = Hex.toNumber(request.chainId) if (request.frames !== undefined) - request_.frames = request.frames.map(Frame.fromRpc) + request_.frames = request.frames.map(FrameRequest.fromRpc) if (request.signatures !== undefined) request_.signatures = request.signatures.map(FrameSignature.fromRpc) if (typeof request.gas !== 'undefined') @@ -210,7 +213,7 @@ export function toRpc(request: toRpc.Input): Rpc { request_rpc.input = request.input } if (request.frames !== undefined) - request_rpc.frames = request.frames.map(Frame.toRpc) + request_rpc.frames = request.frames.map(FrameRequest.toRpc) if (request.signatures !== undefined) request_rpc.signatures = request.signatures.map(FrameSignature.toRpc) if (typeof request.from !== 'undefined') request_rpc.from = request.from diff --git a/src/core/TxEnvelope.ts b/src/core/TxEnvelope.ts index 02564cf86..7f93f2f77 100644 --- a/src/core/TxEnvelope.ts +++ b/src/core/TxEnvelope.ts @@ -781,6 +781,12 @@ export function toTransactionRequest( ...rest, ...(sidecars ? { blobs: sidecars.blobs } : {}), chainId: Hex.toNumber(Hex.fromNumber(rest.chainId)), + // Envelopes commit to zero defaults; request omission would trigger estimation. + frames: rest.frames.map((frame) => ({ + ...frame, + executionGas: frame.executionGas ?? 0n, + stateGas: frame.stateGas ?? 0n, + })), from: sender, } } diff --git a/src/core/_test/FrameRpc.test.ts b/src/core/_test/FrameRpc.test.ts new file mode 100644 index 000000000..a9a3a89a7 --- /dev/null +++ b/src/core/_test/FrameRpc.test.ts @@ -0,0 +1,138 @@ +import { + Frame, + FrameSignature, + TransactionRequest, + TransactionEnvelope, + TxEnvelopeEip8141, +} from 'ox' +import { describe, expect, test } from 'vp/test' + +describe('TransactionRequest.toRpc', () => { + test('preserves omitted frame gas and explicit zero limits', () => { + expect( + TransactionRequest.toRpc({ + type: 'eip8141', + frames: [ + { mode: 'sender' }, + { mode: 'sender', executionGas: 0n }, + { mode: 'sender', stateGas: 0n }, + { mode: 'sender', executionGas: 50_000n, stateGas: 1_000n }, + ], + }), + ).toEqual({ + type: '0x6', + frames: [ + { mode: '0x2', flags: '0x0', data: '0x', value: '0x0' }, + { + mode: '0x2', + flags: '0x0', + data: '0x', + value: '0x0', + executionGas: '0x0', + }, + { + mode: '0x2', + flags: '0x0', + data: '0x', + value: '0x0', + stateGas: '0x0', + }, + { + mode: '0x2', + flags: '0x0', + data: '0x', + value: '0x0', + executionGas: '0xc350', + stateGas: '0x3e8', + }, + ], + }) + }) +}) + +describe('TransactionRequest.fromRpc', () => { + test('decodes minimal requests without assigning gas limits', () => { + expect( + TransactionRequest.fromRpc({ + type: '0x6', + frames: [ + { mode: '0x2' }, + { mode: '0x2', executionGas: '0x0', stateGas: '0x0', target: null }, + ], + }), + ).toEqual({ + type: 'eip8141', + frames: [ + { mode: 2, flags: 0, data: '0x', value: 0n }, + { + mode: 2, + flags: 0, + data: '0x', + value: 0n, + executionGas: 0n, + stateGas: 0n, + }, + ], + }) + }) +}) + +describe('TxEnvelopeEip8141.toRpc', () => { + test('retains complete frame gas fields', () => { + const envelope = TxEnvelopeEip8141.toRpc( + TxEnvelopeEip8141.from({ + chainId: 1, + frames: [Frame.from({ mode: 'sender' })], + sender: '0x1111111111111111111111111111111111111111', + }), + ) + expect(envelope.frames).toEqual([ + { + mode: '0x2', + flags: '0x0', + data: '0x', + value: '0x0', + executionGas: '0x0', + stateGas: '0x0', + }, + ]) + }) +}) + +describe('FrameSignature.fromRpc', () => { + test('accepts empty signers and rejects null signers', () => { + expect(FrameSignature.fromRpc({ scheme: '0x1', signer: '0x' })).toEqual({ + scheme: 'secp256k1', + payload: '0x', + }) + expect(() => + FrameSignature.fromRpc({ + scheme: '0x1', + // @ts-expect-error Null is not an RPC signer. + signer: null, + }), + ).toThrow('signer must be omitted, empty bytes, or an address.') + }) +}) + +describe('TransactionEnvelope.toTransactionRequest', () => { + test('retains committed zero defaults when converting envelopes to requests', () => { + const envelope = TxEnvelopeEip8141.from({ + chainId: 1, + frames: [Frame.from({ mode: 'sender', executionGas: 50_000n })], + sender: '0x1111111111111111111111111111111111111111', + signatures: [FrameSignature.from('0xaabb')], + }) + const request = TransactionEnvelope.toTransactionRequest(envelope) + expect(request.frames).toEqual([ + { mode: 'sender', executionGas: 50_000n, stateGas: 0n }, + ]) + expect( + TxEnvelopeEip8141.getSignPayload( + TransactionRequest.toEnvelope( + request, + ) as TxEnvelopeEip8141.TxEnvelopeEip8141, + ), + ).toBe(TxEnvelopeEip8141.getSignPayload(envelope)) + }) +}) diff --git a/src/core/_test/FrameSignature.test.ts b/src/core/_test/FrameSignature.test.ts index 4eb298c9a..c4296499f 100644 --- a/src/core/_test/FrameSignature.test.ts +++ b/src/core/_test/FrameSignature.test.ts @@ -457,7 +457,7 @@ describe('fromRpc', () => { msg: '0x', scheme: '0x0', signature: '0xaabb', - signer: null, + signer: '0x', }), ).toEqual(FrameSignature.from('0xaabb')) }) diff --git a/src/core/_test/RpcSchema.test-d.ts b/src/core/_test/RpcSchema.test-d.ts index cd5e3cc19..aa64f2d33 100644 --- a/src/core/_test/RpcSchema.test-d.ts +++ b/src/core/_test/RpcSchema.test-d.ts @@ -214,3 +214,48 @@ test('eth_call accepts a 4-tuple with state + block overrides', () => { ] >().toExtend() }) + +test('frame fill responses do not require raw bytes or lookup metadata', () => { + type Filled = Extract< + RpcSchema.Eth, + { Request: { method: 'eth_fillTransaction' } } + >['ReturnType'] + const filled = { + tx: { + type: '0x6', + chainId: '0x1', + nonce: '0x0', + from: '0x1111111111111111111111111111111111111111', + frames: [ + { + mode: '0x2', + flags: '0x0', + executionGas: '0x10000', + stateGas: '0x0', + value: '0x0', + data: '0x', + }, + ], + signatures: [{ scheme: '0x1' }], + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x1', + maxFeePerBlobGas: '0x0', + blobVersionedHashes: [], + }, + } as const satisfies Filled + expectTypeOf(filled).toExtend() +}) + +test('frame simulation results expose payer and frame results', () => { + type Result = Extract< + RpcSchema.Eth, + { Request: { method: 'eth_simulateV1' } } + >['ReturnType'][number] + type Call = NonNullable[number] + expectTypeOf().toEqualTypeOf<`0x${string}` | undefined>() + type Frame = NonNullable[number] + expectTypeOf().toEqualTypeOf<'0x0' | '0x1' | '0x2'>() + expectTypeOf().toEqualTypeOf<`0x${string}`>() + expectTypeOf().toEqualTypeOf<`0x${string}`>() + expectTypeOf().toEqualTypeOf<`0x${string}`>() +}) diff --git a/src/core/_test/TransactionEnvelope.test.ts b/src/core/_test/TransactionEnvelope.test.ts index 862def515..c898c6b21 100644 --- a/src/core/_test/TransactionEnvelope.test.ts +++ b/src/core/_test/TransactionEnvelope.test.ts @@ -434,7 +434,7 @@ describe('toTransactionRequest', () => { test('maps frame sender to request from', () => { expect(TransactionEnvelope.toTransactionRequest(eip8141)).toEqual({ chainId: 1, - frames: [{}], + frames: [{ executionGas: 0n, stateGas: 0n }], from: '0x1111111111111111111111111111111111111111', type: 'eip8141', }) diff --git a/src/core/_test/TransactionRequest.test-d.ts b/src/core/_test/TransactionRequest.test-d.ts index 96e887a12..2c4c4ba2b 100644 --- a/src/core/_test/TransactionRequest.test-d.ts +++ b/src/core/_test/TransactionRequest.test-d.ts @@ -139,7 +139,16 @@ describe('frame generics', () => { test('RPC types', () => { expectTypeOf().toEqualTypeOf< - readonly Frame.Rpc[] | undefined + | readonly { + mode: `0x${string}` + flags?: `0x${string}` | undefined + data?: `0x${string}` | undefined + executionGas?: `0x${string}` | undefined + stateGas?: `0x${string}` | undefined + target?: `0x${string}` | null | undefined + value?: `0x${string}` | undefined + }[] + | undefined >() expectTypeOf().toEqualTypeOf< readonly FrameSignature.Rpc[] | undefined diff --git a/src/core/_test/TxEnvelopeEip8141.test.ts b/src/core/_test/TxEnvelopeEip8141.test.ts index c6f233f67..c6e95e474 100644 --- a/src/core/_test/TxEnvelopeEip8141.test.ts +++ b/src/core/_test/TxEnvelopeEip8141.test.ts @@ -633,10 +633,12 @@ describe('serialize', () => { executionGas: 50_000n, flags: 'approveExecutionAndPayment', mode: 'verify', + stateGas: 0n, }), Frame.from({ executionGas: 50_000n, mode: 'sender', + stateGas: 0n, to: target, value: 1n, }), @@ -848,10 +850,12 @@ describe('serialize', () => { }) expect(hash).toBe(TransactionEnvelope.hash(signed)) expect( - TransactionRequest.toEnvelope( - TransactionEnvelope.toTransactionRequest(signed), + TransactionEnvelope.serialize( + TransactionRequest.toEnvelope( + TransactionEnvelope.toTransactionRequest(signed), + ), ), - ).toEqual(signed) + ).toBe(serialized) expect(TxEnvelopeEip8141.toRpc(signed)).toMatchObject( TransactionRequest.toRpc( TransactionEnvelope.toTransactionRequest(signed), diff --git a/src/core/internal/frameRequest.ts b/src/core/internal/frameRequest.ts new file mode 100644 index 000000000..7d52159e0 --- /dev/null +++ b/src/core/internal/frameRequest.ts @@ -0,0 +1,34 @@ +import * as Frame from '../Frame.js' +import type { Compute, PartialBy } from './types.js' + +/** @internal */ +export type Rpc = Compute< + PartialBy> +> + +/** @internal */ +export function fromRpc(frame: Rpc): Frame.Frame { + const { executionGas, stateGas, ...rest } = Frame.fromRpc({ + ...frame, + data: frame.data ?? '0x', + executionGas: frame.executionGas ?? '0x0', + flags: frame.flags ?? '0x0', + stateGas: frame.stateGas ?? '0x0', + value: frame.value ?? '0x0', + }) + return { + ...rest, + ...(frame.executionGas === undefined ? {} : { executionGas }), + ...(frame.stateGas === undefined ? {} : { stateGas }), + } +} + +/** @internal */ +export function toRpc(frame: Frame.toRpc.Input): Rpc { + const { executionGas, stateGas, ...rest } = Frame.toRpc(frame) + return { + ...rest, + ...(frame.executionGas === undefined ? {} : { executionGas }), + ...(frame.stateGas === undefined ? {} : { stateGas }), + } +} diff --git a/src/core/internal/rpcSchemas/eth.ts b/src/core/internal/rpcSchemas/eth.ts index 661ca3998..588e17ea5 100644 --- a/src/core/internal/rpcSchemas/eth.ts +++ b/src/core/internal/rpcSchemas/eth.ts @@ -4,6 +4,7 @@ import type * as Block from '../../Block.js' import type * as BlockOverrides from '../../BlockOverrides.js' import type * as Fee from '../../Fee.js' import type * as Filter from '../../Filter.js' +import type * as FrameReceipt from '../../FrameReceipt.js' import type * as Hex from '../../Hex.js' import type * as Log from '../../Log.js' import type * as RpcSchema from '../../RpcSchema.js' @@ -11,6 +12,7 @@ import type * as StateOverrides from '../../StateOverrides.js' import type * as Transaction from '../../Transaction.js' import type * as TransactionReceipt from '../../TransactionReceipt.js' import type * as TransactionRequest from '../../TransactionRequest.js' +import type * as TxEnvelope from '../../TxEnvelope.js' /** * Union of all JSON-RPC Methods for the `eth_` namespace. @@ -219,12 +221,12 @@ export type Eth = RpcSchema.From< ReturnType: Fee.FeeHistoryRpc } /** - * Fills the defaults (nonce, gas, gasPrice, or 1559 fields) on a given unsigned transaction, and returns the unsigned transaction along with its raw form + * Fills transaction defaults and returns the unsigned transaction. * * @example * ``` * request({ method: 'eth_fillTransaction', params: [{ from: '0x...', to: '0x...', value: '0x...' }] }) - * // { raw: '0x...', tx: { ... } } + * // { tx: { ... } } * ``` */ | { @@ -234,8 +236,8 @@ export type Eth = RpcSchema.From< } ReturnType: { capabilities?: Record | undefined - raw: Hex.Hex - tx: Transaction.Rpc + raw?: Hex.Hex | undefined + tx: TxEnvelope.Rpc & { from: Address.Address } } } /** @@ -769,6 +771,13 @@ export type Eth = RpcSchema.From< message: string } | undefined + frameResults?: + | readonly (FrameReceipt.Rpc & { + error?: { code: number; message: string } | undefined + returnData: Hex.Hex + })[] + | undefined + payer?: Address.Address | undefined logs?: readonly Log.Rpc[] | undefined gasUsed: Hex.Hex returnData: Hex.Hex diff --git a/src/zod/FrameSignature.ts b/src/zod/FrameSignature.ts index 3037e6bf4..d2fbf99ea 100644 --- a/src/zod/FrameSignature.ts +++ b/src/zod/FrameSignature.ts @@ -61,7 +61,7 @@ export const Rpc = z msg: z.optional(z_Hex.Hex), scheme: z.enum(['0x0', '0x1', '0x2']), signature: z.optional(z_Hex.Hex), - signer: z.optional(z.nullable(z_Address.Address)), + signer: z.optional(z.union([z.literal('0x'), z_Address.Address])), }) .check( z.refine((value) => { diff --git a/src/zod/TransactionRequest.ts b/src/zod/TransactionRequest.ts index 6c29078d8..35771b8ba 100644 --- a/src/zod/TransactionRequest.ts +++ b/src/zod/TransactionRequest.ts @@ -2,7 +2,7 @@ import * as z_AccessList from './AccessList.js' import * as z_Address from './Address.js' import * as z_Authorization from './Authorization.js' -import * as z_Frame from './Frame.js' +import * as z_FrameRequest from './internal/FrameRequest.js' import * as z_FrameSignature from './FrameSignature.js' import * as z_Hex from './Hex.js' import * as z_Number from './Number.js' @@ -38,7 +38,7 @@ export const TransactionRequest = z.object( z_Uint.Uint, z_Number.Number, z_Authorization.ListSigned, - z_Frame.Frame, + z_FrameRequest.FrameRequest, ), ) @@ -48,7 +48,7 @@ export const TransactionRequestToRpc = z.object( z_Uint.UintToRpc, z_Number.NumberToRpc, z_Authorization.ListSignedToRpc, - z_Frame.FrameToRpc, + z_FrameRequest.FrameRequestToRpc, ), ) diff --git a/src/zod/_test/FrameSignature.test.ts b/src/zod/_test/FrameSignature.test.ts index 6bde20376..f1bb79fdf 100644 --- a/src/zod/_test/FrameSignature.test.ts +++ b/src/zod/_test/FrameSignature.test.ts @@ -119,3 +119,27 @@ test('encodes protocol hex signatures', () => { }), ).toEqual(FrameSignature.toRpc(p256)) }) + +describe('RPC signers', () => { + test.each(['0x0', '0x1', '0x2'] as const)( + 'accepts an empty signer for scheme %s', + (scheme) => { + expect( + z.safeDecode(z.FrameSignature.FrameSignature, { + scheme, + signer: '0x', + msg: '0x', + ...(scheme === '0x0' ? { signature: '0x' } : {}), + }).success, + ).toBe(true) + }, + ) + test('rejects a null signer', () => { + expect( + z.safeParse(z.FrameSignature.FrameSignature, { + scheme: '0x1', + signer: null, + }).success, + ).toBe(false) + }) +}) diff --git a/src/zod/_test/RpcSchema.test.ts b/src/zod/_test/RpcSchema.test.ts index f78176a13..89a6e035e 100644 --- a/src/zod/_test/RpcSchema.test.ts +++ b/src/zod/_test/RpcSchema.test.ts @@ -423,3 +423,109 @@ describe('from', () => { expect(schema.abe_foo.method).toMatchInlineSnapshot(`"abe_foo"`) }) }) + +describe('frame simulation results', () => { + test('preserves payer, per-frame output, errors, and gas through the codec', () => { + const block = { + difficulty: '0x0', + extraData: '0x', + gasLimit: '0x100000', + gasUsed: '0x5208', + hash, + logsBloom: '0x', + miner: address, + mixHash: hash, + nonce: '0x0000000000000000', + number: '0x1', + parentHash: hash, + receiptsRoot: hash, + sha3Uncles: hash, + size: '0x1', + stateRoot: hash, + timestamp: '0x1', + transactions: [], + transactionsRoot: hash, + uncles: [], + calls: [ + { + payer: address, + gasUsed: '0x5208', + returnData: '0xdead', + status: '0x0', + logs: [], + frameResults: [ + { + status: '0x1', + gasUsed: '0x2', + executionGasUsed: '0x1', + stateGasUsed: '0x1', + logs: [], + returnData: '0xabcd', + }, + { + status: '0x0', + gasUsed: '0x1', + executionGasUsed: '0x1', + stateGasUsed: '0x0', + logs: [], + returnData: '0xdead', + error: { code: 3, message: 'execution reverted' }, + }, + { + status: '0x2', + gasUsed: '0x0', + executionGasUsed: '0x0', + stateGasUsed: '0x0', + logs: [], + returnData: '0x', + }, + ], + }, + ], + } satisfies z.input[number] + const decoded = z_RpcSchema.decodeReturns( + z_RpcSchema.Eth, + 'eth_simulateV1', + [block], + ) + expect(decoded[0]?.calls).toEqual([ + { + payer: address, + gasUsed: '0x5208', + returnData: '0xdead', + status: '0x0', + logs: [], + frameResults: [ + { + status: 'success', + gasUsed: 2n, + executionGasUsed: 1n, + stateGasUsed: 1n, + logs: [], + returnData: '0xabcd', + }, + { + status: 'reverted', + gasUsed: 1n, + executionGasUsed: 1n, + stateGasUsed: 0n, + logs: [], + returnData: '0xdead', + error: { code: 3, message: 'execution reverted' }, + }, + { + status: 'skipped', + gasUsed: 0n, + executionGasUsed: 0n, + stateGasUsed: 0n, + logs: [], + returnData: '0x', + }, + ], + }, + ]) + expect( + z_RpcSchema.encodeReturns(z_RpcSchema.Eth, 'eth_simulateV1', decoded), + ).toEqual([block]) + }) +}) diff --git a/src/zod/_test/TransactionRequest.test.ts b/src/zod/_test/TransactionRequest.test.ts index d0e8272aa..e903ee37a 100644 --- a/src/zod/_test/TransactionRequest.test.ts +++ b/src/zod/_test/TransactionRequest.test.ts @@ -220,3 +220,64 @@ describe('TransactionRequest', () => { ).toBe(false) }) }) + +describe('frame requests', () => { + test.each([ + z_TransactionRequest.TransactionRequest, + z_TransactionRequest.TransactionRequestToRpc, + ])('preserves gas omission and zero through the request codec', (schema) => { + const decoded = z.decode(schema, { + type: '0x6', + frames: [{ mode: '0x2' }, { mode: '0x2', stateGas: '0x0' }], + }) + expect(decoded).toEqual({ + type: 'eip8141', + frames: [ + { mode: 2, flags: 0, data: '0x', value: 0n }, + { mode: 2, flags: 0, data: '0x', value: 0n, stateGas: 0n }, + ], + }) + expect(z.encode(schema, decoded)).toEqual({ + type: '0x6', + frames: [ + { mode: '0x2', flags: '0x0', data: '0x', value: '0x0' }, + { + mode: '0x2', + flags: '0x0', + data: '0x', + value: '0x0', + stateGas: '0x0', + }, + ], + }) + }) + test('encodes numberish frame limits', () => { + expect( + z.encode(z_TransactionRequest.TransactionRequestToRpc, { + frames: [{ mode: 'sender', executionGas: 50000, stateGas: '0x0' }], + }), + ).toEqual({ + frames: [ + { + mode: '0x2', + flags: '0x0', + data: '0x', + value: '0x0', + executionGas: '0xc350', + stateGas: '0x0', + }, + ], + }) + }) + test.each([ + { mode: '0x3' }, + { mode: '0x2', executionGas: '0x00' }, + { mode: '0x2', stateGas: null }, + {}, + ])('rejects malformed frame request %j', (frame) => { + expect( + z.safeParse(z_TransactionRequest.TransactionRequest, { frames: [frame] }) + .success, + ).toBe(false) + }) +}) diff --git a/src/zod/internal/FrameRequest.ts b/src/zod/internal/FrameRequest.ts new file mode 100644 index 000000000..d4a0a6811 --- /dev/null +++ b/src/zod/internal/FrameRequest.ts @@ -0,0 +1,45 @@ +import * as core_FrameRequest from '../../core/internal/frameRequest.js' +import * as z_Address from '../Address.js' +import * as z_Frame from '../Frame.js' +import * as z_Hex from '../Hex.js' +import * as z from 'zod/mini' + +const quantity = z_Hex.Hex.check( + z.refine( + (value) => /^0x(0|[1-9a-f][0-9a-f]*)$/.test(value), + 'Invalid RPC quantity', + ), +) + +const rpc = z + .object({ + data: z.optional(z_Hex.Hex), + executionGas: z.optional(quantity), + flags: z.optional(z_Hex.Hex), + mode: z_Hex.Hex, + stateGas: z.optional(quantity), + target: z.optional(z.nullable(z_Address.Address)), + value: z.optional(quantity), + }) + .check( + z.refine((value) => { + try { + core_FrameRequest.fromRpc(value) + return true + } catch { + return false + } + }, 'Invalid frame request'), + ) + +/** @internal */ +export const FrameRequest = z.codec(rpc, z_Frame.Decoded, { + decode: core_FrameRequest.fromRpc, + encode: core_FrameRequest.toRpc, +}) + +/** @internal */ +export const FrameRequestToRpc = z.codec(rpc, z_Frame.DecodedToRpc, { + decode: core_FrameRequest.fromRpc, + encode: core_FrameRequest.toRpc, +}) diff --git a/src/zod/internal/rpcSchemas/Eth.ts b/src/zod/internal/rpcSchemas/Eth.ts index bfa60953c..fd55b83d8 100644 --- a/src/zod/internal/rpcSchemas/Eth.ts +++ b/src/zod/internal/rpcSchemas/Eth.ts @@ -5,6 +5,7 @@ import * as z_Block from '../../Block.js' import * as z_BlockOverrides from '../../BlockOverrides.js' import * as z_Fee from '../../Fee.js' import * as z_Filter from '../../Filter.js' +import * as z_FrameReceipt from '../../FrameReceipt.js' import * as z_Hex from '../../Hex.js' import * as z_Log from '../../Log.js' import * as z_Number from '../../Number.js' @@ -322,6 +323,25 @@ export const eth_simulateV1 = from({ message: z.string(), }), ), + frameResults: z.optional( + z.readonly( + z.array( + z.intersection( + z_FrameReceipt.FrameReceipt, + z.object({ + error: z.optional( + z.object({ + code: z.number(), + message: z.string(), + }), + ), + returnData: z_Hex.Hex, + }), + ), + ), + ), + ), + payer: z.optional(z_Address.Address), logs: z.optional(z.array(z_Log.Log)), gasUsed: z_Hex.Hex, returnData: z_Hex.Hex, From 17f7ffccc94f7a81c4d0065cbac97d1a21e70e56 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:43:33 +1000 Subject: [PATCH 2/4] fix: remove null signer guard --- src/core/FrameSignature.ts | 4 ---- src/core/_test/FrameRpc.test.ts | 9 +-------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/core/FrameSignature.ts b/src/core/FrameSignature.ts index f85ec50ba..2970ce8c6 100644 --- a/src/core/FrameSignature.ts +++ b/src/core/FrameSignature.ts @@ -357,10 +357,6 @@ export declare namespace from { * @returns The converted value. */ export function fromRpc(entry: Rpc): FrameSignature { - if (entry.signer === null) - throw new InvalidError( - 'signer must be omitted, empty bytes, or an address.', - ) return fromTuple([ entry.scheme === '0x0' ? '0x' diff --git a/src/core/_test/FrameRpc.test.ts b/src/core/_test/FrameRpc.test.ts index a9a3a89a7..eb236dbcc 100644 --- a/src/core/_test/FrameRpc.test.ts +++ b/src/core/_test/FrameRpc.test.ts @@ -100,18 +100,11 @@ describe('TxEnvelopeEip8141.toRpc', () => { }) describe('FrameSignature.fromRpc', () => { - test('accepts empty signers and rejects null signers', () => { + test('accepts empty signers', () => { expect(FrameSignature.fromRpc({ scheme: '0x1', signer: '0x' })).toEqual({ scheme: 'secp256k1', payload: '0x', }) - expect(() => - FrameSignature.fromRpc({ - scheme: '0x1', - // @ts-expect-error Null is not an RPC signer. - signer: null, - }), - ).toThrow('signer must be omitted, empty bytes, or an address.') }) }) From fc34cc4354c2b39c2be079f8f57f0848658b0739 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:44:24 +1000 Subject: [PATCH 3/4] feat: expose frame request module --- .changeset/frame-rpc-fields.md | 8 ++- package.json | 10 ++++ src/core/FrameRequest.ts | 72 ++++++++++++++++++++++++++ src/core/TransactionRequest.ts | 9 ++-- src/core/_test/FrameRequest.test-d.ts | 16 ++++++ src/core/_test/FrameRequest.test.ts | 41 +++++++++++++++ src/core/internal/frameRequest.ts | 34 ------------ src/index.ts | 14 +++++ src/zod/{internal => }/FrameRequest.ts | 13 ++--- src/zod/TransactionRequest.ts | 2 +- src/zod/_test/FrameRequest.test-d.ts | 15 ++++++ src/zod/_test/FrameRequest.test.ts | 44 ++++++++++++++++ src/zod/z.ts | 1 + 13 files changed, 232 insertions(+), 47 deletions(-) create mode 100644 src/core/FrameRequest.ts create mode 100644 src/core/_test/FrameRequest.test-d.ts create mode 100644 src/core/_test/FrameRequest.test.ts delete mode 100644 src/core/internal/frameRequest.ts rename src/zod/{internal => }/FrameRequest.ts (68%) create mode 100644 src/zod/_test/FrameRequest.test-d.ts create mode 100644 src/zod/_test/FrameRequest.test.ts diff --git a/.changeset/frame-rpc-fields.md b/.changeset/frame-rpc-fields.md index e0a29d30e..19cd746ba 100644 --- a/.changeset/frame-rpc-fields.md +++ b/.changeset/frame-rpc-fields.md @@ -2,4 +2,10 @@ "ox": patch --- -Aligned frame RPC quantities, request gas estimation, signature signers, fill responses, simulation results, and receipt gas totals with the execution API definitions. +Exposed `FrameRequest` and aligned frame request gas estimation, signature signers, fill responses, simulation results, and receipt gas totals with the execution API definitions. + +```ts +import { FrameRequest } from 'ox' + +FrameRequest.toRpc({ mode: 'sender', stateGas: 0n }) +``` diff --git a/package.json b/package.json index 3ef1d7b86..bae4f1d10 100644 --- a/package.json +++ b/package.json @@ -431,6 +431,11 @@ "types": "./dist/core/FrameReceipt.d.ts", "default": "./dist/core/FrameReceipt.js" }, + "./FrameRequest": { + "src": "./src/core/FrameRequest.ts", + "types": "./dist/core/FrameRequest.d.ts", + "default": "./dist/core/FrameRequest.js" + }, "./FrameSignature": { "src": "./src/core/FrameSignature.ts", "types": "./dist/core/FrameSignature.d.ts", @@ -1096,6 +1101,11 @@ "types": "./dist/zod/FrameReceipt.d.ts", "default": "./dist/zod/FrameReceipt.js" }, + "./zod/FrameRequest": { + "src": "./src/zod/FrameRequest.ts", + "types": "./dist/zod/FrameRequest.d.ts", + "default": "./dist/zod/FrameRequest.js" + }, "./zod/FrameSignature": { "src": "./src/zod/FrameSignature.ts", "types": "./dist/zod/FrameSignature.d.ts", diff --git a/src/core/FrameRequest.ts b/src/core/FrameRequest.ts new file mode 100644 index 000000000..26ce09971 --- /dev/null +++ b/src/core/FrameRequest.ts @@ -0,0 +1,72 @@ +import * as Frame from './Frame.js' +import type { Compute, PartialBy } from './internal/types.js' + +/** A frame request whose omitted gas limits are estimated by the node. */ +export type FrameRequest = Frame.Frame + +/** JSON-RPC frame request. Only the execution mode is required. */ +export type Rpc = Compute< + PartialBy> +> + +/** + * Converts an RPC frame request, preserving omitted gas limits for node estimation. + * + * @example + * ```ts twoslash + * import { FrameRequest } from 'ox' + * + * const frame = FrameRequest.fromRpc({ mode: '0x2', stateGas: '0x0' }) + * ``` + * + * @param frame - The RPC frame request. + * @returns The decoded request with omitted gas limits preserved. + */ +export function fromRpc(frame: Rpc): FrameRequest { + const { executionGas, stateGas, ...rest } = Frame.fromRpc({ + ...frame, + data: frame.data ?? '0x', + executionGas: frame.executionGas ?? '0x0', + flags: frame.flags ?? '0x0', + stateGas: frame.stateGas ?? '0x0', + value: frame.value ?? '0x0', + }) + return { + ...rest, + ...(frame.executionGas === undefined ? {} : { executionGas }), + ...(frame.stateGas === undefined ? {} : { stateGas }), + } +} + +export declare namespace fromRpc { + type ErrorType = Frame.fromRpc.ErrorType +} + +/** + * Converts a frame request to RPC fields without filling omitted gas limits. + * + * Explicit zero limits remain zero. Omitted mode, flags, value, and data use frame defaults. + * + * @example + * ```ts twoslash + * import { FrameRequest } from 'ox' + * + * const frame = FrameRequest.toRpc({ mode: 'sender', stateGas: 0n }) + * ``` + * + * @param frame - The frame request. Gas and value accept hex, bigint, or number values. + * @returns The RPC request with omitted gas limits preserved. + */ +export function toRpc(frame: toRpc.Input): Rpc { + const { executionGas, stateGas, ...rest } = Frame.toRpc(frame) + return { + ...rest, + ...(frame.executionGas === undefined ? {} : { executionGas }), + ...(frame.stateGas === undefined ? {} : { stateGas }), + } +} + +export declare namespace toRpc { + type Input = Frame.toRpc.Input + type ErrorType = Frame.toRpc.ErrorType +} diff --git a/src/core/TransactionRequest.ts b/src/core/TransactionRequest.ts index ae1434829..4f553b66a 100644 --- a/src/core/TransactionRequest.ts +++ b/src/core/TransactionRequest.ts @@ -3,10 +3,9 @@ import type * as Address from './Address.js' import * as Authorization from './Authorization.js' import * as Blobs from './Blobs.js' import * as Errors from './Errors.js' -import type * as Frame from './Frame.js' +import * as FrameRequest from './FrameRequest.js' import * as FrameSignature from './FrameSignature.js' import * as Hex from './Hex.js' -import * as FrameRequest from './internal/frameRequest.js' import * as Quantity from './internal/quantity.js' import type { Compute } from './internal/types.js' import type * as Kzg from './Kzg.js' @@ -22,7 +21,7 @@ export type TransactionRequest< type extends string = string, frame = [bigintType] extends [Hex.Hex] ? FrameRequest.Rpc - : Frame.Frame, + : FrameRequest.FrameRequest, signature = [bigintType] extends [Hex.Hex] ? FrameSignature.Rpc : FrameSignature.FrameSignature, @@ -144,7 +143,7 @@ export function fromRpc(request: Rpc): TransactionRequest { export declare namespace fromRpc { export type ErrorType = | Authorization.fromRpcList.ErrorType - | Frame.fromRpc.ErrorType + | FrameRequest.fromRpc.ErrorType | FrameSignature.fromRpc.ErrorType | Hex.toNumber.ErrorType | Hex.toBigInt.ErrorType @@ -261,7 +260,7 @@ export declare namespace toRpc { export type ErrorType = | Authorization.toRpcList.ErrorType - | Frame.toRpc.ErrorType + | FrameRequest.toRpc.ErrorType | FrameSignature.toRpc.ErrorType | Hex.fromNumber.ErrorType | Errors.GlobalErrorType diff --git a/src/core/_test/FrameRequest.test-d.ts b/src/core/_test/FrameRequest.test-d.ts new file mode 100644 index 000000000..22b6457a8 --- /dev/null +++ b/src/core/_test/FrameRequest.test-d.ts @@ -0,0 +1,16 @@ +import { FrameRequest } from 'ox' +import { expectTypeOf, test } from 'vp/test' + +test('public frame request types', () => { + const rpc = { mode: '0x2' } as const satisfies FrameRequest.Rpc + const request = FrameRequest.fromRpc(rpc) + expectTypeOf(request).toEqualTypeOf() + expectTypeOf(FrameRequest.toRpc(request)).toEqualTypeOf() + const input = { + executionGas: '0x1', + stateGas: 0, + } as const satisfies FrameRequest.toRpc.Input + expectTypeOf(FrameRequest.toRpc(input)).toEqualTypeOf() + expectTypeOf().not.toBeNever() + expectTypeOf().not.toBeNever() +}) diff --git a/src/core/_test/FrameRequest.test.ts b/src/core/_test/FrameRequest.test.ts new file mode 100644 index 000000000..d44107ef5 --- /dev/null +++ b/src/core/_test/FrameRequest.test.ts @@ -0,0 +1,41 @@ +import { FrameRequest } from 'ox' +import { describe, expect, test } from 'vp/test' + +test('exports', () => { + expect(Object.keys(FrameRequest).sort()).toMatchInlineSnapshot(` + [ + "fromRpc", + "toRpc", + ] + `) +}) + +describe('fromRpc', () => { + test('preserves omitted execution gas and explicit zero state gas', () => { + expect(FrameRequest.fromRpc({ mode: '0x2', stateGas: '0x0' })) + .toMatchInlineSnapshot(` + { + "data": "0x", + "flags": 0, + "mode": 2, + "stateGas": 0n, + "value": 0n, + } + `) + }) +}) + +describe('toRpc', () => { + test('preserves omitted gas and encodes numberish values', () => { + expect(FrameRequest.toRpc({ mode: 'sender', stateGas: 0, value: '0x1' })) + .toMatchInlineSnapshot(` + { + "data": "0x", + "flags": "0x0", + "mode": "0x2", + "stateGas": "0x0", + "value": "0x1", + } + `) + }) +}) diff --git a/src/core/internal/frameRequest.ts b/src/core/internal/frameRequest.ts deleted file mode 100644 index 7d52159e0..000000000 --- a/src/core/internal/frameRequest.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as Frame from '../Frame.js' -import type { Compute, PartialBy } from './types.js' - -/** @internal */ -export type Rpc = Compute< - PartialBy> -> - -/** @internal */ -export function fromRpc(frame: Rpc): Frame.Frame { - const { executionGas, stateGas, ...rest } = Frame.fromRpc({ - ...frame, - data: frame.data ?? '0x', - executionGas: frame.executionGas ?? '0x0', - flags: frame.flags ?? '0x0', - stateGas: frame.stateGas ?? '0x0', - value: frame.value ?? '0x0', - }) - return { - ...rest, - ...(frame.executionGas === undefined ? {} : { executionGas }), - ...(frame.stateGas === undefined ? {} : { stateGas }), - } -} - -/** @internal */ -export function toRpc(frame: Frame.toRpc.Input): Rpc { - const { executionGas, stateGas, ...rest } = Frame.toRpc(frame) - return { - ...rest, - ...(frame.executionGas === undefined ? {} : { executionGas }), - ...(frame.stateGas === undefined ? {} : { stateGas }), - } -} diff --git a/src/index.ts b/src/index.ts index 822b0133e..8f9894d82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1637,6 +1637,20 @@ export * as Frame from './core/Frame.js' */ export * as FrameReceipt from './core/FrameReceipt.js' +/** + * Convert EIP-8141 frame requests while preserving omitted gas limits for node estimation. + * + * @example + * ```ts twoslash + * import { FrameRequest } from 'ox' + * + * const frame = FrameRequest.toRpc({ mode: 'sender', stateGas: 0n }) + * ``` + * + * @category Frames (EIP-8141) + */ +export * as FrameRequest from './core/FrameRequest.js' + /** * Utilities for constructing, validating, and encoding [EIP-8141](https://eips.ethereum.org/EIPS/eip-8141) signature entries. * diff --git a/src/zod/internal/FrameRequest.ts b/src/zod/FrameRequest.ts similarity index 68% rename from src/zod/internal/FrameRequest.ts rename to src/zod/FrameRequest.ts index d4a0a6811..84975bd07 100644 --- a/src/zod/internal/FrameRequest.ts +++ b/src/zod/FrameRequest.ts @@ -1,7 +1,8 @@ -import * as core_FrameRequest from '../../core/internal/frameRequest.js' -import * as z_Address from '../Address.js' -import * as z_Frame from '../Frame.js' -import * as z_Hex from '../Hex.js' +/* eslint-disable jsdoc-js/require-jsdoc, jsdoc-js/require-description, jsdoc-js/require-example */ +import * as core_FrameRequest from '../core/FrameRequest.js' +import * as z_Address from './Address.js' +import * as z_Frame from './Frame.js' +import * as z_Hex from './Hex.js' import * as z from 'zod/mini' const quantity = z_Hex.Hex.check( @@ -32,13 +33,13 @@ const rpc = z }, 'Invalid frame request'), ) -/** @internal */ +/** Codec between RPC and decoded frame requests, preserving omitted gas limits. */ export const FrameRequest = z.codec(rpc, z_Frame.Decoded, { decode: core_FrameRequest.fromRpc, encode: core_FrameRequest.toRpc, }) -/** @internal */ +/** Encode-only frame request codec accepting numberish gas and value. */ export const FrameRequestToRpc = z.codec(rpc, z_Frame.DecodedToRpc, { decode: core_FrameRequest.fromRpc, encode: core_FrameRequest.toRpc, diff --git a/src/zod/TransactionRequest.ts b/src/zod/TransactionRequest.ts index 35771b8ba..d0101b0dd 100644 --- a/src/zod/TransactionRequest.ts +++ b/src/zod/TransactionRequest.ts @@ -2,7 +2,7 @@ import * as z_AccessList from './AccessList.js' import * as z_Address from './Address.js' import * as z_Authorization from './Authorization.js' -import * as z_FrameRequest from './internal/FrameRequest.js' +import * as z_FrameRequest from './FrameRequest.js' import * as z_FrameSignature from './FrameSignature.js' import * as z_Hex from './Hex.js' import * as z_Number from './Number.js' diff --git a/src/zod/_test/FrameRequest.test-d.ts b/src/zod/_test/FrameRequest.test-d.ts new file mode 100644 index 000000000..d3ad31057 --- /dev/null +++ b/src/zod/_test/FrameRequest.test-d.ts @@ -0,0 +1,15 @@ +import type { FrameRequest } from 'ox' +import { z } from 'ox/zod' +import { expectTypeOf, test } from 'vp/test' + +test('public frame request codecs preserve input and output types', () => { + expectTypeOf< + z.input + >().toEqualTypeOf() + expectTypeOf< + z.output + >().toEqualTypeOf() + expectTypeOf< + z.output + >().toEqualTypeOf() +}) diff --git a/src/zod/_test/FrameRequest.test.ts b/src/zod/_test/FrameRequest.test.ts new file mode 100644 index 000000000..67fbe3218 --- /dev/null +++ b/src/zod/_test/FrameRequest.test.ts @@ -0,0 +1,44 @@ +import { z } from 'ox/zod' +import { describe, expect, test } from 'vp/test' + +describe('FrameRequest', () => { + test('round-trips omitted gas through the public codec', () => { + const frame = z.decode(z.FrameRequest.FrameRequest, { + mode: '0x2', + stateGas: '0x0', + }) + expect(frame).toEqual({ + mode: 2, + flags: 0, + stateGas: 0n, + value: 0n, + data: '0x', + }) + expect(z.encode(z.FrameRequest.FrameRequest, frame)).toEqual({ + mode: '0x2', + flags: '0x0', + stateGas: '0x0', + value: '0x0', + data: '0x', + }) + }) +}) + +describe('FrameRequestToRpc', () => { + test('encodes numberish values through the public codec', () => { + expect( + z.encode(z.FrameRequest.FrameRequestToRpc, { + mode: 'sender', + executionGas: 50000, + stateGas: '0x0', + }), + ).toEqual({ + mode: '0x2', + flags: '0x0', + executionGas: '0xc350', + stateGas: '0x0', + value: '0x0', + data: '0x', + }) + }) +}) diff --git a/src/zod/z.ts b/src/zod/z.ts index dfd706c1d..cdcb4747c 100644 --- a/src/zod/z.ts +++ b/src/zod/z.ts @@ -22,6 +22,7 @@ export * as Filter from './Filter.js' export * as Hash from './Hash.js' export * as Frame from './Frame.js' export * as FrameReceipt from './FrameReceipt.js' +export * as FrameRequest from './FrameRequest.js' export * as FrameSignature from './FrameSignature.js' export * as Hex from './Hex.js' export * as Log from './Log.js' From de7fd28c8951afa5799c8eb513bf5a9bc87b3a31 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:46:37 +1000 Subject: [PATCH 4/4] Update frame-rpc-fields.md --- .changeset/frame-rpc-fields.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.changeset/frame-rpc-fields.md b/.changeset/frame-rpc-fields.md index 19cd746ba..f9e2fc93b 100644 --- a/.changeset/frame-rpc-fields.md +++ b/.changeset/frame-rpc-fields.md @@ -2,10 +2,4 @@ "ox": patch --- -Exposed `FrameRequest` and aligned frame request gas estimation, signature signers, fill responses, simulation results, and receipt gas totals with the execution API definitions. - -```ts -import { FrameRequest } from 'ox' - -FrameRequest.toRpc({ mode: 'sender', stateGas: 0n }) -``` +Aligned to frame tranasction spec.