diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index 90a8440b..11042470 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -9,6 +9,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, @@ -30,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, @@ -63,6 +60,7 @@ 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"; @@ -73,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: @@ -778,224 +775,25 @@ const TransactionDisplay: React.FC = React.memo( )} - {/* Event Logs Section - Collapsible, closed by default */} - {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 */} -
- {t("inputData")} - {transaction.data && transaction.data !== "0x" ? ( -
- {transaction.data} -
- ) : ( - 0x - )} -
- - {/* Decoded Input Data */} - {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 — always shown; super user mode unlocks advanced tabs */} + {dataService && networkId && ( + + )} + {/* 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..ff570233 --- /dev/null +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -0,0 +1,352 @@ +import type React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { CallNode, PrestateTrace } from "../../../../services/adapters/NetworkAdapter"; +import { useCallTreeEnrichment } from "../../../../hooks/useCallTreeEnrichment"; +import { type ContractInfo, fetchContractInfoBatch } from "../../../../utils/contractLookup"; +import { useSettings } from "../../../../context/SettingsContext"; +import { logger } from "../../../../utils/logger"; +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, + networkId, + networkCurrency, + dataService, + logs, + txToAddress, + contractAbi, + inputData, + decodedInputData, + isSuperUser, +}) => { + const { t } = useTranslation("transaction"); + const hasEvents = logs && logs.length > 0; + const hasInputData = inputData && inputData !== "0x"; + 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) { + setCollapsed(false); + } else { + 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); + 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: 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); + }, []); + + // Load call tree on first render (super user only) + useEffect(() => { + if (!isSuperUser) return; + 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)); + }, [ + isSuperUser, + txHash, + dataService, + callTree, + callTreeError, + loadingCallTree, + t, + isUnsupported, + ]); + + // 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); + 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)); + }, [ + isSuperUser, + activeTab, + txHash, + dataService, + prestateTrace, + prestateError, + loadingPrestate, + t, + 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 */} +
+ {hasEvents && ( + + )} + {hasInputData && ( + + )} + {isSuperUser && ( + <> + + + + + )} + +
+ + {/* Tab content */} + {!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 === "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 === "inputData" && inputData && inputData !== "0x" && ( + + )} +
+ )} +
+ ); +}; + +export default TxAnalyser; 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/hooks/useCallTreeEnrichment.ts b/src/hooks/useCallTreeEnrichment.ts new file mode 100644 index 00000000..82c9a409 --- /dev/null +++ b/src/hooks/useCallTreeEnrichment.ts @@ -0,0 +1,60 @@ +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. + * + * 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, + networkId: string, +): { + contracts: Record; + enrichmentLoading: boolean; +} { + const { settings } = useSettings(); + const [contracts, setContracts] = useState>({}); + const [done, setDone] = useState(false); + const abortRef = useRef(null); + + useEffect(() => { + if (!tree || !networkId) return; + + const chainId = Number(networkId); + const addresses = collectAddresses(tree); + if (addresses.length === 0) { + setDone(true); + return; + } + + // Cancel previous fetch + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setDone(false); + setContracts({}); + + fetchContractInfoBatch(addresses, chainId, controller.signal, settings.apiKeys?.etherscan) + .then((map) => { + if (!controller.signal.aborted) setContracts(map); + }) + .finally(() => { + if (!controller.signal.aborted) setDone(true); + }); + + return () => controller.abort(); + }, [tree, networkId, settings.apiKeys?.etherscan]); + + // Loading until tree is provided AND enrichment has completed + const enrichmentLoading = !!tree && !done; + + return { contracts, enrichmentLoading }; +} 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 }; diff --git a/src/locales/en/transaction.json b/src/locales/en/transaction.json index de302aaf..535e54be 100644 --- a/src/locales/en/transaction.json +++ b/src/locales/en/transaction.json @@ -137,5 +137,35 @@ "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...", + "gasProfiler": "Gas Profiler", + "gasBreakdownTitle": "Breakdown", + "gasBreakdownHint": "Click any bar to zoom in and see its breakdown", + "gasResetView": "Reset zoom", + "events": "Events", + "inputDataTab": "Input Data", + "rawInputData": "Raw Input Data", + "expandAll": "Expand All", + "collapseAll": "Collapse All", + "expand": "Expand", + "collapse": "Collapse" } } diff --git a/src/locales/es/transaction.json b/src/locales/es/transaction.json index d858f9d6..144eb31d 100644 --- a/src/locales/es/transaction.json +++ b/src/locales/es/transaction.json @@ -137,5 +137,35 @@ "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...", + "gasProfiler": "Perfil de Gas", + "gasBreakdownTitle": "Desglose", + "gasBreakdownHint": "Haz clic en cualquier barra para ampliar y ver su desglose", + "gasResetView": "Restablecer zoom", + "events": "Eventos", + "inputDataTab": "Datos de Entrada", + "rawInputData": "Datos de Entrada sin Procesar", + "expandAll": "Expandir Todo", + "collapseAll": "Colapsar Todo", + "expand": "Expandir", + "collapse": "Colapsar" } } diff --git a/src/locales/ja/transaction.json b/src/locales/ja/transaction.json index 230af3cf..3c3e0ce7 100644 --- a/src/locales/ja/transaction.json +++ b/src/locales/ja/transaction.json @@ -137,5 +137,35 @@ "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": "コントラクト情報を読み込み中...", + "gasProfiler": "ガスプロファイラー", + "gasBreakdownTitle": "内訳", + "gasBreakdownHint": "任意のバーをクリックしてズームインし、内訳を表示します", + "gasResetView": "ズームをリセット", + "events": "イベント", + "inputDataTab": "入力データ", + "rawInputData": "生の入力データ", + "expandAll": "すべて展開", + "collapseAll": "すべて折りたたむ", + "expand": "展開", + "collapse": "折りたたむ" } } diff --git a/src/locales/pt-BR/transaction.json b/src/locales/pt-BR/transaction.json index 4dba0ed6..6a581851 100644 --- a/src/locales/pt-BR/transaction.json +++ b/src/locales/pt-BR/transaction.json @@ -137,5 +137,35 @@ "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...", + "gasProfiler": "Perfil de Gás", + "gasBreakdownTitle": "Detalhamento", + "gasBreakdownHint": "Clique em qualquer barra para ampliar e ver seu detalhamento", + "gasResetView": "Redefinir zoom", + "events": "Eventos", + "inputDataTab": "Dados de Entrada", + "rawInputData": "Dados de Entrada Brutos", + "expandAll": "Expandir Tudo", + "collapseAll": "Recolher Tudo", + "expand": "Expandir", + "collapse": "Recolher" } } diff --git a/src/locales/zh/transaction.json b/src/locales/zh/transaction.json index d80d449f..0dce1339 100644 --- a/src/locales/zh/transaction.json +++ b/src/locales/zh/transaction.json @@ -137,5 +137,35 @@ "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": "正在加载合约信息...", + "gasProfiler": "Gas 分析", + "gasBreakdownTitle": "分项明细", + "gasBreakdownHint": "点击任意条形以放大并查看其明细", + "gasResetView": "重置缩放", + "events": "事件", + "inputDataTab": "输入数据", + "rawInputData": "原始输入数据", + "expandAll": "全部展开", + "collapseAll": "全部折叠", + "expand": "展开", + "collapse": "折叠" } } 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 ad7f1e85..5d2f5211 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 @@ -377,4 +403,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 2410c6a2..9928467a 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 { @@ -1023,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; @@ -1248,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) */ @@ -1296,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); } @@ -1547,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); @@ -1560,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; @@ -1572,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; @@ -1585,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; @@ -1608,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 { @@ -1617,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 { @@ -1626,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; } @@ -1724,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); @@ -1757,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); } @@ -1791,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; } @@ -1829,13 +1828,587 @@ 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; + 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; + 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-tab--active-base { + color: var(--text-primary); + border-bottom-color: var(--text-primary); +} + +.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-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; +} + +.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; +} + +/* Gas Profiler */ +/* Gas Profiler — Flame / Icicle Chart */ +.gas-profiler-flame { + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 8px; + overflow-x: auto; +} + +.flame-row { + min-width: 0; +} + +.flame-bar { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 4px 8px; + margin-bottom: 1px; + border: none; + border-radius: 3px; + color: #fff; + font-size: 0.78rem; + cursor: pointer; + text-align: left; + transition: filter 0.15s; + min-height: 26px; + overflow: hidden; + white-space: nowrap; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); +} + +.flame-bar:hover { + filter: brightness(1.15); +} + +.flame-bar--error { + outline: 2px solid #ef4444; + outline-offset: -2px; +} + +.flame-bar--selected { + outline: 2px solid var(--text-primary); + outline-offset: -2px; +} + +.flame-bar-label { + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; + font-family: monospace; +} + +.flame-bar-gas { + flex-shrink: 0; + margin-left: 8px; + font-family: monospace; + font-size: 0.72rem; + opacity: 0.85; +} + +.flame-children { + display: flex; + flex-direction: row; + gap: 1px; + margin-top: 1px; +} + +/* Reset zoom button */ +.gas-profiler-reset { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 4px; + padding: 2px 10px; + font-size: 0.78rem; + color: var(--text-secondary); + cursor: pointer; +} + +.gas-profiler-reset:hover { + background: var(--overlay-light-10); +} + +/* Breakdown panel */ +.gas-profiler-breakdown { + margin-top: 12px; + border: 1px solid var(--border-primary); + border-radius: 8px; + overflow: hidden; +} + +.gas-profiler-breakdown-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-primary); + font-size: 0.82rem; +} + +.gas-profiler-breakdown-title { + color: var(--text-secondary); +} + +.gas-profiler-breakdown-title strong { + color: var(--text-primary); +} + +.gas-profiler-breakdown-gas { + font-family: monospace; + font-size: 0.78rem; + color: var(--text-tertiary); +} + +.gas-profiler-breakdown-list { + padding: 4px 0; +} + +.gas-profiler-breakdown-row { + display: grid; + grid-template-columns: 14px 1fr 60px 120px 100px; + gap: 8px; + padding: 5px 14px; + align-items: center; + font-size: 0.8rem; +} + +.gas-profiler-breakdown-row:hover { + background: var(--overlay-light-5); +} + +.gas-profiler-breakdown-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} + +.gas-profiler-breakdown-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: monospace; +} + +.gas-profiler-breakdown-pct { + text-align: right; + font-weight: 600; + color: var(--text-primary); +} + +.gas-profiler-breakdown-value { + text-align: right; + font-family: monospace; + font-size: 0.75rem; + color: var(--text-tertiary); +} + +.gas-profiler-breakdown-bar-bg { + height: 8px; + background: var(--overlay-light-5); + border-radius: 4px; + overflow: hidden; +} + +.gas-profiler-breakdown-bar-fill { + height: 100%; + border-radius: 4px; + min-width: 1px; +} + +.gas-profiler-breakdown-hint { + padding: 6px 14px 10px; + font-size: 0.72rem; + color: var(--text-tertiary); + font-style: italic; +} + +@media (max-width: 640px) { + .gas-profiler-breakdown-row { + grid-template-columns: 14px 1fr 50px 80px; + } + + .gas-profiler-breakdown-bar-bg { + display: none; + } +} + /* Input Data Display */ .input-data-display { background: var(--bg-tertiary); 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; @@ -1978,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 new file mode 100644 index 00000000..d763b8d9 --- /dev/null +++ b/src/utils/callTreeUtils.ts @@ -0,0 +1,126 @@ +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 addresses from a call tree (lowercased) */ +export function collectAddresses(node: CallNode): string[] { + const addrs = new Set(); + function traverse(n: CallNode) { + if (n.from) addrs.add(n.from.toLowerCase()); + if (n.to) addrs.add(n.to.toLowerCase()); + n.calls?.forEach(traverse); + } + traverse(node); + return Array.from(addrs); +} + +/** Parse 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..9b20a155 --- /dev/null +++ b/src/utils/contractLookup.ts @@ -0,0 +1,109 @@ +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 V2 ────────────────────────────────────────────────────────── + try { + const res = await fetch( + `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; + 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); + 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; +}