From 1ec095e2060560f84b3231f9df0782657829fb66 Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Wed, 11 Feb 2026 11:17:20 +1000 Subject: [PATCH 1/2] refactor: gas sim --- .../DataDisplay/Stats/Stats.stories.tsx | 97 +- src/components/DataDisplay/Stats/Stats.tsx | 148 +- .../DataDisplay/Stats/Stats.types.ts | 10 + .../execution/gas-profiler/SimulatePage.tsx | 1365 +++++++++++++---- .../BlockSimulationResults.tsx | 828 ---------- .../BlockSimulationResults/index.ts | 2 - .../BlockSimulationResultsV2.tsx | 1097 +++++++++++++ .../BlockSimulationResultsV2/index.ts | 2 + .../GasScheduleDrawer/GasScheduleDrawer.tsx | 578 +++++++ .../components/GasScheduleDrawer/index.ts | 2 + .../GasScheduleEditor/GasScheduleEditor.tsx | 453 ------ .../components/GasScheduleEditor/index.ts | 2 - .../SimulatePageSkeleton.tsx | 93 -- .../components/SimulatePageSkeleton/index.ts | 1 - .../SimulatorHelpDialog.tsx | 277 ---- .../components/SimulatorHelpDialog/index.ts | 1 - .../gas-profiler/components/index.ts | 6 +- .../hooks/useBlockGasSimulation.ts | 175 --- .../ethereum/execution/gas-profiler/index.ts | 3 + 19 files changed, 2967 insertions(+), 2173 deletions(-) delete mode 100644 src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResults/BlockSimulationResults.tsx delete mode 100644 src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResults/index.ts create mode 100644 src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResultsV2/BlockSimulationResultsV2.tsx create mode 100644 src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResultsV2/index.ts create mode 100644 src/pages/ethereum/execution/gas-profiler/components/GasScheduleDrawer/GasScheduleDrawer.tsx create mode 100644 src/pages/ethereum/execution/gas-profiler/components/GasScheduleDrawer/index.ts delete mode 100644 src/pages/ethereum/execution/gas-profiler/components/GasScheduleEditor/GasScheduleEditor.tsx delete mode 100644 src/pages/ethereum/execution/gas-profiler/components/GasScheduleEditor/index.ts delete mode 100644 src/pages/ethereum/execution/gas-profiler/components/SimulatePageSkeleton/SimulatePageSkeleton.tsx delete mode 100644 src/pages/ethereum/execution/gas-profiler/components/SimulatePageSkeleton/index.ts delete mode 100644 src/pages/ethereum/execution/gas-profiler/components/SimulatorHelpDialog/SimulatorHelpDialog.tsx delete mode 100644 src/pages/ethereum/execution/gas-profiler/components/SimulatorHelpDialog/index.ts delete mode 100644 src/pages/ethereum/execution/gas-profiler/hooks/useBlockGasSimulation.ts diff --git a/src/components/DataDisplay/Stats/Stats.stories.tsx b/src/components/DataDisplay/Stats/Stats.stories.tsx index 4b3894a49..3161b1ebe 100644 --- a/src/components/DataDisplay/Stats/Stats.stories.tsx +++ b/src/components/DataDisplay/Stats/Stats.stories.tsx @@ -1,5 +1,13 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { CursorArrowRaysIcon, EnvelopeOpenIcon, UsersIcon } from '@heroicons/react/24/outline'; +import { + CursorArrowRaysIcon, + EnvelopeOpenIcon, + UsersIcon, + FireIcon, + BoltIcon, + ArrowsRightLeftIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline'; import { Stats } from './Stats'; const meta = { @@ -10,7 +18,7 @@ const meta = { }, decorators: [ Story => ( -
+
), @@ -242,3 +250,88 @@ export const SingleStat: Story = { ], }, }; + +/** Enhanced style with iconColor, valueClassName, subtitle, and accentColor */ +export const EnhancedStyle: Story = { + args: { + gridClassName: 'grid grid-cols-2 gap-3 lg:grid-cols-4', + stats: [ + { + id: 'gas-impact', + name: 'Gas Impact', + value: '+59.8%', + icon: FireIcon, + iconColor: '#ef4444', + valueClassName: 'text-red-500', + subtitle: '111.4M → 178.0M', + accentColor: '#ef44444D', + }, + { + id: 'transactions', + name: 'Transactions', + value: '1,357', + icon: BoltIcon, + iconColor: '#3b82f6', + subtitle: 'across 5 blocks', + accentColor: '#3b82f633', + }, + { + id: 'diverged', + name: 'Diverged', + value: '641', + icon: ArrowsRightLeftIcon, + iconColor: '#f59e0b', + valueClassName: 'text-amber-500', + subtitle: '47.2% of txs', + accentColor: '#f59e0b33', + }, + { + id: 'status', + name: 'Status Changes', + value: '633', + icon: ExclamationTriangleIcon, + iconColor: '#ef4444', + valueClassName: 'text-red-500', + subtitle: 'transactions changed outcome', + accentColor: '#ef444433', + }, + ], + }, +}; + +/** Enhanced style with all-green healthy state */ +export const EnhancedHealthy: Story = { + args: { + gridClassName: 'grid grid-cols-3 gap-3', + stats: [ + { + id: 'diverged', + name: 'Diverged', + value: '0', + icon: ArrowsRightLeftIcon, + iconColor: '#22c55e', + subtitle: '0% of txs', + accentColor: '#22c55e33', + }, + { + id: 'status', + name: 'Status Changes', + value: '0', + icon: ExclamationTriangleIcon, + iconColor: '#22c55e', + subtitle: 'all outcomes preserved', + accentColor: '#22c55e33', + }, + { + id: 'reverts', + name: 'Internal Reverts', + value: '-12', + icon: CursorArrowRaysIcon, + iconColor: '#22c55e', + valueClassName: 'text-green-500', + subtitle: '24 → 12 total', + accentColor: '#22c55e33', + }, + ], + }, +}; diff --git a/src/components/DataDisplay/Stats/Stats.tsx b/src/components/DataDisplay/Stats/Stats.tsx index 243f9ec7d..7aa0b20d1 100644 --- a/src/components/DataDisplay/Stats/Stats.tsx +++ b/src/components/DataDisplay/Stats/Stats.tsx @@ -5,74 +5,116 @@ import clsx from 'clsx'; import { Card } from '@/components/Layout/Card'; import type { StatsProps } from './Stats.types'; -export function Stats({ stats, title, className }: StatsProps): JSX.Element { +export function Stats({ stats, title, className, gridClassName }: StatsProps): JSX.Element { return (
{title &&

{title}

}
- {stats.map(item => ( - - - {item.link.label} - {item.name} stats - -
- ) : undefined - } - > -
- {item.icon && ( -
-
- )} -

{item.name}

-
-
-

{item.value}

- {item.delta && ( + {stats.map(item => { + const hasCustomIcon = item.icon && item.iconColor; + const hasLegacyIcon = item.icon && !item.iconColor; + + return ( + + + {item.link.label} + {item.name} stats + +
+ ) : undefined + } + > +
+ {/* Legacy icon: solid bg-primary square */} + {hasLegacyIcon && ( +
+
+ )} + + {/* Enhanced icon: tinted background with colored icon */} + {hasCustomIcon ? ( +
+
+
+

{item.name}

+
+ ) : ( +

+ {item.name} +

+ )} +
+ +

- {item.delta.type === 'increase' ? ( -

+ {item.delta && !hasCustomIcon && ( +

+ {item.delta.type === 'increase' ? ( +

+ )} +
+ + {/* Subtitle (used with enhanced icon style) */} + {item.subtitle &&
{item.subtitle}
} + + {/* Bottom accent bar */} + {item.accentColor && ( +
)} - - - ))} + + ); + })}
); diff --git a/src/components/DataDisplay/Stats/Stats.types.ts b/src/components/DataDisplay/Stats/Stats.types.ts index 08c4ce35b..8565b50d9 100644 --- a/src/components/DataDisplay/Stats/Stats.types.ts +++ b/src/components/DataDisplay/Stats/Stats.types.ts @@ -7,6 +7,14 @@ export interface Stat { name: string; value: string; icon?: ForwardRefExoticComponent>; + /** Custom CSS class for the value text (e.g. 'text-red-500' for colored values) */ + valueClassName?: string; + /** Subtitle text displayed below the value */ + subtitle?: string; + /** Color string for the icon background tint and icon itself (e.g. '#ef4444' or 'rgb(245, 158, 11)') */ + iconColor?: string; + /** Color string for the bottom accent bar (e.g. '#ef4444' or 'rgb(59, 130, 246)') */ + accentColor?: string; delta?: { value: string; type: DeltaType; @@ -21,4 +29,6 @@ export interface StatsProps { stats: Stat[]; title?: string; className?: string; + /** Override the default grid layout classes */ + gridClassName?: string; } diff --git a/src/pages/ethereum/execution/gas-profiler/SimulatePage.tsx b/src/pages/ethereum/execution/gas-profiler/SimulatePage.tsx index 8e9dbd492..b636530b1 100644 --- a/src/pages/ethereum/execution/gas-profiler/SimulatePage.tsx +++ b/src/pages/ethereum/execution/gas-profiler/SimulatePage.tsx @@ -1,240 +1,626 @@ -import { type JSX, useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import { useSearch, useNavigate, Link } from '@tanstack/react-router'; -import { ArrowLeftIcon, BeakerIcon, PlayIcon, QuestionMarkCircleIcon } from '@heroicons/react/24/outline'; +import { type JSX, useState, useCallback, useMemo, useRef, useEffect } from 'react'; +import { useSearch, Link } from '@tanstack/react-router'; +import { + ArrowLeftIcon, + BeakerIcon, + PlayIcon, + StopIcon, + AdjustmentsHorizontalIcon, + ChevronLeftIcon, + ChevronRightIcon, + XMarkIcon, + ArrowPathIcon, + FireIcon, + ArrowsRightLeftIcon, + ExclamationTriangleIcon, + BoltIcon, + CubeIcon, +} from '@heroicons/react/24/outline'; +import clsx from 'clsx'; +import ReactEChartsCore from 'echarts-for-react/lib/core'; +import * as echarts from 'echarts/core'; +import { LineChart, BarChart } from 'echarts/charts'; +import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; import { Container } from '@/components/Layout/Container'; import { Header } from '@/components/Layout/Header'; import { Card } from '@/components/Layout/Card'; +import { PopoutCard } from '@/components/Layout/PopoutCard'; +import { Stats } from '@/components/DataDisplay/Stats'; +import type { Stat } from '@/components/DataDisplay/Stats/Stats.types'; import { Alert } from '@/components/Feedback/Alert'; import { Input } from '@/components/Forms/Input'; import { Button } from '@/components/Elements/Button'; -import { GasScheduleEditor } from './components/GasScheduleEditor'; -import { BlockSimulationResults } from './components/BlockSimulationResults'; -import { SimulatePageSkeleton, SimulatorHelpDialog } from './components'; -import { useBlockGasSimulation } from './hooks/useBlockGasSimulation'; -import { useGasProfilerBounds } from './hooks/useGasProfilerBounds'; +import { useNetwork } from '@/hooks/useNetwork'; +import { useThemeColors } from '@/hooks/useThemeColors'; +import { GasScheduleDrawer } from './components/GasScheduleDrawer'; +import { BlockSimulationResultsV2 } from './components/BlockSimulationResultsV2'; import { useGasSchedule } from './hooks/useGasSchedule'; -import type { GasSchedule, GasProfilerSimulateSearch } from './SimulatePage.types'; +import type { GasSchedule, BlockSimulationResult, CallError } from './SimulatePage.types'; + +// Register ECharts components +echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]); + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +/** Maximum number of blocks that can be simulated in one range */ +const MAX_BLOCKS = 50; + +/** Available block count options */ +const BLOCK_COUNT_OPTIONS = [5, 10, 25, 50]; + +// ============================================================================ +// TYPES +// ============================================================================ + +interface RangeSimulationState { + status: 'idle' | 'running' | 'completed' | 'cancelled' | 'error'; + currentBlock: number | null; + completedBlocks: number; + totalBlocks: number; + results: BlockSimulationResult[]; + error: string | null; + startTime: number | null; +} + +interface AggregateStats { + totalOriginalGas: number; + totalSimulatedGas: number; + totalTransactions: number; + totalDiverged: number; + totalStatusChanges: number; + totalAdditionalReverts: number; + deltaPercent: number; +} + +// ============================================================================ +// UTILITIES +// ============================================================================ -/** - * Format gas value with comma separators - */ function formatGas(value: number): string { return value.toLocaleString(); } -/** - * Validate if string is a valid block number - */ -function isValidBlockNumber(value: string): boolean { - const cleaned = value.replace(/,/g, ''); - const num = parseInt(cleaned, 10); - return !isNaN(num) && num >= 0; +function formatCompactGas(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return value.toString(); } +function formatDelta(percent: number): string { + const sign = percent > 0 ? '+' : ''; + return `${sign}${percent.toFixed(1)}%`; +} + +function getDeltaColor(percent: number): string { + if (percent < -5) return 'text-green-500'; + if (percent > 5) return 'text-red-500'; + return 'text-muted'; +} + +function calculateAggregateStats(results: BlockSimulationResult[]): AggregateStats { + const stats = results.reduce( + (acc, result) => { + acc.totalOriginalGas += result.original.gasUsed; + acc.totalSimulatedGas += result.simulated.gasUsed; + acc.totalTransactions += result.transactions.length; + acc.totalDiverged += result.transactions.filter(tx => tx.diverged).length; + acc.totalStatusChanges += result.transactions.filter(tx => tx.originalStatus !== tx.simulatedStatus).length; + + const originalReverts = result.transactions.reduce((sum, tx) => sum + tx.originalReverts, 0); + const simulatedReverts = result.transactions.reduce((sum, tx) => sum + tx.simulatedReverts, 0); + acc.totalAdditionalReverts += simulatedReverts - originalReverts; + + return acc; + }, + { + totalOriginalGas: 0, + totalSimulatedGas: 0, + totalTransactions: 0, + totalDiverged: 0, + totalStatusChanges: 0, + totalAdditionalReverts: 0, + } + ); + + const deltaPercent = + stats.totalOriginalGas > 0 + ? ((stats.totalSimulatedGas - stats.totalOriginalGas) / stats.totalOriginalGas) * 100 + : 0; + + return { ...stats, deltaPercent }; +} + +// ============================================================================ +// API +// ============================================================================ + /** - * Gas Repricing Simulator page - * - * Allows researchers to re-execute historical blocks with custom gas schedules - * to analyze how proposed opcode repricing would affect real transactions. + * API response from the backend (matches Erigon response structure) */ -export function SimulatePage(): JSX.Element { - const search = useSearch({ from: '/ethereum/execution/gas-profiler/simulate' }) as GasProfilerSimulateSearch; - const navigate = useNavigate({ from: '/ethereum/execution/gas-profiler/simulate' }); +interface ApiBlockSimulationResponse { + blockNumber: number; + baseFork: string; + customSchedule: GasSchedule; + original: { + gasUsed: number; + gasLimit: number; + wouldExceedLimit: boolean; + }; + simulated: { + gasUsed: number; + gasLimit: number; + wouldExceedLimit: boolean; + }; + transactions: Array<{ + hash: string; + index: number; + originalStatus: string; + simulatedStatus: string; + originalGas: number; + simulatedGas: number; + deltaPercent: number; + diverged: boolean; + originalReverts: number; + simulatedReverts: number; + originalErrors: CallError[] | null; + simulatedErrors: CallError[] | null; + }>; + opcodeBreakdown: Record< + string, + { originalCount: number; originalGas: number; simulatedCount: number; simulatedGas: number } + >; +} - // Block number state - const [blockInput, setBlockInput] = useState(search.block?.toString() ?? ''); - const [inputError, setInputError] = useState(null); +/** Transform API response to match frontend types */ +function transformApiResponse(response: ApiBlockSimulationResponse): BlockSimulationResult { + return { + blockNumber: response.blockNumber, + baseFork: response.baseFork, + customSchedule: response.customSchedule, + original: response.original, + simulated: response.simulated, + transactions: response.transactions.map(tx => ({ + hash: tx.hash, + index: tx.index, + originalStatus: tx.originalStatus === 'success' ? ('success' as const) : ('failed' as const), + simulatedStatus: tx.simulatedStatus === 'success' ? ('success' as const) : ('failed' as const), + originalGas: tx.originalGas, + simulatedGas: tx.simulatedGas, + deltaPercent: tx.deltaPercent, + diverged: tx.diverged, + originalReverts: tx.originalReverts, + simulatedReverts: tx.simulatedReverts, + originalErrors: tx.originalErrors ?? [], + simulatedErrors: tx.simulatedErrors ?? [], + })), + opcodeBreakdown: response.opcodeBreakdown, + }; +} - // Help dialog state - const [helpOpen, setHelpOpen] = useState(false); +// ============================================================================ +// COMPONENT +// ============================================================================ - // Gas schedule state (user overrides) +export function SimulatePage(): JSX.Element { + const search = useSearch({ from: '/ethereum/execution/gas-profiler/simulate' }); + const { currentNetwork } = useNetwork(); + const themeColors = useThemeColors(); + + // Form state + const [startBlock, setStartBlock] = useState(search.block?.toString() ?? ''); + const [blockCount, setBlockCount] = useState(10); const [gasSchedule, setGasSchedule] = useState({}); + const [inputError, setInputError] = useState(null); - // Fetch bounds to validate block range - const { data: bounds, isLoading: boundsLoading, error: boundsError } = useGasProfilerBounds(); + // Drawer state + const [drawerOpen, setDrawerOpen] = useState(false); - // Parse block number from input - const blockNumber = useMemo(() => { - const cleaned = blockInput.replace(/,/g, ''); - if (!cleaned || !isValidBlockNumber(cleaned)) return null; - return parseInt(cleaned, 10); - }, [blockInput]); + // Selection state + const [selectedBlockIndex, setSelectedBlockIndex] = useState(null); - // Debounced block number for gas schedule fetch (500ms delay) - const [debouncedBlockNumber, setDebouncedBlockNumber] = useState(blockNumber); - const debounceTimerRef = useRef(null); + // Simulation state + const [simState, setSimState] = useState({ + status: 'idle', + currentBlock: null, + completedBlocks: 0, + totalBlocks: 0, + results: [], + error: null, + startTime: null, + }); - useEffect(() => { - // Clear any existing timer - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } + // Cancellation ref + const cancelledRef = useRef(false); - // Set new timer - debounceTimerRef.current = setTimeout(() => { - setDebouncedBlockNumber(blockNumber); - }, 500); + // Block card refs for scroll-into-view + const blockCardRefs = useRef>(new Map()); + const blockStripRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); - // Cleanup on unmount or when blockNumber changes + const updateScrollArrows = useCallback(() => { + const el = blockStripRef.current; + if (!el) return; + setCanScrollLeft(el.scrollLeft > 0); + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); + }, []); + + useEffect(() => { + const el = blockStripRef.current; + if (!el) return; + updateScrollArrows(); + el.addEventListener('scroll', updateScrollArrows, { passive: true }); + const observer = new ResizeObserver(updateScrollArrows); + observer.observe(el); return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } + el.removeEventListener('scroll', updateScrollArrows); + observer.disconnect(); }; - }, [blockNumber]); + }, [updateScrollArrows, simState.results.length]); + + const scrollBlockStrip = useCallback((direction: 'left' | 'right') => { + blockStripRef.current?.scrollBy({ left: direction === 'left' ? -300 : 300, behavior: 'smooth' }); + }, []); + + // Scroll selected block card into view when selection changes + useEffect(() => { + if (selectedBlockIndex === null) return; + const el = blockCardRefs.current.get(selectedBlockIndex); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); + } + }, [selectedBlockIndex]); + + // Parse start block + const startBlockNumber = useMemo(() => { + const cleaned = startBlock.replace(/,/g, ''); + const num = parseInt(cleaned, 10); + return !isNaN(num) && num >= 0 ? num : null; + }, [startBlock]); - // Fetch gas schedule defaults for the selected block's fork - // Only fetches when a valid block number is entered (debounced to avoid excessive requests) + // Fetch gas schedule defaults for the start block const { data: gasScheduleDefaults, isLoading: defaultsLoading, error: defaultsError, - } = useGasSchedule({ blockNumber: debouncedBlockNumber }); - - // Simulation hook - const { - simulate, - data: simulationResult, - isLoading: simulating, - error: simulationError, - reset, - } = useBlockGasSimulation({ - blockNumber, - gasSchedule, - }); + } = useGasSchedule({ blockNumber: startBlockNumber }); // Count of modified gas parameters - const modifiedCount = Object.keys(gasSchedule).length; + const modifiedCount = useMemo(() => { + if (!gasScheduleDefaults) return Object.keys(gasSchedule).length; + return Object.keys(gasSchedule).filter(key => { + const defaultParam = gasScheduleDefaults.parameters[key]; + return gasSchedule[key] !== undefined && defaultParam && gasSchedule[key] !== defaultParam.value; + }).length; + }, [gasSchedule, gasScheduleDefaults]); - // Update URL when block number changes - useEffect(() => { - if (blockNumber !== null && blockNumber !== search.block) { - navigate({ - search: { block: blockNumber }, - replace: true, - }); - } - }, [blockNumber, search.block, navigate]); + // Aggregate stats from completed results + const aggregateStats = useMemo(() => calculateAggregateStats(simState.results), [simState.results]); - // When new gas schedule defaults load, filter out any user overrides that don't exist in the new fork - // This preserves user changes while removing params that aren't valid for the new block's fork - useEffect(() => { - if (gasScheduleDefaults) { - setGasSchedule(prev => { - const validKeys = Object.keys(gasScheduleDefaults.parameters); - const filtered: GasSchedule = {}; - for (const [key, value] of Object.entries(prev)) { - if (validKeys.includes(key) && value !== undefined) { - filtered[key] = value; - } - } - return filtered; - }); - } - }, [gasScheduleDefaults]); + // ETA calculation + const estimatedTimeRemaining = useMemo(() => { + if (!simState.startTime || simState.completedBlocks === 0) return null; + const elapsed = Date.now() - simState.startTime; + const avgPerBlock = elapsed / simState.completedBlocks; + const remaining = (simState.totalBlocks - simState.completedBlocks) * avgPerBlock; + return Math.round(remaining / 1000); + }, [simState.startTime, simState.completedBlocks, simState.totalBlocks]); - // Handle input change - const handleInputChange = useCallback( - (e: React.ChangeEvent) => { - const value = e.target.value.replace(/,/g, ''); - setBlockInput(value); - setInputError(null); - reset(); + // Per-block delta values for chart and navigation strip + const blockDeltas = useMemo(() => { + return simState.results.map(result => { + if (result.original.gasUsed === 0) return 0; + return ((result.simulated.gasUsed - result.original.gasUsed) / result.original.gasUsed) * 100; + }); + }, [simState.results]); + + // Chart options for the trend chart + const chartOptions = useMemo(() => { + if (simState.results.length === 0) return null; + + const deltaData = blockDeltas.map((delta, i) => [i, delta]); + const divergedData = simState.results.map((r, i) => [i, r.transactions.filter(tx => tx.diverged).length]); + + return { + grid: { left: 60, right: 60, top: 30, bottom: 50 }, + legend: { + show: true, + top: 0, + right: 0, + textStyle: { color: themeColors.muted, fontSize: 11 }, + itemWidth: 12, + itemHeight: 8, + }, + xAxis: { + type: 'value' as const, + min: 0, + max: simState.results.length - 1, + axisLine: { show: true, lineStyle: { color: themeColors.border } }, + splitLine: { show: false }, + axisLabel: { + color: themeColors.muted, + fontSize: 11, + formatter: (value: number) => { + const blockNum = simState.results[value]?.blockNumber; + return blockNum ? formatGas(blockNum) : ''; + }, + }, + axisTick: { show: false }, + }, + yAxis: [ + { + type: 'value' as const, + axisLine: { show: true, lineStyle: { color: themeColors.border } }, + splitLine: { show: false }, + axisLabel: { + color: themeColors.muted, + fontSize: 11, + formatter: (value: number) => `${value > 0 ? '+' : ''}${value.toFixed(0)}%`, + }, + }, + { + type: 'value' as const, + axisLine: { show: true, lineStyle: { color: themeColors.border } }, + splitLine: { show: false }, + axisLabel: { + color: themeColors.muted, + fontSize: 11, + }, + }, + ], + tooltip: { + trigger: 'axis' as const, + backgroundColor: themeColors.background, + borderColor: themeColors.border, + textStyle: { color: themeColors.foreground, fontSize: 12 }, + formatter: (params: unknown) => { + const p = params as { data: [number, number]; seriesName: string }[]; + if (!p?.[0]) return ''; + const index = p[0].data[0]; + const blockNum = simState.results[index]?.blockNumber; + const deltaItem = p.find(s => s.seriesName === 'Gas Delta'); + const divergedItem = p.find(s => s.seriesName === 'Diverged Txs'); + let html = `Block ${formatGas(blockNum)}`; + if (deltaItem) html += `
Gas Delta: ${formatDelta(deltaItem.data[1])}`; + if (divergedItem) html += `
Diverged: ${divergedItem.data[1]} txs`; + return html; + }, + }, + series: [ + { + name: 'Diverged Txs', + type: 'bar', + yAxisIndex: 1, + data: divergedData, + barWidth: '60%', + itemStyle: { + color: `${themeColors.muted}15`, + borderRadius: [2, 2, 0, 0], + }, + emphasis: { + itemStyle: { color: `${themeColors.muted}30` }, + }, + }, + { + name: 'Gas Delta', + type: 'line', + yAxisIndex: 0, + data: deltaData, + smooth: false, + symbol: 'circle', + symbolSize: (_value: number[], params: { dataIndex: number }) => + params.dataIndex === selectedBlockIndex ? 12 : 6, + itemStyle: { + color: (params: { dataIndex: number; data: [number, number] }) => { + const delta = params.data[1]; + if (params.dataIndex === selectedBlockIndex) return themeColors.primary; + if (delta < -5) return '#22c55e'; + if (delta > 5) return '#ef4444'; + return themeColors.muted; + }, + }, + lineStyle: { + color: themeColors.primary, + width: 2, + }, + emphasis: { + itemStyle: { borderWidth: 2, borderColor: themeColors.primary }, + }, + }, + ], + animation: true, + animationDuration: 300, + }; + }, [simState.results, blockDeltas, selectedBlockIndex, themeColors]); + + // Handle chart click + const handleChartClick = useCallback( + (params: { dataIndex?: number }) => { + if (params.dataIndex !== undefined && params.dataIndex < simState.results.length) { + setSelectedBlockIndex(params.dataIndex); + } }, - [reset] + [simState.results.length] ); - // Handle simulate button click - const handleSimulate = useCallback(async () => { + // Handle input change + const handleStartBlockChange = useCallback((e: React.ChangeEvent) => { + setStartBlock(e.target.value.replace(/,/g, '')); setInputError(null); + }, []); - if (!blockInput) { - setInputError('Enter a block number'); + // Handle gas schedule change + const handleScheduleChange = useCallback((newSchedule: GasSchedule) => { + setGasSchedule(newSchedule); + setGasWarning(false); + }, []); + + // Remove a single gas override (from active changes pills) + const handleRemoveOverride = useCallback( + (key: string) => { + const newSchedule = { ...gasSchedule }; + delete newSchedule[key]; + setGasSchedule(newSchedule); + }, + [gasSchedule] + ); + + // Simulate a single block (API call) + const simulateSingleBlock = useCallback( + async (blockNumber: number): Promise => { + if (!currentNetwork) { + throw new Error('No network selected'); + } + + const response = await fetch(`/api/v1/gas-profiler/${currentNetwork.name}/simulate-block`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + blockNumber, + gasSchedule: { overrides: gasSchedule }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = errorData.error || `Failed to simulate block ${blockNumber}`; + throw new Error(errorMessage); + } + + const apiResult: ApiBlockSimulationResponse = await response.json(); + return transformApiResponse(apiResult); + }, + [gasSchedule, currentNetwork] + ); + + // Run the range simulation + const [gasWarning, setGasWarning] = useState(false); + + const handleSimulate = useCallback(async () => { + if (startBlockNumber === null) { + setInputError('Enter a valid start block'); return; } - const cleaned = blockInput.replace(/,/g, ''); - if (!isValidBlockNumber(cleaned)) { - setInputError('Invalid block number'); + if (modifiedCount === 0) { + setGasWarning(true); + setDrawerOpen(true); return; } - // Note: Bounds check is disabled for the simulation API since - // the simulation API works on any block the Erigon node has, not just indexed blocks - void bounds; + setGasWarning(false); + cancelledRef.current = false; + setSelectedBlockIndex(null); + + setSimState({ + status: 'running', + currentBlock: startBlockNumber, + completedBlocks: 0, + totalBlocks: blockCount, + results: [], + error: null, + startTime: Date.now(), + }); - await simulate(); - }, [blockInput, bounds, simulate]); + const results: BlockSimulationResult[] = []; - // Handle enter key - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSimulate(); + for (let i = 0; i < blockCount; i++) { + if (cancelledRef.current) { + setSimState(prev => ({ ...prev, status: 'cancelled' })); + return; } - }, - [handleSimulate] - ); - // Handle gas schedule change - const handleScheduleChange = useCallback( - (newSchedule: GasSchedule) => { - setGasSchedule(newSchedule); - reset(); // Clear previous results when schedule changes - }, - [reset] - ); + const currentBlockNum = startBlockNumber + i; - // Loading state for initial bounds fetch - if (boundsLoading) { - return ( - -
- - - ); - } - - // Error state for bounds - if (boundsError) { - return ( - -
- - - ); - } - - // No bounds data available - if (!bounds) { - return ( - -
- - - ); - } + setSimState(prev => ({ + ...prev, + currentBlock: currentBlockNum, + })); + + try { + const result = await simulateSingleBlock(currentBlockNum); + results.push(result); + + setSimState(prev => ({ + ...prev, + completedBlocks: prev.completedBlocks + 1, + results: [...results], + })); + + // Auto-select first block when it completes + if (results.length === 1) { + setSelectedBlockIndex(0); + } + } catch (err) { + setSimState(prev => ({ + ...prev, + status: 'error', + error: err instanceof Error ? err.message : 'Unknown error', + })); + return; + } + } + + setSimState(prev => ({ + ...prev, + status: 'completed', + currentBlock: null, + })); + }, [startBlockNumber, blockCount, simulateSingleBlock]); + + // Cancel simulation + const handleCancel = useCallback(() => { + cancelledRef.current = true; + }, []); + + // Reset simulation + const handleReset = useCallback(() => { + setSimState({ + status: 'idle', + currentBlock: null, + completedBlocks: 0, + totalBlocks: 0, + results: [], + error: null, + startTime: null, + }); + setSelectedBlockIndex(null); + }, []); + + // Get modified entries for active changes pills + const modifiedEntries = useMemo(() => { + if (!gasScheduleDefaults) return []; + return Object.entries(gasSchedule).filter(([key, value]) => { + const defaultParam = gasScheduleDefaults.parameters[key]; + return value !== undefined && defaultParam && value !== defaultParam.value; + }); + }, [gasSchedule, gasScheduleDefaults]); + + const modifiedParamNames = useMemo(() => modifiedEntries.map(([key]) => key), [modifiedEntries]); + + const isRunning = simState.status === 'running'; + const hasResults = simState.results.length > 0; + + // Block navigation + const selectedResult = selectedBlockIndex !== null ? simState.results[selectedBlockIndex] : null; + + const handlePrevBlock = useCallback(() => { + setSelectedBlockIndex(prev => (prev !== null && prev > 0 ? prev - 1 : prev)); + }, []); + + const handleNextBlock = useCallback(() => { + setSelectedBlockIndex(prev => (prev !== null && prev < simState.results.length - 1 ? prev + 1 : prev)); + }, [simState.results.length]); return ( -
+
- {/* Back link and help */} -
+ {/* Back link */} +
Gas Profiler Home -
- {/* Help dialog */} - setHelpOpen(false)} /> - - {/* Main content grid */} -
- {/* Left column - Controls */} -
- {/* Block Input */} - -
-
- -
-
-

Simulation Target

-

Choose a block to re-execute

-
-
- - + {/* Config Bar */} + +
+ {/* Start Block */} +
+ + + + + + {startBlockNumber !== null && !inputError && !isRunning && ( +

+ Range: {formatGas(startBlockNumber)} → {formatGas(startBlockNumber + blockCount - 1)} +

+ )} +
- + ))} +
+
+ + {/* Gas Schedule */} +
+ + +
+ + {/* Actions */} +
+ +
+ {!isRunning ? ( + + ) : ( + )} - - - - {/* Gas Schedule Editor - only show when defaults are loaded */} - {(defaultsLoading || (blockNumber !== null && blockNumber !== debouncedBlockNumber)) && - blockNumber !== null && - !defaultsError && ( - -
- - Loading gas parameters for block... -
-
- )} + {hasResults && !isRunning && ( + + )} +
+
+
- {defaultsError && ( - - )} + {/* Validation / Loading / Error messages */} + {inputError && ( + setInputError(null)} + /> + )} + {defaultsLoading && startBlockNumber !== null && ( +
+ + Loading gas parameters... +
+ )} + {defaultsError && ( + + )} + {gasWarning && modifiedCount === 0 && ( + setGasWarning(false)} + /> + )} + - {gasScheduleDefaults && ( - - )} + {/* Active Changes Pills */} + {modifiedEntries.length > 0 && ( +
+ {modifiedEntries.map(([key, value]) => { + const defaultVal = gasScheduleDefaults?.parameters[key]?.value ?? 0; + return ( + + {key} + + {defaultVal}→{value} + + + + ); + })} + +
+ )} + + {/* Progress Bar */} + {isRunning && ( + +
+ {/* Spinner */} +
+ + + + + + {Math.round((simState.completedBlocks / simState.totalBlocks) * 100)} + +
- {!blockNumber && ( - -
- Enter a block number to see available gas parameters for that fork. + {/* Details */} +
+
+ + Block {simState.currentBlock !== null ? formatGas(simState.currentBlock) : '...'} + + + + Simulating + +
+
+ + {simState.completedBlocks} of {simState.totalBlocks} blocks + + {estimatedTimeRemaining !== null && ( + <> + + ~{estimatedTimeRemaining}s remaining + + )}
- +
+
+ + {/* Segmented progress */} +
+ {Array.from({ length: simState.totalBlocks }).map((_, i) => ( +
+ ))} +
+ + )} + + {/* Error */} + {simState.status === 'error' && ( +
+ +
+ )} + + {/* Cancelled */} + {simState.status === 'cancelled' && ( +
+ +
+ )} + + {/* Aggregate Impact */} + {hasResults && ( +
+ {/* Stats Cards */} + { + const gasColor = + aggregateStats.deltaPercent > 0 ? '#ef4444' : aggregateStats.deltaPercent < 0 ? '#22c55e' : '#6b7280'; + const divergedColor = aggregateStats.totalDiverged > 0 ? '#f59e0b' : '#22c55e'; + const statusColor = aggregateStats.totalStatusChanges > 0 ? '#ef4444' : '#22c55e'; + + return [ + { + id: 'gas-impact', + name: 'Gas Impact', + value: formatDelta(aggregateStats.deltaPercent), + icon: FireIcon, + iconColor: gasColor, + subtitle: `${formatCompactGas(aggregateStats.totalOriginalGas)} → ${formatCompactGas(aggregateStats.totalSimulatedGas)}`, + accentColor: `${gasColor}4D`, + }, + { + id: 'transactions', + name: 'Transactions', + value: formatGas(aggregateStats.totalTransactions), + icon: BoltIcon, + iconColor: '#3b82f6', + subtitle: `across ${simState.results.length} block${simState.results.length !== 1 ? 's' : ''}`, + accentColor: '#3b82f633', + }, + { + id: 'diverged', + name: 'Diverged', + value: formatGas(aggregateStats.totalDiverged), + icon: ArrowsRightLeftIcon, + iconColor: divergedColor, + subtitle: + aggregateStats.totalTransactions > 0 + ? `${((aggregateStats.totalDiverged / aggregateStats.totalTransactions) * 100).toFixed(1)}% of txs` + : '0%', + accentColor: `${divergedColor}33`, + }, + { + id: 'status-changes', + name: 'Status Changes', + value: formatGas(aggregateStats.totalStatusChanges), + icon: ExclamationTriangleIcon, + iconColor: statusColor, + subtitle: + aggregateStats.totalStatusChanges > 0 ? 'transactions changed outcome' : 'all outcomes preserved', + accentColor: `${statusColor}33`, + }, + ] satisfies Stat[]; + })()} + /> + + {/* Trend Chart */} + {chartOptions && simState.results.length > 1 && ( + + + )}
+ )} - {/* Right column - Results */} -
- {/* Simulation error */} - {simulationError && ( - + {/* Block Navigation Strip */} + {hasResults && ( +
+ {/* Left scroll arrow */} + {canScrollLeft && ( + )} - {/* Simulation loading */} - {simulating && ( - -
-
-
-
Running Simulation
-
- Re-executing block {blockNumber !== null ? formatGas(blockNumber) : ''} with custom gas schedule... -
-
-
- + {/* Right scroll arrow */} + {canScrollRight && ( + )} - {/* Simulation results */} - {simulationResult && !simulating && } - - {/* Empty state */} - {!simulationResult && !simulating && !simulationError && ( - -
- -
-
Ready to Simulate
-
- Enter a block number and adjust gas parameters to see how repricing would affect transaction costs. - Results will show original vs simulated gas for the entire block. +
+ {simState.results.map((result, index) => { + const delta = blockDeltas[index]; + const isSelected = selectedBlockIndex === index; + const divergedCount = result.transactions.filter(tx => tx.diverged).length; + + return ( + + ); + })} + + {/* Pending blocks */} + {isRunning && + Array.from({ + length: simState.totalBlocks - simState.completedBlocks, + }).map((_, i) => ( +
+
+
Block
+
+ {startBlockNumber !== null ? formatGas(startBlockNumber + simState.completedBlocks + i) : '...'} +
+
+
+
+
Delta
+
+
-
- - )} + ))} +
-
+ )} + + {/* Block Detail Panel */} + {selectedResult && ( + + {/* Header with navigation */} +
+
+
+ +
+
+

+ {formatGas(selectedResult.blockNumber)} +

+ {(() => { + const delta = blockDeltas[selectedBlockIndex!]; + return ( + + {formatDelta(delta)} gas delta + + ); + })()} +
+
+
+ + + {selectedBlockIndex! + 1}/{simState.results.length} + + +
+
+ + {/* Reuse existing BlockSimulationResults */} +
+ +
+
+ )} + + {/* Empty State */} + {!hasResults && !isRunning && ( + +
+ +
+
Ready to Simulate
+
+ Enter a start block, configure gas parameter overrides, then simulate to see how repricing would affect + transaction execution across a range of blocks. +
+
+
+
+ )} + + {/* Gas Schedule Drawer */} + {gasScheduleDefaults && ( + setDrawerOpen(false)} + schedule={gasSchedule} + defaults={gasScheduleDefaults} + onChange={handleScheduleChange} + /> + )} ); } diff --git a/src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResults/BlockSimulationResults.tsx b/src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResults/BlockSimulationResults.tsx deleted file mode 100644 index d8149d58b..000000000 --- a/src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResults/BlockSimulationResults.tsx +++ /dev/null @@ -1,828 +0,0 @@ -import { Fragment, type JSX, useMemo, useState, useCallback, useRef, useEffect } from 'react'; -import { createPortal } from 'react-dom'; -import { Link } from '@tanstack/react-router'; -import { TabGroup, TabPanel, TabPanels } from '@headlessui/react'; -import { - CheckCircleIcon, - ExclamationTriangleIcon, - ArrowsRightLeftIcon, - ChevronDownIcon, - ChevronRightIcon, - CheckIcon, - XMarkIcon, -} from '@heroicons/react/24/outline'; -import clsx from 'clsx'; -import { Card } from '@/components/Layout/Card'; -import { Tab } from '@/components/Navigation/Tab'; -import { ScrollableTabs } from '@/components/Navigation/ScrollableTabs'; -import { EtherscanIcon } from '@/components/Ethereum/EtherscanIcon'; -import { TenderlyIcon } from '@/components/Ethereum/TenderlyIcon'; -import type { BlockSimulationResult, TxSummary, OpcodeSummary, CallError } from '../../SimulatePage.types'; - -/** - * Props for the BlockSimulationResults component - */ -export interface BlockSimulationResultsProps { - /** Simulation result data */ - result: BlockSimulationResult; - /** Optional className */ - className?: string; -} - -/** - * Format gas value with comma separators - */ -function formatGas(value: number): string { - return value.toLocaleString(); -} - -/** - * Format percentage with sign - */ -function formatDelta(percent: number): string { - const sign = percent > 0 ? '+' : ''; - return `${sign}${percent.toFixed(1)}%`; -} - -/** - * Get color class based on delta direction - */ -function getDeltaColor(percent: number): string { - if (percent < -5) return 'text-green-500'; - if (percent > 5) return 'text-red-500'; - return 'text-muted'; -} - -/** - * Renders a list of call errors with indentation based on depth, - * or a success indicator when there are no errors - */ -interface CallErrorListProps { - errors: CallError[]; - variant: 'original' | 'simulated'; - className?: string; -} - -function CallErrorList({ errors, variant, className }: CallErrorListProps): JSX.Element { - const hasErrors = errors && errors.length > 0; - const isOriginal = variant === 'original'; - - // Sort by depth ascending so tree reads root → leaf (top-level call first) - const sortedErrors = hasErrors ? [...errors].sort((a, b) => a.depth - b.depth) : []; - - return ( -
-
- - {isOriginal ? 'Original' : 'Simulated'} - -
- {hasErrors ? ( -
-
- {sortedErrors.map((err, idx) => ( -
- {err.depth > 0 ? '└─' : '•'} - {err.type} - - {err.address} - ({err.error}) -
- ))} -
-
- ) : ( -
- - No errors -
- )} -
- ); -} - -/** - * Portal-based tooltip component for inline elements - */ -interface TooltipProps { - title: string; - /** Description can be a string or JSX for rich formatting */ - description: React.ReactNode; - children: React.ReactNode; - className?: string; - style?: React.CSSProperties; - /** Width class for tooltip (default: w-56) */ - width?: string; -} - -function Tooltip({ title, description, children, className, style, width = 'w-56' }: TooltipProps): JSX.Element { - const [isVisible, setIsVisible] = useState(false); - const [position, setPosition] = useState({ top: 0, left: 0 }); - const ref = useRef(null); - - useEffect(() => { - if (isVisible && ref.current) { - const rect = ref.current.getBoundingClientRect(); - setPosition({ - top: rect.top - 8, - left: rect.left + rect.width / 2, - }); - } - }, [isVisible]); - - const tooltipContent = isVisible && ( -
-
{title}
-
{description}
-
-
- ); - - return ( -
setIsVisible(true)} - onMouseLeave={() => setIsVisible(false)} - > - {children} - {tooltipContent && createPortal(tooltipContent, document.body)} -
- ); -} - -/** - * Sort field type for transactions - */ -type SortField = 'index' | 'original' | 'simulated' | 'delta'; - -/** - * Block Simulation Results component - * - * Displays the results of a block gas simulation including: - * - Block summary comparing original vs simulated gas - * - Gas limit exceeded indicator - * - Top impacted opcodes - * - Transaction impact list - * - * @example - * ```tsx - * - * ``` - */ -export function BlockSimulationResults({ result, className }: BlockSimulationResultsProps): JSX.Element { - const [sortField, setSortField] = useState('delta'); - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); - const [visibleTxCount, setVisibleTxCount] = useState(10); - const [visibleOpcodeCount, setVisibleOpcodeCount] = useState(15); - const [expandedRows, setExpandedRows] = useState>(new Set()); - - // Toggle row expansion - const toggleRowExpansion = useCallback((txHash: string) => { - setExpandedRows(prev => { - const next = new Set(prev); - if (next.has(txHash)) { - next.delete(txHash); - } else { - next.add(txHash); - } - return next; - }); - }, []); - - // Calculate overall gas delta - const overallDelta = useMemo(() => { - if (result.original.gasUsed === 0) return 0; - return ((result.simulated.gasUsed - result.original.gasUsed) / result.original.gasUsed) * 100; - }, [result.original.gasUsed, result.simulated.gasUsed]); - - // Calculate divergence summary - const divergenceSummary = useMemo(() => { - const divergedCount = result.transactions.filter(tx => tx.diverged).length; - const statusChanges = result.transactions.filter(tx => tx.originalStatus !== tx.simulatedStatus).length; - const totalOriginalReverts = result.transactions.reduce((sum, tx) => sum + tx.originalReverts, 0); - const totalSimulatedReverts = result.transactions.reduce((sum, tx) => sum + tx.simulatedReverts, 0); - const additionalReverts = totalSimulatedReverts - totalOriginalReverts; - return { - divergedCount, - statusChanges, - totalOriginalReverts, - totalSimulatedReverts, - additionalReverts, - }; - }, [result.transactions]); - - // Sort transactions - const sortedTransactions = useMemo(() => { - const sorted = [...result.transactions].sort((a, b) => { - switch (sortField) { - case 'index': - return a.index - b.index; - case 'original': - return a.originalGas - b.originalGas; - case 'simulated': - return a.simulatedGas - b.simulatedGas; - case 'delta': - return Math.abs(a.deltaPercent) - Math.abs(b.deltaPercent); - default: - return 0; - } - }); - return sortDir === 'desc' ? sorted.reverse() : sorted; - }, [result.transactions, sortField, sortDir]); - - // Get opcode breakdown (sorted by impact when changes exist, by gas consumed otherwise) - const opcodeBreakdownData = useMemo(() => { - const opcodes = Object.entries(result.opcodeBreakdown) - .map(([opcode, summary]: [string, OpcodeSummary]) => { - // Delta based on total gas consumed (not per-opcode average) - // This shows the real impact including execution path changes - const delta = - summary.originalGas > 0 ? ((summary.simulatedGas - summary.originalGas) / summary.originalGas) * 100 : 0; - return { - opcode, - originalCount: summary.originalCount, - simulatedCount: summary.simulatedCount, - originalGas: summary.originalGas, - simulatedGas: summary.simulatedGas, - delta, - }; - }) - .filter(o => o.originalGas > 0 || o.simulatedGas > 0); // Only show opcodes that consumed gas - - // Check if there are any meaningful changes - const hasChanges = opcodes.some(o => Math.abs(o.delta) > 0.1); - - // Sort by impact if there are changes, otherwise by gas consumed - return hasChanges - ? opcodes.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) - : opcodes.sort((a, b) => b.originalGas - a.originalGas); - }, [result.opcodeBreakdown]); - - // Handle sort change - const handleSortChange = useCallback( - (field: SortField) => { - if (sortField === field) { - setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc')); - } else { - setSortField(field); - setSortDir('desc'); - } - }, - [sortField] - ); - - // Sort header component - const SortHeader = ({ field, children }: { field: SortField; children: React.ReactNode }): JSX.Element => ( - handleSortChange(field)} - className={clsx( - 'cursor-pointer px-3 py-3 text-right text-xs font-semibold whitespace-nowrap transition-colors hover:text-primary', - sortField === field ? 'text-primary' : 'text-foreground' - )} - > - - {children} - {sortField === field && {sortDir === 'asc' ? '↑' : '↓'}} - - - ); - - return ( -
- -
- - Summary - Transactions - - -
- - - {/* Summary Tab - Block Summary + Opcode Breakdown */} - - {/* Block Summary - Consolidated */} - - {/* Gas Usage with Visual Bar */} -
-
-
- {formatGas(result.original.gasUsed)} - - 0 ? 'text-red-500' : overallDelta < 0 ? 'text-green-500' : 'text-foreground' - )} - > - {formatGas(result.simulated.gasUsed)} - - - ({formatDelta(overallDelta)}) - -
-
- Limit: {formatGas(result.simulated.gasLimit)} -
-
- - {/* Visual gas bar */} -
- {/* Simulated gas fill */} - 0 - ? 'bg-amber-500' - : 'bg-green-500' - )} - style={{ - width: `${Math.min((result.simulated.gasUsed / result.simulated.gasLimit) * 100, 100)}%`, - }} - > -
- - {/* Original gas marker */} - -
- - {/* Unused capacity */} - {result.simulated.gasUsed < result.simulated.gasLimit && ( - -
- - )} - {/* 100% limit marker */} - -
- -
- - {/* Limit status */} -
- {result.simulated.wouldExceedLimit ? ( - <> - - - Exceeds limit ({Math.round((result.simulated.gasUsed / result.simulated.gasLimit) * 100)}%) - - - ) : ( - <> - - - Within limit ({Math.round((result.simulated.gasUsed / result.simulated.gasLimit) * 100)}%) - - - )} -
-
- - {/* Divider */} -
- {/* Execution stats */} -
- {divergenceSummary.divergedCount > 0 || - divergenceSummary.statusChanges > 0 || - divergenceSummary.additionalReverts !== 0 ? ( - <> - - - - {divergenceSummary.divergedCount} diverged - - - {divergenceSummary.statusChanges > 0 && ( - - - {divergenceSummary.statusChanges} tx status change - {divergenceSummary.statusChanges !== 1 ? 's' : ''} - - - )} - {divergenceSummary.additionalReverts !== 0 && ( - -

Change in nested call failures across all transactions.

-
- {divergenceSummary.totalOriginalReverts} - - 0 ? 'text-red-400' : 'text-green-400' - } - > - {divergenceSummary.totalSimulatedReverts} - - - ({divergenceSummary.additionalReverts > 0 ? '+' : ''} - {divergenceSummary.additionalReverts}) - -
-

- Internal CALL/DELEGATECALL/STATICCALL failures, not top-level tx status. -

-
- } - width="w-64" - > - - {divergenceSummary.additionalReverts > 0 ? '+' : ''} - {divergenceSummary.additionalReverts} internal revert - {Math.abs(divergenceSummary.additionalReverts) !== 1 ? 's' : ''} - - - )} - - ) : ( - - - No execution divergence - - )} -
-
- - - {/* Opcode Breakdown */} - {opcodeBreakdownData.length > 0 && ( -
-

Opcode Breakdown

-

- Total gas consumed per opcode. Changes reflect both gas cost differences and execution path - divergence. -

-
-
- - - - - - - - - - - - {opcodeBreakdownData.slice(0, visibleOpcodeCount).map(op => ( - - - - - - - - ))} - -
- Opcode - - Executions - - Original Gas - - Simulated Gas - - Delta -
{op.opcode} - {formatGas(op.originalCount)} - {op.originalCount !== op.simulatedCount && ( - → {formatGas(op.simulatedCount)} - )} - - {formatGas(op.originalGas)} - - {formatGas(op.simulatedGas)} - - {formatDelta(op.delta)} -
-
- - {/* Show More Button */} - {opcodeBreakdownData.length > visibleOpcodeCount && ( -
- -
- )} -
-
- )} - - - {/* Transaction Impact Tab */} - -
-
-

Transaction Impact

-
-
- - - D - - Diverged - - - - - E - - Errors (orig→sim) - - -
- {result.transactions.length} transactions -
-
-
-
- - - - # - - - Original - Simulated - Delta - - - - {sortedTransactions.slice(0, visibleTxCount).map((tx: TxSummary) => { - const statusChanged = tx.originalStatus !== tx.simulatedStatus; - const hasErrors = (tx.originalErrors?.length ?? 0) > 0 || (tx.simulatedErrors?.length ?? 0) > 0; - const isExpanded = expandedRows.has(tx.hash); - return ( - - toggleRowExpansion(tx.hash) : undefined} - > - - - - - - - - {/* Expanded row for call errors */} - {isExpanded && hasErrors && ( - - - - )} - - ); - })} - -
- Transaction - - Status -
-
- {hasErrors && - (isExpanded ? ( - - ) : ( - - ))} - {tx.index} -
-
-
- e.stopPropagation()} - > - {tx.hash.slice(0, 10)}...{tx.hash.slice(-8)} - - {tx.diverged && ( - - D - - )} - {hasErrors && ( - - Nested calls that reverted within this transaction. Click row to see details. - - } - width="w-56" - > - - - E - - {(tx.originalReverts > 0 || tx.simulatedReverts > 0) && ( - - {tx.originalReverts}→ - tx.originalReverts - ? 'text-red-400' - : tx.simulatedReverts < tx.originalReverts - ? 'text-green-400' - : '' - } - > - {tx.simulatedReverts} - - - )} - - - )} -
-
-
- {statusChanged ? ( - <> - - {tx.originalStatus === 'success' ? ( - - ) : ( - - )} - - - - {tx.simulatedStatus === 'success' ? ( - - ) : ( - - )} - - - ) : ( - - {tx.originalStatus === 'success' ? ( - - ) : ( - - )} - - )} -
-
- {formatGas(tx.originalGas)} - - {formatGas(tx.simulatedGas)} - - {formatDelta(tx.deltaPercent)} -
-
- - -
-
-
- - {/* Show More Button */} - {result.transactions.length > visibleTxCount && ( -
- -
- )} -
-
-
- - -
- ); -} diff --git a/src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResults/index.ts b/src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResults/index.ts deleted file mode 100644 index 819c4b0d1..000000000 --- a/src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResults/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { BlockSimulationResults } from './BlockSimulationResults'; -export type { BlockSimulationResultsProps } from './BlockSimulationResults'; diff --git a/src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResultsV2/BlockSimulationResultsV2.tsx b/src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResultsV2/BlockSimulationResultsV2.tsx new file mode 100644 index 000000000..a5eee5e0f --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResultsV2/BlockSimulationResultsV2.tsx @@ -0,0 +1,1097 @@ +import { type JSX, useMemo, useState, useCallback, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { Link } from '@tanstack/react-router'; +import { TabGroup, TabPanel, TabPanels } from '@headlessui/react'; +import { + CheckCircleIcon, + ExclamationTriangleIcon, + ArrowsRightLeftIcon, + ChevronRightIcon, + XMarkIcon, + XCircleIcon, +} from '@heroicons/react/24/outline'; +import clsx from 'clsx'; +import { Alert } from '@/components/Feedback/Alert'; +import { Card } from '@/components/Layout/Card'; +import { Stats } from '@/components/DataDisplay/Stats'; +import type { Stat } from '@/components/DataDisplay/Stats/Stats.types'; +import { Tab } from '@/components/Navigation/Tab'; +import { ScrollableTabs } from '@/components/Navigation/ScrollableTabs'; +import { EtherscanIcon } from '@/components/Ethereum/EtherscanIcon'; +import { TenderlyIcon } from '@/components/Ethereum/TenderlyIcon'; +import { PhalconIcon } from '@/components/Ethereum/PhalconIcon'; +import { getOpcodeCategory, CATEGORY_COLORS } from '../../utils/opcodeUtils'; +import type { BlockSimulationResult, TxSummary, OpcodeSummary, CallError } from '../../SimulatePage.types'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface BlockSimulationResultsV2Props { + result: BlockSimulationResult; + /** Names of gas params the user explicitly modified (e.g. ['SSTORE_SET', 'SLOAD_COLD']) */ + modifiedParams?: string[]; + className?: string; +} + +interface OpcodeRowData { + opcode: string; + category: string; + categoryColor: string; + originalCount: number; + simulatedCount: number; + originalGas: number; + simulatedGas: number; + delta: number; + absDelta: number; +} + +interface CategoryGroup { + name: string; + color: string; + totalOriginal: number; + totalSimulated: number; + delta: number; + opcodes: OpcodeRowData[]; +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +function formatGas(value: number): string { + return value.toLocaleString(); +} + +function formatCompactGas(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return value.toString(); +} + +function formatDelta(percent: number): string { + const sign = percent > 0 ? '+' : ''; + return `${sign}${percent.toFixed(1)}%`; +} + +function getDeltaColor(percent: number): string { + if (percent < 0) return 'text-green-500'; + if (percent > 0) return 'text-red-500'; + return 'text-muted'; +} + +// ============================================================================ +// TOOLTIP (portal-based, reused from original) +// ============================================================================ + +interface TooltipProps { + title: string; + description: React.ReactNode; + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; + width?: string; +} + +function Tooltip({ title, description, children, className, style, width = 'w-56' }: TooltipProps): JSX.Element { + const [isVisible, setIsVisible] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const ref = useRef(null); + + useEffect(() => { + if (isVisible && ref.current) { + const rect = ref.current.getBoundingClientRect(); + setPosition({ top: rect.top - 8, left: rect.left + rect.width / 2 }); + } + }, [isVisible]); + + const tooltipContent = isVisible && ( +
+
{title}
+
{description}
+
+
+ ); + + return ( +
setIsVisible(true)} + onMouseLeave={() => setIsVisible(false)} + > + {children} + {tooltipContent && createPortal(tooltipContent, document.body)} +
+ ); +} + +// ============================================================================ +// CALL ERROR LIST (for Transactions tab) +// ============================================================================ + +/** Contained card showing call errors for one execution side */ +function ErrorCard({ + label, + errors, + variant, +}: { + label: string; + errors: CallError[]; + variant: 'original' | 'simulated'; +}): JSX.Element { + const sorted = [...errors].sort((a, b) => a.depth - b.depth); + const isSimulated = variant === 'simulated'; + const hasErrors = sorted.length > 0; + + return ( +
+ {/* Header */} +
+
+ {label} +
+ + {/* Body */} +
+ {hasErrors ? ( +
+ {sorted.map((err, idx) => ( +
+ {err.depth > 0 && {'\u2514'}} + + {err.type} + + {err.address} + ({err.error}) +
+ ))} +
+ ) : ( +
+ + No internal call errors +
+ )} +
+
+ ); +} + +// ============================================================================ +// GAS COMPARISON SECTION +// ============================================================================ + +function GasComparisonSection({ + result, + overallDelta, +}: { + result: BlockSimulationResult; + overallDelta: number; +}): JSX.Element { + const gasLimit = result.simulated.gasLimit; + const origPct = Math.min((result.original.gasUsed / gasLimit) * 100, 100); + const simPct = Math.min((result.simulated.gasUsed / gasLimit) * 100, 100); + const isIncrease = result.simulated.gasUsed > result.original.gasUsed; + const absDelta = Math.abs(result.simulated.gasUsed - result.original.gasUsed); + + const simBarColor = overallDelta > 5 ? 'bg-red-500/30' : overallDelta < -5 ? 'bg-green-500/25' : 'bg-primary/25'; + + return ( +
+ {/* Original row */} + + Gas consumed by the block as executed on-chain. +
+ {formatGas(result.original.gasUsed)} of{' '} + {formatGas(gasLimit)} gas limit ({Math.round(origPct)}% utilization). + + } + className="flex w-full items-center gap-3" + > +
Original
+
+ {formatGas(result.original.gasUsed)} +
+
+
+
+
{Math.round(origPct)}%
+ + + {/* Simulated row */} + + Gas consumed after applying your modified gas schedule. +
+ {isIncrease ? '+' : '\u2212'} + {formatGas(absDelta)} gas ({formatDelta(overallDelta)}) compared to + original. + + } + className="flex w-full items-center gap-3" + > +
Simulated
+
+ {formatGas(result.simulated.gasUsed)} +
+
+
+ {/* Original position marker on simulated bar */} +
+
+
{Math.round(simPct)}%
+ + + {/* Footer: delta + limit status */} +
+
+ + {formatDelta(overallDelta)} + + + ({isIncrease ? '+' : '\u2212'} + {formatCompactGas(absDelta)} gas) + +
+
+ {result.simulated.wouldExceedLimit ? ( + <> + + Exceeds limit + + ) : ( + <> + + Within limit + + )} + · {formatCompactGas(gasLimit)} +
+
+
+ ); +} + +// ============================================================================ +// EXECUTION HEALTH INDICATORS +// ============================================================================ + +function ExecutionHealthSection({ + divergenceSummary, + totalTransactions, +}: { + divergenceSummary: { + divergedCount: number; + statusChanges: number; + totalOriginalReverts: number; + totalSimulatedReverts: number; + additionalReverts: number; + }; + totalTransactions: number; +}): JSX.Element { + const hasDivergence = + divergenceSummary.divergedCount > 0 || + divergenceSummary.statusChanges > 0 || + divergenceSummary.additionalReverts !== 0; + + if (!hasDivergence) { + return ; + } + + const divergedColor = divergenceSummary.divergedCount > 0 ? '#f59e0b' : '#22c55e'; + const statusColor = divergenceSummary.statusChanges > 0 ? '#ef4444' : '#22c55e'; + const revertsColor = + divergenceSummary.additionalReverts > 0 + ? '#ef4444' + : divergenceSummary.additionalReverts < 0 + ? '#22c55e' + : '#6b7280'; + + const stats: Stat[] = [ + { + id: 'diverged', + name: 'Diverged', + value: String(divergenceSummary.divergedCount), + icon: ArrowsRightLeftIcon, + iconColor: divergedColor, + valueClassName: divergenceSummary.divergedCount > 0 ? 'text-amber-500' : 'text-foreground', + subtitle: + totalTransactions > 0 && divergenceSummary.divergedCount > 0 + ? `${((divergenceSummary.divergedCount / totalTransactions) * 100).toFixed(1)}% of txs` + : undefined, + accentColor: `${divergedColor}33`, + }, + { + id: 'status', + name: 'Status Changes', + value: String(divergenceSummary.statusChanges), + icon: ExclamationTriangleIcon, + iconColor: statusColor, + valueClassName: divergenceSummary.statusChanges > 0 ? 'text-red-500' : 'text-foreground', + subtitle: divergenceSummary.statusChanges > 0 ? 'tx outcomes changed' : 'all outcomes preserved', + accentColor: `${statusColor}33`, + }, + { + id: 'reverts', + name: 'Internal Reverts', + value: `${divergenceSummary.additionalReverts > 0 ? '+' : ''}${divergenceSummary.additionalReverts}`, + icon: XMarkIcon, + iconColor: revertsColor, + valueClassName: + divergenceSummary.additionalReverts > 0 + ? 'text-red-500' + : divergenceSummary.additionalReverts < 0 + ? 'text-green-500' + : 'text-foreground', + subtitle: + divergenceSummary.totalOriginalReverts > 0 || divergenceSummary.totalSimulatedReverts > 0 + ? `${divergenceSummary.totalOriginalReverts} \u2192 ${divergenceSummary.totalSimulatedReverts} total` + : undefined, + accentColor: `${revertsColor}33`, + }, + ]; + + return ; +} + +// ============================================================================ +// OPCODE COMPARISON ROW +// ============================================================================ + +function OpcodeRow({ op, maxGas }: { op: OpcodeRowData; maxGas: number }): JSX.Element { + const origWidth = maxGas > 0 ? (op.originalGas / maxGas) * 100 : 0; + const simWidth = maxGas > 0 ? (op.simulatedGas / maxGas) * 100 : 0; + const hasCountChange = op.originalCount !== op.simulatedCount; + + return ( + + Original + {formatGas(op.originalGas)} + Simulated + {formatGas(op.simulatedGas)} + Delta + + {formatDelta(op.delta)} + + {hasCountChange && ( + <> + Executions + + {formatGas(op.originalCount)} → {formatGas(op.simulatedCount)} + + + )} +
+ } + style={{ display: 'block' }} + > +
+ {/* Main row — mirrors category header pattern */} +
+
+ {op.opcode} + {hasCountChange && ( + + {formatGas(op.originalCount)} → {formatGas(op.simulatedCount)} execs + + )} +
+ + {formatCompactGas(op.originalGas)} → {formatCompactGas(op.simulatedGas)} + + + {formatDelta(op.delta)} + +
+ {/* Mini before/after bars */} +
+
+
0 ? 0.5 : 0)}%` }} + /> +
+
+
0 ? 0.5 : 0)}%`, + backgroundColor: `${op.categoryColor}60`, + }} + /> +
+
+
+ + ); +} + +// ============================================================================ +// OPCODE BREAKDOWN SECTION (category-grouped) +// ============================================================================ + +function OpcodeBreakdownSection({ + categoryData, + maxGas, + modifiedParams, +}: { + categoryData: CategoryGroup[]; + maxGas: number; + modifiedParams?: string[]; +}): JSX.Element { + const [expandedCategories, setExpandedCategories] = useState>(() => { + const expanded = new Set(); + + if (modifiedParams && modifiedParams.length > 0) { + // Map modified gas params to their categories, then expand those categories + const modifiedCategories = new Set(modifiedParams.map(p => getOpcodeCategory(p))); + for (const cat of categoryData) { + if (modifiedCategories.has(cat.name)) { + expanded.add(cat.name); + } + } + } + + // Fallback: if no modified params matched, expand categories with delta changes + if (expanded.size === 0) { + for (const cat of categoryData) { + if (Math.abs(cat.delta) > 0.1 || cat.opcodes.some(o => Math.abs(o.delta) > 0.1)) { + expanded.add(cat.name); + } + } + } + + // Final fallback: expand top 3 by gas + if (expanded.size === 0) { + categoryData.slice(0, 3).forEach(c => expanded.add(c.name)); + } + + return expanded; + }); + + const toggleCategory = useCallback((name: string) => { + setExpandedCategories(prev => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }, []); + + return ( +
+ {/* Category groups */} +
+ {categoryData.map(cat => { + const isExpanded = expandedCategories.has(cat.name); + return ( +
+ {/* Category header */} + + + {/* Opcode rows */} + {isExpanded && ( +
+ {cat.opcodes.map(op => ( + + ))} +
+ )} +
+ ); + })} +
+
+ ); +} + +// ============================================================================ +// TRANSACTION IMPACT VIEW (card-based, self-contained) +// ============================================================================ + +type TxFilter = 'all' | 'diverged' | 'status' | 'errors'; +type TxSort = 'delta' | 'index' | 'gas'; + +function TransactionImpactView({ + transactions, + blockNumber, +}: { + transactions: TxSummary[]; + blockNumber: number; +}): JSX.Element { + const [filter, setFilter] = useState('all'); + const [sortBy, setSortBy] = useState('delta'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + const [expandedTxs, setExpandedTxs] = useState>(new Set()); + const [visibleCount, setVisibleCount] = useState(20); + + const toggleExpand = useCallback((hash: string) => { + setExpandedTxs(prev => { + const next = new Set(prev); + if (next.has(hash)) next.delete(hash); + else next.add(hash); + return next; + }); + }, []); + + const counts = useMemo( + () => ({ + all: transactions.length, + diverged: transactions.filter(t => t.diverged).length, + status: transactions.filter(t => t.originalStatus !== t.simulatedStatus).length, + errors: transactions.filter(t => (t.originalErrors?.length ?? 0) > 0 || (t.simulatedErrors?.length ?? 0) > 0) + .length, + }), + [transactions] + ); + + const filteredAndSorted = useMemo(() => { + let txs = [...transactions]; + + switch (filter) { + case 'diverged': + txs = txs.filter(t => t.diverged); + break; + case 'status': + txs = txs.filter(t => t.originalStatus !== t.simulatedStatus); + break; + case 'errors': + txs = txs.filter(t => (t.originalErrors?.length ?? 0) > 0 || (t.simulatedErrors?.length ?? 0) > 0); + break; + } + + txs.sort((a, b) => { + switch (sortBy) { + case 'index': + return a.index - b.index; + case 'gas': + return a.simulatedGas - b.simulatedGas; + case 'delta': + return Math.abs(a.deltaPercent) - Math.abs(b.deltaPercent); + default: + return 0; + } + }); + + if (sortDir === 'desc') txs.reverse(); + return txs; + }, [transactions, filter, sortBy, sortDir]); + + const toggleSort = useCallback( + (field: TxSort) => { + if (sortBy === field) { + setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortBy(field); + setSortDir('desc'); + } + }, + [sortBy] + ); + + const filterOptions: { key: TxFilter; label: string; count: number }[] = [ + { key: 'all', label: 'All', count: counts.all }, + { key: 'diverged', label: 'Diverged', count: counts.diverged }, + { key: 'status', label: 'Status \u0394', count: counts.status }, + { key: 'errors', label: 'Errors', count: counts.errors }, + ]; + + const sortOptions: { key: TxSort; label: string }[] = [ + { key: 'delta', label: 'Impact' }, + { key: 'index', label: 'Index' }, + { key: 'gas', label: 'Gas' }, + ]; + + return ( +
+ {/* Filter + Sort bar */} +
+ {/* Filters */} +
+ {filterOptions.map(opt => ( + + ))} +
+ +
+ + {/* Sort */} +
+ Sort + {sortOptions.map(opt => ( + + ))} +
+
+ + {/* Transaction cards */} +
+ {filteredAndSorted.slice(0, visibleCount).map(tx => { + const statusChanged = tx.originalStatus !== tx.simulatedStatus; + const hasErrors = (tx.originalErrors?.length ?? 0) > 0 || (tx.simulatedErrors?.length ?? 0) > 0; + const isExpanded = expandedTxs.has(tx.hash); + const isNotable = statusChanged || tx.diverged || hasErrors; + const absDelta = Math.abs(tx.simulatedGas - tx.originalGas); + + return ( +
+ {/* Collapsed row — always visible */} + + + {/* Expanded detail section */} + {isExpanded && ( +
+ {/* Detail grid */} +
+
+
Status
+
+ {statusChanged ? ( + + {tx.originalStatus === 'success' ? 'Success' : 'Failed'} →{' '} + {tx.simulatedStatus === 'success' ? 'Success' : 'Failed'} + + ) : ( + + {tx.originalStatus === 'success' ? 'Success' : 'Failed'} + + )} +
+
+
+
Diverged
+
+ + {tx.diverged ? ( + + Yes + + ) : ( + + No + + )} + +
+
+
+
Gas Impact
+
+ {tx.deltaPercent > 0 ? '+' : tx.deltaPercent < 0 ? '\u2212' : ''} + {formatGas(absDelta)} +
+
+
+
Call Errors
+
+ {hasErrors ? ( + + {tx.originalErrors.length} → {tx.simulatedErrors.length} + + ) : ( + None + )} +
+
+
+ + {/* Error cards (if any) */} + {hasErrors && ( +
+ + +
+ )} + + {/* Action links */} +
+ + View tx details → + + +
+
+ )} +
+ ); + })} +
+ + {/* Load more */} + {filteredAndSorted.length > visibleCount && ( +
+ +
+ )} + + {filteredAndSorted.length === 0 && ( +
No transactions match the selected filter.
+ )} +
+ ); +} + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export function BlockSimulationResultsV2({ + result, + modifiedParams, + className, +}: BlockSimulationResultsV2Props): JSX.Element { + // Overall gas delta + const overallDelta = useMemo(() => { + if (result.original.gasUsed === 0) return 0; + return ((result.simulated.gasUsed - result.original.gasUsed) / result.original.gasUsed) * 100; + }, [result.original.gasUsed, result.simulated.gasUsed]); + + // Divergence summary + const divergenceSummary = useMemo(() => { + const divergedCount = result.transactions.filter(tx => tx.diverged).length; + const statusChanges = result.transactions.filter(tx => tx.originalStatus !== tx.simulatedStatus).length; + const totalOriginalReverts = result.transactions.reduce((sum, tx) => sum + tx.originalReverts, 0); + const totalSimulatedReverts = result.transactions.reduce((sum, tx) => sum + tx.simulatedReverts, 0); + return { + divergedCount, + statusChanges, + totalOriginalReverts, + totalSimulatedReverts, + additionalReverts: totalSimulatedReverts - totalOriginalReverts, + }; + }, [result.transactions]); + + // Opcode data with categories + const { categoryData, maxGas } = useMemo(() => { + const opcodes: OpcodeRowData[] = Object.entries(result.opcodeBreakdown) + .map(([opcode, summary]: [string, OpcodeSummary]) => { + const delta = + summary.originalGas > 0 + ? ((summary.simulatedGas - summary.originalGas) / summary.originalGas) * 100 + : summary.simulatedGas > 0 + ? 100 + : 0; + const category = getOpcodeCategory(opcode); + return { + opcode, + category, + categoryColor: CATEGORY_COLORS[category] || '#9ca3af', + originalCount: summary.originalCount, + simulatedCount: summary.simulatedCount, + originalGas: summary.originalGas, + simulatedGas: summary.simulatedGas, + delta, + absDelta: Math.abs(summary.simulatedGas - summary.originalGas), + }; + }) + .filter(o => o.originalGas > 0 || o.simulatedGas > 0); + + const maxGas = Math.max(...opcodes.map(o => Math.max(o.originalGas, o.simulatedGas)), 1); + + // Group by category + const catMap = new Map< + string, + { name: string; color: string; totalOriginal: number; totalSimulated: number; opcodes: OpcodeRowData[] } + >(); + for (const op of opcodes) { + const existing = catMap.get(op.category); + if (existing) { + existing.totalOriginal += op.originalGas; + existing.totalSimulated += op.simulatedGas; + existing.opcodes.push(op); + } else { + catMap.set(op.category, { + name: op.category, + color: op.categoryColor, + totalOriginal: op.originalGas, + totalSimulated: op.simulatedGas, + opcodes: [op], + }); + } + } + + const categories: CategoryGroup[] = Array.from(catMap.values()) + .map(cat => ({ + ...cat, + delta: cat.totalOriginal > 0 ? ((cat.totalSimulated - cat.totalOriginal) / cat.totalOriginal) * 100 : 0, + })) + .sort((a, b) => Math.max(b.totalSimulated, b.totalOriginal) - Math.max(a.totalSimulated, a.totalOriginal)); + + // Sort opcodes within each category by absolute delta + for (const cat of categories) { + cat.opcodes.sort((a, b) => b.absDelta - a.absDelta); + } + + return { categoryData: categories, maxGas }; + }, [result.opcodeBreakdown]); + + return ( +
+ +
+ + Summary + Opcode Breakdown + Transactions + + +
+ + + {/* ============================================================ */} + {/* SUMMARY TAB */} + {/* ============================================================ */} + + {/* Gas Comparison */} + + + + + {/* Execution Health */} + + + + {/* ============================================================ */} + {/* OPCODE BREAKDOWN TAB */} + {/* ============================================================ */} + + {categoryData.length > 0 && ( + + )} + + + {/* ============================================================ */} + {/* TRANSACTIONS TAB */} + {/* ============================================================ */} + + + + +
+
+ ); +} diff --git a/src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResultsV2/index.ts b/src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResultsV2/index.ts new file mode 100644 index 000000000..82f045edf --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/components/BlockSimulationResultsV2/index.ts @@ -0,0 +1,2 @@ +export { BlockSimulationResultsV2 } from './BlockSimulationResultsV2'; +export type { BlockSimulationResultsV2Props } from './BlockSimulationResultsV2'; diff --git a/src/pages/ethereum/execution/gas-profiler/components/GasScheduleDrawer/GasScheduleDrawer.tsx b/src/pages/ethereum/execution/gas-profiler/components/GasScheduleDrawer/GasScheduleDrawer.tsx new file mode 100644 index 000000000..4f2eeee11 --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/components/GasScheduleDrawer/GasScheduleDrawer.tsx @@ -0,0 +1,578 @@ +import { type JSX, useState, useCallback, useMemo, useRef, useEffect, Fragment } from 'react'; +import { createPortal } from 'react-dom'; +import { Dialog, DialogBackdrop, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; +import { XMarkIcon, MagnifyingGlassIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; +import { QuestionMarkCircleIcon } from '@heroicons/react/24/solid'; +import clsx from 'clsx'; +import type { GasSchedule, GasScheduleDefaults } from '../../SimulatePage.types'; +import { getOpcodeCategory, CATEGORY_COLORS } from '../../utils/opcodeUtils'; + +/** Category ordering for display */ +const CATEGORY_ORDER = [ + 'Storage', + 'Transient Storage', + 'Contract', + 'Ethereum State', + 'Hashing', + 'Log', + 'Math', + 'Comparisons', + 'Logic', + 'Bit Ops', + 'Jump', + 'Pop', + 'Push', + 'Dup', + 'Swap', + 'Memory', + 'Misc', + 'Other', +]; + +/** Multiplier options for category-wide scaling */ +const MULTIPLIER_OPTIONS = [0.5, 1.5, 2, 3, 5]; + +/** + * Props for the GasScheduleDrawer component + */ +export interface GasScheduleDrawerProps { + /** Whether the drawer is open */ + open: boolean; + /** Callback to close the drawer */ + onClose: () => void; + /** Current gas schedule overrides */ + schedule: GasSchedule; + /** Default gas schedule values with descriptions (from API) */ + defaults: GasScheduleDefaults; + /** Callback when schedule changes */ + onChange: (schedule: GasSchedule) => void; +} + +/** Grouped category structure */ +interface CategoryGroup { + name: string; + color: string; + keys: string[]; +} + +/** + * Derive slider constraints from default value + */ +function getSliderConstraints(defaultValue: number): { min: number; max: number; step: number } { + if (defaultValue === 0) { + return { min: 0, max: 100, step: 1 }; + } + if (defaultValue <= 10) { + return { min: 0, max: Math.max(50, defaultValue * 5), step: 1 }; + } + if (defaultValue <= 100) { + return { min: 0, max: Math.max(500, defaultValue * 5), step: 10 }; + } + if (defaultValue <= 1000) { + return { min: 0, max: Math.max(5000, defaultValue * 5), step: 50 }; + } + if (defaultValue <= 10000) { + return { min: 0, max: Math.max(50000, defaultValue * 5), step: 100 }; + } + return { min: 0, max: defaultValue * 5, step: 1000 }; +} + +/** + * Info tooltip component with portal-based positioning + */ +function InfoTooltip({ title, description }: { title: string; description: string }): JSX.Element { + const [isVisible, setIsVisible] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const buttonRef = useRef(null); + + useEffect(() => { + if (isVisible && buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setPosition({ + top: rect.top - 8, + left: rect.left + rect.width / 2, + }); + } + }, [isVisible]); + + const tooltipContent = isVisible && ( +
+
{title}
+
{description}
+
+
+ ); + + return ( + + + {tooltipContent && createPortal(tooltipContent, document.body)} + + ); +} + +/** + * GasScheduleDrawer - Slide-out panel for editing gas schedule parameters + * + * Features: + * - Search bar for filtering parameters + * - Category filter chips + * - Per-category multiplier dropdown + * - Compact parameter rows with inline slider and number input + * - Active changes pills at top + * - Modified parameters sorted first within each category + */ +export function GasScheduleDrawer({ + open, + onClose, + schedule, + defaults, + onChange, +}: GasScheduleDrawerProps): JSX.Element { + const [searchQuery, setSearchQuery] = useState(''); + const [activeCategory, setActiveCategory] = useState(null); + + // Build category groups from defaults + const groups = useMemo((): CategoryGroup[] => { + const categoryMap = new Map(); + + for (const key of Object.keys(defaults.parameters)) { + const category = getOpcodeCategory(key); + if (!categoryMap.has(category)) { + categoryMap.set(category, []); + } + categoryMap.get(category)!.push(key); + } + + // Sort keys within each category + for (const keys of categoryMap.values()) { + keys.sort(); + } + + // Sort categories by predefined order + return Array.from(categoryMap.entries()) + .map(([name, keys]) => ({ + name, + color: CATEGORY_COLORS[name] || CATEGORY_COLORS.Other, + keys, + })) + .sort((a, b) => { + const aIndex = CATEGORY_ORDER.indexOf(a.name); + const bIndex = CATEGORY_ORDER.indexOf(b.name); + return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex); + }); + }, [defaults]); + + // Get all modified entries + const modifiedEntries = useMemo(() => { + return Object.entries(schedule).filter(([key, value]) => { + const defaultParam = defaults.parameters[key]; + return value !== undefined && defaultParam && value !== defaultParam.value; + }); + }, [schedule, defaults]); + + // Handle individual parameter change + const handleParamChange = useCallback( + (key: string, value: number | undefined) => { + const newSchedule = { ...schedule }; + if (value === undefined) { + delete newSchedule[key]; + } else { + newSchedule[key] = value; + } + onChange(newSchedule); + }, + [schedule, onChange] + ); + + // Handle category multiplier + const handleCategoryMultiplier = useCallback( + (categoryKeys: string[], multiplier: number) => { + const newSchedule = { ...schedule }; + for (const key of categoryKeys) { + const defaultParam = defaults.parameters[key]; + if (defaultParam) { + const newValue = Math.round(defaultParam.value * multiplier); + if (newValue === defaultParam.value) { + delete newSchedule[key]; + } else { + newSchedule[key] = newValue; + } + } + } + onChange(newSchedule); + }, + [schedule, defaults, onChange] + ); + + // Reset all + const handleResetAll = useCallback(() => { + onChange({}); + }, [onChange]); + + // Remove single override + const handleRemoveOverride = useCallback( + (key: string) => { + const newSchedule = { ...schedule }; + delete newSchedule[key]; + onChange(newSchedule); + }, + [schedule, onChange] + ); + + // Filter groups by search and active category + const filteredGroups = useMemo(() => { + return groups + .filter(group => !activeCategory || group.name === activeCategory) + .map(group => { + if (!searchQuery) return group; + const filtered = group.keys.filter(key => key.toLowerCase().includes(searchQuery.toLowerCase())); + return { ...group, keys: filtered }; + }) + .filter(group => group.keys.length > 0); + }, [groups, searchQuery, activeCategory]); + + // Sort keys within each group: modified first, then alphabetical + const getSortedKeys = useCallback( + (keys: string[]): string[] => { + return [...keys].sort((a, b) => { + const aModified = schedule[a] !== undefined && schedule[a] !== defaults.parameters[a]?.value; + const bModified = schedule[b] !== undefined && schedule[b] !== defaults.parameters[b]?.value; + if (aModified && !bModified) return -1; + if (!aModified && bModified) return 1; + return a.localeCompare(b); + }); + }, + [schedule, defaults] + ); + + // Count modified in a category + const getModifiedCount = useCallback( + (keys: string[]): number => { + return keys.filter(key => { + const value = schedule[key]; + const defaultParam = defaults.parameters[key]; + return value !== undefined && defaultParam && value !== defaultParam.value; + }).length; + }, + [schedule, defaults] + ); + + return ( + + + + + + +
+
+
+ + +
+ {/* Header */} +
+
+ Gas Schedule + +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full bg-transparent text-sm text-foreground placeholder:text-muted focus:outline-hidden" + /> + {searchQuery && ( + + )} +
+ + {/* Category Chips */} +
+ + {groups.map(group => ( + + ))} +
+
+ + {/* Active Changes */} + {modifiedEntries.length > 0 && ( +
+
+ + Active Changes ({modifiedEntries.length}) + + +
+
+ {modifiedEntries.map(([key, value]) => { + const defaultVal = defaults.parameters[key]?.value ?? 0; + return ( + + {key} + + {defaultVal}→{value} + + + + ); + })} +
+
+ )} + + {/* Parameter Categories */} +
+ {filteredGroups.map(group => { + const modCount = getModifiedCount(group.keys); + const sortedKeys = getSortedKeys(group.keys); + + return ( +
+ {/* Category Header */} +
+
+ + {group.name} + {modCount > 0 && ( + + {modCount} + + )} +
+ + {/* Multiplier Dropdown */} +
+ {MULTIPLIER_OPTIONS.map(mult => ( + + ))} +
+
+ + {/* Parameter Rows */} +
+ {sortedKeys.map(key => { + const param = defaults.parameters[key]; + if (!param) return null; + const currentValue = schedule[key] ?? param.value; + const isModified = schedule[key] !== undefined && schedule[key] !== param.value; + const { min, max, step } = getSliderConstraints(param.value); + + return ( +
+ {/* Name */} +
+ + {key} + + {param.description && } +
+ + {/* Number Input */} + { + const val = Number(e.target.value); + handleParamChange(key, val === param.value ? undefined : val); + }} + className={clsx( + 'w-20 shrink-0 rounded-xs border bg-surface px-2 py-1 text-right font-mono text-xs focus:ring-1 focus:ring-primary focus:outline-hidden', + isModified ? 'border-primary/30 text-primary' : 'border-border text-foreground' + )} + /> + + {/* Slider */} + { + const val = Number(e.target.value); + handleParamChange(key, val === param.value ? undefined : val); + }} + className={clsx( + 'min-w-0 flex-1 cursor-pointer appearance-none bg-transparent', + '[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-xs [&::-webkit-slider-runnable-track]:bg-border', + '[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-xs [&::-moz-range-track]:bg-border', + '[&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full', + isModified + ? '[&::-webkit-slider-thumb]:bg-primary' + : '[&::-webkit-slider-thumb]:bg-muted', + '[&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0', + isModified + ? '[&::-moz-range-thumb]:bg-primary' + : '[&::-moz-range-thumb]:bg-muted' + )} + /> + + {/* Default value label */} + + ({param.value.toLocaleString()}) + + + {/* Reset button */} + {isModified && ( + + )} +
+ ); + })} +
+
+ ); + })} + + {filteredGroups.length === 0 && ( +
No parameters match your search.
+ )} +
+ + {/* Footer */} +
+
+ + {modifiedEntries.length > 0 + ? `${modifiedEntries.length} parameter${modifiedEntries.length !== 1 ? 's' : ''} modified` + : 'No changes'} + + +
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/pages/ethereum/execution/gas-profiler/components/GasScheduleDrawer/index.ts b/src/pages/ethereum/execution/gas-profiler/components/GasScheduleDrawer/index.ts new file mode 100644 index 000000000..12acf10ae --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/components/GasScheduleDrawer/index.ts @@ -0,0 +1,2 @@ +export { GasScheduleDrawer } from './GasScheduleDrawer'; +export type { GasScheduleDrawerProps } from './GasScheduleDrawer'; diff --git a/src/pages/ethereum/execution/gas-profiler/components/GasScheduleEditor/GasScheduleEditor.tsx b/src/pages/ethereum/execution/gas-profiler/components/GasScheduleEditor/GasScheduleEditor.tsx deleted file mode 100644 index 0fb233bba..000000000 --- a/src/pages/ethereum/execution/gas-profiler/components/GasScheduleEditor/GasScheduleEditor.tsx +++ /dev/null @@ -1,453 +0,0 @@ -import { type JSX, useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import { createPortal } from 'react-dom'; -import { ChevronDownIcon, ChevronRightIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; -import { QuestionMarkCircleIcon } from '@heroicons/react/24/solid'; -import clsx from 'clsx'; -import { Card } from '@/components/Layout/Card'; -import { Button } from '@/components/Elements/Button'; -import type { GasSchedule, GasScheduleDefaults } from '../../SimulatePage.types'; -import { getOpcodeCategory, isGasParameter, CATEGORY_COLORS } from '../../utils/opcodeUtils'; - -/** - * Info tooltip component with portal-based positioning - * Shows a styled tooltip on hover with title and description - */ -interface InfoTooltipProps { - title: string; - description: string; -} - -function InfoTooltip({ title, description }: InfoTooltipProps): JSX.Element { - const [isVisible, setIsVisible] = useState(false); - const [position, setPosition] = useState({ top: 0, left: 0 }); - const buttonRef = useRef(null); - - const handleMouseEnter = useCallback(() => setIsVisible(true), []); - const handleMouseLeave = useCallback(() => setIsVisible(false), []); - const handleFocus = useCallback(() => setIsVisible(true), []); - const handleBlur = useCallback(() => setIsVisible(false), []); - - // Calculate position when tooltip becomes visible - useEffect(() => { - if (isVisible && buttonRef.current) { - const rect = buttonRef.current.getBoundingClientRect(); - setPosition({ - top: rect.top - 8, - left: rect.left + rect.width / 2, - }); - } - }, [isVisible]); - - const tooltipContent = isVisible && ( -
-
{title}
-
{description}
- {/* Tooltip arrow */} -
-
- ); - - return ( - - - {tooltipContent && createPortal(tooltipContent, document.body)} - - ); -} - -/** - * Props for the GasScheduleEditor component - */ -export interface GasScheduleEditorProps { - /** Current gas schedule values (user overrides) */ - schedule: GasSchedule; - /** Default gas schedule values with descriptions (from API) */ - defaults: GasScheduleDefaults; - /** Callback when any value changes */ - onChange: (schedule: GasSchedule) => void; - /** Optional className for the container */ - className?: string; -} - -/** - * Derive slider constraints from default value - */ -function getSliderConstraints(defaultValue: number): { min: number; max: number; step: number } { - if (defaultValue === 0) { - return { min: 0, max: 100, step: 1 }; - } - if (defaultValue <= 10) { - return { min: 0, max: Math.max(50, defaultValue * 5), step: 1 }; - } - if (defaultValue <= 100) { - return { min: 0, max: Math.max(500, defaultValue * 5), step: 10 }; - } - if (defaultValue <= 1000) { - return { min: 0, max: Math.max(5000, defaultValue * 5), step: 50 }; - } - if (defaultValue <= 10000) { - return { min: 0, max: Math.max(50000, defaultValue * 5), step: 100 }; - } - return { min: 0, max: defaultValue * 5, step: 1000 }; -} - -/** - * Props for individual gas parameter slider - */ -interface GasParameterSliderProps { - name: string; - value: number | undefined; - defaultValue: number; - description?: string; - onChange: (value: number | undefined) => void; -} - -/** - * Individual gas parameter slider with label, description tooltip, and reset - */ -function GasParameterSlider({ - name, - value, - defaultValue, - description, - onChange, -}: GasParameterSliderProps): JSX.Element { - const currentValue = value ?? defaultValue; - const isModified = value !== undefined && value !== defaultValue; - const deltaPercent = defaultValue > 0 ? ((currentValue - defaultValue) / defaultValue) * 100 : 0; - const { min, max, step } = getSliderConstraints(defaultValue); - - const handleInputChange = useCallback( - (e: React.ChangeEvent) => { - const newValue = Number(e.target.value); - if (newValue === defaultValue) { - onChange(undefined); - } else { - onChange(newValue); - } - }, - [defaultValue, onChange] - ); - - const handleReset = useCallback(() => { - onChange(undefined); - }, [onChange]); - - return ( -
-
-
- - {description && } -
-
- {isModified && ( - - {deltaPercent > 0 ? '+' : ''} - {deltaPercent.toFixed(0)}% - - )} - - {currentValue.toLocaleString()} - - {isModified && ( - - )} -
-
-
- - ({defaultValue.toLocaleString()}) -
-
- ); -} - -/** - * Collapsible section for a group of gas parameters - */ -interface GasParameterSectionProps { - name: string; - color: string; - children: React.ReactNode; - defaultExpanded?: boolean; - modifiedCount: number; -} - -function GasParameterSection({ - name, - color, - children, - defaultExpanded = false, - modifiedCount, -}: GasParameterSectionProps): JSX.Element { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); - - return ( -
- - {isExpanded &&
{children}
} -
- ); -} - -/** Category ordering for display */ -const CATEGORY_ORDER = [ - 'Storage', - 'Transient Storage', - 'Contract', - 'Ethereum State', - 'Hashing', - 'Log', - 'Math', - 'Comparisons', - 'Logic', - 'Bit Ops', - 'Jump', - 'Pop', - 'Push', - 'Dup', - 'Swap', - 'Memory', - 'Misc', - 'Other', -]; - -/** - * Gas Schedule Editor component - * - * Dynamically generates UI from API response - no hardcoded parameter list. - * Uses getOpcodeCategory() to group parameters by opcode category. - * Shows descriptions from API as tooltips on hover. - */ -export function GasScheduleEditor({ schedule, defaults, onChange, className }: GasScheduleEditorProps): JSX.Element { - // Build groups dynamically from defaults (API response) - // Each group has opcodes (actual EVM instructions) and parameters (cost modifiers) - const groups = useMemo(() => { - const categoryMap = new Map(); - - // Group all items by category, separating opcodes from parameters - for (const key of Object.keys(defaults.parameters)) { - const category = getOpcodeCategory(key); - const color = CATEGORY_COLORS[category] || CATEGORY_COLORS.Other; - - if (!categoryMap.has(category)) { - categoryMap.set(category, { name: category, color, opcodes: [], parameters: [] }); - } - - const group = categoryMap.get(category)!; - if (isGasParameter(key)) { - group.parameters.push(key); - } else { - group.opcodes.push(key); - } - } - - // Sort items within each category - for (const group of categoryMap.values()) { - group.opcodes.sort(); - group.parameters.sort(); - } - - // Sort categories by predefined order - const sortedGroups = Array.from(categoryMap.values()).sort((a, b) => { - const aIndex = CATEGORY_ORDER.indexOf(a.name); - const bIndex = CATEGORY_ORDER.indexOf(b.name); - const aOrder = aIndex === -1 ? 999 : aIndex; - const bOrder = bIndex === -1 ? 999 : bIndex; - return aOrder - bOrder; - }); - - return sortedGroups; - }, [defaults]); - - // Count total modified parameters - const modifiedCount = useMemo(() => { - return Object.keys(schedule).filter(key => { - const defaultParam = defaults.parameters[key]; - return schedule[key] !== undefined && defaultParam && schedule[key] !== defaultParam.value; - }).length; - }, [schedule, defaults]); - - // Handle individual parameter change - const handleParameterChange = useCallback( - (key: string, value: number | undefined) => { - const newSchedule = { ...schedule }; - if (value === undefined) { - delete newSchedule[key]; - } else { - newSchedule[key] = value; - } - onChange(newSchedule); - }, - [schedule, onChange] - ); - - // Reset all parameters to defaults - const handleResetAll = useCallback(() => { - onChange({}); - }, [onChange]); - - // Get modified count for a list of keys - const getModifiedCount = useCallback( - (keys: string[]): number => { - return keys.filter(key => { - const value = schedule[key]; - const defaultParam = defaults.parameters[key]; - return value !== undefined && defaultParam && value !== defaultParam.value; - }).length; - }, - [schedule, defaults] - ); - - // Get modified count for a group (opcodes + parameters) - const getGroupModifiedCount = useCallback( - (group: { opcodes: string[]; parameters: string[] }): number => { - return getModifiedCount([...group.opcodes, ...group.parameters]); - }, - [getModifiedCount] - ); - - return ( - - {/* Header */} -
-

Gas Schedule

-

Adjust gas costs to simulate repricing

- {modifiedCount > 0 && ( - - )} -
- - {/* Parameter Groups - dynamically generated from API response */} -
- {groups.map(group => ( - - {/* Opcodes sub-section - more prominent */} - {group.opcodes.length > 0 && ( -
-
- Opcodes -
-
-
- {group.opcodes.map(key => { - const param = defaults.parameters[key]; - return ( - handleParameterChange(key, value)} - /> - ); - })} -
-
- )} - {/* Parameters sub-section - subtle */} - {group.parameters.length > 0 && ( -
-
- Parameters -
-
-
- {group.parameters.map(key => { - const param = defaults.parameters[key]; - return ( - handleParameterChange(key, value)} - /> - ); - })} -
-
- )} - - ))} -
- - ); -} diff --git a/src/pages/ethereum/execution/gas-profiler/components/GasScheduleEditor/index.ts b/src/pages/ethereum/execution/gas-profiler/components/GasScheduleEditor/index.ts deleted file mode 100644 index 43e410053..000000000 --- a/src/pages/ethereum/execution/gas-profiler/components/GasScheduleEditor/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { GasScheduleEditor } from './GasScheduleEditor'; -export type { GasScheduleEditorProps } from './GasScheduleEditor'; diff --git a/src/pages/ethereum/execution/gas-profiler/components/SimulatePageSkeleton/SimulatePageSkeleton.tsx b/src/pages/ethereum/execution/gas-profiler/components/SimulatePageSkeleton/SimulatePageSkeleton.tsx deleted file mode 100644 index 483170918..000000000 --- a/src/pages/ethereum/execution/gas-profiler/components/SimulatePageSkeleton/SimulatePageSkeleton.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { type JSX } from 'react'; -import { Card } from '@/components/Layout/Card'; - -/** - * Shimmer animation class - */ -const shimmer = 'animate-pulse bg-muted/20'; - -/** - * Skeleton loading state for the Gas Repricing Simulator page - * Matches the actual page layout with left controls and right results panel - */ -export function SimulatePageSkeleton(): JSX.Element { - return ( -
- {/* Back link skeleton */} -
- - {/* Main content grid */} -
- {/* Left column - Controls */} -
- {/* Simulation Target Card */} - -
-
-
-
-
-
-
-
-
-
- - - {/* Gas Schedule Editor Card */} - - {/* Header */} -
-
-
-
-
-
- {/* Parameter sections */} -
- {[...Array(3)].map((_, sectionIdx) => ( -
-
-
-
-
-
- {sectionIdx === 0 && ( -
- {[...Array(4)].map((_, i) => ( -
-
-
-
-
-
-
-
-
-
- ))} -
- )} -
- ))} -
- -
- - {/* Right column - Results */} -
- -
-
-
-
-
-
-
-
- -
-
-
- ); -} diff --git a/src/pages/ethereum/execution/gas-profiler/components/SimulatePageSkeleton/index.ts b/src/pages/ethereum/execution/gas-profiler/components/SimulatePageSkeleton/index.ts deleted file mode 100644 index 09d3dcc68..000000000 --- a/src/pages/ethereum/execution/gas-profiler/components/SimulatePageSkeleton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SimulatePageSkeleton } from './SimulatePageSkeleton'; diff --git a/src/pages/ethereum/execution/gas-profiler/components/SimulatorHelpDialog/SimulatorHelpDialog.tsx b/src/pages/ethereum/execution/gas-profiler/components/SimulatorHelpDialog/SimulatorHelpDialog.tsx deleted file mode 100644 index 4a278e199..000000000 --- a/src/pages/ethereum/execution/gas-profiler/components/SimulatorHelpDialog/SimulatorHelpDialog.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { type JSX } from 'react'; -import { Dialog } from '@/components/Overlays/Dialog'; - -interface SimulatorHelpDialogProps { - open: boolean; - onClose: () => void; -} - -/** - * Help dialog for the Gas Repricing Simulator - * - * Provides comprehensive documentation about how the simulator works, - * what can and cannot be overridden, and important behaviors to understand. - */ -export function SimulatorHelpDialog({ open, onClose }: SimulatorHelpDialogProps): JSX.Element { - return ( - -
- {/* Overview Section */} -
-

Overview

-

- The simulator re-executes transactions with custom gas costs. It runs the actual EVM with your modified gas - schedule, allowing you to analyze “what if” scenarios for gas repricing proposals. -

-
- - {/* What You Can Override */} -
-
-

Constant Gas Opcodes

-

- Simple opcodes with fixed costs: ADD,{' '} - MUL,{' '} - PUSH1-32,{' '} - DUP1-16,{' '} - SWAP1-16,{' '} - JUMP, etc. Changing these directly affects the - per-execution cost. -

-
- -
-

Dynamic Gas Components

-

- These are cost parameters used by multiple operations (not opcodes themselves): -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KeyDescriptionUsed By
SLOAD_COLDFirst read of storage slotSLOAD
SLOAD_WARMSubsequent reads of same slotSLOAD, SSTORE
SSTORE_SETCreating new storage slotSSTORE
SSTORE_RESETModifying existing slotSSTORE
CALL_COLDFirst access to an addressCALL, STATICCALL, BALANCE, EXTCODESIZE, etc.
CALL_VALUE_XFERSending ETH with a callCALL, CALLCODE
CALL_NEW_ACCOUNTCreating account via transferCALL
MEMORYMemory expansion (linear coefficient)All memory-expanding opcodes
COPYPer-word copy costCALLDATACOPY, CODECOPY, MCOPY, etc.
KECCAK256_WORDPer-word hashing costKECCAK256, CREATE2
- LOG / LOG_TOPIC / LOG_DATA - Logging costsLOG0-4
EXP_BYTEPer-byte exponent costEXP
INIT_CODE_WORDPer-word deployment costCREATE, CREATE2
CREATE_BY_SELFDESTRUCTAccount creation via selfdestructSELFDESTRUCT
-
-
-
- - {/* What You Cannot Override */} -
-

What You Cannot Override

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ComponentReason
Intrinsic gas - Base transaction cost (21000) and calldata pricing are applied before EVM execution -
Memory quadratic cost - The words2 ÷ 512 portion of memory expansion is fixed -
Gas refundsRefund amounts for storage clearing, etc.
Precompile costs - Gas costs for ECRECOVER,{' '} - SHA256, etc. -
- Pre-Berlin SSTORE - - Legacy storage pricing for historical blocks (pre-April 2021) -
-
-
- - {/* Important Behaviors */} -
-

Important Behaviors

- -
-
-

Memory Gas Affects Many Opcodes

-

- The MEMORY parameter controls the linear coefficient - in memory expansion: -

- - memory_cost = (words2 ÷ 512) + (MEMORY × words) - -

- Changing MEMORY affects:{' '} - MLOAD, MSTORE,{' '} - CALL, CREATE,{' '} - KECCAK256, LOG,{' '} - RETURN, and all copy operations. -

-
- -
-

Cold vs Warm Access

-

- Cold access (first touch in transaction) costs more than warm access (already touched). -

-
    -
  • - - - CALL_COLD sets the cold access cost for - addresses. Applies to: CALL,{' '} - STATICCALL, DELEGATECALL,{' '} - CALLCODE, BALANCE,{' '} - EXTCODESIZE, EXTCODEHASH,{' '} - EXTCODECOPY. - -
  • -
  • - - - Warm access cost for calls is controlled by the opcode's base cost ( - CALL, STATICCALL,{' '} - DELEGATECALL, CALLCODE{' '} - sliders). - -
  • -
  • - - - SLOAD_COLD /{' '} - SLOAD_WARM control storage slot access costs. - -
  • -
-
- -
-

Execution Divergence

-

- Since the simulator runs real EVM execution, any gas cost change can cause different behavior: -

-
    -
  • - - - Transactions may run out of gas earlier (if costs increase) or have more gas remaining (if costs - decrease) - -
  • -
  • - - - Contracts using gasleft() checks may take different code paths - -
  • -
  • - - - The “Diverged” indicator shows when original and simulated execution produced different - results - -
  • -
-
-
-
-
-
- ); -} diff --git a/src/pages/ethereum/execution/gas-profiler/components/SimulatorHelpDialog/index.ts b/src/pages/ethereum/execution/gas-profiler/components/SimulatorHelpDialog/index.ts deleted file mode 100644 index 9ece4dd1c..000000000 --- a/src/pages/ethereum/execution/gas-profiler/components/SimulatorHelpDialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SimulatorHelpDialog } from './SimulatorHelpDialog'; diff --git a/src/pages/ethereum/execution/gas-profiler/components/index.ts b/src/pages/ethereum/execution/gas-profiler/components/index.ts index 2f27981a4..6c129be7a 100644 --- a/src/pages/ethereum/execution/gas-profiler/components/index.ts +++ b/src/pages/ethereum/execution/gas-profiler/components/index.ts @@ -29,5 +29,7 @@ export { BlockOpcodeHeatmap } from './BlockOpcodeHeatmap'; export { ContractStorageButton } from './ContractStorageButton'; export { ContractStorageCTA } from './ContractStorageCTA'; export { ContractActionPopover } from './ContractActionPopover'; -export { SimulatePageSkeleton } from './SimulatePageSkeleton'; -export { SimulatorHelpDialog } from './SimulatorHelpDialog'; +export { GasScheduleDrawer } from './GasScheduleDrawer'; +export type { GasScheduleDrawerProps } from './GasScheduleDrawer'; +export { BlockSimulationResultsV2 } from './BlockSimulationResultsV2'; +export type { BlockSimulationResultsV2Props } from './BlockSimulationResultsV2'; diff --git a/src/pages/ethereum/execution/gas-profiler/hooks/useBlockGasSimulation.ts b/src/pages/ethereum/execution/gas-profiler/hooks/useBlockGasSimulation.ts deleted file mode 100644 index d22d700eb..000000000 --- a/src/pages/ethereum/execution/gas-profiler/hooks/useBlockGasSimulation.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { useState, useCallback } from 'react'; -import { useNetwork } from '@/hooks/useNetwork'; -import type { GasSchedule, BlockSimulationResult, CallError } from '../SimulatePage.types'; - -/** - * Parameters for the useBlockGasSimulation hook - */ -export interface UseBlockGasSimulationParams { - /** Block number to simulate */ - blockNumber: number | null; - /** Custom gas schedule (overrides default values) */ - gasSchedule: GasSchedule; -} - -/** - * Return type for the useBlockGasSimulation hook - */ -export interface UseBlockGasSimulationResult { - /** Trigger the simulation */ - simulate: () => Promise; - /** Simulation result data */ - data: BlockSimulationResult | null; - /** Loading state */ - isLoading: boolean; - /** Error state */ - error: Error | null; - /** Reset state */ - reset: () => void; -} - -/** - * API response from the backend (matches Erigon response structure) - */ -interface ApiBlockSimulationResponse { - blockNumber: number; - baseFork: string; - customSchedule: GasSchedule; - original: { - gasUsed: number; - gasLimit: number; - wouldExceedLimit: boolean; - }; - simulated: { - gasUsed: number; - gasLimit: number; - wouldExceedLimit: boolean; - }; - transactions: Array<{ - hash: string; - index: number; - originalStatus: string; - simulatedStatus: string; - originalGas: number; - simulatedGas: number; - deltaPercent: number; - diverged: boolean; - originalReverts: number; - simulatedReverts: number; - originalErrors: CallError[] | null; - simulatedErrors: CallError[] | null; - }>; - opcodeBreakdown: Record< - string, - { originalCount: number; originalGas: number; simulatedCount: number; simulatedGas: number } - >; -} - -/** - * Transform API response to match frontend types - */ -function transformApiResponse(response: ApiBlockSimulationResponse): BlockSimulationResult { - return { - blockNumber: response.blockNumber, - baseFork: response.baseFork, - customSchedule: response.customSchedule, - original: response.original, - simulated: response.simulated, - transactions: response.transactions.map(tx => ({ - hash: tx.hash, - index: tx.index, - originalStatus: tx.originalStatus === 'success' ? ('success' as const) : ('failed' as const), - simulatedStatus: tx.simulatedStatus === 'success' ? ('success' as const) : ('failed' as const), - originalGas: tx.originalGas, - simulatedGas: tx.simulatedGas, - deltaPercent: tx.deltaPercent, - diverged: tx.diverged, - originalReverts: tx.originalReverts, - simulatedReverts: tx.simulatedReverts, - originalErrors: tx.originalErrors ?? [], - simulatedErrors: tx.simulatedErrors ?? [], - })), - opcodeBreakdown: response.opcodeBreakdown, - }; -} - -/** - * Hook for simulating block gas with custom gas schedule - * - * Calls the backend API to re-execute a block with custom gas parameters. - * - * @example - * ```tsx - * const { simulate, data, isLoading, error } = useBlockGasSimulation({ - * blockNumber: 19000000, - * gasSchedule: { SLOAD_COLD: 1500 }, - * }); - * - * const handleSimulate = async () => { - * await simulate(); - * }; - * ``` - */ -export function useBlockGasSimulation({ - blockNumber, - gasSchedule, -}: UseBlockGasSimulationParams): UseBlockGasSimulationResult { - const { currentNetwork } = useNetwork(); - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const simulate = useCallback(async () => { - if (blockNumber === null) { - setError(new Error('Block number is required')); - return; - } - - if (!currentNetwork) { - setError(new Error('No network selected')); - return; - } - - setIsLoading(true); - setError(null); - - try { - const response = await fetch(`/api/v1/gas-profiler/${currentNetwork.name}/simulate-block`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - blockNumber, - gasSchedule: { overrides: gasSchedule }, - }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - const errorMessage = errorData.error || `Simulation failed: ${response.status} ${response.statusText}`; - throw new Error(errorMessage); - } - - const apiResult: ApiBlockSimulationResponse = await response.json(); - const result = transformApiResponse(apiResult); - setData(result); - } catch (err) { - setError(err instanceof Error ? err : new Error('Simulation failed')); - } finally { - setIsLoading(false); - } - }, [blockNumber, gasSchedule, currentNetwork]); - - const reset = useCallback(() => { - setData(null); - setError(null); - setIsLoading(false); - }, []); - - return { - simulate, - data, - isLoading, - error, - reset, - }; -} diff --git a/src/pages/ethereum/execution/gas-profiler/index.ts b/src/pages/ethereum/execution/gas-profiler/index.ts index 4a1a651f0..854728056 100644 --- a/src/pages/ethereum/execution/gas-profiler/index.ts +++ b/src/pages/ethereum/execution/gas-profiler/index.ts @@ -8,5 +8,8 @@ export { CallPage } from './CallPage'; export { TIME_RANGE_CONFIG, TIME_PERIOD_OPTIONS } from './constants'; export type { TimePeriod, ChartConfig } from './constants'; +// Simulate +export { SimulatePage } from './SimulatePage'; + // Legacy export for backwards compatibility export { IndexPage } from './IndexPage'; From 68a44e2775f894bf3fa9a65bd11a3c8fbe44084f Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Wed, 11 Feb 2026 11:20:18 +1000 Subject: [PATCH 2/2] refactor(Stats): extract item.icon into const Icon to comply with JSX rules --- src/components/DataDisplay/Stats/Stats.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/DataDisplay/Stats/Stats.tsx b/src/components/DataDisplay/Stats/Stats.tsx index 7aa0b20d1..6589f49da 100644 --- a/src/components/DataDisplay/Stats/Stats.tsx +++ b/src/components/DataDisplay/Stats/Stats.tsx @@ -18,8 +18,9 @@ export function Stats({ stats, title, className, gridClassName }: StatsProps): J )} > {stats.map(item => { - const hasCustomIcon = item.icon && item.iconColor; - const hasLegacyIcon = item.icon && !item.iconColor; + const Icon = item.icon; + const hasCustomIcon = Icon && item.iconColor; + const hasLegacyIcon = Icon && !item.iconColor; return ( -
)} @@ -54,7 +55,7 @@ export function Stats({ stats, title, className, gridClassName }: StatsProps): J className="flex size-7 items-center justify-center rounded-sm" style={{ backgroundColor: `${item.iconColor}18` }} > -

{item.name}