Skip to content

Commit

Permalink
feat(tracer): add support for logs
Browse files Browse the repository at this point in the history
  • Loading branch information
Rubilmax committed Oct 22, 2024
1 parent 823f2a5 commit b712b34
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 38 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
10 changes: 9 additions & 1 deletion src/actions/traceCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,21 @@ export type TraceCallRpcSchema = {
BlockTag | Hex,
{
tracer: "callTracer" | "prestateTracer";
tracerConfig?: { onlyTopCall?: boolean };
tracerConfig?: { onlyTopCall?: boolean; withLog?: boolean };
},
];
ReturnType: RpcCallTrace;
};

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;
Expand All @@ -49,6 +56,7 @@ export type RpcCallTrace = {
error?: string;
revertReason?: string;
calls?: RpcCallTrace[];
logs?: RpcLogTrace[];
value: Hex;
type: RpcCallType;
};
Expand Down
112 changes: 85 additions & 27 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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) =>
Expand All @@ -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)}]`;
}
Expand All @@ -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<TraceFormatConfig>, level: number) => {
const selector = getSelector(trace.input);

const signature = signatures.functions[selector];
Expand All @@ -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<TraceFormatConfig> = {}, 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<TraceFormatConfig>) {
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();
Expand All @@ -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);
}
20 changes: 14 additions & 6 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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`.
*/
Expand Down Expand Up @@ -34,15 +34,15 @@ export type TracedTransport<transport extends Transport = Transport> = transport
*/
export function traced<transport extends Transport>(
transport: transport,
{ all = false, next = false, failed = true }: Partial<TracerConfig> = {},
{ all = false, next = false, failed = true, gas = false }: Partial<TracerConfig> = {},
): TracedTransport<transport> {
// @ts-ignore: complex overload
return (...config) => {
const instance = transport(...config) as ReturnType<TracedTransport<transport>>;

instance.value = {
...instance.value,
tracer: { all, next, failed },
tracer: { all, next, failed, gas },
};

return {
Expand All @@ -59,13 +59,21 @@ export function traced<transport extends Transport>(
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) {
Expand Down
4 changes: 4 additions & 0 deletions test/setup.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -36,6 +37,7 @@ export const test = vitest.extend<{
HDAccount,
TestRpcSchema<"anvil">,
TestActions &
DealActions<HDAccount> &
TraceActions<typeof mainnet> &
PublicActions<TracedTransport<HttpTransport>, typeof mainnet, HDAccount> &
WalletActions<typeof mainnet, HDAccount>
Expand All @@ -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(
Expand All @@ -55,6 +58,7 @@ export const test = vitest.extend<{
account: testAccount(),
transport: traced(http(rpcUrl)),
})
.extend(dealActions)
.extend(publicActions)
.extend(walletActions)
.extend(traceActions),
Expand Down
12 changes: 8 additions & 4 deletions test/traceCall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,27 +124,31 @@ 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(() => {});

expect(consoleSpy.mock.calls).toMatchInlineSnapshot(`
[
[
"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)
",
],
]
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit b712b34

Please sign in to comment.