diff --git a/package.json b/package.json index b55ee78..6857928 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "lodash.kebabcase": "^4.1.1", "typescript": "^5.6.3", "viem": "^2.21.32", + "viem-deal": "^2.0.2", "vitest": "^2.1.3" }, "lint-staged": { diff --git a/src/actions/traceCall.ts b/src/actions/traceCall.ts index 7c9016d..1ef3de1 100644 --- a/src/actions/traceCall.ts +++ b/src/actions/traceCall.ts @@ -31,7 +31,7 @@ export type TraceCallRpcSchema = { BlockTag | Hex, { tracer: "callTracer" | "prestateTracer"; - tracerConfig?: { onlyTopCall?: boolean }; + tracerConfig?: { onlyTopCall?: boolean; withLog?: boolean }; }, ]; ReturnType: RpcCallTrace; @@ -39,6 +39,13 @@ export type TraceCallRpcSchema = { export type RpcCallType = "CALL" | "STATICCALL" | "DELEGATECALL" | "CREATE" | "CREATE2" | "SELFDESTRUCT" | "CALLCODE"; +export type RpcLogTrace = { + address: Address; + data: Hex; + position: Hex; + topics: [Hex, ...Hex[]]; +}; + export type RpcCallTrace = { from: Address; gas: Hex; @@ -49,6 +56,7 @@ export type RpcCallTrace = { error?: string; revertReason?: string; calls?: RpcCallTrace[]; + logs?: RpcLogTrace[]; value: Hex; type: RpcCallType; }; diff --git a/src/format.ts b/src/format.ts index 84602ca..1345326 100644 --- a/src/format.ts +++ b/src/format.ts @@ -3,12 +3,25 @@ import { writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import colors from "colors/safe.js"; -import { type Address, type Hex, decodeFunctionData, isAddress, parseAbi, slice } from "viem"; -import type { RpcCallTrace } from "./actions/traceCall.js"; +import { + type Address, + type Hex, + concatHex, + decodeEventLog, + decodeFunctionData, + isAddress, + parseAbi, + slice, +} from "viem"; +import type { RpcCallTrace, RpcLogTrace } from "./actions/traceCall.js"; // The requested module 'colors/safe.js' is a CommonJS module, which may not support all module.exports as named exports. // CommonJS modules can always be imported via the default export, for example using: -const { bold, cyan, grey, red, white, yellow } = colors; +const { bold, cyan, grey, red, white, yellow, green, dim, black, magenta } = colors; + +export type TraceFormatConfig = { + gas: boolean; +}; export const signaturesPath = join(homedir(), ".foundry", "cache", "signatures"); @@ -21,21 +34,32 @@ export const signatures: { export const getSelector = (input: Hex) => slice(input, 0, 4); -export const getCallTraceUnknownSelectors = (trace: RpcCallTrace): string => { +export const getCallTraceUnknownFunctionSelectors = (trace: RpcCallTrace): string => { const rest = (trace.calls ?? []) - .flatMap((subtrace) => getCallTraceUnknownSelectors(subtrace)) - .filter(Boolean) - .join(","); + .flatMap((subtrace) => getCallTraceUnknownFunctionSelectors(subtrace)) + .filter(Boolean); - if (!trace.input) return rest; + if (trace.input) { + const inputSelector = getSelector(trace.input); - const selector = getSelector(trace.input); + if (!signatures.functions[inputSelector]) rest.push(inputSelector); + } - if (signatures.functions[selector]) return rest; + return rest.join(","); +}; + +export const getCallTraceUnknownEventSelectors = (trace: RpcCallTrace): string => { + const rest = (trace.calls ?? []).flatMap((subtrace) => getCallTraceUnknownEventSelectors(subtrace)).filter(Boolean); - if (!rest) return selector; + if (trace.logs) { + for (const log of trace.logs) { + const selector = log.topics[0]!; - return `${selector},${rest}`; + if (!signatures.events[selector]) rest.push(selector); + } + } + + return rest.join(","); }; export const getIndentLevel = (level: number, index = false) => @@ -45,7 +69,7 @@ export const formatAddress = (address: Address) => `${address.slice(0, 8)}…${a export const formatArg = (arg: unknown, level: number): string => { if (Array.isArray(arg)) { - const formattedArr = arg.map((arg) => `\n${getIndentLevel(level + 1)}${formatArg(arg, level + 1)},`).join(""); + const formattedArr = arg.map((arg) => `\n${getIndentLevel(level + 1)}${grey(formatArg(arg, level + 1))},`).join(""); return `[${formattedArr ? `${formattedArr}\n` : ""}${getIndentLevel(level)}]`; } @@ -55,19 +79,19 @@ export const formatArg = (arg: unknown, level: number): string => { if (arg == null) return ""; const formattedObj = Object.entries(arg) - .map(([key, value]) => `\n${getIndentLevel(level + 1)}${key}: ${formatArg(value, level + 1)},`) + .map(([key, value]) => `\n${getIndentLevel(level + 1)}${key}: ${grey(formatArg(value, level + 1))},`) .join(""); return `{${formattedObj ? `${formattedObj}\n` : ""}${getIndentLevel(level)}}`; } case "string": - return isAddress(arg, { strict: false }) ? formatAddress(arg) : arg; + return grey(isAddress(arg, { strict: false }) ? formatAddress(arg) : arg); default: - return String(arg); + return grey(String(arg)); } }; -export const formatCallSignature = (trace: RpcCallTrace, level: number) => { +export const formatCallSignature = (trace: RpcCallTrace, config: Partial, level: number) => { const selector = getSelector(trace.input); const signature = signatures.functions[selector]; @@ -83,24 +107,48 @@ export const formatCallSignature = (trace: RpcCallTrace, level: number) => { const formattedArgs = args?.map((arg) => formatArg(arg, level)).join(", "); - return `${bold((trace.error ? red : yellow)(functionName))}(${grey(formattedArgs ?? "")})`; + return `${bold((trace.error ? red : green)(functionName))}${config.gas ? dim(magenta(`{ ${Number(trace.gasUsed).toLocaleString()} / ${Number(trace.gas).toLocaleString()} }`)) : ""}(${formattedArgs ?? ""})`; }; -export const formatCallTrace = (trace: RpcCallTrace, level = 1): string => { - const rest = (trace.calls ?? []).map((subtrace) => formatCallTrace(subtrace, level + 1)).join("\n"); +export const formatCallLog = (log: RpcLogTrace, level: number) => { + const selector = log.topics[0]!; + + const signature = signatures.events[selector]; + if (!signature) return concatHex(log.topics); + + const nbIndexed = log.topics.length - 1; + + const { eventName, args } = decodeEventLog({ + abi: parseAbi( + // @ts-ignore + [`event ${signature}`], + ), + data: concatHex(log.topics.slice(1).concat(log.data)), + topics: log.topics, + strict: false, + }); + + const formattedArgs = args?.map((arg) => formatArg(arg, level)).join(", "); + + return `${getIndentLevel(level + 1, true)}${yellow("LOG")} ${eventName}(${formattedArgs ?? ""})`; +}; + +export const formatCallTrace = (trace: RpcCallTrace, config: Partial = {}, level = 1): string => { + const rest = (trace.calls ?? []).map((subtrace) => formatCallTrace(subtrace, config, level + 1)).join("\n"); const returnValue = trace.revertReason ?? trace.output; - return `${level === 1 ? `${getIndentLevel(level, true)}${cyan("FROM")} ${grey(trace.from)}\n` : ""}${getIndentLevel(level, true)}${yellow(trace.type)} ${trace.from === trace.to ? grey("self") : `(${white(trace.to)})`}.${formatCallSignature(trace, level)}${returnValue ? (trace.error ? red : grey)(` -> ${returnValue}`) : ""} + return `${level === 1 ? `${getIndentLevel(level, true)}${cyan("FROM")} ${grey(trace.from)}\n` : ""}${getIndentLevel(level, true)}${yellow(trace.type)} ${trace.from === trace.to ? grey("self") : `(${white(trace.to)})`}.${formatCallSignature(trace, config, level)}${returnValue ? (trace.error ? red : grey)(` -> ${returnValue}`) : ""}${trace.logs ? `\n${trace.logs.map((log) => formatCallLog(log, level))}` : ""} ${rest}`; }; -export async function formatFullTrace(trace: RpcCallTrace) { - const unknownSelectors = getCallTraceUnknownSelectors(trace); +export async function formatFullTrace(trace: RpcCallTrace, config?: Partial) { + const unknownFunctionSelectors = getCallTraceUnknownFunctionSelectors(trace); + const unknownEventSelectors = getCallTraceUnknownEventSelectors(trace); - if (unknownSelectors) { + if (unknownFunctionSelectors || unknownEventSelectors) { const lookupRes = await fetch( - `https://api.openchain.xyz/signature-database/v1/lookup?filter=false&function=${unknownSelectors}`, + `https://api.openchain.xyz/signature-database/v1/lookup?filter=false${unknownFunctionSelectors ? `&function=${unknownFunctionSelectors}` : ""}${unknownEventSelectors ? `&event=${unknownEventSelectors}` : ""}`, ); const lookup = await lookupRes.json(); @@ -112,12 +160,22 @@ export async function formatFullTrace(trace: RpcCallTrace) { signatures.functions[sig as Hex] = match; }); + Object.entries<{ name: string; filtered: boolean }[]>(lookup.result.event).map(([sig, results]) => { + const match = results.find(({ filtered }) => !filtered)?.name; + if (!match) return; + + signatures.events[sig as Hex] = match; + }); writeFile(signaturesPath, JSON.stringify(signatures)); // Non blocking. } else { - console.warn(`Failed to fetch signatures for unknown selectors: ${unknownSelectors}`, lookup.error, "\n"); + console.warn( + `Failed to fetch signatures for unknown selectors: ${unknownFunctionSelectors},${unknownEventSelectors}`, + lookup.error, + "\n", + ); } } - return formatCallTrace(trace); + return formatCallTrace(trace, config); } diff --git a/src/middleware.ts b/src/middleware.ts index c41b948..06c0fec 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,8 +1,8 @@ import { RawContractError, type Transport } from "viem"; import type { TraceCallRpcSchema } from "./actions/traceCall.js"; -import { formatFullTrace } from "./format.js"; +import { type TraceFormatConfig, formatFullTrace } from "./format.js"; -export type TracerConfig = { +export type TracerConfig = TraceFormatConfig & { /** * Whether to trace all transactions. Default to `false`. */ @@ -34,7 +34,7 @@ export type TracedTransport = transport */ export function traced( transport: transport, - { all = false, next = false, failed = true }: Partial = {}, + { all = false, next = false, failed = true, gas = false }: Partial = {}, ): TracedTransport { // @ts-ignore: complex overload return (...config) => { @@ -42,7 +42,7 @@ export function traced( instance.value = { ...instance.value, - tracer: { all, next, failed }, + tracer: { all, next, failed, gas }, }; return { @@ -59,13 +59,21 @@ export function traced( params[0], // @ts-ignore: params[1] is either undefined or the block identifier params[1] || "latest", - { tracer: "callTracer" }, + { + // @ts-ignore: params[2] may contain state and block overrides + ...params[2], + tracer: "callTracer", + tracerConfig: { + onlyTopCall: false, + withLog: true, + }, + }, ], }, { retryCount: 0 }, ); - return await formatFullTrace(trace); + return await formatFullTrace(trace, instance.value!.tracer); }; switch (method) { diff --git a/test/setup.ts b/test/setup.ts index 9c4135c..52f8699 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,6 +1,7 @@ import { disable } from "colors"; import type { Client, HDAccount, HttpTransport, PublicActions, TestActions, TestRpcSchema, WalletActions } from "viem"; import { http, createTestClient, publicActions, walletActions } from "viem"; +import { type DealActions, dealActions } from "viem-deal"; import { mainnet } from "viem/chains"; import { test as vitest } from "vitest"; import { type TraceActions, type TracedTransport, traceActions, traced } from "../src/index.js"; @@ -36,6 +37,7 @@ export const test = vitest.extend<{ HDAccount, TestRpcSchema<"anvil">, TestActions & + DealActions & TraceActions & PublicActions, typeof mainnet, HDAccount> & WalletActions @@ -46,6 +48,7 @@ export const test = vitest.extend<{ const { rpcUrl, stop } = await spawnAnvil({ forkUrl: process.env.MAINNET_RPC_URL || mainnet.rpcUrls.default.http[0], forkBlockNumber: 20_884_340, + stepsTracing: true, }); await use( @@ -55,6 +58,7 @@ export const test = vitest.extend<{ account: testAccount(), transport: traced(http(rpcUrl)), }) + .extend(dealActions) .extend(publicActions) .extend(walletActions) .extend(traceActions), diff --git a/test/traceCall.test.ts b/test/traceCall.test.ts index 104c123..25faae7 100644 --- a/test/traceCall.test.ts +++ b/test/traceCall.test.ts @@ -124,18 +124,21 @@ describe("traceCall", () => { `); }); - test("should trace next txs even when disabled", async ({ expect, client }) => { + test("should trace next tx with gas even when failed disabled", async ({ expect, client }) => { + const amount = parseUnits("100", 6); const consoleSpy = vi.spyOn(console, "log"); client.transport.tracer.failed = false; client.transport.tracer.next = true; + client.transport.tracer.gas = true; + await client.deal({ erc20: usdc, amount }); await client .writeContract({ address: usdc, abi: erc20Abi, functionName: "transfer", - args: [client.account.address, parseUnits("100", 6)], + args: [client.account.address, amount / 2n], }) .catch(() => {}); @@ -143,8 +146,9 @@ describe("traceCall", () => { [ [ "0 ↳ FROM 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 - 0 ↳ CALL (0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48).transfer(0xf39Fd6…0xf3, 100000000) -> ERC20: transfer amount exceeds balance - 1 ↳ DELEGATECALL (0x43506849d7c04f9138d1a2050bbf3a0c054402dd).transfer(0xf39Fd6…0xf3, 100000000) -> ERC20: transfer amount exceeds balance + 0 ↳ CALL (0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48).transfer{ 37,560 / 29,978,392 }(0xf39Fd6…0xf3, 50000000) -> 0x0000000000000000000000000000000000000000000000000000000000000001 + 1 ↳ DELEGATECALL (0x43506849d7c04f9138d1a2050bbf3a0c054402dd).transfer{ 11,463 / 29,502,848 }(0xf39Fd6…0xf3, 50000000) -> 0x0000000000000000000000000000000000000000000000000000000000000001 + 2 ↳ LOG Transfer(0xf39Fd6…0xf3, 0xf39Fd6…0xf3, 50000000) ", ], ] diff --git a/yarn.lock b/yarn.lock index 86c7ab5..cc932b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1918,6 +1918,11 @@ unicorn-magic@^0.1.0: resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4" integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ== +viem-deal@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/viem-deal/-/viem-deal-2.0.2.tgz#7f682929050dd614147fb5e9ea9a22c478676139" + integrity sha512-cm6GWmVHjvvejm2OOiqAo/hsTYbkErjx0+Ag3T5rPg64tG9ETr+1aTDZNUvK7rDYdzSwLT3+M5w0OCEp2aUIew== + viem@^2.21.32: version "2.21.32" resolved "https://registry.yarnpkg.com/viem/-/viem-2.21.32.tgz#b7f43b2004967036f83500260290cee45189f62a"