diff --git a/examples/frame-transactions/README.md b/examples/frame-transactions/README.md new file mode 100644 index 0000000000..e19d01df18 --- /dev/null +++ b/examples/frame-transactions/README.md @@ -0,0 +1,100 @@ +# 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, execution and state gas limits, and calldata, plus an outer +list of protocol-validated **signatures**. 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 +PRIVATE_KEY=0x... pnpm tsx simple-self-verified.ts +PRIVATE_KEY=0x... pnpm tsx sponsored-transaction.ts +PRIVATE_KEY=0x... pnpm tsx atomic-batch.ts +``` + +`PRIVATE_KEY` defaults to the first Anvil dev key. + +## Examples + +| File | Scenario | +|------|----------| +| `simple-self-verified.ts` | Minimal VERIFY + SENDER flow: the protocol's default code checks the sender's signature and approves, then the sender transfers ETH | +| `sponsored-transaction.ts` | The sender approves execution only; a paymaster VERIFY frame approves payment and is repaid in ERC-20 tokens | +| `atomic-batch.ts` | Two SENDER frames linked with the atomic batch flag: ERC-20 approve then DEX swap, all-or-nothing | + +## Transaction shape + +```ts +const tx: TransactionSerializableEIP8141 = { + chainId, + nonce, + sender, + frames: [{ mode, flags, target, limits: { execution, state }, value, data }], + signatures: [{ scheme, signer, msg, signature }], // optional + maxPriorityFeePerGas, + maxFeePerGas, + maxFeePerBlobGas, // must be 0 / omitted without blobs + blobVersionedHashes, +} +``` + +## Signing + +`account.signTransaction(tx)` signs the canonical EIP-8141 signature hash +(`keccak256(0x06 || rlp(tx))` with the `signature` bytes of every entry whose +`msg` is empty elided) and stores `v || r || s` in the first `SECP256K1` +signature slot that has no explicit `signer`, appending one if needed. The +default code for accounts without code reads that slot to authorise the +transaction. `recoverTransactionAddress` recovers the sender from the same slot. + +## Frame Modes + +| Mode | Name | Behaviour | +|------|------|-----------| +| 0 | DEFAULT | Executes as the entry point (address `0xaa`) | +| 1 | VERIFY | Read-only validation; may call the `APPROVE` opcode | +| 2 | SENDER | Executes as `tx.sender` (requires prior approval) | + +## Frame Flags + +| Bits | Meaning | Valid with | +|------|---------|------------| +| 0-1 | Approval scope: `0x1` payment, `0x2` execution, `0x3` both. Execution scope requires `target` to be `null` or the sender | Any mode | +| 2 | Atomic batch: this frame and the next succeed or revert together. Batched frames may not carry an approval scope | DEFAULT, SENDER | + +## Signature Schemes + +| Scheme | Name | `signature` encoding | +|--------|------|----------------------| +| 0 | ARBITRARY | Arbitrary bytes, `signer` must be `null` | +| 1 | SECP256K1 | `v (1 byte) || r (32 bytes) || s (32 bytes)` | +| 2 | P256 | `r || s || qx || qy` (32 bytes each) | + +`msg` is `'0x'` to sign the canonical transaction hash, or an explicit 32-byte digest. + +## Receipts + +Frame transaction receipts carry `payer` and per-frame `frameReceipts`, each with +`status` (`success`, `reverted`, or `skipped` for frames rolled back by a failed +atomic batch), `gasUsed` (execution gas), `stateGasUsed`, and `logs`. diff --git a/examples/frame-transactions/atomic-batch.ts b/examples/frame-transactions/atomic-batch.ts new file mode 100644 index 0000000000..db50bb63d9 --- /dev/null +++ b/examples/frame-transactions/atomic-batch.ts @@ -0,0 +1,170 @@ +/** + * Atomic Batch Frame Transaction (EIP-8141 Example 2) + * + * Uses the ATOMIC_BATCH_FLAG (0x04) to link two SENDER frames so they + * execute atomically: if either reverts, both revert. + * + * Frame 0 (VERIFY): Default code checks the sender's signature and + * calls APPROVE(EXECUTION_AND_PAYMENT). + * 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. Frames inside a batch + * may not carry an APPROVE scope. + */ + +import { + type Address, + createClient, + encodeFunctionData, + type Hex, + http, + parseGwei, + parseUnits, + type TransactionSerializableEIP8141, +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { sendRawTransaction } from 'viem/actions' + +const RPC_URL = 'https://rpc1.eip-8141.ethrex.xyz' +const CHAIN_ID = 3151908 + +// Demo key and addresses -- replace with your own for a real network. +const PRIVATE_KEY = (process.env.PRIVATE_KEY ?? + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') as Hex + +const account = privateKeyToAccount(PRIVATE_KEY) +const usdcToken: Address = '0x5FbDB2315678afecb367f032d93F642f64180aa3' +const dexRouter: Address = '0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9' +const wethToken: Address = '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0' + +const VERIFY = 1 +const SENDER = 2 +const APPROVE_EXECUTION_AND_PAYMENT = 0x03 +const ATOMIC_BATCH_FLAG = 0x04 + +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: account.address, + maxPriorityFeePerGas: parseGwei('1'), + maxFeePerGas: parseGwei('10'), + frames: [ + // Frame 0 -- VERIFY: default code checks `signatures[0]` and approves. + { + mode: VERIFY, + flags: APPROVE_EXECUTION_AND_PAYMENT, + target: null, + limits: { execution: 30_000n, state: 0n }, + value: 0n, + data: '0x', + }, + + // 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: SENDER, + flags: ATOMIC_BATCH_FLAG, + target: usdcToken, + limits: { execution: 60_000n, state: 25_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: SENDER, + flags: 0, + target: dexRouter, + limits: { execution: 200_000n, state: 50_000n }, + value: 0n, + data: encodeFunctionData({ + abi: dexAbi, + functionName: 'swapExactTokensForTokens', + args: [ + swapAmount, + minOut, + [usdcToken, wethToken], + account.address, + deadline, + ], + }), + }, + ], +} + +async function main() { + const serialized = await account.signTransaction(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 - default code 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 sendRawTransaction(client, { + serializedTransaction: serialized, + }) + 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..83e5583aa6 --- /dev/null +++ b/examples/frame-transactions/simple-self-verified.ts @@ -0,0 +1,93 @@ +/** + * Simple Self-Verified Frame Transaction (EIP-8141 Example 1a) + * + * The most basic EIP-8141 pattern: two frames and one signature. + * + * Frame 0 (VERIFY): Targets `null` (the sender). An account without code + * runs the protocol's default code, which checks the + * sender's SECP256K1 signature in `tx.signatures` and + * calls APPROVE(EXECUTION_AND_PAYMENT). + * Frame 1 (SENDER): Executes a plain ETH transfer as the sender. + * + * `signTransaction` signs the canonical signature hash and places the + * signature (`v || r || s`) into the outer `signatures` list. + */ + +import { + type Address, + createClient, + type Hex, + http, + parseEther, + parseGwei, + type TransactionSerializableEIP8141, +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { sendRawTransaction } from 'viem/actions' + +const RPC_URL = 'https://rpc1.eip-8141.ethrex.xyz' +const CHAIN_ID = 3151908 + +// Demo key -- replace with your own for a real network. +const PRIVATE_KEY = (process.env.PRIVATE_KEY ?? + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') as Hex + +const account = privateKeyToAccount(PRIVATE_KEY) +const recipient: Address = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' + +const VERIFY = 1 +const SENDER = 2 +const APPROVE_EXECUTION_AND_PAYMENT = 0x03 + +const tx: TransactionSerializableEIP8141 = { + type: 'eip8141', + chainId: CHAIN_ID, + nonce: 0, + sender: account.address, + maxPriorityFeePerGas: parseGwei('1'), + maxFeePerGas: parseGwei('10'), + frames: [ + // Frame 0 -- VERIFY: default code checks `signatures[0]` and approves + // both execution and payment for the sender. + { + mode: VERIFY, + flags: APPROVE_EXECUTION_AND_PAYMENT, + target: null, + limits: { execution: 30_000n, state: 0n }, + value: 0n, + data: '0x', + }, + + // Frame 1 -- SENDER: transfer ETH to recipient. + { + mode: SENDER, + flags: 0, + target: recipient, + limits: { execution: 21_000n, state: 0n }, + value: parseEther('0.001'), + data: '0x', + }, + ], +} + +async function main() { + // `signTransaction` appends the sender's SECP256K1 signature to `signatures`. + const serialized = await account.signTransaction(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 sendRawTransaction(client, { + serializedTransaction: serialized, + }) + 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..82a5934215 --- /dev/null +++ b/examples/frame-transactions/sponsored-transaction.ts @@ -0,0 +1,200 @@ +/** + * Sponsored (Paymaster) Frame Transaction (EIP-8141 Example 3) + * + * Demonstrates how a third party can pay gas on behalf of the sender: + * + * Frame 0 (VERIFY): Default code checks the sender's signature and calls + * APPROVE(EXECUTION) -- execution only, no payment. + * Frame 1 (VERIFY): The sponsor's paymaster contract validates the + * request (it may inspect `tx.signatures` via SIGPARAM + * and the next frame via FRAMEPARAM) and calls + * APPROVE(PAYMENT). The sponsor becomes the `payer`. + * Frame 2 (SENDER): The sender pays the sponsor in ERC-20 tokens. + * Frame 3 (SENDER): The sender's intended action (a contract call). + * Frame 4 (DEFAULT): Optional post-op run as the entry point (0xaa), e.g. + * refunding unused fees. + */ + +import { + type Address, + createClient, + encodeFunctionData, + type Hex, + http, + parseGwei, + parseUnits, + type TransactionSerializableEIP8141, +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { sendRawTransaction } from 'viem/actions' + +const RPC_URL = 'https://rpc1.eip-8141.ethrex.xyz' +const CHAIN_ID = 3151908 + +// Demo key and addresses -- replace with your own for a real network. +const PRIVATE_KEY = (process.env.PRIVATE_KEY ?? + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80') as Hex + +const account = privateKeyToAccount(PRIVATE_KEY) +const paymaster: Address = '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0' +const feeToken: Address = '0x5FbDB2315678afecb367f032d93F642f64180aa3' +const targetContract: Address = '0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9' + +const DEFAULT = 0 +const VERIFY = 1 +const SENDER = 2 +const APPROVE_PAYMENT = 0x01 +const APPROVE_EXECUTION = 0x02 + +const erc20Abi = [ + { + name: 'transfer', + type: 'function', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'nonpayable', + }, +] 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's VERIFY entry point checks that frame 2 pays it enough +// tokens for the transaction's `max_cost`, then calls APPROVE(PAYMENT). +// Its DEFAULT post-op refunds the unused portion. +const paymasterAbi = [ + { + name: 'validate', + type: 'function', + inputs: [{ name: 'maxTokenFee', type: 'uint256' }], + outputs: [], + stateMutability: 'view', + }, + { + name: 'postOp', + type: 'function', + inputs: [{ name: 'sender', type: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, +] as const + +const maxTokenFee = parseUnits('5', 6) // up to 5 USDC for gas + +const tx: TransactionSerializableEIP8141 = { + type: 'eip8141', + chainId: CHAIN_ID, + nonce: 1, + sender: account.address, + maxPriorityFeePerGas: parseGwei('1'), + maxFeePerGas: parseGwei('10'), + frames: [ + // Frame 0 -- VERIFY: default code checks `signatures[0]` and approves + // execution only. Payment is left to the sponsor. + { + mode: VERIFY, + flags: APPROVE_EXECUTION, + target: null, + limits: { execution: 30_000n, state: 0n }, + value: 0n, + data: '0x', + }, + + // Frame 1 -- VERIFY: paymaster validates and approves payment. + { + mode: VERIFY, + flags: APPROVE_PAYMENT, + target: paymaster, + limits: { execution: 50_000n, state: 0n }, + value: 0n, + data: encodeFunctionData({ + abi: paymasterAbi, + functionName: 'validate', + args: [maxTokenFee], + }), + }, + + // Frame 2 -- SENDER: pay the sponsor in tokens. + { + mode: SENDER, + flags: 0, + target: feeToken, + limits: { execution: 60_000n, state: 25_000n }, + value: 0n, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [paymaster, maxTokenFee], + }), + }, + + // Frame 3 -- SENDER: the user's actual intent, runs as tx.sender. + { + mode: SENDER, + flags: 0, + target: targetContract, + limits: { execution: 100_000n, state: 25_000n }, + value: 0n, + data: encodeFunctionData({ + abi: storageAbi, + functionName: 'store', + args: [42n], + }), + }, + + // Frame 4 -- DEFAULT: paymaster post-op at the entry point (0xaa). + { + mode: DEFAULT, + flags: 0, + target: paymaster, + limits: { execution: 80_000n, state: 25_000n }, + value: 0n, + data: encodeFunctionData({ + abi: paymasterAbi, + functionName: 'postOp', + args: [account.address], + }), + }, + ], +} + +async function main() { + const serialized = await account.signTransaction(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 - default code approves execution') + console.log(' [1] VERIFY - paymaster approves payment') + console.log(' [2] SENDER - pay the paymaster in tokens') + console.log(' [3] SENDER - store(42) on target contract') + console.log(' [4] DEFAULT - paymaster post-op') + console.log() + + const client = createClient({ transport: http(RPC_URL) }) + + console.log('Sending to', RPC_URL, `(chainId ${CHAIN_ID}) ...`) + const hash = await sendRawTransaction(client, { + serializedTransaction: serialized, + }) + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 5b655b0063..a8c15e1a81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,7 +271,7 @@ importers: dependencies: viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: typescript: specifier: ^5.9.3 @@ -284,7 +284,7 @@ importers: dependencies: viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: typescript: specifier: ^5.9.3 @@ -297,7 +297,7 @@ importers: dependencies: viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: typescript: specifier: ^5.9.3 @@ -310,7 +310,7 @@ importers: dependencies: viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: typescript: specifier: ^5.9.3 @@ -323,7 +323,7 @@ importers: dependencies: viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: typescript: specifier: ^5.9.3 @@ -342,7 +342,7 @@ importers: version: 19.2.3(react@19.2.3) viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: '@types/react': specifier: ^19 @@ -370,7 +370,7 @@ importers: version: 19.2.3(react@19.2.3) viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: '@types/react': specifier: ^19 @@ -392,7 +392,7 @@ importers: dependencies: viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: typescript: specifier: ^5.9.3 @@ -405,7 +405,7 @@ importers: dependencies: viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: typescript: specifier: ^5.9.3 @@ -424,7 +424,7 @@ importers: version: 19.2.3(react@19.2.3) viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: '@types/react': specifier: ^19 @@ -446,7 +446,7 @@ importers: dependencies: viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: typescript: specifier: ^5.9.3 @@ -455,11 +455,24 @@ importers: specifier: 8.0.16 version: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.36.0)(tsx@4.22.4)(yaml@2.8.3) + examples/frame-transactions: + dependencies: + viem: + specifier: file:../../src + version: link:../../src + devDependencies: + tsx: + specifier: ^4.19.0 + version: 4.22.4 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + examples/logs_block-event-logs: dependencies: viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: typescript: specifier: ^5.9.3 @@ -472,7 +485,7 @@ importers: dependencies: viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: typescript: specifier: ^5.9.3 @@ -485,7 +498,7 @@ importers: dependencies: viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: typescript: specifier: ^5.9.3 @@ -548,7 +561,7 @@ importers: version: 19.2.3(react@19.2.3) viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: '@types/react': specifier: ^19 @@ -576,7 +589,7 @@ importers: version: 19.2.3(react@19.2.3) viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: '@types/react': specifier: ^19 @@ -598,7 +611,7 @@ importers: dependencies: viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: typescript: specifier: ^5.9.3 @@ -617,7 +630,7 @@ importers: version: 19.2.3(react@19.2.3) viem: specifier: latest - version: 2.55.8(typescript@5.9.3)(zod@4.4.3) + version: 2.56.3(typescript@5.9.3)(zod@4.4.3) devDependencies: '@types/react': specifier: ^19 @@ -1277,7 +1290,7 @@ packages: resolution: {integrity: sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==} engines: {node: '>=20'} peerDependencies: - hono: '>=4.12.27' + hono: '>=4.12.34' '@iconify-json/lucide@1.2.114': resolution: {integrity: sha512-NbvH3B1BYo6wBtS7joLi7f2UVQOqK2dtZodMFf3kkBs+Tnh9TkRuy8oVHr1RM8UK6bUtvAXxfNlGAah0CuvPCw==} @@ -3885,6 +3898,7 @@ packages: cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + deprecated: v4 is no longer maintained, upgrade to v5 cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} @@ -5367,7 +5381,7 @@ packages: '@modelcontextprotocol/sdk': 1.26.0 elysia: '>=1' express: '>=5' - hono: '>=4.12.27' + hono: '>=4.12.34' viem: '>=2.47.5' peerDependenciesMeta: '@modelcontextprotocol/sdk': @@ -5796,6 +5810,7 @@ packages: prom-client@14.2.0: resolution: {integrity: sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==} engines: {node: '>=10'} + deprecated: prom-client has been replaced by @prometheus-io/client prool@0.2.14: resolution: {integrity: sha512-GAu9hAJqIMac/16sHTm//ya8qfqwh0M+czyRcQYlL8daXYHhSfxOCbNv9SFkmRQ1wZhZHDvDtfqNJZmCcwzJaw==} @@ -6870,8 +6885,8 @@ packages: typescript: optional: true - viem@2.55.8: - resolution: {integrity: sha512-BHqtsmK4iMLuLnRyrPIB1jVrmFVliRIP/K0dnFT7gBOpfo8Ko4ozhkzUCRNfR+Z/ZZdnlnVrh04fAOuIm5Svkg==} + viem@2.56.3: + resolution: {integrity: sha512-vUObq3GO7D3lz9gPNEE/xd5jIUnMbeVDWewh252o54pDEDlW4pDe6FEd/qTGL5RyK52cGHMM7kQ3wjxhilEvkQ==} peerDependencies: typescript: ^5.9.3 peerDependenciesMeta: @@ -14490,7 +14505,7 @@ snapshots: - utf-8-validate - zod - viem@2.55.8(typescript@5.9.3)(zod@4.4.3): + viem@2.56.3(typescript@5.9.3)(zod@4.4.3): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 diff --git a/src/accounts/utils/signTransaction.ts b/src/accounts/utils/signTransaction.ts index 6a8cabc77e..fc592ad96e 100644 --- a/src/accounts/utils/signTransaction.ts +++ b/src/accounts/utils/signTransaction.ts @@ -10,6 +10,7 @@ import { } from '../../utils/hash/keccak256.js' import type { GetTransactionType } from '../../utils/transaction/getTransactionType.js' import { + attachSignatureEIP8141, type SerializeTransactionFn, serializeTransaction, } from '../../utils/transaction/serializeTransaction.js' @@ -44,11 +45,18 @@ export async function signTransaction< >( parameters: SignTransactionParameters, ): Promise> { - const { - privateKey, - transaction, - serializer = serializeTransaction, - } = parameters + const { privateKey, serializer = serializeTransaction } = parameters + + const transaction = (() => { + // For EIP-8141 Transactions, the signature is placed in the outer `signatures` list, + // so reserve its slot up-front: the canonical signature hash commits to the slot's metadata. + if ('frames' in parameters.transaction) + return { + ...parameters.transaction, + signatures: attachSignatureEIP8141(parameters.transaction.signatures), + } as typeof parameters.transaction + return parameters.transaction + })() const signableTransaction = (() => { // For EIP-4844 Transactions, we want to sign the transaction payload body (tx_payload_body) without the sidecars (ie. without the network wrapper). @@ -58,6 +66,18 @@ export async function signTransaction< ...transaction, sidecars: false, } + // For EIP-8141 Transactions, the signature hash elides the `signature` bytes of every + // entry signed over the canonical hash (empty `msg`). + // See: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-8141.md#signature-hash + if ('frames' in transaction) + return { + ...transaction, + signatures: transaction.signatures?.map((signature) => + signature.msg === '0x' + ? { ...signature, signature: '0x' as const } + : signature, + ), + } return transaction })() 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 164917e363..60d30f4f6b 100644 --- a/src/actions/wallet/prepareTransactionRequest.ts +++ b/src/actions/wallet/prepareTransactionRequest.ts @@ -111,7 +111,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. @@ -204,7 +212,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 f955058249..ec0d44769f 100644 --- a/src/actions/wallet/sendTransaction.ts +++ b/src/actions/wallet/sendTransaction.ts @@ -71,7 +71,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 758ff748bd..87c0e94c07 100644 --- a/src/actions/wallet/sendTransactionSync.ts +++ b/src/actions/wallet/sendTransactionSync.ts @@ -80,7 +80,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 1930d7c11a..6cc9fa9dac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1232,6 +1232,9 @@ export type { RpcBlockNumber, RpcFeeHistory, RpcFeeValues, + RpcFrame, + RpcFrameReceipt, + RpcFrameSignature, RpcLog, RpcProof, RpcStateMapping, @@ -1248,12 +1251,20 @@ export type { } from './types/stateOverride.js' export type { AccessList, + Frame, + FrameLimits, + FrameMode, + FrameReceipt, + FrameReceiptStatus, + FrameSignature, + FrameSignatureScheme, Transaction, TransactionBase, TransactionEIP1559, TransactionEIP2930, TransactionEIP4844, TransactionEIP7702, + TransactionEIP8141, TransactionLegacy, TransactionReceipt, TransactionRequest, @@ -1262,6 +1273,7 @@ export type { TransactionRequestEIP2930, TransactionRequestEIP4844, TransactionRequestEIP7702, + TransactionRequestEIP8141, TransactionRequestGeneric, TransactionRequestLegacy, TransactionSerializable, @@ -1270,6 +1282,7 @@ export type { TransactionSerializableEIP2930, TransactionSerializableEIP4844, TransactionSerializableEIP7702, + TransactionSerializableEIP8141, TransactionSerializableGeneric, TransactionSerializableLegacy, TransactionSerialized, @@ -1277,6 +1290,7 @@ export type { TransactionSerializedEIP2930, TransactionSerializedEIP4844, TransactionSerializedEIP7702, + TransactionSerializedEIP8141, TransactionSerializedGeneric, TransactionSerializedLegacy, TransactionType, @@ -1910,9 +1924,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 60704fe1f5..31c4778e99 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..1d3dd7569f 100644 --- a/src/types/rpc.ts +++ b/src/types/rpc.ts @@ -12,16 +12,22 @@ import type { Log } from './log.js' import type { Hex } from './misc.js' import type { Proof } from './proof.js' import type { + Frame, + FrameLimits, + FrameReceipt, + FrameSignature, TransactionEIP1559, TransactionEIP2930, TransactionEIP4844, TransactionEIP7702, + TransactionEIP8141, TransactionLegacy, TransactionReceipt, TransactionRequestEIP1559, TransactionRequestEIP2930, TransactionRequestEIP4844, TransactionRequestEIP7702, + TransactionRequestEIP8141, TransactionRequestLegacy, } from './transaction.js' import type { Omit, OneOf, PartialBy } from './utils.js' @@ -35,8 +41,22 @@ export type TransactionType = | '0x2' | '0x3' | '0x4' + | '0x6' | (string & {}) +/** EIP-8141 frame receipt status: `0x0` failure, `0x1` success, `0x2` skipped. */ +export type FrameStatus = '0x0' | '0x1' | '0x2' + +export type RpcFrame = Omit & { + mode: Quantity + flags: Quantity + limits: FrameLimits + value?: Quantity | undefined +} +export type RpcFrameSignature = Omit & { + scheme: Quantity +} + export type RpcAuthorization = { /** Address of the contract to set as code for the Authority. */ address: Address @@ -64,12 +84,11 @@ export type RpcFeeHistory = FeeHistory export type RpcFeeValues = FeeValues export type RpcLog = Log export type RpcProof = Proof -export type RpcTransactionReceipt = TransactionReceipt< - Quantity, - Index, - Status, - TransactionType -> +export type RpcFrameReceipt = FrameReceipt +export type RpcTransactionReceipt = Omit< + TransactionReceipt, + 'frameReceipts' +> & { frameReceipts?: RpcFrameReceipt[] | undefined } export type RpcTransactionRequest = OneOf< | TransactionRequestLegacy | TransactionRequestEIP2930 @@ -79,6 +98,13 @@ export type RpcTransactionRequest = OneOf< TransactionRequestEIP7702, 'authorizationList' > & { authorizationList?: RpcAuthorizationList | undefined }) + | (Omit< + TransactionRequestEIP8141, + 'frames' | 'signatures' + > & { + frames: readonly RpcFrame[] + signatures?: readonly RpcFrameSignature[] | undefined + }) > // `yParity` is optional on the RPC type as some nodes do not return it // for 1559 & 2930 transactions (they should!). @@ -103,6 +129,13 @@ export type RpcTransaction = OneOf< > & { authorizationList?: RpcAuthorizationList | undefined }, 'yParity' > + | (Omit< + TransactionEIP8141, + 'typeHex' | 'frames' | 'signatures' + > & { + frames: readonly RpcFrame[] + signatures: readonly RpcFrameSignature[] + }) > type SuccessResult = { diff --git a/src/types/transaction.ts b/src/types/transaction.ts index 305bf8c98d..29f8b2399c 100644 --- a/src/types/transaction.ts +++ b/src/types/transaction.ts @@ -33,8 +33,101 @@ export type TransactionType = | 'eip2930' | 'eip4844' | 'eip7702' + | 'eip8141' | (string & {}) +/** EIP-8141 frame execution mode. */ +export type FrameMode = + | 0 // DEFAULT: execute as ENTRY_POINT (address 0xaa) + | 1 // VERIFY: read-only validation; may call the APPROVE opcode + | 2 // SENDER: execute as tx.sender (requires prior approval) + +/** EIP-8141 two-dimensional frame gas limits (`limits = [execution, state]`). */ +export type FrameLimits = { + /** Maximum execution gas the frame may consume. */ + execution: quantity + /** Maximum state gas (EIP-8037) the frame may consume. */ + state: quantity +} + +/** + * 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 (DEFAULT and SENDER modes only). + * Bits 3..: reserved, must be zero. + */ + flags: number + /** Target address for this frame, or `null` to use `tx.sender`. */ + target: Address | null + /** Execution and state gas limits allocated exclusively to this frame. */ + limits: FrameLimits + /** + * 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 +} + +/** EIP-8141 signature verification scheme. */ +export type FrameSignatureScheme = + | 0 // ARBITRARY: arbitrary bytes, not validated by the protocol + | 1 // SECP256K1: `v (1 byte) || r (32 bytes) || s (32 bytes)` + | 2 // P256: `r || s || qx || qy` (32 bytes each) + +/** + * An entry in the EIP-8141 outer `signatures` list: + * `[scheme, signer, msg, signature]`. + */ +export type FrameSignature = { + /** Verification scheme used to interpret `signature`. */ + scheme: FrameSignatureScheme + /** + * Signer address for `SECP256K1` / `P256`, or `null` to resolve to + * `tx.sender`. Must be `null` for `ARBITRARY`. + */ + signer: Address | null + /** + * `'0x'` to sign the canonical transaction signature hash, or an explicit + * non-zero 32-byte digest. + */ + msg: Hex + /** + * Raw signature bytes. Entries with an empty `msg` have these bytes elided + * when computing the canonical signature hash. + */ + signature: Hex +} + +/** Per-frame receipt status: `0x0` failure, `0x1` success, `0x2` skipped. */ +export type FrameReceiptStatus = 'success' | 'reverted' | 'skipped' + +/** Per-frame receipt as defined by EIP-8141: `[status, [execution, state], logs]`. */ +export type FrameReceipt< + quantity = bigint, + index = number, + status = FrameReceiptStatus, +> = { + /** + * Return status of the frame's top-level call. `'skipped'` marks a frame + * that never executed because an earlier frame of its atomic batch failed. + */ + status: status + /** Execution gas consumed by this frame (before refunds). */ + gasUsed: quantity + /** State gas attributed to this frame after all refills and rollbacks. */ + stateGasUsed: quantity + /** Logs emitted during this frame's execution. */ + logs: Log[] +} + export type TransactionReceipt< quantity = bigint, index = number, @@ -77,6 +170,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< @@ -196,6 +293,52 @@ export type TransactionEIP7702< type: type } & FeeValuesEIP1559 +/** + * EIP-8141 Frame Transaction as returned by `eth_getTransactionByHash`. + * Authorization is carried by the outer `signatures` list and the frames + * 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[] + /** Outer signature list, validated before any frame executes. */ + signatures: readonly FrameSignature[] + /** 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, @@ -206,6 +349,7 @@ export type Transaction< | TransactionEIP1559 | TransactionEIP4844 | TransactionEIP7702 + | TransactionEIP8141 > //////////////////////////////////////////////////////////////////////////////////////////// @@ -217,6 +361,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 */ @@ -288,6 +434,39 @@ export type TransactionRequestEIP7702< authorizationList?: AuthorizationList | undefined } +/** + * EIP-8141 Frame Transaction request (for `eth_sendRawTransaction`). + * Authorization is carried by the outer `signatures` list and the frames, + * 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[] + /** Outer signature list, validated before any frame executes. */ + signatures?: readonly FrameSignature[] | undefined + /** 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 @@ -318,6 +497,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< @@ -327,6 +507,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 @@ -405,12 +586,51 @@ 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 in the outer `signatures` list. + * + * The canonical signature hash is `keccak256(serializeTransaction(tx))` with + * the `signature` bytes of every entry whose `msg` is `'0x'` elided. + */ +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[] + /** + * Outer signature list. When signing with `signTransaction`, the first + * `SECP256K1` entry with no explicit `signer`, an empty `msg` and an empty + * `signature` receives the signature (one is appended if none exists). + */ + signatures?: readonly FrameSignature[] | undefined + /** 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.test.ts b/src/utils/formatters/transaction.test.ts index 33e4aff355..79aca36977 100644 --- a/src/utils/formatters/transaction.test.ts +++ b/src/utils/formatters/transaction.test.ts @@ -811,3 +811,104 @@ test('contract deployment transaction', () => { } `) }) + +test('eip8141 transaction', () => { + expect( + formatTransaction({ + blockHash: '0x1', + blockNumber: '0x10f2c', + chainId: '0x1', + from: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + hash: '0x2', + nonce: '0x3', + sender: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + frames: [ + { + mode: '0x1', + flags: '0x3', + target: null, + limits: { execution: '0xc350', state: '0x0' }, + value: '0x0', + data: '0x', + }, + { + mode: '0x2', + flags: '0x0', + target: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + limits: { execution: '0x186a0', state: '0x2710' }, + value: '0xde0b6b3a7640000', + data: '0xcafebabe', + }, + ], + signatures: [ + { + scheme: '0x1', + signer: null, + msg: '0x', + signature: `0x00${'ab'.repeat(64)}`, + }, + ], + maxFeePerBlobGas: '0x0', + maxFeePerGas: '0x2540be400', + maxPriorityFeePerGas: '0x3b9aca00', + blobVersionedHashes: [], + transactionIndex: '0x4', + type: '0x6', + }), + ).toMatchInlineSnapshot(` + { + "blobVersionedHashes": [], + "blockHash": "0x1", + "blockNumber": 69420n, + "chainId": 1, + "frames": [ + { + "data": "0x", + "flags": 3, + "limits": { + "execution": 50000n, + "state": 0n, + }, + "mode": 1, + "target": null, + "value": 0n, + }, + { + "data": "0xcafebabe", + "flags": 0, + "limits": { + "execution": 100000n, + "state": 10000n, + }, + "mode": 2, + "target": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + "value": 1000000000000000000n, + }, + ], + "from": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "gas": undefined, + "gasPrice": undefined, + "hash": "0x2", + "maxFeePerBlobGas": 0n, + "maxFeePerGas": 10000000000n, + "maxPriorityFeePerGas": 1000000000n, + "nonce": 3, + "sender": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "signatures": [ + { + "msg": "0x", + "scheme": 1, + "signature": "0x00abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "signer": null, + }, + ], + "to": null, + "transactionIndex": 4, + "type": "eip8141", + "typeHex": "0x6", + "v": undefined, + "value": undefined, + "yParity": undefined, + } + `) +}) diff --git a/src/utils/formatters/transaction.ts b/src/utils/formatters/transaction.ts index 931a17ce77..3b40fe84b2 100644 --- a/src/utils/formatters/transaction.ts +++ b/src/utils/formatters/transaction.ts @@ -7,8 +7,18 @@ import type { ExtractChainFormatterReturnType, } from '../../types/chain.js' import type { Hex } from '../../types/misc.js' -import type { RpcAuthorizationList, RpcTransaction } from '../../types/rpc.js' -import type { Transaction, TransactionType } from '../../types/transaction.js' +import type { + RpcAuthorizationList, + RpcFrame, + RpcFrameSignature, + RpcTransaction, +} from '../../types/rpc.js' +import type { + Frame, + FrameSignature, + Transaction, + TransactionType, +} from '../../types/transaction.js' import type { ExactPartial, UnionLooseOmit } from '../../types/utils.js' import { hexToNumber } from '../encoding/fromHex.js' import { type DefineFormatterErrorType, defineFormatter } from './formatter.js' @@ -41,6 +51,7 @@ export const transactionType = { '0x2': 'eip1559', '0x3': 'eip4844', '0x4': 'eip7702', + '0x6': 'eip8141', } as const satisfies Record export type FormatTransactionErrorType = ErrorType @@ -87,6 +98,10 @@ export function formatTransaction( transaction_.authorizationList = formatAuthorizationList( transaction.authorizationList, ) + if (transaction.frames) + transaction_.frames = transaction.frames.map(formatFrame) + if (transaction.signatures) + transaction_.signatures = transaction.signatures.map(formatFrameSignature) transaction_.yParity = (() => { // If `yParity` is provided, we will use it. @@ -128,6 +143,29 @@ export const defineTransaction = /*#__PURE__*/ defineFormatter( ////////////////////////////////////////////////////////////////////////////// +function formatFrame(frame: RpcFrame): Frame { + return { + mode: hexToNumber(frame.mode) as Frame['mode'], + flags: hexToNumber(frame.flags), + target: frame.target ?? null, + limits: { + execution: BigInt(frame.limits.execution), + state: BigInt(frame.limits.state), + }, + value: frame.value ? BigInt(frame.value) : 0n, + data: frame.data, + } +} + +function formatFrameSignature(signature: RpcFrameSignature): FrameSignature { + return { + scheme: hexToNumber(signature.scheme) as FrameSignature['scheme'], + signer: signature.signer ?? null, + msg: signature.msg, + signature: signature.signature, + } +} + function formatAuthorizationList( authorizationList: RpcAuthorizationList, ): SignedAuthorizationList { diff --git a/src/utils/formatters/transactionReceipt.test.ts b/src/utils/formatters/transactionReceipt.test.ts index 9fe0fed425..a8ca36bb4b 100644 --- a/src/utils/formatters/transactionReceipt.test.ts +++ b/src/utils/formatters/transactionReceipt.test.ts @@ -136,6 +136,112 @@ 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', + stateGasUsed: '0x3e8', + logs: [ + { + address: '0x15d4c048f83bd7e37d49ea4c83a07267ec4203da', + data: '0x', + topics: [], + }, + ], + }, + { + status: '0x0', + gasUsed: '0x186a0', + stateGasUsed: '0x0', + logs: [], + }, + { + status: '0x2', + gasUsed: '0x0', + logs: [], + }, + ], + } as any) + expect(receipt.type).toBe('eip8141') + expect(receipt.payer).toBe('0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc') + expect(receipt.frameReceipts).toMatchInlineSnapshot(` + [ + { + "gasUsed": 21000n, + "logs": [ + { + "address": "0x15d4c048f83bd7e37d49ea4c83a07267ec4203da", + "blockHash": null, + "blockNumber": null, + "blockTimestamp": undefined, + "data": "0x", + "logIndex": null, + "topics": [], + "transactionHash": null, + "transactionIndex": null, + }, + ], + "stateGasUsed": 1000n, + "status": "success", + }, + { + "gasUsed": 100000n, + "logs": [], + "stateGasUsed": 0n, + "status": "reverted", + }, + { + "gasUsed": 0n, + "logs": [], + "stateGasUsed": 0n, + "status": "skipped", + }, + ] + `) +}) + +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..db1969ccdf 100644 --- a/src/utils/formatters/transactionReceipt.ts +++ b/src/utils/formatters/transactionReceipt.ts @@ -3,8 +3,11 @@ import type { Chain, ExtractChainFormatterReturnType, } from '../../types/chain.js' -import type { RpcTransactionReceipt } from '../../types/rpc.js' -import type { TransactionReceipt } from '../../types/transaction.js' +import type { RpcFrameReceipt, RpcTransactionReceipt } from '../../types/rpc.js' +import type { + FrameReceipt, + TransactionReceipt, +} from '../../types/transaction.js' import type { ExactPartial } from '../../types/utils.js' import { hexToNumber } from '../encoding/fromHex.js' @@ -25,6 +28,13 @@ export const receiptStatuses = { '0x1': 'success', } as const +/** EIP-8141 per-frame statuses. `0x2` marks a frame skipped by a failed atomic batch. */ +export const frameReceiptStatuses = { + '0x0': 'reverted', + '0x1': 'success', + '0x2': 'skipped', +} as const + export type FormatTransactionReceiptErrorType = ErrorType export function formatTransactionReceipt( @@ -70,9 +80,29 @@ export function formatTransactionReceipt( if (transactionReceipt.blobGasUsed) receipt.blobGasUsed = BigInt(transactionReceipt.blobGasUsed) + if (transactionReceipt.payer) receipt.payer = transactionReceipt.payer + if (transactionReceipt.frameReceipts) + receipt.frameReceipts = + transactionReceipt.frameReceipts.map(formatFrameReceipt) + return receipt } +function formatFrameReceipt(frameReceipt: RpcFrameReceipt): FrameReceipt { + return { + status: frameReceiptStatuses[frameReceipt.status] ?? 'reverted', + gasUsed: BigInt(frameReceipt.gasUsed), + stateGasUsed: frameReceipt.stateGasUsed + ? BigInt(frameReceipt.stateGasUsed) + : 0n, + logs: frameReceipt.logs + ? frameReceipt.logs.map( + (log) => formatLog(log) as FrameReceipt['logs'][number], + ) + : [], + } +} + export type DefineTransactionReceiptErrorType = | DefineFormatterErrorType | ErrorType 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 4146939396..36c223f9a6 100644 --- a/src/utils/formatters/transactionRequest.ts +++ b/src/utils/formatters/transactionRequest.ts @@ -41,6 +41,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..ae2da0a277 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,33 @@ test('legacy', async () => { }), ).toMatchInlineSnapshot(`"0xb03B8ffAB1f3Ac3CabE4A0B2ED441fDFd3C96C8E"`) }) + +test('eip8141', async () => { + const transaction = { + chainId: 1, + sender: accounts[0].address, + frames: [ + { + mode: 1, + flags: 0x03, + target: null, + limits: { execution: 21000n, state: 0n }, + data: '0x', + }, + ], + } satisfies TransactionSerializableEIP8141 + + const serializedTransaction = await signTransaction({ + privateKey: accounts[0].privateKey, + transaction, + }) + expect( + await recoverTransactionAddress({ serializedTransaction }), + ).toMatchInlineSnapshot(`"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"`) + + await expect(() => + recoverTransactionAddress({ + serializedTransaction: serializeTransaction(transaction), + }), + ).rejects.toThrow('EIP-8141 transactions require a `SECP256K1` signature') +}) diff --git a/src/utils/signature/recoverTransactionAddress.ts b/src/utils/signature/recoverTransactionAddress.ts index d64d60b679..8cffefdffe 100644 --- a/src/utils/signature/recoverTransactionAddress.ts +++ b/src/utils/signature/recoverTransactionAddress.ts @@ -1,7 +1,10 @@ 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' +import { slice } from '../data/slice.js' +import { hexToNumber } from '../encoding/fromHex.js' import { type Keccak256ErrorType, keccak256 } from '../hash/keccak256.js' import { parseTransaction } from '../transaction/parseTransaction.js' import { @@ -22,6 +25,7 @@ export type RecoverTransactionAddressParameters = { export type RecoverTransactionAddressReturnType = Address export type RecoverTransactionAddressErrorType = + | BaseErrorType | SerializeTransactionErrorType | RecoverAddressErrorType | Keccak256ErrorType @@ -35,6 +39,45 @@ export async function recoverTransactionAddress( const transaction = parseTransaction(serializedTransaction) + // EIP-8141: recover the sender's `SECP256K1` signature (no explicit `signer`) + // over the canonical signature hash, which elides the `signature` bytes of + // every entry with an empty `msg`. + if ('frames' in transaction) { + const signature = (() => { + if (signature_) return signature_ + const entry = transaction.signatures?.find( + (signature) => + signature.scheme === 1 && + signature.signer === null && + signature.msg === '0x' && + signature.signature !== '0x', + ) + if (!entry) + throw new BaseError( + 'EIP-8141 transactions require a `SECP256K1` signature by `sender` over the transaction hash (or an explicit `signature`) to recover an address.', + ) + return { + yParity: hexToNumber(slice(entry.signature, 0, 1)), + r: slice(entry.signature, 1, 33), + s: slice(entry.signature, 33, 65), + } + })() + + return await recoverAddress({ + hash: keccak256( + serializeTransaction({ + ...transaction, + signatures: transaction.signatures?.map((signature) => + signature.msg === '0x' + ? { ...signature, signature: '0x' as const } + : 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..6e6d048bd1 100644 --- a/src/utils/transaction/assertTransaction.test.ts +++ b/src/utils/transaction/assertTransaction.test.ts @@ -6,9 +6,135 @@ 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, + limits: { execution: 50000n, state: 0n }, + 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, + limits: { execution: 1n, state: 0n }, + data: '0x' as const, + })) + expect(() => assertTransactionEIP8141({ ...validTx, frames })).toThrow( + 'MAX_FRAMES (64)', + ) + }) + + test('reserved flag bits rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...validTx, + frames: [ + { + mode: 2, + flags: 0x08, + target: sender, + limits: { execution: 1n, state: 0n }, + data: '0x' as const, + }, + ], + }), + ).toThrow('reserved') + }) + + test('atomic batch flag on VERIFY frame rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...validTx, + frames: [ + { + mode: 1, + flags: 0x04, + target: null, + limits: { execution: 1n, state: 0n }, + data: '0x' as const, + }, + { + mode: 2, + flags: 0, + target: sender, + limits: { execution: 1n, state: 0n }, + data: '0x' as const, + }, + ], + }), + ).toThrow('not valid with VERIFY mode') + }) + + test('total frame gas must be less than 2^64', () => { + expect(() => + assertTransactionEIP8141({ + ...validTx, + frames: [ + { + mode: 2, + flags: 0, + target: sender, + limits: { execution: 2n ** 63n, state: 2n ** 63n }, + data: '0x' as const, + }, + ], + }), + ).toThrow('less than 2^64') + }) + + test('unknown signature scheme rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...validTx, + signatures: [ + { scheme: 5 as any, signer: null, msg: '0x', signature: '0x' }, + ], + }), + ).toThrow('Invalid signature scheme 5') + }) + + test('maxFeePerBlobGas without blobs rejected', () => { + expect(() => + assertTransactionEIP8141({ ...validTx, maxFeePerBlobGas: 1n }), + ).toThrow('`maxFeePerBlobGas` must be 0') + }) + + test('fee cap too high', () => { + expect(() => + assertTransactionEIP8141({ ...validTx, maxFeePerGas: 2n ** 256n }), + ).toThrow('The fee cap') + }) +}) + describe('eip7702', () => { test('invalid chainId', () => { expect(() => diff --git a/src/utils/transaction/assertTransaction.ts b/src/utils/transaction/assertTransaction.ts index 03b18dcdbe..5e915452f8 100644 --- a/src/utils/transaction/assertTransaction.ts +++ b/src/utils/transaction/assertTransaction.ts @@ -24,17 +24,256 @@ import { type TipAboveFeeCapErrorType, } from '../../errors/node.js' import type { ErrorType } from '../../errors/utils.js' +import type { Hex } from '../../types/misc.js' import type { TransactionSerializableEIP1559, TransactionSerializableEIP2930, TransactionSerializableEIP4844, TransactionSerializableEIP7702, + TransactionSerializableEIP8141, TransactionSerializableLegacy, } from '../../types/transaction.js' import { type IsAddressErrorType, isAddress } from '../address/isAddress.js' +import { isAddressEqual } from '../address/isAddressEqual.js' import { size } from '../data/size.js' import { slice } from '../data/slice.js' -import { hexToNumber } from '../encoding/fromHex.js' +import { hexToBigInt, hexToNumber } from '../encoding/fromHex.js' + +export type AssertTransactionEIP8141ErrorType = + | InvalidAddressErrorType + | InvalidChainIdErrorType + | InvalidVersionedHashSizeErrorType + | InvalidVersionedHashVersionErrorType + | FeeCapTooHighErrorType + | TipAboveFeeCapErrorType + | BaseErrorType + | ErrorType + +const maxUint64 = 2n ** 64n - 1n +const MAX_FRAMES = 64 +const VERIFY = 1 +const SENDER = 2 +const APPROVE_SCOPE_MASK = 0x03 +const APPROVE_EXECUTION = 0x02 +const ATOMIC_BATCH_FLAG = 0x04 +const ARBITRARY = 0 +const SECP256K1 = 1 +const P256 = 2 +const EXPIRY_VERIFIER = '0x0000000000000000000000000000000000008141' +const EXPIRY_DATA_LENGTH = 8 +// EIP-7825 transaction gas cap and the constants feeding the intrinsic cost. +const TX_MAX_GAS_LIMIT = 16_777_216n +const FRAME_TX_INTRINSIC_COST = 12_000n +const FRAME_TX_PER_FRAME_COST = 475n +const TX_VALUE_COST = 6_000n +const STANDARD_TOKEN_COST = 4n +const TOTAL_COST_FLOOR_PER_TOKEN = 16n +const FLOOR_TOKENS_PER_BYTE = 4n +const signatureGas = { [ARBITRARY]: 100n, [SECP256K1]: 2_800n, [P256]: 6_700n } + +export function assertTransactionEIP8141( + transaction: TransactionSerializableEIP8141, +) { + const { + chainId, + sender, + frames, + signatures = [], + nonce, + maxFeePerGas, + maxPriorityFeePerGas, + maxFeePerBlobGas, + blobVersionedHashes = [], + } = 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.`, + ) + + for (const hash of blobVersionedHashes) { + const size_ = size(hash) + if (size_ !== 32) + throw new InvalidVersionedHashSizeError({ hash, size: size_ }) + const version = hexToNumber(slice(hash, 0, 1)) + if (version !== versionedHashVersionKzg) + throw new InvalidVersionedHashVersionError({ hash, version }) + } + if (blobVersionedHashes.length === 0 && maxFeePerBlobGas) + throw new BaseError( + '`maxFeePerBlobGas` must be 0 when no blob versioned hashes are included.', + ) + + let signatureVerificationCost = 0n + let dataTokens = 0n + let dataBytes = 0n + for (const signature of signatures) { + if (signature.scheme === SECP256K1 || signature.scheme === P256) { + if (signature.signer !== null && !isAddress(signature.signer)) + throw new InvalidAddressError({ address: signature.signer }) + } else if (signature.scheme === ARBITRARY) { + if (signature.signer !== null) + throw new BaseError('`signer` must be empty for ARBITRARY signatures.') + } else + throw new BaseError( + `Invalid signature scheme ${signature.scheme}. Must be 0 (ARBITRARY), 1 (SECP256K1), or 2 (P256).`, + ) + const msgSize = size(signature.msg) + if (msgSize !== 0 && msgSize !== 32) + throw new BaseError('`msg` must be empty or a 32-byte digest.') + if (msgSize === 32 && hexToBigInt(signature.msg) === 0n) + throw new BaseError('`msg` must not be the zero digest.') + // Empty signature bytes are permitted: they are elided when computing the + // signature hash and filled in by `signTransaction`. + const signatureSize = size(signature.signature) + if (signatureSize !== 0) { + if (signature.scheme === SECP256K1) { + if (signatureSize !== 65) + throw new BaseError( + 'SECP256K1 `signature` must be 65 bytes (`v || r || s`).', + ) + if (hexToNumber(slice(signature.signature, 0, 1)) > 1) + throw new BaseError('SECP256K1 `signature` `v` must be 0 or 1.') + } + if (signature.scheme === P256 && signatureSize !== 128) + throw new BaseError( + 'P256 `signature` must be 128 bytes (`r || s || qx || qy`).', + ) + } + signatureVerificationCost += signatureGas[signature.scheme] + for (const data of [ + signature.signer ?? '0x', + signature.msg, + signature.signature, + ]) { + dataTokens += tokensIn(data) + dataBytes += BigInt(size(data)) + } + } + + let totalFrameGas = 0n + let totalExecutionGas = 0n + let valueTransferCost = 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 and above are reserved and must be zero.`, + ) + if (frame.target !== null && !isAddress(frame.target)) + throw new InvalidAddressError({ address: frame.target }) + 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.', + ) + totalExecutionGas += frame.limits.execution + totalFrameGas += frame.limits.execution + frame.limits.state + if (totalFrameGas > maxUint64) + throw new BaseError( + 'Total frame gas (execution + state) must be less than 2^64.', + ) + if ( + frame.flags & APPROVE_EXECUTION && + frame.target !== null && + !isAddressEqual(frame.target, sender) + ) + throw new BaseError( + 'Frames permitting APPROVE_EXECUTION (flags bit 1) must target `sender` or `null`.', + ) + if (frame.flags & ATOMIC_BATCH_FLAG) { + if (frame.mode === VERIFY) + throw new BaseError( + 'Atomic batch flag (bit 2) is not valid with VERIFY 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 === VERIFY) + throw new BaseError( + 'Frame following an atomic batch frame must not be VERIFY mode.', + ) + } + const inAtomicBatch = + frame.flags & ATOMIC_BATCH_FLAG || + (i > 0 && frames[i - 1].flags & ATOMIC_BATCH_FLAG) + if (inAtomicBatch && frame.flags & APPROVE_SCOPE_MASK) + throw new BaseError( + 'Frames in an atomic batch must not permit an APPROVE scope (flags bits 0-1).', + ) + if ( + frame.mode === VERIFY && + frame.target !== null && + isAddressEqual(frame.target, EXPIRY_VERIFIER) && + (frame.flags !== 0 || + frameValue !== 0n || + frame.limits.state !== 0n || + size(frame.data) !== EXPIRY_DATA_LENGTH) + ) + throw new BaseError( + 'Expiry verifier frames must have zero flags, value and state gas limit, and 8 bytes of data.', + ) + if ( + frameValue > 0n && + frame.target !== null && + !isAddressEqual(frame.target, sender) + ) + valueTransferCost += TX_VALUE_COST + dataTokens += tokensIn(frame.data) + dataBytes += BigInt(size(frame.data)) + } + + // Intrinsic and execution gas must fit the EIP-7825 transaction cap. + const baseCost = + FRAME_TX_INTRINSIC_COST + + BigInt(frames.length) * FRAME_TX_PER_FRAME_COST + + signatureVerificationCost + + valueTransferCost + const intrinsicGas = baseCost + STANDARD_TOKEN_COST * dataTokens + const calldataFloorGas = + baseCost + TOTAL_COST_FLOOR_PER_TOKEN * FLOOR_TOKENS_PER_BYTE * dataBytes + const maxGas = + intrinsicGas + totalExecutionGas > calldataFloorGas + ? intrinsicGas + totalExecutionGas + : calldataFloorGas + if (maxGas > TX_MAX_GAS_LIMIT) + throw new BaseError( + `Intrinsic and execution gas (${maxGas}) exceeds the EIP-7825 transaction gas cap (${TX_MAX_GAS_LIMIT}).`, + ) + + if (maxFeePerGas && maxFeePerGas > maxUint256) + throw new FeeCapTooHighError({ maxFeePerGas }) + if ( + maxPriorityFeePerGas && + maxFeePerGas && + maxPriorityFeePerGas > maxFeePerGas + ) + throw new TipAboveFeeCapError({ maxFeePerGas, maxPriorityFeePerGas }) +} + +/** EIP-7623 weighted token count: zero bytes count 1, non-zero bytes count 4. */ +function tokensIn(data: Hex) { + let zeroBytes = 0 + for (let i = 2; i < data.length; i += 2) + if (data[i] === '0' && data[i + 1] === '0') zeroBytes++ + return BigInt(zeroBytes + (size(data) - zeroBytes) * 4) +} export type AssertTransactionEIP7702ErrorType = | AssertTransactionEIP1559ErrorType diff --git a/src/utils/transaction/eip8141.test.ts b/src/utils/transaction/eip8141.test.ts new file mode 100644 index 0000000000..30b6ecbe72 --- /dev/null +++ b/src/utils/transaction/eip8141.test.ts @@ -0,0 +1,970 @@ +import { describe, expect, test } from 'vitest' +import { accounts } from '~test/constants.js' +import { signTransaction } from '../../accounts/utils/signTransaction.js' +import type { + FrameSignature, + TransactionSerializableEIP8141, + TransactionSerializedEIP8141, +} from '../../types/transaction.js' +import { getAddress } from '../address/getAddress.js' +import { concatHex } from '../data/concat.js' +import { fromRlp } from '../encoding/fromRlp.js' +import { numberToHex } from '../encoding/toHex.js' +import { toRlp } from '../encoding/toRlp.js' +import { keccak256 } from '../hash/keccak256.js' +import { recoverTransactionAddress } from '../signature/recoverTransactionAddress.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 { + attachSignatureEIP8141, + serializeTransaction, +} from './serializeTransaction.js' + +const sender = accounts[0].address +const recipient = getAddress('0x70997970c51812dc3a010c7d01b50e0d17dc79c8') + +const DEFAULT = 0 +const VERIFY = 1 +const SENDER = 2 +const APPROVE_PAYMENT = 0x01 +const APPROVE_EXECUTION = 0x02 +const APPROVE_EXECUTION_AND_PAYMENT = 0x03 +const ATOMIC_BATCH_FLAG = 0x04 + +const verifyFrame = { + mode: VERIFY, + flags: APPROVE_EXECUTION_AND_PAYMENT, + target: null, + limits: { execution: 50_000n, state: 0n }, + value: 0n, + data: '0x', +} as const + +const senderFrame = { + mode: SENDER, + flags: 0, + target: recipient, + limits: { execution: 100_000n, state: 10_000n }, + value: 0n, + data: '0xcafebabe', +} as const + +const baseEIP8141: TransactionSerializableEIP8141 = { + chainId: 1, + nonce: 0, + sender, + frames: [verifyFrame, senderFrame], + maxPriorityFeePerGas: parseGwei('1'), + maxFeePerGas: parseGwei('10'), +} + +/** Reference implementation of the EIP-8141 payload / `compute_sig_hash`. */ +function referenceSigHash(tx: TransactionSerializableEIP8141) { + const quantity = (value: bigint | number | undefined) => + value ? numberToHex(value) : '0x' + return keccak256( + concatHex([ + '0x06', + toRlp([ + quantity(tx.chainId), + quantity(tx.nonce), + tx.sender, + tx.frames.map((frame) => [ + quantity(frame.mode), + quantity(frame.flags), + frame.target ?? '0x', + [quantity(frame.limits.execution), quantity(frame.limits.state)], + quantity(frame.value), + frame.data, + ]), + (tx.signatures ?? []).map((signature) => [ + quantity(signature.scheme), + signature.signer ?? '0x', + signature.msg, + signature.msg === '0x' ? '0x' : signature.signature, + ]), + [ + quantity(tx.maxPriorityFeePerGas), + quantity(tx.maxFeePerGas), + quantity(tx.maxFeePerBlobGas), + ], + tx.blobVersionedHashes ?? [], + ]), + ]), + ) +} + +describe('eip8141 serialization', () => { + test('payload layout: [chain_id, nonce, sender, frames, signatures, fees, blob_versioned_hashes]', () => { + const serialized = serializeTransaction(baseEIP8141) + expect(serialized.slice(0, 4)).toBe('0x06') + + const payload = fromRlp(`0x${serialized.slice(4)}`, 'hex') + expect(payload).toEqual([ + '0x01', + '0x', + sender, + [ + ['0x01', '0x03', '0x', ['0xc350', '0x'], '0x', '0x'], + [ + '0x02', + '0x', + recipient.toLowerCase(), + ['0x0186a0', '0x2710'], + '0x', + '0xcafebabe', + ], + ], + [], + ['0x3b9aca00', '0x02540be400', '0x'], + [], + ]) + }) + + test('signature entries are encoded as [scheme, signer, msg, signature]', () => { + const digest = `0x${'11'.repeat(32)}` as const + const serialized = serializeTransaction({ + ...baseEIP8141, + signatures: [ + { scheme: 1, signer: null, msg: '0x', signature: '0x' }, + { + scheme: 2, + signer: recipient, + msg: digest, + signature: `0x${'22'.repeat(128)}`, + }, + { scheme: 0, signer: null, msg: '0x', signature: '0xdeadbeef' }, + ], + }) + const payload = fromRlp(`0x${serialized.slice(4)}`, 'hex') + expect(payload[4]).toEqual([ + ['0x01', '0x', '0x', '0x'], + ['0x02', recipient.toLowerCase(), digest, `0x${'22'.repeat(128)}`], + ['0x', '0x', '0x', '0xdeadbeef'], + ]) + }) + + test('roundtrip: serialize then parse', () => { + const serialized = serializeTransaction(baseEIP8141) + expect(parseTransaction(serialized)).toEqual({ + ...baseEIP8141, + type: 'eip8141', + }) + }) + + test('roundtrip: signatures, blobs and all frame modes', () => { + const tx: TransactionSerializableEIP8141 = { + ...baseEIP8141, + nonce: 7, + frames: [ + { + mode: DEFAULT, + flags: 0, + target: recipient, + limits: { execution: 30_000n, state: 5_000n }, + value: 0n, + data: '0x1234', + }, + verifyFrame, + { ...senderFrame, value: 1_000_000n }, + ], + signatures: [ + { + scheme: 1, + signer: null, + msg: '0x', + signature: `0x00${'ab'.repeat(64)}`, + }, + { + scheme: 2, + signer: recipient, + msg: `0x${'11'.repeat(32)}`, + signature: `0x${'22'.repeat(128)}`, + }, + { scheme: 0, signer: null, msg: '0x', signature: '0xdeadbeef' }, + ], + maxFeePerBlobGas: parseGwei('2'), + blobVersionedHashes: [`0x01${'00'.repeat(31)}`], + } + expect(parseTransaction(serializeTransaction(tx))).toEqual({ + ...tx, + type: 'eip8141', + }) + }) + + test('minimal transaction (no optional fields)', () => { + const tx: TransactionSerializableEIP8141 = { + chainId: 1, + sender, + frames: [verifyFrame], + } + const serialized = serializeTransaction(tx) + expect(parseTransaction(serialized)).toEqual({ + chainId: 1, + nonce: 0, + sender, + frames: [verifyFrame], + type: 'eip8141', + }) + }) + + test('null target is serialized as empty bytes', () => { + const serialized = serializeTransaction(baseEIP8141) + const payload = fromRlp(`0x${serialized.slice(4)}`, 'hex') as any + expect(payload[3][0][2]).toBe('0x') + expect(parseTransaction(serialized).frames[0].target).toBeNull() + }) + + test('serialized type byte is 0x06', () => { + expect(serializeTransaction(baseEIP8141).startsWith('0x06')).toBe(true) + }) +}) + +describe('eip8141 signing', () => { + test('signature hash elides signature bytes of entries with empty msg', async () => { + const serialized = await signTransaction({ + privateKey: accounts[0].privateKey, + transaction: baseEIP8141, + }) + const signed = parseTransaction(serialized) + expect(signed.signatures).toHaveLength(1) + const [signature] = signed.signatures! + expect(signature.scheme).toBe(1) + expect(signature.signer).toBeNull() + expect(signature.msg).toBe('0x') + // v (1 byte) || r (32 bytes) || s (32 bytes) + expect(signature.signature).toHaveLength(2 + 65 * 2) + expect(['0x00', '0x01']).toContain(signature.signature.slice(0, 4)) + + expect( + await recoverTransactionAddress({ + serializedTransaction: serialized, + }), + ).toBe(getAddress(sender)) + }) + + test('signature hash matches the EIP-8141 reference implementation', async () => { + const serialized = await signTransaction({ + privateKey: accounts[0].privateKey, + transaction: baseEIP8141, + }) + const signed = parseTransaction(serialized) + const [signature] = signed.signatures! + expect( + await recoverTransactionAddress({ + serializedTransaction: serialized, + signature: { + yParity: Number(signature.signature.slice(2, 4)), + r: `0x${signature.signature.slice(4, 68)}`, + s: `0x${signature.signature.slice(68, 132)}`, + }, + }), + ).toBe(getAddress(sender)) + // The unsigned transaction (empty signature slot) hashes to the same value. + expect(referenceSigHash(signed)).toBe( + keccak256( + serializeTransaction({ + ...signed, + signatures: [{ ...signature, signature: '0x' }], + }), + ), + ) + }) + + test('fills the first unsigned SECP256K1 slot and keeps other entries', async () => { + const other: FrameSignature = { + scheme: 1, + signer: recipient, + msg: '0x', + signature: `0x01${'ab'.repeat(64)}`, + } + const transaction: TransactionSerializableEIP8141 = { + ...baseEIP8141, + signatures: [ + other, + { scheme: 1, signer: null, msg: '0x', signature: '0x' }, + ], + } + const serialized = await signTransaction({ + privateKey: accounts[0].privateKey, + transaction, + }) + const signed = parseTransaction(serialized) + expect(signed.signatures).toHaveLength(2) + expect(signed.signatures![0]).toEqual(other) + expect(signed.signatures![1].signature).not.toBe('0x') + + // The hash committed to elides both empty-msg entries' bytes. + expect( + await recoverTransactionAddress({ serializedTransaction: serialized }), + ).toBe(getAddress(sender)) + expect(referenceSigHash(signed)).toBe( + keccak256( + serializeTransaction({ + ...signed, + signatures: signed.signatures!.map((signature) => ({ + ...signature, + signature: '0x' as const, + })), + }), + ), + ) + }) + + test('attachSignatureEIP8141 appends a slot when none exists', () => { + expect(attachSignatureEIP8141(undefined)).toEqual([ + { scheme: 1, signer: null, msg: '0x', signature: '0x' }, + ]) + expect( + attachSignatureEIP8141(undefined, { + r: `0x${'01'.repeat(32)}`, + s: `0x${'02'.repeat(32)}`, + yParity: 1, + }), + ).toEqual([ + { + scheme: 1, + signer: null, + msg: '0x', + signature: `0x01${'01'.repeat(32)}${'02'.repeat(32)}`, + }, + ]) + }) + + test('attachSignatureEIP8141 skips slots with an explicit signer', () => { + const sponsorSlot: FrameSignature = { + scheme: 1, + signer: recipient, + msg: '0x', + signature: '0x', + } + expect(attachSignatureEIP8141([sponsorSlot])).toEqual([ + sponsorSlot, + { scheme: 1, signer: null, msg: '0x', signature: '0x' }, + ]) + }) + + test('attachSignatureEIP8141 derives yParity from v', () => { + const [signature] = attachSignatureEIP8141([], { + r: `0x${'01'.repeat(32)}`, + s: `0x${'02'.repeat(32)}`, + v: 27n, + }) + expect(signature.signature.slice(0, 4)).toBe('0x00') + }) + + test('recoverTransactionAddress rejects unsigned transactions', async () => { + await expect(() => + recoverTransactionAddress({ + serializedTransaction: serializeTransaction(baseEIP8141), + }), + ).rejects.toThrow('EIP-8141 transactions require a `SECP256K1` signature') + }) +}) + +describe('eip8141 getTransactionType', () => { + test('infers eip8141 from frames property', () => { + expect(getTransactionType(baseEIP8141)).toBe('eip8141') + }) + + test('infers eip8141 from explicit type', () => { + expect(getTransactionType({ ...baseEIP8141, type: 'eip8141' })).toBe( + 'eip8141', + ) + }) +}) + +describe('eip8141 getSerializedTransactionType', () => { + test('identifies 0x06 prefix as eip8141', () => { + expect( + getSerializedTransactionType(serializeTransaction(baseEIP8141)), + ).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 "0" is invalid.') + }) + + test('invalid sender address', () => { + expect(() => + assertTransactionEIP8141({ ...baseEIP8141, sender: '0xinvalid' as any }), + ).toThrow('Address "0xinvalid" is invalid.') + }) + + test('empty frames', () => { + expect(() => + assertTransactionEIP8141({ ...baseEIP8141, frames: [] }), + ).toThrow('`frames` must contain at least one frame.') + }) + + test('exceeds MAX_FRAMES (64)', () => { + const frames = Array.from({ length: 65 }, () => senderFrame) + expect(() => assertTransactionEIP8141({ ...baseEIP8141, frames })).toThrow( + 'MAX_FRAMES (64)', + ) + }) + + test('exactly MAX_FRAMES (64) passes', () => { + const frames = Array.from({ length: 64 }, () => ({ + ...senderFrame, + limits: { execution: 1_000n, state: 0n }, + })) + expect(() => + assertTransactionEIP8141({ ...baseEIP8141, frames }), + ).not.toThrow() + }) + + test('invalid frame mode (>2)', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [{ ...senderFrame, mode: 3 as any }], + }), + ).toThrow('Invalid frame mode 3') + }) + + test('reserved flag bits rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [{ ...senderFrame, flags: 0x08 }], + }), + ).toThrow('Bits 3 and above are reserved') + }) + + test('VERIFY frame with zero APPROVE scope is allowed (e.g. expiry verifier)', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [{ ...verifyFrame, flags: 0 }, verifyFrame, senderFrame], + }), + ).not.toThrow() + }) + + test('non-SENDER frame with value rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [{ ...verifyFrame, value: 1n }, senderFrame], + }), + ).toThrow('`frame.value` must be 0 for DEFAULT and VERIFY frames') + }) + + test('APPROVE_EXECUTION scope must target sender or null', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [{ ...verifyFrame, target: recipient }, senderFrame], + }), + ).toThrow('APPROVE_EXECUTION') + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [{ ...verifyFrame, target: sender }, senderFrame], + }), + ).not.toThrow() + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { ...verifyFrame, flags: APPROVE_PAYMENT, target: recipient }, + senderFrame, + ], + }), + ).not.toThrow() + }) + + test('atomic batch flag on VERIFY frame rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [{ ...verifyFrame, flags: ATOMIC_BATCH_FLAG }, senderFrame], + }), + ).toThrow('not valid with VERIFY mode') + }) + + test('atomic batch flag on last frame rejected', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [verifyFrame, { ...senderFrame, flags: ATOMIC_BATCH_FLAG }], + }), + ).toThrow('must not be the last frame') + }) + + test('atomic batch flag: next frame must not be VERIFY', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + verifyFrame, + { ...senderFrame, flags: ATOMIC_BATCH_FLAG }, + verifyFrame, + senderFrame, + ], + }), + ).toThrow('must not be VERIFY mode') + }) + + test('atomic batch is allowed on DEFAULT and SENDER frames', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + verifyFrame, + { ...senderFrame, mode: DEFAULT, flags: ATOMIC_BATCH_FLAG }, + { ...senderFrame, flags: ATOMIC_BATCH_FLAG }, + senderFrame, + ], + }), + ).not.toThrow() + }) + + test('frames in an atomic batch must not permit an APPROVE scope', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + verifyFrame, + { ...senderFrame, flags: ATOMIC_BATCH_FLAG | APPROVE_PAYMENT }, + senderFrame, + ], + }), + ).toThrow('atomic batch must not permit an APPROVE scope') + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + verifyFrame, + { ...senderFrame, flags: ATOMIC_BATCH_FLAG }, + { ...senderFrame, flags: APPROVE_PAYMENT }, + ], + }), + ).toThrow('atomic batch must not permit an APPROVE scope') + }) + + test('total frame gas (execution + state) must be < 2^64', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { ...senderFrame, limits: { execution: 2n ** 63n, state: 0n } }, + { ...senderFrame, limits: { execution: 2n ** 63n, state: 0n } }, + ], + }), + ).toThrow('must be less than 2^64') + }) + + test('intrinsic + execution gas must fit the EIP-7825 cap', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + verifyFrame, + { ...senderFrame, limits: { execution: 16_777_216n, state: 0n } }, + ], + }), + ).toThrow('EIP-7825 transaction gas cap') + // State gas does not count towards the execution cap. + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + verifyFrame, + { ...senderFrame, limits: { execution: 100n, state: 16_777_216n } }, + ], + }), + ).not.toThrow() + // Calldata floor: 65 kB of data costs 16 * 4 gas per byte. + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + verifyFrame, + { ...senderFrame, data: `0x${'00'.repeat(262_144)}` }, + ], + }), + ).toThrow('EIP-7825 transaction gas cap') + }) + + test('expiry verifier frame constraints', () => { + const expiryFrame = { + mode: VERIFY, + flags: 0, + target: '0x0000000000000000000000000000000000008141', + limits: { execution: 10_000n, state: 0n }, + value: 0n, + data: '0x0000000068000000', + } as const + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [expiryFrame, verifyFrame, senderFrame], + }), + ).not.toThrow() + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [{ ...expiryFrame, data: '0x00' }, verifyFrame, senderFrame], + }), + ).toThrow('Expiry verifier frames') + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { ...expiryFrame, limits: { execution: 10_000n, state: 1n } }, + verifyFrame, + senderFrame, + ], + }), + ).toThrow('Expiry verifier frames') + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [ + { ...expiryFrame, flags: APPROVE_PAYMENT }, + verifyFrame, + senderFrame, + ], + }), + ).toThrow('Expiry verifier frames') + }) + + test('invalid frame target address', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + frames: [verifyFrame, { ...senderFrame, target: '0xnope' as any }], + }), + ).toThrow('Address "0xnope" is invalid.') + }) + + test('fee cap too high', () => { + expect(() => + assertTransactionEIP8141({ ...baseEIP8141, maxFeePerGas: 2n ** 256n }), + ).toThrow('The fee cap') + }) + + test('tip above fee cap', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + maxFeePerGas: 1n, + maxPriorityFeePerGas: 2n, + }), + ).toThrow('The provided tip') + }) +}) + +describe('eip8141 signature constraints', () => { + const withSignature = (signature: FrameSignature) => ({ + ...baseEIP8141, + signatures: [signature], + }) + + test('unknown scheme rejected', () => { + expect(() => + assertTransactionEIP8141( + withSignature({ + scheme: 3 as any, + signer: null, + msg: '0x', + signature: '0x', + }), + ), + ).toThrow('Invalid signature scheme 3') + }) + + test('ARBITRARY signer must be empty', () => { + expect(() => + assertTransactionEIP8141( + withSignature({ + scheme: 0, + signer: recipient, + msg: '0x', + signature: '0x1234', + }), + ), + ).toThrow('`signer` must be empty for ARBITRARY signatures.') + expect(() => + assertTransactionEIP8141( + withSignature({ scheme: 0, signer: null, msg: '0x', signature: '0x' }), + ), + ).not.toThrow() + }) + + test('msg must be empty or a non-zero 32-byte digest', () => { + expect(() => + assertTransactionEIP8141( + withSignature({ + scheme: 1, + signer: null, + msg: '0x01', + signature: '0x', + }), + ), + ).toThrow('`msg` must be empty or a 32-byte digest.') + expect(() => + assertTransactionEIP8141( + withSignature({ + scheme: 1, + signer: null, + msg: `0x${'00'.repeat(32)}`, + signature: '0x', + }), + ), + ).toThrow('`msg` must not be the zero digest.') + }) + + test('SECP256K1 signature must be 65 bytes with v in {0, 1}', () => { + expect(() => + assertTransactionEIP8141( + withSignature({ + scheme: 1, + signer: null, + msg: '0x', + signature: `0x${'ab'.repeat(64)}`, + }), + ), + ).toThrow('must be 65 bytes') + expect(() => + assertTransactionEIP8141( + withSignature({ + scheme: 1, + signer: null, + msg: '0x', + signature: `0x1b${'ab'.repeat(64)}`, + }), + ), + ).toThrow('`v` must be 0 or 1') + }) + + test('P256 signature must be 128 bytes', () => { + expect(() => + assertTransactionEIP8141( + withSignature({ + scheme: 2, + signer: recipient, + msg: '0x', + signature: `0x${'ab'.repeat(64)}`, + }), + ), + ).toThrow('must be 128 bytes') + }) + + test('invalid signer address rejected', () => { + expect(() => + assertTransactionEIP8141( + withSignature({ + scheme: 1, + signer: '0xnope' as any, + msg: '0x', + signature: '0x', + }), + ), + ).toThrow('Address "0xnope" is invalid.') + }) +}) + +describe('eip8141 blob-field invariants', () => { + test('maxFeePerBlobGas non-zero without blobs rejected', () => { + expect(() => + assertTransactionEIP8141({ ...baseEIP8141, maxFeePerBlobGas: 1n }), + ).toThrow('`maxFeePerBlobGas` must be 0 when no blob versioned hashes') + }) + + test('blob versioned hashes must be 32 bytes with version 0x01', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + maxFeePerBlobGas: 1n, + blobVersionedHashes: ['0x0100'], + }), + ).toThrow('Versioned hash "0x0100" size is invalid.') + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + maxFeePerBlobGas: 1n, + blobVersionedHashes: [`0x02${'00'.repeat(31)}`], + }), + ).toThrow('version is invalid') + }) + + test('blobs present with valid maxFeePerBlobGas passes', () => { + expect(() => + assertTransactionEIP8141({ + ...baseEIP8141, + maxFeePerBlobGas: 1n, + blobVersionedHashes: [`0x01${'00'.repeat(31)}`], + }), + ).not.toThrow() + }) +}) + +describe('eip8141 parser strictness', () => { + const payload = (tx: TransactionSerializableEIP8141) => + fromRlp(`0x${serializeTransaction(tx).slice(4)}`, 'hex') as any[] + const encode = (items: any) => + concatHex(['0x06', toRlp(items)]) as TransactionSerializedEIP8141 + + test('rejects wrong number of top-level RLP items', () => { + const items = payload(baseEIP8141) + expect(() => parseTransaction(encode(items.slice(0, 6)))).toThrow( + 'Invalid serialized transaction of type "eip8141" was provided.', + ) + }) + + test('rejects fees list with wrong length', () => { + const items = payload(baseEIP8141) + items[5] = items[5].slice(0, 2) + expect(() => parseTransaction(encode(items))).toThrow( + 'Invalid serialized transaction of type "eip8141" was provided.', + ) + }) + + test('rejects frame tuple with fewer than 6 elements', () => { + const items = payload(baseEIP8141) + items[3][0] = items[3][0].slice(0, 5) + expect(() => parseTransaction(encode(items))).toThrow( + 'Invalid serialized transaction of type "eip8141" was provided.', + ) + }) + + test('rejects frame limits with wrong length', () => { + const items = payload(baseEIP8141) + items[3][0][3] = ['0x01'] + expect(() => parseTransaction(encode(items))).toThrow( + 'Invalid serialized transaction of type "eip8141" was provided.', + ) + }) + + test('rejects signature tuple with wrong length', () => { + const items = payload(baseEIP8141) + items[4] = [['0x01', '0x', '0x']] + expect(() => parseTransaction(encode(items))).toThrow( + 'Invalid serialized transaction of type "eip8141" was provided.', + ) + }) + + test('rejects nonce above Number.MAX_SAFE_INTEGER', () => { + const items = payload(baseEIP8141) + items[1] = numberToHex(2n ** 60n) + expect(() => parseTransaction(encode(items))).toThrow( + 'Invalid serialized transaction of type "eip8141" was provided.', + ) + }) + + test('rejects frame mode > 2', () => { + const items = payload(baseEIP8141) + items[3][0][0] = '0x03' + expect(() => parseTransaction(encode(items))).toThrow( + 'Invalid serialized transaction of type "eip8141" was provided.', + ) + }) +}) + +describe('eip8141 spec examples', () => { + test('Example 1a: simple ETH transfer', () => { + const tx: TransactionSerializableEIP8141 = { + ...baseEIP8141, + frames: [ + verifyFrame, + { + mode: SENDER, + flags: 0, + target: recipient, + limits: { execution: 21_000n, state: 0n }, + value: 1_000_000_000_000_000n, + data: '0x', + }, + ], + } + expect(parseTransaction(serializeTransaction(tx))).toEqual({ + ...tx, + type: 'eip8141', + }) + }) + + test('Example 1b: account deployment (DEFAULT + VERIFY + SENDER)', () => { + const tx: TransactionSerializableEIP8141 = { + ...baseEIP8141, + frames: [ + { + mode: DEFAULT, + flags: 0, + target: getAddress('0x0000000000000000000000000000000000007997'), + limits: { execution: 200_000n, state: 100_000n }, + value: 0n, + data: '0xdeadbeef', + }, + verifyFrame, + senderFrame, + ], + } + expect(() => assertTransactionEIP8141(tx)).not.toThrow() + expect(parseTransaction(serializeTransaction(tx))).toEqual({ + ...tx, + type: 'eip8141', + }) + }) + + test('Example 2: atomic approve + swap', () => { + const tx: TransactionSerializableEIP8141 = { + ...baseEIP8141, + frames: [ + verifyFrame, + { ...senderFrame, flags: ATOMIC_BATCH_FLAG }, + senderFrame, + ], + } + expect(() => assertTransactionEIP8141(tx)).not.toThrow() + expect(parseTransaction(serializeTransaction(tx))).toEqual({ + ...tx, + type: 'eip8141', + }) + }) + + test('Example 3: sponsored transaction', () => { + const sponsor = getAddress('0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc') + const tx: TransactionSerializableEIP8141 = { + ...baseEIP8141, + frames: [ + { ...verifyFrame, flags: APPROVE_EXECUTION }, + { + ...verifyFrame, + flags: APPROVE_PAYMENT, + target: sponsor, + data: '0x1234', + }, + senderFrame, + senderFrame, + { ...senderFrame, mode: DEFAULT, target: sponsor }, + ], + signatures: [ + { scheme: 1, signer: null, msg: '0x', signature: '0x' }, + { + scheme: 1, + signer: sponsor, + msg: '0x', + signature: `0x00${'ab'.repeat(64)}`, + }, + ], + } + expect(() => assertTransactionEIP8141(tx)).not.toThrow() + expect(parseTransaction(serializeTransaction(tx))).toEqual({ + ...tx, + type: 'eip8141', + }) + }) +}) 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..2403b6386f 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,23 @@ describe('attributes', () => { expect(type).toEqual('eip7702') }) + test('eip8141 (frames property)', () => { + const type = getTransactionType({ + frames: [ + { + mode: 1, + flags: 3, + target: null, + limits: { execution: 1n, state: 0n }, + 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 fe0dd96f56..055f00d2b1 100644 --- a/src/utils/transaction/parseTransaction.ts +++ b/src/utils/transaction/parseTransaction.ts @@ -18,6 +18,8 @@ import type { import type { Hex, Signature } from '../../types/misc.js' import type { AccessList, + Frame, + FrameSignature, TransactionRequestEIP2930, TransactionRequestLegacy, TransactionSerializable, @@ -25,16 +27,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' @@ -49,17 +54,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 { @@ -79,6 +85,7 @@ export type ParseTransactionReturnType< ? TransactionSerializableEIP4844 : never) | (type extends 'eip7702' ? TransactionSerializableEIP7702 : never) + | (type extends 'eip8141' ? TransactionSerializableEIP8141 : never) | (type extends 'legacy' ? TransactionSerializableLegacy : never) : TransactionSerializable @@ -88,6 +95,7 @@ export type ParseTransactionErrorType = | ParseTransactionEIP2930ErrorType | ParseTransactionEIP4844ErrorType | ParseTransactionEIP7702ErrorType + | ParseTransactionEIP8141ErrorType | ParseTransactionLegacyErrorType export function parseTransaction< @@ -115,11 +123,152 @@ 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, + signaturesArray, + fees, + blobVersionedHashes, + ] = transactionArray + + if ( + transactionArray.length !== 7 || + !Array.isArray(fees) || + fees.length !== 3 + ) + throw new InvalidSerializedTransactionError({ + attributes: { + chainId, + nonce, + sender, + frames: framesArray, + signatures: signaturesArray, + fees, + blobVersionedHashes, + }, + serializedTransaction, + type: 'eip8141', + }) + + const [maxPriorityFeePerGas, maxFeePerGas, maxFeePerBlobGas] = fees as Hex[] + + const frames: Frame[] = (framesArray as RecursiveArray[]).map( + (frameArray) => { + const tuple = frameArray as RecursiveArray[] + const limits = tuple[3] as Hex[] + if (tuple.length !== 6 || !Array.isArray(limits) || limits.length !== 2) + throw new InvalidSerializedTransactionError({ + attributes: { frame: tuple }, + serializedTransaction, + type: 'eip8141', + }) + const [mode, flags, target, , value, data] = tuple as Hex[] + 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, + limits: { + execution: limits[0] === '0x' ? 0n : hexToBigInt(limits[0]), + state: limits[1] === '0x' ? 0n : hexToBigInt(limits[1]), + }, + value: value === '0x' ? 0n : hexToBigInt(value), + data: isHex(data) && data !== '0x' ? data : '0x', + } + }, + ) + + const signatures: FrameSignature[] = ( + signaturesArray as RecursiveArray[] + ).map((signatureArray) => { + const tuple = signatureArray as Hex[] + if (tuple.length !== 4) + throw new InvalidSerializedTransactionError({ + attributes: { signature: tuple }, + serializedTransaction, + type: 'eip8141', + }) + const [scheme, signer, msg, signature] = tuple + return { + scheme: (scheme === '0x' + ? 0 + : hexToNumber(scheme)) as FrameSignature['scheme'], + signer: isHex(signer) && signer !== '0x' ? getAddress(signer) : null, + msg: isHex(msg) && msg !== '0x' ? msg : '0x', + signature: isHex(signature) && signature !== '0x' ? signature : '0x', + } + }) + + const transaction: TransactionSerializableEIP8141 = { + chainId: hexToNumber(chainId as Hex), + sender: sender as Hex, + frames, + type: 'eip8141', + } + + if (signatures.length > 0) transaction.signatures = signatures + + 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..4a24e7acae 100644 --- a/src/utils/transaction/serializeTransaction.ts +++ b/src/utils/transaction/serializeTransaction.ts @@ -10,11 +10,13 @@ import type { SignatureLegacy, } from '../../types/misc.js' import type { + FrameSignature, TransactionSerializable, TransactionSerializableEIP1559, TransactionSerializableEIP2930, TransactionSerializableEIP4844, TransactionSerializableEIP7702, + TransactionSerializableEIP8141, TransactionSerializableGeneric, TransactionSerializableLegacy, TransactionSerialized, @@ -22,10 +24,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, @@ -47,6 +51,7 @@ import { toBlobSidecars, } from '../blob/toBlobSidecars.js' import { type ConcatHexErrorType, concatHex } from '../data/concat.js' +import { pad } from '../data/pad.js' import { trim } from '../data/trim.js' import { bytesToHex, @@ -54,17 +59,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 +109,7 @@ export type SerializeTransactionErrorType = | SerializeTransactionEIP2930ErrorType | SerializeTransactionEIP4844ErrorType | SerializeTransactionEIP7702ErrorType + | SerializeTransactionEIP8141ErrorType | SerializeTransactionLegacyErrorType | ErrorType @@ -140,12 +147,127 @@ export function serializeTransaction< signature, ) as SerializedTransactionReturnType + if (type === 'eip8141') + return serializeTransactionEIP8141( + transaction as TransactionSerializableEIP8141, + signature, + ) as SerializedTransactionReturnType + return serializeTransactionLegacy( transaction as TransactionSerializableLegacy, signature as SignatureLegacy, ) as SerializedTransactionReturnType } +type SerializeTransactionEIP8141ErrorType = + | AssertTransactionEIP8141ErrorType + | ConcatHexErrorType + | GetAddressErrorType + | NumberToHexErrorType + | ToRlpErrorType + | ErrorType + +function serializeTransactionEIP8141( + transaction: TransactionSerializableEIP8141, + signature?: Signature | undefined, +): TransactionSerializedEIP8141 { + const { + chainId, + nonce, + sender, + frames, + maxPriorityFeePerGas, + maxFeePerGas, + maxFeePerBlobGas, + blobVersionedHashes, + } = transaction + + const signatures = signature + ? attachSignatureEIP8141(transaction.signatures, signature) + : (transaction.signatures ?? []) + + assertTransactionEIP8141({ ...transaction, signatures }) + + const serializedFrames = frames.map((frame) => [ + frame.mode ? numberToHex(frame.mode) : '0x', + frame.flags ? numberToHex(frame.flags) : '0x', + frame.target ? getAddress(frame.target) : '0x', + [ + frame.limits.execution ? numberToHex(frame.limits.execution) : '0x', + frame.limits.state ? numberToHex(frame.limits.state) : '0x', + ], + frame.value ? numberToHex(frame.value) : '0x', + frame.data ?? '0x', + ]) + + const serializedSignatures = signatures.map((signature) => [ + signature.scheme ? numberToHex(signature.scheme) : '0x', + signature.signer ? getAddress(signature.signer) : '0x', + signature.msg ?? '0x', + signature.signature ?? '0x', + ]) + + return concatHex([ + '0x06', + toRlp([ + numberToHex(chainId), + nonce ? numberToHex(nonce) : '0x', + sender, + serializedFrames, + serializedSignatures, + [ + maxPriorityFeePerGas ? numberToHex(maxPriorityFeePerGas) : '0x', + maxFeePerGas ? numberToHex(maxFeePerGas) : '0x', + maxFeePerBlobGas ? numberToHex(maxFeePerBlobGas) : '0x', + ], + blobVersionedHashes ?? [], + ]), + ]) as TransactionSerializedEIP8141 +} + +/** + * Returns the EIP-8141 `signatures` list with the sender's unsigned + * `SECP256K1` slot (no explicit `signer`, empty `msg`, empty `signature`) + * filled in with `signature`, encoded as `v (1 byte) || r (32 bytes) || s (32 bytes)`. + * A slot is appended when none exists, so an unsigned transaction and its + * signed counterpart share the same canonical signature hash. + */ +export function attachSignatureEIP8141( + signatures: readonly FrameSignature[] | undefined, + signature?: Signature | undefined, +): FrameSignature[] { + const signatures_ = [...(signatures ?? [])] + let index = signatures_.findIndex( + (signature) => + signature.scheme === 1 && + signature.signer === null && + signature.msg === '0x' && + signature.signature === '0x', + ) + if (index === -1) { + signatures_.push({ scheme: 1, signer: null, msg: '0x', signature: '0x' }) + index = signatures_.length - 1 + } + if (!signature) return signatures_ + + const yParity = (() => { + if (signature.yParity === 0 || signature.yParity === 1) + return signature.yParity + if (signature.v === 0n || signature.v === 27n) return 0 + if (signature.v === 1n || signature.v === 28n) return 1 + throw new InvalidLegacyVError({ v: signature.v as bigint }) + })() + signatures_[index] = { + ...signatures_[index], + signature: concatHex([ + numberToHex(yParity, { size: 1 }), + pad(signature.r, { size: 32 }), + pad(signature.s, { size: 32 }), + ]), + } + return signatures_ +} + 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' >()