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
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/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/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/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 99eb0630..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) {
@@ -481,7 +771,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/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