From 8964852f7043b457c7ee7a4bcc58035b1bf7c397 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Sun, 12 Apr 2026 11:22:52 -0300 Subject: [PATCH 1/8] v0.0.1 --- RELEASE.md | 23 ++++++++--------------- packages/adapters-langchain/CHANGELOG.md | 2 ++ packages/algorithms/CHANGELOG.md | 2 ++ packages/cli/CHANGELOG.md | 2 ++ packages/utils/CHANGELOG.md | 1 + 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index afe6817..84bf649 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -25,9 +25,11 @@ All packages are published under the `@openscan` npm scope with public access. 1. A developer creates a changeset describing what changed 2. The changeset is committed and pushed as part of a PR to `main` 3. On merge, the GitHub Actions **Release** workflow runs -4. The [`changesets/action`](https://github.com/changesets/action) either: - - **Creates a "Version Packages" PR** — if there are pending changesets, it bumps versions, updates changelogs, and opens a PR titled `chore: version packages` - - **Publishes to npm** — if the "Version Packages" PR was just merged (no pending changesets, but versions were bumped), it publishes all updated packages with [npm provenance](https://docs.npmjs.com/generating-provenance-statements) +4. The workflow: + - Applies pending changesets (bumps versions, updates changelogs, removes consumed changeset files) + - Builds all packages + - Publishes updated packages to npm with [npm provenance](https://docs.npmjs.com/generating-provenance-statements) + - Commits and pushes the version changes back to `main` ### Dependency Order @@ -60,18 +62,9 @@ This creates a markdown file in `.changeset/`. Commit it with your PR. Push your branch and open a PR to `main`. Once merged, the Release workflow picks up the changeset. -### Step 3: Merge the Version PR +### Step 3: Automatic Version and Publish -The workflow creates a PR titled **"chore: version packages"** that: -- Consumes all pending changesets -- Bumps `version` in each `package.json` -- Updates `CHANGELOG.md` files - -Review and merge this PR. - -### Step 4: Automatic Publish - -Once the version PR merges, the Release workflow runs again and publishes all updated packages to npm. +Once merged, the Release workflow automatically versions, builds, publishes to npm, and pushes the version changes back to `main`. No additional steps required. ## Pre-release (Alpha) Versions @@ -129,7 +122,7 @@ pnpm --filter @openscan/utils exec npm publish --access public The release pipeline is defined in `.github/workflows/release.yml` and requires: - **`NPM_TOKEN`** — a granular access token from npmjs.com scoped to the `@openscan` org with read/write permissions. Added as a GitHub Actions secret. -- **`GITHUB_TOKEN`** — automatically provided by GitHub Actions. Used to create the version PR. +- **`GITHUB_TOKEN`** — automatically provided by GitHub Actions. Used to push version changes back to `main`. ### Creating an npm Token diff --git a/packages/adapters-langchain/CHANGELOG.md b/packages/adapters-langchain/CHANGELOG.md index a95df80..6019cbc 100644 --- a/packages/adapters-langchain/CHANGELOG.md +++ b/packages/adapters-langchain/CHANGELOG.md @@ -4,7 +4,9 @@ ### Patch Changes +- General fixes - 4ec24b6: General release fixes +- Updated dependencies - Updated dependencies [4ec24b6] - @openscan/algorithms@0.0.1 - @openscan/utils@0.0.1 diff --git a/packages/algorithms/CHANGELOG.md b/packages/algorithms/CHANGELOG.md index 607f1dc..9ae9325 100644 --- a/packages/algorithms/CHANGELOG.md +++ b/packages/algorithms/CHANGELOG.md @@ -4,6 +4,8 @@ ### Patch Changes +- General fixes - 4ec24b6: General release fixes +- Updated dependencies - Updated dependencies [4ec24b6] - @openscan/utils@0.0.1 diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 62557a8..365ee38 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -4,7 +4,9 @@ ### Patch Changes +- General fixes - 4ec24b6: General release fixes +- Updated dependencies - Updated dependencies [4ec24b6] - @openscan/algorithms@0.0.1 - @openscan/utils@0.0.1 diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index d95fefb..2f85e2d 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -4,4 +4,5 @@ ### Patch Changes +- General fixes - 4ec24b6: General release fixes From a39d80de7a1a97950c06f2ca16c927ef367f97cb Mon Sep 17 00:00:00 2001 From: Mati OS Date: Fri, 17 Apr 2026 12:07:05 -0300 Subject: [PATCH 2/8] feat(utils): Add tx analysis --- packages/utils/CLAUDE.md | 20 +- packages/utils/src/index.ts | 36 ++ packages/utils/src/tx-analysis/analyze.ts | 121 +++++ packages/utils/src/tx-analysis/callTree.ts | 131 ++++++ packages/utils/src/tx-analysis/contracts.ts | 163 +++++++ packages/utils/src/tx-analysis/errors.ts | 7 + packages/utils/src/tx-analysis/fetchers.ts | 127 ++++++ packages/utils/src/tx-analysis/index.ts | 36 ++ packages/utils/src/tx-analysis/structLogs.ts | 294 ++++++++++++ packages/utils/src/tx-analysis/types.ts | 96 ++++ packages/utils/tests/tx-analysis.test.ts | 455 +++++++++++++++++++ 11 files changed, 1485 insertions(+), 1 deletion(-) create mode 100644 packages/utils/src/tx-analysis/analyze.ts create mode 100644 packages/utils/src/tx-analysis/callTree.ts create mode 100644 packages/utils/src/tx-analysis/contracts.ts create mode 100644 packages/utils/src/tx-analysis/errors.ts create mode 100644 packages/utils/src/tx-analysis/fetchers.ts create mode 100644 packages/utils/src/tx-analysis/index.ts create mode 100644 packages/utils/src/tx-analysis/structLogs.ts create mode 100644 packages/utils/src/tx-analysis/types.ts create mode 100644 packages/utils/tests/tx-analysis.test.ts diff --git a/packages/utils/CLAUDE.md b/packages/utils/CLAUDE.md index f32cc52..80666e5 100644 --- a/packages/utils/CLAUDE.md +++ b/packages/utils/CLAUDE.md @@ -27,6 +27,7 @@ src/ ├── hex/ # Hex/data utilities ├── signatures/ # Signature parsing ├── tx/ # Transaction input decoding +├── tx-analysis/ # Transaction tracing: call trees, prestate diff, contract enrichment ├── units/ # Unit conversions (wei, gwei, ether) ├── index.ts # Public API barrel export └── types.ts # Shared type definitions @@ -79,4 +80,21 @@ src/ ## network-connectors -Optional peer dep — only used by `detectAddressType()`. Guard with dynamic import. +Optional peer dep — only used by `detectAddressType()` and `tx-analysis/analyzeTx`. Guard with dynamic import. + +## tx-analysis module + +Transaction tracing + enrichment toolkit. Entry point: `analyzeTx({ txHash, chainId, rpcUrls, ... })`. + +**American spelling** — `analyzeTx`, `AnalyzeTxInput`, `AnalyzeTxLog` (the directory name `tx-analysis` is dialect-neutral and kept as-is). + +Invariants to preserve when editing: + +- **URL encoding**: all user-provided values interpolated into Sourcify / Etherscan URLs go through `encodeURIComponent`. The Etherscan URL carries the API key — unencoded input can smuggle or leak query params. +- **Proxy resolution is bounded**: `fetchContractInfo` recurses through `proxyResolution.implementations[0].address` with `MAX_PROXY_DEPTH = 5` and a `visited` Set for cycle detection. Do not remove either guard. +- **Concurrency cap**: `fetchContractInfoBatch` uses a worker pool capped at `DEFAULT_CONCURRENCY = 6` (overridable via `opts.concurrency`). A tx touching many addresses otherwise bursts the external APIs into rate limits. +- **Depth-aware struct-log parsing**: `buildCallTreeFromStructLogs` aligns its frame stack to `log.depth` on every iteration and only pushes a new frame when the next log's depth actually increases. Pushing on every CALL-family opcode breaks precompile / EOA / early-fail calls — the parent's RETURN would pop the wrong frame. +- **Parity trace indexing**: `normalizeParityCallTrace` indexes children once into a `Map`. Do not regress to `traces.filter(...)` per node (O(n²) on large traces). +- **Peer dep stays dynamic**: `@openscan/network-connectors` is imported via `await import(...)` in `analyze.ts` — never add it to `dependencies`. + +Section errors (`callTree`, `prestate`, `rawTrace`) are returned per-section rather than thrown, so one unsupported tracer doesn't void the rest of the result. Preserve that contract. diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 208adc9..e631cd4 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -63,3 +63,39 @@ export type { VerifyLinkParams } from "./chain/index.js"; // EIP-712 export { encodeTypedData, hashTypedData } from "./eip712/index.js"; + +// Transaction analysis (debug tx toolkit) +export { + analyzeTx, + buildCallTreeFromStructLogs, + buildPrestateFromStructLogs, + collectAddresses, + countByType, + countCalls, + countReverts, + fetchContractInfo, + fetchContractInfoBatch, + hexToGas, + isUnsupportedTraceError, + loadCallTrace, + loadPrestateTrace, + loadRawTrace, + normalizeGethCallTrace, + normalizeParityCallTrace, +} from "./tx-analysis/index.js"; +export type { + AnalyzeTxInput, + AnalyzeTxLog, + CallNode, + ContractInfo, + FetchContractInfoOptions, + ParityTrace, + PrestateAccountState, + PrestateTrace, + TraceLog, + TraceResult, + TraceRpcClient, + TxAnalysis, + TxAnalysisSection, + TxContext, +} from "./tx-analysis/index.js"; diff --git a/packages/utils/src/tx-analysis/analyze.ts b/packages/utils/src/tx-analysis/analyze.ts new file mode 100644 index 0000000..8fb813a --- /dev/null +++ b/packages/utils/src/tx-analysis/analyze.ts @@ -0,0 +1,121 @@ +import { collectAddresses, countByType, countCalls, countReverts } from "./callTree.js"; +import { fetchContractInfoBatch } from "./contracts.js"; +import { isUnsupportedTraceError } from "./errors.js"; +import { loadCallTrace, loadPrestateTrace, loadRawTrace, type TraceRpcClient } from "./fetchers.js"; +import type { AnalyzeTxInput, TxAnalysis } from "./types.js"; + +interface SectionOutcome { + data: T | null; + error: string | null; +} + +async function runSection(loader: () => Promise): Promise> { + try { + const data = await loader(); + return { data, error: data ? null : "not supported" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + data: null, + error: isUnsupportedTraceError(message) ? "not supported" : message, + }; + } +} + +async function resolveClient(input: AnalyzeTxInput): Promise { + if (!input.rpcUrls || input.rpcUrls.length === 0) { + throw new Error("analyzeTx: rpcUrls is required when a client is not provided"); + } + // biome-ignore lint/suspicious/noExplicitAny: peer dep imported dynamically + const nc = (await import("@openscan/network-connectors")) as any; + return nc.ClientFactory.createClient(input.chainId, { + type: "fallback", + rpcUrls: input.rpcUrls, + }) as TraceRpcClient; +} + +/** + * High-level "debug a transaction" orchestrator. + * + * Composes trace loaders, contract enrichment, and call-tree analytics into + * a single call that CLI commands, LangChain tools, and agent skills can + * consume directly. + * + * Each optional section returns its own error rather than throwing, so a + * partial result is still useful when one trace method isn't supported. + */ +export async function analyzeTx(input: AnalyzeTxInput): Promise { + const include = { + callTree: input.include?.callTree ?? true, + prestate: input.include?.prestate ?? false, + rawTrace: input.include?.rawTrace ?? false, + contracts: input.include?.contracts ?? true, + }; + + const client = await resolveClient(input); + const close = (client as unknown as { close?: () => Promise }).close; + + try { + const [callTree, prestate, rawTrace] = await Promise.all([ + include.callTree + ? runSection(() => loadCallTrace(client, input.txHash)) + : Promise.resolve>({ + data: null, + error: null, + }), + include.prestate + ? runSection(() => loadPrestateTrace(client, input.txHash)) + : Promise.resolve>({ + data: null, + error: null, + }), + include.rawTrace + ? runSection(() => loadRawTrace(client, input.txHash)) + : Promise.resolve>({ + data: null, + error: null, + }), + ]); + + const addressSet = new Set(); + if (callTree.data) { + for (const a of collectAddresses(callTree.data)) addressSet.add(a); + } + if (input.logs) { + for (const log of input.logs) { + const addr = log.address?.toLowerCase(); + if (addr) addressSet.add(addr); + } + } + const addresses = Array.from(addressSet); + + let contracts: Record = {}; + if (include.contracts && addresses.length > 0) { + contracts = await fetchContractInfoBatch(addresses, input.chainId, { + signal: input.signal, + etherscanKey: input.etherscanKey, + }); + } + + const analytics = callTree.data + ? { + totalCalls: countCalls(callTree.data), + reverts: countReverts(callTree.data), + byType: countByType(callTree.data), + } + : undefined; + + return { + callTree, + prestate, + rawTrace, + contracts, + addresses, + analytics, + }; + } finally { + if (typeof close === "function") { + await close.call(client).catch(() => {}); + } + } +} diff --git a/packages/utils/src/tx-analysis/callTree.ts b/packages/utils/src/tx-analysis/callTree.ts new file mode 100644 index 0000000..ccffffd --- /dev/null +++ b/packages/utils/src/tx-analysis/callTree.ts @@ -0,0 +1,131 @@ +import type { CallNode } from "./types.js"; + +/** + * Normalize a Geth callTracer response to CallNode. + * The callTracer already returns a nested tree — we just map field names. + */ +// biome-ignore lint/suspicious/noExplicitAny: generic callTracer response +export function normalizeGethCallTrace(raw: any): CallNode { + return { + type: (raw.type || "CALL").toUpperCase(), + from: raw.from ?? "", + to: raw.to, + value: raw.value, + gas: raw.gas, + gasUsed: raw.gasUsed, + input: raw.input, + output: raw.output, + error: raw.error, + revertReason: raw.revertReason, + // biome-ignore lint/suspicious/noExplicitAny: generic callTracer response + calls: raw.calls?.map((c: any) => normalizeGethCallTrace(c)), + }; +} + +interface ParityTraceAction { + callType?: string; + from?: string; + to?: string; + value?: string; + gas?: string; + input?: string; +} + +interface ParityTraceResult { + gasUsed?: string; + output?: string; + address?: string; +} + +export interface ParityTrace { + type: string; + action: ParityTraceAction; + result?: ParityTraceResult; + error?: string; + traceAddress: number[]; + subtraces: number; +} + +/** + * Normalize a flat Parity/arbtrace_transaction response to a CallNode tree. + * The traceAddress array encodes position in the call hierarchy. + */ +export function normalizeParityCallTrace(traces: ParityTrace[]): CallNode | null { + if (!traces || traces.length === 0) return null; + + const root = traces.find((t) => t.traceAddress.length === 0); + if (!root) return null; + + const childrenByParent = new Map(); + for (const t of traces) { + if (t.traceAddress.length === 0) continue; + const parentKey = t.traceAddress.slice(0, -1).join(","); + const bucket = childrenByParent.get(parentKey); + if (bucket) bucket.push(t); + else childrenByParent.set(parentKey, [t]); + } + + function buildNode(trace: ParityTrace): CallNode { + const action = trace.action; + const result = trace.result; + + const childTraces = childrenByParent.get(trace.traceAddress.join(",")) ?? []; + const children = childTraces.map(buildNode); + + return { + type: (action.callType || trace.type || "CALL").toUpperCase(), + from: action.from ?? "", + to: action.to ?? result?.address, + value: action.value, + gas: action.gas, + gasUsed: result?.gasUsed, + input: action.input, + output: result?.output, + error: trace.error, + calls: children.length > 0 ? children : undefined, + }; + } + + return buildNode(root); +} + +/** Count total calls in a tree */ +export function countCalls(node: CallNode): number { + return 1 + (node.calls?.reduce((sum, c) => sum + countCalls(c), 0) ?? 0); +} + +/** Count reverted calls in a tree */ +export function countReverts(node: CallNode): number { + const selfReverted = node.error ? 1 : 0; + return selfReverted + (node.calls?.reduce((sum, c) => sum + countReverts(c), 0) ?? 0); +} + +/** Count calls by type (CALL, STATICCALL, DELEGATECALL, CREATE, etc.) */ +export function countByType(node: CallNode): Record { + const counts: Record = {}; + function traverse(n: CallNode) { + const type = n.type.toUpperCase(); + counts[type] = (counts[type] ?? 0) + 1; + n.calls?.forEach(traverse); + } + traverse(node); + return counts; +} + +/** Collect all unique addresses from a call tree (lowercased). */ +export function collectAddresses(node: CallNode): string[] { + const addrs = new Set(); + function traverse(n: CallNode) { + if (n.from) addrs.add(n.from.toLowerCase()); + if (n.to) addrs.add(n.to.toLowerCase()); + n.calls?.forEach(traverse); + } + traverse(node); + return Array.from(addrs); +} + +/** Parse a hex gas value to a number (returns undefined when input is falsy). */ +export function hexToGas(hex: string | undefined): number | undefined { + if (!hex) return undefined; + return Number.parseInt(hex.startsWith("0x") ? hex : `0x${hex}`, 16); +} diff --git a/packages/utils/src/tx-analysis/contracts.ts b/packages/utils/src/tx-analysis/contracts.ts new file mode 100644 index 0000000..12fd480 --- /dev/null +++ b/packages/utils/src/tx-analysis/contracts.ts @@ -0,0 +1,163 @@ +import type { ContractInfo } from "./types.js"; + +const MAX_PROXY_DEPTH = 5; +const DEFAULT_CONCURRENCY = 6; + +export interface FetchContractInfoOptions { + /** Optional AbortSignal to cancel in-flight requests. */ + signal?: AbortSignal; + /** + * Etherscan V2 API key. When omitted, only Sourcify is queried. + * Contracts that are only on Etherscan will return null without a key. + */ + etherscanKey?: string; + /** + * Optional `fetch` override. Defaults to the global `fetch`. Useful for + * injecting a custom agent, proxy, or test stub. + */ + fetcher?: typeof fetch; + /** + * Maximum concurrent requests in `fetchContractInfoBatch`. Defaults to 6. + */ + concurrency?: number; +} + +function getFetcher(opts?: FetchContractInfoOptions): typeof fetch { + return opts?.fetcher ?? fetch; +} + +async function fetchEtherscanVerification( + address: string, + chainId: number, + opts?: FetchContractInfoOptions, + // biome-ignore lint/suspicious/noExplicitAny: Etherscan response shape varies +): Promise { + const key = opts?.etherscanKey; + if (!key) return null; + + const fetchFn = getFetcher(opts); + try { + const url = `https://api.etherscan.io/v2/api?chainid=${encodeURIComponent(chainId)}&module=contract&action=getsourcecode&address=${encodeURIComponent(address)}&apikey=${encodeURIComponent(key)}`; + const res = await fetchFn(url, { signal: opts?.signal }); + if (!res.ok) return null; + return await res.json(); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") throw err; + return null; + } +} + +async function fetchSingleContract( + address: string, + chainId: number, + opts: FetchContractInfoOptions | undefined, + visited: Set, + depth: number, +): Promise { + const fetchFn = getFetcher(opts); + + try { + const res = await fetchFn( + `https://sourcify.dev/server/v2/contract/${encodeURIComponent(chainId)}/${encodeURIComponent(address)}?fields=abi,compilation,proxyResolution`, + { signal: opts?.signal }, + ); + if (res.ok) { + // biome-ignore lint/suspicious/noExplicitAny: Sourcify response shape varies + const data = (await res.json()) as any; + const name = data?.compilation?.name; + let abi = data?.abi; + + const implAddrRaw = data?.proxyResolution?.implementations?.[0]?.address; + if (implAddrRaw && depth < MAX_PROXY_DEPTH) { + const implAddr = String(implAddrRaw).toLowerCase(); + if (!visited.has(implAddr)) { + visited.add(implAddr); + const implInfo = await fetchSingleContract(implAddr, chainId, opts, visited, depth + 1); + if (implInfo?.abi) { + abi = abi ? [...abi, ...implInfo.abi] : implInfo.abi; + } + } + } + + if (abi || name) { + return { name, abi }; + } + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return null; + } + + try { + const json = await fetchEtherscanVerification(address, chainId, opts); + if ( + json?.status === "1" && + Array.isArray(json.result) && + json.result[0]?.ABI && + json.result[0].ABI !== "Contract source code not verified" + ) { + const r = json.result[0]; + const abi = JSON.parse(r.ABI); + return { name: r.ContractName || undefined, abi }; + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return null; + } + + return null; +} + +/** + * Fetch contract name + ABI for a single address. + * Tries Sourcify first; falls back to Etherscan V2 if `etherscanKey` is provided. + * Proxy implementations are followed up to MAX_PROXY_DEPTH with cycle detection. + * Stateless — no in-memory cache. Callers manage their own caching if needed. + */ +export async function fetchContractInfo( + address: string, + chainId: number, + opts?: FetchContractInfoOptions, +): Promise { + const visited = new Set([address.toLowerCase()]); + return fetchSingleContract(address, chainId, opts, visited, 0); +} + +/** + * Fetch contract info for multiple addresses with a bounded concurrency pool. + * Returns a map of lowercased address → ContractInfo (only addresses where + * verification was found are included). + */ +export async function fetchContractInfoBatch( + addresses: string[], + chainId: number, + opts?: FetchContractInfoOptions, +): Promise> { + const concurrency = Math.max(1, opts?.concurrency ?? DEFAULT_CONCURRENCY); + const results: (ContractInfo | null)[] = new Array(addresses.length).fill(null); + + let next = 0; + async function worker(): Promise { + while (true) { + const i = next++; + if (i >= addresses.length) return; + const addr = addresses[i]; + if (!addr) continue; + try { + results[i] = await fetchContractInfo(addr, chainId, opts); + } catch { + results[i] = null; + } + } + } + + const workerCount = Math.min(concurrency, addresses.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + + const map: Record = {}; + for (let i = 0; i < addresses.length; i++) { + const addr = addresses[i]?.toLowerCase(); + if (!addr) continue; + const info = results[i]; + if (info) map[addr] = info; + } + return map; +} diff --git a/packages/utils/src/tx-analysis/errors.ts b/packages/utils/src/tx-analysis/errors.ts new file mode 100644 index 0000000..6133808 --- /dev/null +++ b/packages/utils/src/tx-analysis/errors.ts @@ -0,0 +1,7 @@ +/** + * Classify an error message as "the RPC does not support this trace method". + * Covers the common wording across geth, erigon, nethermind and hosted providers. + */ +export function isUnsupportedTraceError(msg: string): boolean { + return /method not found|not supported|unsupported|does not exist/i.test(msg); +} diff --git a/packages/utils/src/tx-analysis/fetchers.ts b/packages/utils/src/tx-analysis/fetchers.ts new file mode 100644 index 0000000..7698e13 --- /dev/null +++ b/packages/utils/src/tx-analysis/fetchers.ts @@ -0,0 +1,127 @@ +import { normalizeGethCallTrace } from "./callTree.js"; +import { isUnsupportedTraceError } from "./errors.js"; +import { buildCallTreeFromStructLogs, buildPrestateFromStructLogs } from "./structLogs.js"; +import type { CallNode, PrestateTrace, TraceResult, TxContext } from "./types.js"; + +/** + * Minimal shape of `@openscan/network-connectors` NetworkClient used here. + * Declared locally so utils stays zero-dep; the peer package's client + * implicitly satisfies this interface. + */ +export interface TraceRpcClient { + execute( + method: string, + params?: unknown[], + // biome-ignore lint/suspicious/noExplicitAny: network-connectors StrategyResult shape + ): Promise<{ success: boolean; data?: T; errors?: any[] }>; +} + +interface RpcTx { + from?: string; + to?: string; + value?: string; + gas?: string; + input?: string; +} + +async function getTxContext(client: TraceRpcClient, txHash: string): Promise { + const res = await client.execute("eth_getTransactionByHash", [txHash]); + if (!res.success || !res.data) return null; + const tx = res.data; + return { + from: tx.from ?? "", + to: tx.to ?? "", + value: tx.value ?? "0x0", + gas: tx.gas ?? "0x0", + input: tx.input ?? "0x", + }; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Load the call tree for a transaction. + * + * Tries Geth's `callTracer` first. If the node responds with an + * "unsupported" style error (e.g., Hardhat v3), falls back to the default + * struct-log tracer and reconstructs the tree from opcodes. + * + * Returns `null` when no trace could be produced. + */ +export async function loadCallTrace( + client: TraceRpcClient, + txHash: string, +): Promise { + try { + const res = await client.execute("debug_traceTransaction", [ + txHash, + { tracer: "callTracer" }, + ]); + if (res.success && res.data) { + return normalizeGethCallTrace(res.data); + } + const firstError = res.errors?.[0]; + const msg = + typeof firstError?.error === "string" ? firstError.error : (firstError?.message ?? ""); + if (msg && !isUnsupportedTraceError(msg)) return null; + } catch (err) { + if (!isUnsupportedTraceError(errorMessage(err))) throw err; + } + + const [traceRes, ctx] = await Promise.all([ + client.execute("debug_traceTransaction", [txHash, {}]), + getTxContext(client, txHash), + ]); + const trace = traceRes.success ? (traceRes.data ?? null) : null; + if (!trace?.structLogs || !ctx) return null; + return buildCallTreeFromStructLogs(trace, ctx); +} + +/** + * Load the prestate/poststate diff for a transaction. + * + * Tries Geth's `prestateTracer` with `diffMode: true`. Falls back to + * reconstructing a best-effort state diff from SLOAD/SSTORE opcodes in + * the default struct-log tracer when the native tracer is unsupported. + */ +export async function loadPrestateTrace( + client: TraceRpcClient, + txHash: string, +): Promise { + try { + const res = await client.execute("debug_traceTransaction", [ + txHash, + { tracer: "prestateTracer", tracerConfig: { diffMode: true } }, + ]); + if (res.success && res.data) return res.data; + const firstError = res.errors?.[0]; + const msg = + typeof firstError?.error === "string" ? firstError.error : (firstError?.message ?? ""); + if (msg && !isUnsupportedTraceError(msg)) return null; + } catch (err) { + if (!isUnsupportedTraceError(errorMessage(err))) throw err; + } + + const [traceRes, ctx] = await Promise.all([ + client.execute("debug_traceTransaction", [txHash, {}]), + getTxContext(client, txHash), + ]); + const trace = traceRes.success ? (traceRes.data ?? null) : null; + if (!trace?.structLogs || !ctx) return null; + return buildPrestateFromStructLogs(trace, ctx); +} + +/** + * Load the raw opcode-level trace (`debug_traceTransaction` with the default + * struct-log tracer). Returns `null` if unavailable. + */ +export async function loadRawTrace( + client: TraceRpcClient, + txHash: string, +): Promise { + const res = await client.execute("debug_traceTransaction", [txHash, {}]); + if (!res.success) return null; + return res.data ?? null; +} diff --git a/packages/utils/src/tx-analysis/index.ts b/packages/utils/src/tx-analysis/index.ts new file mode 100644 index 0000000..e41682d --- /dev/null +++ b/packages/utils/src/tx-analysis/index.ts @@ -0,0 +1,36 @@ +export type { + AnalyzeTxInput, + AnalyzeTxLog, + CallNode, + ContractInfo, + PrestateAccountState, + PrestateTrace, + TraceLog, + TraceResult, + TxAnalysis, + TxAnalysisSection, + TxContext, +} from "./types.js"; + +export { + collectAddresses, + countByType, + countCalls, + countReverts, + hexToGas, + normalizeGethCallTrace, + normalizeParityCallTrace, +} from "./callTree.js"; +export type { ParityTrace } from "./callTree.js"; + +export { buildCallTreeFromStructLogs, buildPrestateFromStructLogs } from "./structLogs.js"; + +export { fetchContractInfo, fetchContractInfoBatch } from "./contracts.js"; +export type { FetchContractInfoOptions } from "./contracts.js"; + +export { isUnsupportedTraceError } from "./errors.js"; + +export { loadCallTrace, loadPrestateTrace, loadRawTrace } from "./fetchers.js"; +export type { TraceRpcClient } from "./fetchers.js"; + +export { analyzeTx } from "./analyze.js"; diff --git a/packages/utils/src/tx-analysis/structLogs.ts b/packages/utils/src/tx-analysis/structLogs.ts new file mode 100644 index 0000000..475fdfe --- /dev/null +++ b/packages/utils/src/tx-analysis/structLogs.ts @@ -0,0 +1,294 @@ +import type { + CallNode, + PrestateAccountState, + PrestateTrace, + TraceLog, + TraceResult, + TxContext, +} from "./types.js"; + +const CALL_OPS = new Set(["CALL", "STATICCALL", "DELEGATECALL", "CALLCODE", "CREATE", "CREATE2"]); +const RETURN_OPS = new Set(["RETURN", "REVERT", "STOP", "SELFDESTRUCT", "INVALID"]); + +function stackAddr(word: string): string { + const raw = word.replace(/^0x/, "").padStart(40, "0"); + return `0x${raw.slice(-40)}`; +} + +function ensureHex(word: string): string { + return word.startsWith("0x") ? word : `0x${word}`; +} + +function toHex(n: number): string { + return `0x${n.toString(16)}`; +} + +interface FrameInfo { + node: CallNode; + startGas: number; + depth: number; +} + +/** + * Build a CallNode tree from EVM struct logs (opcode-level trace). + * + * Used when the RPC only supports the default struct log tracer and not + * Geth's `callTracer` — the primary real-world case is Hardhat v3. + * + * Tracks CALL/STATICCALL/DELEGATECALL/CREATE opcodes and their corresponding + * RETURN/REVERT/STOP to reconstruct the call hierarchy. + */ +export function buildCallTreeFromStructLogs(trace: TraceResult, tx: TxContext): CallNode { + const root: CallNode = { + type: "CALL", + from: tx.from, + to: tx.to, + value: tx.value, + gas: tx.gas, + gasUsed: toHex(trace.gas), + input: tx.input, + output: trace.returnValue ? ensureHex(trace.returnValue) : undefined, + error: trace.failed ? "execution reverted" : undefined, + calls: [], + }; + + const stack: FrameInfo[] = [{ node: root, startGas: Number.parseInt(tx.gas, 16) || 0, depth: 1 }]; + const logs = trace.structLogs; + + for (let i = 0; i < logs.length; i++) { + const log = logs[i] as TraceLog; + + // Align the stack to the opcode's depth: pop any frames whose context has + // already returned (e.g., a precompile CALL never entered a child frame, + // or a nested RETURN happened without us popping yet). + while (stack.length > 1 && (stack[stack.length - 1] as FrameInfo).depth > log.depth) { + stack.pop(); + } + + if (RETURN_OPS.has(log.op)) { + const top = stack[stack.length - 1] as FrameInfo; + if (top.depth === log.depth) { + if (log.op === "REVERT") top.node.error = "execution reverted"; + top.node.gasUsed = toHex(Math.max(0, top.startGas - log.gas)); + } + continue; + } + + if (CALL_OPS.has(log.op) && log.stack) { + const child = extractCallFromStack(log, stack); + if (!child) continue; + const parent = stack[stack.length - 1] as FrameInfo; + if (!parent.node.calls) parent.node.calls = []; + parent.node.calls.push(child.node); + + const next = logs[i + 1]; + const startsNewFrame = next?.depth === log.depth + 1; + if (startsNewFrame) { + child.depth = log.depth + 1; + stack.push(child); + } + // Otherwise the call never entered a child context (precompile, EOA, + // early failure) — leave it as a leaf; gasUsed stays undefined. + } + } + + cleanEmptyCalls(root); + return root; +} + +/** + * Stack layout at a CALL-type opcode (top of stack = last element): + * CALL/CALLCODE: gas, to, value, inOffset, inSize, outOffset, outSize + * STATICCALL/DELEGATECALL: gas, to, inOffset, inSize, outOffset, outSize + * CREATE: value, offset, size + * CREATE2: value, offset, size, salt + */ +function extractCallFromStack(log: TraceLog, callStack: FrameInfo[]): FrameInfo | null { + const s = log.stack; + if (!s || s.length === 0) return null; + + const parent = callStack[callStack.length - 1] as FrameInfo; + let type = log.op; + let to: string | undefined; + let value: string | undefined; + let gas: string | undefined; + + const len = s.length; + + switch (log.op) { + case "CALL": + case "CALLCODE": { + if (len < 7) return null; + const gasWord = s[len - 1] as string; + const toWord = s[len - 2] as string; + const valWord = s[len - 3] as string; + gas = ensureHex(gasWord); + to = stackAddr(toWord); + value = ensureHex(valWord); + break; + } + case "STATICCALL": + case "DELEGATECALL": { + if (len < 6) return null; + const gasWord = s[len - 1] as string; + const toWord = s[len - 2] as string; + gas = ensureHex(gasWord); + to = stackAddr(toWord); + value = "0x0"; + break; + } + case "CREATE": { + if (len < 3) return null; + const valWord = s[len - 1] as string; + value = ensureHex(valWord); + gas = toHex(log.gas); + type = "CREATE"; + to = undefined; + break; + } + case "CREATE2": { + if (len < 4) return null; + const valWord = s[len - 1] as string; + value = ensureHex(valWord); + gas = toHex(log.gas); + type = "CREATE2"; + to = undefined; + break; + } + default: + return null; + } + + const from = + log.op === "DELEGATECALL" + ? (parent.node.from ?? "") + : (parent.node.to ?? parent.node.from ?? ""); + + const node: CallNode = { + type: type.toUpperCase(), + from, + to, + value, + gas, + gasUsed: undefined, + input: undefined, + output: undefined, + calls: [], + }; + + const startGas = gas ? Number.parseInt(gas, 16) || 0 : log.gas; + return { node, startGas, depth: log.depth + 1 }; +} + +function cleanEmptyCalls(node: CallNode): void { + if (node.calls && node.calls.length === 0) { + node.calls = undefined; + } else if (node.calls) { + for (const child of node.calls) { + cleanEmptyCalls(child); + } + } +} + +/** + * Build a PrestateTrace (pre/post state diff) from EVM struct logs by tracking + * SLOAD/SSTORE opcodes. Best-effort approximation — struct logs don't contain + * the full pre/post state that a native prestateTracer would give. + */ +export function buildPrestateFromStructLogs( + trace: TraceResult, + tx: TxContext, +): PrestateTrace | null { + const pre: Record = {}; + const post: Record = {}; + + const storageReads: Record> = {}; + const storageWrites: Record> = {}; + + const addressByDepth: Record = {}; + addressByDepth[1] = tx.to.toLowerCase(); + + for (const log of trace.structLogs) { + const currentAddr = addressByDepth[log.depth] ?? tx.to.toLowerCase(); + + if (log.op === "SLOAD" && log.stack && log.storage) { + const slot = log.stack[log.stack.length - 1]; + if (slot !== undefined) { + const addr = currentAddr; + if (!storageReads[addr]) storageReads[addr] = {}; + const hexSlot = ensureHex(slot); + const rawSlot = slot.replace(/^0x/, ""); + const storageVal = log.storage[rawSlot]; + if (storageVal) { + (storageReads[addr] as Record)[hexSlot] = ensureHex(storageVal); + } + } + } + + if (log.op === "SSTORE" && log.stack) { + const len = log.stack.length; + if (len >= 2) { + const slot = log.stack[len - 1]; + const val = log.stack[len - 2]; + if (slot !== undefined && val !== undefined) { + const addr = currentAddr; + if (!storageWrites[addr]) storageWrites[addr] = {}; + const hexSlot = ensureHex(slot); + (storageWrites[addr] as Record)[hexSlot] = ensureHex(val); + + const addrReads = storageReads[addr]; + if ((!addrReads || !addrReads[hexSlot]) && log.storage) { + if (!storageReads[addr]) storageReads[addr] = {}; + const rawSlot = slot.replace(/^0x/, ""); + const storageVal = log.storage[rawSlot]; + if (storageVal) { + (storageReads[addr] as Record)[hexSlot] = ensureHex(storageVal); + } + } + } + } + } + + if (CALL_OPS.has(log.op) && log.stack) { + const len = log.stack.length; + if (log.op === "CALL" || log.op === "CALLCODE" || log.op === "STATICCALL") { + const toWord = len >= 2 ? log.stack[len - 2] : undefined; + if (toWord) { + addressByDepth[log.depth + 1] = stackAddr(toWord); + } + } else if (log.op === "DELEGATECALL") { + addressByDepth[log.depth + 1] = currentAddr; + } + } + } + + for (const [addr, slots] of Object.entries(storageReads)) { + if (!pre[addr]) pre[addr] = {}; + (pre[addr] as PrestateAccountState).storage = slots; + } + + for (const [addr, slots] of Object.entries(storageWrites)) { + if (!post[addr]) post[addr] = {}; + const preStorage = storageReads[addr] ?? {}; + (post[addr] as PrestateAccountState).storage = { ...preStorage, ...slots }; + } + + for (const addr of Object.keys(storageWrites)) { + if (!pre[addr]) pre[addr] = {}; + const preEntry = pre[addr] as PrestateAccountState; + if (!preEntry.storage) preEntry.storage = storageReads[addr] ?? {}; + } + + const sender = tx.from.toLowerCase(); + const receiver = tx.to.toLowerCase(); + if (!pre[sender]) pre[sender] = {}; + if (!post[sender]) post[sender] = {}; + if (!pre[receiver]) pre[receiver] = {}; + if (!post[receiver]) post[receiver] = {}; + + if (Object.keys(pre).length === 0 && Object.keys(post).length === 0) { + return null; + } + + return { pre, post }; +} diff --git a/packages/utils/src/tx-analysis/types.ts b/packages/utils/src/tx-analysis/types.ts new file mode 100644 index 0000000..cbb9844 --- /dev/null +++ b/packages/utils/src/tx-analysis/types.ts @@ -0,0 +1,96 @@ +export interface TraceLog { + pc: number; + op: string; + gas: number; + gasCost: number; + depth: number; + stack: string[]; + memory?: string[]; + storage?: Record; +} + +export interface TraceResult { + gas: number; + failed: boolean; + returnValue: string; + structLogs: TraceLog[]; +} + +export interface CallNode { + type: string; + from: string; + to?: string; + value?: string; + gas?: string; + gasUsed?: string; + input?: string; + output?: string; + error?: string; + revertReason?: string; + calls?: CallNode[]; +} + +export interface PrestateAccountState { + balance?: string; + nonce?: number; + code?: string; + storage?: Record; +} + +export interface PrestateTrace { + pre: Record; + post: Record; +} + +export interface ContractInfo { + name?: string; + // biome-ignore lint/suspicious/noExplicitAny: ABI entries are dynamic + abi?: any[]; +} + +export interface TxContext { + from: string; + to: string; + value: string; + gas: string; + input: string; +} + +export interface TxAnalysisSection { + data: T | null; + error: string | null; +} + +export interface TxAnalysis { + callTree: TxAnalysisSection; + prestate: TxAnalysisSection; + rawTrace: TxAnalysisSection; + contracts: Record; + addresses: string[]; + analytics?: { + totalCalls: number; + reverts: number; + byType: Record; + }; +} + +export interface AnalyzeTxLog { + address?: string; + topics?: string[]; + data?: string; +} + +export interface AnalyzeTxInput { + txHash: string; + chainId: number; + rpcUrls?: string[]; + logs?: AnalyzeTxLog[]; + include?: { + callTree?: boolean; + prestate?: boolean; + rawTrace?: boolean; + contracts?: boolean; + }; + etherscanKey?: string; + signal?: AbortSignal; +} diff --git a/packages/utils/tests/tx-analysis.test.ts b/packages/utils/tests/tx-analysis.test.ts new file mode 100644 index 0000000..b0e0074 --- /dev/null +++ b/packages/utils/tests/tx-analysis.test.ts @@ -0,0 +1,455 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + buildCallTreeFromStructLogs, + collectAddresses, + countByType, + countCalls, + countReverts, + fetchContractInfo, + fetchContractInfoBatch, + hexToGas, + isUnsupportedTraceError, + normalizeGethCallTrace, + normalizeParityCallTrace, +} from "../src/tx-analysis/index.js"; +import type { CallNode, ParityTrace, TraceResult } from "../src/tx-analysis/index.js"; + +describe("normalizeGethCallTrace", () => { + it("uppercases type and preserves nested calls", () => { + const raw = { + type: "call", + from: "0xA", + to: "0xB", + gas: "0x1", + gasUsed: "0x1", + calls: [{ type: "staticcall", from: "0xB", to: "0xC", gas: "0x0", gasUsed: "0x0" }], + }; + const node = normalizeGethCallTrace(raw); + assert.equal(node.type, "CALL"); + assert.equal(node.calls?.[0]?.type, "STATICCALL"); + assert.equal(node.calls?.[0]?.to, "0xC"); + }); + + it("defaults type to CALL when missing", () => { + const node = normalizeGethCallTrace({ from: "0xA" }); + assert.equal(node.type, "CALL"); + assert.equal(node.from, "0xA"); + }); + + it("passes through revertReason", () => { + const node = normalizeGethCallTrace({ type: "CALL", from: "0xA", revertReason: "bad state" }); + assert.equal(node.revertReason, "bad state"); + }); +}); + +describe("normalizeParityCallTrace", () => { + it("builds a tree from a flat traceAddress list", () => { + const traces: ParityTrace[] = [ + { + type: "call", + action: { callType: "call", from: "0xA", to: "0xB" }, + result: { gasUsed: "0x1" }, + traceAddress: [], + subtraces: 2, + }, + { + type: "call", + action: { callType: "staticcall", from: "0xB", to: "0xC" }, + traceAddress: [0], + subtraces: 0, + }, + { + type: "call", + action: { callType: "delegatecall", from: "0xB", to: "0xD" }, + traceAddress: [1], + subtraces: 0, + }, + ]; + const root = normalizeParityCallTrace(traces); + assert.ok(root); + assert.equal(root?.type, "CALL"); + assert.equal(root?.calls?.length, 2); + assert.equal(root?.calls?.[0]?.type, "STATICCALL"); + assert.equal(root?.calls?.[1]?.type, "DELEGATECALL"); + }); + + it("returns null on empty input", () => { + assert.equal(normalizeParityCallTrace([]), null); + }); + + it("returns null when no root trace exists", () => { + const traces: ParityTrace[] = [ + { + type: "call", + action: { from: "0xA" }, + traceAddress: [0], + subtraces: 0, + }, + ]; + assert.equal(normalizeParityCallTrace(traces), null); + }); +}); + +describe("call tree analytics", () => { + const tree: CallNode = { + type: "CALL", + from: "0xAAA", + to: "0xBBB", + calls: [ + { + type: "STATICCALL", + from: "0xBBB", + to: "0xCCC", + error: "execution reverted", + }, + { + type: "DELEGATECALL", + from: "0xBBB", + to: "0xDDD", + calls: [ + { + type: "CALL", + from: "0xDDD", + to: "0xEEE", + }, + ], + }, + ], + }; + + it("counts calls recursively", () => { + assert.equal(countCalls(tree), 4); + }); + + it("counts reverts", () => { + assert.equal(countReverts(tree), 1); + }); + + it("counts by type", () => { + const counts = countByType(tree); + assert.deepEqual(counts, { CALL: 2, STATICCALL: 1, DELEGATECALL: 1 }); + }); + + it("collects unique lowercased addresses", () => { + const addrs = collectAddresses(tree).sort(); + assert.deepEqual(addrs, ["0xaaa", "0xbbb", "0xccc", "0xddd", "0xeee"]); + }); +}); + +describe("hexToGas", () => { + it("parses plain hex", () => { + assert.equal(hexToGas("0x5208"), 21000); + }); + + it("accepts hex without 0x prefix", () => { + assert.equal(hexToGas("5208"), 21000); + }); + + it("returns undefined for falsy input", () => { + assert.equal(hexToGas(undefined), undefined); + assert.equal(hexToGas(""), undefined); + }); +}); + +describe("isUnsupportedTraceError", () => { + it("matches geth's 'method not found'", () => { + assert.equal(isUnsupportedTraceError("the method debug_traceTransaction does not exist"), true); + }); + + it("matches 'not supported' phrasing", () => { + assert.equal(isUnsupportedTraceError("this tracer is not supported"), true); + }); + + it("is case-insensitive", () => { + assert.equal(isUnsupportedTraceError("UNSUPPORTED feature"), true); + }); + + it("rejects unrelated errors", () => { + assert.equal(isUnsupportedTraceError("transaction not found"), false); + assert.equal(isUnsupportedTraceError("insufficient funds"), false); + }); +}); + +describe("buildCallTreeFromStructLogs", () => { + it("reconstructs a single-frame root with no sub-calls", () => { + const trace: TraceResult = { + gas: 21000, + failed: false, + returnValue: "", + structLogs: [], + }; + const tree = buildCallTreeFromStructLogs(trace, { + from: "0xA", + to: "0xB", + value: "0x0", + gas: "0x5208", + input: "0x", + }); + assert.equal(tree.type, "CALL"); + assert.equal(tree.from, "0xA"); + assert.equal(tree.to, "0xB"); + assert.equal(tree.gasUsed, "0x5208"); + assert.equal(tree.calls, undefined); + }); + + it("builds a child frame for a CALL opcode and closes it on RETURN", () => { + const trace: TraceResult = { + gas: 100, + failed: false, + returnValue: "", + structLogs: [ + { + pc: 0, + op: "CALL", + gas: 1000, + gasCost: 0, + depth: 1, + // Stack bottom → top: outSize, outOffset, inSize, inOffset, value, to, gas + stack: [ + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x000000000000000000000000000000000000000000000000000000000000aaaa", + "0x64", + ], + }, + { pc: 1, op: "RETURN", gas: 500, gasCost: 0, depth: 2, stack: [] }, + ], + }; + const tree = buildCallTreeFromStructLogs(trace, { + from: "0xroot", + to: "0xparent", + value: "0x0", + gas: "0x3e8", + input: "0x", + }); + assert.equal(tree.calls?.length, 1); + const child = tree.calls?.[0]; + assert.equal(child?.type, "CALL"); + assert.equal(child?.to, "0x000000000000000000000000000000000000aaaa"); + assert.equal(child?.from, "0xparent"); + }); + + it("marks the root as reverted when trace.failed is true", () => { + const trace: TraceResult = { + gas: 21000, + failed: true, + returnValue: "", + structLogs: [], + }; + const tree = buildCallTreeFromStructLogs(trace, { + from: "0xA", + to: "0xB", + value: "0x0", + gas: "0x5208", + input: "0x", + }); + assert.equal(tree.error, "execution reverted"); + }); + + it("treats a CALL with no child-context as a leaf and does not corrupt the parent frame", () => { + // CALL at depth 1 followed by RETURN at depth 1 — the target was a + // precompile / EOA / early-failing call that never entered a child frame. + // The naive implementation would push a frame on CALL and pop it on the + // parent's RETURN, losing the parent's gasUsed annotation. + const trace: TraceResult = { + gas: 500, + failed: false, + returnValue: "", + structLogs: [ + { + pc: 0, + op: "CALL", + gas: 1000, + gasCost: 0, + depth: 1, + stack: [ + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x000000000000000000000000000000000000000000000000000000000000dead", + "0x64", + ], + }, + { pc: 1, op: "RETURN", gas: 500, gasCost: 0, depth: 1, stack: [] }, + ], + }; + const tree = buildCallTreeFromStructLogs(trace, { + from: "0xroot", + to: "0xparent", + value: "0x0", + gas: "0x3e8", + input: "0x", + }); + assert.equal(tree.calls?.length, 1); + const child = tree.calls?.[0]; + assert.equal(child?.to, "0x000000000000000000000000000000000000dead"); + assert.equal(child?.calls, undefined); + // Root's RETURN at depth 1 should annotate the root (not a phantom child). + assert.equal(tree.gasUsed, "0x1f4"); + }); + + it("re-aligns after a nested RETURN so a later sibling CALL attaches to the right parent", () => { + // depth 1: CALL → depth 2: CALL → depth 3: RETURN → depth 2: CALL + // The second CALL at depth 2 should be a sibling of the first CALL, both + // children of the root — not a child of the popped depth-3 frame. + const callStack = [ + "0x0", + "0x0", + "0x0", + "0x0", + "0x0", + "0x000000000000000000000000000000000000000000000000000000000000aaaa", + "0x64", + ]; + const trace: TraceResult = { + gas: 500, + failed: false, + returnValue: "", + structLogs: [ + { pc: 0, op: "CALL", gas: 1000, gasCost: 0, depth: 1, stack: callStack }, + { pc: 1, op: "CALL", gas: 800, gasCost: 0, depth: 2, stack: callStack }, + { pc: 2, op: "RETURN", gas: 700, gasCost: 0, depth: 3, stack: [] }, + { pc: 3, op: "CALL", gas: 600, gasCost: 0, depth: 2, stack: callStack }, + { pc: 4, op: "RETURN", gas: 500, gasCost: 0, depth: 3, stack: [] }, + { pc: 5, op: "RETURN", gas: 400, gasCost: 0, depth: 2, stack: [] }, + ], + }; + const tree = buildCallTreeFromStructLogs(trace, { + from: "0xroot", + to: "0xparent", + value: "0x0", + gas: "0x3e8", + input: "0x", + }); + assert.equal(tree.calls?.length, 1, "root should have one direct child (the depth-2 frame)"); + const depth2 = tree.calls?.[0]; + assert.equal(depth2?.calls?.length, 2, "depth-2 frame should own two sibling sub-calls"); + }); +}); + +describe("normalizeParityCallTrace deep nesting", () => { + it("indexes children by parent traceAddress without rescanning", () => { + const traces: ParityTrace[] = [ + { + type: "call", + action: { callType: "call", from: "0xA", to: "0xB" }, + traceAddress: [], + subtraces: 1, + }, + { + type: "call", + action: { callType: "call", from: "0xB", to: "0xC" }, + traceAddress: [0], + subtraces: 1, + }, + { + type: "call", + action: { callType: "staticcall", from: "0xC", to: "0xD" }, + traceAddress: [0, 0], + subtraces: 1, + }, + { + type: "call", + action: { callType: "delegatecall", from: "0xD", to: "0xE" }, + traceAddress: [0, 0, 0], + subtraces: 0, + }, + ]; + const root = normalizeParityCallTrace(traces); + assert.equal(root?.calls?.[0]?.calls?.[0]?.calls?.[0]?.type, "DELEGATECALL"); + assert.equal(root?.calls?.[0]?.calls?.[0]?.calls?.[0]?.to, "0xE"); + }); +}); + +describe("fetchContractInfo proxy safety", () => { + function makeSourcifyFetcher( + chain: Record, + ): typeof fetch { + return (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + const match = url.match(/\/contract\/\d+\/([^?]+)/); + const addr = match?.[1]?.toLowerCase() ?? ""; + const entry = chain[addr]; + if (!entry) { + return new Response(JSON.stringify({}), { status: 404 }); + } + const body = { + compilation: entry.name ? { name: entry.name } : undefined, + abi: entry.abi, + proxyResolution: entry.impl ? { implementations: [{ address: entry.impl }] } : undefined, + }; + return new Response(JSON.stringify(body), { status: 200 }); + }) as unknown as typeof fetch; + } + + it("stops on a proxy cycle (A → B → A) without infinite recursion", async () => { + const fetcher = makeSourcifyFetcher({ + "0xaaa": { name: "A", abi: [{ type: "function", name: "a" }], impl: "0xbbb" }, + "0xbbb": { name: "B", abi: [{ type: "function", name: "b" }], impl: "0xaaa" }, + }); + const info = await fetchContractInfo("0xaaa", 1, { fetcher }); + assert.ok(info); + // The cycle back to A must be ignored — we get A's ABI merged with B's once. + const names = (info?.abi ?? []).map((e) => (e as { name: string }).name).sort(); + assert.deepEqual(names, ["a", "b"]); + }); + + it("caps proxy depth at MAX_PROXY_DEPTH", async () => { + const chain: Record = {}; + // 10-long proxy chain: 0x0 → 0x1 → ... → 0x9 + for (let i = 0; i < 10; i++) { + const addr = `0x${i.toString(16).padStart(3, "0")}`; + const next = i < 9 ? `0x${(i + 1).toString(16).padStart(3, "0")}` : undefined; + chain[addr] = { abi: [{ type: "function", name: `f${i}` }], impl: next }; + } + const fetcher = makeSourcifyFetcher(chain); + const info = await fetchContractInfo("0x000", 1, { fetcher }); + assert.ok(info); + // MAX_PROXY_DEPTH = 5 → at most 6 distinct ABIs (root + 5 proxy hops). + assert.ok((info?.abi?.length ?? 0) <= 6, `expected <= 6 entries, got ${info?.abi?.length}`); + }); +}); + +describe("contract URL encoding", () => { + it("encodes chainId, address, and Etherscan API key", async () => { + const captured: string[] = []; + const fetcher = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + captured.push(url); + return new Response("{}", { status: 404 }); + }) as unknown as typeof fetch; + await fetchContractInfo("0xabc&evil=1", 1, { fetcher, etherscanKey: "k&y=x" }); + assert.ok( + captured.some((u) => u.includes(encodeURIComponent("0xabc&evil=1"))), + `address not encoded in ${captured.join(" | ")}`, + ); + assert.ok( + captured.some((u) => u.includes(encodeURIComponent("k&y=x"))), + `apikey not encoded in ${captured.join(" | ")}`, + ); + }); +}); + +describe("fetchContractInfoBatch concurrency", () => { + it("caps concurrent in-flight requests", async () => { + let inFlight = 0; + let peak = 0; + const fetcher = (async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 10)); + inFlight--; + return new Response("{}", { status: 404 }); + }) as unknown as typeof fetch; + const addresses = Array.from({ length: 30 }, (_, i) => `0x${i.toString(16).padStart(40, "0")}`); + await fetchContractInfoBatch(addresses, 1, { fetcher, concurrency: 4 }); + assert.ok(peak <= 4, `peak=${peak} exceeded concurrency=4`); + assert.ok(peak > 1, `expected some parallelism, peak=${peak}`); + }); +}); From e94d699d974f48462d25add469b4528afead2673 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Fri, 17 Apr 2026 16:51:27 -0300 Subject: [PATCH 3/8] feat(cli): Add tx analysis --- CLAUDE.md | 2 +- README.md | 23 +++++- packages/cli/CLAUDE.md | 23 +++++- packages/cli/src/bin.ts | 41 +++++++++-- packages/cli/src/commands/util/analyzeTx.ts | 80 +++++++++++++++++++++ packages/cli/src/handlers/index.ts | 1 + packages/cli/src/index.ts | 2 + 7 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/commands/util/analyzeTx.ts diff --git a/CLAUDE.md b/CLAUDE.md index 28ffc27..61a72dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full specification. | Package | Description | |---------|-------------| -| `@openscan/utils` | Zero-dep utilities: hex, units, address validation, ABI, events, signatures | +| `@openscan/utils` | Zero-dep utilities: hex, units, address validation, ABI, events, signatures, tx-analysis (call trees, prestate diffs, contract enrichment) | | `@openscan/algorithms` | On-chain algorithms: tx history, token balance, gas price | | `@openscan/cli` | CLI tool (`openscan`) wrapping algorithms and utils | | `@openscan/skills` | Markdown-based procedural knowledge for AI agents (skills.sh format, in `skills/` not `packages/`) | diff --git a/README.md b/README.md index dbda1a7..d648cfe 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Modular, TypeScript-first system for on-chain blockchain analysis. Built on top | Package | Description | |---------|-------------| -| `@openscan/utils` | Zero-dep utilities: hex, units, address validation, ABI, events, signatures | +| `@openscan/utils` | Zero-dep utilities: hex, units, address validation, ABI, events, signatures, tx-analysis (call trees, prestate diffs, contract enrichment) | | `@openscan/algorithms` | On-chain algorithms: tx history, token balance, gas price | | `@openscan/cli` | CLI tool (`openscan`) wrapping algorithms and utils | | `@openscan/skills` | Markdown-based procedural knowledge for AI agents (skills.sh format) | @@ -20,6 +20,27 @@ pnpm install pnpm build ``` +## CLI Usage + +After `pnpm build`, invoke the CLI via `node packages/cli/dist/bin.js ` (or link the `openscan` bin). + +### `analyze-tx ` + +High-level transaction debugger. Wraps `analyzeTx` from `@openscan/utils` to fetch the call tree, prestate diff, raw structLogs trace, and contract metadata in one pass. Requires an RPC that supports `debug_traceTransaction` (e.g., Alchemy) — falls back per-section with `{ data: null, error: "not supported" }` when a tracer isn't available. + +```bash +# Defaults: call tree + contracts +openscan analyze-tx 0x --chain 1 --alchemy-key "$ALCHEMY_API_KEY" + +# Full: add prestate diff and raw trace, enrich contracts via Etherscan +openscan analyze-tx 0x --chain 1 --alchemy-key "$ALCHEMY_API_KEY" \ + --include-prestate --include-raw-trace --etherscan-key "$ETHERSCAN_API_KEY" +``` + +Flags: `--include-prestate`, `--include-raw-trace`, `--skip-call-tree`, `--skip-contracts`, `--etherscan-key` (or `ETHERSCAN_API_KEY` env var). + +Result includes `verificationLinks` pointing at `https://openscan.eth.link/#/:chainId/tx/:txHash`. + ## Testing ### Run all tests diff --git a/packages/cli/CLAUDE.md b/packages/cli/CLAUDE.md index 29ff924..29d7c0e 100644 --- a/packages/cli/CLAUDE.md +++ b/packages/cli/CLAUDE.md @@ -30,6 +30,10 @@ export { handler as fooHandler }; - **Dual entry**: `bin.ts` for CLI usage, `index.ts` for programmatic imports. - **Handler barrel**: All handlers are re-exported from `src/handlers/index.ts` for the OpenClaw adapter. +## Verification Links + +`bin.ts` auto-appends `verificationLinks` to any object-typed `result.data` by calling `buildVerificationLinks` from `@openscan/utils`. It picks up `address` or `txHash` from either `commandArgs` (first positional flows) or `result.data`. Commands that expose a natural primary target (address / txHash / blockNumber) get the correct OpenScan link (`https://openscan.eth.link/#/:chainId/{tx,address,block}/:value`) for free — no per-command wiring needed. + ## RPC Resolution - `--rpc` is **optional**. If omitted, public RPCs are auto-resolved from `@openscan/metadata` for the given chain. @@ -97,7 +101,7 @@ src/ export { handler as fooHandler }; ``` - **For utility commands** — use RPC directly with try/finally: + **For utility commands that own the RPC client** — use RPC directly with try/finally: ```typescript import { validateAddress } from "@openscan/utils"; @@ -117,6 +121,23 @@ src/ await client.close(); } }; + ``` + + **For utility commands that delegate to an orchestrator** (e.g. `analyzeTx`) — pass `rpcUrls` through; the orchestrator owns the client lifecycle. Do **not** wrap in try/finally: + + ```typescript + import { analyzeTx } from "@openscan/utils"; + import type { CommandDefinition, CommandHandler } from "../../types.js"; + + const handler: CommandHandler = async (args, ctx) => { + const analysis = await analyzeTx({ + txHash: args.txHash as string, + chainId: Number(ctx.chainId), + rpcUrls: ctx.rpcUrls, + // include, etherscanKey, ... + }); + return { exitCode: 0, data: analysis }; + }; export const barCommand: CommandDefinition = { name: "bar", diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 7f8dcbe..3303422 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -7,6 +7,7 @@ import { tokenBalanceCommand } from "./commands/algo/tokenBalance.js"; import { addressTypeCommand } from "./commands/util/addressType.js"; import { decodeInputCommand } from "./commands/util/decodeInput.js"; import { balanceCommand } from "./commands/util/balance.js"; +import { analyzeTxCommand } from "./commands/util/analyzeTx.js"; import { formatOutput } from "./output/formatters.js"; import { resolveRpcUrls } from "./rpc/resolve.js"; import { buildVerificationLinks } from "@openscan/utils"; @@ -20,6 +21,7 @@ registry.register(tokenBalanceCommand); registry.register(addressTypeCommand); registry.register(decodeInputCommand); registry.register(balanceCommand); +registry.register(analyzeTxCommand); const main = defineCommand({ meta: { @@ -73,6 +75,30 @@ const main = defineCommand({ "token-address": { type: "string", alias: "t", description: "Token contract address" }, abi: { type: "string", description: "Path to ABI JSON file" }, granularity: { type: "string", description: "Data granularity" }, + "include-prestate": { + type: "boolean", + description: "Include prestate diff (analyze-tx)", + default: false, + }, + "include-raw-trace": { + type: "boolean", + description: "Include raw structLogs trace (analyze-tx)", + default: false, + }, + "skip-call-tree": { + type: "boolean", + description: "Skip call tree fetch (analyze-tx)", + default: false, + }, + "skip-contracts": { + type: "boolean", + description: "Skip contract metadata enrichment (analyze-tx)", + default: false, + }, + "etherscan-key": { + type: "string", + description: "Etherscan API key (or ETHERSCAN_API_KEY env var)", + }, }, async run({ args }) { const commandName = args.command; @@ -124,6 +150,11 @@ const main = defineCommand({ if (args["token-address"]) commandArgs["token-address"] = args["token-address"]; if (args.abi) commandArgs.abi = args.abi; if (args.granularity) commandArgs.granularity = args.granularity; + if (args["include-prestate"]) commandArgs["include-prestate"] = args["include-prestate"]; + if (args["include-raw-trace"]) commandArgs["include-raw-trace"] = args["include-raw-trace"]; + if (args["skip-call-tree"]) commandArgs["skip-call-tree"] = args["skip-call-tree"]; + if (args["skip-contracts"]) commandArgs["skip-contracts"] = args["skip-contracts"]; + if (args["etherscan-key"]) commandArgs["etherscan-key"] = args["etherscan-key"]; // For commands that take data as first arg if (commandName === "decode-input" && args.args) { @@ -133,12 +164,12 @@ const main = defineCommand({ const result = await registry.execute(commandName, commandArgs, ctx); if (result.data && typeof result.data === "object") { - const address = (commandArgs.address ?? (result.data as Record).address) as - | string - | undefined; - const links = buildVerificationLinks({ chainId: ctx.chainId, address }); + const data = result.data as Record; + const address = (commandArgs.address ?? data.address) as string | undefined; + const txHash = (commandArgs.txHash ?? data.txHash) as string | undefined; + const links = buildVerificationLinks({ chainId: ctx.chainId, address, txHash }); if (links.length > 0) { - (result.data as Record).verificationLinks = links; + data.verificationLinks = links; } } diff --git a/packages/cli/src/commands/util/analyzeTx.ts b/packages/cli/src/commands/util/analyzeTx.ts new file mode 100644 index 0000000..0746f4a --- /dev/null +++ b/packages/cli/src/commands/util/analyzeTx.ts @@ -0,0 +1,80 @@ +import { analyzeTx } from "@openscan/utils"; +import type { CommandDefinition, CommandHandler } from "../../types.js"; + +const handler: CommandHandler = async (args, ctx) => { + const txHash = args.txHash as string; + + if (!txHash || !txHash.startsWith("0x")) { + return { + exitCode: 1, + error: { code: "INVALID_INPUT", message: "txHash must be a hex string starting with 0x" }, + }; + } + + if (ctx.rpcUrls.length === 0) { + return { + exitCode: 1, + error: { code: "NO_RPC", message: "At least one RPC URL is required" }, + }; + } + + const etherscanKey = + (args["etherscan-key"] as string | undefined) ?? process.env.ETHERSCAN_API_KEY; + + const analysis = await analyzeTx({ + txHash, + chainId: Number(ctx.chainId), + rpcUrls: ctx.rpcUrls, + include: { + callTree: !args["skip-call-tree"], + prestate: Boolean(args["include-prestate"]), + rawTrace: Boolean(args["include-raw-trace"]), + contracts: !args["skip-contracts"], + }, + etherscanKey, + }); + + return { exitCode: 0, data: analysis }; +}; + +export const analyzeTxCommand: CommandDefinition = { + name: "analyze-tx", + description: "Analyze a transaction: call tree, prestate diff, raw trace, contracts", + args: [ + { name: "txHash", description: "Transaction hash (0x...)", required: true, type: "string" }, + ], + flags: [ + { + name: "include-prestate", + description: "Include prestate diff", + type: "boolean", + default: false, + }, + { + name: "include-raw-trace", + description: "Include raw structLogs trace", + type: "boolean", + default: false, + }, + { + name: "skip-call-tree", + description: "Skip call tree fetch", + type: "boolean", + default: false, + }, + { + name: "skip-contracts", + description: "Skip contract metadata enrichment", + type: "boolean", + default: false, + }, + { + name: "etherscan-key", + description: "Etherscan API key (or ETHERSCAN_API_KEY env var)", + type: "string", + }, + ], + handler, +}; + +export { handler as analyzeTxHandler }; diff --git a/packages/cli/src/handlers/index.ts b/packages/cli/src/handlers/index.ts index cbea0d8..4f7dfab 100644 --- a/packages/cli/src/handlers/index.ts +++ b/packages/cli/src/handlers/index.ts @@ -5,3 +5,4 @@ export { tokenBalanceHandler } from "../commands/algo/tokenBalance.js"; export { addressTypeHandler } from "../commands/util/addressType.js"; export { decodeInputHandler } from "../commands/util/decodeInput.js"; export { balanceHandler } from "../commands/util/balance.js"; +export { analyzeTxHandler } from "../commands/util/analyzeTx.js"; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5143371..9979d43 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,6 +9,7 @@ export { tokenBalanceCommand } from "./commands/algo/tokenBalance.js"; export { addressTypeCommand } from "./commands/util/addressType.js"; export { decodeInputCommand } from "./commands/util/decodeInput.js"; export { balanceCommand } from "./commands/util/balance.js"; +export { analyzeTxCommand } from "./commands/util/analyzeTx.js"; // Handlers for direct invocation export { @@ -18,6 +19,7 @@ export { addressTypeHandler, decodeInputHandler, balanceHandler, + analyzeTxHandler, } from "./handlers/index.js"; // RPC resolution From 49c2883edaf2585cf633dd7e4360a0cd88cc5ecd Mon Sep 17 00:00:00 2001 From: Mati OS Date: Fri, 17 Apr 2026 17:11:24 -0300 Subject: [PATCH 4/8] docs(claude): add workflow rules for TDD, commits, and pre-finish gate --- CLAUDE.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 61a72dd..a970f21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,14 +36,19 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full specification. - **Zero production deps** in `@openscan/utils` (matching network-connectors philosophy) - **ES Modules** throughout (`"type": "module"`) -## Before Committing - -```bash -pnpm format:fix -pnpm lint:fix -pnpm typecheck -pnpm test -``` +## Workflow Rules + +- **TDD always**: Write tests first, then implementation. Before writing any tests, ask the user which tests to add. +- **Never co-author commits**: Do not add `Co-Authored-By: Claude` or any Claude/Anthropic attribution to commits. +- **Conventional Commits**: All commit messages must follow the [Conventional Commits](https://www.conventionalcommits.org/) spec (e.g. `feat(cli): ...`, `fix(utils): ...`, `chore: ...`). +- **Pre-finish gate**: Before declaring a task finished, all of the following must pass: + + ```bash + pnpm format:fix + pnpm lint:fix + pnpm typecheck + pnpm test + ``` ## Key Patterns From b70f544da9e8fe8f4a3cfea7a05386bbfbde7297 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Fri, 17 Apr 2026 17:32:34 -0300 Subject: [PATCH 5/8] feat(adapters-langchain): add getTransactionAnalysis tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps analyzeTx from @openscan/utils as a LangChain tool so agents can fetch call trees, prestate/poststate diffs, and contract metadata for a given tx hash. Exposes CLI-flag parity (includePrestate, includeRawTrace, skipCallTree, skipContracts, etherscanKey) as Zod fields. Tool description follows a 3-sentence what/when/when-NOT structure to disambiguate from get_transaction_history and warn about trace-enabled RPC requirements — top-level description is load-bearing because some LangChain provider adapters drop Zod .describe() text (langchainjs#9099). --- packages/adapters-langchain/CLAUDE.md | 1 + packages/adapters-langchain/README.md | 25 +++++ .../adapters-langchain/scripts/txAnalysis.ts | 43 ++++++++ packages/adapters-langchain/src/index.ts | 1 + .../src/tools/txAnalysis.ts | 103 ++++++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 packages/adapters-langchain/scripts/txAnalysis.ts create mode 100644 packages/adapters-langchain/src/tools/txAnalysis.ts diff --git a/packages/adapters-langchain/CLAUDE.md b/packages/adapters-langchain/CLAUDE.md index c6ea351..11b0552 100644 --- a/packages/adapters-langchain/CLAUDE.md +++ b/packages/adapters-langchain/CLAUDE.md @@ -142,5 +142,6 @@ The `scripts/` directory contains per-tool demo scripts that exercise each LangC | `txHistory.ts` | `getTransactionHistory` | Recent transactions for an address | | `addressType.ts` | `getAddressType` | Identify address type (EOA vs contract) | | `tokenBalance.ts` | `getTokenBalanceHistory` | Token balance history for an address | +| `txAnalysis.ts` | `getTransactionAnalysis` | Analyze a transaction (call tree + contracts) | When adding a new tool, add a corresponding demo script following the same pattern. diff --git a/packages/adapters-langchain/README.md b/packages/adapters-langchain/README.md index 681304a..adf5fb1 100644 --- a/packages/adapters-langchain/README.md +++ b/packages/adapters-langchain/README.md @@ -29,6 +29,7 @@ import { getGasPriceHistory, getAddressType, getTokenBalanceHistory, + getTransactionAnalysis, } from "@openscan/adapters-langchain"; const tools = [ @@ -36,6 +37,7 @@ const tools = [ getGasPriceHistory, getAddressType, getTokenBalanceHistory, + getTransactionAnalysis, ]; const llm = new ChatOpenAI({ model: "gpt-4o" }); @@ -64,6 +66,7 @@ console.log(result.output); | `getGasPriceHistory` | `get_gas_price_history` | Get gas price history by sampling blocks exponentially | | `getAddressType` | `detect_address_type` | Detect whether an address is an EOA, contract, or proxy | | `getTokenBalanceHistory` | `get_token_balance_history` | Track ERC-20 token balance changes via Transfer event logs | +| `getTransactionAnalysis` | `get_transaction_analysis` | Analyze a single confirmed tx: call tree, contracts, optional state diff / raw trace | ## RPC Resolution @@ -150,6 +153,22 @@ const result = await getTokenBalanceHistory.invoke({ console.log(result); // JSON string of balance changes over time ``` +### Transaction Analysis + +```typescript +import { getTransactionAnalysis } from "@openscan/adapters-langchain"; + +const result = await getTransactionAnalysis.invoke({ + txHash: "0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060", + chainId: 1, + alchemyKey: process.env.ALCHEMY_API_KEY, // recommended — trace RPC needed +}); + +console.log(result); // JSON string: call tree + addresses + contracts (+ optional prestate/raw trace) +``` + +Note: `analyzeTx` requires a trace-enabled RPC (`debug_traceTransaction`). Public RPCs frequently lack this; passing `alchemyKey` or explicit trace-enabled `rpcUrls` is strongly recommended. + ### Using with Alchemy Pass `alchemyKey` to any tool for premium RPC access: @@ -191,6 +210,12 @@ const result = await getGasPriceHistory.invoke({ | `getAddressType` | `address` | `string` | Yes | Address to classify | | `getTokenBalanceHistory` | `address` | `string` | Yes | Holder address to track | | `getTokenBalanceHistory` | `tokenAddress` | `string` | Yes | ERC-20 token contract address | +| `getTransactionAnalysis` | `txHash` | `string` | Yes | 0x-prefixed transaction hash | +| `getTransactionAnalysis` | `includePrestate` | `boolean` | No | Include pre/post state storage diff (default: false) | +| `getTransactionAnalysis` | `includeRawTrace` | `boolean` | No | Include raw opcode-level trace (default: false) | +| `getTransactionAnalysis` | `skipCallTree` | `boolean` | No | Skip call tree fetch (default: false) | +| `getTransactionAnalysis` | `skipContracts` | `boolean` | No | Skip contract metadata enrichment (default: false) | +| `getTransactionAnalysis` | `etherscanKey` | `string` | No | Etherscan API key (defaults to ETHERSCAN_API_KEY env var) | ## Architecture diff --git a/packages/adapters-langchain/scripts/txAnalysis.ts b/packages/adapters-langchain/scripts/txAnalysis.ts new file mode 100644 index 0000000..54f30be --- /dev/null +++ b/packages/adapters-langchain/scripts/txAnalysis.ts @@ -0,0 +1,43 @@ +import { createAgent } from "langchain"; +import { ChatGroq } from "@langchain/groq"; +import dotenvFlow from 'dotenv-flow'; +import { + getTransactionHistory, + getGasPriceHistory, + getAddressType, + getTokenBalanceHistory, + getTransactionAnalysis, +} from "@openscan/adapters-langchain"; + +dotenvFlow.config(); + +const model = new ChatGroq({ + model: "openai/gpt-oss-120b", + apiKey: process.env.API_KEY +}); + +const agent = createAgent({ + model, + tools: [ + getTransactionHistory, + getGasPriceHistory, + getAddressType, + getTokenBalanceHistory, + getTransactionAnalysis, + ] +}); + + +const r = await agent.invoke({ + messages: [{ role: "user", content: "Analyze Ethereum transaction 0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060 — what did it do and which contracts did it touch?" }], + }); + +const toolsUsed = r.messages + .filter((m: any) => m.tool_calls?.length > 0) + .flatMap((m: any) => m.tool_calls.map((tc: any) => tc.name)); +const response = r.messages.at(-1)?.content; + +console.log("\n--- Tools used ---"); +console.log(toolsUsed.join(", ")); +console.log("\n--- Response ---"); +console.log(response); diff --git a/packages/adapters-langchain/src/index.ts b/packages/adapters-langchain/src/index.ts index 3d55589..73419b8 100644 --- a/packages/adapters-langchain/src/index.ts +++ b/packages/adapters-langchain/src/index.ts @@ -3,3 +3,4 @@ export { getTransactionHistory } from "./tools/txHistory.js"; export { getGasPriceHistory } from "./tools/gasPrice.js"; export { getAddressType } from "./tools/addressType.js"; export { getTokenBalanceHistory } from "./tools/tokenBalance.js"; +export { getTransactionAnalysis } from "./tools/txAnalysis.js"; diff --git a/packages/adapters-langchain/src/tools/txAnalysis.ts b/packages/adapters-langchain/src/tools/txAnalysis.ts new file mode 100644 index 0000000..e532d7b --- /dev/null +++ b/packages/adapters-langchain/src/tools/txAnalysis.ts @@ -0,0 +1,103 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { analyzeTx } from "@openscan/utils"; +import { resolveRpcUrls } from "../rpc.js"; +import { injectVerificationLinks } from "../verify.js"; + +export const getTransactionAnalysis = tool( + async ({ + txHash, + chainId, + rpcUrls, + alchemyKey, + includePrestate, + includeRawTrace, + skipCallTree, + skipContracts, + etherscanKey, + }) => { + if (!txHash?.startsWith("0x")) { + return `Invalid txHash: must be a 0x-prefixed hex string`; + } + + const resolvedRpcUrls = rpcUrls ?? resolveRpcUrls({ chainId, alchemyKey }); + const analysis = await analyzeTx({ + txHash, + chainId, + rpcUrls: resolvedRpcUrls, + include: { + callTree: !skipCallTree, + prestate: Boolean(includePrestate), + rawTrace: Boolean(includeRawTrace), + contracts: !skipContracts, + }, + etherscanKey: etherscanKey ?? process.env.ETHERSCAN_API_KEY, + }); + + return JSON.stringify(injectVerificationLinks(analysis, { chainId, txHash }), null, 2); + }, + { + name: "get_transaction_analysis", + description: [ + "Analyze a single confirmed EVM transaction: returns its decoded call tree (nested internal calls with from/to/value/gasUsed/revertReason), a list of all addresses touched, per-address contract metadata (name + ABI from Sourcify/Etherscan), and optionally the prestate/poststate storage diff and raw opcode trace.", + "Use this when the user asks what a transaction did, why it reverted, which contracts it called, what state it changed, or to decode opaque calldata on a specific tx hash.", + "Do NOT use this to list a wallet's transactions (use get_transaction_history) or for pending/unconfirmed txs. Requires a trace-enabled RPC (debug_traceTransaction); public RPCs often lack this — pass alchemyKey or explicit rpcUrls for reliability.", + ].join(" "), + schema: z.object({ + txHash: z + .string() + .describe( + "The 66-char transaction hash, 0x-prefixed (e.g. 0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060). Must be a confirmed transaction.", + ), + chainId: z + .number() + .describe( + "EVM chain ID (1=Ethereum mainnet, 137=Polygon, 8453=Base, 42161=Arbitrum, 10=Optimism)", + ), + rpcUrls: z + .array(z.string()) + .optional() + .describe( + "Trace-enabled RPC endpoints. Omit to auto-resolve from public RPCs (may lack debug_traceTransaction).", + ), + alchemyKey: z + .string() + .optional() + .describe( + "Alchemy API key — strongly recommended since public RPCs often don't support debug_traceTransaction.", + ), + includePrestate: z + .boolean() + .optional() + .default(false) + .describe( + "Include pre/post state storage diff. Expensive — only set true when the user asks about state changes.", + ), + includeRawTrace: z + .boolean() + .optional() + .default(false) + .describe( + "Include raw opcode-level structLogs. Very large output — only set true when debugging opcode-level behavior.", + ), + skipCallTree: z + .boolean() + .optional() + .default(false) + .describe("Skip the call tree. Leave false unless you only need contracts/addresses."), + skipContracts: z + .boolean() + .optional() + .default(false) + .describe( + "Skip contract metadata enrichment (names + ABIs). Leave false unless minimizing latency.", + ), + etherscanKey: z + .string() + .optional() + .describe( + "Etherscan API key for contract verification fallback (defaults to ETHERSCAN_API_KEY env var).", + ), + }), + }, +); From d69ec9c8fd28b9535dbf5799303a4e9e39c88ca5 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Fri, 17 Apr 2026 17:32:39 -0300 Subject: [PATCH 6/8] refactor(adapters-langchain): improve tool descriptions for LLM selection - get_transaction_history: expand description with "use when" + "do NOT use" guidance pointing to get_transaction_analysis for single-tx analysis, preventing the mirror-image misrouting between the two tools. - address fields (txHistory, tokenBalance, addressType): add 42-char 0x-prefixed format hint with a concrete example so the LLM formats arguments correctly without hallucinating. --- packages/adapters-langchain/src/tools/addressType.ts | 6 +++++- .../adapters-langchain/src/tools/tokenBalance.ts | 12 ++++++++++-- packages/adapters-langchain/src/tools/txHistory.ts | 8 ++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/adapters-langchain/src/tools/addressType.ts b/packages/adapters-langchain/src/tools/addressType.ts index f5198ce..8bee411 100644 --- a/packages/adapters-langchain/src/tools/addressType.ts +++ b/packages/adapters-langchain/src/tools/addressType.ts @@ -27,7 +27,11 @@ export const getAddressType = tool( name: "detect_address_type", description: "Detect whether a blockchain address is an EOA, contract, or proxy", schema: z.object({ - address: z.string().describe("Blockchain address to check"), + address: z + .string() + .describe( + "42-char 0x-prefixed hex address to check (e.g. 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48)", + ), chainId: z.number().describe("EVM chain ID"), rpcUrls: z .array(z.string()) diff --git a/packages/adapters-langchain/src/tools/tokenBalance.ts b/packages/adapters-langchain/src/tools/tokenBalance.ts index 19b97af..17c51ad 100644 --- a/packages/adapters-langchain/src/tools/tokenBalance.ts +++ b/packages/adapters-langchain/src/tools/tokenBalance.ts @@ -30,8 +30,16 @@ export const getTokenBalanceHistory = tool( name: "get_token_balance_history", description: "Track ERC-20 token balance changes for an address via Transfer event logs", schema: z.object({ - address: z.string().describe("The holder address to track"), - tokenAddress: z.string().describe("The ERC-20 token contract address"), + address: z + .string() + .describe( + "42-char 0x-prefixed holder address to track (e.g. 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045)", + ), + tokenAddress: z + .string() + .describe( + "42-char 0x-prefixed ERC-20 token contract address (e.g. 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 for USDC)", + ), chainId: z.number().describe("EVM chain ID"), rpcUrls: z .array(z.string()) diff --git a/packages/adapters-langchain/src/tools/txHistory.ts b/packages/adapters-langchain/src/tools/txHistory.ts index 31c00c2..c934cc3 100644 --- a/packages/adapters-langchain/src/tools/txHistory.ts +++ b/packages/adapters-langchain/src/tools/txHistory.ts @@ -29,9 +29,13 @@ export const getTransactionHistory = tool( { name: "get_transaction_history", description: - "Get on-chain transaction history for a blockchain address by scanning Transfer event logs", + "List recent on-chain transactions touching a wallet/contract address by scanning Transfer event logs, returning a paginated array of transfer events. Use when the user asks for a wallet's activity, recent sends/receives, or to enumerate transfers involving an address. Do NOT use to analyze a single transaction — use get_transaction_analysis for that (tx hash → call tree / contracts / state diff).", schema: z.object({ - address: z.string().describe("The blockchain address to look up"), + address: z + .string() + .describe( + "42-char 0x-prefixed hex address to look up (e.g. 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045)", + ), chainId: z.number().describe("EVM chain ID (1=Ethereum, 137=Polygon, etc.)"), rpcUrls: z .array(z.string()) From 132c545deb1bf38e71ad26539f5e5c6c6f9177c3 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Sat, 18 Apr 2026 00:01:46 -0300 Subject: [PATCH 7/8] fear(skill): Add tx analysis --- skills/blockchain-exploration/SKILL.md | 1 + .../rules/tx-analysis.md | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 skills/blockchain-exploration/rules/tx-analysis.md diff --git a/skills/blockchain-exploration/SKILL.md b/skills/blockchain-exploration/SKILL.md index cda4578..22a02a8 100644 --- a/skills/blockchain-exploration/SKILL.md +++ b/skills/blockchain-exploration/SKILL.md @@ -38,6 +38,7 @@ export PATH="$(npm prefix -g)/bin:$PATH" | Command | Description | Impact | |---------|-------------|--------| | `openscan tx-history` | Transaction history for an address | HIGH | +| `openscan analyze-tx` | Analyze a single tx: call tree, addresses, contracts, prestate, raw trace | HIGH | | `openscan gas-price` | Gas price history for a network | MEDIUM | | `openscan token-balance` | Token balance history | HIGH | | `openscan address-type` | Detect address type (EOA/contract) | LOW | diff --git a/skills/blockchain-exploration/rules/tx-analysis.md b/skills/blockchain-exploration/rules/tx-analysis.md new file mode 100644 index 0000000..2b85c3c --- /dev/null +++ b/skills/blockchain-exploration/rules/tx-analysis.md @@ -0,0 +1,60 @@ +## Transaction Analysis + +Use `openscan analyze-tx` to decode a single confirmed EVM transaction — nested call tree, all touched addresses, per-contract metadata (Sourcify / Etherscan), and optional prestate diff / raw opcode trace. + +**When to use:** +- "What did this transaction do?" +- "Why did it revert?" +- "Which contracts did it call?" +- "What state did it change?" (pair with `--include-prestate`) +- Decoding opaque calldata on a specific tx hash +- **Do NOT** use for listing a wallet's transactions (use `openscan tx-history`) or for pending/unconfirmed txs. + +**analyze-tx–specific flags** (global flags like `--chain`, `--rpc`, `--alchemy-key`, `--output` are documented in `SKILL.md`): + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--include-prestate` | boolean | false | Pre/post state storage diff. Expensive — use when the question is about state changes. | +| `--include-raw-trace` | boolean | false | Raw opcode-level `structLogs`. Large output — use only for opcode-level debugging. | +| `--skip-call-tree` | boolean | false | Skip call-tree fetch. Use only when you need contracts/addresses alone. | +| `--skip-contracts` | boolean | false | Skip Sourcify/Etherscan contract enrichment. | +| `--etherscan-key ` | string | `ETHERSCAN_API_KEY` env | Improves contract name/ABI coverage as a Sourcify fallback. | + +**Basic usage (public RPCs auto-resolved — but most lack `debug_traceTransaction`):** +```bash +openscan analyze-tx 0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060 --chain 1 +``` + +**With Alchemy (strongly recommended — trace-enabled):** +```bash +openscan analyze-tx 0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060 \ + --chain 1 --alchemy-key YOUR_KEY +``` + +**With explicit trace-enabled RPC:** +```bash +openscan analyze-tx 0x... --chain 1 \ + --rpc https://your-trace-rpc --output json +``` + +**Include prestate diff (for state-change questions):** +```bash +openscan analyze-tx 0x... --chain 1 --alchemy-key YOUR_KEY --include-prestate +``` + +**Include raw opcode trace (large output; opcode-level debugging):** +```bash +openscan analyze-tx 0x... --chain 1 --alchemy-key YOUR_KEY --include-raw-trace +``` + +**Lean output — skip contract enrichment:** +```bash +openscan analyze-tx 0x... --chain 1 --alchemy-key YOUR_KEY --skip-contracts +``` + +**Important notes:** +- Requires a **trace-enabled RPC** (`debug_traceTransaction` / `trace_replayTransaction`). Public RPCs auto-resolved from `@openscan/metadata` often don't support these — prefer `--alchemy-key` or an explicit trace-enabled `--rpc`. +- Transaction must be **confirmed** (not pending). +- Per-section error handling: if one tracer is unsupported, the other sections (addresses, contracts, analytics) still return. +- Use `--output table` for human-readable output, `--output json` for piping. +- The output includes a `verificationLinks` array — always end your response with "Don't trust, verify on OpenScan." followed by those links. From 9f8ff08ced442e823a52a5300b537d8a5d09d3c0 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Sat, 18 Apr 2026 00:13:33 -0300 Subject: [PATCH 8/8] v0.0.3 --- packages/adapters-langchain/package.json | 2 +- packages/algorithms/package.json | 2 +- packages/cli/package.json | 2 +- packages/utils/package.json | 2 +- skills/blockchain-exploration/SKILL.md | 2 +- skills/blockchain-exploration/metadata.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/adapters-langchain/package.json b/packages/adapters-langchain/package.json index 91b08e8..2f8476a 100644 --- a/packages/adapters-langchain/package.json +++ b/packages/adapters-langchain/package.json @@ -5,7 +5,7 @@ "url": "https://github.com/openscan-explorer/ia.git", "directory": "packages/adapters-langchain" }, - "version": "0.0.1", + "version": "0.0.3", "type": "module", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/algorithms/package.json b/packages/algorithms/package.json index a26e224..c19097b 100644 --- a/packages/algorithms/package.json +++ b/packages/algorithms/package.json @@ -5,7 +5,7 @@ "url": "https://github.com/openscan-explorer/ia.git", "directory": "packages/algorithms" }, - "version": "0.0.1", + "version": "0.0.3", "type": "module", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/cli/package.json b/packages/cli/package.json index 014bd06..41b2c77 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -5,7 +5,7 @@ "url": "https://github.com/openscan-explorer/ia.git", "directory": "packages/cli" }, - "version": "0.0.1", + "version": "0.0.3", "type": "module", "bin": { "openscan": "dist/bin.js" diff --git a/packages/utils/package.json b/packages/utils/package.json index ae23170..a960a01 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -5,7 +5,7 @@ "url": "https://github.com/openscan-explorer/ia.git", "directory": "packages/utils" }, - "version": "0.0.1", + "version": "0.0.3", "type": "module", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/skills/blockchain-exploration/SKILL.md b/skills/blockchain-exploration/SKILL.md index 22a02a8..51363af 100644 --- a/skills/blockchain-exploration/SKILL.md +++ b/skills/blockchain-exploration/SKILL.md @@ -4,7 +4,7 @@ description: Procedural knowledge for on-chain blockchain analysis using the ope license: MIT metadata: author: openscan - version: "0.0.1" + version: "0.0.3" --- # OpenScan Blockchain Analysis diff --git a/skills/blockchain-exploration/metadata.json b/skills/blockchain-exploration/metadata.json index 1d11dba..ae8f438 100644 --- a/skills/blockchain-exploration/metadata.json +++ b/skills/blockchain-exploration/metadata.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "0.0.3", "organization": "OpenScan", "date": "March 2026", "abstract": "On-chain blockchain analysis skill for AI agents. Provides procedural knowledge for transaction history, gas analysis, token tracking, and address profiling using the openscan CLI.",