diff --git a/examples/frame-transactions/README.md b/examples/frame-transactions/README.md new file mode 100644 index 0000000000..5f9ff3c935 --- /dev/null +++ b/examples/frame-transactions/README.md @@ -0,0 +1,49 @@ +# EIP-8141 Frame Transaction Examples + +Frame transactions ([EIP-8141](https://eips.ethereum.org/EIPS/eip-8141)) replace the +single-call transaction model with an ordered list of **frames**, each specifying an +execution mode, target, gas budget, and calldata. This enables native account abstraction, +sponsored gas, and atomic multi-operation batches at the protocol level. + +## Prerequisites + +These examples use the local `viem` package from this repository: + +```bash +cd examples/frame-transactions +pnpm install # links viem from ../../src +``` + +## RPC Endpoint + +All examples target the public demo node: + +``` +https://rpc1.eip-8141.ethrex.xyz +https://rpc2.eip-8141.ethrex.xyz +https://rpc3.eip-8141.ethrex.xyz +``` + +## Running + +```bash +pnpm tsx simple-self-verified.ts +pnpm tsx sponsored-transaction.ts +pnpm tsx atomic-batch.ts +``` + +## Examples + +| File | Scenario | +|------|----------| +| `simple-self-verified.ts` | Minimal VERIFY + SENDER flow: the sender's validator approves, then the sender executes a call | +| `sponsored-transaction.ts` | Third-party pays gas via a DEFAULT frame running paymaster logic at the entry point | +| `atomic-batch.ts` | Two SENDER frames linked with the atomic batch flag: ERC-20 approve then DEX swap, all-or-nothing | + +## Frame Modes + +| Mode | Name | Behaviour | +|------|------|-----------| +| 0 | DEFAULT | Executes as the entry point (address `0xaa`) | +| 1 | VERIFY | Read-only validation; must call the `APPROVE` opcode | +| 2 | SENDER | Executes as `tx.sender` (requires prior approval) | diff --git a/examples/frame-transactions/atomic-batch.ts b/examples/frame-transactions/atomic-batch.ts new file mode 100644 index 0000000000..af7d3f9884 --- /dev/null +++ b/examples/frame-transactions/atomic-batch.ts @@ -0,0 +1,175 @@ +/** + * Atomic Batch Frame Transaction + * + * Uses the ATOMIC_BATCH_FLAG (0x04) to link two SENDER frames so they + * execute atomically: if either reverts, both revert. + * + * Frame 0 (VERIFY): Sender's validator approves. + * Frame 1 (SENDER): ERC-20 `approve` -- grant the DEX router an allowance. + * flags=0x04 (atomic) links this frame to the next. + * Frame 2 (SENDER): DEX `swapExactTokensForTokens` -- swap tokens. + * flags=0x00 (last frame in the atomic group). + * + * Without atomicity, a successful approve followed by a reverted swap + * would leave a dangling allowance. The atomic batch flag guarantees + * all-or-nothing execution at the protocol level. + */ + +import { + type Address, + createClient, + encodeFunctionData, + type Hex, + http, + parseGwei, + parseUnits, + serializeTransaction, + type TransactionSerializableEIP8141, +} from 'viem' + +const RPC_URL = 'https://rpc1.eip-8141.ethrex.xyz' +const CHAIN_ID = 3151908 + +// Demo addresses. +const sender: Address = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' +const validator: Address = '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512' +const usdcToken: Address = '0x5FbDB2315678afecb367f032d93F642f64180aa3' +const dexRouter: Address = '0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9' +const wethToken: Address = '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0' + +const ATOMIC_BATCH_FLAG = 0x04 + +const validatorAbi = [ + { + name: 'validate', + type: 'function', + inputs: [{ name: 'txHash', type: 'bytes32' }], + outputs: [], + stateMutability: 'view', + }, +] as const + +const erc20Abi = [ + { + name: 'approve', + type: 'function', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'nonpayable', + }, +] as const + +const dexAbi = [ + { + name: 'swapExactTokensForTokens', + type: 'function', + inputs: [ + { name: 'amountIn', type: 'uint256' }, + { name: 'amountOutMin', type: 'uint256' }, + { name: 'path', type: 'address[]' }, + { name: 'to', type: 'address' }, + { name: 'deadline', type: 'uint256' }, + ], + outputs: [{ name: 'amounts', type: 'uint256[]' }], + stateMutability: 'nonpayable', + }, +] as const + +const swapAmount = parseUnits('1000', 6) // 1000 USDC (6 decimals) +const minOut = parseUnits('0.3', 18) // minimum 0.3 WETH out +const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600) // 1 hour + +const tx: TransactionSerializableEIP8141 = { + type: 'eip8141', + chainId: CHAIN_ID, + nonce: 2, + sender, + maxPriorityFeePerGas: parseGwei('1'), + maxFeePerGas: parseGwei('10'), + maxFeePerBlobGas: 0n, + blobVersionedHashes: [], + frames: [ + // Frame 0 -- VERIFY: sender's validator authorises. + { + mode: 1, + flags: 0x03, + target: validator, + gasLimit: 50_000n, + value: 0n, + data: encodeFunctionData({ + abi: validatorAbi, + functionName: 'validate', + args: [ + '0x0000000000000000000000000000000000000000000000000000000000000002', + ], + }), + }, + + // Frame 1 -- SENDER + ATOMIC: approve the DEX router to spend USDC. + // The atomic batch flag (0x04) links this frame to the next one. + // If the swap in frame 2 reverts, this approve is also rolled back. + { + mode: 2, + flags: ATOMIC_BATCH_FLAG, + target: usdcToken, + gasLimit: 60_000n, + value: 0n, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [dexRouter, swapAmount], + }), + }, + + // Frame 2 -- SENDER: swap USDC -> WETH on the DEX. + // flags=0x00: last frame in the atomic group, no further chaining. + { + mode: 2, + flags: 0x00, + target: dexRouter, + gasLimit: 200_000n, + value: 0n, + data: encodeFunctionData({ + abi: dexAbi, + functionName: 'swapExactTokensForTokens', + args: [swapAmount, minOut, [usdcToken, wethToken], sender, deadline], + }), + }, + ], +} + +async function main() { + const serialized = serializeTransaction(tx) + console.log( + 'Serialized atomic-batch EIP-8141 tx:', + serialized.slice(0, 66), + '...', + ) + console.log('Type byte: 0x06 (EIP-8141)') + console.log('Frames:', tx.frames.length) + console.log(' [0] VERIFY - validator approves') + console.log(' [1] SENDER (atomic) - approve USDC for DEX router') + console.log(' [2] SENDER - swap USDC -> WETH') + console.log() + console.log( + 'Atomic guarantee: if the swap reverts, the approve is rolled back too.', + ) + console.log() + + const client = createClient({ transport: http(RPC_URL) }) + + console.log('Sending to', RPC_URL, `(chainId ${CHAIN_ID}) ...`) + const hash = await client.request({ + method: 'eth_sendRawTransaction' as any, + params: [serialized as Hex], + }) + console.log('Transaction hash:', hash) +} + +main().catch((err) => { + console.log('Failed to send frame transaction.', err) + process.exit(1) +}) diff --git a/examples/frame-transactions/package.json b/examples/frame-transactions/package.json new file mode 100644 index 0000000000..0d0456efb1 --- /dev/null +++ b/examples/frame-transactions/package.json @@ -0,0 +1,12 @@ +{ + "name": "example-frame-transactions", + "private": true, + "type": "module", + "dependencies": { + "viem": "file:../../src" + }, + "devDependencies": { + "tsx": "^4.19.0", + "typescript": "^5.0.3" + } +} diff --git a/examples/frame-transactions/simple-self-verified.ts b/examples/frame-transactions/simple-self-verified.ts new file mode 100644 index 0000000000..22cd185ecc --- /dev/null +++ b/examples/frame-transactions/simple-self-verified.ts @@ -0,0 +1,105 @@ +/** + * Simple Self-Verified Frame Transaction + * + * The most basic EIP-8141 pattern: two frames. + * + * Frame 0 (VERIFY): The sender's validator contract runs read-only + * validation and calls APPROVE to authorise the tx. + * Frame 1 (SENDER): Executes a plain ETH transfer as the sender. + * + * No third-party payer, no batching -- just native account abstraction. + */ + +import { + type Address, + createClient, + encodeFunctionData, + type Hex, + http, + parseEther, + parseGwei, + serializeTransaction, + type TransactionSerializableEIP8141, +} from 'viem' + +const RPC_URL = 'https://rpc1.eip-8141.ethrex.xyz' +const CHAIN_ID = 3151908 + +// Demo addresses -- replace with your own for a real network. +const sender: Address = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' +const validator: Address = '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512' +const recipient: Address = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' + +// Minimal validator ABI -- the VERIFY frame calls `validate` on the +// sender's validator contract. The contract is expected to inspect +// the transaction context and call the APPROVE opcode if it is valid. +const validatorAbi = [ + { + name: 'validate', + type: 'function', + inputs: [{ name: 'txHash', type: 'bytes32' }], + outputs: [], + stateMutability: 'view', + }, +] as const + +const tx: TransactionSerializableEIP8141 = { + type: 'eip8141', + chainId: CHAIN_ID, + nonce: 0, + sender, + maxPriorityFeePerGas: parseGwei('1'), + maxFeePerGas: parseGwei('10'), + maxFeePerBlobGas: 0n, + blobVersionedHashes: [], + frames: [ + // Frame 0 -- VERIFY: read-only validation by the sender's validator. + // flags=0x01 means approval scope covers the immediate next frame. + { + mode: 1, + flags: 0x01, + target: validator, + gasLimit: 50_000n, + value: 0n, + data: encodeFunctionData({ + abi: validatorAbi, + functionName: 'validate', + args: [ + '0x0000000000000000000000000000000000000000000000000000000000000000', + ], + }), + }, + + // Frame 1 -- SENDER: transfer ETH to recipient. + { + mode: 2, + flags: 0x00, + target: recipient, + gasLimit: 21_000n, + value: parseEther('0.001'), + data: '0x', + }, + ], +} + +async function main() { + const serialized = serializeTransaction(tx) + console.log('Serialized EIP-8141 tx:', serialized.slice(0, 66), '...') + console.log('Type byte: 0x06 (EIP-8141)') + console.log('Frames:', tx.frames.length) + console.log() + + const client = createClient({ transport: http(RPC_URL) }) + + console.log('Sending to', RPC_URL, `(chainId ${CHAIN_ID}) ...`) + const hash = await client.request({ + method: 'eth_sendRawTransaction' as any, + params: [serialized as Hex], + }) + console.log('Transaction hash:', hash) +} + +main().catch((err) => { + console.log('Failed to send frame transaction.', err) + process.exit(1) +}) diff --git a/examples/frame-transactions/sponsored-transaction.ts b/examples/frame-transactions/sponsored-transaction.ts new file mode 100644 index 0000000000..fa9425ad86 --- /dev/null +++ b/examples/frame-transactions/sponsored-transaction.ts @@ -0,0 +1,161 @@ +/** + * Sponsored (Paymaster) Frame Transaction + * + * Demonstrates how a third party can pay gas on behalf of the sender + * using three frames: + * + * Frame 0 (VERIFY): The sender's validator approves the transaction. + * Frame 1 (SENDER): The sender's intended action (a contract call). + * Frame 2 (DEFAULT): Runs as the entry point (0xaa), executing paymaster + * logic that deducts fees from the sponsor rather + * than the sender's balance. + * + * The DEFAULT frame is the key: it executes at the protocol entry point + * address, which has special authority to manage gas payment on behalf + * of an external sponsor. + */ + +import { + type Address, + createClient, + encodeFunctionData, + type Hex, + http, + parseGwei, + serializeTransaction, + type TransactionSerializableEIP8141, +} from 'viem' + +const RPC_URL = 'https://rpc1.eip-8141.ethrex.xyz' +const CHAIN_ID = 3151908 + +// Demo addresses. +const sender: Address = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' +const validator: Address = '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512' +const paymaster: Address = '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0' +const targetContract: Address = '0x5FbDB2315678afecb367f032d93F642f64180aa3' + +const validatorAbi = [ + { + name: 'validate', + type: 'function', + inputs: [{ name: 'txHash', type: 'bytes32' }], + outputs: [], + stateMutability: 'view', + }, +] as const + +// The sender wants to call `store(uint256)` on a target contract. +const storageAbi = [ + { + name: 'store', + type: 'function', + inputs: [{ name: 'value', type: 'uint256' }], + outputs: [], + stateMutability: 'nonpayable', + }, +] as const + +// The paymaster contract's `sponsorGas` method is invoked in the DEFAULT +// frame. This runs at the entry point address and arranges for the +// sponsor to cover all gas costs. +const paymasterAbi = [ + { + name: 'sponsorGas', + type: 'function', + inputs: [ + { name: 'sponsor', type: 'address' }, + { name: 'sender', type: 'address' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, +] as const + +const tx: TransactionSerializableEIP8141 = { + type: 'eip8141', + chainId: CHAIN_ID, + nonce: 1, + sender, + maxPriorityFeePerGas: parseGwei('1'), + maxFeePerGas: parseGwei('10'), + maxFeePerBlobGas: 0n, + blobVersionedHashes: [], + frames: [ + // Frame 0 -- VERIFY: sender's validator authorises. + // flags=0x03 means approval scope covers all subsequent frames. + { + mode: 1, + flags: 0x03, + target: validator, + gasLimit: 50_000n, + value: 0n, + data: encodeFunctionData({ + abi: validatorAbi, + functionName: 'validate', + args: [ + '0x0000000000000000000000000000000000000000000000000000000000000001', + ], + }), + }, + + // Frame 1 -- SENDER: the user's actual intent, runs as tx.sender. + { + mode: 2, + flags: 0x00, + target: targetContract, + gasLimit: 100_000n, + value: 0n, + data: encodeFunctionData({ + abi: storageAbi, + functionName: 'store', + args: [42n], + }), + }, + + // Frame 2 -- DEFAULT: paymaster logic at the entry point. + // Executes as address 0xaa, calling the paymaster contract to + // debit the sponsor's pre-funded balance instead of the sender's. + { + mode: 0, + flags: 0x00, + target: paymaster, + gasLimit: 80_000n, + value: 0n, + data: encodeFunctionData({ + abi: paymasterAbi, + functionName: 'sponsorGas', + args: [paymaster, sender], + }), + }, + ], +} + +async function main() { + const serialized = serializeTransaction(tx) + console.log( + 'Serialized sponsored EIP-8141 tx:', + serialized.slice(0, 66), + '...', + ) + console.log('Type byte: 0x06 (EIP-8141)') + console.log('Frames:', tx.frames.length) + console.log(' [0] VERIFY - validator approves') + console.log(' [1] SENDER - store(42) on target contract') + console.log(' [2] DEFAULT - paymaster sponsors gas') + console.log() + + const client = createClient({ transport: http(RPC_URL) }) + + console.log('Sending to', RPC_URL, `(chainId ${CHAIN_ID}) ...`) + const hash = await client.request({ + method: 'eth_sendRawTransaction' as any, + params: [serialized as Hex], + }) + console.log('Transaction hash:', hash) +} + +main().catch((err) => { + console.log('Failed to send frame transaction.', err) + process.exit(1) +}) diff --git a/examples/frame-transactions/tsconfig.json b/examples/frame-transactions/tsconfig.json new file mode 100644 index 0000000000..23e7586435 --- /dev/null +++ b/examples/frame-transactions/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["."] +} diff --git a/package.json b/package.json index 94ca73197d..3899a8cae3 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ "check:types": "tsc -b", "check:unused": "pnpm clean && knip", "gen:tempo-abis": "bun scripts/generateTempoAbis.ts", - "postinstall": "git submodule update --init --recursive && pnpm contracts:build", + "postinstall": "node -e \"if (process.env.INIT_CWD && process.env.INIT_CWD !== process.cwd()) process.exit(0)\" && git submodule update --init --recursive && pnpm contracts:build", "preconstruct": "bun scripts/preconstruct.ts", - "preinstall": "pnpx only-allow pnpm", + "preinstall": "node -e \"if (process.env.INIT_CWD && process.env.INIT_CWD !== process.cwd()) process.exit(0)\" && pnpx only-allow pnpm", "prepare": "pnpm simple-git-hooks", "prepublishOnly": "bun scripts/prepublishOnly.ts", "size": "size-limit", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43ed92c2d1..0174fefcbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,6 +425,19 @@ importers: specifier: 7.3.2 version: 7.3.2(@types/node@24.5.2)(jiti@2.6.0)(lightningcss@1.30.2)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.3) + examples/frame-transactions: + dependencies: + viem: + specifier: file:../../src + version: link:../../src + devDependencies: + tsx: + specifier: ^4.19.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + examples/logs_block-event-logs: dependencies: viem: @@ -4936,14 +4949,6 @@ packages: outvariant@1.4.0: resolution: {integrity: sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==} - ox@0.14.15: - resolution: {integrity: sha512-3TubCmbKen/cuZQzX0qDbOS5lojjdSZ90lqKxWIDWd5siuJ0IJBaTXMYs8eMPLcraqnOwGZazz3apHPGiRCkGQ==} - peerDependencies: - typescript: ^5.9.3 - peerDependenciesMeta: - typescript: - optional: true - ox@0.14.17: resolution: {integrity: sha512-jOzNb2Wlfzsr8z/GoCtd1bf6OSRuWuysvbhnHGD+7fV1WRbcBR6B0RYoe3xWnUedF7zp4l5APmS7CzAhUok/lA==} peerDependencies: @@ -6128,16 +6133,16 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - viem@2.47.17: - resolution: {integrity: sha512-yNUKw6b1nd1i96GcJPqp096w5VVjUky/6PLT8UeUsEArzhD9YRrC0QJ50o8YEF7xA6M0FK8e6u5tAMyBLLl7tw==} + viem@2.47.6: + resolution: {integrity: sha512-zExmbI99NGvMdYa7fmqSTLgkwh48dmhgEqFrUgkpL4kfG4XkVefZ8dZqIKVUhZo6Uhf0FrrEXOsHm9LUyIvI2Q==} peerDependencies: typescript: ^5.9.3 peerDependenciesMeta: typescript: optional: true - viem@2.47.6: - resolution: {integrity: sha512-zExmbI99NGvMdYa7fmqSTLgkwh48dmhgEqFrUgkpL4kfG4XkVefZ8dZqIKVUhZo6Uhf0FrrEXOsHm9LUyIvI2Q==} + viem@2.48.1: + resolution: {integrity: sha512-GJC3gKV1Hngeo1IB9YanJKHH2pcmoqDymyPxddmzDtG8boXA7eFw8qdnn1PSaToJ93f3LpOZPlLLJ9beAF/Lzg==} peerDependencies: typescript: ^5.9.3 peerDependenciesMeta: @@ -7959,7 +7964,7 @@ snapshots: pino-pretty: 10.3.1 prom-client: 14.2.0 type-fest: 4.39.0 - viem: 2.47.17(typescript@5.9.3)(zod@3.23.8) + viem: 2.48.1(typescript@5.9.3)(zod@3.23.8) yargs: 17.7.2 zod: 3.23.8 zod-validation-error: 1.5.0(zod@3.23.8) @@ -11253,7 +11258,7 @@ snapshots: outvariant@1.4.0: {} - ox@0.14.15(typescript@5.9.3)(zod@3.23.8): + ox@0.14.17(typescript@5.9.3)(zod@3.23.8): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -12676,15 +12681,15 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - viem@2.47.17(typescript@5.9.3)(zod@3.23.8): + viem@2.47.6(typescript@5.9.3)(zod@4.3.6): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@3.23.8) + abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6) isows: 1.0.7(ws@8.18.3) - ox: 0.14.15(typescript@5.9.3)(zod@3.23.8) + ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) ws: 8.18.3 optionalDependencies: typescript: 5.9.3 @@ -12693,15 +12698,15 @@ snapshots: - utf-8-validate - zod - viem@2.47.6(typescript@5.9.3)(zod@4.3.6): + viem@2.48.1(typescript@5.9.3)(zod@3.23.8): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6) + abitype: 1.2.3(typescript@5.9.3)(zod@3.23.8) isows: 1.0.7(ws@8.18.3) - ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) + ox: 0.14.17(typescript@5.9.3)(zod@3.23.8) ws: 8.18.3 optionalDependencies: typescript: 5.9.3 diff --git a/src/actions/public/getTransaction.test-d.ts b/src/actions/public/getTransaction.test-d.ts index ac3502add3..fac384a326 100644 --- a/src/actions/public/getTransaction.test-d.ts +++ b/src/actions/public/getTransaction.test-d.ts @@ -6,14 +6,7 @@ import { optimism } from '../../chains/index.js' import { createPublicClient } from '../../clients/createPublicClient.js' import { http } from '../../clients/transports/http.js' import type { Hex } from '../../types/misc.js' -import type { - Transaction, - TransactionEIP1559, - TransactionEIP2930, - TransactionEIP4844, - TransactionLegacy, -} from '../../types/transaction.js' -import type { Prettify } from '../../types/utils.js' +import type { Transaction } from '../../types/transaction.js' import { getTransaction } from './getTransaction.js' const client = anvilMainnet.getClient() @@ -23,22 +16,6 @@ test('default', async () => { hash: '0x', }) expectTypeOf(transaction).toEqualTypeOf>() - if (transaction.type === 'legacy') - expectTypeOf(transaction).toEqualTypeOf< - Prettify> - >() - if (transaction.type === 'eip1559') - expectTypeOf(transaction).toEqualTypeOf< - Prettify> - >() - if (transaction.type === 'eip2930') - expectTypeOf(transaction).toEqualTypeOf< - Prettify> - >() - if (transaction.type === 'eip4844') - expectTypeOf(transaction).toEqualTypeOf< - Prettify> - >() }) test('blockTag = "latest"', async () => { diff --git a/src/actions/wallet/prepareTransactionRequest.ts b/src/actions/wallet/prepareTransactionRequest.ts index 23689ee1ec..5951528611 100644 --- a/src/actions/wallet/prepareTransactionRequest.ts +++ b/src/actions/wallet/prepareTransactionRequest.ts @@ -112,7 +112,15 @@ export type PrepareTransactionRequestRequest< chainOverride extends Chain | undefined = Chain | undefined, /// _derivedChain extends Chain | undefined = DeriveChain, -> = UnionOmit, 'from'> & + _formattedTransactionRequest extends + FormattedTransactionRequest<_derivedChain> = FormattedTransactionRequest<_derivedChain>, +> = UnionOmit< + Exclude< + _formattedTransactionRequest, + { frames: readonly unknown[] } | { type?: 'eip8141' } + >, + 'from' +> & GetTransactionRequestKzgParameter & { /** * Nonce manager to use for the transaction request. @@ -175,7 +183,13 @@ export type PrepareTransactionRequestReturnType< > = Prettify< UnionRequiredBy< Extract< - UnionOmit, 'from'> & + UnionOmit< + Exclude< + FormattedTransactionRequest<_derivedChain>, + { frames: readonly unknown[] } | { type?: 'eip8141' } + >, + 'from' + > & (_derivedChain extends Chain ? { chain: _derivedChain } : { chain?: undefined }) & diff --git a/src/actions/wallet/sendTransaction.ts b/src/actions/wallet/sendTransaction.ts index 606ba9021b..4253ecfe69 100644 --- a/src/actions/wallet/sendTransaction.ts +++ b/src/actions/wallet/sendTransaction.ts @@ -70,7 +70,15 @@ export type SendTransactionRequest< chainOverride extends Chain | undefined = Chain | undefined, /// _derivedChain extends Chain | undefined = DeriveChain, -> = UnionOmit, 'from'> & + _formattedTransactionRequest extends + FormattedTransactionRequest<_derivedChain> = FormattedTransactionRequest<_derivedChain>, +> = UnionOmit< + Exclude< + _formattedTransactionRequest, + { frames: readonly unknown[] } | { type?: 'eip8141' } + >, + 'from' +> & GetTransactionRequestKzgParameter export type SendTransactionParameters< diff --git a/src/actions/wallet/sendTransactionSync.ts b/src/actions/wallet/sendTransactionSync.ts index ab34b03c30..c70932521f 100644 --- a/src/actions/wallet/sendTransactionSync.ts +++ b/src/actions/wallet/sendTransactionSync.ts @@ -79,7 +79,15 @@ export type SendTransactionSyncRequest< chainOverride extends Chain | undefined = Chain | undefined, /// _derivedChain extends Chain | undefined = DeriveChain, -> = UnionOmit, 'from'> & + _formattedTransactionRequest extends + FormattedTransactionRequest<_derivedChain> = FormattedTransactionRequest<_derivedChain>, +> = UnionOmit< + Exclude< + _formattedTransactionRequest, + { frames: readonly unknown[] } | { type?: 'eip8141' } + >, + 'from' +> & GetTransactionRequestKzgParameter export type SendTransactionSyncParameters< diff --git a/src/actions/wallet/signTransaction.ts b/src/actions/wallet/signTransaction.ts index 9511d5b670..85c30559a4 100644 --- a/src/actions/wallet/signTransaction.ts +++ b/src/actions/wallet/signTransaction.ts @@ -46,7 +46,15 @@ export type SignTransactionRequest< chainOverride extends Chain | undefined = Chain | undefined, /// _derivedChain extends Chain | undefined = DeriveChain, -> = UnionOmit, 'from'> + _formattedTransactionRequest extends + FormattedTransactionRequest<_derivedChain> = FormattedTransactionRequest<_derivedChain>, +> = UnionOmit< + Exclude< + _formattedTransactionRequest, + { frames: readonly unknown[] } | { type?: 'eip8141' } + >, + 'from' +> export type SignTransactionParameters< chain extends Chain | undefined, diff --git a/src/celo/formatters.test-d.ts b/src/celo/formatters.test-d.ts index 8256d4de49..eaaae4b5f5 100644 --- a/src/celo/formatters.test-d.ts +++ b/src/celo/formatters.test-d.ts @@ -59,6 +59,7 @@ describe('smoke', () => { | 'eip1559' | 'eip4844' | 'eip7702' + | 'eip8141' | 'cip42' | 'cip64' | 'deposit' diff --git a/src/index.ts b/src/index.ts index 227838e307..e225fffaef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1214,12 +1214,15 @@ export type { } from './types/stateOverride.js' export type { AccessList, + Frame, + FrameMode, Transaction, TransactionBase, TransactionEIP1559, TransactionEIP2930, TransactionEIP4844, TransactionEIP7702, + TransactionEIP8141, TransactionLegacy, TransactionReceipt, TransactionRequest, @@ -1228,6 +1231,7 @@ export type { TransactionRequestEIP2930, TransactionRequestEIP4844, TransactionRequestEIP7702, + TransactionRequestEIP8141, TransactionRequestGeneric, TransactionRequestLegacy, TransactionSerializable, @@ -1236,6 +1240,7 @@ export type { TransactionSerializableEIP2930, TransactionSerializableEIP4844, TransactionSerializableEIP7702, + TransactionSerializableEIP8141, TransactionSerializableGeneric, TransactionSerializableLegacy, TransactionSerialized, @@ -1243,6 +1248,7 @@ export type { TransactionSerializedEIP2930, TransactionSerializedEIP4844, TransactionSerializedEIP7702, + TransactionSerializedEIP8141, TransactionSerializedGeneric, TransactionSerializedLegacy, TransactionType, @@ -1871,9 +1877,11 @@ export { export { type AssertTransactionEIP1559ErrorType, type AssertTransactionEIP2930ErrorType, + type AssertTransactionEIP8141ErrorType, type AssertTransactionLegacyErrorType, assertTransactionEIP1559, assertTransactionEIP2930, + assertTransactionEIP8141, assertTransactionLegacy, } from './utils/transaction/assertTransaction.js' export { diff --git a/src/op-stack/formatters.test-d.ts b/src/op-stack/formatters.test-d.ts index 8f046e4f91..79160c80b5 100644 --- a/src/op-stack/formatters.test-d.ts +++ b/src/op-stack/formatters.test-d.ts @@ -69,7 +69,13 @@ describe('smoke', () => { }) expectTypeOf(transaction.type).toEqualTypeOf< - 'legacy' | 'eip2930' | 'eip1559' | 'eip4844' | 'eip7702' | 'deposit' + | 'legacy' + | 'eip2930' + | 'eip1559' + | 'eip4844' + | 'eip7702' + | 'eip8141' + | 'deposit' >() expectTypeOf( transaction.type === 'deposit' && transaction.isSystemTx, diff --git a/src/types/rpc.ts b/src/types/rpc.ts index 2ede6a3937..5fef2f11c7 100644 --- a/src/types/rpc.ts +++ b/src/types/rpc.ts @@ -16,12 +16,14 @@ import type { TransactionEIP2930, TransactionEIP4844, TransactionEIP7702, + TransactionEIP8141, TransactionLegacy, TransactionReceipt, TransactionRequestEIP1559, TransactionRequestEIP2930, TransactionRequestEIP4844, TransactionRequestEIP7702, + TransactionRequestEIP8141, TransactionRequestLegacy, } from './transaction.js' import type { Omit, OneOf, PartialBy } from './utils.js' @@ -35,6 +37,7 @@ export type TransactionType = | '0x2' | '0x3' | '0x4' + | '0x6' | (string & {}) export type RpcAuthorization = { @@ -79,6 +82,7 @@ export type RpcTransactionRequest = OneOf< TransactionRequestEIP7702, 'authorizationList' > & { authorizationList?: RpcAuthorizationList | undefined }) + | TransactionRequestEIP8141 > // `yParity` is optional on the RPC type as some nodes do not return it // for 1559 & 2930 transactions (they should!). @@ -103,6 +107,7 @@ export type RpcTransaction = OneOf< > & { authorizationList?: RpcAuthorizationList | undefined }, 'yParity' > + | Omit, 'typeHex'> > type SuccessResult = { diff --git a/src/types/transaction.ts b/src/types/transaction.ts index ce860a4eb1..b1eaf1d30d 100644 --- a/src/types/transaction.ts +++ b/src/types/transaction.ts @@ -33,8 +33,51 @@ export type TransactionType = | 'eip2930' | 'eip4844' | 'eip7702' + | 'eip8141' | (string & {}) +/** EIP-8141 frame execution mode (lower 8 bits of `mode` field). */ +export type FrameMode = + | 0 // DEFAULT: execute as ENTRY_POINT (address 0xaa) + | 1 // VERIFY: read-only validation; must call APPROVE opcode + | 2 // SENDER: execute as tx.sender (requires prior approval) + +/** + * A single frame in an EIP-8141 Frame Transaction. + * Each frame captures one unit of execution, validation, or payment. + */ +export type Frame = { + /** Execution mode: 0=DEFAULT, 1=VERIFY, 2=SENDER. */ + mode: FrameMode + /** + * Flag bits configuring execution constraints. + * Bits 0-1: approval scope (APPROVE_SCOPE_MASK = 0x03). + * Bit 2: atomic batch flag (SENDER mode only). + * Bits 3-7: reserved, must be zero. + */ + flags: number + /** Target address for this frame, or `null` to use `tx.sender`. */ + target: Address | null + /** Gas allocated exclusively to this frame. */ + gasLimit: bigint + /** + * Must be `0` for `DEFAULT` and `VERIFY` modes; only `SENDER` may be non-zero. + */ + value?: bigint | undefined + /** Input data passed to the frame. */ + data: Hex +} + +/** Per-frame receipt as defined by EIP-8141: `[status, gas_used, logs]`. */ +export type FrameReceipt = { + /** Return status of the frame's top-level call. */ + status: 'success' | 'reverted' + /** Gas consumed by this frame. */ + gasUsed: quantity + /** Logs emitted during this frame's execution. */ + logs: Log[] +} + export type TransactionReceipt< quantity = bigint, index = number, @@ -77,6 +120,10 @@ export type TransactionReceipt< transactionIndex: index /** Transaction type */ type: type + /** Address that paid the fee. Only present for EIP-8141 frame transactions. */ + payer?: Address | undefined + /** Per-frame receipts. Only present for EIP-8141 frame transactions. */ + frameReceipts?: FrameReceipt[] | undefined } export type TransactionBase< @@ -194,6 +241,49 @@ export type TransactionEIP7702< type: type } & FeeValuesEIP1559 +/** + * EIP-8141 Frame Transaction as returned by `eth_getTransactionByHash`. + * Authorization is embedded in frame data rather than a top-level ECDSA signature, + * so `r`, `s`, `v`, `yParity`, `to`, `value`, and `input` are absent. + */ +export type TransactionEIP8141< + quantity = bigint, + index = number, + isPending extends boolean = boolean, + type = 'eip8141', +> = { + /** Hash of block containing this transaction, or `null` if pending. */ + blockHash: isPending extends true ? null : Hash + /** Number of block containing this transaction, or `null` if pending. */ + blockNumber: isPending extends true ? null : quantity + /** Chain ID that this transaction is valid on. */ + chainId: index + /** List of versioned blob hashes (empty when no blobs are attached). */ + blobVersionedHashes: readonly Hex[] + /** Transaction sender address (explicit in the EIP-8141 envelope). */ + from: Address + /** Maximum fee per blob gas unit (EIP-4844 blob fee field). */ + maxFeePerBlobGas: quantity + /** Maximum total fee per gas unit. */ + maxFeePerGas: quantity + /** Maximum priority fee per gas unit (miner tip). */ + maxPriorityFeePerGas: quantity + /** Hash of this transaction. */ + hash: Hash + /** Unique number identifying this transaction. */ + nonce: index + /** Explicit sender address committed to in the transaction envelope. */ + sender: Address + /** Ordered list of execution frames. */ + frames: readonly Frame[] + /** Index of this transaction in the block, or `null` if pending. */ + transactionIndex: isPending extends true ? null : index + /** The type represented as hex. */ + typeHex: Hex | null + /** Transaction type identifier. */ + type: type +} + export type Transaction< quantity = bigint, index = number, @@ -204,6 +294,7 @@ export type Transaction< | TransactionEIP1559 | TransactionEIP4844 | TransactionEIP7702 + | TransactionEIP8141 > //////////////////////////////////////////////////////////////////////////////////////////// @@ -215,6 +306,8 @@ export type TransactionRequestBase< index = number, type = string, > = { + /** Chain ID that this transaction is valid on. */ + chainId?: number | undefined /** Contract code or a hashed method call with encoded args */ data?: Hex | undefined /** Transaction sender */ @@ -286,6 +379,36 @@ export type TransactionRequestEIP7702< authorizationList?: AuthorizationList | undefined } +/** + * EIP-8141 Frame Transaction request (for `eth_sendRawTransaction`). + * Authorization is carried inside frame data, so there is no top-level ECDSA + * signature and no `to`/`value`/`data` fields at the envelope level. + */ +export type TransactionRequestEIP8141< + quantity = bigint, + index = number, + type = 'eip8141', +> = { + /** Transaction type identifier. */ + type?: type | undefined + /** Chain ID that this transaction is valid on. */ + chainId?: number | undefined + /** Unique number identifying this transaction. */ + nonce?: index | undefined + /** Explicit sender address committed to in the transaction envelope. */ + sender: Address + /** Ordered list of execution frames. */ + frames: readonly Frame[] + /** Maximum priority fee per gas unit. */ + maxPriorityFeePerGas?: quantity | undefined + /** Maximum total fee per gas unit. */ + maxFeePerGas?: quantity | undefined + /** Maximum fee per blob gas unit (use 0 / omit when no blobs). */ + maxFeePerBlobGas?: quantity | undefined + /** List of versioned blob hashes (omit or leave empty when no blobs). */ + blobVersionedHashes?: readonly Hex[] | undefined +} + export type TransactionRequest = OneOf< | TransactionRequestLegacy | TransactionRequestEIP2930 @@ -316,6 +439,7 @@ export type TransactionSerializedEIP1559 = `0x02${string}` export type TransactionSerializedEIP2930 = `0x01${string}` export type TransactionSerializedEIP4844 = `0x03${string}` export type TransactionSerializedEIP7702 = `0x04${string}` +export type TransactionSerializedEIP8141 = `0x06${string}` export type TransactionSerializedLegacy = Branded<`0x${string}`, 'legacy'> export type TransactionSerializedGeneric = `0x${string}` export type TransactionSerialized< @@ -325,6 +449,7 @@ export type TransactionSerialized< | (type extends 'eip2930' ? TransactionSerializedEIP2930 : never) | (type extends 'eip4844' ? TransactionSerializedEIP4844 : never) | (type extends 'eip7702' ? TransactionSerializedEIP7702 : never) + | (type extends 'eip8141' ? TransactionSerializedEIP8141 : never) | (type extends 'legacy' ? TransactionSerializedLegacy : never), > = IsNever extends true ? TransactionSerializedGeneric : result @@ -403,12 +528,42 @@ export type TransactionSerializableEIP7702< yParity?: number | undefined } +/** + * EIP-8141 Frame Transaction ready for RLP serialization. + * Does not extend `TransactionSerializableBase` because there is no ECDSA + * signature envelope; authorization lives inside the VERIFY frame data. + */ +export type TransactionSerializableEIP8141< + quantity = bigint, + index = number, +> = { + /** Chain ID that this transaction is valid on. */ + chainId: number + /** Unique number identifying this transaction. */ + nonce?: index | undefined + /** Explicit sender address committed to in the transaction envelope. */ + sender: Address + /** Ordered list of execution frames (1 to MAX_FRAMES = 64). */ + frames: readonly Frame[] + /** Maximum priority fee per gas unit. */ + maxPriorityFeePerGas?: quantity | undefined + /** Maximum total fee per gas unit. */ + maxFeePerGas?: quantity | undefined + /** Maximum fee per blob gas unit (must be 0 when no blobs are included). */ + maxFeePerBlobGas?: quantity | undefined + /** Versioned blob hashes (must be empty when `maxFeePerBlobGas` is 0). */ + blobVersionedHashes?: readonly Hex[] | undefined + /** Transaction type discriminant. */ + type?: 'eip8141' | undefined +} + export type TransactionSerializable = OneOf< | TransactionSerializableLegacy | TransactionSerializableEIP2930 | TransactionSerializableEIP1559 | TransactionSerializableEIP4844 | TransactionSerializableEIP7702 + | TransactionSerializableEIP8141 > export type TransactionSerializableGeneric< diff --git a/src/types/window.ts b/src/types/window.ts index b7cfa646a6..58a648c7f8 100644 --- a/src/types/window.ts +++ b/src/types/window.ts @@ -1,7 +1 @@ -import type { EIP1193Provider } from './eip1193.js' - -declare global { - interface Window { - ethereum?: EIP1193Provider | undefined - } -} +import 'ox/window' diff --git a/src/utils/formatters/transaction.ts b/src/utils/formatters/transaction.ts index 6f3ff04ddf..7809b56f42 100644 --- a/src/utils/formatters/transaction.ts +++ b/src/utils/formatters/transaction.ts @@ -41,6 +41,7 @@ export const transactionType = { '0x2': 'eip1559', '0x3': 'eip4844', '0x4': 'eip7702', + '0x6': 'eip8141', } as const satisfies Record export type FormatTransactionErrorType = ErrorType diff --git a/src/utils/formatters/transactionReceipt.test.ts b/src/utils/formatters/transactionReceipt.test.ts index 9fe0fed425..ccbf9a68ca 100644 --- a/src/utils/formatters/transactionReceipt.test.ts +++ b/src/utils/formatters/transactionReceipt.test.ts @@ -136,6 +136,70 @@ test('unknown type', () => { `) }) +test('eip8141 receipt with payer and frameReceipts', () => { + const receipt = formatTransactionReceipt({ + blockHash: + '0x89644bbd5c8d682a2e9611170e6c1f02573d866d286f006cbf517eec7254ec2d', + blockNumber: '0xe6e55f', + contractAddress: null, + cumulativeGasUsed: '0x58b887', + effectiveGasPrice: '0x2beb40be9', + from: '0xa152f8bb749c55e9943a3a0a3111d18ee2b3f94e', + gasUsed: '0x9458', + logs: [], + logsBloom: '0x00', + status: '0x1', + to: null, + transactionHash: + '0xa4b1f606b66105fa45cb5db23d2f6597075701e7f0e2367f4e6a39d17a8cf98b', + transactionIndex: '0x45', + type: '0x6', + payer: '0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc', + frameReceipts: [ + { + status: '0x1', + gasUsed: '0x5208', + logs: [], + }, + { + status: '0x0', + gasUsed: '0x186a0', + logs: [], + }, + ], + } as any) + expect(receipt.type).toBe('eip8141') + expect(receipt.payer).toBe('0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc') + expect(receipt.frameReceipts).toHaveLength(2) + expect(receipt.frameReceipts![0].status).toBe('success') + expect(receipt.frameReceipts![0].gasUsed).toBe(21000n) + expect(receipt.frameReceipts![1].status).toBe('reverted') + expect(receipt.frameReceipts![1].gasUsed).toBe(100000n) +}) + +test('non-eip8141 receipt has no payer or frameReceipts', () => { + const receipt = formatTransactionReceipt({ + blockHash: + '0x89644bbd5c8d682a2e9611170e6c1f02573d866d286f006cbf517eec7254ec2d', + blockNumber: '0xe6e55f', + contractAddress: null, + cumulativeGasUsed: '0x58b887', + effectiveGasPrice: '0x2beb40be9', + from: '0xa152f8bb749c55e9943a3a0a3111d18ee2b3f94e', + gasUsed: '0x9458', + logs: [], + logsBloom: '0x00', + status: '0x1', + to: '0x15d4c048f83bd7e37d49ea4c83a07267ec4203da', + transactionHash: + '0xa4b1f606b66105fa45cb5db23d2f6597075701e7f0e2367f4e6a39d17a8cf98b', + transactionIndex: '0x45', + type: '0x2', + }) + expect(receipt.payer).toBeUndefined() + expect(receipt.frameReceipts).toBeUndefined() +}) + test('nullish values', () => { expect( formatTransactionReceipt({ diff --git a/src/utils/formatters/transactionReceipt.ts b/src/utils/formatters/transactionReceipt.ts index f94ef433be..fc97bea84d 100644 --- a/src/utils/formatters/transactionReceipt.ts +++ b/src/utils/formatters/transactionReceipt.ts @@ -1,10 +1,15 @@ +import type { Address } from 'abitype' + import type { ErrorType } from '../../errors/utils.js' import type { Chain, ExtractChainFormatterReturnType, } from '../../types/chain.js' import type { RpcTransactionReceipt } from '../../types/rpc.js' -import type { TransactionReceipt } from '../../types/transaction.js' +import type { + FrameReceipt, + TransactionReceipt, +} from '../../types/transaction.js' import type { ExactPartial } from '../../types/utils.js' import { hexToNumber } from '../encoding/fromHex.js' @@ -70,6 +75,20 @@ export function formatTransactionReceipt( if (transactionReceipt.blobGasUsed) receipt.blobGasUsed = BigInt(transactionReceipt.blobGasUsed) + if ((transactionReceipt as any).payer) + receipt.payer = (transactionReceipt as any).payer as Address + if ((transactionReceipt as any).frameReceipts) { + receipt.frameReceipts = ( + (transactionReceipt as any).frameReceipts as any[] + ).map( + (fr: any): FrameReceipt => ({ + status: fr.status === '0x1' ? 'success' : 'reverted', + gasUsed: BigInt(fr.gasUsed), + logs: fr.logs ? fr.logs.map((log: any) => formatLog(log)) : [], + }), + ) + } + return receipt } diff --git a/src/utils/formatters/transactionRequest.test.ts b/src/utils/formatters/transactionRequest.test.ts index 6c3fb32276..e102b99388 100644 --- a/src/utils/formatters/transactionRequest.test.ts +++ b/src/utils/formatters/transactionRequest.test.ts @@ -356,6 +356,7 @@ test('rpcTransactionType', () => { "eip2930": "0x1", "eip4844": "0x3", "eip7702": "0x4", + "eip8141": "0x6", "legacy": "0x0", } `) diff --git a/src/utils/formatters/transactionRequest.ts b/src/utils/formatters/transactionRequest.ts index 8ec6393c35..1dc3857c5a 100644 --- a/src/utils/formatters/transactionRequest.ts +++ b/src/utils/formatters/transactionRequest.ts @@ -29,6 +29,7 @@ export const rpcTransactionType = { eip1559: '0x2', eip4844: '0x3', eip7702: '0x4', + eip8141: '0x6', } as const export type FormatTransactionRequestErrorType = ErrorType diff --git a/src/utils/signature/recoverTransactionAddress.test.ts b/src/utils/signature/recoverTransactionAddress.test.ts index 6a1c68551d..a6cf1e039d 100644 --- a/src/utils/signature/recoverTransactionAddress.test.ts +++ b/src/utils/signature/recoverTransactionAddress.test.ts @@ -13,6 +13,7 @@ import { walletActions } from '../../clients/decorators/wallet.js' import type { TransactionSerializable, TransactionSerializableEIP4844, + TransactionSerializableEIP8141, TransactionSerializedLegacy, } from '../../types/transaction.js' import { sidecarsToVersionedHashes } from '../blob/sidecarsToVersionedHashes.js' @@ -127,6 +128,8 @@ test('via `getTransaction`', async () => { blockNumber: anvilMainnet.forkBlockNumber - 15n, index: 0, }) + if (transaction.type === 'eip8141') + throw new Error('Unexpected eip8141 transaction in legacy fixture block.') const serializedTransaction = serializeTransaction({ ...transaction, data: transaction.input, @@ -145,3 +148,25 @@ test('legacy', async () => { }), ).toMatchInlineSnapshot(`"0xb03B8ffAB1f3Ac3CabE4A0B2ED441fDFd3C96C8E"`) }) + +test('eip8141 requires explicit signature', async () => { + const serializedTransaction = serializeTransaction({ + chainId: 1, + sender: accounts[0].address, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 21000n, + data: '0x', + }, + ], + } satisfies TransactionSerializableEIP8141) + + await expect(() => + recoverTransactionAddress({ + serializedTransaction, + }), + ).rejects.toThrow('EIP-8141 transactions require an explicit `signature`') +}) diff --git a/src/utils/signature/recoverTransactionAddress.ts b/src/utils/signature/recoverTransactionAddress.ts index d64d60b679..24ddc6526b 100644 --- a/src/utils/signature/recoverTransactionAddress.ts +++ b/src/utils/signature/recoverTransactionAddress.ts @@ -1,4 +1,5 @@ import type { Address } from 'abitype' +import { BaseError, type BaseErrorType } from '../../errors/base.js' import type { ErrorType } from '../../errors/utils.js' import type { ByteArray, Hex, Signature } from '../../types/misc.js' import type { TransactionSerialized } from '../../types/transaction.js' @@ -22,6 +23,7 @@ export type RecoverTransactionAddressParameters = { export type RecoverTransactionAddressReturnType = Address export type RecoverTransactionAddressErrorType = + | BaseErrorType | SerializeTransactionErrorType | RecoverAddressErrorType | Keccak256ErrorType @@ -35,6 +37,18 @@ export async function recoverTransactionAddress( const transaction = parseTransaction(serializedTransaction) + if ('frames' in transaction) { + if (!signature_) + throw new BaseError( + 'EIP-8141 transactions require an explicit `signature` to recover an address.', + ) + + return await recoverAddress({ + hash: keccak256(serializeTransaction(transaction)), + signature: signature_, + }) + } + const signature = signature_ ?? { r: transaction.r!, s: transaction.s!, diff --git a/src/utils/transaction/assertTransaction.test.ts b/src/utils/transaction/assertTransaction.test.ts index 6d0c88ebbd..efd9a4bfc6 100644 --- a/src/utils/transaction/assertTransaction.test.ts +++ b/src/utils/transaction/assertTransaction.test.ts @@ -6,9 +6,148 @@ import { assertTransactionEIP2930, assertTransactionEIP4844, assertTransactionEIP7702, + assertTransactionEIP8141, assertTransactionLegacy, } from './assertTransaction.js' +const sender = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266' as const + +describe('eip8141', () => { + const validTx = { + chainId: 1, + sender, + frames: [ + { + mode: 1 as const, + flags: 0x03, + target: null, + gasLimit: 50000n, + data: '0xab' as const, + }, + ], + } + + test('valid transaction passes', () => { + expect(() => assertTransactionEIP8141(validTx)).not.toThrow() + }) + + test('zero-address sender rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...validTx, + sender: '0x0000000000000000000000000000000000000000', + }), + ).toThrow('zero address') + }) + + test('MAX_FRAMES is 64', () => { + const frames = Array.from({ length: 65 }, () => ({ + mode: 0 as const, + flags: 0, + target: sender, + gasLimit: 1n, + data: '0x' as const, + })) + expect(() => assertTransactionEIP8141({ ...validTx, frames })).toThrow( + 'MAX_FRAMES (64)', + ) + }) + + test('VERIFY with zero scope rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...validTx, + frames: [ + { + mode: 1, + flags: 0x00, + target: null, + gasLimit: 1n, + data: '0x' as const, + }, + ], + }), + ).toThrow('non-zero APPROVE scope') + }) + + test('reserved flag bits rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...validTx, + frames: [ + { + mode: 2, + flags: 0x08, + target: null, + gasLimit: 1n, + data: '0x' as const, + }, + ], + }), + ).toThrow('reserved') + }) + + test('atomic batch on DEFAULT rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...validTx, + frames: [ + { + mode: 0, + flags: 0x04, + target: sender, + gasLimit: 1n, + data: '0x' as const, + }, + { + mode: 2, + flags: 0x00, + target: null, + gasLimit: 1n, + data: '0x' as const, + }, + ], + }), + ).toThrow('only valid with SENDER') + }) + + test('gas limit per frame bounded to 2^63-1', () => { + expect(() => + assertTransactionEIP8141({ + ...validTx, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 2n ** 63n, + data: '0x' as const, + }, + ], + }), + ).toThrow('gasLimit') + }) + + test('fee cap too high', () => { + expect(() => + assertTransactionEIP8141({ + ...validTx, + maxFeePerGas: maxUint256 + 1n, + }), + ).toThrow() + }) + + test('tip above fee cap', () => { + expect(() => + assertTransactionEIP8141({ + ...validTx, + maxFeePerGas: parseGwei('1'), + maxPriorityFeePerGas: parseGwei('2'), + }), + ).toThrow() + }) +}) + describe('eip7702', () => { test('invalid chainId', () => { expect(() => diff --git a/src/utils/transaction/assertTransaction.ts b/src/utils/transaction/assertTransaction.ts index 03b18dcdbe..decd4a837c 100644 --- a/src/utils/transaction/assertTransaction.ts +++ b/src/utils/transaction/assertTransaction.ts @@ -29,6 +29,7 @@ import type { TransactionSerializableEIP2930, TransactionSerializableEIP4844, TransactionSerializableEIP7702, + TransactionSerializableEIP8141, TransactionSerializableLegacy, } from '../../types/transaction.js' import { type IsAddressErrorType, isAddress } from '../address/isAddress.js' @@ -36,6 +37,105 @@ import { size } from '../data/size.js' import { slice } from '../data/slice.js' import { hexToNumber } from '../encoding/fromHex.js' +export type AssertTransactionEIP8141ErrorType = + | InvalidAddressErrorType + | InvalidChainIdErrorType + | BaseErrorType + | ErrorType + +const maxUint64 = 2n ** 64n - 1n +const maxInt64 = 2n ** 63n - 1n +const MAX_FRAMES = 64 +const VERIFY = 1 +const SENDER = 2 +const APPROVE_SCOPE_MASK = 0x03 +const ATOMIC_BATCH_FLAG = 0x04 + +export function assertTransactionEIP8141( + transaction: TransactionSerializableEIP8141, +) { + const { chainId, sender, frames, nonce, maxFeePerGas, maxPriorityFeePerGas } = + transaction + if (chainId <= 0) throw new InvalidChainIdError({ chainId }) + if (!isAddress(sender)) throw new InvalidAddressError({ address: sender }) + if (sender === '0x0000000000000000000000000000000000000000') + throw new BaseError('`sender` must not be the zero address.') + if (typeof nonce === 'number' && BigInt(nonce) > maxUint64) + throw new BaseError('`nonce` must be less than 2^64.') + if (!frames || frames.length === 0) + throw new BaseError('`frames` must contain at least one frame.') + if (frames.length > MAX_FRAMES) + throw new BaseError( + `\`frames\` must not exceed MAX_FRAMES (${MAX_FRAMES}) per EIP-8141.`, + ) + let totalFrameGas = 0n + for (let i = 0; i < frames.length; i++) { + const frame = frames[i] + if (frame.mode > 2) + throw new BaseError( + `Invalid frame mode ${frame.mode}. Must be 0 (DEFAULT), 1 (VERIFY), or 2 (SENDER).`, + ) + if (frame.flags >= 8) + throw new BaseError( + `Invalid frame flags ${frame.flags}. Bits 3-7 are reserved and must be zero.`, + ) + if (frame.mode === VERIFY && (frame.flags & APPROVE_SCOPE_MASK) === 0) + throw new BaseError( + 'VERIFY frames must permit a non-zero APPROVE scope (flags bits 0-1).', + ) + if (frame.flags & ATOMIC_BATCH_FLAG) { + if (frame.mode !== SENDER) + throw new BaseError( + 'Atomic batch flag (bit 2) is only valid with SENDER mode.', + ) + if (i + 1 >= frames.length) + throw new BaseError( + 'Frame with atomic batch flag must not be the last frame.', + ) + if (frames[i + 1].mode !== SENDER) + throw new BaseError( + 'Frame following an atomic batch frame must be SENDER mode.', + ) + } + if (frame.target !== null && !isAddress(frame.target)) + throw new InvalidAddressError({ address: frame.target }) + if (frame.gasLimit > maxInt64) + throw new BaseError('`frame.gasLimit` must be <= 2^63 - 1.') + const frameValue = frame.value ?? 0n + if (frameValue > maxUint256) + throw new BaseError('`frame.value` must be less than 2^256.') + if (frameValue < 0n) + throw new BaseError('`frame.value` must not be negative.') + if (frame.mode !== SENDER && frameValue !== 0n) + throw new BaseError( + '`frame.value` must be 0 for DEFAULT and VERIFY frames per EIP-8141.', + ) + totalFrameGas += frame.gasLimit + if (totalFrameGas > maxInt64) + throw new BaseError('Total frame gas must be <= 2^63 - 1.') + } + if (maxFeePerGas && maxFeePerGas > maxUint256) + throw new FeeCapTooHighError({ maxFeePerGas }) + if ( + maxPriorityFeePerGas && + maxFeePerGas && + maxPriorityFeePerGas > maxFeePerGas + ) + throw new TipAboveFeeCapError({ maxFeePerGas, maxPriorityFeePerGas }) + + const { maxFeePerBlobGas, blobVersionedHashes } = transaction + const hasBlobs = + blobVersionedHashes !== undefined && blobVersionedHashes.length > 0 + if (!hasBlobs && maxFeePerBlobGas !== undefined && maxFeePerBlobGas !== 0n) + throw new BaseError( + '`maxFeePerBlobGas` must be 0 when no blob versioned hashes are included.', + ) + if (hasBlobs && (maxFeePerBlobGas === undefined || maxFeePerBlobGas === 0n)) + throw new BaseError( + '`maxFeePerBlobGas` must be non-zero when blob versioned hashes are present.', + ) +} + export type AssertTransactionEIP7702ErrorType = | AssertTransactionEIP1559ErrorType | InvalidAddressErrorType diff --git a/src/utils/transaction/eip8141.test.ts b/src/utils/transaction/eip8141.test.ts new file mode 100644 index 0000000000..1b3aa36bfc --- /dev/null +++ b/src/utils/transaction/eip8141.test.ts @@ -0,0 +1,1148 @@ +import { describe, expect, test } from 'vitest' +import { maxUint256 } from '../../constants/number.js' +import type { + TransactionSerializableEIP8141, + TransactionSerializedEIP8141, +} from '../../types/transaction.js' +import { getAddress } from '../address/getAddress.js' +import { fromRlp } from '../encoding/fromRlp.js' +import { numberToHex } from '../encoding/toHex.js' +import { toRlp } from '../encoding/toRlp.js' +import { parseGwei } from '../unit/parseGwei.js' +import { assertTransactionEIP8141 } from './assertTransaction.js' +import { getSerializedTransactionType } from './getSerializedTransactionType.js' +import { getTransactionType } from './getTransactionType.js' +import { parseTransaction } from './parseTransaction.js' +import { serializeTransaction } from './serializeTransaction.js' + +const sender = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266' as const + +const baseEIP8141: TransactionSerializableEIP8141 = { + chainId: 1, + nonce: 0, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 50000n, + value: 0n, + data: '0xdeadbeef', + }, + { + mode: 2, + flags: 0x00, + target: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + gasLimit: 100000n, + value: 0n, + data: '0xcafebabe', + }, + ], + maxPriorityFeePerGas: parseGwei('1'), + maxFeePerGas: parseGwei('10'), + maxFeePerBlobGas: 0n, + blobVersionedHashes: [], +} + +describe('eip8141 serialization', () => { + test('roundtrip: SENDER frame value preserved', () => { + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 21000n, + value: 0n, + data: '0x', + }, + { + mode: 2, + flags: 0x00, + target: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + gasLimit: 21000n, + value: 1234567890123456789n, + data: '0x', + }, + ], + } + const parsed = parseTransaction(serializeTransaction(tx)) + expect(parsed.frames[1].value).toBe(1234567890123456789n) + }) + + test('roundtrip: serialize then parse', () => { + const serialized = serializeTransaction(baseEIP8141) + expect(serialized.startsWith('0x06')).toBe(true) + const parsed = parseTransaction(serialized) + const { + maxFeePerBlobGas: _, + blobVersionedHashes: __, + ...expected + } = baseEIP8141 + expect(parsed).toEqual({ + ...expected, + type: 'eip8141', + frames: expected.frames.map((f) => ({ + ...f, + target: f.target ? getAddress(f.target) : null, + })), + }) + }) + + test('minimal transaction (no optional fields)', () => { + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 21000n, + value: 0n, + data: '0x00', + }, + ], + } + const serialized = serializeTransaction(tx) + expect(serialized.startsWith('0x06')).toBe(true) + const parsed = parseTransaction(serialized) + expect(parsed.chainId).toBe(1) + expect(parsed.sender).toBe(sender) + expect(parsed.frames).toHaveLength(1) + expect(parsed.frames[0].mode).toBe(1) + expect(parsed.frames[0].flags).toBe(0x03) + expect(parsed.frames[0].target).toBeNull() + expect(parsed.frames[0].gasLimit).toBe(21000n) + }) + + test('preserves flags field through roundtrip', () => { + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + sender, + frames: [ + { + mode: 1, + flags: 0x01, + target: null, + gasLimit: 50000n, + value: 0n, + data: '0xab', + }, + { + mode: 1, + flags: 0x02, + target: null, + gasLimit: 50000n, + value: 0n, + data: '0xcd', + }, + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 50000n, + value: 0n, + data: '0xef', + }, + ], + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.frames[0].flags).toBe(0x01) + expect(parsed.frames[1].flags).toBe(0x02) + expect(parsed.frames[2].flags).toBe(0x03) + }) + + test('atomic batch flag roundtrip', () => { + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 50000n, + value: 0n, + data: '0xaa', + }, + { + mode: 2, + flags: 0x04, + target: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + gasLimit: 100000n, + value: 0n, + data: '0xbb', + }, + { + mode: 2, + flags: 0x00, + target: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + gasLimit: 100000n, + value: 0n, + data: '0xcc', + }, + ], + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.frames[1].flags).toBe(0x04) + expect(parsed.frames[2].flags).toBe(0x00) + }) + + test('all three modes', () => { + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + sender, + frames: [ + { + mode: 0, + flags: 0x00, + target: sender, + gasLimit: 10000n, + value: 0n, + data: '0x', + }, + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 50000n, + value: 0n, + data: '0xdeadbeef', + }, + { + mode: 2, + flags: 0x00, + target: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + gasLimit: 100000n, + value: 0n, + data: '0xcafebabe', + }, + ], + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.frames[0].mode).toBe(0) + expect(parsed.frames[1].mode).toBe(1) + expect(parsed.frames[2].mode).toBe(2) + }) + + test('fee fields preserved', () => { + const serialized = serializeTransaction(baseEIP8141) + const parsed = parseTransaction(serialized) + expect(parsed.maxPriorityFeePerGas).toBe(parseGwei('1')) + expect(parsed.maxFeePerGas).toBe(parseGwei('10')) + }) + + test('blob fields preserved when present', () => { + const tx: TransactionSerializableEIP8141 = { + ...baseEIP8141, + maxFeePerBlobGas: 1000n, + blobVersionedHashes: [ + '0x01febabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe', + ], + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.maxFeePerBlobGas).toBe(1000n) + expect(parsed.blobVersionedHashes).toEqual([ + '0x01febabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe', + ]) + }) + + test('nonce field preserved', () => { + const tx: TransactionSerializableEIP8141 = { + ...baseEIP8141, + nonce: 42, + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.nonce).toBe(42) + }) +}) + +describe('eip8141 getTransactionType', () => { + test('infers eip8141 from frames property', () => { + expect( + getTransactionType({ + frames: baseEIP8141.frames, + sender, + chainId: 1, + } as any), + ).toBe('eip8141') + }) + + test('infers eip8141 from explicit type', () => { + expect(getTransactionType({ type: 'eip8141' } as any)).toBe('eip8141') + }) +}) + +describe('eip8141 getSerializedTransactionType', () => { + test('identifies 0x06 prefix as eip8141', () => { + expect(getSerializedTransactionType('0x06abc')).toBe('eip8141') + }) +}) + +describe('eip8141 assertTransaction', () => { + test('valid transaction passes', () => { + expect(() => assertTransactionEIP8141(baseEIP8141)).not.toThrow() + }) + + test('invalid chainId', () => { + expect(() => + assertTransactionEIP8141({ ...baseEIP8141, chainId: 0 }), + ).toThrow('Chain ID') + }) + + test('invalid sender address', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + sender: '0xinvalid' as any, + }), + ).toThrow('invalid') + }) + + test('zero-address sender rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + sender: '0x0000000000000000000000000000000000000000', + }), + ).toThrow('zero address') + }) + + test('empty frames', () => { + expect(() => + assertTransactionEIP8141({ ...baseEIP8141, frames: [] }), + ).toThrow('at least one frame') + }) + + test('exceeds MAX_FRAMES (64)', () => { + const frames = Array.from({ length: 65 }, () => ({ + mode: 0 as const, + flags: 0, + target: sender, + gasLimit: 1n, + value: 0n, + data: '0x' as const, + })) + expect(() => assertTransactionEIP8141({ ...baseEIP8141, frames })).toThrow( + 'MAX_FRAMES (64)', + ) + }) + + test('exactly MAX_FRAMES (64) passes', () => { + const frames = Array.from({ length: 64 }, () => ({ + mode: 0 as const, + flags: 0, + target: sender, + gasLimit: 1n, + value: 0n, + data: '0x' as const, + })) + expect(() => + assertTransactionEIP8141({ ...baseEIP8141, frames }), + ).not.toThrow() + }) + + test('invalid frame mode (>2)', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 3 as any, + flags: 0, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).toThrow('Invalid frame mode') + }) + + test('invalid frame flags (>=8, reserved bits set)', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 2, + flags: 8, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).toThrow('Bits 3-7 are reserved') + }) + + test('flags=0xff rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 2, + flags: 0xff, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).toThrow('Bits 3-7 are reserved') + }) + + test('VERIFY frame with zero APPROVE scope rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 1, + flags: 0x00, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).toThrow('non-zero APPROVE scope') + }) + + test('VERIFY frame with APPROVE_PAYMENT (0x01) passes', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 1, + flags: 0x01, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).not.toThrow() + }) + + test('VERIFY frame with APPROVE_EXECUTION (0x02) passes', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 1, + flags: 0x02, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).not.toThrow() + }) + + test('VERIFY frame with APPROVE_PAYMENT_AND_EXECUTION (0x03) passes', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).not.toThrow() + }) + + test('atomic batch flag on non-SENDER mode rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 0, + flags: 0x04, + target: sender, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + { + mode: 2, + flags: 0x00, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).toThrow('only valid with SENDER mode') + }) + + test('atomic batch flag on last frame rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + { + mode: 2, + flags: 0x04, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).toThrow('must not be the last frame') + }) + + test('atomic batch flag: next frame must be SENDER', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + { + mode: 2, + flags: 0x04, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + { + mode: 0, + flags: 0x00, + target: sender, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).toThrow('following an atomic batch frame must be SENDER') + }) + + test('valid atomic batch: SENDER frames with flag set then unset', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + { + mode: 2, + flags: 0x04, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + { + mode: 2, + flags: 0x00, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).not.toThrow() + }) + + test('frame gas_limit > 2^63-1 rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 2n ** 63n, + value: 0n, + data: '0x', + }, + ], + }), + ).toThrow('gasLimit` must be <= 2^63 - 1') + }) + + test('frame gas_limit at 2^63-1 passes', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 2n ** 63n - 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).not.toThrow() + }) + + test('total frame gas > 2^63-1 rejected', () => { + const gasPerFrame = 2n ** 63n - 1n + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: gasPerFrame, + data: '0x', + }, + { + mode: 2, + flags: 0x00, + target: null, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).toThrow('Total frame gas must be <= 2^63 - 1') + }) + + test('invalid frame target address', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { + mode: 1, + flags: 0x03, + target: '0xbad' as any, + gasLimit: 1n, + value: 0n, + data: '0x', + }, + ], + }), + ).toThrow('invalid') + }) + + test('fee cap too high', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + maxFeePerGas: maxUint256 + 1n, + }), + ).toThrow() + }) + + test('tip above fee cap', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + maxFeePerGas: parseGwei('1'), + maxPriorityFeePerGas: parseGwei('2'), + }), + ).toThrow() + }) +}) + +describe('eip8141 spec examples', () => { + test('Example 1: Simple Transaction (self-verify + sender)', () => { + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + nonce: 0, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 50000n, + value: 0n, + data: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefde', + }, + { + mode: 2, + flags: 0x00, + target: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + gasLimit: 100000n, + value: 0n, + data: '0xcafebabe', + }, + ], + maxPriorityFeePerGas: parseGwei('1'), + maxFeePerGas: parseGwei('10'), + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.frames[0].mode).toBe(1) + expect(parsed.frames[0].flags).toBe(0x03) + expect(parsed.frames[1].mode).toBe(2) + expect(parsed.frames[1].flags).toBe(0x00) + }) + + test('Example 2: Atomic Approve + Swap (verify + 2 SENDER atomic batch)', () => { + const erc20 = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as const + const dex = '0x1111111254fb6c44bac0bed2854e76f90643097d' as const + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + nonce: 5, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 50000n, + value: 0n, + data: '0xabcdef', + }, + { + mode: 2, + flags: 0x04, + target: erc20, + gasLimit: 60000n, + value: 0n, + data: '0x095ea7b3', + }, + { + mode: 2, + flags: 0x00, + target: dex, + gasLimit: 200000n, + value: 0n, + data: '0x12345678', + }, + ], + maxPriorityFeePerGas: parseGwei('2'), + maxFeePerGas: parseGwei('20'), + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.frames[1].flags & 0x04).toBe(0x04) + expect(parsed.frames[2].flags & 0x04).toBe(0x00) + }) + + test('Example 3: Sponsored Transaction (only_verify + pay + sender)', () => { + const sponsor = '0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc' as const + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + nonce: 10, + sender, + frames: [ + { + mode: 1, + flags: 0x02, + target: null, + gasLimit: 30000n, + value: 0n, + data: '0xdeadbeef', + }, + { + mode: 1, + flags: 0x01, + target: sponsor, + gasLimit: 40000n, + value: 0n, + data: '0xfeedface', + }, + { + mode: 2, + flags: 0x00, + target: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + gasLimit: 100000n, + value: 0n, + data: '0xcafebabe', + }, + ], + maxPriorityFeePerGas: parseGwei('1'), + maxFeePerGas: parseGwei('10'), + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.frames[0].flags & 0x03).toBe(0x02) + expect(parsed.frames[1].flags & 0x03).toBe(0x01) + }) + + test('Example 1b: Account deployment (DEFAULT + VERIFY + SENDER)', () => { + const deployer = '0x4e59b44847b379578588920ca78fbf26c0b4956c' as const + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + nonce: 0, + sender, + frames: [ + { + mode: 0, + flags: 0x00, + target: deployer, + gasLimit: 200000n, + value: 0n, + data: '0x600060005260206000f3', + }, + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 50000n, + value: 0n, + data: '0xaabbccdd', + }, + { + mode: 2, + flags: 0x00, + target: null, + gasLimit: 100000n, + value: 0n, + data: '0x11223344', + }, + ], + maxPriorityFeePerGas: parseGwei('1'), + maxFeePerGas: parseGwei('10'), + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.frames[0].mode).toBe(0) + expect(parsed.frames[1].mode).toBe(1) + expect(parsed.frames[2].mode).toBe(2) + }) + + test('multiple consecutive atomic batches', () => { + const target1 = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as const + const target2 = '0x1111111254fb6c44bac0bed2854e76f90643097d' as const + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + nonce: 0, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 50000n, + value: 0n, + data: '0xaa', + }, + { + mode: 2, + flags: 0x04, + target: target1, + gasLimit: 60000n, + value: 0n, + data: '0xbb', + }, + { + mode: 2, + flags: 0x00, + target: target1, + gasLimit: 60000n, + value: 0n, + data: '0xcc', + }, + { + mode: 2, + flags: 0x04, + target: target2, + gasLimit: 80000n, + value: 0n, + data: '0xdd', + }, + { + mode: 2, + flags: 0x04, + target: target2, + gasLimit: 80000n, + value: 0n, + data: '0xee', + }, + { + mode: 2, + flags: 0x00, + target: target2, + gasLimit: 80000n, + value: 0n, + data: '0xff', + }, + ], + maxPriorityFeePerGas: parseGwei('1'), + maxFeePerGas: parseGwei('10'), + } + expect(() => assertTransactionEIP8141(tx)).not.toThrow() + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.frames).toHaveLength(6) + }) +}) + +describe('eip8141 blob-field invariants', () => { + test('maxFeePerBlobGas non-zero without blobs rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + maxFeePerBlobGas: 1000n, + blobVersionedHashes: [], + }), + ).toThrow('`maxFeePerBlobGas` must be 0 when no blob versioned hashes') + }) + + test('maxFeePerBlobGas non-zero with undefined blobs rejected', () => { + const { blobVersionedHashes: _, ...tx } = baseEIP8141 + expect(() => + assertTransactionEIP8141({ + ...tx, + maxFeePerBlobGas: 1000n, + }), + ).toThrow('`maxFeePerBlobGas` must be 0 when no blob versioned hashes') + }) + + test('blobs present but maxFeePerBlobGas is 0 rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + maxFeePerBlobGas: 0n, + blobVersionedHashes: [ + '0x01febabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe', + ], + }), + ).toThrow( + '`maxFeePerBlobGas` must be non-zero when blob versioned hashes are present', + ) + }) + + test('blobs present but maxFeePerBlobGas undefined rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + maxFeePerBlobGas: undefined, + blobVersionedHashes: [ + '0x01febabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe', + ], + }), + ).toThrow( + '`maxFeePerBlobGas` must be non-zero when blob versioned hashes are present', + ) + }) + + test('blobs present with valid maxFeePerBlobGas passes', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + maxFeePerBlobGas: 1000n, + blobVersionedHashes: [ + '0x01febabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe', + ], + }), + ).not.toThrow() + }) + + test('no blobs and maxFeePerBlobGas=0 passes', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + maxFeePerBlobGas: 0n, + blobVersionedHashes: [], + }), + ).not.toThrow() + }) + + test('no blobs and maxFeePerBlobGas undefined passes', () => { + const { maxFeePerBlobGas: _, blobVersionedHashes: __, ...tx } = baseEIP8141 + expect(() => assertTransactionEIP8141(tx)).not.toThrow() + }) +}) + +describe('eip8141 parser strictness', () => { + test('rejects frame tuple with fewer than 6 elements', () => { + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 21000n, + value: 0n, + data: '0xaa', + }, + ], + } + const serialized = serializeTransaction(tx) + const hex = serialized.slice(4) + const items = fromRlp(`0x${hex}`, 'hex') as any[] + const frames = items[3] as any[] + frames[0] = frames[0].slice(0, 5) + const reEncoded = + `0x06${toRlp(items).slice(2)}` as TransactionSerializedEIP8141 + expect(() => parseTransaction(reEncoded)).toThrow() + }) + + test('rejects nonce above Number.MAX_SAFE_INTEGER', () => { + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 21000n, + value: 0n, + data: '0xaa', + }, + ], + nonce: 42, + } + const serialized = serializeTransaction(tx) + const hex = serialized.slice(4) + const items = fromRlp(`0x${hex}`, 'hex') as any[] + items[1] = numberToHex(BigInt(Number.MAX_SAFE_INTEGER) + 1n) + const reEncoded = + `0x06${toRlp(items).slice(2)}` as TransactionSerializedEIP8141 + expect(() => parseTransaction(reEncoded)).toThrow() + }) +}) + +describe('eip8141 edge cases', () => { + test('null target resolves correctly (serialized as empty 0x)', () => { + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 21000n, + value: 0n, + data: '0x', + }, + ], + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.frames[0].target).toBeNull() + }) + + test('explicit target address preserved', () => { + const target = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as const + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target, + gasLimit: 21000n, + value: 0n, + data: '0x', + }, + ], + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.frames[0].target).toBe(getAddress(target)) + }) + + test('empty data preserved as 0x', () => { + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 21000n, + value: 0n, + data: '0x', + }, + ], + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.frames[0].data).toBe('0x') + }) + + test('zero gasLimit preserved', () => { + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + sender, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + gasLimit: 0n, + value: 0n, + data: '0xaa', + }, + ], + } + const serialized = serializeTransaction(tx) + const parsed = parseTransaction(serialized) + expect(parsed.frames[0].gasLimit).toBe(0n) + }) + + test('serialized type byte is 0x06', () => { + const serialized = serializeTransaction(baseEIP8141) + expect(serialized.slice(0, 4)).toBe('0x06') + }) + + test('parse rejects wrong number of top-level RLP items', () => { + expect(() => + parseTransaction('0x06c50102030405' as TransactionSerializedEIP8141), + ).toThrow() + }) +}) diff --git a/src/utils/transaction/getSerializedTransactionType.test.ts b/src/utils/transaction/getSerializedTransactionType.test.ts index fd7986105d..28b2dec9b2 100644 --- a/src/utils/transaction/getSerializedTransactionType.test.ts +++ b/src/utils/transaction/getSerializedTransactionType.test.ts @@ -21,6 +21,12 @@ test('eip4844', () => { expect(type).toEqual('eip4844') }) +test('eip8141', () => { + const type = getSerializedTransactionType('0x06abc') + assertType<'eip8141'>(type) + expect(type).toEqual('eip8141') +}) + test('legacy', () => { const type = getSerializedTransactionType('0xc7c') assertType<'legacy'>(type) diff --git a/src/utils/transaction/getSerializedTransactionType.ts b/src/utils/transaction/getSerializedTransactionType.ts index d6284b8f17..51561335a5 100644 --- a/src/utils/transaction/getSerializedTransactionType.ts +++ b/src/utils/transaction/getSerializedTransactionType.ts @@ -10,6 +10,7 @@ import type { TransactionSerializedEIP2930, TransactionSerializedEIP4844, TransactionSerializedEIP7702, + TransactionSerializedEIP8141, TransactionSerializedGeneric, TransactionSerializedLegacy, TransactionType, @@ -34,6 +35,9 @@ export type GetSerializedTransactionType< | (serializedTransaction extends TransactionSerializedEIP7702 ? 'eip7702' : never) + | (serializedTransaction extends TransactionSerializedEIP8141 + ? 'eip8141' + : never) | (serializedTransaction extends TransactionSerializedLegacy ? 'legacy' : never), @@ -56,6 +60,9 @@ export function getSerializedTransactionType< ): GetSerializedTransactionType { const serializedType = sliceHex(serializedTransaction, 0, 1) + if (serializedType === '0x06') + return 'eip8141' as GetSerializedTransactionType + if (serializedType === '0x04') return 'eip7702' as GetSerializedTransactionType diff --git a/src/utils/transaction/getTransactionType.test-d.ts b/src/utils/transaction/getTransactionType.test-d.ts index 1a7deb5876..79a49c0a69 100644 --- a/src/utils/transaction/getTransactionType.test-d.ts +++ b/src/utils/transaction/getTransactionType.test-d.ts @@ -8,6 +8,7 @@ import type { import type { TransactionSerializableEIP4844, TransactionSerializableEIP7702, + TransactionSerializableEIP8141, } from '../../types/transaction.js' import { getTransactionType } from './getTransactionType.js' @@ -17,7 +18,7 @@ test('empty', () => { test('opaque', () => { expectTypeOf(getTransactionType({} as TransactionSerializable)).toEqualTypeOf< - 'legacy' | 'eip1559' | 'eip2930' | 'eip4844' | 'eip7702' + 'legacy' | 'eip1559' | 'eip2930' | 'eip4844' | 'eip7702' | 'eip8141' >() expectTypeOf( getTransactionType({} as TransactionSerializableLegacy), @@ -34,6 +35,9 @@ test('opaque', () => { expectTypeOf( getTransactionType({} as TransactionSerializableEIP7702), ).toEqualTypeOf<'eip7702'>() + expectTypeOf( + getTransactionType({} as TransactionSerializableEIP8141), + ).toEqualTypeOf<'eip8141'>() }) test('const: type', () => { diff --git a/src/utils/transaction/getTransactionType.test.ts b/src/utils/transaction/getTransactionType.test.ts index 431c424a81..85bdb151b8 100644 --- a/src/utils/transaction/getTransactionType.test.ts +++ b/src/utils/transaction/getTransactionType.test.ts @@ -27,6 +27,12 @@ describe('type', () => { expect(type).toEqual('eip7702') }) + test('eip8141', () => { + const type = getTransactionType({ chainId: 1, type: 'eip8141' }) + assertType<'eip8141'>(type) + expect(type).toEqual('eip8141') + }) + test('legacy', () => { const type = getTransactionType({ type: 'legacy' }) assertType<'legacy'>(type) @@ -120,6 +126,15 @@ describe('attributes', () => { expect(type).toEqual('eip7702') }) + test('eip8141 (frames property)', () => { + const type = getTransactionType({ + frames: [{ mode: 1, flags: 3, target: null, gasLimit: 1n, data: '0x' }], + sender: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + chainId: 1, + } as any) + expect(type).toEqual('eip8141') + }) + test('legacy', () => { const type = getTransactionType({ gasPrice: 1n }) assertType<'legacy'>(type) diff --git a/src/utils/transaction/getTransactionType.ts b/src/utils/transaction/getTransactionType.ts index 777c7e9eab..889a0d6ee2 100644 --- a/src/utils/transaction/getTransactionType.ts +++ b/src/utils/transaction/getTransactionType.ts @@ -13,6 +13,7 @@ import type { TransactionSerializableEIP2930, TransactionSerializableEIP4844, TransactionSerializableEIP7702, + TransactionSerializableEIP8141, TransactionSerializableGeneric, } from '../../types/transaction.js' import type { Assign, ExactPartial, IsNever, OneOf } from '../../types/utils.js' @@ -27,6 +28,7 @@ export type GetTransactionType< | (transaction extends EIP2930Properties ? 'eip2930' : never) | (transaction extends EIP4844Properties ? 'eip4844' : never) | (transaction extends EIP7702Properties ? 'eip7702' : never) + | (transaction extends EIP8141Properties ? 'eip8141' : never) | (transaction['type'] extends TransactionSerializableGeneric['type'] ? Extract : never), @@ -48,6 +50,12 @@ export function getTransactionType< if (transaction.type) return transaction.type as GetTransactionType + if ( + typeof (transaction as TransactionSerializableEIP8141).frames !== + 'undefined' + ) + return 'eip8141' as any + if (typeof transaction.authorizationList !== 'undefined') return 'eip7702' as any @@ -132,3 +140,6 @@ type EIP7702Properties = Assign< authorizationList: TransactionSerializableEIP7702['authorizationList'] } > +type EIP8141Properties = { + frames: TransactionSerializableEIP8141['frames'] +} diff --git a/src/utils/transaction/parseTransaction.ts b/src/utils/transaction/parseTransaction.ts index 8432398d44..93c754b654 100644 --- a/src/utils/transaction/parseTransaction.ts +++ b/src/utils/transaction/parseTransaction.ts @@ -16,6 +16,7 @@ import type { import type { Hex, Signature } from '../../types/misc.js' import type { AccessList, + Frame, TransactionRequestEIP2930, TransactionRequestLegacy, TransactionSerializable, @@ -23,16 +24,19 @@ import type { TransactionSerializableEIP2930, TransactionSerializableEIP4844, TransactionSerializableEIP7702, + TransactionSerializableEIP8141, TransactionSerializableLegacy, TransactionSerialized, TransactionSerializedEIP1559, TransactionSerializedEIP2930, TransactionSerializedEIP4844, TransactionSerializedEIP7702, + TransactionSerializedEIP8141, TransactionSerializedGeneric, TransactionType, } from '../../types/transaction.js' import type { IsNarrowable, Mutable } from '../../types/utils.js' +import { type GetAddressErrorType, getAddress } from '../address/getAddress.js' import { type IsAddressErrorType, isAddress } from '../address/isAddress.js' import { toBlobSidecars } from '../blob/toBlobSidecars.js' import { type IsHexErrorType, isHex } from '../data/isHex.js' @@ -47,17 +51,18 @@ import { import { type FromRlpErrorType, fromRlp } from '../encoding/fromRlp.js' import type { RecursiveArray } from '../encoding/toRlp.js' import { isHash } from '../hash/isHash.js' - import { type AssertTransactionEIP1559ErrorType, type AssertTransactionEIP2930ErrorType, type AssertTransactionEIP4844ErrorType, type AssertTransactionEIP7702ErrorType, + type AssertTransactionEIP8141ErrorType, type AssertTransactionLegacyErrorType, assertTransactionEIP1559, assertTransactionEIP2930, assertTransactionEIP4844, assertTransactionEIP7702, + assertTransactionEIP8141, assertTransactionLegacy, } from './assertTransaction.js' import { @@ -77,6 +82,7 @@ export type ParseTransactionReturnType< ? TransactionSerializableEIP4844 : never) | (type extends 'eip7702' ? TransactionSerializableEIP7702 : never) + | (type extends 'eip8141' ? TransactionSerializableEIP8141 : never) | (type extends 'legacy' ? TransactionSerializableLegacy : never) : TransactionSerializable @@ -86,6 +92,7 @@ export type ParseTransactionErrorType = | ParseTransactionEIP2930ErrorType | ParseTransactionEIP4844ErrorType | ParseTransactionEIP7702ErrorType + | ParseTransactionEIP8141ErrorType | ParseTransactionLegacyErrorType export function parseTransaction< @@ -113,11 +120,121 @@ export function parseTransaction< serializedTransaction as TransactionSerializedEIP7702, ) as ParseTransactionReturnType + if (type === 'eip8141') + return parseTransactionEIP8141( + serializedTransaction as TransactionSerializedEIP8141, + ) as ParseTransactionReturnType + return parseTransactionLegacy( serializedTransaction, ) as ParseTransactionReturnType } +type ParseTransactionEIP8141ErrorType = + | ToTransactionArrayErrorType + | AssertTransactionEIP8141ErrorType + | HexToBigIntErrorType + | HexToNumberErrorType + | InvalidSerializedTransactionErrorType + | IsHexErrorType + | GetAddressErrorType + | ErrorType + +function parseTransactionEIP8141( + serializedTransaction: TransactionSerializedEIP8141, +): TransactionSerializableEIP8141 { + const transactionArray = toTransactionArray(serializedTransaction) + + const [ + chainId, + nonce, + sender, + framesArray, + maxPriorityFeePerGas, + maxFeePerGas, + maxFeePerBlobGas, + blobVersionedHashes, + ] = transactionArray + + if (transactionArray.length !== 8) + throw new InvalidSerializedTransactionError({ + attributes: { + chainId, + nonce, + sender, + frames: framesArray, + maxPriorityFeePerGas, + maxFeePerGas, + maxFeePerBlobGas, + blobVersionedHashes, + }, + serializedTransaction, + type: 'eip8141', + }) + + const frames: Frame[] = (framesArray as RecursiveArray[]).map( + (frameArray) => { + const tuple = frameArray as Hex[] + if (tuple.length !== 6) + throw new InvalidSerializedTransactionError({ + attributes: { frame: tuple }, + serializedTransaction, + type: 'eip8141', + }) + const [mode, flags, target, gasLimit, value, data] = tuple + const parsedMode = mode === '0x' ? 0 : hexToNumber(mode) + if (parsedMode > 2) + throw new InvalidSerializedTransactionError({ + attributes: { frameMode: parsedMode }, + serializedTransaction, + type: 'eip8141', + }) + return { + mode: parsedMode as Frame['mode'], + flags: flags === '0x' ? 0 : hexToNumber(flags), + target: isHex(target) && target !== '0x' ? getAddress(target) : null, + gasLimit: gasLimit === '0x' ? 0n : hexToBigInt(gasLimit), + value: value === '0x' ? 0n : hexToBigInt(value), + data: isHex(data) && data !== '0x' ? data : '0x', + } + }, + ) + + const transaction: TransactionSerializableEIP8141 = { + chainId: hexToNumber(chainId as Hex), + sender: sender as Hex, + frames, + type: 'eip8141', + } + + if (isHex(nonce)) { + if (nonce === '0x') { + transaction.nonce = 0 + } else { + const nonceValue = hexToBigInt(nonce) + if (nonceValue > BigInt(Number.MAX_SAFE_INTEGER)) + throw new InvalidSerializedTransactionError({ + attributes: { nonce: nonceValue }, + serializedTransaction, + type: 'eip8141', + }) + transaction.nonce = Number(nonceValue) + } + } + if (isHex(maxFeePerGas) && maxFeePerGas !== '0x') + transaction.maxFeePerGas = hexToBigInt(maxFeePerGas) + if (isHex(maxPriorityFeePerGas) && maxPriorityFeePerGas !== '0x') + transaction.maxPriorityFeePerGas = hexToBigInt(maxPriorityFeePerGas) + if (isHex(maxFeePerBlobGas) && maxFeePerBlobGas !== '0x') + transaction.maxFeePerBlobGas = hexToBigInt(maxFeePerBlobGas) + if (Array.isArray(blobVersionedHashes) && blobVersionedHashes.length > 0) + transaction.blobVersionedHashes = blobVersionedHashes as Hex[] + + assertTransactionEIP8141(transaction) + + return transaction +} + type ParseTransactionEIP7702ErrorType = | ToTransactionArrayErrorType | AssertTransactionEIP7702ErrorType diff --git a/src/utils/transaction/serializeTransaction.ts b/src/utils/transaction/serializeTransaction.ts index 060eb8a6bb..83e28fdb60 100644 --- a/src/utils/transaction/serializeTransaction.ts +++ b/src/utils/transaction/serializeTransaction.ts @@ -15,6 +15,7 @@ import type { TransactionSerializableEIP2930, TransactionSerializableEIP4844, TransactionSerializableEIP7702, + TransactionSerializableEIP8141, TransactionSerializableGeneric, TransactionSerializableLegacy, TransactionSerialized, @@ -22,10 +23,12 @@ import type { TransactionSerializedEIP2930, TransactionSerializedEIP4844, TransactionSerializedEIP7702, + TransactionSerializedEIP8141, TransactionSerializedLegacy, TransactionType, } from '../../types/transaction.js' import type { MaybePromise, OneOf } from '../../types/utils.js' +import { type GetAddressErrorType, getAddress } from '../address/getAddress.js' import { type SerializeAuthorizationListErrorType, serializeAuthorizationList, @@ -54,17 +57,18 @@ import { numberToHex, } from '../encoding/toHex.js' import { type ToRlpErrorType, toRlp } from '../encoding/toRlp.js' - import { type AssertTransactionEIP1559ErrorType, type AssertTransactionEIP2930ErrorType, type AssertTransactionEIP4844ErrorType, type AssertTransactionEIP7702ErrorType, + type AssertTransactionEIP8141ErrorType, type AssertTransactionLegacyErrorType, assertTransactionEIP1559, assertTransactionEIP2930, assertTransactionEIP4844, assertTransactionEIP7702, + assertTransactionEIP8141, assertTransactionLegacy, } from './assertTransaction.js' import { @@ -103,6 +107,7 @@ export type SerializeTransactionErrorType = | SerializeTransactionEIP2930ErrorType | SerializeTransactionEIP4844ErrorType | SerializeTransactionEIP7702ErrorType + | SerializeTransactionEIP8141ErrorType | SerializeTransactionLegacyErrorType | ErrorType @@ -140,12 +145,65 @@ export function serializeTransaction< signature, ) as SerializedTransactionReturnType + if (type === 'eip8141') + return serializeTransactionEIP8141( + transaction as TransactionSerializableEIP8141, + ) as SerializedTransactionReturnType + return serializeTransactionLegacy( transaction as TransactionSerializableLegacy, signature as SignatureLegacy, ) as SerializedTransactionReturnType } +type SerializeTransactionEIP8141ErrorType = + | AssertTransactionEIP8141ErrorType + | ConcatHexErrorType + | GetAddressErrorType + | NumberToHexErrorType + | ToRlpErrorType + | ErrorType + +function serializeTransactionEIP8141( + transaction: TransactionSerializableEIP8141, +): TransactionSerializedEIP8141 { + const { + chainId, + nonce, + sender, + frames, + maxPriorityFeePerGas, + maxFeePerGas, + maxFeePerBlobGas, + blobVersionedHashes, + } = transaction + + assertTransactionEIP8141(transaction) + + const serializedFrames = frames.map((frame) => [ + frame.mode ? numberToHex(frame.mode) : '0x', + frame.flags ? numberToHex(frame.flags) : '0x', + frame.target ? getAddress(frame.target) : '0x', + frame.gasLimit ? numberToHex(frame.gasLimit) : '0x', + frame.value ? numberToHex(frame.value) : '0x', + frame.data ?? '0x', + ]) + + return concatHex([ + '0x06', + toRlp([ + numberToHex(chainId), + nonce ? numberToHex(nonce) : '0x', + sender, + serializedFrames, + maxPriorityFeePerGas ? numberToHex(maxPriorityFeePerGas) : '0x', + maxFeePerGas ? numberToHex(maxFeePerGas) : '0x', + maxFeePerBlobGas ? numberToHex(maxFeePerBlobGas) : '0x', + blobVersionedHashes ?? [], + ]), + ]) as TransactionSerializedEIP8141 +} + type SerializeTransactionEIP7702ErrorType = | AssertTransactionEIP7702ErrorType | SerializeAuthorizationListErrorType diff --git a/src/zksync/actions/claimFailedDeposit.ts b/src/zksync/actions/claimFailedDeposit.ts index ad4e1f6713..0d85b4fada 100644 --- a/src/zksync/actions/claimFailedDeposit.ts +++ b/src/zksync/actions/claimFailedDeposit.ts @@ -176,6 +176,8 @@ export async function claimFailedDeposit< throw new CannotClaimSuccessfulDepositError({ hash: depositHash }) const tx = await getTransaction(l2Client, { hash: depositHash }) + if (!tx.input) + throw new Error('Deposit transaction is missing input calldata.') // Undo the aliasing, since the Mailbox contract set it as for contract address. const l1BridgeAddress = undoL1ToL2Alias(receipt.from) diff --git a/src/zksync/actions/signTransaction.ts b/src/zksync/actions/signTransaction.ts index ab2cdbe175..e560b17599 100644 --- a/src/zksync/actions/signTransaction.ts +++ b/src/zksync/actions/signTransaction.ts @@ -38,7 +38,10 @@ export type SignTransactionParameters< GetAccountParameter & GetChainParameter -export type SignTransactionReturnType = SignTransactionReturnType_ +export type SignTransactionReturnType = Exclude< + SignTransactionReturnType_, + `0x06${string}` +> export type SignTransactionErrorType = SignTransactionErrorType_ @@ -91,5 +94,8 @@ export async function signTransaction< args: SignTransactionParameters, ): Promise { if (isEIP712Transaction(args)) return signEip712Transaction(client, args) - return await signTransaction_(client, args as any) + return (await signTransaction_( + client, + args as any, + )) as SignTransactionReturnType } diff --git a/src/zksync/formatters.test-d.ts b/src/zksync/formatters.test-d.ts index cc482fa0c9..5380f951c6 100644 --- a/src/zksync/formatters.test-d.ts +++ b/src/zksync/formatters.test-d.ts @@ -136,6 +136,7 @@ describe('smoke', () => { | 'eip1559' | 'eip4844' | 'eip7702' + | 'eip8141' | 'eip712' | 'priority' >()