From 58152621770dd7c7eb50762975021481a3c8fa03 Mon Sep 17 00:00:00 2001 From: Timidan Date: Wed, 22 Apr 2026 21:06:07 +0100 Subject: [PATCH 1/2] feat: add hooks for storage URL synchronization, event signature lookup, and simulation debugging --- api/_shared/limits.ts | 6 + api/edb/[...path].ts | 30 +- api/explorer/etherscan.ts | 24 + api/explorer/etherscanShared.ts | 87 +--- api/lifi-composer.ts | 3 +- api/lifi-earn.ts | 3 +- api/llm-recommend.ts | 96 +++- api/vertexAuth.ts | 122 ----- edb | 2 +- package.json | 3 +- scripts/bridge-config.mjs | 1 - scripts/bridge-ffi-adapter.mjs | 123 +++++ scripts/bridge-ffi-schema.mjs | 80 +++ scripts/bridge-schemas.mjs | 105 ++++ scripts/bridge-security.mjs | 17 - scripts/check-inline-copy.mjs | 22 - scripts/debug-sessions.mjs | 173 +------ scripts/http-compression.mjs | 30 +- scripts/keep-alive-manager.mjs | 50 +- scripts/simulator-bridge.mjs | 288 +++++------ scripts/trace-processing.mjs | 106 ++-- src/components/SimulationResultsPage.tsx | 2 +- .../contract/ContractAddressInput.tsx | 1 - src/components/debug/DebugStatePanel.tsx | 27 +- src/components/debug/DebugToolbar.tsx | 3 +- src/components/debug/DebugWindow.tsx | 14 +- src/components/debug/EvaluateModal.tsx | 21 +- src/components/debug/ExecutionTree.tsx | 11 - .../execution-trace/useTraceState.ts | 3 +- src/components/explorer/ContractDiff.tsx | 3 +- src/components/explorer/SourceTools.tsx | 25 +- src/components/explorer/StorageCells.tsx | 5 +- src/components/explorer/StorageTableView.tsx | 18 +- .../storage-viewer/fetchStorageLayout.ts | 14 - .../storage-viewer/useSlotResolution.ts | 30 +- .../storage-viewer/useStorageEvidence.ts | 27 - .../explorer/storageViewerHelpers.ts | 18 + .../explorer/useStorageAutoDiscoveryScan.ts | 104 ++++ src/components/explorer/useStorageProbe.ts | 257 ++++++++++ src/components/explorer/useStorageUrlSync.ts | 70 +++ .../explorer/useStorageViewerState.ts | 282 ++--------- .../integrations/lifi-earn/TokenIcon.tsx | 6 +- .../integrations/lifi-earn/VaultList.tsx | 3 +- .../lifi-earn/concierge/FlowDiagram.tsx | 16 +- .../lifi-earn/concierge/IdleSweepPanel.tsx | 12 +- .../concierge/VaultRecommendations.tsx | 76 ++- .../lifi-earn/concierge/executionMachine.ts | 4 +- .../concierge/hooks/fetchAssetPrices.ts | 78 +-- .../concierge/hooks/useExecutionLegs.ts | 7 - .../concierge/hooks/useIdleBalances.ts | 19 +- .../hooks/useVaultRecommendations.ts | 63 +-- .../concierge/intent/IntentPanel.tsx | 132 ++--- .../concierge/intent/hooks/useIntentParser.ts | 42 +- .../intent/hooks/useIntentRecommendation.ts | 57 +-- .../integrations/lifi-earn/concierge/types.ts | 3 - .../integrations/lifi-earn/earnApi.ts | 103 ++-- .../integrations/lifi-earn/types.ts | 3 - src/components/simple-grid/GridContext.tsx | 1 - .../simple-grid/hooks/useSimulationState.tsx | 3 +- .../simulation-results/ContractsTab.tsx | 17 +- .../simulation-results/EventsTab.tsx | 23 +- .../simulation-results/StateTab.tsx | 14 +- .../simulation-results/TransactionSummary.tsx | 2 +- .../simulation-results/eventDecoder.ts | 27 +- .../simulation-results/formatters.ts | 54 +- src/components/simulation-results/types.ts | 2 +- .../useEventSignatureLookup.ts | 109 ++++ .../useSimulationDebugActions.ts | 298 +++++++++++ .../useSimulationHistoryLoader.ts | 160 ++++++ .../useSimulationPageHelpers.ts | 26 - .../useSimulationPageState.ts | 468 ++---------------- .../useTraceSourceResolver.ts | 92 ++++ .../TransactionReplayView.tsx | 2 +- src/contexts/DebugContext.tsx | 8 +- src/contexts/NetworkConfigContext.tsx | 5 - src/contexts/SimulationContext.tsx | 34 +- src/contexts/debug/DebugProvider.tsx | 277 ++++++----- src/contexts/debug/debugHelpers.ts | 53 +- src/contexts/debug/evalSnapshotResolver.ts | 43 +- src/contexts/debug/sessionRef.ts | 13 + src/contexts/debug/solidityStructLayout.ts | 281 +++++++---- src/contexts/debug/structStorageDecoding.ts | 5 +- src/contexts/debug/types.ts | 11 - src/contexts/debug/useDebugEvaluation.ts | 13 +- src/contexts/debug/useDebugNavigation.ts | 50 +- src/contexts/debug/useDebugSession.ts | 30 +- src/contexts/debug/useDebugWindow.ts | 47 +- src/hooks/useBreakpoint.ts | 13 - src/hooks/useContractInputs.ts | 112 ++--- src/hooks/useDecodedTrace.ts | 68 ++- src/hooks/useLiveRef.ts | 10 + src/hooks/useNativeTokenPrice.ts | 18 +- src/hooks/useUniversalSearch.ts | 6 +- src/services/DebugBridgeService.ts | 17 +- src/services/SimulationHistoryService.ts | 21 +- src/types/contractInfo.ts | 2 +- src/types/debug.ts | 1 - src/utils/asyncCache.ts | 76 +++ src/utils/cache/limitedCache.ts | 37 ++ src/utils/cache/sourcifyCache.ts | 1 - src/utils/classifyError.ts | 79 +++ src/utils/concurrency.ts | 55 ++ src/utils/llmJsonParser.ts | 27 + src/utils/priceRegistry.ts | 74 +++ src/utils/resolver/ContractResolver.ts | 96 ++-- src/utils/resolver/contractContext.ts | 61 +-- src/utils/resolver/diamondResolver.ts | 50 +- src/utils/resolver/index.ts | 15 +- src/utils/resolver/multiChainSearch.ts | 138 ------ src/utils/resolver/proxyResolver.ts | 68 --- src/utils/resolver/sources/index.ts | 1 + src/utils/resolver/sources/whatsabi.ts | 78 +++ src/utils/resolver/types.ts | 52 +- src/utils/tokenMovements.ts | 33 +- src/utils/traceDecoder/analysisHelpers.ts | 38 -- src/utils/traceDecoder/decodeTraceFinalize.ts | 6 +- src/utils/traceDecoder/decodeTraceInit.ts | 50 +- src/utils/traceDecoder/sourceParser.ts | 6 + src/utils/traceDecoder/types.ts | 1 - .../bridgeSimulation.ts | 7 +- .../transaction-simulation/requestBuilding.ts | 11 - .../transaction-simulation/revertHandling.ts | 129 ++--- .../simulateAssetMovements.ts | 1 - .../simulationEntryPoints.ts | 4 - src/utils/transactionSimulation.ts | 34 -- src/utils/withAbortTimeout.ts | 34 ++ 126 files changed, 3512 insertions(+), 3116 deletions(-) create mode 100644 api/_shared/limits.ts delete mode 100644 api/vertexAuth.ts create mode 100644 scripts/bridge-ffi-adapter.mjs create mode 100644 scripts/bridge-ffi-schema.mjs create mode 100644 scripts/bridge-schemas.mjs delete mode 100644 scripts/check-inline-copy.mjs create mode 100644 src/components/explorer/useStorageAutoDiscoveryScan.ts create mode 100644 src/components/explorer/useStorageProbe.ts create mode 100644 src/components/explorer/useStorageUrlSync.ts delete mode 100644 src/components/integrations/lifi-earn/concierge/hooks/useExecutionLegs.ts create mode 100644 src/components/simulation-results/useEventSignatureLookup.ts create mode 100644 src/components/simulation-results/useSimulationDebugActions.ts create mode 100644 src/components/simulation-results/useSimulationHistoryLoader.ts create mode 100644 src/components/simulation-results/useTraceSourceResolver.ts create mode 100644 src/contexts/debug/sessionRef.ts create mode 100644 src/hooks/useLiveRef.ts create mode 100644 src/utils/asyncCache.ts create mode 100644 src/utils/cache/limitedCache.ts create mode 100644 src/utils/classifyError.ts create mode 100644 src/utils/concurrency.ts create mode 100644 src/utils/llmJsonParser.ts create mode 100644 src/utils/priceRegistry.ts delete mode 100644 src/utils/resolver/multiChainSearch.ts create mode 100644 src/utils/resolver/sources/whatsabi.ts delete mode 100644 src/utils/transactionSimulation.ts create mode 100644 src/utils/withAbortTimeout.ts diff --git a/api/_shared/limits.ts b/api/_shared/limits.ts new file mode 100644 index 0000000..b277d50 --- /dev/null +++ b/api/_shared/limits.ts @@ -0,0 +1,6 @@ +export const EDB_MAX_BODY_BYTES = 50 * 1024 * 1024; +export const EDB_FETCH_TIMEOUT_MS = 120_000; + +export const LIFI_UPSTREAM_TIMEOUT_MS = 25_000; + +export const LLM_UPSTREAM_TIMEOUT_MS = 55_000; diff --git a/api/edb/[...path].ts b/api/edb/[...path].ts index 3ab54ed..082a733 100644 --- a/api/edb/[...path].ts +++ b/api/edb/[...path].ts @@ -1,13 +1,15 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; import { maybeInjectDefaultEtherscanKey } from "../edbShared.js"; +import { + EDB_MAX_BODY_BYTES, + EDB_FETCH_TIMEOUT_MS, +} from "../_shared/limits.js"; export const config = { api: { bodyParser: false }, maxDuration: 300, }; -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"]); // CORS allowlist — dev servers by default; extend via EDB_CORS_ALLOWED_ORIGINS (comma-separated). @@ -58,7 +60,7 @@ function getRawBody(req: VercelRequest): Promise { let total = 0; req.on("data", (chunk: Buffer) => { total += chunk.length; - if (total > MAX_BODY_BYTES) { + if (total > EDB_MAX_BODY_BYTES) { req.destroy(); reject(new Error("body_too_large")); return; @@ -106,12 +108,26 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { // Extract sub-path from URL — more reliable than req.query.path across Vercel runtimes const urlPath = (req.url || "").split("?")[0]; - const subPath = urlPath.replace(/^\/api\/edb\/?/, ""); + const subPath = urlPath.replace(/^\/api\/edb\/?/, "").replace(/\/+$/, ""); - // Validate each path segment const parts = subPath ? subPath.split("/") : []; + if (parts.length > 8) { + return res.status(400).json({ error: "invalid_path" }); + } for (const seg of parts) { - if (seg === "." || seg === ".." || /[^a-zA-Z0-9_\-:.]/.test(seg)) { + let decoded: string; + try { + decoded = decodeURIComponent(seg); + } catch { + return res.status(400).json({ error: "invalid_path" }); + } + if ( + decoded === "" || + decoded === "." || + decoded === ".." || + decoded.length > 256 || + /[^a-zA-Z0-9_\-:.]/.test(decoded) + ) { return res.status(400).json({ error: "invalid_path" }); } } @@ -150,7 +166,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { req.on("close", () => controller.abort()); } else { // Regular requests get a hard timeout - const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const timer = setTimeout(() => controller.abort(), EDB_FETCH_TIMEOUT_MS); req.on("close", () => clearTimeout(timer)); } diff --git a/api/explorer/etherscan.ts b/api/explorer/etherscan.ts index fe439b2..1877ad6 100644 --- a/api/explorer/etherscan.ts +++ b/api/explorer/etherscan.ts @@ -6,8 +6,29 @@ export const config = { maxDuration: 30, }; +const ALLOWED_ORIGINS = new Set( + (process.env.ALLOWED_ORIGINS || "").split(",").filter(Boolean) +); + +function getAllowedOrigin(req: VercelRequest): string | null { + const origin = req.headers.origin; + if (!origin) return null; + if (ALLOWED_ORIGINS.has(origin)) return origin; + if (origin.startsWith("http://localhost:")) return origin; + const host = req.headers.host; + if (host && origin === `https://${host}`) return origin; + return null; +} + export default async function handler(req: VercelRequest, res: VercelResponse) { + const allowedOrigin = getAllowedOrigin(req); + if (req.method === "OPTIONS") { + if (allowedOrigin) { + res.setHeader("Access-Control-Allow-Origin", allowedOrigin); + } + res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, x-proxy-secret"); res.status(204).setHeader("cache-control", "no-store").end(); return; } @@ -23,6 +44,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const response = await handleEtherscanLookup(req.body, process.env); res.status(response.status); + if (allowedOrigin) { + res.setHeader("Access-Control-Allow-Origin", allowedOrigin); + } response.headers.forEach((value, key) => { res.setHeader(key, value); }); diff --git a/api/explorer/etherscanShared.ts b/api/explorer/etherscanShared.ts index b90554a..004cb77 100644 --- a/api/explorer/etherscanShared.ts +++ b/api/explorer/etherscanShared.ts @@ -1,11 +1,27 @@ +import { z } from "zod"; + export type EtherscanContractAction = "getabi" | "getsourcecode"; -export interface EtherscanLookupRequest { - action: EtherscanContractAction; - address: string; - chainId: number; - personalApiKey?: string; -} +export const EtherscanLookupRequestSchema = z.object({ + action: z.enum(["getabi", "getsourcecode"]), + address: z + .string() + .trim() + .regex(/^0x[a-fA-F0-9]{40}$/, "invalid address"), + chainId: z + .union([ + z.number().int().positive(), + z.string().trim().regex(/^[1-9]\d*$/), + ]) + .transform((v) => (typeof v === "string" ? Number(v) : v)), + personalApiKey: z + .string() + .trim() + .min(1) + .optional(), +}); + +export type EtherscanLookupRequest = z.infer; type ExplorerChainConfig = { apiBaseUrl: string; @@ -65,33 +81,12 @@ const ETHERSCAN_CHAINS: Record = { }, }; -const isObject = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -const normalizeString = (value: unknown): string | undefined => { - if (typeof value !== "string") { - return undefined; - } +const normalizeEnvString = (value: unknown): string | undefined => { + if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; }; -const normalizeChainId = (value: unknown): number | undefined => { - if (typeof value === "number" && Number.isInteger(value) && value > 0) { - return value; - } - if (typeof value === "string" && /^\d+$/.test(value.trim())) { - return Number(value.trim()); - } - return undefined; -}; - -const isValidAddress = (value: unknown): value is string => - typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value.trim()); - -const isSupportedAction = (value: unknown): value is EtherscanContractAction => - value === "getabi" || value === "getsourcecode"; - const jsonResponse = (status: number, payload: Record): Response => new Response(JSON.stringify(payload), { status, @@ -101,36 +96,8 @@ const jsonResponse = (status: number, payload: Record): Respons export function parseEtherscanLookupRequest( body: unknown ): EtherscanLookupRequest | null { - if (!isObject(body)) { - return null; - } - - const chainId = normalizeChainId(body.chainId); - const action = body.action; - const address = normalizeString(body.address); - const personalApiKey = normalizeString(body.personalApiKey); - - if (!chainId || !isSupportedAction(action) || !address || !isValidAddress(address)) { - return null; - } - - return { - action, - address, - chainId, - personalApiKey, - }; -} - -function resolveDefaultApiKey( - chainId: number, - env: NodeJS.ProcessEnv -): string | undefined { - const chain = ETHERSCAN_CHAINS[chainId]; - if (!chain) { - return undefined; - } - return normalizeString(env.ETHERSCAN_API_KEY); + const parsed = EtherscanLookupRequestSchema.safeParse(body); + return parsed.success ? parsed.data : null; } export async function handleEtherscanLookup( @@ -147,7 +114,7 @@ export async function handleEtherscanLookup( return jsonResponse(400, { error: "unsupported_chain" }); } - const apiKey = request.personalApiKey || resolveDefaultApiKey(request.chainId, env); + const apiKey = request.personalApiKey || normalizeEnvString(env.ETHERSCAN_API_KEY); if (!apiKey) { return jsonResponse(503, { error: "explorer_key_not_configured" }); } diff --git a/api/lifi-composer.ts b/api/lifi-composer.ts index 813a83b..30dfc59 100644 --- a/api/lifi-composer.ts +++ b/api/lifi-composer.ts @@ -1,5 +1,6 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; import * as crypto from "crypto"; +import { LIFI_UPSTREAM_TIMEOUT_MS } from "./_shared/limits.js"; export const config = { api: { bodyParser: false }, @@ -102,7 +103,7 @@ export default async function handler( "x-lifi-api-key": LIFI_API_KEY, Accept: "application/json", }, - signal: AbortSignal.timeout(25000), + signal: AbortSignal.timeout(LIFI_UPSTREAM_TIMEOUT_MS), }); const body = await upstreamRes.text(); diff --git a/api/lifi-earn.ts b/api/lifi-earn.ts index 0b8551b..2be3487 100644 --- a/api/lifi-earn.ts +++ b/api/lifi-earn.ts @@ -1,5 +1,6 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; import * as crypto from "crypto"; +import { LIFI_UPSTREAM_TIMEOUT_MS } from "./_shared/limits.js"; export const config = { api: { bodyParser: false }, @@ -96,7 +97,7 @@ export default async function handler( "x-lifi-api-key": LIFI_API_KEY, Accept: "application/json", }, - signal: AbortSignal.timeout(25000), + signal: AbortSignal.timeout(LIFI_UPSTREAM_TIMEOUT_MS), }); const body = await upstreamRes.text(); diff --git a/api/llm-recommend.ts b/api/llm-recommend.ts index 90f9cf7..e8e68c2 100644 --- a/api/llm-recommend.ts +++ b/api/llm-recommend.ts @@ -1,8 +1,9 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; import * as crypto from "crypto"; +import { LLM_UPSTREAM_TIMEOUT_MS } from "./_shared/limits.js"; export const config = { - api: { bodyParser: true }, + api: { bodyParser: { sizeLimit: "64kb" } }, maxDuration: 60, }; @@ -35,7 +36,58 @@ function hasValidSecret(req: VercelRequest): boolean { return crypto.timingSafeEqual(a, b); } -const MAX_BODY_BYTES = 64 * 1024; +// Per-instance sliding-window rate limit. Vercel serverless may spread traffic +// across cold instances so this is best-effort, not a hard guarantee — but it +// removes the client bypass and is enough to deter casual abuse. +const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; +const RATE_LIMIT_PER_ADDRESS = 3; +const RATE_LIMIT_PER_IP = 30; +const RATE_LIMIT_MAX_BUCKETS = 2_000; +const rateLimitBuckets = new Map(); + +function normalizeAddress(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim().toLowerCase(); + if (!/^0x[0-9a-f]{40}$/.test(trimmed)) return null; + return trimmed; +} + +function extractTargetAddress(req: VercelRequest): string | null { + const header = req.headers["x-target-address"]; + const raw = Array.isArray(header) ? header[0] : header; + return normalizeAddress(raw); +} + +function getClientIp(req: VercelRequest): string { + const fwd = req.headers["x-forwarded-for"]; + const raw = Array.isArray(fwd) ? fwd[0] : fwd; + if (typeof raw === "string" && raw.length > 0) { + return raw.split(",")[0]!.trim(); + } + const realIp = req.headers["x-real-ip"]; + if (typeof realIp === "string" && realIp.length > 0) return realIp; + return req.socket?.remoteAddress ?? "unknown"; +} + +function checkRateLimit(key: string, limit: number, now: number): number { + const log = rateLimitBuckets.get(key) ?? []; + const recent = log.filter((t) => now - t < RATE_LIMIT_WINDOW_MS); + if (recent.length >= limit) { + const oldest = recent[0]!; + return Math.max(1, Math.ceil((RATE_LIMIT_WINDOW_MS - (now - oldest)) / 1000)); + } + return 0; +} + +function recordRateLimit(key: string, now: number): void { + const log = rateLimitBuckets.get(key) ?? []; + log.push(now); + if (rateLimitBuckets.size >= RATE_LIMIT_MAX_BUCKETS && !rateLimitBuckets.has(key)) { + const firstKey = rateLimitBuckets.keys().next().value; + if (firstKey !== undefined) rateLimitBuckets.delete(firstKey); + } + rateLimitBuckets.set(key, log); +} export default async function handler(req: VercelRequest, res: VercelResponse) { const allowedOrigin = getAllowedOrigin(req); @@ -43,7 +95,10 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { if (req.method === "OPTIONS") { if (allowedOrigin) res.setHeader("Access-Control-Allow-Origin", allowedOrigin); res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, x-proxy-secret"); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, x-proxy-secret, x-target-address" + ); return res.status(204).end(); } @@ -73,8 +128,30 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } const serialized = JSON.stringify(body); - if (serialized.length > MAX_BODY_BYTES) { - return res.status(413).json({ error: "Request body too large" }); + + const now = Date.now(); + const clientIp = getClientIp(req); + const targetAddress = extractTargetAddress(req); + + if (allowedOrigin) { + res.setHeader("Access-Control-Allow-Origin", allowedOrigin); + } + + const ipKey = `ip:${clientIp}`; + const ipRetry = checkRateLimit(ipKey, RATE_LIMIT_PER_IP, now); + if (ipRetry > 0) { + res.setHeader("Retry-After", String(ipRetry)); + return res.status(429).json({ error: "rate_limited_ip", retryAfterSec: ipRetry }); + } + + let addressKey: string | null = null; + if (targetAddress) { + addressKey = `addr:${targetAddress}`; + const addrRetry = checkRateLimit(addressKey, RATE_LIMIT_PER_ADDRESS, now); + if (addrRetry > 0) { + res.setHeader("Retry-After", String(addrRetry)); + return res.status(429).json({ error: "rate_limited_address", retryAfterSec: addrRetry }); + } } if (!GEMINI_API_KEY) { @@ -93,14 +170,17 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { method: "POST", headers: geminiHeaders, body: serialized, - signal: AbortSignal.timeout(55_000), + signal: AbortSignal.timeout(LLM_UPSTREAM_TIMEOUT_MS), }); const text = await upstreamRes.text(); - if (allowedOrigin) { - res.setHeader("Access-Control-Allow-Origin", allowedOrigin); + // Only charge the rate limit buckets on successful upstream calls. + if (upstreamRes.status >= 200 && upstreamRes.status < 300) { + recordRateLimit(ipKey, now); + if (addressKey) recordRateLimit(addressKey, now); } + res.setHeader("Content-Type", "application/json"); res.setHeader("X-Gemini-Model", GEMINI_MODEL); return res.status(upstreamRes.status).send(text); diff --git a/api/vertexAuth.ts b/api/vertexAuth.ts deleted file mode 100644 index 010026b..0000000 --- a/api/vertexAuth.ts +++ /dev/null @@ -1,122 +0,0 @@ -import crypto from "crypto"; - -interface ServiceAccountKey { - project_id: string; - private_key: string; - client_email: string; -} - -interface TokenCache { - token: string; - expiresAt: number; -} - -let cachedToken: TokenCache | null = null; - -/** - * Load the service account key from either: - * 1. GOOGLE_SA_KEY_JSON env var (raw JSON string — for Vercel) - * 2. A local JSON file path in GOOGLE_APPLICATION_CREDENTIALS - */ -function loadServiceAccountKey(): ServiceAccountKey | null { - const raw = process.env.GOOGLE_SA_KEY_JSON; - if (raw) { - try { - return JSON.parse(raw); - } catch { - return null; - } - } - - const filePath = process.env.GOOGLE_APPLICATION_CREDENTIALS; - if (filePath) { - try { - // Dynamic require for local dev — Vercel bundles won't hit this path - // eslint-disable-next-line @typescript-eslint/no-var-requires - const fs = require("fs"); - const content = fs.readFileSync(filePath, "utf-8"); - return JSON.parse(content); - } catch { - return null; - } - } - - return null; -} - -function base64url(input: Buffer | string): string { - const buf = typeof input === "string" ? Buffer.from(input) : input; - return buf.toString("base64url"); -} - -/** - * Create a signed JWT for Google OAuth2 token exchange. - */ -function createJwt(sa: ServiceAccountKey): string { - const now = Math.floor(Date.now() / 1000); - const header = { alg: "RS256", typ: "JWT" }; - const payload = { - iss: sa.client_email, - sub: sa.client_email, - aud: "https://oauth2.googleapis.com/token", - scope: "https://www.googleapis.com/auth/cloud-platform", - iat: now, - exp: now + 3600, - }; - - const segments = [ - base64url(JSON.stringify(header)), - base64url(JSON.stringify(payload)), - ]; - const signingInput = segments.join("."); - - const sign = crypto.createSign("RSA-SHA256"); - sign.update(signingInput); - const signature = sign.sign(sa.private_key); - - return `${signingInput}.${base64url(signature)}`; -} - -/** - * Get a valid access token, minting a new one if the cached token is expired. - * Returns null if no service account key is configured. - */ -export async function getAccessToken(): Promise { - // Return cached token if still valid (with 60s buffer) - if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) { - return cachedToken.token; - } - - const sa = loadServiceAccountKey(); - if (!sa) return null; - - const jwt = createJwt(sa); - - const res = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`, - }); - - if (!res.ok) { - const text = await res.text(); - console.error("[vertexAuth] Token exchange failed:", res.status, text); - return null; - } - - const data = (await res.json()) as { access_token: string; expires_in: number }; - cachedToken = { - token: data.access_token, - expiresAt: Date.now() + data.expires_in * 1000, - }; - - return cachedToken.token; -} - -/** - * Get the Vertex AI project ID from the service account key. - */ -export function getProjectId(): string | null { - const sa = loadServiceAccountKey(); - return sa?.project_id ?? null; -} diff --git a/edb b/edb index 1ba2fca..c5b32ca 160000 --- a/edb +++ b/edb @@ -1 +1 @@ -Subproject commit 1ba2fcaca73cee96bf10107b7b0f98ab1ceab1a4 +Subproject commit c5b32ca53e7c2146e647830af486b6481aa37548 diff --git a/package.json b/package.json index e4f3c3b..03c9e3a 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,7 @@ "qa:live:matrix": "node scripts/perf/live-qa-matrix.mjs", "probe:testnets": "node scripts/testnet-probe.js", "simulator:server": "node scripts/simulator-bridge.mjs", - "preview": "vite preview", - "check:inline-copy": "node scripts/check-inline-copy.mjs" + "preview": "vite preview" }, "dependencies": { "@dynamic-labs/ethereum": "^4.30.3", diff --git a/scripts/bridge-config.mjs b/scripts/bridge-config.mjs index 5faac78..3dc1032 100644 --- a/scripts/bridge-config.mjs +++ b/scripts/bridge-config.mjs @@ -4,7 +4,6 @@ import { resolve as pathResolve } from "node:path"; import { existsSync } from "node:fs"; -import { totalmem, freemem } from "node:os"; export const PORT = Number(process.env.SIMULATOR_BRIDGE_PORT ?? 5789); export const EDB_API_KEY = process.env.EDB_API_KEY || ""; diff --git a/scripts/bridge-ffi-adapter.mjs b/scripts/bridge-ffi-adapter.mjs new file mode 100644 index 0000000..75182b7 --- /dev/null +++ b/scripts/bridge-ffi-adapter.mjs @@ -0,0 +1,123 @@ +// ============================================================================= +// FFI shape pin — Adapter between the Rust simulator stdout and the bridge +// ============================================================================= +// Single entry point for turning simulator output into a normalized shape: +// - parseSimulatorOutput(stdoutString) parses + validates + normalizes +// - extractDebugSession(result) consolidates camelCase/snake_case coalescing +// +// Before this adapter, call sites did ad-hoc `result && typeof result === +// "object"` guards and manually coalesced `debugSession`/`debug_session`, +// `rpcPort`/`rpc_port`. That pattern is replaced by these helpers. +// ============================================================================= + +import { extractJsonFromOutputInternal } from "./simulation-runner.mjs"; +import { validateFfiResult } from "./bridge-ffi-schema.mjs"; + +/** + * Parse the simulator's stdout (raw string or already-parsed JSON) and run it + * through the FFI schema. Validation issues are logged as warnings but do NOT + * reject — the schema is `.passthrough()` everywhere so real output should + * always pass; this is defense against unexpected shape drift. + * + * @param {string | object} raw - stdout string or pre-parsed object + * @param {{ label?: string, silentFailures?: boolean }} [options] + * @returns {Record} + */ +export function parseSimulatorOutput(raw, options = {}) { + const label = options.label ?? "simulator"; + let obj; + if (typeof raw === "string") { + const jsonStr = extractJsonFromOutputInternal(raw, { + silentFailures: options.silentFailures === true, + }); + try { + obj = JSON.parse(jsonStr); + } catch (err) { + throw new Error( + `[${label}] failed to parse JSON from stdout: ${err?.message ?? String(err)}`, + ); + } + } else if (raw && typeof raw === "object") { + obj = raw; + } else { + throw new Error(`[${label}] unsupported response type: ${typeof raw}`); + } + + const result = validateFfiResult(obj); + if (!result.ok) { + console.warn( + `[${label}] FFI result schema mismatch (proceeding with raw output):`, + JSON.stringify(result.issues).slice(0, 400), + ); + return obj; + } + return result.data; +} + +/** + * Normalize a debug-session payload from the simulator output. Returns null + * when the session isn't fully specified. + * + * @param {Record | null | undefined} result + * @returns {{ sessionId: string | null, rpcUrl: string, rpcPort: number, snapshotCount: number } | null} + */ +export function extractDebugSession(result) { + if (!result || typeof result !== "object") return null; + + const session = + (result.debugSession && typeof result.debugSession === "object" && result.debugSession) || + (result.debug_session && typeof result.debug_session === "object" && result.debug_session) || + null; + if (!session) return null; + + const rpcUrl = + typeof session.rpcUrl === "string" + ? session.rpcUrl + : typeof session.rpc_url === "string" + ? session.rpc_url + : ""; + const rpcPortRaw = session.rpcPort ?? session.rpc_port; + const snapshotCountRaw = session.snapshotCount ?? session.snapshot_count ?? 0; + const sessionId = + typeof session.sessionId === "string" + ? session.sessionId + : typeof session.session_id === "string" + ? session.session_id + : null; + + const rpcPort = Number(rpcPortRaw); + const snapshotCount = Number(snapshotCountRaw); + + if (!Number.isInteger(rpcPort) || rpcPort <= 0) return null; + if (!rpcUrl) return null; + + return { + sessionId, + rpcUrl, + rpcPort, + snapshotCount: Number.isFinite(snapshotCount) ? snapshotCount : 0, + }; +} + +/** + * Return the renderedTrace subtree if present and well-formed, otherwise null. + * @param {Record | null | undefined} result + */ +export function getRenderedTrace(result) { + if (!result || typeof result !== "object") return null; + const rt = result.renderedTrace ?? result.rendered_trace; + if (!rt || typeof rt !== "object") return null; + if (!Array.isArray(rt.rows) || rt.rows.length === 0) return null; + return rt; +} + +/** + * Return the rawTrace subtree if present, otherwise null. + * @param {Record | null | undefined} result + */ +export function getRawTrace(result) { + if (!result || typeof result !== "object") return null; + const rawTrace = result.rawTrace ?? result.raw_trace; + if (!rawTrace || typeof rawTrace !== "object") return null; + return rawTrace; +} diff --git a/scripts/bridge-ffi-schema.mjs b/scripts/bridge-ffi-schema.mjs new file mode 100644 index 0000000..71ce855 --- /dev/null +++ b/scripts/bridge-ffi-schema.mjs @@ -0,0 +1,80 @@ +// ============================================================================= +// FFI shape pin — Zod schema for Rust simulator stdout JSON +// ============================================================================= +// The simulator binary (edb-simulator) writes a JSON object to stdout. Fields +// are optional because different invocation paths (simulate / keep-alive / +// lite-trace) emit different subsets. The schema is intentionally lenient — +// `.passthrough()` on every object lets unknown fields flow through; strict +// validation would risk rejecting real output when the Rust side evolves. +// +// Consumers should use the adapter in `bridge-ffi-adapter.mjs`, not call +// Zod directly — that's where camelCase/snake_case coalescing and typed +// accessors live. +// ============================================================================= + +import { z } from "zod"; + +const LooseObject = z.object({}).passthrough(); + +const NumericLike = z.union([z.number(), z.string()]); + +export const DebugSessionFfiSchema = z + .object({ + sessionId: z.string().optional(), + session_id: z.string().optional(), + rpcUrl: z.string().optional(), + rpc_url: z.string().optional(), + rpcPort: NumericLike.optional(), + rpc_port: NumericLike.optional(), + snapshotCount: NumericLike.optional(), + snapshot_count: NumericLike.optional(), + }) + .passthrough(); + +export const RenderedTraceFfiSchema = z + .object({ + rows: z.array(LooseObject).optional(), + sourceTexts: z.record(z.unknown()).optional(), + schemaVersion: NumericLike.optional(), + }) + .passthrough(); + +export const RawTraceFfiSchema = z + .object({ + inner: LooseObject.optional(), + artifacts: z.record(LooseObject).optional(), + snapshots: z.array(z.unknown()).optional(), + sources: z.record(z.unknown()).optional(), + opcodeTrace: z.array(LooseObject).optional(), + opcodeLines: z.record(z.unknown()).optional(), + }) + .passthrough(); + +export const SimulatorFfiResultSchema = z + .object({ + success: z.boolean().optional(), + error: z.string().optional(), + errorMessage: z.string().optional(), + renderedTrace: RenderedTraceFfiSchema.optional(), + rawTrace: RawTraceFfiSchema.optional(), + traceSchemaVersion: NumericLike.optional(), + traceLite: LooseObject.optional(), + traceMeta: LooseObject.optional(), + traceQuality: LooseObject.optional(), + debugSession: DebugSessionFfiSchema.optional(), + debug_session: DebugSessionFfiSchema.optional(), + }) + .passthrough(); + +/** + * Validate raw JSON against the FFI result schema. + * @param {unknown} raw + * @returns {{ ok: true, data: any } | { ok: false, issues: unknown[] }} + */ +export function validateFfiResult(raw) { + const parsed = SimulatorFfiResultSchema.safeParse(raw); + if (!parsed.success) { + return { ok: false, issues: parsed.error.issues }; + } + return { ok: true, data: parsed.data }; +} diff --git a/scripts/bridge-schemas.mjs b/scripts/bridge-schemas.mjs new file mode 100644 index 0000000..44cbf20 --- /dev/null +++ b/scripts/bridge-schemas.mjs @@ -0,0 +1,105 @@ +// ============================================================================= +// Bridge request body schemas — Zod +// ============================================================================= +// Validates incoming POST bodies at the /simulate, /trace/*, /debug/* endpoints +// so downstream code can trust shape without ad-hoc `typeof` guards. +// ============================================================================= + +import { z } from "zod"; +import { readBody } from "./simulation-runner.mjs"; + +const HexStringSchema = z.string().regex(/^0x[0-9a-fA-F]*$/); +const TxHashSchema = z.string().regex(/^0x[0-9a-fA-F]{64}$/); +const SessionIdSchema = z.string().min(1).max(256); + +const TxLikeSchema = z + .object({ + from: HexStringSchema.optional(), + to: HexStringSchema.nullable().optional(), + value: z.union([z.string(), z.number()]).optional(), + data: HexStringSchema.optional(), + input: HexStringSchema.optional(), + gas: z.union([z.string(), z.number()]).optional(), + gasLimit: z.union([z.string(), z.number()]).optional(), + gasPrice: z.union([z.string(), z.number()]).optional(), + maxFeePerGas: z.union([z.string(), z.number()]).optional(), + maxPriorityFeePerGas: z.union([z.string(), z.number()]).optional(), + nonce: z.union([z.string(), z.number()]).optional(), + chainId: z.union([z.string(), z.number()]).optional(), + type: z.union([z.string(), z.number()]).optional(), + }) + .passthrough(); + +const ArtifactSchema = z + .object({ + address: HexStringSchema.optional(), + name: z.string().optional(), + sources: z.record(z.unknown()).optional(), + }) + .passthrough(); + +const AnalysisOptionsSchema = z.record(z.unknown()).optional(); + +export const SimulateSchema = z + .object({ + rpcUrl: z.string().url(), + chainId: z.union([z.number().int().positive(), z.string()]).optional(), + transaction: TxLikeSchema.optional(), + txHash: TxHashSchema.optional(), + mode: z.enum(["local", "onchain"]).optional(), + enableDebug: z.boolean().optional(), + analysisOptions: AnalysisOptionsSchema, + artifacts: z.array(ArtifactSchema).optional(), + artifacts_inline: z.record(z.unknown()).optional(), + blockNumber: z.union([z.number(), z.string()]).optional(), + blockTag: z.string().optional(), + }) + .passthrough() + .refine((b) => !!b.transaction || !!b.txHash, { + message: "transaction or txHash is required", + }); + +export const TraceDetailSchema = z.object({ + id: z.string().min(1).max(128), +}); + +const DebugPreparePayloadSchema = SimulateSchema.innerType() + .extend({ + chainId: z.union([z.number().int().positive(), z.string()]), + }) + .passthrough() + .refine((b) => !!b.transaction || !!b.txHash, { + message: "transaction or txHash is required", + }); + +export const DebugPrepareSchema = DebugPreparePayloadSchema; +export const DebugStartSchema = DebugPreparePayloadSchema; + +export const DebugRpcSchema = z + .object({ + sessionId: SessionIdSchema, + method: z.string().min(1).max(128), + params: z.array(z.unknown()).optional(), + }) + .passthrough(); + +export const DebugEndSchema = z.object({ + sessionId: SessionIdSchema, +}); + +/** + * Read JSON body and validate against a schema. + * Returns `{ ok: true, data }` on success or `{ ok: false, issues }` on failure. + * @template T + * @param {import('node:http').IncomingMessage} req + * @param {z.ZodType} schema + * @returns {Promise<{ ok: true, data: T } | { ok: false, issues: unknown }>} + */ +export async function readAndValidate(req, schema) { + const raw = await readBody(req); + const parsed = schema.safeParse(raw); + if (!parsed.success) { + return { ok: false, issues: parsed.error.issues }; + } + return { ok: true, data: parsed.data }; +} diff --git a/scripts/bridge-security.mjs b/scripts/bridge-security.mjs index b8851c5..b4e1caa 100644 --- a/scripts/bridge-security.mjs +++ b/scripts/bridge-security.mjs @@ -32,20 +32,3 @@ export function redactRpcUrl(url) { return "[invalid-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/scripts/check-inline-copy.mjs b/scripts/check-inline-copy.mjs deleted file mode 100644 index ed51e7b..0000000 --- a/scripts/check-inline-copy.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import { readFile } from "node:fs/promises"; - -const TARGET_FILE = "src/components/simple-grid/layout/CalldataSection.tsx"; -const REQUIRED_SNIPPETS = [ - 'import { CopyButton } from "../../ui/copy-button";', - " !source.includes(snippet)); - -if (missing.length > 0) { - console.error(`Inline copy button check failed in ${TARGET_FILE}.`); - for (const snippet of missing) { - console.error(`Missing snippet: ${snippet}`); - } - process.exit(1); -} - -console.log(`Inline copy button source check passed in ${TARGET_FILE}.`); diff --git a/scripts/debug-sessions.mjs b/scripts/debug-sessions.mjs index ad40857..15d3d20 100644 --- a/scripts/debug-sessions.mjs +++ b/scripts/debug-sessions.mjs @@ -1,45 +1,13 @@ // ============================================================================= -// Debug Sessions — EDB server, WebSocket, debug session management, async prep +// Debug Sessions — keep-alive session start, async prep infrastructure // ============================================================================= -import http from "node:http"; -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import WebSocket from "ws"; - -import { - EDB_WS_PORT, - EDB_BINARY_PATH, -} from "./bridge-config.mjs"; - import { redactRpcUrl } from "./bridge-security.mjs"; import { gatedRunSimulationWithKeepAlive, } from "./keep-alive-manager.mjs"; -// ============================================================================= -// EDB Server + WebSocket (legacy codepath) -// ============================================================================= - -/** @type {import('node:child_process').ChildProcess | null} */ -let edbServerProcess = null; - -/** @type {WebSocket | null} */ -let edbWebSocket = null; - -/** - * @typedef {Object} DebugSession - * @property {string} sessionId - * @property {number} rpcPort - * @property {number} snapshotCount - * @property {string[]} sourceFiles - * @property {number} createdAt - */ - -/** @type {Map} */ -const debugSessions = new Map(); - function buildDebugAnalysisOptions(params) { const incoming = params.analysisOptions && typeof params.analysisOptions === "object" @@ -89,85 +57,6 @@ function getKeepAliveSessionError(result) { } } -/** - * Start the EDB server process if not already running - */ -export async function ensureEdbServer() { - if (edbServerProcess && !edbServerProcess.killed) { - if (edbWebSocket && edbWebSocket.readyState === WebSocket.OPEN) { - return; - } - await connectWebSocket(); - return; - } - - if (!existsSync(EDB_BINARY_PATH)) { - throw new Error( - `EDB binary not found at ${EDB_BINARY_PATH}. Build with: cargo build -p edb --manifest-path edb/Cargo.toml`, - ); - } - - console.log(`[simulator-bridge] starting EDB server on ws://127.0.0.1:${EDB_WS_PORT}`); - - edbServerProcess = spawn(EDB_BINARY_PATH, ["server", "--ws-port", String(EDB_WS_PORT)], { - stdio: ["ignore", "pipe", "pipe"], - }); - - edbServerProcess.stdout?.on("data", (data) => { - console.log(`[edb-server] ${data.toString().trim()}`); - }); - - edbServerProcess.stderr?.on("data", (data) => { - console.error(`[edb-server] ${data.toString().trim()}`); - }); - - edbServerProcess.on("error", (err) => { - console.error("[edb-server] process error:", err); - edbServerProcess = null; - }); - - edbServerProcess.on("close", (code) => { - console.log(`[edb-server] process exited with code ${code}`); - edbServerProcess = null; - edbWebSocket = null; - }); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - await connectWebSocket(); -} - -/** - * Connect to EDB server via WebSocket - */ -function connectWebSocket() { - return new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://127.0.0.1:${EDB_WS_PORT}`); - - const timeout = setTimeout(() => { - ws.close(); - reject(new Error("WebSocket connection timeout")); - }, 5000); - - ws.on("open", () => { - clearTimeout(timeout); - console.log("[simulator-bridge] connected to EDB server via WebSocket"); - edbWebSocket = ws; - resolve(); - }); - - ws.on("error", (err) => { - clearTimeout(timeout); - console.error("[simulator-bridge] WebSocket error:", err); - reject(err); - }); - - ws.on("close", () => { - console.log("[simulator-bridge] WebSocket connection closed"); - edbWebSocket = null; - }); - }); -} - // ============================================================================= // Debug Session CRUD // ============================================================================= @@ -176,7 +65,6 @@ function connectWebSocket() { * Start a debug session via simulator keep-alive mode. * @param {import('./bridge-config.mjs').SimulationSemaphore} simulationSemaphore * @param {Object} params - * @returns {Promise} */ export async function startDebugSession(simulationSemaphore, params) { const hasTxPayload = Boolean(params.transaction); @@ -229,65 +117,6 @@ export async function startDebugSession(simulationSemaphore, params) { }; } -/** - * Make an RPC call to a debug session (legacy path) - */ -export async function debugRpcCall(sessionId, method, params = []) { - const session = debugSessions.get(sessionId); - if (!session) { - throw new Error(`Debug session not found: ${sessionId}`); - } - - const rpcRequest = { - jsonrpc: "2.0", - id: Date.now(), - method, - params, - }; - - return new Promise((resolve, reject) => { - const req = http.request( - { - hostname: "127.0.0.1", - port: session.rpcPort, - path: "/", - method: "POST", - headers: { "Content-Type": "application/json" }, - }, - (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => { - try { - const response = JSON.parse(data); - if (response.error) { - reject(new Error(response.error.message || JSON.stringify(response.error))); - } else { - resolve(response.result); - } - } catch (err) { - reject(new Error(`Failed to parse RPC response: ${err.message}`)); - } - }); - }, - ); - req.on("error", reject); - req.write(JSON.stringify(rpcRequest)); - req.end(); - }); -} - -/** - * End a debug session (legacy path) - */ -export function endDebugSession(sessionId) { - const session = debugSessions.get(sessionId); - if (session) { - debugSessions.delete(sessionId); - console.log(`[simulator-bridge] debug session ended: ${sessionId}`); - } -} - // ============================================================================= // Async Debug Preparation Infrastructure // ============================================================================= diff --git a/scripts/http-compression.mjs b/scripts/http-compression.mjs index 0ae773a..7d92d4f 100644 --- a/scripts/http-compression.mjs +++ b/scripts/http-compression.mjs @@ -40,14 +40,18 @@ function negotiateEncoding(req) { * @param {import('node:http').IncomingMessage} req * @param {number} statusCode * @param {unknown} data — will be JSON.stringify'd + * @param {Record} [extraHeaders] — optional extra response headers + * (e.g. Retry-After). Reserved headers (Content-Type, Content-Length, + * Content-Encoding, Vary) managed by this function are not overridable. */ -export function sendJson(res, req, statusCode, data) { +export function sendJson(res, req, statusCode, data, extraHeaders) { const jsonBody = JSON.stringify(data); const bodyBytes = Buffer.byteLength(jsonBody, "utf8"); // Small responses: skip compression overhead if (bodyBytes < MIN_COMPRESS_BYTES) { res.writeHead(statusCode, { + ...(extraHeaders || {}), "Content-Type": "application/json", "Content-Length": String(bodyBytes), "Vary": "Accept-Encoding", @@ -60,6 +64,7 @@ export function sendJson(res, req, statusCode, data) { if (encoding === "identity") { res.writeHead(statusCode, { + ...(extraHeaders || {}), "Content-Type": "application/json", "Content-Length": String(bodyBytes), "Vary": "Accept-Encoding", @@ -81,6 +86,7 @@ export function sendJson(res, req, statusCode, data) { // Remove Content-Length since we're streaming compressed data res.writeHead(statusCode, { + ...(extraHeaders || {}), "Content-Type": "application/json", "Content-Encoding": encoding, "Vary": "Accept-Encoding", @@ -89,3 +95,25 @@ export function sendJson(res, req, statusCode, data) { compressor.pipe(res); compressor.end(jsonBody); } + +/** + * Send an error JSON response through the compression pipeline. + * + * This is a thin wrapper over sendJson that exists to mark the call site as + * an error path and keep the wire shape of `payload` identical to the + * pre-migration inline `res.writeHead(...); res.end(JSON.stringify(payload))`. + * + * The `payload` object is passed through to sendJson unchanged — no fields + * are added, renamed, or dropped. Callers that were emitting `{ error }`, + * `{ error, details }`, `{ success: false, error, ... }`, or nested shapes + * continue to emit exactly the same JSON on the wire. + * + * @param {import('node:http').ServerResponse} res + * @param {import('node:http').IncomingMessage} req + * @param {number} statusCode + * @param {unknown} payload — the error object to serialize (shape unchanged) + * @param {Record} [extraHeaders] — e.g. { "Retry-After": "30" } + */ +export function sendError(res, req, statusCode, payload, extraHeaders) { + sendJson(res, req, statusCode, payload, extraHeaders); +} diff --git a/scripts/keep-alive-manager.mjs b/scripts/keep-alive-manager.mjs index 290e6c9..1eb13c8 100644 --- a/scripts/keep-alive-manager.mjs +++ b/scripts/keep-alive-manager.mjs @@ -22,6 +22,7 @@ import { } from "./bridge-config.mjs"; import { redactRpcUrl } from "./bridge-security.mjs"; import { terminatePidWithFallback, extractJsonFromOutputInternal } from "./simulation-runner.mjs"; +import { extractDebugSession, parseSimulatorOutput } from "./bridge-ffi-adapter.mjs"; /** * @typedef {Object} KeepAliveSession @@ -187,40 +188,12 @@ function looksLikeKeepAliveSimulationResult(result) { } function normalizeKeepAliveDebugSession(result) { - if (!result || typeof result !== "object") { - return null; - } - - const rawSession = - result.debugSession && typeof result.debugSession === "object" - ? result.debugSession - : result.debug_session && typeof result.debug_session === "object" - ? result.debug_session - : null; - - if (!rawSession) { - return null; - } - - const rpcPortRaw = rawSession.rpcPort ?? rawSession.rpc_port; - const snapshotCountRaw = rawSession.snapshotCount ?? rawSession.snapshot_count; - const rpcUrl = - typeof rawSession.rpcUrl === "string" - ? rawSession.rpcUrl - : typeof rawSession.rpc_url === "string" - ? rawSession.rpc_url - : ""; - const rpcPort = Number(rpcPortRaw); - const snapshotCount = Number(snapshotCountRaw ?? 0); - - if (!Number.isInteger(rpcPort) || rpcPort <= 0 || !rpcUrl) { - return null; - } - + const session = extractDebugSession(result); + if (!session) return null; return { - rpcPort, - rpcUrl, - snapshotCount: Number.isFinite(snapshotCount) ? snapshotCount : 0, + rpcPort: session.rpcPort, + rpcUrl: session.rpcUrl, + snapshotCount: session.snapshotCount, }; } @@ -339,11 +312,16 @@ export function runSimulationWithKeepAlive(payload, options = {}) { reject(error); }; - const resolveParsedKeepAliveResult = (result) => { - if (settled || jsonParsed || !looksLikeKeepAliveSimulationResult(result)) { + const resolveParsedKeepAliveResult = (rawResult) => { + if (settled || jsonParsed || !looksLikeKeepAliveSimulationResult(rawResult)) { return false; } + const result = parseSimulatorOutput(rawResult, { + label: "simulator-bridge keep-alive", + silentFailures: true, + }); + jsonParsed = true; recentBuffer = Buffer.alloc(0); clearFileParseTimer(); @@ -547,7 +525,7 @@ export async function gatedRunSimulationWithKeepAlive(simulationSemaphore, paylo const release = await simulationSemaphore.acquire(signal); try { const memErr = checkMemoryPressure(); - if (memErr) { release(); throw memErr; } + if (memErr) throw memErr; return await runSimulationWithKeepAlive(payload, options); } finally { release(); diff --git a/scripts/simulator-bridge.mjs b/scripts/simulator-bridge.mjs index 0188ba0..b34e1d2 100644 --- a/scripts/simulator-bridge.mjs +++ b/scripts/simulator-bridge.mjs @@ -29,7 +29,7 @@ import { } from "./bridge-config.mjs"; import { redactRpcUrl } from "./bridge-security.mjs"; -import { sendJson } from "./http-compression.mjs"; +import { sendJson, sendError } from "./http-compression.mjs"; import { traceDetailStore, @@ -68,6 +68,27 @@ import { runAsyncDebugPrep, } from "./debug-sessions.mjs"; +import { + SimulateSchema, + TraceDetailSchema, + DebugPrepareSchema, + DebugStartSchema, + DebugRpcSchema, + DebugEndSchema, +} from "./bridge-schemas.mjs"; + +function validateBody(schema, body, res, req) { + const parsed = schema.safeParse(body); + if (!parsed.success) { + sendError(res, req, 400, { + error: "invalid_body", + issues: parsed.error.issues, + }); + return null; + } + return parsed.data; +} + // ============================================================================= // Startup Validation // ============================================================================= @@ -85,14 +106,13 @@ const allowedOriginsSet = new Set(ALLOWED_ORIGINS); // Helper: 503 Capacity Error Response // ============================================================================= -function send503(res, err) { +function send503(res, req, err) { const retryAfterSec = Math.ceil(SIMULATION_QUEUE_TIMEOUT_MS / 1000); - res.writeHead(503, { - "Content-Type": "application/json", - "Retry-After": String(retryAfterSec), - }); - res.end( - JSON.stringify({ + sendError( + res, + req, + 503, + { success: false, error: err.message, code: err.code, @@ -103,7 +123,8 @@ function send503(res, err) { maxConcurrent: simulationSemaphore.maxConcurrent, maxQueue: simulationSemaphore.maxQueueSize, }, - }), + }, + { "Retry-After": String(retryAfterSec) }, ); } @@ -136,8 +157,7 @@ const server = http.createServer(async (req, res) => { const urlObj = new URL(req.url, `http://${req.headers.host}`); const queryKey = urlObj.searchParams.get("apiKey"); if (headerKey !== EDB_API_KEY && queryKey !== EDB_API_KEY) { - res.writeHead(401, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "unauthorized" })); + sendError(res, req, 401, { error: "unauthorized" }); return; } } @@ -147,32 +167,29 @@ const server = http.createServer(async (req, res) => { // Health check if (req.method === "GET" && url === "/health") { pruneTraceDetailStore(); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - status: "ok", - edbServerRunning: false, - activeSessions: keepAliveSessions.size, - traceDetailHandles: traceDetailStore.size, - traceDetailBytes: Array.from(traceDetailStore.values()).reduce( - (sum, entry) => sum + getTraceDetailEntryBytes(entry), - 0, - ), - concurrency: { - activeSimulations: simulationSemaphore.activeCount, - queuedRequests: simulationSemaphore.queueLength, - maxConcurrent: simulationSemaphore.maxConcurrent, - maxQueue: simulationSemaphore.maxQueueSize, - queueTimeoutMs: SIMULATION_QUEUE_TIMEOUT_MS, - }, - memory: { - totalMB: Math.round(totalmem() / (1024 * 1024)), - freeMB: Math.round(freemem() / (1024 * 1024)), - pressureThresholdMB: MEMORY_PRESSURE_THRESHOLD_MB, - hardLimitMB: MEMORY_PRESSURE_HARD_LIMIT_MB, - }, - }), - ); + sendJson(res, req, 200, { + status: "ok", + edbServerRunning: false, + activeSessions: keepAliveSessions.size, + traceDetailHandles: traceDetailStore.size, + traceDetailBytes: Array.from(traceDetailStore.values()).reduce( + (sum, entry) => sum + getTraceDetailEntryBytes(entry), + 0, + ), + concurrency: { + activeSimulations: simulationSemaphore.activeCount, + queuedRequests: simulationSemaphore.queueLength, + maxConcurrent: simulationSemaphore.maxConcurrent, + maxQueue: simulationSemaphore.maxQueueSize, + queueTimeoutMs: SIMULATION_QUEUE_TIMEOUT_MS, + }, + memory: { + totalMB: Math.round(totalmem() / (1024 * 1024)), + freeMB: Math.round(freemem() / (1024 * 1024)), + pressureThresholdMB: MEMORY_PRESSURE_THRESHOLD_MB, + hardLimitMB: MEMORY_PRESSURE_HARD_LIMIT_MB, + }, + }); return; } @@ -185,8 +202,7 @@ const server = http.createServer(async (req, res) => { const prepareId = sseMatch[1]; const job = prepareJobs.get(prepareId); if (!job) { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "prepare_job_not_found" })); + sendError(res, req, 404, { error: "prepare_job_not_found" }); return; } @@ -241,36 +257,30 @@ const server = http.createServer(async (req, res) => { const prepareId = pollMatch[1]; const job = prepareJobs.get(prepareId); if (!job) { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "prepare_job_not_found" })); + sendError(res, req, 404, { error: "prepare_job_not_found" }); return; } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - prepareId: job.prepareId, - status: job.status, - stage: job.stage, - progressPct: job.progressPct, - message: job.message, - sessionId: job.sessionId, - snapshotCount: job.snapshotCount, - sourceFiles: job.sourceFiles, - error: job.error, - }), - ); + sendJson(res, req, 200, { + prepareId: job.prepareId, + status: job.status, + stage: job.stage, + progressPct: job.progressPct, + message: job.message, + sessionId: job.sessionId, + snapshotCount: job.snapshotCount, + sourceFiles: job.sourceFiles, + error: job.error, + }); return; } - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "not_found" })); + sendError(res, req, 404, { error: "not_found" }); return; } if (req.method !== "POST") { - res.writeHead(405, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "method_not_allowed" })); + sendError(res, req, 405, { error: "method_not_allowed" }); return; } @@ -282,8 +292,10 @@ const server = http.createServer(async (req, res) => { // Simulation Endpoint // ========================================================================= case "/simulate": { - const { rpcUrl, transaction, txHash, mode, enableDebug } = body; - const payload = JSON.stringify(body); + const data = validateBody(SimulateSchema, body, res, req); + if (!data) return; + const { rpcUrl, transaction, txHash, mode, enableDebug } = data; + const payload = JSON.stringify(data); const enableLiteTraceTransport = TRACE_LITE_TRANSPORT_ENABLED && enableDebug === false; @@ -292,7 +304,7 @@ const server = http.createServer(async (req, res) => { release = await simulationSemaphore.acquire(abortSignalFromReq(req)); } catch (capacityErr) { if (capacityErr instanceof SimulationCapacityError) { - send503(res, capacityErr); + send503(res, req, capacityErr); break; } throw capacityErr; @@ -301,8 +313,7 @@ const server = http.createServer(async (req, res) => { try { const memErr = checkMemoryPressure(); if (memErr) { - release(); - send503(res, memErr); + send503(res, req, memErr); break; } @@ -398,28 +409,22 @@ const server = http.createServer(async (req, res) => { console.error( "[simulator-bridge] trace output exceeds Node.js string limit, cannot proceed", ); - res.writeHead(413, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - success: false, - error: - "Transaction trace output is too large to process — the trace data exceeded processing limits. Try simulating a simpler transaction.", - details: errorMessage, - }), - ); + sendError(res, req, 413, { + success: false, + error: + "Transaction trace output is too large to process — the trace data exceeded processing limits. Try simulating a simpler transaction.", + details: errorMessage, + }); break; } // Fail closed for debug requests: never silently downgrade to // non-debug simulation when keep-alive bootstrap fails. - res.writeHead(502, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - success: false, - error: "debug_bootstrap_failed", - details: errorMessage, - }), - ); + sendError(res, req, 502, { + success: false, + error: "debug_bootstrap_failed", + details: errorMessage, + }); } } else { try { @@ -436,23 +441,17 @@ const server = http.createServer(async (req, res) => { err.message?.includes("ERR_STRING_TOO_LONG") || err.message?.includes("string longer than"); if (isOutputTooLarge) { - res.writeHead(413, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - success: false, - error: - "Transaction trace output is too large to process — the trace data exceeded processing limits. Try simulating a simpler transaction.", - details: err.message, - }), - ); + sendError(res, req, 413, { + success: false, + error: + "Transaction trace output is too large to process — the trace data exceeded processing limits. Try simulating a simpler transaction.", + details: err.message, + }); } else { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - success: false, - error: err.message || "Simulation failed", - }), - ); + sendError(res, req, 500, { + success: false, + error: err.message || "Simulation failed", + }); } } } @@ -466,18 +465,14 @@ const server = http.createServer(async (req, res) => { // Trace Detail Endpoint // ========================================================================= case "/trace/detail": { - const { id } = body || {}; - if (!id || typeof id !== "string") { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Missing required field: id" })); - return; - } + const data = validateBody(TraceDetailSchema, body, res, req); + if (!data) return; + const { id } = data; pruneTraceDetailStore(); const detailEntry = traceDetailStore.get(id); if (!detailEntry) { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "trace_detail_not_found" })); + sendError(res, req, 404, { error: "trace_detail_not_found" }); return; } let decodedFields; @@ -489,8 +484,7 @@ const server = http.createServer(async (req, res) => { error, ); traceDetailStore.delete(id); - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "trace_detail_decode_failed" })); + sendError(res, req, 500, { error: "trace_detail_decode_failed" }); return; } @@ -508,6 +502,8 @@ const server = http.createServer(async (req, res) => { // ========================================================================= case "/debug/prepare": { + const data = validateBody(DebugPrepareSchema, body, res, req); + if (!data) return; const { rpcUrl, chainId, @@ -517,18 +513,7 @@ const server = http.createServer(async (req, res) => { analysisOptions, artifacts, artifacts_inline, - } = body; - - if (!rpcUrl || !chainId || (!transaction && !txHash)) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - error: - "Missing required fields: rpcUrl, chainId, and (transaction or txHash)", - }), - ); - return; - } + } = data; pruneStalePrepareSessions(); @@ -569,12 +554,13 @@ const server = http.createServer(async (req, res) => { ); }); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ prepareId })); + sendJson(res, req, 200, { prepareId }); break; } case "/debug/start": { + const data = validateBody(DebugStartSchema, body, res, req); + if (!data) return; const { rpcUrl, chainId, @@ -584,18 +570,7 @@ const server = http.createServer(async (req, res) => { analysisOptions, artifacts, artifacts_inline, - } = body; - - if (!rpcUrl || !chainId || (!transaction && !txHash)) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - error: - "Missing required fields: rpcUrl, chainId, and (transaction or txHash)", - }), - ); - return; - } + } = data; const session = await startDebugSession(simulationSemaphore, { rpcUrl, @@ -618,17 +593,9 @@ const server = http.createServer(async (req, res) => { } case "/debug/rpc": { - const { sessionId, method, params } = body; - - if (!sessionId || !method) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - error: "Missing required fields: sessionId, method", - }), - ); - return; - } + const data = validateBody(DebugRpcSchema, body, res, req); + if (!data) return; + const { sessionId, method, params } = data; const keepAliveSession = keepAliveSessions.get(sessionId); if (keepAliveSession) { @@ -639,31 +606,23 @@ const server = http.createServer(async (req, res) => { ); sendJson(res, req, 200, { result }); } else { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ error: `Debug session not found: ${sessionId}` }), - ); + sendError(res, req, 404, { + error: `Debug session not found: ${sessionId}`, + }); } break; } case "/debug/end": { - const { sessionId } = body; - - if (!sessionId) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ error: "Missing required field: sessionId" }), - ); - return; - } + const data = validateBody(DebugEndSchema, body, res, req); + if (!data) return; + const { sessionId } = data; if (keepAliveSessions.has(sessionId)) { endKeepAliveSession(sessionId); } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ success: true })); + sendJson(res, req, 200, { success: true }); break; } @@ -684,17 +643,18 @@ const server = http.createServer(async (req, res) => { } default: - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "not_found" })); + sendError(res, req, 404, { error: "not_found" }); } } catch (err) { if (err instanceof SimulationCapacityError) { - send503(res, err); + send503(res, req, err); return; } console.error(`[simulator-bridge] error on ${url}:`, err); - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "internal_error", details: String(err) })); + sendError(res, req, 500, { + error: "internal_error", + details: String(err), + }); } }); diff --git a/scripts/trace-processing.mjs b/scripts/trace-processing.mjs index c13a915..d9339f3 100644 --- a/scripts/trace-processing.mjs +++ b/scripts/trace-processing.mjs @@ -14,6 +14,7 @@ import { encodeTraceDetailPayload, pruneTraceDetailStore, } from "./trace-detail-store.mjs"; +import { parseSimulatorOutput, getRenderedTrace, getRawTrace } from "./bridge-ffi-adapter.mjs"; // ============================================================================= // EVM Opcode Name Mapping @@ -382,70 +383,61 @@ export function buildTraceLite(rawTrace) { // ============================================================================= export function parseSimulationResult(raw) { - let result; - if (raw && typeof raw === "object") { - result = raw; - } else if (typeof raw === "string") { - result = JSON.parse(raw); - if (!result || typeof result !== "object") { - throw new Error("simulator returned non-object JSON response"); - } - } else { - throw new Error("simulator returned unsupported response type"); - } + const result = parseSimulatorOutput(raw, { label: "simulator-bridge" }); // V3 rendered trace: when the Rust engine provides fully-decoded rows, // set traceSchemaVersion=3 so the frontend can skip TypeScript decode. - if (result.renderedTrace && typeof result.renderedTrace === "object") { - const rt = result.renderedTrace; - if (Array.isArray(rt.rows) && rt.rows.length > 0) { - result.traceSchemaVersion = 3; - - const rawTrace = result.rawTrace; - if (rawTrace && typeof rawTrace === "object") { - const heavyFields = ["snapshots", "sources", "opcodeTrace"]; - const strippedSizes = {}; - for (const field of heavyFields) { - if (rawTrace[field]) { - const size = Array.isArray(rawTrace[field]) ? rawTrace[field].length : "object"; - strippedSizes[field] = size; - delete rawTrace[field]; - } - if (rawTrace.inner && typeof rawTrace.inner === "object" && rawTrace.inner[field]) { - delete rawTrace.inner[field]; - } - } - if (rawTrace.artifacts && typeof rawTrace.artifacts === "object") { - let artifactCount = 0; - for (const [addr, artifact] of Object.entries(rawTrace.artifacts)) { - if (artifact && typeof artifact === "object") { - const meta = artifact.meta || null; - const cName = meta?.ContractName || meta?.Name || null; - const storageLayout = extractStorageLayoutFromArtifact(artifact, cName); - rawTrace.artifacts[addr] = { - ...(meta ? { meta } : {}), - ...(storageLayout ? { storageLayout } : {}), - }; - artifactCount++; - } - } - strippedSizes["artifacts"] = `${artifactCount} contracts (kept meta)`; - } - console.log( - `[simulator-bridge] V3 rendered trace: ${rt.rows.length} rows, ` + - `${Object.keys(rt.sourceTexts || {}).length} source files — ` + - `stripped heavy fields: ${JSON.stringify(strippedSizes)}` - ); - } else { - console.log( - `[simulator-bridge] V3 rendered trace: ${rt.rows.length} rows, ` + - `${Object.keys(rt.sourceTexts || {}).length} source files, ` + - `schemaVersion=${rt.schemaVersion}` - ); + const rt = getRenderedTrace(result); + if (!rt) return result; + + result.traceSchemaVersion = 3; + const rawTrace = getRawTrace(result); + + if (!rawTrace) { + console.log( + `[simulator-bridge] V3 rendered trace: ${rt.rows.length} rows, ` + + `${Object.keys(rt.sourceTexts || {}).length} source files, ` + + `schemaVersion=${rt.schemaVersion}` + ); + return result; + } + + const heavyFields = ["snapshots", "sources", "opcodeTrace"]; + const strippedSizes = {}; + for (const field of heavyFields) { + if (rawTrace[field]) { + const size = Array.isArray(rawTrace[field]) ? rawTrace[field].length : "object"; + strippedSizes[field] = size; + delete rawTrace[field]; + } + if (rawTrace.inner && typeof rawTrace.inner === "object" && rawTrace.inner[field]) { + delete rawTrace.inner[field]; + } + } + + if (rawTrace.artifacts && typeof rawTrace.artifacts === "object") { + let artifactCount = 0; + for (const [addr, artifact] of Object.entries(rawTrace.artifacts)) { + if (artifact && typeof artifact === "object") { + const meta = artifact.meta || null; + const cName = meta?.ContractName || meta?.Name || null; + const storageLayout = extractStorageLayoutFromArtifact(artifact, cName); + rawTrace.artifacts[addr] = { + ...(meta ? { meta } : {}), + ...(storageLayout ? { storageLayout } : {}), + }; + artifactCount++; } } + strippedSizes["artifacts"] = `${artifactCount} contracts (kept meta)`; } + console.log( + `[simulator-bridge] V3 rendered trace: ${rt.rows.length} rows, ` + + `${Object.keys(rt.sourceTexts || {}).length} source files — ` + + `stripped heavy fields: ${JSON.stringify(strippedSizes)}` + ); + return result; } diff --git a/src/components/SimulationResultsPage.tsx b/src/components/SimulationResultsPage.tsx index 0880d73..746634a 100644 --- a/src/components/SimulationResultsPage.tsx +++ b/src/components/SimulationResultsPage.tsx @@ -31,7 +31,7 @@ const SimulationResultsPage: React.FC = (props) => { activeTab, setActiveTab, searchQuery, setSearchQuery, deferredSearchQuery, traceFilters, handleToggleFilter, - highlightedTraceRow, highlightedValue, setHighlightedValue, + highlightedValue, setHighlightedValue, isLoadingFromHistory, loadError, lookedUpEventNames, eventNameFilter, setEventNameFilter, eventContractFilter, setEventContractFilter, diff --git a/src/components/contract/ContractAddressInput.tsx b/src/components/contract/ContractAddressInput.tsx index 707f571..e5df2c1 100644 --- a/src/components/contract/ContractAddressInput.tsx +++ b/src/components/contract/ContractAddressInput.tsx @@ -72,7 +72,6 @@ export interface ContractAddressInputProps { | "blockscout" | "etherscan" | "blockscout-bytecode" - | "blockscout-ebd" | "whatsabi" | "manual" | "restored" diff --git a/src/components/debug/DebugStatePanel.tsx b/src/components/debug/DebugStatePanel.tsx index 2e90775..ba9e7ea 100644 --- a/src/components/debug/DebugStatePanel.tsx +++ b/src/components/debug/DebugStatePanel.tsx @@ -6,7 +6,7 @@ * Shows: function, opcode, addresses, gas info, decoded inputs/outputs */ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import { useDebug } from '../../contexts/DebugContext'; import { ScrollArea } from '../ui/scroll-area'; import { cn } from '../../lib/utils'; @@ -118,7 +118,6 @@ function buildStateFromSnapshot( decodedOutput?: Record; totalGasUsed?: number; callStack?: Array<{ address: string; functionName?: string }>; - callerBalance?: string | null; // ETH balance in human-readable format } ): Record { const state: Record = {}; @@ -164,17 +163,11 @@ function buildStateFromSnapshot( const callerAddress = options.callStack[callerIndex]?.address; state['caller'] = { address: callerAddress, - ...(options?.callerBalance ? { - balance: options.callerBalance, - } : {}), }; } else if (options?.from) { // If no callStack, use from address as caller state['caller'] = { address: options.from, - ...(options?.callerBalance ? { - balance: options.callerBalance, - } : {}), }; } @@ -257,22 +250,7 @@ export const DebugStatePanel: React.FC = React.memo(({ className, simulationContext, }) => { - const { currentSnapshot, error, callStack, session } = useDebug(); - const [callerBalance, setCallerBalance] = useState(null); - - // Determine caller address - either from callStack or from simulationContext - const callerAddress = callStack?.length && callStack.length > 1 - ? callStack[callStack.length - 2]?.address - : simulationContext?.from; - - // Fetch caller balance when caller address changes - // Note: Disabled for debug sessions as the EDB RPC server doesn't support eth_getBalance - // The balance would need to come from the original chain RPC, which isn't available in debug mode - useEffect(() => { - // Skip balance fetch - EDB debug RPC doesn't support eth_getBalance properly - // and we don't want to spam the console with errors - setCallerBalance(null); - }, [callerAddress, session?.rpcUrl]); + const { currentSnapshot, error, callStack } = useDebug(); if (!currentSnapshot) { return ( @@ -298,7 +276,6 @@ export const DebugStatePanel: React.FC = React.memo(({ address: frame.address, functionName: frame.functionName, })), - callerBalance, }; const stateData = buildStateFromSnapshot(currentSnapshot, buildOptions); diff --git a/src/components/debug/DebugToolbar.tsx b/src/components/debug/DebugToolbar.tsx index 9241bbe..c57efa3 100644 --- a/src/components/debug/DebugToolbar.tsx +++ b/src/components/debug/DebugToolbar.tsx @@ -26,6 +26,7 @@ import { } from '../ui/dropdown-menu'; import { useDebug } from '../../contexts/DebugContext'; import { useSimulation } from '../../contexts/SimulationContext'; +import { isTraceSessionId } from '../../contexts/debug/sessionRef'; import { EvaluateModal } from './EvaluateModal'; interface DebugToolbarProps { @@ -64,7 +65,7 @@ export const DebugToolbar: React.FC = React.memo(({ className ? 'Enable Debug mode during simulation to use expression evaluation' : 'No active debug session') : null; - const isTraceBasedSession = session?.sessionId?.startsWith('trace-') ?? false; + const isTraceBasedSession = session ? isTraceSessionId(session.sessionId) : false; const currentTraceIndex = useMemo(() => { if (!isTraceBasedSession || currentSnapshotId === null) return -1; return snapshotList.findIndex((snap) => snap.id === currentSnapshotId); diff --git a/src/components/debug/DebugWindow.tsx b/src/components/debug/DebugWindow.tsx index 9d86b19..1a848e0 100644 --- a/src/components/debug/DebugWindow.tsx +++ b/src/components/debug/DebugWindow.tsx @@ -7,7 +7,7 @@ import { ResizablePanel, ResizablePanelGroup, } from '../ui/resizable'; -import { useDebug, DebugProvider } from '../../contexts/DebugContext'; +import { useDebug } from '../../contexts/DebugContext'; import { useSimulation } from '../../contexts/SimulationContext'; import DebugToolbar from './DebugToolbar'; import SourceViewPanel from './SourceViewPanel'; @@ -392,20 +392,8 @@ const DebugWindowInner: React.FC = React.memo(({ className }) DebugWindowInner.displayName = 'DebugWindowInner'; -export const DebugWindow: React.FC = React.memo((props) => { - return ( - - - - ); -}); - -DebugWindow.displayName = 'DebugWindow'; - export const DebugWindowWithContext: React.FC = React.memo((props) => { return ; }); DebugWindowWithContext.displayName = 'DebugWindowWithContext'; - -export default DebugWindow; diff --git a/src/components/debug/EvaluateModal.tsx b/src/components/debug/EvaluateModal.tsx index 1814755..b392970 100644 --- a/src/components/debug/EvaluateModal.tsx +++ b/src/components/debug/EvaluateModal.tsx @@ -15,6 +15,8 @@ import { useSimulation } from '../../contexts/SimulationContext'; import { useNetworkConfig } from '../../contexts/NetworkConfigContext'; import { getChainById } from '../../utils/chains'; import { extractInlineArtifacts } from '../../utils/debugArtifacts'; +import { raceWithTimeout } from '../../utils/withAbortTimeout'; +import { isTraceSessionId } from '../../contexts/debug/sessionRef'; import type { SolValue, DebugVariable } from '../../types/debug'; import { cn } from '../../lib/utils'; import LoadingSpinner from '../shared/LoadingSpinner'; @@ -37,21 +39,12 @@ interface EvalResult { const LIVE_SESSION_BOOTSTRAP_TIMEOUT_MS = 120000; const EVALUATION_TIMEOUT_MS = 15000; -async function withTimeout( +function withTimeout( promise: Promise, timeoutMs: number, timeoutMessage: string ): Promise { - let timeoutId: ReturnType | null = null; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); - }); - - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - if (timeoutId) clearTimeout(timeoutId); - } + return raceWithTimeout(promise, timeoutMs, () => new Error(timeoutMessage)); } function hasUnreadFields(value: SolValue | DebugVariable): boolean { @@ -179,7 +172,7 @@ export const EvaluateModal: React.FC = React.memo(({ currentDebugSession?.sessionId || prepSessionId || null; const hasValidCurrentLiveSession = !!session && - !session.sessionId.startsWith('trace-') && + !isTraceSessionId(session.sessionId) && (!targetSessionId || session.sessionId === targetSessionId) && (!expectedSimulationId || session.simulationId === expectedSimulationId); @@ -310,8 +303,8 @@ export const EvaluateModal: React.FC = React.memo(({ !session || !!( currentSimulation?.debugSession?.sessionId && - !session?.sessionId.startsWith('trace-') && - session?.sessionId !== currentSimulation.debugSession.sessionId + !isTraceSessionId(session.sessionId) && + session.sessionId !== currentSimulation.debugSession.sessionId ); if (shouldEnsureLiveSession) { diff --git a/src/components/debug/ExecutionTree.tsx b/src/components/debug/ExecutionTree.tsx index 6da3208..1a21045 100644 --- a/src/components/debug/ExecutionTree.tsx +++ b/src/components/debug/ExecutionTree.tsx @@ -388,16 +388,6 @@ export const ExecutionTree: React.FC = React.memo(({ classNa return applyCollapseFilter(filteredRows, collapsedIds); }, [filteredRows, collapsedIds]); - const rowHasVisibleChildren = useMemo(() => { - const map = new Map(); - for (let i = 0; i < visibleRows.length; i++) { - const row = visibleRows[i]; - const nextRow = visibleRows[i + 1]; - map.set(row.id, nextRow !== undefined && nextRow.depth > row.depth); - } - return map; - }, [visibleRows]); - const rowCanCollapse = useMemo(() => { const map = new Map(); for (const row of filteredRows) { @@ -465,7 +455,6 @@ export const ExecutionTree: React.FC = React.memo(({ classNa ) : ( visibleRows.map((row, idx) => { const canCollapse = rowCanCollapse.get(row.id) ?? false; - const hasVisibleChildren = rowHasVisibleChildren.get(row.id) ?? false; const showToggle = canCollapse; return ( diff --git a/src/components/execution-trace/useTraceState.ts b/src/components/execution-trace/useTraceState.ts index 31a73c8..8272be8 100644 --- a/src/components/execution-trace/useTraceState.ts +++ b/src/components/execution-trace/useTraceState.ts @@ -9,6 +9,7 @@ import { } from "../../utils/signatureDatabase"; import { networkConfigManager } from "../../config/networkConfig"; import { decodeCalldataWithSignature, formatParamValue } from "./traceTypes"; +import { copyTextToClipboard } from "../../utils/clipboard"; import type { TraceRow, TraceFilters, @@ -662,7 +663,7 @@ export function useTraceState(props: UseTraceStateProps) { ]); const handleCopy = useCallback((text: string) => { - navigator.clipboard.writeText(text).catch(() => {}); + copyTextToClipboard(text).catch(() => {}); }, []); const handleJumpToStep = useCallback( diff --git a/src/components/explorer/ContractDiff.tsx b/src/components/explorer/ContractDiff.tsx index 4a31d60..b8489d2 100644 --- a/src/components/explorer/ContractDiff.tsx +++ b/src/components/explorer/ContractDiff.tsx @@ -7,6 +7,7 @@ import { GitDiff, CircleNotch, Copy, Check } from '@phosphor-icons/react'; import { getChainById } from '@/utils/chains'; import { getSharedProvider } from '@/utils/providerPool'; import { prepareBytecode, diffHexChars, type NormalizeMode, type DiffChar } from '@/utils/bytecodeDiff'; +import { copyTextToClipboard } from '@/utils/clipboard'; import NetworkSelector, { EXTENDED_NETWORKS, type ExtendedChain } from '@/components/shared/NetworkSelector'; import { isAddress } from 'ethers/lib/utils'; import '@/styles/ContractDiff.css'; @@ -30,7 +31,7 @@ const INITIAL_SIDE: BytecodeSide = { const CopyBtn: React.FC<{ text: string; label: string }> = ({ text, label }) => { const [copied, setCopied] = useState(false); const copy = () => { - navigator.clipboard.writeText(text); + copyTextToClipboard(text).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 1500); }; diff --git a/src/components/explorer/SourceTools.tsx b/src/components/explorer/SourceTools.tsx index e508013..5af880c 100644 --- a/src/components/explorer/SourceTools.tsx +++ b/src/components/explorer/SourceTools.tsx @@ -1,9 +1,7 @@ -import React, { useState, Suspense, useEffect, useCallback } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { Tabs, TabsList, TabsTrigger } from '../ui/tabs'; +import React, { useState, Suspense, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; import { AnimatedTabContent } from '../ui/animated-tabs'; import ContractExplorer from './ContractExplorer'; -import { Code, GitDiff, Database } from '@phosphor-icons/react'; type SourceSubTool = 'explorer' | 'diff' | 'storage'; @@ -22,7 +20,6 @@ function isSourceSubTool(value: string | null): value is SourceSubTool { const SourceTools: React.FC = ({ initialTool = 'explorer' }) => { const location = useLocation(); - const navigate = useNavigate(); const [activeTool, setActiveTool] = useState(initialTool); useEffect(() => { @@ -37,24 +34,6 @@ const SourceTools: React.FC = ({ initialTool = 'explorer' }) = } }, [location.search, activeTool]); - const handleToolChange = useCallback((value: string) => { - if (!isSourceSubTool(value)) return; - - setActiveTool(value); - - const params = new URLSearchParams(location.search); - if (params.get('tool') === value) return; - - params.set('tool', value); - navigate( - { - pathname: location.pathname, - search: `?${params.toString()}`, - }, - { replace: true }, - ); - }, [location.pathname, location.search, navigate]); - return (
{/* Sub-tool selector is now in the capsule Navigation — driven via ?tool= URL param */} diff --git a/src/components/explorer/StorageCells.tsx b/src/components/explorer/StorageCells.tsx index ef5ca5a..41f51a1 100644 --- a/src/components/explorer/StorageCells.tsx +++ b/src/components/explorer/StorageCells.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Database, Hash, Shield, BracketsCurly, WarningCircle } from '@phosphor-icons/react'; import { middleTruncate } from './storageViewerHelpers'; import type { ResolvedSlot } from './storageViewerTypes'; +import { copyTextToClipboard } from '../../utils/clipboard'; export const CopyableCell: React.FC<{ value: string; @@ -16,7 +17,7 @@ export const CopyableCell: React.FC<{ const handleCopy = React.useCallback((e: React.MouseEvent) => { e.stopPropagation(); if (!copyText) return; - navigator.clipboard.writeText(copyText); + copyTextToClipboard(copyText).catch(() => {}); setCopied(true); clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1200); @@ -51,7 +52,7 @@ export const ClickableValue: React.FC<{ const handleCopy = React.useCallback((e: React.MouseEvent) => { e.stopPropagation(); if (!fullValue || fullValue === '\u2014') return; - navigator.clipboard.writeText(fullValue); + copyTextToClipboard(fullValue).catch(() => {}); setCopied(true); clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1200); diff --git a/src/components/explorer/StorageTableView.tsx b/src/components/explorer/StorageTableView.tsx index c4e3591..aa01091 100644 --- a/src/components/explorer/StorageTableView.tsx +++ b/src/components/explorer/StorageTableView.tsx @@ -13,7 +13,7 @@ import { Input } from '../ui/input'; import { Badge } from '../ui/badge'; import { CopyableCell, ClickableValue } from './StorageCells'; import { SlotRowWithInspector } from './SlotRow'; -import { shortHex, simplifyType } from './storageViewerHelpers'; +import { shortHex, simplifyType, storageKeyType } from './storageViewerHelpers'; import { ZERO_VALUE, MAPPING_TABLE_GRID, SLOT_TABLE_GRID } from './storageViewerTypes'; import type { DiscoveredMappingKey, ResolvedSlot, PathSegment } from './storageViewerTypes'; import type { useAutoDiscovery } from './storage-viewer/useAutoDiscovery'; @@ -142,19 +142,9 @@ export const StorageTableView: React.FC = ({ const currentSeg = pathSegments[pathSegments.length - 1]; const isArrayDrillDown = currentSeg.slotKind === 'dynamic_array'; const keyTypeId = currentSeg.keyTypeId; - let keyTypeLabel = isArrayDrillDown ? 'Array index (uint256)' : 'uint256'; - if (!isArrayDrillDown && keyTypeId) { - if (keyTypeId.includes('address') || keyTypeId.startsWith('t_contract')) keyTypeLabel = 'address'; - else if (keyTypeId.includes('bytes32')) keyTypeLabel = 'bytes32'; - else if (keyTypeId.includes('bool')) keyTypeLabel = 'bool'; - else if (/uint\d/.test(keyTypeId)) { - const m = keyTypeId.match(/uint(\d+)/); - keyTypeLabel = m ? `uint${m[1]}` : 'uint256'; - } else if (/int\d/.test(keyTypeId)) { - const m = keyTypeId.match(/int(\d+)/); - keyTypeLabel = m ? `int${m[1]}` : 'int256'; - } - } + const keyTypeLabel = isArrayDrillDown + ? 'Array index (uint256)' + : storageKeyType(keyTypeId); const hasInput = keyInput.trim().length > 0; let arrayLength: string | null = null; diff --git a/src/components/explorer/storage-viewer/fetchStorageLayout.ts b/src/components/explorer/storage-viewer/fetchStorageLayout.ts index 5bebb94..3d6027e 100644 --- a/src/components/explorer/storage-viewer/fetchStorageLayout.ts +++ b/src/components/explorer/storage-viewer/fetchStorageLayout.ts @@ -83,20 +83,6 @@ export async function fetchStorageLayoutFromSourcify( return extractStorageLayout(data); } -/** - * Fetch source files from Sourcify V2 API for AST-based reconstruction. - * Uses the shared cache to avoid redundant requests. - */ -async function fetchSourcesFromSourcify( - chainId: number, - address: string, - signal?: AbortSignal, -): Promise<{ files: Record; contractName: string; compilerVersion?: string } | null> { - const data = await fetchSourcifyV2Cached(chainId, address, ['sources', 'compilation'], signal); - if (!data) return null; - return extractSources(data); -} - /** * Fetch BOTH storage layout AND sources in a single V2 API call. * Returns both results, avoiding the 2-request-per-facet pattern. diff --git a/src/components/explorer/storage-viewer/useSlotResolution.ts b/src/components/explorer/storage-viewer/useSlotResolution.ts index 71671ba..316322c 100644 --- a/src/components/explorer/storage-viewer/useSlotResolution.ts +++ b/src/components/explorer/storage-viewer/useSlotResolution.ts @@ -10,7 +10,6 @@ import type { } from '../../../types/debug'; import { buildSlotMap, - tryResolveMappingSlot, tryResolveArraySlot, } from '../../../utils/storageLayoutResolver'; import { @@ -248,7 +247,6 @@ function extractMappingEntries(layout: StorageLayoutResponse): MappingEntry[] { export function useSlotResolution( evidence: SlotEvidence[], layout: StorageLayoutResponse | null, - knownKeys: string[] = [], ) { // Defer layout changes so the table keeps showing old resolved data // while the new slotMap/descriptorIndex/resolvedSlots recompute. @@ -397,27 +395,7 @@ export function useSlotResolution( }; } - // 3. Try mapping resolution with known keys - if (deferredLayout && knownKeys.length > 0) { - const mappingResult = tryResolveMappingSlot(slot, deferredLayout, knownKeys); - if (mappingResult) { - // Override heuristic decodedFields with type-aware decode when type info is available - const derivedFields = value && mappingResult.valueTypeLabel - ? typeAwareDecode(value, mappingResult.valueTypeLabel, mappingResult.valueNumberOfBytes ?? 32, mappingResult.valueEncoding ?? 'inplace') - : undefined; - return { - ...base, - ...(derivedFields ? { decodedFields: derivedFields } : {}), - label: mappingResult.label, - typeLabel: mappingResult.valueTypeLabel ?? 'mapping', - decodeKind: 'derived' as const, - confidence: 'medium' as const, - kind: 'leaf' as const, - }; - } - } - - // 4. Try array element resolution + // 3. Try array element resolution if (deferredLayout) { const arrayResult = tryResolveArraySlot(slot, deferredLayout); if (arrayResult) { @@ -437,7 +415,7 @@ export function useSlotResolution( } } - // 5. Check if it has layout metadata (from seedFromLayout) + // 4. Check if it has layout metadata (from seedFromLayout) if (layoutLabel) { // Type-aware decode: override heuristic fields when we know the scalar type const metaDerived = value && typeLabelFromLayout @@ -453,7 +431,7 @@ export function useSlotResolution( }; } - // 6. Unknown slot + // 5. Unknown slot return { ...base, decodeKind: 'unknown' as const, @@ -461,7 +439,7 @@ export function useSlotResolution( kind: 'leaf' as const, }; }); - }, [evidence, slotMap, descriptorIndex, deferredLayout, knownKeys, layoutEntryIndex]); + }, [evidence, slotMap, descriptorIndex, deferredLayout, layoutEntryIndex]); /** Filter helpers */ const getResolved = useCallback( diff --git a/src/components/explorer/storage-viewer/useStorageEvidence.ts b/src/components/explorer/storage-viewer/useStorageEvidence.ts index a51a41d..b25bd3b 100644 --- a/src/components/explorer/storage-viewer/useStorageEvidence.ts +++ b/src/components/explorer/storage-viewer/useStorageEvidence.ts @@ -738,22 +738,6 @@ export function useStorageEvidence() { [], ); - const addTraceSlots = useCallback( - (items: Array<{ address: string; slot: string; before?: string; after?: string }>) => { - mergeEvidence( - items.map((item) => ({ - address: item.address, - slot: formatSlotHex(BigInt(item.slot)), - source: 'trace' as SlotSource, - before: item.before, - after: item.after, - })), - ); - }, - [mergeEvidence], - ); - - /** Cancel an in-progress load without clearing already-collected evidence */ const cancelLoad = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; @@ -762,15 +746,6 @@ export function useStorageEvidence() { setError(null); }, []); - const clearEvidence = useCallback(() => { - abortRef.current?.abort(); - setEvidence([]); - setLayout(null); - setLayoutConfidence(null); - setLoadingPhase('idle'); - setError(null); - }, []); - return { evidence, layout, @@ -786,7 +761,5 @@ export function useStorageEvidence() { readAndUpdateSlot, readSlotFromRpc, readSlotFromEdb, - addTraceSlots, - clearEvidence, }; } diff --git a/src/components/explorer/storageViewerHelpers.ts b/src/components/explorer/storageViewerHelpers.ts index 48fa7eb..2d21623 100644 --- a/src/components/explorer/storageViewerHelpers.ts +++ b/src/components/explorer/storageViewerHelpers.ts @@ -2,6 +2,24 @@ import type { ResolvedSlot } from './storageViewerTypes'; import type { SlotSource } from '../../types/debug'; import { ZERO_VALUE } from './storageViewerTypes'; +// Map a Solidity storage-layout `keyTypeId` (e.g. `t_address`, `t_uint256`, +// `t_bool`, `t_contract_X_Y`) to one of the canonical Solidity key types we +// use for `keccak(abi.encode(key, slot))`. Returns `'uint256'` when the id is +// empty or unrecognized. +export function storageKeyType(keyTypeId: string | null | undefined): string { + if (!keyTypeId) return 'uint256'; + if (keyTypeId.includes('address') || keyTypeId.startsWith('t_contract')) return 'address'; + if (keyTypeId.includes('bytes32')) return 'bytes32'; + if (keyTypeId.includes('bool')) return 'bool'; + const u = keyTypeId.match(/uint(\d+)/); + if (u) return `uint${u[1]}`; + const i = keyTypeId.match(/int(\d+)/); + if (i) return `int${i[1]}`; + if (keyTypeId.includes('uint')) return 'uint256'; + if (keyTypeId.includes('int')) return 'int256'; + return 'uint256'; +} + /** Shorten hex for display */ export function shortHex(hex: string, head = 8, tail = 6): string { if (hex.length <= head + tail + 2) return hex; diff --git a/src/components/explorer/useStorageAutoDiscoveryScan.ts b/src/components/explorer/useStorageAutoDiscoveryScan.ts new file mode 100644 index 0000000..69d263b --- /dev/null +++ b/src/components/explorer/useStorageAutoDiscoveryScan.ts @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { useAutoDiscovery } from './storage-viewer/useAutoDiscovery'; + +type Discovery = ReturnType; + +interface Args { + discovery: Discovery; + layout: Parameters[0]['layout'] | null; + contractAddress: string; + chainId: number; + mappingEntriesForDiscovery: Parameters[0]['mappingEntries']; + isLoading: boolean; + lookbackBlocks: number; +} + +/** + * Auto-triggers the event scanner once a layout is ready, and exposes manual + * start/rescan callbacks for the discovery toolbar. + */ +export function useStorageAutoDiscoveryScan({ + discovery, + layout, + contractAddress, + chainId, + mappingEntriesForDiscovery, + isLoading, + lookbackBlocks, +}: Args) { + const { startScan: discoveryStartScan, stopScan: discoveryStopScan } = discovery; + const autoScanTriggered = useRef(false); + + useEffect(() => { + if ( + !autoScanTriggered.current && + layout && + contractAddress.trim() && + !isLoading + ) { + autoScanTriggered.current = true; + discoveryStartScan({ + chainId, + contractAddress: contractAddress.trim(), + layout, + mappingEntries: mappingEntriesForDiscovery, + lookbackBlocks, + }); + } + return () => { + discoveryStopScan(); + autoScanTriggered.current = false; + }; + }, [ + layout, + mappingEntriesForDiscovery, + contractAddress, + isLoading, + chainId, + lookbackBlocks, + discoveryStartScan, + discoveryStopScan, + ]); + + const handleStartDiscovery = useCallback(() => { + if (!layout || !contractAddress.trim()) return; + discoveryStartScan({ + chainId, + contractAddress: contractAddress.trim(), + layout, + mappingEntries: mappingEntriesForDiscovery, + lookbackBlocks, + }); + }, [ + layout, + mappingEntriesForDiscovery, + contractAddress, + chainId, + lookbackBlocks, + discoveryStartScan, + ]); + + const handleRescanDiscovery = useCallback(() => { + if (!layout || !contractAddress.trim()) return; + discovery.rescan({ + chainId, + contractAddress: contractAddress.trim(), + layout, + mappingEntries: mappingEntriesForDiscovery, + lookbackBlocks, + }); + }, [ + layout, + mappingEntriesForDiscovery, + contractAddress, + chainId, + lookbackBlocks, + discovery, + ]); + + const resetAutoScanTrigger = useCallback(() => { + autoScanTriggered.current = false; + }, []); + + return { handleStartDiscovery, handleRescanDiscovery, resetAutoScanTrigger }; +} diff --git a/src/components/explorer/useStorageProbe.ts b/src/components/explorer/useStorageProbe.ts new file mode 100644 index 0000000..a6eb23c --- /dev/null +++ b/src/components/explorer/useStorageProbe.ts @@ -0,0 +1,257 @@ +import { useCallback, useMemo, useState } from 'react'; +import { + computeArrayElementSlot, + computeMappingSlot, + computeNestedMappingSlot, + formatSlotHex, + parseSlotInput, +} from '../../utils/storageSlotCalculator'; +import type { + DiscoveredMappingKey, + MappingKey, + PathSegment, + SlotMode, +} from './storageViewerTypes'; + +interface Args { + contractAddress: string; + chainId: number; + sessionId: string | null; + session: { totalSnapshots?: number } | null; + pathSegments: PathSegment[]; + setPathSegments: (updater: (prev: PathSegment[]) => PathSegment[]) => void; + mappingEntriesBySlot: Map< + string, + { variable?: string; keyTypeId?: string } | undefined + >; + setManualKeys: ( + updater: ( + prev: Map, + ) => Map, + ) => void; + addManualSlot: (address: string, slot: string) => void; + readAndUpdateSlot: ( + chainId: number, + address: string, + slot: string, + ) => Promise; + readSlotFromRpc: ( + chainId: number, + address: string, + slot: string, + ) => Promise; + readSlotFromEdb: ( + sessionId: string, + snapshotIdx: number, + slot: string, + ) => Promise; +} + +/** + * Slot-probe form state: the base slot + optional mapping/array/nested keys, + * the live-computed derived slot, and the action that commits a probe by + * reading its value from RPC (and optionally EDB at the latest snapshot). + */ +export function useStorageProbe(args: Args) { + const { + contractAddress, + chainId, + sessionId, + session, + pathSegments, + setPathSegments, + mappingEntriesBySlot, + setManualKeys, + addManualSlot, + readAndUpdateSlot, + readSlotFromRpc, + readSlotFromEdb, + } = args; + + const [probeMode, setProbeMode] = useState('simple'); + const [baseSlotInput, setBaseSlotInput] = useState('0'); + const [mappingKey, setMappingKey] = useState({ + type: 'address', + value: '', + }); + const [arrayIndex, setArrayIndex] = useState('0'); + const [nestedKeys, setNestedKeys] = useState([ + { type: 'address', value: '' }, + ]); + const [manualSlotReading, setManualSlotReading] = useState(false); + + const computedSlot = useMemo(() => { + try { + const baseSlot = parseSlotInput(baseSlotInput); + + switch (probeMode) { + case 'simple': + return { hex: formatSlotHex(baseSlot), raw: baseSlot, error: null }; + + case 'mapping': { + if (!mappingKey.value.trim()) + return { hex: '', raw: 0n, error: null }; + const slot = computeMappingSlot( + baseSlot, + mappingKey.value.trim(), + mappingKey.type, + ); + return { hex: formatSlotHex(slot), raw: slot, error: null }; + } + + case 'array': { + const index = BigInt(arrayIndex || '0'); + const slot = computeArrayElementSlot(baseSlot, index); + return { hex: formatSlotHex(slot), raw: slot, error: null }; + } + + case 'nested': { + const validKeys = nestedKeys.filter((k) => k.value.trim()); + if (validKeys.length === 0) return { hex: '', raw: 0n, error: null }; + const slot = computeNestedMappingSlot(baseSlot, validKeys); + return { hex: formatSlotHex(slot), raw: slot, error: null }; + } + } + } catch (e: unknown) { + return { + hex: '', + raw: 0n, + error: e instanceof Error ? e.message : 'Computation failed', + }; + } + }, [probeMode, baseSlotInput, mappingKey, arrayIndex, nestedKeys]); + + const handleProbeSlot = useCallback(async () => { + const addr = contractAddress.trim(); + if (!addr || !computedSlot.hex) return; + + setManualSlotReading(true); + try { + if (probeMode === 'mapping' && mappingKey.value.trim()) { + const baseSlotHex = formatSlotHex(parseSlotInput(baseSlotInput)); + const mappingEntry = mappingEntriesBySlot.get(baseSlotHex.toLowerCase()); + + const variable = mappingEntry?.variable || `slot_${baseSlotInput}`; + const keyType = mappingKey.type; + const key = mappingKey.value.trim(); + const derivedSlotHex = computedSlot.hex; + + const entry: DiscoveredMappingKey = { + key, + keyType, + derivedSlot: derivedSlotHex, + value: null, + variable, + baseSlot: baseSlotHex, + source: 'manual_lookup', + sourceLabel: 'Manual', + sources: ['manual_lookup'], + sourceLabels: ['Manual'], + evidenceCount: 1, + }; + + setManualKeys((prev) => { + const next = new Map(prev); + const bucket = baseSlotHex.toLowerCase(); + const existing = next.get(bucket) || []; + if ( + !existing.some( + (e) => + e.key === key && + e.derivedSlot.toLowerCase() === derivedSlotHex.toLowerCase(), + ) + ) { + next.set(bucket, [...existing, entry]); + } + return next; + }); + + if (pathSegments.length === 0) { + setPathSegments(() => [ + { + label: variable, + variable, + baseSlot: baseSlotHex, + keyTypeId: mappingEntry?.keyTypeId, + }, + ]); + } + + readSlotFromRpc(chainId, addr, derivedSlotHex).then((value) => { + if (value) { + setManualKeys((prev) => { + const next = new Map(prev); + const bucket = baseSlotHex.toLowerCase(); + const existing = next.get(bucket) || []; + next.set( + bucket, + existing.map((e) => + e.derivedSlot.toLowerCase() === derivedSlotHex.toLowerCase() + ? { ...e, value } + : e, + ), + ); + return next; + }); + } + }); + } else { + addManualSlot(addr, computedSlot.hex); + await readAndUpdateSlot(chainId, addr, computedSlot.hex); + } + + if (sessionId && session?.totalSnapshots && session.totalSnapshots > 0) { + await readSlotFromEdb(sessionId, session.totalSnapshots - 1, computedSlot.hex); + } + } finally { + setManualSlotReading(false); + } + }, [ + contractAddress, + computedSlot.hex, + chainId, + sessionId, + session?.totalSnapshots, + addManualSlot, + readAndUpdateSlot, + readSlotFromEdb, + probeMode, + mappingKey, + baseSlotInput, + mappingEntriesBySlot, + readSlotFromRpc, + pathSegments.length, + setManualKeys, + setPathSegments, + ]); + + const addNestedKey = useCallback( + () => setNestedKeys((prev) => [...prev, { type: 'address', value: '' }]), + [], + ); + const removeNestedKey = useCallback( + (i: number) => setNestedKeys((prev) => prev.filter((_, idx) => idx !== i)), + [], + ); + const updateNestedKey = useCallback( + (i: number, field: 'type' | 'value', val: string) => { + setNestedKeys((prev) => { + const updated = [...prev]; + updated[i] = { ...updated[i], [field]: val }; + return updated; + }); + }, + [], + ); + + return { + probeMode, setProbeMode, + baseSlotInput, setBaseSlotInput, + mappingKey, setMappingKey, + arrayIndex, setArrayIndex, + nestedKeys, addNestedKey, removeNestedKey, updateNestedKey, + manualSlotReading, + computedSlot, + handleProbeSlot, + }; +} diff --git a/src/components/explorer/useStorageUrlSync.ts b/src/components/explorer/useStorageUrlSync.ts new file mode 100644 index 0000000..9768d4f --- /dev/null +++ b/src/components/explorer/useStorageUrlSync.ts @@ -0,0 +1,70 @@ +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; +import { isAddress } from 'ethers/lib/utils'; +import type { Chain } from '../../types'; +import { getExplorerChains, getChainById } from '../../utils/chains'; + +interface Args { + selectedChain: Chain; + setSelectedChain: (chain: Chain) => void; + contractAddress: string; + setContractAddress: (addr: string) => void; + handleFetch: () => Promise | void; +} + +/** + * Reads ?address=&chainId= from the URL, syncs state, then triggers a fetch + * once the state has settled. Decoupled from the fetch itself so the main + * hook keeps ownership of the AbortController plumbing. + */ +export function useStorageUrlSync({ + selectedChain, + setSelectedChain, + contractAddress, + setContractAddress, + handleFetch, +}: Args) { + const location = useLocation(); + const pendingUrlFetchRef = useRef<{ address: string; chainId: number } | null>( + null, + ); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const requestedAddress = params.get('address')?.trim(); + if (!requestedAddress || !isAddress(requestedAddress)) return; + + const requestedChainIdRaw = params.get('chainId'); + const requestedChainId = requestedChainIdRaw + ? Number.parseInt(requestedChainIdRaw, 10) + : Number.NaN; + const fallbackChain = getChainById(1) || getExplorerChains()[0]; + const nextChain = getChainById(requestedChainId) || fallbackChain; + + if (selectedChain.id !== nextChain.id) { + setSelectedChain(nextChain); + } + if ( + contractAddress.trim().toLowerCase() !== requestedAddress.toLowerCase() + ) { + setContractAddress(requestedAddress); + } + + pendingUrlFetchRef.current = { + address: requestedAddress.toLowerCase(), + chainId: nextChain.id, + }; + }, [location.search]); + + useEffect(() => { + const pending = pendingUrlFetchRef.current; + if (!pending) return; + + const currentAddress = contractAddress.trim().toLowerCase(); + if (!currentAddress || currentAddress !== pending.address) return; + if (selectedChain.id !== pending.chainId) return; + + pendingUrlFetchRef.current = null; + void handleFetch(); + }, [contractAddress, selectedChain.id, handleFetch]); +} diff --git a/src/components/explorer/useStorageViewerState.ts b/src/components/explorer/useStorageViewerState.ts index d5f8879..0a75694 100644 --- a/src/components/explorer/useStorageViewerState.ts +++ b/src/components/explorer/useStorageViewerState.ts @@ -1,6 +1,4 @@ import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; -import { useLocation } from 'react-router-dom'; -import { isAddress } from 'ethers/lib/utils'; import type { Chain } from '../../types'; import { getExplorerChains, getChainById } from '../../utils/chains'; import { useStorageEvidence } from './storage-viewer/useStorageEvidence'; @@ -11,9 +9,7 @@ import { useDebug } from '../../contexts/DebugContext'; import { computeMappingSlot, computeArrayElementSlot, - computeNestedMappingSlot, formatSlotHex, - parseSlotInput, ZERO_WORD, } from '../../utils/storageSlotCalculator'; import { resolveContractContext } from '../../utils/resolver/contractContext'; @@ -21,19 +17,19 @@ import { resolveLeafValueType } from '../../utils/storageLayoutResolver'; import type { ProxyInfo } from '../../utils/resolver/types'; import type { ViewFilter, - SlotMode, - MappingKey, StorageIconState, ResolvedSlot, PathSegment, DiscoveredMappingKey, } from './storageViewerTypes'; -import { shortHex } from './storageViewerHelpers'; +import { shortHex, storageKeyType } from './storageViewerHelpers'; import { useGridCharLimits } from './storageViewerHooks'; import { useStorageViewerData } from './useStorageViewerData'; +import { useStorageProbe } from './useStorageProbe'; +import { useStorageAutoDiscoveryScan } from './useStorageAutoDiscoveryScan'; +import { useStorageUrlSync } from './useStorageUrlSync'; export function useStorageViewerState() { - const location = useLocation(); const { session } = useDebug(); const [contractAddress, setContractAddress] = useState(''); @@ -62,15 +58,6 @@ export function useStorageViewerState() { }, []); const [pathSegments, setPathSegments] = useState([]); - - const [probeMode, setProbeMode] = useState('simple'); - const [baseSlotInput, setBaseSlotInput] = useState('0'); - const [mappingKey, setMappingKey] = useState({ type: 'address', value: '' }); - const [arrayIndex, setArrayIndex] = useState('0'); - const [nestedKeys, setNestedKeys] = useState([ - { type: 'address', value: '' }, - ]); - const [manualSlotReading, setManualSlotReading] = useState(false); const [postLoadResolving, setPostLoadResolving] = useState(false); const [isFetchPending, setIsFetchPending] = useState(false); @@ -109,10 +96,9 @@ export function useStorageViewerState() { getMappingEntries, getMappingEntriesImmediate, isLayoutPending, - } = useSlotResolution(evidence, layout, []); + } = useSlotResolution(evidence, layout); const mappingEntries = useMemo(() => getMappingEntries(), [getMappingEntries]); - // Non-deferred mapping entries for auto-discovery (avoids stale data) const mappingEntriesForDiscovery = useMemo(() => getMappingEntriesImmediate(), [getMappingEntriesImmediate]); const mappingEntriesBySlot = useMemo(() => { @@ -129,26 +115,20 @@ export function useStorageViewerState() { const [slotGraphOpen, setSlotGraphOpen] = useState(false); const discovery = useAutoDiscovery(); - const { startScan: discoveryStartScan, stopScan: discoveryStopScan } = discovery; - const [lookbackBlocks, setLookbackBlocks] = useState(20_000); - const autoScanTriggered = useRef(false); - const pendingUrlFetchRef = useRef<{ address: string; chainId: number } | null>(null); + const lookbackBlocks = 20_000; const sessionId = session?.sessionId ?? null; const chainId = selectedChain.id; const isMappingView = pathSegments.length > 0; - // Dynamic per-column character limits based on actual rendered widths const tableHeaderRef = useRef(null); const viewKey = isMappingView ? 'mapping' : 'standard'; const charLimits = useGridCharLimits(tableHeaderRef, viewKey); - /** Merged keys: manual + auto-discovered */ const mergedKeys = useMemo(() => { return discovery.mergeWithManualKeys(manualKeys); }, [manualKeys, discovery]); - /** Map derivedSlot -> key for displaying the KEY column in mapping view */ const keyBySlot = useMemo(() => { if (!isMappingView) return new Map(); const currentSegment = pathSegments[pathSegments.length - 1]; @@ -161,37 +141,20 @@ export function useStorageViewerState() { return map; }, [isMappingView, pathSegments, mergedKeys]); - const computedSlot = useMemo(() => { - try { - const baseSlot = parseSlotInput(baseSlotInput); - - switch (probeMode) { - case 'simple': - return { hex: formatSlotHex(baseSlot), raw: baseSlot, error: null }; - - case 'mapping': { - if (!mappingKey.value.trim()) return { hex: '', raw: 0n, error: null }; - const slot = computeMappingSlot(baseSlot, mappingKey.value.trim(), mappingKey.type); - return { hex: formatSlotHex(slot), raw: slot, error: null }; - } - - case 'array': { - const index = BigInt(arrayIndex || '0'); - const slot = computeArrayElementSlot(baseSlot, index); - return { hex: formatSlotHex(slot), raw: slot, error: null }; - } - - case 'nested': { - const validKeys = nestedKeys.filter((k) => k.value.trim()); - if (validKeys.length === 0) return { hex: '', raw: 0n, error: null }; - const slot = computeNestedMappingSlot(baseSlot, validKeys); - return { hex: formatSlotHex(slot), raw: slot, error: null }; - } - } - } catch (e: unknown) { - return { hex: '', raw: 0n, error: e instanceof Error ? e.message : 'Computation failed' }; - } - }, [probeMode, baseSlotInput, mappingKey, arrayIndex, nestedKeys]); + // Slot-probe form state + derived slot + commit action. + const { + probeMode, setProbeMode, + baseSlotInput, setBaseSlotInput, + mappingKey, setMappingKey, + arrayIndex, setArrayIndex, + nestedKeys, addNestedKey, removeNestedKey, updateNestedKey, + manualSlotReading, computedSlot, handleProbeSlot, + } = useStorageProbe({ + contractAddress, chainId, sessionId, session, + pathSegments, setPathSegments, + mappingEntriesBySlot, setManualKeys, + addManualSlot, readAndUpdateSlot, readSlotFromRpc, readSlotFromEdb, + }); const { stats, filteredSlots, displayRows, treeGroups } = useStorageViewerData({ resolvedSlots, @@ -208,6 +171,17 @@ export function useStorageViewerState() { layout, }); + // Auto-trigger the event scanner once a layout is ready; expose manual + // start/rescan for the discovery toolbar. + const { + handleStartDiscovery, + handleRescanDiscovery, + resetAutoScanTrigger, + } = useStorageAutoDiscoveryScan({ + discovery, layout, contractAddress, chainId, + mappingEntriesForDiscovery, isLoading, lookbackBlocks, + }); + const handleCancel = useCallback(() => { contextAbortRef.current?.abort(); contextAbortRef.current = null; @@ -234,9 +208,8 @@ export function useStorageViewerState() { setManualKeys(new Map()); setContractMeta(null); discovery.stopScan(); - autoScanTriggered.current = false; + resetAutoScanTrigger(); - // Resolve contract context let proxyType: import('../../utils/resolver/types').ProxyType | undefined; let diamondFacets: import('../../utils/resolver/types').FacetInfo[] | null = null; let implAddresses: string[] = []; @@ -265,7 +238,7 @@ export function useStorageViewerState() { proxyInfo: ctx.proxyInfo || null, }); - const metaForSources = ctx.implementationMetadata || ctx.metadata; + const metaForSources = ctx.implementationMetadata || ctx.metadata; if (metaForSources?.sources && Object.keys(metaForSources.sources).length > 0) { sourceBundle = { files: metaForSources.sources, @@ -383,165 +356,18 @@ export function useStorageViewerState() { performance.mark('storage-fetch-end'); performance.measure('storage-slot-table-paint', 'storage-fetch-start', 'storage-fetch-end'); - }, [contractAddress, chainId, sessionId, loadStorageForContract, seedFromLayout, seedDiamondNamespace, readSlotFromRpc, discovery]); - - useEffect(() => { - const params = new URLSearchParams(location.search); - const requestedAddress = params.get('address')?.trim(); - if (!requestedAddress || !isAddress(requestedAddress)) return; - - const requestedChainIdRaw = params.get('chainId'); - const requestedChainId = requestedChainIdRaw ? Number.parseInt(requestedChainIdRaw, 10) : Number.NaN; - const fallbackChain = getChainById(1) || getExplorerChains()[0]; - const nextChain = getChainById(requestedChainId) || fallbackChain; - - if (selectedChain.id !== nextChain.id) { - setSelectedChain(nextChain); - } - if (contractAddress.trim().toLowerCase() !== requestedAddress.toLowerCase()) { - setContractAddress(requestedAddress); - } - - pendingUrlFetchRef.current = { - address: requestedAddress.toLowerCase(), - chainId: nextChain.id, - }; - }, [location.search]); - - useEffect(() => { - const pending = pendingUrlFetchRef.current; - if (!pending) return; - - const currentAddress = contractAddress.trim().toLowerCase(); - if (!currentAddress || currentAddress !== pending.address) return; - if (selectedChain.id !== pending.chainId) return; - - pendingUrlFetchRef.current = null; - void handleFetch(); - }, [contractAddress, selectedChain.id, handleFetch]); - - useEffect(() => { - if ( - !autoScanTriggered.current && - layout && - contractAddress.trim() && - !isLoading - ) { - autoScanTriggered.current = true; - discoveryStartScan({ - chainId, - contractAddress: contractAddress.trim(), - layout, - mappingEntries: mappingEntriesForDiscovery, - lookbackBlocks, - }); - } - return () => { - discoveryStopScan(); - autoScanTriggered.current = false; - }; - }, [layout, mappingEntriesForDiscovery, contractAddress, isLoading, chainId, lookbackBlocks, discoveryStartScan, discoveryStopScan]); - - const handleStartDiscovery = useCallback(() => { - if (!layout || !contractAddress.trim()) return; - discoveryStartScan({ - chainId, - contractAddress: contractAddress.trim(), - layout, - mappingEntries: mappingEntriesForDiscovery, - lookbackBlocks, - }); - }, [layout, mappingEntriesForDiscovery, contractAddress, chainId, lookbackBlocks, discoveryStartScan]); - - const handleRescanDiscovery = useCallback(() => { - if (!layout || !contractAddress.trim()) return; - discovery.rescan({ - chainId, - contractAddress: contractAddress.trim(), - layout, - mappingEntries: mappingEntriesForDiscovery, - lookbackBlocks, - }); - }, [layout, mappingEntriesForDiscovery, contractAddress, chainId, lookbackBlocks, discovery]); - - const handleProbeSlot = useCallback(async () => { - const addr = contractAddress.trim(); - if (!addr || !computedSlot.hex) return; - - setManualSlotReading(true); - try { - if (probeMode === 'mapping' && mappingKey.value.trim()) { - const baseSlotHex = formatSlotHex(parseSlotInput(baseSlotInput)); - const mappingEntry = mappingEntriesBySlot.get(baseSlotHex.toLowerCase()); - - const variable = mappingEntry?.variable || `slot_${baseSlotInput}`; - const keyType = mappingKey.type; - const key = mappingKey.value.trim(); - const derivedSlotHex = computedSlot.hex; - - const entry: DiscoveredMappingKey = { - key, - keyType, - derivedSlot: derivedSlotHex, - value: null, - variable, - baseSlot: baseSlotHex, - source: 'manual_lookup', - sourceLabel: 'Manual', - sources: ['manual_lookup'], - sourceLabels: ['Manual'], - evidenceCount: 1, - }; - - setManualKeys((prev) => { - const next = new Map(prev); - const bucket = baseSlotHex.toLowerCase(); - const existing = next.get(bucket) || []; - if (!existing.some((e) => e.key === key && e.derivedSlot.toLowerCase() === derivedSlotHex.toLowerCase())) { - next.set(bucket, [...existing, entry]); - } - return next; - }); - - if (pathSegments.length === 0) { - setPathSegments([{ - label: variable, - variable, - baseSlot: baseSlotHex, - keyTypeId: mappingEntry?.keyTypeId, - }]); - } - - readSlotFromRpc(chainId, addr, derivedSlotHex).then((value) => { - if (value) { - setManualKeys((prev) => { - const next = new Map(prev); - const bucket = baseSlotHex.toLowerCase(); - const existing = next.get(bucket) || []; - next.set( - bucket, - existing.map((e) => - e.derivedSlot.toLowerCase() === derivedSlotHex.toLowerCase() - ? { ...e, value } - : e, - ), - ); - return next; - }); - } - }); - } else { - addManualSlot(addr, computedSlot.hex); - await readAndUpdateSlot(chainId, addr, computedSlot.hex); - } + }, [ + contractAddress, chainId, sessionId, + loadStorageForContract, seedFromLayout, seedDiamondNamespace, readSlotFromRpc, + discovery, resetAutoScanTrigger, + ]); - if (sessionId && session?.totalSnapshots && session.totalSnapshots > 0) { - await readSlotFromEdb(sessionId, session.totalSnapshots - 1, computedSlot.hex); - } - } finally { - setManualSlotReading(false); - } - }, [contractAddress, computedSlot.hex, chainId, sessionId, session?.totalSnapshots, addManualSlot, readAndUpdateSlot, readSlotFromEdb, probeMode, mappingKey, baseSlotInput, mappingEntriesBySlot, readSlotFromRpc, pathSegments.length]); + // ?address=&chainId= → state sync → auto-fetch once state settles. + useStorageUrlSync({ + selectedChain, setSelectedChain, + contractAddress, setContractAddress, + handleFetch, + }); const toggleSlotExpansion = useCallback((slotHex: string) => { setExpandedSlot((prev) => (prev === slotHex ? null : slotHex)); @@ -586,14 +412,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' : storageKeyType(keyTypeId); const key = keyInput.trim(); setIsLookingUp(true); @@ -639,7 +458,6 @@ export function useStorageViewerState() { } }, [keyInput, pathSegments, chainId, contractAddress, readSlotFromRpc]); - const navigateTo = useCallback((segIdx: number) => { if (segIdx < 0) { setPathSegments([]); @@ -658,16 +476,6 @@ export function useStorageViewerState() { }); }, []); - const addNestedKey = useCallback(() => setNestedKeys((prev) => [...prev, { type: 'address', value: '' }]), []); - const removeNestedKey = useCallback((i: number) => setNestedKeys((prev) => prev.filter((_, idx) => idx !== i)), []); - const updateNestedKey = useCallback((i: number, field: 'type' | 'value', val: string) => { - setNestedKeys((prev) => { - const updated = [...prev]; - updated[i] = { ...updated[i], [field]: val }; - return updated; - }); - }, []); - const handleExportCsv = useCallback(() => { const rows = displayRows.map((slot) => { const decoded = slot.decodedFields?.map((field) => `${field.label}: ${field.decoded}`).join('; ') || ''; diff --git a/src/components/integrations/lifi-earn/TokenIcon.tsx b/src/components/integrations/lifi-earn/TokenIcon.tsx index bf53f93..cdc9ad1 100644 --- a/src/components/integrations/lifi-earn/TokenIcon.tsx +++ b/src/components/integrations/lifi-earn/TokenIcon.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { getTokenIconUrls } from "../../../utils/tokenMovements"; interface TokenIconProps { @@ -14,7 +14,11 @@ export function TokenIcon({ token, chainId, className }: TokenIconProps) { return sources; }, [token.address, token.logoURI, chainId]); + const urlsKey = useMemo(() => urls.join("|"), [urls]); const [srcIndex, setSrcIndex] = useState(0); + useEffect(() => { + setSrcIndex(0); + }, [urlsKey]); const fallbackSvg = useMemo( () => `data:image/svg+xml,${encodeURIComponent(token.symbol.charAt(0).toUpperCase())}`, diff --git a/src/components/integrations/lifi-earn/VaultList.tsx b/src/components/integrations/lifi-earn/VaultList.tsx index 9d8b241..8bba175 100644 --- a/src/components/integrations/lifi-earn/VaultList.tsx +++ b/src/components/integrations/lifi-earn/VaultList.tsx @@ -16,6 +16,7 @@ import { PopoverContent, } from "../../../components/ui/popover"; import { SUPPORTED_CHAINS } from "../../../utils/chains"; +import { copyTextToClipboard } from "../../../utils/clipboard"; import ChainIcon from "../../icons/ChainIcon"; import { TokenIcon } from "./TokenIcon"; import type { EarnVault, VaultFilters } from "./types"; @@ -717,7 +718,7 @@ export function VaultCard({ const handleCopyAddress = (e: React.MouseEvent) => { e.stopPropagation(); - navigator.clipboard.writeText(vault.address); + copyTextToClipboard(vault.address).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 1500); }; diff --git a/src/components/integrations/lifi-earn/concierge/FlowDiagram.tsx b/src/components/integrations/lifi-earn/concierge/FlowDiagram.tsx index 04edfca..aa8e832 100644 --- a/src/components/integrations/lifi-earn/concierge/FlowDiagram.tsx +++ b/src/components/integrations/lifi-earn/concierge/FlowDiagram.tsx @@ -182,13 +182,9 @@ function statusToBorder(status: LegStatus | "idle"): string { return "border-emerald-500/70"; case "failed": return "border-red-500/70"; - case "quoting": - case "approving": case "executing": case "bridging": return "border-amber-500/70"; - case "ready": - return "border-blue-500/70"; case "pending": case "idle": default: @@ -202,13 +198,9 @@ function statusToEdgeColor(status: LegStatus | "idle"): string { return "#10b981"; case "failed": return "#ef4444"; - case "quoting": - case "approving": case "executing": case "bridging": return "#f59e0b"; - case "ready": - return "#3b82f6"; case "pending": case "idle": default: @@ -217,12 +209,7 @@ function statusToEdgeColor(status: LegStatus | "idle"): string { } function statusIsAnimating(status: LegStatus | "idle"): boolean { - return ( - status === "quoting" || - status === "approving" || - status === "executing" || - status === "bridging" - ); + return status === "executing" || status === "bridging"; } export type RoutingMode = "per-asset" | "consolidate"; @@ -557,7 +544,6 @@ function labelForEdge(sel: SelectedSource, dest: EarnVault): string { function rollupStatus(statuses: Array): LegStatus | "idle" { if (statuses.some((s) => s === "failed")) return "failed"; if (statuses.some(statusIsAnimating)) return "bridging"; - if (statuses.some((s) => s === "ready")) return "ready"; if (statuses.some((s) => s === "pending")) return "pending"; if (statuses.every((s) => s === "done")) return "done"; return "idle"; diff --git a/src/components/integrations/lifi-earn/concierge/IdleSweepPanel.tsx b/src/components/integrations/lifi-earn/concierge/IdleSweepPanel.tsx index 68aff04..f15e654 100644 --- a/src/components/integrations/lifi-earn/concierge/IdleSweepPanel.tsx +++ b/src/components/integrations/lifi-earn/concierge/IdleSweepPanel.tsx @@ -1,11 +1,11 @@ -import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import React, { useState, useCallback, useEffect, useMemo, useReducer, useRef } from "react"; import { useAccount } from "wagmi"; import { Button } from "../../../../components/ui/button"; import { useIdleBalances } from "./hooks/useIdleBalances"; import { useVaultRecommendations } from "./hooks/useVaultRecommendations"; -import { useExecutionLegs } from "./hooks/useExecutionLegs"; +import { initialLegState, legsReducer } from "./executionMachine"; import { IdleAssetsTable, keyForAsset, rawFromPercent } from "./IdleAssetsTable"; -import { VaultRecommendations } from "./VaultRecommendations"; +import { VaultRecommendations, portfolioTargets } from "./VaultRecommendations"; import { DestinationPicker } from "./DestinationPicker"; import { ExecutionQueue } from "./ExecutionQueue"; import { FlowDiagram, type RoutingMode } from "./FlowDiagram"; @@ -96,7 +96,7 @@ export function IdleSweepPanel({ targetAddress }: IdleSweepPanelProps) { } }, [hasAnyDestination]); - const { state: legState, dispatch: legDispatch } = useExecutionLegs(); + const [legState, legDispatch] = useReducer(legsReducer, initialLegState); const { data: recsData, @@ -282,7 +282,7 @@ export function IdleSweepPanel({ targetAddress }: IdleSweepPanelProps) { const queueBuilt = legState.legs.length > 0; const hasInFlightStep = legState.legs.some((l) => - ["quoting", "approving", "executing", "bridging", "ready"].includes(l.status) + ["executing", "bridging"].includes(l.status) ); useEffect(() => { if (isReadOnly) return; @@ -539,7 +539,7 @@ export function IdleSweepPanel({ targetAddress }: IdleSweepPanelProps) { isRetrying={recsFetching} /> ; + targets: RecommendationTarget[]; recommendations: VaultRecommendation[]; destination: EarnVault | null; perAssetDestinations?: Map; onPick: (vault: EarnVault, selectionKey: string) => void; isLoading?: boolean; sourceTokenSymbol?: string | null; - /** Explicit card header title for non-portfolio mode (e.g. "Top Vaults", "ETH Vaults"). Null = derive from asset symbol (portfolio mode). */ - headerTitle?: string | null; - /** Chain ID to show in card header. Null = hide chain icon. */ - headerChainId?: number | null; /** Notice text to show above recommendations (e.g. symbol-relaxation warning). */ headerNotice?: string | null; /** Cap on visible vault slots. 1 = Best only, 2 = Best+Safest, 3 = +1 Alt, 4/null = all. */ resultCount?: number | null; } +/** Convenience: build portfolio-mode targets from an idle-sweep selections map. */ +export function portfolioTargets( + selections: Map, +): RecommendationTarget[] { + const out: RecommendationTarget[] = []; + for (const [key, sel] of selections) { + out.push({ + key, + displayTitle: sel.asset.token.symbol, + displayChainId: sel.asset.chainId, + displayChainName: sel.asset.chainName, + sourceChainId: sel.asset.chainId > 0 ? sel.asset.chainId : null, + }); + } + return out; +} + export function VaultRecommendations({ - selections, + targets, recommendations, destination, perAssetDestinations, onPick, isLoading = false, sourceTokenSymbol, - headerTitle, - headerChainId, headerNotice, resultCount, }: VaultRecommendationsProps) { - if (selections.size === 0) { + if (targets.length === 0) { return (
Select one or more idle assets above to see vault recommendations. @@ -60,12 +93,13 @@ export function VaultRecommendations({ ); } + const targetByKey = new Map(targets.map((t) => [t.key, t])); const relevant = recommendations.filter((r) => - selections.has(`${r.forChainId}:${r.forTokenAddress}`) + targetByKey.has(`${r.forChainId}:${r.forTokenAddress}`), ); if (isLoading && relevant.length === 0) { - return ; + return ; } if (relevant.length === 0) { @@ -76,7 +110,6 @@ export function VaultRecommendations({ ); } - // Determine how many slots to show based on resultCount const maxSlots = resultCount != null ? Math.min(Math.max(resultCount, 1), 4) : 4; return ( @@ -89,8 +122,8 @@ export function VaultRecommendations({
{relevant.map((rec) => { const key = `${rec.forChainId}:${rec.forTokenAddress}`; - const sel = selections.get(key); - if (!sel) return null; + const target = targetByKey.get(key); + if (!target) return null; const activeDestination = perAssetDestinations?.get(key) ?? destination; @@ -105,7 +138,6 @@ export function VaultRecommendations({ .slice(0, 2) .map((p) => ({ label: "Alt", pick: p })), ]; - // Sort populated picks first so capping never hides a valid recommendation. const populated = allSlots.filter((s) => s.pick !== null); const empty = allSlots.filter((s) => s.pick === null); const slots = [...populated, ...empty].slice(0, maxSlots); @@ -113,26 +145,26 @@ export function VaultRecommendations({ slots.push({ label: "—", pick: null }); } - const displayTitle = headerTitle ?? sel.asset.token.symbol; - const displayChainId = headerTitle != null ? headerChainId : sel.asset.chainId; + const showChainIcon = + target.displayChainId != null && target.displayChainId > 0; return (
- {displayTitle} + {target.displayTitle} - {displayChainId != null && displayChainId > 0 && ( + {showChainIcon && ( <> · - + - {sel.asset.chainName} + {target.displayChainName} @@ -158,7 +190,7 @@ export function VaultRecommendations({ } onPick={(v) => onPick(v, key)} sourceTokenSymbol={sourceTokenSymbol} - sourceChainId={sel.asset.chainId > 0 ? sel.asset.chainId : undefined} + sourceChainId={target.sourceChainId ?? undefined} /> ))}
diff --git a/src/components/integrations/lifi-earn/concierge/executionMachine.ts b/src/components/integrations/lifi-earn/concierge/executionMachine.ts index c54a1bf..408f966 100644 --- a/src/components/integrations/lifi-earn/concierge/executionMachine.ts +++ b/src/components/integrations/lifi-earn/concierge/executionMachine.ts @@ -108,9 +108,7 @@ export function legsReducer(state: LegState, action: LegAction): LegState { } case "NEXT": { const nextIdx = state.legs.findIndex( - (l, i) => - i > state.currentIndex && - (l.status === "pending" || l.status === "ready") + (l, i) => i > state.currentIndex && l.status === "pending" ); return { ...state, currentIndex: nextIdx }; } diff --git a/src/components/integrations/lifi-earn/concierge/hooks/fetchAssetPrices.ts b/src/components/integrations/lifi-earn/concierge/hooks/fetchAssetPrices.ts index 5a0609d..36fc632 100644 --- a/src/components/integrations/lifi-earn/concierge/hooks/fetchAssetPrices.ts +++ b/src/components/integrations/lifi-earn/concierge/hooks/fetchAssetPrices.ts @@ -2,84 +2,16 @@ // the asset could not be priced — keep amountUsd null, don't fabricate zero. import { formatUnits } from "viem"; import type { IdleAsset } from "../types"; +import { + DEFILLAMA_CHAIN_SLUG, + NATIVE_COINGECKO_ID, +} from "../../../../../utils/priceRegistry"; const NATIVE_SENTINELS = new Set([ "0x0000000000000000000000000000000000000000", "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", ]); -const LLAMA_CHAIN_SLUG: Record = { - 1: "ethereum", - 10: "optimism", - 25: "cronos", - 56: "bsc", - 100: "xdai", - 130: "unichain", - 137: "polygon", - 146: "sonic", - 204: "op_bnb", - 250: "fantom", - 252: "fraxtal", - 324: "era", - 1088: "metis", - 1135: "lisk", - 1284: "moonbeam", - 1329: "sei", - 1868: "soneium", - 2020: "ronin", - 2741: "abstract", - 5000: "mantle", - 8453: "base", - 33139: "apechain", - 34443: "mode", - 42161: "arbitrum", - 42220: "celo", - 43114: "avax", - 57073: "ink", - 59144: "linea", - 60808: "bob", - 80094: "berachain", - 81457: "blast", - 167000: "taiko", - 534352: "scroll", -}; - -const NATIVE_COINGECKO_ID: Record = { - 1: "coingecko:ethereum", - 10: "coingecko:ethereum", - 25: "coingecko:crypto-com-chain", - 56: "coingecko:binancecoin", - 100: "coingecko:xdai", - 130: "coingecko:ethereum", - 137: "coingecko:matic-network", - 146: "coingecko:sonic-3", - 204: "coingecko:binancecoin", - 250: "coingecko:fantom", - 252: "coingecko:frax", - 324: "coingecko:ethereum", - 1088: "coingecko:metis-token", - 1135: "coingecko:ethereum", - 1284: "coingecko:moonbeam", - 1329: "coingecko:sei-network", - 1868: "coingecko:ethereum", - 2020: "coingecko:ronin", - 2741: "coingecko:ethereum", - 5000: "coingecko:mantle", - 8453: "coingecko:ethereum", - 33139: "coingecko:apecoin", - 34443: "coingecko:ethereum", - 42161: "coingecko:ethereum", - 42220: "coingecko:celo", - 43114: "coingecko:avalanche-2", - 57073: "coingecko:ethereum", - 59144: "coingecko:ethereum", - 60808: "coingecko:bitcoin", - 80094: "coingecko:berachain-bera", - 81457: "coingecko:ethereum", - 167000: "coingecko:ethereum", - 534352: "coingecko:ethereum", -}; - function keyFor(chainId: number, address: string): string { return `${chainId}:${address.toLowerCase()}`; } @@ -92,7 +24,7 @@ function coinIdForAsset(asset: IdleAsset): string | null { if (isNative(asset.token.address)) { return NATIVE_COINGECKO_ID[asset.chainId] ?? null; } - const slug = LLAMA_CHAIN_SLUG[asset.chainId]; + const slug = DEFILLAMA_CHAIN_SLUG[asset.chainId]; if (!slug) return null; return `${slug}:${asset.token.address.toLowerCase()}`; } diff --git a/src/components/integrations/lifi-earn/concierge/hooks/useExecutionLegs.ts b/src/components/integrations/lifi-earn/concierge/hooks/useExecutionLegs.ts deleted file mode 100644 index 6cbf745..0000000 --- a/src/components/integrations/lifi-earn/concierge/hooks/useExecutionLegs.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useReducer } from "react"; -import { initialLegState, legsReducer } from "../executionMachine"; - -export function useExecutionLegs() { - const [state, dispatch] = useReducer(legsReducer, initialLegState); - return { state, dispatch }; -} diff --git a/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts b/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts index 533f377..028f614 100644 --- a/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts +++ b/src/components/integrations/lifi-earn/concierge/hooks/useIdleBalances.ts @@ -49,8 +49,25 @@ export function useIdleBalances(targetAddress: string | null, perChainTimeoutMs [vaultsQuery.data] ); + const underlyingsSignature = useMemo(() => { + if (!underlyingsByChain || underlyingsByChain.size === 0) return ""; + const chainIds = Array.from(underlyingsByChain.keys() as IterableIterator).sort( + (a: number, b: number) => a - b + ); + return chainIds + .map((id) => { + const tokens = (underlyingsByChain.get(id) ?? []) as Array<{ address: string }>; + const addrs = tokens + .map((t) => t.address.toLowerCase()) + .sort() + .join(","); + return `${id}:${addrs}`; + }) + .join("|"); + }, [underlyingsByChain]); + const scanQuery = useQuery({ - queryKey: ["concierge-idle-balances", targetAddress, underlyingsByChain.size], + queryKey: ["concierge-idle-balances", targetAddress, underlyingsSignature], enabled: !!targetAddress && (underlyingsByChain?.size ?? 0) > 0, staleTime: 60 * 1000, diff --git a/src/components/integrations/lifi-earn/concierge/hooks/useVaultRecommendations.ts b/src/components/integrations/lifi-earn/concierge/hooks/useVaultRecommendations.ts index 7a6259b..34918dc 100644 --- a/src/components/integrations/lifi-earn/concierge/hooks/useVaultRecommendations.ts +++ b/src/components/integrations/lifi-earn/concierge/hooks/useVaultRecommendations.ts @@ -20,6 +20,9 @@ import { } from "../fallback"; import { llmRecommendationSchema, type LlmRecommendationResponse } from "../schema"; import { DEFAULT_CONFIG } from "../types"; +import { setLimitedCacheEntry } from "../../../../../utils/cache/limitedCache"; +import { classifyErrorMessage, isRetryableErrorKind } from "../../../../../utils/classifyError"; +import { parseLlmJson } from "../../../../../utils/llmJsonParser"; // Client-side rate limit: 3 LLM calls per address per hour for non-connected addresses. const LLM_RATE_LIMIT = 3; @@ -45,19 +48,12 @@ function recordLlmCall(address: string): void { const key = address.toLowerCase(); const log = llmCallLog.get(key) ?? []; log.push(Date.now()); - llmCallLog.set(key, log); - // Evict oldest entries if map grows too large - if (llmCallLog.size > LLM_MAX_TRACKED_ADDRESSES) { - const firstKey = llmCallLog.keys().next().value; - if (firstKey !== undefined) llmCallLog.delete(firstKey); - } + setLimitedCacheEntry(llmCallLog, key, log, LLM_MAX_TRACKED_ADDRESSES); } -// "live" → call Gemini, "fixture" → read from fixtures/llm/*.json, -// "off" → skip LLM and always use rules-based fallback. +// "live" → call Gemini, "off" → skip LLM and always use rules-based fallback. const LLM_MODE = - (import.meta.env.VITE_LLM_MODE as "live" | "fixture" | "off" | undefined) ?? - "live"; + (import.meta.env.VITE_LLM_MODE as "live" | "off" | undefined) ?? "live"; export interface RecommendationsResult { recommendations: VaultRecommendation[]; @@ -156,7 +152,7 @@ export function useVaultRecommendations(args: { llmError: string | null; }> => { const skipLlm = isExternalAddress && targetAddress != null && isLlmRateLimited(targetAddress); - const result = await fetchRecommendationForAsset(source, cands, chainRestrictedVaults, skipLlm); + const result = await fetchRecommendationForAsset(source, cands, chainRestrictedVaults, skipLlm, targetAddress ?? null); if (!skipLlm && isExternalAddress && targetAddress != null) { recordLlmCall(targetAddress); } @@ -200,6 +196,7 @@ async function fetchRecommendationForAsset( candidates: EarnVault[], vaultPool: EarnVault[], skipLlm = false, + targetAddress: string | null = null, ): Promise<{ rec: VaultRecommendation; llmError: string | null }> { const asset = source.asset; if (LLM_MODE === "off" || skipLlm) { @@ -217,14 +214,14 @@ async function fetchRecommendationForAsset( let lastError: string | null = null; for (let attempt = 0; attempt < 2; attempt++) { try { - const raw = await postLlmRecommend(request); + const raw = await postLlmRecommend(request, { targetAddress }); const text = extractGeminiText(raw); if (!text) { // eslint-disable-next-line no-console console.warn("[concierge] raw LLM response (no text extracted):", raw); throw new Error("empty LLM response"); } - const json = safeParseJson(text); + const json = parseLlmJson(text); if (!json) { // eslint-disable-next-line no-console console.warn("[concierge] LLM text (not valid JSON):", text.slice(0, 500)); @@ -242,13 +239,17 @@ async function fetchRecommendationForAsset( lastError = null; break; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - lastError = msg; + const classified = classifyErrorMessage(err); + lastError = classified.message; // eslint-disable-next-line no-console console.warn( - `[concierge] LLM attempt ${attempt + 1} failed for ${keyOf(asset)}:`, - msg + `[concierge] LLM attempt ${attempt + 1} failed for ${keyOf(asset)} (${classified.kind}):`, + classified.message, ); + if (!isRetryableErrorKind(classified.kind)) { + parsed = null; + break; + } if (attempt === 1) parsed = null; } } @@ -402,34 +403,6 @@ function extractGeminiText(raw: unknown): string | null { } } -function safeParseJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - // Strip common noise: code fences, leading commentary - const stripped = text - .replace(/^```(?:json)?/i, "") - .replace(/```$/i, "") - .trim(); - try { - return JSON.parse(stripped); - } catch { - // Thinking models sometimes prepend prose before the JSON object. - // Find the first `{` and last `}` and try parsing that substring. - const first = stripped.indexOf("{"); - const last = stripped.lastIndexOf("}"); - if (first >= 0 && last > first) { - try { - return JSON.parse(stripped.slice(first, last + 1)); - } catch { - /* fall through */ - } - } - return null; - } - } -} - function mergeLlmWithCandidates( assets: IdleAsset[], candidateMap: Map, diff --git a/src/components/integrations/lifi-earn/concierge/intent/IntentPanel.tsx b/src/components/integrations/lifi-earn/concierge/intent/IntentPanel.tsx index c7320c9..adadc1c 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/IntentPanel.tsx +++ b/src/components/integrations/lifi-earn/concierge/intent/IntentPanel.tsx @@ -12,7 +12,10 @@ import { } from "@phosphor-icons/react"; import { Textarea } from "../../../../../components/ui/textarea"; import ChainIcon from "../../../../icons/ChainIcon"; -import { VaultRecommendations } from "../VaultRecommendations"; +import { + VaultRecommendations, + type RecommendationTarget, +} from "../VaultRecommendations"; import { LlmErrorAlert } from "../LlmErrorAlert"; import { ExecutionQueue } from "../ExecutionQueue"; import { initialLegState, legsReducer } from "../executionMachine"; @@ -139,8 +142,9 @@ const EXAMPLE_INTENTS = [ "Best vault for my ETH even if I need to swap tokens", ]; -// Zero address stands in for "target token" when we hand a synthetic IdleAsset -// to . Only used as a lookup key; never an actual ERC20. +// Sentinel key used as forChainId/forTokenAddress by useIntentRecommendation in +// single-target mode (no wallet asset drives the recommendation). The matching +// RecommendationTarget is built with the same synth key below. const SYNTH_TOKEN_ADDRESS = "0x0000000000000000000000000000000000000000"; function formatTvl(tvlUsd: string | number): string { @@ -321,7 +325,11 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I const parser = useIntentParser(); const thinkingLabel = useThinkingLabel(parser.isPending); // ── my_assets mode: fan out per wallet asset ────────────────────────── - const isMyAssetsMode = intent?.my_assets && idleAssets.length > 0; + // `wantsMyAssetsMode` gates empty-state branches; `isMyAssetsMode` gates + // actual per-asset rendering. They diverge when the intent wants my_assets + // but there are no idle assets yet (disconnected / empty wallet). + const wantsMyAssetsMode = Boolean(intent?.my_assets); + const isMyAssetsMode = wantsMyAssetsMode && idleAssets.length > 0; // Deduplicate wallet assets by symbol (keep highest-USD-value per symbol) const dedupedAssets = useMemo(() => { @@ -357,15 +365,16 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I // Single-target mode vault query (also used for consolidate — global search) const singleIntent = useMemo(() => { if (!intent) return null; - if (!isMyAssetsMode) return intent; - // Consolidate mode: do a global vault search (no target_symbol) using the - // user's APY/TVL/objective filters. Any transactional vault is a candidate - // because LI.FI handles swaps from the user's held tokens. - if (intent.routing_mode === "consolidate") { - return { ...intent, my_assets: false, target_symbol: null }; + // Never fall back to single-target when the intent wants my_assets: the + // empty-state branches below need a null query to render. + if (wantsMyAssetsMode) { + if (isMyAssetsMode && intent.routing_mode === "consolidate") { + return { ...intent, my_assets: false, target_symbol: null }; + } + return null; } - return null; // per-asset mode uses useMultiAssetVaults instead - }, [intent, isMyAssetsMode]); + return intent; + }, [intent, wantsMyAssetsMode, isMyAssetsMode]); const { ranked, isLoading: vaultsLoading, @@ -396,45 +405,10 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I return map; }, [chains]); - // Build a synthetic "asset" that stands in for the user's intent so we can - // reuse , which was originally written for idle-sweep. - // The asset's chain/symbol populate the card header; address is a sentinel. - const synthAsset = useMemo(() => { - if (!intent || isMyAssetsMode) return null; - const chainId = intent.target_chain_id ?? 0; - const chainName = - intent.target_chain_id !== null - ? (chainNameById.get(intent.target_chain_id) ?? - `chain ${intent.target_chain_id}`) - : "Any chain"; - return { - chainId, - chainName, - token: { - address: SYNTH_TOKEN_ADDRESS, - symbol: (intent.target_symbol ?? "ANY").toUpperCase(), - decimals: 18, - }, - amountRaw: "0", - amountDecimal: "0", - amountUsd: null, - }; - }, [intent, isMyAssetsMode, chainNameById]); - - const synthSelections = useMemo>(() => { - const map = new Map(); - if (isMyAssetsMode) { - // One selection per deduped wallet asset - for (const a of dedupedAssets) { - const key = `${a.chainId}:${a.token.address.toLowerCase()}`; - map.set(key, { asset: a, amountRaw: a.amountRaw }); - } - } else if (synthAsset) { - const key = `${synthAsset.chainId}:${synthAsset.token.address.toLowerCase()}`; - map.set(key, { asset: synthAsset, amountRaw: "0" }); - } - return map; - }, [isMyAssetsMode, dedupedAssets, synthAsset]); + // Synth key used to anchor the single-target recommendation in the + // RecommendationTarget list. Must match what useIntentRecommendation writes + // into buildRecommendation's forChainId/forTokenAddress. + const singleTargetChainId = intent?.target_chain_id ?? 0; // Presentation props for the non-portfolio card header. // Computed here so VaultRecommendations doesn't infer mode from token symbols. @@ -478,18 +452,58 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I // Single-target recommendation const singleRecArgs = useMemo( () => - intent && synthAsset && effectiveRanked.length > 0 + intent && !isMyAssetsMode && effectiveRanked.length > 0 ? { - synthChainId: synthAsset.chainId, - synthTokenAddress: synthAsset.token.address, + synthChainId: singleTargetChainId, + synthTokenAddress: SYNTH_TOKEN_ADDRESS, intent, rankedVaults: effectiveRanked, walletAssets: idleAssets, } : null, - [intent, synthAsset, effectiveRanked, idleAssets], + [intent, isMyAssetsMode, singleTargetChainId, effectiveRanked, idleAssets], ); + // Normalized per-card targets for . Replaces the + // earlier synthetic-IdleAsset + selections-map plumbing. + const intentTargets = useMemo(() => { + if (isMyAssetsMode) { + return dedupedAssets.map((a) => ({ + key: `${a.chainId}:${a.token.address.toLowerCase()}`, + displayTitle: a.token.symbol, + displayChainId: a.chainId, + displayChainName: a.chainName, + sourceChainId: a.chainId > 0 ? a.chainId : null, + })); + } + if (!intent) return []; + const displayTitle = + presentationProps.headerTitle ?? + (intent.target_symbol?.toUpperCase() ?? "Top Vaults"); + const displayChainId = presentationProps.headerChainId; + const displayChainName = + displayChainId != null + ? (chainNameById.get(displayChainId) ?? `chain ${displayChainId}`) + : ""; + return [ + { + key: `${singleTargetChainId}:${SYNTH_TOKEN_ADDRESS.toLowerCase()}`, + displayTitle, + displayChainId, + displayChainName, + // Intent mode has no fixed source chain, so skip bridge detection. + sourceChainId: null, + }, + ]; + }, [ + isMyAssetsMode, + dedupedAssets, + intent, + presentationProps, + chainNameById, + singleTargetChainId, + ]); + const { recommendation: singleRec, isLoading: singleRecLoading, @@ -849,13 +863,13 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I {/* Recommendation card — reuses the same component idle-sweep uses */} {hasIntent && ( <> - {isMyAssetsMode && !isConsolidateMode && !isConnected && !scanAddress ? ( + {wantsMyAssetsMode && !isConsolidateMode && !isConnected && !scanAddress ? (

Connect your wallet or enter an address to get per-asset recommendations.

- ) : isMyAssetsMode && !isConsolidateMode && dedupedAssets.length === 0 ? ( + ) : wantsMyAssetsMode && !isConsolidateMode && dedupedAssets.length === 0 ? (

No idle assets found in your wallet. @@ -943,7 +957,7 @@ export function IntentPanel({ onSelectVault, targetAddress: externalAddress }: I ) : ( <> diff --git a/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentParser.ts b/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentParser.ts index 49b7ef0..9628978 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentParser.ts +++ b/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentParser.ts @@ -2,11 +2,12 @@ import { useMutation } from "@tanstack/react-query"; import { postLlmRecommend } from "../../../earnApi"; import { parsedIntentSchema, type ParsedIntent, DEFAULT_INTENT } from "../schema"; import type { EarnChainInfo, EarnProtocolInfo } from "../../../types"; +import { classifyErrorMessage, isRetryableErrorKind } from "../../../../../../utils/classifyError"; +import { parseLlmJson } from "../../../../../../utils/llmJsonParser"; // "live" → call Gemini, "off" → skip LLM (dev aid, returns DEFAULT_INTENT). const LLM_MODE = - (import.meta.env.VITE_LLM_MODE as "live" | "fixture" | "off" | undefined) ?? - "live"; + (import.meta.env.VITE_LLM_MODE as "live" | "off" | undefined) ?? "live"; export interface ParseIntentArgs { text: string; @@ -38,7 +39,7 @@ export function useIntentParser() { const raw = await postLlmRecommend(request); const responseText = extractGeminiText(raw); if (!responseText) throw new Error("empty LLM response"); - const json = safeParseJson(responseText); + const json = parseLlmJson(responseText); if (!json) throw new Error("LLM did not return JSON"); const result = parsedIntentSchema.safeParse(json); if (!result.success) { @@ -50,10 +51,14 @@ export function useIntentParser() { } return { intent: result.data, rawText: trimmed }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - lastError = msg; + const classified = classifyErrorMessage(err); + lastError = classified.message; // eslint-disable-next-line no-console - console.warn(`[intent-parser] attempt ${attempt + 1} failed:`, msg); + console.warn( + `[intent-parser] attempt ${attempt + 1} failed (${classified.kind}):`, + classified.message, + ); + if (!isRetryableErrorKind(classified.kind)) break; } } throw new Error(lastError ?? "Failed to parse intent"); @@ -192,28 +197,3 @@ function extractGeminiText(raw: unknown): string | null { return null; } } - -function safeParseJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - const stripped = text - .replace(/^```(?:json)?/i, "") - .replace(/```$/i, "") - .trim(); - try { - return JSON.parse(stripped); - } catch { - const first = stripped.indexOf("{"); - const last = stripped.lastIndexOf("}"); - if (first >= 0 && last > first) { - try { - return JSON.parse(stripped.slice(first, last + 1)); - } catch { - /* fall through */ - } - } - return null; - } - } -} diff --git a/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts b/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts index eb0f40a..a06c039 100644 --- a/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts +++ b/src/components/integrations/lifi-earn/concierge/intent/hooks/useIntentRecommendation.ts @@ -2,6 +2,8 @@ import { useQuery } from "@tanstack/react-query"; import { postLlmRecommend } from "../../../earnApi"; import { llmRecommendationSchema } from "../../schema"; import { DEFAULT_CONFIG } from "../../types"; +import { classifyErrorMessage, isRetryableErrorKind } from "../../../../../../utils/classifyError"; +import { parseLlmJson } from "../../../../../../utils/llmJsonParser"; import type { VaultRecommendation, RecommendationPick, @@ -44,8 +46,7 @@ function isDuplicatePick( // "live" → call Gemini, "off" → skip LLM entirely and use rules fallback. const LLM_MODE = - (import.meta.env.VITE_LLM_MODE as "live" | "fixture" | "off" | undefined) ?? - "live"; + (import.meta.env.VITE_LLM_MODE as "live" | "off" | undefined) ?? "live"; // How many of the already-ranked intent vaults we surface to the LLM. Must // stay low — candidate payload size drives token cost and latency. @@ -95,7 +96,18 @@ export function useIntentRecommendation( args?.synthTokenAddress ?? "", intentCacheKey(args?.intent), args?.rankedVaults.slice(0, args?.intent?.target_symbol === null && !args?.intent?.my_assets ? MAX_CANDIDATES_DISCOVERY : MAX_CANDIDATES_DEFAULT).map((v) => v.slug).join(",") ?? "", - args?.walletAssets.map((a) => `${a.chainId}:${a.token.symbol}`).join(",") ?? "", + // Mirror the prompt's filter/sort/slice so the key tracks what the LLM actually sees, + // bucketing USD amounts logarithmically to avoid refetching on every price tick. + args?.walletAssets + .filter((a) => (a.amountUsd ?? 0) > 0.5) + .sort((a, b) => (b.amountUsd ?? 0) - (a.amountUsd ?? 0)) + .slice(0, 15) + .map((a) => { + const usd = a.amountUsd ?? 0; + const bucket = usd <= 0 ? "0" : String(Math.min(6, Math.floor(Math.log10(usd)))); + return `${a.chainId}:${a.token.symbol}:${bucket}`; + }) + .join(",") ?? "", ] as const, enabled: !!args && args.rankedVaults.length > 0, staleTime: 5 * 60 * 1000, @@ -152,7 +164,7 @@ export async function buildRecommendation( const raw = await postLlmRecommend(request); const text = extractGeminiText(raw); if (!text) throw new Error("empty LLM response"); - const json = safeParseJson(text); + const json = parseLlmJson(text); if (!json) throw new Error("LLM did not return JSON"); const result = llmRecommendationSchema.safeParse(json); if (!result.success) { @@ -235,10 +247,16 @@ export async function buildRecommendation( llmError: null, }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - lastError = msg; + const classified = classifyErrorMessage(err); + lastError = classified.message; // eslint-disable-next-line no-console - console.warn(`[intent-rec] LLM attempt ${attempt + 1} failed:`, msg); + console.warn( + `[intent-rec] LLM attempt ${attempt + 1} failed (${classified.kind}):`, + classified.message, + ); + // Validation / auth / rate-limit / not-found retries just burn quota. + // Only retry transient server/network/timeout classes. + if (!isRetryableErrorKind(classified.kind)) break; } } @@ -515,31 +533,6 @@ function extractGeminiText(raw: unknown): string | null { } } -function safeParseJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - const stripped = text - .replace(/^```(?:json)?/i, "") - .replace(/```$/i, "") - .trim(); - try { - return JSON.parse(stripped); - } catch { - // Thinking models sometimes prepend prose before the JSON object. - const first = stripped.indexOf("{"); - const last = stripped.lastIndexOf("}"); - if (first >= 0 && last > first) { - try { - return JSON.parse(stripped.slice(first, last + 1)); - } catch { - /* fall through */ - } - } - return null; - } - } -} function formatApy(apy: number | null): string { if (apy == null) return "—"; diff --git a/src/components/integrations/lifi-earn/concierge/types.ts b/src/components/integrations/lifi-earn/concierge/types.ts index 7a1b7e9..deec094 100644 --- a/src/components/integrations/lifi-earn/concierge/types.ts +++ b/src/components/integrations/lifi-earn/concierge/types.ts @@ -43,9 +43,6 @@ export interface Leg { export type LegStatus = | "pending" - | "quoting" - | "ready" - | "approving" | "executing" | "bridging" | "done" diff --git a/src/components/integrations/lifi-earn/earnApi.ts b/src/components/integrations/lifi-earn/earnApi.ts index 5eb3196..2e41703 100644 --- a/src/components/integrations/lifi-earn/earnApi.ts +++ b/src/components/integrations/lifi-earn/earnApi.ts @@ -11,8 +11,33 @@ import type { const EARN_PROXY = "/api/lifi-earn"; const COMPOSER_PROXY = "/api/lifi-composer"; -function proxyHeaders(): HeadersInit { - return {}; + +async function fetchJson( + url: string, + options: { + timeoutMs?: number; + errorLabel: string; + method?: string; + headers?: HeadersInit; + body?: BodyInit; + withBodyText?: boolean; + }, +): Promise { + const init: RequestInit = {}; + if (options.method) init.method = options.method; + if (options.headers) init.headers = options.headers; + if (options.body !== undefined) init.body = options.body; + if (options.timeoutMs != null) init.signal = AbortSignal.timeout(options.timeoutMs); + + const res = await fetch(url, init); + if (!res.ok) { + if (options.withBodyText) { + const text = await res.text().catch(() => ""); + throw new Error(`${options.errorLabel}: ${res.status} ${text}`); + } + throw new Error(`${options.errorLabel}: ${res.status} ${res.statusText}`); + } + return res.json() as Promise; } // API key is now mandatory — injected by the `/api/lifi-earn` proxy (vite dev / @@ -37,57 +62,38 @@ export async function fetchEarnVaults(params?: { if (params?.minTvlUsd != null && params.minTvlUsd > 0) url.searchParams.set("minTvlUsd", String(params.minTvlUsd)); if (params?.limit) url.searchParams.set("limit", String(params.limit)); - const res = await fetch(url.toString(), { - signal: AbortSignal.timeout(15000), + return fetchJson(url.toString(), { + timeoutMs: 15000, + errorLabel: "Earn API error", }); - - if (!res.ok) { - throw new Error(`Earn API error: ${res.status} ${res.statusText}`); - } - - return res.json(); } // Authoritative list of chains Earn indexes. Used to gate the chain filter so // the dropdown only offers chains that actually have vaults. Small payload, // cached aggressively via React Query. export async function fetchEarnChains(): Promise { - const res = await fetch( + return fetchJson( `${window.location.origin}${EARN_PROXY}/v1/chains`, - { signal: AbortSignal.timeout(15000) }, + { timeoutMs: 15000, errorLabel: "Earn chains error" }, ); - if (!res.ok) { - throw new Error(`Earn chains error: ${res.status} ${res.statusText}`); - } - return res.json(); } // Authoritative list of protocols Earn supports. Used to populate the protocol // filter dropdown without waiting for background vault pages to arrive. export async function fetchEarnProtocols(): Promise { - const res = await fetch( + return fetchJson( `${window.location.origin}${EARN_PROXY}/v1/protocols`, - { signal: AbortSignal.timeout(15000) }, + { timeoutMs: 15000, errorLabel: "Earn protocols error" }, ); - if (!res.ok) { - throw new Error(`Earn protocols error: ${res.status} ${res.statusText}`); - } - return res.json(); } export async function fetchEarnPositions( address: string ): Promise { - const res = await fetch( + return fetchJson( `${window.location.origin}${EARN_PROXY}/v1/portfolio/${address}/positions`, - { signal: AbortSignal.timeout(15000) } + { timeoutMs: 15000, errorLabel: "Earn Portfolio error" }, ); - - if (!res.ok) { - throw new Error(`Earn Portfolio error: ${res.status} ${res.statusText}`); - } - - return res.json(); } // Composer's /v1/quote is case-sensitive on token addresses: a checksummed @@ -123,7 +129,6 @@ export async function fetchComposerQuote(params: { url.searchParams.set("integrator", "hexkit"); const res = await fetch(url.toString(), { - headers: proxyHeaders(), signal: AbortSignal.timeout(30000), }); @@ -180,20 +185,26 @@ export function extractUniqueUnderlyings( const LLM_PROXY = "/api/llm-recommend"; -export async function postLlmRecommend(body: unknown): Promise { - const res = await fetch(LLM_PROXY, { +export async function postLlmRecommend( + body: unknown, + opts: { targetAddress?: string | null } = {} +): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + if (opts.targetAddress && /^0x[0-9a-fA-F]{40}$/.test(opts.targetAddress)) { + headers["x-target-address"] = opts.targetAddress.toLowerCase(); + } + return fetchJson(LLM_PROXY, { method: "POST", - headers: { "Content-Type": "application/json", ...proxyHeaders() }, + headers, body: JSON.stringify(body), // Gemini 3 Pro thinking responses run 20-50s on realistic payloads; 90s // covers the worst case. The serverless proxy itself caps at 60s. - signal: AbortSignal.timeout(90_000), + timeoutMs: 90_000, + errorLabel: "LLM proxy error", + withBodyText: true, }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`LLM proxy error: ${res.status} ${text}`); - } - return res.json(); } export async function fetchCrossChainStatus(params: { @@ -206,13 +217,9 @@ export async function fetchCrossChainStatus(params: { url.searchParams.set("fromChain", String(params.fromChain)); url.searchParams.set("toChain", String(params.toChain)); - const res = await fetch(url.toString(), { - headers: proxyHeaders(), - signal: AbortSignal.timeout(15000), + return fetchJson(url.toString(), { + timeoutMs: 15000, + errorLabel: "Status API error", + withBodyText: true, }); - if (!res.ok) { - const body = await res.text().catch(() => ""); - throw new Error(`Status API error: ${res.status} ${body}`); - } - return res.json(); } diff --git a/src/components/integrations/lifi-earn/types.ts b/src/components/integrations/lifi-earn/types.ts index c147b19..04e2db6 100644 --- a/src/components/integrations/lifi-earn/types.ts +++ b/src/components/integrations/lifi-earn/types.ts @@ -17,12 +17,9 @@ export interface EarnProtocol { } // GET /v1/chains response element — authoritative list of chains Earn indexes. -// networkCaip is EIP-155 CAIP-2 format ("eip155:1") — unused today but kept for -// future WalletConnect v2 scope wiring. export interface EarnChainInfo { chainId: number; name: string; - networkCaip: string; } // GET /v1/protocols response element. `name` is the slug we compare against diff --git a/src/components/simple-grid/GridContext.tsx b/src/components/simple-grid/GridContext.tsx index db73f46..50ba34e 100644 --- a/src/components/simple-grid/GridContext.tsx +++ b/src/components/simple-grid/GridContext.tsx @@ -56,7 +56,6 @@ export type AbiSourceType = | "blockscout" | "etherscan" | "blockscout-bytecode" - | "blockscout-ebd" | "whatsabi" | "manual" | "restored" diff --git a/src/components/simple-grid/hooks/useSimulationState.tsx b/src/components/simple-grid/hooks/useSimulationState.tsx index 86e3bd3..b386393 100644 --- a/src/components/simple-grid/hooks/useSimulationState.tsx +++ b/src/components/simple-grid/hooks/useSimulationState.tsx @@ -14,7 +14,7 @@ import { getCallNodeError, type SimulationCallNode, } from "../../../utils/simulationArtifacts"; -import { simulateTransaction } from "../../../utils/transactionSimulation"; +import { simulateTransaction } from "../../../utils/transaction-simulation"; import { resolveProxyInfo } from "../../../utils/resolver"; import type { ProxyInfo } from "../../../utils/resolver"; import { CopyButton } from "../../ui/copy-button"; @@ -157,7 +157,6 @@ export function useSimulationState(deps: UseSimulationStateDeps) { transactionWithOverrides, selectedNetwork, normalizedFrom, - provider, { enableDebug: simulationOverrides.enableDebug === true } ); // Persist the effective sender used by the simulator for debugging/QA parity. diff --git a/src/components/simulation-results/ContractsTab.tsx b/src/components/simulation-results/ContractsTab.tsx index 07982d4..3ec7ae7 100644 --- a/src/components/simulation-results/ContractsTab.tsx +++ b/src/components/simulation-results/ContractsTab.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import type { SimulationResult } from "../../types/transaction"; import { shortenAddress } from "../shared/AddressDisplay"; import { SourceBadge } from "../shared/ContractBadges"; +import { parseRawTraceObject } from "./formatters"; type SourceProvider = 'etherscan' | 'sourcify' | 'blockscout' | null; @@ -35,13 +36,7 @@ const explorerBase: Record = { export const ContractsTab: React.FC = ({ result, contractContext }) => { const navigate = useNavigate(); - const rawTrace = (result as any)?.rawTrace; - let parsedRawTrace: any = null; - try { - parsedRawTrace = typeof rawTrace === 'string' ? JSON.parse(rawTrace) : rawTrace; - } catch { - parsedRawTrace = rawTrace; - } + const parsedRawTrace = parseRawTraceObject((result as any)?.rawTrace); const traceArtifacts = parsedRawTrace?.artifacts || {}; const traceOpcodeLines = parsedRawTrace?.opcodeLines || {}; @@ -120,13 +115,6 @@ export const ContractsTab: React.FC = ({ result, contractCont const chainId = (result as any)?.chainId || contractContext?.networkId || 1; const chainExplorer = explorerBase[chainId] || explorerBase[1]; - const getExplorerDisplayName = (provider: SourceProvider): string | null => { - if (provider === 'sourcify') return 'Sourcify'; - if (provider === 'blockscout') return chainExplorer.blockscoutName || 'Blockscout'; - if (provider === 'etherscan') return chainExplorer.etherscanName; - return null; - }; - const getExplorerUrl = (addr: string, provider: SourceProvider): string | null => { if (provider === 'sourcify') { return `https://repo.sourcify.dev/contracts/full_match/${chainId}/${addr}/`; @@ -200,7 +188,6 @@ export const ContractsTab: React.FC = ({ result, contractCont {/* Table Rows */} {contracts.map((contract, index) => { const explorerUrl = contract.verified ? getExplorerUrl(contract.address, contract.sourceProvider) : null; - const sourceLabel = getExplorerDisplayName(contract.sourceProvider); return (

= ({ if (decodedTrace?.rows) { decodedTrace.rows.forEach((row: any) => { if (row.name?.startsWith("LOG") && row.decodedLog) { - const rawTopics = (row.logInfo?.topics || []).map((t: any) => { - const hex = String(t).replace(/^0x/, ""); - return "0x" + hex.padStart(64, "0"); - }); + const rawTopics = (row.logInfo?.topics || []).map(normalizeTopic); let rawData = ""; if (row.logInfo && row.memory) { @@ -194,21 +191,7 @@ export const EventsTab: React.FC = ({ eventArgs = decoded.args; } if (!eventName || eventName === "Anonymous Event") { - let topic0: string | null = null; - if (event.data?.topics?.[0]) { - topic0 = - "0x" + - String(event.data.topics[0]).replace(/^0x/, "").padStart(64, "0"); - } else if (event.topics?.[0]) { - topic0 = - "0x" + String(event.topics[0]).replace(/^0x/, "").padStart(64, "0"); - } else if (event.logInfo?.topics?.[0]) { - topic0 = - "0x" + - String(event.logInfo.topics[0]) - .replace(/^0x/, "") - .padStart(64, "0"); - } + const topic0 = eventTopic0(event); if (topic0 && lookedUpEventNames[topic0]) { eventName = lookedUpEventNames[topic0]; } diff --git a/src/components/simulation-results/StateTab.tsx b/src/components/simulation-results/StateTab.tsx index 1f2ccce..f54314b 100644 --- a/src/components/simulation-results/StateTab.tsx +++ b/src/components/simulation-results/StateTab.tsx @@ -12,6 +12,7 @@ import { type SlotDescriptor, } from "../../utils/storageLayoutDecode"; import { formatTokenValue } from "../../utils/displayFormatters"; +import { parseRawTraceObject } from "./formatters"; import "./StateTab.css"; /** Format a decoded value, optionally applying token decimal formatting */ @@ -93,15 +94,10 @@ function renderHighlightedHex( } export const StateTab: React.FC = ({ result, artifacts, contractContext }) => { - // Memoize parsed rawTrace to avoid JSON.parse on every render - const parsedRawTrace = useMemo(() => { - const rawTrace = (result as any)?.rawTrace; - try { - return typeof rawTrace === 'string' ? JSON.parse(rawTrace) : rawTrace; - } catch { - return rawTrace; - } - }, [(result as any)?.rawTrace]); + const parsedRawTrace = useMemo( + () => parseRawTraceObject((result as any)?.rawTrace), + [(result as any)?.rawTrace] + ); const rtArtifacts = useMemo( () => parsedRawTrace?.artifacts || {}, diff --git a/src/components/simulation-results/TransactionSummary.tsx b/src/components/simulation-results/TransactionSummary.tsx index 18bb144..e10c425 100644 --- a/src/components/simulation-results/TransactionSummary.tsx +++ b/src/components/simulation-results/TransactionSummary.tsx @@ -3,7 +3,7 @@ import { CopyButton } from "../ui/copy-button"; import { HoverCard, HoverCardTrigger, HoverCardContent } from "../ui/hover-card"; import ChainIcon, { type ChainKey } from "../icons/ChainIcon"; import { networkToChainKey } from "./constants"; -import { formatTimestamp, formatGwei, formatEth, calculateTxFee } from "./formatters"; +import { formatTimestamp, formatGwei, formatEth } from "./formatters"; import { useNativeTokenPrice } from "../../hooks/useNativeTokenPrice"; interface TransactionSummaryProps { diff --git a/src/components/simulation-results/eventDecoder.ts b/src/components/simulation-results/eventDecoder.ts index d5dbec1..7f113ed 100644 --- a/src/components/simulation-results/eventDecoder.ts +++ b/src/components/simulation-results/eventDecoder.ts @@ -1,5 +1,21 @@ import { ethers } from "ethers"; import { COMMON_EVENTS_ABI } from "./constants"; +import { setLimitedCacheEntry } from "../../utils/cache/limitedCache"; + +// Normalize a topic hex string to 32-byte 0x-prefixed form. +export function normalizeTopic(topic: unknown): string { + return "0x" + String(topic).replace(/^0x/, "").padStart(64, "0"); +} + +// Extract topic[0] (event signature hash) from a log-shaped object, in +// whichever field it happens to live (data.topics, topics, logInfo.topics). +export function eventTopic0(event: any): string | null { + const raw = + (event?.data?.topics?.[0] as unknown) || + (event?.topics?.[0] as unknown) || + (event?.logInfo?.topics?.[0] as unknown); + return raw ? normalizeTopic(raw) : null; +} // Lazy-init common events interface let commonEventsIface: ethers.utils.Interface | null = null; @@ -34,11 +50,7 @@ function getOrCreateInterface(abi: any[]): ethers.utils.Interface | null { if (!iface) { try { iface = new ethers.utils.Interface(abi); - interfaceCache.set(key, iface); - if (interfaceCache.size > 100) { - const first = interfaceCache.keys().next().value; - if (first) interfaceCache.delete(first); - } + setLimitedCacheEntry(interfaceCache, key, iface, 100); } catch { return null; } } return iface; @@ -78,10 +90,7 @@ export function decodeRawEvent( if (!rawTopics || rawTopics.length === 0) return null; - const topicHex = rawTopics.map((t: any) => { - const hex = String(t).replace(/^0x/, ''); - return '0x' + hex.padStart(64, '0'); - }); + const topicHex = rawTopics.map(normalizeTopic); const interfacesToTry: ethers.utils.Interface[] = []; diff --git a/src/components/simulation-results/formatters.ts b/src/components/simulation-results/formatters.ts index 0982b05..d998fb3 100644 --- a/src/components/simulation-results/formatters.ts +++ b/src/components/simulation-results/formatters.ts @@ -1,13 +1,14 @@ import { OPCODE_MNEMONICS } from "./constants"; -import { shortenAddress, shortenHash } from '../shared/AddressDisplay'; -export const formatLongAddress = (value?: string | null) => { - if (!value) return "0x0"; - if (value === "0x0000000000000000000000000000000000000000") { - return "Zero Address (0x0000\u20260000)"; +export function parseRawTraceObject(rawTrace: unknown): any { + if (rawTrace === null || rawTrace === undefined) return null; + if (typeof rawTrace !== "string") return rawTrace; + try { + return JSON.parse(rawTrace); + } catch { + return rawTrace; } - return `${shortenAddress(value)} (${shortenHash(value, 10, 4)})`; -}; +} export const getOpcodeName = (opcode?: number | null) => { if (typeof opcode !== "number") return "OP"; @@ -30,7 +31,7 @@ export const snapshotFrameKey = (frameId: unknown): string | undefined => { }; export const formatTimestamp = (value?: number | null) => { - if (!value) return "\u2014"; + if (!value) return "—"; try { const date = new Date(value * 1000); @@ -63,7 +64,7 @@ export const formatTimestamp = (value?: number | null) => { return `${relativeTime} (${absoluteTime})`; } catch { - return "\u2014"; + return "—"; } }; @@ -72,30 +73,29 @@ function formatBigIntUnits(wei: bigint, decimals: number, displayDecimals: numbe const divisor = 10n ** BigInt(decimals); const intPart = wei / divisor; const fracPart = wei % divisor; - // Pad fractional part and truncate to desired display precision const fracStr = fracPart.toString().padStart(decimals, '0').slice(0, displayDecimals); return `${intPart}.${fracStr}`; } export const formatGwei = (weiValue?: string | null) => { - if (!weiValue) return "\u2014"; + if (!weiValue) return "—"; try { const wei = BigInt(weiValue); return `${formatBigIntUnits(wei, 9, 2)} Gwei`; } catch { - return "\u2014"; + return "—"; } }; export const formatEth = (weiValue?: string | null) => { - if (!weiValue) return "\u2014"; + if (!weiValue) return "—"; try { const wei = BigInt(weiValue); - const isSmall = wei < 10n ** 14n; // < 0.0001 ETH + const isSmall = wei < 10n ** 14n; const displayDecimals = isSmall ? 6 : 4; return `${formatBigIntUnits(wei, 18, displayDecimals)} ETH`; } catch { - return "\u2014"; + return "—"; } }; @@ -118,20 +118,8 @@ export const calculateIntrinsicGas = (calldata?: string | null): number => { return INTRINSIC_BASE + calldataGas; }; -export const calculateTxFee = (gasUsed?: string | null, gasPrice?: string | null) => { - if (!gasUsed || !gasPrice) return "\u2014"; - try { - const gas = BigInt(gasUsed); - const price = BigInt(gasPrice); - const feeInWei = gas * price; - return formatEth(feeInWei.toString()); - } catch { - return "\u2014"; - } -}; - export const formatTxType = (type?: number | null) => { - if (type === null || type === undefined) return "\u2014"; + if (type === null || type === undefined) return "—"; switch (type) { case 0: return "Legacy (0)"; @@ -158,13 +146,3 @@ export const parseGasSafe = (value: string | number | null | undefined): number const MAX_REASONABLE_GAS = 100_000_000; return (Number.isFinite(num) && num > 0 && num < MAX_REASONABLE_GAS) ? num : 0; }; - -/** Format contract name with address */ -export const formatContractDisplay = (address?: string, name?: string) => { - if (!address) return "—"; - const short = shortenAddress(address); - if (name && name !== address) { - return `${name}(${short})`; - } - return short; -}; diff --git a/src/components/simulation-results/types.ts b/src/components/simulation-results/types.ts index 8b17459..c1db7b6 100644 --- a/src/components/simulation-results/types.ts +++ b/src/components/simulation-results/types.ts @@ -5,7 +5,7 @@ export interface SimulationResultsPageProps { onReSimulate?: () => void; } -export type SimulatorTab = "summary" | "contracts" | "events" | "assets" | "state" | "gas" | "debug"; +export type SimulatorTab = "summary" | "contracts" | "events" | "state"; export type TraceRowType = "call" | "opcode" | "event" | "storage"; /** diff --git a/src/components/simulation-results/useEventSignatureLookup.ts b/src/components/simulation-results/useEventSignatureLookup.ts new file mode 100644 index 0000000..870eab0 --- /dev/null +++ b/src/components/simulation-results/useEventSignatureLookup.ts @@ -0,0 +1,109 @@ +import { useEffect, useState } from "react"; +import { + lookupEventSignatures, + getCachedSignatures, + cacheSignature, +} from "../../utils/signatureDatabase"; +import { decodeRawEvent } from "./eventDecoder"; +import type { SimulatorTab } from "./types"; + +interface Args { + activeTab: SimulatorTab; + events: any[]; + contractContext: any; +} + +/** + * Resolves human-readable names for anonymous events by checking the local + * signature cache first, then batching unknowns to the signature DB. + * Also owns the event-tab filter state because it sits alongside the same + * event data and has no other home. + */ +export function useEventSignatureLookup({ + activeTab, + events, + contractContext, +}: Args) { + const [lookedUpEventNames, setLookedUpEventNames] = useState< + Record + >({}); + const [eventNameFilter, setEventNameFilter] = useState(""); + const [eventContractFilter, setEventContractFilter] = useState(""); + + useEffect(() => { + if (activeTab !== "events" || events.length === 0) return; + + const lookupUnknownEvents = async () => { + const cachedSignatures = getCachedSignatures("event"); + const cachedNamesToAdd: Record = {}; + const allAbis: any[] = []; + if (contractContext?.abi) allAbis.push(contractContext.abi); + if (contractContext?.diamondFacets) { + contractContext.diamondFacets.forEach((f: any) => { + if (f.abi) allAbis.push(f.abi); + }); + } + const unknownTopics: string[] = []; + + events.forEach((event: any) => { + if (event.name && event.name !== "Anonymous Event") return; + let topic0: string | null = null; + if (event.data?.topics?.[0]) topic0 = String(event.data.topics[0]); + else if (event.topics?.[0]) topic0 = String(event.topics[0]); + if (!topic0) return; + const normalizedTopic = + "0x" + topic0.replace(/^0x/, "").padStart(64, "0"); + if (cachedSignatures[normalizedTopic]) { + if (!lookedUpEventNames[normalizedTopic]) { + cachedNamesToAdd[normalizedTopic] = + cachedSignatures[normalizedTopic].name; + } + return; + } + if (lookedUpEventNames[normalizedTopic]) return; + const decoded = decodeRawEvent(event, allAbis); + if (decoded?.name) return; + if (!unknownTopics.includes(normalizedTopic)) + unknownTopics.push(normalizedTopic); + }); + + if (Object.keys(cachedNamesToAdd).length > 0) { + setLookedUpEventNames((prev) => ({ ...prev, ...cachedNamesToAdd })); + } + + if (unknownTopics.length > 0) { + try { + const response = await lookupEventSignatures(unknownTopics); + if (response.ok && response.result?.event) { + const newNames: Record = {}; + Object.entries(response.result.event).forEach( + ([hash, signatures]) => { + if (signatures && signatures.length > 0) { + const name = signatures[0].name; + const eventName = name.split("(")[0]; + newNames[hash] = eventName; + cacheSignature(hash, eventName, "event"); + } + }, + ); + if (Object.keys(newNames).length > 0) { + setLookedUpEventNames((prev) => ({ ...prev, ...newNames })); + } + } + } catch (err) { + console.warn("[Events] Failed to look up event signatures:", err); + } + } + }; + + lookupUnknownEvents(); + }, [activeTab, events, contractContext, lookedUpEventNames]); + + return { + lookedUpEventNames, + eventNameFilter, + setEventNameFilter, + eventContractFilter, + setEventContractFilter, + }; +} diff --git a/src/components/simulation-results/useSimulationDebugActions.ts b/src/components/simulation-results/useSimulationDebugActions.ts new file mode 100644 index 0000000..fdb7019 --- /dev/null +++ b/src/components/simulation-results/useSimulationDebugActions.ts @@ -0,0 +1,298 @@ +import { useCallback, useEffect, useRef } from "react"; +import { traceVaultService } from "../../services/TraceVaultService"; +import { isTraceSessionId } from "../../contexts/debug/sessionRef"; +import { getChainById } from "../../utils/chains"; +import type { + ContractContextExtras, + SimulationResultExtras, +} from "./gasHelpers"; + +interface DebugSlice { + openDebugWindow: () => void; + session: { sessionId?: string; simulationId?: string } | null; + initFromTraceData: (args: { + simulationId: string; + chainId: number; + traceRows: any[]; + sourceTexts: Record; + rawTrace?: any; + }) => void; + connectToSession: (args: { + sessionId: string; + rpcPort: number; + snapshotCount: number; + chainId: number; + simulationId: string; + }) => Promise; + debugPrepState: { simulationId?: string; status?: string } | null | undefined; + startDebugPrep: ( + request: { + rpcUrl: string; + chainId: number; + txHash: string; + blockTag?: string; + }, + simulationId: string, + ) => void; +} + +interface Args extends DebugSlice { + result: any; + contractContext: any; + contextSimulationId: string | null; + id: string | undefined; + decodedTrace: any; + resolveRpcUrl: (chainId: number, fallback?: string) => { url: string }; + showSuccess: (title: string, message: string) => void; + showError: (title: string, message: string) => void; +} + +/** + * Encapsulates the "prepare / reuse / open" dance for the debug window: + * - auto-starts replay prep when a sim was requested with debugEnabled + * - reuses a live EDB session when possible + * - falls back to trace-only debugging when no live session exists + */ +export function useSimulationDebugActions(args: Args) { + const { + result, + contractContext, + contextSimulationId, + id, + decodedTrace, + resolveRpcUrl, + showSuccess, + showError, + openDebugWindow, + session: debugSession, + initFromTraceData, + connectToSession, + debugPrepState, + startDebugPrep, + } = args; + + const autoStartedDebugPrepRef = useRef>(new Set()); + + const buildReplayDebugPrepRequest = useCallback(() => { + const resultWithExtras = result as + | (typeof result & SimulationResultExtras) + | null; + const contextWithExtras = contractContext as typeof contractContext & + ContractContextExtras; + const txHash = + resultWithExtras?.transactionHash || contextWithExtras?.replayTxHash; + if (!txHash) return null; + + const chainId = result?.chainId || contractContext?.networkId || 1; + const chain = getChainById(chainId); + if (!chain) return null; + + const rpcUrl = resolveRpcUrl(chain.id, chain.rpcUrl).url; + if (!rpcUrl) return null; + + const forkBlockTag = + typeof resultWithExtras?.forkBlockTag === "string" && + resultWithExtras.forkBlockTag.trim() + ? resultWithExtras.forkBlockTag.trim() + : undefined; + + return { + rpcUrl, + chainId, + txHash, + ...(forkBlockTag ? { blockTag: forkBlockTag } : {}), + }; + }, [contractContext, resolveRpcUrl, result]); + + const startReplayDebugPreparation = useCallback( + (simulationId: string) => { + const prepStateForSimulation = + debugPrepState?.simulationId === simulationId ? debugPrepState : null; + if ( + prepStateForSimulation?.status === "queued" || + prepStateForSimulation?.status === "preparing" || + prepStateForSimulation?.status === "ready" + ) { + return true; + } + + const request = buildReplayDebugPrepRequest(); + if (!request) return false; + + autoStartedDebugPrepRef.current.add(simulationId); + startDebugPrep(request, simulationId); + return true; + }, + [buildReplayDebugPrepRequest, debugPrepState, startDebugPrep], + ); + + useEffect(() => { + const resultWithExtras = result as + | (typeof result & SimulationResultExtras) + | null; + const contextWithExtras = contractContext as typeof contractContext & + ContractContextExtras; + const debugRequested = + resultWithExtras?.debugEnabled === true || + contextWithExtras?.debugEnabled === true; + + if (!debugRequested || result?.debugSession?.sessionId) return; + + const txHash = + resultWithExtras?.transactionHash || contextWithExtras?.replayTxHash; + if (!txHash) return; + + const simulationId = + resultWithExtras?.simulationId || contextSimulationId || id; + if (!simulationId || autoStartedDebugPrepRef.current.has(simulationId)) + return; + + const prepStateForSimulation = + debugPrepState?.simulationId === simulationId ? debugPrepState : null; + if ( + prepStateForSimulation?.status === "queued" || + prepStateForSimulation?.status === "preparing" || + prepStateForSimulation?.status === "ready" || + prepStateForSimulation?.status === "failed" + ) { + return; + } + + startReplayDebugPreparation(simulationId); + }, [ + contractContext, + contextSimulationId, + debugPrepState, + id, + result, + startReplayDebugPreparation, + ]); + + const handleOpenDebug = useCallback(async () => { + const resultWithExtras = result as + | (typeof result & SimulationResultExtras) + | null; + const contextWithExtras = contractContext as typeof contractContext & + ContractContextExtras; + const debugRequested = + resultWithExtras?.debugEnabled === true || + contextWithExtras?.debugEnabled === true; + + const chainId = result?.chainId || contractContext?.networkId || 1; + const simulationId = + resultWithExtras?.simulationId || + contextSimulationId || + id || + `debug-${Date.now()}`; + const debugPrepForSimulation = + debugPrepState?.simulationId === simulationId ? debugPrepState : null; + + if ( + debugPrepForSimulation?.status === "queued" || + debugPrepForSimulation?.status === "preparing" + ) { + return; + } + + const targetLiveSessionId = result?.debugSession?.sessionId || null; + const isExistingTraceSession = debugSession?.sessionId + ? isTraceSessionId(debugSession.sessionId) + : false; + const hasReusableLiveSession = + !!debugSession && + !isExistingTraceSession && + ((targetLiveSessionId && + debugSession.sessionId === targetLiveSessionId) || + (!targetLiveSessionId && debugSession.simulationId === simulationId)); + if (hasReusableLiveSession) { + openDebugWindow(); + return; + } + + let traceForDebug = decodedTrace; + const hasHeavyTraceRows = !!decodedTrace?.rows?.some( + (row: any) => Array.isArray(row.stack) || Array.isArray(row.memory), + ); + if (decodedTrace?.rows?.length && !hasHeavyTraceRows) { + try { + const fullTrace = await traceVaultService.loadDecodedTrace( + simulationId, + { includeHeavy: true }, + ); + if (fullTrace?.rows?.length) traceForDebug = fullTrace as any; + } catch (err) { + console.warn("[handleOpenDebug] Failed to load full trace:", err); + } + } + + if (result?.debugSession?.sessionId) { + try { + await connectToSession({ + sessionId: result.debugSession.sessionId, + rpcPort: result.debugSession.rpcPort, + snapshotCount: result.debugSession.snapshotCount, + chainId, + simulationId, + }); + openDebugWindow(); + return; + } catch (err) { + console.warn("Failed to connect to live EDB session:", err); + if (debugRequested) { + showError( + "Debug Session Unavailable", + "This simulation was requested with live debugging, but its session could not be reconnected. Re-simulate with Debug enabled.", + ); + return; + } + } + } + + if (debugRequested) { + if (startReplayDebugPreparation(simulationId)) { + showSuccess( + "Preparing Debug Session", + "Building a live debug session for this replay. The debugger will be available when preparation completes.", + ); + } else { + showError( + "Debug Session Missing", + "This replay is missing the RPC or transaction context required to prepare a live debug session. Re-run the replay with Debug enabled.", + ); + } + return; + } + + if (traceForDebug?.rows && traceForDebug.rows.length > 0) { + initFromTraceData({ + simulationId, + chainId, + traceRows: traceForDebug.rows, + sourceTexts: traceForDebug.sourceTexts || {}, + rawTrace: resultWithExtras?.rawTrace, + }); + openDebugWindow(); + return; + } + + openDebugWindow(); + }, [ + connectToSession, + contextSimulationId, + contractContext, + debugPrepState, + debugSession, + decodedTrace, + id, + initFromTraceData, + openDebugWindow, + result, + showError, + showSuccess, + startReplayDebugPreparation, + ]); + + const hasLiveDebugSession = !!result?.debugSession?.sessionId; + + return { handleOpenDebug, hasLiveDebugSession }; +} diff --git a/src/components/simulation-results/useSimulationHistoryLoader.ts b/src/components/simulation-results/useSimulationHistoryLoader.ts new file mode 100644 index 0000000..d33cfd5 --- /dev/null +++ b/src/components/simulation-results/useSimulationHistoryLoader.ts @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from "react"; +import { traceVaultService } from "../../services/TraceVaultService"; +import type { SimulationResult } from "../../types/transaction"; +import type { DecodedTraceMeta } from "../../contexts/SimulationContext"; +import type { DecodedTraceRow } from "../../types/decoded-trace"; +import type { ContractContext } from "../../utils/resolver"; +import { hasInternalInfo } from "./useSimulationPageHelpers"; + +interface SimulationContextSlice { + currentSimulation: SimulationResult | null; + setSimulation: ( + result: SimulationResult, + ctx: ContractContext | null, + opts?: { skipHistorySave?: boolean }, + ) => void; + setDecodedTraceRows: (rows: DecodedTraceRow[]) => void; + setDecodedTraceMeta: (meta: DecodedTraceMeta) => void; + setSourceTexts: (texts: Record) => void; +} + +interface Args extends SimulationContextSlice { + id: string | undefined; + propResult: SimulationResult | null | undefined; + isFreshNavigation: boolean; +} + +/** + * Loads a simulation from history when the page mounts with an id but no + * pre-existing prop or context simulation. Also rehydrates the decoded trace + * and source bundle from TraceVaultService when available. + */ +export function useSimulationHistoryLoader({ + id, + propResult, + isFreshNavigation, + currentSimulation, + setSimulation, + setDecodedTraceRows, + setDecodedTraceMeta, + setSourceTexts, +}: Args) { + const [isLoadingFromHistory, setIsLoadingFromHistory] = useState(false); + const [loadError, setLoadError] = useState(null); + const hasAttemptedLoad = useRef(false); + + useEffect(() => { + const loadFromHistory = async () => { + if (propResult || currentSimulation || !id) return; + if (hasAttemptedLoad.current) return; + + hasAttemptedLoad.current = true; + setIsLoadingFromHistory(true); + setLoadError(null); + + try { + const { simulationHistoryService } = await import( + "../../services/SimulationHistoryService" + ); + const stored = await simulationHistoryService.getSimulation(id); + + if (!stored) { + setLoadError("Simulation not found"); + return; + } + + 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)); + } + } + } catch (err) { + console.error( + "[SimulationResultsPage] Failed to load from history:", + err, + ); + setLoadError("Failed to load simulation from history"); + } finally { + setIsLoadingFromHistory(false); + } + }; + + if (propResult || currentSimulation || !id || hasAttemptedLoad.current) + return; + + if (isFreshNavigation) { + setIsLoadingFromHistory(true); + const timer = window.setTimeout(() => { + loadFromHistory(); + }, 500); + return () => window.clearTimeout(timer); + } + + loadFromHistory(); + }, [ + id, + propResult, + currentSimulation, + setSimulation, + setDecodedTraceRows, + setDecodedTraceMeta, + setSourceTexts, + isFreshNavigation, + ]); + + useEffect(() => { + if (currentSimulation) setIsLoadingFromHistory(false); + }, [currentSimulation]); + + useEffect(() => { + hasAttemptedLoad.current = false; + }, [id]); + + return { isLoadingFromHistory, loadError }; +} diff --git a/src/components/simulation-results/useSimulationPageHelpers.ts b/src/components/simulation-results/useSimulationPageHelpers.ts index 36b5aad..5a1bbf2 100644 --- a/src/components/simulation-results/useSimulationPageHelpers.ts +++ b/src/components/simulation-results/useSimulationPageHelpers.ts @@ -28,32 +28,6 @@ export const hasInternalInfo = (rows?: InternalInfoRow[]): boolean => row?.hasChildren ); -// ---- Context / result extras ------------------------------------------ - -export type ContractContextExtras = { - debugEnabled?: boolean; - networkId?: number; - networkName?: string; - blockOverride?: string | number; - fromAddress?: string; - address?: string; - calldata?: string; - ethValue?: string; -}; - -export type SimulationResultExtras = { - simulationId?: string; - transactionHash?: string; - debugEnabled?: boolean; - chainId?: number; - networkName?: string; - forkBlockTag?: string | number; - rawTrace?: { snapshots?: unknown[] }; - blockNumber?: string | number; - gasLimit?: string | number; - gas?: string | number; -}; - // ---- Address-to-name map builder -------------------------------------- export function buildAddressToNameMap( diff --git a/src/components/simulation-results/useSimulationPageState.ts b/src/components/simulation-results/useSimulationPageState.ts index 8ccd95b..5a196f4 100644 --- a/src/components/simulation-results/useSimulationPageState.ts +++ b/src/components/simulation-results/useSimulationPageState.ts @@ -1,6 +1,5 @@ -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; import { useNavigate, useParams, useLocation } from "react-router-dom"; -import { lookupEventSignatures, getCachedSignatures, cacheSignature } from "../../utils/signatureDatabase"; import { extractSimulationArtifacts, flattenCallTreeEntries, @@ -11,27 +10,24 @@ 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 { useDecodedTrace } from "../../hooks/useDecodedTrace"; import { useDebug } from "../../contexts/DebugContext"; -import { getChainById } from "../../utils/chains"; import type { SimulationResultsPageProps, SimulatorTab } from "./types"; -import { decodeRawEvent } from "./eventDecoder"; import { useTraceRows } from "./useTraceRows"; +import { useSimulationHistoryLoader } from "./useSimulationHistoryLoader"; +import { useEventSignatureLookup } from "./useEventSignatureLookup"; +import { useTraceSourceResolver } from "./useTraceSourceResolver"; +import { useSimulationDebugActions } from "./useSimulationDebugActions"; -// Extracted helpers import { - type InternalInfoRow, - type ContractContextExtras, - type SimulationResultExtras, - hasInternalInfo, buildAddressToNameMap, buildRevertInfo, buildTraceDiagnostics, buildEnrichedTraceRows, buildCallSummaryRow, } from "./useSimulationPageHelpers"; +import type { ContractContextExtras, SimulationResultExtras } from "./gasHelpers"; export type { ContractContextExtras, SimulationResultExtras }; @@ -63,97 +59,14 @@ export function useSimulationPageState(props: SimulationResultsPageProps) { const [traceFilters, setTraceFilters] = useState({ gas: true, full: true, storage: true, events: true, }); - const [highlightedTraceRow, setHighlightedTraceRow] = useState(null); const [highlightedValue, setHighlightedValue] = useState(null); - const [isLoadingFromHistory, setIsLoadingFromHistory] = useState(false); - const [loadError, setLoadError] = useState(null); - const hasAttemptedLoad = useRef(false); - const resolvedAddressesRef = useRef>(new Set()); - const autoStartedDebugPrepRef = useRef>(new Set()); - const [lookedUpEventNames, setLookedUpEventNames] = useState>({}); - const [eventNameFilter, setEventNameFilter] = useState(""); - const [eventContractFilter, setEventContractFilter] = useState(""); - - // ---- Load from history ---- - useEffect(() => { - const loadFromHistory = async () => { - if (propResult || currentSimulation || !id) return; - if (hasAttemptedLoad.current) return; - - hasAttemptedLoad.current = true; - setIsLoadingFromHistory(true); - 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)); - } - } - } else { - setLoadError(`Simulation not found`); - } - } catch (err) { - console.error("[SimulationResultsPage] Failed to load from history:", err); - setLoadError("Failed to load simulation from history"); - } finally { - setIsLoadingFromHistory(false); - } - }; - - if (propResult || currentSimulation || !id || hasAttemptedLoad.current) return; - - if (isFreshNavigation) { - setIsLoadingFromHistory(true); - const timer = window.setTimeout(() => { loadFromHistory(); }, 500); - return () => window.clearTimeout(timer); - } - - loadFromHistory(); - }, [id, propResult, currentSimulation, setSimulation, setDecodedTraceRows, setDecodedTraceMeta, setSourceTexts, isFreshNavigation]); - - useEffect(() => { - if (currentSimulation) setIsLoadingFromHistory(false); - }, [currentSimulation]); - useEffect(() => { - hasAttemptedLoad.current = false; - }, [id]); + // History loader — rehydrates a stored simulation when opened via /sim/:id. + const { isLoadingFromHistory, loadError } = useSimulationHistoryLoader({ + id, propResult, isFreshNavigation, + currentSimulation, setSimulation, + setDecodedTraceRows, setDecodedTraceMeta, setSourceTexts, + }); const result = propResult || currentSimulation; @@ -233,73 +146,16 @@ export function useSimulationPageState(props: SimulationResultsPageProps) { const events = useMemo(() => artifacts?.events ?? [], [artifacts?.events]); const storageDiffs = useMemo(() => artifacts?.storageDiffs ?? [], [artifacts?.storageDiffs]); - // ---- Event signature lookup ---- - useEffect(() => { - if (activeTab !== 'events' || events.length === 0) return; - - const lookupUnknownEvents = async () => { - const cachedSignatures = getCachedSignatures('event'); - const cachedNamesToAdd: Record = {}; - const allAbis: any[] = []; - if (contractContext?.abi) allAbis.push(contractContext.abi); - if (contractContext?.diamondFacets) { - contractContext.diamondFacets.forEach((f: any) => { if (f.abi) allAbis.push(f.abi); }); - } - const unknownTopics: string[] = []; - - events.forEach((event: any) => { - if (event.name && event.name !== 'Anonymous Event') return; - let topic0: string | null = null; - if (event.data?.topics?.[0]) topic0 = String(event.data.topics[0]); - else if (event.topics?.[0]) topic0 = String(event.topics[0]); - if (!topic0) return; - const normalizedTopic = '0x' + topic0.replace(/^0x/, '').padStart(64, '0'); - if (cachedSignatures[normalizedTopic]) { - if (!lookedUpEventNames[normalizedTopic]) { - cachedNamesToAdd[normalizedTopic] = cachedSignatures[normalizedTopic].name; - } - return; - } - if (lookedUpEventNames[normalizedTopic]) return; - const decoded = decodeRawEvent(event, allAbis); - if (decoded?.name) return; - if (!unknownTopics.includes(normalizedTopic)) unknownTopics.push(normalizedTopic); - }); - - if (Object.keys(cachedNamesToAdd).length > 0) { - setLookedUpEventNames(prev => ({ ...prev, ...cachedNamesToAdd })); - } - - if (unknownTopics.length > 0) { - try { - const response = await lookupEventSignatures(unknownTopics); - if (response.ok && response.result?.event) { - const newNames: Record = {}; - Object.entries(response.result.event).forEach(([hash, signatures]) => { - if (signatures && signatures.length > 0) { - const name = signatures[0].name; - const eventName = name.split('(')[0]; - newNames[hash] = eventName; - cacheSignature(hash, eventName, 'event'); - } - }); - if (Object.keys(newNames).length > 0) { - setLookedUpEventNames(prev => ({ ...prev, ...newNames })); - } - } - } catch (err) { - console.warn('[Events] Failed to look up event signatures:', err); - } - } - }; - - lookupUnknownEvents(); - }, [activeTab, events, contractContext, lookedUpEventNames]); + // Event signature hydration for anonymous events in the Events tab. + const { + lookedUpEventNames, + eventNameFilter, setEventNameFilter, + eventContractFilter, setEventContractFilter, + } = useEventSignatureLookup({ activeTab, events, contractContext }); // ---- 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 { @@ -327,270 +183,17 @@ export function useSimulationPageState(props: SimulationResultsPageProps) { decodeMode: "lite", }); - const buildReplayDebugPrepRequest = useCallback(() => { - const resultWithExtras = result as (typeof result & SimulationResultExtras) | null; - const contextWithExtras = contractContext as (typeof contractContext & ContractContextExtras); - const txHash = resultWithExtras?.transactionHash || contextWithExtras?.replayTxHash; - if (!txHash) { - return null; - } - - const chainId = result?.chainId || contractContext?.networkId || 1; - const chain = getChainById(chainId); - if (!chain) { - return null; - } - - const rpcUrl = resolveRpcUrl(chain.id, chain.rpcUrl).url; - if (!rpcUrl) { - return null; - } - - const forkBlockTag = - typeof resultWithExtras?.forkBlockTag === "string" && resultWithExtras.forkBlockTag.trim() - ? resultWithExtras.forkBlockTag.trim() - : undefined; - - return { - rpcUrl, - chainId, - txHash, - ...(forkBlockTag ? { blockTag: forkBlockTag } : {}), - }; - }, [contractContext, resolveRpcUrl, result]); - - const startReplayDebugPreparation = useCallback( - (simulationId: string) => { - const prepStateForSimulation = - debugPrepState?.simulationId === simulationId ? debugPrepState : null; - if ( - prepStateForSimulation?.status === "queued" || - prepStateForSimulation?.status === "preparing" || - prepStateForSimulation?.status === "ready" - ) { - return true; - } - - const request = buildReplayDebugPrepRequest(); - if (!request) { - return false; - } - - autoStartedDebugPrepRef.current.add(simulationId); - startDebugPrep(request, simulationId); - return true; - }, - [buildReplayDebugPrepRequest, debugPrepState, startDebugPrep] - ); - - useEffect(() => { - const resultWithExtras = result as (typeof result & SimulationResultExtras) | null; - const contextWithExtras = contractContext as (typeof contractContext & ContractContextExtras); - const debugRequested = - resultWithExtras?.debugEnabled === true || - contextWithExtras?.debugEnabled === true; - - if (!debugRequested || result?.debugSession?.sessionId) { - return; - } - - const txHash = resultWithExtras?.transactionHash || contextWithExtras?.replayTxHash; - if (!txHash) { - return; - } - - const simulationId = resultWithExtras?.simulationId || contextSimulationId || id; - if (!simulationId || autoStartedDebugPrepRef.current.has(simulationId)) { - return; - } - - const prepStateForSimulation = - debugPrepState?.simulationId === simulationId ? debugPrepState : null; - if ( - prepStateForSimulation?.status === "queued" || - prepStateForSimulation?.status === "preparing" || - prepStateForSimulation?.status === "ready" || - prepStateForSimulation?.status === "failed" - ) { - return; - } - - startReplayDebugPreparation(simulationId); - }, [ - contractContext, - contextSimulationId, - debugPrepState, - id, - result, - startReplayDebugPreparation, - ]); - - // ---- Debug window ---- - const handleOpenDebug = useCallback(async () => { - const resultWithExtras = result as (typeof result & SimulationResultExtras) | null; - const contextWithExtras = contractContext as (typeof contractContext & ContractContextExtras); - const debugRequested = - resultWithExtras?.debugEnabled === true || - contextWithExtras?.debugEnabled === true; - - const chainId = result?.chainId || contractContext?.networkId || 1; - const simulationId = - resultWithExtras?.simulationId || contextSimulationId || id || `debug-${Date.now()}`; - const debugPrepForSimulation = - debugPrepState?.simulationId === simulationId ? debugPrepState : null; - - if ( - debugPrepForSimulation?.status === "queued" || - debugPrepForSimulation?.status === "preparing" - ) { - return; - } - - const targetLiveSessionId = result?.debugSession?.sessionId || null; - const isExistingTraceSession = debugSession?.sessionId?.startsWith('trace-'); - const hasReusableLiveSession = - !!debugSession && !isExistingTraceSession && - ( - (targetLiveSessionId && debugSession.sessionId === targetLiveSessionId) || - (!targetLiveSessionId && debugSession.simulationId === simulationId) - ); - if (hasReusableLiveSession) { openDebugWindow(); return; } - - let traceForDebug = decodedTrace; - const hasHeavyTraceRows = - !!decodedTrace?.rows?.some((row: any) => Array.isArray(row.stack) || Array.isArray(row.memory)); - if (decodedTrace?.rows?.length && !hasHeavyTraceRows) { - try { - const fullTrace = await traceVaultService.loadDecodedTrace(simulationId, { includeHeavy: true }); - if (fullTrace?.rows?.length) traceForDebug = fullTrace as any; - } catch (err) { - console.warn("[handleOpenDebug] Failed to load full trace:", err); - } - } - - if (result?.debugSession?.sessionId) { - try { - await connectToSession({ - sessionId: result.debugSession.sessionId, - rpcPort: result.debugSession.rpcPort, - snapshotCount: result.debugSession.snapshotCount, - chainId, simulationId, - }); - openDebugWindow(); - return; - } catch (err) { - console.warn('Failed to connect to live EDB session:', err); - if (debugRequested) { - showError( - "Debug Session Unavailable", - "This simulation was requested with live debugging, but its session could not be reconnected. Re-simulate with Debug enabled." - ); - return; - } - } - } - - if (debugRequested) { - if (startReplayDebugPreparation(simulationId)) { - showSuccess( - "Preparing Debug Session", - "Building a live debug session for this replay. The debugger will be available when preparation completes." - ); - } else { - showError( - "Debug Session Missing", - "This replay is missing the RPC or transaction context required to prepare a live debug session. Re-run the replay with Debug enabled." - ); - } - return; - } - - if (traceForDebug?.rows && traceForDebug.rows.length > 0) { - initFromTraceData({ - simulationId, chainId, - traceRows: traceForDebug.rows, - sourceTexts: traceForDebug.sourceTexts || {}, - rawTrace: resultWithExtras?.rawTrace, - }); - openDebugWindow(); - return; - } - - openDebugWindow(); - }, [ - connectToSession, - contextSimulationId, - contractContext, - debugPrepState, - debugSession, - decodedTrace, - id, - initFromTraceData, - openDebugWindow, - result, - showError, - showSuccess, - startReplayDebugPreparation, - ]); - - const hasLiveDebugSession = !!result?.debugSession?.sessionId; - - // ---- Trace source resolution ---- - const contractContextRef = useRef(contractContext); - contractContextRef.current = contractContext; - - useEffect(() => { - const resolveTraceSources = async () => { - const ctx = contractContextRef.current; - if (!decodedTrace?.rows || !ctx) return; - - const txFrom = ctx.fromAddress?.toLowerCase(); - const addresses = collectTraceAddresses(decodedTrace.rows, txFrom); - if (addresses.size === 0) return; - - const addressKey = Array.from(addresses).sort().join(','); - if (resolvedAddressesRef.current.has(addressKey)) return; - resolvedAddressesRef.current.add(addressKey); - - const contractMap = createTraceContractMap(addresses); - const addressList = Array.from(addresses).slice(0, 10); - - try { - const { contractResolver } = await import('../../utils/resolver/ContractResolver'); - const chainId = ctx.networkId; - const chainName = ctx.networkName; - - await Promise.allSettled( - addressList.map(async (addr) => { - try { - const result = await Promise.race([ - contractResolver.resolve(addr, { id: chainId, name: chainName } as any), - new Promise((_, reject) => - setTimeout(() => reject(new Error('timeout')), 5000) - ) - ]); - if (result && result.verified) { - const contract = contractMap.get(addr); - if (contract) { - contract.name = result.name || contract.name; - contract.sourceCode = result.metadata?.sourceCode; - contract.verified = true; - contract.sourceProvider = result.source || undefined; - } - } - return result; - } catch { return null; } - }) - ); - - setTraceContracts(contractMap); - } catch (err) { - console.warn('[SimResultsPage] Failed to resolve trace sources:', err); - } - }; + // Debug actions — prep, reuse live session, fall back to trace mode. + const { handleOpenDebug, hasLiveDebugSession } = useSimulationDebugActions({ + result, contractContext, contextSimulationId, id, decodedTrace, + resolveRpcUrl, showSuccess, showError, + openDebugWindow, session: debugSession, + initFromTraceData, connectToSession, + debugPrepState, startDebugPrep, + }); - resolveTraceSources(); - }, [decodedTrace, setTraceContracts]); + // Trace source resolution — fetch verified contract names into the sim ctx. + useTraceSourceResolver({ decodedTrace, contractContext, setTraceContracts }); useEffect(() => { if (decodedTrace?.sourceTexts && Object.keys(decodedTrace.sourceTexts).length > 0) { @@ -674,18 +277,18 @@ export function useSimulationPageState(props: SimulationResultsPageProps) { ); const formatAddressWithName = useCallback((address: string): { display: string; hasName: boolean } => { - if (!address || address === "\u2014") return { display: address, hasName: false }; + if (!address || address === "—") return { display: address, hasName: false }; if (!/^0x[a-fA-F0-9]{40}$/.test(address)) return { display: address, hasName: false }; const normalized = address.toLowerCase(); const name = addressToName.get(normalized); if (name) return { display: name, hasName: true }; - return { display: `${address.slice(0, 10)}\u2026${address.slice(-8)}`, hasName: false }; + return { display: `${address.slice(0, 10)}…${address.slice(-8)}`, hasName: false }; }, [addressToName]); const normalizeValue = useCallback((value: string | undefined | null): string | null => { if (!value) return null; const trimmed = value.trim(); - if (!trimmed || trimmed === "0x" || trimmed === "0x0" || trimmed === "\u2014") return null; + if (!trimmed || trimmed === "0x" || trimmed === "0x0" || trimmed === "—") return null; if (trimmed.startsWith("0x")) return trimmed.toLowerCase(); return trimmed; }, []); @@ -709,19 +312,12 @@ export function useSimulationPageState(props: SimulationResultsPageProps) { const handleGoToRevert = useCallback(() => { if (!revertRowId) return; - setHighlightedTraceRow(revertRowId); requestAnimationFrame(() => { const element = document.getElementById(`trace-row-${revertRowId}`); element?.scrollIntoView({ behavior: "smooth", block: "center" }); }); }, [revertRowId]); - useEffect(() => { - if (!highlightedTraceRow) return; - const timer = window.setTimeout(() => setHighlightedTraceRow(null), 2000); - return () => window.clearTimeout(timer); - }, [highlightedTraceRow]); - // ---- Trace diagnostics (delegated) ---- const traceDiagnostics = useMemo( () => @@ -747,7 +343,7 @@ export function useSimulationPageState(props: SimulationResultsPageProps) { searchQuery, setSearchQuery, deferredSearchQuery, traceFilters, handleToggleFilter, // UI state - highlightedTraceRow, highlightedValue, setHighlightedValue, + highlightedValue, setHighlightedValue, isLoadingFromHistory, loadError, // events lookedUpEventNames, eventNameFilter, setEventNameFilter, diff --git a/src/components/simulation-results/useTraceSourceResolver.ts b/src/components/simulation-results/useTraceSourceResolver.ts new file mode 100644 index 0000000..1de1afa --- /dev/null +++ b/src/components/simulation-results/useTraceSourceResolver.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef } from "react"; +import { + collectTraceAddresses, + createTraceContractMap, + type TraceContract, +} from "../../utils/traceAddressCollector"; + +interface Args { + decodedTrace: any; + contractContext: any; + setTraceContracts: (map: Map) => void; +} + +/** + * Resolves verified source code for addresses that appear in the decoded + * trace and publishes the resulting contract map into the simulation context. + * Throttled by an address-set fingerprint so the same batch isn't resolved + * twice while the trace is still in flight. + */ +export function useTraceSourceResolver({ + decodedTrace, + contractContext, + setTraceContracts, +}: Args) { + const contractContextRef = useRef(contractContext); + contractContextRef.current = contractContext; + + const resolvedAddressesRef = useRef>(new Set()); + + useEffect(() => { + const resolveTraceSources = async () => { + const ctx = contractContextRef.current; + if (!decodedTrace?.rows || !ctx) return; + + const txFrom = ctx.fromAddress?.toLowerCase(); + const addresses = collectTraceAddresses(decodedTrace.rows, txFrom); + if (addresses.size === 0) return; + + const addressKey = Array.from(addresses).sort().join(","); + if (resolvedAddressesRef.current.has(addressKey)) return; + resolvedAddressesRef.current.add(addressKey); + + const contractMap = createTraceContractMap(addresses); + const addressList = Array.from(addresses).slice(0, 10); + + try { + const { contractResolver } = await import( + "../../utils/resolver/ContractResolver" + ); + const chainId = ctx.networkId; + const chainName = ctx.networkName; + + await Promise.allSettled( + addressList.map(async (addr) => { + try { + const result = await Promise.race([ + contractResolver.resolve(addr, { + id: chainId, + name: chainName, + } as any), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timeout")), 5000), + ), + ]); + if (result && result.verified) { + const contract = contractMap.get(addr); + if (contract) { + contract.name = result.name || contract.name; + contract.sourceCode = result.metadata?.sourceCode; + contract.verified = true; + contract.sourceProvider = result.source || undefined; + } + } + return result; + } catch { + return null; + } + }), + ); + + setTraceContracts(contractMap); + } catch (err) { + console.warn( + "[SimResultsPage] Failed to resolve trace sources:", + err, + ); + } + }; + + resolveTraceSources(); + }, [decodedTrace, setTraceContracts]); +} diff --git a/src/components/transaction-builder/TransactionReplayView.tsx b/src/components/transaction-builder/TransactionReplayView.tsx index 2a22ad9..5884b52 100644 --- a/src/components/transaction-builder/TransactionReplayView.tsx +++ b/src/components/transaction-builder/TransactionReplayView.tsx @@ -18,7 +18,7 @@ import { cn } from "@/lib/utils"; import type { Chain } from "../../types"; import { replayTransactionWithSimulator, -} from "../../utils/transactionSimulation"; +} from "../../utils/transaction-simulation"; import { useSimulation } from "../../contexts/SimulationContext"; import { useNetworkConfig } from "../../contexts/NetworkConfigContext"; import { classifySimulationError } from "../../utils/errorParser"; diff --git a/src/contexts/DebugContext.tsx b/src/contexts/DebugContext.tsx index a87b0e8..7675798 100644 --- a/src/contexts/DebugContext.tsx +++ b/src/contexts/DebugContext.tsx @@ -10,4 +10,10 @@ * continue to work without changes. */ -export { DebugProvider, useDebug, default } from './debug/DebugProvider'; +export { + DebugProvider, + useDebug, + useDebugSessionContext, + useDebugNavigationContext, + useDebugInspectionContext, +} from './debug/DebugProvider'; diff --git a/src/contexts/NetworkConfigContext.tsx b/src/contexts/NetworkConfigContext.tsx index ee5fc11..b4dcc5e 100644 --- a/src/contexts/NetworkConfigContext.tsx +++ b/src/contexts/NetworkConfigContext.tsx @@ -75,7 +75,6 @@ const readAck = (): boolean => { const writeAck = (): void => { if (typeof window === 'undefined') return; window.localStorage.setItem(DEFAULTS_ACK_KEY, '1'); - window.dispatchEvent(new CustomEvent('rpc-defaults-acknowledged')); }; export const NetworkConfigProvider: React.FC = ({ @@ -107,16 +106,12 @@ export const NetworkConfigProvider: React.FC = ({ } }; - const handleAck = () => setHasAcknowledgedDefaults(true); - window.addEventListener('network-config-updated', handleConfigUpdate); window.addEventListener('storage', handleStorage); - window.addEventListener('rpc-defaults-acknowledged', handleAck); return () => { window.removeEventListener('network-config-updated', handleConfigUpdate); window.removeEventListener('storage', handleStorage); - window.removeEventListener('rpc-defaults-acknowledged', handleAck); }; }, []); diff --git a/src/contexts/SimulationContext.tsx b/src/contexts/SimulationContext.tsx index 9a02a69..68b60a7 100644 --- a/src/contexts/SimulationContext.tsx +++ b/src/contexts/SimulationContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from "react"; +import React, { createContext, useContext, useState, useCallback, useMemo } from "react"; import type { SimulationResult } from "../types/transaction"; import type { TraceContract } from "../utils/traceAddressCollector"; import type { DecodedTraceRow } from "../utils/traceDecoder"; @@ -17,15 +17,8 @@ export interface DecodedTraceMeta { } /** - * Fields that are safe to strip from in-memory rawTrace. - * We preserve snapshots/opcode maps to keep trace decoding deterministic after reloads. - */ -const HEAVY_TRACE_FIELDS = [ - '__rawText', // Raw JSON text stored for gas extraction (entire response as string) -]; - -/** - * Strip only non-essential raw text from a simulation result. + * Strip raw response text from in-memory rawTrace to cut memory use; + * snapshots/opcode maps are preserved for deterministic decoding. */ function stripHeavyTraceDataForRuntime(result: SimulationResult): SimulationResult { if (!result || typeof result !== 'object') return result; @@ -34,13 +27,7 @@ function stripHeavyTraceDataForRuntime(result: SimulationResult): SimulationResu if (stripped.rawTrace && typeof stripped.rawTrace === 'object') { const rawTrace = { ...stripped.rawTrace }; - - for (const field of HEAVY_TRACE_FIELDS) { - if (field in rawTrace) { - delete rawTrace[field]; - } - } - + delete rawTrace.__rawText; stripped.rawTrace = rawTrace; } @@ -116,8 +103,6 @@ const SimulationContext = createContext( undefined ); -const STORAGE_KEY = "web3-toolkit:simulation-state"; - export const SimulationProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { @@ -130,17 +115,6 @@ export const SimulationProvider: React.FC<{ children: React.ReactNode }> = ({ // Decoded trace metadata - persisted separately to survive page refresh const [decodedTraceMeta, setDecodedTraceMetaState] = useState(null); - // Clear old localStorage data on mount to free space - useEffect(() => { - if (typeof window !== "undefined") { - try { - localStorage.removeItem(STORAGE_KEY); - } catch { - // Ignore errors - } - } - }, []); - const setSimulation = useCallback((result: SimulationResult, contractCtx?: SimulationContractContext, options?: SetSimulationOptions) => { let nextResult = result; if (result?.rawTrace && typeof result.rawTrace === 'object') { diff --git a/src/contexts/debug/DebugProvider.tsx b/src/contexts/debug/DebugProvider.tsx index 370dedb..f362221 100644 --- a/src/contexts/debug/DebugProvider.tsx +++ b/src/contexts/debug/DebugProvider.tsx @@ -1,7 +1,10 @@ /** - * Debug Provider Component + * Debug Provider — 3-context split (A3). * - * Composes all debug hooks and provides the unified DebugContext. + * Session state, navigation state, and inspection state are published on + * three distinct contexts so consumers only rerender when the slice they + * actually read changes. `useDebug()` still merges all three for backwards + * compatibility — existing consumers don't need to migrate. */ import React, { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react'; @@ -28,14 +31,77 @@ import { useDebugPrep } from './useDebugPrep'; import type { DebugSharedState } from './types'; import type { DecodedTraceRow } from '../../utils/traceDecoder'; import type { parseFunctions } from '../../utils/traceDecoder/sourceParser'; +import { useLiveRef } from '../../hooks/useLiveRef'; -const DebugContext = createContext(undefined); +type SessionSlice = Pick< + DebugContextValue, + | 'session' + | 'isLoading' + | 'error' + | 'isDebugging' + | 'startSession' + | 'connectToSession' + | 'endSession' + | 'initFromTraceData' + | 'loadSnapshotBatch' + | 'openDebugWindow' + | 'openDebugAtSnapshot' + | 'openDebugAtRevert' + | 'closeDebugWindow' + | 'debugPrepState' + | 'startDebugPrep' + | 'cancelDebugPrep' +>; + +type NavigationSlice = Pick< + DebugContextValue, + | 'totalSnapshots' + | 'currentSnapshotId' + | 'currentSnapshot' + | 'snapshotCache' + | 'snapshotList' + | 'sourceFiles' + | 'currentFile' + | 'currentLine' + | 'currentExecutingAddress' + | 'callStack' + | 'goToSnapshot' + | 'stepNext' + | 'stepPrev' + | 'stepNextCall' + | 'stepPrevCall' + | 'stepUp' + | 'stepOver' + | 'continueToBreakpoint' + | 'setCurrentFile' + | 'setCurrentLine' + | 'setCurrentExecutingAddress' + | 'setEvalHint' +>; + +type InspectionSlice = Pick< + DebugContextValue, + | 'breakpoints' + | 'watchExpressions' + | 'storageDiffs' + | 'addBreakpoint' + | 'removeBreakpoint' + | 'toggleBreakpoint' + | 'updateBreakpointCondition' + | 'evaluateExpression' + | 'addWatchExpression' + | 'removeWatchExpression' + | 'refreshWatchExpressions' +>; + +const DebugSessionContext = createContext(undefined); +const DebugNavigationContext = createContext(undefined); +const DebugInspectionContext = createContext(undefined); export const DebugProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { decodedTraceRows, currentSimulation, contractContext } = useSimulation(); const { resolveRpcUrl } = useNetworkConfig(); - // Compute RPC URL for storage fallback based on current chain const rpcFallbackConfig = useMemo(() => { const chainId = contractContext?.networkId || currentSimulation?.chainId || 1; const chain = getChainById(chainId); @@ -46,21 +112,16 @@ export const DebugProvider: React.FC<{ children: React.ReactNode }> = ({ childre return { rpcUrl, contractAddress, blockTag: String(blockTag) }; }, [contractContext, currentSimulation, resolveRpcUrl]); - // Session state const [session, setSession] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - - // Debug window state const [isDebugging, setIsDebugging] = useState(false); - // Snapshot state const [currentSnapshotId, setCurrentSnapshotId] = useState(null); const [currentSnapshot, setCurrentSnapshot] = useState(null); const [snapshotCache, setSnapshotCache] = useState>(new Map()); const [snapshotList, setSnapshotList] = useState([]); - // Source code state const [sourceFiles, setSourceFiles] = useState>(new Map()); const sourceFilesRef = useRef>(new Map()); const updateSourceFiles = useCallback((files: Map) => { @@ -75,35 +136,22 @@ export const DebugProvider: React.FC<{ children: React.ReactNode }> = ({ childre functionName?: string | null; } | null>(null); - // Current executing contract (for Diamond proxy support) const [currentExecutingAddress, setCurrentExecutingAddress] = useState(null); - // Breakpoints const [breakpoints, setBreakpoints] = useState([]); - const [breakpointHits, setBreakpointHits] = useState>(new Map()); - // Watch expressions const [watchExpressions, setWatchExpressions] = useState([]); - - // Storage const [storageDiffs, setStorageDiffs] = useState([]); - // Session validity const [sessionInvalid, setSessionInvalid] = useState(false); - // Refs for stable callbacks - const sessionRef = useRef(session); - sessionRef.current = session; + const sessionRef = useLiveRef(session); const functionRangesRef = useRef>>(new Map()); - const decodedTraceRowsRef = useRef(null); - const traceRowsRef = useRef([]); - - useEffect(() => { - decodedTraceRowsRef.current = decodedTraceRows; - }, [decodedTraceRows]); - - // Keep traceRowsRef in sync whenever decodedTraceRows changes. - // Clearing stale refs prevents navigation/eval from using rows from a previous session. + const decodedTraceRowsRef = useLiveRef(decodedTraceRows); + // traceRowsRef is overwritten manually by `initFromTraceData` with a heavier + // vault-loaded trace than decodedTraceRows. Only resync on decodedTraceRows + // change so unrelated provider re-renders don't stomp that assignment. + const traceRowsRef = useRef(decodedTraceRows ?? []); useEffect(() => { traceRowsRef.current = decodedTraceRows ?? []; }, [decodedTraceRows]); @@ -141,8 +189,6 @@ export const DebugProvider: React.FC<{ children: React.ReactNode }> = ({ childre setCurrentExecutingAddress, breakpoints, setBreakpoints, - breakpointHits, - setBreakpointHits, watchExpressions, setWatchExpressions, storageDiffs, @@ -168,52 +214,57 @@ export const DebugProvider: React.FC<{ children: React.ReactNode }> = ({ childre [decodedTraceRows, currentSnapshotId] ); - const contextValue: DebugContextValue = useMemo( + const sessionValue = useMemo( () => ({ - // Session state session, isLoading, error, - - // Debug window state isDebugging, + startSession: sessionActions.startSession, + connectToSession: sessionActions.connectToSession, + endSession: sessionActions.endSession, + initFromTraceData: sessionActions.initFromTraceData, + loadSnapshotBatch: sessionActions.loadSnapshotBatch, + openDebugWindow: windowActions.openDebugWindow, + openDebugAtSnapshot: windowActions.openDebugAtSnapshot, + openDebugAtRevert: windowActions.openDebugAtRevert, + closeDebugWindow: windowActions.closeDebugWindow, + debugPrepState: prepActions.debugPrepState, + startDebugPrep: prepActions.startDebugPrep, + cancelDebugPrep: prepActions.cancelDebugPrep, + }), + [ + session, + isLoading, + error, + isDebugging, + sessionActions.startSession, + sessionActions.connectToSession, + sessionActions.endSession, + sessionActions.initFromTraceData, + sessionActions.loadSnapshotBatch, + windowActions.openDebugWindow, + windowActions.openDebugAtSnapshot, + windowActions.openDebugAtRevert, + windowActions.closeDebugWindow, + prepActions.debugPrepState, + prepActions.startDebugPrep, + prepActions.cancelDebugPrep, + ] + ); - // Snapshot navigation + const navigationValue = useMemo( + () => ({ totalSnapshots, currentSnapshotId, currentSnapshot, snapshotCache, snapshotList, - - // Source code sourceFiles, currentFile, currentLine, - - // Current executing contract currentExecutingAddress, - - // Breakpoints - breakpoints, - breakpointHits, - - // Watch expressions - watchExpressions, - - // Call stack callStack, - - // Storage - storageDiffs, - - // Session actions - startSession: sessionActions.startSession, - connectToSession: sessionActions.connectToSession, - endSession: sessionActions.endSession, - initFromTraceData: sessionActions.initFromTraceData, - loadSnapshotBatch: sessionActions.loadSnapshotBatch, - - // Navigation actions goToSnapshot: navigationActions.goToSnapshot, stepNext: navigationActions.stepNext, stepPrev: navigationActions.stepPrev, @@ -222,41 +273,12 @@ export const DebugProvider: React.FC<{ children: React.ReactNode }> = ({ childre stepUp: navigationActions.stepUp, stepOver: navigationActions.stepOver, continueToBreakpoint: navigationActions.continueToBreakpoint, - - // Breakpoint actions - addBreakpoint: breakpointActions.addBreakpoint, - removeBreakpoint: breakpointActions.removeBreakpoint, - toggleBreakpoint: breakpointActions.toggleBreakpoint, - updateBreakpointCondition: breakpointActions.updateBreakpointCondition, - - // Evaluation actions - evaluateExpression: evaluationActions.evaluateExpression, - addWatchExpression: evaluationActions.addWatchExpression, - removeWatchExpression: evaluationActions.removeWatchExpression, - refreshWatchExpressions: evaluationActions.refreshWatchExpressions, - - // Debug window actions - openDebugWindow: windowActions.openDebugWindow, - openDebugAtSnapshot: windowActions.openDebugAtSnapshot, - openDebugAtRevert: windowActions.openDebugAtRevert, - closeDebugWindow: windowActions.closeDebugWindow, - - // Async debug preparation - debugPrepState: prepActions.debugPrepState, - startDebugPrep: prepActions.startDebugPrep, - cancelDebugPrep: prepActions.cancelDebugPrep, - - // Setters setCurrentFile, setCurrentLine, setCurrentExecutingAddress, setEvalHint, }), [ - session, - isLoading, - error, - isDebugging, totalSnapshots, currentSnapshotId, currentSnapshot, @@ -266,16 +288,7 @@ export const DebugProvider: React.FC<{ children: React.ReactNode }> = ({ childre currentFile, currentLine, currentExecutingAddress, - breakpoints, - breakpointHits, - watchExpressions, callStack, - storageDiffs, - sessionActions.startSession, - sessionActions.connectToSession, - sessionActions.endSession, - sessionActions.initFromTraceData, - sessionActions.loadSnapshotBatch, navigationActions.goToSnapshot, navigationActions.stepNext, navigationActions.stepPrev, @@ -284,6 +297,27 @@ export const DebugProvider: React.FC<{ children: React.ReactNode }> = ({ childre navigationActions.stepUp, navigationActions.stepOver, navigationActions.continueToBreakpoint, + ] + ); + + const inspectionValue = useMemo( + () => ({ + breakpoints, + watchExpressions, + storageDiffs, + addBreakpoint: breakpointActions.addBreakpoint, + removeBreakpoint: breakpointActions.removeBreakpoint, + toggleBreakpoint: breakpointActions.toggleBreakpoint, + updateBreakpointCondition: breakpointActions.updateBreakpointCondition, + evaluateExpression: evaluationActions.evaluateExpression, + addWatchExpression: evaluationActions.addWatchExpression, + removeWatchExpression: evaluationActions.removeWatchExpression, + refreshWatchExpressions: evaluationActions.refreshWatchExpressions, + }), + [ + breakpoints, + watchExpressions, + storageDiffs, breakpointActions.addBreakpoint, breakpointActions.removeBreakpoint, breakpointActions.toggleBreakpoint, @@ -292,26 +326,43 @@ export const DebugProvider: React.FC<{ children: React.ReactNode }> = ({ childre evaluationActions.addWatchExpression, evaluationActions.removeWatchExpression, evaluationActions.refreshWatchExpressions, - windowActions.openDebugWindow, - windowActions.openDebugAtSnapshot, - windowActions.openDebugAtRevert, - windowActions.closeDebugWindow, - prepActions.debugPrepState, - prepActions.startDebugPrep, - prepActions.cancelDebugPrep, - setEvalHint, ] ); - return {children}; + return ( + + + + {children} + + + + ); +}; + +export const useDebugSessionContext = (): SessionSlice => { + const value = useContext(DebugSessionContext); + if (!value) throw new Error('useDebugSessionContext must be used within DebugProvider'); + return value; +}; + +export const useDebugNavigationContext = (): NavigationSlice => { + const value = useContext(DebugNavigationContext); + if (!value) throw new Error('useDebugNavigationContext must be used within DebugProvider'); + return value; +}; + +export const useDebugInspectionContext = (): InspectionSlice => { + const value = useContext(DebugInspectionContext); + if (!value) throw new Error('useDebugInspectionContext must be used within DebugProvider'); + return value; }; export const useDebug = (): DebugContextValue => { - const context = useContext(DebugContext); - if (!context) { - throw new Error('useDebug must be used within DebugProvider'); - } - return context; + const sessionSlice = useDebugSessionContext(); + const navSlice = useDebugNavigationContext(); + const inspectionSlice = useDebugInspectionContext(); + return { ...sessionSlice, ...navSlice, ...inspectionSlice }; }; -export default DebugContext; +export default DebugSessionContext; diff --git a/src/contexts/debug/debugHelpers.ts b/src/contexts/debug/debugHelpers.ts index 88cd7d0..2e5c7bb 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,42 +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'; - -export { - getSourceLineText, - deriveStructValueFromTrace, - deriveScalarStateValueFromTrace, - computeDynamicArrayDataSlot, - fillUnreadFieldsFromStorage, - matchesSourceLocation, - findNearestHookSnapshotIdBySource, - findNearestHookSnapshotIdByFunction, -} from './structStorageDecoding'; - // ── Gated debug logger ───────────────────────────────────────────────── const EDB_DEBUG_LOGS = import.meta.env.DEV && typeof localStorage !== 'undefined' && localStorage.getItem('edb:debugLogs') === '1'; @@ -137,6 +100,22 @@ export function createEvalError( }; } +// ── Trace-row → snapshot item ────────────────────────────────────────── + +export function buildSnapshotItem(row: { + id: number; + sourceFile?: unknown; + line?: unknown; + visualDepth?: number; + depth?: number; +}): SnapshotListItem { + return { + id: row.id, + type: (row.sourceFile && row.line) ? 'hook' : 'opcode', + depth: row.visualDepth ?? row.depth ?? 0, + }; +} + // ── Call stack building ──────────────────────────────────────────────── export function buildCallStackFromDecodedRows( diff --git a/src/contexts/debug/evalSnapshotResolver.ts b/src/contexts/debug/evalSnapshotResolver.ts index 164cd80..fb0ac09 100644 --- a/src/contexts/debug/evalSnapshotResolver.ts +++ b/src/contexts/debug/evalSnapshotResolver.ts @@ -23,6 +23,8 @@ import { debugLog, HOOK_SCAN_CHUNK_SIZE, } from './debugHelpers'; +import { setNumericBoundedCacheEntry } from '../../utils/cache/limitedCache'; +import { isTraceSessionId } from './sessionRef'; // ── Constants ────────────────────────────────────────────────────────── @@ -38,27 +40,6 @@ export const EVAL_VARIABLE_HINT_CACHE_MAX = 1024; */ export const EVAL_TOTAL_BUDGET_MS = 12000; -// ── LRU cache helper ────────────────────────────────────────────────── - -export function setLimitedCacheEntry( - cache: Map, - key: string, - value: T, - maxEntries: number -) { - if (cache.has(key)) { - cache.delete(key); - } - cache.set(key, value); - if (cache.size <= maxEntries) { - return; - } - const oldestKey = cache.keys().next().value; - if (oldestKey) { - cache.delete(oldestKey); - } -} - // ── Snapshot ID validation ───────────────────────────────────────────── export function isValidSnapshotId(snapshotId: number | null, totalSnapshots: number): snapshotId is number { @@ -132,11 +113,7 @@ export async function waitForLiveSessionReady( 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)); - } + setNumericBoundedCacheEntry(next, candidateId, resolved, 500); return next; }); return { ready: true, snapshotId: candidateId }; @@ -219,7 +196,11 @@ 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.setSnapshotCache((prev) => { + const next = new Map(prev); + setNumericBoundedCacheEntry(next, snapshotId, resolved, 500); + return next; + }); if (resolved.type !== 'hook') return null; if (!matchesTraceId(resolved.frameId, traceId)) { return null; @@ -314,7 +295,7 @@ export async function resolveEvalSnapshotId( if (deps.sessionInvalid) { return null; } - if (activeSession.sessionId.startsWith('trace-')) { + if (isTraceSessionId(activeSession.sessionId)) { return null; } @@ -373,7 +354,11 @@ 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.setSnapshotCache((prev) => { + const next = new Map(prev); + setNumericBoundedCacheEntry(next, candidateId, resolved, 500); + return next; + }); if (resolved.type === 'hook' && matchesTraceId(resolved.frameId, currentTraceId)) { return candidateId; } diff --git a/src/contexts/debug/sessionRef.ts b/src/contexts/debug/sessionRef.ts new file mode 100644 index 0000000..2814839 --- /dev/null +++ b/src/contexts/debug/sessionRef.ts @@ -0,0 +1,13 @@ +export const TRACE_SESSION_PREFIX = 'trace-'; + +export function isTraceSessionId(id: string): boolean { + return id.startsWith(TRACE_SESSION_PREFIX); +} + +export type DebugSessionRef = + | { kind: 'trace'; id: string } + | { kind: 'live'; id: string }; + +export function parseDebugSessionId(id: string): DebugSessionRef { + return isTraceSessionId(id) ? { kind: 'trace', id } : { kind: 'live', id }; +} diff --git a/src/contexts/debug/solidityStructLayout.ts b/src/contexts/debug/solidityStructLayout.ts index 7749374..929fe12 100644 --- a/src/contexts/debug/solidityStructLayout.ts +++ b/src/contexts/debug/solidityStructLayout.ts @@ -4,8 +4,28 @@ * Source-level analysis for Solidity struct definitions, type parsing, * field layout computation, and scalar value decoding. * Used by struct-based storage decoding in structStorageDecoding.ts. + * + * Parsing is backed by `@solidity-parser/parser`; layout computation and + * decoding follow Solidity's packing rules. */ +import { parse, visit } from '@solidity-parser/parser'; +import type { + SourceUnit, + ContractDefinition, + FunctionDefinition, + StructDefinition, + VariableDeclaration, + VariableDeclarationStatement, + StateVariableDeclaration, + FileLevelConstant, + TypeName, + ArrayTypeName, + Mapping as MappingTypeNode, + UserDefinedTypeName, + ElementaryTypeName, + Expression, +} from '@solidity-parser/parser/dist/src/ast-types'; import type { SourceFile, DebugVariable, @@ -32,60 +52,123 @@ export type StructFieldLayout = { arrayElementSize?: number; }; -// ── Source text helpers ───────────────────────────────────────────────── +// ── AST parse cache ──────────────────────────────────────────────────── + +const astCache = new WeakMap, Map>(); -export function stripSolidityComments(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/\/\/.*$/gm, ''); +function parseFile(content: string): SourceUnit | null { + try { + return parse(content, { tolerant: true, loc: false, range: false }) as SourceUnit; + } catch { + return null; + } } -export function extractBraceBlock(source: string, startIndex: number): string | null { - const openIndex = source.indexOf('{', startIndex); - if (openIndex === -1) return null; - let depth = 0; - for (let i = openIndex; i < source.length; i += 1) { - const char = source[i]; - if (char === '{') depth += 1; - if (char === '}') depth -= 1; - if (depth === 0) { - return source.slice(openIndex + 1, i); +function getParsedFiles(sourceFiles: Map): Map { + const cached = astCache.get(sourceFiles); + if (cached) return cached; + const parsed = new Map(); + for (const [path, file] of sourceFiles.entries()) { + parsed.set(path, parseFile(file.content)); + } + astCache.set(sourceFiles, parsed); + return parsed; +} + +function parseSingleSource(source: string): SourceUnit | null { + return parseFile(source); +} + +// ── TypeName rendering (AST → source-text form) ──────────────────────── + +type ConstantMap = ReadonlyMap; + +function renderTypeName(node: TypeName, constants?: ConstantMap): string { + switch (node.type) { + case 'ElementaryTypeName': + return (node as ElementaryTypeName).name; + case 'UserDefinedTypeName': + return (node as UserDefinedTypeName).namePath; + case 'Mapping': { + const mapping = node as MappingTypeNode; + return `mapping(${renderTypeName(mapping.keyType, constants)} => ${renderTypeName(mapping.valueType, constants)})`; } + case 'ArrayTypeName': { + const array = node as ArrayTypeName; + const lengthStr = renderArrayLength(array.length, constants); + return `${renderTypeName(array.baseTypeName, constants)}[${lengthStr}]`; + } + case 'FunctionTypeName': + return 'function'; + default: + return 'unknown'; } - return null; } -export function extractParenBlock(source: string, startIndex: number): { body: string; endIndex: number } | null { - const openIndex = source.indexOf('(', startIndex); - if (openIndex === -1) return null; - let depth = 0; - for (let i = openIndex; i < source.length; i += 1) { - const char = source[i]; - if (char === '(') depth += 1; - if (char === ')') depth -= 1; - if (depth === 0) { - return { body: source.slice(openIndex + 1, i), endIndex: i }; +function renderArrayLength(expr: Expression | null, constants?: ConstantMap): string { + if (!expr) return ''; + if (expr.type === 'NumberLiteral') return expr.number; + if (expr.type === 'HexLiteral') return expr.value; + if (expr.type === 'Identifier' && constants) { + const resolved = constants.get(expr.name); + if (resolved) return resolved; + } + return ''; +} + +// ── Constant resolution (file-level + contract-level numeric literals) ── + +function literalToNumericString(expr: Expression | null | undefined): string | null { + if (!expr) return null; + if (expr.type === 'NumberLiteral') { + // Normalize to decimal so parseTypeSpec's /\[[0-9]*\]/g regex accepts it; + // NumberLiteral.number can be "0xA", "1_000", "1e2", etc. + const raw = expr.number.replace(/_/g, ''); + try { + const big = BigInt(raw); + return big.toString(10); + } catch { + const n = Number(raw); + return Number.isFinite(n) ? String(n) : null; + } + } + if (expr.type === 'HexLiteral') { + try { + return BigInt(expr.value).toString(10); + } catch { + return null; } } return null; } -export function splitParams(params: string): string[] { - const result: string[] = []; - let depth = 0; - let current = ''; - for (const char of params) { - if (char === '(') depth += 1; - if (char === ')') depth -= 1; - if (char === ',' && depth === 0) { - if (current.trim()) result.push(current.trim()); - current = ''; - } else { - current += char; +function buildConstantsMap( + ast: SourceUnit, + contract: ContractDefinition | null, +): ConstantMap { + const out = new Map(); + + for (const node of ast.children) { + if (node.type !== 'FileLevelConstant') continue; + const fc = node as FileLevelConstant; + const value = literalToNumericString(fc.initialValue); + if (value !== null) out.set(fc.name, value); + } + + if (contract) { + for (const sub of contract.subNodes) { + if (sub.type !== 'StateVariableDeclaration') continue; + const svd = sub as StateVariableDeclaration; + const shared = literalToNumericString(svd.initialValue); + for (const v of svd.variables) { + if (!v.isDeclaredConst || !v.name) continue; + const value = literalToNumericString(v.expression) ?? shared; + if (value !== null) out.set(v.name, value); + } } } - if (current.trim()) result.push(current.trim()); - return result; + + return out; } // ── Variable type extraction from source ─────────────────────────────── @@ -93,42 +176,42 @@ export function splitParams(params: string): string[] { export function findVariableTypeInFunction( source: string, functionName: string, - variableName: string + variableName: string, ): string | null { - const cleaned = stripSolidityComments(source); - const fnRegex = new RegExp(`function\\s+${functionName}\\s*\\(`, 'm'); - const fnMatch = fnRegex.exec(cleaned); - if (!fnMatch) return null; - - // First, check function parameters - const paramsBlock = extractParenBlock(cleaned, fnMatch.index); - if (!paramsBlock) return null; - const params = splitParams(paramsBlock.body); - for (const param of params) { - const tokens = param.split(/\s+/).filter(Boolean); - if (tokens.length < 2) continue; - const name = tokens[tokens.length - 1]; - if (name !== variableName) continue; - const typeTokens = tokens - .slice(0, -1) - .filter((token) => !['storage', 'memory', 'calldata'].includes(token)); - const typeName = typeTokens.join(' '); - return typeName || null; - } - - // If not found in parameters, search in function body for local variable declarations - const fnBody = extractBraceBlock(cleaned, paramsBlock.endIndex); - if (fnBody) { - const localVarPatterns = [ - new RegExp(`([A-Za-z_][A-Za-z0-9_]*)\\s+(?:storage|memory|calldata)\\s+${variableName}\\b`, 'm'), - new RegExp(`([A-Za-z_][A-Za-z0-9_]*)\\s+${variableName}\\s*[=;]`, 'm'), - ]; - - for (const pattern of localVarPatterns) { - const localMatch = pattern.exec(fnBody); - if (localMatch) { - return localMatch[1]; + const ast = parseSingleSource(source); + if (!ast) return null; + + for (const node of ast.children) { + if (node.type !== 'ContractDefinition') continue; + const contract = node as ContractDefinition; + const constants = buildConstantsMap(ast, contract); + for (const sub of contract.subNodes) { + if (sub.type !== 'FunctionDefinition') continue; + const fn = sub as FunctionDefinition; + if (fn.name !== functionName) continue; + + for (const param of fn.parameters) { + if (param.name === variableName && param.typeName) { + return renderTypeName(param.typeName, constants); + } } + + if (!fn.body) continue; + let found: string | null = null; + visit(fn.body, { + VariableDeclarationStatement: (stmt: VariableDeclarationStatement) => { + if (found) return; + for (const decl of stmt.variables) { + if (!decl || decl.type !== 'VariableDeclaration') continue; + const v = decl as VariableDeclaration; + if (v.name === variableName && v.typeName) { + found = renderTypeName(v.typeName, constants); + return; + } + } + }, + }); + if (found) return found; } } @@ -182,26 +265,44 @@ export function getBaseTypeSize(base: string): number | null { export function findStructFields( structName: string, - sourceFiles: Map + sourceFiles: Map, ): StructFieldDef[] | null { - for (const file of sourceFiles.values()) { - const cleaned = stripSolidityComments(file.content); - const structRegex = new RegExp(`struct\\s+${structName}\\s*\\{`, 'm'); - const match = structRegex.exec(cleaned); - if (!match) continue; - const body = extractBraceBlock(cleaned, match.index); - if (!body) continue; - const entries = body - .split(';') - .map((entry) => entry.replace(/\s+/g, ' ').trim()) - .filter(Boolean); + const parsed = getParsedFiles(sourceFiles); + for (const ast of parsed.values()) { + if (!ast) continue; + const found = findStructInUnit(ast, structName); + if (found) return found; + } + return null; +} + +function findStructInUnit(ast: SourceUnit, structName: string): StructFieldDef[] | null { + const extract = ( + node: StructDefinition, + constants: ConstantMap, + ): StructFieldDef[] | null => { const fields: StructFieldDef[] = []; - for (const entry of entries) { - const fieldMatch = entry.match(/^(.+)\s+([A-Za-z_][A-Za-z0-9_]*)$/); - if (!fieldMatch) continue; - fields.push({ type: fieldMatch[1].trim(), name: fieldMatch[2].trim() }); + for (const member of node.members) { + if (!member.typeName || !member.name) continue; + fields.push({ name: member.name, type: renderTypeName(member.typeName, constants) }); } return fields.length > 0 ? fields : null; + }; + + for (const node of ast.children) { + if (node.type === 'StructDefinition' && (node as StructDefinition).name === structName) { + const fields = extract(node as StructDefinition, buildConstantsMap(ast, null)); + if (fields) return fields; + } + if (node.type === 'ContractDefinition') { + const contract = node as ContractDefinition; + for (const sub of contract.subNodes) { + if (sub.type === 'StructDefinition' && (sub as StructDefinition).name === structName) { + const fields = extract(sub as StructDefinition, buildConstantsMap(ast, contract)); + if (fields) return fields; + } + } + } } return null; } diff --git a/src/contexts/debug/structStorageDecoding.ts b/src/contexts/debug/structStorageDecoding.ts index abae322..37a80cb 100644 --- a/src/contexts/debug/structStorageDecoding.ts +++ b/src/contexts/debug/structStorageDecoding.ts @@ -481,7 +481,8 @@ export async function fillUnreadFieldsFromStorage( } const elementType = child.type.replace('[]', ''); - const elementSize = elementType === 'address' ? 1 : 1; + // Dynamic arrays reserve one slot per element when sizeof(T) > 16 bytes. + // Packed small-element arrays (e.g. uint8[]) are not supported here. const arrayChildren: DebugVariable[] = []; const readBatchSize = 8; for (let start = 0; start < maxElements; start += readBatchSize) { @@ -489,7 +490,7 @@ export async function fillUnreadFieldsFromStorage( const indexes = Array.from({ length: end - start }, (_, idx) => start + idx); const chunkValues = await Promise.all( indexes.map(async (j) => { - const elementSlot = dataSlot + BigInt(j * elementSize); + const elementSlot = dataSlot + BigInt(j); const elementValueHex = await debugBridgeService.getStorage( sessionId, snapshotId, diff --git a/src/contexts/debug/types.ts b/src/contexts/debug/types.ts index abfe91b..58240cf 100644 --- a/src/contexts/debug/types.ts +++ b/src/contexts/debug/types.ts @@ -13,7 +13,6 @@ import type { BreakpointLocation, WatchExpression, StorageDiffEntry, - HookSnapshotDetail, EvalResult, StartDebugSessionRequest, DebugSessionConnectOptions, @@ -70,8 +69,6 @@ export interface DebugSharedState { // Breakpoints breakpoints: Breakpoint[]; setBreakpoints: Dispatch>; - breakpointHits: Map; - setBreakpointHits: Dispatch>>; // Watch expressions watchExpressions: WatchExpression[]; @@ -152,14 +149,6 @@ export interface DebugEvaluationActions { addWatchExpression: (expression: string) => void; removeWatchExpression: (id: string) => void; refreshWatchExpressions: () => Promise; - resolveEvalSnapshotId: () => Promise; - scanForHookSnapshot: ( - sessionId: string, - baseSnapshotId: number, - traceId: number | null, - maxOffset: number, - predicate?: (detail: HookSnapshotDetail) => boolean - ) => Promise<{ snapshotId: number; detail: HookSnapshotDetail } | null>; } /** diff --git a/src/contexts/debug/useDebugEvaluation.ts b/src/contexts/debug/useDebugEvaluation.ts index 4e7e61b..c28e6c0 100644 --- a/src/contexts/debug/useDebugEvaluation.ts +++ b/src/contexts/debug/useDebugEvaluation.ts @@ -39,7 +39,6 @@ import { } from './structStorageDecoding'; import { isValidSnapshotId, - setLimitedCacheEntry, waitForLiveSessionReady, scanForHookSnapshot, resolveEvalSnapshotId, @@ -47,6 +46,8 @@ import { EVAL_VARIABLE_HINT_CACHE_MAX, EVAL_TOTAL_BUDGET_MS, } from './evalSnapshotResolver'; +import { setLimitedCacheEntry, setNumericBoundedCacheEntry } from '../../utils/cache/limitedCache'; +import { isTraceSessionId } from './sessionRef'; import type { DebugSharedState, DebugEvaluationActions } from './types'; const NO_HOOK_SNAPSHOTS_ERROR = @@ -344,11 +345,7 @@ export function useDebugEvaluation(state: DebugSharedState): DebugEvaluationActi 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)); - } + setNumericBoundedCacheEntry(next, bestMatch!.snapshotId, bestMatch!.snapshot, 500); return next; }); @@ -372,7 +369,7 @@ export function useDebugEvaluation(state: DebugSharedState): DebugEvaluationActi }; } const totalSnapshots = activeSession.totalSnapshots ?? session?.totalSnapshots ?? 0; - const isTraceSession = activeSession.sessionId.startsWith('trace-'); + const isTraceSession = isTraceSessionId(activeSession.sessionId); const traceRows = decodedTraceRowsRef.current; const traceSnapshotExists = (snapshotId: number | null | undefined): snapshotId is number => typeof snapshotId === 'number' && @@ -1076,7 +1073,5 @@ export function useDebugEvaluation(state: DebugSharedState): DebugEvaluationActi addWatchExpression, removeWatchExpression, refreshWatchExpressions, - resolveEvalSnapshotId: resolveEvalSnapshotIdCb, - scanForHookSnapshot: scanForHookSnapshotCb, }; } diff --git a/src/contexts/debug/useDebugNavigation.ts b/src/contexts/debug/useDebugNavigation.ts index 9c435ec..d77de74 100644 --- a/src/contexts/debug/useDebugNavigation.ts +++ b/src/contexts/debug/useDebugNavigation.ts @@ -9,6 +9,7 @@ import type { SnapshotListItem } from '../../types/debug'; import { debugBridgeService } from '../../services/DebugBridgeService'; import type { DebugSharedState } from './types'; import type { DebugSessionActions } from './types'; +import { buildSnapshotItem } from './debugHelpers'; export function useDebugNavigation( state: DebugSharedState, @@ -58,12 +59,7 @@ export function useDebugNavigation( if (traceRows && traceRows.length > 0) { const traceRow = traceRows.find((r: any) => r.id === snapshotId); if (traceRow) { - const snapshotItem = { - id: traceRow.id, - type: (traceRow.sourceFile && traceRow.line) ? 'hook' as const : 'opcode' as const, - depth: traceRow.visualDepth ?? traceRow.depth ?? 0, - }; - goToSnapshotFromTrace(snapshotItem, traceRows); + goToSnapshotFromTrace(buildSnapshotItem(traceRow), traceRows); return; } } @@ -82,12 +78,7 @@ export function useDebugNavigation( const currentIndex = traceRows.findIndex((r: any) => r.id === currentSnapshotId); if (currentIndex >= 0 && currentIndex < traceRows.length - 1) { const nextRow = traceRows[currentIndex + 1]; - const snapshotItem = { - id: nextRow.id, - type: (nextRow.sourceFile && nextRow.line) ? 'hook' as const : 'opcode' as const, - depth: nextRow.visualDepth ?? nextRow.depth ?? 0, - }; - goToSnapshotFromTrace(snapshotItem, traceRows); + goToSnapshotFromTrace(buildSnapshotItem(nextRow), traceRows); return; } // Desync: traceRows non-empty but currentSnapshotId not found — fall through @@ -118,12 +109,7 @@ export function useDebugNavigation( const currentIndex = traceRows.findIndex((r: any) => r.id === currentSnapshotId); if (currentIndex > 0) { const prevRow = traceRows[currentIndex - 1]; - const snapshotItem = { - id: prevRow.id, - type: (prevRow.sourceFile && prevRow.line) ? 'hook' as const : 'opcode' as const, - depth: prevRow.visualDepth ?? prevRow.depth ?? 0, - }; - goToSnapshotFromTrace(snapshotItem, traceRows); + goToSnapshotFromTrace(buildSnapshotItem(prevRow), traceRows); return; } // Desync: traceRows non-empty but currentSnapshotId not found — fall through @@ -236,12 +222,7 @@ export function useDebugNavigation( const navigateTo = async (row: any) => { if (useTraceRows) { - const snapshotItem = { - id: row.id, - type: (row.sourceFile && row.line) ? 'hook' as const : 'opcode' as const, - depth: row.visualDepth ?? row.depth ?? 0, - }; - goToSnapshotFromTrace(snapshotItem, traceRows); + goToSnapshotFromTrace(buildSnapshotItem(row), traceRows); } else { // Desync fallback: we're using snapshotList because traceRows didn't contain // the current snapshot. Check if the TARGET is in traceRowsRef before calling @@ -249,12 +230,7 @@ export function useDebugNavigation( const currentTraceRows = traceRowsRef.current; const targetInTraceRows = currentTraceRows?.find((r: any) => r.id === row.id); if (targetInTraceRows) { - const snapshotItem = { - id: row.id, - type: (targetInTraceRows.sourceFile && targetInTraceRows.line) ? 'hook' as const : 'opcode' as const, - depth: targetInTraceRows.visualDepth ?? targetInTraceRows.depth ?? 0, - }; - goToSnapshotFromTrace(snapshotItem, currentTraceRows); + goToSnapshotFromTrace(buildSnapshotItem(targetInTraceRows), currentTraceRows); } else if (session && row.id >= 0) { // Target not in traceRows either — use bridge API directly // Guard: only for positive IDs (bridge APIs don't accept negative synthetic IDs) @@ -365,24 +341,14 @@ export function useDebugNavigation( const navigateTo = async (row: any) => { if (useTraceRows) { - const snapshotItem = { - id: row.id, - type: (row.sourceFile && row.line) ? 'hook' as const : 'opcode' as const, - depth: row.visualDepth ?? row.depth ?? 0, - }; - goToSnapshotFromTrace(snapshotItem, traceRows); + goToSnapshotFromTrace(buildSnapshotItem(row), traceRows); } else { // Desync fallback: check if target exists in traceRows before calling // goToSnapshotFromTrace (which no-ops if id not found). const currentTraceRows = traceRowsRef.current; const targetInTraceRows = currentTraceRows?.find((r: any) => r.id === row.id); if (targetInTraceRows) { - const snapshotItem = { - id: row.id, - type: (targetInTraceRows.sourceFile && targetInTraceRows.line) ? 'hook' as const : 'opcode' as const, - depth: targetInTraceRows.visualDepth ?? targetInTraceRows.depth ?? 0, - }; - goToSnapshotFromTrace(snapshotItem, currentTraceRows); + goToSnapshotFromTrace(buildSnapshotItem(targetInTraceRows), currentTraceRows); } else if (session && row.id >= 0) { await goToSnapshotInternal(session.sessionId, row.id); } diff --git a/src/contexts/debug/useDebugSession.ts b/src/contexts/debug/useDebugSession.ts index 86d5dd8..24d5242 100644 --- a/src/contexts/debug/useDebugSession.ts +++ b/src/contexts/debug/useDebugSession.ts @@ -25,6 +25,8 @@ import { isSessionNotFoundError, debugLog, } from './debugHelpers'; +import { setNumericBoundedCacheEntry } from '../../utils/cache/limitedCache'; +import { isTraceSessionId } from './sessionRef'; import type { DebugSharedState, DebugSessionActions } from './types'; const INITIAL_SNAPSHOT_PREFETCH_COUNT = 20; @@ -87,7 +89,6 @@ export function useDebugSession(state: DebugSharedState): DebugSessionActions { setCurrentLine, setEvalHint, setBreakpoints, - setBreakpointHits, setWatchExpressions, setStorageDiffs, traceRowsRef, @@ -154,7 +155,11 @@ 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 => { + const next = new Map(prev); + setNumericBoundedCacheEntry(next, row.id, snapshot, 500); + return next; + }); }, []); const goToSnapshotInternal = useCallback(async ( @@ -179,7 +184,11 @@ 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 => { + const next = new Map(prev); + setNumericBoundedCacheEntry(next, snapshotId, snapshot!, 500); + return next; + }); } const resolvedSnapshot = snapshot ? enhanceHookSnapshot(snapshot, sourceFilesRef.current) : snapshot; @@ -187,7 +196,11 @@ 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 => { + const next = new Map(prev); + setNumericBoundedCacheEntry(next, snapshotId, resolvedSnapshot, 500); + return next; + }); } // Update source location if hook snapshot @@ -316,7 +329,6 @@ export function useDebugSession(state: DebugSharedState): DebugSessionActions { updateSourceFiles(files); setSnapshotCache(new Map()); setSnapshotList([]); - setBreakpointHits(new Map()); const firstFile = Array.from(files.keys())[0]; if (firstFile) { @@ -394,7 +406,6 @@ export function useDebugSession(state: DebugSharedState): DebugSessionActions { updateSourceFiles(files); setSnapshotCache(new Map()); setSnapshotList([]); - setBreakpointHits(new Map()); const firstFile = Array.from(files.keys())[0]; if (firstFile) { @@ -455,7 +466,6 @@ export function useDebugSession(state: DebugSharedState): DebugSessionActions { setCurrentLine(null); setEvalHint(null); setBreakpoints([]); - setBreakpointHits(new Map()); setWatchExpressions([]); setStorageDiffs([]); setError(null); @@ -531,11 +541,11 @@ export function useDebugSession(state: DebugSharedState): DebugSessionActions { startedAt: Date.now(), }; + sessionRef.current = newSession; setSession(newSession); updateSourceFiles(files); setSnapshotList(snapshots); setSnapshotCache(new Map()); - setBreakpointHits(new Map()); setError(null); const firstFile = Array.from(files.keys())[0]; @@ -551,14 +561,14 @@ export function useDebugSession(state: DebugSharedState): DebugSessionActions { }, []); const isTraceBasedSession = useCallback(() => { - return session?.sessionId.startsWith('trace-') ?? false; + return session ? isTraceSessionId(session.sessionId) : false; }, [session]); const loadSnapshotBatch = useCallback( async (startId: number, count: number) => { if (!session) return; - if (session.sessionId.startsWith('trace-')) { + if (isTraceSessionId(session.sessionId)) { return; } diff --git a/src/contexts/debug/useDebugWindow.ts b/src/contexts/debug/useDebugWindow.ts index dba27bf..e1353bd 100644 --- a/src/contexts/debug/useDebugWindow.ts +++ b/src/contexts/debug/useDebugWindow.ts @@ -7,6 +7,7 @@ import { useCallback, useRef } from 'react'; import { debugBridgeService } from '../../services/DebugBridgeService'; +import { isTraceSessionId } from './sessionRef'; import type { DebugSharedState, DebugWindowActions, DebugSessionActions } from './types'; export function useDebugWindow( @@ -31,9 +32,9 @@ export function useDebugWindow( const openDebugWindow = useCallback(() => { setIsDebugging(true); if (session && currentSnapshotId === null && session.totalSnapshots > 0) { - if (session.sessionId.startsWith('trace-') && snapshotList.length > 0) { + if (isTraceSessionId(session.sessionId) && snapshotList.length > 0) { goToSnapshotFromTrace(snapshotList[0], traceRowsRef.current); - } else if (!session.sessionId.startsWith('trace-')) { + } else if (!isTraceSessionId(session.sessionId)) { goToSnapshotInternal(session.sessionId, 0); } } @@ -43,7 +44,7 @@ export function useDebugWindow( setIsDebugging(true); if (!session) return; - if (session.sessionId.startsWith('trace-')) { + if (isTraceSessionId(session.sessionId)) { const snapshotItem = snapshotList.find(s => s.id === snapshotId); if (snapshotItem) { goToSnapshotFromTrace(snapshotItem, traceRowsRef.current); @@ -71,7 +72,7 @@ export function useDebugWindow( setIsDebugging(true); if (!session) return; - const isTraceBased = session.sessionId.startsWith('trace-'); + const isTraceBased = isTraceSessionId(session.sessionId); // Search the snapshot list we already have in memory let revertSnapshotId: number | null = null; @@ -83,23 +84,31 @@ export function useDebugWindow( } } - // If not found and more snapshots exist, fetch directly from the bridge - // (avoids stale closure issue with loadSnapshotBatch + scanning snapshotList) + // If not found, scan the bridge backwards in batches until REVERT is located + // or the cap is reached. REVERT is usually near the end but can be buried under + // post-revert cleanup opcodes on large sessions. if (!isTraceBased && revertSnapshotId === null) { - // Fetch last 100 snapshots where REVERT is most likely to be - const startId = Math.max(0, session.totalSnapshots - 100); - const response = await debugBridgeService.getSnapshotBatch({ - sessionId: session.sessionId, - startId, - count: Math.min(100, session.totalSnapshots), - }); - - for (let i = response.snapshots.length - 1; i >= 0; i--) { - const snap = response.snapshots[i]; - if (snap.type === 'opcode' && snap.opcodeName === 'REVERT') { - revertSnapshotId = snap.id; - break; + const BATCH = 200; + const MAX_SCAN = Math.min(session.totalSnapshots, 2000); + let scanned = 0; + while (revertSnapshotId === null && scanned < MAX_SCAN) { + const startId = Math.max(0, session.totalSnapshots - scanned - BATCH); + const count = Math.min(BATCH, session.totalSnapshots - startId); + if (count <= 0) break; + const response = await debugBridgeService.getSnapshotBatch({ + sessionId: session.sessionId, + startId, + count, + }); + for (let i = response.snapshots.length - 1; i >= 0; i--) { + const snap = response.snapshots[i]; + if (snap.type === 'opcode' && snap.opcodeName === 'REVERT') { + revertSnapshotId = snap.id; + break; + } } + if (startId === 0) break; + scanned += BATCH; } } diff --git a/src/hooks/useBreakpoint.ts b/src/hooks/useBreakpoint.ts index 060cb1c..5d54ea4 100644 --- a/src/hooks/useBreakpoint.ts +++ b/src/hooks/useBreakpoint.ts @@ -31,25 +31,12 @@ export function useBreakpoint(): BreakpointResult { ); useEffect(() => { - const queries = BREAKPOINTS.map((bp) => ({ - name: bp.name, - mql: window.matchMedia(`(min-width: ${bp.minWidth}px)`), - })); - const handleChange = () => { setWidth(window.innerWidth); }; - queries.forEach(({ mql }) => { - mql.addEventListener("change", handleChange); - }); - window.addEventListener("resize", handleChange); - return () => { - queries.forEach(({ mql }) => { - mql.removeEventListener("change", handleChange); - }); window.removeEventListener("resize", handleChange); }; }, []); diff --git a/src/hooks/useContractInputs.ts b/src/hooks/useContractInputs.ts index c156ae2..cc329b5 100644 --- a/src/hooks/useContractInputs.ts +++ b/src/hooks/useContractInputs.ts @@ -1,6 +1,8 @@ import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { ethers } from 'ethers'; import { getDefaultValue } from '../components/ContractInputComponent'; import type { ABIInput } from '../components/ContractInputComponent'; +import { useLiveRef } from './useLiveRef'; interface InputState { value: any; @@ -65,74 +67,47 @@ export function useContractInputs({ inputs, onValuesChange, onCalldataGenerated, } }, [selectedFunction?.name, inputs, applyTrigger]); - // Store callbacks in refs to avoid dependency issues - const onValuesChangeRef = useRef(onValuesChange); - const onCalldataGeneratedRef = useRef(onCalldataGenerated); - const selectedFunctionRef = useRef(selectedFunction); - const inputsRef = useRef(inputs); + const onValuesChangeRef = useLiveRef(onValuesChange); + const onCalldataGeneratedRef = useLiveRef(onCalldataGenerated); + const inputsRef = useLiveRef(inputs); - // Keep refs updated - useEffect(() => { - onValuesChangeRef.current = onValuesChange; - onCalldataGeneratedRef.current = onCalldataGenerated; - selectedFunctionRef.current = selectedFunction; - inputsRef.current = inputs; - }); + const iface = useMemo( + () => (selectedFunction ? new ethers.utils.Interface([selectedFunction]) : null), + [selectedFunction] + ); const handleInputChange = useCallback((inputName: string, value: any, isValid: boolean) => { - setInputStates(prev => { - const newStates = { - ...prev, - [inputName]: { value, isValid } - }; - - // Extract current values and validity - const currentValues: Record = {}; - let allValid = true; - - Object.entries(newStates).forEach(([name, state]) => { - currentValues[name] = state.value; - if (!state.isValid) { - allValid = false; - } - }); + setInputStates(prev => ({ + ...prev, + [inputName]: { value, isValid } + })); + }, []); - // Schedule callback outside of state setter to avoid issues - setTimeout(() => { - // Notify parent of changes - if (onValuesChangeRef.current) { - onValuesChangeRef.current(currentValues, allValid); - } + useEffect(() => { + const currentValues: Record = {}; + let allValid = true; + for (const [name, state] of Object.entries(inputStates)) { + currentValues[name] = state.value; + if (!state.isValid) allValid = false; + } - // Generate calldata if function is available - const func = selectedFunctionRef.current; - const currentInputs = inputsRef.current; - if (onCalldataGeneratedRef.current && func && allValid) { - try { - import('ethers').then(({ ethers }) => { - const formattedArgs = currentInputs.map(input => { - const state = newStates[input.name]; - if (!state) return formatValueForContract(getDefaultValue(input.type), input.type); - return formatValueForContract(state.value, input.type); - }); - - const iface = new ethers.utils.Interface([func]); - const calldata = iface.encodeFunctionData(func.name, formattedArgs); - onCalldataGeneratedRef.current?.(calldata); - }).catch(error => { - console.error('Failed to generate calldata:', error); - onCalldataGeneratedRef.current?.("0x"); - }); - } catch (error) { - console.error('Failed to generate calldata:', error); - onCalldataGeneratedRef.current?.("0x"); - } - } - }, 0); + onValuesChangeRef.current?.(currentValues, allValid); - return newStates; - }); - }, []); + if (iface && selectedFunction && allValid && onCalldataGeneratedRef.current) { + try { + const formattedArgs = inputsRef.current.map(input => { + const state = inputStates[input.name]; + if (!state) return formatValueForContract(getDefaultValue(input.type), input.type); + return formatValueForContract(state.value, input.type); + }); + const calldata = iface.encodeFunctionData(selectedFunction.name, formattedArgs); + onCalldataGeneratedRef.current(calldata); + } catch (error) { + console.error('Failed to generate calldata:', error); + onCalldataGeneratedRef.current('0x'); + } + } + }, [inputStates, iface, selectedFunction]); const getCurrentValues = useCallback((): Record => { const values: Record = {}; @@ -225,8 +200,17 @@ function formatValueForContract(value: any, type: string): any { const result = value.map(item => formatValueForContract(item, baseType)); return result; } else if (type.includes('uint') || type.includes('int')) { - const num = typeof value === 'number' ? value : parseInt(value.toString(), 10); - return isNaN(num) ? 0 : num; + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error(`Invalid integer for ${type}: ${value}`); + } + return value; + } + const str = value.toString().trim(); + if (!/^-?\d+$/.test(str)) { + throw new Error(`Invalid integer for ${type}: "${str}"`); + } + return parseInt(str, 10); } else if (type === 'bool') { if (typeof value === 'boolean') return value; return value === 'true' || value === true; diff --git a/src/hooks/useDecodedTrace.ts b/src/hooks/useDecodedTrace.ts index d4d0310..6f58b98 100644 --- a/src/hooks/useDecodedTrace.ts +++ b/src/hooks/useDecodedTrace.ts @@ -8,6 +8,7 @@ import { getCachedRawTraceText, clearCachedRawTraceText, } from "../utils/traceRawTextCache"; +import { setLimitedCacheEntry } from "../utils/cache/limitedCache"; type DecodedTrace = ReturnType; @@ -31,16 +32,28 @@ interface UseDecodedTraceResult { const MAX_DECODE_CACHE = 3; const updateCache = (cache: Map, key: string, value: DecodedTrace) => { - if (cache.has(key)) { - cache.delete(key); - } - cache.set(key, value); - if (cache.size > MAX_DECODE_CACHE) { - const firstKey = cache.keys().next().value; - if (firstKey) { - cache.delete(firstKey); - } - } + setLimitedCacheEntry(cache, key, value, MAX_DECODE_CACHE); +}; + +type TraceDecodeWorkerMessage = { + id: string; + decoded?: DecodedTrace; + error?: string; +}; + +interface CreateTraceDecodeWorkerOptions { + onMessage: (event: MessageEvent) => void; +} + +const createTraceDecodeWorker = ({ + onMessage, +}: CreateTraceDecodeWorkerOptions): Worker => { + const worker = new Worker( + new URL("../workers/traceDecoderWorker.ts", import.meta.url), + { type: "module" } + ); + worker.addEventListener("message", onMessage); + return worker; }; const parseJson = (rawText: string) => { @@ -111,15 +124,13 @@ export const useDecodedTrace = ({ onDecodedRef.current = onDecoded; decodeModeRef.current = decodeMode; + const handleWorkerMessageRef = useRef< + ((event: MessageEvent) => void) | null + >(null); + useEffect(() => { if (typeof Worker === "undefined") return; - const worker = new Worker( - new URL("../workers/traceDecoderWorker.ts", import.meta.url), - { type: "module" } - ); - workerRef.current = worker; - const clearDecodeTimeout = () => { if (decodeTimeoutRef.current !== null) { window.clearTimeout(decodeTimeoutRef.current); @@ -127,7 +138,7 @@ export const useDecodedTrace = ({ } }; - const handleMessage = (event: MessageEvent<{ id: string; decoded?: DecodedTrace; error?: string }>) => { + const handleMessage = (event: MessageEvent) => { if (pendingRequestRef.current !== event.data.id) { return; } @@ -163,15 +174,20 @@ export const useDecodedTrace = ({ setIsDecoding(false); }; - worker.addEventListener("message", handleMessage); + handleWorkerMessageRef.current = handleMessage; + + const worker = createTraceDecodeWorker({ onMessage: handleMessage }); + workerRef.current = worker; return () => { clearDecodeTimeout(); - worker.removeEventListener("message", handleMessage); - worker.terminate(); - if (workerRef.current === worker) { - workerRef.current = null; + const currentWorker = workerRef.current; + if (currentWorker) { + currentWorker.removeEventListener("message", handleMessage); + currentWorker.terminate(); } + workerRef.current = null; + handleWorkerMessageRef.current = null; }; }, []); @@ -382,10 +398,10 @@ export const useDecodedTrace = ({ workerRef.current = null; decodeOnMain(); try { - workerRef.current = new Worker( - new URL("../workers/traceDecoderWorker.ts", import.meta.url), - { type: "module" } - ); + const handler = handleWorkerMessageRef.current; + if (handler) { + workerRef.current = createTraceDecodeWorker({ onMessage: handler }); + } } catch {} }, 15000); diff --git a/src/hooks/useLiveRef.ts b/src/hooks/useLiveRef.ts new file mode 100644 index 0000000..016956e --- /dev/null +++ b/src/hooks/useLiveRef.ts @@ -0,0 +1,10 @@ +import { useEffect, useRef, type MutableRefObject } from 'react'; + +// Ref that tracks `value` post-commit. Read inside async/callbacks, not during render. +export function useLiveRef(value: T): MutableRefObject { + const ref = useRef(value); + useEffect(() => { + ref.current = value; + }); + return ref; +} diff --git a/src/hooks/useNativeTokenPrice.ts b/src/hooks/useNativeTokenPrice.ts index 0ffd947..042f97d 100644 --- a/src/hooks/useNativeTokenPrice.ts +++ b/src/hooks/useNativeTokenPrice.ts @@ -6,25 +6,13 @@ */ import { useState, useEffect, useRef } from 'react'; - -// Chain ID → DeFiLlama identifier for the native wrapped token -const NATIVE_TOKEN_ID: Record = { - 1: 'coingecko:ethereum', - 10: 'coingecko:ethereum', // Optimism uses ETH - 56: 'coingecko:binancecoin', - 137: 'coingecko:matic-network', - 8453: 'coingecko:ethereum', // Base uses ETH - 42161: 'coingecko:ethereum', // Arbitrum uses ETH - 43114: 'coingecko:avalanche-2', - 534352: 'coingecko:ethereum', // Scroll uses ETH - 324: 'coingecko:ethereum', // zkSync uses ETH -}; +import { NATIVE_COINGECKO_ID } from '../utils/priceRegistry'; const CACHE_TTL = 10 * 60 * 1000; // 10 minutes const priceCache = new Map(); async function fetchNativePrice(chainId: number): Promise { - const coinId = NATIVE_TOKEN_ID[chainId] ?? 'coingecko:ethereum'; + const coinId = NATIVE_COINGECKO_ID[chainId] ?? 'coingecko:ethereum'; const cached = priceCache.get(coinId); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) { @@ -65,7 +53,7 @@ export interface NativeTokenPrice { */ export function useNativeTokenPrice(chainId: number = 1): NativeTokenPrice { const [price, setPrice] = useState(() => { - const coinId = NATIVE_TOKEN_ID[chainId] ?? 'coingecko:ethereum'; + const coinId = NATIVE_COINGECKO_ID[chainId] ?? 'coingecko:ethereum'; const cached = priceCache.get(coinId); return cached && Date.now() - cached.fetchedAt < CACHE_TTL ? cached.price : null; }); diff --git a/src/hooks/useUniversalSearch.ts b/src/hooks/useUniversalSearch.ts index 0294739..7214c15 100644 --- a/src/hooks/useUniversalSearch.ts +++ b/src/hooks/useUniversalSearch.ts @@ -225,12 +225,8 @@ export function useUniversalSearch(): UseUniversalSearchReturn { ); const persistTxReplayIntent = useCallback((txHash: string, noAutoReplay = false) => { - const networkId = 1; - const replayData = { + const replayData: Record = { transactionHash: txHash, - networkId, - chainId: networkId, - networkName: `Chain ${networkId}`, timestamp: Date.now(), noAutoReplay, source: 'universal-search', diff --git a/src/services/DebugBridgeService.ts b/src/services/DebugBridgeService.ts index b5849af..3b7c489 100644 --- a/src/services/DebugBridgeService.ts +++ b/src/services/DebugBridgeService.ts @@ -34,6 +34,7 @@ import type { import { networkConfigManager } from '../config/networkConfig'; import { getSimulatorBridgeUrl, getBridgeHeaders } from '../utils/env'; import { extractInlineArtifacts } from '../utils/debugArtifacts'; +import { setLimitedCacheEntry } from '../utils/cache/limitedCache'; import { transformEdbSnapshot, toSolValue, @@ -132,17 +133,7 @@ class DebugBridgeService { private storageValueCache = new Map(); private putStorageCache(cacheKey: string, value: string): void { - if (this.storageValueCache.has(cacheKey)) { - this.storageValueCache.delete(cacheKey); - } - this.storageValueCache.set(cacheKey, value); - if (this.storageValueCache.size <= STORAGE_CACHE_MAX_ENTRIES) { - return; - } - const oldestKey = this.storageValueCache.keys().next().value; - if (oldestKey) { - this.storageValueCache.delete(oldestKey); - } + setLimitedCacheEntry(this.storageValueCache, cacheKey, value, STORAGE_CACHE_MAX_ENTRIES); } private clearStorageCacheForSession(sessionId: string): void { @@ -422,10 +413,6 @@ class DebugBridgeService { ); } - if (!sessionId) { - throw new Error('Failed to start debug session'); - } - let trace: { entries?: TraceEntry[]; rootId?: number } | null = null; let resolvedSnapshotCount = snapshotCount; if (includeTrace) { diff --git a/src/services/SimulationHistoryService.ts b/src/services/SimulationHistoryService.ts index 3ebe96b..1788e95 100644 --- a/src/services/SimulationHistoryService.ts +++ b/src/services/SimulationHistoryService.ts @@ -49,15 +49,6 @@ function isSensitiveKey(key: string): boolean { return SENSITIVE_KEY_SET.has(normalized); } -/** - * Large fields that should NOT be stored to prevent memory bloat - * These are stripped from rawTrace before storage - * NOTE: snapshots are intentionally preserved for trace fidelity across reloads. - */ -const HEAVY_TRACE_FIELDS = [ - '__rawText', // Raw JSON text stored for gas extraction -]; - /** * Recursively remove sensitive fields from an object */ @@ -93,17 +84,11 @@ function stripHeavyTraceData(result: any): any { const stripped = { ...result }; - // Strip heavy fields from rawTrace + // Strip raw JSON text (kept only for gas extraction at decode time). + // Snapshots/opcodes/source maps stay for deterministic decoding on reload. if (stripped.rawTrace && typeof stripped.rawTrace === 'object') { const rawTrace = { ...stripped.rawTrace }; - - // Remove heavy fields - for (const field of HEAVY_TRACE_FIELDS) { - if (field in rawTrace) { - delete rawTrace[field]; - } - } - + delete rawTrace.__rawText; stripped.rawTrace = rawTrace; } diff --git a/src/types/contractInfo.ts b/src/types/contractInfo.ts index ef159b3..2c8ddc7 100644 --- a/src/types/contractInfo.ts +++ b/src/types/contractInfo.ts @@ -8,7 +8,7 @@ export interface ContractInfoResult { chain: Chain; contractName?: string; abi?: string; - source?: 'sourcify' | 'blockscout' | 'etherscan' | 'blockscout-bytecode' | 'blockscout-ebd' | 'whatsabi'; + source?: 'sourcify' | 'blockscout' | 'etherscan' | 'blockscout-bytecode' | 'whatsabi'; explorerName?: string; verified?: boolean; // Optional tokenType for legacy UI; current detection happens elsewhere diff --git a/src/types/debug.ts b/src/types/debug.ts index 68e3d75..c6871bb 100644 --- a/src/types/debug.ts +++ b/src/types/debug.ts @@ -493,7 +493,6 @@ export interface DebugContextValue { // Breakpoints breakpoints: Breakpoint[]; - breakpointHits: Map; // Watch expressions watchExpressions: WatchExpression[]; diff --git a/src/utils/asyncCache.ts b/src/utils/asyncCache.ts new file mode 100644 index 0000000..8f36f2c --- /dev/null +++ b/src/utils/asyncCache.ts @@ -0,0 +1,76 @@ +export interface AsyncCache { + get(key: K): Promise; + peek(key: K): V | undefined; + invalidate(key: K): void; + clear(): void; +} + +export interface AsyncCacheOptions { + loader: (key: K) => Promise; + maxEntries?: number; + ttlMs?: number; + serialize?: (key: K) => string; +} + +interface Entry { + value: V; + expiresAt: number; +} + +// LRU + optional TTL cache that dedupes concurrent loads for the same key. +export function createAsyncCache(options: AsyncCacheOptions): AsyncCache { + const { loader, maxEntries = 256, ttlMs, serialize } = options; + const keyOf = serialize ?? ((k: K) => (typeof k === 'string' ? k : JSON.stringify(k))); + const entries = new Map>(); + const inflight = new Map>(); + + const touch = (k: string, entry: Entry) => { + entries.delete(k); + entries.set(k, entry); + if (entries.size > maxEntries) { + const oldest = entries.keys().next().value; + if (oldest !== undefined) entries.delete(oldest); + } + }; + + const readFresh = (k: string): V | undefined => { + const e = entries.get(k); + if (!e) return undefined; + if (ttlMs !== undefined && Date.now() > e.expiresAt) { + entries.delete(k); + return undefined; + } + touch(k, e); + return e.value; + }; + + return { + async get(key: K) { + const k = keyOf(key); + const cached = readFresh(k); + if (cached !== undefined) return cached; + const existing = inflight.get(k); + if (existing) return existing; + const promise = loader(key) + .then((value) => { + const expiresAt = ttlMs !== undefined ? Date.now() + ttlMs : Number.POSITIVE_INFINITY; + touch(k, { value, expiresAt }); + return value; + }) + .finally(() => { + inflight.delete(k); + }); + inflight.set(k, promise); + return promise; + }, + peek(key: K) { + return readFresh(keyOf(key)); + }, + invalidate(key: K) { + entries.delete(keyOf(key)); + }, + clear() { + entries.clear(); + }, + }; +} diff --git a/src/utils/cache/limitedCache.ts b/src/utils/cache/limitedCache.ts new file mode 100644 index 0000000..9c6152c --- /dev/null +++ b/src/utils/cache/limitedCache.ts @@ -0,0 +1,37 @@ +// LRU set: touching a key promotes it, oldest entry evicted when over maxSize. +export function setLimitedCacheEntry( + cache: Map, + key: K, + value: V, + maxSize: number, +): void { + if (cache.has(key)) { + cache.delete(key); + } + cache.set(key, value); + if (cache.size <= maxSize) { + return; + } + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } +} + +// Evicts the LOWEST-numbered keys — used for monotonic IDs (e.g. snapshot IDs) +// where "keep the N highest-numbered" matters more than insertion recency. +export function setNumericBoundedCacheEntry( + cache: Map, + key: number, + value: V, + maxSize: number, +): void { + cache.set(key, value); + if (cache.size <= maxSize) { + return; + } + const sortedKeys = [...cache.keys()].sort((a, b) => a - b); + for (let i = 0; i < cache.size - maxSize; i += 1) { + cache.delete(sortedKeys[i]); + } +} diff --git a/src/utils/cache/sourcifyCache.ts b/src/utils/cache/sourcifyCache.ts index bdbc60f..79cd0a6 100644 --- a/src/utils/cache/sourcifyCache.ts +++ b/src/utils/cache/sourcifyCache.ts @@ -8,7 +8,6 @@ * - fetchStorageLayout.ts (storage layout + sources) * - resolver/sources/sourcify.ts (ABI + metadata + sources) * - transaction-simulation/artifactFetching.ts (metadata + sources) - * - fetchers/sourcify.ts (legacy fetcher) * * Design: * - Cache is keyed by `${chainId}:${address}:${fieldsKey}` where fieldsKey diff --git a/src/utils/classifyError.ts b/src/utils/classifyError.ts new file mode 100644 index 0000000..679c5cc --- /dev/null +++ b/src/utils/classifyError.ts @@ -0,0 +1,79 @@ +export type ClassifiedErrorKind = + | 'aborted' + | 'timeout' + | 'rate-limit' + | 'network' + | 'auth' + | 'not-found' + | 'validation' + | 'server' + | 'unknown'; + +export interface ClassifiedError { + kind: ClassifiedErrorKind; + message: string; +} + +// Extract a readable message without leaking prototype chain details. +function messageFrom(err: unknown): string { + if (err == null) return ''; + if (typeof err === 'string') return err; + if (err instanceof Error) return err.message; + if (typeof err === 'object') { + const anyErr = err as Record; + if (typeof anyErr.message === 'string') return anyErr.message; + if (typeof anyErr.error === 'string') return anyErr.error; + } + try { + return String(err); + } catch { + return ''; + } +} + +// Normalize heterogenous error shapes (Fetch, ethers, bridge prose) into a +// small set of user-facing kinds. Returns the original message verbatim so +// callers can render it; use `kind` to drive retry / UX decisions. +export function classifyErrorMessage(err: unknown): ClassifiedError { + const message = messageFrom(err); + const lower = message.toLowerCase(); + + if ((err as { name?: string } | null)?.name === 'AbortError' || lower.includes('aborted') || lower.includes('canceled') || lower.includes('cancelled')) { + return { kind: 'aborted', message }; + } + if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('etimedout')) { + return { kind: 'timeout', message }; + } + if (lower.includes('rate limit') || lower.includes('rate-limit') || lower.includes('429') || lower.includes('too many requests')) { + return { kind: 'rate-limit', message }; + } + if (lower.includes('unauthorized') || lower.includes('forbidden') || lower.includes('401') || lower.includes('403')) { + return { kind: 'auth', message }; + } + if (lower.includes('not found') || lower.includes('404')) { + return { kind: 'not-found', message }; + } + if ( + lower.includes('econnreset') || + lower.includes('econnrefused') || + lower.includes('enotfound') || + lower.includes('network') || + lower.includes('fetch failed') || + lower.includes('failed to fetch') + ) { + return { kind: 'network', message }; + } + if (lower.includes('invalid') || lower.includes('malformed') || lower.includes('bad request') || lower.includes('400')) { + return { kind: 'validation', message }; + } + if (lower.includes('500') || lower.includes('502') || lower.includes('503') || lower.includes('504') || lower.includes('internal server')) { + return { kind: 'server', message }; + } + + return { kind: 'unknown', message }; +} + +// Helper: should this error kind be retried on a simple ladder? +export function isRetryableErrorKind(kind: ClassifiedErrorKind): boolean { + return kind === 'timeout' || kind === 'network' || kind === 'server'; +} diff --git a/src/utils/concurrency.ts b/src/utils/concurrency.ts new file mode 100644 index 0000000..adb0972 --- /dev/null +++ b/src/utils/concurrency.ts @@ -0,0 +1,55 @@ +export interface MapLimitOptions { + signal?: AbortSignal; + stopOnError?: boolean; +} + +// Runs `task` over `items` with at most `limit` concurrent in-flight calls. +// Preserves output order. Honors AbortSignal; rejects the whole run on first +// error when stopOnError=true (default). +export async function mapLimit( + items: readonly T[], + limit: number, + task: (item: T, index: number) => Promise, + options: MapLimitOptions = {} +): Promise { + const { signal, stopOnError = true } = options; + if (items.length === 0) return []; + const concurrency = Math.max(1, Math.min(limit, items.length)); + const results: R[] = new Array(items.length); + let nextIndex = 0; + let aborted = false; + let firstError: unknown = null; + + const runWorker = async () => { + while (true) { + if (aborted) return; + if (signal?.aborted) { + aborted = true; + throw signal.reason instanceof Error ? signal.reason : new Error('Aborted'); + } + const i = nextIndex++; + if (i >= items.length) return; + try { + results[i] = await task(items[i], i); + } catch (err) { + if (stopOnError) { + aborted = true; + if (firstError === null) firstError = err; + throw err; + } + results[i] = undefined as unknown as R; + } + } + }; + + const workers = Array.from({ length: concurrency }, runWorker); + try { + await Promise.all(workers); + } catch (err) { + throw firstError ?? err; + } + return results; +} + +// Convenience alias — same semantics as mapLimit but reads as "run with concurrency". +export const runWithConcurrency = mapLimit; diff --git a/src/utils/llmJsonParser.ts b/src/utils/llmJsonParser.ts new file mode 100644 index 0000000..ea7584b --- /dev/null +++ b/src/utils/llmJsonParser.ts @@ -0,0 +1,27 @@ +// Thinking/chat models often return JSON wrapped in code fences or preceded +// by prose. Try progressively looser parses: raw → fence-stripped → first-`{` +// to last-`}` slice. Returns null when nothing parses. +export function parseLlmJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + const stripped = text + .replace(/^```(?:json)?/i, "") + .replace(/```$/i, "") + .trim(); + try { + return JSON.parse(stripped); + } catch { + const first = stripped.indexOf("{"); + const last = stripped.lastIndexOf("}"); + if (first >= 0 && last > first) { + try { + return JSON.parse(stripped.slice(first, last + 1)); + } catch { + /* fall through */ + } + } + return null; + } + } +} diff --git a/src/utils/priceRegistry.ts b/src/utils/priceRegistry.ts new file mode 100644 index 0000000..18a8db6 --- /dev/null +++ b/src/utils/priceRegistry.ts @@ -0,0 +1,74 @@ +// Chain-ID → price-API identifiers. One table per provider to make it obvious +// when a chain is missing a slug/id for a given source. + +export const DEFILLAMA_CHAIN_SLUG: Record = { + 1: "ethereum", + 10: "optimism", + 25: "cronos", + 56: "bsc", + 100: "xdai", + 130: "unichain", + 137: "polygon", + 146: "sonic", + 204: "op_bnb", + 250: "fantom", + 252: "fraxtal", + 324: "era", + 1088: "metis", + 1135: "lisk", + 1284: "moonbeam", + 1329: "sei", + 1868: "soneium", + 2020: "ronin", + 2741: "abstract", + 5000: "mantle", + 8453: "base", + 33139: "apechain", + 34443: "mode", + 42161: "arbitrum", + 42220: "celo", + 43114: "avax", + 57073: "ink", + 59144: "linea", + 60808: "bob", + 80094: "berachain", + 81457: "blast", + 167000: "taiko", + 534352: "scroll", +}; + +export const NATIVE_COINGECKO_ID: Record = { + 1: "coingecko:ethereum", + 10: "coingecko:ethereum", + 25: "coingecko:crypto-com-chain", + 56: "coingecko:binancecoin", + 100: "coingecko:xdai", + 130: "coingecko:ethereum", + 137: "coingecko:matic-network", + 146: "coingecko:sonic-3", + 204: "coingecko:binancecoin", + 250: "coingecko:fantom", + 252: "coingecko:frax", + 324: "coingecko:ethereum", + 1088: "coingecko:metis-token", + 1135: "coingecko:ethereum", + 1284: "coingecko:moonbeam", + 1329: "coingecko:sei-network", + 1868: "coingecko:ethereum", + 2020: "coingecko:ronin", + 2741: "coingecko:ethereum", + 5000: "coingecko:mantle", + 8453: "coingecko:ethereum", + 33139: "coingecko:apecoin", + 34443: "coingecko:ethereum", + 42161: "coingecko:ethereum", + 42220: "coingecko:celo", + 43114: "coingecko:avalanche-2", + 57073: "coingecko:ethereum", + 59144: "coingecko:ethereum", + 60808: "coingecko:bitcoin", + 80094: "coingecko:berachain-bera", + 81457: "coingecko:ethereum", + 167000: "coingecko:ethereum", + 534352: "coingecko:ethereum", +}; diff --git a/src/utils/resolver/ContractResolver.ts b/src/utils/resolver/ContractResolver.ts index 5b56bf3..5e696c8 100644 --- a/src/utils/resolver/ContractResolver.ts +++ b/src/utils/resolver/ContractResolver.ts @@ -21,14 +21,17 @@ import type { Source, SourceAttempt, AbiItem, - SOURCE_CONFIGS, } from './types'; import { extractExternalFunctions } from './types'; import { contractCache } from './ContractCache'; -import { fetchEtherscan, fetchSourcify, fetchBlockscout } from './sources'; +import { fetchEtherscan, fetchSourcify, fetchBlockscout, fetchWhatsabi } from './sources'; +import { raceWithTimeout } from '../withAbortTimeout'; -const SETTLEMENT_WINDOW_MS = 200; // Time to wait for better sources after first success const SOURCE_TIMEOUT_MS = 5000; // Default timeout per source +// Head-start given to the verified explorer sources (Sourcify/Etherscan/Blockscout) +// before whatsabi is allowed to begin its bytecode analysis. If a verified result +// lands inside this window the race aborts and whatsabi never touches RPC. +const WHATSABI_DELAY_MS = 2000; function createEmptyResult(address: string, chainId: number, chain: Chain): ResolveResult { return { @@ -107,7 +110,10 @@ class ContractResolver { try { const result = await resolvePromise; - if (result.abi) { + // Only persist verified results. Inferred (whatsabi) ABIs are never + // cached — we want every future request to get a fresh shot at the + // verified explorers rather than reading a bytecode-guessed ABI back. + if (result.abi && result.confidence === 'verified') { await contractCache.set(address, chainId, result); } @@ -131,24 +137,30 @@ class ContractResolver { options.signal.addEventListener('abort', () => controller.abort(), { once: true }); } - const sourceOrder = this.getSourceOrder(options.preferredSources); + const sourceOrder = this.getSourceOrder(options.preferredSources, options.enableInferred); type SourceFetcher = { source: Source; fetch: () => Promise; }; - const fetchers: SourceFetcher[] = sourceOrder.map((source) => ({ - source, - fetch: () => - this.fetchWithTimeout( + const fetchers: SourceFetcher[] = sourceOrder.map((source) => { + const runFetch = () => + this.fetchWithTimeout(source, address, chain, options, controller.signal); + + // whatsabi joins the race after a head-start so the HTTP-only verified + // sources get first crack. If any of them returns verified within the + // window, controller.abort() fires and the delay rejects before + // whatsabi ever hits RPC. + if (source === 'whatsabi') { + return { source, - address, - chain, - options, - controller.signal - ), - })); + fetch: () => this.delayedFetch(WHATSABI_DELAY_MS, runFetch, controller.signal), + }; + } + + return { source, fetch: runFetch }; + }); let bestResult: SourceResult | null = null; let resolved = false; @@ -274,16 +286,11 @@ class ContractResolver { options: ResolveOptions, signal: AbortSignal ): Promise { - const timeoutPromise = new Promise((_, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(`${source} timed out after ${SOURCE_TIMEOUT_MS}ms`)); - }, SOURCE_TIMEOUT_MS); - - signal.addEventListener('abort', () => clearTimeout(timeoutId)); - }); - - const fetchPromise = this.fetchFromSource(source, address, chain, options, signal); - return Promise.race([fetchPromise, timeoutPromise]); + return raceWithTimeout( + this.fetchFromSource(source, address, chain, options, signal), + SOURCE_TIMEOUT_MS, + () => new Error(`${source} timed out after ${SOURCE_TIMEOUT_MS}ms`) + ); } private async fetchFromSource( @@ -303,21 +310,44 @@ class ContractResolver { case 'blockscout': return fetchBlockscout(address, chain, options.blockscoutApiKey, signal); - case 'blockscout-ebd': - // TODO: Implement Blockscout EBD (bytecode database) source - return { success: false, error: 'Blockscout EBD not yet implemented' }; - case 'whatsabi': - // TODO: Implement WhatsABI source for unverified contracts - return { success: false, error: 'WhatsABI not yet implemented' }; + return fetchWhatsabi(address, chain, signal); default: return { success: false, error: `Unknown source: ${source}` }; } } - private getSourceOrder(preferred?: Source[]): Source[] { - const defaultOrder: Source[] = ['sourcify', 'etherscan', 'blockscout']; + private delayedFetch( + delayMs: number, + fetch: () => Promise, + signal: AbortSignal + ): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException('Aborted', 'AbortError')); + }; + + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + fetch().then(resolve, reject); + }, delayMs); + + signal.addEventListener('abort', onAbort, { once: true }); + }); + } + + private getSourceOrder(preferred?: Source[], enableInferred = true): Source[] { + const verifiedOrder: Source[] = ['sourcify', 'etherscan', 'blockscout']; + const defaultOrder: Source[] = enableInferred + ? [...verifiedOrder, 'whatsabi'] + : verifiedOrder; if (!preferred || preferred.length === 0) { return defaultOrder; diff --git a/src/utils/resolver/contractContext.ts b/src/utils/resolver/contractContext.ts index a273e35..8cbbec1 100644 --- a/src/utils/resolver/contractContext.ts +++ b/src/utils/resolver/contractContext.ts @@ -41,6 +41,7 @@ import { contractResolver } from './ContractResolver'; import { hasDiamondLoupeFunctions } from './diamondLoupe'; import { detectTokenType, type TokenDetectionResult } from '../universalTokenDetector'; import { networkConfigManager } from '../../config/networkConfig'; +import { raceWithTimeout } from '../withAbortTimeout'; export interface ContractContext { address: string; @@ -233,8 +234,13 @@ function mergeAbis(proxyAbi: AbiItem[], implAbi: AbiItem[]): AbiItem[] { function getAbiItemKey(item: AbiItem): string | null { if (!item.name) return null; - const inputTypes = (item.inputs || []).map((i) => i.type).join(','); - return `${item.type}:${item.name}(${inputTypes})`; + try { + const sighash = ethers.utils.Fragment.from(item as any).format('sighash'); + return `${item.type}:${sighash}`; + } catch { + const inputTypes = (item.inputs || []).map((i) => i.type).join(','); + return `${item.type}:${item.name}(${inputTypes})`; + } } const IMPLEMENTATION_SELECTOR = '0x5c60da1b'; // implementation() @@ -486,12 +492,11 @@ async function doResolve( if (opts.detectProxy && provider) { parallelTasks.push( - Promise.race([ + raceWithTimeout( resolveProxyInfo(address, chain, provider), - new Promise((resolve) => - setTimeout(() => resolve({ isProxy: false }), opts.proxyTimeout) - ), - ]) + opts.proxyTimeout, + () => ({ isProxy: false }) + ) .then((result) => { if (result.isProxy) { proxyInfo = result; @@ -607,21 +612,16 @@ async function doResolve( if (opts.detectToken && provider) { opts.onProgress?.('Detecting token type...'); try { - const detection = await Promise.race([ + const detection = await raceWithTimeout( detectTokenType(provider, address), - new Promise((resolve) => - setTimeout( - () => - resolve({ - type: 'unknown', - isDiamond: false, - method: 'timeout', - confidence: 0, - }), - opts.proxyTimeout - ) - ), - ]); + opts.proxyTimeout, + () => ({ + type: 'unknown', + isDiamond: false, + method: 'timeout', + confidence: 0, + }) + ); if (detection.type !== 'unknown') { tokenType = detection.type as 'ERC20' | 'ERC721' | 'ERC1155'; @@ -700,22 +700,3 @@ async function doResolve( return result; } -/** - * Quick check if a contract exists on chain - */ -export async function contractExists( - address: string, - chain: Chain -): Promise { - if (!address || !address.startsWith('0x') || address.length !== 42) { - return false; - } - - try { - const provider = getSharedProvider(chain); - const code = await provider.getCode(address); - return !!code && code !== '0x'; - } catch { - return false; - } -} diff --git a/src/utils/resolver/diamondResolver.ts b/src/utils/resolver/diamondResolver.ts index ef0eb88..ede3f02 100644 --- a/src/utils/resolver/diamondResolver.ts +++ b/src/utils/resolver/diamondResolver.ts @@ -46,14 +46,21 @@ function extractFunctions(abi: AbiItem[]): ExternalFunction[] { .filter((item): item is AbiItem & { name: string } => item.type === 'function' && !!item.name ) - .map((item) => ({ - name: item.name, - signature: `${item.name}(${(item.inputs || []).map((i) => i.type).join(',')})`, - selector: '', - inputs: item.inputs || [], - outputs: item.outputs || [], - stateMutability: item.stateMutability || 'nonpayable', - })); + .map((item) => { + const signature = `${item.name}(${(item.inputs || []).map((i) => i.type).join(',')})`; + let selector = ''; + try { + selector = ethers.utils.id(signature).slice(0, 10); + } catch {} + return { + name: item.name, + signature, + selector, + inputs: item.inputs || [], + outputs: item.outputs || [], + stateMutability: item.stateMutability || 'nonpayable', + }; + }); } function mergeFacetAbis(facets: FacetInfo[]): AbiItem[] { @@ -64,16 +71,7 @@ function mergeFacetAbis(facets: FacetInfo[]): AbiItem[] { if (!facet.abi) continue; for (const item of facet.abi) { - let key: string; - if (item.type === 'function' && item.name) { - key = `function:${item.name}:${(item.inputs || []).map((i) => i.type).join(',')}`; - } else if (item.type === 'event' && item.name) { - key = `event:${item.name}`; - } else if (item.type === 'error' && item.name) { - key = `error:${item.name}`; - } else { - key = `${item.type}:${JSON.stringify(item, (_k, v) => typeof v === 'bigint' ? v.toString() : v)}`; - } + const key = abiItemDedupKey(item); if (!seenSignatures.has(key)) { seenSignatures.add(key); @@ -85,6 +83,21 @@ function mergeFacetAbis(facets: FacetInfo[]): AbiItem[] { return combined; } +function abiItemDedupKey(item: AbiItem): string { + if ( + (item.type === 'function' || item.type === 'event' || item.type === 'error') && + item.name + ) { + try { + const sighash = ethers.utils.Fragment.from(item as any).format('sighash'); + return `${item.type}:${sighash}`; + } catch {} + } + return `${item.type}:${JSON.stringify(item, (_k, v) => + typeof v === 'bigint' ? v.toString() : v + )}`; +} + /** * Detect if a contract is a Diamond by calling facetAddresses(). */ @@ -170,7 +183,6 @@ export async function resolveDiamond( contractResolver.resolve(facetAddress, chain, { signal, etherscanApiKey: options.etherscanApiKey, - priority: 'speed', }), ]); diff --git a/src/utils/resolver/index.ts b/src/utils/resolver/index.ts index 0426bc9..a999fea 100644 --- a/src/utils/resolver/index.ts +++ b/src/utils/resolver/index.ts @@ -13,13 +13,6 @@ // Main resolver export { contractResolver, ContractResolver } from './ContractResolver'; -// Multi-chain search -export { - searchAcrossChains, - quickSearchAcrossChains, - findAllDeployments, -} from './multiChainSearch'; - // Diamond resolution helpers (lightweight ABI checks only) export { hasDiamondLoupeFunctions, @@ -29,8 +22,6 @@ export { // Proxy resolution export { resolveProxyInfo, - isLikelyProxy, - resolveNestedProxies, clearProxyCache, clearAllProxyCache, } from './proxyResolver'; @@ -38,7 +29,6 @@ export { // Contract context (unified proxy + token detection) export { resolveContractContext, - contractExists, clearContextCache, clearAllContextCache, type ContractContext, @@ -55,7 +45,6 @@ export type { Confidence, SourceStatus, SourceAttempt, - SourceConfig, // ABI types AbiItem, @@ -77,8 +66,6 @@ export type { ContractMetadata, // Search types - MultiChainSearchOptions, - MultiChainSearchResult, DiamondResolveOptions, // Cache types @@ -87,4 +74,4 @@ export type { } from './types'; // Constants & Helpers -export { SOURCE_CONFIGS, isVerifiedConfidence, isReadFunction, isWriteFunction, extractExternalFunctions } from './types'; +export { isVerifiedConfidence, isReadFunction, isWriteFunction, extractExternalFunctions } from './types'; diff --git a/src/utils/resolver/multiChainSearch.ts b/src/utils/resolver/multiChainSearch.ts deleted file mode 100644 index 0d2416b..0000000 --- a/src/utils/resolver/multiChainSearch.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Multi-Chain Search - * - * Searches for a contract across multiple chains in parallel. - * - * Key optimization: All chains are searched simultaneously instead of sequentially. - * This reduces total search time from O(n * timeout) to O(timeout). - */ - -import type { Chain } from '../../types'; -import type { - ResolveResult, - MultiChainSearchOptions, - MultiChainSearchResult, -} from './types'; -import { contractResolver } from './ContractResolver'; - -export async function searchAcrossChains( - address: string, - chains: Chain[], - options: MultiChainSearchOptions = {} -): Promise { - const startTime = performance.now(); - const controller = new AbortController(); - const results = new Map(); - const errors = new Map(); - let firstFound: ResolveResult | null = null; - - if (options.signal) { - options.signal.addEventListener('abort', () => controller.abort()); - } - - const chainPromises = chains.map(async (chain) => { - const chainId = chain.id; - - try { - if (controller.signal.aborted) { - return { chainId, result: null, error: 'Aborted' }; - } - - const result = await contractResolver.resolve(address, chain, { - signal: controller.signal, - etherscanApiKey: options.etherscanApiKey, - priority: 'speed', - }); - - results.set(chainId, result); - options.onProgress?.(chainId, result); - - if (result.abi && !firstFound) { - firstFound = result; - - // Give other chains a short grace period before aborting - if (options.stopOnFirst) { - setTimeout(() => { - controller.abort(); - }, 500); - } - } - - return { chainId, result, error: undefined }; - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (error instanceof Error && error.name === 'AbortError') { - return { chainId, result: null, error: 'Aborted' }; - } - - errors.set(chainId, errorMessage); - options.onProgress?.(chainId, null, errorMessage); - - return { chainId, result: null, error: errorMessage }; - } - }); - - await Promise.allSettled(chainPromises); - - return { - results, - firstFound, - errors, - duration: performance.now() - startTime, - }; -} - -export async function quickSearchAcrossChains( - address: string, - chains: Chain[], - options: Omit = {} -): Promise { - const result = await searchAcrossChains(address, chains, { - ...options, - stopOnFirst: true, - }); - - return result.firstFound; -} - -export async function findAllDeployments( - address: string, - chains: Chain[], - options: Omit = {} -): Promise<{ - deployments: Array<{ chain: Chain; result: ResolveResult }>; - notFound: Chain[]; - errors: Array<{ chain: Chain; error: string }>; - duration: number; -}> { - const result = await searchAcrossChains(address, chains, { - ...options, - stopOnFirst: false, - }); - - const deployments: Array<{ chain: Chain; result: ResolveResult }> = []; - const notFound: Chain[] = []; - const errorList: Array<{ chain: Chain; error: string }> = []; - - for (const chain of chains) { - const chainResult = result.results.get(chain.id); - const chainError = result.errors.get(chain.id); - - if (chainResult?.abi) { - deployments.push({ chain, result: chainResult }); - } else if (chainError) { - errorList.push({ chain, error: chainError }); - } else { - notFound.push(chain); - } - } - - return { - deployments, - notFound, - errors: errorList, - duration: result.duration, - }; -} diff --git a/src/utils/resolver/proxyResolver.ts b/src/utils/resolver/proxyResolver.ts index 744892e..7afea56 100644 --- a/src/utils/resolver/proxyResolver.ts +++ b/src/utils/resolver/proxyResolver.ts @@ -447,71 +447,3 @@ export async function resolveProxyInfo( } } -/** - * Quick check if an address is likely a proxy (without full resolution) - * Useful for fast filtering before detailed resolution - */ -export async function isLikelyProxy( - address: string, - chain: Chain, - provider?: ethers.providers.Provider -): Promise { - if (!isValidAddress(address)) return false; - - const resolvedProvider = provider || getSharedProvider(chain); - - try { - const code = await resolvedProvider.getCode(address); - if (!code || code === '0x') return false; - - if (detectEip1167Clone(code)) return true; - - const implSlot = await resolvedProvider.getStorageAt(address, EIP1967_IMPLEMENTATION_SLOT); - if (slotToAddress(implSlot)) return true; - - const beaconSlot = await resolvedProvider.getStorageAt(address, EIP1967_BEACON_SLOT); - if (slotToAddress(beaconSlot)) return true; - - return false; - } catch { - return false; - } -} - -/** - * Resolve nested proxies (proxy pointing to another proxy) - * Returns all implementation addresses in the chain - * - * @param address - Starting proxy address - * @param chain - Chain object - * @param maxDepth - Maximum nesting depth (default: 3) - * @returns Array of implementation addresses in resolution order - */ -export async function resolveNestedProxies( - address: string, - chain: Chain, - maxDepth: number = 3 -): Promise { - const implementations: string[] = []; - const visited = new Set(); - let current = address.toLowerCase(); - - for (let depth = 0; depth < maxDepth; depth++) { - if (visited.has(current)) { - // Cycle detected - break; - } - visited.add(current); - - const proxyInfo = await resolveProxyInfo(current, chain); - if (!proxyInfo.isProxy || !proxyInfo.implementationAddress) { - break; - } - - const impl = proxyInfo.implementationAddress.toLowerCase(); - implementations.push(proxyInfo.implementationAddress); - current = impl; - } - - return implementations; -} diff --git a/src/utils/resolver/sources/index.ts b/src/utils/resolver/sources/index.ts index 5574ffc..a64f65f 100644 --- a/src/utils/resolver/sources/index.ts +++ b/src/utils/resolver/sources/index.ts @@ -5,3 +5,4 @@ export { fetchEtherscan } from './etherscan'; export { fetchSourcify } from './sourcify'; export { fetchBlockscout } from './blockscout'; +export { fetchWhatsabi } from './whatsabi'; diff --git a/src/utils/resolver/sources/whatsabi.ts b/src/utils/resolver/sources/whatsabi.ts new file mode 100644 index 0000000..8e88869 --- /dev/null +++ b/src/utils/resolver/sources/whatsabi.ts @@ -0,0 +1,78 @@ +/** + * WhatsABI Source — last-resort fallback for unverified contracts. + * + * Infers an ABI from on-chain bytecode via @shazow/whatsabi. Unlike the + * explorer sources, this hits the user's RPC instead of a public HTTP + * indexer, so the resolver only runs it when the verified explorers are + * failing (enforced by delayed-race scheduling in ContractResolver). + * + * Security invariants enforced here: + * - Confidence is hard-capped at 'inferred' (or 'bytecode-only' for pure + * selector extraction). This source NEVER reports 'verified', even if + * the underlying library claims it — downstream code gating on + * `verified` must not be fooled. + * - Proxy info from whatsabi is intentionally dropped; contractContext.ts + * remains the authoritative proxy detector. + * - Aborts are honored via Promise.race. whatsabi has no native + * cancellation, so in-flight RPC may leak, but its result is discarded. + */ + +import type { Chain } from '../../../types'; +import type { AbiItem, Confidence, SourceResult } from '../types'; +import { fetchFromWhatsABI } from '../../whatsabiFetcher'; + +export async function fetchWhatsabi( + address: string, + chain: Chain, + signal: AbortSignal +): Promise { + if (signal.aborted) { + return { success: false, error: 'Aborted', source: 'whatsabi' }; + } + + const abortPromise = new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true } + ); + }); + + try { + const result = await Promise.race([fetchFromWhatsABI(address, chain), abortPromise]); + + if (!result.success || !result.abi) { + return { + success: false, + error: result.error ?? 'WhatsABI returned no ABI', + source: 'whatsabi', + }; + } + + let parsed: AbiItem[]; + try { + parsed = JSON.parse(result.abi) as AbiItem[]; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + success: false, + error: `WhatsABI returned malformed ABI JSON: ${message}`, + source: 'whatsabi', + }; + } + + const confidence: Confidence = + result.confidence === 'extracted' ? 'bytecode-only' : 'inferred'; + + return { + success: true, + abi: parsed, + name: result.contractName, + source: 'whatsabi', + confidence, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: message, source: 'whatsabi' }; + } +} diff --git a/src/utils/resolver/types.ts b/src/utils/resolver/types.ts index b96046a..f982beb 100644 --- a/src/utils/resolver/types.ts +++ b/src/utils/resolver/types.ts @@ -5,9 +5,10 @@ * Designed for parallel fetching, proper caching, and progressive enhancement. */ +import { ethers } from 'ethers'; import type { Chain } from '../../types'; -export type Source = 'sourcify' | 'etherscan' | 'blockscout' | 'blockscout-ebd' | 'whatsabi'; +export type Source = 'sourcify' | 'etherscan' | 'blockscout' | 'whatsabi'; export type Confidence = 'verified' | 'inferred' | 'bytecode-only'; @@ -21,13 +22,6 @@ export interface SourceAttempt { confidence?: Confidence; } -export interface SourceConfig { - name: Source; - timeout: number; - priority: number; // Lower = preferred (tried first in race) - rateLimit?: number; // Requests per second (for throttling) -} - export interface AbiInput { name: string; type: string; @@ -162,12 +156,21 @@ export interface ResolveResult { export interface ResolveOptions { signal?: AbortSignal; - priority?: 'speed' | 'completeness'; skipCache?: boolean; preferredSources?: Source[]; etherscanApiKey?: string; blockscoutApiKey?: string; onProgress?: (attempt: SourceAttempt) => void; + /** + * Allow the resolver to fall back to bytecode-inferred sources + * (currently: whatsabi) when no verified explorer returns an ABI. + * Inferred results report `confidence: 'inferred'` and `verified: false`, + * and are never persisted to the contract cache. Defaults to true. + * Callers doing security-critical decoding (e.g. decoding of untrusted + * calldata where a fabricated ABI could mislead a user) should set this + * to false. + */ + enableInferred?: boolean; } export interface SourceResult { @@ -201,20 +204,6 @@ export interface CacheStats { totalMisses: number; } -export interface MultiChainSearchOptions { - signal?: AbortSignal; - stopOnFirst?: boolean; - etherscanApiKey?: string; - onProgress?: (chainId: number, result: ResolveResult | null, error?: string) => void; -} - -export interface MultiChainSearchResult { - results: Map; - firstFound: ResolveResult | null; - errors: Map; - duration: number; -} - export interface DiamondResolveOptions { signal?: AbortSignal; concurrency?: number; @@ -222,14 +211,6 @@ export interface DiamondResolveOptions { onFacetProgress?: (completed: number, total: number, facet?: FacetInfo) => void; } -export const SOURCE_CONFIGS: Record = { - sourcify: { name: 'sourcify', timeout: 4000, priority: 1 }, - etherscan: { name: 'etherscan', timeout: 5000, priority: 2, rateLimit: 5 }, - blockscout: { name: 'blockscout', timeout: 5000, priority: 3 }, - 'blockscout-ebd': { name: 'blockscout-ebd', timeout: 8000, priority: 4 }, - whatsabi: { name: 'whatsabi', timeout: 6000, priority: 5 }, -}; - export const isVerifiedConfidence = (confidence: Confidence): boolean => confidence === 'verified'; @@ -253,10 +234,15 @@ export function extractExternalFunctions(abi: AbiItem[]): { for (const item of abi) { if (item.type !== 'function' || !item.name) continue; + const signature = `${item.name}(${(item.inputs || []).map((i) => i.type).join(',')})`; + let selector = ''; + try { + selector = ethers.utils.id(signature).slice(0, 10); + } catch {} const fn: ExternalFunction = { name: item.name, - signature: `${item.name}(${(item.inputs || []).map((i) => i.type).join(',')})`, - selector: '', // Will be computed if needed + signature, + selector, inputs: item.inputs || [], outputs: item.outputs || [], stateMutability: item.stateMutability || 'nonpayable', diff --git a/src/utils/tokenMovements.ts b/src/utils/tokenMovements.ts index 6e36db1..f449918 100644 --- a/src/utils/tokenMovements.ts +++ b/src/utils/tokenMovements.ts @@ -1,4 +1,6 @@ import { ethers } from "ethers"; +import { setLimitedCacheEntry } from "./cache/limitedCache"; +import { DEFILLAMA_CHAIN_SLUG } from "./priceRegistry"; // Standard event topic hashes for token transfers export const TRANSFER_TOPIC = ethers.utils.id("Transfer(address,address,uint256)"); @@ -143,21 +145,6 @@ export function getTokenIconUrls(tokenAddress: string, chainId: number = 1): str return urls; } -// Chain ID to DeFiLlama chain name mapping -const CHAIN_ID_TO_LLAMA: Record = { - 1: "ethereum", - 10: "optimism", - 56: "bsc", - 137: "polygon", - 250: "fantom", - 42161: "arbitrum", - 43114: "avax", - 8453: "base", - 324: "zksync", - 59144: "linea", - 534352: "scroll", -}; - // Price cache with TTL (5 minutes) const MAX_TOKEN_CACHE_SIZE = 500; const priceCache = new Map(); @@ -172,7 +159,7 @@ export async function fetchTokenPrice( tokenAddress: string, chainId: number = 1 ): Promise { - const chainName = CHAIN_ID_TO_LLAMA[chainId]; + const chainName = DEFILLAMA_CHAIN_SLUG[chainId]; if (!chainName) { console.warn(`Unknown chain ID ${chainId} for DeFiLlama price lookup`); return null; @@ -211,11 +198,7 @@ export async function fetchTokenPrice( }; // Cache the result - priceCache.set(cacheKey, { price, fetchedAt: Date.now() }); - if (priceCache.size > MAX_TOKEN_CACHE_SIZE) { - const first = priceCache.keys().next().value; - if (first) priceCache.delete(first); - } + setLimitedCacheEntry(priceCache, cacheKey, { price, fetchedAt: Date.now() }, MAX_TOKEN_CACHE_SIZE); return price; } catch (e) { @@ -238,7 +221,7 @@ export async function fetchTokenPrices( const addressToKey = new Map(); for (const { address, chainId = 1 } of tokens) { - const chainName = CHAIN_ID_TO_LLAMA[chainId]; + const chainName = DEFILLAMA_CHAIN_SLUG[chainId]; if (!chainName) continue; const cacheKey = `${chainName}:${address.toLowerCase()}`; @@ -343,11 +326,7 @@ export async function fetchTokenMetadata( } const metadata: TokenMetadata = { symbol, name, decimals }; - tokenMetadataCache.set(cacheKey, metadata); - if (tokenMetadataCache.size > MAX_TOKEN_CACHE_SIZE) { - const first = tokenMetadataCache.keys().next().value; - if (first) tokenMetadataCache.delete(first); - } + setLimitedCacheEntry(tokenMetadataCache, cacheKey, metadata, MAX_TOKEN_CACHE_SIZE); return metadata; } catch (e) { console.warn(`Failed to fetch metadata for ${tokenAddress}:`, e); diff --git a/src/utils/traceDecoder/analysisHelpers.ts b/src/utils/traceDecoder/analysisHelpers.ts index 6c80a00..5e03b74 100644 --- a/src/utils/traceDecoder/analysisHelpers.ts +++ b/src/utils/traceDecoder/analysisHelpers.ts @@ -24,8 +24,6 @@ export interface AnalysisLocals { resolveCodeAddrForFrame: (frameId: any) => string | undefined; /** Full PcInfo for a given (pc, frameId) */ pcInfoForPc: (pc: number, frameId?: any) => PcInfo | undefined; - /** Resolved source line for a given (pc, frameId) */ - lineForPc: (pc: number, frameId?: any) => number | undefined; /** Function name at a given pc */ fnForPc: (pc: number, frameId?: any) => string | null; /** Modifier name at a given pc */ @@ -42,8 +40,6 @@ export interface AnalysisLocals { line: number | null | undefined, frameId?: any ) => string | null; - /** Map from fn name → lowest entry PC */ - fnEntryPc: Map; /** All JUMP/JUMPI opcode rows */ allJumps: DecodedTraceRow[]; /** Extract traceId from frame_id */ @@ -358,21 +354,6 @@ export function buildAnalysisLocals(ctx: DecodeTraceContext, callFrameRows: Deco 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; @@ -496,23 +477,6 @@ export function buildAnalysisLocals(ctx: DecodeTraceContext, callFrameRows: Deco const jumpOpcodes = new Set(["JUMP", "JUMPI"]); - const fnEntryPc = new Map(); - const fnEntryHasJumpdest = new Set(); - opRows.forEach((r) => { - if (!r.fn || r.pc === undefined) return; - const existing = fnEntryPc.get(r.fn); - if (r.name === "JUMPDEST") { - if (!fnEntryHasJumpdest.has(r.fn) || existing === undefined || r.pc < existing) { - fnEntryPc.set(r.fn, r.pc); - fnEntryHasJumpdest.add(r.fn); - } - return; - } - if (!fnEntryHasJumpdest.has(r.fn) && (existing === undefined || r.pc < existing)) { - fnEntryPc.set(r.fn, r.pc); - } - }); - const allJumps = opRows.filter((r) => jumpOpcodes.has(r.name)); const traceIdFromFrame = (frameId: any): number | null => { @@ -603,14 +567,12 @@ export function buildAnalysisLocals(ctx: DecodeTraceContext, callFrameRows: Deco callFrameRows, resolveCodeAddrForFrame, pcInfoForPc, - lineForPc, fnForPc, modifierForPc, fnForPcIfAtEntry, jumpTypeForPc, getSourceContent, findSingleCalledFunctionOnLine, - fnEntryPc, allJumps, traceIdFromFrame, opRowIndexByIdForJump, diff --git a/src/utils/traceDecoder/decodeTraceFinalize.ts b/src/utils/traceDecoder/decodeTraceFinalize.ts index 77470e9..b395d3e 100644 --- a/src/utils/traceDecoder/decodeTraceFinalize.ts +++ b/src/utils/traceDecoder/decodeTraceFinalize.ts @@ -50,7 +50,6 @@ export function phaseFinalize(ctx: DecodeTraceContext): { rowIdx: number; traceId: number | undefined; depth: number; - ownId: number; lastValidId: number; // most recent valid child id seen foundChild: boolean; } @@ -141,14 +140,14 @@ export function phaseFinalize(ctx: DecodeTraceContext): { if (isEntry) { childStack.push({ rowIdx: i, traceId: rowTraceId, depth: rowDepth, - ownId: row.id, lastValidId: row.id, foundChild: false, + lastValidId: row.id, foundChild: false, }); } // Push new frame for external call opcodes without entryMeta (rail detection) else if (externalCallOpcodes.has(row.name) && !row.hasChildren) { childStack.push({ rowIdx: i, traceId: rowTraceId, depth: rowDepth, - ownId: row.id, lastValidId: row.id, foundChild: false, + lastValidId: row.id, foundChild: false, }); } } @@ -353,7 +352,6 @@ export function phaseFinalize(ctx: DecodeTraceContext): { // ========================================================================== // FALLBACK: Extract events from LOG opcodes // ========================================================================== - const callEventsCount = rawEvents.length; if (rowsWithJumps && Array.isArray(rowsWithJumps)) { const existingEventSigs = new Set(); rawEvents.forEach(evt => { diff --git a/src/utils/traceDecoder/decodeTraceInit.ts b/src/utils/traceDecoder/decodeTraceInit.ts index ca33ab9..a2d8490 100644 --- a/src/utils/traceDecoder/decodeTraceInit.ts +++ b/src/utils/traceDecoder/decodeTraceInit.ts @@ -7,7 +7,6 @@ import { ethers } from "ethers"; import type { RawTrace, DecodedTraceRow, PcInfo, DecodeTraceContext, FunctionRange } from './types'; import { opcodeNames, STATIC_GAS_COSTS, getStaticGasCost } from './opcodes'; -import { formatAbiVal } from './formatting'; import { parseFunctions, parseModifiers, parseFunctionSignatures, fnForLine } from './sourceParser'; import { buildFullPcLineMap } from './pcMapper'; import { getCallFrames } from './stackDecoding'; @@ -27,10 +26,6 @@ export function phaseInit(raw: RawTrace): DecodeTraceContext { const sources = (raw as any).sources || {}; const artifacts = (raw as any).artifacts || {}; - const firstSourceKey = Object.keys(sources)[0]; - const firstSource = firstSourceKey ? sources[firstSourceKey] : null; - const sourceFiles = firstSource?.Source?.sources; - // Combine sources from ALL artifacts for Diamond proxy patterns const allArtifactSources: Record = {}; @@ -179,9 +174,17 @@ export function phaseInit(raw: RawTrace): DecodeTraceContext { const addAbiItems = (abiArray: AbiItemLike[]) => { for (const item of abiArray) { - const sigKey = item.type === 'event' || item.type === 'function' - ? `${item.type}:${item.name}:${(item.inputs || []).map((i) => i.type).join(',')}` - : JSON.stringify(item); + let sigKey: string; + if ((item.type === 'event' || item.type === 'function') && item.name) { + try { + const sighash = ethers.utils.Fragment.from(item as any).format('sighash'); + sigKey = `${item.type}:${sighash}`; + } catch { + sigKey = `${item.type}:${item.name}:${(item.inputs || []).map((i) => i.type).join(',')}`; + } + } else { + sigKey = JSON.stringify(item); + } if (!seenSignatures.has(sigKey)) { seenSignatures.add(sigKey); combinedAbi.push(item); @@ -272,15 +275,6 @@ export function phaseInit(raw: RawTrace): DecodeTraceContext { } }); - const childrenByParentId = new Map(); - traceIdToParentId.forEach((parentId, childId) => { - if (parentId !== null && parentId !== undefined) { - const children = childrenByParentId.get(parentId) || []; - children.push(childId); - childrenByParentId.set(parentId, children); - } - }); - const primaryAddr = raw.inner?.inner?.[0]?.code_address || raw.inner?.inner?.[0]?.codeAddress || @@ -703,27 +697,6 @@ export function phaseInit(raw: RawTrace): DecodeTraceContext { } }); - // Parse call input to extract function name and arguments - let inputParsed: { sig: string; args: any[]; fragment: any } | null = null; - let decodedInputArgs: { name: string; type: string; value: string }[] | null = null; - - if (iface && call?.input) { - try { - const parsed = iface.parseTransaction({ data: call.input }); - if (parsed) { - const argsArray = Array.from(parsed.args); - inputParsed = { sig: parsed.signature, args: argsArray, fragment: parsed.functionFragment }; - if (parsed.functionFragment?.inputs) { - decodedInputArgs = parsed.functionFragment.inputs.map((inp: any, idx: number) => ({ - name: inp.name || `arg${idx}`, - type: inp.type, - value: formatAbiVal(inp.type, parsed.args[idx]), - })); - } - } - } catch {} - } - // Get contract name from first artifact metadata const mainArtifactKey = raw.artifacts ? Object.keys(raw.artifacts)[0] : null; const mainArtifact = mainArtifactKey ? (raw.artifacts as any)[mainArtifactKey] : null; @@ -754,7 +727,6 @@ export function phaseInit(raw: RawTrace): DecodeTraceContext { traceIdToParentId, traceIdToCodeAddr, traceIdToTarget, - childrenByParentId, storageDiffsBySlot, primaryAddr, hasAnyArtifacts, diff --git a/src/utils/traceDecoder/sourceParser.ts b/src/utils/traceDecoder/sourceParser.ts index 7212cbe..7c6fa08 100644 --- a/src/utils/traceDecoder/sourceParser.ts +++ b/src/utils/traceDecoder/sourceParser.ts @@ -251,6 +251,9 @@ export function validateSrcMapContent( /** * Validates that the SOURCE line (caller side) contains a call to the function. + * + * Regex probe; compensates for EDB internal-JUMP src-map drift. Remove once + * upstream emits accurate caller-site line metadata. */ export function validateSourceLineContainsFunctionCall( sourceTexts: Record, @@ -280,6 +283,9 @@ export function validateSourceLineContainsFunctionCall( /** * Find the correct line number where a function is actually called. * Source maps for internal JUMPs are often inaccurate. + * + * Regex probe retained until EDB emits accurate src-line metadata for + * internal JUMPs; see callHierarchy.ts for consumer sites. */ export function findCorrectCallLine( sourceTexts: Record, diff --git a/src/utils/traceDecoder/types.ts b/src/utils/traceDecoder/types.ts index c16aad1..eb48dec 100644 --- a/src/utils/traceDecoder/types.ts +++ b/src/utils/traceDecoder/types.ts @@ -196,7 +196,6 @@ export interface DecodeTraceContext { traceIdToParentId: Map; traceIdToCodeAddr: Map; traceIdToTarget: Map; - childrenByParentId: Map; storageDiffsBySlot: Map; // Addresses diff --git a/src/utils/transaction-simulation/bridgeSimulation.ts b/src/utils/transaction-simulation/bridgeSimulation.ts index 91204b8..cf35331 100644 --- a/src/utils/transaction-simulation/bridgeSimulation.ts +++ b/src/utils/transaction-simulation/bridgeSimulation.ts @@ -21,6 +21,10 @@ import { networkConfigManager } from "../../config/networkConfig"; import { classifySimulationError } from "../errorParser"; import { lookupFunctionSignatures } from "../signatureDatabase"; +// Bridge responses bundle traces + artifacts and can legitimately be multi-MB; +// cap at 50 MB to avoid OOM from runaway/hostile payloads. +const MAX_TRACE_SIZE_BYTES = 50 * 1024 * 1024; + import { type BridgeSimulationResponsePayload, type BridgeAnalysisOptions, @@ -132,8 +136,7 @@ export const postSimulatorJob = async ( headers: getBridgeHeaders(), transformResponse: [ (data: string) => { - // Guard against excessively large responses (>50MB) - if (typeof data === "string" && data.length > 50 * 1024 * 1024) { + if (typeof data === "string" && data.length > MAX_TRACE_SIZE_BYTES) { throw new Error( "Bridge response exceeds maximum size limit (50MB)", ); diff --git a/src/utils/transaction-simulation/requestBuilding.ts b/src/utils/transaction-simulation/requestBuilding.ts index 7d66830..76611df 100644 --- a/src/utils/transaction-simulation/requestBuilding.ts +++ b/src/utils/transaction-simulation/requestBuilding.ts @@ -24,7 +24,6 @@ export const DEFAULT_ANALYSIS_OPTIONS: BridgeAnalysisOptions = { quickMode: true, collectCallTree: true, collectEvents: true, - collectStorageDiff: true, collectStorageDiffs: true, // Snapshots provide full VM state per opcode — required for the legacy // 3-phase FE decode to produce rich trace data (function args, internal @@ -171,16 +170,6 @@ export const mergeAnalysisOptions = ( return { ...DEFAULT_ANALYSIS_OPTIONS, ...(overrides ?? {}), - collectStorageDiffs: - overrides?.collectStorageDiffs ?? - overrides?.collectStorageDiff ?? - DEFAULT_ANALYSIS_OPTIONS.collectStorageDiffs, - collectStorageDiff: - overrides?.collectStorageDiff ?? - overrides?.collectStorageDiffs ?? - DEFAULT_ANALYSIS_OPTIONS.collectStorageDiff, - collectSnapshots: - overrides?.collectSnapshots ?? DEFAULT_ANALYSIS_OPTIONS.collectSnapshots, ...(etherscanKey ? { etherscanApiKey: etherscanKey } : {}), artifactSourcePriority, }; diff --git a/src/utils/transaction-simulation/revertHandling.ts b/src/utils/transaction-simulation/revertHandling.ts index cb1289b..4c73a3b 100644 --- a/src/utils/transaction-simulation/revertHandling.ts +++ b/src/utils/transaction-simulation/revertHandling.ts @@ -70,77 +70,86 @@ export function normalizeErrorArgs(args: unknown[]): unknown[] { }); } -const HEX_DATA_REGEX = /0x[0-9a-fA-F]{8,}/; - -export function findRevertDataInError(error: any): string | null { - if (!error) { - return null; - } - - const visited = new Set(); - const stack: any[] = [error]; - let iterations = 0; - - while (stack.length && iterations < 200) { - iterations += 1; - const current = stack.pop(); - if (current === null || current === undefined) { - continue; +// Ethers / viem / wagmi put revert data on known carriers; walking arbitrary +// object keys picks up tx hashes, addresses, request bodies etc. +const REVERT_DATA_KEYS = [ + 'data', + 'revertData', + 'returnData', + 'output', + 'result', + 'raw', + 'rawData', +] as const; + +const NESTED_ERROR_KEYS = [ + 'error', + 'cause', + 'originalError', + 'info', +] as const; + +const REVERT_HEX_REGEX = /0x[0-9a-fA-F]{8,}/; + +function extractHexFromCarrier(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + if (/^0x[0-9a-fA-F]+$/.test(trimmed) && trimmed.length >= 10) { + return ensureHexPrefix(trimmed); } - - if (typeof current === 'string') { - const match = current.match(HEX_DATA_REGEX); - if (match && match[0]) { - return ensureHexPrefix(match[0]); - } - continue; - } - - if (typeof current !== 'object') { - continue; + const match = trimmed.match(REVERT_HEX_REGEX); + if (match && match[0]) { + return ensureHexPrefix(match[0]); } - - if (visited.has(current)) { - continue; + return null; + } + if (value && typeof value === 'object') { + const inner = (value as Record).data; + if (typeof inner === 'string') { + return extractHexFromCarrier(inner); } - visited.add(current); + } + return null; +} - if (Array.isArray(current)) { - for (const item of current) { - stack.push(item); +export function findRevertDataInError(error: any): string | null { + if (!error) return null; + + const visited = new Set(); + const queue: unknown[] = [error]; + const MAX_NODES = 32; + let processed = 0; + + while (queue.length && processed < MAX_NODES) { + processed += 1; + const current = queue.shift(); + if (!current || typeof current !== 'object') continue; + if (visited.has(current as object)) continue; + visited.add(current as object); + + const node = current as Record; + + for (const key of REVERT_DATA_KEYS) { + if (key in node) { + const hex = extractHexFromCarrier(node[key]); + if (hex) return hex; } - continue; } - for (const [key, value] of Object.entries(current)) { - if (value === null || value === undefined) { - continue; - } - - if (key === 'config' || key === 'request') { - continue; - } - - if (typeof value === 'string') { - const trimmed = value.trim(); - const match = trimmed.match(HEX_DATA_REGEX); - if (match && match[0]) { - return ensureHexPrefix(match[0]); - } - - if ( - (trimmed.startsWith('{') || trimmed.startsWith('[')) && - trimmed.length <= 10_000 - ) { + for (const key of NESTED_ERROR_KEYS) { + const child = node[key]; + if (child && typeof child === 'object') { + queue.push(child); + } else if (typeof child === 'string' && child.length <= 10_000) { + const trimmed = child.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { try { - const parsed = JSON.parse(trimmed); - stack.push(parsed); + queue.push(JSON.parse(trimmed)); } catch { - // ignore JSON parse errors + // non-JSON string in nested-error slot — ignore } } - } else if (typeof value === 'object') { - stack.push(value); } } } diff --git a/src/utils/transaction-simulation/simulateAssetMovements.ts b/src/utils/transaction-simulation/simulateAssetMovements.ts index c189255..f0ea7f3 100644 --- a/src/utils/transaction-simulation/simulateAssetMovements.ts +++ b/src/utils/transaction-simulation/simulateAssetMovements.ts @@ -30,7 +30,6 @@ export async function simulateAssetMovements( tx, chain, fromAddress, - undefined, // liteEventsOnly skips the Sourcify/Blockscout artifact pre-fetch and // tells EDB to disable per-opcode snapshot collection. We only read // rawEvents for Transfer extraction below, so none of the heavy trace diff --git a/src/utils/transaction-simulation/simulationEntryPoints.ts b/src/utils/transaction-simulation/simulationEntryPoints.ts index 23e1e71..352dfea 100644 --- a/src/utils/transaction-simulation/simulationEntryPoints.ts +++ b/src/utils/transaction-simulation/simulationEntryPoints.ts @@ -12,9 +12,6 @@ import { } from "./revertHandling"; import { classifySimulationError } from "../errorParser"; -// Re-export for barrel -export { replayTransactionWithSimulator } from "./bridgeSimulation"; - export interface SimulationExecutionOptions { enableDebug?: boolean; /** @@ -31,7 +28,6 @@ export const simulateTransaction = async ( transaction: TransactionRequest, chain: Chain, fromAddress: string, - _provider?: unknown, options: SimulationExecutionOptions = {}, ): Promise => { try { diff --git a/src/utils/transactionSimulation.ts b/src/utils/transactionSimulation.ts deleted file mode 100644 index 2f8dc3c..0000000 --- a/src/utils/transactionSimulation.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Thin barrel re-export — all implementation moved to ./transaction-simulation/ -// Preserves existing import paths: import { ... } from '../utils/transactionSimulation' - -export { - // Types - type SourcifySourceEntry, - type SourcifyArtifact, - type SourcifyMetadataResult, - type BridgeSimulationResponsePayload, - type BridgeAnalysisOptions, - type RevertDetails, - PANIC_CODE_MESSAGES, - - // Artifact fetching - buildArtifactsFromSourcify, - fetchBlockscoutMetadata, - - // Revert handling - ensureHexPrefix, - parseReasonFromString, - decodeRevertData, - extractRevertDetails, - buildFailureRawTrace, - findRevertDataInError, - normalizeErrorArgs, - - // Bridge simulation - postSimulatorJob, - trySimulatorBridge, - replayTransactionWithSimulator, - - // Simulation entry points - simulateTransaction, -} from './transaction-simulation'; diff --git a/src/utils/withAbortTimeout.ts b/src/utils/withAbortTimeout.ts new file mode 100644 index 0000000..b8e591d --- /dev/null +++ b/src/utils/withAbortTimeout.ts @@ -0,0 +1,34 @@ +// Combined abort: fires when `signal` aborts OR `ms` elapses. +export function withAbortTimeout( + signal: AbortSignal | undefined, + ms: number +): AbortSignal { + const timeoutSignal = AbortSignal.timeout(ms); + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; +} + +// Races `op` against a `ms` timer. If the timer wins, `onTimeout` decides: +// an Error return rejects, a value return resolves with the fallback. +export function raceWithTimeout( + op: Promise, + ms: number, + onTimeout: () => T | Error +): Promise { + const timeoutPromise = new Promise((resolve, reject) => { + const signal = AbortSignal.timeout(ms); + signal.addEventListener( + 'abort', + () => { + const result = onTimeout(); + if (result instanceof Error) { + reject(result); + } else { + resolve(result); + } + }, + { once: true } + ); + }); + + return Promise.race([op, timeoutPromise]); +} From 6b663c2915fe39b5b7bf27d6e0c96ab4e11543b3 Mon Sep 17 00:00:00 2001 From: Timidan Date: Wed, 22 Apr 2026 21:34:38 +0100 Subject: [PATCH 2/2] feat: update simulation debug actions and history loader for improved type handling --- .../lifi-earn/concierge/fixtures/llm/.gitkeep | 0 .../useSimulationDebugActions.ts | 2 +- .../useSimulationHistoryLoader.ts | 16 +++++++++++----- 3 files changed, 12 insertions(+), 6 deletions(-) delete mode 100644 src/components/integrations/lifi-earn/concierge/fixtures/llm/.gitkeep diff --git a/src/components/integrations/lifi-earn/concierge/fixtures/llm/.gitkeep b/src/components/integrations/lifi-earn/concierge/fixtures/llm/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/components/simulation-results/useSimulationDebugActions.ts b/src/components/simulation-results/useSimulationDebugActions.ts index fdb7019..6bf8b9f 100644 --- a/src/components/simulation-results/useSimulationDebugActions.ts +++ b/src/components/simulation-results/useSimulationDebugActions.ts @@ -24,7 +24,7 @@ interface DebugSlice { chainId: number; simulationId: string; }) => Promise; - debugPrepState: { simulationId?: string; status?: string } | null | undefined; + debugPrepState: { simulationId?: string | null; status?: string } | null | undefined; startDebugPrep: ( request: { rpcUrl: string; diff --git a/src/components/simulation-results/useSimulationHistoryLoader.ts b/src/components/simulation-results/useSimulationHistoryLoader.ts index d33cfd5..df52a78 100644 --- a/src/components/simulation-results/useSimulationHistoryLoader.ts +++ b/src/components/simulation-results/useSimulationHistoryLoader.ts @@ -1,17 +1,23 @@ import { useEffect, useRef, useState } from "react"; import { traceVaultService } from "../../services/TraceVaultService"; import type { SimulationResult } from "../../types/transaction"; -import type { DecodedTraceMeta } from "../../contexts/SimulationContext"; -import type { DecodedTraceRow } from "../../types/decoded-trace"; -import type { ContractContext } from "../../utils/resolver"; +import type { + DecodedTraceMeta, + SimulationContractContext, +} from "../../contexts/SimulationContext"; +import type { DecodedTraceRow } from "../../utils/traceDecoder/types"; import { hasInternalInfo } from "./useSimulationPageHelpers"; +interface SetSimulationOptions { + skipHistorySave?: boolean; +} + interface SimulationContextSlice { currentSimulation: SimulationResult | null; setSimulation: ( result: SimulationResult, - ctx: ContractContext | null, - opts?: { skipHistorySave?: boolean }, + contractContext?: SimulationContractContext, + options?: SetSimulationOptions, ) => void; setDecodedTraceRows: (rows: DecodedTraceRow[]) => void; setDecodedTraceMeta: (meta: DecodedTraceMeta) => void;