From 853e49e7d235785d8066f757911411f194dc1c47 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 31 Dec 2024 13:23:32 +1100 Subject: [PATCH] feat: `eth_simulateV1` (#3172) * wip * rm * wip * wip * up * chore: changeset --- .changeset/sour-tools-exercise.md | 5 + pnpm-lock.yaml | 58 +++- site/pages/docs/actions/public/simulate.md | 332 +++++++++++++++++++ site/sidebar.ts | 4 + src/actions/index.test.ts | 1 + src/actions/index.ts | 6 + src/actions/public/simulate.test.ts | 356 +++++++++++++++++++++ src/actions/public/simulate.ts | 290 +++++++++++++++++ src/clients/createClient.test.ts | 1 + src/clients/createPublicClient.test.ts | 5 + src/clients/createTestClient.test.ts | 1 + src/clients/createWalletClient.test.ts | 1 + src/clients/decorators/public.test.ts | 1 + src/clients/decorators/public.ts | 51 +++ src/package.json | 2 +- src/types/calls.ts | 31 +- src/types/eip1193.ts | 38 +++ src/types/multicall.ts | 39 ++- 18 files changed, 1195 insertions(+), 27 deletions(-) create mode 100644 .changeset/sour-tools-exercise.md create mode 100644 site/pages/docs/actions/public/simulate.md create mode 100644 src/actions/public/simulate.test.ts create mode 100644 src/actions/public/simulate.ts diff --git a/.changeset/sour-tools-exercise.md b/.changeset/sour-tools-exercise.md new file mode 100644 index 0000000000..4f33345373 --- /dev/null +++ b/.changeset/sour-tools-exercise.md @@ -0,0 +1,5 @@ +--- +"viem": minor +--- + +Added `simulate` Action (`eth_simulateV1`). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 218df25e52..09675ab879 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -559,8 +559,8 @@ importers: specifier: 1.0.6 version: 1.0.6(ws@8.18.0) ox: - specifier: 0.4.4 - version: 0.4.4(typescript@5.7.2)(zod@3.23.8) + specifier: 0.6.0 + version: 0.6.0(typescript@5.7.2)(zod@3.23.8) webauthn-p256: specifier: 0.0.10 version: 0.0.10 @@ -4284,6 +4284,14 @@ packages: typescript: optional: true + ox@0.6.0: + resolution: {integrity: sha512-blUzTLidvUlshv0O02CnLFqBLidNzPoAZdIth894avUAotTuWziznv6IENv5idRuOSSP3dH8WzcYw84zVdu0Aw==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -5434,6 +5442,14 @@ packages: typescript: optional: true + viem@2.21.60: + resolution: {integrity: sha512-fzelL587wOtgNNKphbFCa/Ac9AgFGYKNdEZ04s5OO9Ua6Wu/3qIwjRmq3Z2rmiixr8HSqOHXjWLua6NiuUoRDg==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + viem@file:src: resolution: {directory: src, type: directory} peerDependencies: @@ -6690,7 +6706,7 @@ snapshots: pino-http: 8.6.1 pino-pretty: 10.3.1 prom-client: 14.2.0 - viem: 2.21.59(typescript@5.6.2)(zod@3.23.8) + viem: 2.21.60(typescript@5.6.2)(zod@3.23.8) yargs: 17.7.2 zod: 3.23.8 zod-validation-error: 1.5.0(zod@3.23.8) @@ -9977,7 +9993,21 @@ snapshots: transitivePeerDependencies: - zod - ox@0.4.4(typescript@5.7.2)(zod@3.23.8): + ox@0.6.0(typescript@5.6.2)(zod@3.23.8): + dependencies: + '@adraffy/ens-normalize': 1.11.0 + '@noble/curves': 1.7.0 + '@noble/hashes': 1.6.1 + '@scure/bip32': 1.6.0 + '@scure/bip39': 1.5.0 + abitype: 1.0.7(typescript@5.6.2)(zod@3.23.8) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - zod + + ox@0.6.0(typescript@5.7.2)(zod@3.23.8): dependencies: '@adraffy/ens-normalize': 1.11.0 '@noble/curves': 1.7.0 @@ -11295,7 +11325,7 @@ snapshots: - utf-8-validate - zod - viem@file:src(typescript@5.6.2)(zod@3.23.8): + viem@2.21.60(typescript@5.6.2)(zod@3.23.8): dependencies: '@noble/curves': 1.7.0 '@noble/hashes': 1.6.1 @@ -11313,6 +11343,24 @@ snapshots: - utf-8-validate - zod + viem@file:src(typescript@5.6.2)(zod@3.23.8): + dependencies: + '@noble/curves': 1.7.0 + '@noble/hashes': 1.6.1 + '@scure/bip32': 1.6.0 + '@scure/bip39': 1.5.0 + abitype: 1.0.7(typescript@5.6.2)(zod@3.23.8) + isows: 1.0.6(ws@8.18.0) + ox: 0.6.0(typescript@5.6.2)(zod@3.23.8) + webauthn-p256: 0.0.10 + ws: 8.18.0 + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + vite-node@1.0.4(@types/node@20.16.12)(terser@5.36.0): dependencies: cac: 6.7.14 diff --git a/site/pages/docs/actions/public/simulate.md b/site/pages/docs/actions/public/simulate.md new file mode 100644 index 0000000000..e19d919e95 --- /dev/null +++ b/site/pages/docs/actions/public/simulate.md @@ -0,0 +1,332 @@ +--- +description: Simulates a set of calls on block(s). +--- + +# simulate + +Simulates a set of calls on block(s) with optional block and state overrides. Internally uses the [`eth_simulateV1` JSON-RPC method](https://github.com/ethereum/execution-apis/pull/484). + +## Usage + +:::code-group + +```ts twoslash [example.ts] +import { parseEther } from 'viem' +import { client } from './config' + +const result = await client.simulate({ + blocks: [{ + blockOverrides: { + number: 69420n, + }, + calls: [ + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + to: '0xcb98643b8786950F0461f3B0edf99D88F274574D', + value: parseEther('2'), + }, + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1'), + }, + ], + stateOverrides: [{ + address: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + balance: parseEther('10'), + }], + }] +}) +``` + +```ts twoslash [config.ts] filename="config.ts" +import { createPublicClient, http } from 'viem' +import { mainnet } from 'viem/chains' + +export const client = createPublicClient({ + chain: mainnet, + transport: http(), +}) +``` + +::: + +### Contract Calls + +The `calls` property also accepts **Contract Calls**, and can be used via the `abi`, `functionName`, and `args` properties. + +:::code-group + +```ts twoslash [example.ts] +import { parseEther } from 'viem' +import { client } from './config' + +const abi = parseAbi([ + 'function approve(address, uint256) returns (bool)', + 'function transferFrom(address, address, uint256) returns (bool)', +]) + +const hash = await client.simulate({ // [!code focus:99] + blocks: [{ + calls: [ + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1') + }, + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + to: '0xFBA3912Ca04dd458c843e2EE08967fC04f3579c2', + abi, + functionName: 'approve', + args: [ + '0xa5cc3c03994DB5b0d9A5eEdD10CabaB0813678AC', + 100n + ], + }, + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + to: '0xFBA3912Ca04dd458c843e2EE08967fC04f3579c2', + abi, + functionName: 'transferFrom', + args: [ + '0xa5cc3c03994DB5b0d9A5eEdD10CabaB0813678AC', + '0x0000000000000000000000000000000000000000', + 100n + ], + }, + ], + }] +}) +``` + +```ts twoslash [config.ts] filename="config.ts" +import { createPublicClient, http } from 'viem' +import { mainnet } from 'viem/chains' + +export const client = createPublicClient({ + chain: mainnet, + transport: http(), +}) +``` + +::: + +## Return Value + +`simulateReturnType` + +Simulation results. + +## Parameters + +### blocks + +Blocks to simulate. + +### blocks.calls + +- **Type:** `TransactionRequest[]` + +Calls to simulate. Each call can consist of transaction request properties. + +```ts twoslash +import { client } from './config' +// ---cut--- +const result = await client.simulate({ + blocks: [{ + blockOverrides: { + number: 69420n, + }, + calls: [ // [!code focus] + { // [!code focus] + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', // [!code focus] + data: '0xdeadbeef', // [!code focus] + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', // [!code focus] + }, // [!code focus] + { // [!code focus] + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', // [!code focus] + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', // [!code focus] + value: parseEther('1'), // [!code focus] + }, // [!code focus] + ], // [!code focus] + stateOverrides: [{ + address: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + balance: parseEther('10'), + }], + }] +}) +``` + +### blocks.blockOverrides + +- **Type:** `BlockOverrides` + +Values to override on the block. + +```ts twoslash +import { client } from './config' +// ---cut--- +const result = await client.simulate({ + blocks: [{ + blockOverrides: { // [!code focus] + number: 69420n, // [!code focus] + }, // [!code focus] + calls: [ + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + data: '0xdeadbeef', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + }, + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1'), + }, + ], + stateOverrides: [{ + address: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + balance: parseEther('10'), + }], + }] +}) +``` + +### blocks.stateOverrides + +- **Type:** `StateOverride` + +State overrides. + +```ts twoslash +import { client } from './config' +// ---cut--- +const result = await client.simulate({ + blocks: [{ + blockOverrides: { + number: 69420n, + }, + calls: [ + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + data: '0xdeadbeef', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + }, + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1'), + }, + ], + stateOverrides: [{ // [!code focus] + address: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', // [!code focus] + balance: parseEther('10'), // [!code focus] + }], // [!code focus] + }] +}) +``` + +### returnFullTransactions + +- **Type:** `boolean` + +Whether to return the full transactions. + +```ts twoslash +import { client } from './config' +// ---cut--- +const result = await client.simulate({ + blocks: [{ + blockOverrides: { + number: 69420n, + }, + calls: [ + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + data: '0xdeadbeef', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + }, + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1'), + }, + ], + stateOverrides: [{ + address: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + balance: parseEther('10'), + }], + }] + returnFullTransactions: true, // [!code focus] +}) +``` + +### traceTransfers + +- **Type:** `boolean` + +Whether to trace transfers. + +```ts twoslash +import { client } from './config' +// ---cut--- +const result = await client.simulate({ + blocks: [{ + blockOverrides: { + number: 69420n, + }, + calls: [ + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + data: '0xdeadbeef', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + }, + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1'), + }, + ], + stateOverrides: [{ + address: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + balance: parseEther('10'), + }], + }] + traceTransfers: true, // [!code focus] +}) +``` + +### validation + +- **Type:** `boolean` + +Whether to enable validation mode. + +```ts twoslash +import { client } from './config' +// ---cut--- +const result = await client.simulate({ + blocks: [{ + blockOverrides: { + number: 69420n, + }, + calls: [ + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + data: '0xdeadbeef', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + }, + { + from: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + value: parseEther('1'), + }, + ], + stateOverrides: [{ + address: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + balance: parseEther('10'), + }], + }] + validation: true, // [!code focus] +}) +``` diff --git a/site/sidebar.ts b/site/sidebar.ts index c912322a9b..0a44d657f0 100644 --- a/site/sidebar.ts +++ b/site/sidebar.ts @@ -96,6 +96,10 @@ export const sidebar = { text: 'getBlockTransactionCount', link: '/docs/actions/public/getBlockTransactionCount', }, + { + text: 'simulate', + link: '/docs/actions/public/simulate', + }, { text: 'watchBlockNumber', link: '/docs/actions/public/watchBlockNumber', diff --git a/src/actions/index.test.ts b/src/actions/index.test.ts index e566cb8876..c840a2026c 100644 --- a/src/actions/index.test.ts +++ b/src/actions/index.test.ts @@ -91,6 +91,7 @@ test('exports actions', () => { "signMessage": [Function], "signTransaction": [Function], "signTypedData": [Function], + "simulate": [Function], "simulateContract": [Function], "snapshot": [Function], "stopImpersonatingAccount": [Function], diff --git a/src/actions/index.ts b/src/actions/index.ts index 6bcaa89ef8..3b0b13a6f9 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -249,6 +249,12 @@ export { type MulticallReturnType, multicall, } from './public/multicall.js' +export { + type SimulateErrorType, + type SimulateParameters, + type SimulateReturnType, + simulate, +} from './public/simulate.js' export { type OnBlock, type OnBlockParameter, diff --git a/src/actions/public/simulate.test.ts b/src/actions/public/simulate.test.ts new file mode 100644 index 0000000000..9d867bfc67 --- /dev/null +++ b/src/actions/public/simulate.test.ts @@ -0,0 +1,356 @@ +import { expect, test } from 'vitest' + +import { + usdcContractConfig, + wagmiContractConfig, +} from '../../../test/src/abis.js' +import { accounts } from '../../../test/src/constants.js' +import { mainnetClient } from '../../../test/src/utils.js' +import { maxUint256 } from '../../constants/number.js' +import { parseEther, parseGwei } from '../../utils/index.js' +import { simulate } from './simulate.js' + +test('default', async () => { + const result = await simulate(mainnetClient, { + blocks: [ + { + calls: [ + { + account: accounts[0].address, + to: accounts[1].address, + value: parseEther('1'), + }, + { + account: accounts[0].address, + to: accounts[2].address, + value: parseEther('1'), + }, + { + abi: wagmiContractConfig.abi, + functionName: 'name', + to: wagmiContractConfig.address, + }, + ], + stateOverrides: [ + { + address: accounts[0].address, + balance: parseEther('10000'), + }, + ], + }, + ], + }) + + expect(result[0].calls).toMatchInlineSnapshot(` + [ + { + "data": "0x", + "gasUsed": 21000n, + "logs": [], + "result": null, + "status": "success", + }, + { + "data": "0x", + "gasUsed": 21000n, + "logs": [], + "result": null, + "status": "success", + }, + { + "data": "0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000057761676d69000000000000000000000000000000000000000000000000000000", + "gasUsed": 24371n, + "logs": [], + "result": "wagmi", + "status": "success", + }, + ] + `) +}) + +test('args: blockOverrides', async () => { + const result = await simulate(mainnetClient, { + blocks: [ + { + calls: [ + { + account: accounts[0].address, + to: accounts[1].address, + value: parseEther('1'), + }, + ], + blockOverrides: { + baseFeePerGas: parseGwei('100'), + gasLimit: 60_000_000n, + }, + stateOverrides: [ + { + address: accounts[0].address, + balance: parseEther('10000'), + }, + ], + }, + ], + }) + + expect(result[0].baseFeePerGas).toBe(parseGwei('100')) + expect(result[0].gasLimit).toBe(60_000_000n) +}) + +test('behavior: fee cap too high', async () => { + await expect(() => + simulate(mainnetClient, { + blocks: [ + { + calls: [ + { + account: accounts[0].address, + to: accounts[1].address, + value: parseEther('1'), + maxFeePerGas: maxUint256 + 1n, + }, + ], + }, + ], + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [FeeCapTooHighError: The fee cap (\`maxFeePerGas\` = 115792089237316195423570985008687907853269984665640564039457584007913.129639936 gwei) cannot be higher than the maximum allowed value (2^256-1). + + Version: viem@x.y.z] + `) +}) + +test('behavior: tip higher than fee cap', async () => { + await expect(() => + simulate(mainnetClient, { + blocks: [ + { + calls: [ + { + account: accounts[0].address, + to: accounts[1].address, + value: parseEther('1'), + maxPriorityFeePerGas: parseGwei('11'), + maxFeePerGas: parseGwei('10'), + }, + ], + }, + ], + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + ` + [TipAboveFeeCapError: The provided tip (\`maxPriorityFeePerGas\` = 11 gwei) cannot be higher than the fee cap (\`maxFeePerGas\` = 10 gwei). + + Version: viem@x.y.z] + `, + ) +}) + +test('behavior: gas too low', async () => { + await expect(() => + simulate(mainnetClient, { + blocks: [ + { + calls: [ + { + account: accounts[0].address, + to: accounts[1].address, + value: parseEther('1'), + gas: 100n, + }, + ], + stateOverrides: [ + { + address: accounts[0].address, + balance: parseEther('10000'), + }, + ], + }, + ], + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + ` + [IntrinsicGasTooLowError: The amount of gas provided for the transaction is too low. + + Details: err: intrinsic gas too low: have 100, want 21000 (supplied gas 100) + Version: viem@x.y.z] + `, + ) +}) + +test('behavior: gas too high', async () => { + await expect(() => + simulate(mainnetClient, { + blocks: [ + { + calls: [ + { + account: accounts[0].address, + to: accounts[1].address, + value: parseEther('1'), + gas: 100_000_000_000_000_000n, + }, + ], + stateOverrides: [ + { + address: accounts[0].address, + balance: parseEther('10000'), + }, + ], + }, + ], + }), + ).rejects.toThrowError('block gas limit reached') +}) + +test('behavior: insufficient funds', async () => { + await expect(() => + simulate(mainnetClient, { + blocks: [ + { + calls: [ + { + account: accounts[0].address, + to: accounts[1].address, + value: parseEther('1'), + }, + ], + }, + ], + }), + ).rejects.toThrowError('insufficient funds for gas * price + value') +}) + +test('behavior: contract function does not exist', async () => { + const result = await simulate(mainnetClient, { + blocks: [ + { + calls: [ + { + abi: wagmiContractConfig.abi, + functionName: 'mint', + to: usdcContractConfig.address, + }, + ], + stateOverrides: [ + { + address: accounts[0].address, + balance: parseEther('10000'), + }, + ], + }, + ], + }) + expect(result[0].calls).toMatchInlineSnapshot( + ` + [ + { + "data": "0x", + "error": [ContractFunctionExecutionError: The contract function "mint" returned no data ("0x"). + + This could be due to any of the following: + - The contract does not have the function "mint", + - The parameters passed to the contract function may be invalid, or + - The address is not a contract. + + Contract Call: + address: 0x0000000000000000000000000000000000000000 + function: mint() + + Version: viem@x.y.z], + "gasUsed": 28585n, + "logs": [], + "status": "failure", + }, + ] + `, + ) +}) + +test('behavior: contract function does not exist', async () => { + const result = await simulate(mainnetClient, { + blocks: [ + { + calls: [ + { + data: '0xdeadbeef', + to: wagmiContractConfig.address, + }, + ], + stateOverrides: [ + { + address: accounts[0].address, + balance: parseEther('10000'), + }, + ], + }, + ], + }) + expect(result[0].calls).toMatchInlineSnapshot( + ` + [ + { + "data": "0x", + "error": [ContractFunctionExecutionError: The contract function "" returned no data ("0x"). + + This could be due to any of the following: + - The contract does not have the function "", + - The parameters passed to the contract function may be invalid, or + - The address is not a contract. + + Contract Call: + address: 0x0000000000000000000000000000000000000000 + + Version: viem@x.y.z], + "gasUsed": 21277n, + "logs": [], + "status": "failure", + }, + ] + `, + ) +}) + +test('behavior: contract revert', async () => { + const result = await simulate(mainnetClient, { + blocks: [ + { + calls: [ + { + abi: wagmiContractConfig.abi, + functionName: 'mint', + to: wagmiContractConfig.address, + args: [1n], + }, + ], + stateOverrides: [ + { + address: accounts[0].address, + balance: parseEther('10000'), + }, + ], + }, + ], + }) + expect(result[0].calls).toMatchInlineSnapshot( + ` + [ + { + "data": "0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000011546f6b656e2049442069732074616b656e000000000000000000000000000000", + "error": [ContractFunctionExecutionError: The contract function "mint" reverted with the following reason: + Token ID is taken + + Contract Call: + address: 0x0000000000000000000000000000000000000000 + function: mint(uint256 tokenId) + args: (1) + + Version: viem@x.y.z], + "gasUsed": 23813n, + "logs": [], + "status": "failure", + }, + ] + `, + ) +}) diff --git a/src/actions/public/simulate.ts b/src/actions/public/simulate.ts new file mode 100644 index 0000000000..858c2c723f --- /dev/null +++ b/src/actions/public/simulate.ts @@ -0,0 +1,290 @@ +import type { Abi, AbiStateMutability, Address, Narrow } from 'abitype' +import * as BlockOverrides from 'ox/BlockOverrides' + +import { + type ParseAccountErrorType, + parseAccount, +} from '../../accounts/utils/parseAccount.js' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import { AbiDecodingZeroDataError } from '../../errors/abi.js' +import type { BaseError } from '../../errors/base.js' +import { RawContractError } from '../../errors/contract.js' +import { UnknownNodeError } from '../../errors/node.js' +import type { ErrorType } from '../../errors/utils.js' +import type { Account } from '../../types/account.js' +import type { Block, BlockTag } from '../../types/block.js' +import type { Call, Calls } from '../../types/calls.js' +import type { Chain } from '../../types/chain.js' +import type { Log } from '../../types/log.js' +import type { Hex } from '../../types/misc.js' +import type { MulticallResults } from '../../types/multicall.js' +import type { StateOverride } from '../../types/stateOverride.js' +import type { TransactionRequest } from '../../types/transaction.js' +import type { ExactPartial, UnionOmit } from '../../types/utils.js' +import { + type DecodeFunctionResultErrorType, + decodeFunctionResult, +} from '../../utils/abi/decodeFunctionResult.js' +import { + type EncodeFunctionDataErrorType, + encodeFunctionData, +} from '../../utils/abi/encodeFunctionData.js' +import { + type NumberToHexErrorType, + numberToHex, +} from '../../utils/encoding/toHex.js' +import { getContractError } from '../../utils/errors/getContractError.js' +import { + type GetNodeErrorReturnType, + getNodeError, +} from '../../utils/errors/getNodeError.js' +import { + type FormatBlockErrorType, + formatBlock, +} from '../../utils/formatters/block.js' +import { formatLog } from '../../utils/formatters/log.js' +import { + type FormatTransactionRequestErrorType, + formatTransactionRequest, +} from '../../utils/formatters/transactionRequest.js' +import { + type SerializeStateOverrideErrorType, + serializeStateOverride, +} from '../../utils/stateOverride.js' +import { + type AssertRequestErrorType, + assertRequest, +} from '../../utils/transaction/assertRequest.js' + +type CallExtraProperties = ExactPartial< + UnionOmit< + TransactionRequest, + 'blobs' | 'data' | 'kzg' | 'to' | 'sidecars' | 'value' + > +> & { + /** Account attached to the call (msg.sender). */ + account?: Account | Address | undefined +} + +export type SimulateParameters< + calls extends readonly unknown[] = readonly unknown[], +> = { + /** Blocks to simulate. */ + blocks: readonly { + /** Block overrides. */ + blockOverrides?: BlockOverrides.BlockOverrides | undefined + /** Calls to execute. */ + calls: Calls, CallExtraProperties> + /** State overrides. */ + stateOverrides?: StateOverride | undefined + }[] + /** Whether to return the full transactions. */ + returnFullTransactions?: boolean + /** Whether to trace transfers. */ + traceTransfers?: boolean + /** Whether to enable validation mode. */ + validation?: boolean +} & ( + | { + /** The balance of the account at a block number. */ + blockNumber?: bigint | undefined + blockTag?: undefined + } + | { + blockNumber?: undefined + /** + * The balance of the account at a block tag. + * @default 'latest' + */ + blockTag?: BlockTag | undefined + } +) + +export type SimulateReturnType< + calls extends readonly unknown[] = readonly unknown[], +> = readonly (Block & { + calls: MulticallResults< + Narrow, + true, + { + extraProperties: { + data: Hex + gasUsed: bigint + logs?: Log[] | undefined + } + error: Error + mutability: AbiStateMutability + } + > +})[] + +export type SimulateErrorType = + | AssertRequestErrorType + | DecodeFunctionResultErrorType + | EncodeFunctionDataErrorType + | FormatBlockErrorType + | FormatTransactionRequestErrorType + | GetNodeErrorReturnType + | ParseAccountErrorType + | SerializeStateOverrideErrorType + | NumberToHexErrorType + | ErrorType + +/** + * Simulates a set of calls on block(s) with optional block and state overrides. + * + * @example + * ```ts + * import { createClient, http, parseEther } from 'viem' + * import { simulate } from 'viem/actions' + * import { mainnet } from 'viem/chains' + * + * const client = createClient({ + * chain: mainnet, + * transport: http(), + * }) + * + * const result = await simulate(client, { + * blocks: [{ + * blockOverrides: { + * number: 69420n, + * }, + * calls: [{ + * { + * account: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + * data: '0xdeadbeef', + * to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + * }, + * { + * account: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + * to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + * value: parseEther('1'), + * }, + * }], + * stateOverrides: [{ + * address: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + * balance: parseEther('10'), + * }], + * }] + * }) + * ``` + * + * @param client - Client to use. + * @param parameters - {@link SimulateParameters} + * @returns Simulated blocks. {@link SimulateReturnType} + */ +export async function simulate< + chain extends Chain | undefined, + const calls extends readonly unknown[], +>( + client: Client, + parameters: SimulateParameters, +): Promise> { + const { + blockNumber, + blockTag = 'latest', + blocks, + returnFullTransactions, + traceTransfers, + validation, + } = parameters + + try { + const blockStateCalls = [] + for (const block of blocks) { + const blockOverrides = block.blockOverrides + ? BlockOverrides.toRpc(block.blockOverrides) + : undefined + const calls = block.calls.map((call_) => { + const call = call_ as Call + const account = call.account ? parseAccount(call.account) : undefined + const request = { + ...call, + data: call.abi ? encodeFunctionData(call) : call.data, + from: call.from ?? account?.address, + } as const + assertRequest(request) + return formatTransactionRequest(request) + }) + const stateOverrides = block.stateOverrides + ? serializeStateOverride(block.stateOverrides) + : undefined + + blockStateCalls.push({ + blockOverrides, + calls, + stateOverrides, + }) + } + + const blockNumberHex = blockNumber ? numberToHex(blockNumber) : undefined + const block = blockNumberHex || blockTag + + const result = await client.request({ + method: 'eth_simulateV1', + params: [ + { blockStateCalls, returnFullTransactions, traceTransfers, validation }, + block, + ], + }) + + return result.map((block, i) => ({ + ...formatBlock(block), + calls: block.calls.map((call, j) => { + const { abi, args, functionName, to } = blocks[i].calls[j] as Call< + unknown, + CallExtraProperties + > + + const data = call.error?.data ?? call.returnData + const gasUsed = BigInt(call.gasUsed) + const logs = call.logs?.map((log) => formatLog(log)) + const status = call.status === '0x1' ? 'success' : 'failure' + + const result = abi + ? decodeFunctionResult({ + abi, + data, + functionName, + }) + : null + + const error = (() => { + if (status === 'success') return undefined + + let error = undefined + if (call.error?.data === '0x') error = new AbiDecodingZeroDataError() + else if (call.error) error = new RawContractError(call.error) + + if (!error) return undefined + return getContractError(error, { + abi: (abi ?? []) as Abi, + address: to, + args, + functionName: functionName ?? '', + }) + })() + + return { + data, + gasUsed, + logs, + status, + ...(status === 'success' + ? { + result, + } + : { + error, + }), + } + }), + })) as unknown as SimulateReturnType + } catch (e) { + const cause = e as BaseError + const error = getNodeError(cause, {}) + if (error instanceof UnknownNodeError) throw cause + throw error + } +} diff --git a/src/clients/createClient.test.ts b/src/clients/createClient.test.ts index 5ced2a7152..eed9c6cf72 100644 --- a/src/clients/createClient.test.ts +++ b/src/clients/createClient.test.ts @@ -533,6 +533,7 @@ describe('extends', () => { "readContract": [Function], "request": [Function], "sendRawTransaction": [Function], + "simulate": [Function], "simulateContract": [Function], "transport": { "fetchOptions": undefined, diff --git a/src/clients/createPublicClient.test.ts b/src/clients/createPublicClient.test.ts index 1a055acedf..134876bbbc 100644 --- a/src/clients/createPublicClient.test.ts +++ b/src/clients/createPublicClient.test.ts @@ -79,6 +79,7 @@ test('creates', () => { "readContract": [Function], "request": [Function], "sendRawTransaction": [Function], + "simulate": [Function], "simulateContract": [Function], "transport": { "key": "mock", @@ -218,6 +219,7 @@ describe('transports', () => { "readContract": [Function], "request": [Function], "sendRawTransaction": [Function], + "simulate": [Function], "simulateContract": [Function], "transport": { "fetchOptions": undefined, @@ -322,6 +324,7 @@ describe('transports', () => { "readContract": [Function], "request": [Function], "sendRawTransaction": [Function], + "simulate": [Function], "simulateContract": [Function], "transport": { "getRpcClient": [Function], @@ -408,6 +411,7 @@ describe('transports', () => { "readContract": [Function], "request": [Function], "sendRawTransaction": [Function], + "simulate": [Function], "simulateContract": [Function], "transport": { "key": "custom", @@ -550,6 +554,7 @@ test('extend', () => { "signMessage": [Function], "signTransaction": [Function], "signTypedData": [Function], + "simulate": [Function], "simulateContract": [Function], "snapshot": [Function], "stopImpersonatingAccount": [Function], diff --git a/src/clients/createTestClient.test.ts b/src/clients/createTestClient.test.ts index 34b817afe2..6cc890d7da 100644 --- a/src/clients/createTestClient.test.ts +++ b/src/clients/createTestClient.test.ts @@ -398,6 +398,7 @@ test('extend', () => { "signMessage": [Function], "signTransaction": [Function], "signTypedData": [Function], + "simulate": [Function], "simulateContract": [Function], "snapshot": [Function], "stopImpersonatingAccount": [Function], diff --git a/src/clients/createWalletClient.test.ts b/src/clients/createWalletClient.test.ts index 4df6548bf1..810c7d46e6 100644 --- a/src/clients/createWalletClient.test.ts +++ b/src/clients/createWalletClient.test.ts @@ -482,6 +482,7 @@ test('extend', () => { "signMessage": [Function], "signTransaction": [Function], "signTypedData": [Function], + "simulate": [Function], "simulateContract": [Function], "snapshot": [Function], "stopImpersonatingAccount": [Function], diff --git a/src/clients/decorators/public.test.ts b/src/clients/decorators/public.test.ts index 7ff4622c08..21f46511cd 100644 --- a/src/clients/decorators/public.test.ts +++ b/src/clients/decorators/public.test.ts @@ -75,6 +75,7 @@ test('default', async () => { "prepareTransactionRequest": [Function], "readContract": [Function], "sendRawTransaction": [Function], + "simulate": [Function], "simulateContract": [Function], "uninstallFilter": [Function], "verifyMessage": [Function], diff --git a/src/clients/decorators/public.ts b/src/clients/decorators/public.ts index bdb5d7fc8f..9f69b3eab3 100644 --- a/src/clients/decorators/public.ts +++ b/src/clients/decorators/public.ts @@ -180,6 +180,11 @@ import { type ReadContractReturnType, readContract, } from '../../actions/public/readContract.js' +import { + type SimulateParameters, + type SimulateReturnType, + simulate, +} from '../../actions/public/simulate.js' import { type SimulateContractParameters, type SimulateContractReturnType, @@ -1520,6 +1525,51 @@ export type PublicActions< sendRawTransaction: ( args: SendRawTransactionParameters, ) => Promise + /** + * Simulates a set of calls on block(s) with optional block and state overrides. + * + * @example + * ```ts + * import { createPublicClient, http, parseEther } from 'viem' + * import { mainnet } from 'viem/chains' + * + * const client = createPublicClient({ + * chain: mainnet, + * transport: http(), + * }) + * + * const result = await client.simulate({ + * blocks: [{ + * blockOverrides: { + * number: 69420n, + * }, + * calls: [{ + * { + * account: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + * data: '0xdeadbeef', + * to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + * }, + * { + * account: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + * to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + * value: parseEther('1'), + * }, + * }], + * stateOverrides: [{ + * address: '0x5a0b54d5dc17e482fe8b0bdca5320161b95fb929', + * balance: parseEther('10'), + * }], + * }] + * }) + * ``` + * + * @param client - Client to use. + * @param parameters - {@link SimulateParameters} + * @returns Simulated blocks. {@link SimulateReturnType} + */ + simulate: ( + args: SimulateParameters, + ) => Promise> /** * Simulates/validates a contract interaction. This is useful for retrieving **return data** and **revert reasons** of contract write functions. * @@ -1908,6 +1958,7 @@ export function publicActions< prepareTransactionRequest(client as any, args as any) as any, readContract: (args) => readContract(client, args), sendRawTransaction: (args) => sendRawTransaction(client, args), + simulate: (args) => simulate(client, args), simulateContract: (args) => simulateContract(client, args), verifyMessage: (args) => verifyMessage(client, args), verifySiweMessage: (args) => verifySiweMessage(client, args), diff --git a/src/package.json b/src/package.json index cf297e8d19..8fbd00316c 100644 --- a/src/package.json +++ b/src/package.json @@ -150,7 +150,7 @@ "@scure/bip39": "1.5.0", "abitype": "1.0.7", "isows": "1.0.6", - "ox": "0.4.4", + "ox": "0.6.0", "webauthn-p256": "0.0.10", "ws": "8.18.0" }, diff --git a/src/types/calls.ts b/src/types/calls.ts index 695cf4c0ca..bf413cd695 100644 --- a/src/types/calls.ts +++ b/src/types/calls.ts @@ -3,31 +3,40 @@ import type { Hex } from './misc.js' import type { GetMulticallContractParameters } from './multicall.js' import type { OneOf, Prettify } from './utils.js' -export type Call = OneOf< - | { +export type Call< + call = unknown, + extraProperties extends Record = {}, +> = OneOf< + | (extraProperties & { data?: Hex | undefined to: Address value?: bigint | undefined - } - | (Omit< - GetMulticallContractParameters, - 'address' - > & { - to: Address - value?: bigint | undefined }) + | (extraProperties & + (Omit< + GetMulticallContractParameters, + 'address' + > & { + to: Address + value?: bigint | undefined + })) > export type Calls< calls extends readonly unknown[], + extraProperties extends Record = {}, /// result extends readonly any[] = [], > = calls extends readonly [] // no calls, return empty ? readonly [] : calls extends readonly [infer call] // one call left before returning `result` - ? readonly [...result, Prettify>] + ? readonly [...result, Prettify>] : calls extends readonly [infer call, ...infer rest] // grab first call and recurse through `rest` - ? Calls<[...rest], [...result, Prettify>]> + ? Calls< + [...rest], + extraProperties, + [...result, Prettify>] + > : readonly unknown[] extends calls ? calls : // If `calls` is *some* array but we couldn't assign `unknown[]` to it, then it must hold some known/homogenous type! diff --git a/src/types/eip1193.ts b/src/types/eip1193.ts index 6cbf4ec1e6..0221ae2172 100644 --- a/src/types/eip1193.ts +++ b/src/types/eip1193.ts @@ -1,5 +1,6 @@ import type { Address } from 'abitype' +import type * as BlockOverrides from 'ox/BlockOverrides' import type { RpcEstimateUserOperationGasReturnType, RpcGetUserOperationByHashReturnType, @@ -1124,6 +1125,43 @@ export type PublicRpcSchema = [ Parameters: [signedTransaction: Hex] ReturnType: Hash }, + /** + * @description Simulates execution of a set of calls with optional block and state overrides. + * @example + * provider.request({ method: 'eth_simulateV1', params: [{ blockStateCalls: [{ calls: [{ from: '0x...', to: '0x...', value: '0x...', data: '0x...' }] }] }, 'latest'] }) + * // => { ... } + */ + { + Method: 'eth_simulateV1' + Parameters: [ + { + blockStateCalls: readonly { + blockOverrides?: BlockOverrides.Rpc | undefined + calls?: readonly ExactPartial[] | undefined + stateOverrides?: RpcStateOverride | undefined + }[] + returnFullTransactions?: boolean | undefined + traceTransfers?: boolean | undefined + validation?: boolean | undefined + }, + BlockNumber | BlockTag, + ] + ReturnType: readonly (Block & { + calls: readonly { + error?: + | { + data?: Hex | undefined + code: number + message: string + } + | undefined + logs?: readonly Log[] | undefined + gasUsed: Hex + returnData: Hex + status: Hex + }[] + })[] + }, /** * @description Destroys a filter based on filter ID * @link https://eips.ethereum.org/EIPS/eip-1474 diff --git a/src/types/multicall.ts b/src/types/multicall.ts index 6b16ec164d..ba6e436868 100644 --- a/src/types/multicall.ts +++ b/src/types/multicall.ts @@ -68,9 +68,10 @@ export type MulticallResults< contracts extends readonly unknown[] = readonly ContractFunctionParameters[], allowFailure extends boolean = true, options extends { - error?: Error + error?: Error | undefined + extraProperties?: Record | undefined mutability: AbiStateMutability - } = { error: Error; mutability: AbiStateMutability }, + } = { error: Error; extraProperties: {}; mutability: AbiStateMutability }, /// result extends any[] = [], > = contracts extends readonly [] // no contracts, return empty @@ -81,7 +82,8 @@ export type MulticallResults< MulticallResponse< GetMulticallContractReturnType, options['error'], - allowFailure + allowFailure, + options['extraProperties'] >, ] : contracts extends readonly [infer contract, ...infer rest] // grab first contract and recurse through `rest` @@ -94,12 +96,18 @@ export type MulticallResults< MulticallResponse< GetMulticallContractReturnType, options['error'], - allowFailure + allowFailure, + options['extraProperties'] >, ] > : readonly unknown[] extends contracts - ? MulticallResponse[] + ? MulticallResponse< + unknown, + options['error'], + allowFailure, + options['extraProperties'] + >[] : // If `contracts` is *some* array but we couldn't assign `unknown[]` to it, then it must hold some known/homogenous type! // use this to infer the param types in the case of Array.map() argument contracts extends readonly (infer contract extends @@ -107,23 +115,34 @@ export type MulticallResults< ? MulticallResponse< GetMulticallContractReturnType, options['error'], - allowFailure + allowFailure, + options['extraProperties'] >[] : // Fallback - MulticallResponse[] + MulticallResponse< + unknown, + options['error'], + allowFailure, + options['extraProperties'] + >[] export type MulticallResponse< result = unknown, error = unknown, allowFailure extends boolean = true, + extraProperties extends Record | undefined = {}, > = allowFailure extends true ? - | { error?: undefined; result: result; status: 'success' } - | { + | (extraProperties & { + error?: undefined + result: result + status: 'success' + }) + | (extraProperties & { error: unknown extends error ? Error : error result?: undefined status: 'failure' - } + }) : result // infer contract parameters from `unknown`