├── .nvmrc ├── funding.json ├── src ├── index.ts ├── traceActions.ts ├── middleware.ts ├── actions │ └── traceCall.ts └── format.ts ├── tsconfig.build.json ├── test ├── fixtures.ts ├── setup.ts ├── anvil.ts └── traceCall.test.ts ├── .github ├── workflows │ ├── push.yml │ ├── test.yml │ └── release.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── vitest.config.ts ├── tsconfig.json ├── vitest.setup.ts ├── biome.json ├── LICENSE ├── .gitignore ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /funding.json: -------------------------------------------------------------------------------- 1 | { 2 | "opRetro": { 3 | "projectId": "0x363b77a4022f98f4d529b8a771d22f49b1092293980aab154fff63980bd1b2bf" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./actions/traceCall"; 2 | export * from "./traceActions"; 3 | export * from "./middleware"; 4 | export * from "./format"; 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { mnemonicToAccount } from "viem/accounts"; 2 | 3 | export const testAccount = (addressIndex?: number) => 4 | mnemonicToAccount( 5 | "test test test test test test test test test test test junk", 6 | { addressIndex }, 7 | ); 8 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Push 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | uses: ./.github/workflows/test.yml 11 | secrets: inherit 12 | 13 | release: 14 | needs: test 15 | 16 | uses: ./.github/workflows/release.yml 17 | secrets: inherit 18 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reporter: process.env.CI ? ["lcov"] : ["text", "json", "html"], 7 | include: ["src"], 8 | exclude: ["test"], 9 | }, 10 | sequence: { 11 | concurrent: true, 12 | }, 13 | globalSetup: "vitest.setup.ts", 14 | testTimeout: 30_000, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "outDir": "lib", 7 | "rootDir": ".", 8 | "baseUrl": ".", 9 | "strict": true, 10 | "declaration": true, 11 | "esModuleInterop": true, 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "noUncheckedIndexedAccess": true 15 | }, 16 | "include": ["src", "test"] 17 | } 18 | -------------------------------------------------------------------------------- /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { exec, execSync } from "node:child_process"; 2 | 3 | export const setup = async () => { 4 | try { 5 | const data = await execSync("lsof -c anvil -t"); 6 | 7 | const pids = data.toString().split("\n").slice(0, -1); 8 | 9 | console.debug(`Clearing ports: ${pids.join(", ")}`); 10 | 11 | for (const pid of pids) { 12 | exec(`kill -9 ${pid}`, (error) => { 13 | if (error) console.error(`Error while killing ${pid}: ${error}`); 14 | }); 15 | } 16 | } catch {} 17 | }; 18 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.3/schema.json", 3 | "files": { 4 | "ignore": ["out", "lib", "dist", ".pnp.*", ".vscode", "package.json"] 5 | }, 6 | "formatter": { 7 | "enabled": true, 8 | "formatWithErrors": false, 9 | "indentStyle": "space", 10 | "indentWidth": 2, 11 | "lineWidth": 120 12 | }, 13 | "organizeImports": { 14 | "enabled": true 15 | }, 16 | "linter": { 17 | "enabled": true, 18 | "rules": { 19 | "style": { 20 | "noNonNullAssertion": "off" 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐛 Bug Report" 3 | about: Report a reproducible bug or regression. 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Current Behavior 11 | 12 | 13 | 14 | ## Expected Behavior 15 | 16 | 17 | 18 | ## Steps to Reproduce the Problem 19 | 20 | 1. 21 | 1. 22 | 1. 23 | 24 | ## Environment 25 | 26 | - Version: 27 | - Platform: 28 | - Node.js Version: 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Jest Test Suite 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - src/** 7 | - test/** 8 | - package.json 9 | - yarn.lock 10 | workflow_call: 11 | 12 | jobs: 13 | vitest: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version-file: .nvmrc 22 | cache: yarn 23 | 24 | - uses: foundry-rs/foundry-toolchain@v1 25 | 26 | - run: yarn --frozen-lockfile 27 | 28 | - run: yarn test --coverage 29 | env: 30 | MAINNET_RPC_URL: https://eth-mainnet.g.alchemy.com/v2/${{ secrets.ALCHEMY_KEY }} 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | NPM_TOKEN: 7 | required: true 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | semantic-release: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 22 23 | cache: yarn 24 | 25 | - run: yarn --frozen-lockfile 26 | 27 | - run: yarn build 28 | 29 | - run: npx semantic-release 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Romain Milon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/traceActions.ts: -------------------------------------------------------------------------------- 1 | import type { Chain, Client, Transport } from "viem"; 2 | import { type RpcCallTrace, type TraceCallParameters, traceCall } from "./actions/traceCall"; 3 | 4 | export type TraceActions = { 5 | /** 6 | * Traces a call. 7 | * 8 | * @param args - {@link TraceCallParameters} 9 | * 10 | * @example 11 | * import { createClient, http } from 'viem' 12 | * import { mainnet } from 'viem/chains' 13 | * import { traceActions } from 'viem-tracer' 14 | * 15 | * const client = createClient({ 16 | * chain: mainnet, 17 | * transport: http(), 18 | * }).extend(traceActions) 19 | * await client.traceCall({ 20 | * account: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', 21 | * to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', 22 | * value: parseEther('1'), 23 | * }) 24 | */ 25 | traceCall: (args: TraceCallParameters) => Promise; 26 | }; 27 | 28 | export function traceActions(client: Client): TraceActions { 29 | return { 30 | traceCall: (args) => traceCall(client, args), 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🌈 Feature request 3 | about: Suggest an amazing new idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Feature Request 11 | 12 | **Is your feature request related to a problem? Please describe.** 13 | 14 | 15 | **Describe the solution you'd like** 16 | 17 | 18 | **Describe alternatives you've considered** 19 | 20 | 21 | ## Are you willing to resolve this issue by submitting a Pull Request? 22 | 23 | 26 | 27 | - [ ] Yes, I have the time, and I know how to start. 28 | - [ ] Yes, I have the time, but I don't know how to start. I would need guidance. 29 | - [ ] No, I don't have the time, although I believe I could do it if I had the time... 30 | - [ ] No, I don't have the time and I wouldn't even know how to start. 31 | 32 | 35 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | ### Description of change 9 | 10 | 23 | 24 | ### Pull-Request Checklist 25 | 26 | 31 | 32 | - [ ] Code is up-to-date with the `main` branch 33 | - [ ] `npm run lint` passes with this change 34 | - [ ] `npm run test` passes with this change 35 | - [ ] This pull request links relevant issues as `Fixes #0000` 36 | - [ ] There are new or updated unit tests validating the change 37 | - [ ] Documentation has been updated to reflect this change 38 | - [ ] The new commits follow conventions outlined in the [conventional commit spec](https://www.conventionalcommits.org/en/v1.0.0/) 39 | 40 | 43 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { disable } from "colors"; 2 | import type { 3 | Client, 4 | HDAccount, 5 | HttpTransport, 6 | PublicActions, 7 | PublicRpcSchema, 8 | TestActions, 9 | TestRpcSchema, 10 | WalletActions, 11 | WalletRpcSchema, 12 | } from "viem"; 13 | import { http, createTestClient, publicActions, walletActions } from "viem"; 14 | import { type DealActions, dealActions } from "viem-deal"; 15 | import { mainnet } from "viem/chains"; 16 | import { test as vitest } from "vitest"; 17 | import { type TraceActions, type TracedTransport, traceActions, traced } from "../src/index"; 18 | import { spawnAnvil } from "./anvil"; 19 | import { testAccount } from "./fixtures"; 20 | 21 | // Vitest needs to serialize BigInts to JSON, so we need to add a toJSON method to BigInt.prototype. 22 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt#use_within_json 23 | // @ts-ignore 24 | BigInt.prototype.toJSON = function () { 25 | return this.toString(); 26 | }; 27 | 28 | disable(); 29 | 30 | declare global { 31 | namespace NodeJS { 32 | interface Process { 33 | __tinypool_state__: { 34 | isChildProcess: boolean; 35 | isTinypoolWorker: boolean; 36 | workerData: null; 37 | workerId: number; 38 | }; 39 | } 40 | } 41 | } 42 | 43 | export const test = vitest.extend<{ 44 | client: Client< 45 | TracedTransport, 46 | typeof mainnet, 47 | HDAccount, 48 | TestRpcSchema<"anvil"> | PublicRpcSchema | WalletRpcSchema, 49 | TestActions & 50 | DealActions & 51 | TraceActions & 52 | PublicActions, typeof mainnet, HDAccount> & 53 | WalletActions 54 | >; 55 | }>({ 56 | // biome-ignore lint/correctness/noEmptyPattern: required by vitest at runtime 57 | client: async ({}, use) => { 58 | const { rpcUrl, stop } = await spawnAnvil({ 59 | forkUrl: process.env.MAINNET_RPC_URL || mainnet.rpcUrls.default.http[0], 60 | forkBlockNumber: 20_884_340, 61 | stepsTracing: true, 62 | }); 63 | 64 | await use( 65 | createTestClient({ 66 | chain: mainnet, 67 | mode: "anvil", 68 | account: testAccount(), 69 | transport: traced(http(rpcUrl)), 70 | }) 71 | .extend(dealActions) 72 | .extend(publicActions) 73 | .extend(walletActions) 74 | .extend(traceActions), 75 | ); 76 | 77 | await stop(); 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env.test 73 | .env.local 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | 118 | # Compiled code 119 | lib/ 120 | cache/ 121 | .vscode 122 | .env 123 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "viem-tracer", 3 | "version": "1.0.0", 4 | "author": { 5 | "name": "Romain (Rubilmax) Milon", 6 | "email": "rmilon@gmail.com", 7 | "url": "https://github.com/rubilmax" 8 | }, 9 | "license": "MIT", 10 | "main": "lib/index.js", 11 | "types": "lib/index.d.ts", 12 | "files": [ 13 | "lib" 14 | ], 15 | "packageManager": "yarn@1.22.22", 16 | "scripts": { 17 | "prepare": "husky", 18 | "lint": "biome check", 19 | "build": "tsc --build tsconfig.build.json", 20 | "test": "dotenv -- vitest" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/Rubilmax/viem-tracer.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/Rubilmax/viem-tracer/issues" 28 | }, 29 | "keywords": [ 30 | "viem", 31 | "trace", 32 | "hardhat", 33 | "anvil", 34 | "test", 35 | "cheat", 36 | "rpc", 37 | "erc20" 38 | ], 39 | "dependencies": { 40 | "colors": "^1.4.0" 41 | }, 42 | "peerDependencies": { 43 | "viem": "^2.21.0" 44 | }, 45 | "devDependencies": { 46 | "@biomejs/biome": "^1.9.4", 47 | "@commitlint/cli": "^19.6.0", 48 | "@commitlint/config-conventional": "^19.6.0", 49 | "@types/lodash.kebabcase": "^4.1.9", 50 | "@types/node": "^22.10.2", 51 | "@vitest/coverage-v8": "^2.1.8", 52 | "conventional-changelog-conventionalcommits": "^8.0.0", 53 | "dotenv-cli": "^7.4.4", 54 | "husky": "^9.1.7", 55 | "lint-staged": "^15.2.11", 56 | "lodash.kebabcase": "^4.1.1", 57 | "semantic-release": "^24.2.0", 58 | "typescript": "^5.7.2", 59 | "viem": "^2.29.0", 60 | "viem-deal": "^2.0.4", 61 | "vitest": "^2.1.8" 62 | }, 63 | "lint-staged": { 64 | "*.ts": "yarn biome check" 65 | }, 66 | "commitlint": { 67 | "extends": [ 68 | "@commitlint/config-conventional" 69 | ] 70 | }, 71 | "release": { 72 | "branches": [ 73 | "main", 74 | "next" 75 | ], 76 | "plugins": [ 77 | [ 78 | "@semantic-release/commit-analyzer", 79 | { 80 | "preset": "conventionalcommits", 81 | "releaseRules": [ 82 | { 83 | "type": "build", 84 | "scope": "deps", 85 | "release": "patch" 86 | } 87 | ] 88 | } 89 | ], 90 | [ 91 | "@semantic-release/release-notes-generator", 92 | { 93 | "preset": "conventionalcommits", 94 | "presetConfig": { 95 | "types": [ 96 | { 97 | "type": "feat", 98 | "section": "Features" 99 | }, 100 | { 101 | "type": "fix", 102 | "section": "Bug Fixes" 103 | }, 104 | { 105 | "type": "build", 106 | "section": "Dependencies and Other Build Updates", 107 | "hidden": false 108 | } 109 | ] 110 | } 111 | } 112 | ], 113 | "@semantic-release/npm", 114 | "@semantic-release/github" 115 | ] 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # viem-tracer 2 | 3 | [![npm package][npm-img]][npm-url] 4 | [![Build Status][build-img]][build-url] 5 | [![Downloads][downloads-img]][downloads-url] 6 | [![Issues][issues-img]][issues-url] 7 | [![Commitizen Friendly][commitizen-img]][commitizen-url] 8 | [![Semantic Release][semantic-release-img]][semantic-release-url] 9 | 10 | Debug transactions via traces by automatically decoding them with the help of [openchain.xyz](https://openchain.xyz/)! 11 | 12 | - Automatically append traces to error messages of failed `eth_estimateGas` and `eth_sendTransaction` RPC requests. 13 | - Add support for [`debug_traceCall`](https://www.quicknode.com/docs/ethereum/debug_traceCall) to a Viem client with correct types! 14 | 15 | ## Installation 16 | 17 | ```bash 18 | npm install viem-tracer 19 | ``` 20 | 21 | ```bash 22 | yarn add viem-tracer 23 | ``` 24 | 25 | ## Usage 26 | 27 | ```typescript 28 | import { createTestClient, http } from 'viem'; 29 | import { foundry } from 'viem/chains'; 30 | import { traceActions, traced } from 'viem-tracer'; 31 | 32 | const client = createTestClient({ 33 | mode: "anvil", 34 | chain: foundry, 35 | transport: traced( // Automatically trace failed transactions (or programmatically) 36 | http(), 37 | { all: false, next: false, failed: true } // Optional, default tracer config 38 | ), 39 | }).extend(traceActions); // Extend client with the `client.traceCall` action 40 | 41 | // Returns the call trace as formatted by the requested tracer. 42 | await client.traceCall({ 43 | account: "0xA0Cf798816D4b9b9866b5330EEa46a18382f251e", 44 | to: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", 45 | value: parseEther("1"), 46 | // tracer: "prestateTracer", // Defaults to "callTracer". 47 | }); 48 | 49 | // Failing `eth_estimateGas` and `eth_sendTransaction` RPC requests will automatically append the transaction traces to the error: 50 | await client.writeContract({ 51 | abi: erc20Abi, 52 | address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 53 | functionName: "transfer", 54 | args: ["0xA0Cf798816D4b9b9866b5330EEa46a18382f251e", 100_000000n], 55 | }); 56 | 57 | // 0 ↳ FROM 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 58 | // 0 ↳ CALL (0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48).transfer(0xf39F...0xf3, 100000000) -> ERC20: transfer amount exceeds balance 59 | // 1 ↳ DELEGATECALL (0x43506849D7C04F9138D1A2050bbF3A0c054402dd).transfer(0xf39F...0xf3, 100000000) -> ERC20: transfer amount exceeds balance 60 | 61 | client.transport.tracer.all = true; // If you want to trace all submitted transactions, failing or not. 62 | client.transport.tracer.next = true; // If you want to trace the next submitted transaction. 63 | client.transport.tracer.next = false; // If you DON'T want to trace the next submitted transaction. 64 | client.transport.tracer.failed = false; // If you don't want to append traces to failed transactions. 65 | 66 | ``` 67 | 68 | > [!NOTE] 69 | > You can disable colors via the `colors` package: 70 | > ```typescript 71 | > import { disable } from "colors"; 72 | > 73 | > disable(); 74 | > ``` 75 | 76 | 77 | [build-img]: https://github.com/rubilmax/viem-tracer/actions/workflows/release.yml/badge.svg 78 | [build-url]: https://github.com/rubilmax/viem-tracer/actions/workflows/release.yml 79 | [downloads-img]: https://img.shields.io/npm/dt/viem-tracer 80 | [downloads-url]: https://www.npmtrends.com/viem-tracer 81 | [npm-img]: https://img.shields.io/npm/v/viem-tracer 82 | [npm-url]: https://www.npmjs.com/package/viem-tracer 83 | [issues-img]: https://img.shields.io/github/issues/rubilmax/viem-tracer 84 | [issues-url]: https://github.com/rubilmax/viem-tracer/issues 85 | [codecov-img]: https://codecov.io/gh/rubilmax/viem-tracer/branch/main/graph/badge.svg 86 | [codecov-url]: https://codecov.io/gh/rubilmax/viem-tracer 87 | [semantic-release-img]: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg 88 | [semantic-release-url]: https://github.com/semantic-release/semantic-release 89 | [commitizen-img]: https://img.shields.io/badge/commitizen-friendly-brightgreen.svg 90 | [commitizen-url]: http://commitizen.github.io/cz-cli/ 91 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseError, 3 | type Hash, 4 | type RpcTransactionReceipt, 5 | type RpcTransactionRequest, 6 | type TransactionExecutionError, 7 | type Transport, 8 | WaitForTransactionReceiptTimeoutError, 9 | } from "viem"; 10 | import type { TraceCallRpcSchema } from "./actions/traceCall"; 11 | import { type TraceFormatConfig, formatFullTrace } from "./format"; 12 | 13 | export type TracerConfig = TraceFormatConfig & { 14 | /** 15 | * Whether to trace all transactions. Defaults to `false`. 16 | */ 17 | all: boolean; 18 | /** 19 | * Whether to trace the next submitted transaction. Defaults to `undefined`. 20 | */ 21 | next?: boolean; 22 | /** 23 | * Whether to trace all failed transactions. Defaults to `true`. 24 | */ 25 | failed: boolean; 26 | }; 27 | 28 | export type TracedTransport = 29 | transport extends Transport< 30 | infer type, 31 | infer rpcAttributes, 32 | infer eip1193RequestFn 33 | > 34 | ? Transport< 35 | type, 36 | rpcAttributes & { 37 | tracer: TracerConfig; 38 | }, 39 | eip1193RequestFn 40 | > 41 | : never; 42 | 43 | export class ExecutionRevertedTraceError extends BaseError { 44 | static code = 3; 45 | static nodeMessage = /execution reverted/; 46 | 47 | constructor( 48 | trace: string, 49 | message = "execution reverted for an unknown reason." 50 | ) { 51 | super(message, { 52 | name: "ExecutionRevertedError", 53 | metaMessages: [trace], 54 | }); 55 | } 56 | } 57 | 58 | /** 59 | * @description Overloads a transport intended to be used with a test client, to trace and debug transactions. 60 | */ 61 | export function traced( 62 | transport: transport, 63 | { all = false, next, failed = true, gas, raw }: Partial = {} 64 | ): TracedTransport { 65 | // @ts-ignore: complex overload 66 | return (...config) => { 67 | const instance = transport(...config) as ReturnType< 68 | TracedTransport 69 | >; 70 | 71 | instance.value = { 72 | ...instance.value, 73 | tracer: { all, next, failed, gas, raw }, 74 | }; 75 | 76 | return { 77 | ...instance, 78 | async request(args, options) { 79 | const { method, params } = args; 80 | if ( 81 | method !== "eth_estimateGas" && 82 | method !== "eth_sendTransaction" && 83 | method !== "wallet_sendTransaction" 84 | ) 85 | return instance.request(args, options); 86 | 87 | const { tracer } = instance.value!; 88 | 89 | // @ts-expect-error: params[0] is the rpc transaction request 90 | const tx = params[0] as RpcTransactionRequest; 91 | 92 | const traceCall = async (message?: string) => { 93 | const trace = await instance.request( 94 | { 95 | method: "debug_traceCall", 96 | params: [ 97 | tx, 98 | // @ts-expect-error: params[1] is either undefined or the block identifier 99 | params[1] || "latest", 100 | { 101 | // @ts-expect-error: params[2] may contain state and block overrides 102 | ...params[2], 103 | tracer: "callTracer", 104 | tracerConfig: { 105 | onlyTopCall: false, 106 | withLog: true, 107 | }, 108 | }, 109 | ], 110 | }, 111 | { retryCount: 0 } 112 | ); 113 | 114 | return new ExecutionRevertedTraceError( 115 | await formatFullTrace(trace, tracer), 116 | message || trace.revertReason || trace.error 117 | ); 118 | }; 119 | 120 | if (tracer.next || (tracer.next == null && tracer.all)) { 121 | try { 122 | console.log((await traceCall()).metaMessages![0]); 123 | } catch (error) { 124 | console.warn("Failed to trace transaction:"); 125 | console.trace(error); 126 | } 127 | } 128 | 129 | const res = await instance 130 | .request(args, options) 131 | .catch(async (error) => { 132 | if (tracer.next || (tracer.next == null && tracer.failed)) { 133 | const trace = await traceCall( 134 | (error as TransactionExecutionError).details 135 | ); 136 | 137 | trace.stack = error.stack; 138 | 139 | throw trace; 140 | } 141 | 142 | throw error; 143 | }) 144 | .finally(() => { 145 | tracer.next = undefined; 146 | }); 147 | 148 | if (method !== "eth_estimateGas") { 149 | let receipt: RpcTransactionReceipt | null = null; 150 | 151 | try { 152 | for (let i = 0; i < 720; i++) { 153 | receipt = await instance.request({ 154 | method: "eth_getTransactionReceipt", 155 | params: [res], 156 | }); 157 | 158 | if (receipt) break; 159 | 160 | await new Promise((resolve) => setTimeout(resolve, 250)); 161 | } 162 | 163 | if (!receipt) 164 | throw new WaitForTransactionReceiptTimeoutError({ 165 | hash: res as Hash, 166 | }); 167 | if (receipt.status === "0x0") throw await traceCall(); 168 | } catch (error) { 169 | if (error instanceof ExecutionRevertedTraceError) throw error; 170 | 171 | console.warn("Failed to trace transaction:"); 172 | console.trace(error); 173 | } 174 | } 175 | 176 | return res; 177 | }, 178 | }; 179 | }; 180 | } 181 | -------------------------------------------------------------------------------- /src/actions/traceCall.ts: -------------------------------------------------------------------------------- 1 | import type { AssertRequestParameters } from "node_modules/viem/_types/utils/transaction/assertRequest"; 2 | import { 3 | type Account, 4 | type Address, 5 | BaseError, 6 | type BlockTag, 7 | type Chain, 8 | type Client, 9 | type ExactPartial, 10 | type FormattedTransactionRequest, 11 | type Hex, 12 | type RpcTransactionRequest, 13 | type TransactionRequest, 14 | type Transport, 15 | type UnionOmit, 16 | assertRequest, 17 | formatTransactionRequest, 18 | numberToHex, 19 | } from "viem"; 20 | import { parseAccount } from "viem/accounts"; 21 | import { prepareTransactionRequest } from "viem/actions"; 22 | import { recoverAuthorizationAddress } from "viem/experimental"; 23 | import { extract, getTransactionError } from "viem/utils"; 24 | 25 | export type TraceCallRpcSchema = { 26 | Method: "debug_traceCall"; 27 | Parameters: 28 | | [ExactPartial, Hex | BlockTag] 29 | | [ 30 | ExactPartial, 31 | BlockTag | Hex, 32 | { 33 | tracer: "callTracer" | "prestateTracer"; 34 | tracerConfig?: { onlyTopCall?: boolean; withLog?: boolean }; 35 | }, 36 | ]; 37 | ReturnType: RpcCallTrace; 38 | }; 39 | 40 | export type RpcCallType = "CALL" | "STATICCALL" | "DELEGATECALL" | "CREATE" | "CREATE2" | "SELFDESTRUCT" | "CALLCODE"; 41 | 42 | export type RpcLogTrace = { 43 | address: Address; 44 | data: Hex; 45 | position: Hex; 46 | topics: [Hex, ...Hex[]]; 47 | }; 48 | 49 | export type RpcCallTrace = { 50 | from: Address; 51 | gas: Hex; 52 | gasUsed: Hex; 53 | to: Address; 54 | input: Hex; 55 | output: Hex; 56 | error?: string; 57 | revertReason?: string; 58 | calls?: RpcCallTrace[]; 59 | logs?: RpcLogTrace[]; 60 | value?: Hex; 61 | type: RpcCallType; 62 | }; 63 | 64 | export type TraceCallParameters = UnionOmit< 65 | FormattedTransactionRequest, 66 | "from" 67 | > & { 68 | account?: Account | Address | undefined; 69 | tracer?: "callTracer" | "prestateTracer"; 70 | tracerConfig?: { onlyTopCall?: boolean }; 71 | } & ( 72 | | { 73 | blockNumber?: bigint | undefined; 74 | blockTag?: undefined; 75 | } 76 | | { 77 | blockNumber?: undefined; 78 | blockTag?: BlockTag | undefined; 79 | } 80 | ); 81 | 82 | /** 83 | * Traces a call. 84 | * 85 | * - JSON-RPC Methods: [`debug_traceCall`](https://www.quicknode.com/docs/ethereum/debug_traceCall) 86 | * 87 | * @param client - Client to use 88 | * @param parameters - {@link TraceCallParameters} 89 | * @returns The call trace. {@link RpcCallTrace} 90 | * 91 | * @example 92 | * import { createPublicClient, http, parseEther } from 'viem' 93 | * import { mainnet } from 'viem/chains' 94 | * import { traceCall } from 'viem-tracer' 95 | * 96 | * const client = createPublicClient({ 97 | * chain: mainnet, 98 | * transport: http(), 99 | * }) 100 | * const gasEstimate = await traceCall(client, { 101 | * account: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', 102 | * to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', 103 | * value: parseEther('1'), 104 | * }) 105 | */ 106 | export async function traceCall( 107 | client: Client, 108 | { tracer = "callTracer", tracerConfig, ...args }: TraceCallParameters, 109 | ) { 110 | const account_ = args.account ?? client.account; 111 | const account = account_ ? parseAccount(account_) : null; 112 | 113 | try { 114 | const { 115 | accessList, 116 | authorizationList, 117 | blobs, 118 | blobVersionedHashes, 119 | blockNumber, 120 | blockTag = "latest", 121 | data, 122 | gas, 123 | gasPrice, 124 | maxFeePerBlobGas, 125 | maxFeePerGas, 126 | maxPriorityFeePerGas, 127 | nonce, 128 | value, 129 | ...tx 130 | } = (await prepareTransactionRequest(client, { 131 | // biome-ignore lint/suspicious/noExplicitAny: not inferred correctly 132 | ...(args as any), 133 | parameters: ["blobVersionedHashes", "chainId", "fees", "nonce", "type"], 134 | })) as TraceCallParameters; 135 | 136 | const blockNumberHex = blockNumber ? numberToHex(blockNumber) : undefined; 137 | const block = blockNumberHex || blockTag; 138 | 139 | const to = await (async () => { 140 | // If `to` exists on the parameters, use that. 141 | if (tx.to) return tx.to; 142 | 143 | // If no `to` exists, and we are sending a EIP-7702 transaction, use the 144 | // address of the first authorization in the list. 145 | if (authorizationList && authorizationList.length > 0) 146 | return await recoverAuthorizationAddress({ 147 | authorization: authorizationList[0]!, 148 | }).catch(() => { 149 | throw new BaseError("`to` is required. Could not infer from `authorizationList`"); 150 | }); 151 | 152 | // Otherwise, we are sending a deployment transaction. 153 | return undefined; 154 | })(); 155 | 156 | assertRequest(args as AssertRequestParameters); 157 | 158 | const chainFormat = client.chain?.formatters?.transactionRequest?.format; 159 | const format = chainFormat || formatTransactionRequest; 160 | 161 | const request = format({ 162 | // Pick out extra data that might exist on the chain's transaction request type. 163 | ...extract(tx, { format: chainFormat }), 164 | from: account?.address, 165 | accessList, 166 | authorizationList, 167 | blobs, 168 | blobVersionedHashes, 169 | data, 170 | gas, 171 | gasPrice, 172 | maxFeePerBlobGas, 173 | maxFeePerGas, 174 | maxPriorityFeePerGas, 175 | nonce, 176 | to, 177 | value, 178 | } as TransactionRequest); 179 | 180 | const trace = await client.request( 181 | { 182 | method: "debug_traceCall", 183 | params: [request, block, { tracer, tracerConfig }], 184 | }, 185 | { retryCount: 0 }, 186 | ); 187 | 188 | return trace; 189 | } catch (err) { 190 | throw getTransactionError(err as BaseError, { 191 | ...args, 192 | account, 193 | chain: client.chain, 194 | }); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /test/anvil.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "node:child_process"; 2 | import _kebabCase from "lodash.kebabcase"; 3 | 4 | export interface AnvilArgs { 5 | /** 6 | * Number of dev accounts to generate and configure. 7 | * 8 | * @defaultValue 10 9 | */ 10 | accounts?: number | undefined; 11 | /** 12 | * Set the Access-Control-Allow-Origin response header (CORS). 13 | * 14 | * @defaultValue * 15 | */ 16 | allowOrigin?: string | undefined; 17 | /** 18 | * Enable autoImpersonate on startup 19 | */ 20 | autoImpersonate?: boolean | undefined; 21 | /** 22 | * The balance of every dev account in Ether. 23 | * 24 | * @defaultValue 10000 25 | */ 26 | balance?: number | bigint | undefined; 27 | /** 28 | * The base fee in a block. 29 | */ 30 | blockBaseFeePerGas?: number | bigint | undefined; 31 | /** 32 | * Block time in seconds for interval mining. 33 | */ 34 | blockTime?: number | undefined; 35 | /** 36 | * Path or alias to the Anvil binary. 37 | */ 38 | binary?: string | undefined; 39 | /** 40 | * The chain id. 41 | */ 42 | chainId?: number | undefined; 43 | /** 44 | * EIP-170: Contract code size limit in bytes. Useful to increase this because of tests. 45 | * 46 | * @defaultValue 0x6000 (~25kb) 47 | */ 48 | codeSizeLimit?: number | undefined; 49 | /** 50 | * Sets the number of assumed available compute units per second for this fork provider. 51 | * 52 | * @defaultValue 350 53 | * @see https://github.com/alchemyplatform/alchemy-docs/blob/master/documentation/compute-units.md#rate-limits-cups 54 | */ 55 | computeUnitsPerSecond?: number | undefined; 56 | /** 57 | * Writes output of `anvil` as json to user-specified file. 58 | */ 59 | configOut?: string | undefined; 60 | /** 61 | * Sets the derivation path of the child key to be derived. 62 | * 63 | * @defaultValue m/44'/60'/0'/0/ 64 | */ 65 | derivationPath?: string | undefined; 66 | /** 67 | * Disable the `call.gas_limit <= block.gas_limit` constraint. 68 | */ 69 | disableBlockGasLimit?: boolean | undefined; 70 | /** 71 | * Dump the state of chain on exit to the given file. If the value is a directory, the state will be 72 | * written to `/state.json`. 73 | */ 74 | dumpState?: string | undefined; 75 | /** 76 | * Fetch state over a remote endpoint instead of starting from an empty state. 77 | * 78 | * If you want to fetch state from a specific block number, add a block number like `http://localhost:8545@1400000` 79 | * or use the `forkBlockNumber` option. 80 | */ 81 | forkUrl?: string | undefined; 82 | /** 83 | * Fetch state from a specific block number over a remote endpoint. 84 | * 85 | * Requires `forkUrl` to be set. 86 | */ 87 | forkBlockNumber?: number | bigint | undefined; 88 | /** 89 | * Specify chain id to skip fetching it from remote endpoint. This enables offline-start mode. 90 | * 91 | * You still must pass both `forkUrl` and `forkBlockNumber`, and already have your required state cached 92 | * on disk, anything missing locally would be fetched from the remote. 93 | */ 94 | forkChainId?: number | undefined; 95 | /** 96 | * Specify headers to send along with any request to the remote JSON-RPC server in forking mode. 97 | * 98 | * e.g. "User-Agent: test-agent" 99 | * 100 | * Requires `forkUrl` to be set. 101 | */ 102 | forkHeader?: Record | undefined; 103 | /** 104 | * Initial retry backoff on encountering errors. 105 | */ 106 | forkRetryBackoff?: number | undefined; 107 | /** 108 | * The block gas limit. 109 | */ 110 | gasLimit?: number | bigint | undefined; 111 | /** 112 | * The gas price. 113 | */ 114 | gasPrice?: number | bigint | undefined; 115 | /** 116 | * Disable minimum priority fee to set the gas price to zero. 117 | */ 118 | disableMinPriorityFee?: boolean | undefined; 119 | /** 120 | * The EVM hardfork to use. 121 | */ 122 | hardfork?: 123 | | "Frontier" 124 | | "Homestead" 125 | | "Dao" 126 | | "Tangerine" 127 | | "SpuriousDragon" 128 | | "Byzantium" 129 | | "Constantinople" 130 | | "Petersburg" 131 | | "Istanbul" 132 | | "Muirglacier" 133 | | "Berlin" 134 | | "London" 135 | | "ArrowGlacier" 136 | | "GrayGlacier" 137 | | "Paris" 138 | | "Shanghai" 139 | | "Cancun" 140 | | "Prague" 141 | | "Latest" 142 | | undefined; 143 | /** 144 | * The host the server will listen on. 145 | */ 146 | host?: string | undefined; 147 | /** 148 | * Initialize the genesis block with the given `genesis.json` file. 149 | */ 150 | init?: string | undefined; 151 | /** 152 | * Launch an ipc server at the given path or default path = `/tmp/anvil.ipc`. 153 | */ 154 | ipc?: string | undefined; 155 | /** 156 | * Initialize the chain from a previously saved state snapshot. 157 | */ 158 | loadState?: string | undefined; 159 | /** 160 | * BIP39 mnemonic phrase used for generating accounts. 161 | */ 162 | mnemonic?: string | undefined; 163 | /** 164 | * Automatically generates a BIP39 mnemonic phrase, and derives accounts from it. 165 | */ 166 | mnemonicRandom?: boolean | undefined; 167 | /** 168 | * Disable CORS. 169 | */ 170 | noCors?: boolean | undefined; 171 | /** 172 | * Disable auto and interval mining, and mine on demand instead. 173 | */ 174 | noMining?: boolean | undefined; 175 | /** 176 | * Disables rate limiting for this node's provider. 177 | * 178 | * @defaultValue false 179 | * @see https://github.com/alchemyplatform/alchemy-docs/blob/master/documentation/compute-units.md#rate-limits-cups 180 | */ 181 | noRateLimit?: boolean | undefined; 182 | /** 183 | * Explicitly disables the use of RPC caching. 184 | * 185 | * All storage slots are read entirely from the endpoint. 186 | */ 187 | noStorageCaching?: boolean | undefined; 188 | /** 189 | * How transactions are sorted in the mempool. 190 | * 191 | * @defaultValue fees 192 | */ 193 | order?: string | undefined; 194 | /** 195 | * Run an Optimism chain. 196 | */ 197 | optimism?: boolean | undefined; 198 | /** 199 | * Port number to listen on. 200 | * 201 | * @defaultValue 8545 202 | */ 203 | port?: number | undefined; 204 | /** 205 | * Don't keep full chain history. If a number argument is specified, at most this number of states is kept in memory. 206 | */ 207 | pruneHistory?: number | undefined | boolean; 208 | /** 209 | * Number of retry requests for spurious networks (timed out requests). 210 | * 211 | * @defaultValue 5 212 | */ 213 | retries?: number | undefined; 214 | /** 215 | * Don't print anything on startup and don't print logs. 216 | */ 217 | silent?: boolean | undefined; 218 | /** 219 | * Slots in an epoch. 220 | */ 221 | slotsInAnEpoch?: number | undefined; 222 | /** 223 | * Enable steps tracing used for debug calls returning geth-style traces. 224 | */ 225 | stepsTracing?: boolean | undefined; 226 | /** 227 | * Interval in seconds at which the status is to be dumped to disk. 228 | */ 229 | stateInterval?: number | undefined; 230 | /** 231 | * This is an alias for both `loadState` and `dumpState`. It initializes the chain with the state stored at the 232 | * file, if it exists, and dumps the chain's state on exit 233 | */ 234 | state?: string | undefined; 235 | /** 236 | * Timeout in ms for requests sent to remote JSON-RPC server in forking mode. 237 | * 238 | * @defaultValue 45000 239 | */ 240 | timeout?: number | undefined; 241 | /** 242 | * The timestamp of the genesis block. 243 | */ 244 | timestamp?: number | bigint | undefined; 245 | /** 246 | * Number of blocks with transactions to keep in memory. 247 | */ 248 | transactionBlockKeeper?: number | undefined; 249 | } 250 | 251 | /** 252 | * Converts an object of options to an array of command line arguments. 253 | * 254 | * @param options The options object. 255 | * @returns The command line arguments. 256 | */ 257 | function toArgs(obj: AnvilArgs) { 258 | return Object.entries(obj).flatMap(([key, value]) => { 259 | if (value === undefined) return []; 260 | 261 | if (Array.isArray(value)) return [`--${_kebabCase(key)}`, value.join(",")]; 262 | 263 | if (typeof value === "object" && value !== null) { 264 | return Object.entries(value).flatMap(([subKey, subValue]) => { 265 | if (subValue === undefined) return []; 266 | 267 | const flag = `--${_kebabCase(`${key}.${subKey}`)}`; 268 | return [flag, Array.isArray(subValue) ? subValue.join(",") : subValue]; 269 | }); 270 | } 271 | 272 | const flag = `--${_kebabCase(key)}`; 273 | 274 | if (value === false) return [flag, "false"]; 275 | if (value === true) return [flag]; 276 | 277 | const stringified = value.toString(); 278 | if (stringified === "") return [flag]; 279 | 280 | return [flag, stringified]; 281 | }); 282 | } 283 | 284 | export const MAX_TEST_PER_THREAD = 512; 285 | 286 | let port = 10000 + (process.__tinypool_state__.workerId - 1) * MAX_TEST_PER_THREAD; 287 | 288 | export const spawnAnvil = async ( 289 | args: AnvilArgs, 290 | ): Promise<{ 291 | rpcUrl: `http://localhost:${number}`; 292 | stop: () => boolean; 293 | }> => { 294 | const instancePort = port++; 295 | 296 | let started = false; 297 | 298 | const stop = await new Promise<() => boolean>((resolve, reject) => { 299 | const subprocess = spawn("anvil", toArgs({ ...args, port: instancePort })); 300 | 301 | subprocess.stdout.on("data", (data) => { 302 | const message = `[port ${instancePort}] ${data.toString()}`; 303 | 304 | // console.debug(message); 305 | 306 | if (message.includes("Listening on")) { 307 | started = true; 308 | resolve(() => subprocess.kill("SIGINT")); 309 | } 310 | }); 311 | 312 | subprocess.stderr.on("data", (data) => { 313 | const message = `[port ${instancePort}] ${data.toString()}`; 314 | 315 | if (!started) reject(message); 316 | else console.warn(message); 317 | }); 318 | }); 319 | 320 | return { 321 | rpcUrl: `http://localhost:${instancePort}`, 322 | stop, 323 | }; 324 | }; 325 | -------------------------------------------------------------------------------- /src/format.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; 2 | import { homedir } from "node:os"; 3 | import { dirname, join } from "node:path"; 4 | import colors from "colors/safe"; 5 | import { 6 | type Abi, 7 | type AbiFunction, 8 | type Address, 9 | type Hex, 10 | concatHex, 11 | decodeAbiParameters, 12 | decodeEventLog, 13 | decodeFunctionData, 14 | erc20Abi, 15 | erc721Abi, 16 | erc1155Abi, 17 | erc4626Abi, 18 | formatEther, 19 | isAddress, 20 | isHex, 21 | multicall3Abi, 22 | parseAbi, 23 | size, 24 | slice, 25 | zeroHash, 26 | } from "viem"; 27 | import type { RpcCallTrace, RpcLogTrace } from "./actions/traceCall"; 28 | 29 | // The requested module 'colors/safe.js' is a CommonJS module, which may not support all module.exports as named exports. 30 | // CommonJS modules can always be imported via the default export, for example using: 31 | const { bold, cyan, grey, red, white, yellow, green, dim, magenta } = colors; 32 | 33 | export interface TraceFormatConfig { 34 | /** 35 | * Whether to trace gas with each call. Defaults to `false`. 36 | */ 37 | gas?: boolean; 38 | /** 39 | * Whether to trace raw step with each call. Defaults to `false`. 40 | */ 41 | raw?: boolean; 42 | /** 43 | * Whether to show full arguments for each call. Defaults to `false`. 44 | */ 45 | fullArgs?: boolean; 46 | } 47 | 48 | export interface SignaturesCache { 49 | events: Record; 50 | functions: Record; 51 | } 52 | 53 | export const getSignaturesCachePath = () => 54 | join(homedir(), ".foundry", "cache", "signatures"); 55 | 56 | export const loadSignaturesCache = (): SignaturesCache => { 57 | try { 58 | return JSON.parse( 59 | readFileSync(getSignaturesCachePath(), { encoding: "utf8" }) 60 | ); 61 | } catch {} 62 | 63 | return { events: {}, functions: {} }; 64 | }; 65 | 66 | export const getSelector = (input: Hex) => slice(input, 0, 4); 67 | 68 | export const getCallTraceUnknownFunctionSelectors = ( 69 | trace: RpcCallTrace, 70 | signatures: SignaturesCache 71 | ): string => { 72 | const rest = (trace.calls ?? []) 73 | .flatMap((subtrace) => 74 | getCallTraceUnknownFunctionSelectors(subtrace, signatures) 75 | ) 76 | .filter(Boolean); 77 | 78 | if (trace.input) { 79 | const inputSelector = getSelector(trace.input); 80 | 81 | if (!signatures.functions[inputSelector]) rest.push(inputSelector); 82 | } 83 | 84 | return rest.join(","); 85 | }; 86 | 87 | export const getCallTraceUnknownEventSelectors = ( 88 | trace: RpcCallTrace, 89 | signatures: SignaturesCache 90 | ): string => { 91 | const rest = (trace.calls ?? []) 92 | .flatMap((subtrace) => 93 | getCallTraceUnknownEventSelectors(subtrace, signatures) 94 | ) 95 | .filter(Boolean); 96 | 97 | if (trace.logs) { 98 | for (const log of trace.logs) { 99 | const selector = log.topics[0]!; 100 | 101 | if (!signatures.events[selector]) rest.push(selector); 102 | } 103 | } 104 | 105 | return rest.join(","); 106 | }; 107 | 108 | export const getIndentLevel = (level: number, index = false) => 109 | `${" ".repeat(level - 1)}${index ? cyan(`${level - 1} ↳ `) : " "}`; 110 | 111 | export const formatAddress = (address: Address) => 112 | `${slice(address, 0, 4)}…${slice(address, -2).slice(2)}`; 113 | export const formatHex = (hex: Hex) => { 114 | if (hex === zeroHash) return "bytes(0)"; 115 | 116 | return size(hex) > 8 ? `${slice(hex, 0, 4)}…${slice(hex, -1).slice(2)}` : hex; 117 | }; 118 | export const formatInt = (value: bigint | number) => { 119 | for (let i = 32n; i <= 256n; i++) 120 | if (BigInt(value) === 2n ** i - 1n) return `2 ** ${i} - 1`; 121 | 122 | return String(value); 123 | }; 124 | 125 | export const formatArg = ( 126 | arg: unknown, 127 | level: number, 128 | config: Partial 129 | ): string => { 130 | if (Array.isArray(arg)) { 131 | const { length } = arg; 132 | const wrapLines = length > 5 || arg.some((a) => Array.isArray(a)); 133 | 134 | const formattedArr = arg 135 | .map( 136 | (arg, i) => 137 | `${wrapLines ? `\n${getIndentLevel(level + 1)}` : ""}${grey( 138 | formatArg(arg, level + 1, config) 139 | )}${i !== length - 1 || wrapLines ? "," : ""}` 140 | ) 141 | .join(wrapLines ? "" : " "); 142 | 143 | if (!wrapLines) return `[${formattedArr}]`; 144 | 145 | return `[${formattedArr ? `${formattedArr}\n` : ""}${getIndentLevel( 146 | level 147 | )}]`; 148 | } 149 | 150 | switch (typeof arg) { 151 | case "object": { 152 | if (arg == null) return ""; 153 | 154 | const formattedObj = Object.entries(arg) 155 | .map( 156 | ([key, value]) => 157 | `\n${getIndentLevel(level + 1)}${key}: ${grey( 158 | formatArg(value, level + 1, config) 159 | )},` 160 | ) 161 | .join(""); 162 | 163 | return `{${formattedObj ? `${formattedObj}\n` : ""}${getIndentLevel( 164 | level 165 | )}}`; 166 | } 167 | case "string": 168 | if (config.fullArgs) return grey(arg); 169 | 170 | return grey( 171 | isAddress(arg, { strict: false }) 172 | ? formatAddress(arg) 173 | : isHex(arg) 174 | ? formatHex(arg) 175 | : arg 176 | ); 177 | case "bigint": 178 | case "number": 179 | if (config.fullArgs) return grey(String(arg)); 180 | 181 | return grey(formatInt(arg)); 182 | default: 183 | return grey(String(arg)); 184 | } 185 | }; 186 | 187 | export const formatCallSignature = ( 188 | trace: RpcCallTrace, 189 | config: Partial, 190 | level: number, 191 | signatures: SignaturesCache 192 | ) => { 193 | const selector = getSelector(trace.input); 194 | 195 | const signature = 196 | signatures.functions[selector] ?? 197 | (trace.input === "0x" ? "receive()" : undefined); 198 | if (!signature) return trace.input; 199 | 200 | const functionName = signature.split("(")[0]!; 201 | let args: readonly unknown[] | undefined; 202 | 203 | try { 204 | ({ args } = decodeFunctionData({ 205 | abi: parseAbi( 206 | // @ts-ignore 207 | [`function ${signature}`] 208 | ), 209 | data: trace.input, 210 | })); 211 | } catch { 212 | // Decoding function data can fail for many reasons, including invalid ABI. 213 | } 214 | 215 | const value = BigInt(trace.value ?? "0x0"); 216 | const formattedArgs = args 217 | ?.map((arg) => formatArg(arg, level, config)) 218 | .join(", "); 219 | 220 | const error = trace.revertReason || trace.error; 221 | let returnValue = error || trace.output; 222 | 223 | try { 224 | if (error == null) { 225 | const functionAbi = (erc20Abi as Abi) 226 | .concat(erc721Abi) 227 | .concat(erc1155Abi) 228 | .concat(erc4626Abi) 229 | .concat(multicall3Abi) 230 | .find( 231 | (abi): abi is AbiFunction => 232 | abi.type === "function" && abi.name === functionName 233 | ); 234 | 235 | if (functionAbi != null) { 236 | const decodedOutputs = decodeAbiParameters( 237 | functionAbi.outputs, 238 | trace.output 239 | ); 240 | 241 | returnValue = decodedOutputs 242 | .map((arg) => formatArg(arg, level, config)) 243 | .join(", "); 244 | } 245 | } 246 | } catch {} 247 | 248 | return `${bold( 249 | (trace.revertReason || trace.error ? red : green)(functionName) 250 | )}${value !== 0n ? grey(`{ ${white(formatEther(value))} ETH }`) : ""}${ 251 | config.gas 252 | ? grey( 253 | `[ ${dim(magenta(Number(trace.gasUsed).toLocaleString()))} / ${dim( 254 | magenta(Number(trace.gas).toLocaleString()) 255 | )} ]` 256 | ) 257 | : "" 258 | }(${formattedArgs ?? ""})${ 259 | returnValue ? (error ? red : grey)(` -> ${returnValue}`) : "" 260 | }`; 261 | }; 262 | 263 | export const formatCallLog = ( 264 | log: RpcLogTrace, 265 | level: number, 266 | signatures: SignaturesCache, 267 | config: Partial 268 | ) => { 269 | const selector = log.topics[0]!; 270 | 271 | const signature = signatures.events[selector]; 272 | if (!signature) return concatHex(log.topics); 273 | 274 | const { eventName, args } = decodeEventLog({ 275 | abi: parseAbi( 276 | // @ts-ignore 277 | [`event ${signature}`] 278 | ), 279 | data: concatHex(log.topics.slice(1).concat(log.data)), 280 | topics: log.topics, 281 | strict: false, 282 | }); 283 | 284 | const formattedArgs = args 285 | ?.map((arg) => formatArg(arg, level, config)) 286 | .join(", "); 287 | 288 | return `${getIndentLevel(level + 1, true)}${yellow("LOG")} ${eventName}(${ 289 | formattedArgs ?? "" 290 | })`; 291 | }; 292 | 293 | export const formatCallTrace = ( 294 | trace: RpcCallTrace, 295 | config: Partial = {}, 296 | signatures: SignaturesCache = loadSignaturesCache(), 297 | level = 1 298 | ): string => { 299 | const rest = (trace.calls ?? []) 300 | .map((subtrace) => formatCallTrace(subtrace, config, signatures, level + 1)) 301 | .join("\n"); 302 | 303 | const indentLevel = getIndentLevel(level, true); 304 | 305 | return `${ 306 | level === 1 ? `${indentLevel}${cyan("FROM")} ${grey(trace.from)}\n` : "" 307 | }${indentLevel}${yellow(trace.type)} ${ 308 | trace.from === trace.to ? grey("self") : `(${white(trace.to)})` 309 | }.${formatCallSignature(trace, config, level, signatures)}${ 310 | trace.logs 311 | ? `\n${trace.logs.map((log) => 312 | formatCallLog(log, level, signatures, config) 313 | )}` 314 | : "" 315 | } 316 | ${config.raw ? `${grey(JSON.stringify(trace))}\n` : ""}${rest}`; 317 | }; 318 | 319 | export async function formatFullTrace( 320 | trace: RpcCallTrace, 321 | config?: Partial, 322 | signatures: SignaturesCache = loadSignaturesCache() 323 | ) { 324 | const unknownFunctionSelectors = getCallTraceUnknownFunctionSelectors( 325 | trace, 326 | signatures 327 | ); 328 | const unknownEventSelectors = getCallTraceUnknownEventSelectors( 329 | trace, 330 | signatures 331 | ); 332 | 333 | if (unknownFunctionSelectors || unknownEventSelectors) { 334 | const searchParams = new URLSearchParams({ filter: "false" }); 335 | if (unknownFunctionSelectors) 336 | searchParams.append("function", unknownFunctionSelectors); 337 | if (unknownEventSelectors) 338 | searchParams.append("event", unknownEventSelectors); 339 | 340 | const lookupRes = await fetch( 341 | `https://api.openchain.xyz/signature-database/v1/lookup?${searchParams.toString()}` 342 | ); 343 | const lookup = await lookupRes.json(); 344 | 345 | if (lookup.ok) { 346 | Object.entries<{ name: string; filtered: boolean }[] | null>( 347 | lookup.result.function 348 | ).map(([sig, results]) => { 349 | const match = results?.find(({ filtered }) => !filtered)?.name; 350 | if (!match) return; 351 | 352 | signatures.functions[sig as Hex] = match; 353 | }); 354 | Object.entries<{ name: string; filtered: boolean }[] | null>( 355 | lookup.result.event 356 | ).map(([sig, results]) => { 357 | const match = results?.find(({ filtered }) => !filtered)?.name; 358 | if (!match) return; 359 | 360 | signatures.events[sig as Hex] = match; 361 | }); 362 | 363 | const path = getSignaturesCachePath(); 364 | if (!existsSync(path)) mkdirSync(dirname(path), { recursive: true }); 365 | 366 | writeFileSync(path, JSON.stringify(signatures)); 367 | } else { 368 | console.warn( 369 | `Failed to fetch signatures for unknown selectors: ${unknownFunctionSelectors},${unknownEventSelectors}`, 370 | lookup.error, 371 | "\n" 372 | ); 373 | } 374 | } 375 | 376 | return white(formatCallTrace(trace, config, signatures)); 377 | } 378 | -------------------------------------------------------------------------------- /test/traceCall.test.ts: -------------------------------------------------------------------------------- 1 | import { encodeFunctionData, erc20Abi, parseUnits, zeroAddress } from "viem"; 2 | import { describe, vi } from "vitest"; 3 | import { test } from "./setup"; 4 | 5 | const usdc = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; 6 | 7 | describe("traceCall", () => { 8 | test("should trace call", async ({ expect, client }) => { 9 | const traces = await client.traceCall({ 10 | to: usdc, 11 | data: encodeFunctionData({ 12 | abi: erc20Abi, 13 | functionName: "transfer", 14 | args: [client.account.address, parseUnits("100", 6)], 15 | }), 16 | }); 17 | 18 | expect(traces).toMatchInlineSnapshot(` 19 | { 20 | "calls": [ 21 | { 22 | "error": "execution reverted", 23 | "from": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", 24 | "gas": "0x1c22d8c", 25 | "gasUsed": "0x14f1", 26 | "input": "0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100", 27 | "output": "0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002645524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e63650000000000000000000000000000000000000000000000000000", 28 | "revertReason": "ERC20: transfer amount exceeds balance", 29 | "to": "0x43506849d7c04f9138d1a2050bbf3a0c054402dd", 30 | "type": "DELEGATECALL", 31 | "value": "0x0", 32 | }, 33 | ], 34 | "error": "execution reverted", 35 | "from": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", 36 | "gas": "0x1c96f24", 37 | "gasUsed": "0x85d9", 38 | "input": "0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100", 39 | "output": "0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002645524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e63650000000000000000000000000000000000000000000000000000", 40 | "revertReason": "ERC20: transfer amount exceeds balance", 41 | "to": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", 42 | "type": "CALL", 43 | "value": "0x0", 44 | } 45 | `); 46 | }); 47 | 48 | test("should trace failing eth_estimateGas by default", async ({ 49 | expect, 50 | client, 51 | }) => { 52 | await expect( 53 | client.writeContract({ 54 | address: usdc, 55 | abi: erc20Abi, 56 | functionName: "transfer", 57 | args: [client.account.address, parseUnits("100", 6)], 58 | }) 59 | ).rejects.toMatchInlineSnapshot(` 60 | [ContractFunctionExecutionError: execution reverted: ERC20: transfer amount exceeds balance 61 | 62 | 0 ↳ FROM 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 63 | 0 ↳ CALL (0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48).transfer(0xf39Fd6e5…2266, 100000000) -> ERC20: transfer amount exceeds balance 64 | 1 ↳ DELEGATECALL (0x43506849d7c04f9138d1a2050bbf3a0c054402dd).transfer(0xf39Fd6e5…2266, 100000000) -> ERC20: transfer amount exceeds balance 65 | 66 | 67 | Estimate Gas Arguments: 68 | from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 69 | to: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 70 | data: 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100 71 | maxFeePerGas: 7.588479508 gwei 72 | maxPriorityFeePerGas: 1 gwei 73 | nonce: 783 74 | 75 | Request Arguments: 76 | from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 77 | to: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 78 | data: 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100 79 | 80 | Contract Call: 81 | address: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 82 | function: transfer(address recipient, uint256 amount) 83 | args: (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 100000000) 84 | sender: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 85 | 86 | Docs: https://viem.sh/docs/contract/writeContract 87 | Version: viem@2.39.0] 88 | `); 89 | }); 90 | 91 | test("should trace failed ETH send", async ({ expect, client }) => { 92 | await expect( 93 | client.sendTransaction({ 94 | to: usdc, 95 | value: 1n, 96 | }) 97 | ).rejects.toMatchInlineSnapshot(` 98 | [TransactionExecutionError: execution reverted 99 | 100 | 0 ↳ FROM 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 101 | 0 ↳ CALL (0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48).receive{ 0.000000000000000001 ETH }() -> execution reverted 102 | 1 ↳ DELEGATECALL (0x43506849d7c04f9138d1a2050bbf3a0c054402dd).receive{ 0.000000000000000001 ETH }() -> execution reverted 103 | 104 | 105 | Estimate Gas Arguments: 106 | from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 107 | to: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 108 | value: 0.000000000000000001 ETH 109 | maxFeePerGas: 7.588479508 gwei 110 | maxPriorityFeePerGas: 1 gwei 111 | nonce: 783 112 | 113 | Request Arguments: 114 | from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 115 | to: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 116 | value: 0.000000000000000001 ETH 117 | 118 | Version: viem@2.39.0] 119 | `); 120 | }); 121 | 122 | test("should trace failing eth_sendTransaction by default", async ({ 123 | expect, 124 | client, 125 | }) => { 126 | await expect( 127 | client.request({ 128 | method: "eth_sendTransaction", 129 | params: [ 130 | { 131 | to: usdc, 132 | data: encodeFunctionData({ 133 | abi: erc20Abi, 134 | functionName: "transfer", 135 | args: [client.account.address, parseUnits("100", 6)], 136 | }), 137 | }, 138 | ], 139 | }) 140 | ).rejects.toMatchInlineSnapshot(` 141 | [ExecutionRevertedError: ERC20: transfer from the zero address 142 | 143 | 0 ↳ FROM 0x0000000000000000000000000000000000000000 144 | 0 ↳ CALL (0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48).transfer(0xf39Fd6e5…2266, 100000000) -> ERC20: transfer from the zero address 145 | 1 ↳ DELEGATECALL (0x43506849d7c04f9138d1a2050bbf3a0c054402dd).transfer(0xf39Fd6e5…2266, 100000000) -> ERC20: transfer from the zero address 146 | 147 | 148 | Version: viem@2.39.0] 149 | `); 150 | }); 151 | 152 | test("should not trace failing txs when disabled", async ({ 153 | expect, 154 | client, 155 | }) => { 156 | client.transport.tracer.failed = false; 157 | 158 | await expect( 159 | client.writeContract({ 160 | address: usdc, 161 | abi: erc20Abi, 162 | functionName: "transfer", 163 | args: [client.account.address, parseUnits("100", 6)], 164 | }) 165 | ).rejects.toMatchInlineSnapshot(` 166 | [ContractFunctionExecutionError: The contract function "transfer" reverted with the following reason: 167 | ERC20: transfer amount exceeds balance 168 | 169 | Contract Call: 170 | address: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 171 | function: transfer(address recipient, uint256 amount) 172 | args: (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 100000000) 173 | sender: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 174 | 175 | Docs: https://viem.sh/docs/contract/writeContract 176 | Version: viem@2.39.0] 177 | `); 178 | 179 | client.transport.tracer.failed = true; 180 | 181 | await expect( 182 | client.writeContract({ 183 | address: usdc, 184 | abi: erc20Abi, 185 | functionName: "transfer", 186 | args: [client.account.address, parseUnits("100", 6)], 187 | }) 188 | ).rejects.toMatchInlineSnapshot(` 189 | [ContractFunctionExecutionError: execution reverted: ERC20: transfer amount exceeds balance 190 | 191 | 0 ↳ FROM 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 192 | 0 ↳ CALL (0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48).transfer(0xf39Fd6e5…2266, 100000000) -> ERC20: transfer amount exceeds balance 193 | 1 ↳ DELEGATECALL (0x43506849d7c04f9138d1a2050bbf3a0c054402dd).transfer(0xf39Fd6e5…2266, 100000000) -> ERC20: transfer amount exceeds balance 194 | 195 | 196 | Estimate Gas Arguments: 197 | from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 198 | to: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 199 | data: 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100 200 | maxFeePerGas: 7.588479508 gwei 201 | maxPriorityFeePerGas: 1 gwei 202 | nonce: 783 203 | 204 | Request Arguments: 205 | from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 206 | to: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 207 | data: 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100 208 | 209 | Contract Call: 210 | address: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 211 | function: transfer(address recipient, uint256 amount) 212 | args: (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 100000000) 213 | sender: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 214 | 215 | Docs: https://viem.sh/docs/contract/writeContract 216 | Version: viem@2.39.0] 217 | `); 218 | }); 219 | 220 | test("should trace next tx with gas even when failed disabled", async ({ 221 | expect, 222 | client, 223 | }) => { 224 | const amount = parseUnits("100", 6); 225 | const consoleSpy = vi.spyOn(console, "log"); 226 | 227 | client.transport.tracer.failed = false; 228 | client.transport.tracer.next = true; 229 | client.transport.tracer.gas = true; 230 | 231 | await client.deal({ erc20: usdc, amount }); 232 | await client 233 | .writeContract({ 234 | address: usdc, 235 | abi: erc20Abi, 236 | functionName: "transfer", 237 | args: [client.account.address, amount / 2n], 238 | }) 239 | .catch(() => {}); 240 | 241 | expect(consoleSpy.mock.calls).toMatchInlineSnapshot(` 242 | [ 243 | [ 244 | "0 ↳ FROM 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 245 | 0 ↳ CALL (0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48).transfer[ 37,560 / 29,978,392 ](0xf39Fd6e5…2266, 50000000) -> true 246 | 1 ↳ DELEGATECALL (0x43506849d7c04f9138d1a2050bbf3a0c054402dd).transfer[ 11,463 / 29,502,848 ](0xf39Fd6e5…2266, 50000000) -> true 247 | 2 ↳ LOG Transfer(0xf39Fd6e5…2266, 0xf39Fd6e5…2266, 50000000) 248 | ", 249 | ], 250 | ] 251 | `); 252 | 253 | consoleSpy.mockRestore(); 254 | }); 255 | 256 | test("should not trace next tx when next disabled", async ({ 257 | expect, 258 | client, 259 | }) => { 260 | client.transport.tracer.next = false; 261 | 262 | await expect( 263 | client.writeContract({ 264 | address: usdc, 265 | abi: erc20Abi, 266 | functionName: "transfer", 267 | args: [client.account.address, parseUnits("100", 6)], 268 | }) 269 | ).rejects.toMatchInlineSnapshot(` 270 | [ContractFunctionExecutionError: The contract function "transfer" reverted with the following reason: 271 | ERC20: transfer amount exceeds balance 272 | 273 | Contract Call: 274 | address: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 275 | function: transfer(address recipient, uint256 amount) 276 | args: (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 100000000) 277 | sender: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 278 | 279 | Docs: https://viem.sh/docs/contract/writeContract 280 | Version: viem@2.39.0] 281 | `); 282 | 283 | await expect( 284 | client.writeContract({ 285 | address: usdc, 286 | abi: erc20Abi, 287 | functionName: "transfer", 288 | args: [client.account.address, parseUnits("100", 6)], 289 | }) 290 | ).rejects.toMatchInlineSnapshot(` 291 | [ContractFunctionExecutionError: execution reverted: ERC20: transfer amount exceeds balance 292 | 293 | 0 ↳ FROM 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 294 | 0 ↳ CALL (0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48).transfer(0xf39Fd6e5…2266, 100000000) -> ERC20: transfer amount exceeds balance 295 | 1 ↳ DELEGATECALL (0x43506849d7c04f9138d1a2050bbf3a0c054402dd).transfer(0xf39Fd6e5…2266, 100000000) -> ERC20: transfer amount exceeds balance 296 | 297 | 298 | Estimate Gas Arguments: 299 | from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 300 | to: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 301 | data: 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100 302 | maxFeePerGas: 7.588479508 gwei 303 | maxPriorityFeePerGas: 1 gwei 304 | nonce: 783 305 | 306 | Request Arguments: 307 | from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 308 | to: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 309 | data: 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100 310 | 311 | Contract Call: 312 | address: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 313 | function: transfer(address recipient, uint256 amount) 314 | args: (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 100000000) 315 | sender: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 316 | 317 | Docs: https://viem.sh/docs/contract/writeContract 318 | Version: viem@2.39.0] 319 | `); 320 | }); 321 | 322 | test("should trace raw steps when enabled", async ({ expect, client }) => { 323 | client.transport.tracer.raw = true; 324 | 325 | await expect( 326 | client.writeContract({ 327 | address: usdc, 328 | abi: erc20Abi, 329 | functionName: "transfer", 330 | args: [client.account.address, parseUnits("100", 6)], 331 | }) 332 | ).rejects.toMatchInlineSnapshot(` 333 | [ContractFunctionExecutionError: execution reverted: ERC20: transfer amount exceeds balance 334 | 335 | 0 ↳ FROM 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 336 | 0 ↳ CALL (0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48).transfer(0xf39Fd6e5…2266, 100000000) -> ERC20: transfer amount exceeds balance 337 | {"from":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","gas":"0x1c96f24","gasUsed":"0x85d9","to":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","input":"0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100","output":"0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002645524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e63650000000000000000000000000000000000000000000000000000","error":"execution reverted","revertReason":"ERC20: transfer amount exceeds balance","calls":[{"from":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","gas":"0x1c22d8c","gasUsed":"0x14f1","to":"0x43506849d7c04f9138d1a2050bbf3a0c054402dd","input":"0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100","output":"0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002645524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e63650000000000000000000000000000000000000000000000000000","error":"execution reverted","revertReason":"ERC20: transfer amount exceeds balance","value":"0x0","type":"DELEGATECALL"}],"value":"0x0","type":"CALL"} 338 | 1 ↳ DELEGATECALL (0x43506849d7c04f9138d1a2050bbf3a0c054402dd).transfer(0xf39Fd6e5…2266, 100000000) -> ERC20: transfer amount exceeds balance 339 | {"from":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","gas":"0x1c22d8c","gasUsed":"0x14f1","to":"0x43506849d7c04f9138d1a2050bbf3a0c054402dd","input":"0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100","output":"0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002645524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e63650000000000000000000000000000000000000000000000000000","error":"execution reverted","revertReason":"ERC20: transfer amount exceeds balance","value":"0x0","type":"DELEGATECALL"} 340 | 341 | 342 | Estimate Gas Arguments: 343 | from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 344 | to: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 345 | data: 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100 346 | maxFeePerGas: 7.588479508 gwei 347 | maxPriorityFeePerGas: 1 gwei 348 | nonce: 783 349 | 350 | Request Arguments: 351 | from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 352 | to: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 353 | data: 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100 354 | 355 | Contract Call: 356 | address: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 357 | function: transfer(address recipient, uint256 amount) 358 | args: (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 100000000) 359 | sender: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 360 | 361 | Docs: https://viem.sh/docs/contract/writeContract 362 | Version: viem@2.39.0] 363 | `); 364 | 365 | await expect( 366 | client.writeContract({ 367 | address: usdc, 368 | abi: erc20Abi, 369 | functionName: "transfer", 370 | args: [client.account.address, parseUnits("100", 6)], 371 | }) 372 | ).rejects.toMatchInlineSnapshot(` 373 | [ContractFunctionExecutionError: execution reverted: ERC20: transfer amount exceeds balance 374 | 375 | 0 ↳ FROM 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 376 | 0 ↳ CALL (0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48).transfer(0xf39Fd6e5…2266, 100000000) -> ERC20: transfer amount exceeds balance 377 | {"from":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","gas":"0x1c96f24","gasUsed":"0x85d9","to":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","input":"0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100","output":"0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002645524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e63650000000000000000000000000000000000000000000000000000","error":"execution reverted","revertReason":"ERC20: transfer amount exceeds balance","calls":[{"from":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","gas":"0x1c22d8c","gasUsed":"0x14f1","to":"0x43506849d7c04f9138d1a2050bbf3a0c054402dd","input":"0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100","output":"0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002645524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e63650000000000000000000000000000000000000000000000000000","error":"execution reverted","revertReason":"ERC20: transfer amount exceeds balance","value":"0x0","type":"DELEGATECALL"}],"value":"0x0","type":"CALL"} 378 | 1 ↳ DELEGATECALL (0x43506849d7c04f9138d1a2050bbf3a0c054402dd).transfer(0xf39Fd6e5…2266, 100000000) -> ERC20: transfer amount exceeds balance 379 | {"from":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","gas":"0x1c22d8c","gasUsed":"0x14f1","to":"0x43506849d7c04f9138d1a2050bbf3a0c054402dd","input":"0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100","output":"0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002645524332303a207472616e7366657220616d6f756e7420657863656564732062616c616e63650000000000000000000000000000000000000000000000000000","error":"execution reverted","revertReason":"ERC20: transfer amount exceeds balance","value":"0x0","type":"DELEGATECALL"} 380 | 381 | 382 | Estimate Gas Arguments: 383 | from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 384 | to: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 385 | data: 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100 386 | maxFeePerGas: 7.588479508 gwei 387 | maxPriorityFeePerGas: 1 gwei 388 | nonce: 783 389 | 390 | Request Arguments: 391 | from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 392 | to: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 393 | data: 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000005f5e100 394 | 395 | Contract Call: 396 | address: 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 397 | function: transfer(address recipient, uint256 amount) 398 | args: (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 100000000) 399 | sender: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 400 | 401 | Docs: https://viem.sh/docs/contract/writeContract 402 | Version: viem@2.39.0] 403 | `); 404 | }); 405 | }); 406 | --------------------------------------------------------------------------------