From f64215d19935bd6092ba50f723659430b0b58195 Mon Sep 17 00:00:00 2001 From: Timidan Date: Thu, 28 May 2026 14:32:12 +0100 Subject: [PATCH 1/3] chore: tier-A CLAUDE.md cleanup pass (12 surgical fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied 9 unambiguous fixes from a 10-zone CLAUDE.md audit (~150 total findings). Only items with zero behavior change, zero parallel-work conflict (PR #20 lifi-intents, #21 mezo, #22 cleanup), and clear dead-code or surface-confusion signal were applied; the rest are deferred to the PR description as a backlog. Net: 12 files, -65 lines net. Surface confusion (P1): - structStorageDecoding.ts:484 — collapse identical-branches ternary 'const elementSize = elementType === 'address' ? 1 : 1' → '= 1' - storageSlotCalculator.ts:86 — parseSlotInput dropped redundant hex branch (both branches called BigInt(trimmed)) - edbTraceConverter.ts:55 — same redundant-hex-branch in parseTraceValue Speculative API surface (P2): - SolidityViewer.tsx — removed unused props highlightLine, scrollToLine, theme (sole caller never passes any) - PackingVisualizer.tsx — removed unused rawHex prop + caller's pass - api/{lifi-composer,lifi-earn,edb-proxy}.ts — dropped 'HEAD' from ALLOWED_METHODS (no caller issues HEAD) Dead exports (P3): - scripts/bridge-security.mjs — sanitizeForLogging (zero callers) - scripts/bridge-config.mjs — TRACE_DETAIL_COMPACT_ARTIFACTS env constant (zero readers) - eslint.config.js — ignore globs for nonexistent dirs (current_bundle, test-results, test-*, **/test-*.js, test-app-*) --- api/edb-proxy.ts | 2 +- api/lifi-composer.ts | 2 +- api/lifi-earn.ts | 2 +- eslint.config.js | 5 --- scripts/bridge-config.mjs | 1 - scripts/bridge-security.mjs | 16 -------- src/components/explorer/SlotRow.tsx | 2 +- src/components/explorer/SolidityViewer.tsx | 38 +------------------ .../storage-viewer/PackingVisualizer.tsx | 3 +- src/contexts/debug/structStorageDecoding.ts | 2 +- src/utils/edbTraceConverter.ts | 3 -- src/utils/storageSlotCalculator.ts | 4 -- 12 files changed, 7 insertions(+), 73 deletions(-) diff --git a/api/edb-proxy.ts b/api/edb-proxy.ts index cbcf8d97..09514fda 100644 --- a/api/edb-proxy.ts +++ b/api/edb-proxy.ts @@ -8,7 +8,7 @@ export const config = { const MAX_BODY_BYTES = 50 * 1024 * 1024; // 50 MB (artifacts_inline can be large) const FETCH_TIMEOUT_MS = 120_000; // 2 min for regular requests -const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]); +const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS"]); // CORS allowlist — dev servers by default; extend via EDB_CORS_ALLOWED_ORIGINS (comma-separated). const DEFAULT_ALLOWED_ORIGINS = new Set([ diff --git a/api/lifi-composer.ts b/api/lifi-composer.ts index 813a83b2..75a40ecc 100644 --- a/api/lifi-composer.ts +++ b/api/lifi-composer.ts @@ -8,7 +8,7 @@ export const config = { const LIFI_BASE = "https://li.quest"; const LIFI_API_KEY = process.env.LIFI_API_KEY || ""; -const ALLOWED_METHODS = new Set(["GET", "OPTIONS", "HEAD"]); +const ALLOWED_METHODS = new Set(["GET", "OPTIONS"]); const ALLOWED_ORIGINS = new Set( (process.env.ALLOWED_ORIGINS || "").split(",").filter(Boolean) ); diff --git a/api/lifi-earn.ts b/api/lifi-earn.ts index 0b8551bd..3c4a81a6 100644 --- a/api/lifi-earn.ts +++ b/api/lifi-earn.ts @@ -8,7 +8,7 @@ export const config = { const LIFI_EARN_BASE = "https://earn.li.fi"; const LIFI_API_KEY = process.env.LIFI_API_KEY || ""; -const ALLOWED_METHODS = new Set(["GET", "OPTIONS", "HEAD"]); +const ALLOWED_METHODS = new Set(["GET", "OPTIONS"]); const ALLOWED_ORIGINS = new Set( (process.env.ALLOWED_ORIGINS || "").split(",").filter(Boolean) ); diff --git a/eslint.config.js b/eslint.config.js index 3eabd895..68144d3f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,14 +8,9 @@ export default tseslint.config( ignores: [ 'dist/**', 'node_modules/**', - 'current_bundle/**', - 'test-results/**', 'scripts/**', 'public/**', 'tests/**', - 'test-*/**', - '**/test-*.js', - 'test-app-*/**', '.claude/worktrees/**', 'edb/**', 'starknet-sim/**', diff --git a/scripts/bridge-config.mjs b/scripts/bridge-config.mjs index 5faac78e..fcdc60bc 100644 --- a/scripts/bridge-config.mjs +++ b/scripts/bridge-config.mjs @@ -22,7 +22,6 @@ export const TRACE_DETAIL_GZIP_MIN_BYTES = Number( ); export const TRACE_STRIP_OPCODE_LINES = process.env.TRACE_DETAIL_STRIP_OPCODE_LINES === "true"; export const TRACE_DETAIL_STRIP_OPCODE_TRACE = process.env.TRACE_DETAIL_STRIP_OPCODE_TRACE !== "false"; -export const TRACE_DETAIL_COMPACT_ARTIFACTS = process.env.TRACE_DETAIL_COMPACT_ARTIFACTS !== "false"; export const TRACE_V2_BRIDGE_JS_FALLBACK = process.env.SIM_TRACE_V2_BRIDGE_JS_FALLBACK === "true"; export const TRACE_LITE_TRANSPORT_ENABLED = process.env.SIM_TRACE_V2_LITE_TRANSPORT !== "false"; diff --git a/scripts/bridge-security.mjs b/scripts/bridge-security.mjs index b8851c54..b4737629 100644 --- a/scripts/bridge-security.mjs +++ b/scripts/bridge-security.mjs @@ -33,19 +33,3 @@ export function redactRpcUrl(url) { } } -/** - * Sanitize an object for logging - redacts sensitive fields - * @param {Object} obj - * @returns {Object} - */ -export function sanitizeForLogging(obj) { - if (!obj || typeof obj !== "object") return obj; - const sanitized = { ...obj }; - if ("rpcUrl" in sanitized) { - sanitized.rpcUrl = redactRpcUrl(sanitized.rpcUrl); - } - if ("rpc_url" in sanitized) { - sanitized.rpc_url = redactRpcUrl(sanitized.rpc_url); - } - return sanitized; -} diff --git a/src/components/explorer/SlotRow.tsx b/src/components/explorer/SlotRow.tsx index 3e228eb3..a2827160 100644 --- a/src/components/explorer/SlotRow.tsx +++ b/src/components/explorer/SlotRow.tsx @@ -239,7 +239,7 @@ const InlineInspector: React.FC = ({ slot }) => { Slot Packing Layout
- +
)} diff --git a/src/components/explorer/SolidityViewer.tsx b/src/components/explorer/SolidityViewer.tsx index f36146ec..1591e197 100644 --- a/src/components/explorer/SolidityViewer.tsx +++ b/src/components/explorer/SolidityViewer.tsx @@ -26,14 +26,8 @@ export interface SolidityViewerProps { selectedFile: string | null; /** Callback when file selection changes */ onFileSelect?: (path: string) => void; - /** Optional: Highlight specific line (1-indexed) */ - highlightLine?: number; - /** Optional: Scroll to line on mount/change */ - scrollToLine?: number; /** Optional: Show/hide the built-in file tree sidebar */ showFileTree?: boolean; - /** Optional: Custom theme */ - theme?: 'vs-dark' | 'vs-light' | 'hc-black'; /** Optional: Additional CSS class */ className?: string; /** Optional: Height (default: 100%) */ @@ -44,15 +38,11 @@ export const SolidityViewer: React.FC = ({ files, selectedFile, onFileSelect, - highlightLine, - scrollToLine, showFileTree = true, - theme = 'vs-dark', className, height = '100%', }) => { const editorRef = useRef(null); - const decorationsRef = useRef([]); const containerRef = useRef(null); // Force Monaco to remount when container becomes visible after display:none toggle. @@ -116,32 +106,6 @@ export const SolidityViewer: React.FC = ({ setupSolidityMonaco(monaco); }, []); - useEffect(() => { - if (!editorRef.current || !highlightLine) return; - - const editor = editorRef.current; - const monaco = (window as { monaco?: typeof import('monaco-editor') }).monaco; - if (!monaco) return; - - decorationsRef.current = editor.deltaDecorations(decorationsRef.current, []); - decorationsRef.current = editor.deltaDecorations([], [ - { - range: new monaco.Range(highlightLine, 1, highlightLine, 1), - options: { - isWholeLine: true, - className: 'highlighted-line', - linesDecorationsClassName: 'highlighted-line-gutter', - }, - }, - ]); - }, [highlightLine, currentContent]); - - useEffect(() => { - if (!editorRef.current || !scrollToLine) return; - - editorRef.current.revealLineInCenter(scrollToLine); - }, [scrollToLine, currentContent]); - const handleFileSelect = useCallback( (path: string) => { onFileSelect?.(path); @@ -155,7 +119,7 @@ export const SolidityViewer: React.FC = ({ height={height} language={getLanguageFromPath(selectedFile)} value={currentContent} - theme={theme === 'vs-dark' ? SOLIDITY_THEME_NAME : theme} + theme={SOLIDITY_THEME_NAME} options={SOLIDITY_EDITOR_OPTIONS} onMount={handleEditorMount} loading={ diff --git a/src/components/explorer/storage-viewer/PackingVisualizer.tsx b/src/components/explorer/storage-viewer/PackingVisualizer.tsx index 15fa0366..7158757d 100644 --- a/src/components/explorer/storage-viewer/PackingVisualizer.tsx +++ b/src/components/explorer/storage-viewer/PackingVisualizer.tsx @@ -19,7 +19,6 @@ import type { DecodedSlotField } from '../../../types/debug'; interface PackingVisualizerProps { fields: DecodedSlotField[]; - rawHex?: string; } /** Color palette for Solidity types */ @@ -50,7 +49,7 @@ interface ByteSegment { isGap: boolean; } -const PackingVisualizer: React.FC = ({ fields, rawHex }) => { +const PackingVisualizer: React.FC = ({ fields }) => { /** Build segments for the 32-byte lane */ const segments = useMemo((): ByteSegment[] => { if (fields.length === 0) return []; diff --git a/src/contexts/debug/structStorageDecoding.ts b/src/contexts/debug/structStorageDecoding.ts index 99eb0630..bf8a65f9 100644 --- a/src/contexts/debug/structStorageDecoding.ts +++ b/src/contexts/debug/structStorageDecoding.ts @@ -481,7 +481,7 @@ export async function fillUnreadFieldsFromStorage( } const elementType = child.type.replace('[]', ''); - const elementSize = elementType === 'address' ? 1 : 1; + const elementSize = 1; const arrayChildren: DebugVariable[] = []; const readBatchSize = 8; for (let start = 0; start < maxElements; start += readBatchSize) { diff --git a/src/utils/edbTraceConverter.ts b/src/utils/edbTraceConverter.ts index 03c77ffd..26de1c87 100644 --- a/src/utils/edbTraceConverter.ts +++ b/src/utils/edbTraceConverter.ts @@ -53,9 +53,6 @@ const parseTraceValue = (value: unknown): ethers.BigNumber | null => { return null; } try { - if (trimmed.startsWith("0x") || trimmed.startsWith("0X")) { - return ethers.BigNumber.from(trimmed); - } return ethers.BigNumber.from(trimmed); } catch { return null; diff --git a/src/utils/storageSlotCalculator.ts b/src/utils/storageSlotCalculator.ts index 0b295e86..42da3a8c 100644 --- a/src/utils/storageSlotCalculator.ts +++ b/src/utils/storageSlotCalculator.ts @@ -86,9 +86,5 @@ export const DIAMOND_NAMESPACES = [ export function parseSlotInput(input: string): bigint { const trimmed = input.trim(); if (!trimmed) throw new Error('Empty slot input'); - if (trimmed.startsWith('0x') || trimmed.startsWith('0X')) { - return BigInt(trimmed); - } - // Try decimal return BigInt(trimmed); } From 214f89bd15d2446a84066d51269b0b4c567ab986 Mon Sep 17 00:00:00 2001 From: Timidan Date: Sat, 30 May 2026 15:43:24 +0100 Subject: [PATCH 2/3] refactor: deepen architecture + efficiency optimizations Consolidate duplicated/scattered logic behind single seams and add measured, behaviour-preserving efficiency wins. Deepening: - trace decoder: createPcResolvers factory + single traceIdFromFrame - storage: placeField/elementsPerSlot + walkStorageEntries primitives; resolveAbiKeyType/buildScalarDescriptor (fixes silent string-key probe) - contract resolution: delete deprecated multiSource/comprehensive shims; facetAdapter over the resolver seam - debug eval: traceRowScoring + snapshotCacheStore (one eviction policy) - sim persistence: simulationStore coordinator; deleteDecodedTrace fixes orphaned OPFS traces on delete - integrations: evmRead provider helper Efficiency: - diamond loupe facets()-first reuses selectors (drops per-facet RPC) - bounded mapping-slot keccak cache - SelectorDecoder batched + chunked OpenChain lookup with circuit breaker - recomputeHierarchy O(n^2) -> O(n); skip redundant recompute on OPFS load - lazy-load Navigation, split framer-motion to an async chunk --- src/App.tsx | 8 +- src/components/InlineFacetLoader.tsx | 10 +- src/components/SimulationHistoryPage.tsx | 92 +-- src/components/TransactionBuilderHub.tsx | 18 +- src/components/TransactionBuilderWagmi.tsx | 22 +- .../storage-viewer/mappingKeyDiscovery.ts | 54 +- .../storage-viewer/useSlotResolution.ts | 54 +- .../explorer/useStorageViewerState.ts | 10 +- .../integrations/lifi-earn/hooks/evmRead.ts | 60 ++ .../lifi-earn/hooks/useTokenAllowance.ts | 39 +- .../lifi-earn/hooks/useTokenBalance.ts | 41 +- src/components/shared/SelectorDecoder.tsx | 111 +++- .../signature-database/SearchTab.tsx | 238 ++++--- .../simple-grid/hooks/useContractState.ts | 35 +- .../simulation-results/StateTab.tsx | 16 +- .../useSimulationPageState.ts | 78 +-- src/components/transaction-builder/types.ts | 34 + src/contexts/debug/debugHelpers.ts | 26 - src/contexts/debug/evalSnapshotResolver.ts | 108 ++- src/contexts/debug/snapshotCacheStore.ts | 45 ++ src/contexts/debug/solidityStructLayout.ts | 144 ---- src/contexts/debug/structStorageDecoding.ts | 316 ++++++++- src/contexts/debug/traceRowScoring.ts | 122 ++++ src/contexts/debug/useDebugEvaluation.ts | 212 +----- src/contexts/debug/useDebugSession.ts | 7 +- src/hooks/useUniversalSearch.ts | 2 +- src/services/TraceVaultService.ts | 66 +- src/services/simulationStore.ts | 179 +++++ src/utils/comprehensiveContractFetcher.ts | 132 ---- src/utils/diamondFacetFetcher.ts | 613 +++++------------- src/utils/multiSourceAbiFetcher.ts | 101 --- src/utils/resolver/diamondResolver.ts | 61 +- src/utils/resolver/facetAdapter.ts | 47 ++ src/utils/solidity-layout/allocator.ts | 70 +- .../solidity-layout/allocatorTypeHelpers.ts | 58 +- src/utils/storageLayoutDecode.ts | 78 +-- src/utils/storageLayoutResolver.ts | 158 +++-- src/utils/storageSlotCalculator.ts | 70 ++ src/utils/traceDecoder/analysisHelpers.ts | 133 +--- src/utils/traceDecoder/callHierarchy.ts | 23 +- src/utils/traceDecoder/decodeTraceInit.ts | 43 +- src/utils/traceDecoder/jumpAnalysis.ts | 45 +- src/utils/traceDecoder/pcResolution.ts | 189 ++++++ vite.config.ts | 1 + 44 files changed, 2044 insertions(+), 1925 deletions(-) create mode 100644 src/components/integrations/lifi-earn/hooks/evmRead.ts create mode 100644 src/contexts/debug/snapshotCacheStore.ts create mode 100644 src/contexts/debug/traceRowScoring.ts create mode 100644 src/services/simulationStore.ts delete mode 100644 src/utils/comprehensiveContractFetcher.ts delete mode 100644 src/utils/multiSourceAbiFetcher.ts create mode 100644 src/utils/resolver/facetAdapter.ts create mode 100644 src/utils/traceDecoder/pcResolution.ts diff --git a/src/App.tsx b/src/App.tsx index 22732d3a..f9243f18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,6 @@ import PersistentTools from "./components/PersistentTools"; import { ToolkitProvider } from "./contexts/ToolkitContext"; import { SimulationProvider } from "./contexts/SimulationContext"; import { DebugProvider } from "./contexts/DebugContext"; -import Navigation from "./components/Navigation"; import ErrorBoundary from "./components/ErrorBoundary"; import { NotificationProvider } from "./components/NotificationManager"; import { RouteMetaTags } from "./components/shared/RouteMetaTags"; @@ -21,6 +20,7 @@ import HomePage from "./components/HomePage"; import MobileDrawer from "./components/MobileDrawer"; import { useBreakpoint } from "./hooks/useBreakpoint"; +const Navigation = React.lazy(() => import("./components/Navigation")); const SimulationResultsPage = React.lazy(() => import("./components/SimulationResultsPage")); const RpcSettingsModal = React.lazy(() => import("./components/RpcSettingsModal")); const StorageManagerModal = React.lazy(() => import("./components/StorageManagerModal")); @@ -80,7 +80,11 @@ function App() { ) : (
- {!isMobile && } + {!isMobile && ( + + + + )}
diff --git a/src/components/InlineFacetLoader.tsx b/src/components/InlineFacetLoader.tsx index 13a5f0ba..df7790cc 100644 --- a/src/components/InlineFacetLoader.tsx +++ b/src/components/InlineFacetLoader.tsx @@ -1,7 +1,7 @@ import React, { useState, useCallback, useEffect, useRef } from "react"; import { fetchDiamondFacets, - getDiamondFacetAddresses, + getDiamondFacetAddressesWithSelectors, type DiamondFacet, } from "../utils/diamondFacetFetcher"; import { networkConfigManager } from "../config/networkConfig"; @@ -91,10 +91,8 @@ export const InlineFacetLoader: React.FC = ({ setShowDetails(false); setFacetDetails([]); - const facetAddresses = await getDiamondFacetAddresses( - chain, - diamondAddress - ); + const { addresses: facetAddresses, loupeSelectors } = + await getDiamondFacetAddressesWithSelectors(chain, diamondAddress); if (requestIdRef.current !== requestId) { return; @@ -170,7 +168,7 @@ export const InlineFacetLoader: React.FC = ({ }); onProgressChange?.(p); }, - { etherscanApiKey } + { etherscanApiKey, loupeSelectors } ); if (requestIdRef.current !== requestId) { diff --git a/src/components/SimulationHistoryPage.tsx b/src/components/SimulationHistoryPage.tsx index 25df900d..aa4c8964 100644 --- a/src/components/SimulationHistoryPage.tsx +++ b/src/components/SimulationHistoryPage.tsx @@ -10,7 +10,12 @@ import { type StoredSimulation, type SimulationHistoryFilter } from '../services/SimulationHistoryService'; -import { traceVaultService, recomputeHierarchy } from '../services/TraceVaultService'; +import { + loadStoredSimulation, + deleteStoredSimulation, + deleteStoredSimulations, + clearStoredSimulations, +} from '../services/simulationStore'; import { useSimulation } from '../contexts/SimulationContext'; import { SUPPORTED_CHAINS } from '../utils/chains'; import { Button } from './ui/button'; @@ -18,7 +23,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './ui/table'; import { Checkbox } from './ui/checkbox'; import { shortenAddress } from './shared/AddressDisplay'; -import { hasInternalInfo } from './simulation-results/useSimulationPageHelpers'; import '../styles/SimulationHistory.css'; // Helper to format timestamp @@ -169,76 +173,20 @@ const SimulationHistoryPage: React.FC = () => { const handleViewSimulation = useCallback(async (sim: StoredSimulation) => { // In lightweight mode, we don't have result/contractContext - need to fetch try { - const fullSim = await simulationHistoryService.getSimulation(sim.id); - if (fullSim?.result && fullSim?.contractContext) { + const loaded = await loadStoredSimulation(sim.id); + if (loaded) { // Set simulation in context and navigate to results // Pass skipHistorySave to avoid creating duplicate history entries - setSimulation(fullSim.result, fullSim.contractContext, { skipHistorySave: true }); - - let restoredFromVault = false; - try { - const traceBundle = await traceVaultService.loadDecodedTrace(sim.id, { - includeHeavy: false, - }); - - const opfsRowCount = traceBundle?.rows?.length ?? 0; - const indexedDbRowCount = fullSim.decodedTraceRows?.length ?? 0; - const opfsHasInternal = hasInternalInfo(traceBundle?.rows); - const indexedDbHasInternal = hasInternalInfo(fullSim.decodedTraceRows); - - // Prefer OPFS if it has rows with hierarchy info - // Fall back to IndexedDB only if OPFS is empty/missing hierarchy but IndexedDB has it - let rowsToUse: any[] | undefined; - let sourceLabel: string = 'unknown'; - - if (opfsRowCount > 0 && opfsHasInternal) { - // OPFS has full data with hierarchy - use it - rowsToUse = traceBundle!.rows; - sourceLabel = 'OPFS'; - } else if (indexedDbRowCount > 0 && indexedDbHasInternal) { - // IndexedDB has hierarchy but OPFS doesn't - use IndexedDB - rowsToUse = fullSim.decodedTraceRows; - sourceLabel = 'IndexedDB'; - } else if (opfsRowCount > 0) { - // OPFS has rows (even without hierarchy) - use it - rowsToUse = traceBundle!.rows; - sourceLabel = 'OPFS (no hierarchy)'; - } else if (indexedDbRowCount > 0) { - // IndexedDB has rows as last resort - rowsToUse = fullSim.decodedTraceRows; - sourceLabel = 'IndexedDB (no hierarchy)'; - } - - if (rowsToUse && rowsToUse.length > 0) { - // Recompute hierarchy from depth relationships to fix traces where - // hasChildren wasn't computed correctly for nested call frames - const fixedRows = recomputeHierarchy(rowsToUse); - setDecodedTraceRows(fixedRows); - if (traceBundle?.sourceTexts && Object.keys(traceBundle.sourceTexts).length > 0) { - setSourceTexts(traceBundle.sourceTexts); - } - // Set trace metadata including rawEvents for TokenMovementsPanel - setDecodedTraceMeta({ - sourceLines: traceBundle?.sourceLines ?? [], - callMeta: traceBundle?.callMeta, - rawEvents: traceBundle?.rawEvents ?? [], - implementationToProxy: traceBundle?.implementationToProxy ?? new Map(), - }); - restoredFromVault = true; - } - } catch { - // Fallback: restore decoded rows from IndexedDB on OPFS failure - if (fullSim.decodedTraceRows && fullSim.decodedTraceRows.length > 0) { - const fixedRows = recomputeHierarchy(fullSim.decodedTraceRows); - setDecodedTraceRows(fixedRows); - restoredFromVault = true; - } - } + setSimulation(loaded.result, loaded.contractContext, { skipHistorySave: true }); - // Final fallback: restore legacy decoded rows from IndexedDB (should rarely hit this) - if (!restoredFromVault && fullSim.decodedTraceRows && fullSim.decodedTraceRows.length > 0) { - const fixedRows = recomputeHierarchy(fullSim.decodedTraceRows); - setDecodedTraceRows(fixedRows); + if (loaded.decodedRows && loaded.decodedRows.length > 0) { + setDecodedTraceRows(loaded.decodedRows); + } + if (loaded.sourceTexts) { + setSourceTexts(loaded.sourceTexts); + } + if (loaded.meta) { + setDecodedTraceMeta(loaded.meta); } // Set the simulation ID in context for consistency @@ -269,7 +217,7 @@ const SimulationHistoryPage: React.FC = () => { // Handle delete simulation const handleDeleteSimulation = useCallback(async (id: string) => { try { - await simulationHistoryService.deleteSimulation(id); + await deleteStoredSimulation(id); setSimulations(prev => prev.filter(s => s.id !== id)); setSelectedIds(prev => { const next = new Set(prev); @@ -289,7 +237,7 @@ const SimulationHistoryPage: React.FC = () => { if (!confirmed) return; try { - await simulationHistoryService.deleteSimulations(Array.from(selectedIds)); + await deleteStoredSimulations(Array.from(selectedIds)); setSimulations(prev => prev.filter(s => !selectedIds.has(s.id))); setSelectedIds(new Set()); } catch { @@ -303,7 +251,7 @@ const SimulationHistoryPage: React.FC = () => { if (!confirmed) return; try { - await simulationHistoryService.clearAll(); + await clearStoredSimulations(); setSimulations([]); setSelectedIds(new Set()); } catch { diff --git a/src/components/TransactionBuilderHub.tsx b/src/components/TransactionBuilderHub.tsx index a034d6d0..dfab1c9e 100644 --- a/src/components/TransactionBuilderHub.tsx +++ b/src/components/TransactionBuilderHub.tsx @@ -7,11 +7,9 @@ import { LayoutTransitionWrapper } from "./ui/animated-tabs"; import { useSimulation } from "../contexts/SimulationContext"; import { AnimatedZapIcon, AnimatedPlayIcon } from "./icons/IconLibrary"; import { SUPPORTED_CHAINS } from "../utils/chains"; +import { TXHASH_REPLAY_KEY, parseBuilderIntent } from "./transaction-builder/types"; type BuilderMode = "live" | "simulation"; -type BuilderIntentMode = "live" | "simulation" | "replay"; - -const TXHASH_REPLAY_KEY = 'web3-toolkit:txhash-replay'; const loadSimpleGridUI = () => import("./simple-grid"); const loadTransactionBuilderWagmi = () => import("./TransactionBuilderWagmi"); @@ -19,18 +17,10 @@ const loadTransactionBuilderWagmi = () => import("./TransactionBuilderWagmi"); const SimpleGridUI = React.lazy(loadSimpleGridUI); const TransactionBuilderWagmi = React.lazy(loadTransactionBuilderWagmi); -function parseBuilderIntentMode(search: string): BuilderIntentMode | null { - const mode = new URLSearchParams(search).get('mode'); - if (mode === 'live' || mode === 'simulation' || mode === 'replay') { - return mode; - } - return null; -} - const TransactionBuilderHub: React.FC = () => { const { contractContext } = useSimulation(); const location = useLocation(); - const urlIntentMode = parseBuilderIntentMode(location.search); + const urlIntentMode = parseBuilderIntent(location.search).mode; const builderSearchParams = useMemo(() => new URLSearchParams(location.search), [location.search]); const liveInitialContractData = useMemo(() => { @@ -59,9 +49,9 @@ const TransactionBuilderHub: React.FC = () => { // Initialize mode based on whether there's simulation context or clone/replay data const [mode, setMode] = useState(() => { - const initialIntentMode = parseBuilderIntentMode(location.search); + const initialIntentMode = parseBuilderIntent(location.search).mode; if (initialIntentMode === 'live') return 'live'; - if (initialIntentMode === 'simulation' || initialIntentMode === 'replay') return 'simulation'; + if (initialIntentMode === 'simulation') return 'simulation'; // Check for clone query param (set by SimulationHistoryPage) if (hasCloneParam) { diff --git a/src/components/TransactionBuilderWagmi.tsx b/src/components/TransactionBuilderWagmi.tsx index d9b1c211..f69e4bd9 100644 --- a/src/components/TransactionBuilderWagmi.tsx +++ b/src/components/TransactionBuilderWagmi.tsx @@ -12,6 +12,7 @@ import { TXHASH_REPLAY_KEY, TXHASH_REPLAY_EVENT, TXHASH_REPLAY_LAST_INTENT_KEY, + parseBuilderIntent, } from "./transaction-builder/types"; import { TransactionReplayView } from "./transaction-builder/TransactionReplayView"; import { renderModeToggle } from "./transaction-builder/renderModeToggle"; @@ -86,13 +87,9 @@ const TransactionBuilderWagmi: React.FC = () => { // Initialize viewMode: "replay" if txHash replay data exists, otherwise "builder" const [viewMode, setViewMode] = useState(() => { - const params = new URLSearchParams(location.search); - const requestedMode = params.get('mode'); - if (requestedMode === 'replay' || params.get('replay') === 'txhash') { - return "replay"; - } - if (requestedMode === 'simulation') { - return "builder"; + const intentViewMode = parseBuilderIntent(location.search).viewMode; + if (intentViewMode) { + return intentViewMode; } // Check for txHash replay data (set by SimulationResultsPage for re-simulation) @@ -245,14 +242,9 @@ const TransactionBuilderWagmi: React.FC = () => { // Keep route intent in sync while this component stays mounted in PersistentTools. useEffect(() => { - const params = new URLSearchParams(location.search); - const requestedMode = params.get('mode'); - if (requestedMode === 'replay' || params.get('replay') === 'txhash') { - setViewMode('replay'); - return; - } - if (requestedMode === 'simulation') { - setViewMode('builder'); + const intentViewMode = parseBuilderIntent(location.search).viewMode; + if (intentViewMode) { + setViewMode(intentViewMode); } }, [location.search]); diff --git a/src/components/explorer/storage-viewer/mappingKeyDiscovery.ts b/src/components/explorer/storage-viewer/mappingKeyDiscovery.ts index d59ec8a2..856e350d 100644 --- a/src/components/explorer/storage-viewer/mappingKeyDiscovery.ts +++ b/src/components/explorer/storage-viewer/mappingKeyDiscovery.ts @@ -16,7 +16,7 @@ import { ethers } from 'ethers'; import type { StorageLayoutResponse } from '../../../types/debug'; import type { MappingEntry } from './useSlotResolution'; -import { computeMappingSlot, computeNestedMappingSlot, formatSlotHex } from '../../../utils/storageSlotCalculator'; +import { computeMappingSlot, computeNestedMappingSlot, formatSlotHex, resolveAbiKeyType } from '../../../utils/storageSlotCalculator'; import { scanLogs, type LogEntry, type ScanProgress } from './rpcLogScanner'; // ─── Types ─────────────────────────────────────────────────────────── @@ -117,46 +117,6 @@ const CANONICAL_EVENTS = { // ─── Type Resolution ──────────────────────────────────────────────── -/** - * Resolve a layout typeId to its canonical Solidity key type. - * Looks up the type definition's `label` first (most reliable), - * then falls back to parsing the typeId string. - */ -function resolveKeyType( - typeId: string, - layout: StorageLayoutResponse, -): string | null { - // Try the type definition's label first — this is the canonical Solidity type - const typeDef = layout.types[typeId]; - if (typeDef?.label) { - const label = typeDef.label.trim(); - // Contract types are addresses - if (label.startsWith('contract ') || label.startsWith('interface ')) return 'address'; - // Enum types are uint8 in storage - if (label.startsWith('enum ')) return 'uint8'; - // Direct Solidity type labels - if (label === 'address' || label === 'address payable') return 'address'; - if (label === 'bool') return 'bool'; - if (label === 'string') return 'bytes32'; // string keys in mappings are hashed - if (/^bytes\d{0,2}$/.test(label)) return label; // bytes1..bytes32 - if (/^uint\d+$/.test(label)) return label; // uint8..uint256 - if (/^int\d+$/.test(label)) return label; // int8..int256 - } - // Fallback: parse the typeId string (e.g. "t_address", "t_uint256", "t_contract(IERC20)") - if (!typeId) return null; - if (typeId.startsWith('t_contract') || typeId.startsWith('t_address')) return 'address'; - if (typeId.startsWith('t_bool')) return 'bool'; - if (typeId.startsWith('t_enum')) return 'uint8'; - if (typeId.startsWith('t_string')) return 'bytes32'; - const bytesMatch = typeId.match(/^t_bytes(\d+)$/); - if (bytesMatch) return `bytes${bytesMatch[1]}`; - const uintMatch = typeId.match(/^t_uint(\d+)$/); - if (uintMatch) return `uint${uintMatch[1]}`; - const intMatch = typeId.match(/^t_int(\d+)$/); - if (intMatch) return `int${intMatch[1]}`; - return null; -} - function normalizeKeyType(type: string | null | undefined): NormalizedKeyType | null { if (!type) return null; if (type === 'address' || type === 'address payable') return 'address'; @@ -320,14 +280,14 @@ function buildExpectedKeyKinds( for (const entry of mappingEntries) { const rootType = normalizeKeyType( - entry.keyTypeId ? resolveKeyType(entry.keyTypeId, layout) : null, + entry.keyTypeId ? resolveAbiKeyType({ typeId: entry.keyTypeId, typeLabel: layout.types[entry.keyTypeId]?.label }) : null, ); if (rootType) expected.add(rootType); if (!entry.valueTypeId) continue; const valueType = layout.types[entry.valueTypeId]; if (!valueType || valueType.encoding !== 'mapping' || !valueType.key) continue; - const nestedType = normalizeKeyType(resolveKeyType(valueType.key, layout)); + const nestedType = normalizeKeyType(resolveAbiKeyType({ typeId: valueType.key, typeLabel: layout.types[valueType.key]?.label })); if (nestedType) expected.add(nestedType); } @@ -431,7 +391,7 @@ function appendPoolCandidates( if (candidateMap.size >= MAX_CANDIDATES) return; const rootKind = normalizeKeyType( - mapping.keyTypeId ? resolveKeyType(mapping.keyTypeId, layout) : null, + mapping.keyTypeId ? resolveAbiKeyType({ typeId: mapping.keyTypeId, typeLabel: layout.types[mapping.keyTypeId]?.label }) : null, ); if (!rootKind) continue; @@ -440,7 +400,7 @@ function appendPoolCandidates( const valueType = mapping.valueTypeId ? layout.types[mapping.valueTypeId] : null; const nestedKind = valueType?.encoding === 'mapping' && valueType.key - ? normalizeKeyType(resolveKeyType(valueType.key, layout)) + ? normalizeKeyType(resolveAbiKeyType({ typeId: valueType.key, typeLabel: layout.types[valueType.key]?.label })) : null; if (!nestedKind) { @@ -728,7 +688,7 @@ async function verifyCandidate( const keyTypeId = mappingEntry.keyTypeId; let keyType = candidate.keyType; if (keyTypeId) { - const resolvedType = resolveKeyType(keyTypeId, layout); + const resolvedType = resolveAbiKeyType({ typeId: keyTypeId, typeLabel: layout.types[keyTypeId]?.label }); if (resolvedType) keyType = resolvedType; } @@ -751,7 +711,7 @@ async function verifyCandidate( // Determine nested key type using the same resolver const nestedKeyTypeId = valueTypeDef.key; if (nestedKeyTypeId) { - const resolved = resolveKeyType(nestedKeyTypeId, layout); + const resolved = resolveAbiKeyType({ typeId: nestedKeyTypeId, typeLabel: layout.types[nestedKeyTypeId]?.label }); if (resolved) resolvedNestedKeyType = resolved; } diff --git a/src/components/explorer/storage-viewer/useSlotResolution.ts b/src/components/explorer/storage-viewer/useSlotResolution.ts index 71671ba2..791042f9 100644 --- a/src/components/explorer/storage-viewer/useSlotResolution.ts +++ b/src/components/explorer/storage-viewer/useSlotResolution.ts @@ -12,13 +12,14 @@ import { buildSlotMap, tryResolveMappingSlot, tryResolveArraySlot, + walkStorageEntries, } from '../../../utils/storageLayoutResolver'; import { buildSlotDescriptors, decodeSlotValue, type SlotDescriptor, } from '../../../utils/storageLayoutDecode'; -import { formatSlotHex, PROXY_SLOTS, ZERO_WORD } from '../../../utils/storageSlotCalculator'; +import { formatSlotHex, PROXY_SLOTS, ZERO_WORD, buildScalarDescriptor } from '../../../utils/storageSlotCalculator'; export interface MappingEntry { variable: string; @@ -127,15 +128,7 @@ function typeAwareDecode( if (!SAFE_DECODE_TYPES.test(typeLabel)) return undefined; try { - const syntheticDescriptor: SlotDescriptor = { - label: '', - typeLabel, - typeKey: '', - offset: 0, - size, - encoding, - entry: { label: '', offset: 0, slot: '0', type: '', astId: 0, contract: '' }, - }; + const syntheticDescriptor = buildScalarDescriptor({ typeLabel, size, encoding }); const decoded = decodeSlotValue(value, syntheticDescriptor); return [{ label: '', typeLabel, decoded, offset: 0, size }]; } catch { @@ -160,26 +153,14 @@ function findLayoutEntryBySlot( slotHex: string, layout: StorageLayoutResponse, ): StorageLayoutEntry | null { - for (const entry of layout.storage) { - if (formatSlotHex(BigInt(entry.slot)) === slotHex) { - return entry; - } - - const typeInfo = layout.types[entry.type]; - if (typeInfo?.encoding === 'inplace' && typeInfo.members) { - for (const member of typeInfo.members) { - const memberSlot = BigInt(entry.slot) + BigInt(member.slot); - if (formatSlotHex(memberSlot) === slotHex) { - return { - ...member, - slot: memberSlot.toString(), - }; - } - } - } - } - - return null; + let found: StorageLayoutEntry | null = null; + walkStorageEntries(layout, ({ entry, slot, slotHex: visitedHex, isMember }) => { + if (found) return; + if (visitedHex !== slotHex) return; + found = isMember ? { ...entry, slot: slot.toString() } : entry; + }); + + return found; } /** @@ -271,16 +252,9 @@ export function useSlotResolution( const layoutEntryIndex = useMemo(() => { const index = new Map(); if (!deferredLayout) return index; - for (const entry of deferredLayout.storage) { - index.set(formatSlotHex(BigInt(entry.slot)), entry); - const typeInfo = deferredLayout.types[entry.type]; - if (typeInfo?.encoding === 'inplace' && typeInfo.members) { - for (const member of typeInfo.members) { - const memberSlot = BigInt(entry.slot) + BigInt(member.slot); - index.set(formatSlotHex(memberSlot), { ...member, slot: memberSlot.toString() }); - } - } - } + walkStorageEntries(deferredLayout, ({ entry, slot, slotHex, isMember }) => { + index.set(slotHex, isMember ? { ...entry, slot: slot.toString() } : entry); + }); return index; }, [deferredLayout]); diff --git a/src/components/explorer/useStorageViewerState.ts b/src/components/explorer/useStorageViewerState.ts index 8b03c8dc..5c97a031 100644 --- a/src/components/explorer/useStorageViewerState.ts +++ b/src/components/explorer/useStorageViewerState.ts @@ -14,6 +14,7 @@ import { computeNestedMappingSlot, formatSlotHex, parseSlotInput, + resolveAbiKeyType, ZERO_WORD, } from '../../utils/storageSlotCalculator'; import { resolveContractContext } from '../../utils/resolver/contractContext'; @@ -586,14 +587,7 @@ export function useStorageViewerState() { const isArray = currentSegment.slotKind === 'dynamic_array'; const keyTypeId = currentSegment.keyTypeId; - let keyType = 'uint256'; - if (!isArray && keyTypeId) { - if (keyTypeId.includes('address') || keyTypeId.startsWith('t_contract')) keyType = 'address'; - else if (keyTypeId.includes('bytes32')) keyType = 'bytes32'; - else if (keyTypeId.includes('bool')) keyType = 'bool'; - else if (keyTypeId.includes('uint')) keyType = 'uint256'; - else if (keyTypeId.includes('int')) keyType = 'int256'; - } + const keyType = isArray ? 'uint256' : (resolveAbiKeyType({ typeId: keyTypeId }) ?? 'uint256'); const key = keyInput.trim(); setIsLookingUp(true); diff --git a/src/components/integrations/lifi-earn/hooks/evmRead.ts b/src/components/integrations/lifi-earn/hooks/evmRead.ts new file mode 100644 index 00000000..c850721c --- /dev/null +++ b/src/components/integrations/lifi-earn/hooks/evmRead.ts @@ -0,0 +1,60 @@ +import { ethers } from "ethers"; +import { networkConfigManager } from "../../../../config/networkConfig"; +import { SUPPORTED_CHAINS } from "../../../../utils/chains"; +import { isNativeToken } from "../../../../utils/addressConstants"; + +export function getReadProvider( + chainId: number, +): ethers.providers.JsonRpcProvider { + const chain = SUPPORTED_CHAINS.find((c) => c.id === chainId); + if (!chain) throw new Error(`Chain ${chainId} not supported`); + + const resolution = networkConfigManager.resolveRpcUrl(chainId, chain.rpcUrl); + if (!resolution.url) { + throw new Error( + `No RPC URL configured for chain ${chainId}. Set a custom RPC or enable the public fallback in Network Settings.`, + ); + } + return new ethers.providers.JsonRpcProvider(resolution.url); +} + +export async function readErc20Balance( + tokenAddress: string, + ownerAddress: string, + chainId: number, +): Promise { + const provider = getReadProvider(chainId); + + if (isNativeToken(tokenAddress)) { + const raw: ethers.BigNumber = await provider.getBalance(ownerAddress); + return raw.toString(); + } + + const contract = new ethers.Contract( + tokenAddress, + ["function balanceOf(address owner) view returns (uint256)"], + provider, + ); + const raw: ethers.BigNumber = await contract.balanceOf(ownerAddress); + return raw.toString(); +} + +export async function readErc20Allowance( + tokenAddress: string, + ownerAddress: string, + spenderAddress: string, + chainId: number, +): Promise { + const provider = getReadProvider(chainId); + const contract = new ethers.Contract( + tokenAddress, + ["function allowance(address owner, address spender) view returns (uint256)"], + provider, + ); + + const allowance: ethers.BigNumber = await contract.allowance( + ownerAddress, + spenderAddress, + ); + return allowance.toString(); +} diff --git a/src/components/integrations/lifi-earn/hooks/useTokenAllowance.ts b/src/components/integrations/lifi-earn/hooks/useTokenAllowance.ts index 3cca0c1a..55825476 100644 --- a/src/components/integrations/lifi-earn/hooks/useTokenAllowance.ts +++ b/src/components/integrations/lifi-earn/hooks/useTokenAllowance.ts @@ -1,40 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { ethers } from "ethers"; -import { networkConfigManager } from "../../../../config/networkConfig"; -import { SUPPORTED_CHAINS } from "../../../../utils/chains"; - -const ERC20_ALLOWANCE_ABI = [ - "function allowance(address owner, address spender) view returns (uint256)", -]; - -async function fetchAllowance( - tokenAddress: string, - ownerAddress: string, - spenderAddress: string, - chainId: number -): Promise { - const chain = SUPPORTED_CHAINS.find((c) => c.id === chainId); - if (!chain) throw new Error(`Chain ${chainId} not supported`); - - const resolution = networkConfigManager.resolveRpcUrl(chainId, chain.rpcUrl); - if (!resolution.url) { - throw new Error( - `No RPC URL configured for chain ${chainId}. Set a custom RPC or enable the public fallback in Network Settings.`, - ); - } - const provider = new ethers.providers.JsonRpcProvider(resolution.url); - const contract = new ethers.Contract( - tokenAddress, - ERC20_ALLOWANCE_ABI, - provider - ); - - const allowance: ethers.BigNumber = await contract.allowance( - ownerAddress, - spenderAddress - ); - return allowance.toString(); -} +import { readErc20Allowance } from "./evmRead"; export function useTokenAllowance(params: { tokenAddress: string | null; @@ -51,7 +16,7 @@ export function useTokenAllowance(params: { params.chainId, ], queryFn: () => - fetchAllowance( + readErc20Allowance( params.tokenAddress!, params.ownerAddress!, params.spenderAddress!, diff --git a/src/components/integrations/lifi-earn/hooks/useTokenBalance.ts b/src/components/integrations/lifi-earn/hooks/useTokenBalance.ts index d1bbab62..b615d51a 100644 --- a/src/components/integrations/lifi-earn/hooks/useTokenBalance.ts +++ b/src/components/integrations/lifi-earn/hooks/useTokenBalance.ts @@ -1,42 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { ethers } from "ethers"; -import { networkConfigManager } from "../../../../config/networkConfig"; -import { SUPPORTED_CHAINS } from "../../../../utils/chains"; -import { isNativeToken } from "../../../../utils/addressConstants"; - -const ERC20_BALANCE_ABI = [ - "function balanceOf(address owner) view returns (uint256)", -]; - -async function fetchBalance( - tokenAddress: string, - ownerAddress: string, - chainId: number, -): Promise { - const chain = SUPPORTED_CHAINS.find((c) => c.id === chainId); - if (!chain) throw new Error(`Chain ${chainId} not supported`); - - const resolution = networkConfigManager.resolveRpcUrl(chainId, chain.rpcUrl); - if (!resolution.url) { - throw new Error( - `No RPC URL configured for chain ${chainId}. Set a custom RPC or enable the public fallback in Network Settings.`, - ); - } - const provider = new ethers.providers.JsonRpcProvider(resolution.url); - - if (isNativeToken(tokenAddress)) { - const raw: ethers.BigNumber = await provider.getBalance(ownerAddress); - return raw.toString(); - } - - const contract = new ethers.Contract( - tokenAddress, - ERC20_BALANCE_ABI, - provider, - ); - const raw: ethers.BigNumber = await contract.balanceOf(ownerAddress); - return raw.toString(); -} +import { readErc20Balance } from "./evmRead"; export function useTokenBalance(params: { tokenAddress: string | null; @@ -51,7 +14,7 @@ export function useTokenBalance(params: { params.chainId, ], queryFn: () => - fetchBalance( + readErc20Balance( params.tokenAddress!, params.ownerAddress!, params.chainId!, diff --git a/src/components/shared/SelectorDecoder.tsx b/src/components/shared/SelectorDecoder.tsx index 862bf7ec..ef6998ff 100644 --- a/src/components/shared/SelectorDecoder.tsx +++ b/src/components/shared/SelectorDecoder.tsx @@ -40,21 +40,89 @@ const SelectorDecoder: React.FC = ({ setIsDecoding(true); setProgress({ current: 0, total: selectors.length }); - const results: DecodedSelector[] = []; + const results: (DecodedSelector | null)[] = []; try { + // Pass 1: resolve every selector against local sources (custom + cache) + // synchronously. Selectors that need a network lookup are collected as + // misses (preserving order via placeholder slots in `results`). + const customSignatures = getCustomSignatures(); + const cachedFunctions = getCachedSignatures('function'); + const misses: { cleanSelector: string; slot: number }[] = []; + for (let i = 0; i < selectors.length; i++) { const selector = selectors[i]; setProgress({ current: i + 1, total: selectors.length }); - const decoded = await decodeSingleSelector(selector); - if (decoded) { - results.push(decoded); + const local = decodeLocalSelector(selector, customSignatures, cachedFunctions); + if (local) { + results.push(local); + } else { + const cleanSelector = selector.startsWith('0x') ? selector : `0x${selector}`; + misses.push({ cleanSelector, slot: results.length }); + results.push(null); + } + } + + // Pass 2: resolve misses against OpenChain in chunks (comma-joined per + // chunk). Chunking bounds the request URL length for large facets, and a + // failed chunk falls back to per-selector lookups so one failure can't drop + // every result (the pre-batch serial path could partially succeed). + if (misses.length > 0) { + const SELECTOR_LOOKUP_CHUNK = 50; + const CIRCUIT_BREAK_FAILURES = 3; // consecutive failures ⇒ OpenChain is down + const functionMap: SignatureResponse['result']['function'] = {}; + // Circuit breaker: once OpenChain looks down, stop issuing requests so a + // failed chunk's per-selector fallback can't fan out into N serial failures. + let consecutiveFailures = 0; + let circuitOpen = false; + + for (let c = 0; c < misses.length && !circuitOpen; c += SELECTOR_LOOKUP_CHUNK) { + const chunk = misses + .slice(c, c + SELECTOR_LOOKUP_CHUNK) + .map(m => m.cleanSelector); + try { + const openChainResult = await lookupFunctionSignatures(chunk); + Object.assign(functionMap, openChainResult.result?.function ?? {}); + consecutiveFailures = 0; + } catch (error) { + console.warn('Batched selector lookup failed; retrying this chunk per-selector:', error); + for (const sel of chunk) { + if (circuitOpen) break; + try { + const single = await lookupFunctionSignatures([sel]); + Object.assign(functionMap, single.result?.function ?? {}); + consecutiveFailures = 0; + } catch { + // leave this selector unresolved + consecutiveFailures += 1; + if (consecutiveFailures >= CIRCUIT_BREAK_FAILURES) { + circuitOpen = true; // give up the remaining lookups + } + } + } + } + } + + for (const { cleanSelector, slot } of misses) { + const signatures = functionMap[cleanSelector]; + if (signatures && signatures.length > 0) { + const signature = signatures[0]; + results[slot] = { + selector: cleanSelector, + signature: typeof signature === 'string' ? signature : signature.name, + source: 'openchain', + confidence: signatures.length > 1 ? 'medium' : 'high' + }; + } } } - setDecodedResults(results); - onDecoded?.(results); + // Drop unresolved placeholder slots, preserving original order. + const finalResults = results.filter((r): r is DecodedSelector => r != null); + + setDecodedResults(finalResults); + onDecoded?.(finalResults); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to decode selectors'; onError?.(errorMessage); @@ -69,12 +137,17 @@ const SelectorDecoder: React.FC = ({ } }, [selectorsKey, decodeSelectors]); - const decodeSingleSelector = async (selector: string): Promise => { + // Resolve a single selector against local sources only (no network). Returns + // null when the selector must fall through to the batched OpenChain lookup. + const decodeLocalSelector = ( + selector: string, + customSignatures: ReturnType, + cachedFunctions: ReturnType + ): DecodedSelector | null => { // Ensure selector is properly formatted const cleanSelector = selector.startsWith('0x') ? selector : `0x${selector}`; - + // 1. Try custom signatures first (highest confidence) - const customSignatures = getCustomSignatures(); const customMatch = customSignatures.find(sig => sig.signature.includes(cleanSelector)); if (customMatch) { return { @@ -86,7 +159,6 @@ const SelectorDecoder: React.FC = ({ } // 2. Try cached signatures (medium-high confidence) - const cachedFunctions = getCachedSignatures('function'); if (cachedFunctions[cleanSelector]) { const cached = cachedFunctions[cleanSelector]; return { @@ -97,25 +169,6 @@ const SelectorDecoder: React.FC = ({ }; } - // 3. Try OpenChain lookup (medium confidence) - try { - const openChainResult: SignatureResponse = await lookupFunctionSignatures([cleanSelector]); - const signatures = openChainResult.result?.function?.[cleanSelector]; - - if (signatures && signatures.length > 0) { - // Use the first signature (most common) - const signature = signatures[0]; - return { - selector: cleanSelector, - signature: typeof signature === 'string' ? signature : signature.name, - source: 'openchain', - confidence: signatures.length > 1 ? 'medium' : 'high' - }; - } - } catch (error) { - console.warn(`Failed to lookup selector ${cleanSelector}:`, error); - } - return null; }; diff --git a/src/components/signature-database/SearchTab.tsx b/src/components/signature-database/SearchTab.tsx index 5035e9a3..4f84f067 100644 --- a/src/components/signature-database/SearchTab.tsx +++ b/src/components/signature-database/SearchTab.tsx @@ -1,9 +1,9 @@ import React from "react"; +import { List } from "react-window"; import { SearchIcon } from "../icons/IconLibrary"; import { CopyButton } from "../ui/copy-button"; import { Badge } from "../ui/badge"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs"; -import { ScrollArea } from "../ui/scroll-area"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { WarningCircle } from "@phosphor-icons/react"; import { @@ -175,117 +175,145 @@ const SearchTab: React.FC = ({ value="functions" className="mt-2 responsive-scroll" > - -
- {flattenedFunctionResults.map((item, i) => ( - - -
- + { + const item = flattenedFunctionResults[index]; + if (!item) return
; + return ( +
+ + +
+ + {item.name} + +
+
+ - {item.name} - -
- - -
- - Selector - -
- {item.hash} - -
-
-
- - Signature - - -
-
- - ))} -
- +
+ + Selector + +
+ {item.hash} + +
+
+
+ + Signature + + +
+ + +
+ ); + }} + /> +
)} {flattenedEventResults.length > 0 && ( - -
- {flattenedEventResults.map((item, i) => ( - - -
- + { + const item = flattenedEventResults[index]; + if (!item) return
; + return ( +
+ + +
+ + {item.name} + +
+
+ - {item.name} - -
- - -
- - Topic Hash - -
- - {item.hash.slice(0, 10)} - {"\u2026"} - {item.hash.slice(-6)} - - -
-
-
- - Signature - - -
-
- - ))} -
- +
+ + Topic Hash + +
+ + {item.hash.slice(0, 10)} + {"\u2026"} + {item.hash.slice(-6)} + + +
+
+
+ + Signature + + +
+ + +
+ ); + }} + /> +
)} diff --git a/src/components/simple-grid/hooks/useContractState.ts b/src/components/simple-grid/hooks/useContractState.ts index d7856631..ece720da 100644 --- a/src/components/simple-grid/hooks/useContractState.ts +++ b/src/components/simple-grid/hooks/useContractState.ts @@ -5,7 +5,7 @@ import { useState, useCallback, useRef } from "react"; import { ethers } from "ethers"; import type { Chain, ContractInfo } from "../../../types"; import { SUPPORTED_CHAINS, getChainById } from "../../../utils/chains"; -import { fetchContractInfoComprehensive } from "../../../utils/comprehensiveContractFetcher"; +import { contractResolver } from "../../../utils/resolver"; import { resolveContractContext } from "../../../utils/resolver"; import type { ProxyInfo } from "../../../utils/resolver"; import { detectTokenType } from "../../../utils/universalTokenDetector"; @@ -312,23 +312,36 @@ export function useContractState(deps: UseContractStateDeps) { try { const chainConfig = getChainById(selectedNetwork?.id || 0) || (selectedNetwork as Chain); - const result = await fetchContractInfoComprehensive( - contractAddress, - chainConfig, - (progress) => { if (!isStale()) setSearchProgress(progress); } - ); + const result = await contractResolver.resolve(contractAddress, chainConfig, { + onProgress: (attempt) => { + if (isStale()) return; + setSearchProgress({ + source: attempt.source, + status: + attempt.status === "success" + ? "found" + : attempt.status === "failed" || attempt.status === "timeout" + ? "not_found" + : attempt.status === "fetching" + ? "searching" + : "error", + message: attempt.error, + }); + }, + }); if (isStale()) return; - if (result.success && result.abi) { + if (result.abi) { try { - const parsedABI = sanitizeAbiEntries(JSON.parse(result.abi)); + const abiString = JSON.stringify(result.abi); + const parsedABI = sanitizeAbiEntries(JSON.parse(abiString)); const contractInfoObj: ContractInfo = { address: result.address, chain: result.chain, - abi: result.abi, + abi: abiString, verified: !!result.verified, - name: result.contractName || undefined, + name: result.name || undefined, }; if (isStale()) return; setContractInfo(contractInfoObj); @@ -339,7 +352,7 @@ export function useContractState(deps: UseContractStateDeps) { const eventSignatures = getEventSignatures(parsedABI); await detectAndFetchTokenInfo(parsedABI, true, functionNames, eventSignatures); if (isStale()) return; - if (result.contractName) setContractName(result.contractName); + if (result.name) setContractName(result.name); if (result.tokenInfo) tokenSetters.setTokenInfo(result.tokenInfo); if (result.source) setAbiSource(result.source as AbiSourceType); diff --git a/src/components/simulation-results/StateTab.tsx b/src/components/simulation-results/StateTab.tsx index 1f2ccceb..095a9d7f 100644 --- a/src/components/simulation-results/StateTab.tsx +++ b/src/components/simulation-results/StateTab.tsx @@ -274,6 +274,20 @@ export const StateTab: React.FC = ({ result, artifacts, contractC return { match, fields }; }; + // Memoize decoding per diff so it doesn't re-run matchSlot/decodeDiffFields + // (which can hash mapping slots) on every re-render. Recomputes only when the + // layouts, descriptor indices, known keys, or diffs change. + const decodedByDiff = useMemo(() => { + const map = new Map(); + for (const diff of storageDiffs) { + map.set(diff, getDecodedInfo(diff)); + } + return map; + // getDecodedInfo closes over layouts/descriptorIndices/knownKeys; depend on + // those plus storageDiffs so memo invalidates exactly when inputs change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layouts, descriptorIndices, knownKeys, storageDiffs]); + // Group diffs by contract address const groupedByContract = (() => { const map = new Map = ({ result, artifacts, contractC {contract.diffs.map((diff: any, diffIdx: number) => { const beforeHex = formatHex(diff.before); const afterHex = formatHex(diff.after || diff.value); - const decoded = getDecodedInfo(diff); + const decoded = decodedByDiff.get(diff) ?? null; return (
diff --git a/src/components/simulation-results/useSimulationPageState.ts b/src/components/simulation-results/useSimulationPageState.ts index eeda6fff..3d4ba8b5 100644 --- a/src/components/simulation-results/useSimulationPageState.ts +++ b/src/components/simulation-results/useSimulationPageState.ts @@ -7,12 +7,17 @@ import { type SimulationCallNode, } from "../../utils/simulationArtifacts"; import { copyTextToClipboard } from "../../utils/clipboard"; +import { TXHASH_REPLAY_KEY } from "../transaction-builder/types"; import { useSimulation } from "../../contexts/SimulationContext"; import { useNetworkConfig } from "../../contexts/NetworkConfigContext"; import { useNotifications } from "../NotificationManager"; import type { TraceFilters } from "../ExecutionStackTrace"; import { collectTraceAddresses, createTraceContractMap } from "../../utils/traceAddressCollector"; import { traceVaultService } from "../../services/TraceVaultService"; +import { + loadStoredSimulation, + persistDecodedTrace as coordinatorPersist, +} from "../../services/simulationStore"; import { useDecodedTrace } from "../../hooks/useDecodedTrace"; import { useDebug } from "../../contexts/DebugContext"; import { getChainById } from "../../utils/chains"; @@ -25,7 +30,6 @@ import { type InternalInfoRow, type ContractContextExtras, type SimulationResultExtras, - hasInternalInfo, buildAddressToNameMap, buildRevertInfo, buildTraceDiagnostics, @@ -85,45 +89,18 @@ export function useSimulationPageState(props: SimulationResultsPageProps) { setLoadError(null); try { - const { simulationHistoryService } = await import('../../services/SimulationHistoryService'); - const stored = await simulationHistoryService.getSimulation(id); - - if (stored) { - setSimulation(stored.result, stored.contractContext, { skipHistorySave: true }); - try { - const traceBundle = await traceVaultService.loadDecodedTrace(id, { includeHeavy: false }); - let rowsToUse = traceBundle?.rows; - if ( - stored.decodedTraceRows && - stored.decodedTraceRows.length > 0 && - (!rowsToUse || - rowsToUse.length === 0 || - (!hasInternalInfo(rowsToUse) && - hasInternalInfo(stored.decodedTraceRows))) - ) { - const { recomputeHierarchy } = await import('../../services/TraceVaultService'); - rowsToUse = recomputeHierarchy(stored.decodedTraceRows); - } - if (rowsToUse && rowsToUse.length > 0) { - setDecodedTraceRows(rowsToUse); - } - if (traceBundle?.sourceTexts && Object.keys(traceBundle.sourceTexts).length > 0) { - setSourceTexts(traceBundle.sourceTexts); - } - if (traceBundle) { - setDecodedTraceMeta({ - sourceLines: traceBundle.sourceLines ?? [], - callMeta: traceBundle.callMeta, - rawEvents: traceBundle.rawEvents ?? [], - implementationToProxy: traceBundle.implementationToProxy, - }); - } - } catch (traceErr) { - console.warn("[SimulationResultsPage] Failed to load trace vault:", traceErr); - if (stored.decodedTraceRows && stored.decodedTraceRows.length > 0) { - const { recomputeHierarchy } = await import('../../services/TraceVaultService'); - setDecodedTraceRows(recomputeHierarchy(stored.decodedTraceRows)); - } + const loaded = await loadStoredSimulation(id); + + if (loaded) { + setSimulation(loaded.result, loaded.contractContext, { skipHistorySave: true }); + if (loaded.decodedRows && loaded.decodedRows.length > 0) { + setDecodedTraceRows(loaded.decodedRows); + } + if (loaded.sourceTexts) { + setSourceTexts(loaded.sourceTexts); + } + if (loaded.meta) { + setDecodedTraceMeta(loaded.meta); } } else { setLoadError(`Simulation not found`); @@ -183,7 +160,7 @@ export function useSimulationPageState(props: SimulationResultsPageProps) { typeof resultDebugEnabled === 'boolean' ? resultDebugEnabled : typeof contextDebugEnabled === 'boolean' ? contextDebugEnabled : false, }; - localStorage.setItem('web3-toolkit:txhash-replay', JSON.stringify(replayData)); + localStorage.setItem(TXHASH_REPLAY_KEY, JSON.stringify(replayData)); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('web3-toolkit:txhash-replay-updated', { detail: replayData })); } @@ -299,24 +276,7 @@ export function useSimulationPageState(props: SimulationResultsPageProps) { // ---- Persist decoded trace ---- const persistDecodedTrace = useCallback( async (decoded: any, simulationId: string) => { - const hasJumpRows = decoded?.rows?.some((r: any) => r?.destFn || r?.jumpMarker || r?.isInternalCall); - const jumpRowCount = decoded?.rows?.filter((r: any) => r?.destFn || r?.jumpMarker || r?.isInternalCall).length ?? 0; - - try { - const existingTrace = await traceVaultService.loadDecodedTrace(simulationId, { includeHeavy: false }); - const existingJumpCount = existingTrace?.rows?.filter((r: any) => r?.destFn || r?.jumpMarker || r?.isInternalCall).length ?? 0; - - if (existingJumpCount > 0 && jumpRowCount === 0) return; - - const saved = await traceVaultService.saveDecodedTrace(simulationId, decoded); - const rowsToStore = saved?.lite?.rows ?? decoded.rows; - const { simulationHistoryService } = await import("../../services/SimulationHistoryService"); - await simulationHistoryService.updateSimulationDecodedRows(simulationId, rowsToStore, { - maxRetries: 6, delayMs: 150, - }); - } catch (err) { - console.error("[SimulationResults] Failed to persist trace:", err); - } + await coordinatorPersist(simulationId, decoded); }, [] ); diff --git a/src/components/transaction-builder/types.ts b/src/components/transaction-builder/types.ts index 34a12607..640d5001 100644 --- a/src/components/transaction-builder/types.ts +++ b/src/components/transaction-builder/types.ts @@ -6,6 +6,40 @@ import type { Chain } from "../../types"; // ---- View mode type ---- export type SimulationViewMode = "builder" | "replay"; +// ---- Canonical URL/storage intent parser ---- +// Resolves the `?mode=live|simulation|replay` and `?replay=txhash` URL signals +// into the surfaces the Hub (live-vs-simulation) and Wagmi (builder-vs-replay) +// state machines consume. A `null` field means the URL carried no signal for +// that surface, in which case callers keep their existing runtime fallbacks +// (localStorage / clone / contractContext). +export interface BuilderIntent { + mode: "live" | "simulation" | null; + viewMode: "builder" | "replay" | null; +} + +export function parseBuilderIntent(search: string): BuilderIntent { + const params = new URLSearchParams(search); + const requestedMode = params.get("mode"); + + // Hub surface: live-vs-simulation. `replay` collapses to simulation. + let mode: BuilderIntent["mode"] = null; + if (requestedMode === "live") { + mode = "live"; + } else if (requestedMode === "simulation" || requestedMode === "replay") { + mode = "simulation"; + } + + // Wagmi surface: builder-vs-replay within simulation. + let viewMode: BuilderIntent["viewMode"] = null; + if (requestedMode === "replay" || params.get("replay") === "txhash") { + viewMode = "replay"; + } else if (requestedMode === "simulation") { + viewMode = "builder"; + } + + return { mode, viewMode }; +} + // ---- Transaction preview data fetched before enabling replay ---- export interface TxPreviewData { from: string; diff --git a/src/contexts/debug/debugHelpers.ts b/src/contexts/debug/debugHelpers.ts index d980ec2b..129688f2 100644 --- a/src/contexts/debug/debugHelpers.ts +++ b/src/contexts/debug/debugHelpers.ts @@ -6,7 +6,6 @@ * * Solidity struct layout analysis lives in ./solidityStructLayout.ts. * Storage-based struct decoding lives in ./structStorageDecoding.ts. - * This module re-exports everything for backward compatibility. */ import type { @@ -19,31 +18,6 @@ import type { } from '../../types/debug'; import type { DecodedTraceRow } from '../../utils/traceDecoder'; -// ── Re-exports from extracted modules ────────────────────────────────── - -export type { - StructFieldDef, - StructFieldLayout, -} from './solidityStructLayout'; - -export { - stripSolidityComments, - extractBraceBlock, - extractParenBlock, - splitParams, - findVariableTypeInFunction, - parseTypeSpec, - getBaseTypeSize, - findStructFields, - buildStructLayout, - toBigIntValue, - formatHex, - decodeScalarValue, - decodeFieldFromSlot, - parseStorageRead, - parseStorageWrite, -} from './solidityStructLayout'; - // ── Gated debug logger ───────────────────────────────────────────────── const EDB_DEBUG_LOGS = import.meta.env.DEV && typeof localStorage !== 'undefined' && localStorage.getItem('edb:debugLogs') === '1'; diff --git a/src/contexts/debug/evalSnapshotResolver.ts b/src/contexts/debug/evalSnapshotResolver.ts index befd0293..477e693d 100644 --- a/src/contexts/debug/evalSnapshotResolver.ts +++ b/src/contexts/debug/evalSnapshotResolver.ts @@ -9,6 +9,7 @@ import type { SourceFile, HookSnapshotDetail, } from '../../types/debug'; +import type { DecodedTraceRow } from '../../utils/traceDecoder'; import { debugBridgeService } from '../../services/DebugBridgeService'; import { enhanceHookSnapshot, @@ -18,6 +19,11 @@ import { debugLog, HOOK_SCAN_CHUNK_SIZE, } from './debugHelpers'; +import type { SnapshotCacheWriter } from './snapshotCacheStore'; +import { + getTraceRowBytecodeAddress, + scoreOpcodeSnapshotCandidate, +} from './traceRowScoring'; // ── Constants ────────────────────────────────────────────────────────── @@ -73,7 +79,7 @@ export interface LiveSessionReadyDeps { sessionRef: { current: { sessionId: string; totalSnapshots?: number } | null }; sourceFilesRef: { current: Map }; snapshotCache: Map; - setSnapshotCache: (updater: (prev: Map) => Map) => void; + snapshotCacheWriter: SnapshotCacheWriter; } export async function waitForLiveSessionReady( @@ -125,15 +131,7 @@ export async function waitForLiveSessionReady( snapshotId: candidateId, }); const resolved = enhanceHookSnapshot(response.snapshot, deps.sourceFilesRef.current); - deps.setSnapshotCache((prev) => { - const next = new Map(prev); - next.set(candidateId, resolved); - if (next.size > 500) { - const sortedKeys = [...next.keys()].sort((a, b) => a - b); - sortedKeys.slice(0, next.size - 500).forEach((k) => next.delete(k)); - } - return next; - }); + deps.snapshotCacheWriter.set(candidateId, resolved); return { ready: true, snapshotId: candidateId }; } catch (err) { if (isSessionNotFoundError(err)) { @@ -167,7 +165,7 @@ export interface ScanForHookSnapshotDeps { session: { totalSnapshots?: number } | null; sourceFilesRef: { current: Map }; snapshotCache: Map; - setSnapshotCache: (updater: (prev: Map) => Map) => void; + snapshotCacheWriter: SnapshotCacheWriter; } export async function scanForHookSnapshot( @@ -214,7 +212,7 @@ export async function scanForHookSnapshot( snapshotId, }); const resolved = enhanceHookSnapshot(response.snapshot, deps.sourceFilesRef.current); - deps.setSnapshotCache((prev) => { const next = new Map(prev); next.set(snapshotId, resolved); if (next.size > 500) { const sortedKeys = [...next.keys()].sort((a, b) => a - b); sortedKeys.slice(0, next.size - 500).forEach(k => next.delete(k)); } return next; }); + deps.snapshotCacheWriter.set(snapshotId, resolved); if (resolved.type !== 'hook') return null; if (!matchesTraceId(resolved.frameId, traceId)) { return null; @@ -291,7 +289,7 @@ export interface ResolveEvalSnapshotDeps { currentSnapshotId: number | null; currentSnapshot: DebugSnapshot | null; snapshotCache: Map; - setSnapshotCache: (updater: (prev: Map) => Map) => void; + snapshotCacheWriter: SnapshotCacheWriter; snapshotList: SnapshotListItem[]; setSnapshotList: (list: SnapshotListItem[]) => void; sourceFilesRef: { current: Map }; @@ -380,7 +378,7 @@ export async function resolveEvalSnapshotId( snapshotId: candidateId, }); const resolved = enhanceHookSnapshot(response.snapshot, deps.sourceFilesRef.current); - deps.setSnapshotCache((prev) => { const next = new Map(prev); next.set(candidateId, resolved); if (next.size > 500) { const sortedKeys = [...next.keys()].sort((a, b) => a - b); sortedKeys.slice(0, next.size - 500).forEach(k => next.delete(k)); } return next; }); + deps.snapshotCacheWriter.set(candidateId, resolved); if (resolved.type === 'hook' && matchesTraceId(resolved.frameId, currentTraceId)) { return candidateId; } @@ -703,3 +701,85 @@ export async function resolveEvalSnapshotId( return null; } + +// ── Resolve live snapshot from trace row ─────────────────────────────── + +export interface ResolveLiveSnapshotDeps { + decodedTraceRowsRef: { current: DecodedTraceRow[] | null }; + snapshotCache: Map; + snapshotCacheWriter: SnapshotCacheWriter; + traceToLiveSnapshotCacheRef: { current: Map }; +} + +export async function resolveLiveSnapshotFromTraceRow( + deps: ResolveLiveSnapshotDeps, + sessionId: string, + traceStepId: number +): Promise<{ snapshotId: number; snapshot: DebugSnapshot } | null> { + const traceRow = deps.decodedTraceRowsRef.current?.find((row) => row.id === traceStepId) ?? null; + const bytecodeAddress = getTraceRowBytecodeAddress(traceRow); + if (!traceRow || !bytecodeAddress || typeof traceRow.pc !== 'number') { + return null; + } + + const cacheKey = `${sessionId}:${traceStepId}:${bytecodeAddress}:${traceRow.pc}`; + const cachedSnapshotId = deps.traceToLiveSnapshotCacheRef.current.get(cacheKey); + if (typeof cachedSnapshotId === 'number') { + const cachedSnapshot = deps.snapshotCache.get(cachedSnapshotId); + if (cachedSnapshot) { + return { snapshotId: cachedSnapshotId, snapshot: cachedSnapshot }; + } + } + + const breakpointHits = await debugBridgeService.getBreakpointHits({ + sessionId, + breakpoints: [ + { + location: { + type: 'opcode', + bytecodeAddress, + pc: traceRow.pc, + }, + }, + ], + }); + + const candidateIds = breakpointHits.hits.filter((id) => Number.isInteger(id) && id >= 0); + if (candidateIds.length === 0) { + return null; + } + + let bestMatch: + | { snapshotId: number; snapshot: DebugSnapshot; score: number } + | null = null; + + for (const candidateId of candidateIds.slice(0, 16)) { + try { + const response = await debugBridgeService.getSnapshot({ + sessionId, + snapshotId: candidateId, + }); + const candidateSnapshot = response.snapshot; + const score = scoreOpcodeSnapshotCandidate(traceRow, candidateSnapshot); + + if (!bestMatch || score > bestMatch.score) { + bestMatch = { + snapshotId: candidateId, + snapshot: candidateSnapshot, + score, + }; + } + } catch { + // Ignore candidate fetch errors and continue scoring remaining hits. + } + } + + if (!bestMatch) { + return null; + } + + deps.traceToLiveSnapshotCacheRef.current.set(cacheKey, bestMatch.snapshotId); + deps.snapshotCacheWriter.set(bestMatch.snapshotId, bestMatch.snapshot); + + return { snapshotId: bestMatch.snapshotId, snapshot: bestMatch.snapshot }; +} diff --git a/src/contexts/debug/snapshotCacheStore.ts b/src/contexts/debug/snapshotCacheStore.ts new file mode 100644 index 00000000..666f0cbd --- /dev/null +++ b/src/contexts/debug/snapshotCacheStore.ts @@ -0,0 +1,45 @@ +// Single recency-LRU eviction policy (cap SNAPSHOT_CACHE_MAX) for the +// number-keyed DebugSnapshot cache shared across the debug hooks. + +import type { DebugSnapshot } from '../../types/debug'; + +export const SNAPSHOT_CACHE_MAX = 500; + +// Pure recency-LRU write: returns a new Map, evicting the oldest entry past the cap. +export function writeSnapshotToCache( + prev: Map, + id: number, + snapshot: DebugSnapshot +): Map { + const next = new Map(prev); + if (next.has(id)) { + next.delete(id); + } + next.set(id, snapshot); + while (next.size > SNAPSHOT_CACHE_MAX) { + const oldestKey = next.keys().next().value; + if (oldestKey === undefined) break; + next.delete(oldestKey); + } + return next; +} + +export interface SnapshotCacheWriter { + set(id: number, snapshot: DebugSnapshot): void; +} + +/** + * Wraps a React setSnapshotCache updater so callers can write through the one + * eviction policy with { set(id, snapshot) }. + */ +export function createSnapshotCacheWriter( + setSnapshotCache: ( + updater: (prev: Map) => Map + ) => void +): SnapshotCacheWriter { + return { + set(id: number, snapshot: DebugSnapshot) { + setSnapshotCache((prev) => writeSnapshotToCache(prev, id, snapshot)); + }, + }; +} diff --git a/src/contexts/debug/solidityStructLayout.ts b/src/contexts/debug/solidityStructLayout.ts index 77493742..be83d9ed 100644 --- a/src/contexts/debug/solidityStructLayout.ts +++ b/src/contexts/debug/solidityStructLayout.ts @@ -135,49 +135,6 @@ export function findVariableTypeInFunction( return null; } -// ── Type specification parsing ───────────────────────────────────────── - -export function parseTypeSpec(typeName: string): { - base: string; - arrayDims: Array; - isMapping: boolean; - isDynamic: boolean; -} { - const cleaned = typeName.replace(/\s+/g, ' ').trim(); - if (cleaned.startsWith('mapping')) { - return { base: 'mapping', arrayDims: [], isMapping: true, isDynamic: true }; - } - - const arrayDims: Array = []; - const arrayRegex = /\[[0-9]*\]/g; - let match: RegExpExecArray | null; - while ((match = arrayRegex.exec(cleaned)) !== null) { - const value = match[0].slice(1, -1); - arrayDims.push(value ? Number(value) : null); - } - - const base = cleaned.replace(arrayRegex, '').trim(); - const isDynamic = - base === 'string' || - base === 'bytes' || - arrayDims.some((dim) => dim === null); - return { base, arrayDims, isMapping: false, isDynamic }; -} - -export function getBaseTypeSize(base: string): number | null { - if (base === 'bool') return 1; - if (base === 'address') return 20; - if (base === 'byte') return 1; - const bytesMatch = base.match(/^bytes(\d+)$/); - if (bytesMatch) return Number(bytesMatch[1]); - const intMatch = base.match(/^(u?int)(\d+)?$/); - if (intMatch) { - const bits = intMatch[2] ? Number(intMatch[2]) : 256; - return bits / 8; - } - return null; -} - // ── Struct field extraction ──────────────────────────────────────────── export function findStructFields( @@ -206,107 +163,6 @@ export function findStructFields( return null; } -// ── Layout computation (Solidity packing rules) ──────────────────────── - -export function buildStructLayout(fields: StructFieldDef[]): StructFieldLayout[] { - const layouts: StructFieldLayout[] = []; - let slot = 0; - let offset = 0; - - for (const field of fields) { - const typeSpec = parseTypeSpec(field.type); - const baseSize = getBaseTypeSize(typeSpec.base); - const isMapping = typeSpec.isMapping; - const isDynamicArray = typeSpec.arrayDims.some((dim) => dim === null); - const isDynamic = - typeSpec.isDynamic || - isMapping || - baseSize === null; - - if (isDynamic) { - if (offset > 0) { - slot += 1; - offset = 0; - } - layouts.push({ - name: field.name, - type: field.type, - base: typeSpec.base, - slotOffset: slot, - byteOffset: 0, - sizeBytes: 32, - isDynamic: true, - isMapping, - arrayElementBase: isDynamicArray ? typeSpec.base : undefined, - arrayElementSize: isDynamicArray && baseSize ? baseSize : undefined, - }); - slot += 1; - continue; - } - - let sizeBytes = baseSize ?? 32; - let arrayLength: number | undefined; - let arrayElementBase: string | undefined; - let arrayElementSize: number | undefined; - - if (typeSpec.arrayDims.length > 0 && baseSize !== null) { - arrayLength = typeSpec.arrayDims.reduce((acc: number, dim) => acc * (dim ?? 0), 1); - arrayElementBase = typeSpec.base; - arrayElementSize = baseSize; - sizeBytes = baseSize * (arrayLength ?? 1); - } - - if (sizeBytes > 32) { - if (offset > 0) { - slot += 1; - offset = 0; - } - layouts.push({ - name: field.name, - type: field.type, - base: typeSpec.base, - slotOffset: slot, - byteOffset: 0, - sizeBytes, - isDynamic: false, - isMapping, - arrayLength, - arrayElementBase, - arrayElementSize, - }); - slot += Math.ceil(sizeBytes / 32); - continue; - } - - if (offset + sizeBytes > 32) { - slot += 1; - offset = 0; - } - - layouts.push({ - name: field.name, - type: field.type, - base: typeSpec.base, - slotOffset: slot, - byteOffset: offset, - sizeBytes, - isDynamic: false, - isMapping, - arrayLength, - arrayElementBase, - arrayElementSize, - }); - - offset += sizeBytes; - if (offset === 32) { - slot += 1; - offset = 0; - } - } - - return layouts; -} - // ── Scalar & field value decoding ────────────────────────────────────── export function toBigIntValue(value: unknown): bigint | null { diff --git a/src/contexts/debug/structStorageDecoding.ts b/src/contexts/debug/structStorageDecoding.ts index bf8a65f9..4889b7d7 100644 --- a/src/contexts/debug/structStorageDecoding.ts +++ b/src/contexts/debug/structStorageDecoding.ts @@ -26,6 +26,8 @@ import { type SlotDescriptor, } from '../../utils/storageLayoutDecode'; import { reconstructStorageLayout } from '../../utils/solidity-layout'; +import { placeField, elementsPerSlot, type SlotCursor } from '../../utils/solidity-layout/allocatorTypeHelpers'; +import type { StorageLayoutResponse } from '../../types/debug'; import { debugLog, resolveSourceContent, @@ -37,10 +39,10 @@ import { import { findVariableTypeInFunction, findStructFields, - buildStructLayout, decodeFieldFromSlot, parseStorageRead, parseStorageWrite, + type StructFieldDef, type StructFieldLayout, } from './solidityStructLayout'; @@ -58,6 +60,257 @@ export function getSourceLineText( return lines[line - 1] ?? null; } +// ── AST → StructFieldLayout adapter ───────────────────────────────────── + +/** + * Derive the scalar `base` used by decodeScalarValue from a type label. + * Contract references decode as addresses; everything else uses the label + * verbatim (uintN, intN, bool, bytesN, enum X are all handled downstream). + */ +function baseFromTypeLabel(label: string): string { + if (label === 'address' || label.startsWith('contract ')) return 'address'; + return label; +} + +/** + * Map a single AST storage member (relative to its struct) to a + * StructFieldLayout, resolving array/dynamic/mapping shape from the layout + * type definitions. `slotBase` is the struct-relative slot of the parent. + */ +function memberToFieldLayout( + member: { label: string; offset: number; slot: string; type: string }, + layout: StorageLayoutResponse, + slotBase: number, +): StructFieldLayout { + const typeDef = layout.types[member.type]; + const slotOffset = slotBase + Number(member.slot); + const typeLabel = typeDef?.label ?? member.type; + const encoding = typeDef?.encoding ?? 'inplace'; + const sizeBytes = typeDef + ? Math.min(parseInt(typeDef.numberOfBytes, 10) || 32, 32) + : 32; + + // Mapping member + if (encoding === 'mapping') { + return { + name: member.label, + type: typeLabel, + base: baseFromTypeLabel(typeLabel), + slotOffset, + byteOffset: 0, + sizeBytes: 32, + isDynamic: false, + isMapping: true, + }; + } + + // Dynamic bytes/string or dynamic array + if (encoding === 'bytes' || encoding === 'dynamic_array') { + const elemBaseId = typeDef?.value; + const elemDef = elemBaseId ? layout.types[elemBaseId] : undefined; + return { + name: member.label, + type: typeLabel, + base: baseFromTypeLabel(typeLabel), + slotOffset, + byteOffset: 0, + sizeBytes: 32, + isDynamic: true, + isMapping: false, + arrayElementBase: elemDef ? baseFromTypeLabel(elemDef.label) : undefined, + arrayElementSize: elemDef + ? Math.min(parseInt(elemDef.numberOfBytes, 10) || 32, 32) + : undefined, + }; + } + + // Fixed array: type id "t_array()_storage" + const arrayMatch = member.type.match(/^t_array\((.+)\)(\d+)_storage$/); + if (arrayMatch) { + const elemId = arrayMatch[1]; + const arrayLength = Number(arrayMatch[2]); + const elemDef = layout.types[elemId]; + const elemSize = elemDef + ? Math.min(parseInt(elemDef.numberOfBytes, 10) || 32, 32) + : 1; + return { + name: member.label, + type: typeLabel, + base: elemDef ? baseFromTypeLabel(elemDef.label) : 'uint256', + slotOffset, + byteOffset: member.offset, + sizeBytes, + isDynamic: false, + isMapping: false, + arrayLength, + arrayElementBase: elemDef ? baseFromTypeLabel(elemDef.label) : undefined, + arrayElementSize: elemSize, + }; + } + + // Scalar (elementary / enum / contract) — packed inplace + return { + name: member.label, + type: typeLabel, + base: baseFromTypeLabel(typeLabel), + slotOffset, + byteOffset: member.offset, + sizeBytes, + isDynamic: false, + isMapping: false, + }; +} + +/** + * Adapt the AST allocator's struct member entries (layout.types[structTypeId] + * .members) into the flat StructFieldLayout[] the debug decoder consumes. + * + * Nested structs are flattened: each nested member is emitted with its + * absolute (struct-relative) slot offset and a dotted name, fixing the + * old regex walker's bug of treating a nested struct as a single 1-slot + * dynamic field. + */ +export function astStructMembersToFieldLayouts( + structTypeId: string, + layout: StorageLayoutResponse, +): StructFieldLayout[] { + const result: StructFieldLayout[] = []; + + const walk = (typeId: string, slotBase: number, namePrefix: string) => { + const typeDef = layout.types[typeId]; + if (!typeDef?.members) return; + for (const member of typeDef.members) { + const nestedDef = layout.types[member.type]; + // Nested struct: encoding inplace with its own members → flatten + if (nestedDef?.encoding === 'inplace' && nestedDef.members) { + walk( + member.type, + slotBase + Number(member.slot), + `${namePrefix}${member.label}.`, + ); + continue; + } + const field = memberToFieldLayout(member, layout, slotBase); + field.name = `${namePrefix}${field.name}`; + result.push(field); + } + }; + + walk(structTypeId, 0, ''); + return result; +} + +/** + * Fallback adapter: lay out source-scanned struct fields using the + * single-sourced packing primitive (placeField / elementsPerSlot) when the + * struct is not reachable via the contract AST. Does NOT use the deleted + * regex layout walker. + */ +export function structFieldsToFieldLayouts(fields: StructFieldDef[]): StructFieldLayout[] { + const layouts: StructFieldLayout[] = []; + let slot = 0; + let offset = 0; + const cur: SlotCursor = { slot: 0, offset: 0 }; // reused across fields (no per-field alloc) + + for (const field of fields) { + const cleaned = field.type.replace(/\s+/g, ' ').trim(); + const isMapping = cleaned.startsWith('mapping'); + + const arrayDims: Array = []; + const arrayRegex = /\[[0-9]*\]/g; + let m: RegExpExecArray | null; + while ((m = arrayRegex.exec(cleaned)) !== null) { + const v = m[0].slice(1, -1); + arrayDims.push(v ? Number(v) : null); + } + const base = cleaned.replace(arrayRegex, '').trim(); + const baseSize = baseTypeSize(base); + const isDynamicArray = arrayDims.some((dim) => dim === null); + const isDynamic = + base === 'string' || base === 'bytes' || isDynamicArray || isMapping || baseSize === null; + + if (isDynamic) { + if (offset > 0) { slot += 1; offset = 0; } + layouts.push({ + name: field.name, + type: field.type, + base, + slotOffset: slot, + byteOffset: 0, + sizeBytes: 32, + isDynamic: true, + isMapping, + arrayElementBase: isDynamicArray ? base : undefined, + arrayElementSize: isDynamicArray && baseSize ? baseSize : undefined, + }); + slot += 1; + continue; + } + + let arrayLength: number | undefined; + let arrayElementBase: string | undefined; + let arrayElementSize: number | undefined; + + if (arrayDims.length > 0 && baseSize !== null) { + arrayLength = arrayDims.reduce((acc: number, dim) => acc * (dim ?? 0), 1); + arrayElementBase = base; + arrayElementSize = baseSize; + // Fixed array starts on a fresh slot; spans ceil(length / per-slot) slots + if (offset > 0) { slot += 1; offset = 0; } + layouts.push({ + name: field.name, + type: field.type, + base, + slotOffset: slot, + byteOffset: 0, + sizeBytes: Math.min(baseSize, 32), + isDynamic: false, + isMapping, + arrayLength, + arrayElementBase, + arrayElementSize, + }); + const perSlot = elementsPerSlot(baseSize) || 1; + slot += Math.max(1, Math.ceil((arrayLength ?? 0) / perSlot)); + continue; + } + + // Scalar — pack via the single-sourced primitive (cursor mutated in place) + cur.slot = slot; cur.offset = offset; + const fieldOffset = placeField(cur, baseSize); + const placementSlot = fieldOffset + baseSize >= 32 ? cur.slot - 1 : cur.slot; + layouts.push({ + name: field.name, + type: field.type, + base, + slotOffset: placementSlot, + byteOffset: fieldOffset, + sizeBytes: baseSize, + isDynamic: false, + isMapping, + }); + slot = cur.slot; + offset = cur.offset; + } + + return layouts; +} + +/** Byte size of an elementary base type, or null if non-elementary. */ +function baseTypeSize(base: string): number | null { + if (base === 'bool') return 1; + if (base === 'address') return 20; + if (base === 'byte') return 1; + const bytesMatch = base.match(/^bytes(\d+)$/); + if (bytesMatch) return Number(bytesMatch[1]); + const intMatch = base.match(/^(u?int)(\d+)?$/); + if (intMatch) { + const bits = intMatch[2] ? Number(intMatch[2]) : 256; + return bits / 8; + } + return null; +} + // ── Trace-based struct derivation ────────────────────────────────────── export function deriveStructValueFromTrace(params: { @@ -118,16 +371,49 @@ export function deriveStructValueFromTrace(params: { const structName = variableType.split(/\s+/)[0]; debugLog('[deriveStructValueFromTrace] Struct name:', structName); - const fields = findStructFields(structName, sourceFiles); - if (!fields) { - debugLog('[deriveStructValueFromTrace] FAIL: No struct fields found for', structName); - return null; + + // Primary path: reconstruct the contract's storage layout via the AST + // allocator and adapt the struct's member entries. The struct must be + // reachable from the current row's contract for t_struct(name)_storage to + // appear in layout.types. + let layout: StructFieldLayout[] | null = null; + const currentRow = traceRows.find((row) => row.id === snapshotId) ?? null; + const contractName = + currentRow?.contract || + currentRow?.entryMeta?.codeContractName || + currentRow?.entryMeta?.targetContractName || + null; + if (contractName) { + const files: Record = {}; + for (const [path, file] of sourceFiles.entries()) { + files[path] = file.content; + } + const reconstruction = reconstructStorageLayout({ files, contractName }); + const structTypeId = `t_struct(${structName})_storage`; + if (reconstruction.layout.types[structTypeId]) { + const astLayout = astStructMembersToFieldLayouts(structTypeId, reconstruction.layout); + if (astLayout.length > 0) { + layout = astLayout; + debugLog('[deriveStructValueFromTrace] AST layout built with', astLayout.length, 'fields'); + } + } } - debugLog('[deriveStructValueFromTrace] Found fields:', fields.length); - const layout = buildStructLayout(fields); - if (layout.length === 0) { - debugLog('[deriveStructValueFromTrace] FAIL: Empty layout'); - return null; + + // Fallback: source-scan the struct (all files) and lay it out through the + // single-sourced packing primitive when the AST path can't reach it. + if (!layout) { + const fields = findStructFields(structName, sourceFiles); + if (!fields) { + debugLog('[deriveStructValueFromTrace] FAIL: No struct fields found for', structName); + return null; + } + debugLog('[deriveStructValueFromTrace] Found fields (fallback):', fields.length); + const fallbackLayout = structFieldsToFieldLayouts(fields); + if (fallbackLayout.length === 0) { + debugLog('[deriveStructValueFromTrace] FAIL: Empty layout'); + return null; + } + layout = fallbackLayout; } debugLog('[deriveStructValueFromTrace] Layout built with', layout.length, 'fields'); debugLog('[deriveStructValueFromTrace] Field layout:', JSON.stringify(layout.map(f => ({ @@ -164,11 +450,15 @@ export function deriveStructValueFromTrace(params: { const lineText = getSourceLineText(sourceFiles, row.sourceFile, row.line); if (!lineText) continue; const match = lineText.match( - new RegExp(`\\b${variableName}\\s*\\.\\s*([A-Za-z_][A-Za-z0-9_]*)`) + new RegExp(`\\b${variableName}((?:\\s*\\.\\s*[A-Za-z_][A-Za-z0-9_]*)+)`) ); if (!match) continue; - const fieldName = match[1]; - const fieldLayout = layout.find((entry) => entry.name === fieldName); + // Full dotted member path so nested-struct leaves (e.g. "inner.a", emitted by + // the AST adapter) resolve; fall back to the first segment for flat layouts. + const fieldPath = match[1].replace(/\s+/g, '').replace(/^\./, ''); + const fieldLayout = + layout.find((entry) => entry.name === fieldPath) || + layout.find((entry) => entry.name === fieldPath.split('.')[0]); if (!fieldLayout) continue; let storageAccess = parseStorageRead(row.storage_read); if (!storageAccess) { diff --git a/src/contexts/debug/traceRowScoring.ts b/src/contexts/debug/traceRowScoring.ts new file mode 100644 index 00000000..4c74f4fc --- /dev/null +++ b/src/contexts/debug/traceRowScoring.ts @@ -0,0 +1,122 @@ +/** + * traceRowScoring - Pure scoring/extraction helpers for matching opcode + * snapshots against decoded trace rows. + * + * No React, no hook refs, no I/O — directly unit-testable. Moved verbatim + * from useDebugEvaluation.ts. + */ + +import type { DebugSnapshot } from '../../types/debug'; + +export interface TraceRowScoreInput { + frame_id?: Array; + pc?: number; + name?: string; + stackTop?: string | null; + stackDepth?: number; + storage_read?: { slot?: string; value?: string } | null; + storage_write?: { slot?: string; after?: string } | null; +} + +export interface OpcodeSnapshotDetail { + pc?: number; + opcodeName?: string; + stack?: string[]; + storageAccess?: { type: 'read' | 'write'; slot: string; value?: string }; +} + +export function normalizeTraceFrameId(frameId?: Array | null): string | null { + if (!Array.isArray(frameId) || frameId.length === 0) return null; + return frameId.map((part) => String(part)).join('-'); +} + +export function getTraceRowBytecodeAddress(row: { entryMeta?: { codeAddress?: string; target?: string } | null } | null): string | null { + const value = row?.entryMeta?.codeAddress || row?.entryMeta?.target || null; + return value ? value.toLowerCase() : null; +} + +export function getTraceRowStorageAccess( + row: { + storage_read?: { slot?: string; value?: string } | null; + storage_write?: { slot?: string; after?: string } | null; + } | null +): { type: 'read' | 'write'; slot: string; value?: string } | null { + if (row?.storage_read?.slot) { + return { + type: 'read', + slot: row.storage_read.slot.toLowerCase(), + value: row.storage_read.value, + }; + } + if (row?.storage_write?.slot) { + return { + type: 'write', + slot: row.storage_write.slot.toLowerCase(), + value: row.storage_write.after, + }; + } + return null; +} + +export function getOpcodePc(snapshot: DebugSnapshot | null | undefined): number | null { + if (!snapshot || snapshot.type !== 'opcode') return null; + const detail = snapshot.detail as { pc?: number }; + return typeof detail.pc === 'number' ? detail.pc : null; +} + +export function scoreOpcodeSnapshotCandidate( + traceRow: TraceRowScoreInput, + snapshot: DebugSnapshot +): number { + let score = 0; + const opcodeDetail = + snapshot.type === 'opcode' + ? (snapshot.detail as OpcodeSnapshotDetail) + : null; + const traceFrameId = normalizeTraceFrameId(traceRow.frame_id); + if (traceFrameId && snapshot.frameId === traceFrameId) { + score += 100; + } + if (snapshot.type === 'opcode' && opcodeDetail?.pc === traceRow.pc) { + score += 50; + } + if ( + snapshot.type === 'opcode' && + traceRow.name && + opcodeDetail?.opcodeName?.toUpperCase() === traceRow.name.toUpperCase() + ) { + score += 25; + } + + const traceStorageAccess = getTraceRowStorageAccess(traceRow); + const snapshotStorageAccess = + snapshot.type === 'opcode' ? opcodeDetail?.storageAccess ?? null : null; + if ( + traceStorageAccess && + snapshotStorageAccess && + snapshotStorageAccess.type === traceStorageAccess.type && + snapshotStorageAccess.slot.toLowerCase() === traceStorageAccess.slot + ) { + score += 40; + if ( + traceStorageAccess.value && + snapshotStorageAccess.value && + snapshotStorageAccess.value.toLowerCase() === traceStorageAccess.value.toLowerCase() + ) { + score += 15; + } + } + + if (snapshot.type === 'opcode') { + const stack = Array.isArray(opcodeDetail?.stack) ? opcodeDetail.stack : []; + const stackTop = stack.length > 0 ? stack[stack.length - 1] : null; + if (traceRow.stackTop && stackTop && stackTop.toLowerCase() === traceRow.stackTop.toLowerCase()) { + score += 10; + } + if (typeof traceRow.stackDepth === 'number' && stack.length === traceRow.stackDepth) { + score += 5; + } + } + + return score; +} diff --git a/src/contexts/debug/useDebugEvaluation.ts b/src/contexts/debug/useDebugEvaluation.ts index c2455b0c..9ecd133e 100644 --- a/src/contexts/debug/useDebugEvaluation.ts +++ b/src/contexts/debug/useDebugEvaluation.ts @@ -2,7 +2,7 @@ * useDebugEvaluation - Expression evaluation and watch expressions hook. * Snapshot resolution helpers live in ./evalSnapshotResolver.ts. */ -import React, { useCallback, useRef } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import type { HookSnapshotDetail, WatchExpression, @@ -43,10 +43,13 @@ import { waitForLiveSessionReady, scanForHookSnapshot, resolveEvalSnapshotId, + resolveLiveSnapshotFromTraceRow, EVAL_SNAPSHOT_HINT_CACHE_MAX, EVAL_VARIABLE_HINT_CACHE_MAX, EVAL_TOTAL_BUDGET_MS, } from './evalSnapshotResolver'; +import { getTraceRowBytecodeAddress, getOpcodePc } from './traceRowScoring'; +import { createSnapshotCacheWriter } from './snapshotCacheStore'; import type { DebugSharedState, DebugEvaluationActions } from './types'; const NO_HOOK_SNAPSHOTS_ERROR = @@ -57,115 +60,6 @@ const hookContextMismatchError = (step: number, traceId: number | null, file: st const SESSION_EXPIRED_ERROR = 'Debug session expired. Please re-run the simulation to debug again.'; -function normalizeTraceFrameId(frameId?: Array | null): string | null { - if (!Array.isArray(frameId) || frameId.length === 0) return null; - return frameId.map((part) => String(part)).join('-'); -} - -function getTraceRowBytecodeAddress(row: { entryMeta?: { codeAddress?: string; target?: string } | null } | null): string | null { - const value = row?.entryMeta?.codeAddress || row?.entryMeta?.target || null; - return value ? value.toLowerCase() : null; -} - -function getTraceRowStorageAccess( - row: { - storage_read?: { slot?: string; value?: string } | null; - storage_write?: { slot?: string; after?: string } | null; - } | null -): { type: 'read' | 'write'; slot: string; value?: string } | null { - if (row?.storage_read?.slot) { - return { - type: 'read', - slot: row.storage_read.slot.toLowerCase(), - value: row.storage_read.value, - }; - } - if (row?.storage_write?.slot) { - return { - type: 'write', - slot: row.storage_write.slot.toLowerCase(), - value: row.storage_write.after, - }; - } - return null; -} - -function getOpcodePc(snapshot: DebugSnapshot | null | undefined): number | null { - if (!snapshot || snapshot.type !== 'opcode') return null; - const detail = snapshot.detail as { pc?: number }; - return typeof detail.pc === 'number' ? detail.pc : null; -} - -function scoreOpcodeSnapshotCandidate( - traceRow: { - frame_id?: Array; - pc?: number; - name?: string; - stackTop?: string | null; - stackDepth?: number; - storage_read?: { slot?: string; value?: string } | null; - storage_write?: { slot?: string; after?: string } | null; - }, - snapshot: DebugSnapshot -): number { - let score = 0; - const opcodeDetail = - snapshot.type === 'opcode' - ? (snapshot.detail as { - pc?: number; - opcodeName?: string; - stack?: string[]; - storageAccess?: { type: 'read' | 'write'; slot: string; value?: string }; - }) - : null; - const traceFrameId = normalizeTraceFrameId(traceRow.frame_id); - if (traceFrameId && snapshot.frameId === traceFrameId) { - score += 100; - } - if (snapshot.type === 'opcode' && opcodeDetail?.pc === traceRow.pc) { - score += 50; - } - if ( - snapshot.type === 'opcode' && - traceRow.name && - opcodeDetail?.opcodeName?.toUpperCase() === traceRow.name.toUpperCase() - ) { - score += 25; - } - - const traceStorageAccess = getTraceRowStorageAccess(traceRow); - const snapshotStorageAccess = - snapshot.type === 'opcode' ? opcodeDetail?.storageAccess ?? null : null; - if ( - traceStorageAccess && - snapshotStorageAccess && - snapshotStorageAccess.type === traceStorageAccess.type && - snapshotStorageAccess.slot.toLowerCase() === traceStorageAccess.slot - ) { - score += 40; - if ( - traceStorageAccess.value && - snapshotStorageAccess.value && - snapshotStorageAccess.value.toLowerCase() === traceStorageAccess.value.toLowerCase() - ) { - score += 15; - } - } - - if (snapshot.type === 'opcode') { - const stack = Array.isArray(opcodeDetail?.stack) ? opcodeDetail.stack : []; - const stackTop = stack.length > 0 ? stack[stack.length - 1] : null; - if (traceRow.stackTop && stackTop && stackTop.toLowerCase() === traceRow.stackTop.toLowerCase()) { - score += 10; - } - if (typeof traceRow.stackDepth === 'number' && stack.length === traceRow.stackDepth) { - score += 5; - } - } - - return score; -} - export function useDebugEvaluation(state: DebugSharedState): DebugEvaluationActions { const { session, @@ -205,6 +99,13 @@ export function useDebugEvaluation(state: DebugSharedState): DebugEvaluationActi // the bridge with 3× the RPC load. const evalInflightRef = useRef>>(new Map()); + // Single eviction-policy writer over snapshotCache (recency-LRU, cap 500). + // setSnapshotCache identity is stable, so the writer identity is stable too. + const snapshotCacheWriter = useMemo( + () => createSnapshotCacheWriter(setSnapshotCache), + [setSnapshotCache] + ); + // ── Wrapped resolver callbacks (delegate to extracted pure functions) ── const waitForLiveSessionReadyCb = useCallback( @@ -214,7 +115,7 @@ export function useDebugEvaluation(state: DebugSharedState): DebugEvaluationActi sessionRef, sourceFilesRef, snapshotCache, - setSnapshotCache, + snapshotCacheWriter, }, timeoutMs), [sessionInvalid, snapshotCache] ); @@ -244,7 +145,7 @@ export function useDebugEvaluation(state: DebugSharedState): DebugEvaluationActi currentSnapshotId: baseSnapshotId ?? null, currentSnapshot, snapshotCache, - setSnapshotCache, + snapshotCacheWriter, snapshotList, setSnapshotList, sourceFilesRef, @@ -277,7 +178,7 @@ export function useDebugEvaluation(state: DebugSharedState): DebugEvaluationActi session, sourceFilesRef, snapshotCache, - setSnapshotCache, + snapshotCacheWriter, }, predicate, timeoutMs @@ -289,82 +190,17 @@ export function useDebugEvaluation(state: DebugSharedState): DebugEvaluationActi async ( sessionId: string, traceStepId: number - ): Promise<{ snapshotId: number; snapshot: DebugSnapshot } | null> => { - const traceRow = decodedTraceRowsRef.current?.find((row) => row.id === traceStepId) ?? null; - const bytecodeAddress = getTraceRowBytecodeAddress(traceRow); - if (!traceRow || !bytecodeAddress || typeof traceRow.pc !== 'number') { - return null; - } - - const cacheKey = `${sessionId}:${traceStepId}:${bytecodeAddress}:${traceRow.pc}`; - const cachedSnapshotId = traceToLiveSnapshotCacheRef.current.get(cacheKey); - if (typeof cachedSnapshotId === 'number') { - const cachedSnapshot = snapshotCache.get(cachedSnapshotId); - if (cachedSnapshot) { - return { snapshotId: cachedSnapshotId, snapshot: cachedSnapshot }; - } - } - - const breakpointHits = await debugBridgeService.getBreakpointHits({ + ): Promise<{ snapshotId: number; snapshot: DebugSnapshot } | null> => + resolveLiveSnapshotFromTraceRow( + { + decodedTraceRowsRef, + snapshotCache, + snapshotCacheWriter, + traceToLiveSnapshotCacheRef, + }, sessionId, - breakpoints: [ - { - location: { - type: 'opcode', - bytecodeAddress, - pc: traceRow.pc, - }, - }, - ], - }); - - const candidateIds = breakpointHits.hits.filter((id) => Number.isInteger(id) && id >= 0); - if (candidateIds.length === 0) { - return null; - } - - let bestMatch: - | { snapshotId: number; snapshot: DebugSnapshot; score: number } - | null = null; - - for (const candidateId of candidateIds.slice(0, 16)) { - try { - const response = await debugBridgeService.getSnapshot({ - sessionId, - snapshotId: candidateId, - }); - const candidateSnapshot = response.snapshot; - const score = scoreOpcodeSnapshotCandidate(traceRow, candidateSnapshot); - - if (!bestMatch || score > bestMatch.score) { - bestMatch = { - snapshotId: candidateId, - snapshot: candidateSnapshot, - score, - }; - } - } catch { - // Ignore candidate fetch errors and continue scoring remaining hits. - } - } - - if (!bestMatch) { - return null; - } - - traceToLiveSnapshotCacheRef.current.set(cacheKey, bestMatch.snapshotId); - setSnapshotCache((prev) => { - const next = new Map(prev); - next.set(bestMatch!.snapshotId, bestMatch!.snapshot); - if (next.size > 500) { - const sortedKeys = [...next.keys()].sort((a, b) => a - b); - sortedKeys.slice(0, next.size - 500).forEach((key) => next.delete(key)); - } - return next; - }); - - return { snapshotId: bestMatch.snapshotId, snapshot: bestMatch.snapshot }; - }, + traceStepId + ), [decodedTraceRowsRef, snapshotCache, setSnapshotCache] ); diff --git a/src/contexts/debug/useDebugSession.ts b/src/contexts/debug/useDebugSession.ts index 86d5dd85..0912ebb6 100644 --- a/src/contexts/debug/useDebugSession.ts +++ b/src/contexts/debug/useDebugSession.ts @@ -25,6 +25,7 @@ import { isSessionNotFoundError, debugLog, } from './debugHelpers'; +import { writeSnapshotToCache } from './snapshotCacheStore'; import type { DebugSharedState, DebugSessionActions } from './types'; const INITIAL_SNAPSHOT_PREFETCH_COUNT = 20; @@ -154,7 +155,7 @@ export function useDebugSession(state: DebugSharedState): DebugSessionActions { setStorageDiffs(diffs); // Cache the snapshot - setSnapshotCache(prev => { const next = new Map(prev); next.set(row.id, snapshot); if (next.size > 500) { const sortedKeys = [...next.keys()].sort((a, b) => a - b); sortedKeys.slice(0, next.size - 500).forEach(k => next.delete(k)); } return next; }); + setSnapshotCache(prev => writeSnapshotToCache(prev, row.id, snapshot)); }, []); const goToSnapshotInternal = useCallback(async ( @@ -179,7 +180,7 @@ export function useDebugSession(state: DebugSharedState): DebugSessionActions { }); snapshot = response.snapshot; - setSnapshotCache(prev => { const next = new Map(prev); next.set(snapshotId, snapshot!); if (next.size > 500) { const sortedKeys = [...next.keys()].sort((a, b) => a - b); sortedKeys.slice(0, next.size - 500).forEach(k => next.delete(k)); } return next; }); + setSnapshotCache(prev => writeSnapshotToCache(prev, snapshotId, snapshot!)); } const resolvedSnapshot = snapshot ? enhanceHookSnapshot(snapshot, sourceFilesRef.current) : snapshot; @@ -187,7 +188,7 @@ export function useDebugSession(state: DebugSharedState): DebugSessionActions { setCurrentSnapshotId(snapshotId); setCurrentSnapshot(resolvedSnapshot || null); if (resolvedSnapshot) { - setSnapshotCache(prev => { const next = new Map(prev); next.set(snapshotId, resolvedSnapshot); if (next.size > 500) { const sortedKeys = [...next.keys()].sort((a, b) => a - b); sortedKeys.slice(0, next.size - 500).forEach(k => next.delete(k)); } return next; }); + setSnapshotCache(prev => writeSnapshotToCache(prev, snapshotId, resolvedSnapshot)); } // Update source location if hook snapshot diff --git a/src/hooks/useUniversalSearch.ts b/src/hooks/useUniversalSearch.ts index 0294739d..b5af4683 100644 --- a/src/hooks/useUniversalSearch.ts +++ b/src/hooks/useUniversalSearch.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { ethers } from 'ethers'; +import { TXHASH_REPLAY_KEY } from '../components/transaction-builder/types'; export type InputType = | 'address' @@ -91,7 +92,6 @@ function detectInputType(input: string): InputType { return 'unknown'; } -const TXHASH_REPLAY_KEY = 'web3-toolkit:txhash-replay'; const TXHASH_REPLAY_EVENT = 'web3-toolkit:txhash-replay-updated'; const TXHASH_REPLAY_LAST_INTENT_KEY = 'web3-toolkit:txhash-replay-last-intent'; const RECENT_SEARCHES_KEY = 'web3-toolkit:recent-searches'; diff --git a/src/services/TraceVaultService.ts b/src/services/TraceVaultService.ts index 6c9e3f30..f32e873a 100644 --- a/src/services/TraceVaultService.ts +++ b/src/services/TraceVaultService.ts @@ -191,32 +191,34 @@ export const recomputeHierarchy = (rows: DecodedTraceRow[]): DecodedTraceRow[] = // Work on a copy to avoid mutating the original const result = rows.map((row) => ({ ...row })); + const n = result.length; + const depths = result.map((r) => { + // Normalize to a finite number: a non-finite depth (NaN) would behave + // differently between the old forward-scan and the new stack pass. + const d = r.visualDepth ?? (r as any).depth ?? 0; + return Number.isFinite(d) ? d : 0; + }); - for (let i = 0; i < result.length; i++) { - const row = result[i]; - const rowDepth = row.visualDepth ?? (row as any).depth ?? 0; - - let hasChildren = false; - let childEndId: number | undefined = undefined; - - // Look ahead to find children - for (let j = i + 1; j < result.length; j++) { - const nextRow = result[j]; - const nextDepth = nextRow.visualDepth ?? (nextRow as any).depth ?? 0; - - // Stop when we return to same or shallower depth - if (nextDepth <= rowDepth) { - break; - } - - // Found a child (higher depth) - hasChildren = true; - childEndId = nextRow.id; + // Single O(n) monotonic-stack pass replacing the old O(n^2) per-row look-ahead. + // boundary[i] = the first index j > i whose depth is <= depths[i] (or n). + // The old loop included rows i+1..boundary[i]-1 (the contiguous deeper run) and + // set childEndId to the last one — identical output, no quadratic scan. + const boundary = new Array(n); + const stack: number[] = []; + for (let i = 0; i < n; i++) { + while (stack.length > 0 && depths[stack[stack.length - 1]] >= depths[i]) { + boundary[stack.pop() as number] = i; } + stack.push(i); + } + while (stack.length > 0) boundary[stack.pop() as number] = n; + for (let i = 0; i < n; i++) { + const row = result[i]; + const hasChildren = boundary[i] > i + 1; // a deeper run exists at i+1..boundary[i]-1 if (hasChildren) { row.hasChildren = true; - (row as any).childEndId = childEndId; + (row as any).childEndId = result[boundary[i] - 1].id; (row as any).isLeafCall = false; } else if (row.hasChildren === undefined) { // Only set to false if not already set @@ -319,6 +321,28 @@ class TraceVaultService { implementationToProxy, }; } + + async deleteDecodedTrace(simulationId: string): Promise { + if (!supportsOpfs()) { + return; + } + + const root = await navigator.storage.getDirectory(); + let base: FileSystemDirectoryHandle; + try { + base = await root.getDirectoryHandle(TRACE_DIR, { create: false }); + } catch (error: any) { + if (error?.name === "NotFoundError") return; + throw error; + } + + try { + await base.removeEntry(simulationId, { recursive: true }); + } catch (error: any) { + if (error?.name === "NotFoundError") return; + throw error; + } + } } export const traceVaultService = new TraceVaultService(); diff --git a/src/services/simulationStore.ts b/src/services/simulationStore.ts new file mode 100644 index 00000000..fd6203f7 --- /dev/null +++ b/src/services/simulationStore.ts @@ -0,0 +1,179 @@ +/** + * Simulation persistence coordinator. + * + * Owns the cross-service policy (OPFS-vs-IndexedDB load ladder, the + * persist-decoded-trace write path, and the cross-store delete) that used to + * live duplicated in SimulationHistoryPage and useSimulationPageState. + * SimulationHistoryService and TraceVaultService are private collaborators of + * this module — callers depend on the coordinator, not the leaf stores. + */ + +import { simulationHistoryService } from "./SimulationHistoryService"; +import { + traceVaultService, + recomputeHierarchy, + type TraceVaultDecodedTrace, +} from "./TraceVaultService"; +import { hasInternalInfo } from "../components/simulation-results/useSimulationPageHelpers"; +import type { DecodedTraceRow } from "../utils/traceDecoder"; +import type { DecodedTraceMeta } from "../contexts/SimulationContext"; + +export interface LoadedSimulation { + result: any; + contractContext: any; + decodedRows: DecodedTraceRow[] | null; + sourceTexts: Record | null; + meta: DecodedTraceMeta | null; +} + +const TRACE_DIR = "trace-vault"; + +/** + * The 4-branch row-selection ladder (OPFS+internal -> IndexedDB+internal -> + * OPFS-any -> IndexedDB-any). Pure: exported for unit testing. + */ +export const pickTraceRows = ( + opfsRows: DecodedTraceRow[] | undefined, + indexedDbRows: DecodedTraceRow[] | undefined +): { rows: DecodedTraceRow[] | null; recompute: boolean } => { + const opfsRowCount = opfsRows?.length ?? 0; + const indexedDbRowCount = indexedDbRows?.length ?? 0; + const opfsHasInternal = hasInternalInfo(opfsRows); + const indexedDbHasInternal = hasInternalInfo(indexedDbRows); + + // Prefer OPFS if it has rows with hierarchy info + // Fall back to IndexedDB only if OPFS is empty/missing hierarchy but IndexedDB has it + let rowsToUse: DecodedTraceRow[] | undefined; + let fromIndexedDb = false; + + if (opfsRowCount > 0 && opfsHasInternal) { + // OPFS has full data with hierarchy - use it + rowsToUse = opfsRows; + } else if (indexedDbRowCount > 0 && indexedDbHasInternal) { + // IndexedDB has hierarchy but OPFS doesn't - use IndexedDB + rowsToUse = indexedDbRows; + fromIndexedDb = true; + } else if (opfsRowCount > 0) { + // OPFS has rows (even without hierarchy) - use it + rowsToUse = opfsRows; + } else if (indexedDbRowCount > 0) { + // IndexedDB has rows as last resort + rowsToUse = indexedDbRows; + fromIndexedDb = true; + } + + if (rowsToUse && rowsToUse.length > 0) { + // OPFS rows arrive already hierarchy-recomputed from loadDecodedTrace; only + // raw IndexedDB rows still need a recompute. This avoids a redundant second + // O(n) pass over the (often large) OPFS row set on every history load. + return { rows: rowsToUse, recompute: fromIndexedDb }; + } + + return { rows: null, recompute: false }; +}; + +export async function loadStoredSimulation( + id: string +): Promise { + const stored = await simulationHistoryService.getSimulation(id); + if (!stored?.result || !stored?.contractContext) { + return null; + } + + try { + const traceBundle = await traceVaultService.loadDecodedTrace(id, { + includeHeavy: false, + }); + + const { rows, recompute } = pickTraceRows(traceBundle?.rows, stored.decodedTraceRows); + + const decodedRows = rows ? (recompute ? recomputeHierarchy(rows) : rows) : null; + const sourceTexts = + traceBundle?.sourceTexts && + Object.keys(traceBundle.sourceTexts).length > 0 + ? traceBundle.sourceTexts + : null; + const meta: DecodedTraceMeta = { + sourceLines: traceBundle?.sourceLines ?? [], + callMeta: traceBundle?.callMeta, + rawEvents: traceBundle?.rawEvents ?? [], + implementationToProxy: + traceBundle?.implementationToProxy ?? new Map(), + }; + + return { + result: stored.result, + contractContext: stored.contractContext, + decodedRows, + sourceTexts, + meta, + }; + } catch { + // Fallback: restore decoded rows from IndexedDB on OPFS failure + const decodedRows = + stored.decodedTraceRows && stored.decodedTraceRows.length > 0 + ? recomputeHierarchy(stored.decodedTraceRows) + : null; + return { + result: stored.result, + contractContext: stored.contractContext, + decodedRows, + sourceTexts: null, + meta: null, + }; + } +} + +export async function persistDecodedTrace( + simulationId: string, + decoded: TraceVaultDecodedTrace +): Promise { + const jumpRowCount = + (decoded as any)?.rows?.filter( + (r: any) => r?.destFn || r?.jumpMarker || r?.isInternalCall + ).length ?? 0; + + try { + const existingTrace = await traceVaultService.loadDecodedTrace(simulationId, { + includeHeavy: false, + }); + const existingJumpCount = + existingTrace?.rows?.filter( + (r: any) => r?.destFn || r?.jumpMarker || r?.isInternalCall + ).length ?? 0; + + if (existingJumpCount > 0 && jumpRowCount === 0) return; + + const saved = await traceVaultService.saveDecodedTrace(simulationId, decoded); + const rowsToStore = saved?.lite?.rows ?? decoded.rows; + await simulationHistoryService.updateSimulationDecodedRows(simulationId, rowsToStore, { + maxRetries: 6, + delayMs: 150, + }); + } catch (err) { + console.error("[SimulationResults] Failed to persist trace:", err); + } +} + +export async function deleteStoredSimulation(id: string): Promise { + await simulationHistoryService.deleteSimulation(id); + await traceVaultService.deleteDecodedTrace(id); +} + +export async function deleteStoredSimulations(ids: string[]): Promise { + await simulationHistoryService.deleteSimulations(ids); + await Promise.allSettled(ids.map((id) => traceVaultService.deleteDecodedTrace(id))); +} + +export async function clearStoredSimulations(): Promise { + await simulationHistoryService.clearAll(); + if (typeof navigator !== "undefined" && navigator.storage?.getDirectory) { + const root = await navigator.storage.getDirectory(); + try { + await root.removeEntry(TRACE_DIR, { recursive: true }); + } catch (error: any) { + if (error?.name === "NotFoundError") return; + throw error; + } + } +} diff --git a/src/utils/comprehensiveContractFetcher.ts b/src/utils/comprehensiveContractFetcher.ts deleted file mode 100644 index 0d761bdb..00000000 --- a/src/utils/comprehensiveContractFetcher.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Comprehensive Contract Fetcher (Compatibility Layer) - * - * This module delegates to the optimized resolver system. - * Provides backward-compatible `fetchContractInfoComprehensive()` API. - * - * @deprecated Use `contractResolver` from `./resolver` directly for new code. - */ - -import type { Chain } from "../types"; -import type { ContractInfoResult } from "../types/contractInfo"; -import { contractResolver, type ResolveResult } from "./resolver"; - -export type { ContractInfoResult } from "../types/contractInfo"; - -/** - * Progress callback options (kept for backward compatibility) - */ -interface ProgressOptions { - progressCallback?: (progress: { - source: string; - status: "searching" | "found" | "not_found" | "error"; - message?: string; - }) => void; - etherscanApiKey?: string; - blockscoutApiKey?: string; - preferredSources?: ("sourcify" | "blockscout" | "etherscan")[]; -} - -/** - * Convert new ResolveResult to legacy ContractInfoResult format - */ -const toContractInfoResult = ( - result: ResolveResult, - address: string, - chain: Chain -): ContractInfoResult => { - return { - success: !!result.abi, - address, - chain, - contractName: result.name || undefined, - abi: result.abi ? JSON.stringify(result.abi) : undefined, - source: result.source || undefined, - explorerName: result.source - ? result.source.charAt(0).toUpperCase() + result.source.slice(1) - : undefined, - verified: result.verified, - tokenInfo: result.tokenInfo - ? { - name: result.tokenInfo.name, - symbol: result.tokenInfo.symbol, - decimals: result.tokenInfo.decimals, - totalSupply: result.tokenInfo.totalSupply, - } - : undefined, - externalFunctions: [...result.functions.read, ...result.functions.write].map( - (fn) => ({ - name: fn.name, - signature: fn.signature, - inputs: fn.inputs.map((i) => ({ name: i.name, type: i.type })), - outputs: fn.outputs.map((o) => ({ name: o.name, type: o.type })), - stateMutability: fn.stateMutability, - }) - ), - error: result.error, - searchProgress: result.attempts.map((a) => ({ - source: a.source, - status: - a.status === "success" - ? "found" - : a.status === "failed" || a.status === "timeout" - ? "not_found" - : a.status === "fetching" - ? "searching" - : "error", - message: a.error, - })), - }; -}; - -/** - * Fetch comprehensive contract info. - * - * This function now uses the optimized resolver which: - * - Races all sources in parallel - * - Has built-in request deduplication - * - Uses two-layer caching (memory + IndexedDB) - * - * @deprecated Use `contractResolver.resolve()` from `./resolver` for new code. - */ -export const fetchContractInfoComprehensive = async ( - address: string, - chain: Chain, - progressCallback?: ProgressOptions["progressCallback"], - options: Omit = {} -): Promise => { - try { - const result = await contractResolver.resolve(address, chain, { - etherscanApiKey: options.etherscanApiKey, - blockscoutApiKey: options.blockscoutApiKey, - preferredSources: options.preferredSources, - onProgress: progressCallback - ? (attempt) => { - progressCallback({ - source: attempt.source, - status: - attempt.status === "success" - ? "found" - : attempt.status === "failed" || attempt.status === "timeout" - ? "not_found" - : attempt.status === "fetching" - ? "searching" - : "error", - message: attempt.error, - }); - } - : undefined, - }); - - return toContractInfoResult(result, address, chain); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { - success: false, - address, - chain, - error: errorMessage, - searchProgress: [], - }; - } -}; diff --git a/src/utils/diamondFacetFetcher.ts b/src/utils/diamondFacetFetcher.ts index c8ff4810..a4f0d589 100644 --- a/src/utils/diamondFacetFetcher.ts +++ b/src/utils/diamondFacetFetcher.ts @@ -1,14 +1,13 @@ -import axios from "axios"; import { ethers } from "ethers"; -import type { Chain, ExtendedABIFetchResult, ExplorerSource } from "../types"; +import type { Chain, ExplorerSource } from "../types"; +import { contractResolver } from "./resolver"; +import type { FacetInfo } from "./resolver"; +import { facetInfoToDiamondFacet } from "./resolver/facetAdapter"; import { fetchFromWhatsABI, createFunctionStubsFromSelectors, - type SelectorFunctionStub, } from "./whatsabiFetcher"; -import { fetchContractABIMultiSource } from "./multiSourceAbiFetcher"; import { networkConfigManager } from "../config/networkConfig"; -import { postEtherscanLookup } from "./etherscanProxy"; // Diamond facet information export interface DiamondFacet { @@ -48,14 +47,17 @@ interface FacetFetchOptions { provider?: ethers.providers.Provider; preferredSources?: ExplorerSource[]; onPreferredSourceDetected?: (source: ExplorerSource) => void; + /** + * Selectors per facet captured from a prior facets() loupe call (see + * getDiamondFacetAddressesWithSelectors). When a facet is present here it is + * used directly (0 RPC) instead of issuing facetFunctionSelectors(); facets + * absent from the map fall back to the per-facet RPC call. + */ + loupeSelectors?: Map; } -const facetFetchCache = new Map>(); -const FACET_CACHE_MAX_SIZE = 200; - // Batch processing configuration const BATCH_SIZE = 6; -const FETCH_TIMEOUT = 10000; // 10 seconds per facet // Helper to get RPC URL for a chain function getRpcUrl(chain: Chain): string { @@ -68,241 +70,45 @@ function getRpcUrl(chain: Chain): string { return chain.rpcUrl; } -// Helper to get explorer API URLs (using Vite proxy paths) -function getExplorerUrls(chain: Chain, address: string) { - const id = chain.id; - - const blockscoutProxyMap: Record = { - 137: "/api/polygon-blockscout", - 42161: "/api/arbitrum-blockscout", - 84532: "/api/base-sepolia-blockscout", - 4202: "/api/lisk-sepolia-blockscout", - }; - - const blockscoutBase = blockscoutProxyMap[id] || "/api/blockscout"; - const sourcifyBase = "/api/sourcify"; - - return { - blockscout: `${blockscoutBase}?module=contract&action=getabi&address=${address}`, - sourcify: `${sourcifyBase}/server/files/${id}/${address}`, - }; -} - -// Fetch ABI from Sourcify (repo endpoint) -async function fetchFromSourcify( - chain: Chain, - address: string -): Promise { - try { - const id = chain.id; - const endpoints = [ - `/api/repo/contracts/full_match/${id}/${address}/metadata.json`, - `/api/repo/contracts/partial_match/${id}/${address}/metadata.json`, - ]; - - for (const url of endpoints) { - try { - const response = await axios.get(url, { timeout: FETCH_TIMEOUT }); - if (response.status === 200 && response.data) { - const metadata = response.data as { - output?: { abi?: unknown[] }; - settings?: { compilationTarget?: Record }; - metadata?: { name?: string }; - }; - const abi = Array.isArray(metadata?.output?.abi) - ? (metadata.output!.abi as unknown[]) - : []; - if (abi.length > 0) { - let contractName: string | undefined; - const compilationTarget = metadata?.settings?.compilationTarget; - if (compilationTarget) { - const keys = Object.keys(compilationTarget); - if (keys.length > 0) contractName = compilationTarget[keys[0]]; - } - if (!contractName && metadata?.metadata?.name) { - contractName = metadata.metadata.name; - } - return { - abi: JSON.stringify(abi), - source: "Sourcify", - contractName, - success: true, - }; - } - } - } catch (e) { - // try next endpoint - continue; - } - } - - return null; - } catch (error) { - return null; - } -} - -// Fetch ABI from Etherscan -async function fetchFromEtherscan( - chain: Chain, - address: string, - apiKey?: string -): Promise { - try { - const response = await postEtherscanLookup({ - action: "getabi", - address, - chainId: chain.id, - personalApiKey: apiKey, - }); - const data = await response.json(); - - if (response.ok && data.status === "1" && data.result !== "Contract source code not verified") { - const abi = JSON.parse(data.result); - return { - abi: JSON.stringify(abi), - source: "Etherscan", - success: true, - }; - } - return null; - } catch (error) { - return null; - } -} - -// Fetch ABI from Blockscout -async function fetchFromBlockscout( - chain: Chain, - address: string, - apiKey?: string -): Promise { - try { - const id = chain.id; - // Use vite proxy base, target will be base-mainnet.blockscout.com for Base by default - const basePath = - id === 137 - ? "/api/polygon-blockscout" - : id === 42161 - ? "/api/arbitrum-blockscout" - : id === 84532 - ? "/api/base-sepolia-blockscout" - : id === 4202 - ? "/api/lisk-sepolia-blockscout" - : "/api/blockscout"; - - // Try Etherscan-style and Blockscout v2 endpoints - const keyParam = apiKey ? `&apikey=${encodeURIComponent(apiKey)}` : ""; - const tokenParam = apiKey ? `?token=${encodeURIComponent(apiKey)}` : ""; - const endpoints = [ - `${basePath}?module=contract&action=getabi&address=${address}${keyParam}`, - `${basePath}/v2/smart-contracts/${address}${tokenParam}`, - ]; - - let interimAbi: unknown[] | null = null; - let interimName: string | undefined; - - for (const url of endpoints) { - try { - const response = await axios.get(url, { timeout: FETCH_TIMEOUT }); - // Etherscan-style - if (response.data?.status === "1" && response.data?.result) { - const rawResult = response.data.result; - if (rawResult === "Contract source code not verified") { - continue; - } - try { - const parsed = JSON.parse(rawResult); - if (Array.isArray(parsed)) { - interimAbi = parsed as unknown[]; - } - } catch { - continue; - } - // v1 path does not include name, try to fetch name below - continue; - } - // Blockscout v2 - if (response.data?.abi && Array.isArray(response.data.abi)) { - interimAbi = response.data.abi as unknown[]; - interimName = response.data.name || response.data.contract_name; - break; - } - } catch { - continue; - } - } - - // If we have ABI from v1 but no name, fetch name via v2 or getsourcecode - if (interimAbi && !interimName) { - const nameEndpoints = [ - `${basePath}?module=contract&action=getsourcecode&address=${address}${keyParam}`, - `${basePath}/v2/smart-contracts/${address}${tokenParam}`, - ]; - for (const nurl of nameEndpoints) { - try { - const r = await axios.get(nurl, { timeout: FETCH_TIMEOUT }); - if (r.data?.status === "1" && r.data?.result?.[0]?.ContractName) { - interimName = r.data.result[0].ContractName; - break; - } - if (r.data?.name || r.data?.contract_name) { - interimName = r.data.name || r.data.contract_name; - break; - } - } catch { - /* try next */ - } - } - } - - if (interimAbi && Array.isArray(interimAbi) && interimAbi.length > 0) { - return { - abi: JSON.stringify(interimAbi), - source: "Blockscout", - contractName: interimName, - success: true, - }; - } - - return null; - } catch (error) { - return null; - } -} - -// Categorize functions into read and write +// Split a raw ABI into read (view/pure) and write functions. function categorizeFunctions(abi: unknown[]): { read: unknown[]; write: unknown[]; } { - const readFunctions: unknown[] = []; - const writeFunctions: unknown[] = []; - + const read: unknown[] = []; + const write: unknown[] = []; (abi || []).forEach((item: unknown) => { const entry = item as { type?: string; stateMutability?: string }; if (entry?.type === "function") { - if ( - entry.stateMutability === "view" || - entry.stateMutability === "pure" - ) { - readFunctions.push(item); + if (entry.stateMutability === "view" || entry.stateMutability === "pure") { + read.push(item); } else { - writeFunctions.push(item); + write.push(item); } } }); - - return { read: readFunctions, write: writeFunctions }; + return { read, write }; } -// Fetch ABI for a single facet +// Fetch selectors for a single facet from the diamond's loupe interface. +// When the facets() loupe call already returned this facet's selectors (passed +// via loupeSelectors), use them directly — 0 RPC. Facets absent from the map +// fall back to the per-facet facetFunctionSelectors() call (e.g. diamonds whose +// facets() reverted and resolved addresses via facetAddresses()). async function fetchFacetSelectors( chain: Chain, diamondAddress: string, facetAddress: string, - provider?: ethers.providers.Provider + provider?: ethers.providers.Provider, + loupeSelectors?: Map ): Promise { + const cached = loupeSelectors?.get(facetAddress); + // Only reuse a NON-EMPTY cached set; an empty array (a facet missing from a + // malformed facets() response) must still fall back to the per-facet RPC. + if (cached && cached.length > 0) { + return cached.map((selector) => selector.toLowerCase()); + } + try { const rpcProvider = provider ?? new ethers.providers.JsonRpcProvider(getRpcUrl(chain)); @@ -329,191 +135,82 @@ async function fetchFacetABI( facetAddress: string, options: FacetFetchOptions = {} ): Promise { - const cacheKey = `${chain.id}:${facetAddress.toLowerCase()}`; - if (facetFetchCache.has(cacheKey)) { - return facetFetchCache.get(cacheKey)!; + const [selectors, result] = await Promise.all([ + fetchFacetSelectors( + chain, + diamondAddress, + facetAddress, + options.provider, + options.loupeSelectors + ), + contractResolver.resolve(facetAddress, chain, { + etherscanApiKey: options.etherscanApiKey, + blockscoutApiKey: options.blockscoutApiKey ?? options.etherscanApiKey, + preferredSources: options.preferredSources, + }), + ]); + + if (result.source) { + options.onPreferredSourceDetected?.(result.source as ExplorerSource); } - const promise = (async (): Promise => { - let resolvedAbi: unknown[] | null = null; - let resolvedName = "Facet"; - let resolvedSource = "Unknown"; - let isVerified = false; - let confidence: "verified" | "inferred" | "extracted" = "extracted"; - let selectors: string[] = []; - let selectorStubs: SelectorFunctionStub[] = []; - - try { - const result = await fetchContractABIMultiSource(facetAddress, chain, { - etherscanApiKey: options.etherscanApiKey, - blockscoutApiKey: - options.blockscoutApiKey ?? options.etherscanApiKey, - provider: options.provider, - preferredSources: options.preferredSources, - }); - if (result && result.success && typeof result.abi === "string") { - const parsed = JSON.parse(result.abi) as unknown[]; - if (Array.isArray(parsed) && parsed.length > 0) { - resolvedAbi = parsed; - resolvedName = - result.contractName && String(result.contractName).trim() !== "" - ? String(result.contractName) - : resolvedName; - resolvedSource = result.source || resolvedSource; - const normalizedSource = result.source - ? String(result.source).toLowerCase() - : undefined; - if (normalizedSource === "blockscout") { - options.onPreferredSourceDetected?.("blockscout"); - } else if ( - normalizedSource && - (normalizedSource === "sourcify" || normalizedSource === "etherscan") - ) { - options.onPreferredSourceDetected?.( - normalizedSource as ExplorerSource - ); - } - selectors = Array.isArray(result.selectors) - ? result.selectors.map((selector) => selector.toLowerCase()) - : selectors; - if ( - result.confidence === "verified" || - result.confidence === "inferred" || - result.confidence === "extracted" - ) { - confidence = result.confidence; - isVerified = result.confidence === "verified"; - } else { - isVerified = - resolvedSource !== "whatsabi" && resolvedSource !== "Selectors"; - confidence = isVerified ? "verified" : "extracted"; - } - } - } - } catch { - // Aggregator ABI fetch failed, try individual sources - } + // Verified / explorer-resolved ABI: map through the resolver-shape adapter. + if (result.abi && result.abi.length > 0) { + const facetInfo: FacetInfo = { + address: facetAddress, + name: result.name || undefined, + abi: result.abi, + confidence: result.confidence, + source: result.source || undefined, + selectors, + functions: [...result.functions.read, ...result.functions.write], + }; + + return facetInfoToDiamondFacet(facetInfo); + } - if (!resolvedAbi) { - const sourceRunners: Array<{ - key: ExplorerSource; - runner: () => Promise; - }> = [ - { - key: "sourcify", - runner: () => fetchFromSourcify(chain, facetAddress), - }, - { - key: "etherscan", - runner: () => fetchFromEtherscan( - chain, - facetAddress, - options.etherscanApiKey - ), - }, - { - key: "blockscout", - runner: () => - fetchFromBlockscout( - chain, - facetAddress, - options.blockscoutApiKey ?? options.etherscanApiKey - ), - }, - ]; - - const preferenceOrder = options.preferredSources?.length - ? [ - ...options.preferredSources, - ...sourceRunners - .map((runner) => runner.key) - .filter((key) => !options.preferredSources?.includes(key)), - ] - : sourceRunners.map((runner) => runner.key); - - for (const sourceKey of preferenceOrder) { - const runnerEntry = sourceRunners.find((entry) => entry.key === sourceKey); - if (!runnerEntry) { - continue; - } + // Unverified facet: the resolver only races verified explorer sources, so fall + // back to WhatsABI bytecode analysis, then loupe-selector stubs, so unverified + // facets still surface inferred/extracted functions (preserving the pre-refactor + // behaviour without re-introducing the deleted source ladder). + let resolvedAbi: unknown[] | null = null; + let resolvedName = result.name || "Facet"; + let resolvedSource = "Unknown"; + let confidence: "verified" | "inferred" | "extracted" = "extracted"; + let inferenceSource: "whatsabi" | "selectors" | undefined; - try { - const result = await runnerEntry.runner(); - if (!result || !result.success || typeof result.abi !== "string") { - continue; - } - - const parsed = JSON.parse(result.abi) as unknown[]; - if (!Array.isArray(parsed) || parsed.length === 0) { - continue; - } - - resolvedAbi = parsed; - resolvedName = - result.contractName && String(result.contractName).trim() !== "" - ? String(result.contractName) - : resolvedName; - resolvedSource = result.source || runnerEntry.key; - - options.onPreferredSourceDetected?.(runnerEntry.key); - - if ( - result.confidence === "verified" || - result.confidence === "inferred" || - result.confidence === "extracted" - ) { - confidence = result.confidence; - isVerified = result.confidence === "verified"; - } - break; - } catch { - // Fallback ABI fetch failed, try next source - } - } + try { + const whatsabiResult = await fetchFromWhatsABI( + facetAddress, + chain, + options.provider + ); + if (whatsabiResult.success && whatsabiResult.abi) { + resolvedAbi = JSON.parse(whatsabiResult.abi) as unknown[]; + resolvedName = whatsabiResult.contractName || "Facet"; + resolvedSource = "WhatsABI"; + confidence = whatsabiResult.confidence; + inferenceSource = "whatsabi"; } + } catch { + // WhatsABI analysis failed; fall through to selector-based inference. + } - // WhatsABI fallback for unverified facets - if (!resolvedAbi) { + if ((!resolvedAbi || resolvedAbi.length === 0) && selectors.length > 0) { try { - const whatsabiResult = await fetchFromWhatsABI(facetAddress, chain); - if (whatsabiResult.success && whatsabiResult.abi) { - resolvedAbi = JSON.parse(whatsabiResult.abi) as unknown[]; - resolvedName = whatsabiResult.contractName || "Facet"; - resolvedSource = "WhatsABI"; - confidence = whatsabiResult.confidence; - selectors = whatsabiResult.selectors || []; - } + const stubs = await createFunctionStubsFromSelectors( + selectors, + facetAddress, + resolvedName + ); + resolvedAbi = stubs.map((stub) => stub.abi); + resolvedSource = "Selectors"; + confidence = stubs.some((stub) => stub.confidence === "inferred") + ? "inferred" + : "extracted"; + inferenceSource = "selectors"; } catch { - // WhatsABI analysis failed, try selector-based inference - } - } - - // Selector-based inference if we still don't have an ABI or if WhatsABI returned empty - if (!resolvedAbi || (Array.isArray(resolvedAbi) && resolvedAbi.length === 0)) { - selectors = selectors.length - ? selectors - : await fetchFacetSelectors( - chain, - diamondAddress, - facetAddress, - options.provider - ); - - if (selectors.length > 0) { - try { - selectorStubs = await createFunctionStubsFromSelectors( - selectors, - facetAddress, - resolvedName - ); - resolvedAbi = selectorStubs.map((stub) => stub.abi); - resolvedSource = "Selectors"; - confidence = selectorStubs.some((stub) => stub.confidence === "inferred") - ? "inferred" - : "extracted"; - } catch { - // Selector stub building failed - } + // Selector stub building failed. } } @@ -522,13 +219,12 @@ async function fetchFacetABI( } const functions = categorizeFunctions(resolvedAbi); - - // If we inferred the ABI via selectors but state classification is empty, expose inferred functions via read list - if (!isVerified && functions.read.length === 0 && resolvedAbi.length > 0) { - functions.read = resolvedAbi.filter((item) => { - const entry = item as { type?: string }; - return entry?.type === "function"; - }); + // If selector/bytecode inference produced functions but state classification is + // empty, expose them via the read list (matches pre-refactor behaviour). + if (functions.read.length === 0 && resolvedAbi.length > 0) { + functions.read = resolvedAbi.filter( + (item) => (item as { type?: string })?.type === "function" + ); } return { @@ -536,33 +232,12 @@ async function fetchFacetABI( name: resolvedName, abi: resolvedAbi, source: resolvedSource, - isVerified, + isVerified: false, functions, selectors, confidence, - inferenceSource: isVerified ? "verified" : resolvedSource === "WhatsABI" ? "whatsabi" : resolvedSource === "Selectors" ? "selectors" : undefined, + inferenceSource, }; - })(); - - facetFetchCache.set(cacheKey, promise); - - // LRU eviction: if cache exceeds max size, delete oldest entries - if (facetFetchCache.size > FACET_CACHE_MAX_SIZE) { - const keysIter = facetFetchCache.keys(); - while (facetFetchCache.size > FACET_CACHE_MAX_SIZE) { - const oldest = keysIter.next(); - if (oldest.done) break; - facetFetchCache.delete(oldest.value); - } - } - - try { - const result = await promise; - return result; - } catch (error) { - facetFetchCache.delete(cacheKey); - throw error; - } } // Process facets in batches @@ -690,11 +365,15 @@ export async function fetchDiamondFacets( return allFacets; } -// Helper to get facet addresses from Diamond contract -export async function getDiamondFacetAddresses( +// Helper to get facet addresses from Diamond contract, also exposing the +// per-facet selectors when discovery falls back to facets() (which returns each +// facet's selectors in the same call). Thread the returned loupeSelectors map +// into fetchDiamondFacets (options.loupeSelectors) so per-facet +// facetFunctionSelectors() RPC calls are skipped for facets present in it. +export async function getDiamondFacetAddressesWithSelectors( chain: Chain, diamondAddress: string -): Promise { +): Promise<{ addresses: string[]; loupeSelectors?: Map }> { try { const { ethers } = await import("ethers"); const provider = new ethers.providers.JsonRpcProvider(getRpcUrl(chain)); @@ -707,42 +386,66 @@ export async function getDiamondFacetAddresses( "function facets() external view returns (tuple(address facetAddress, bytes4[] functionSelectors)[] facets_)", ]; - // Try facetAddresses() first + // Prefer facets(): one call returns every facet address AND its selectors, + // so the per-facet facetFunctionSelectors() RPC is avoided for the common + // EIP-2535 case (the selectors are reused via the returned loupeSelectors map). try { const contract = new ethers.Contract( diamondAddress, - loupeFacetAddressesABI, + loupeFacetsABI, provider ); - const facetAddresses: string[] = await contract.facetAddresses(); - if (Array.isArray(facetAddresses) && facetAddresses.length > 0) { - return facetAddresses; + const facets: Array<{ + facetAddress: string; + functionSelectors: string[]; + }> = await contract.facets(); + if (Array.isArray(facets) && facets.length > 0) { + const loupeSelectors = new Map(); + for (const f of facets) { + if (!f.facetAddress) continue; + loupeSelectors.set(f.facetAddress, (f.functionSelectors || []).slice()); + } + const addresses = Array.from( + new Set(facets.map((f) => f.facetAddress)) + ).filter(Boolean); + if (addresses.length > 0) { + return { addresses, loupeSelectors }; + } } } catch { - // fall through to facets() + // facets() not implemented / reverted — fall back to facetAddresses() } - // Fallback: use facets() and extract addresses + // Fallback: facetAddresses() (addresses only; selectors fetched per-facet later + // for diamonds that implement facetAddresses() but not facets()). try { const contract = new ethers.Contract( diamondAddress, - loupeFacetsABI, + loupeFacetAddressesABI, provider ); - const facets: Array<{ - facetAddress: string; - functionSelectors: string[]; - }> = await contract.facets(); - const addresses = Array.from( - new Set((facets || []).map((f) => f.facetAddress)) - ).filter(Boolean); - return addresses; + const facetAddresses: string[] = await contract.facetAddresses(); + if (Array.isArray(facetAddresses) && facetAddresses.length > 0) { + return { addresses: facetAddresses }; + } } catch { - // Fallback facets() also failed + // Both loupe calls failed } - return []; + return { addresses: [] }; } catch { - return []; + return { addresses: [] }; } } + +// Helper to get facet addresses from Diamond contract +export async function getDiamondFacetAddresses( + chain: Chain, + diamondAddress: string +): Promise { + const { addresses } = await getDiamondFacetAddressesWithSelectors( + chain, + diamondAddress + ); + return addresses; +} diff --git a/src/utils/multiSourceAbiFetcher.ts b/src/utils/multiSourceAbiFetcher.ts deleted file mode 100644 index c5ad7e47..00000000 --- a/src/utils/multiSourceAbiFetcher.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Multi-Source ABI Fetcher (Compatibility Layer) - * - * This module now delegates to the new optimized resolver system. - * All existing imports continue to work without changes. - * - * @deprecated Use `contractResolver` from `./resolver` directly for new code. - */ - -import { ethers } from "ethers"; -import type { - Chain, - ExtendedABIFetchResult, - ExtendedABITokenInfo, - ExplorerSource, -} from "../types"; -import { - contractResolver, - type ResolveResult, -} from "./resolver"; - -const isValidAddress = (address: string) => - address?.startsWith("0x") && address.length === 42; - -/** - * Convert new ResolveResult to legacy ExtendedABIFetchResult format - */ -const toExtendedResult = (result: ResolveResult): ExtendedABIFetchResult => { - const tokenInfo: ExtendedABITokenInfo | undefined = result.tokenInfo - ? { - name: result.tokenInfo.name, - symbol: result.tokenInfo.symbol, - decimals: result.tokenInfo.decimals?.toString(), - totalSupply: result.tokenInfo.totalSupply, - } - : undefined; - - return { - success: !!result.abi, - abi: result.abi ? JSON.stringify(result.abi) : undefined, - error: result.error, - source: result.source || undefined, - explorerName: result.source - ? result.source.charAt(0).toUpperCase() + result.source.slice(1) - : undefined, - contractName: result.name || undefined, - tokenInfo, - confidence: result.confidence, - }; -}; - -export interface FetchABIMultiSourceOptions { - etherscanApiKey?: string; - blockscoutApiKey?: string; - provider?: ethers.providers.Provider; - preferredSources?: ExplorerSource[]; -} - -/** - * Fetch contract ABI from multiple sources. - * - * This function now uses the optimized resolver which: - * - Races all sources in parallel - * - Has built-in request deduplication - * - Uses two-layer caching (memory + IndexedDB) - * - * @deprecated Use `contractResolver.resolve()` from `./resolver` for new code. - */ -export const fetchContractABIMultiSource = async ( - contractAddress: string, - chain: Chain, - options: FetchABIMultiSourceOptions = {} -): Promise => { - const { etherscanApiKey, blockscoutApiKey, preferredSources } = options; - - // Validate address - if (!isValidAddress(contractAddress)) { - return { - success: false, - error: "Invalid contract address format", - }; - } - - try { - // Use the new optimized resolver - const result = await contractResolver.resolve(contractAddress, chain, { - etherscanApiKey, - blockscoutApiKey, - preferredSources, - }); - - return toExtendedResult(result); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { - success: false, - error: errorMessage, - }; - } -}; - diff --git a/src/utils/resolver/diamondResolver.ts b/src/utils/resolver/diamondResolver.ts index ef0eb887..71eecffe 100644 --- a/src/utils/resolver/diamondResolver.ts +++ b/src/utils/resolver/diamondResolver.ts @@ -91,26 +91,52 @@ function mergeFacetAbis(facets: FacetInfo[]): AbiItem[] { export async function detectDiamond( address: string, chain: Chain -): Promise<{ isDiamond: boolean; facetAddresses?: string[] }> { +): Promise<{ + isDiamond: boolean; + facetAddresses?: string[]; + // Per-facet selectors captured when detection falls back to facets() (which + // returns each facet's selectors in the same call). Used to skip per-facet + // facetFunctionSelectors() RPC calls in resolveDiamond. + loupeSelectors?: Map; +}> { try { const provider = getSharedProvider(chain); const contract = new ethers.Contract(address, DIAMOND_LOUPE_ABI, provider); + // Prefer facets(): one call returns addresses AND selectors, so resolveDiamond + // can skip the per-facet facetFunctionSelectors() RPCs for the common case. + try { + const facets = await contract.facets(); + if (Array.isArray(facets) && facets.length > 0) { + const loupeSelectors = new Map(); + for (const f of facets as Array<{ + facetAddress: string; + functionSelectors: string[]; + }>) { + if (!f.facetAddress) continue; + loupeSelectors.set( + f.facetAddress, + (f.functionSelectors || []).slice() + ); + } + const addresses = Array.from( + new Set(facets.map((f: { facetAddress: string }) => f.facetAddress)) + ).filter(Boolean); + if (addresses.length > 0) { + return { isDiamond: true, facetAddresses: addresses, loupeSelectors }; + } + } + } catch { + // facets() not implemented / reverted — fall back to facetAddresses() + } + try { const addresses = await contract.facetAddresses(); if (Array.isArray(addresses) && addresses.length > 0) { return { isDiamond: true, facetAddresses: addresses }; } } catch { - try { - const facets = await contract.facets(); - if (Array.isArray(facets) && facets.length > 0) { - const addresses = facets.map((f: { facetAddress: string }) => f.facetAddress); - return { isDiamond: true, facetAddresses: addresses }; - } - } catch { - // Not a diamond - } + // Not a diamond } return { isDiamond: false }; @@ -122,8 +148,18 @@ export async function detectDiamond( async function getFacetSelectors( diamondAddress: string, facetAddress: string, - chain: Chain + chain: Chain, + loupeSelectors?: Map ): Promise { + // Reuse selectors already returned by detectDiamond's facets() call (0 RPC). + // Facets absent from the map fall back to the per-facet RPC call (e.g. + // diamonds resolved via facetAddresses() where facets() wasn't read). + const cached = loupeSelectors?.get(facetAddress); + // Only reuse a non-empty cached set; an empty array still falls back to RPC. + if (cached && cached.length > 0) { + return cached; + } + try { const provider = getSharedProvider(chain); const contract = new ethers.Contract(diamondAddress, DIAMOND_LOUPE_ABI, provider); @@ -154,6 +190,7 @@ export async function resolveDiamond( } const facetAddresses = detection.facetAddresses; + const loupeSelectors = detection.loupeSelectors; const facets: FacetInfo[] = []; let completed = 0; const total = facetAddresses.length; @@ -166,7 +203,7 @@ export async function resolveDiamond( const batchPromises = batch.map(async (facetAddress): Promise => { try { const [selectors, resolveResult] = await Promise.all([ - getFacetSelectors(diamondAddress, facetAddress, chain), + getFacetSelectors(diamondAddress, facetAddress, chain, loupeSelectors), contractResolver.resolve(facetAddress, chain, { signal, etherscanApiKey: options.etherscanApiKey, diff --git a/src/utils/resolver/facetAdapter.ts b/src/utils/resolver/facetAdapter.ts new file mode 100644 index 00000000..5349964e --- /dev/null +++ b/src/utils/resolver/facetAdapter.ts @@ -0,0 +1,47 @@ +/** + * Facet Adapter + * + * Pure mapping from the resolver's FacetInfo shape to the legacy DiamondFacet + * UI shape consumed across the simple-grid diamond components. + * + * Pure and synchronous — no I/O. Directly unit-testable. + */ + +import type { FacetInfo, ExternalFunction } from './types'; +import { isReadFunction, isWriteFunction } from './types'; +import type { DiamondFacet } from '../diamondFacetFetcher'; + +export function facetInfoToDiamondFacet(facet: FacetInfo): DiamondFacet { + const confidence: "verified" | "inferred" | "extracted" = + facet.confidence === 'bytecode-only' ? 'extracted' : facet.confidence; + const isVerified = facet.confidence === 'verified'; + + const read: ExternalFunction[] = []; + const write: ExternalFunction[] = []; + for (const fn of facet.functions) { + if (isReadFunction(fn)) { + read.push(fn); + } else if (isWriteFunction(fn)) { + write.push(fn); + } + } + + const inferenceSource: "verified" | "whatsabi" | "selectors" | undefined = + isVerified + ? 'verified' + : facet.confidence === 'inferred' && !facet.source + ? 'selectors' + : undefined; + + return { + address: facet.address, + name: facet.name || 'Facet', + abi: (facet.abi ?? []) as unknown[], + source: facet.source || 'Unknown', + isVerified, + functions: { read, write }, + selectors: facet.selectors, + confidence, + inferenceSource, + }; +} diff --git a/src/utils/solidity-layout/allocator.ts b/src/utils/solidity-layout/allocator.ts index 789f31cb..b173f0f6 100644 --- a/src/utils/solidity-layout/allocator.ts +++ b/src/utils/solidity-layout/allocator.ts @@ -32,6 +32,8 @@ import { buildTypeLabel, computeStructSlotCount, computeFixedArraySlotCount, + placeField, + type SlotCursor, } from './allocatorTypeHelpers'; // Re-export public API from the helpers module so existing consumers @@ -150,21 +152,18 @@ function allocateVar( return; } - // Elementary types and enums -- pack into current slot if they fit - if (state.offset > 0 && (32 - state.offset) < size) { - // Doesn't fit in remaining space -- move to next slot - state.slot += 1; - state.offset = 0; - } - + // Elementary types and enums -- pack into current slot if they fit. + // placeField mutates state (the cursor) to the post-advance position. + const fieldOffset = placeField(state, size); + const postSlot = state.slot; + const postOffset = state.offset; + // Place the cursor at the (pre-advance) position so the recorded entry + // captures the field's slot/offset, then apply the post-advance cursor. + state.slot = fieldOffset + size >= 32 ? postSlot - 1 : postSlot; + state.offset = fieldOffset; recordEntry(state, name, contractName, typeId); - state.offset += size; - - // If we filled the slot exactly, advance - if (state.offset >= 32) { - state.slot += 1; - state.offset = 0; - } + state.slot = postSlot; + state.offset = postOffset; return; } @@ -252,17 +251,9 @@ function allocateStructMembers( continue; } - // Inplace member -- pack + // Inplace member -- pack (placeField mutates the state cursor in place) if (memberSize !== null) { - if (state.offset > 0 && (32 - state.offset) < memberSize) { - state.slot += 1; - state.offset = 0; - } - state.offset += memberSize; - if (state.offset >= 32) { - state.slot += 1; - state.offset = 0; - } + placeField(state, memberSize); } else { // Unknown size member -- allocate full slot advanceToSlotBoundary(state); @@ -312,15 +303,7 @@ function allocateFixedArray( if (elemSize <= 32) { // Small elements pack within slots for (let i = 0; i < arrayLength; i++) { - if (state.offset > 0 && (32 - state.offset) < elemSize) { - state.slot += 1; - state.offset = 0; - } - state.offset += elemSize; - if (state.offset >= 32) { - state.slot += 1; - state.offset = 0; - } + placeField(state, elemSize); // mutates the state cursor; no per-element alloc } } else { // Large elements (e.g. nested fixed arrays) -- each takes ceil(elemSize/32) slots @@ -399,6 +382,7 @@ function buildStructMemberEntries( const entries: StorageLayoutEntry[] = []; let memberSlot = 0; let memberOffset = 0; + const cur: SlotCursor = { slot: 0, offset: 0 }; // reused across members (no per-field alloc) for (const member of structDef.members) { const memberTypeId = buildTypeId(member.typeName, state.symbols); @@ -471,23 +455,21 @@ function buildStructMemberEntries( // Inplace packing if (memberSize !== null) { - if (memberOffset > 0 && (32 - memberOffset) < memberSize) { - memberSlot += 1; - memberOffset = 0; - } + cur.slot = memberSlot; cur.offset = memberOffset; + const fieldOffset = placeField(cur, memberSize); + // Record at the (pre-advance) placement slot/offset, then carry the + // post-advance cursor forward. + const placementSlot = fieldOffset + memberSize >= 32 ? cur.slot - 1 : cur.slot; entries.push({ astId: state.astIdCounter++, contract: structDef.contractName || structDef.name, label: member.name, - offset: memberOffset, - slot: String(memberSlot), + offset: fieldOffset, + slot: String(placementSlot), type: memberTypeId, }); - memberOffset += memberSize; - if (memberOffset >= 32) { - memberSlot += 1; - memberOffset = 0; - } + memberSlot = cur.slot; + memberOffset = cur.offset; } else { // Unknown size if (memberOffset > 0) { diff --git a/src/utils/solidity-layout/allocatorTypeHelpers.ts b/src/utils/solidity-layout/allocatorTypeHelpers.ts index c396cb68..3e4fc1ee 100644 --- a/src/utils/solidity-layout/allocatorTypeHelpers.ts +++ b/src/utils/solidity-layout/allocatorTypeHelpers.ts @@ -143,6 +143,49 @@ export function getTypeSize(typeName: ParsedTypeName, symbols: SymbolTable): num } } +// ---- packing primitive ------------------------------------------------ + +/** + * Canonical EVM 32-byte slot fit/advance rule for an inplace field of `size` + * bytes, starting from the current {slot, offset}. + * + * If the field does not fit in the remaining space of the current slot, it + * moves to the next slot. The returned `fieldOffset` is the PRE-advance byte + * offset where the field is placed (use this for the emitted entry.offset). + * Mutates `cursor` to the position AFTER placing the field (advancing to the + * next slot if the current one filled, offset >= 32) and returns the field's + * PRE-advance byte offset. + * + * Allocation-free: callers reuse one cursor across a packing loop instead of + * allocating a result object per field. The arithmetic is identical to the + * prior pure form. + */ +export interface SlotCursor { + slot: number; + offset: number; +} + +export function placeField(cursor: SlotCursor, size: number): number { + if (cursor.offset > 0 && (32 - cursor.offset) < size) { + cursor.slot += 1; + cursor.offset = 0; + } + const fieldOffset = cursor.offset; + cursor.offset += size; + if (cursor.offset >= 32) { + cursor.slot += 1; + cursor.offset = 0; + } + return fieldOffset; +} + +/** + * Number of `size`-byte elements that pack into a single 32-byte slot. + */ +export function elementsPerSlot(size: number): number { + return Math.floor(32 / size); +} + // ---- struct slot count ------------------------------------------------ /** @@ -154,6 +197,7 @@ export function computeStructSlotCount( ): number { let slot = 0; let offset = 0; + const cur: SlotCursor = { slot: 0, offset: 0 }; // reused across members (no per-field alloc) for (const member of structDef.members) { const memberEncoding = getEncoding(member.typeName, symbols); @@ -183,15 +227,9 @@ export function computeStructSlotCount( // Inplace if (memberSize !== null) { - if (offset > 0 && (32 - offset) < memberSize) { - slot += 1; - offset = 0; - } - offset += memberSize; - if (offset >= 32) { - slot += 1; - offset = 0; - } + cur.slot = slot; cur.offset = offset; + placeField(cur, memberSize); + slot = cur.slot; offset = cur.offset; } else { if (offset > 0) { slot += 1; offset = 0; } slot += 1; @@ -232,7 +270,7 @@ export function computeFixedArraySlotCount( const elemSize = getTypeSize(typeName.base, symbols); if (elemSize !== null) { if (elemSize <= 32) { - const elemsPerSlot = Math.floor(32 / elemSize); + const elemsPerSlot = elementsPerSlot(elemSize); return Math.ceil(length / elemsPerSlot); } // Large elements (e.g. nested fixed arrays) -- each takes multiple slots diff --git a/src/utils/storageLayoutDecode.ts b/src/utils/storageLayoutDecode.ts index f8eeff9d..4a411ce2 100644 --- a/src/utils/storageLayoutDecode.ts +++ b/src/utils/storageLayoutDecode.ts @@ -16,12 +16,11 @@ import { ethers } from 'ethers'; import type { StorageLayoutResponse, StorageLayoutEntry, - StorageTypeDefinition, StorageDiffEntry, } from '../types/debug'; import type { SimulationResult } from '../types/transaction'; -import { formatSlotHex, parseSlotInput } from './storageSlotCalculator'; -import { resolveSlotLabelComprehensive } from './storageLayoutResolver'; +import { formatSlotHex, parseSlotInput, buildScalarDescriptor } from './storageSlotCalculator'; +import { resolveSlotLabelComprehensive, walkStorageEntries } from './storageLayoutResolver'; /** A single field that occupies (part of) a storage slot */ export interface SlotDescriptor { @@ -97,14 +96,34 @@ export function buildSlotDescriptors( } } - for (const entry of layout.storage) { - const baseSlotBigint = BigInt(entry.slot); - const typeInfo: StorageTypeDefinition | undefined = layout.types[entry.type]; + walkStorageEntries(layout, ({ entry, slotHex, isMember, parentLabel, typeInfo, hasMembers }) => { const encoding = typeInfo?.encoding ?? 'unknown'; const typeLabel = typeInfo?.label ?? entry.type; const size = typeInfo ? Math.ceil(parseInt(typeInfo.numberOfBytes, 10) || 32) : 32; - // Create the top-level descriptor for this entry + if (isMember) { + // Inlined struct member — relative to the parent struct. + const memberDescriptor: SlotDescriptor = { + label: `${parentLabel}.${entry.label}`, + typeLabel, + typeKey: entry.type, + offset: entry.offset, + size: Math.min(size, 32), + encoding, + entry, + }; + addDescriptor(slotHex, memberDescriptor); + return; + } + + // If it's a struct with members, add members only (not the parent struct + // descriptor — it would produce a meaningless whole-word decode alongside + // the meaningful member-level decodes) + if (encoding === 'inplace' && hasMembers) { + return; + } + + // Non-struct entries: add the top-level descriptor directly const topDescriptor: SlotDescriptor = { label: entry.label, typeLabel, @@ -114,40 +133,8 @@ export function buildSlotDescriptors( encoding, entry, }; - - const slotHex = formatSlotHex(baseSlotBigint); - - // If it's a struct with members, add members only (not the parent struct - // descriptor — it would produce a meaningless whole-word decode alongside - // the meaningful member-level decodes) - if (encoding === 'inplace' && typeInfo?.members) { - for (const member of typeInfo.members) { - const memberSlotBigint = baseSlotBigint + BigInt(member.slot); - const memberSlotHex = formatSlotHex(memberSlotBigint); - const memberTypeInfo = layout.types[member.type]; - const memberEncoding = memberTypeInfo?.encoding ?? 'unknown'; - const memberTypeLabel = memberTypeInfo?.label ?? member.type; - const memberSize = memberTypeInfo - ? Math.ceil(parseInt(memberTypeInfo.numberOfBytes, 10) || 32) - : 32; - - const memberDescriptor: SlotDescriptor = { - label: `${entry.label}.${member.label}`, - typeLabel: memberTypeLabel, - typeKey: member.type, - offset: member.offset, - size: Math.min(memberSize, 32), - encoding: memberEncoding, - entry: member, - }; - - addDescriptor(memberSlotHex, memberDescriptor); - } - } else { - // Non-struct entries: add the top-level descriptor directly - addDescriptor(slotHex, topDescriptor); - } - } + addDescriptor(slotHex, topDescriptor); + }); return index; } @@ -347,16 +334,13 @@ export function decodeDiffFields( if (match.valueTypeLabel) { // Build a synthetic descriptor for type-aware decoding. // Use actual size from resolved leaf type for correct signed int / bytesN decode. - const leafSize = match.valueNumberOfBytes ?? 32; - const syntheticDescriptor: SlotDescriptor = { + const syntheticDescriptor = buildScalarDescriptor({ label: match.resolvedLabel ?? 'unknown', typeLabel: match.valueTypeLabel, typeKey: match.valueTypeId ?? '', - offset: 0, - size: leafSize, + size: match.valueNumberOfBytes ?? 32, encoding: match.valueEncoding ?? 'inplace', - entry: { label: '', offset: 0, slot: '0', type: match.valueTypeId ?? '', astId: 0, contract: '' }, - }; + }); try { if (beforeHex) beforeDecoded = decodeSlotValue(beforeHex, syntheticDescriptor); } catch { /* malformed hex */ } diff --git a/src/utils/storageLayoutResolver.ts b/src/utils/storageLayoutResolver.ts index b24b5967..88017251 100644 --- a/src/utils/storageLayoutResolver.ts +++ b/src/utils/storageLayoutResolver.ts @@ -18,7 +18,7 @@ import type { StorageLayoutEntry, StorageTypeDefinition, } from '../types/debug'; -import { computeMappingSlot, computeArrayElementSlot, formatSlotHex } from './storageSlotCalculator'; +import { computeMappingSlot, computeArrayElementSlot, formatSlotHex, resolveAbiKeyType } from './storageSlotCalculator'; /** Structured result from slot resolution, carrying type info for decoding */ export interface SlotResolutionResult { @@ -84,6 +84,67 @@ export function resolveLeafValueType( }; } +/** A single visit emitted by walkStorageEntries. */ +export interface StorageEntryVisit { + /** The layout entry being visited (a top-level entry or a struct member). */ + entry: StorageLayoutEntry; + /** Absolute slot for this entry (top-level slot, or parent.slot + member.slot). */ + slot: bigint; + /** Canonical 0x-padded hex of `slot`. */ + slotHex: string; + /** True when `entry` is an inlined struct member. */ + isMember: boolean; + /** Parent entry's label (member visits only). */ + parentLabel?: string; + /** The type definition for `entry`, if known. */ + typeInfo?: StorageTypeDefinition; + /** Whether this entry has inlined struct members that will be visited next (top-level only). */ + hasMembers: boolean; +} + +/** + * Single layout-walk visitor over top-level storage entries AND their inlined + * struct members. Each top-level entry is visited first (isMember=false), + * followed by each of its members (isMember=true) at parent.slot + member.slot. + * + * This is the one place member-slot arithmetic lives; the slot-map, descriptor, + * and slot-resolution walkers are thin callbacks over it. + */ +export function walkStorageEntries( + layout: StorageLayoutResponse, + visit: (v: StorageEntryVisit) => void, +): void { + for (const entry of layout.storage) { + const baseSlot = BigInt(entry.slot); + const typeInfo = layout.types[entry.type]; + const hasMembers = !!typeInfo?.members; + + visit({ + entry, + slot: baseSlot, + slotHex: formatSlotHex(baseSlot), + isMember: false, + typeInfo, + hasMembers, + }); + + if (typeInfo?.members) { + for (const member of typeInfo.members) { + const memberSlot = baseSlot + BigInt(member.slot); + visit({ + entry: member, + slot: memberSlot, + slotHex: formatSlotHex(memberSlot), + isMember: true, + parentLabel: entry.label, + typeInfo: layout.types[member.type], + hasMembers: false, + }); + } + } + } +} + /** * Build a map from slot hex → variable label for all simple (non-derived) slots. * Simple variables, structs inlined in storage, and fixed-size arrays. @@ -91,25 +152,19 @@ export function resolveLeafValueType( export function buildSlotMap(layout: StorageLayoutResponse): Map { const map = new Map(); - for (const entry of layout.storage) { - const slotHex = formatSlotHex(BigInt(entry.slot)); - const typeInfo = layout.types[entry.type]; + walkStorageEntries(layout, ({ entry, slotHex, isMember, parentLabel, typeInfo }) => { + if (isMember) { + map.set(slotHex, `${parentLabel}.${entry.label} (${typeInfo?.label || entry.type})`); + return; + } if (!typeInfo) { map.set(slotHex, entry.label); - continue; + return; } if (typeInfo.encoding === 'inplace') { map.set(slotHex, `${entry.label} (${typeInfo.label})`); - if (typeInfo.members) { - for (const member of typeInfo.members) { - const memberSlot = BigInt(entry.slot) + BigInt(member.slot); - const memberSlotHex = formatSlotHex(memberSlot); - const memberType = layout.types[member.type]; - map.set(memberSlotHex, `${entry.label}.${member.label} (${memberType?.label || member.type})`); - } - } } if (typeInfo.encoding === 'mapping') { @@ -123,7 +178,7 @@ export function buildSlotMap(layout: StorageLayoutResponse): Map if (typeInfo.encoding === 'bytes') { map.set(slotHex, `${entry.label} (${typeInfo.label})`); } - } + }); return map; } @@ -183,6 +238,43 @@ export function tryResolveMappingSlot( return null; } +/** + * Memo cache for computeMappingSlot, keyed by `${seed}-${key}-${type}`. + * + * computeMappingSlot is target-independent: the same (seed, key, type) always + * derives the same slot, regardless of which target slot the DFS is searching + * for. matchSlot resolves one target slot per storage diff, so without a cache + * the identical (seed, key, type) keccak hashes are recomputed for every diff. + * Caching the keccak (not the target-dependent SlotResolutionResult) hashes each + * distinct (seed, key, type) exactly once and reuses it across all target slots. + */ +const MAPPING_SLOT_CACHE_MAX = 5000; +const mappingSlotCache = new Map(); + +function computeMappingSlotCached( + seed: bigint, + key: string, + keyType: string +): bigint { + const cacheKey = `${seed.toString()}-${key}-${keyType}`; + const cached = mappingSlotCache.get(cacheKey); + if (cached !== undefined) { + // Recency refresh so the bounded cache evicts genuinely-cold entries. + mappingSlotCache.delete(cacheKey); + mappingSlotCache.set(cacheKey, cached); + return cached; + } + const derived = computeMappingSlot(seed, key, keyType); + mappingSlotCache.set(cacheKey, derived); + // Bound the module-global cache; it would otherwise grow unbounded across a + // long session. Evict the least-recently-used entry once over the cap. + if (mappingSlotCache.size > MAPPING_SLOT_CACHE_MAX) { + const oldest = mappingSlotCache.keys().next().value; + if (oldest !== undefined) mappingSlotCache.delete(oldest); + } + return derived; +} + /** * DFS search through nested mapping structure to find the target slot. */ @@ -206,8 +298,7 @@ function dfsMappingSearch( const keyTypeId = typeDef.key; if (!keyTypeId) return null; const keyTypeInfo = layout.types[keyTypeId]; - const keyTypeName = keyTypeInfo?.label || 'uint256'; - const abiKeyType = mapSolidityTypeToAbiType(keyTypeName); + const abiKeyType = resolveAbiKeyType({ typeId: keyTypeId, typeLabel: keyTypeInfo?.label }) ?? 'uint256'; // The value type at this level const valueTypeId = typeDef.value; @@ -215,7 +306,7 @@ function dfsMappingSearch( for (const key of knownKeys) { let derivedSlot: bigint; try { - derivedSlot = computeMappingSlot(currentSeed, key, abiKeyType); + derivedSlot = computeMappingSlotCached(currentSeed, key, abiKeyType); } catch { continue; // key not valid for this type } @@ -358,32 +449,11 @@ function findDirectEntry( ): StorageLayoutEntry | null { const normalized = formatSlotHex(BigInt(slot)); - for (const entry of layout.storage) { - const slotHex = formatSlotHex(BigInt(entry.slot)); - if (slotHex === normalized) return entry; - - // Check struct members - const typeInfo = layout.types[entry.type]; - if (typeInfo?.members) { - for (const member of typeInfo.members) { - const memberSlot = BigInt(entry.slot) + BigInt(member.slot); - if (formatSlotHex(memberSlot) === normalized) return member; - } - } - } - - return null; -} + let found: StorageLayoutEntry | null = null; + walkStorageEntries(layout, ({ entry, slotHex }) => { + if (found) return; + if (slotHex === normalized) found = entry; + }); -/** Map Solidity type names to ABI encoder type strings */ -function mapSolidityTypeToAbiType(typeName: string): string { - if (typeName === 'address') return 'address'; - if (typeName === 'bool') return 'bool'; - if (typeName === 'string') return 'string'; - if (typeName.startsWith('uint')) return typeName; - if (typeName.startsWith('int')) return typeName; - if (typeName.startsWith('bytes')) return typeName; - // Contract types (e.g. "contract IERC20") are addresses - if (typeName.startsWith('contract ')) return 'address'; - return 'uint256'; + return found; } diff --git a/src/utils/storageSlotCalculator.ts b/src/utils/storageSlotCalculator.ts index 42da3a8c..183a510f 100644 --- a/src/utils/storageSlotCalculator.ts +++ b/src/utils/storageSlotCalculator.ts @@ -1,4 +1,5 @@ import { ethers } from 'ethers'; +import type { SlotDescriptor } from './storageLayoutDecode'; /** * Compute the storage slot for a Solidity mapping entry. @@ -88,3 +89,72 @@ export function parseSlotInput(input: string): bigint { if (!trimmed) throw new Error('Empty slot input'); return BigInt(trimmed); } + +/** + * Canonical resolver: layout typeId/label → ABI-encoder key-type string. + * + * Resolves the Solidity type of a mapping key into the type string that + * `computeMappingSlot` feeds to the ABI encoder. Prefers the type + * definition's `label` (most reliable), then falls back to parsing the + * `typeId` string. Returns `null` when the type is unrecognized — callers + * keep their own `?? 'uint256'` (or candidate) fallback. + */ +export function resolveAbiKeyType(opts: { typeId?: string; typeLabel?: string }): string | null { + const { typeId, typeLabel } = opts; + + // Try the type definition's label first — this is the canonical Solidity type + if (typeLabel) { + const label = typeLabel.trim(); + // Contract types are addresses + if (label.startsWith('contract ') || label.startsWith('interface ')) return 'address'; + // Enum types are uint8 in storage + if (label.startsWith('enum ')) return 'uint8'; + // Direct Solidity type labels + if (label === 'address' || label === 'address payable') return 'address'; + if (label === 'bool') return 'bool'; + if (label === 'string') return 'bytes32'; // string keys in mappings are hashed + if (/^bytes\d{0,2}$/.test(label)) return label; // bytes1..bytes32 + if (/^uint\d+$/.test(label)) return label; // uint8..uint256 + if (/^int\d+$/.test(label)) return label; // int8..int256 + } + // Fallback: parse the typeId string (e.g. "t_address", "t_uint256", "t_contract(IERC20)") + if (!typeId) return null; + if (typeId.startsWith('t_contract') || typeId.startsWith('t_address')) return 'address'; + if (typeId.startsWith('t_bool')) return 'bool'; + if (typeId.startsWith('t_enum')) return 'uint8'; + if (typeId.startsWith('t_string')) return 'bytes32'; + const bytesMatch = typeId.match(/^t_bytes(\d+)$/); + if (bytesMatch) return `bytes${bytesMatch[1]}`; + const uintMatch = typeId.match(/^t_uint(\d+)$/); + if (uintMatch) return `uint${uintMatch[1]}`; + const intMatch = typeId.match(/^t_int(\d+)$/); + if (intMatch) return `int${intMatch[1]}`; + return null; +} + +/** + * Build a synthetic single-field SlotDescriptor for type-aware scalar decoding. + * + * Used by derived-slot decode paths (mapping/array leaf values) that have a + * resolved value type but no real layout entry. Fills offset 0, default size + * 32, default encoding 'inplace', and a placeholder layout entry so callers + * stop hand-fabricating the same literal. + */ +export function buildScalarDescriptor(args: { + label?: string; + typeLabel: string; + typeKey?: string; + size?: number; + encoding?: string; +}): SlotDescriptor { + const { label = '', typeLabel, typeKey = '', size = 32, encoding = 'inplace' } = args; + return { + label, + typeLabel, + typeKey, + offset: 0, + size, + encoding, + entry: { label: '', offset: 0, slot: '0', type: typeKey, astId: 0, contract: '' }, + }; +} diff --git a/src/utils/traceDecoder/analysisHelpers.ts b/src/utils/traceDecoder/analysisHelpers.ts index df68abab..08d39e73 100644 --- a/src/utils/traceDecoder/analysisHelpers.ts +++ b/src/utils/traceDecoder/analysisHelpers.ts @@ -5,10 +5,11 @@ import { ethers } from "ethers"; import type { DecodedTraceRow, PcInfo, DecodeTraceContext, FunctionRange } from './types'; import { formatAbiVal } from './formatting'; -import { fnForLine, fnForLineIfAtStart, parseFunctions, parseFunctionSignatures, +import { fnForLine, parseFunctions, parseFunctionSignatures, buildSourceTextResolver } from './sourceParser'; import { getCallFrames, getERC20FunctionsInterface, getERC721FunctionsInterface } from './stackDecoding'; +import { createPcResolvers, traceIdFromFrame } from './pcResolution'; // ── Shared helper state passed between analysis sub-phases ───────────── @@ -326,124 +327,18 @@ export function buildAnalysisLocals(ctx: DecodeTraceContext, callFrameRows: Deco const hasMultipleContractMaps = traceIdToCodeAddr.size > 1 || pcMapsPerContract.size > 1; const resolveCodeAddrForFrame = (frameId: any): string | undefined => { - if (!Array.isArray(frameId) || frameId.length < 1) return undefined; - const traceId = typeof frameId[0] === 'number' ? frameId[0] : parseInt(String(frameId[0]), 10); - if (isNaN(traceId)) return undefined; + const traceId = traceIdFromFrame(frameId); + if (traceId === null) return undefined; return traceIdToCodeAddr.get(traceId); }; - const getPcInfoForOpcode = (pc: number, frameId: any): PcInfo | undefined => { - if (Array.isArray(frameId) && frameId.length >= 1) { - const traceId = typeof frameId[0] === 'number' ? frameId[0] : parseInt(String(frameId[0]), 10); - if (isNaN(traceId)) return undefined; - if (unverifiedTraceIds.has(traceId)) return undefined; - const codeAddr = traceIdToCodeAddr.get(traceId); - if (codeAddr) { - const contractPcMap = pcMapsPerContract.get(codeAddr); - if (contractPcMap?.has(pc)) return contractPcMap.get(pc); - if (hasMultipleContractMaps) return undefined; - } - } - return pcMapFull?.get(pc); - }; - - const pcInfoForPc = (pc: number, frameId?: any): PcInfo | undefined => { - if (frameId) { - const info = getPcInfoForOpcode(pc, frameId); - if (info) return info; - } - return pcMapFull ? pcMapFull.get(pc) : undefined; - }; - - const lineForPc = (pc: number, frameId?: any): number | undefined => { - const pcInfo = pcInfoForPc(pc, frameId); - if (pcInfo?.line !== undefined) return pcInfo.line; - if (frameId) { - const codeAddr = resolveCodeAddrForFrame(frameId); - if (codeAddr) { - const filtered = pcMapsFilteredPerContract.get(codeAddr); - if (filtered?.has(pc)) return filtered.get(pc); - if (hasMultipleContractMaps) return undefined; - } - } - if (pcMapFiltered && pcMapFiltered.has(pc)) return pcMapFiltered.get(pc); - return undefined; - }; - - const fnForPc = (pc: number, frameId?: any) => { - const pcInfo = pcInfoForPc(pc, frameId); - if (!pcInfo) return null; - if (pcInfo.line === undefined) return null; - const { line, file } = pcInfo; - if (file) { - let fileFnRanges = fnRangesPerFile.get(file); - if (!fileFnRanges || fileFnRanges.length === 0) { - const filename = file.split('/').pop() || file; - fileFnRanges = fnRangesPerFile.get(filename); - } - if (fileFnRanges && fileFnRanges.length > 0) { - const fn = fnForLine(fileFnRanges, line); - if (fn) return fn; - } - return null; - } - const codeAddr = resolveCodeAddrForFrame(frameId); - if (codeAddr) { - const contractFnRanges = ctx.codeAddrToFnRanges.get(codeAddr); - if (contractFnRanges && contractFnRanges.length > 0) { - const fn = fnForLine(contractFnRanges, line); - if (fn) return fn; - } - if (hasMultipleContractMaps) return null; - } - return hasMultipleContractMaps ? null : fnForLine(fnRanges, line); - }; - - const modifierForPc = (pc: number, frameId?: any): string | null => { - const pcInfo = pcInfoForPc(pc, frameId); - if (!pcInfo || pcInfo.line === undefined) return null; - const { line, file } = pcInfo; - if (!file) return null; - - let fileModifierRanges = modifierRangesPerFile.get(file); - if (!fileModifierRanges || fileModifierRanges.length === 0) { - const filename = file.split('/').pop() || file; - fileModifierRanges = modifierRangesPerFile.get(filename); - } - if (!fileModifierRanges || fileModifierRanges.length === 0) return null; - - return fnForLine(fileModifierRanges, line); - }; - - const fnForPcIfAtEntry = (pc: number, frameId?: any): string | null => { - const pcInfo = pcInfoForPc(pc, frameId); - if (!pcInfo || pcInfo.line === undefined) return null; - const { line, file } = pcInfo; - if (file) { - let fileFnRanges = fnRangesPerFile.get(file); - if (!fileFnRanges || fileFnRanges.length === 0) { - const filename = file.split('/').pop() || file; - fileFnRanges = fnRangesPerFile.get(filename); - } - if (fileFnRanges && fileFnRanges.length > 0) { - return fnForLineIfAtStart(fileFnRanges, line, 15); - } - return null; - } - const codeAddr = resolveCodeAddrForFrame(frameId); - if (codeAddr) { - const contractFnRanges = ctx.codeAddrToFnRanges.get(codeAddr); - if (contractFnRanges && contractFnRanges.length > 0) { - return fnForLineIfAtStart(contractFnRanges, line, 15); - } - if (hasMultipleContractMaps) return null; - } - return hasMultipleContractMaps ? null : fnForLineIfAtStart(fnRanges, line, 15); - }; - - const jumpTypeForPc = (pc: number, frameId?: any): PcInfo['jumpType'] | undefined => { - return pcInfoForPc(pc, frameId)?.jumpType; - }; + const { pcInfoForPc, lineForPc, fnForPc, modifierForPc, fnForPcIfAtEntry, + jumpTypeForPc } = createPcResolvers({ + pcMapFull, pcMapFiltered, pcMapsPerContract, pcMapsFilteredPerContract, + traceIdToCodeAddr, codeAddrToFnRanges: ctx.codeAddrToFnRanges, + fnRangesPerFile, modifierRangesPerFile, fnRanges, unverifiedTraceIds, + hasMultipleContractMaps, + }); // Use cached resolver const getSourceContent = buildSourceTextResolver(sourceTexts); @@ -512,12 +407,6 @@ export function buildAnalysisLocals(ctx: DecodeTraceContext, callFrameRows: Deco const allJumps = opRows.filter((r) => jumpOpcodes.has(r.name)); - const traceIdFromFrame = (frameId: any): number | null => { - if (!Array.isArray(frameId) || frameId.length < 1) return null; - const traceId = typeof frameId[0] === 'number' ? frameId[0] : parseInt(String(frameId[0]), 10); - return Number.isNaN(traceId) ? null : traceId; - }; - const opRowIndexByIdForJump = new Map(); opRows.forEach((row, idx) => { if (row.id !== undefined) opRowIndexByIdForJump.set(row.id, idx); diff --git a/src/utils/traceDecoder/callHierarchy.ts b/src/utils/traceDecoder/callHierarchy.ts index d489c7f9..e48930ff 100644 --- a/src/utils/traceDecoder/callHierarchy.ts +++ b/src/utils/traceDecoder/callHierarchy.ts @@ -6,6 +6,7 @@ import type { DecodedTraceRow, DecodeTraceContext, FnCallInfo } from './types'; import { parseLogStack, decodeLogWithFallback } from './eventDecoding'; import { validateSourceLineContainsFunctionCall, findCorrectCallLine } from './sourceParser'; import type { AnalysisLocals } from './analysisHelpers'; +import { traceIdFromFrame } from './pcResolution'; // ── Row assembly + LOG decoding ──────────────────────────────────────── @@ -131,8 +132,8 @@ export function buildCallHierarchy( const frameId = cfr.frame_id; const entryFn = cfr.entryMeta?.function || cfr.fn; if (Array.isArray(frameId) && frameId.length >= 1 && entryFn) { - const traceId = typeof frameId[0] === 'number' ? frameId[0] : parseInt(String(frameId[0]), 10); - if (!isNaN(traceId)) { + const traceId = traceIdFromFrame(frameId); + if (traceId !== null) { const cleanFn = entryFn.includes('.') ? entryFn.split('.').pop() || entryFn : entryFn; frameIdToEntryFn.set(traceId, cleanFn); } @@ -149,11 +150,7 @@ export function buildCallHierarchy( const targetFn = row.destFn; const isRecursive = callerFn !== null && callerFn === targetFn; - let frameTraceId = 0; - const frameId = row.frame_id; - if (Array.isArray(frameId) && frameId.length >= 1) { - frameTraceId = typeof frameId[0] === 'number' ? frameId[0] : parseInt(String(frameId[0]), 10); - } + const frameTraceId = traceIdFromFrame(row.frame_id) ?? 0; const destFile = row.destSourceFile || row.sourceFile; const destLine = row.destLine ?? null; @@ -227,11 +224,7 @@ export function buildCallHierarchy( } internalCallStack.length = returnIndex + 1; } else if (returnIndex < 0) { - let opFrameTraceId = 0; - const opFrameId = opRow.frame_id; - if (Array.isArray(opFrameId) && opFrameId.length >= 1) { - opFrameTraceId = typeof opFrameId[0] === 'number' ? opFrameId[0] : parseInt(String(opFrameId[0]), 10); - } + const opFrameTraceId = traceIdFromFrame(opRow.frame_id) ?? 0; const externalEntryFnRaw = frameIdToEntryFn.get(opFrameTraceId); const externalEntryFn = externalEntryFnRaw?.includes('(') ? externalEntryFnRaw.split('(')[0] : externalEntryFnRaw; @@ -325,11 +318,7 @@ export function buildCallHierarchy( } if (returnOpcodes.has(opRow.name)) { - let opFrameTraceId = 0; - const opFrameId = opRow.frame_id; - if (Array.isArray(opFrameId) && opFrameId.length >= 1) { - opFrameTraceId = typeof opFrameId[0] === 'number' ? opFrameId[0] : parseInt(String(opFrameId[0]), 10); - } + const opFrameTraceId = traceIdFromFrame(opRow.frame_id) ?? 0; closeOpenCalls(opRow.id, opFrameTraceId); } else if (opRow.name && jumpOpcodes.has(opRow.name) && opJumpType === 'o') { const topId = internalCallStack[internalCallStack.length - 1]; diff --git a/src/utils/traceDecoder/decodeTraceInit.ts b/src/utils/traceDecoder/decodeTraceInit.ts index 228af93e..5e77d16e 100644 --- a/src/utils/traceDecoder/decodeTraceInit.ts +++ b/src/utils/traceDecoder/decodeTraceInit.ts @@ -11,6 +11,7 @@ import { formatAbiVal } from './formatting'; import { parseFunctions, parseModifiers, parseFunctionSignatures, fnForLine } from './sourceParser'; import { buildFullPcLineMap } from './pcMapper'; import { getCallFrames } from './stackDecoding'; +import { createPcResolvers, traceIdFromFrame } from './pcResolution'; type ArtifactSourceValue = string | { content?: string }; type FunctionSignatureMap = Record; @@ -455,23 +456,15 @@ export function phaseInit(raw: RawTrace): DecodeTraceContext { }); } - const getPcInfoForOpcode = (pc: number, frameId: any): PcInfo | undefined => { - if (Array.isArray(frameId) && frameId.length >= 1) { - const traceId = typeof frameId[0] === 'number' ? frameId[0] : parseInt(String(frameId[0]), 10); - if (unverifiedTraceIds.has(traceId)) { - // Do not borrow lines from the primary contract for unverified frames. - // Cross-contract fallback attribution is a major source of false src maps. - return undefined; - } - const codeAddr = traceIdToCodeAddr.get(traceId); - if (codeAddr) { - const contractPcMap = pcMapsPerContract.get(codeAddr); - if (contractPcMap?.has(pc)) return contractPcMap.get(pc); - if (hasMultipleContractMaps) return undefined; - } - } - return pcMapFull?.get(pc); - }; + const { getPcInfoForOpcode } = createPcResolvers({ + pcMapFull, pcMapFiltered, pcMapsPerContract, pcMapsFilteredPerContract, + traceIdToCodeAddr, codeAddrToFnRanges: new Map(), + fnRangesPerFile, modifierRangesPerFile, fnRanges, unverifiedTraceIds, + hasMultipleContractMaps, + // Preserve init's original behaviour: a non-numeric frame traceId falls + // through to the global pcMap rather than yielding no info. + fallBackToGlobalOnInvalidFrame: true, + }); const opcodeDetails = snaps.map((s: any) => s.detail?.Opcode).filter(Boolean); @@ -513,9 +506,9 @@ export function phaseInit(raw: RawTrace): DecodeTraceContext { let depth: number | undefined; const frameId = cur.frame_id; - if (Array.isArray(frameId) && frameId.length >= 1) { - const traceId = typeof frameId[0] === 'number' ? frameId[0] : parseInt(String(frameId[0]), 10); - if (traceIdToDepth.has(traceId)) depth = traceIdToDepth.get(traceId); + const arrayTraceId = traceIdFromFrame(frameId); + if (arrayTraceId !== null) { + if (traceIdToDepth.has(arrayTraceId)) depth = traceIdToDepth.get(arrayTraceId); } else if (frameId && typeof frameId === 'object' && (frameId as any).trace_id !== undefined) { const traceId = (frameId as any).trace_id; if (traceIdToDepth.has(traceId)) depth = traceIdToDepth.get(traceId); @@ -638,8 +631,9 @@ export function phaseInit(raw: RawTrace): DecodeTraceContext { opRows.forEach((r) => { let isInUnverifiedFrame = false; let traceId: number | undefined; - if (Array.isArray(r.frame_id) && r.frame_id.length >= 1) { - traceId = typeof r.frame_id[0] === 'number' ? r.frame_id[0] : parseInt(String(r.frame_id[0]), 10); + const arrayTraceId = traceIdFromFrame(r.frame_id); + if (arrayTraceId !== null) { + traceId = arrayTraceId; } else if (r.frame_id && typeof r.frame_id === 'object' && (r.frame_id as any).trace_id !== undefined) { traceId = (r.frame_id as any).trace_id; } @@ -676,8 +670,9 @@ export function phaseInit(raw: RawTrace): DecodeTraceContext { let traceId: number | undefined; const frameId = r.frame_id; - if (Array.isArray(frameId) && frameId.length >= 1) { - traceId = typeof frameId[0] === 'number' ? frameId[0] : parseInt(String(frameId[0]), 10); + const arrayTraceId = traceIdFromFrame(frameId); + if (arrayTraceId !== null) { + traceId = arrayTraceId; } else if (frameId && typeof frameId === 'object' && (frameId as any).trace_id !== undefined) { traceId = (frameId as any).trace_id; } diff --git a/src/utils/traceDecoder/jumpAnalysis.ts b/src/utils/traceDecoder/jumpAnalysis.ts index 1895798b..d0632ffc 100644 --- a/src/utils/traceDecoder/jumpAnalysis.ts +++ b/src/utils/traceDecoder/jumpAnalysis.ts @@ -28,12 +28,6 @@ export function buildJumpRows( traceIdFromFrame, opRowIndexByIdForJump, nextRowInFrame, jumpDestPcFromRow, getFnVisibility } = locals; - const getTraceIdFromFrame = (frameId: any): number | null => { - if (!Array.isArray(frameId) || frameId.length < 1) return null; - const traceId = typeof frameId[0] === 'number' ? frameId[0] : parseInt(String(frameId[0]), 10); - return isNaN(traceId) ? null : traceId; - }; - // ── Build raw jump rows ────────────────────────────────────────────── const jumpRows = locals.allJumps @@ -284,7 +278,7 @@ export function buildJumpRows( const frameEntrySourceByTrace = new Map(); for (const entryRow of callFrameRows) { - const traceId = getTraceIdFromFrame(entryRow.frame_id); + const traceId = traceIdFromFrame(entryRow.frame_id); if (traceId === null) continue; const rawFn = entryRow.entryMeta?.function || entryRow.fn; @@ -298,7 +292,7 @@ export function buildJumpRows( frameEntryFnByTrace.set(traceId, cleanFn); const firstEntryOp = opRows.find((row) => { - const rowTraceId = getTraceIdFromFrame(row.frame_id); + const rowTraceId = traceIdFromFrame(row.frame_id); return rowTraceId === traceId && row.fn === cleanFn && !!row.sourceFile && @@ -329,7 +323,7 @@ export function buildJumpRows( } const isRowStaticallyReachableFromEntry = (row: DecodedTraceRow): boolean => { - const traceId = getTraceIdFromFrame(row.frame_id); + const traceId = traceIdFromFrame(row.frame_id); if (traceId === null || !row.fn || !row.destFn) return true; const reachableFns = reachableFnsByTrace.get(traceId); if (!reachableFns || reachableFns.size === 0) return true; @@ -353,7 +347,7 @@ export function buildJumpRows( .slice() .sort((a, b) => (a.id ?? 0) - (b.id ?? 0)) .filter((row) => { - const traceId = getTraceIdFromFrame(row.frame_id); + const traceId = traceIdFromFrame(row.frame_id); if (traceId === null) return true; const callerFn = row.fn || null; @@ -528,13 +522,13 @@ export function buildJumpRows( for (const calleeFn of directCallees) { const hasDirectEdge = allJumpRows.some((row) => { - const rowTraceId = getTraceIdFromFrame(row.frame_id); + const rowTraceId = traceIdFromFrame(row.frame_id); return rowTraceId === traceId && row.fn === entryFn && row.destFn === calleeFn; }); if (hasDirectEdge) continue; const candidateIndex = allJumpRows.findIndex((row) => { - const rowTraceId = getTraceIdFromFrame(row.frame_id); + const rowTraceId = traceIdFromFrame(row.frame_id); return rowTraceId === traceId && row.fn === calleeFn; }); if (candidateIndex < 0) continue; @@ -567,15 +561,25 @@ export function buildJumpRows( // ── Source-line rescue ─────────────────────────────────────────────── + // Precompute lookup sets in single O(n) passes so the rescue loop below + // does Set.has() instead of full-array .some() scans per row. + const existingEdgeKeys = new Set(); + for (const row of allJumpRows) { + const rowTraceId = traceIdFromFrame(row.frame_id); + existingEdgeKeys.add(`${rowTraceId}|${row.fn}|${row.destFn}`); + } + const traceFnOpKeys = new Set(); + for (const op of opRows) { + const opTraceId = traceIdFromFrame(op.frame_id); + traceFnOpKeys.add(`${opTraceId}|${op.fn}`); + } + const hasEdge = (traceId: number, callerFn: string, calleeFn: string): boolean => - allJumpRows.some((row) => { - const rowTraceId = getTraceIdFromFrame(row.frame_id); - return rowTraceId === traceId && row.fn === callerFn && row.destFn === calleeFn; - }); + existingEdgeKeys.has(`${traceId}|${callerFn}|${calleeFn}`); const synthesizedFromSource: DecodedTraceRow[] = []; for (const row of allJumpRows) { - const traceId = getTraceIdFromFrame(row.frame_id); + const traceId = traceIdFromFrame(row.frame_id); if (traceId === null) continue; if (!row.srcSourceFile || row.srcLine === null || row.srcLine === undefined) continue; @@ -593,10 +597,7 @@ export function buildJumpRows( if (!callerFromSourceLine || callerFromSourceLine === lineCalleeFn) continue; if (hasEdge(traceId, callerFromSourceLine, lineCalleeFn)) continue; - const hasCalleeOps = opRows.some((op) => { - const opTraceId = getTraceIdFromFrame(op.frame_id); - return opTraceId === traceId && op.fn === lineCalleeFn; - }); + const hasCalleeOps = traceFnOpKeys.has(`${traceId}|${lineCalleeFn}`); if (!hasCalleeOps) continue; const calleeDef = findFunctionDefinition(lineCalleeFn); @@ -619,7 +620,7 @@ export function buildJumpRows( // ── Dedup ──────────────────────────────────────────────────────────── const dedupeKey = (row: DecodedTraceRow): string => { - const traceId = getTraceIdFromFrame(row.frame_id); + const traceId = traceIdFromFrame(row.frame_id); return `${traceId ?? -1}|${row.fn ?? ''}|${row.destFn ?? ''}|${row.srcSourceFile ?? ''}|${row.srcLine ?? ''}`; }; diff --git a/src/utils/traceDecoder/pcResolution.ts b/src/utils/traceDecoder/pcResolution.ts new file mode 100644 index 00000000..6420f268 --- /dev/null +++ b/src/utils/traceDecoder/pcResolution.ts @@ -0,0 +1,189 @@ +/** + * Single home for the PC-resolution strategy (contract-pcMap precedence + + * unverified guard + hasMultipleContractMaps guard + filtered/global fallbacks) + * and the canonical traceIdFromFrame, consumed by both buildAnalysisLocals + * (analysisHelpers.ts) and phaseInit (decodeTraceInit.ts). + */ + +import type { PcInfo, FunctionRange } from './types'; +import { fnForLine, fnForLineIfAtStart } from './sourceParser'; + +/** Extract traceId from a frame_id array; null when absent or non-numeric. */ +export const traceIdFromFrame = (frameId: any): number | null => { + if (!Array.isArray(frameId) || frameId.length < 1) return null; + const traceId = typeof frameId[0] === 'number' ? frameId[0] : parseInt(String(frameId[0]), 10); + return Number.isNaN(traceId) ? null : traceId; +}; + +/** Maps/flags read by the PC-resolution family. */ +export interface PcResolutionMaps { + pcMapFull: Map | null; + pcMapFiltered: Map | null; + pcMapsPerContract: Map>; + pcMapsFilteredPerContract: Map>; + traceIdToCodeAddr: Map; + codeAddrToFnRanges: Map; + fnRangesPerFile: Map; + modifierRangesPerFile: Map; + fnRanges: FunctionRange[]; + unverifiedTraceIds: Set; + hasMultipleContractMaps: boolean; + /** + * When a frame's traceId is non-numeric, fall through to the global pcMapFull + * (decodeTraceInit's original behaviour) instead of returning undefined + * (analysis's original behaviour). Defaults to false. + */ + fallBackToGlobalOnInvalidFrame?: boolean; +} + +/** The PC-resolution closure family. */ +export interface PcResolvers { + getPcInfoForOpcode: (pc: number, frameId: any) => PcInfo | undefined; + pcInfoForPc: (pc: number, frameId?: any) => PcInfo | undefined; + lineForPc: (pc: number, frameId?: any) => number | undefined; + fnForPc: (pc: number, frameId?: any) => string | null; + modifierForPc: (pc: number, frameId?: any) => string | null; + fnForPcIfAtEntry: (pc: number, frameId?: any) => string | null; + jumpTypeForPc: (pc: number, frameId?: any) => PcInfo['jumpType'] | undefined; +} + +/** + * Build all PC-resolution closures over the given maps/flags. The factory + * closes over the maps argument, NOT a DecodeTraceContext. + */ +export function createPcResolvers(maps: PcResolutionMaps): PcResolvers { + const { pcMapFull, pcMapFiltered, pcMapsPerContract, pcMapsFilteredPerContract, + traceIdToCodeAddr, codeAddrToFnRanges, fnRangesPerFile, + modifierRangesPerFile, fnRanges, unverifiedTraceIds, + hasMultipleContractMaps, fallBackToGlobalOnInvalidFrame } = maps; + + const resolveCodeAddrForFrame = (frameId: any): string | undefined => { + if (!Array.isArray(frameId) || frameId.length < 1) return undefined; + const traceId = typeof frameId[0] === 'number' ? frameId[0] : parseInt(String(frameId[0]), 10); + if (isNaN(traceId)) return undefined; + return traceIdToCodeAddr.get(traceId); + }; + + const getPcInfoForOpcode = (pc: number, frameId: any): PcInfo | undefined => { + if (Array.isArray(frameId) && frameId.length >= 1) { + const traceId = typeof frameId[0] === 'number' ? frameId[0] : parseInt(String(frameId[0]), 10); + if (isNaN(traceId)) return fallBackToGlobalOnInvalidFrame ? pcMapFull?.get(pc) : undefined; + if (unverifiedTraceIds.has(traceId)) return undefined; + const codeAddr = traceIdToCodeAddr.get(traceId); + if (codeAddr) { + const contractPcMap = pcMapsPerContract.get(codeAddr); + if (contractPcMap?.has(pc)) return contractPcMap.get(pc); + if (hasMultipleContractMaps) return undefined; + } + } + return pcMapFull?.get(pc); + }; + + const pcInfoForPc = (pc: number, frameId?: any): PcInfo | undefined => { + if (frameId) { + const info = getPcInfoForOpcode(pc, frameId); + if (info) return info; + } + return pcMapFull ? pcMapFull.get(pc) : undefined; + }; + + const lineForPc = (pc: number, frameId?: any): number | undefined => { + const pcInfo = pcInfoForPc(pc, frameId); + if (pcInfo?.line !== undefined) return pcInfo.line; + if (frameId) { + const codeAddr = resolveCodeAddrForFrame(frameId); + if (codeAddr) { + const filtered = pcMapsFilteredPerContract.get(codeAddr); + if (filtered?.has(pc)) return filtered.get(pc); + if (hasMultipleContractMaps) return undefined; + } + } + if (pcMapFiltered && pcMapFiltered.has(pc)) return pcMapFiltered.get(pc); + return undefined; + }; + + const fnForPc = (pc: number, frameId?: any) => { + const pcInfo = pcInfoForPc(pc, frameId); + if (!pcInfo) return null; + if (pcInfo.line === undefined) return null; + const { line, file } = pcInfo; + if (file) { + let fileFnRanges = fnRangesPerFile.get(file); + if (!fileFnRanges || fileFnRanges.length === 0) { + const filename = file.split('/').pop() || file; + fileFnRanges = fnRangesPerFile.get(filename); + } + if (fileFnRanges && fileFnRanges.length > 0) { + const fn = fnForLine(fileFnRanges, line); + if (fn) return fn; + } + return null; + } + const codeAddr = resolveCodeAddrForFrame(frameId); + if (codeAddr) { + const contractFnRanges = codeAddrToFnRanges.get(codeAddr); + if (contractFnRanges && contractFnRanges.length > 0) { + const fn = fnForLine(contractFnRanges, line); + if (fn) return fn; + } + if (hasMultipleContractMaps) return null; + } + return hasMultipleContractMaps ? null : fnForLine(fnRanges, line); + }; + + const modifierForPc = (pc: number, frameId?: any): string | null => { + const pcInfo = pcInfoForPc(pc, frameId); + if (!pcInfo || pcInfo.line === undefined) return null; + const { line, file } = pcInfo; + if (!file) return null; + + let fileModifierRanges = modifierRangesPerFile.get(file); + if (!fileModifierRanges || fileModifierRanges.length === 0) { + const filename = file.split('/').pop() || file; + fileModifierRanges = modifierRangesPerFile.get(filename); + } + if (!fileModifierRanges || fileModifierRanges.length === 0) return null; + + return fnForLine(fileModifierRanges, line); + }; + + const fnForPcIfAtEntry = (pc: number, frameId?: any): string | null => { + const pcInfo = pcInfoForPc(pc, frameId); + if (!pcInfo || pcInfo.line === undefined) return null; + const { line, file } = pcInfo; + if (file) { + let fileFnRanges = fnRangesPerFile.get(file); + if (!fileFnRanges || fileFnRanges.length === 0) { + const filename = file.split('/').pop() || file; + fileFnRanges = fnRangesPerFile.get(filename); + } + if (fileFnRanges && fileFnRanges.length > 0) { + return fnForLineIfAtStart(fileFnRanges, line, 15); + } + return null; + } + const codeAddr = resolveCodeAddrForFrame(frameId); + if (codeAddr) { + const contractFnRanges = codeAddrToFnRanges.get(codeAddr); + if (contractFnRanges && contractFnRanges.length > 0) { + return fnForLineIfAtStart(contractFnRanges, line, 15); + } + if (hasMultipleContractMaps) return null; + } + return hasMultipleContractMaps ? null : fnForLineIfAtStart(fnRanges, line, 15); + }; + + const jumpTypeForPc = (pc: number, frameId?: any): PcInfo['jumpType'] | undefined => { + return pcInfoForPc(pc, frameId)?.jumpType; + }; + + return { + getPcInfoForOpcode, + pcInfoForPc, + lineForPc, + fnForPc, + modifierForPc, + fnForPcIfAtEntry, + jumpTypeForPc, + }; +} diff --git a/vite.config.ts b/vite.config.ts index 676bd8e7..93a5e746 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -172,6 +172,7 @@ export default defineConfig(({ mode }) => { vendor: ["react", "react-dom", "react-router-dom"], wagmi: ["wagmi", "@wagmi/core", "@wagmi/connectors"], ethers: ["ethers"], + "framer-motion": ["framer-motion"], }, }, }, From 5324d0175ae760f4e13fb8c261fb329d2837dcb5 Mon Sep 17 00:00:00 2001 From: Timidan Date: Sat, 30 May 2026 15:49:02 +0100 Subject: [PATCH 3/3] chore: gitignore local tasks/ scratch artifacts --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 96b1c4b1..88d28c32 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,9 @@ edb *.spec.js *.spec.mjs *.spec.mts + +# Local task/scratch artifacts (plans, benches, reports) — kept out of the repo +tasks/ /tmp video/out .superpowers