From 75ab6add8113d2658976db437ef32cdce9fdb8bf Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 6 Mar 2026 11:17:01 -0300 Subject: [PATCH 01/16] feat(tx): add TX Analyser with call tree, state changes, and contract enrichment Adds a TX Analyser panel (super user mode) to the transaction details page. - Adds getAnalyserCallTrace / getAnalyserPrestateTrace to all network adapters (EVM, Arbitrum, Optimism, Base, BNB, Polygon) using debug_traceTransaction with callTracer / prestateTracer; Arbitrum falls back to arbtrace_transaction - New CallNode / PrestateTrace types in NetworkAdapter - New callTreeUtils: normalizeGethCallTrace, normalizeParityCallTrace, countCalls, countReverts, countByType, collectAddresses, hexToGas - New contractLookup utility: fetches contract name + ABI from Sourcify (Etherscan fallback when key configured); session-level cache - New useCallTreeEnrichment hook: enriches all call tree addresses in parallel, aborts on tree change - TxAnalyser component: Call Tree tab with expandable nodes, decoded function calls, contract names, per-type summary; State Changes tab with balance/nonce/storage/code diffs and contract names - Collapsible TX Analyser section in TransactionDisplay (super users only) - Translations for all 5 locales (en, es, ja, pt-BR, zh) - All CSS styles for analyser, call tree, and state change components --- .../pages/evm/tx/TransactionDisplay.tsx | 27 + src/components/pages/evm/tx/TxAnalyser.tsx | 541 ++++++++++++++++++ src/hooks/useCallTreeEnrichment.ts | 51 ++ src/locales/en/transaction.json | 19 + src/locales/es/transaction.json | 19 + src/locales/ja/transaction.json | 19 + src/locales/pt-BR/transaction.json | 19 + src/locales/zh/transaction.json | 19 + .../ArbitrumAdapter/ArbitrumAdapter.ts | 38 ++ .../adapters/BNBAdapter/BNBAdapter.ts | 25 +- .../adapters/BaseAdapter/BaseAdapter.ts | 24 + .../adapters/EVMAdapter/EVMAdapter.ts | 26 +- src/services/adapters/NetworkAdapter.ts | 42 ++ .../OptimismAdapter/OptimismAdapter.ts | 24 + .../adapters/PolygonAdapter/PolygonAdapter.ts | 25 +- src/styles/components.css | 313 ++++++++++ src/types/index.ts | 1 + src/utils/addressTypeDetection.test.ts | 2 +- src/utils/callTreeUtils.ts | 125 ++++ src/utils/contractLookup.ts | 97 ++++ 20 files changed, 1452 insertions(+), 4 deletions(-) create mode 100644 src/components/pages/evm/tx/TxAnalyser.tsx create mode 100644 src/hooks/useCallTreeEnrichment.ts create mode 100644 src/utils/callTreeUtils.ts create mode 100644 src/utils/contractLookup.ts diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index 2c6269c8..f8230863 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -8,6 +8,8 @@ import { getNetworkById } from "../../../../config/networks"; import { AppContext } from "../../../../context"; import { useSourcify } from "../../../../hooks/useSourcify"; import { useTransactionPreAnalysis } from "../../../../hooks/useTransactionPreAnalysis"; +import { useSettings } from "../../../../context/SettingsContext"; +import TxAnalyser from "./TxAnalyser"; import type { DataService } from "../../../../services/DataService"; import { fetchToken, @@ -62,12 +64,14 @@ const TransactionDisplay: React.FC = React.memo( onProviderSelect, }) => { const { t } = useTranslation("transaction"); + const { isSuperUser } = useSettings(); const network = networkId ? getNetworkById(networkId) : undefined; const networkName = network?.name ?? "Unknown Network"; const networkCurrency = network?.currency ?? "ETH"; const [_showRawData, _setShowRawData] = useState(false); const [_showLogs, _setShowLogs] = useState(false); + const [showAnalyser, setShowAnalyser] = useState(false); const [callTargetToken, setCallTargetToken] = useState(null); const [callTargetTokenListMatch, setCallTargetTokenListMatch] = useState( null, @@ -990,6 +994,29 @@ const TransactionDisplay: React.FC = React.memo( )} + {/* TX Analyser (Super User Mode) */} + {isSuperUser && dataService && networkId && ( +
+ {/** biome-ignore lint/a11y/useButtonType: */} + + {showAnalyser && ( +
+ +
+ )} +
+ )} + {/* Debug Trace Section (Localhost Only) */} {isTraceAvailable && (
diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx new file mode 100644 index 00000000..3190a569 --- /dev/null +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -0,0 +1,541 @@ +import type React from "react"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { DataService } from "../../../../services/DataService"; +import type { + CallNode, + PrestateAccountState, + PrestateTrace, +} from "../../../../services/adapters/NetworkAdapter"; +import { useCallTreeEnrichment } from "../../../../hooks/useCallTreeEnrichment"; +import { countByType, countCalls, countReverts, hexToGas } from "../../../../utils/callTreeUtils"; +import type { ContractInfo } from "../../../../utils/contractLookup"; +import { decodeFunctionCall } from "../../../../utils/inputDecoder"; +import { formatNativeFromWei } from "../../../../utils/unitFormatters"; +import { logger } from "../../../../utils/logger"; +import LongString from "../../../common/LongString"; + +interface TxAnalyserProps { + txHash: string; + networkId: string; + networkCurrency: string; + dataService: DataService; +} + +type AnalyserTab = "callTree" | "stateChanges"; + +// ─── Call type color mapping ─────────────────────────────────────────────── + +const CALL_TYPE_COLORS: Record = { + CALL: "#3b82f6", + DELEGATECALL: "#f97316", + STATICCALL: "#8b5cf6", + CREATE: "#10b981", + CREATE2: "#10b981", + SELFDESTRUCT: "#ef4444", +}; + +function getCallTypeColor(type: string): string { + return CALL_TYPE_COLORS[type.toUpperCase()] ?? "#6b7280"; +} + +/** Truncate a decoded param value for inline display */ +function truncateParamValue(value: string, max = 20): string { + if (!value) return ""; + const str = String(value); + return str.length > max ? `${str.slice(0, max)}…` : str; +} + +// ─── Call Tree Node ──────────────────────────────────────────────────────── + +const CallTreeNode: React.FC<{ + node: CallNode; + networkId: string; + networkCurrency: string; + depth: number; + defaultExpanded: boolean; + contracts: Record; +}> = ({ node, networkId, networkCurrency, depth, defaultExpanded, contracts }) => { + const [expanded, setExpanded] = useState(defaultExpanded); + const hasChildren = node.calls && node.calls.length > 0; + const isError = !!node.error; + const color = getCallTypeColor(node.type); + + const formattedValue = + node.value && node.value !== "0x0" && node.value !== "0x" + ? formatNativeFromWei(node.value, networkCurrency, 4) + : null; + + const gasUsed = hexToGas(node.gasUsed); + + // Contract info for the target address + const contractInfo = node.to ? contracts[node.to.toLowerCase()] : undefined; + + // Decode the function call if we have an ABI + const decoded = + node.input && node.input !== "0x" && contractInfo?.abi + ? decodeFunctionCall(node.input, contractInfo.abi) + : null; + + const addressLink = (addr: string | undefined, name?: string) => { + if (!addr) return null; + return ( + e.stopPropagation()} + > + {name ? ( + {name} + ) : ( + + )} + + ); + }; + + return ( +
+ {/* Node header */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: call tree expand/collapse via click */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: call tree expand/collapse via click */} +
hasChildren && setExpanded((e) => !e)} + style={{ cursor: hasChildren ? "pointer" : "default" }} + > + {/* Expand toggle */} + {hasChildren ? (expanded ? "▾" : "▸") : "·"} + + {/* Call type badge */} + + {node.type} + + + {/* From → To (with contract names) */} + + {addressLink(node.from, contracts[node.from?.toLowerCase() ?? ""]?.name)} + {node.to && ( + <> + + {addressLink(node.to, contractInfo?.name)} + + )} + + + {/* Decoded function call */} + {decoded && ( + + {decoded.functionName} + {decoded.params.length > 0 && ( + + ({decoded.params.map((p) => truncateParamValue(p.value, 16)).join(", ")}) + + )} + + )} + + {/* Value */} + {formattedValue && {formattedValue}} + + {/* Gas used */} + {gasUsed !== undefined && ( + {gasUsed.toLocaleString()} gas + )} + + {/* Error badge */} + {isError && {node.error}} +
+ + {/* Children */} + {expanded && hasChildren && ( +
+ {node.calls?.map((child, i) => ( + + ))} +
+ )} +
+ ); +}; + +// ─── Call Tree Tab ───────────────────────────────────────────────────────── + +const CALL_TYPE_LABELS: Record = { + CALL: "CALL", + STATICCALL: "STATICCALL", + DELEGATECALL: "DELEGATECALL", + CREATE: "CREATE", + CREATE2: "CREATE2", + SELFDESTRUCT: "SELFDESTRUCT", +}; + +const CallTreeTab: React.FC<{ + root: CallNode; + networkId: string; + networkCurrency: string; + contracts: Record; + enrichmentLoading: boolean; +}> = ({ root, networkId, networkCurrency, contracts, enrichmentLoading }) => { + const { t } = useTranslation("transaction"); + const totalCalls = countCalls(root); + const totalReverts = countReverts(root); + const gasUsed = hexToGas(root.gasUsed); + const typeCounts = countByType(root); + + return ( +
+ {/* Summary bar */} +
+ {gasUsed !== undefined && ( + {t("analyser.summaryGas", { gas: gasUsed.toLocaleString() })} + )} + {t("analyser.summaryCalls", { calls: totalCalls })} + {/* Per-type breakdown */} + {Object.entries(typeCounts) + .filter(([type]) => type !== "CALL" && CALL_TYPE_LABELS[type]) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => ( + + {count}× {type} + + ))} + {totalReverts > 0 && ( + + {t("analyser.summaryReverts", { reverts: totalReverts })} + + )} + {enrichmentLoading && ( + {t("analyser.enriching")} + )} +
+ {/* Tree */} +
+ +
+
+ ); +}; + +// ─── State Changes Tab ───────────────────────────────────────────────────── + +function formatHexBalance(hex: string | undefined): string { + if (!hex) return "—"; + try { + const bn = BigInt(hex); + const eth = Number(bn) / 1e18; + return eth.toFixed(6); + } catch { + return hex; + } +} + +function balanceDiff(pre?: string, post?: string): string | null { + if (pre === undefined && post === undefined) return null; + if (pre === post) return null; + try { + const preBn = BigInt(pre ?? "0x0"); + const postBn = BigInt(post ?? "0x0"); + const diff = postBn - preBn; + const sign = diff >= 0n ? "+" : ""; + const eth = Number(diff) / 1e18; + return `${sign}${eth.toFixed(6)}`; + } catch { + return null; + } +} + +const StateChangesTab: React.FC<{ + trace: PrestateTrace; + networkId: string; + networkCurrency: string; + contracts: Record; +}> = ({ trace, networkId, networkCurrency, contracts }) => { + const { t } = useTranslation("transaction"); + + const allAddresses = Array.from(new Set([...Object.keys(trace.pre), ...Object.keys(trace.post)])); + + if (allAddresses.length === 0) { + return ( +
+
{t("analyser.noChanges")}
+
+ ); + } + + return ( +
+ {allAddresses.map((address) => { + const pre: PrestateAccountState = trace.pre[address] ?? {}; + const post: PrestateAccountState = trace.post[address] ?? {}; + + const balDiff = balanceDiff(pre.balance, post.balance); + const nonceDiff = + pre.nonce !== post.nonce && (pre.nonce !== undefined || post.nonce !== undefined); + const storageKeys = Array.from( + new Set([...Object.keys(pre.storage ?? {}), ...Object.keys(post.storage ?? {})]), + ).filter((k) => pre.storage?.[k] !== post.storage?.[k]); + const codeChanged = pre.code !== post.code; + + if (!balDiff && !nonceDiff && storageKeys.length === 0 && !codeChanged) return null; + + const contractName = contracts[address.toLowerCase()]?.name; + + return ( +
+
+ + {contractName ? ( + <> + {contractName} + + + + + ) : ( + + )} + +
+ +
+ {/* Balance */} + {balDiff && ( +
+ + {t("analyser.balanceChange")} ({networkCurrency}) + + {formatHexBalance(pre.balance)} + + {formatHexBalance(post.balance)} + + {balDiff} + +
+ )} + + {/* Nonce */} + {nonceDiff && ( +
+ {t("analyser.nonceChange")} + {pre.nonce ?? "—"} + + {post.nonce ?? "—"} + + +{(post.nonce ?? 0) - (pre.nonce ?? 0)} + +
+ )} + + {/* Code */} + {codeChanged && ( +
+ {t("analyser.codeDeployed")} + + {post.code ? `${post.code.slice(0, 20)}…` : "—"} + +
+ )} + + {/* Storage */} + {storageKeys.map((slot) => ( +
+ {t("analyser.storageChange")} + + + + + + + + + + +
+ ))} +
+
+ ); + })} +
+ ); +}; + +// ─── Main TxAnalyser ─────────────────────────────────────────────────────── + +const TxAnalyser: React.FC = ({ + txHash, + networkId, + networkCurrency, + dataService, +}) => { + const { t } = useTranslation("transaction"); + const [activeTab, setActiveTab] = useState("callTree"); + + const [callTree, setCallTree] = useState(null); + const [prestateTrace, setPrestateTrace] = useState(null); + const [loadingCallTree, setLoadingCallTree] = useState(false); + const [loadingPrestate, setLoadingPrestate] = useState(false); + const [callTreeError, setCallTreeError] = useState(null); + const [prestateError, setPrestateError] = useState(null); + + // Contract name + ABI enrichment for the call tree + const { contracts, enrichmentLoading } = useCallTreeEnrichment(callTree, networkId); + + const isUnsupported = useCallback((msg: string) => { + return /method not found|not supported|unsupported|does not exist/i.test(msg); + }, []); + + // Load call tree on first render + useEffect(() => { + if (callTree || callTreeError || loadingCallTree) return; + setLoadingCallTree(true); + dataService.networkAdapter + .getAnalyserCallTrace(txHash) + .then((data) => { + if (data) { + setCallTree(data); + } else { + setCallTreeError(t("analyser.notSupported")); + } + }) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("TX Analyser call trace error:", msg); + setCallTreeError( + isUnsupported(msg) ? t("analyser.notSupported") : `${t("analyser.error")}: ${msg}`, + ); + }) + .finally(() => setLoadingCallTree(false)); + }, [txHash, dataService, callTree, callTreeError, loadingCallTree, t, isUnsupported]); + + // Load prestate when switching to that tab + useEffect(() => { + if (activeTab !== "stateChanges") return; + if (prestateTrace || prestateError || loadingPrestate) return; + setLoadingPrestate(true); + dataService.networkAdapter + .getAnalyserPrestateTrace(txHash) + .then((data) => { + if (data) { + setPrestateTrace(data); + } else { + setPrestateError(t("analyser.notSupported")); + } + }) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("TX Analyser prestate trace error:", msg); + setPrestateError( + isUnsupported(msg) ? t("analyser.notSupported") : `${t("analyser.error")}: ${msg}`, + ); + }) + .finally(() => setLoadingPrestate(false)); + }, [ + activeTab, + txHash, + dataService, + prestateTrace, + prestateError, + loadingPrestate, + t, + isUnsupported, + ]); + + return ( +
+ {/* Tab bar */} +
+ + +
+ + {/* Tab content */} +
+ {activeTab === "callTree" && ( + <> + {loadingCallTree &&
{t("analyser.loading")}
} + {callTreeError && ( +
+
{callTreeError}
+ {callTreeError === t("analyser.notSupported") && ( +
{t("analyser.traceHint")}
+ )} +
+ )} + {callTree && ( + + )} + + )} + + {activeTab === "stateChanges" && ( + <> + {loadingPrestate &&
{t("analyser.loading")}
} + {prestateError && ( +
+
{prestateError}
+ {prestateError === t("analyser.notSupported") && ( +
{t("analyser.traceHint")}
+ )} +
+ )} + {prestateTrace && ( + + )} + + )} +
+
+ ); +}; + +export default TxAnalyser; diff --git a/src/hooks/useCallTreeEnrichment.ts b/src/hooks/useCallTreeEnrichment.ts new file mode 100644 index 00000000..ef6eae1b --- /dev/null +++ b/src/hooks/useCallTreeEnrichment.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from "react"; +import { useSettings } from "../context/SettingsContext"; +import type { CallNode } from "../services/adapters/NetworkAdapter"; +import { collectAddresses } from "../utils/callTreeUtils"; +import { type ContractInfo, fetchContractInfoBatch } from "../utils/contractLookup"; + +/** + * Fetches contract names + ABIs for all unique addresses in a call tree. + * Uses Sourcify as primary source, Etherscan as fallback (if key configured). + * Returns a map of lowercased address → ContractInfo. + */ +export function useCallTreeEnrichment( + tree: CallNode | null, + networkId: string, +): { + contracts: Record; + enrichmentLoading: boolean; +} { + const { settings } = useSettings(); + const [contracts, setContracts] = useState>({}); + const [enrichmentLoading, setEnrichmentLoading] = useState(false); + const abortRef = useRef(null); + + useEffect(() => { + if (!tree || !networkId) return; + + const chainId = Number(networkId); + const addresses = collectAddresses(tree); + if (addresses.length === 0) return; + + // Cancel previous fetch + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setEnrichmentLoading(true); + setContracts({}); + + fetchContractInfoBatch(addresses, chainId, controller.signal, settings.apiKeys?.etherscan) + .then((map) => { + if (!controller.signal.aborted) setContracts(map); + }) + .finally(() => { + if (!controller.signal.aborted) setEnrichmentLoading(false); + }); + + return () => controller.abort(); + }, [tree, networkId, settings.apiKeys?.etherscan]); + + return { contracts, enrichmentLoading }; +} diff --git a/src/locales/en/transaction.json b/src/locales/en/transaction.json index de302aaf..c583df3c 100644 --- a/src/locales/en/transaction.json +++ b/src/locales/en/transaction.json @@ -137,5 +137,24 @@ "cost": "Cost", "depth": "Depth", "stack": "Stack" + }, + "analyser": { + "title": "TX Analyser", + "analyse": "Analyse", + "callTree": "Call Tree", + "stateChanges": "State Changes", + "loading": "Loading trace data...", + "notSupported": "Your RPC does not support trace methods. Try an archive node or a trace-enabled provider.", + "traceHint": "Providers like dRPC and Tenderly support debug_traceTransaction.", + "error": "Error", + "summaryGas": "Gas used: {{gas}}", + "summaryCalls": "{{calls}} calls", + "summaryReverts": "{{reverts}} reverts", + "noChanges": "No state changes detected", + "balanceChange": "Balance", + "nonceChange": "Nonce", + "storageChange": "Storage", + "codeDeployed": "Code", + "enriching": "Loading contract info..." } } diff --git a/src/locales/es/transaction.json b/src/locales/es/transaction.json index d858f9d6..2bd463e5 100644 --- a/src/locales/es/transaction.json +++ b/src/locales/es/transaction.json @@ -137,5 +137,24 @@ "cost": "Costo", "depth": "Profundidad", "stack": "Stack" + }, + "analyser": { + "title": "Analizador de TX", + "analyse": "Analizar", + "callTree": "Árbol de Llamadas", + "stateChanges": "Cambios de Estado", + "loading": "Cargando datos de trace...", + "notSupported": "Tu RPC no soporta métodos de trace. Probá un nodo archivo o un proveedor con soporte de trace.", + "traceHint": "Proveedores como dRPC y Tenderly soportan debug_traceTransaction.", + "error": "Error", + "summaryGas": "Gas usado: {{gas}}", + "summaryCalls": "{{calls}} llamadas", + "summaryReverts": "{{reverts}} reverts", + "noChanges": "No se detectaron cambios de estado", + "balanceChange": "Balance", + "nonceChange": "Nonce", + "storageChange": "Storage", + "codeDeployed": "Código", + "enriching": "Cargando info de contratos..." } } diff --git a/src/locales/ja/transaction.json b/src/locales/ja/transaction.json index 230af3cf..f7c6e9a9 100644 --- a/src/locales/ja/transaction.json +++ b/src/locales/ja/transaction.json @@ -137,5 +137,24 @@ "cost": "コスト", "depth": "深度", "stack": "スタック" + }, + "analyser": { + "title": "TXアナライザー", + "analyse": "分析", + "callTree": "コールツリー", + "stateChanges": "状態変化", + "loading": "トレースデータを読み込み中...", + "notSupported": "お使いのRPCはトレースメソッドをサポートしていません。アーカイブノードまたはトレース対応プロバイダーをお試しください。", + "traceHint": "dRPCやTenderlyなどのプロバイダーはdebug_traceTransactionをサポートしています。", + "error": "エラー", + "summaryGas": "ガス使用量: {{gas}}", + "summaryCalls": "{{calls}} コール", + "summaryReverts": "{{reverts}} リバート", + "noChanges": "状態変化は検出されませんでした", + "balanceChange": "残高", + "nonceChange": "ノンス", + "storageChange": "ストレージ", + "codeDeployed": "コード", + "enriching": "コントラクト情報を読み込み中..." } } diff --git a/src/locales/pt-BR/transaction.json b/src/locales/pt-BR/transaction.json index 4dba0ed6..2c1de9cb 100644 --- a/src/locales/pt-BR/transaction.json +++ b/src/locales/pt-BR/transaction.json @@ -137,5 +137,24 @@ "cost": "Custo", "depth": "Profundidade", "stack": "Pilha" + }, + "analyser": { + "title": "Analisador de TX", + "analyse": "Analisar", + "callTree": "Árvore de Chamadas", + "stateChanges": "Mudanças de Estado", + "loading": "Carregando dados de trace...", + "notSupported": "Seu RPC não suporta métodos de trace. Tente um nó arquivo ou um provedor com suporte a trace.", + "traceHint": "Provedores como dRPC e Tenderly suportam debug_traceTransaction.", + "error": "Erro", + "summaryGas": "Gas usado: {{gas}}", + "summaryCalls": "{{calls}} chamadas", + "summaryReverts": "{{reverts}} reverts", + "noChanges": "Nenhuma mudança de estado detectada", + "balanceChange": "Saldo", + "nonceChange": "Nonce", + "storageChange": "Storage", + "codeDeployed": "Código", + "enriching": "Carregando info de contratos..." } } diff --git a/src/locales/zh/transaction.json b/src/locales/zh/transaction.json index d80d449f..d74621c5 100644 --- a/src/locales/zh/transaction.json +++ b/src/locales/zh/transaction.json @@ -137,5 +137,24 @@ "cost": "消耗", "depth": "深度", "stack": "栈" + }, + "analyser": { + "title": "交易分析器", + "analyse": "分析", + "callTree": "调用树", + "stateChanges": "状态变化", + "loading": "正在加载追踪数据...", + "notSupported": "您的 RPC 不支持追踪方法。请尝试使用存档节点或支持追踪的提供商。", + "traceHint": "dRPC 和 Tenderly 等提供商支持 debug_traceTransaction。", + "error": "错误", + "summaryGas": "Gas 消耗:{{gas}}", + "summaryCalls": "{{calls}} 次调用", + "summaryReverts": "{{reverts}} 次回滚", + "noChanges": "未检测到状态变化", + "balanceChange": "余额", + "nonceChange": "Nonce", + "storageChange": "存储", + "codeDeployed": "代码", + "enriching": "正在加载合约信息..." } } diff --git a/src/services/adapters/ArbitrumAdapter/ArbitrumAdapter.ts b/src/services/adapters/ArbitrumAdapter/ArbitrumAdapter.ts index de5eb42f..78b65827 100644 --- a/src/services/adapters/ArbitrumAdapter/ArbitrumAdapter.ts +++ b/src/services/adapters/ArbitrumAdapter/ArbitrumAdapter.ts @@ -1,5 +1,7 @@ import { type BlockNumberOrTag, NetworkAdapter, type TraceResult } from "../NetworkAdapter"; import type { Block, Transaction, Address, NetworkStats, DataWithMetadata } from "../../../types"; +import type { CallNode, PrestateTrace } from "../NetworkAdapter"; +import { normalizeGethCallTrace, normalizeParityCallTrace } from "../../../utils/callTreeUtils"; import { logger } from "../../../utils/logger"; import { transformArbitrumBlockToBlock, @@ -292,4 +294,40 @@ export class ArbitrumAdapter extends NetworkAdapter { return null; } } + + async getAnalyserCallTrace(txHash: string): Promise { + // Try Geth callTracer first (works on dRPC, Tenderly for Arbitrum) + try { + const result = await this.client.debugTraceTransaction(txHash, { tracer: "callTracer" }); + if (result.data) return normalizeGethCallTrace(result.data); + } catch { + // Fall through to arbtrace + } + + // Fall back to arbtrace_transaction (Parity format — always available on Arbitrum) + try { + const result = await this.client.arbtraceTransaction(txHash); + if (result.data) { + // biome-ignore lint/suspicious/noExplicitAny: Parity trace format + return normalizeParityCallTrace(result.data as any); + } + return null; + } catch (error) { + logger.error("Error getting Arbitrum analyser call trace:", error); + return null; + } + } + + async getAnalyserPrestateTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { + tracer: "prestateTracer", + tracerConfig: { diffMode: true }, + }); + return (result.data as PrestateTrace) ?? null; + } catch (error) { + logger.error("Error getting Arbitrum analyser prestate trace:", error); + return null; + } + } } diff --git a/src/services/adapters/BNBAdapter/BNBAdapter.ts b/src/services/adapters/BNBAdapter/BNBAdapter.ts index 414256ca..fe675c46 100644 --- a/src/services/adapters/BNBAdapter/BNBAdapter.ts +++ b/src/services/adapters/BNBAdapter/BNBAdapter.ts @@ -1,6 +1,7 @@ import { type BlockNumberOrTag, NetworkAdapter } from "../NetworkAdapter"; import type { Block, Transaction, Address, NetworkStats, DataWithMetadata } from "../../../types"; -import type { TraceResult } from "../NetworkAdapter"; +import type { TraceResult, CallNode, PrestateTrace } from "../NetworkAdapter"; +import { normalizeGethCallTrace } from "../../../utils/callTreeUtils"; import { logger } from "../../../utils/logger"; import { transformBNBBlockToBlock, @@ -292,4 +293,26 @@ export class BNBAdapter extends NetworkAdapter { return null; } } + async getAnalyserCallTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { tracer: "callTracer" }); + return result.data ? normalizeGethCallTrace(result.data) : null; + } catch (error) { + logger.error("Error getting analyser call trace:", error); + return null; + } + } + + async getAnalyserPrestateTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { + tracer: "prestateTracer", + tracerConfig: { diffMode: true }, + }); + return (result.data as PrestateTrace) ?? null; + } catch (error) { + logger.error("Error getting analyser prestate trace:", error); + return null; + } + } } diff --git a/src/services/adapters/BaseAdapter/BaseAdapter.ts b/src/services/adapters/BaseAdapter/BaseAdapter.ts index 73020e90..dd23c7f0 100644 --- a/src/services/adapters/BaseAdapter/BaseAdapter.ts +++ b/src/services/adapters/BaseAdapter/BaseAdapter.ts @@ -1,4 +1,6 @@ import { type BlockNumberOrTag, NetworkAdapter, type TraceResult } from "../NetworkAdapter"; +import type { CallNode, PrestateTrace } from "../NetworkAdapter"; +import { normalizeGethCallTrace } from "../../../utils/callTreeUtils"; import type { Block, Transaction, Address, NetworkStats, DataWithMetadata } from "../../../types"; import { logger } from "../../../utils/logger"; import { @@ -291,4 +293,26 @@ export class BaseAdapter extends NetworkAdapter { return null; } } + async getAnalyserCallTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { tracer: "callTracer" }); + return result.data ? normalizeGethCallTrace(result.data) : null; + } catch (error) { + logger.error("Error getting analyser call trace:", error); + return null; + } + } + + async getAnalyserPrestateTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { + tracer: "prestateTracer", + tracerConfig: { diffMode: true }, + }); + return (result.data as PrestateTrace) ?? null; + } catch (error) { + logger.error("Error getting analyser prestate trace:", error); + return null; + } + } } diff --git a/src/services/adapters/EVMAdapter/EVMAdapter.ts b/src/services/adapters/EVMAdapter/EVMAdapter.ts index d4e4cded..905571a6 100644 --- a/src/services/adapters/EVMAdapter/EVMAdapter.ts +++ b/src/services/adapters/EVMAdapter/EVMAdapter.ts @@ -1,6 +1,7 @@ import { type BlockNumberOrTag, NetworkAdapter } from "../NetworkAdapter"; import type { Block, Transaction, Address, NetworkStats, DataWithMetadata } from "../../../types"; -import type { TraceResult } from "../NetworkAdapter"; +import type { CallNode, PrestateTrace, TraceResult } from "../NetworkAdapter"; +import { normalizeGethCallTrace } from "../../../utils/callTreeUtils"; import { logger } from "../../../utils/logger"; import { transformRPCBlockToBlock, @@ -299,4 +300,27 @@ export class EVMAdapter extends NetworkAdapter { return null; } } + + async getAnalyserCallTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { tracer: "callTracer" }); + return result.data ? normalizeGethCallTrace(result.data) : null; + } catch (error) { + logger.error("Error getting analyser call trace:", error); + return null; + } + } + + async getAnalyserPrestateTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { + tracer: "prestateTracer", + tracerConfig: { diffMode: true }, + }); + return (result.data as PrestateTrace) ?? null; + } catch (error) { + logger.error("Error getting analyser prestate trace:", error); + return null; + } + } } diff --git a/src/services/adapters/NetworkAdapter.ts b/src/services/adapters/NetworkAdapter.ts index 1dcb2f3b..4f0ba315 100644 --- a/src/services/adapters/NetworkAdapter.ts +++ b/src/services/adapters/NetworkAdapter.ts @@ -42,6 +42,32 @@ export interface TraceCallConfig { withLog?: boolean; }; } + +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; +} /** * Base interface for blockchain-specific services * All chain implementations must conform to this unified API @@ -351,4 +377,20 @@ export abstract class NetworkAdapter { * @returns Array of trace results or null */ abstract getBlockTrace(blockHash: string): Promise; + + /** + * Get call tree trace using debug_traceTransaction with callTracer. + * Available in Super User Mode — no localhost restriction. + * @param txHash - Transaction hash + * @returns Nested CallNode tree or null + */ + abstract getAnalyserCallTrace(txHash: string): Promise; + + /** + * Get prestate diff trace using debug_traceTransaction with prestateTracer. + * Available in Super User Mode — no localhost restriction. + * @param txHash - Transaction hash + * @returns PrestateTrace with pre/post state or null + */ + abstract getAnalyserPrestateTrace(txHash: string): Promise; } diff --git a/src/services/adapters/OptimismAdapter/OptimismAdapter.ts b/src/services/adapters/OptimismAdapter/OptimismAdapter.ts index 13e72397..cb1935e4 100644 --- a/src/services/adapters/OptimismAdapter/OptimismAdapter.ts +++ b/src/services/adapters/OptimismAdapter/OptimismAdapter.ts @@ -1,4 +1,6 @@ import { type BlockNumberOrTag, NetworkAdapter, type TraceResult } from "../NetworkAdapter"; +import type { CallNode, PrestateTrace } from "../NetworkAdapter"; +import { normalizeGethCallTrace } from "../../../utils/callTreeUtils"; import type { Block, Transaction, Address, NetworkStats, DataWithMetadata } from "../../../types"; import { logger } from "../../../utils/logger"; import { @@ -291,4 +293,26 @@ export class OptimismAdapter extends NetworkAdapter { return null; } } + async getAnalyserCallTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { tracer: "callTracer" }); + return result.data ? normalizeGethCallTrace(result.data) : null; + } catch (error) { + logger.error("Error getting analyser call trace:", error); + return null; + } + } + + async getAnalyserPrestateTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { + tracer: "prestateTracer", + tracerConfig: { diffMode: true }, + }); + return (result.data as PrestateTrace) ?? null; + } catch (error) { + logger.error("Error getting analyser prestate trace:", error); + return null; + } + } } diff --git a/src/services/adapters/PolygonAdapter/PolygonAdapter.ts b/src/services/adapters/PolygonAdapter/PolygonAdapter.ts index 90b7d06b..eaf106eb 100644 --- a/src/services/adapters/PolygonAdapter/PolygonAdapter.ts +++ b/src/services/adapters/PolygonAdapter/PolygonAdapter.ts @@ -1,6 +1,7 @@ import { type BlockNumberOrTag, NetworkAdapter } from "../NetworkAdapter"; import type { Block, Transaction, Address, NetworkStats, DataWithMetadata } from "../../../types"; -import type { TraceResult } from "../NetworkAdapter"; +import type { TraceResult, CallNode, PrestateTrace } from "../NetworkAdapter"; +import { normalizeGethCallTrace } from "../../../utils/callTreeUtils"; import { logger } from "../../../utils/logger"; import { transformPolygonBlockToBlock, @@ -291,4 +292,26 @@ export class PolygonAdapter extends NetworkAdapter { return null; } } + async getAnalyserCallTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { tracer: "callTracer" }); + return result.data ? normalizeGethCallTrace(result.data) : null; + } catch (error) { + logger.error("Error getting analyser call trace:", error); + return null; + } + } + + async getAnalyserPrestateTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { + tracer: "prestateTracer", + tracerConfig: { diffMode: true }, + }); + return (result.data as PrestateTrace) ?? null; + } catch (error) { + logger.error("Error getting analyser prestate trace:", error); + return null; + } + } } diff --git a/src/styles/components.css b/src/styles/components.css index e8d2f17f..0cad299e 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1829,6 +1829,319 @@ button.tx-section-header-toggle { padding: 10px; } +/* ── TX Analyser ─────────────────────────────────────────────────────────── */ + +.tx-analyser { + margin-top: 16px; + border: 1px solid var(--border-primary); + border-radius: 10px; + overflow: hidden; + background: var(--bg-secondary); +} + +.tx-analyser-tabs { + display: flex; + border-bottom: 1px solid var(--border-primary); + background: var(--bg-tertiary); +} + +.tx-analyser-tab { + padding: 10px 18px; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + margin-bottom: -1px; +} + +.tx-analyser-tab:hover { + color: var(--text-primary); +} + +.tx-analyser-tab--active { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +.tx-analyser-body { + padding: 16px; + min-height: 80px; +} + +.analyser-tab-content { + display: flex; + flex-direction: column; + gap: 12px; +} + +.analyser-loading { + text-align: center; + padding: 32px; + color: var(--text-secondary); +} + +.analyser-error { + padding: 16px; + border-radius: 8px; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--color-error); + font-size: 0.875rem; +} + +.analyser-hint { + margin-top: 8px; + color: var(--text-secondary); + font-size: 0.8rem; +} + +.analyser-empty { + text-align: center; + padding: 32px; + color: var(--text-tertiary); + font-size: 0.875rem; +} + +.analyser-summary { + display: flex; + gap: 16px; + flex-wrap: wrap; + font-size: 0.8rem; + color: var(--text-secondary); + padding: 8px 12px; + background: var(--bg-tertiary); + border-radius: 6px; + border: 1px solid var(--border-primary); +} + +.analyser-summary-reverts { + color: var(--color-error); +} + +/* ── Call Tree ───────────────────────────────────────────────────────────── */ + +.call-tree-root { + overflow-x: auto; +} + +.call-tree-node { + font-size: 0.82rem; + font-family: "monospace"; +} + +.call-tree-node + .call-tree-node { + margin-top: 2px; +} + +.call-tree-node--error > .call-tree-node-header { + background: rgba(239, 68, 68, 0.05); + border-radius: 4px; +} + +.call-tree-node-header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 5px 6px; + border-radius: 4px; + transition: background 0.1s; + min-width: max-content; +} + +.call-tree-node-header:hover { + background: var(--overlay-light-1); +} + +.call-tree-toggle { + width: 14px; + flex-shrink: 0; + color: var(--text-tertiary); + font-size: 0.9rem; +} + +.call-tree-type-badge { + display: inline-flex; + align-items: center; + font-size: 0.7rem; + font-weight: 700; + padding: 1px 7px; + border-radius: 4px; + border: 1px solid; + letter-spacing: 0.03em; + flex-shrink: 0; +} + +.call-tree-addresses { + display: flex; + align-items: center; + gap: 4px; +} + +.call-tree-address { + color: var(--color-link); + text-decoration: none; +} + +.call-tree-address:hover { + text-decoration: underline; +} + +.call-tree-arrow { + color: var(--text-tertiary); +} + +.call-tree-value { + color: #10b981; + font-weight: 600; + font-size: 0.78rem; +} + +.call-tree-gas { + color: var(--text-tertiary); + font-size: 0.75rem; +} + +.call-tree-error-badge { + background: rgba(239, 68, 68, 0.12); + color: var(--color-error); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 4px; + padding: 1px 6px; + font-size: 0.7rem; + font-weight: 600; +} + +.call-tree-children { + padding-left: 20px; + border-left: 2px solid var(--border-primary); + margin-left: 6px; + margin-top: 2px; + margin-bottom: 2px; +} + +/* ── State Changes ───────────────────────────────────────────────────────── */ + +.state-change-block { + border: 1px solid var(--border-primary); + border-radius: 8px; + overflow: hidden; +} + +.state-change-address { + padding: 8px 12px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-primary); + font-size: 0.85rem; + font-family: "monospace"; + font-weight: 600; +} + +.state-change-rows { + display: flex; + flex-direction: column; +} + +.state-change-row { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + font-size: 0.8rem; + font-family: "monospace"; + border-bottom: 1px solid var(--border-primary); + flex-wrap: wrap; +} + +.state-change-row:last-child { + border-bottom: none; +} + +.state-change-row--storage { + background: var(--overlay-light-1); +} + +.state-change-label { + color: var(--text-secondary); + min-width: 120px; + font-size: 0.75rem; + font-family: "Outfit", sans-serif; +} + +.state-change-slot { + color: var(--text-tertiary); + font-size: 0.75rem; +} + +.state-change-before { + color: var(--text-primary); + opacity: 0.6; +} + +.state-change-arrow { + color: var(--text-tertiary); +} + +.state-change-after { + color: var(--text-primary); + font-weight: 500; +} + +.state-change-diff { + margin-left: auto; + font-weight: 700; + font-size: 0.78rem; +} + +.state-change-diff--positive { + color: #10b981; +} + +.state-change-diff--negative { + color: var(--color-error); +} + +.state-change-code { + color: var(--text-tertiary); + font-size: 0.75rem; +} + +.state-change-addr-sub { + color: var(--text-tertiary); + font-size: 0.75rem; + margin-left: 4px; +} + +.call-tree-contract-name { + color: var(--text-primary); + font-weight: 600; +} + +.call-tree-decoded { + color: var(--color-accent); + font-size: 0.8rem; + margin-left: 6px; +} + +.call-tree-decoded-params { + color: var(--text-secondary); +} + +.analyser-summary-type { + font-size: 0.8rem; + font-weight: 500; +} + +.analyser-summary-loading { + color: var(--text-tertiary); + font-size: 0.8rem; + font-style: italic; +} + /* Input Data Display */ .input-data-display { background: var(--bg-tertiary); diff --git a/src/types/index.ts b/src/types/index.ts index 2abb8c9c..b54277c2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -421,6 +421,7 @@ export type RpcUrlsContextType = Record; export interface ApiKeys { infura?: string; alchemy?: string; + etherscan?: string; groq?: string; openai?: string; anthropic?: string; diff --git a/src/utils/addressTypeDetection.test.ts b/src/utils/addressTypeDetection.test.ts index 7f9432f4..7cccf2a6 100644 --- a/src/utils/addressTypeDetection.test.ts +++ b/src/utils/addressTypeDetection.test.ts @@ -52,7 +52,7 @@ describe("checkEIP7702Delegation", () => { describe("getEIP7702DelegateAddress", () => { it("extracts address from valid delegation", () => { const addr = "1234567890abcdef1234567890abcdef12345678"; - const delegation = "0xef0100" + addr; + const delegation = `0xef0100${addr}`; expect(getEIP7702DelegateAddress(delegation)).toBe(`0x${addr}`); }); diff --git a/src/utils/callTreeUtils.ts b/src/utils/callTreeUtils.ts new file mode 100644 index 00000000..010cd11b --- /dev/null +++ b/src/utils/callTreeUtils.ts @@ -0,0 +1,125 @@ +import type { CallNode } from "../services/adapters/NetworkAdapter"; + +/** + * 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; +} + +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; + + function buildNode(trace: ParityTrace): CallNode { + const action = trace.action; + const result = trace.result; + + const children = traces + .filter((t) => { + if (t.traceAddress.length !== trace.traceAddress.length + 1) return false; + return trace.traceAddress.every((v, i) => t.traceAddress[i] === v); + }) + .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 `to` addresses from a call tree (lowercased) */ +export function collectAddresses(node: CallNode): string[] { + const addrs = new Set(); + function traverse(n: CallNode) { + if (n.to) addrs.add(n.to.toLowerCase()); + n.calls?.forEach(traverse); + } + traverse(node); + return Array.from(addrs); +} + +/** Parse hex gas value to number */ +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/src/utils/contractLookup.ts b/src/utils/contractLookup.ts new file mode 100644 index 00000000..56dc5f22 --- /dev/null +++ b/src/utils/contractLookup.ts @@ -0,0 +1,97 @@ +import { logger } from "./logger"; + +export interface ContractInfo { + name?: string; + // biome-ignore lint/suspicious/noExplicitAny: ABI types are dynamic + abi?: any[]; +} + +// Session-level cache keyed by "chainId:address" +const cache = new Map(); + +/** + * Fetch contract name + ABI for a single address. + * Tries Sourcify first; falls back to Etherscan V2 API if a key is provided. + * Results are cached in memory for the session. + */ +export async function fetchContractInfo( + address: string, + chainId: number, + signal?: AbortSignal, + etherscanKey?: string, +): Promise { + const cacheKey = `${chainId}:${address.toLowerCase()}`; + if (cache.has(cacheKey)) return cache.get(cacheKey) ?? null; + + // ── Sourcify ───────────────────────────────────────────────────────────── + try { + const res = await fetch( + `https://sourcify.dev/server/v2/contract/${chainId}/${address}?fields=abi,name`, + { signal }, + ); + if (res.ok) { + const data = await res.json(); + if (data?.abi || data?.name) { + const info: ContractInfo = { name: data.name, abi: data.abi }; + cache.set(cacheKey, info); + return info; + } + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return null; + logger.debug("Sourcify lookup failed for", address, err); + } + + // ── Etherscan fallback ──────────────────────────────────────────────────── + if (etherscanKey) { + try { + const url = `https://api.etherscan.io/v2/api?chainid=${chainId}&module=contract&action=getsourcecode&address=${address}&apikey=${etherscanKey}`; + const res = await fetch(url, { signal }); + const json = await res.json(); + 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); + const info: ContractInfo = { name: r.ContractName || undefined, abi }; + cache.set(cacheKey, info); + return info; + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return null; + logger.debug("Etherscan lookup failed for", address, err); + } + } + + cache.set(cacheKey, null); + return null; +} + +/** + * Fetch contract info for multiple addresses in parallel. + * Returns a map of lowercased address → ContractInfo. + */ +export async function fetchContractInfoBatch( + addresses: string[], + chainId: number, + signal?: AbortSignal, + etherscanKey?: string, +): Promise> { + const results = await Promise.allSettled( + addresses.map((addr) => fetchContractInfo(addr, chainId, signal, etherscanKey)), + ); + + const map: Record = {}; + for (let i = 0; i < addresses.length; i++) { + const result = results[i]; + const addr = addresses[i]?.toLowerCase(); + if (!addr) continue; + if (result?.status === "fulfilled" && result.value) { + map[addr] = result.value; + } + } + return map; +} From 5d95719c4d33319ca7660145193979f56a9a640b Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 17:06:08 -0300 Subject: [PATCH 02/16] feat(tx-analyser): add Gas Profiler tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Gas Profiler tab to the TX Analyser that shows how gas is spent across all operations in a transaction. Flattens the call tree into a sorted table with columns: type, contract, function, gas used (with percentage bar). Reuses existing call tree data — no additional RPC calls needed. Translations added for all 5 locales (en, es, ja, pt-BR, zh). --- src/components/pages/evm/tx/TxAnalyser.tsx | 151 ++++++++++++++++++++- src/locales/en/transaction.json | 8 +- src/locales/es/transaction.json | 8 +- src/locales/ja/transaction.json | 8 +- src/locales/pt-BR/transaction.json | 8 +- src/locales/zh/transaction.json | 8 +- src/styles/components.css | 102 ++++++++++++++ 7 files changed, 287 insertions(+), 6 deletions(-) diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx index 3190a569..dd5cea5b 100644 --- a/src/components/pages/evm/tx/TxAnalyser.tsx +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -23,7 +23,7 @@ interface TxAnalyserProps { dataService: DataService; } -type AnalyserTab = "callTree" | "stateChanges"; +type AnalyserTab = "callTree" | "gasProfiler" | "stateChanges"; // ─── Call type color mapping ─────────────────────────────────────────────── @@ -386,6 +386,131 @@ const StateChangesTab: React.FC<{ ); }; +// ─── Gas Profiler Tab ───────────────────────────────────────────────────── + +interface GasProfileEntry { + type: string; + from: string; + to?: string; + gasUsed: number; + percentage: number; + input?: string; + error?: string; + depth: number; +} + +function flattenGasEntries(node: CallNode, depth = 0): GasProfileEntry[] { + const entries: GasProfileEntry[] = []; + const gasUsed = hexToGas(node.gasUsed) ?? 0; + entries.push({ + type: node.type, + from: node.from, + to: node.to, + gasUsed, + percentage: 0, // computed after + input: node.input, + error: node.error, + depth, + }); + if (node.calls) { + for (const child of node.calls) { + entries.push(...flattenGasEntries(child, depth + 1)); + } + } + return entries; +} + +const GasProfilerTab: React.FC<{ + root: CallNode; + networkId: string; + contracts: Record; +}> = ({ root, networkId, contracts }) => { + const { t } = useTranslation("transaction"); + const totalGas = hexToGas(root.gasUsed) ?? 1; + + const entries = flattenGasEntries(root) + .map((e) => ({ ...e, percentage: (e.gasUsed / totalGas) * 100 })) + .sort((a, b) => b.gasUsed - a.gasUsed); + + return ( +
+
+ {t("analyser.summaryGas", { gas: totalGas.toLocaleString() })} + {t("analyser.gasProfilerEntries", { count: entries.length })} +
+
+ {/* Header */} +
+ {t("analyser.gasColType")} + {t("analyser.gasColTarget")} + {t("analyser.gasColFunction")} + {t("analyser.gasColGas")} + +
+ {/* Rows */} + {entries.map((entry, i) => { + const contractInfo = entry.to ? contracts[entry.to.toLowerCase()] : undefined; + const decoded = + entry.input && entry.input !== "0x" && contractInfo?.abi + ? decodeFunctionCall(entry.input, contractInfo.abi) + : null; + const color = getCallTypeColor(entry.type); + + return ( +
+ + + {entry.type} + + + + {entry.to ? ( + + {contractInfo?.name ?? } + + ) : ( + "—" + )} + + + {decoded ? ( + {decoded.functionName} + ) : entry.input && entry.input !== "0x" ? ( + {entry.input.slice(0, 10)} + ) : ( + "—" + )} + + + {entry.gasUsed.toLocaleString()} + {entry.percentage.toFixed(1)}% + + + + +
+ ); + })} +
+
+ ); +}; + // ─── Main TxAnalyser ─────────────────────────────────────────────────────── const TxAnalyser: React.FC = ({ @@ -478,6 +603,13 @@ const TxAnalyser: React.FC = ({ > {t("analyser.callTree")} + + {node.calls && node.calls.length > 0 && ( +
+ {node.calls.map((child, i) => ( + + ))} +
+ )} +
+ ); +}; + const GasProfilerTab: React.FC<{ root: CallNode; networkId: string; contracts: Record; }> = ({ root, networkId, contracts }) => { const { t } = useTranslation("transaction"); + const [zoomNode, setZoomNode] = useState(root); + const [selected, setSelected] = useState(null); + + const zoomGas = hexToGas(zoomNode.gasUsed) ?? 1; const totalGas = hexToGas(root.gasUsed) ?? 1; + const isZoomed = zoomNode !== root; + + const handleSelect = useCallback((node: CallNode) => { + setSelected(node); + setZoomNode(node); + }, []); - const entries = flattenGasEntries(root) - .map((e) => ({ ...e, percentage: (e.gasUsed / totalGas) * 100 })) - .sort((a, b) => b.gasUsed - a.gasUsed); + const resetZoom = useCallback(() => { + setZoomNode(root); + setSelected(null); + }, [root]); + + const breakdown = selected + ? getChildBreakdown(selected, hexToGas(selected.gasUsed) ?? 1, contracts) + : []; return (
{t("analyser.summaryGas", { gas: totalGas.toLocaleString() })} - {t("analyser.gasProfilerEntries", { count: entries.length })} + {isZoomed && ( + + )}
-
- {/* Header */} -
- {t("analyser.gasColType")} - {t("analyser.gasColTarget")} - {t("analyser.gasColFunction")} - {t("analyser.gasColGas")} - -
- {/* Rows */} - {entries.map((entry, i) => { - const contractInfo = entry.to ? contracts[entry.to.toLowerCase()] : undefined; - const decoded = - entry.input && entry.input !== "0x" && contractInfo?.abi - ? decodeFunctionCall(entry.input, contractInfo.abi) - : null; - const color = getCallTypeColor(entry.type); - - return ( -
- - - {entry.type} - - - - {entry.to ? ( - - {contractInfo?.name ?? } - - ) : ( - "—" - )} - - - {decoded ? ( - {decoded.functionName} - ) : entry.input && entry.input !== "0x" ? ( - {entry.input.slice(0, 10)} - ) : ( - "—" - )} - - - {entry.gasUsed.toLocaleString()} - {entry.percentage.toFixed(1)}% - - + {/* Flame chart */} +
+ +
+ {/* Breakdown panel */} + {selected && breakdown.length > 0 && ( +
+
+ + {t("analyser.gasBreakdownTitle")}:{" "} + {getFlameLabel(selected, contracts)} + + + {(hexToGas(selected.gasUsed) ?? 0).toLocaleString()} gas + +
+
+ {breakdown.map((entry, i) => ( +
- -
- ); - })} -
+ + {entry.to ? ( + + {entry.label} + + ) : ( + entry.label + )} + + {entry.pct.toFixed(1)}% + + {entry.gas.toLocaleString()} gas + + + + +
+ ))} +
+
{t("analyser.gasBreakdownHint")}
+
+ )}
); }; @@ -623,7 +717,11 @@ const TxAnalyser: React.FC = ({
{activeTab === "callTree" && ( <> - {loadingCallTree &&
{t("analyser.loading")}
} + {(loadingCallTree || enrichmentLoading) && ( +
+ {loadingCallTree ? t("analyser.loading") : t("analyser.enriching")} +
+ )} {callTreeError && (
{callTreeError}
@@ -632,13 +730,13 @@ const TxAnalyser: React.FC = ({ )}
)} - {callTree && ( + {callTree && !enrichmentLoading && ( )} @@ -646,7 +744,11 @@ const TxAnalyser: React.FC = ({ {activeTab === "gasProfiler" && ( <> - {loadingCallTree &&
{t("analyser.loading")}
} + {(loadingCallTree || enrichmentLoading) && ( +
+ {loadingCallTree ? t("analyser.loading") : t("analyser.enriching")} +
+ )} {callTreeError && (
{callTreeError}
@@ -655,7 +757,7 @@ const TxAnalyser: React.FC = ({ )}
)} - {callTree && ( + {callTree && !enrichmentLoading && ( )} @@ -663,7 +765,11 @@ const TxAnalyser: React.FC = ({ {activeTab === "stateChanges" && ( <> - {loadingPrestate &&
{t("analyser.loading")}
} + {(loadingPrestate || enrichmentLoading) && ( +
+ {loadingPrestate ? t("analyser.loading") : t("analyser.enriching")} +
+ )} {prestateError && (
{prestateError}
@@ -672,7 +778,7 @@ const TxAnalyser: React.FC = ({ )}
)} - {prestateTrace && ( + {prestateTrace && !enrichmentLoading && ( Date: Thu, 12 Mar 2026 17:32:19 -0300 Subject: [PATCH 04/16] feat(tx-analyser): move event logs into analyser as Events tab In super user mode, event logs are now displayed inside the TX Analyser as a dedicated Events tab instead of a separate collapsible section. The Events tab leverages enriched contract ABIs from the call tree enrichment to decode events from all addresses, not just the tx recipient. Non-super-user mode retains the original collapsible event logs section. --- .../pages/evm/tx/TransactionDisplay.tsx | 7 +- src/components/pages/evm/tx/TxAnalyser.tsx | 211 +++++++++++++++++- src/locales/en/transaction.json | 3 +- src/locales/es/transaction.json | 3 +- src/locales/ja/transaction.json | 3 +- src/locales/pt-BR/transaction.json | 3 +- src/locales/zh/transaction.json | 3 +- 7 files changed, 224 insertions(+), 9 deletions(-) diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index a1700412..61bc3381 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -782,8 +782,8 @@ const TransactionDisplay: React.FC = React.memo(
)} - {/* Event Logs Section - Collapsible, closed by default */} - {transaction.receipt && transaction.receipt.logs.length > 0 && ( + {/* Event Logs Section - Hidden in super user mode (moved to TX Analyser) */} + {!isSuperUser && transaction.receipt && transaction.receipt.logs.length > 0 && (
)} diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx index d662809d..7cef4ed9 100644 --- a/src/components/pages/evm/tx/TxAnalyser.tsx +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -2,6 +2,7 @@ import type React from "react"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; +import type { EthLog } from "@openscan/network-connectors"; import type { DataService } from "../../../../services/DataService"; import type { CallNode, @@ -11,7 +12,17 @@ import type { import { useCallTreeEnrichment } from "../../../../hooks/useCallTreeEnrichment"; import { countByType, countCalls, countReverts, hexToGas } from "../../../../utils/callTreeUtils"; import type { ContractInfo } from "../../../../utils/contractLookup"; -import { decodeFunctionCall } from "../../../../utils/inputDecoder"; +import { + type DecodedEvent, + decodeEventLog, + formatDecodedValue, + getEventTypeColor, +} from "../../../../utils/eventDecoder"; +import { + type DecodedInput, + decodeEventWithAbi, + decodeFunctionCall, +} from "../../../../utils/inputDecoder"; import { formatNativeFromWei } from "../../../../utils/unitFormatters"; import { logger } from "../../../../utils/logger"; import LongString from "../../../common/LongString"; @@ -21,9 +32,13 @@ interface TxAnalyserProps { networkId: string; networkCurrency: string; dataService: DataService; + logs?: EthLog[]; + txToAddress?: string; + // biome-ignore lint/suspicious/noExplicitAny: ABI types are dynamic + contractAbi?: any[]; } -type AnalyserTab = "callTree" | "gasProfiler" | "stateChanges"; +type AnalyserTab = "callTree" | "gasProfiler" | "stateChanges" | "events"; // ─── Call type color mapping ─────────────────────────────────────────────── @@ -605,6 +620,176 @@ const GasProfilerTab: React.FC<{ ); }; +// ─── Event Logs Tab ────────────────────────────────────────────────────── + +const EventLogsTab: React.FC<{ + logs: EthLog[]; + networkId: string; + txToAddress?: string; + // biome-ignore lint/suspicious/noExplicitAny: ABI types are dynamic + contractAbi?: any[]; + contracts: Record; +}> = ({ logs, networkId, txToAddress, contractAbi, contracts }) => { + const { t } = useTranslation("transaction"); + + return ( +
+
+ + {t("analyser.events")} ({logs.length}) + +
+
+ {logs.map((log, index) => { + let decoded: DecodedEvent | null = null; + let abiDecoded: DecodedInput | null = null; + + // Try enriched ABI first (from call tree enrichment) + const enrichedContract = log.address ? contracts[log.address.toLowerCase()] : undefined; + + if (enrichedContract?.abi && log.topics) { + abiDecoded = decodeEventWithAbi(log.topics, log.data || "0x", enrichedContract.abi); + } + + // Try tx recipient ABI + if ( + !abiDecoded && + txToAddress && + log.address?.toLowerCase() === txToAddress.toLowerCase() && + contractAbi && + log.topics + ) { + abiDecoded = decodeEventWithAbi(log.topics, log.data || "0x", contractAbi); + } + + // Fallback to standard event lookup + if (!abiDecoded && log.topics) { + decoded = decodeEventLog(log.topics, log.data || "0x"); + } + + const hasDecoded = abiDecoded || decoded; + const displayName = abiDecoded?.functionName || decoded?.name; + const displaySignature = abiDecoded?.signature || decoded?.fullSignature; + const displayParams = abiDecoded?.params || decoded?.params || []; + + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: log index is stable +
+
{index}
+
+ {hasDecoded && ( +
+ + {displayName} + + + {displaySignature} + + {abiDecoded && ( + + {t("logsAbi")} + + )} +
+ )} + +
+ {t("logsAddress")} + + + {enrichedContract?.name ? ( + <> + {enrichedContract.name}{" "} + + () + + + ) : ( + log.address + )} + + +
+ + {displayParams.length > 0 && ( +
+ {t("logsDecoded")} +
+ {displayParams.map((param, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: param index is stable +
+ {param.name} + ({param.type}) + + {param.type === "address" ? ( + + {param.value} + + ) : ( + formatDecodedValue(param.value, param.type) + )} + + {param.indexed && ( + {t("logsIndexed")} + )} +
+ ))} +
+
+ )} + + {log.topics && log.topics.length > 0 && ( +
+ + {hasDecoded ? t("logsRawTopics") : t("logsTopics")} + +
+ {log.topics.map((topic: string, i: number) => ( +
+ [{i}] + {topic} +
+ ))} +
+
+ )} + + {log.data && log.data !== "0x" && ( +
+ + {hasDecoded ? t("logsRawData") : t("logsData")} + +
+ {log.data} +
+
+ )} +
+
+ ); + })} +
+
+ ); +}; + // ─── Main TxAnalyser ─────────────────────────────────────────────────────── const TxAnalyser: React.FC = ({ @@ -612,6 +797,9 @@ const TxAnalyser: React.FC = ({ networkId, networkCurrency, dataService, + logs, + txToAddress, + contractAbi, }) => { const { t } = useTranslation("transaction"); const [activeTab, setActiveTab] = useState("callTree"); @@ -711,6 +899,15 @@ const TxAnalyser: React.FC = ({ > {t("analyser.stateChanges")} + {logs && logs.length > 0 && ( + + )} {/* Tab content */} @@ -788,6 +985,16 @@ const TxAnalyser: React.FC = ({ )} )} + + {activeTab === "events" && logs && logs.length > 0 && ( + + )} ); diff --git a/src/locales/en/transaction.json b/src/locales/en/transaction.json index d0a7dc6f..ca11e020 100644 --- a/src/locales/en/transaction.json +++ b/src/locales/en/transaction.json @@ -159,6 +159,7 @@ "gasProfiler": "Gas Profiler", "gasBreakdownTitle": "Breakdown", "gasBreakdownHint": "Click any bar to zoom in and see its breakdown", - "gasResetView": "Reset zoom" + "gasResetView": "Reset zoom", + "events": "Events" } } diff --git a/src/locales/es/transaction.json b/src/locales/es/transaction.json index 9fa8fa7a..ed3d8604 100644 --- a/src/locales/es/transaction.json +++ b/src/locales/es/transaction.json @@ -159,6 +159,7 @@ "gasProfiler": "Perfil de Gas", "gasBreakdownTitle": "Desglose", "gasBreakdownHint": "Haz clic en cualquier barra para ampliar y ver su desglose", - "gasResetView": "Restablecer zoom" + "gasResetView": "Restablecer zoom", + "events": "Eventos" } } diff --git a/src/locales/ja/transaction.json b/src/locales/ja/transaction.json index f8cf9dbc..9f438d74 100644 --- a/src/locales/ja/transaction.json +++ b/src/locales/ja/transaction.json @@ -159,6 +159,7 @@ "gasProfiler": "ガスプロファイラー", "gasBreakdownTitle": "内訳", "gasBreakdownHint": "任意のバーをクリックしてズームインし、内訳を表示します", - "gasResetView": "ズームをリセット" + "gasResetView": "ズームをリセット", + "events": "イベント" } } diff --git a/src/locales/pt-BR/transaction.json b/src/locales/pt-BR/transaction.json index ed0d0a57..88a6bcb9 100644 --- a/src/locales/pt-BR/transaction.json +++ b/src/locales/pt-BR/transaction.json @@ -159,6 +159,7 @@ "gasProfiler": "Perfil de Gás", "gasBreakdownTitle": "Detalhamento", "gasBreakdownHint": "Clique em qualquer barra para ampliar e ver seu detalhamento", - "gasResetView": "Redefinir zoom" + "gasResetView": "Redefinir zoom", + "events": "Eventos" } } diff --git a/src/locales/zh/transaction.json b/src/locales/zh/transaction.json index d9ebc748..0327a252 100644 --- a/src/locales/zh/transaction.json +++ b/src/locales/zh/transaction.json @@ -159,6 +159,7 @@ "gasProfiler": "Gas 分析", "gasBreakdownTitle": "分项明细", "gasBreakdownHint": "点击任意条形以放大并查看其明细", - "gasResetView": "重置缩放" + "gasResetView": "重置缩放", + "events": "事件" } } From bd8f0940cc65e84f91144c4fdb62d6d0fbd8739c Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 17:36:19 -0300 Subject: [PATCH 05/16] feat(tx-analyser): move input data into analyser as Input Data tab In super user mode, raw and decoded input data are now displayed inside the TX Analyser as an Input Data tab. The tab also tries enriched ABIs from call tree enrichment as a fallback for decoding. Non-super-user mode retains the original input data rows in the transaction details. --- .../pages/evm/tx/TransactionDisplay.tsx | 30 +++--- src/components/pages/evm/tx/TxAnalyser.tsx | 93 ++++++++++++++++++- src/locales/en/transaction.json | 4 +- src/locales/es/transaction.json | 4 +- src/locales/ja/transaction.json | 4 +- src/locales/pt-BR/transaction.json | 4 +- src/locales/zh/transaction.json | 4 +- 7 files changed, 124 insertions(+), 19 deletions(-) diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index 61bc3381..26d9ff49 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -949,20 +949,22 @@ const TransactionDisplay: React.FC = React.memo( )} {/* Full-width rows: long content */} - {/* Input Data */} -
- {t("inputData")} - {transaction.data && transaction.data !== "0x" ? ( -
- {transaction.data} -
- ) : ( - 0x - )} -
+ {/* Input Data - Hidden in super user mode (moved to TX Analyser) */} + {!isSuperUser && ( +
+ {t("inputData")} + {transaction.data && transaction.data !== "0x" ? ( +
+ {transaction.data} +
+ ) : ( + 0x + )} +
+ )} - {/* Decoded Input Data */} - {decodedInput && ( + {/* Decoded Input Data - Hidden in super user mode (moved to TX Analyser) */} + {!isSuperUser && decodedInput && (
{t("decodedInput")}
@@ -1020,6 +1022,8 @@ const TransactionDisplay: React.FC = React.memo( logs={transaction.receipt?.logs} txToAddress={transaction.to} contractAbi={contractData?.abi} + inputData={transaction.data} + decodedInputData={decodedInput} />
)} diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx index 7cef4ed9..4cac2e93 100644 --- a/src/components/pages/evm/tx/TxAnalyser.tsx +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -36,9 +36,11 @@ interface TxAnalyserProps { txToAddress?: string; // biome-ignore lint/suspicious/noExplicitAny: ABI types are dynamic contractAbi?: any[]; + inputData?: string; + decodedInputData?: DecodedInput | null; } -type AnalyserTab = "callTree" | "gasProfiler" | "stateChanges" | "events"; +type AnalyserTab = "callTree" | "gasProfiler" | "stateChanges" | "events" | "inputData"; // ─── Call type color mapping ─────────────────────────────────────────────── @@ -620,6 +622,74 @@ const GasProfilerTab: React.FC<{ ); }; +// ─── Input Data Tab ────────────────────────────────────────────────────── + +const InputDataTab: React.FC<{ + inputData: string; + decodedInput: DecodedInput | null; + networkId: string; + contracts: Record; + txToAddress?: string; +}> = ({ inputData, decodedInput, networkId, contracts, txToAddress }) => { + const { t } = useTranslation("transaction"); + + // Try enriched ABI decode if no decoded input from sourcify/local + const resolved = + decodedInput ?? + (() => { + if (!txToAddress || !inputData || inputData === "0x") return null; + const enriched = contracts[txToAddress.toLowerCase()]; + if (!enriched?.abi) return null; + return decodeFunctionCall(inputData, enriched.abi); + })(); + + return ( +
+ {resolved && ( +
+
+ {t("decodedInput")} +
+
+
+ {resolved.functionName} + {resolved.signature} +
+ {resolved.params.length > 0 && ( +
+ {resolved.params.map((param, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: params have stable order +
+ {param.name} + ({param.type}) + + {param.type === "address" ? ( + + {param.value} + + ) : ( + formatDecodedValue(param.value, param.type) + )} + +
+ ))} +
+ )} +
+
+ )} +
+
+ {t("analyser.rawInputData")} +
+
+ {inputData} +
+
+
+ ); +}; + // ─── Event Logs Tab ────────────────────────────────────────────────────── const EventLogsTab: React.FC<{ @@ -800,6 +870,8 @@ const TxAnalyser: React.FC = ({ logs, txToAddress, contractAbi, + inputData, + decodedInputData, }) => { const { t } = useTranslation("transaction"); const [activeTab, setActiveTab] = useState("callTree"); @@ -908,6 +980,15 @@ const TxAnalyser: React.FC = ({ {t("analyser.events")} ({logs.length}) )} + {inputData && inputData !== "0x" && ( + + )}
{/* Tab content */} @@ -995,6 +1076,16 @@ const TxAnalyser: React.FC = ({ contracts={contracts} /> )} + + {activeTab === "inputData" && inputData && inputData !== "0x" && ( + + )} ); diff --git a/src/locales/en/transaction.json b/src/locales/en/transaction.json index ca11e020..4541d49c 100644 --- a/src/locales/en/transaction.json +++ b/src/locales/en/transaction.json @@ -160,6 +160,8 @@ "gasBreakdownTitle": "Breakdown", "gasBreakdownHint": "Click any bar to zoom in and see its breakdown", "gasResetView": "Reset zoom", - "events": "Events" + "events": "Events", + "inputDataTab": "Input Data", + "rawInputData": "Raw Input Data" } } diff --git a/src/locales/es/transaction.json b/src/locales/es/transaction.json index ed3d8604..75426807 100644 --- a/src/locales/es/transaction.json +++ b/src/locales/es/transaction.json @@ -160,6 +160,8 @@ "gasBreakdownTitle": "Desglose", "gasBreakdownHint": "Haz clic en cualquier barra para ampliar y ver su desglose", "gasResetView": "Restablecer zoom", - "events": "Eventos" + "events": "Eventos", + "inputDataTab": "Datos de Entrada", + "rawInputData": "Datos de Entrada sin Procesar" } } diff --git a/src/locales/ja/transaction.json b/src/locales/ja/transaction.json index 9f438d74..07194217 100644 --- a/src/locales/ja/transaction.json +++ b/src/locales/ja/transaction.json @@ -160,6 +160,8 @@ "gasBreakdownTitle": "内訳", "gasBreakdownHint": "任意のバーをクリックしてズームインし、内訳を表示します", "gasResetView": "ズームをリセット", - "events": "イベント" + "events": "イベント", + "inputDataTab": "入力データ", + "rawInputData": "生の入力データ" } } diff --git a/src/locales/pt-BR/transaction.json b/src/locales/pt-BR/transaction.json index 88a6bcb9..8e40509b 100644 --- a/src/locales/pt-BR/transaction.json +++ b/src/locales/pt-BR/transaction.json @@ -160,6 +160,8 @@ "gasBreakdownTitle": "Detalhamento", "gasBreakdownHint": "Clique em qualquer barra para ampliar e ver seu detalhamento", "gasResetView": "Redefinir zoom", - "events": "Eventos" + "events": "Eventos", + "inputDataTab": "Dados de Entrada", + "rawInputData": "Dados de Entrada Brutos" } } diff --git a/src/locales/zh/transaction.json b/src/locales/zh/transaction.json index 0327a252..dc96d09f 100644 --- a/src/locales/zh/transaction.json +++ b/src/locales/zh/transaction.json @@ -160,6 +160,8 @@ "gasBreakdownTitle": "分项明细", "gasBreakdownHint": "点击任意条形以放大并查看其明细", "gasResetView": "重置缩放", - "events": "事件" + "events": "事件", + "inputDataTab": "输入数据", + "rawInputData": "原始输入数据" } } From d6f137fe08257470e2e7ce41f5c6a7c721e0e125 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 17:37:36 -0300 Subject: [PATCH 06/16] feat(tx-analyser): always show analyser in super user mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the collapsible toggle button — the TX Analyser section is now always visible when super user mode is active. --- .../pages/evm/tx/TransactionDisplay.tsx | 36 ++++++------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index 26d9ff49..f71827e1 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -72,7 +72,6 @@ const TransactionDisplay: React.FC = React.memo( const [_showRawData, _setShowRawData] = useState(false); const [_showLogs, _setShowLogs] = useState(false); - const [showAnalyser, setShowAnalyser] = useState(false); const [callTargetToken, setCallTargetToken] = useState(null); const [callTargetTokenListMatch, setCallTargetTokenListMatch] = useState( null, @@ -1004,30 +1003,17 @@ const TransactionDisplay: React.FC = React.memo( {/* TX Analyser (Super User Mode) */} {isSuperUser && dataService && networkId && ( -
- {/** biome-ignore lint/a11y/useButtonType: */} - - {showAnalyser && ( -
- -
- )} -
+ )} {/* Debug Trace Section (Localhost Only) */} From 73b9038c58cedddc482a675a9538da3a72eb5390 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 17:42:07 -0300 Subject: [PATCH 07/16] fix(tx-analyser): prevent rendering before contract enrichment starts Fix race condition where call tree tabs rendered with empty contracts for one frame before the enrichment useEffect had a chance to run. Track a pendingEnrichment state to bridge the gap between when the call tree arrives and when the async enrichment effect fires. --- src/hooks/useCallTreeEnrichment.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/useCallTreeEnrichment.ts b/src/hooks/useCallTreeEnrichment.ts index ef6eae1b..dff53241 100644 --- a/src/hooks/useCallTreeEnrichment.ts +++ b/src/hooks/useCallTreeEnrichment.ts @@ -19,8 +19,12 @@ export function useCallTreeEnrichment( const { settings } = useSettings(); const [contracts, setContracts] = useState>({}); const [enrichmentLoading, setEnrichmentLoading] = useState(false); + const enrichedTreeRef = useRef(null); const abortRef = useRef(null); + // Track whether enrichment is needed but hasn't started yet + const pendingEnrichment = !!tree && tree !== enrichedTreeRef.current && !enrichmentLoading; + useEffect(() => { if (!tree || !networkId) return; @@ -33,6 +37,7 @@ export function useCallTreeEnrichment( const controller = new AbortController(); abortRef.current = controller; + enrichedTreeRef.current = tree; setEnrichmentLoading(true); setContracts({}); @@ -47,5 +52,5 @@ export function useCallTreeEnrichment( return () => controller.abort(); }, [tree, networkId, settings.apiKeys?.etherscan]); - return { contracts, enrichmentLoading }; + return { contracts, enrichmentLoading: enrichmentLoading || pendingEnrichment }; } From 35eb444e03793ad463f0ab60cdd90d13ef4727d0 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 17:45:46 -0300 Subject: [PATCH 08/16] fix(tx-analyser): ensure enrichment blocks rendering until complete Replace pendingEnrichment workaround with a simpler done-flag approach. enrichmentLoading is now true from the moment a call tree exists until all contract info has been fetched, eliminating any render gap. --- src/hooks/useCallTreeEnrichment.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/hooks/useCallTreeEnrichment.ts b/src/hooks/useCallTreeEnrichment.ts index dff53241..82c9a409 100644 --- a/src/hooks/useCallTreeEnrichment.ts +++ b/src/hooks/useCallTreeEnrichment.ts @@ -8,6 +8,9 @@ import { type ContractInfo, fetchContractInfoBatch } from "../utils/contractLook * Fetches contract names + ABIs for all unique addresses in a call tree. * Uses Sourcify as primary source, Etherscan as fallback (if key configured). * Returns a map of lowercased address → ContractInfo. + * + * enrichmentLoading is true from the moment a tree is provided until + * all contract info has been resolved, so callers can gate rendering. */ export function useCallTreeEnrichment( tree: CallNode | null, @@ -18,27 +21,25 @@ export function useCallTreeEnrichment( } { const { settings } = useSettings(); const [contracts, setContracts] = useState>({}); - const [enrichmentLoading, setEnrichmentLoading] = useState(false); - const enrichedTreeRef = useRef(null); + const [done, setDone] = useState(false); const abortRef = useRef(null); - // Track whether enrichment is needed but hasn't started yet - const pendingEnrichment = !!tree && tree !== enrichedTreeRef.current && !enrichmentLoading; - useEffect(() => { if (!tree || !networkId) return; const chainId = Number(networkId); const addresses = collectAddresses(tree); - if (addresses.length === 0) return; + if (addresses.length === 0) { + setDone(true); + return; + } // Cancel previous fetch abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; - enrichedTreeRef.current = tree; - setEnrichmentLoading(true); + setDone(false); setContracts({}); fetchContractInfoBatch(addresses, chainId, controller.signal, settings.apiKeys?.etherscan) @@ -46,11 +47,14 @@ export function useCallTreeEnrichment( if (!controller.signal.aborted) setContracts(map); }) .finally(() => { - if (!controller.signal.aborted) setEnrichmentLoading(false); + if (!controller.signal.aborted) setDone(true); }); return () => controller.abort(); }, [tree, networkId, settings.apiKeys?.etherscan]); - return { contracts, enrichmentLoading: enrichmentLoading || pendingEnrichment }; + // Loading until tree is provided AND enrichment has completed + const enrichmentLoading = !!tree && !done; + + return { contracts, enrichmentLoading }; } From fcdea820cd89c94f0a1dc822fe871e61ee180628 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 17:50:40 -0300 Subject: [PATCH 09/16] fix(contractLookup): use correct Sourcify V2 API fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sourcify V2 API does not have a top-level 'name' field — the contract name is nested under compilation.name. Changed the request from ?fields=abi,name (which returned a 400 error) to ?fields=abi,compilation and extracted the name from compilation.name. --- src/utils/contractLookup.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/utils/contractLookup.ts b/src/utils/contractLookup.ts index 56dc5f22..d3fc1e5b 100644 --- a/src/utils/contractLookup.ts +++ b/src/utils/contractLookup.ts @@ -23,16 +23,18 @@ export async function fetchContractInfo( const cacheKey = `${chainId}:${address.toLowerCase()}`; if (cache.has(cacheKey)) return cache.get(cacheKey) ?? null; - // ── Sourcify ───────────────────────────────────────────────────────────── + // ── Sourcify V2 ────────────────────────────────────────────────────────── try { const res = await fetch( - `https://sourcify.dev/server/v2/contract/${chainId}/${address}?fields=abi,name`, + `https://sourcify.dev/server/v2/contract/${chainId}/${address}?fields=abi,compilation`, { signal }, ); if (res.ok) { const data = await res.json(); - if (data?.abi || data?.name) { - const info: ContractInfo = { name: data.name, abi: data.abi }; + const name = data?.compilation?.name; + const abi = data?.abi; + if (abi || name) { + const info: ContractInfo = { name, abi }; cache.set(cacheKey, info); return info; } From e1aa71e48d298d037c985fbbd67b013e517b2c6d Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 17:57:08 -0300 Subject: [PATCH 10/16] feat(tx-analyser): show analyser for all users with Events and Input Data tabs The TX Analyser is now always visible on transaction pages. Base users see Events and Input Data tabs. Super user mode unlocks Call Tree, Gas Profiler, and State Changes tabs. Trace fetching is skipped in base mode. Removed duplicate event logs and input data sections from TransactionDisplay since TxAnalyser now handles them for all users. --- .../pages/evm/tx/TransactionDisplay.tsx | 232 +----------------- src/components/pages/evm/tx/TxAnalyser.tsx | 80 +++--- 2 files changed, 53 insertions(+), 259 deletions(-) diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index f71827e1..11042470 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -32,12 +32,7 @@ import type { TransactionReceiptArbitrum, TransactionReceiptOptimism, } from "../../../../types"; -import { - type DecodedEvent, - decodeEventLog, - formatDecodedValue, - getEventTypeColor, -} from "../../../../utils/eventDecoder"; +import { type DecodedEvent, decodeEventLog } from "../../../../utils/eventDecoder"; import { type DecodedInput, decodeEventWithAbi, @@ -76,7 +71,6 @@ const TransactionDisplay: React.FC = React.memo( const [callTargetTokenListMatch, setCallTargetTokenListMatch] = useState( null, ); - const [showEventLogs, setShowEventLogs] = useState(false); const [showTrace, setShowTrace] = useState(false); const [traceData, setTraceData] = useState(null); // biome-ignore lint/suspicious/noExplicitAny: @@ -781,228 +775,11 @@ const TransactionDisplay: React.FC = React.memo( )} - {/* Event Logs Section - Hidden in super user mode (moved to TX Analyser) */} - {!isSuperUser && transaction.receipt && transaction.receipt.logs.length > 0 && ( -
- - {showEventLogs && ( -
- {/** biome-ignore lint/suspicious/noExplicitAny: */} - {transaction.receipt.logs.map((log: any, index: number) => { - // Try ABI-based decoding first if log is from tx.to and we have contract data - let decoded: DecodedEvent | null = null; - let abiDecoded: DecodedInput | null = null; - - const isFromTxRecipient = - transaction.to && - log.address && - log.address.toLowerCase() === transaction.to.toLowerCase(); - - if (isFromTxRecipient && contractData?.abi && log.topics) { - abiDecoded = decodeEventWithAbi( - log.topics, - log.data || "0x", - contractData.abi, - ); - } - - // Fallback to standard event lookup - if (!abiDecoded && log.topics) { - decoded = decodeEventLog(log.topics, log.data || "0x"); - } - - // Determine which decoded data to display - const hasDecoded = abiDecoded || decoded; - const displayName = abiDecoded?.functionName || decoded?.name; - const displaySignature = abiDecoded?.signature || decoded?.fullSignature; - const displayParams = abiDecoded?.params || decoded?.params || []; - - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: -
-
{index}
-
- {/* Decoded Event Header */} - {hasDecoded && ( -
- - {displayName} - - - {displaySignature} - - {abiDecoded && ( - - {t("logsAbi")} - - )} -
- )} - - {/* Address */} -
- {t("logsAddress")} - - {networkId ? ( - - {log.address} - - ) : ( - log.address - )} - -
- - {/* Decoded Parameters */} - {displayParams.length > 0 && ( -
- {t("logsDecoded")} -
- {displayParams.map((param, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: -
- {param.name} - ({param.type}) - - {param.type === "address" && networkId ? ( - - {param.value} - - ) : ( - formatDecodedValue(param.value, param.type) - )} - - {param.indexed && ( - {t("logsIndexed")} - )} -
- ))} -
-
- )} - - {/* Raw Topics (collapsed if decoded) */} - {log.topics && log.topics.length > 0 && ( -
- - {hasDecoded ? t("logsRawTopics") : t("logsTopics")} - -
- {log.topics.map((topic: string, i: number) => ( -
- [{i}] - {topic} -
- ))} -
-
- )} - - {/* Raw Data */} - {log.data && log.data !== "0x" && ( -
- - {hasDecoded ? t("logsRawData") : t("logsData")} - -
- {log.data} -
-
- )} -
-
- ); - })} -
- )} -
- )} - - {/* Full-width rows: long content */} - {/* Input Data - Hidden in super user mode (moved to TX Analyser) */} - {!isSuperUser && ( -
- {t("inputData")} - {transaction.data && transaction.data !== "0x" ? ( -
- {transaction.data} -
- ) : ( - 0x - )} -
- )} - - {/* Decoded Input Data - Hidden in super user mode (moved to TX Analyser) */} - {!isSuperUser && decodedInput && ( -
- {t("decodedInput")} -
-
- {decodedInput.functionName} - {decodedInput.signature} -
- {decodedInput.params.length > 0 && ( -
- {decodedInput.params.map((param, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: params have stable order -
- {param.name} - ({param.type}) - - {param.type === "address" && networkId ? ( - - {param.value} - - ) : ( - formatDecodedValue(param.value, param.type) - )} - -
- ))} -
- )} -
-
- )} + {/* Event Logs + Input Data are now always in TX Analyser */} - {/* TX Analyser (Super User Mode) */} - {isSuperUser && dataService && networkId && ( + {/* TX Analyser — always shown; super user mode unlocks advanced tabs */} + {dataService && networkId && ( = React.memo( contractAbi={contractData?.abi} inputData={transaction.data} decodedInputData={decodedInput} + isSuperUser={isSuperUser} /> )} diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx index 4cac2e93..907f1b4e 100644 --- a/src/components/pages/evm/tx/TxAnalyser.tsx +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -38,6 +38,7 @@ interface TxAnalyserProps { contractAbi?: any[]; inputData?: string; decodedInputData?: DecodedInput | null; + isSuperUser?: boolean; } type AnalyserTab = "callTree" | "gasProfiler" | "stateChanges" | "events" | "inputData"; @@ -704,11 +705,6 @@ const EventLogsTab: React.FC<{ return (
-
- - {t("analyser.events")} ({logs.length}) - -
{logs.map((log, index) => { let decoded: DecodedEvent | null = null; @@ -872,9 +868,13 @@ const TxAnalyser: React.FC = ({ contractAbi, inputData, decodedInputData, + isSuperUser, }) => { const { t } = useTranslation("transaction"); - const [activeTab, setActiveTab] = useState("callTree"); + const hasEvents = logs && logs.length > 0; + const hasInputData = inputData && inputData !== "0x"; + const defaultTab: AnalyserTab = hasEvents ? "events" : hasInputData ? "inputData" : "callTree"; + const [activeTab, setActiveTab] = useState(defaultTab); const [callTree, setCallTree] = useState(null); const [prestateTrace, setPrestateTrace] = useState(null); @@ -890,8 +890,9 @@ const TxAnalyser: React.FC = ({ return /method not found|not supported|unsupported|does not exist/i.test(msg); }, []); - // Load call tree on first render + // Load call tree on first render (super user only) useEffect(() => { + if (!isSuperUser) return; if (callTree || callTreeError || loadingCallTree) return; setLoadingCallTree(true); dataService.networkAdapter @@ -911,10 +912,20 @@ const TxAnalyser: React.FC = ({ ); }) .finally(() => setLoadingCallTree(false)); - }, [txHash, dataService, callTree, callTreeError, loadingCallTree, t, isUnsupported]); + }, [ + isSuperUser, + txHash, + dataService, + callTree, + callTreeError, + loadingCallTree, + t, + isUnsupported, + ]); - // Load prestate when switching to that tab + // Load prestate when switching to that tab (super user only) useEffect(() => { + if (!isSuperUser) return; if (activeTab !== "stateChanges") return; if (prestateTrace || prestateError || loadingPrestate) return; setLoadingPrestate(true); @@ -936,6 +947,7 @@ const TxAnalyser: React.FC = ({ }) .finally(() => setLoadingPrestate(false)); }, [ + isSuperUser, activeTab, txHash, dataService, @@ -950,28 +962,7 @@ const TxAnalyser: React.FC = ({
{/* Tab bar */}
- - - - {logs && logs.length > 0 && ( + {hasEvents && ( )} - {inputData && inputData !== "0x" && ( + {hasInputData && ( )} + {isSuperUser && ( + <> + + + + + )}
{/* Tab content */} From 0d7154d610de2e3cdf9bae448a7c6df13f13c1ac Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 18:00:10 -0300 Subject: [PATCH 11/16] style(tx-analyser): differentiate base and super user tab highlights Base user tabs (Events, Input Data) use --text-primary when active. Super user tabs (Call Tree, Gas Profiler, State Changes) use --color-accent to visually signal the user is viewing advanced data. --- src/components/pages/evm/tx/TxAnalyser.tsx | 4 ++-- src/styles/components.css | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx index 907f1b4e..ac21c584 100644 --- a/src/components/pages/evm/tx/TxAnalyser.tsx +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -965,7 +965,7 @@ const TxAnalyser: React.FC = ({ {hasEvents && ( + + +
+ {changedAddresses.map((address) => { const pre: PrestateAccountState = trace.pre[address] ?? {}; const post: PrestateAccountState = trace.post[address] ?? {}; - const balDiff = balanceDiff(pre.balance, post.balance); const nonceDiff = pre.nonce !== post.nonce && (pre.nonce !== undefined || post.nonce !== undefined); @@ -317,86 +352,90 @@ const StateChangesTab: React.FC<{ new Set([...Object.keys(pre.storage ?? {}), ...Object.keys(post.storage ?? {})]), ).filter((k) => pre.storage?.[k] !== post.storage?.[k]); const codeChanged = pre.code !== post.code; - - if (!balDiff && !nonceDiff && storageKeys.length === 0 && !codeChanged) return null; - const contractName = contracts[address.toLowerCase()]?.name; + const isExpanded = expandedSet.has(address); return (
-
- + {/* biome-ignore lint/a11y/noStaticElementInteractions: collapsible header */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: collapsible header */} +
toggleAddress(address)} + > + {isExpanded ? "▾" : "▸"} + e.stopPropagation()} + > {contractName ? ( <> {contractName} - - - + ({address}) ) : ( - + address )}
-
- {/* Balance */} - {balDiff && ( -
- - {t("analyser.balanceChange")} ({networkCurrency}) - - {formatHexBalance(pre.balance)} - - {formatHexBalance(post.balance)} - - {balDiff} - -
- )} + {isExpanded && ( +
+ {balDiff && ( +
+ + {t("analyser.balanceChange")} ({networkCurrency}) + + {formatHexBalance(pre.balance)} + + {formatHexBalance(post.balance)} + + {balDiff} + +
+ )} - {/* Nonce */} - {nonceDiff && ( -
- {t("analyser.nonceChange")} - {pre.nonce ?? "—"} - - {post.nonce ?? "—"} - - +{(post.nonce ?? 0) - (pre.nonce ?? 0)} - -
- )} + {nonceDiff && ( +
+ {t("analyser.nonceChange")} + {pre.nonce ?? "—"} + + {post.nonce ?? "—"} + + +{(post.nonce ?? 0) - (pre.nonce ?? 0)} + +
+ )} - {/* Code */} - {codeChanged && ( -
- {t("analyser.codeDeployed")} - - {post.code ? `${post.code.slice(0, 20)}…` : "—"} - -
- )} + {codeChanged && ( +
+ {t("analyser.codeDeployed")} + + {post.code ? `${post.code.slice(0, 20)}…` : "—"} + +
+ )} - {/* Storage */} - {storageKeys.map((slot) => ( -
- {t("analyser.storageChange")} - - - - - - - - - - -
- ))} -
+ {storageKeys.map((slot) => ( +
+ {t("analyser.storageChange")} + + + + + + + + + + +
+ ))} +
+ )}
); })} @@ -593,10 +632,7 @@ const GasProfilerTab: React.FC<{ /> {entry.to ? ( - + {entry.label} ) : ( @@ -653,7 +689,6 @@ const InputDataTab: React.FC<{
- {resolved.functionName} {resolved.signature}
{resolved.params.length > 0 && ( @@ -702,9 +737,35 @@ const EventLogsTab: React.FC<{ contracts: Record; }> = ({ logs, networkId, txToAddress, contractAbi, contracts }) => { const { t } = useTranslation("transaction"); + const [expandedSet, setExpandedSet] = useState>(new Set()); + + const expandAll = () => setExpandedSet(new Set(logs.map((_, i) => i))); + const collapseAll = () => setExpandedSet(new Set()); + + const toggleLog = (idx: number) => { + setExpandedSet((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); + }; return (
+
+ + {logs.length} {t("analyser.events").toLowerCase()} + + + + + +
{logs.map((log, index) => { let decoded: DecodedEvent | null = null; @@ -738,13 +799,18 @@ const EventLogsTab: React.FC<{ const displaySignature = abiDecoded?.signature || decoded?.fullSignature; const displayParams = abiDecoded?.params || decoded?.params || []; + const isExpanded = expandedSet.has(index); + return ( // biome-ignore lint/suspicious/noArrayIndexKey: log index is stable
-
{index}
-
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: collapsible header */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: collapsible header */} +
toggleLog(index)}> + {isExpanded ? "▾" : "▸"} + {index} {hasDecoded && ( -
+
{displaySignature} - {abiDecoded && ( - - {t("logsAbi")} - - )}
)} + + e.stopPropagation()} + > + {enrichedContract?.name ?? log.address} + + +
-
- {t("logsAddress")} - - - {enrichedContract?.name ? ( - <> - {enrichedContract.name}{" "} - - () - - - ) : ( - log.address - )} - - -
+ {isExpanded && ( +
+
+ {t("logsAddress")} + + + {enrichedContract?.name ? ( + <> + {enrichedContract.name}{" "} + + () + + + ) : ( + log.address + )} + + +
- {displayParams.length > 0 && ( -
- {t("logsDecoded")} -
- {displayParams.map((param, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: param index is stable -
- {param.name} - ({param.type}) - - {param.type === "address" ? ( - - {param.value} - - ) : ( - formatDecodedValue(param.value, param.type) + {displayParams.length > 0 && ( +
+ {t("logsDecoded")} +
+ {displayParams.map((param, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: param index is stable +
+ {param.name} + ({param.type}) + + {param.type === "address" ? ( + + {param.value} + + ) : ( + formatDecodedValue(param.value, param.type) + )} + + {param.indexed && ( + {t("logsIndexed")} )} - - {param.indexed && ( - {t("logsIndexed")} - )} -
- ))} +
+ ))} +
-
- )} + )} - {log.topics && log.topics.length > 0 && ( -
- - {hasDecoded ? t("logsRawTopics") : t("logsTopics")} - -
- {log.topics.map((topic: string, i: number) => ( -
- [{i}] - {topic} -
- ))} + {log.topics && log.topics.length > 0 && ( +
+ + {hasDecoded ? t("logsRawTopics") : t("logsTopics")} + +
+ {log.topics.map((topic: string, i: number) => ( +
+ [{i}] + {topic} +
+ ))} +
-
- )} + )} - {log.data && log.data !== "0x" && ( -
- - {hasDecoded ? t("logsRawData") : t("logsData")} - -
- {log.data} + {log.data && log.data !== "0x" && ( +
+ + {hasDecoded ? t("logsRawData") : t("logsData")} + +
+ {log.data} +
-
- )} -
+ )} +
+ )}
); })} diff --git a/src/locales/en/transaction.json b/src/locales/en/transaction.json index 4541d49c..44412039 100644 --- a/src/locales/en/transaction.json +++ b/src/locales/en/transaction.json @@ -162,6 +162,8 @@ "gasResetView": "Reset zoom", "events": "Events", "inputDataTab": "Input Data", - "rawInputData": "Raw Input Data" + "rawInputData": "Raw Input Data", + "expandAll": "Expand All", + "collapseAll": "Collapse All" } } diff --git a/src/locales/es/transaction.json b/src/locales/es/transaction.json index 75426807..4f50f569 100644 --- a/src/locales/es/transaction.json +++ b/src/locales/es/transaction.json @@ -162,6 +162,8 @@ "gasResetView": "Restablecer zoom", "events": "Eventos", "inputDataTab": "Datos de Entrada", - "rawInputData": "Datos de Entrada sin Procesar" + "rawInputData": "Datos de Entrada sin Procesar", + "expandAll": "Expandir Todo", + "collapseAll": "Colapsar Todo" } } diff --git a/src/locales/ja/transaction.json b/src/locales/ja/transaction.json index 07194217..6a5b14c0 100644 --- a/src/locales/ja/transaction.json +++ b/src/locales/ja/transaction.json @@ -162,6 +162,8 @@ "gasResetView": "ズームをリセット", "events": "イベント", "inputDataTab": "入力データ", - "rawInputData": "生の入力データ" + "rawInputData": "生の入力データ", + "expandAll": "すべて展開", + "collapseAll": "すべて折りたたむ" } } diff --git a/src/locales/pt-BR/transaction.json b/src/locales/pt-BR/transaction.json index 8e40509b..ffaa2f27 100644 --- a/src/locales/pt-BR/transaction.json +++ b/src/locales/pt-BR/transaction.json @@ -162,6 +162,8 @@ "gasResetView": "Redefinir zoom", "events": "Eventos", "inputDataTab": "Dados de Entrada", - "rawInputData": "Dados de Entrada Brutos" + "rawInputData": "Dados de Entrada Brutos", + "expandAll": "Expandir Tudo", + "collapseAll": "Recolher Tudo" } } diff --git a/src/locales/zh/transaction.json b/src/locales/zh/transaction.json index dc96d09f..8707937c 100644 --- a/src/locales/zh/transaction.json +++ b/src/locales/zh/transaction.json @@ -162,6 +162,8 @@ "gasResetView": "重置缩放", "events": "事件", "inputDataTab": "输入数据", - "rawInputData": "原始输入数据" + "rawInputData": "原始输入数据", + "expandAll": "全部展开", + "collapseAll": "全部折叠" } } diff --git a/src/styles/components.css b/src/styles/components.css index 226bebc0..d57d9312 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -513,9 +513,8 @@ button.tx-section-header-toggle { .tx-log { display: flex; + flex-direction: column; border-bottom: 1px solid var(--color-primary-alpha-10); - padding: 16px; - gap: 16px; } .tx-log:last-child { @@ -523,8 +522,8 @@ button.tx-section-header-toggle { } .tx-log-index { - flex: 0 0 32px; - height: 32px; + flex: 0 0 24px; + height: 24px; background: var(--color-primary-alpha-15); border-radius: 50%; display: flex; @@ -532,16 +531,16 @@ button.tx-section-header-toggle { justify-content: center; font-family: "Outfit", sans-serif; font-weight: 600; - font-size: 0.85rem; + font-size: 0.75rem; color: var(--color-primary); } .tx-log-content { - flex: 1; display: flex; flex-direction: column; gap: 8px; min-width: 0; + padding: 8px 16px 8px 16px; } .tx-log-row { @@ -613,7 +612,6 @@ button.tx-section-header-toggle { display: flex; align-items: center; gap: 10px; - margin-bottom: 12px; flex-wrap: wrap; } @@ -633,6 +631,7 @@ button.tx-section-header-toggle { font-size: 0.75rem; color: var(--text-secondary); cursor: help; + margin-left: 8px; } .tx-log-params { @@ -2046,6 +2045,56 @@ button.tx-section-header-toggle { font-weight: 600; } +.state-change-address--toggle { + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; +} + +.state-change-address--toggle:hover { + background: var(--overlay-light-2); +} + +.analyser-expand-controls { + margin-left: auto; + display: flex; + gap: 8px; +} + +.analyser-expand-btn { + background: none; + border: 1px solid var(--border-primary); + border-radius: 4px; + padding: 2px 8px; + font-size: 0.75rem; + color: var(--text-secondary); + cursor: pointer; +} + +.analyser-expand-btn:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); +} + +.tx-log-header--toggle { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + font-size: 0.85rem; +} + +.tx-log-header--toggle:hover { + background: var(--overlay-light-2); +} + +.tx-log-header-address { + font-size: 0.8rem; + margin-left: auto; +} + .state-change-rows { display: flex; flex-direction: column; From 3a5575c4233fa390a37408db1479eb36f39ca436 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 21:48:40 -0300 Subject: [PATCH 13/16] feat(tx-analyser): enrich log addresses and resolve proxy ABIs - Fetch contract info for event log addresses independently of call tree, so event decoding works for all users (not just super user) - Resolve proxy contract implementations via Sourcify proxyResolution and merge implementation ABI for proper event/function decoding --- src/components/pages/evm/tx/TxAnalyser.tsx | 73 +++++++++++++++++++--- src/utils/contractLookup.ts | 14 ++++- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx index d22ad86c..339c3a0b 100644 --- a/src/components/pages/evm/tx/TxAnalyser.tsx +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -1,5 +1,5 @@ import type React from "react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import type { EthLog } from "@openscan/network-connectors"; @@ -11,7 +11,8 @@ import type { } from "../../../../services/adapters/NetworkAdapter"; import { useCallTreeEnrichment } from "../../../../hooks/useCallTreeEnrichment"; import { countByType, countCalls, countReverts, hexToGas } from "../../../../utils/callTreeUtils"; -import type { ContractInfo } from "../../../../utils/contractLookup"; +import { type ContractInfo, fetchContractInfoBatch } from "../../../../utils/contractLookup"; +import { useSettings } from "../../../../context/SettingsContext"; import { type DecodedEvent, decodeEventLog, @@ -958,7 +959,52 @@ const TxAnalyser: React.FC = ({ const [prestateError, setPrestateError] = useState(null); // Contract name + ABI enrichment for the call tree - const { contracts, enrichmentLoading } = useCallTreeEnrichment(callTree, networkId); + const { contracts: treeContracts, enrichmentLoading } = useCallTreeEnrichment( + callTree, + networkId, + ); + + // Enrich log addresses independently (works for all users, not just super) + const { settings } = useSettings(); + const [logContracts, setLogContracts] = useState>({}); + const [logEnrichmentDone, setLogEnrichmentDone] = useState(false); + const logAbortRef = useRef(null); + + // Stable key: sorted unique addresses from logs + const logAddresses = logs + ? Array.from(new Set(logs.map((l) => l.address?.toLowerCase()).filter(Boolean) as string[])) + : []; + const logAddressKey = logAddresses.join(","); + + useEffect(() => { + if (!logAddressKey || !networkId) { + setLogEnrichmentDone(true); + return; + } + + const addresses = logAddressKey.split(","); + + logAbortRef.current?.abort(); + const controller = new AbortController(); + logAbortRef.current = controller; + + setLogEnrichmentDone(false); + + const chainId = Number(networkId); + fetchContractInfoBatch(addresses, chainId, controller.signal, settings.apiKeys?.etherscan) + .then((map) => { + if (!controller.signal.aborted) setLogContracts(map); + }) + .finally(() => { + if (!controller.signal.aborted) setLogEnrichmentDone(true); + }); + + return () => controller.abort(); + }, [logAddressKey, networkId, settings.apiKeys?.etherscan]); + + // Merge tree + log contracts (tree contracts take priority since they include call tree addresses) + const contracts = { ...logContracts, ...treeContracts }; + const logEnrichmentLoading = !!(logs && logs.length > 0) && !logEnrichmentDone; const isUnsupported = useCallback((msg: string) => { return /method not found|not supported|unsupported|does not exist/i.test(msg); @@ -1158,13 +1204,20 @@ const TxAnalyser: React.FC = ({ )} {activeTab === "events" && logs && logs.length > 0 && ( - + <> + {logEnrichmentLoading && ( +
{t("analyser.enriching")}
+ )} + {!logEnrichmentLoading && ( + + )} + )} {activeTab === "inputData" && inputData && inputData !== "0x" && ( diff --git a/src/utils/contractLookup.ts b/src/utils/contractLookup.ts index d3fc1e5b..9b20a155 100644 --- a/src/utils/contractLookup.ts +++ b/src/utils/contractLookup.ts @@ -26,13 +26,23 @@ export async function fetchContractInfo( // ── Sourcify V2 ────────────────────────────────────────────────────────── try { const res = await fetch( - `https://sourcify.dev/server/v2/contract/${chainId}/${address}?fields=abi,compilation`, + `https://sourcify.dev/server/v2/contract/${chainId}/${address}?fields=abi,compilation,proxyResolution`, { signal }, ); if (res.ok) { const data = await res.json(); const name = data?.compilation?.name; - const abi = data?.abi; + let abi = data?.abi; + + // If this is a proxy, fetch the implementation ABI and merge it + const implAddr = data?.proxyResolution?.implementations?.[0]?.address; + if (implAddr) { + const implInfo = await fetchContractInfo(implAddr, chainId, signal, etherscanKey); + if (implInfo?.abi) { + abi = abi ? [...abi, ...implInfo.abi] : implInfo.abi; + } + } + if (abi || name) { const info: ContractInfo = { name, abi }; cache.set(cacheKey, info); From 59d9ac54630f3c93acf6e32b437cf3bba9351cae Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 13 Mar 2026 09:01:22 -0300 Subject: [PATCH 14/16] fix(tx): prevent re-fetch when toggling super user mode - Stabilize usePersistentCache callbacks with refs so toggling isSuperUser doesn't recreate getCached/setCached identities - Reset active analyser tab to a base tab when leaving super user mode --- src/components/pages/evm/tx/TxAnalyser.tsx | 9 +++++++++ src/hooks/usePersistentCache.ts | 20 ++++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx index 339c3a0b..8649639d 100644 --- a/src/components/pages/evm/tx/TxAnalyser.tsx +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -951,6 +951,15 @@ const TxAnalyser: React.FC = ({ const defaultTab: AnalyserTab = hasEvents ? "events" : hasInputData ? "inputData" : "callTree"; const [activeTab, setActiveTab] = useState(defaultTab); + // Reset to a base tab when leaving super user mode + // biome-ignore lint/correctness/useExhaustiveDependencies: only react to isSuperUser changes + useEffect(() => { + if (!isSuperUser) { + const superTabs: AnalyserTab[] = ["callTree", "gasProfiler", "stateChanges"]; + setActiveTab((prev) => (superTabs.includes(prev) ? defaultTab : prev)); + } + }, [isSuperUser]); + const [callTree, setCallTree] = useState(null); const [prestateTrace, setPrestateTrace] = useState(null); const [loadingCallTree, setLoadingCallTree] = useState(false); diff --git a/src/hooks/usePersistentCache.ts b/src/hooks/usePersistentCache.ts index 6c663195..157ee0be 100644 --- a/src/hooks/usePersistentCache.ts +++ b/src/hooks/usePersistentCache.ts @@ -1,10 +1,12 @@ -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { useSettings } from "../context/SettingsContext"; import { buildPersistentCacheKey, getCachedData, setCachedData } from "../utils/persistentCache"; /** * Hook that provides persistent cache operations. * All operations are no-ops when super user mode is disabled. + * Callbacks have stable identity so they don't trigger re-fetches + * when isSuperUser changes. * * Usage: * const { getCached, setCached } = usePersistentCache(); @@ -15,22 +17,28 @@ export function usePersistentCache() { const { isSuperUser, settings } = useSettings(); const maxSizeBytes = (settings.persistentCacheSizeMB ?? 10) * 1024 * 1024; + // Use refs so callbacks stay stable across isSuperUser toggles + const isSuperUserRef = useRef(isSuperUser); + isSuperUserRef.current = isSuperUser; + const maxSizeBytesRef = useRef(maxSizeBytes); + maxSizeBytesRef.current = maxSizeBytes; + const getCached = useCallback( (networkId: string, type: string, identifier: string): T | null => { - if (!isSuperUser) return null; + if (!isSuperUserRef.current) return null; const key = buildPersistentCacheKey(networkId, type, identifier); return getCachedData(key); }, - [isSuperUser], + [], ); const setCached = useCallback( (networkId: string, type: string, identifier: string, data: T): void => { - if (!isSuperUser) return; + if (!isSuperUserRef.current) return; const key = buildPersistentCacheKey(networkId, type, identifier); - setCachedData(key, data, maxSizeBytes); + setCachedData(key, data, maxSizeBytesRef.current); }, - [isSuperUser, maxSizeBytes], + [], ); return { getCached, setCached }; From 93df6b618280420c013095dc2fa315c7655f3230 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 13 Mar 2026 09:19:19 -0300 Subject: [PATCH 15/16] feat(tx-analyser): add collapse/expand toggle with smart defaults - Add collapse/expand button on the tab bar with text and arrow - Clicking active tab toggles collapse; clicking other tab expands to it - Base user mode starts collapsed; super user mode starts expanded - Switching to super user auto-expands the analyser - Add expand/collapse i18n keys to all 5 locales --- src/components/pages/evm/tx/TxAnalyser.tsx | 234 ++++++++++++--------- src/locales/en/transaction.json | 4 +- src/locales/es/transaction.json | 4 +- src/locales/ja/transaction.json | 4 +- src/locales/pt-BR/transaction.json | 4 +- src/locales/zh/transaction.json | 4 +- src/styles/components.css | 15 ++ 7 files changed, 160 insertions(+), 109 deletions(-) diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx index 8649639d..009206fa 100644 --- a/src/components/pages/evm/tx/TxAnalyser.tsx +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -954,7 +954,9 @@ const TxAnalyser: React.FC = ({ // Reset to a base tab when leaving super user mode // biome-ignore lint/correctness/useExhaustiveDependencies: only react to isSuperUser changes useEffect(() => { - if (!isSuperUser) { + if (isSuperUser) { + setCollapsed(false); + } else { const superTabs: AnalyserTab[] = ["callTree", "gasProfiler", "stateChanges"]; setActiveTab((prev) => (superTabs.includes(prev) ? defaultTab : prev)); } @@ -1087,6 +1089,20 @@ const TxAnalyser: React.FC = ({ isUnsupported, ]); + const [collapsed, setCollapsed] = useState(!isSuperUser); + + const handleTabClick = useCallback( + (tab: AnalyserTab) => { + if (tab === activeTab) { + setCollapsed((c) => !c); + } else { + setActiveTab(tab); + setCollapsed(false); + } + }, + [activeTab], + ); + return (
{/* Tab bar */} @@ -1095,7 +1111,7 @@ const TxAnalyser: React.FC = ({ @@ -1104,7 +1120,7 @@ const TxAnalyser: React.FC = ({ @@ -1114,131 +1130,141 @@ const TxAnalyser: React.FC = ({ )} +
{/* Tab content */} -
- {activeTab === "callTree" && ( - <> - {(loadingCallTree || enrichmentLoading) && ( -
- {loadingCallTree ? t("analyser.loading") : t("analyser.enriching")} -
- )} - {callTreeError && ( -
-
{callTreeError}
- {callTreeError === t("analyser.notSupported") && ( -
{t("analyser.traceHint")}
- )} -
- )} - {callTree && !enrichmentLoading && ( - - )} - - )} + {!collapsed && ( +
+ {activeTab === "callTree" && ( + <> + {(loadingCallTree || enrichmentLoading) && ( +
+ {loadingCallTree ? t("analyser.loading") : t("analyser.enriching")} +
+ )} + {callTreeError && ( +
+
{callTreeError}
+ {callTreeError === t("analyser.notSupported") && ( +
{t("analyser.traceHint")}
+ )} +
+ )} + {callTree && !enrichmentLoading && ( + + )} + + )} - {activeTab === "gasProfiler" && ( - <> - {(loadingCallTree || enrichmentLoading) && ( -
- {loadingCallTree ? t("analyser.loading") : t("analyser.enriching")} -
- )} - {callTreeError && ( -
-
{callTreeError}
- {callTreeError === t("analyser.notSupported") && ( -
{t("analyser.traceHint")}
- )} -
- )} - {callTree && !enrichmentLoading && ( - - )} - - )} + {activeTab === "gasProfiler" && ( + <> + {(loadingCallTree || enrichmentLoading) && ( +
+ {loadingCallTree ? t("analyser.loading") : t("analyser.enriching")} +
+ )} + {callTreeError && ( +
+
{callTreeError}
+ {callTreeError === t("analyser.notSupported") && ( +
{t("analyser.traceHint")}
+ )} +
+ )} + {callTree && !enrichmentLoading && ( + + )} + + )} - {activeTab === "stateChanges" && ( - <> - {(loadingPrestate || enrichmentLoading) && ( -
- {loadingPrestate ? t("analyser.loading") : t("analyser.enriching")} -
- )} - {prestateError && ( -
-
{prestateError}
- {prestateError === t("analyser.notSupported") && ( -
{t("analyser.traceHint")}
- )} -
- )} - {prestateTrace && !enrichmentLoading && ( - - )} - - )} + {activeTab === "stateChanges" && ( + <> + {(loadingPrestate || enrichmentLoading) && ( +
+ {loadingPrestate ? t("analyser.loading") : t("analyser.enriching")} +
+ )} + {prestateError && ( +
+
{prestateError}
+ {prestateError === t("analyser.notSupported") && ( +
{t("analyser.traceHint")}
+ )} +
+ )} + {prestateTrace && !enrichmentLoading && ( + + )} + + )} - {activeTab === "events" && logs && logs.length > 0 && ( - <> - {logEnrichmentLoading && ( -
{t("analyser.enriching")}
- )} - {!logEnrichmentLoading && ( - - )} - - )} + {activeTab === "events" && logs && logs.length > 0 && ( + <> + {logEnrichmentLoading && ( +
{t("analyser.enriching")}
+ )} + {!logEnrichmentLoading && ( + + )} + + )} - {activeTab === "inputData" && inputData && inputData !== "0x" && ( - - )} -
+ {activeTab === "inputData" && inputData && inputData !== "0x" && ( + + )} +
+ )}
); }; diff --git a/src/locales/en/transaction.json b/src/locales/en/transaction.json index 44412039..535e54be 100644 --- a/src/locales/en/transaction.json +++ b/src/locales/en/transaction.json @@ -164,6 +164,8 @@ "inputDataTab": "Input Data", "rawInputData": "Raw Input Data", "expandAll": "Expand All", - "collapseAll": "Collapse All" + "collapseAll": "Collapse All", + "expand": "Expand", + "collapse": "Collapse" } } diff --git a/src/locales/es/transaction.json b/src/locales/es/transaction.json index 4f50f569..144eb31d 100644 --- a/src/locales/es/transaction.json +++ b/src/locales/es/transaction.json @@ -164,6 +164,8 @@ "inputDataTab": "Datos de Entrada", "rawInputData": "Datos de Entrada sin Procesar", "expandAll": "Expandir Todo", - "collapseAll": "Colapsar Todo" + "collapseAll": "Colapsar Todo", + "expand": "Expandir", + "collapse": "Colapsar" } } diff --git a/src/locales/ja/transaction.json b/src/locales/ja/transaction.json index 6a5b14c0..3c3e0ce7 100644 --- a/src/locales/ja/transaction.json +++ b/src/locales/ja/transaction.json @@ -164,6 +164,8 @@ "inputDataTab": "入力データ", "rawInputData": "生の入力データ", "expandAll": "すべて展開", - "collapseAll": "すべて折りたたむ" + "collapseAll": "すべて折りたたむ", + "expand": "展開", + "collapse": "折りたたむ" } } diff --git a/src/locales/pt-BR/transaction.json b/src/locales/pt-BR/transaction.json index ffaa2f27..6a581851 100644 --- a/src/locales/pt-BR/transaction.json +++ b/src/locales/pt-BR/transaction.json @@ -164,6 +164,8 @@ "inputDataTab": "Dados de Entrada", "rawInputData": "Dados de Entrada Brutos", "expandAll": "Expandir Tudo", - "collapseAll": "Recolher Tudo" + "collapseAll": "Recolher Tudo", + "expand": "Expandir", + "collapse": "Recolher" } } diff --git a/src/locales/zh/transaction.json b/src/locales/zh/transaction.json index 8707937c..0dce1339 100644 --- a/src/locales/zh/transaction.json +++ b/src/locales/zh/transaction.json @@ -164,6 +164,8 @@ "inputDataTab": "输入数据", "rawInputData": "原始输入数据", "expandAll": "全部展开", - "collapseAll": "全部折叠" + "collapseAll": "全部折叠", + "expand": "展开", + "collapse": "折叠" } } diff --git a/src/styles/components.css b/src/styles/components.css index d57d9312..c8f01f41 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1840,10 +1840,25 @@ button.tx-section-header-toggle { .tx-analyser-tabs { display: flex; + align-items: center; border-bottom: 1px solid var(--border-primary); background: var(--bg-tertiary); } +.tx-analyser-collapse-btn { + margin-left: auto; + padding: 6px 12px; + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + font-size: 0.85rem; +} + +.tx-analyser-collapse-btn:hover { + color: var(--text-primary); +} + .tx-analyser-tab { padding: 10px 18px; font-size: 0.875rem; From 50a7c5df8198f3078d110d2f8bb0a02225eb7606 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 13 Mar 2026 09:46:47 -0300 Subject: [PATCH 16/16] refactor(tx-analyser): split into separate files and fix review findings - Split TxAnalyser.tsx (1,272 lines) into 6 files under analyser/ directory - Fix collectAddresses to collect both from and to addresses (DELEGATECALL enrichment) - Fix CSS "monospace" to unquoted generic family name --- src/components/pages/evm/tx/TxAnalyser.tsx | 934 +----------------- .../pages/evm/tx/analyser/CallTreeTab.tsx | 186 ++++ .../pages/evm/tx/analyser/EventLogsTab.tsx | 218 ++++ .../pages/evm/tx/analyser/GasProfilerTab.tsx | 225 +++++ .../pages/evm/tx/analyser/InputDataTab.tsx | 73 ++ .../pages/evm/tx/analyser/StateChangesTab.tsx | 199 ++++ src/components/pages/evm/tx/analyser/types.ts | 31 + src/styles/components.css | 40 +- src/utils/callTreeUtils.ts | 3 +- 9 files changed, 961 insertions(+), 948 deletions(-) create mode 100644 src/components/pages/evm/tx/analyser/CallTreeTab.tsx create mode 100644 src/components/pages/evm/tx/analyser/EventLogsTab.tsx create mode 100644 src/components/pages/evm/tx/analyser/GasProfilerTab.tsx create mode 100644 src/components/pages/evm/tx/analyser/InputDataTab.tsx create mode 100644 src/components/pages/evm/tx/analyser/StateChangesTab.tsx create mode 100644 src/components/pages/evm/tx/analyser/types.ts diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx index 009206fa..ff570233 100644 --- a/src/components/pages/evm/tx/TxAnalyser.tsx +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -1,937 +1,17 @@ import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; -import type { EthLog } from "@openscan/network-connectors"; -import type { DataService } from "../../../../services/DataService"; -import type { - CallNode, - PrestateAccountState, - PrestateTrace, -} from "../../../../services/adapters/NetworkAdapter"; +import type { CallNode, PrestateTrace } from "../../../../services/adapters/NetworkAdapter"; import { useCallTreeEnrichment } from "../../../../hooks/useCallTreeEnrichment"; -import { countByType, countCalls, countReverts, hexToGas } from "../../../../utils/callTreeUtils"; import { type ContractInfo, fetchContractInfoBatch } from "../../../../utils/contractLookup"; import { useSettings } from "../../../../context/SettingsContext"; -import { - type DecodedEvent, - decodeEventLog, - formatDecodedValue, - getEventTypeColor, -} from "../../../../utils/eventDecoder"; -import { - type DecodedInput, - decodeEventWithAbi, - decodeFunctionCall, -} from "../../../../utils/inputDecoder"; -import { formatNativeFromWei } from "../../../../utils/unitFormatters"; import { logger } from "../../../../utils/logger"; -import LongString from "../../../common/LongString"; - -interface TxAnalyserProps { - txHash: string; - networkId: string; - networkCurrency: string; - dataService: DataService; - logs?: EthLog[]; - txToAddress?: string; - // biome-ignore lint/suspicious/noExplicitAny: ABI types are dynamic - contractAbi?: any[]; - inputData?: string; - decodedInputData?: DecodedInput | null; - isSuperUser?: boolean; -} - -type AnalyserTab = "callTree" | "gasProfiler" | "stateChanges" | "events" | "inputData"; - -// ─── Call type color mapping ─────────────────────────────────────────────── - -const CALL_TYPE_COLORS: Record = { - CALL: "#3b82f6", - DELEGATECALL: "#f97316", - STATICCALL: "#8b5cf6", - CREATE: "#10b981", - CREATE2: "#10b981", - SELFDESTRUCT: "#ef4444", -}; - -function getCallTypeColor(type: string): string { - return CALL_TYPE_COLORS[type.toUpperCase()] ?? "#6b7280"; -} - -/** Truncate a decoded param value for inline display */ -function truncateParamValue(value: string, max = 20): string { - if (!value) return ""; - const str = String(value); - return str.length > max ? `${str.slice(0, max)}…` : str; -} - -// ─── Call Tree Node ──────────────────────────────────────────────────────── - -const CallTreeNode: React.FC<{ - node: CallNode; - networkId: string; - networkCurrency: string; - depth: number; - defaultExpanded: boolean; - contracts: Record; -}> = ({ node, networkId, networkCurrency, depth, defaultExpanded, contracts }) => { - const [expanded, setExpanded] = useState(defaultExpanded); - const hasChildren = node.calls && node.calls.length > 0; - const isError = !!node.error; - const color = getCallTypeColor(node.type); - - const formattedValue = - node.value && node.value !== "0x0" && node.value !== "0x" - ? formatNativeFromWei(node.value, networkCurrency, 4) - : null; - - const gasUsed = hexToGas(node.gasUsed); - - // Contract info for the target address - const contractInfo = node.to ? contracts[node.to.toLowerCase()] : undefined; - - // Decode the function call if we have an ABI - const decoded = - node.input && node.input !== "0x" && contractInfo?.abi - ? decodeFunctionCall(node.input, contractInfo.abi) - : null; - - const addressLink = (addr: string | undefined, name?: string) => { - if (!addr) return null; - return ( - e.stopPropagation()} - > - {name ? {name} : addr} - - ); - }; - - return ( -
- {/* Node header */} - {/* biome-ignore lint/a11y/noStaticElementInteractions: call tree expand/collapse via click */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: call tree expand/collapse via click */} -
hasChildren && setExpanded((e) => !e)} - style={{ cursor: hasChildren ? "pointer" : "default" }} - > - {/* Expand toggle */} - {hasChildren ? (expanded ? "▾" : "▸") : "·"} - - {/* Call type badge */} - - {node.type} - - - {/* From → To (with contract names) */} - - {addressLink(node.from, contracts[node.from?.toLowerCase() ?? ""]?.name)} - {node.to && ( - <> - - {addressLink(node.to, contractInfo?.name)} - - )} - - - {/* Decoded function call */} - {decoded && ( - - {decoded.functionName} - {decoded.params.length > 0 && ( - - ({decoded.params.map((p) => truncateParamValue(p.value, 16)).join(", ")}) - - )} - - )} - - {/* Value */} - {formattedValue && {formattedValue}} - - {/* Gas used */} - {gasUsed !== undefined && ( - {gasUsed.toLocaleString()} gas - )} - - {/* Error badge */} - {isError && {node.error}} -
- - {/* Children */} - {expanded && hasChildren && ( -
- {node.calls?.map((child, i) => ( - - ))} -
- )} -
- ); -}; - -// ─── Call Tree Tab ───────────────────────────────────────────────────────── - -const CALL_TYPE_LABELS: Record = { - CALL: "CALL", - STATICCALL: "STATICCALL", - DELEGATECALL: "DELEGATECALL", - CREATE: "CREATE", - CREATE2: "CREATE2", - SELFDESTRUCT: "SELFDESTRUCT", -}; - -const CallTreeTab: React.FC<{ - root: CallNode; - networkId: string; - networkCurrency: string; - contracts: Record; - enrichmentLoading: boolean; -}> = ({ root, networkId, networkCurrency, contracts, enrichmentLoading }) => { - const { t } = useTranslation("transaction"); - const totalCalls = countCalls(root); - const totalReverts = countReverts(root); - const gasUsed = hexToGas(root.gasUsed); - const typeCounts = countByType(root); - - return ( -
- {/* Summary bar */} -
- {gasUsed !== undefined && ( - {t("analyser.summaryGas", { gas: gasUsed.toLocaleString() })} - )} - {t("analyser.summaryCalls", { calls: totalCalls })} - {/* Per-type breakdown */} - {Object.entries(typeCounts) - .filter(([type]) => type !== "CALL" && CALL_TYPE_LABELS[type]) - .sort((a, b) => b[1] - a[1]) - .map(([type, count]) => ( - - {count}× {type} - - ))} - {totalReverts > 0 && ( - - {t("analyser.summaryReverts", { reverts: totalReverts })} - - )} - {enrichmentLoading && ( - {t("analyser.enriching")} - )} -
- {/* Tree */} -
- -
-
- ); -}; - -// ─── State Changes Tab ───────────────────────────────────────────────────── - -function formatHexBalance(hex: string | undefined): string { - if (!hex) return "—"; - try { - const bn = BigInt(hex); - const eth = Number(bn) / 1e18; - return eth.toFixed(6); - } catch { - return hex; - } -} - -function balanceDiff(pre?: string, post?: string): string | null { - if (pre === undefined && post === undefined) return null; - if (pre === post) return null; - try { - const preBn = BigInt(pre ?? "0x0"); - const postBn = BigInt(post ?? "0x0"); - const diff = postBn - preBn; - const sign = diff >= 0n ? "+" : ""; - const eth = Number(diff) / 1e18; - return `${sign}${eth.toFixed(6)}`; - } catch { - return null; - } -} - -const StateChangesTab: React.FC<{ - trace: PrestateTrace; - networkId: string; - networkCurrency: string; - contracts: Record; -}> = ({ trace, networkId, networkCurrency, contracts }) => { - const { t } = useTranslation("transaction"); - const [expandedSet, setExpandedSet] = useState>(new Set()); - - const allAddresses = Array.from(new Set([...Object.keys(trace.pre), ...Object.keys(trace.post)])); - - // Filter to only addresses with actual changes - const changedAddresses = allAddresses.filter((address) => { - const pre: PrestateAccountState = trace.pre[address] ?? {}; - const post: PrestateAccountState = trace.post[address] ?? {}; - const balDiff = balanceDiff(pre.balance, post.balance); - const nonceDiff = - pre.nonce !== post.nonce && (pre.nonce !== undefined || post.nonce !== undefined); - const storageKeys = Array.from( - new Set([...Object.keys(pre.storage ?? {}), ...Object.keys(post.storage ?? {})]), - ).filter((k) => pre.storage?.[k] !== post.storage?.[k]); - const codeChanged = pre.code !== post.code; - return !!(balDiff || nonceDiff || storageKeys.length > 0 || codeChanged); - }); - - if (changedAddresses.length === 0) { - return ( -
-
{t("analyser.noChanges")}
-
- ); - } - - const toggleAddress = (addr: string) => { - setExpandedSet((prev) => { - const next = new Set(prev); - if (next.has(addr)) next.delete(addr); - else next.add(addr); - return next; - }); - }; - - const expandAll = () => setExpandedSet(new Set(changedAddresses)); - const collapseAll = () => setExpandedSet(new Set()); - - return ( -
-
- - {changedAddresses.length} {t("analyser.stateChanges").toLowerCase()} - - - - - -
- {changedAddresses.map((address) => { - const pre: PrestateAccountState = trace.pre[address] ?? {}; - const post: PrestateAccountState = trace.post[address] ?? {}; - const balDiff = balanceDiff(pre.balance, post.balance); - const nonceDiff = - pre.nonce !== post.nonce && (pre.nonce !== undefined || post.nonce !== undefined); - const storageKeys = Array.from( - new Set([...Object.keys(pre.storage ?? {}), ...Object.keys(post.storage ?? {})]), - ).filter((k) => pre.storage?.[k] !== post.storage?.[k]); - const codeChanged = pre.code !== post.code; - const contractName = contracts[address.toLowerCase()]?.name; - const isExpanded = expandedSet.has(address); - - return ( -
- {/* biome-ignore lint/a11y/noStaticElementInteractions: collapsible header */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: collapsible header */} -
toggleAddress(address)} - > - {isExpanded ? "▾" : "▸"} - e.stopPropagation()} - > - {contractName ? ( - <> - {contractName} - ({address}) - - ) : ( - address - )} - -
- - {isExpanded && ( -
- {balDiff && ( -
- - {t("analyser.balanceChange")} ({networkCurrency}) - - {formatHexBalance(pre.balance)} - - {formatHexBalance(post.balance)} - - {balDiff} - -
- )} - - {nonceDiff && ( -
- {t("analyser.nonceChange")} - {pre.nonce ?? "—"} - - {post.nonce ?? "—"} - - +{(post.nonce ?? 0) - (pre.nonce ?? 0)} - -
- )} - - {codeChanged && ( -
- {t("analyser.codeDeployed")} - - {post.code ? `${post.code.slice(0, 20)}…` : "—"} - -
- )} - - {storageKeys.map((slot) => ( -
- {t("analyser.storageChange")} - - - - - - - - - - -
- ))} -
- )} -
- ); - })} -
- ); -}; - -// ─── Gas Profiler Tab (Flame / Icicle Chart) ───────────────────────────── - -function getFlameColor(node: CallNode): string { - if (node.error) return "#ef4444"; - const typeColor = CALL_TYPE_COLORS[node.type]; - if (typeColor) return typeColor; - // hash-based fallback - const addr = node.to ?? node.from; - let h = 0; - for (let i = 0; i < addr.length; i++) h = (h * 31 + addr.charCodeAt(i)) & 0xffffff; - return `hsl(${h % 360}, 55%, 50%)`; -} - -function getFlameLabel(node: CallNode, contracts: Record): string { - const contractInfo = node.to ? contracts[node.to.toLowerCase()] : undefined; - const target = contractInfo?.name ?? (node.to ? `${node.to.slice(0, 10)}…` : node.type); - - // Try full ABI decode first - if (node.input && node.input.length >= 10 && node.input !== "0x" && contractInfo?.abi) { - const decoded = decodeFunctionCall(node.input, contractInfo.abi); - if (decoded) return `${target}.${decoded.functionName}()`; - } - - // Fallback: show 4-byte selector if present - if (node.input && node.input.length >= 10 && node.input !== "0x") { - return `${target}.${node.input.slice(0, 10)}()`; - } - - // No input data (plain ETH transfer or CREATE) - if (node.type === "CREATE" || node.type === "CREATE2") return `${target} [${node.type}]`; - return `${target} [${node.type}]`; -} - -interface BreakdownEntry { - label: string; - gas: number; - pct: number; - color: string; - type: string; - to?: string; -} - -function getChildBreakdown( - node: CallNode, - parentGas: number, - contracts: Record, -): BreakdownEntry[] { - if (!node.calls?.length) return []; - const entries: BreakdownEntry[] = node.calls.map((child) => { - const gas = hexToGas(child.gasUsed) ?? 0; - return { - label: getFlameLabel(child, contracts), - gas, - pct: parentGas > 0 ? (gas / parentGas) * 100 : 0, - color: getFlameColor(child), - type: child.type, - to: child.to, - }; - }); - const childSum = entries.reduce((s, e) => s + e.gas, 0); - const selfGas = parentGas - childSum; - if (selfGas > 0) { - entries.unshift({ - label: "self", - gas: selfGas, - pct: (selfGas / parentGas) * 100, - color: "var(--text-tertiary)", - type: "self", - }); - } - return entries.sort((a, b) => b.gas - a.gas); -} - -const FlameRow: React.FC<{ - node: CallNode; - totalGas: number; - contracts: Record; - networkId: string; - selected: CallNode | null; - onSelect: (node: CallNode) => void; -}> = ({ node, totalGas, contracts, networkId, selected, onSelect }) => { - const gas = hexToGas(node.gasUsed) ?? 0; - const widthPct = totalGas > 0 ? (gas / totalGas) * 100 : 0; - if (widthPct < 0.3) return null; - - const color = getFlameColor(node); - const label = getFlameLabel(node, contracts); - const isSelected = selected === node; - - return ( -
- - {node.calls && node.calls.length > 0 && ( -
- {node.calls.map((child, i) => ( - - ))} -
- )} -
- ); -}; - -const GasProfilerTab: React.FC<{ - root: CallNode; - networkId: string; - contracts: Record; -}> = ({ root, networkId, contracts }) => { - const { t } = useTranslation("transaction"); - const [zoomNode, setZoomNode] = useState(root); - const [selected, setSelected] = useState(null); - - const zoomGas = hexToGas(zoomNode.gasUsed) ?? 1; - const totalGas = hexToGas(root.gasUsed) ?? 1; - const isZoomed = zoomNode !== root; - - const handleSelect = useCallback((node: CallNode) => { - setSelected(node); - setZoomNode(node); - }, []); - - const resetZoom = useCallback(() => { - setZoomNode(root); - setSelected(null); - }, [root]); - - const breakdown = selected - ? getChildBreakdown(selected, hexToGas(selected.gasUsed) ?? 1, contracts) - : []; - - return ( -
-
- {t("analyser.summaryGas", { gas: totalGas.toLocaleString() })} - {isZoomed && ( - - )} -
- {/* Flame chart */} -
- -
- {/* Breakdown panel */} - {selected && breakdown.length > 0 && ( -
-
- - {t("analyser.gasBreakdownTitle")}:{" "} - {getFlameLabel(selected, contracts)} - - - {(hexToGas(selected.gasUsed) ?? 0).toLocaleString()} gas - -
-
- {breakdown.map((entry, i) => ( -
- - - {entry.to ? ( - - {entry.label} - - ) : ( - entry.label - )} - - {entry.pct.toFixed(1)}% - - {entry.gas.toLocaleString()} gas - - - - -
- ))} -
-
{t("analyser.gasBreakdownHint")}
-
- )} -
- ); -}; - -// ─── Input Data Tab ────────────────────────────────────────────────────── - -const InputDataTab: React.FC<{ - inputData: string; - decodedInput: DecodedInput | null; - networkId: string; - contracts: Record; - txToAddress?: string; -}> = ({ inputData, decodedInput, networkId, contracts, txToAddress }) => { - const { t } = useTranslation("transaction"); - - // Try enriched ABI decode if no decoded input from sourcify/local - const resolved = - decodedInput ?? - (() => { - if (!txToAddress || !inputData || inputData === "0x") return null; - const enriched = contracts[txToAddress.toLowerCase()]; - if (!enriched?.abi) return null; - return decodeFunctionCall(inputData, enriched.abi); - })(); - - return ( -
- {resolved && ( -
-
- {t("decodedInput")} -
-
-
- {resolved.signature} -
- {resolved.params.length > 0 && ( -
- {resolved.params.map((param, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: params have stable order -
- {param.name} - ({param.type}) - - {param.type === "address" ? ( - - {param.value} - - ) : ( - formatDecodedValue(param.value, param.type) - )} - -
- ))} -
- )} -
-
- )} -
-
- {t("analyser.rawInputData")} -
-
- {inputData} -
-
-
- ); -}; - -// ─── Event Logs Tab ────────────────────────────────────────────────────── - -const EventLogsTab: React.FC<{ - logs: EthLog[]; - networkId: string; - txToAddress?: string; - // biome-ignore lint/suspicious/noExplicitAny: ABI types are dynamic - contractAbi?: any[]; - contracts: Record; -}> = ({ logs, networkId, txToAddress, contractAbi, contracts }) => { - const { t } = useTranslation("transaction"); - const [expandedSet, setExpandedSet] = useState>(new Set()); - - const expandAll = () => setExpandedSet(new Set(logs.map((_, i) => i))); - const collapseAll = () => setExpandedSet(new Set()); - - const toggleLog = (idx: number) => { - setExpandedSet((prev) => { - const next = new Set(prev); - if (next.has(idx)) next.delete(idx); - else next.add(idx); - return next; - }); - }; - - return ( -
-
- - {logs.length} {t("analyser.events").toLowerCase()} - - - - - -
-
- {logs.map((log, index) => { - let decoded: DecodedEvent | null = null; - let abiDecoded: DecodedInput | null = null; - - // Try enriched ABI first (from call tree enrichment) - const enrichedContract = log.address ? contracts[log.address.toLowerCase()] : undefined; - - if (enrichedContract?.abi && log.topics) { - abiDecoded = decodeEventWithAbi(log.topics, log.data || "0x", enrichedContract.abi); - } - - // Try tx recipient ABI - if ( - !abiDecoded && - txToAddress && - log.address?.toLowerCase() === txToAddress.toLowerCase() && - contractAbi && - log.topics - ) { - abiDecoded = decodeEventWithAbi(log.topics, log.data || "0x", contractAbi); - } - - // Fallback to standard event lookup - if (!abiDecoded && log.topics) { - decoded = decodeEventLog(log.topics, log.data || "0x"); - } - - const hasDecoded = abiDecoded || decoded; - const displayName = abiDecoded?.functionName || decoded?.name; - const displaySignature = abiDecoded?.signature || decoded?.fullSignature; - const displayParams = abiDecoded?.params || decoded?.params || []; - - const isExpanded = expandedSet.has(index); - - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: log index is stable -
- {/* biome-ignore lint/a11y/noStaticElementInteractions: collapsible header */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: collapsible header */} -
toggleLog(index)}> - {isExpanded ? "▾" : "▸"} - {index} - {hasDecoded && ( -
- - {displayName} - - - {displaySignature} - -
- )} - - e.stopPropagation()} - > - {enrichedContract?.name ?? log.address} - - -
- - {isExpanded && ( -
-
- {t("logsAddress")} - - - {enrichedContract?.name ? ( - <> - {enrichedContract.name}{" "} - - () - - - ) : ( - log.address - )} - - -
- - {displayParams.length > 0 && ( -
- {t("logsDecoded")} -
- {displayParams.map((param, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: param index is stable -
- {param.name} - ({param.type}) - - {param.type === "address" ? ( - - {param.value} - - ) : ( - formatDecodedValue(param.value, param.type) - )} - - {param.indexed && ( - {t("logsIndexed")} - )} -
- ))} -
-
- )} - - {log.topics && log.topics.length > 0 && ( -
- - {hasDecoded ? t("logsRawTopics") : t("logsTopics")} - -
- {log.topics.map((topic: string, i: number) => ( -
- [{i}] - {topic} -
- ))} -
-
- )} - - {log.data && log.data !== "0x" && ( -
- - {hasDecoded ? t("logsRawData") : t("logsData")} - -
- {log.data} -
-
- )} -
- )} -
- ); - })} -
-
- ); -}; - -// ─── Main TxAnalyser ─────────────────────────────────────────────────────── +import type { AnalyserTab, TxAnalyserProps } from "./analyser/types"; +import CallTreeTab from "./analyser/CallTreeTab"; +import StateChangesTab from "./analyser/StateChangesTab"; +import GasProfilerTab from "./analyser/GasProfilerTab"; +import InputDataTab from "./analyser/InputDataTab"; +import EventLogsTab from "./analyser/EventLogsTab"; const TxAnalyser: React.FC = ({ txHash, diff --git a/src/components/pages/evm/tx/analyser/CallTreeTab.tsx b/src/components/pages/evm/tx/analyser/CallTreeTab.tsx new file mode 100644 index 00000000..76689dae --- /dev/null +++ b/src/components/pages/evm/tx/analyser/CallTreeTab.tsx @@ -0,0 +1,186 @@ +import type React from "react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { CallNode } from "../../../../../services/adapters/NetworkAdapter"; +import { + countByType, + countCalls, + countReverts, + hexToGas, +} from "../../../../../utils/callTreeUtils"; +import type { ContractInfo } from "../../../../../utils/contractLookup"; +import { decodeFunctionCall } from "../../../../../utils/inputDecoder"; +import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; +import { getCallTypeColor } from "./types"; + +function truncateParamValue(value: string, max = 20): string { + if (!value) return ""; + const str = String(value); + return str.length > max ? `${str.slice(0, max)}…` : str; +} + +const CallTreeNode: React.FC<{ + node: CallNode; + networkId: string; + networkCurrency: string; + depth: number; + defaultExpanded: boolean; + contracts: Record; +}> = ({ node, networkId, networkCurrency, depth, defaultExpanded, contracts }) => { + const [expanded, setExpanded] = useState(defaultExpanded); + const hasChildren = node.calls && node.calls.length > 0; + const isError = !!node.error; + const color = getCallTypeColor(node.type); + + const formattedValue = + node.value && node.value !== "0x0" && node.value !== "0x" + ? formatNativeFromWei(node.value, networkCurrency, 4) + : null; + + const gasUsed = hexToGas(node.gasUsed); + const contractInfo = node.to ? contracts[node.to.toLowerCase()] : undefined; + + const decoded = + node.input && node.input !== "0x" && contractInfo?.abi + ? decodeFunctionCall(node.input, contractInfo.abi) + : null; + + const addressLink = (addr: string | undefined, name?: string) => { + if (!addr) return null; + return ( + e.stopPropagation()} + > + {name ? {name} : addr} + + ); + }; + + return ( +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: call tree expand/collapse via click */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: call tree expand/collapse via click */} +
hasChildren && setExpanded((e) => !e)} + style={{ cursor: hasChildren ? "pointer" : "default" }} + > + {hasChildren ? (expanded ? "▾" : "▸") : "·"} + + {node.type} + + + {addressLink(node.from, contracts[node.from?.toLowerCase() ?? ""]?.name)} + {node.to && ( + <> + + {addressLink(node.to, contractInfo?.name)} + + )} + + {decoded && ( + + {decoded.functionName} + {decoded.params.length > 0 && ( + + ({decoded.params.map((p) => truncateParamValue(p.value, 16)).join(", ")}) + + )} + + )} + {formattedValue && {formattedValue}} + {gasUsed !== undefined && ( + {gasUsed.toLocaleString()} gas + )} + {isError && {node.error}} +
+ {expanded && hasChildren && ( +
+ {node.calls?.map((child, i) => ( + + ))} +
+ )} +
+ ); +}; + +const CALL_TYPE_LABELS: Record = { + CALL: "CALL", + STATICCALL: "STATICCALL", + DELEGATECALL: "DELEGATECALL", + CREATE: "CREATE", + CREATE2: "CREATE2", + SELFDESTRUCT: "SELFDESTRUCT", +}; + +const CallTreeTab: React.FC<{ + root: CallNode; + networkId: string; + networkCurrency: string; + contracts: Record; + enrichmentLoading: boolean; +}> = ({ root, networkId, networkCurrency, contracts, enrichmentLoading }) => { + const { t } = useTranslation("transaction"); + const totalCalls = countCalls(root); + const totalReverts = countReverts(root); + const gasUsed = hexToGas(root.gasUsed); + const typeCounts = countByType(root); + + return ( +
+
+ {gasUsed !== undefined && ( + {t("analyser.summaryGas", { gas: gasUsed.toLocaleString() })} + )} + {t("analyser.summaryCalls", { calls: totalCalls })} + {Object.entries(typeCounts) + .filter(([type]) => type !== "CALL" && CALL_TYPE_LABELS[type]) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => ( + + {count}× {type} + + ))} + {totalReverts > 0 && ( + + {t("analyser.summaryReverts", { reverts: totalReverts })} + + )} + {enrichmentLoading && ( + {t("analyser.enriching")} + )} +
+
+ +
+
+ ); +}; + +export default CallTreeTab; diff --git a/src/components/pages/evm/tx/analyser/EventLogsTab.tsx b/src/components/pages/evm/tx/analyser/EventLogsTab.tsx new file mode 100644 index 00000000..f7672e86 --- /dev/null +++ b/src/components/pages/evm/tx/analyser/EventLogsTab.tsx @@ -0,0 +1,218 @@ +import type React from "react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { EthLog } from "@openscan/network-connectors"; +import type { ContractInfo } from "../../../../../utils/contractLookup"; +import { + type DecodedEvent, + decodeEventLog, + formatDecodedValue, + getEventTypeColor, +} from "../../../../../utils/eventDecoder"; +import { decodeEventWithAbi } from "../../../../../utils/inputDecoder"; +import LongString from "../../../../common/LongString"; + +const EventLogsTab: React.FC<{ + logs: EthLog[]; + networkId: string; + txToAddress?: string; + // biome-ignore lint/suspicious/noExplicitAny: ABI types are dynamic + contractAbi?: any[]; + contracts: Record; +}> = ({ logs, networkId, txToAddress, contractAbi, contracts }) => { + const { t } = useTranslation("transaction"); + const [expandedSet, setExpandedSet] = useState>(new Set()); + + const expandAll = () => setExpandedSet(new Set(logs.map((_, i) => i))); + const collapseAll = () => setExpandedSet(new Set()); + + const toggleLog = (idx: number) => { + setExpandedSet((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); + }; + + return ( +
+
+ + {logs.length} {t("analyser.events").toLowerCase()} + + + + + +
+
+ {logs.map((log, index) => { + let decoded: DecodedEvent | null = null; + let abiDecoded: ReturnType = null; + + // Try enriched ABI first (from call tree enrichment) + const enrichedContract = log.address ? contracts[log.address.toLowerCase()] : undefined; + + if (enrichedContract?.abi && log.topics) { + abiDecoded = decodeEventWithAbi(log.topics, log.data || "0x", enrichedContract.abi); + } + + // Try tx recipient ABI + if ( + !abiDecoded && + txToAddress && + log.address?.toLowerCase() === txToAddress.toLowerCase() && + contractAbi && + log.topics + ) { + abiDecoded = decodeEventWithAbi(log.topics, log.data || "0x", contractAbi); + } + + // Fallback to standard event lookup + if (!abiDecoded && log.topics) { + decoded = decodeEventLog(log.topics, log.data || "0x"); + } + + const hasDecoded = abiDecoded || decoded; + const displayName = abiDecoded?.functionName || decoded?.name; + const displaySignature = abiDecoded?.signature || decoded?.fullSignature; + const displayParams = abiDecoded?.params || decoded?.params || []; + + const isExpanded = expandedSet.has(index); + + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: log index is stable +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: collapsible header */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: collapsible header */} +
toggleLog(index)}> + {isExpanded ? "▾" : "▸"} + {index} + {hasDecoded && ( +
+ + {displayName} + + + {displaySignature} + +
+ )} + + e.stopPropagation()} + > + {enrichedContract?.name ?? log.address} + + +
+ + {isExpanded && ( +
+
+ {t("logsAddress")} + + + {enrichedContract?.name ? ( + <> + {enrichedContract.name}{" "} + + () + + + ) : ( + log.address + )} + + +
+ + {displayParams.length > 0 && ( +
+ {t("logsDecoded")} +
+ {displayParams.map((param, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: param index is stable +
+ {param.name} + ({param.type}) + + {param.type === "address" ? ( + + {param.value} + + ) : ( + formatDecodedValue(param.value, param.type) + )} + + {param.indexed && ( + {t("logsIndexed")} + )} +
+ ))} +
+
+ )} + + {log.topics && log.topics.length > 0 && ( +
+ + {hasDecoded ? t("logsRawTopics") : t("logsTopics")} + +
+ {log.topics.map((topic: string, i: number) => ( +
+ [{i}] + {topic} +
+ ))} +
+
+ )} + + {log.data && log.data !== "0x" && ( +
+ + {hasDecoded ? t("logsRawData") : t("logsData")} + +
+ {log.data} +
+
+ )} +
+ )} +
+ ); + })} +
+
+ ); +}; + +export default EventLogsTab; diff --git a/src/components/pages/evm/tx/analyser/GasProfilerTab.tsx b/src/components/pages/evm/tx/analyser/GasProfilerTab.tsx new file mode 100644 index 00000000..73db3e88 --- /dev/null +++ b/src/components/pages/evm/tx/analyser/GasProfilerTab.tsx @@ -0,0 +1,225 @@ +import type React from "react"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { CallNode } from "../../../../../services/adapters/NetworkAdapter"; +import { hexToGas } from "../../../../../utils/callTreeUtils"; +import type { ContractInfo } from "../../../../../utils/contractLookup"; +import { decodeFunctionCall } from "../../../../../utils/inputDecoder"; +import { CALL_TYPE_COLORS } from "./types"; + +function getFlameColor(node: CallNode): string { + if (node.error) return "#ef4444"; + const typeColor = CALL_TYPE_COLORS[node.type]; + if (typeColor) return typeColor; + // hash-based fallback + const addr = node.to ?? node.from; + let h = 0; + for (let i = 0; i < addr.length; i++) h = (h * 31 + addr.charCodeAt(i)) & 0xffffff; + return `hsl(${h % 360}, 55%, 50%)`; +} + +function getFlameLabel(node: CallNode, contracts: Record): string { + const contractInfo = node.to ? contracts[node.to.toLowerCase()] : undefined; + const target = contractInfo?.name ?? (node.to ? `${node.to.slice(0, 10)}…` : node.type); + + // Try full ABI decode first + if (node.input && node.input.length >= 10 && node.input !== "0x" && contractInfo?.abi) { + const decoded = decodeFunctionCall(node.input, contractInfo.abi); + if (decoded) return `${target}.${decoded.functionName}()`; + } + + // Fallback: show 4-byte selector if present + if (node.input && node.input.length >= 10 && node.input !== "0x") { + return `${target}.${node.input.slice(0, 10)}()`; + } + + // No input data (plain ETH transfer or CREATE) + if (node.type === "CREATE" || node.type === "CREATE2") return `${target} [${node.type}]`; + return `${target} [${node.type}]`; +} + +interface BreakdownEntry { + label: string; + gas: number; + pct: number; + color: string; + type: string; + to?: string; +} + +function getChildBreakdown( + node: CallNode, + parentGas: number, + contracts: Record, +): BreakdownEntry[] { + if (!node.calls?.length) return []; + const entries: BreakdownEntry[] = node.calls.map((child) => { + const gas = hexToGas(child.gasUsed) ?? 0; + return { + label: getFlameLabel(child, contracts), + gas, + pct: parentGas > 0 ? (gas / parentGas) * 100 : 0, + color: getFlameColor(child), + type: child.type, + to: child.to, + }; + }); + const childSum = entries.reduce((s, e) => s + e.gas, 0); + const selfGas = parentGas - childSum; + if (selfGas > 0) { + entries.unshift({ + label: "self", + gas: selfGas, + pct: (selfGas / parentGas) * 100, + color: "var(--text-tertiary)", + type: "self", + }); + } + return entries.sort((a, b) => b.gas - a.gas); +} + +const FlameRow: React.FC<{ + node: CallNode; + totalGas: number; + contracts: Record; + networkId: string; + selected: CallNode | null; + onSelect: (node: CallNode) => void; +}> = ({ node, totalGas, contracts, networkId, selected, onSelect }) => { + const gas = hexToGas(node.gasUsed) ?? 0; + const widthPct = totalGas > 0 ? (gas / totalGas) * 100 : 0; + if (widthPct < 0.3) return null; + + const color = getFlameColor(node); + const label = getFlameLabel(node, contracts); + const isSelected = selected === node; + + return ( +
+ + {node.calls && node.calls.length > 0 && ( +
+ {node.calls.map((child, i) => ( + + ))} +
+ )} +
+ ); +}; + +const GasProfilerTab: React.FC<{ + root: CallNode; + networkId: string; + contracts: Record; +}> = ({ root, networkId, contracts }) => { + const { t } = useTranslation("transaction"); + const [zoomNode, setZoomNode] = useState(root); + const [selected, setSelected] = useState(null); + + const zoomGas = hexToGas(zoomNode.gasUsed) ?? 1; + const totalGas = hexToGas(root.gasUsed) ?? 1; + const isZoomed = zoomNode !== root; + + const handleSelect = useCallback((node: CallNode) => { + setSelected(node); + setZoomNode(node); + }, []); + + const resetZoom = useCallback(() => { + setZoomNode(root); + setSelected(null); + }, [root]); + + const breakdown = selected + ? getChildBreakdown(selected, hexToGas(selected.gasUsed) ?? 1, contracts) + : []; + + return ( +
+
+ {t("analyser.summaryGas", { gas: totalGas.toLocaleString() })} + {isZoomed && ( + + )} +
+ {/* Flame chart */} +
+ +
+ {/* Breakdown panel */} + {selected && breakdown.length > 0 && ( +
+
+ + {t("analyser.gasBreakdownTitle")}:{" "} + {getFlameLabel(selected, contracts)} + + + {(hexToGas(selected.gasUsed) ?? 0).toLocaleString()} gas + +
+
+ {breakdown.map((entry, i) => ( +
+ + + {entry.to ? ( + + {entry.label} + + ) : ( + entry.label + )} + + {entry.pct.toFixed(1)}% + + {entry.gas.toLocaleString()} gas + + + + +
+ ))} +
+
{t("analyser.gasBreakdownHint")}
+
+ )} +
+ ); +}; + +export default GasProfilerTab; diff --git a/src/components/pages/evm/tx/analyser/InputDataTab.tsx b/src/components/pages/evm/tx/analyser/InputDataTab.tsx new file mode 100644 index 00000000..0e866389 --- /dev/null +++ b/src/components/pages/evm/tx/analyser/InputDataTab.tsx @@ -0,0 +1,73 @@ +import type React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { ContractInfo } from "../../../../../utils/contractLookup"; +import { formatDecodedValue } from "../../../../../utils/eventDecoder"; +import { type DecodedInput, decodeFunctionCall } from "../../../../../utils/inputDecoder"; + +const InputDataTab: React.FC<{ + inputData: string; + decodedInput: DecodedInput | null; + networkId: string; + contracts: Record; + txToAddress?: string; +}> = ({ inputData, decodedInput, networkId, contracts, txToAddress }) => { + const { t } = useTranslation("transaction"); + + // Try enriched ABI decode if no decoded input from sourcify/local + const resolved = + decodedInput ?? + (() => { + if (!txToAddress || !inputData || inputData === "0x") return null; + const enriched = contracts[txToAddress.toLowerCase()]; + if (!enriched?.abi) return null; + return decodeFunctionCall(inputData, enriched.abi); + })(); + + return ( +
+ {resolved && ( +
+
+ {t("decodedInput")} +
+
+
+ {resolved.signature} +
+ {resolved.params.length > 0 && ( +
+ {resolved.params.map((param, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: params have stable order +
+ {param.name} + ({param.type}) + + {param.type === "address" ? ( + + {param.value} + + ) : ( + formatDecodedValue(param.value, param.type) + )} + +
+ ))} +
+ )} +
+
+ )} +
+
+ {t("analyser.rawInputData")} +
+
+ {inputData} +
+
+
+ ); +}; + +export default InputDataTab; diff --git a/src/components/pages/evm/tx/analyser/StateChangesTab.tsx b/src/components/pages/evm/tx/analyser/StateChangesTab.tsx new file mode 100644 index 00000000..d6985c56 --- /dev/null +++ b/src/components/pages/evm/tx/analyser/StateChangesTab.tsx @@ -0,0 +1,199 @@ +import type React from "react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { + PrestateAccountState, + PrestateTrace, +} from "../../../../../services/adapters/NetworkAdapter"; +import type { ContractInfo } from "../../../../../utils/contractLookup"; +import LongString from "../../../../common/LongString"; + +function formatHexBalance(hex: string | undefined): string { + if (!hex) return "—"; + try { + const bn = BigInt(hex); + const eth = Number(bn) / 1e18; + return eth.toFixed(6); + } catch { + return hex; + } +} + +function balanceDiff(pre?: string, post?: string): string | null { + if (pre === undefined && post === undefined) return null; + if (pre === post) return null; + try { + const preBn = BigInt(pre ?? "0x0"); + const postBn = BigInt(post ?? "0x0"); + const diff = postBn - preBn; + const sign = diff >= 0n ? "+" : ""; + const eth = Number(diff) / 1e18; + return `${sign}${eth.toFixed(6)}`; + } catch { + return null; + } +} + +const StateChangesTab: React.FC<{ + trace: PrestateTrace; + networkId: string; + networkCurrency: string; + contracts: Record; +}> = ({ trace, networkId, networkCurrency, contracts }) => { + const { t } = useTranslation("transaction"); + const [expandedSet, setExpandedSet] = useState>(new Set()); + + const allAddresses = Array.from(new Set([...Object.keys(trace.pre), ...Object.keys(trace.post)])); + + // Filter to only addresses with actual changes + const changedAddresses = allAddresses.filter((address) => { + const pre: PrestateAccountState = trace.pre[address] ?? {}; + const post: PrestateAccountState = trace.post[address] ?? {}; + const balDiff = balanceDiff(pre.balance, post.balance); + const nonceDiff = + pre.nonce !== post.nonce && (pre.nonce !== undefined || post.nonce !== undefined); + const storageKeys = Array.from( + new Set([...Object.keys(pre.storage ?? {}), ...Object.keys(post.storage ?? {})]), + ).filter((k) => pre.storage?.[k] !== post.storage?.[k]); + const codeChanged = pre.code !== post.code; + return !!(balDiff || nonceDiff || storageKeys.length > 0 || codeChanged); + }); + + if (changedAddresses.length === 0) { + return ( +
+
{t("analyser.noChanges")}
+
+ ); + } + + const toggleAddress = (addr: string) => { + setExpandedSet((prev) => { + const next = new Set(prev); + if (next.has(addr)) next.delete(addr); + else next.add(addr); + return next; + }); + }; + + const expandAll = () => setExpandedSet(new Set(changedAddresses)); + const collapseAll = () => setExpandedSet(new Set()); + + return ( +
+
+ + {changedAddresses.length} {t("analyser.stateChanges").toLowerCase()} + + + + + +
+ {changedAddresses.map((address) => { + const pre: PrestateAccountState = trace.pre[address] ?? {}; + const post: PrestateAccountState = trace.post[address] ?? {}; + const balDiff = balanceDiff(pre.balance, post.balance); + const nonceDiff = + pre.nonce !== post.nonce && (pre.nonce !== undefined || post.nonce !== undefined); + const storageKeys = Array.from( + new Set([...Object.keys(pre.storage ?? {}), ...Object.keys(post.storage ?? {})]), + ).filter((k) => pre.storage?.[k] !== post.storage?.[k]); + const codeChanged = pre.code !== post.code; + const contractName = contracts[address.toLowerCase()]?.name; + const isExpanded = expandedSet.has(address); + + return ( +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: collapsible header */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: collapsible header */} +
toggleAddress(address)} + > + {isExpanded ? "▾" : "▸"} + e.stopPropagation()} + > + {contractName ? ( + <> + {contractName} + ({address}) + + ) : ( + address + )} + +
+ + {isExpanded && ( +
+ {balDiff && ( +
+ + {t("analyser.balanceChange")} ({networkCurrency}) + + {formatHexBalance(pre.balance)} + + {formatHexBalance(post.balance)} + + {balDiff} + +
+ )} + + {nonceDiff && ( +
+ {t("analyser.nonceChange")} + {pre.nonce ?? "—"} + + {post.nonce ?? "—"} + + +{(post.nonce ?? 0) - (pre.nonce ?? 0)} + +
+ )} + + {codeChanged && ( +
+ {t("analyser.codeDeployed")} + + {post.code ? `${post.code.slice(0, 20)}…` : "—"} + +
+ )} + + {storageKeys.map((slot) => ( +
+ {t("analyser.storageChange")} + + + + + + + + + + +
+ ))} +
+ )} +
+ ); + })} +
+ ); +}; + +export default StateChangesTab; diff --git a/src/components/pages/evm/tx/analyser/types.ts b/src/components/pages/evm/tx/analyser/types.ts new file mode 100644 index 00000000..74f1315a --- /dev/null +++ b/src/components/pages/evm/tx/analyser/types.ts @@ -0,0 +1,31 @@ +import type { DecodedInput } from "../../../../../utils/inputDecoder"; +import type { DataService } from "../../../../../services/DataService"; + +export type AnalyserTab = "callTree" | "gasProfiler" | "stateChanges" | "events" | "inputData"; + +export interface TxAnalyserProps { + txHash: string; + networkId: string; + networkCurrency: string; + dataService: DataService; + logs?: import("@openscan/network-connectors").EthLog[]; + txToAddress?: string; + // biome-ignore lint/suspicious/noExplicitAny: ABI types are dynamic + contractAbi?: any[]; + inputData?: string; + decodedInputData?: DecodedInput | null; + isSuperUser?: boolean; +} + +export const CALL_TYPE_COLORS: Record = { + CALL: "#3b82f6", + DELEGATECALL: "#f97316", + STATICCALL: "#8b5cf6", + CREATE: "#10b981", + CREATE2: "#10b981", + SELFDESTRUCT: "#ef4444", +}; + +export function getCallTypeColor(type: string): string { + return CALL_TYPE_COLORS[type.toUpperCase()] ?? "#6b7280"; +} diff --git a/src/styles/components.css b/src/styles/components.css index c8f01f41..9928467a 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1022,14 +1022,14 @@ button.tx-section-header-toggle { color: var(--color-primary); font-weight: 600; text-decoration: none; - font-family: "monospace"; + font-family: monospace; font-size: 0.85rem; word-break: break-all; flex: 1; } .list-item-hash-plain { - font-family: "monospace"; + font-family: monospace; font-size: 0.85rem; word-break: break-all; flex: 1; @@ -1247,7 +1247,7 @@ button.tx-section-header-toggle { font-size: 1.2rem; color: var(--color-primary); font-weight: 600; - font-family: "monospace"; + font-family: monospace; } /* Network Cards (for About page) */ @@ -1295,7 +1295,7 @@ button.tx-section-header-toggle { .network-chain-id { font-size: 0.85rem; - font-family: "monospace"; + font-family: monospace; color: var(--text-secondary); } @@ -1546,7 +1546,7 @@ button.tx-section-header-toggle { background: var(--color-primary-alpha-10); border: 1px solid var(--color-primary-alpha-30); border-radius: 8px; - font-family: "monospace"; + font-family: monospace; font-size: 0.85rem; word-break: break-all; color: var(--color-primary); @@ -1559,7 +1559,7 @@ button.tx-section-header-toggle { background: var(--overlay-light-3); border: 1px solid var(--overlay-light-10); border-radius: 8px; - font-family: "monospace"; + font-family: monospace; font-size: 0.75rem; word-break: break-all; max-height: 200px; @@ -1571,7 +1571,7 @@ button.tx-section-header-toggle { background: var(--color-primary-alpha-8); border: 1px solid var(--color-primary-alpha-20); border-radius: 6px 6px 0 0; - font-family: "monospace"; + font-family: monospace; font-size: 0.85rem; color: var(--color-primary); font-weight: 600; @@ -1584,7 +1584,7 @@ button.tx-section-header-toggle { border: 1px solid var(--color-primary-alpha-20); border-top: none; border-radius: 0 0 6px 6px; - font-family: "monospace"; + font-family: monospace; font-size: 0.75rem; color: var(--text-primary); max-height: 400px; @@ -1607,7 +1607,7 @@ button.tx-section-header-toggle { color: var(--color-info); border-radius: 6px; font-size: 0.8rem; - font-family: "monospace"; + font-family: monospace; } .abi-badge-event { @@ -1616,7 +1616,7 @@ button.tx-section-header-toggle { color: var(--color-accent); border-radius: 6px; font-size: 0.8rem; - font-family: "monospace"; + font-family: monospace; } .abi-badge-constructor { @@ -1625,7 +1625,7 @@ button.tx-section-header-toggle { color: var(--color-warning); border-radius: 6px; font-size: 0.8rem; - font-family: "monospace"; + font-family: monospace; display: inline-block; } @@ -1723,13 +1723,13 @@ button.tx-section-header-toggle { } .log-value { - font-family: "monospace"; + font-family: monospace; color: var(--text-primary); font-weight: 500; } .log-topic-item { - font-family: "monospace"; + font-family: monospace; font-size: 0.85rem; margin-left: 10px; color: var(--text-primary); @@ -1756,7 +1756,7 @@ button.tx-section-header-toggle { display: grid; gap: 8px; font-size: 0.85rem; - font-family: "monospace"; + font-family: monospace; color: var(--text-primary); } @@ -1790,7 +1790,7 @@ button.tx-section-header-toggle { background: rgba(0, 0, 0, 0.02); padding: 10px; border-radius: 6px; - font-family: "monospace"; + font-family: monospace; font-size: 0.75rem; } @@ -1949,7 +1949,7 @@ button.tx-section-header-toggle { .call-tree-node { font-size: 0.82rem; - font-family: "monospace"; + font-family: monospace; } .call-tree-node + .call-tree-node { @@ -2056,7 +2056,7 @@ button.tx-section-header-toggle { background: var(--bg-tertiary); border-bottom: 1px solid var(--border-primary); font-size: 0.85rem; - font-family: "monospace"; + font-family: monospace; font-weight: 600; } @@ -2121,7 +2121,7 @@ button.tx-section-header-toggle { gap: 8px; padding: 7px 12px; font-size: 0.8rem; - font-family: "monospace"; + font-family: monospace; border-bottom: 1px solid var(--border-primary); flex-wrap: wrap; } @@ -2408,7 +2408,7 @@ button.tx-section-header-toggle { padding: 15px; border-radius: 8px; border: 1px solid var(--border-primary); - font-family: "monospace"; + font-family: monospace; font-size: 0.85rem; word-break: break-all; max-height: 300px; @@ -2551,7 +2551,7 @@ button.tx-section-header-toggle { /* Address Display Specific */ .address-block-number { - font-family: "monospace"; + font-family: monospace; font-size: 1.1rem; } diff --git a/src/utils/callTreeUtils.ts b/src/utils/callTreeUtils.ts index 010cd11b..d763b8d9 100644 --- a/src/utils/callTreeUtils.ts +++ b/src/utils/callTreeUtils.ts @@ -107,10 +107,11 @@ export function countByType(node: CallNode): Record { return counts; } -/** Collect all unique `to` addresses from a call tree (lowercased) */ +/** 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); }