From 75ab6add8113d2658976db437ef32cdce9fdb8bf Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 6 Mar 2026 11:17:01 -0300 Subject: [PATCH 01/43] 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 09dfa1e084a39090a2c45cd46fd7380516b4a9a1 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 11 Mar 2026 16:14:18 -0300 Subject: [PATCH 02/43] fix(search): block ENS resolution on non-EVM networks ENS names searched on Bitcoin network resolved to Ethereum addresses and navigated to an invalid BTC address page showing all zeroes. Now shows a clear error message when ENS is used on non-EVM networks. Closes #291 --- src/hooks/useSearch.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts index 280cc014..490e7630 100644 --- a/src/hooks/useSearch.ts +++ b/src/hooks/useSearch.ts @@ -2,7 +2,7 @@ import { useCallback, useContext, useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { AppContext, useNetworks } from "../context"; import { ENSService } from "../services/ENS/ENSService"; -import { resolveNetwork } from "../utils/networkResolver"; +import { isEVMNetwork, resolveNetwork } from "../utils/networkResolver"; interface UseSearchResult { searchTerm: string; @@ -58,6 +58,12 @@ export function useSearch(): UseSearchResult { // Check if it's an ENS name if (ENSService.isENSName(term)) { + // ENS is only available on EVM networks + if (resolvedNetwork && !isEVMNetwork(resolvedNetwork)) { + setError("ENS names are only supported on EVM networks."); + return; + } + setIsResolving(true); try { if (!ensService) { @@ -68,7 +74,7 @@ export function useSearch(): UseSearchResult { const resolvedAddress = await ensService.resolve(term); if (resolvedAddress) { - const targetChainId = networkId || "1"; + const targetChainId = networkId || "eth"; navigate(`/${targetChainId}/address/${resolvedAddress}`, { state: { ensName: term }, }); @@ -118,7 +124,7 @@ export function useSearch(): UseSearchResult { setSearchTerm(""); } }, - [searchTerm, networkId, navigate, ensService], + [searchTerm, networkId, resolvedNetwork, navigate, ensService], ); return { From bc7cfed60daa2ffe206a6dc57fdd2c0b95892e4c Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 11 Mar 2026 16:33:32 -0300 Subject: [PATCH 03/43] fix(bitcoin): decode and display OP_RETURN payload in transaction outputs OP_RETURN outputs were shown as a static "OP_RETURN (Data)" label without displaying the actual payload. Now decodes the scriptPubKey hex, parsing push data opcodes and attempting UTF-8 decoding. Shows readable text when valid, falls back to raw hex otherwise. Closes #294 --- .../bitcoin/BitcoinTransactionDisplay.tsx | 24 +++++-- src/styles/components.css | 27 +++++++ src/utils/bitcoinUtils.ts | 70 +++++++++++++++++++ 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx b/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx index b60f5ee5..2e29b92c 100644 --- a/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx +++ b/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx @@ -15,12 +15,28 @@ import { import { calculateTotalInput, calculateTotalOutput, + decodeOpReturnData, hasWitness, isCoinbaseTransaction, isRBFEnabled, } from "../../../utils/bitcoinUtils"; import AIAnalysisPanel from "../../common/AIAnalysis/AIAnalysisPanel"; +const OpReturnDisplay: React.FC<{ hex: string }> = ({ hex }) => { + const decoded = useMemo(() => decodeOpReturnData(hex), [hex]); + return ( +
+ OP_RETURN + {decoded.text ? ( + {decoded.text} + ) : ( + {decoded.hex} + )} + +
+ ); +}; + interface BitcoinTransactionDisplayProps { transaction: BitcoinTransaction; networkId?: string; @@ -422,12 +438,10 @@ const BitcoinTransactionDisplay: React.FC = Reac )} + ) : output.scriptPubKey.type === "nulldata" ? ( + ) : ( - - {output.scriptPubKey.type === "nulldata" - ? "OP_RETURN (Data)" - : output.scriptPubKey.type} - + {output.scriptPubKey.type} )}
diff --git a/src/styles/components.css b/src/styles/components.css index 69c11b9e..2410c6a2 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -7132,6 +7132,33 @@ button.tx-section-header-toggle { font-style: italic; } +.btc-op-return { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; + word-break: break-all; +} + +.btc-op-return-label { + font-weight: 600; + font-size: 0.8rem; + color: var(--text-secondary); + background: var(--overlay-light-5); + padding: 2px 6px; + border-radius: 4px; + white-space: nowrap; +} + +.btc-op-return-text { + color: var(--text-primary); +} + +.btc-op-return-hex { + color: var(--text-secondary); + font-size: 0.85rem; +} + .btc-io-details { display: flex; flex-wrap: wrap; diff --git a/src/utils/bitcoinUtils.ts b/src/utils/bitcoinUtils.ts index 1923aa36..5c712dba 100644 --- a/src/utils/bitcoinUtils.ts +++ b/src/utils/bitcoinUtils.ts @@ -60,3 +60,73 @@ export function hasWitness(tx: BitcoinTransaction): boolean { export function isCoinbaseTransaction(tx: BitcoinTransaction): boolean { return tx.vin.length === 1 && !tx.vin[0]?.txid; } + +/** + * Decode OP_RETURN payload from scriptPubKey hex. + * Script format: 6a [push opcodes] + * Returns the decoded UTF-8 string if valid, otherwise the raw hex data. + */ +export function decodeOpReturnData(hex: string): { text: string | null; hex: string } { + // Strip the OP_RETURN opcode (0x6a) prefix + if (!hex.startsWith("6a") || hex.length < 4) { + return { text: null, hex }; + } + + let offset = 2; // skip OP_RETURN (6a) + const dataChunks: string[] = []; + + while (offset < hex.length) { + const opByte = parseInt(hex.slice(offset, offset + 2), 16); + if (Number.isNaN(opByte)) break; + offset += 2; + + let pushLen: number; + if (opByte >= 0x01 && opByte <= 0x4b) { + // Direct push: opByte is the number of bytes to push + pushLen = opByte; + } else if (opByte === 0x4c) { + // OP_PUSHDATA1: next 1 byte is the length + pushLen = parseInt(hex.slice(offset, offset + 2), 16); + if (Number.isNaN(pushLen)) break; + offset += 2; + } else if (opByte === 0x4d) { + // OP_PUSHDATA2: next 2 bytes (little-endian) is the length + const lo = parseInt(hex.slice(offset, offset + 2), 16); + const hi = parseInt(hex.slice(offset + 2, offset + 4), 16); + if (Number.isNaN(lo) || Number.isNaN(hi)) break; + pushLen = lo | (hi << 8); + offset += 4; + } else { + // Unknown opcode or OP_0, skip rest + break; + } + + const chunkHex = hex.slice(offset, offset + pushLen * 2); + if (chunkHex.length < pushLen * 2) break; + dataChunks.push(chunkHex); + offset += pushLen * 2; + } + + const rawHex = dataChunks.join(""); + if (!rawHex) { + return { text: null, hex }; + } + + // Try to decode as UTF-8 + try { + const bytes = new Uint8Array(rawHex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(rawHex.slice(i * 2, i * 2 + 2), 16); + } + const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + // Check if it has enough printable characters to be meaningful text + const printable = decoded.replace(/[^\x20-\x7E\u00A0-\uFFFF]/g, ""); + if (printable.length >= decoded.length * 0.5) { + return { text: decoded, hex: rawHex }; + } + } catch { + // Not valid UTF-8 + } + + return { text: null, hex: rawHex }; +} From 784678ad74a1e52423a954f04b8036ec99667c7a Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 11:40:46 -0300 Subject: [PATCH 04/43] fix(routing): redirect to home with warning for invalid network URLs Invalid network segments like /undefined/txs silently fell back to Ethereum mainnet, showing misleading data. Now validates the network param against enabled networks and redirects to the home page with a warning notification when the network is not recognized. Closes #290 --- src/App.tsx | 25 +++++++++++--------- src/components/common/ValidateNetwork.tsx | 28 +++++++++++++++++++++++ src/locales/en/common.json | 5 +++- src/locales/es/common.json | 5 +++- 4 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 src/components/common/ValidateNetwork.tsx diff --git a/src/App.tsx b/src/App.tsx index 3ba0fcab..d09f0a50 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import ErrorBoundary from "./components/common/ErrorBoundary"; import Footer from "./components/common/Footer"; import { IsometricBlocks } from "./components/common/IsometricBlocks"; import NotificationDisplay from "./components/common/NotificationDisplay"; +import ValidateNetwork from "./components/common/ValidateNetwork"; import Navbar from "./components/navbar"; import "./styles/base.css"; import "./styles/styles.css"; @@ -148,17 +149,19 @@ function AppContent() { } /> } /> } /> - {/* EVM network routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* EVM network routes — validated */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } />
diff --git a/src/components/common/ValidateNetwork.tsx b/src/components/common/ValidateNetwork.tsx new file mode 100644 index 00000000..a18a5328 --- /dev/null +++ b/src/components/common/ValidateNetwork.tsx @@ -0,0 +1,28 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Navigate, Outlet, useParams } from "react-router-dom"; +import { isNetworkEnabled } from "../../config/networks"; +import { useNotifications } from "../../context/NotificationContext"; + +export default function ValidateNetwork() { + const { networkId } = useParams<{ networkId: string }>(); + const { addNotification } = useNotifications(); + const { t } = useTranslation(); + const isValid = !!networkId && isNetworkEnabled(networkId); + + useEffect(() => { + if (!isValid) { + addNotification( + t("errors.networkNotFoundMessage", { network: networkId || "" }), + "warning", + 8000, + ); + } + }, [isValid, networkId, addNotification, t]); + + if (!isValid) { + return ; + } + + return ; +} diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 624436d1..1313d994 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -48,7 +48,10 @@ "goHome": "Go Home", "errorDetailsSummary": "Error Details (Development Mode)", "errorLabel": "Error:", - "componentStackLabel": "Component Stack:" + "componentStackLabel": "Component Stack:", + "networkNotFound": "Network Not Found", + "networkNotFoundMessage": "The network \"{{network}}\" is not recognized. Please check the URL or select a valid network.", + "viewNetworks": "View Networks" }, "notifications": { "closeAriaLabel": "Close notification" diff --git a/src/locales/es/common.json b/src/locales/es/common.json index c4c8cfce..c37a511a 100644 --- a/src/locales/es/common.json +++ b/src/locales/es/common.json @@ -48,7 +48,10 @@ "goHome": "Ir al inicio", "errorDetailsSummary": "Detalles del error (modo desarrollo)", "errorLabel": "Error:", - "componentStackLabel": "Stack del componente:" + "componentStackLabel": "Stack del componente:", + "networkNotFound": "Red no encontrada", + "networkNotFoundMessage": "La red \"{{network}}\" no es reconocida. Revisá la URL o seleccioná una red válida.", + "viewNetworks": "Ver redes" }, "notifications": { "closeAriaLabel": "Cerrar notificación" From c80adf8eb76025e59305153080b2a858b2a62bfc Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 11:47:16 -0300 Subject: [PATCH 05/43] style(notifications): yellow warning style, rounded borders, position under navbar Warning notifications now have a yellow background tint and border. Notifications are positioned sticky under the navbar instead of fixed overlapping it, with rounded borders (12px). --- src/styles/styles.css | 60 +++++++++++++------------------------------ 1 file changed, 18 insertions(+), 42 deletions(-) diff --git a/src/styles/styles.css b/src/styles/styles.css index cbf35360..79505716 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -634,37 +634,7 @@ a.navbar-logo-dropdown-item:hover { } /* Notifications */ -.notification-container { - position: fixed; - top: 20px; - left: 50%; - transform: translateX(-50%); - display: flex; - flex-direction: column; - gap: 10px; - z-index: 1000; - width: clamp(240px, 90vw, 520px); -} - -.notification { - padding: 14px 18px; -} - -.notification__content { - display: flex; - align-items: flex-start; - gap: 12px; -} - -.notification__message { - flex: 1; - line-height: 1.3; -} - -.notification__close { - padding: 2px 6px; - cursor: pointer; -} +/* Base notification styles moved to notification section below */ /* Loading */ .loading-container { @@ -2496,36 +2466,42 @@ a.navbar-logo-dropdown-item:hover { /* Notifications */ .notification-container { - position: fixed; - top: 20px; - left: 50%; - transform: translateX(-50%); + position: sticky; + top: 0; display: flex; flex-direction: column; gap: 10px; z-index: 1000; - width: clamp(240px, 90vw, 520px); + max-width: 1400px; + margin: 0 auto; + padding: 0 24px; } .notification { padding: 14px 18px; - border-radius: var(--radius-sm); - box-shadow: 0 4px 14px -2px rgba(0, 0, 0, 0.5); - border-left: 5px solid var(--accent); + border-radius: 12px; + box-shadow: 0 4px 14px -2px rgba(0, 0, 0, 0.2); + border: 1px solid var(--overlay-light-10); background: var(--panel); backdrop-filter: blur(10px); } .notification--success { - border-left-color: var(--success); + border-color: var(--success); } .notification--warning { - border-left-color: var(--warning); + background: rgba(255, 193, 7, 0.12); + border-color: var(--warning); + color: var(--warning); +} + +.notification--warning .notification__message { + color: var(--warning); } .notification--error { - border-left-color: var(--danger); + border-color: var(--danger); } .notification__content { From 2ad6ba34f720d81d85712c939ec00af7ee698a79 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 11:53:14 -0300 Subject: [PATCH 06/43] fix(notifications): constrain width, float over content, prevent duplicate Notification container is now fixed at top 70px (under navbar), centered, max 520px wide. Warning style uses yellow tint. Added ref guard to prevent duplicate notifications from React Strict Mode. --- src/components/common/ValidateNetwork.tsx | 6 ++++-- src/styles/styles.css | 11 ++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/common/ValidateNetwork.tsx b/src/components/common/ValidateNetwork.tsx index a18a5328..79aa5b86 100644 --- a/src/components/common/ValidateNetwork.tsx +++ b/src/components/common/ValidateNetwork.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, Outlet, useParams } from "react-router-dom"; import { isNetworkEnabled } from "../../config/networks"; @@ -9,9 +9,11 @@ export default function ValidateNetwork() { const { addNotification } = useNotifications(); const { t } = useTranslation(); const isValid = !!networkId && isNetworkEnabled(networkId); + const notifiedRef = useRef(false); useEffect(() => { - if (!isValid) { + if (!isValid && !notifiedRef.current) { + notifiedRef.current = true; addNotification( t("errors.networkNotFoundMessage", { network: networkId || "" }), "warning", diff --git a/src/styles/styles.css b/src/styles/styles.css index 79505716..92d7e12e 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -2466,18 +2466,19 @@ a.navbar-logo-dropdown-item:hover { /* Notifications */ .notification-container { - position: sticky; - top: 0; + position: fixed; + top: 70px; + left: 50%; + transform: translateX(-50%); display: flex; flex-direction: column; + align-items: center; gap: 10px; z-index: 1000; - max-width: 1400px; - margin: 0 auto; - padding: 0 24px; } .notification { + width: clamp(240px, 90vw, 520px); padding: 14px 18px; border-radius: 12px; box-shadow: 0 4px 14px -2px rgba(0, 0, 0, 0.2); From dfd82ba9975abb703ccff96d944a4b4b3935079e Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 13:19:06 -0300 Subject: [PATCH 07/43] feat(ai): add free Groq AI analysis via Cloudflare Worker proxy Add openscan-groq provider that proxies AI requests through a Cloudflare Worker, allowing users to use AI analysis without configuring their own API key. Worker: Hono app with CORS origin allowlist, per-IP rate limiting (10 req/min), payload validation, and Groq API forwarding. Frontend: new openscan-groq provider (first in priority order), callOpenScanProxy method in AIService, keyless resolution in useAIAnalysis hook, filtered from Settings API key UI. Closes #269 --- src/components/pages/settings/index.tsx | 9 +++- src/config/aiProviders.ts | 11 +++++ src/hooks/useAIAnalysis.ts | 7 ++- src/services/AIService.ts | 40 ++++++++++++++++- src/types/index.ts | 8 +++- vite.config.ts | 3 ++ worker/package.json | 18 ++++++++ worker/src/index.ts | 22 ++++++++++ worker/src/middleware/cors.ts | 57 +++++++++++++++++++++++++ worker/src/middleware/rateLimit.ts | 53 +++++++++++++++++++++++ worker/src/middleware/validate.ts | 41 ++++++++++++++++++ worker/src/routes/analyze.ts | 46 ++++++++++++++++++++ worker/src/types.ts | 22 ++++++++++ worker/tsconfig.json | 17 ++++++++ worker/wrangler.toml | 10 +++++ 15 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 worker/package.json create mode 100644 worker/src/index.ts create mode 100644 worker/src/middleware/cors.ts create mode 100644 worker/src/middleware/rateLimit.ts create mode 100644 worker/src/middleware/validate.ts create mode 100644 worker/src/routes/analyze.ts create mode 100644 worker/src/types.ts create mode 100644 worker/tsconfig.json create mode 100644 worker/wrangler.toml diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 128a4135..873473f5 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -498,8 +498,13 @@ const Settings: React.FC = () => { }); }, []); - const primaryAIProviderId = AI_PROVIDER_ORDER[0] ?? ("groq" as AIProvider); - const otherAIProviderIds = AI_PROVIDER_ORDER.filter( + // Filter out keyless providers (openscan-groq) — they don't need user-configured keys + type KeyRequiredProvider = Exclude; + const keyRequiredProviders = AI_PROVIDER_ORDER.filter( + (id): id is KeyRequiredProvider => AI_PROVIDERS[id].keyUrl !== "", + ); + const primaryAIProviderId = keyRequiredProviders[0] ?? ("groq" as KeyRequiredProvider); + const otherAIProviderIds = keyRequiredProviders.filter( (providerId) => providerId !== primaryAIProviderId, ); diff --git a/src/config/aiProviders.ts b/src/config/aiProviders.ts index d25f0de5..00b5aa1e 100644 --- a/src/config/aiProviders.ts +++ b/src/config/aiProviders.ts @@ -3,8 +3,18 @@ import type { AIProvider, AIProviderConfig } from "../types"; /** * Static configuration for supported AI providers. * No API keys stored here - users provide their own keys via Settings. + * "openscan-groq" is a free proxy — no key required. */ export const AI_PROVIDERS: Record = { + "openscan-groq": { + id: "openscan-groq", + name: "OpenScan Groq", + baseUrl: + process.env.REACT_APP_OPENSCAN_GROQ_AI_URL ?? + "https://openscan-groq-ai-proxy.openscan.workers.dev", + defaultModel: "groq/compound", + keyUrl: "", + }, groq: { id: "groq", name: "Groq", @@ -48,6 +58,7 @@ export const AI_PROVIDERS: Record = { * When resolving which provider to use, the first provider with a configured key wins. */ export const AI_PROVIDER_ORDER: AIProvider[] = [ + "openscan-groq", "groq", "gemini", "perplexity", diff --git a/src/hooks/useAIAnalysis.ts b/src/hooks/useAIAnalysis.ts index 94de2e0b..a37447b7 100644 --- a/src/hooks/useAIAnalysis.ts +++ b/src/hooks/useAIAnalysis.ts @@ -45,10 +45,13 @@ export function useAIAnalysis( provider: (typeof AI_PROVIDERS)[AIProvider]; apiKey: string; } | null => { - const apiKeys = settings.apiKeys; - if (!apiKeys) return null; + const apiKeys = settings.apiKeys ?? {}; for (const id of AI_PROVIDER_ORDER) { + // openscan-groq is a free proxy — no API key needed + if (id === "openscan-groq") { + return { provider: AI_PROVIDERS[id], apiKey: "" }; + } const key = apiKeys[id]; if (key) { return { provider: AI_PROVIDERS[id], apiKey: key }; diff --git a/src/services/AIService.ts b/src/services/AIService.ts index f455bfd3..0d8c4753 100644 --- a/src/services/AIService.ts +++ b/src/services/AIService.ts @@ -61,7 +61,7 @@ export class AIService { }); try { - const content = await this.callAPI(system, user); + const content = await this.callAPI(system, user, request.type); return { summary: content, timestamp: Date.now(), @@ -78,7 +78,14 @@ export class AIService { } } - private async callAPI(system: string, user: string): Promise { + private async callAPI( + system: string, + user: string, + analysisType: AIAnalysisType, + ): Promise { + if (this.provider.id === "openscan-groq") { + return this.callOpenScanProxy(system, user, analysisType); + } if (this.provider.id === "anthropic") { return this.callAnthropic(system, user); } @@ -88,6 +95,35 @@ export class AIService { return this.callOpenAICompatible(system, user); } + private async callOpenScanProxy( + system: string, + user: string, + analysisType: AIAnalysisType, + ): Promise { + const url = `${this.provider.baseUrl}/ai/analyze`; + const body = { + type: analysisType, + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + }; + + const response = await this.fetchWithRetry(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + const content = data?.choices?.[0]?.message?.content; + if (typeof content !== "string") { + logger.error("Unexpected OpenScan proxy response format:", data); + throw new AIServiceError("Failed to parse AI response", "parse_error"); + } + return content; + } + private async callOpenAICompatible(system: string, user: string): Promise { const url = `${this.provider.baseUrl}/chat/completions`; const body = { diff --git a/src/types/index.ts b/src/types/index.ts index 92b8e135..efc5b2b4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -432,7 +432,13 @@ export interface ApiKeys { /** * Supported AI providers for blockchain analysis */ -export type AIProvider = "groq" | "openai" | "anthropic" | "perplexity" | "gemini"; +export type AIProvider = + | "openscan-groq" + | "groq" + | "openai" + | "anthropic" + | "perplexity" + | "gemini"; /** * Configuration for an AI provider diff --git a/vite.config.ts b/vite.config.ts index 38fa1a12..7ec37b31 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -58,6 +58,9 @@ export default defineConfig({ "process.env.REACT_APP_OPENSCAN_NETWORKS": JSON.stringify( process.env.REACT_APP_OPENSCAN_NETWORKS || "" ), + "process.env.REACT_APP_OPENSCAN_GROQ_AI_URL": JSON.stringify( + process.env.REACT_APP_OPENSCAN_GROQ_AI_URL || "https://openscan-groq-ai-proxy.openscan.workers.dev" + ), "import.meta.env.VITE_ENVIRONMENT": JSON.stringify( process.env.NODE_ENV || "development" ), diff --git a/worker/package.json b/worker/package.json new file mode 100644 index 00000000..c691d553 --- /dev/null +++ b/worker/package.json @@ -0,0 +1,18 @@ +{ + "name": "openscan-groq-ai-proxy", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "hono": "^4.7.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250109.0", + "typescript": "^5.7.0", + "wrangler": "^4.72.0" + } +} diff --git a/worker/src/index.ts b/worker/src/index.ts new file mode 100644 index 00000000..c180ba61 --- /dev/null +++ b/worker/src/index.ts @@ -0,0 +1,22 @@ +import { Hono } from "hono"; +import type { Env } from "./types"; +import { corsMiddleware } from "./middleware/cors"; +import { rateLimitMiddleware } from "./middleware/rateLimit"; +import { validateMiddleware } from "./middleware/validate"; +import { analyzeHandler } from "./routes/analyze"; + +const app = new Hono<{ Bindings: Env }>(); + +// Global CORS handling (including preflight) +app.use("*", corsMiddleware); + +// POST /ai/analyze — rate limit, validate, then handle +app.post("/ai/analyze", rateLimitMiddleware, validateMiddleware, analyzeHandler); + +// Health check +app.get("/health", (c) => c.json({ status: "ok" })); + +// 404 for everything else +app.all("*", (c) => c.json({ error: "Not found" }, 404)); + +export default app; diff --git a/worker/src/middleware/cors.ts b/worker/src/middleware/cors.ts new file mode 100644 index 00000000..1ecea302 --- /dev/null +++ b/worker/src/middleware/cors.ts @@ -0,0 +1,57 @@ +import type { Context, Next } from "hono"; +import type { Env } from "../types"; + +/** + * Check if an origin is allowed. + * Entries starting with "*." are suffix patterns — e.g. "*.openscan.netlify.app" + * matches "https://pr-123--openscan.netlify.app". + * All other entries are exact matches. + */ +function isOriginAllowed(origin: string, allowed: string[]): boolean { + for (const entry of allowed) { + if (entry.startsWith("*.")) { + const suffix = entry.slice(1); // e.g. ".openscan.netlify.app" + try { + const { hostname } = new URL(origin); + if (hostname.endsWith(suffix) || hostname === suffix.slice(1)) { + return true; + } + } catch { + continue; + } + } else if (origin === entry) { + return true; + } + } + return false; +} + +export async function corsMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + const origin = c.req.header("Origin") ?? ""; + const allowed = c.env.ALLOWED_ORIGINS.split(",").map((o) => o.trim()); + + if (c.req.method === "OPTIONS") { + if (!isOriginAllowed(origin, allowed)) { + return c.text("Forbidden", 403); + } + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", + }, + }); + } + + if (origin && !isOriginAllowed(origin, allowed)) { + return c.text("Forbidden", 403); + } + + await next(); + + if (origin && isOriginAllowed(origin, allowed)) { + c.header("Access-Control-Allow-Origin", origin); + } +} diff --git a/worker/src/middleware/rateLimit.ts b/worker/src/middleware/rateLimit.ts new file mode 100644 index 00000000..859c1889 --- /dev/null +++ b/worker/src/middleware/rateLimit.ts @@ -0,0 +1,53 @@ +import type { Context, Next } from "hono"; +import type { Env } from "../types"; + +const WINDOW_MS = 60_000; // 1 minute +const MAX_REQUESTS = 10; + +interface RateLimitEntry { + timestamps: number[]; +} + +// In-memory store — resets on worker cold start, which is acceptable +// for a lightweight rate limiter. For stricter limits, use Durable Objects. +const store = new Map(); + +// Periodic cleanup to prevent unbounded growth +let lastCleanup = Date.now(); +const CLEANUP_INTERVAL_MS = 300_000; // 5 minutes + +function cleanup(now: number) { + if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; + lastCleanup = now; + for (const [key, entry] of store) { + if (entry.timestamps.every((ts) => now - ts > WINDOW_MS)) { + store.delete(key); + } + } +} + +export async function rateLimitMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + const ip = c.req.header("CF-Connecting-IP") ?? c.req.header("X-Forwarded-For") ?? "unknown"; + const now = Date.now(); + + cleanup(now); + + let entry = store.get(ip); + if (!entry) { + entry = { timestamps: [] }; + store.set(ip, entry); + } + + // Sliding window: keep only timestamps within the window + entry.timestamps = entry.timestamps.filter((ts) => now - ts < WINDOW_MS); + + if (entry.timestamps.length >= MAX_REQUESTS) { + const oldestInWindow = entry.timestamps[0]!; + const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldestInWindow)) / 1000); + c.header("Retry-After", String(retryAfterSec)); + return c.json({ error: "Rate limit exceeded. Try again later." }, 429); + } + + entry.timestamps.push(now); + await next(); +} diff --git a/worker/src/middleware/validate.ts b/worker/src/middleware/validate.ts new file mode 100644 index 00000000..69d7a0d8 --- /dev/null +++ b/worker/src/middleware/validate.ts @@ -0,0 +1,41 @@ +import type { Context, Next } from "hono"; +import { type AnalyzeRequestBody, VALID_ANALYSIS_TYPES, type Env } from "../types"; + +const MAX_BODY_SIZE = 32 * 1024; // 32 KB + +export async function validateMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + const contentLength = c.req.header("Content-Length"); + if (contentLength && Number.parseInt(contentLength, 10) > MAX_BODY_SIZE) { + return c.json({ error: "Request body too large" }, 413); + } + + let body: AnalyzeRequestBody; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } + + if (!body.type || !VALID_ANALYSIS_TYPES.includes(body.type)) { + return c.json( + { error: `Invalid type. Must be one of: ${VALID_ANALYSIS_TYPES.join(", ")}` }, + 400, + ); + } + + if (!Array.isArray(body.messages) || body.messages.length !== 2) { + return c.json({ error: "messages must be an array of exactly 2 entries" }, 400); + } + + const [systemMsg, userMsg] = body.messages; + if (systemMsg?.role !== "system" || typeof systemMsg?.content !== "string") { + return c.json({ error: "First message must have role 'system' with string content" }, 400); + } + if (userMsg?.role !== "user" || typeof userMsg?.content !== "string") { + return c.json({ error: "Second message must have role 'user' with string content" }, 400); + } + + // Store validated body for route handler + c.set("validatedBody" as never, body as never); + await next(); +} diff --git a/worker/src/routes/analyze.ts b/worker/src/routes/analyze.ts new file mode 100644 index 00000000..bbd3fe7d --- /dev/null +++ b/worker/src/routes/analyze.ts @@ -0,0 +1,46 @@ +import type { Context } from "hono"; +import type { AnalyzeRequestBody, Env } from "../types"; + +const MAX_TOKENS = 4096; +const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"; + +export async function analyzeHandler(c: Context<{ Bindings: Env }>) { + const body = c.get("validatedBody" as never) as unknown as AnalyzeRequestBody; + const model = c.env.GROQ_MODEL || "groq/compound"; + + const groqBody = { + model, + messages: body.messages, + max_tokens: MAX_TOKENS, + temperature: 0, + }; + + try { + const response = await fetch(GROQ_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${c.env.GROQ_API_KEY}`, + }, + body: JSON.stringify(groqBody), + }); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "Groq rate limit exceeded" }, 429); + } + if (status === 401) { + return c.json({ error: "AI service configuration error" }, 500); + } + return c.json({ error: `AI service error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to AI service" }, 502); + } +} diff --git a/worker/src/types.ts b/worker/src/types.ts new file mode 100644 index 00000000..4ac5db72 --- /dev/null +++ b/worker/src/types.ts @@ -0,0 +1,22 @@ +export const VALID_ANALYSIS_TYPES = [ + "transaction", + "account", + "contract", + "block", + "bitcoin_transaction", + "bitcoin_block", + "bitcoin_address", +] as const; + +export type AIAnalysisType = (typeof VALID_ANALYSIS_TYPES)[number]; + +export interface AnalyzeRequestBody { + type: AIAnalysisType; + messages: Array<{ role: "system" | "user"; content: string }>; +} + +export interface Env { + GROQ_API_KEY: string; + ALLOWED_ORIGINS: string; + GROQ_MODEL: string; +} diff --git a/worker/tsconfig.json b/worker/tsconfig.json new file mode 100644 index 00000000..d8dd20b7 --- /dev/null +++ b/worker/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"] +} diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 00000000..099b8578 --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,10 @@ +name = "openscan-groq-ai-proxy" +main = "src/index.ts" +compatibility_date = "2024-12-01" + +[vars] +ALLOWED_ORIGINS = "http://openscan.eth.link,https://openscan-explorer.github.io,https://openscan.netlify.app,*.openscan.netlify.app" +GROQ_MODEL = "groq/compound" + +# Secret: GROQ_API_KEY — set via `wrangler secret put GROQ_API_KEY` + From 3780c541d1f7302ffe3ba770549f9694f394939d Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 13:24:38 -0300 Subject: [PATCH 08/43] fix(worker): fix CORS wildcard matching for Netlify preview URLs Netlify previews use -- separator (pr-306--openscan.netlify.app), not subdomain dots. Update wildcard pattern and matching logic. --- worker/src/middleware/cors.ts | 12 ++++++------ worker/wrangler.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/worker/src/middleware/cors.ts b/worker/src/middleware/cors.ts index 1ecea302..3f908012 100644 --- a/worker/src/middleware/cors.ts +++ b/worker/src/middleware/cors.ts @@ -3,17 +3,17 @@ import type { Env } from "../types"; /** * Check if an origin is allowed. - * Entries starting with "*." are suffix patterns — e.g. "*.openscan.netlify.app" - * matches "https://pr-123--openscan.netlify.app". - * All other entries are exact matches. + * Entries starting with "*" are suffix patterns on the hostname — e.g. + * "*--openscan.netlify.app" matches "https://pr-306--openscan.netlify.app". + * All other entries are exact origin matches. */ function isOriginAllowed(origin: string, allowed: string[]): boolean { for (const entry of allowed) { - if (entry.startsWith("*.")) { - const suffix = entry.slice(1); // e.g. ".openscan.netlify.app" + if (entry.startsWith("*")) { + const suffix = entry.slice(1); // e.g. "--openscan.netlify.app" try { const { hostname } = new URL(origin); - if (hostname.endsWith(suffix) || hostname === suffix.slice(1)) { + if (hostname.endsWith(suffix)) { return true; } } catch { diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 099b8578..70cf84c7 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -3,7 +3,7 @@ main = "src/index.ts" compatibility_date = "2024-12-01" [vars] -ALLOWED_ORIGINS = "http://openscan.eth.link,https://openscan-explorer.github.io,https://openscan.netlify.app,*.openscan.netlify.app" +ALLOWED_ORIGINS = "http://openscan.eth.link,https://openscan-explorer.github.io,https://openscan.netlify.app,*--openscan.netlify.app" GROQ_MODEL = "groq/compound" # Secret: GROQ_API_KEY — set via `wrangler secret put GROQ_API_KEY` From 475b027d454344ebf26c5691ad01f13094107b1e Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 12 Mar 2026 15:22:17 -0300 Subject: [PATCH 09/43] fix(nav): prevent Super User toggle button layout shift Move Super User toggle to a fixed position after Theme toggle (before Settings). Remove the conditional SUPER USER badge that caused layout shift. DevTools button now appears to the left of the fixed icons when Super User mode is enabled. Fixes #298 --- src/components/navbar/index.tsx | 77 +++++++++++++++------------------ 1 file changed, 36 insertions(+), 41 deletions(-) diff --git a/src/components/navbar/index.tsx b/src/components/navbar/index.tsx index 1e5fb374..aa6b0240 100644 --- a/src/components/navbar/index.tsx +++ b/src/components/navbar/index.tsx @@ -108,47 +108,6 @@ const Navbar = () => { {/* Desktop icons - hidden on mobile */}
    - {isSuperUser && ( -
  • - {t("nav.superUserBadge")} -
  • - )} -
  • - -
  • {isSuperUser && (
  • +
  • + +
  • + + {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 14/43] 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 15/43] 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 16/43] 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 17/43] 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 18/43] 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 19/43] 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 20/43] 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 21/43] 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 23/43] 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 24/43] 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 25/43] 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 26/43] 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); } From c5fbff4fb80a1b1435808b95d682d05938e80e06 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 13 Mar 2026 17:21:40 -0300 Subject: [PATCH 27/43] feat(settings): add export/import configuration Add Export and Import buttons to the Settings Cache & Data section. - New utility src/utils/configExportImport.ts with exportConfig(), importConfig(), and downloadConfigFile() functions - Export reads openScan_user_settings, OPENSCAN_RPC_URLS_V3, OPENSCAN_ARTIFACTS_JSON_V1, and openScan_language from localStorage and downloads as openscan-config-YYYY-MM-DD.json - Import validates the JSON, confirms overwrite, writes each key back using existing storage utilities, then reloads the page - Error toast shown for invalid/malformed config files - i18n keys added to all 5 locales (en, es, ja, pt-BR, zh) - settings-toast-error CSS class added for error feedback --- src/components/pages/settings/index.tsx | 85 ++++++++++++++++- src/locales/en/settings.json | 14 +++ src/locales/es/settings.json | 14 +++ src/locales/ja/settings.json | 14 +++ src/locales/pt-BR/settings.json | 14 +++ src/locales/zh/settings.json | 14 +++ src/styles/styles.css | 5 + src/utils/configExportImport.ts | 117 ++++++++++++++++++++++++ 8 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/utils/configExportImport.ts diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 2efa70ba..119a023b 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -19,6 +19,7 @@ import type { } from "../../../types"; import { AI_PROVIDERS, AI_PROVIDER_ORDER } from "../../../config/aiProviders"; import { clearAICache } from "../../common/AIAnalysis/aiCache"; +import { downloadConfigFile, exportConfig, importConfig } from "../../../utils/configExportImport"; import { logger } from "../../../utils/logger"; import { getChainIdFromNetwork } from "../../../utils/networkResolver"; import { clearPersistentCache, getPersistentCacheSize } from "../../../utils/persistentCache"; @@ -97,6 +98,8 @@ const Settings: React.FC = () => { }); const [saveSuccess, setSaveSuccess] = useState(false); const [cacheCleared, setCacheCleared] = useState(false); + const [importSuccess, setImportSuccess] = useState(false); + const [importError, setImportError] = useState(false); const [draggedItem, setDraggedItem] = useState<{ networkId: string; index: number; @@ -222,6 +225,42 @@ const Settings: React.FC = () => { window.location.reload(); }, [t]); + const handleExportConfig = useCallback(() => { + if (!window.confirm(t("cacheData.exportConfig.sensitiveWarning"))) return; + const config = exportConfig(); + downloadConfigFile(config); + }, [t]); + + const handleImportConfig = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + // Reset input so the same file can be re-selected + e.target.value = ""; + + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const data = JSON.parse(ev.target?.result as string); + if (!window.confirm(t("cacheData.importConfig.confirmMessage"))) return; + const result = importConfig(data); + if (result.success) { + setImportSuccess(true); + setTimeout(() => window.location.reload(), 1500); + } else { + setImportError(true); + setTimeout(() => setImportError(false), 3000); + } + } catch { + setImportError(true); + setTimeout(() => setImportError(false), 3000); + } + }; + reader.readAsText(file); + }, + [t], + ); + // Helper to get URLs as array from localRpc const getLocalRpcArray = useCallback( (networkId: string): string[] => { @@ -528,7 +567,7 @@ const Settings: React.FC = () => { return ( <> {/* Fixed Toast Notifications */} - {(saveSuccess || cacheCleared) && ( + {(saveSuccess || cacheCleared || importSuccess || importError) && (
{saveSuccess && (
@@ -540,6 +579,16 @@ const Settings: React.FC = () => { ✓ {t("toasts.cacheCleared")}
)} + {importSuccess && ( +
+ ✓ {t("cacheData.importConfig.success")} +
+ )} + {importError && ( +
+ ✗ {t("cacheData.importConfig.invalidFile")} +
+ )}
)} @@ -641,6 +690,40 @@ const Settings: React.FC = () => { ⚠️ {t("cacheData.clearSiteData.button")}
+
+
+
+
{t("cacheData.exportConfig.label")}
+
+ {t("cacheData.exportConfig.description")} +
+
+ +
+
+
+
+
{t("cacheData.importConfig.label")}
+
+ {t("cacheData.importConfig.description")} +
+
+ +
{/* RPC Strategy Section */} diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 34dfeb5d..ae6941ff 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -27,6 +27,20 @@ "description": "Clear all data including settings, RPC configs, and API keys.", "button": "Clear Site Data", "confirmMessage": "This will clear all settings, RPC configurations, API keys, and cached data. The page will reload. Continue?" + }, + "exportConfig": { + "label": "Export Configuration", + "description": "Download all settings, RPC configs, and API keys as a JSON file.", + "button": "Export", + "sensitiveWarning": "The exported file contains sensitive data (API keys). Store it securely. Continue?" + }, + "importConfig": { + "label": "Import Configuration", + "description": "Restore settings from a previously exported JSON file.", + "button": "Import", + "confirmMessage": "This will overwrite all current settings, RPC configurations, and API keys. Continue?", + "invalidFile": "Invalid configuration file. Please select a valid OpenScan config JSON.", + "success": "Configuration imported successfully!" } }, "rpcStrategy": { diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 3c6a2068..967da2c4 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -27,6 +27,20 @@ "description": "Borrar todos los datos, incluyendo configuración, RPCs y API keys.", "button": "Limpiar Datos del Sitio", "confirmMessage": "Esto va a borrar toda la configuración, RPCs, API keys y datos cacheados. La página se va a recargar. ¿Continuar?" + }, + "exportConfig": { + "label": "Exportar Configuración", + "description": "Descargar toda la configuración, RPCs y API keys como archivo JSON.", + "button": "Exportar", + "sensitiveWarning": "El archivo exportado contiene datos sensibles (API keys). Guardalo de forma segura. ¿Continuar?" + }, + "importConfig": { + "label": "Importar Configuración", + "description": "Restaurar configuración desde un archivo JSON exportado previamente.", + "button": "Importar", + "confirmMessage": "Esto va a sobrescribir toda la configuración actual, RPCs y API keys. ¿Continuar?", + "invalidFile": "Archivo de configuración inválido. Seleccioná un archivo JSON de OpenScan válido.", + "success": "¡Configuración importada correctamente!" } }, "rpcStrategy": { diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 38bd6313..26c1bae0 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -27,6 +27,20 @@ "description": "設定、RPC設定、APIキーを含むすべてのデータをクリアします。", "button": "サイトデータをクリア", "confirmMessage": "これにより、すべての設定、RPC設定、APIキー、キャッシュデータがクリアされます。ページが再読み込みされます。続行しますか?" + }, + "exportConfig": { + "label": "設定をエクスポート", + "description": "すべての設定、RPC設定、APIキーをJSONファイルとしてダウンロードします。", + "button": "エクスポート", + "sensitiveWarning": "エクスポートされたファイルには機密データ(APIキー)が含まれています。安全に保管してください。続行しますか?" + }, + "importConfig": { + "label": "設定をインポート", + "description": "以前エクスポートしたJSONファイルから設定を復元します。", + "button": "インポート", + "confirmMessage": "現在のすべての設定、RPC設定、APIキーが上書きされます。続行しますか?", + "invalidFile": "無効な設定ファイルです。有効なOpenScan設定JSONを選択してください。", + "success": "設定が正常にインポートされました!" } }, "rpcStrategy": { diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index ed5184e5..c8a3f7bb 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -27,6 +27,20 @@ "description": "Limpar todos os dados incluindo configurações, configurações RPC e chaves de API.", "button": "Limpar Dados do Site", "confirmMessage": "Isso vai limpar todas as configurações, configurações RPC, chaves de API e dados em cache. A página será recarregada. Continuar?" + }, + "exportConfig": { + "label": "Exportar Configuração", + "description": "Baixar todas as configurações, configurações RPC e chaves de API como arquivo JSON.", + "button": "Exportar", + "sensitiveWarning": "O arquivo exportado contém dados sensíveis (chaves de API). Armazene-o com segurança. Continuar?" + }, + "importConfig": { + "label": "Importar Configuração", + "description": "Restaurar configurações de um arquivo JSON exportado anteriormente.", + "button": "Importar", + "confirmMessage": "Isso vai sobrescrever todas as configurações atuais, configurações RPC e chaves de API. Continuar?", + "invalidFile": "Arquivo de configuração inválido. Selecione um JSON de configuração OpenScan válido.", + "success": "Configuração importada com sucesso!" } }, "rpcStrategy": { diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 9fc352a3..04944e06 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -27,6 +27,20 @@ "description": "清除所有数据,包括设置、RPC 配置和 API 密钥。", "button": "清除站点数据", "confirmMessage": "这将清除所有设置、RPC 配置、API 密钥和缓存数据。页面将重新加载。继续?" + }, + "exportConfig": { + "label": "导出配置", + "description": "将所有设置、RPC 配置和 API 密钥下载为 JSON 文件。", + "button": "导出", + "sensitiveWarning": "导出的文件包含敏感数据(API 密钥)。请安全存储。继续?" + }, + "importConfig": { + "label": "导入配置", + "description": "从之前导出的 JSON 文件恢复设置。", + "button": "导入", + "confirmMessage": "这将覆盖所有当前设置、RPC 配置和 API 密钥。继续?", + "invalidFile": "无效的配置文件。请选择有效的 OpenScan 配置 JSON。", + "success": "配置导入成功!" } }, "rpcStrategy": { diff --git a/src/styles/styles.css b/src/styles/styles.css index cbf35360..569cfcbd 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -5217,6 +5217,11 @@ code { color: white; } +.settings-toast-error { + background: linear-gradient(135deg, #e53e3e 0%, #c53030 100%); + color: white; +} + @keyframes slideDown { from { opacity: 0; diff --git a/src/utils/configExportImport.ts b/src/utils/configExportImport.ts new file mode 100644 index 00000000..8443f656 --- /dev/null +++ b/src/utils/configExportImport.ts @@ -0,0 +1,117 @@ +import { saveJsonFilesToStorage } from "./artifactsStorage"; +import { saveRpcUrlsToStorage } from "./rpcStorage"; + +const CONFIG_VERSION = 1; + +const EXPORTED_KEYS = { + settings: "openScan_user_settings", + rpcUrls: "OPENSCAN_RPC_URLS_V3", + artifacts: "OPENSCAN_ARTIFACTS_JSON_V1", + language: "openScan_language", +} as const; + +interface OpenScanConfig { + version: number; + settings?: Record; + rpcUrls?: Record; + // biome-ignore lint/suspicious/noExplicitAny: artifacts have dynamic shape + artifacts?: Record; + language?: string; +} + +function parseStorageItem(key: string): unknown { + try { + const raw = localStorage.getItem(key); + if (raw === null) return undefined; + return JSON.parse(raw); + } catch { + return undefined; + } +} + +/** + * Reads user configuration from localStorage and returns a serializable object. + */ +export function exportConfig(): OpenScanConfig { + const config: OpenScanConfig = { version: CONFIG_VERSION }; + + const settings = parseStorageItem(EXPORTED_KEYS.settings); + if (settings && typeof settings === "object") { + config.settings = settings as Record; + } + + const rpcUrls = parseStorageItem(EXPORTED_KEYS.rpcUrls); + if (rpcUrls && typeof rpcUrls === "object") { + config.rpcUrls = rpcUrls as Record; + } + + const artifacts = parseStorageItem(EXPORTED_KEYS.artifacts); + if (artifacts && typeof artifacts === "object") { + config.artifacts = artifacts as Record; + } + + const language = localStorage.getItem(EXPORTED_KEYS.language); + if (language) { + config.language = language; + } + + return config; +} + +/** + * Validates and writes an imported configuration to localStorage. + */ +export function importConfig(data: unknown): { success: boolean; error?: string } { + if (!data || typeof data !== "object") { + return { success: false, error: "Invalid configuration file." }; + } + + const config = data as Record; + + if (config.version !== CONFIG_VERSION) { + return { success: false, error: `Unsupported config version: ${String(config.version)}` }; + } + + if (config.settings !== undefined && typeof config.settings !== "object") { + return { success: false, error: "Invalid settings format." }; + } + + // Apply settings + if (config.settings && typeof config.settings === "object") { + localStorage.setItem(EXPORTED_KEYS.settings, JSON.stringify(config.settings)); + } + + // Apply RPC URLs + if (config.rpcUrls && typeof config.rpcUrls === "object") { + saveRpcUrlsToStorage(config.rpcUrls as Record); + } + + // Apply artifacts + if (config.artifacts && typeof config.artifacts === "object") { + saveJsonFilesToStorage(config.artifacts as Record); + } + + // Apply language + if (typeof config.language === "string") { + localStorage.setItem(EXPORTED_KEYS.language, config.language); + } + + return { success: true }; +} + +/** + * Triggers a browser download of the config as a JSON file. + */ +export function downloadConfigFile(config: OpenScanConfig): void { + const dateStr = new Date().toISOString().slice(0, 10); + const filename = `openscan-config-${dateStr}.json`; + const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + + URL.revokeObjectURL(url); +} From 7dadf7d524d3c993fd4ead2c7a63ab8a24905211 Mon Sep 17 00:00:00 2001 From: Jose Aloha Date: Thu, 12 Mar 2026 10:34:06 -0300 Subject: [PATCH 28/43] feat(settings): add devtools-style tabs and autosave config Fixes openscan-explorer/explorer#292 Fixes openscan-explorer/explorer#297 --- src/components/pages/settings/index.tsx | 1732 +++++++++++++---------- src/styles/styles.css | 140 ++ 2 files changed, 1129 insertions(+), 743 deletions(-) diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 346b7b7e..0b13b2a7 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -1,6 +1,6 @@ import type React from "react"; -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { MetaMaskIcon } from "../../common/MetaMaskIcon"; import { getEnabledNetworks } from "../../../config/networks"; @@ -52,6 +52,20 @@ const ALCHEMY_BTC_NETWORKS: Record = { "bip122:00000000da84f2bafbbc53dee25a72ae": "btc-testnet", }; +const SETTINGS_ACTIVE_TAB_KEY = "openscan_settings_active_tab"; + +type SettingsTab = "network" | "providers" | "display" | "advanced"; + +const SETTINGS_TABS: Array<{ id: SettingsTab; label: string }> = [ + { id: "network", label: "🌐 Network" }, + { id: "providers", label: "🔑 Providers" }, + { id: "display", label: "🎨 Display" }, + { id: "advanced", label: "🧪 Advanced" }, +]; + +const isValidSettingsTab = (value: string | null): value is SettingsTab => + value === "network" || value === "providers" || value === "display" || value === "advanced"; + const getInfuraUrl = (chainId: number, apiKey: string): string | null => { const slug = INFURA_NETWORKS[chainId]; return slug ? `https://${slug}.infura.io/v3/${apiKey}` : null; @@ -72,14 +86,16 @@ const isAlchemyUrl = (url: string): boolean => url.includes("alchemy.com"); const Settings: React.FC = () => { const { t, i18n } = useTranslation("settings"); + const location = useLocation(); + const navigate = useNavigate(); const { rpcUrls, setRpcUrls } = useContext(AppContext); const { settings, updateSettings, isSuperUser } = useSettings(); const { enabledNetworks } = useNetworks(); const { isMetaMaskAvailable, isSupported, setAsDefaultExplorer } = useMetaMaskExplorer(); + const [activeTab, setActiveTab] = useState("network"); const [localRpc, setLocalRpc] = useState>({ ...rpcUrls, }); - const [saveSuccess, setSaveSuccess] = useState(false); const [cacheCleared, setCacheCleared] = useState(false); const [draggedItem, setDraggedItem] = useState<{ networkId: string; @@ -112,12 +128,289 @@ const Settings: React.FC = () => { >({}); const [persistentCacheBytes, setPersistentCacheBytes] = useState(() => getPersistentCacheSize()); const [syncingChain, setSyncingChain] = useState(null); + const [autoSaveState, setAutoSaveState] = useState<"idle" | "saving" | "saved">("idle"); + + const initialRenderRef = useRef(true); + const saveTimerRef = useRef(null); + const savedHintTimerRef = useRef(null); + const lastSavedDraftRef = useRef(""); + + const normalizeRpcConfig = useCallback( + (draft: Record): RpcUrlsContextType => { + const normalized = Object.keys(draft) + .sort() + .reduce((acc, networkId) => { + const value = draft[networkId]; + if (typeof value === "string") { + acc[networkId] = value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + return acc; + } + + if (Array.isArray(value)) { + acc[networkId] = value.map((item) => item.trim()).filter(Boolean); + return acc; + } + + return acc; + }, {} as RpcUrlsContextType); + + return normalized; + }, + [], + ); - // Sync localRpc when context rpcUrls changes (e.g., after save) + const serializeDraft = useCallback( + (draftRpc: Record, draftApiKeys: typeof localApiKeys): string => { + const normalizedRpc = normalizeRpcConfig(draftRpc); + const sortedRpcEntries = Object.keys(normalizedRpc) + .sort() + .map((networkId) => [networkId, normalizedRpc[networkId]]); + + const sortedApiEntries = Object.keys(draftApiKeys) + .sort() + .map((key) => [key, draftApiKeys[key as keyof typeof draftApiKeys]]); + + return JSON.stringify({ rpc: sortedRpcEntries, apiKeys: sortedApiEntries }); + }, + [normalizeRpcConfig], + ); + + const applyProviderUrlsToRpcs = useCallback( + ( + parsedRpc: RpcUrlsContextType, + previousApiKeys: { infura?: string; alchemy?: string }, + nextApiKeys: { infura: string; alchemy: string }, + ) => { + const prevInfuraKey = previousApiKeys.infura || ""; + const prevAlchemyKey = previousApiKeys.alchemy || ""; + + for (const chainId of Object.keys(INFURA_NETWORKS).map(Number)) { + const networkId = `eip155:${chainId}`; + let urls: string[] = (parsedRpc[networkId] as string[]) || []; + + const oldInfuraUrl = prevInfuraKey ? getInfuraUrl(chainId, prevInfuraKey) : null; + const newInfuraUrl = nextApiKeys.infura ? getInfuraUrl(chainId, nextApiKeys.infura) : null; + + if (oldInfuraUrl && oldInfuraUrl !== newInfuraUrl) { + urls = urls.filter((url) => url !== oldInfuraUrl); + } + + if (newInfuraUrl && !urls.includes(newInfuraUrl)) { + urls = [newInfuraUrl, ...urls]; + } + + parsedRpc[networkId] = urls; + } + + for (const chainId of Object.keys(ALCHEMY_NETWORKS).map(Number)) { + const networkId = `eip155:${chainId}`; + let urls: string[] = (parsedRpc[networkId] as string[]) || []; + + const oldAlchemyUrl = prevAlchemyKey ? getAlchemyUrl(chainId, prevAlchemyKey) : null; + const newAlchemyUrl = nextApiKeys.alchemy + ? getAlchemyUrl(chainId, nextApiKeys.alchemy) + : null; + + if (oldAlchemyUrl && oldAlchemyUrl !== newAlchemyUrl) { + urls = urls.filter((url) => url !== oldAlchemyUrl); + } + + if (newAlchemyUrl && !urls.includes(newAlchemyUrl)) { + urls = [newAlchemyUrl, ...urls]; + } + + parsedRpc[networkId] = urls; + } + }, + [], + ); + + const persistConfiguration = useCallback( + ( + draftRpc: Record, + draftApiKeys: typeof localApiKeys, + options?: { silent?: boolean }, + ) => { + const parsedRpc = normalizeRpcConfig(draftRpc); + + const nextApiKeys = { + infura: draftApiKeys.infura, + alchemy: draftApiKeys.alchemy, + }; + + applyProviderUrlsToRpcs(parsedRpc, settings.apiKeys || {}, nextApiKeys); + + updateSettings({ + apiKeys: { + infura: draftApiKeys.infura || undefined, + alchemy: draftApiKeys.alchemy || undefined, + etherscan: draftApiKeys.etherscan || undefined, + groq: draftApiKeys.groq || undefined, + openai: draftApiKeys.openai || undefined, + anthropic: draftApiKeys.anthropic || undefined, + perplexity: draftApiKeys.perplexity || undefined, + gemini: draftApiKeys.gemini || undefined, + }, + }); + + setRpcUrls(parsedRpc); + lastSavedDraftRef.current = serializeDraft(draftRpc, draftApiKeys); + + if (!options?.silent) { + setAutoSaveState("saved"); + if (savedHintTimerRef.current) { + window.clearTimeout(savedHintTimerRef.current); + } + savedHintTimerRef.current = window.setTimeout(() => { + setAutoSaveState("idle"); + }, 1800); + } + }, + [ + applyProviderUrlsToRpcs, + normalizeRpcConfig, + serializeDraft, + setRpcUrls, + settings.apiKeys, + updateSettings, + ], + ); + + // Keep local inputs in sync when context values are updated from elsewhere. useEffect(() => { setLocalRpc({ ...rpcUrls }); }, [rpcUrls]); + useEffect(() => { + setLocalApiKeys({ + infura: settings.apiKeys?.infura || "", + alchemy: settings.apiKeys?.alchemy || "", + etherscan: settings.apiKeys?.etherscan || "", + groq: settings.apiKeys?.groq || "", + openai: settings.apiKeys?.openai || "", + anthropic: settings.apiKeys?.anthropic || "", + perplexity: settings.apiKeys?.perplexity || "", + gemini: settings.apiKeys?.gemini || "", + }); + }, [settings.apiKeys]); + + // Hydrate active tab from URL (query/hash) or previous session. + useEffect(() => { + const params = new URLSearchParams(location.search); + const tabFromQuery = params.get("tab"); + const tabFromHash = location.hash ? location.hash.replace(/^#/, "") : null; + + let nextTab: SettingsTab | null = null; + + if (isValidSettingsTab(tabFromQuery)) { + nextTab = tabFromQuery; + } else if (isValidSettingsTab(tabFromHash)) { + nextTab = tabFromHash; + } else { + const stored = localStorage.getItem(SETTINGS_ACTIVE_TAB_KEY); + if (isValidSettingsTab(stored)) { + nextTab = stored; + } + } + + if (!nextTab) { + return; + } + + if (nextTab !== activeTab) { + setActiveTab(nextTab); + } + + localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, nextTab); + + if (!isValidSettingsTab(tabFromQuery) || tabFromQuery !== nextTab) { + const updatedParams = new URLSearchParams(location.search); + updatedParams.set("tab", nextTab); + navigate( + { + pathname: location.pathname, + search: `?${updatedParams.toString()}`, + hash: `#${nextTab}`, + }, + { replace: true }, + ); + } + }, [activeTab, location.hash, location.pathname, location.search, navigate]); + + const handleTabChange = useCallback( + (tab: SettingsTab) => { + if (tab === activeTab) { + return; + } + + setActiveTab(tab); + localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, tab); + + const updatedParams = new URLSearchParams(location.search); + updatedParams.set("tab", tab); + + navigate( + { + pathname: location.pathname, + search: `?${updatedParams.toString()}`, + hash: `#${tab}`, + }, + { replace: true }, + ); + }, + [activeTab, location.pathname, location.search, navigate], + ); + + useEffect(() => { + const currentDraft = serializeDraft(localRpc, localApiKeys); + + if (initialRenderRef.current) { + initialRenderRef.current = false; + lastSavedDraftRef.current = currentDraft; + return; + } + + if (currentDraft === lastSavedDraftRef.current) { + return; + } + + setAutoSaveState("saving"); + + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + } + + saveTimerRef.current = window.setTimeout(() => { + persistConfiguration(localRpc, localApiKeys); + saveTimerRef.current = null; + }, 450); + + return () => { + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + } + }; + }, [localApiKeys, localRpc, persistConfiguration, serializeDraft]); + + useEffect(() => { + return () => { + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + const currentDraft = serializeDraft(localRpc, localApiKeys); + if (currentDraft !== lastSavedDraftRef.current) { + persistConfiguration(localRpc, localApiKeys, { silent: true }); + } + } + + if (savedHintTimerRef.current) { + window.clearTimeout(savedHintTimerRef.current); + } + }; + }, [localApiKeys, localRpc, persistConfiguration, serializeDraft]); + const updateField = useCallback((networkId: string, value: string) => { setLocalRpc((prev) => ({ ...prev, [networkId]: value })); }, []); @@ -137,15 +430,10 @@ const Settings: React.FC = () => { // Clear all caches const clearAllCaches = useCallback(() => { - // Clear metadata service caches clearSupportersCache(); - // Clear metadata RPC cache clearMetadataRpcCache(); - // Clear localStorage caches if any localStorage.removeItem("openscan_cache"); - // Clear AI analysis cache clearAICache(); - // Clear persistent cache (super user) clearPersistentCache(); setPersistentCacheBytes(0); setCacheCleared(true); @@ -160,11 +448,9 @@ const Settings: React.FC = () => { return; } - // Clear localStorage and sessionStorage localStorage.clear(); sessionStorage.clear(); - // Clear all cookies for this site for (const cookie of document.cookie.split(";")) { const name = cookie.split("=")[0]?.trim(); if (name) { @@ -173,7 +459,6 @@ const Settings: React.FC = () => { } } - // Clear IndexedDB databases if (window.indexedDB?.databases) { try { const databases = await window.indexedDB.databases(); @@ -187,7 +472,6 @@ const Settings: React.FC = () => { } } - // Clear Cache Storage (Service Worker caches) if ("caches" in window) { try { const cacheNames = await caches.keys(); @@ -197,7 +481,6 @@ const Settings: React.FC = () => { } } - // Unregister service workers if ("serviceWorker" in navigator) { try { const registrations = await navigator.serviceWorker.getRegistrations(); @@ -246,16 +529,8 @@ const Settings: React.FC = () => { return urlMap; }, []); - /** - * Get CSS class for an RPC tag based on metadata endpoint properties - * - rpc-tracking: has tracking (tracking !== "none") → yellow - * - rpc-opensource: no tracking + open source → green - * - rpc-private: no tracking + not open source → light green - * - no class: URL not found in metadata (user-added) - */ const getRpcTagClass = useCallback( (url: string): string => { - // Personal API key URLs have tracking enabled if (isInfuraUrl(url) || isAlchemyUrl(url)) return "rpc-tracking"; const ep = metadataUrlMap.get(url); if (!ep) return ""; @@ -266,10 +541,6 @@ const Settings: React.FC = () => { [metadataUrlMap], ); - /** - * Get display label for an RPC tag. - * Priority: Infura/Alchemy personal → metadata provider name → hostname from URL - */ const getRpcTagLabel = useCallback( (url: string): string => { if (isInfuraUrl(url)) return "Infura Personal"; @@ -289,17 +560,13 @@ const Settings: React.FC = () => { navigator.clipboard.writeText(url); }, []); - // Handle setting OpenScan as MetaMask default explorer - // networkId: CAIP-2 format, chainId: numeric for MetaMask API const handleSetMetaMaskExplorer = useCallback( async (networkId: string, chainId: number | undefined) => { - // MetaMask only supports EVM networks with numeric chainId if (chainId === undefined) return; const network = enabledNetworks.find((n) => n.networkId === networkId); if (!network) return; - // Get the RPC URLs for this network from context (now keyed by networkId) const networkRpcUrls = rpcUrls[networkId] || []; setMetamaskStatus((prev) => ({ ...prev, [networkId]: "loading" })); @@ -406,124 +673,17 @@ const Settings: React.FC = () => { } const sorted = sortRpcsByQuality(urls, resultsMap, metadataUrlMap); updateField(chainId, sorted.join(",")); - setRpcUrls({ ...rpcUrls, [chainId]: sorted }); setSyncingChain(null); }, - [getLocalRpcArray, metadataUrlMap, updateField, setRpcUrls, rpcUrls], + [getLocalRpcArray, metadataUrlMap, updateField], ); - const save = () => { - // Convert comma-separated strings into arrays for each networkId - const parsed: RpcUrlsContextType = Object.keys(localRpc).reduce((acc, networkId) => { - const val = localRpc[networkId]; - if (typeof val === "string") { - const arr = val - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - acc[networkId] = arr; - } else if (val !== undefined) { - acc[networkId] = val; - } - return acc; - }, {} as RpcUrlsContextType); - - // Get previous API keys from settings - const prevInfuraKey = settings.apiKeys?.infura || ""; - const prevAlchemyKey = settings.apiKeys?.alchemy || ""; - - // Process each EVM chain to add/remove provider URLs (Infura/Alchemy only support EVM) - // Map chainId to networkId (CAIP-2 format: "eip155:") - for (const chainId of Object.keys(INFURA_NETWORKS).map(Number)) { - const networkId = `eip155:${chainId}`; - let urls: string[] = (parsed[networkId] as string[]) || []; - - // Handle Infura - const oldInfuraUrl = prevInfuraKey ? getInfuraUrl(chainId, prevInfuraKey) : null; - const newInfuraUrl = localApiKeys.infura ? getInfuraUrl(chainId, localApiKeys.infura) : null; - - // Remove old Infura URL if key changed or removed - if (oldInfuraUrl && oldInfuraUrl !== newInfuraUrl) { - urls = urls.filter((u) => u !== oldInfuraUrl); - } - // Add new Infura URL if key added and not already present - if (newInfuraUrl && !urls.includes(newInfuraUrl)) { - urls = [newInfuraUrl, ...urls]; - } - - parsed[networkId] = urls; - } - - for (const chainId of Object.keys(ALCHEMY_NETWORKS).map(Number)) { - const networkId = `eip155:${chainId}`; - let urls: string[] = (parsed[networkId] as string[]) || []; - - // Handle Alchemy - const oldAlchemyUrl = prevAlchemyKey ? getAlchemyUrl(chainId, prevAlchemyKey) : null; - const newAlchemyUrl = localApiKeys.alchemy - ? getAlchemyUrl(chainId, localApiKeys.alchemy) - : null; - - // Remove old Alchemy URL if key changed or removed - if (oldAlchemyUrl && oldAlchemyUrl !== newAlchemyUrl) { - urls = urls.filter((u) => u !== oldAlchemyUrl); - } - // Add new Alchemy URL if key added and not already present - if (newAlchemyUrl && !urls.includes(newAlchemyUrl)) { - urls = [newAlchemyUrl, ...urls]; - } - - parsed[networkId] = urls; - } - - // Handle Alchemy Bitcoin endpoints - for (const btcNetworkId of Object.keys(ALCHEMY_BTC_NETWORKS)) { - let urls: string[] = (parsed[btcNetworkId] as string[]) || []; - - const oldAlchemyBtcUrl = prevAlchemyKey - ? getAlchemyBtcUrl(btcNetworkId, prevAlchemyKey) - : null; - const newAlchemyBtcUrl = localApiKeys.alchemy - ? getAlchemyBtcUrl(btcNetworkId, localApiKeys.alchemy) - : null; - - if (oldAlchemyBtcUrl && oldAlchemyBtcUrl !== newAlchemyBtcUrl) { - urls = urls.filter((u) => u !== oldAlchemyBtcUrl); - } - if (newAlchemyBtcUrl && !urls.includes(newAlchemyBtcUrl)) { - urls = [newAlchemyBtcUrl, ...urls]; - } - - parsed[btcNetworkId] = urls; - } - - // Save API keys to settings - updateSettings({ - apiKeys: { - infura: localApiKeys.infura || undefined, - alchemy: localApiKeys.alchemy || undefined, - etherscan: localApiKeys.etherscan || undefined, - groq: localApiKeys.groq || undefined, - openai: localApiKeys.openai || undefined, - anthropic: localApiKeys.anthropic || undefined, - perplexity: localApiKeys.perplexity || undefined, - gemini: localApiKeys.gemini || undefined, - }, - }); - - setRpcUrls(parsed); - setSaveSuccess(true); - setTimeout(() => setSaveSuccess(false), 3000); - }; - - // Get enabled networks from config - // Use networkId as primary key for RPC storage, keep chainId for EVM-specific features const chainConfigs = useMemo(() => { return getEnabledNetworks().map((network) => { const chainId = getChainIdFromNetwork(network); return { - id: network.networkId, // Primary key for RPC storage (CAIP-2 format) - chainId: chainId, // Keep chainId separate for EVM-specific features (Infura, Alchemy, MetaMask) + id: network.networkId, + chainId: chainId, name: network.name, type: network.type, }; @@ -540,21 +700,18 @@ const Settings: React.FC = () => { (providerId) => providerId !== primaryAIProviderId, ); + const autoSaveMessage = + autoSaveState === "saving" + ? "Saving…" + : autoSaveState === "saved" + ? "Saved" + : "Auto-save enabled"; + return ( <> - {/* Fixed Toast Notifications */} - {(saveSuccess || cacheCleared) && ( + {cacheCleared && (
- {saveSuccess && ( -
- ✓ {t("toasts.settingsSaved")} -
- )} - {cacheCleared && ( -
- ✓ {t("toasts.cacheCleared")} -
- )} +
✓ {t("toasts.cacheCleared")}
)} @@ -562,649 +719,738 @@ const Settings: React.FC = () => {

{t("pageTitle")}

- {/* Settings Grid: 2x2 layout */} -
- {/* Appearance Settings Section */} -
-

🎨 {t("appearance.title")}

-

{t("appearance.description")}

- -
-
-
- {t("appearance.backgroundBlocks.label")} -
-
- {t("appearance.backgroundBlocks.description")} -
-
- -
-
- - {/* Language Settings Section */} -
-

🌐 {t("language.title")}

-

{t("language.description")}

- -
-
-
{t("language.label")}
-
{t("language.selectDescription")}
-
- -
+
+
+ +
- {/* Cache & Data Section */} -
-

🗑️ {t("cacheData.title")}

-

{t("cacheData.description")}

- -
-
-
{t("cacheData.clearCache.label")}
-
- {t("cacheData.clearCache.description")} -
-
- -
-
-
-
-
{t("cacheData.clearSiteData.label")}
-
- {t("cacheData.clearSiteData.description")} -
-
+
+ {SETTINGS_TABS.map((tab) => ( -
+ ))}
+
- {/* RPC Strategy Section */} -
-

⚡ {t("rpcStrategy.title")}

-

{t("rpcStrategy.description")}

- -
-
-
- {t("rpcStrategy.requestStrategy.label")} +
+ {activeTab === "display" && ( +
+
+
+

🎨 {t("appearance.title")}

+

{t("appearance.description")}

+ +
+
+
+ {t("appearance.backgroundBlocks.label")} +
+
+ {t("appearance.backgroundBlocks.description")} +
+
+ +
-
- Fallback: {t("rpcStrategy.requestStrategy.fallbackDesc")} -
- Parallel: {t("rpcStrategy.requestStrategy.parallelDesc")} -
- Race: {t("rpcStrategy.requestStrategy.raceDesc")} + +
+

🌐 {t("language.title")}

+

{t("language.description")}

+ +
+
+
{t("language.label")}
+
+ {t("language.selectDescription")} +
+
+ +
-
+ )} + + {activeTab === "providers" && ( +
+
+ + {autoSaveMessage} + +
+ +
+
+

🔑 {t("apiKeys.title")}

+

{t("apiKeys.description")}

+ +
+
+ {t("apiKeys.infura.name")} + + {t("apiKeys.infura.getKey")} → + +
+
+ + setLocalApiKeys((prev) => ({ ...prev, infura: e.target.value })) + } + placeholder={t("apiKeys.infura.placeholder")} + /> + +
+
- {/* Max Parallel Requests - Only show when parallel mode is active */} - {(settings.rpcStrategy === "parallel" || settings.rpcStrategy === "race") && ( -
-
-
- {t("rpcStrategy.maxParallelRequests.label")} +
+
+ {t("apiKeys.alchemy.name")} + + {t("apiKeys.alchemy.getKey")} → + +
+
+ + setLocalApiKeys((prev) => ({ ...prev, alchemy: e.target.value })) + } + placeholder={t("apiKeys.alchemy.placeholder")} + /> + +
-
- {t("rpcStrategy.maxParallelRequests.description")} + +
+
+ {t("apiKeys.etherscan.name")} + + {t("apiKeys.etherscan.getKey")} → + +
+
+ + setLocalApiKeys((prev) => ({ ...prev, etherscan: e.target.value })) + } + placeholder={t("apiKeys.etherscan.placeholder")} + /> + +
- -
- )} -
- {/* API Keys Section */} -
-

🔑 {t("apiKeys.title")}

-

{t("apiKeys.description")}

- -
-
- {t("apiKeys.infura.name")} - - {t("apiKeys.infura.getKey")} → - -
-
- - setLocalApiKeys((prev) => ({ ...prev, infura: e.target.value })) - } - placeholder={t("apiKeys.infura.placeholder")} - /> - -
-
+
+

🤖 {t("apiKeys.aiTitle")}

+

{t("apiKeys.aiDescription")}

-
-
- {t("apiKeys.alchemy.name")} - - {t("apiKeys.alchemy.getKey")} → - -
-
- - setLocalApiKeys((prev) => ({ ...prev, alchemy: e.target.value })) - } - placeholder={t("apiKeys.alchemy.placeholder")} - /> - -
-
+
+
+ + {t(`apiKeys.${primaryAIProviderId}.name`)} + + + {t(`apiKeys.${primaryAIProviderId}.getKey`)} → + +
+
+ + setLocalApiKeys((prev) => ({ + ...prev, + [primaryAIProviderId]: e.target.value, + })) + } + placeholder={t(`apiKeys.${primaryAIProviderId}.placeholder`)} + /> + +
+
-
-
- {t("apiKeys.etherscan.name")} - - {t("apiKeys.etherscan.getKey")} → - -
-
- - setLocalApiKeys((prev) => ({ ...prev, etherscan: e.target.value })) - } - placeholder={t("apiKeys.etherscan.placeholder")} - /> - + + + {aiKeysExpanded && ( +
+ {otherAIProviderIds.map((providerId) => { + const provider = AI_PROVIDERS[providerId]; + return ( +
+
+ + {t(`apiKeys.${providerId}.name`)} + + + {t(`apiKeys.${providerId}.getKey`)} → + +
+
+ + setLocalApiKeys((prev) => ({ + ...prev, + [providerId]: e.target.value, + })) + } + placeholder={t(`apiKeys.${providerId}.placeholder`)} + /> + +
+
+ ); + })} +
+ )} + +
+
+
+ {t("apiKeys.promptVersion.label")} +
+
+ {t("apiKeys.promptVersion.description")} +
+
+ +
+
-
- - {/* AI Provider API Keys */} -
-

🤖 {t("apiKeys.aiTitle")}

-

{t("apiKeys.aiDescription")}

+ )} -
-
- - {t(`apiKeys.${primaryAIProviderId}.name`)} + {activeTab === "network" && ( +
+ -
- - setLocalApiKeys((prev) => ({ - ...prev, - [primaryAIProviderId]: e.target.value, - })) - } - placeholder={t(`apiKeys.${primaryAIProviderId}.placeholder`)} - /> -
-
- - - {aiKeysExpanded && ( -
- {otherAIProviderIds.map((providerId) => { - const provider = AI_PROVIDERS[providerId]; - return ( -
-
- - {t(`apiKeys.${providerId}.name`)} - - - {t(`apiKeys.${providerId}.getKey`)} → - +
+

⚡ {t("rpcStrategy.title")}

+

{t("rpcStrategy.description")}

+ +
+
+
+ {t("rpcStrategy.requestStrategy.label")} +
+
+ Fallback: {t("rpcStrategy.requestStrategy.fallbackDesc")} +
+ Parallel: {t("rpcStrategy.requestStrategy.parallelDesc")} +
+ Race: {t("rpcStrategy.requestStrategy.raceDesc")} +
+
+ +
+ + {(settings.rpcStrategy === "parallel" || settings.rpcStrategy === "race") && ( +
+
+
+ {t("rpcStrategy.maxParallelRequests.label")}
-
- - setLocalApiKeys((prev) => ({ - ...prev, - [providerId]: e.target.value, - })) - } - placeholder={t(`apiKeys.${providerId}.placeholder`)} - /> - +
+ {t("rpcStrategy.maxParallelRequests.description")}
- ); - })} + +
+ )}
- )} -
-
-
{t("apiKeys.promptVersion.label")}
-
- {t("apiKeys.promptVersion.description")} +
+
+

🔗 {t("rpcEndpoints.title")}

+ + {t("rpcEndpoints.testEndpoints")} → +
-
- -
-
- {/* Super User Section - only visible in super user mode */} - {isSuperUser && ( -
-

{t("superUser.title")}

-

{t("superUser.description")}

- -
-
-
- {t("superUser.persistentCache.sizeLimit.label")} -
-
- {t("superUser.persistentCache.sizeLimit.description")} -
+

{t("rpcEndpoints.description")}

+ +
+ + {t("rpcEndpoints.legendOpensource")} + + + {t("rpcEndpoints.legendPrivate")} + + + {t("rpcEndpoints.legendTracking")} +
- -
-
-
-
- {t("superUser.persistentCache.label")} -
-
- {t("superUser.persistentCache.usage", { - used: `${(persistentCacheBytes / (1024 * 1024)).toFixed(2)} MB`, - limit: `${settings.persistentCacheSizeMB ?? 10} MB`, - })} -
+
+ {chainConfigs.map((chain) => { + const isExpanded = expandedChains.has(chain.id); + const rpcCount = getLocalRpcArray(chain.id).length; + + return ( +
+
+ + {isMetaMaskAvailable && chain.chainId !== undefined && ( + + )} + + + {rpcCount} RPC{rpcCount !== 1 ? "s" : ""} + +
+ + {isExpanded && ( +
+ updateField(chain.id, e.target.value)} + placeholder="https://eth-mainnet.g.alchemy.com/v2/YOUR-API-KEY" + /> + + {chain.chainId === 31337 && ( +
+ 💡 {t("rpcEndpoints.localhostHelp")}{" "} + + {t("rpcEndpoints.localhostHelpLink")} + +
+ )} + + {(() => { + const rpcArray = getLocalRpcArray(chain.id); + if (rpcArray.length === 0) return null; + return ( +
+ + {t("rpcEndpoints.currentRPCs")} + +
+ {rpcArray.map((url, idx) => ( +
handleDragStart(chain.id, idx)} + onDragOver={handleDragOver} + onDrop={() => handleDrop(chain.id, idx)} + onDragEnd={() => setDraggedItem(null)} + onClick={() => copyToClipboard(url)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") + copyToClipboard(url); + }} + > + {idx + 1} + + {getRpcTagLabel(url)} + + +
+ ))} +
+
+ ); + })()} +
+ )} +
+ ); + })}
-
)} -
- - {/* Save Button - positioned after general settings */} -
- -
- - {/* RPC Configuration Section */} -
-
-

🔗 {t("rpcEndpoints.title")}

- - {t("rpcEndpoints.testEndpoints")} → - -
-

{t("rpcEndpoints.description")}

- -
- - {t("rpcEndpoints.legendOpensource")} - - - {t("rpcEndpoints.legendPrivate")} - - - {t("rpcEndpoints.legendTracking")} - -
- -
- {chainConfigs.map((chain) => { - const isExpanded = expandedChains.has(chain.id); - const rpcCount = getLocalRpcArray(chain.id).length; - return ( -
- {/* Collapsible Header */} -
+ {activeTab === "advanced" && ( +
+
+
+

🗑️ {t("cacheData.title")}

+

{t("cacheData.description")}

+ +
+
+
{t("cacheData.clearCache.label")}
+
+ {t("cacheData.clearCache.description")} +
+
- {isMetaMaskAvailable && chain.chainId !== undefined && ( - - )} +
+ +
+ +
+
+
+ {t("cacheData.clearSiteData.label")} +
+
+ {t("cacheData.clearSiteData.description")} +
+
- - {rpcCount} RPC{rpcCount !== 1 ? "s" : ""} -
+
- {/* Collapsible Content */} - {isExpanded && ( -
- updateField(chain.id, e.target.value)} - placeholder="https://eth-mainnet.g.alchemy.com/v2/YOUR-API-KEY" - /> + {isSuperUser && ( +
+

{t("superUser.title")}

+

{t("superUser.description")}

- {/* Help text for localhost network */} - {chain.chainId === 31337 && ( -
- 💡 {t("rpcEndpoints.localhostHelp")}{" "} - - {t("rpcEndpoints.localhostHelpLink")} - +
+
+
+ {t("superUser.persistentCache.sizeLimit.label")} +
+
+ {t("superUser.persistentCache.sizeLimit.description")}
- )} +
+ +
- {/* Display current RPC list as tags */} - {(() => { - const rpcArray = getLocalRpcArray(chain.id); - if (rpcArray.length === 0) return null; - return ( -
- - {t("rpcEndpoints.currentRPCs")} - -
- {rpcArray.map((url, idx) => ( - // biome-ignore lint/a11y/noStaticElementInteractions: Drag-and-drop requires these handlers -
handleDragStart(chain.id, idx)} - onDragOver={handleDragOver} - onDrop={() => handleDrop(chain.id, idx)} - onDragEnd={() => setDraggedItem(null)} - onClick={() => copyToClipboard(url)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") copyToClipboard(url); - }} - > - {idx + 1} - - {getRpcTagLabel(url)} - - -
- ))} -
-
- ); - })()} +
+
+
+ {t("superUser.persistentCache.label")} +
+
+ {t("superUser.persistentCache.usage", { + used: `${(persistentCacheBytes / (1024 * 1024)).toFixed(2)} MB`, + limit: `${settings.persistentCacheSizeMB ?? 10} MB`, + })} +
+
+
- )} -
- ); - })} -
+
+ )} +
+
+ )}
diff --git a/src/styles/styles.css b/src/styles/styles.css index 92d7e12e..9f5d98bd 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -4655,6 +4655,129 @@ code { text-align: center; } +.settings-tabs-shell { + margin-bottom: 20px; +} + +.settings-tabs { + display: flex; + gap: 8px; + padding: 4px; + background: var(--color-primary-alpha-6); + border-radius: 12px; + border: 1px solid var(--color-primary-alpha-10); + min-height: 52px; +} + +.settings-tab { + flex: 1; + padding: 10px 14px; + border: none; + border-radius: 8px; + color: var(--text-primary); + font-family: "Outfit", sans-serif; + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; + background: transparent; +} + +.settings-tab:hover { + background: var(--color-primary-alpha-10); + color: var(--color-primary); +} + +.settings-tab.active { + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-muted) 100%); + color: white; + box-shadow: 0 2px 8px var(--color-primary-alpha-30); +} + +.settings-tabs-mobile { + display: none; + position: relative; +} + +.settings-tabs-mobile-select { + width: 100%; + padding: 12px 40px 12px 16px; + font-size: 0.95rem; + font-weight: 600; + font-family: "Outfit", sans-serif; + color: white; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-muted) 100%); + border: none; + border-radius: 10px; + cursor: pointer; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + box-shadow: 0 2px 8px var(--color-primary-alpha-30); +} + +.settings-tabs-mobile-arrow { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + color: white; + font-size: 0.75rem; + pointer-events: none; +} + +.settings-tab-panel-wrap { + min-height: 280px; +} + +.settings-tab-panel { + animation: settingsTabFade 0.18s ease; +} + +@keyframes settingsTabFade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.settings-tab-grid { + align-items: start; +} + +.settings-autosave-row { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.settings-autosave-pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 6px 10px; + font-size: 0.75rem; + font-weight: 600; + border: 1px solid var(--color-primary-alpha-20); + background: var(--color-primary-alpha-6); + color: var(--text-secondary); + min-width: 130px; + justify-content: center; +} + +.settings-autosave-pill.saving { + color: var(--color-primary); + border-color: var(--color-primary-alpha-40); +} + +.settings-autosave-pill.saved { + color: #16a34a; + border-color: rgba(34, 197, 94, 0.4); + background: rgba(34, 197, 94, 0.08); +} + .settings-top-row { display: grid; grid-template-columns: 1fr 1fr; @@ -4674,6 +4797,23 @@ code { .settings-grid { grid-template-columns: 1fr; } + + .settings-tabs-mobile { + display: block; + margin-bottom: 12px; + } + + .settings-tabs { + display: none; + } + + .settings-autosave-row { + justify-content: flex-start; + } + + .settings-autosave-pill { + min-width: auto; + } } .settings-success-message { From 38710ecc7bc7b46758b210372ecc82133d2a5e88 Mon Sep 17 00:00:00 2001 From: Jose Aloha Date: Thu, 12 Mar 2026 11:59:18 -0300 Subject: [PATCH 29/43] fix(settings): require manual save on providers tab --- src/components/pages/settings/index.tsx | 73 ++++++++++++++++++++++--- src/styles/styles.css | 7 +++ 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 0b13b2a7..bd2e06fd 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -340,12 +340,25 @@ const Settings: React.FC = () => { } }, [activeTab, location.hash, location.pathname, location.search, navigate]); + const currentDraft = useMemo( + () => serializeDraft(localRpc, localApiKeys), + [localApiKeys, localRpc, serializeDraft], + ); + const handleTabChange = useCallback( (tab: SettingsTab) => { if (tab === activeTab) { return; } + if ( + activeTab !== "providers" && + tab === "providers" && + currentDraft !== lastSavedDraftRef.current + ) { + persistConfiguration(localRpc, localApiKeys, { silent: true }); + } + setActiveTab(tab); localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, tab); @@ -361,12 +374,19 @@ const Settings: React.FC = () => { { replace: true }, ); }, - [activeTab, location.pathname, location.search, navigate], + [ + activeTab, + currentDraft, + localApiKeys, + localRpc, + location.pathname, + location.search, + navigate, + persistConfiguration, + ], ); useEffect(() => { - const currentDraft = serializeDraft(localRpc, localApiKeys); - if (initialRenderRef.current) { initialRenderRef.current = false; lastSavedDraftRef.current = currentDraft; @@ -377,6 +397,15 @@ const Settings: React.FC = () => { return; } + // Providers tab uses explicit save action to avoid auto-save loops while typing API keys. + if (activeTab === "providers") { + if (saveTimerRef.current) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + return; + } + setAutoSaveState("saving"); if (saveTimerRef.current) { @@ -393,14 +422,14 @@ const Settings: React.FC = () => { window.clearTimeout(saveTimerRef.current); } }; - }, [localApiKeys, localRpc, persistConfiguration, serializeDraft]); + }, [activeTab, currentDraft, localApiKeys, localRpc, persistConfiguration]); useEffect(() => { return () => { if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); - const currentDraft = serializeDraft(localRpc, localApiKeys); - if (currentDraft !== lastSavedDraftRef.current) { + + if (activeTab !== "providers" && currentDraft !== lastSavedDraftRef.current) { persistConfiguration(localRpc, localApiKeys, { silent: true }); } } @@ -409,7 +438,7 @@ const Settings: React.FC = () => { window.clearTimeout(savedHintTimerRef.current); } }; - }, [localApiKeys, localRpc, persistConfiguration, serializeDraft]); + }, [activeTab, currentDraft, localApiKeys, localRpc, persistConfiguration]); const updateField = useCallback((networkId: string, value: string) => { setLocalRpc((prev) => ({ ...prev, [networkId]: value })); @@ -707,6 +736,19 @@ const Settings: React.FC = () => { ? "Saved" : "Auto-save enabled"; + const hasUnsavedChanges = currentDraft !== lastSavedDraftRef.current; + const providersSaveMessage = + autoSaveState === "saving" + ? "Saving…" + : hasUnsavedChanges + ? "Unsaved changes" + : "All changes saved"; + + const handleSaveProviders = useCallback(() => { + setAutoSaveState("saving"); + persistConfiguration(localRpc, localApiKeys); + }, [localApiKeys, localRpc, persistConfiguration]); + return ( <> {cacheCleared && ( @@ -831,11 +873,24 @@ const Settings: React.FC = () => { aria-labelledby="settings-tab-providers" >
- - {autoSaveMessage} + + {providersSaveMessage}
+
+ +
+

🔑 {t("apiKeys.title")}

diff --git a/src/styles/styles.css b/src/styles/styles.css index 9f5d98bd..a085e14a 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -5204,6 +5204,13 @@ code { box-shadow: 0 6px 16px var(--color-primary-alpha-40); } +.settings-save-button:disabled { + cursor: not-allowed; + opacity: 0.6; + transform: none; + box-shadow: none; +} + /* Sticky Save Button Container */ /* Save Section */ .settings-save-section { From bd0336403a588ff0d42fc0f141f8a39d0d9559ec Mon Sep 17 00:00:00 2001 From: Jose Aloha Date: Thu, 12 Mar 2026 12:16:15 -0300 Subject: [PATCH 30/43] fix(settings): save providers config on first click and align status row --- src/components/pages/settings/index.tsx | 7 ++----- src/styles/responsive.css | 9 +++++++++ src/styles/styles.css | 12 ++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index bd2e06fd..e2ccc3d9 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -257,7 +257,7 @@ const Settings: React.FC = () => { }); setRpcUrls(parsedRpc); - lastSavedDraftRef.current = serializeDraft(draftRpc, draftApiKeys); + lastSavedDraftRef.current = serializeDraft(parsedRpc, draftApiKeys); if (!options?.silent) { setAutoSaveState("saved"); @@ -872,15 +872,12 @@ const Settings: React.FC = () => { role="tabpanel" aria-labelledby="settings-tab-providers" > -
+
{providersSaveMessage} -
- -
)} -
+
{t("apiKeys.promptVersion.label")} diff --git a/src/styles/responsive.css b/src/styles/responsive.css index f17262b4..6255fc25 100644 --- a/src/styles/responsive.css +++ b/src/styles/responsive.css @@ -1410,12 +1410,19 @@ } .settings-providers-save-row { - flex-wrap: wrap; - justify-content: flex-start; + grid-template-columns: 1fr; + gap: 10px; } .settings-providers-save-row .settings-save-button { + grid-column: 1; width: auto; + justify-self: center; + } + + .settings-providers-save-row .settings-autosave-pill { + grid-column: 1; + justify-self: end; } } diff --git a/src/styles/styles.css b/src/styles/styles.css index 40fdc74c..3f4a748b 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -4754,13 +4754,22 @@ code { } .settings-providers-save-row { - display: flex; - justify-content: flex-end; + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; - gap: 12px; margin-bottom: 12px; } +.settings-providers-save-row .settings-save-button { + grid-column: 2; + justify-self: center; +} + +.settings-providers-save-row .settings-autosave-pill { + grid-column: 3; + justify-self: end; +} + .settings-autosave-pill { display: inline-flex; align-items: center; @@ -5326,6 +5335,10 @@ code { margin-top: 12px; } +.settings-prompt-version-item { + margin-top: 20px; +} + /* Toast Notifications */ .settings-toast-container { position: fixed; From 2c0391c5468cd11c493368f634b59a037f5e67ea Mon Sep 17 00:00:00 2001 From: Jose Aloha Date: Thu, 12 Mar 2026 12:29:19 -0300 Subject: [PATCH 32/43] fix(settings): keep providers save button centered with right-aligned status --- src/styles/responsive.css | 2 +- src/styles/styles.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/styles/responsive.css b/src/styles/responsive.css index 6255fc25..5b187283 100644 --- a/src/styles/responsive.css +++ b/src/styles/responsive.css @@ -1410,7 +1410,7 @@ } .settings-providers-save-row { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); gap: 10px; } diff --git a/src/styles/styles.css b/src/styles/styles.css index 3f4a748b..838100a9 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -4755,7 +4755,7 @@ code { .settings-providers-save-row { display: grid; - grid-template-columns: 1fr auto 1fr; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; margin-bottom: 12px; } From 677b1ca8b272c7b3f350472cd9d90866919d9e0d Mon Sep 17 00:00:00 2001 From: Jose Aloha Date: Thu, 12 Mar 2026 12:51:23 -0300 Subject: [PATCH 33/43] fix(settings): align providers save button and status on one desktop row --- src/styles/responsive.css | 7 +++++-- src/styles/styles.css | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/styles/responsive.css b/src/styles/responsive.css index 5b187283..c89cd844 100644 --- a/src/styles/responsive.css +++ b/src/styles/responsive.css @@ -1411,17 +1411,20 @@ .settings-providers-save-row { grid-template-columns: minmax(0, 1fr); + grid-template-areas: + "status" + "save"; gap: 10px; } .settings-providers-save-row .settings-save-button { - grid-column: 1; + grid-area: save; width: auto; justify-self: center; } .settings-providers-save-row .settings-autosave-pill { - grid-column: 1; + grid-area: status; justify-self: end; } } diff --git a/src/styles/styles.css b/src/styles/styles.css index 838100a9..b3766ce7 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -4756,17 +4756,18 @@ code { .settings-providers-save-row { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + grid-template-areas: ". save status"; align-items: center; margin-bottom: 12px; } .settings-providers-save-row .settings-save-button { - grid-column: 2; + grid-area: save; justify-self: center; } .settings-providers-save-row .settings-autosave-pill { - grid-column: 3; + grid-area: status; justify-self: end; } From 2c0eae885ff6d66c0223542151512a9a9249ee69 Mon Sep 17 00:00:00 2001 From: Jose Aloha Date: Thu, 12 Mar 2026 13:18:58 -0300 Subject: [PATCH 34/43] fix(settings): disable autosave indicators in providers tab --- src/components/pages/settings/index.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 8f38939d..3afcf822 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -403,6 +403,9 @@ const Settings: React.FC = () => { window.clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } + if (autoSaveState === "saving") { + setAutoSaveState("idle"); + } return; } @@ -422,7 +425,7 @@ const Settings: React.FC = () => { window.clearTimeout(saveTimerRef.current); } }; - }, [activeTab, currentDraft, localApiKeys, localRpc, persistConfiguration]); + }, [activeTab, autoSaveState, currentDraft, localApiKeys, localRpc, persistConfiguration]); useEffect(() => { return () => { @@ -737,12 +740,7 @@ const Settings: React.FC = () => { : "Auto-save enabled"; const hasUnsavedChanges = currentDraft !== lastSavedDraftRef.current; - const providersSaveMessage = - autoSaveState === "saving" - ? "Saving…" - : hasUnsavedChanges - ? "Unsaved changes" - : "All changes saved"; + const providersSaveMessage = hasUnsavedChanges ? "Unsaved changes" : "All changes saved"; const handleSaveProviders = useCallback(() => { setAutoSaveState("saving"); @@ -874,7 +872,7 @@ const Settings: React.FC = () => { >
{providersSaveMessage} @@ -882,7 +880,7 @@ const Settings: React.FC = () => { type="button" className="settings-save-button" onClick={handleSaveProviders} - disabled={!hasUnsavedChanges || autoSaveState === "saving"} + disabled={!hasUnsavedChanges} > {t("saveConfiguration")} From 69a1a978bf691bc51884ca27b84fa1f4be33bf58 Mon Sep 17 00:00:00 2001 From: Jose Aloha Date: Thu, 12 Mar 2026 13:19:36 -0300 Subject: [PATCH 35/43] fix(settings): address rpc tag static interaction a11y lint --- src/components/pages/settings/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 3afcf822..159d486c 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -1349,10 +1349,13 @@ const Settings: React.FC = () => {
{rpcArray.map((url, idx) => ( + // biome-ignore lint/a11y/useSemanticElements: Tag container needs drag/drop behavior and contains a dedicated delete button.
handleDragStart(chain.id, idx)} onDragOver={handleDragOver} From 78cfc0af4ba5da767738b338a1dcbdd50e9c61c2 Mon Sep 17 00:00:00 2001 From: Jose Aloha Date: Thu, 12 Mar 2026 16:20:21 -0300 Subject: [PATCH 36/43] fix(settings): remove duplicate tab hash and group provider keys --- src/components/pages/settings/index.tsx | 16 ++++++++-------- src/styles/styles.css | 9 +++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 159d486c..0561b9f8 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -297,18 +297,16 @@ const Settings: React.FC = () => { }); }, [settings.apiKeys]); - // Hydrate active tab from URL (query/hash) or previous session. + // Hydrate active tab from URL query or previous session. + // We intentionally use a single source of truth (query param) to avoid duplicate `#...#...` URLs. useEffect(() => { const params = new URLSearchParams(location.search); const tabFromQuery = params.get("tab"); - const tabFromHash = location.hash ? location.hash.replace(/^#/, "") : null; let nextTab: SettingsTab | null = null; if (isValidSettingsTab(tabFromQuery)) { nextTab = tabFromQuery; - } else if (isValidSettingsTab(tabFromHash)) { - nextTab = tabFromHash; } else { const stored = localStorage.getItem(SETTINGS_ACTIVE_TAB_KEY); if (isValidSettingsTab(stored)) { @@ -326,19 +324,18 @@ const Settings: React.FC = () => { localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, nextTab); - if (!isValidSettingsTab(tabFromQuery) || tabFromQuery !== nextTab) { + if (tabFromQuery !== nextTab) { const updatedParams = new URLSearchParams(location.search); updatedParams.set("tab", nextTab); navigate( { pathname: location.pathname, search: `?${updatedParams.toString()}`, - hash: `#${nextTab}`, }, { replace: true }, ); } - }, [activeTab, location.hash, location.pathname, location.search, navigate]); + }, [activeTab, location.pathname, location.search, navigate]); const currentDraft = useMemo( () => serializeDraft(localRpc, localApiKeys), @@ -369,7 +366,6 @@ const Settings: React.FC = () => { { pathname: location.pathname, search: `?${updatedParams.toString()}`, - hash: `#${tab}`, }, { replace: true }, ); @@ -891,6 +887,8 @@ const Settings: React.FC = () => {

🔑 {t("apiKeys.title")}

{t("apiKeys.description")}

+

RPC API Keys

+
{t("apiKeys.infura.name")} @@ -965,6 +963,8 @@ const Settings: React.FC = () => {
+

Verifications API Keys

+
{t("apiKeys.etherscan.name")} diff --git a/src/styles/styles.css b/src/styles/styles.css index b3766ce7..9d8d3a98 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -5260,6 +5260,15 @@ code { margin-top: 0; } +.settings-api-key-group-title { + margin: 16px 0 4px; + font-size: 0.9rem; + font-weight: 700; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.02em; +} + .settings-api-key-header { display: flex; justify-content: space-between; From 60d0cf82b62e58888b2d149d166b4797db872f64 Mon Sep 17 00:00:00 2001 From: Jose Aloha Date: Thu, 12 Mar 2026 16:21:56 -0300 Subject: [PATCH 37/43] fix(settings): normalize tabs URL and split API key sections --- src/components/pages/settings/index.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 0561b9f8..d9b5f697 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -324,18 +324,21 @@ const Settings: React.FC = () => { localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, nextTab); - if (tabFromQuery !== nextTab) { + const hasHashFragment = Boolean(location.hash); + + if (tabFromQuery !== nextTab || hasHashFragment) { const updatedParams = new URLSearchParams(location.search); updatedParams.set("tab", nextTab); navigate( { pathname: location.pathname, search: `?${updatedParams.toString()}`, + hash: "", }, { replace: true }, ); } - }, [activeTab, location.pathname, location.search, navigate]); + }, [activeTab, location.hash, location.pathname, location.search, navigate]); const currentDraft = useMemo( () => serializeDraft(localRpc, localApiKeys), @@ -366,6 +369,7 @@ const Settings: React.FC = () => { { pathname: location.pathname, search: `?${updatedParams.toString()}`, + hash: "", }, { replace: true }, ); @@ -884,11 +888,9 @@ const Settings: React.FC = () => {
-

🔑 {t("apiKeys.title")}

+

🔑 RPC API Keys

{t("apiKeys.description")}

-

RPC API Keys

-
{t("apiKeys.infura.name")} @@ -962,8 +964,13 @@ const Settings: React.FC = () => {
+
-

Verifications API Keys

+
+

🧾 Verifications API Keys

+

+ API keys used for source-code verification services. +

From d0d216b8f755b58a46d52367670dc46b7f325ad4 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 16 Mar 2026 10:03:21 -0300 Subject: [PATCH 38/43] feat(evm): display EIP-4844 blob metadata on blocks and transactions Wire through blob gas fields from RPC responses to the UI. Block pages now show Blob Gas Used, Excess Blob Gas, and derived Blob Count for post-Dencun blocks. Transaction pages show Max Fee Per Blob Gas, Blob Gas Price, Blob Gas Used, Blob Count, and Blob Versioned Hashes for Type 3 transactions, with purple accent styling. Closes #300 --- .../pages/evm/block/BlockDisplay.tsx | 26 +++++++++ .../pages/evm/tx/TransactionDisplay.tsx | 56 +++++++++++++++++++ src/locales/en/block.json | 3 + src/locales/en/transaction.json | 6 ++ src/locales/es/block.json | 3 + src/locales/es/transaction.json | 6 ++ src/locales/ja/block.json | 3 + src/locales/ja/transaction.json | 6 ++ src/locales/pt-BR/block.json | 3 + src/locales/pt-BR/transaction.json | 6 ++ src/locales/zh/block.json | 3 + src/locales/zh/transaction.json | 6 ++ .../adapters/ArbitrumAdapter/utils.ts | 12 ++++ src/services/adapters/BNBAdapter/utils.ts | 16 +++++- src/services/adapters/BaseAdapter/utils.ts | 16 +++++- src/services/adapters/EVMAdapter/utils.ts | 16 +++++- .../adapters/OptimismAdapter/utils.ts | 16 +++++- src/services/adapters/PolygonAdapter/utils.ts | 16 +++++- src/styles/components.css | 9 +++ src/types/index.ts | 4 ++ 20 files changed, 222 insertions(+), 10 deletions(-) diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index a6546577..c8de7882 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -250,6 +250,14 @@ const BlockDisplay: React.FC = React.memo(
)} + + {/* Blob Gas Used (EIP-4844) */} + {block.blobGasUsed && Number(block.blobGasUsed) > 0 && ( +
+ {t("blobGasUsed")} + {Number(block.blobGasUsed).toLocaleString()} +
+ )}
{/* Right Column */} @@ -287,6 +295,24 @@ const BlockDisplay: React.FC = React.memo(
)} + {/* Blob fields continued (EIP-4844) */} + {block.blobGasUsed && Number(block.blobGasUsed) > 0 && ( + <> +
+ {t("excessBlobGas")} + + {Number(block.excessBlobGas).toLocaleString()} + +
+
+ {t("blobCount")} + + {Math.floor(Number(block.blobGasUsed) / 131072)} + +
+ + )} + {/* Arbitrum-specific fields */} {isArbitrumBlock(block) && ( <> diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index 11042470..2ac7a1d0 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -775,6 +775,62 @@ const TransactionDisplay: React.FC = React.memo(
)} + {/* EIP-4844 Blob Transaction fields */} + {transaction.type === "0x3" && ( +
+
+ {transaction.maxFeePerBlobGas && ( +
+ {t("maxFeePerBlobGas")} + {formatGwei(transaction.maxFeePerBlobGas)} +
+ )} + {transaction.receipt?.blobGasPrice && ( +
+ {t("blobGasPrice")} + + {formatGwei(transaction.receipt.blobGasPrice)} + +
+ )} +
+
+ {transaction.receipt?.blobGasUsed && ( +
+ {t("blobGasUsed")} + + {Number(transaction.receipt.blobGasUsed).toLocaleString()} + +
+ )} + {transaction.blobVersionedHashes && + transaction.blobVersionedHashes.length > 0 && ( +
+ {t("blobCount")} + {transaction.blobVersionedHashes.length} +
+ )} +
+
+ )} + + {/* Blob Versioned Hashes */} + {transaction.type === "0x3" && + transaction.blobVersionedHashes && + transaction.blobVersionedHashes.length > 0 && ( +
+ {t("blobVersionedHashes")} + + {transaction.blobVersionedHashes.map((hash) => ( +
+ + +
+ ))} +
+
+ )} + {/* Event Logs + Input Data are now always in TX Analyser */}
diff --git a/src/locales/en/block.json b/src/locales/en/block.json index c66f2ef2..012ab8f8 100644 --- a/src/locales/en/block.json +++ b/src/locales/en/block.json @@ -10,6 +10,9 @@ "gasLimit": "Gas Limit:", "baseFeePerGas": "Base Fee Per Gas:", "burntFees": "Burnt Fees", + "blobGasUsed": "Blob Gas Used:", + "excessBlobGas": "Excess Blob Gas:", + "blobCount": "Blob Count:", "extraData": "Extra Data", "difficulty": "Difficulty", "totalDifficulty": "Total Difficulty", diff --git a/src/locales/en/transaction.json b/src/locales/en/transaction.json index 535e54be..7d1e86d8 100644 --- a/src/locales/en/transaction.json +++ b/src/locales/en/transaction.json @@ -43,6 +43,12 @@ "l1GasPrice": "L1 Gas Price:", "l1GasUsed": "L1 Gas Used:", "l1FeeScalar": "L1 Fee Scalar:", + "maxFeePerBlobGas": "Max Fee Per Blob Gas:", + "blobGasPrice": "Blob Gas Price:", + "blobGasUsed": "Blob Gas Used:", + "blobCount": "Blob Count:", + "blobVersionedHashes": "Blob Versioned Hashes:", + "blobTransaction": "Blob Transaction (EIP-4844)", "pending": "Pending", "logsAddress": "Address", "logsDecoded": "Decoded", diff --git a/src/locales/es/block.json b/src/locales/es/block.json index 00fdb297..1e6ac57a 100644 --- a/src/locales/es/block.json +++ b/src/locales/es/block.json @@ -10,6 +10,9 @@ "gasLimit": "Límite de gas:", "baseFeePerGas": "Fee base por gas:", "burntFees": "Fees quemados", + "blobGasUsed": "Gas de Blob Usado:", + "excessBlobGas": "Gas de Blob Excedente:", + "blobCount": "Cantidad de Blobs:", "extraData": "Datos extra", "difficulty": "Dificultad", "totalDifficulty": "Dificultad total", diff --git a/src/locales/es/transaction.json b/src/locales/es/transaction.json index 144eb31d..5a620b35 100644 --- a/src/locales/es/transaction.json +++ b/src/locales/es/transaction.json @@ -43,6 +43,12 @@ "l1GasPrice": "Gas Price L1:", "l1GasUsed": "Gas Usado L1:", "l1FeeScalar": "L1 Fee Scalar:", + "maxFeePerBlobGas": "Fee Máximo por Blob Gas:", + "blobGasPrice": "Precio de Blob Gas:", + "blobGasUsed": "Blob Gas Usado:", + "blobCount": "Cantidad de Blobs:", + "blobVersionedHashes": "Hashes Versionados de Blob:", + "blobTransaction": "Transacción Blob (EIP-4844)", "pending": "Pendiente", "logsAddress": "Dirección", "logsDecoded": "Decodificado", diff --git a/src/locales/ja/block.json b/src/locales/ja/block.json index f81d2887..64028b44 100644 --- a/src/locales/ja/block.json +++ b/src/locales/ja/block.json @@ -10,6 +10,9 @@ "gasLimit": "ガスリミット:", "baseFeePerGas": "ガスあたりの基本手数料:", "burntFees": "バーン手数料", + "blobGasUsed": "Blob Gas 使用量:", + "excessBlobGas": "超過 Blob Gas:", + "blobCount": "Blob 数:", "extraData": "追加データ", "difficulty": "難易度", "totalDifficulty": "総難易度", diff --git a/src/locales/ja/transaction.json b/src/locales/ja/transaction.json index 3c3e0ce7..8a8f169c 100644 --- a/src/locales/ja/transaction.json +++ b/src/locales/ja/transaction.json @@ -43,6 +43,12 @@ "l1GasPrice": "L1ガス価格:", "l1GasUsed": "L1使用ガス:", "l1FeeScalar": "L1手数料スカラー:", + "maxFeePerBlobGas": "Blob Gas あたりの最大手数料:", + "blobGasPrice": "Blob Gas 価格:", + "blobGasUsed": "Blob Gas 使用量:", + "blobCount": "Blob 数:", + "blobVersionedHashes": "Blob バージョンハッシュ:", + "blobTransaction": "Blob トランザクション (EIP-4844)", "pending": "保留中", "logsAddress": "アドレス", "logsDecoded": "デコード済み", diff --git a/src/locales/pt-BR/block.json b/src/locales/pt-BR/block.json index e932a4db..fffa2231 100644 --- a/src/locales/pt-BR/block.json +++ b/src/locales/pt-BR/block.json @@ -10,6 +10,9 @@ "gasLimit": "Limite de Gas:", "baseFeePerGas": "Taxa Base por Gas:", "burntFees": "Taxas Queimadas", + "blobGasUsed": "Gas de Blob Usado:", + "excessBlobGas": "Gas de Blob Excedente:", + "blobCount": "Contagem de Blobs:", "extraData": "Dados Extras", "difficulty": "Dificuldade", "totalDifficulty": "Dificuldade Total", diff --git a/src/locales/pt-BR/transaction.json b/src/locales/pt-BR/transaction.json index 6a581851..8eb42032 100644 --- a/src/locales/pt-BR/transaction.json +++ b/src/locales/pt-BR/transaction.json @@ -43,6 +43,12 @@ "l1GasPrice": "Preço do Gas L1:", "l1GasUsed": "Gas L1 Usado:", "l1FeeScalar": "Escalar de Taxa L1:", + "maxFeePerBlobGas": "Taxa Máxima por Blob Gas:", + "blobGasPrice": "Preço do Blob Gas:", + "blobGasUsed": "Blob Gas Usado:", + "blobCount": "Contagem de Blobs:", + "blobVersionedHashes": "Hashes Versionados de Blob:", + "blobTransaction": "Transação Blob (EIP-4844)", "pending": "Pendente", "logsAddress": "Endereço", "logsDecoded": "Decodificado", diff --git a/src/locales/zh/block.json b/src/locales/zh/block.json index cabf5034..e079fce9 100644 --- a/src/locales/zh/block.json +++ b/src/locales/zh/block.json @@ -10,6 +10,9 @@ "gasLimit": "Gas 上限:", "baseFeePerGas": "每 Gas 基础费用:", "burntFees": "已销毁费用", + "blobGasUsed": "Blob Gas 使用量:", + "excessBlobGas": "超额 Blob Gas:", + "blobCount": "Blob 数量:", "extraData": "额外数据", "difficulty": "难度", "totalDifficulty": "总难度", diff --git a/src/locales/zh/transaction.json b/src/locales/zh/transaction.json index 0dce1339..29bf431c 100644 --- a/src/locales/zh/transaction.json +++ b/src/locales/zh/transaction.json @@ -43,6 +43,12 @@ "l1GasPrice": "L1 Gas 价格:", "l1GasUsed": "L1 已用 Gas:", "l1FeeScalar": "L1 费用系数:", + "maxFeePerBlobGas": "每 Blob Gas 最大费用:", + "blobGasPrice": "Blob Gas 价格:", + "blobGasUsed": "Blob Gas 使用量:", + "blobCount": "Blob 数量:", + "blobVersionedHashes": "Blob 版本哈希:", + "blobTransaction": "Blob 交易 (EIP-4844)", "pending": "待处理", "logsAddress": "地址", "logsDecoded": "已解码", diff --git a/src/services/adapters/ArbitrumAdapter/utils.ts b/src/services/adapters/ArbitrumAdapter/utils.ts index f7cafdde..3d8ceae6 100644 --- a/src/services/adapters/ArbitrumAdapter/utils.ts +++ b/src/services/adapters/ArbitrumAdapter/utils.ts @@ -65,6 +65,12 @@ export function transformArbitrumTransactionToTransaction( gasPrice: rpcTx.gasPrice ? BigInt(rpcTx.gasPrice).toString() : "0", maxFeePerGas: undefined, maxPriorityFeePerGas: undefined, + maxFeePerBlobGas: (rpcTx as unknown as Record).maxFeePerBlobGas + ? BigInt((rpcTx as unknown as Record).maxFeePerBlobGas as string).toString() + : undefined, + blobVersionedHashes: (rpcTx as unknown as Record).blobVersionedHashes as + | string[] + | undefined, nonce: rpcTx.nonce ? parseInt(rpcTx.nonce, 16).toString() : "0", data: rpcTx.input, blockNumber: rpcTx.blockNumber ? parseInt(rpcTx.blockNumber, 16).toString() : "0", @@ -96,6 +102,12 @@ export function transformArbitrumTransactionToTransaction( transactionHash: receipt.transactionHash, transactionIndex: parseInt(receipt.transactionIndex, 16).toString(), type: receipt.type, + blobGasUsed: (receipt as unknown as Record).blobGasUsed + ? BigInt((receipt as unknown as Record).blobGasUsed as string).toString() + : undefined, + blobGasPrice: (receipt as unknown as Record).blobGasPrice + ? BigInt((receipt as unknown as Record).blobGasPrice as string).toString() + : undefined, }; transaction.receipt = arbitrumReceipt; } diff --git a/src/services/adapters/BNBAdapter/utils.ts b/src/services/adapters/BNBAdapter/utils.ts index b28fa9b9..4a6738d7 100644 --- a/src/services/adapters/BNBAdapter/utils.ts +++ b/src/services/adapters/BNBAdapter/utils.ts @@ -38,8 +38,14 @@ export function transformBNBBlockToBlock(rpcBlock: BNBBlock): Block { totalDifficulty: rpcBlock.totalDifficulty ? BigInt(rpcBlock.totalDifficulty).toString() : BigInt(rpcBlock.difficulty).toString(), - blobGasUsed: "", - excessBlobGas: "", + blobGasUsed: (() => { + const val = (rpcBlock as unknown as Record).blobGasUsed; + return typeof val === "string" && val !== "0x0" && val !== "0x" ? BigInt(val).toString() : ""; + })(), + excessBlobGas: (() => { + const val = (rpcBlock as unknown as Record).excessBlobGas; + return typeof val === "string" && val !== "0x0" && val !== "0x" ? BigInt(val).toString() : ""; + })(), withdrawalsRoot: rpcBlock.withdrawalsRoot || "", withdrawals: rpcBlock.withdrawals ? rpcBlock.withdrawals.map((w) => ({ @@ -71,6 +77,10 @@ export function transformBNBTransactionToTransaction( maxPriorityFeePerGas: rpcTx.maxPriorityFeePerGas ? BigInt(rpcTx.maxPriorityFeePerGas).toString() : undefined, + maxFeePerBlobGas: rpcTx.maxFeePerBlobGas + ? BigInt(rpcTx.maxFeePerBlobGas).toString() + : undefined, + blobVersionedHashes: rpcTx.blobVersionedHashes, nonce: rpcTx.nonce ? parseInt(rpcTx.nonce, 16).toString() : "0", data: rpcTx.input, blockNumber: rpcTx.blockNumber ? parseInt(rpcTx.blockNumber, 16).toString() : "0", @@ -102,6 +112,8 @@ export function transformBNBTransactionToTransaction( transactionHash: receipt.transactionHash, transactionIndex: parseInt(receipt.transactionIndex, 16).toString(), type: receipt.type, + blobGasUsed: receipt.blobGasUsed ? BigInt(receipt.blobGasUsed).toString() : undefined, + blobGasPrice: receipt.blobGasPrice ? BigInt(receipt.blobGasPrice).toString() : undefined, }; } diff --git a/src/services/adapters/BaseAdapter/utils.ts b/src/services/adapters/BaseAdapter/utils.ts index fd078187..dbf246d8 100644 --- a/src/services/adapters/BaseAdapter/utils.ts +++ b/src/services/adapters/BaseAdapter/utils.ts @@ -42,8 +42,14 @@ export function transformBaseBlockToBlock(rpcBlock: BaseBlock): Block { totalDifficulty: rpcBlock.totalDifficulty ? BigInt(rpcBlock.totalDifficulty).toString() : BigInt(rpcBlock.difficulty).toString(), - blobGasUsed: "", - excessBlobGas: "", + blobGasUsed: (() => { + const val = (rpcBlock as unknown as Record).blobGasUsed; + return typeof val === "string" && val !== "0x0" && val !== "0x" ? BigInt(val).toString() : ""; + })(), + excessBlobGas: (() => { + const val = (rpcBlock as unknown as Record).excessBlobGas; + return typeof val === "string" && val !== "0x0" && val !== "0x" ? BigInt(val).toString() : ""; + })(), withdrawalsRoot: rpcBlock.withdrawalsRoot || "", withdrawals: rpcBlock.withdrawals ? rpcBlock.withdrawals.map((w) => ({ @@ -75,6 +81,10 @@ export function transformBaseTransactionToTransaction( maxPriorityFeePerGas: rpcTx.maxPriorityFeePerGas ? BigInt(rpcTx.maxPriorityFeePerGas).toString() : undefined, + maxFeePerBlobGas: rpcTx.maxFeePerBlobGas + ? BigInt(rpcTx.maxFeePerBlobGas).toString() + : undefined, + blobVersionedHashes: rpcTx.blobVersionedHashes, nonce: rpcTx.nonce ? parseInt(rpcTx.nonce, 16).toString() : "0", data: rpcTx.input, blockNumber: rpcTx.blockNumber ? parseInt(rpcTx.blockNumber, 16).toString() : "0", @@ -106,6 +116,8 @@ export function transformBaseTransactionToTransaction( transactionHash: receipt.transactionHash, transactionIndex: parseInt(receipt.transactionIndex, 16).toString(), type: receipt.type, + blobGasUsed: receipt.blobGasUsed ? BigInt(receipt.blobGasUsed).toString() : undefined, + blobGasPrice: receipt.blobGasPrice ? BigInt(receipt.blobGasPrice).toString() : undefined, // Base-specific L1 fee fields (OP Stack compatible) l1Fee: receipt.l1Fee || "0", l1GasPrice: receipt.l1GasPrice || "0", diff --git a/src/services/adapters/EVMAdapter/utils.ts b/src/services/adapters/EVMAdapter/utils.ts index d4f2f4f4..eecec543 100644 --- a/src/services/adapters/EVMAdapter/utils.ts +++ b/src/services/adapters/EVMAdapter/utils.ts @@ -36,8 +36,14 @@ export function transformRPCBlockToBlock(rpcBlock: EthBlock): Block { totalDifficulty: rpcBlock.totalDifficulty ? BigInt(rpcBlock.totalDifficulty).toString() : BigInt(rpcBlock.difficulty).toString(), - blobGasUsed: "", - excessBlobGas: "", + blobGasUsed: (() => { + const val = (rpcBlock as unknown as Record).blobGasUsed; + return typeof val === "string" && val !== "0x0" && val !== "0x" ? BigInt(val).toString() : ""; + })(), + excessBlobGas: (() => { + const val = (rpcBlock as unknown as Record).excessBlobGas; + return typeof val === "string" && val !== "0x0" && val !== "0x" ? BigInt(val).toString() : ""; + })(), withdrawalsRoot: rpcBlock.withdrawalsRoot || "", withdrawals: rpcBlock.withdrawals || [], }; @@ -62,6 +68,10 @@ export function transformRPCTransactionToTransaction( maxPriorityFeePerGas: rpcTx.maxPriorityFeePerGas ? BigInt(rpcTx.maxPriorityFeePerGas).toString() : undefined, + maxFeePerBlobGas: rpcTx.maxFeePerBlobGas + ? BigInt(rpcTx.maxFeePerBlobGas).toString() + : undefined, + blobVersionedHashes: rpcTx.blobVersionedHashes, nonce: rpcTx.nonce ? parseInt(rpcTx.nonce, 16).toString() : "0", data: rpcTx.input, blockNumber: rpcTx.blockNumber ? parseInt(rpcTx.blockNumber, 16).toString() : "0", @@ -93,6 +103,8 @@ export function transformRPCTransactionToTransaction( transactionHash: receipt.transactionHash, transactionIndex: parseInt(receipt.transactionIndex, 16).toString(), type: receipt.type, + blobGasUsed: receipt.blobGasUsed ? BigInt(receipt.blobGasUsed).toString() : undefined, + blobGasPrice: receipt.blobGasPrice ? BigInt(receipt.blobGasPrice).toString() : undefined, }; } diff --git a/src/services/adapters/OptimismAdapter/utils.ts b/src/services/adapters/OptimismAdapter/utils.ts index 204238cf..e05fb694 100644 --- a/src/services/adapters/OptimismAdapter/utils.ts +++ b/src/services/adapters/OptimismAdapter/utils.ts @@ -42,8 +42,14 @@ export function transformOptimismBlockToBlock(rpcBlock: OptimismBlock): Block { totalDifficulty: rpcBlock.totalDifficulty ? BigInt(rpcBlock.totalDifficulty).toString() : BigInt(rpcBlock.difficulty).toString(), - blobGasUsed: "", - excessBlobGas: "", + blobGasUsed: (() => { + const val = (rpcBlock as unknown as Record).blobGasUsed; + return typeof val === "string" && val !== "0x0" && val !== "0x" ? BigInt(val).toString() : ""; + })(), + excessBlobGas: (() => { + const val = (rpcBlock as unknown as Record).excessBlobGas; + return typeof val === "string" && val !== "0x0" && val !== "0x" ? BigInt(val).toString() : ""; + })(), withdrawalsRoot: rpcBlock.withdrawalsRoot || "", withdrawals: rpcBlock.withdrawals ? rpcBlock.withdrawals.map((w) => ({ @@ -75,6 +81,10 @@ export function transformOptimismTransactionToTransaction( maxPriorityFeePerGas: rpcTx.maxPriorityFeePerGas ? BigInt(rpcTx.maxPriorityFeePerGas).toString() : undefined, + maxFeePerBlobGas: rpcTx.maxFeePerBlobGas + ? BigInt(rpcTx.maxFeePerBlobGas).toString() + : undefined, + blobVersionedHashes: rpcTx.blobVersionedHashes, nonce: rpcTx.nonce ? parseInt(rpcTx.nonce, 16).toString() : "0", data: rpcTx.input, blockNumber: rpcTx.blockNumber ? parseInt(rpcTx.blockNumber, 16).toString() : "0", @@ -106,6 +116,8 @@ export function transformOptimismTransactionToTransaction( transactionHash: receipt.transactionHash, transactionIndex: parseInt(receipt.transactionIndex, 16).toString(), type: receipt.type, + blobGasUsed: receipt.blobGasUsed ? BigInt(receipt.blobGasUsed).toString() : undefined, + blobGasPrice: receipt.blobGasPrice ? BigInt(receipt.blobGasPrice).toString() : undefined, // Optimism-specific L1 fee fields l1Fee: receipt.l1Fee || "0", l1GasPrice: receipt.l1GasPrice || "0", diff --git a/src/services/adapters/PolygonAdapter/utils.ts b/src/services/adapters/PolygonAdapter/utils.ts index f6cb1be3..a63f2bf7 100644 --- a/src/services/adapters/PolygonAdapter/utils.ts +++ b/src/services/adapters/PolygonAdapter/utils.ts @@ -40,8 +40,14 @@ export function transformPolygonBlockToBlock(rpcBlock: PolygonBlock): Block { totalDifficulty: rpcBlock.totalDifficulty ? BigInt(rpcBlock.totalDifficulty).toString() : BigInt(rpcBlock.difficulty).toString(), - blobGasUsed: "", - excessBlobGas: "", + blobGasUsed: (() => { + const val = (rpcBlock as unknown as Record).blobGasUsed; + return typeof val === "string" && val !== "0x0" && val !== "0x" ? BigInt(val).toString() : ""; + })(), + excessBlobGas: (() => { + const val = (rpcBlock as unknown as Record).excessBlobGas; + return typeof val === "string" && val !== "0x0" && val !== "0x" ? BigInt(val).toString() : ""; + })(), withdrawalsRoot: rpcBlock.withdrawalsRoot || "", withdrawals: rpcBlock.withdrawals || [], }; @@ -66,6 +72,10 @@ export function transformPolygonTransactionToTransaction( maxPriorityFeePerGas: rpcTx.maxPriorityFeePerGas ? BigInt(rpcTx.maxPriorityFeePerGas).toString() : undefined, + maxFeePerBlobGas: rpcTx.maxFeePerBlobGas + ? BigInt(rpcTx.maxFeePerBlobGas).toString() + : undefined, + blobVersionedHashes: rpcTx.blobVersionedHashes, nonce: rpcTx.nonce ? parseInt(rpcTx.nonce, 16).toString() : "0", data: rpcTx.input, blockNumber: rpcTx.blockNumber ? parseInt(rpcTx.blockNumber, 16).toString() : "0", @@ -97,6 +107,8 @@ export function transformPolygonTransactionToTransaction( transactionHash: receipt.transactionHash, transactionIndex: parseInt(receipt.transactionIndex, 16).toString(), type: receipt.type, + blobGasUsed: receipt.blobGasUsed ? BigInt(receipt.blobGasUsed).toString() : undefined, + blobGasPrice: receipt.blobGasPrice ? BigInt(receipt.blobGasPrice).toString() : undefined, }; } diff --git a/src/styles/components.css b/src/styles/components.css index 9928467a..afa56a31 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -164,6 +164,15 @@ color: #0052ff; } +.tx-row-blob { + background: rgba(139, 92, 246, 0.1); + border-left: 3px solid #8b5cf6; +} + +.tx-row-blob .tx-label { + color: #8b5cf6; +} + /* Block Display - Block navigation and details */ .block-height-value { font-weight: 600; diff --git a/src/types/index.ts b/src/types/index.ts index 90956b38..169da421 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -74,6 +74,8 @@ export interface Transaction { gasPrice: string; maxFeePerGas?: string; maxPriorityFeePerGas?: string; + maxFeePerBlobGas?: string; + blobVersionedHashes?: string[]; hash: string; nonce: string; to: string; @@ -108,6 +110,8 @@ export interface TransactionReceipt { transactionHash: string; transactionIndex: string; type: string; + blobGasUsed?: string; + blobGasPrice?: string; } export interface TransactionReceiptArbitrum extends TransactionReceipt { From 0286df9d73faba2a7e25205f7b62974f1ccc654c Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 16 Mar 2026 10:09:47 -0300 Subject: [PATCH 39/43] fix(address): recognize Sourcify V2 "match" value as verified The normalizeMatch() function only recognized "exact_match" and "perfect" as valid verification states, but Sourcify V2 API returns "match" for verified contracts. This caused all Sourcify-verified contracts (e.g. USDT) to display as "not verified". Fixes #312 --- src/hooks/useSourcify.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useSourcify.ts b/src/hooks/useSourcify.ts index 41efc29f..038f5be0 100644 --- a/src/hooks/useSourcify.ts +++ b/src/hooks/useSourcify.ts @@ -75,7 +75,7 @@ interface SourcifyV2Raw { function normalizeMatch(raw?: string): "perfect" | "partial" | null { if (!raw) return null; - if (raw === "exact_match" || raw === "perfect") return "perfect"; + if (raw === "match" || raw === "exact_match" || raw === "perfect") return "perfect"; if (raw.includes("partial")) return "partial"; return null; } From 0663699e4c1d7a38b2581575674c358174dbac07 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 16 Mar 2026 11:58:10 -0300 Subject: [PATCH 40/43] fix(ci): fail audit on moderate+ severity vulnerabilities The audit script was using || true which silenced all findings. Now npm audit will exit non-zero for moderate, high, and critical. --- scripts/audit.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/audit.sh b/scripts/audit.sh index 1da20e39..55924383 100755 --- a/scripts/audit.sh +++ b/scripts/audit.sh @@ -8,7 +8,7 @@ echo "Generating package-lock.json..." npm i --package-lock-only --silent echo "Running npm audit..." -npm audit "$@" || true +npm audit --audit-level=moderate "$@" echo "Cleaning up..." rm -f package-lock.json From a75a8bb9953fab9b38af8d93ea6f7a8bde7ec3b2 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 16 Mar 2026 12:23:07 -0300 Subject: [PATCH 41/43] fix(settings): prevent tab flicker, always show super user section, add spacing - Tab hydration useEffect re-ran on every tab change causing visible content refresh. Use a ref guard so hydration only runs once on mount. - Super user section in advanced tab is now always visible for all users. - Add spacing between cache limit and cache size items in super user section to match the cache & data section style. --- src/components/pages/settings/index.tsx | 117 ++++++++++++------------ 1 file changed, 59 insertions(+), 58 deletions(-) diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 435b8bda..956a0962 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -90,7 +90,7 @@ const Settings: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); const { rpcUrls, setRpcUrls } = useContext(AppContext); - const { settings, updateSettings, isSuperUser } = useSettings(); + const { settings, updateSettings } = useSettings(); const { enabledNetworks } = useNetworks(); const { isMetaMaskAvailable, isSupported, setAsDefaultExplorer } = useMetaMaskExplorer(); const [activeTab, setActiveTab] = useState("network"); @@ -300,9 +300,15 @@ const Settings: React.FC = () => { }); }, [settings.apiKeys]); - // Hydrate active tab from URL query or previous session. - // We intentionally use a single source of truth (query param) to avoid duplicate `#...#...` URLs. + // Hydrate active tab from URL query or previous session (mount only). + // handleTabChange owns all subsequent URL updates — the guard prevents re-running. + const tabHydratedRef = useRef(false); useEffect(() => { + if (tabHydratedRef.current) { + return; + } + tabHydratedRef.current = true; + const params = new URLSearchParams(location.search); const tabFromQuery = params.get("tab"); @@ -321,15 +327,10 @@ const Settings: React.FC = () => { return; } - if (nextTab !== activeTab) { - setActiveTab(nextTab); - } - + setActiveTab(nextTab); localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, nextTab); - const hasHashFragment = Boolean(location.hash); - - if (tabFromQuery !== nextTab || hasHashFragment) { + if (tabFromQuery !== nextTab || Boolean(location.hash)) { const updatedParams = new URLSearchParams(location.search); updatedParams.set("tab", nextTab); navigate( @@ -341,7 +342,7 @@ const Settings: React.FC = () => { { replace: true }, ); } - }, [activeTab, location.hash, location.pathname, location.search, navigate]); + }, [location.hash, location.pathname, location.search, navigate]); const currentDraft = useMemo( () => serializeDraft(localRpc, localApiKeys), @@ -1549,60 +1550,60 @@ const Settings: React.FC = () => {
- {isSuperUser && ( -
-

{t("superUser.title")}

-

{t("superUser.description")}

+
+

{t("superUser.title")}

+

{t("superUser.description")}

-
-
-
- {t("superUser.persistentCache.sizeLimit.label")} -
-
- {t("superUser.persistentCache.sizeLimit.description")} -
+
+
+
+ {t("superUser.persistentCache.sizeLimit.label")} +
+
+ {t("superUser.persistentCache.sizeLimit.description")}
-
+ +
-
-
-
- {t("superUser.persistentCache.label")} -
-
- {t("superUser.persistentCache.usage", { - used: `${(persistentCacheBytes / (1024 * 1024)).toFixed(2)} MB`, - limit: `${settings.persistentCacheSizeMB ?? 10} MB`, - })} -
+
+ +
+
+
+ {t("superUser.persistentCache.label")} +
+
+ {t("superUser.persistentCache.usage", { + used: `${(persistentCacheBytes / (1024 * 1024)).toFixed(2)} MB`, + limit: `${settings.persistentCacheSizeMB ?? 10} MB`, + })}
-
+
- )} +
)} From 4e88b0c9c2e236325285608173a4456abe2c06dc Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 16 Mar 2026 12:35:20 -0300 Subject: [PATCH 42/43] feat(settings): add super user toggle to advanced tab Add a Super User Mode toggle in the advanced tab's super user section, mirroring the navbar toggle. Also add missing superUser i18n keys for ja, pt-BR, and zh locales. --- src/components/pages/settings/index.tsx | 24 +++++++++++++++++++++++- src/locales/en/settings.json | 4 ++++ src/locales/es/settings.json | 4 ++++ src/locales/ja/settings.json | 22 ++++++++++++++++++++++ src/locales/pt-BR/settings.json | 22 ++++++++++++++++++++++ src/locales/zh/settings.json | 22 ++++++++++++++++++++++ 6 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 956a0962..5bf1f322 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -90,7 +90,7 @@ const Settings: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); const { rpcUrls, setRpcUrls } = useContext(AppContext); - const { settings, updateSettings } = useSettings(); + const { settings, updateSettings, isSuperUser, toggleSuperUserMode } = useSettings(); const { enabledNetworks } = useNetworks(); const { isMetaMaskAvailable, isSupported, setAsDefaultExplorer } = useMetaMaskExplorer(); const [activeTab, setActiveTab] = useState("network"); @@ -1554,6 +1554,28 @@ const Settings: React.FC = () => {

{t("superUser.title")}

{t("superUser.description")}

+
+
+
{t("superUser.enabled.label")}
+
+ {t("superUser.enabled.description")} +
+
+ +
+ +
+
diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 44227a92..7f9b9898 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -130,6 +130,10 @@ "superUser": { "title": "Super User", "description": "Advanced settings for super user mode.", + "enabled": { + "label": "Super User Mode", + "description": "Enable advanced features like TX Analyser, call tree, gas profiler, and state changes." + }, "persistentCache": { "label": "Persistent Cache", "description": "Cache blockchain data in local storage for faster access across sessions.", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 52669a27..620b9f04 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -130,6 +130,10 @@ "superUser": { "title": "Super Usuario", "description": "Configuración avanzada para el modo super usuario.", + "enabled": { + "label": "Modo Super Usuario", + "description": "Habilitar funciones avanzadas como Analizador de TX, árbol de llamadas, perfilador de gas y cambios de estado." + }, "persistentCache": { "label": "Cache Persistente", "description": "Cachear datos de blockchain en almacenamiento local para acceso más rápido entre sesiones.", diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index b669ecda..3ded7c54 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -127,6 +127,28 @@ "settingsSaved": "設定が正常に保存されました!", "cacheCleared": "キャッシュをクリアしました!" }, + "superUser": { + "title": "スーパーユーザー", + "description": "スーパーユーザーモードの詳細設定。", + "enabled": { + "label": "スーパーユーザーモード", + "description": "TXアナライザー、コールツリー、ガスプロファイラー、状態変更などの高度な機能を有効にします。" + }, + "persistentCache": { + "label": "永続キャッシュ", + "description": "セッション間のアクセスを高速化するために、ブロックチェーンデータをローカルストレージにキャッシュします。", + "sizeLimit": { + "label": "キャッシュサイズ制限", + "description": "永続キャッシュの最大ストレージ容量。" + }, + "usage": "{{used}} / {{limit}} 使用中", + "clear": { + "label": "永続キャッシュをクリア", + "description": "ローカルストレージからキャッシュされたブロックチェーンデータをすべて削除します。", + "button": "キャッシュをクリア" + } + } + }, "rpcEndpoints": { "title": "RPCエンドポイント", "description": "各ネットワークのRPC URLを設定します。ネットワークをクリックして展開し、エンドポイントを設定してください。", diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index 938756b4..eac0b08d 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -127,6 +127,28 @@ "settingsSaved": "Configurações salvas com sucesso!", "cacheCleared": "Cache limpo!" }, + "superUser": { + "title": "Super Usuário", + "description": "Configurações avançadas para o modo super usuário.", + "enabled": { + "label": "Modo Super Usuário", + "description": "Habilitar recursos avançados como Analisador de TX, árvore de chamadas, perfilador de gas e mudanças de estado." + }, + "persistentCache": { + "label": "Cache Persistente", + "description": "Armazenar dados de blockchain no armazenamento local para acesso mais rápido entre sessões.", + "sizeLimit": { + "label": "Limite de Tamanho do Cache", + "description": "Espaço máximo de armazenamento para o cache persistente." + }, + "usage": "Usando {{used}} de {{limit}}", + "clear": { + "label": "Limpar Cache Persistente", + "description": "Remover todos os dados de blockchain armazenados do armazenamento local.", + "button": "Limpar Cache" + } + } + }, "rpcEndpoints": { "title": "Endpoints RPC", "description": "Configure URLs RPC para cada rede. Clique em uma rede para expandir e configurar seus endpoints.", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 64683c40..91216afd 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -127,6 +127,28 @@ "settingsSaved": "配置保存成功!", "cacheCleared": "缓存已清除!" }, + "superUser": { + "title": "超级用户", + "description": "超级用户模式的高级设置。", + "enabled": { + "label": "超级用户模式", + "description": "启用高级功能,如交易分析器、调用树、Gas分析器和状态变更。" + }, + "persistentCache": { + "label": "持久缓存", + "description": "将区块链数据缓存到本地存储中,以加快跨会话访问速度。", + "sizeLimit": { + "label": "缓存大小限制", + "description": "持久缓存的最大存储空间。" + }, + "usage": "已使用 {{used}} / {{limit}}", + "clear": { + "label": "清除持久缓存", + "description": "从本地存储中删除所有缓存的区块链数据。", + "button": "清除缓存" + } + } + }, "rpcEndpoints": { "title": "RPC 端点", "description": "为每个网络配置 RPC URL。点击网络展开并配置其端点。", From ef25cf5ccfaae2ce092ad7ceac147207a5a31d85 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 16 Mar 2026 13:34:07 -0300 Subject: [PATCH 43/43] feat(tx): decode and display UTF-8 text in transaction input data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ABI decoding fails, attempt to decode the hex input as UTF-8 text. If ≥80% of characters are printable, show the decoded text above the raw hex in a readable pre-formatted block. This handles text messages embedded in transactions like the Ethereum Foundation mandate. Closes #311 --- .../pages/evm/tx/analyser/InputDataTab.tsx | 19 +++++++++- src/locales/en/transaction.json | 1 + src/locales/es/transaction.json | 1 + src/locales/ja/transaction.json | 1 + src/locales/pt-BR/transaction.json | 1 + src/locales/zh/transaction.json | 1 + src/styles/components.css | 18 +++++++++ src/utils/inputDecoder.ts | 38 +++++++++++++++++++ 8 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/components/pages/evm/tx/analyser/InputDataTab.tsx b/src/components/pages/evm/tx/analyser/InputDataTab.tsx index 0e866389..6dc58f84 100644 --- a/src/components/pages/evm/tx/analyser/InputDataTab.tsx +++ b/src/components/pages/evm/tx/analyser/InputDataTab.tsx @@ -3,7 +3,11 @@ 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"; +import { + type DecodedInput, + decodeFunctionCall, + tryDecodeUtf8, +} from "../../../../../utils/inputDecoder"; const InputDataTab: React.FC<{ inputData: string; @@ -24,6 +28,9 @@ const InputDataTab: React.FC<{ return decodeFunctionCall(inputData, enriched.abi); })(); + // If no ABI decode, try UTF-8 text decode + const utf8Text = !resolved ? tryDecodeUtf8(inputData) : null; + return (
{resolved && ( @@ -58,6 +65,16 @@ const InputDataTab: React.FC<{
)} + {utf8Text && ( +
+
+ {t("analyser.utf8Text")} +
+
+
{utf8Text}
+
+
+ )}
{t("analyser.rawInputData")} diff --git a/src/locales/en/transaction.json b/src/locales/en/transaction.json index 7d1e86d8..61369bed 100644 --- a/src/locales/en/transaction.json +++ b/src/locales/en/transaction.json @@ -169,6 +169,7 @@ "events": "Events", "inputDataTab": "Input Data", "rawInputData": "Raw Input Data", + "utf8Text": "UTF-8 Decoded Text", "expandAll": "Expand All", "collapseAll": "Collapse All", "expand": "Expand", diff --git a/src/locales/es/transaction.json b/src/locales/es/transaction.json index 5a620b35..8961b03c 100644 --- a/src/locales/es/transaction.json +++ b/src/locales/es/transaction.json @@ -169,6 +169,7 @@ "events": "Eventos", "inputDataTab": "Datos de Entrada", "rawInputData": "Datos de Entrada sin Procesar", + "utf8Text": "Texto Decodificado UTF-8", "expandAll": "Expandir Todo", "collapseAll": "Colapsar Todo", "expand": "Expandir", diff --git a/src/locales/ja/transaction.json b/src/locales/ja/transaction.json index 8a8f169c..38f39e56 100644 --- a/src/locales/ja/transaction.json +++ b/src/locales/ja/transaction.json @@ -169,6 +169,7 @@ "events": "イベント", "inputDataTab": "入力データ", "rawInputData": "生の入力データ", + "utf8Text": "UTF-8 デコードテキスト", "expandAll": "すべて展開", "collapseAll": "すべて折りたたむ", "expand": "展開", diff --git a/src/locales/pt-BR/transaction.json b/src/locales/pt-BR/transaction.json index 8eb42032..8225b8a2 100644 --- a/src/locales/pt-BR/transaction.json +++ b/src/locales/pt-BR/transaction.json @@ -169,6 +169,7 @@ "events": "Eventos", "inputDataTab": "Dados de Entrada", "rawInputData": "Dados de Entrada Brutos", + "utf8Text": "Texto Decodificado UTF-8", "expandAll": "Expandir Tudo", "collapseAll": "Recolher Tudo", "expand": "Expandir", diff --git a/src/locales/zh/transaction.json b/src/locales/zh/transaction.json index 29bf431c..71985afb 100644 --- a/src/locales/zh/transaction.json +++ b/src/locales/zh/transaction.json @@ -169,6 +169,7 @@ "events": "事件", "inputDataTab": "输入数据", "rawInputData": "原始输入数据", + "utf8Text": "UTF-8 解码文本", "expandAll": "全部展开", "collapseAll": "全部折叠", "expand": "展开", diff --git a/src/styles/components.css b/src/styles/components.css index afa56a31..01058c03 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -387,6 +387,24 @@ color: var(--text-primary); } +.tx-input-utf8 { + background: var(--overlay-light-5); + border: 1px solid var(--overlay-light-10); + border-radius: 6px; + padding: 12px; + max-height: 400px; + overflow: auto; +} + +.tx-input-utf8 pre { + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 0.8rem; + white-space: pre-wrap; + word-wrap: break-word; + color: var(--text-primary); + margin: 0; +} + .tx-empty { color: var(--text-secondary); font-style: italic; diff --git a/src/utils/inputDecoder.ts b/src/utils/inputDecoder.ts index 312f2357..f7757bd0 100644 --- a/src/utils/inputDecoder.ts +++ b/src/utils/inputDecoder.ts @@ -227,3 +227,41 @@ export function decodeEventWithAbi( }; } } + +/** + * Try to decode hex input data as UTF-8 text. + * Returns the decoded string if ≥80% of characters are printable, null otherwise. + */ +export function tryDecodeUtf8(hex: string): string | null { + if (!hex || hex === "0x" || hex.length < 4) return null; + + const cleaned = hex.startsWith("0x") ? hex.slice(2) : hex; + if (cleaned.length === 0 || cleaned.length % 2 !== 0) return null; + + const bytes = new Uint8Array(cleaned.length / 2); + for (let i = 0; i < cleaned.length; i += 2) { + bytes[i / 2] = Number.parseInt(cleaned.substring(i, i + 2), 16); + } + + const decoded = new TextDecoder("utf-8", { fatal: false }).decode(bytes); + + // Check if mostly printable (letters, digits, punctuation, whitespace) + let printable = 0; + for (let i = 0; i < decoded.length; i++) { + const code = decoded.charCodeAt(i); + if ( + (code >= 0x20 && code <= 0x7e) || // ASCII printable + code === 0x09 || // tab + code === 0x0a || // newline + code === 0x0d || // carriage return + code >= 0x80 // multibyte (accented chars, CJK, emoji, etc.) + ) { + printable++; + } + } + + const ratio = printable / decoded.length; + if (ratio < 0.8) return null; + + return decoded; +}