From 7a01fbb9b9d0df88811e6540262269831dd4d7ad Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Wed, 18 Feb 2026 13:25:26 +1000 Subject: [PATCH 1/2] feat(CallTraceView): prioritize precompile contract names over function names for better clarity in call labels refactor(useTransactionGasData): improve handling of precompile addresses in labels and statistics for accurate trace representations --- .../CallTraceView/CallTraceView.tsx | 22 +++++++++++-------- .../hooks/useTransactionGasData.ts | 19 +++++++++++++--- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/pages/ethereum/execution/gas-profiler/components/CallTraceView/CallTraceView.tsx b/src/pages/ethereum/execution/gas-profiler/components/CallTraceView/CallTraceView.tsx index bea917a01..93f284d15 100644 --- a/src/pages/ethereum/execution/gas-profiler/components/CallTraceView/CallTraceView.tsx +++ b/src/pages/ethereum/execution/gas-profiler/components/CallTraceView/CallTraceView.tsx @@ -360,15 +360,19 @@ function TraceRow({ [onToggleExpand, callId] ); - // Display label - const displayLabel = - node.functionName || - node.contractName || - (frame.target_address - ? `${frame.target_address.slice(0, 10)}...${frame.target_address.slice(-8)}` - : isContractCreation - ? 'Contract Creation' - : 'Unknown'); + // Display label — for precompiles, prefer the precompile name (contractName) over functionName + // because the "function selector" is just the first 4 bytes of calldata, not a real selector. + const displayLabel = isPrecompile + ? node.contractName || + node.functionName || + (frame.target_address ? `${frame.target_address.slice(0, 10)}...${frame.target_address.slice(-8)}` : 'Unknown') + : node.functionName || + node.contractName || + (frame.target_address + ? `${frame.target_address.slice(0, 10)}...${frame.target_address.slice(-8)}` + : isContractCreation + ? 'Contract Creation' + : 'Unknown'); return ( <> diff --git a/src/pages/ethereum/execution/gas-profiler/hooks/useTransactionGasData.ts b/src/pages/ethereum/execution/gas-profiler/hooks/useTransactionGasData.ts index 732fe8e34..d341cbe34 100644 --- a/src/pages/ethereum/execution/gas-profiler/hooks/useTransactionGasData.ts +++ b/src/pages/ethereum/execution/gas-profiler/hooks/useTransactionGasData.ts @@ -7,7 +7,7 @@ import { fetchAllPages } from '@/utils/api-pagination'; import type { TransactionMetadata, CallTreeNode, OpcodeStats } from '../IndexPage.types'; import { useContractOwners, type ContractOwnerMap } from './useContractOwners'; import { useFunctionSignatures, type FunctionSignatureMap } from './useFunctionSignatures'; -import { getPrecompileOwnerMap } from '../utils/precompileNames'; +import { getPrecompileOwnerMap, isPrecompileAddress } from '../utils/precompileNames'; /** * Per-call-frame opcode statistics for "interesting" opcodes @@ -104,7 +104,11 @@ export function getFunctionName( /** * Get a combined label for a call frame. * Priority: function name > contract name > truncated address > fallback - * Like Phalcon, we show just the function name when available for cleaner display. + * + * For precompile addresses, the priority is reversed: contract name (the precompile + * name like "ecAdd") takes precedence over function name, because the "function + * selector" for precompile calls is just the first 4 bytes of raw calldata, not a + * real Solidity function selector. */ export function getCallLabel( targetAddress: string | null | undefined, @@ -113,6 +117,11 @@ export function getCallLabel( functionSignatures: FunctionSignatureMap, fallback = 'Root' ): string { + // For precompile addresses, prefer the precompile name over spurious function matches + if (isPrecompileAddress(targetAddress)) { + return getContractLabel(targetAddress, contractOwners, fallback); + } + // If we have a function name, just show that (cleanest display) const funcName = getFunctionName(functionSelector, functionSignatures); if (funcName) { @@ -152,8 +161,12 @@ function buildCallTree( const targetName = owner?.contract_name ?? null; // Look up function name from dim_function_signature + // For precompile addresses, skip function name lookup — the "selector" is just the + // first 4 bytes of raw calldata, not a real Solidity function selector. const functionSelector = frame.function_selector ?? null; - const functionSig = functionSelector ? functionSignatures[functionSelector.toLowerCase()] : undefined; + const isPrecompile = isPrecompileAddress(frame.target_address); + const functionSig = + !isPrecompile && functionSelector ? functionSignatures[functionSelector.toLowerCase()] : undefined; const functionName = functionSig?.name ?? null; // Build label: prioritize function name > contract name > truncated address From 8a4c6c2c8b098921b3272bf4052bae752859f01e Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Wed, 18 Feb 2026 14:54:25 +1000 Subject: [PATCH 2/2] feat(gas-profiler): add resource gas breakdown components and hooks for enhanced resource analytics - Introduce new components: `ResourceGasBreakdown`, `ResourceGasTable`, and `ResourceGasHelp` to display resource gas data. - Implement hooks: `useBlockResourceGas` and `useCallFrameResourceGas` for fetching resource breakdown at block and call frame levels. - Update existing pages (BlockPage, CallPage, TransactionPage) to include resource analytics in new "Resources" tab for better user insights. - Enhance utility functions for processing resource gas data, allowing users to see how their transactions consume gas across various system resources. --- .../execution/gas-profiler/BlockPage.tsx | 57 ++++- .../execution/gas-profiler/CallPage.tsx | 35 +++- .../gas-profiler/TransactionPage.tsx | 51 ++++- .../CallTraceView/CallTraceView.tsx | 7 + .../ResourceGasBreakdown.tsx | 139 ++++++++++++ .../components/ResourceGasBreakdown/index.ts | 2 + .../ResourceGasHelp/ResourceGasHelp.tsx | 147 +++++++++++++ .../components/ResourceGasHelp/index.ts | 1 + .../ResourceGasTable/ResourceGasTable.tsx | 197 ++++++++++++++++++ .../components/ResourceGasTable/index.ts | 2 + .../ResourceViewToggle/ResourceViewToggle.tsx | 40 ++++ .../components/ResourceViewToggle/index.ts | 2 + .../gas-profiler/components/index.ts | 5 + .../execution/gas-profiler/hooks/index.ts | 6 + .../gas-profiler/hooks/useBlockResourceGas.ts | 64 ++++++ .../hooks/useCallFrameResourceGas.ts | 88 ++++++++ .../hooks/useTransactionResourceGas.ts | 69 ++++++ .../execution/gas-profiler/utils/index.ts | 10 + .../gas-profiler/utils/resourceGas.ts | 187 +++++++++++++++++ 19 files changed, 1088 insertions(+), 21 deletions(-) create mode 100644 src/pages/ethereum/execution/gas-profiler/components/ResourceGasBreakdown/ResourceGasBreakdown.tsx create mode 100644 src/pages/ethereum/execution/gas-profiler/components/ResourceGasBreakdown/index.ts create mode 100644 src/pages/ethereum/execution/gas-profiler/components/ResourceGasHelp/ResourceGasHelp.tsx create mode 100644 src/pages/ethereum/execution/gas-profiler/components/ResourceGasHelp/index.ts create mode 100644 src/pages/ethereum/execution/gas-profiler/components/ResourceGasTable/ResourceGasTable.tsx create mode 100644 src/pages/ethereum/execution/gas-profiler/components/ResourceGasTable/index.ts create mode 100644 src/pages/ethereum/execution/gas-profiler/components/ResourceViewToggle/ResourceViewToggle.tsx create mode 100644 src/pages/ethereum/execution/gas-profiler/components/ResourceViewToggle/index.ts create mode 100644 src/pages/ethereum/execution/gas-profiler/hooks/useBlockResourceGas.ts create mode 100644 src/pages/ethereum/execution/gas-profiler/hooks/useCallFrameResourceGas.ts create mode 100644 src/pages/ethereum/execution/gas-profiler/hooks/useTransactionResourceGas.ts create mode 100644 src/pages/ethereum/execution/gas-profiler/utils/resourceGas.ts diff --git a/src/pages/ethereum/execution/gas-profiler/BlockPage.tsx b/src/pages/ethereum/execution/gas-profiler/BlockPage.tsx index d29523e50..87ed531ff 100644 --- a/src/pages/ethereum/execution/gas-profiler/BlockPage.tsx +++ b/src/pages/ethereum/execution/gas-profiler/BlockPage.tsx @@ -40,8 +40,13 @@ import { TRANSACTION_BUCKETS, BlockOpcodeHeatmap, ContractActionPopover, + ResourceGasBreakdown, + ResourceGasTable, + ResourceGasHelp, } from './components'; import type { ContractInteractionItem, TopGasItem } from './components'; +import { useBlockResourceGas } from './hooks/useBlockResourceGas'; +import { useCallFrameResourceGas } from './hooks/useCallFrameResourceGas'; import { useNetwork } from '@/hooks/useNetwork'; import { CATEGORY_COLORS, CALL_TYPE_COLORS, getOpcodeCategory, getEtherscanBaseUrl, isMainnet } from './utils'; import type { GasProfilerBlockSearch } from './IndexPage.types'; @@ -97,7 +102,7 @@ function filterAndSortTransactions( } // Tab hash values for URL-based navigation -const BLOCK_TAB_HASHES = ['overview', 'opcodes', 'transactions', 'calls'] as const; +const BLOCK_TAB_HASHES = ['overview', 'opcodes', 'resources', 'transactions', 'calls'] as const; /** * Block detail page - shows all transactions in a block with analytics @@ -165,6 +170,21 @@ export function BlockPage(): JSX.Element { enabled: !isNaN(blockNumber), }); + // Fetch block-level resource gas breakdown + const { + entries: resourceEntries, + refund: resourceRefund, + total: resourceTotal, + isLoading: resourceLoading, + } = useBlockResourceGas({ blockNumber: isNaN(blockNumber) ? null : blockNumber }); + + // Fetch per-opcode resource gas for the block (all transactions) + const { opcodeRows: blockOpcodeResourceRows, isLoading: blockResourceOpcodeLoading } = useCallFrameResourceGas({ + transactionHash: null, + blockNumber: isNaN(blockNumber) ? null : blockNumber, + callFrameId: null, + }); + // Handle sort change (uses local state to avoid scroll reset) const handleSortChange = useCallback( (newSort: TransactionSortField) => { @@ -1057,17 +1077,21 @@ export function BlockPage(): JSX.Element { Overview Opcodes + Resources Transactions Calls - - - Simulate - +
+ {selectedTabIndex === 2 && } + + + Simulate + +
@@ -1220,6 +1244,21 @@ export function BlockPage(): JSX.Element { )} + {/* Resources Tab */} + + + + + + {/* Transactions Tab */} {/* Filter bar */} diff --git a/src/pages/ethereum/execution/gas-profiler/CallPage.tsx b/src/pages/ethereum/execution/gas-profiler/CallPage.tsx index 526db138e..a16cfe7c9 100644 --- a/src/pages/ethereum/execution/gas-profiler/CallPage.tsx +++ b/src/pages/ethereum/execution/gas-profiler/CallPage.tsx @@ -33,7 +33,11 @@ import { GasFormula, CallTraceView, ContractStorageCTA, + ResourceGasBreakdown, + ResourceGasTable, + ResourceGasHelp, } from './components'; +import { useCallFrameResourceGas } from './hooks/useCallFrameResourceGas'; import { useNetwork } from '@/hooks/useNetwork'; import { CATEGORY_COLORS, getOpcodeCategory, getEtherscanBaseUrl, isMainnet } from './utils'; import { isPrecompileAddress } from './utils/precompileNames'; @@ -171,6 +175,17 @@ export function CallPage(): JSX.Element { callFrameId: callIdNum, }); + // Fetch per-opcode resource gas for this call frame + const { + entries: frameResourceEntries, + opcodeRows: frameOpcodeResourceRows, + isLoading: frameResourceLoading, + } = useCallFrameResourceGas({ + transactionHash: txHash, + blockNumber, + callFrameId: callIdNum, + }); + // Find the current frame from all frames const currentFrame = useMemo(() => { if (!txData?.callFrames) return null; @@ -292,6 +307,7 @@ export function CallPage(): JSX.Element { const hash = window.location.hash.slice(1); if (hash === 'overview') return 0; if (hash === 'opcodes') return 1; + if (hash === 'resources') return 2; return 0; }, []); const [selectedTabIndex, setSelectedTabIndex] = useState(getTabIndexFromHash); @@ -635,10 +651,14 @@ export function CallPage(): JSX.Element { {/* Tabbed Content */} - - Overview - Opcodes - +
+ + Overview + Opcodes + Resources + + {selectedTabIndex === 2 && } +
{/* Overview Tab */} @@ -788,6 +808,13 @@ export function CallPage(): JSX.Element { )}
+ + {/* Resources Tab */} + + + + +
diff --git a/src/pages/ethereum/execution/gas-profiler/TransactionPage.tsx b/src/pages/ethereum/execution/gas-profiler/TransactionPage.tsx index 4646a647b..5017a3295 100644 --- a/src/pages/ethereum/execution/gas-profiler/TransactionPage.tsx +++ b/src/pages/ethereum/execution/gas-profiler/TransactionPage.tsx @@ -35,9 +35,13 @@ import { TopItemsByGasTable, CallTraceView, ContractActionPopover, + ResourceGasBreakdown, + ResourceGasTable, + ResourceGasHelp, } from './components'; import type { TopGasItem, CallFrameData } from './components'; import { getCallLabel } from './hooks/useTransactionGasData'; +import { useCallFrameResourceGas } from './hooks/useCallFrameResourceGas'; import { useNetwork } from '@/hooks/useNetwork'; import { CATEGORY_COLORS, @@ -114,6 +118,17 @@ export function TransactionPage(): JSX.Element { blockNumber, }); + // Fetch per-opcode resource gas for the resource view + const { + entries: txResourceCategoryEntries, + opcodeRows: txOpcodeResourceRows, + isLoading: txResourceOpcodeLoading, + } = useCallFrameResourceGas({ + transactionHash: txHash, + blockNumber, + callFrameId: null, + }); + // Derive badge stats from full opcode data (SSTORE, SLOAD, LOG*, CREATE*, SELFDESTRUCT) const callFrameOpcodeStats = useMemo((): Map => { const statsMap = new Map(); @@ -501,8 +516,9 @@ export function TransactionPage(): JSX.Element { if (hash === 'overview') return 0; if (hash === 'trace') return 1; if (hash === 'opcodes') return 2; - if (hash === 'calls') return 3; - if (hash === 'contracts') return 4; + if (hash === 'resources') return 3; + if (hash === 'calls') return 4; + if (hash === 'contracts') return 5; return 0; }, []); const [selectedTabIndex, setSelectedTabIndex] = useState(getInitialTabIndex); @@ -818,12 +834,16 @@ export function TransactionPage(): JSX.Element { {/* Tabbed Content */} - - Overview - {!isSimpleTransfer && Trace} - {!isSimpleTransfer && Opcodes} - {!isSimpleTransfer && callTypeChartData.length > 0 && Internal Txs} - +
+ + Overview + {!isSimpleTransfer && Trace} + {!isSimpleTransfer && Opcodes} + {!isSimpleTransfer && Resources} + {!isSimpleTransfer && callTypeChartData.length > 0 && Internal Txs} + + {selectedTabIndex === 3 && } +
{/* Overview Tab */} @@ -999,6 +1019,21 @@ export function TransactionPage(): JSX.Element { )} + {/* Resources Tab - only show if not a simple transfer */} + {!isSimpleTransfer && ( + + + + + + )} + {/* Internal Txs Tab - only show if not a simple transfer and there are internal txs */} {!isSimpleTransfer && callTypeChartData.length > 0 && ( diff --git a/src/pages/ethereum/execution/gas-profiler/components/CallTraceView/CallTraceView.tsx b/src/pages/ethereum/execution/gas-profiler/components/CallTraceView/CallTraceView.tsx index 93f284d15..6c9da4d86 100644 --- a/src/pages/ethereum/execution/gas-profiler/components/CallTraceView/CallTraceView.tsx +++ b/src/pages/ethereum/execution/gas-profiler/components/CallTraceView/CallTraceView.tsx @@ -415,6 +415,13 @@ function TraceRow({ )} + {/* Precompile badge */} + {isPrecompile && ( + + precompile + + )} + {/* Contract/Function name */} {node.contractName && !isPrecompile && ( diff --git a/src/pages/ethereum/execution/gas-profiler/components/ResourceGasBreakdown/ResourceGasBreakdown.tsx b/src/pages/ethereum/execution/gas-profiler/components/ResourceGasBreakdown/ResourceGasBreakdown.tsx new file mode 100644 index 000000000..df21b415a --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/components/ResourceGasBreakdown/ResourceGasBreakdown.tsx @@ -0,0 +1,139 @@ +import { type JSX } from 'react'; +import { PopoutCard } from '@/components/Layout/PopoutCard'; +import { Card } from '@/components/Layout/Card'; +import { BarChart } from '@/components/Charts/Bar'; +import type { ResourceGasEntry } from '../../utils/resourceGas'; +import { RESOURCE_COLORS } from '../../utils/resourceGas'; + +export interface ResourceGasBreakdownProps { + /** Resource gas entries sorted by gas desc */ + entries: ResourceGasEntry[]; + /** Gas refund amount (shown as footnote when > 0) */ + refund?: number; + /** Total resource gas for percentage calculations */ + total?: number; + /** Card title */ + title?: string; + /** Card subtitle */ + subtitle?: string; + /** Whether data is loading */ + loading?: boolean; + /** Additional class name */ + className?: string; +} + +/** + * Format gas value with comma separators + */ +function formatGas(value: number): string { + return value.toLocaleString(); +} + +/** + * Format gas value compactly for bar labels (e.g. 57.3M, 850K) + */ +function formatGasCompact(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.toLocaleString(); +} + +/** + * Resource gas breakdown using a horizontal bar chart. + * Uses the core BarChart component for an honest, readable visualization + * that handles extreme skew naturally. + */ +export function ResourceGasBreakdown({ + entries, + refund = 0, + total: totalProp, + title = 'Resource Gas Breakdown', + subtitle = 'What system resources did gas pay for?', + loading = false, + className, +}: ResourceGasBreakdownProps): JSX.Element { + const total = totalProp ?? entries.reduce((sum, e) => sum + e.gas, 0); + + if (loading) { + return ( + +
+

{title}

+

{subtitle}

+
+
+
+
+ + ); + } + + if (entries.length === 0) { + return ( + +
+

{title}

+
+
No resource gas data available
+
+ ); + } + + // Reverse entries so largest is at top (ECharts horizontal bar renders bottom-up) + const reversed = [...entries].reverse(); + + const barData = reversed.map(entry => ({ + value: entry.gas, + color: entry.color, + })); + + const labels = reversed.map(entry => entry.category); + + // Dynamic height: 36px per bar + 60px for axis/padding + const chartHeight = entries.length * 36 + 60; + + return ( + + {() => ( +
+ {/* Total gas header */} +
+ Total resource gas + {formatGas(total)} +
+ + {/* Horizontal bar chart */} + formatGasCompact(params.value)} + barWidth="65%" + tooltipFormatter={params => { + const p = Array.isArray(params) ? params[0] : params; + const val = typeof p.value === 'number' ? p.value : 0; + const pct = total > 0 ? ((val / total) * 100).toFixed(1) : '0.0'; + return `${p.name as string}
${formatGas(val)} gas (${pct}%)`; + }} + categoryLabelInterval={0} + valueAxisLabelFormatter={(v: number) => formatGasCompact(v)} + /> + + {/* Refund footnote */} + {refund > 0 && ( +
+
+ + Refund +
+ -{formatGas(refund)} +
+ )} +
+ )} +
+ ); +} diff --git a/src/pages/ethereum/execution/gas-profiler/components/ResourceGasBreakdown/index.ts b/src/pages/ethereum/execution/gas-profiler/components/ResourceGasBreakdown/index.ts new file mode 100644 index 000000000..0c8a62184 --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/components/ResourceGasBreakdown/index.ts @@ -0,0 +1,2 @@ +export { ResourceGasBreakdown } from './ResourceGasBreakdown'; +export type { ResourceGasBreakdownProps } from './ResourceGasBreakdown'; diff --git a/src/pages/ethereum/execution/gas-profiler/components/ResourceGasHelp/ResourceGasHelp.tsx b/src/pages/ethereum/execution/gas-profiler/components/ResourceGasHelp/ResourceGasHelp.tsx new file mode 100644 index 000000000..ac363333c --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/components/ResourceGasHelp/ResourceGasHelp.tsx @@ -0,0 +1,147 @@ +import { type JSX, useState } from 'react'; +import { InformationCircleIcon } from '@heroicons/react/24/outline'; +import { Dialog } from '@/components/Overlays/Dialog'; + +/** + * Help modal explaining how resource gas decomposition works. + * Renders as a small info button that opens a detailed reference dialog. + */ +export function ResourceGasHelp(): JSX.Element { + const [open, setOpen] = useState(false); + + return ( + <> + + + setOpen(false)} title="How is resource gas calculated?" size="xl"> +
+

+ Every unit of EVM gas pays for a specific system resource. We decompose gas into 7 categories so you can see + what a transaction actually spent its gas on. +

+ + {/* Resource categories */} +
+

Resources

+
+ + Pure EVM execution. The cost of running instructions: arithmetic, logic, control flow, stack + manipulation, and precompile execution (ecrecover, bn256Pairing, etc.). For opcodes that touch storage + or accounts, only the base “warm access” cost is counted here — the cold penalty goes + to Address Access. + + + + The cost of expanding EVM memory. The EVM charges a quadratic fee as memory grows, meaning large memory + usage gets disproportionately expensive. Applies to any opcode that reads, writes, or returns memory. + + + + The first-touch penalty for accounts and storage slots (EIP-2929). The first time a transaction accesses + an address or storage slot, it pays a cold access surcharge. Repeat accesses within the same transaction + are warm and much cheaper. This incentivizes access locality. + + + + The cost of writing to Ethereum’s persistent state. This is the bulk of what SSTORE charges + (beyond the base and cold costs), the code deposit cost when deploying contracts, and SELFDESTRUCT fund + transfers. Net state cost can be computed as State Growth − Gas Refund, since clearing storage + slots generates refunds. + + + + The cost of data that nodes must store but the EVM doesn’t re-read. This includes LOG event data, + contract deployment code storage overhead, and a portion of intrinsic transaction costs. A share of + per-byte calldata cost is also attributed here. + + + + The cost of indexing LOG event topics in the block’s bloom filter. Each topic on a LOG opcode has + a portion of its per-topic charge attributed to bloom filter maintenance, with the remainder going to + History. + + + + The cost of including transaction calldata in the block. This only appears at the transaction level + (always zero for individual opcodes) and scales with the number and type of calldata bytes, plus a fixed + overhead from the intrinsic base cost. + +
+
+ + {/* How it works */} +
+

How it works

+

The decomposition happens in three layers:

+
    +
  1. + Opcode level — Each EVM opcode’s gas is split + into the resources it consumes based on its semantics. For example, a cold SLOAD splits into a small + Compute portion (the warm base) and a larger Address Access portion (the cold premium). A CREATE splits + across Compute, Address Access, State Growth, and History. The invariant{' '} + sum of all 7 resources = total gas holds for every single opcode. +
  2. +
  3. + Transaction level — Opcode-level resources are summed + across all call frames in the transaction. Precompile execution gas is added to Compute. Intrinsic gas + (the fixed cost every transaction pays before EVM execution) is decomposed across Compute, History, + Address Access, State Growth, and Block Size based on whether it’s a regular transaction or + contract creation, plus per-byte calldata costs. +
  4. +
  5. + Block level — Transaction-level resources are summed + across all transactions in the block. +
  6. +
+
+ + {/* Precompiles */} +
+

Precompile calls

+

+ Calls to precompiled contracts (ecrecover, sha256, bn256Pairing, etc.) appear as separate call frames. + Their execution gas is attributed entirely to Compute, while the parent CALL opcode retains only its + access overhead. +

+
+ + {/* Gas refunds */} +
+

Gas refunds

+

+ SSTORE operations that clear storage slots generate gas refunds. Refunds are reported separately and are + not subtracted from any resource category. +

+
+
+
+ + ); +} + +/** Styled definition for a resource category */ +function ResourceDef({ + color, + term, + children, +}: { + color: string; + term: string; + children: React.ReactNode; +}): JSX.Element { + return ( +
+ +
+
{term}
+
{children}
+
+
+ ); +} diff --git a/src/pages/ethereum/execution/gas-profiler/components/ResourceGasHelp/index.ts b/src/pages/ethereum/execution/gas-profiler/components/ResourceGasHelp/index.ts new file mode 100644 index 000000000..ed8a41781 --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/components/ResourceGasHelp/index.ts @@ -0,0 +1 @@ +export { ResourceGasHelp } from './ResourceGasHelp'; diff --git a/src/pages/ethereum/execution/gas-profiler/components/ResourceGasTable/ResourceGasTable.tsx b/src/pages/ethereum/execution/gas-profiler/components/ResourceGasTable/ResourceGasTable.tsx new file mode 100644 index 000000000..4817a1873 --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/components/ResourceGasTable/ResourceGasTable.tsx @@ -0,0 +1,197 @@ +import { type JSX, useState, useMemo } from 'react'; +import clsx from 'clsx'; +import { Card } from '@/components/Layout/Card'; +import { RESOURCE_COLORS } from '../../utils/resourceGas'; +import type { OpcodeResourceRow } from '../../utils/resourceGas'; + +export interface ResourceGasTableProps { + /** Per-opcode resource attribution rows */ + rows: OpcodeResourceRow[]; + /** Maximum rows to show before "show more" */ + maxRows?: number; + /** Whether data is loading */ + loading?: boolean; +} + +type SortField = + | 'opcode' + | 'totalGas' + | 'count' + | 'compute' + | 'memory' + | 'addressAccess' + | 'stateGrowth' + | 'history' + | 'bloomTopics' + | 'blockSize'; + +const ALL_RESOURCE_COLUMNS: { field: SortField; label: string; color: string }[] = [ + { field: 'compute', label: 'Compute', color: RESOURCE_COLORS.Compute }, + { field: 'memory', label: 'Memory', color: RESOURCE_COLORS.Memory }, + { field: 'addressAccess', label: 'Addr Access', color: RESOURCE_COLORS['Address Access'] }, + { field: 'stateGrowth', label: 'State Growth', color: RESOURCE_COLORS['State Growth'] }, + { field: 'history', label: 'History', color: RESOURCE_COLORS.History }, + { field: 'bloomTopics', label: 'Bloom', color: RESOURCE_COLORS['Bloom Topics'] }, + { field: 'blockSize', label: 'Block Size', color: RESOURCE_COLORS['Block Size'] }, +]; + +/** + * Format gas value with comma separators + */ +function formatGas(value: number): string { + if (value === 0) return '\u2013'; + return value.toLocaleString(); +} + +/** + * Sortable table showing per-opcode resource gas attribution. + * Styled to match OpcodeAnalysis table (Opcode Details). + */ +export function ResourceGasTable({ rows, maxRows = 20, loading = false }: ResourceGasTableProps): JSX.Element { + const [sortField, setSortField] = useState('totalGas'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + const [showAll, setShowAll] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + + // Only show resource columns that have at least one non-zero value + const visibleColumns = useMemo( + () => + ALL_RESOURCE_COLUMNS.filter(col => rows.some(row => (row[col.field as keyof OpcodeResourceRow] as number) > 0)), + [rows] + ); + + const handleSort = (field: SortField): void => { + if (sortField === field) { + setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDir('desc'); + } + }; + + const filtered = searchTerm ? rows.filter(row => row.opcode.toLowerCase().includes(searchTerm.toLowerCase())) : rows; + + const sorted = [...filtered].sort((a, b) => { + const aVal = a[sortField] as number | string; + const bVal = b[sortField] as number | string; + if (typeof aVal === 'string' && typeof bVal === 'string') { + return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); + } + const diff = (aVal as number) - (bVal as number); + return sortDir === 'asc' ? diff : -diff; + }); + + const displayed = showAll ? sorted : sorted.slice(0, maxRows); + const hasMore = !showAll && sorted.length > maxRows; + const remainingCount = sorted.length - maxRows; + + if (loading) { + return ( + +
+ + ); + } + + if (rows.length === 0) { + return ( + +

No per-opcode resource data available

+
+ ); + } + + const SortHeader = ({ + field, + children, + align = 'right', + }: { + field: SortField; + children: React.ReactNode; + align?: 'left' | 'right'; + }): JSX.Element => ( + handleSort(field)} + className={clsx( + 'cursor-pointer px-3 py-3.5 text-sm font-semibold whitespace-nowrap transition-colors hover:text-primary', + align === 'right' ? 'text-right' : 'text-left', + sortField === field ? 'text-primary' : 'text-foreground' + )} + > + + {children} + {sortField === field && {sortDir === 'asc' ? '↑' : '↓'}} + + + ); + + return ( +
+
+
+

Resource Details

+

Click column headers to sort

+
+ setSearchTerm(e.target.value)} + className="rounded-xs border border-border bg-surface px-3 py-1.5 text-sm text-foreground placeholder:text-muted focus:border-primary focus:outline-none" + /> +
+
+
+ + + + + Opcode + + Count + Total Gas + {visibleColumns.map(col => ( + + + {col.label} + + ))} + + + + {displayed.map(row => ( + + + + + {visibleColumns.map(col => ( + + ))} + + ))} + +
{row.opcode}{row.count.toLocaleString()} + {row.totalGas.toLocaleString()} + + {formatGas(row[col.field as keyof OpcodeResourceRow] as number)} +
+
+ + {hasMore && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/pages/ethereum/execution/gas-profiler/components/ResourceGasTable/index.ts b/src/pages/ethereum/execution/gas-profiler/components/ResourceGasTable/index.ts new file mode 100644 index 000000000..a79e20e21 --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/components/ResourceGasTable/index.ts @@ -0,0 +1,2 @@ +export { ResourceGasTable } from './ResourceGasTable'; +export type { ResourceGasTableProps } from './ResourceGasTable'; diff --git a/src/pages/ethereum/execution/gas-profiler/components/ResourceViewToggle/ResourceViewToggle.tsx b/src/pages/ethereum/execution/gas-profiler/components/ResourceViewToggle/ResourceViewToggle.tsx new file mode 100644 index 000000000..dcaaf2e96 --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/components/ResourceViewToggle/ResourceViewToggle.tsx @@ -0,0 +1,40 @@ +import { type JSX } from 'react'; +import clsx from 'clsx'; + +export type ResourceViewMode = 'opcode' | 'resource'; + +export interface ResourceViewToggleProps { + value: ResourceViewMode; + onChange: (mode: ResourceViewMode) => void; +} + +/** + * Segmented toggle to switch between "By Opcode" and "By Resource" views. + * Includes tooltip descriptions explaining what each view answers. + */ +export function ResourceViewToggle({ value, onChange }: ResourceViewToggleProps): JSX.Element { + return ( +
+ + +
+ ); +} diff --git a/src/pages/ethereum/execution/gas-profiler/components/ResourceViewToggle/index.ts b/src/pages/ethereum/execution/gas-profiler/components/ResourceViewToggle/index.ts new file mode 100644 index 000000000..384f0d8ce --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/components/ResourceViewToggle/index.ts @@ -0,0 +1,2 @@ +export { ResourceViewToggle } from './ResourceViewToggle'; +export type { ResourceViewMode, ResourceViewToggleProps } from './ResourceViewToggle'; diff --git a/src/pages/ethereum/execution/gas-profiler/components/index.ts b/src/pages/ethereum/execution/gas-profiler/components/index.ts index 6c129be7a..a704bad1c 100644 --- a/src/pages/ethereum/execution/gas-profiler/components/index.ts +++ b/src/pages/ethereum/execution/gas-profiler/components/index.ts @@ -33,3 +33,8 @@ export { GasScheduleDrawer } from './GasScheduleDrawer'; export type { GasScheduleDrawerProps } from './GasScheduleDrawer'; export { BlockSimulationResultsV2 } from './BlockSimulationResultsV2'; export type { BlockSimulationResultsV2Props } from './BlockSimulationResultsV2'; +export { ResourceGasBreakdown } from './ResourceGasBreakdown'; +export type { ResourceGasBreakdownProps } from './ResourceGasBreakdown'; +export { ResourceGasTable } from './ResourceGasTable'; +export type { ResourceGasTableProps } from './ResourceGasTable'; +export { ResourceGasHelp } from './ResourceGasHelp'; diff --git a/src/pages/ethereum/execution/gas-profiler/hooks/index.ts b/src/pages/ethereum/execution/gas-profiler/hooks/index.ts index 492c70efe..0ae49a017 100644 --- a/src/pages/ethereum/execution/gas-profiler/hooks/index.ts +++ b/src/pages/ethereum/execution/gas-profiler/hooks/index.ts @@ -29,3 +29,9 @@ export type { UseFunctionSignaturesResult, FunctionSignatureMap, } from './useFunctionSignatures'; +export { useBlockResourceGas } from './useBlockResourceGas'; +export type { UseBlockResourceGasOptions, UseBlockResourceGasResult } from './useBlockResourceGas'; +export { useTransactionResourceGas } from './useTransactionResourceGas'; +export type { UseTransactionResourceGasOptions, UseTransactionResourceGasResult } from './useTransactionResourceGas'; +export { useCallFrameResourceGas } from './useCallFrameResourceGas'; +export type { UseCallFrameResourceGasOptions, UseCallFrameResourceGasResult } from './useCallFrameResourceGas'; diff --git a/src/pages/ethereum/execution/gas-profiler/hooks/useBlockResourceGas.ts b/src/pages/ethereum/execution/gas-profiler/hooks/useBlockResourceGas.ts new file mode 100644 index 000000000..0ec0a0cd7 --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/hooks/useBlockResourceGas.ts @@ -0,0 +1,64 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { intBlockResourceGasServiceListOptions } from '@/api/@tanstack/react-query.gen'; +import type { IntBlockResourceGas } from '@/api/types.gen'; +import { useNetwork } from '@/hooks/useNetwork'; +import { toResourceEntries, getResourceRefund, getTotalResourceGas } from '../utils/resourceGas'; +import type { ResourceGasEntry } from '../utils/resourceGas'; + +export interface UseBlockResourceGasOptions { + blockNumber: number | null; +} + +export interface UseBlockResourceGasResult { + /** Resource breakdown entries sorted by gas desc */ + entries: ResourceGasEntry[]; + /** Raw API record */ + record: IntBlockResourceGas | null; + /** Gas refund value */ + refund: number; + /** Total resource gas (sum of 7 categories) */ + total: number; + isLoading: boolean; + error: Error | null; +} + +/** + * Hook to fetch block-level resource gas breakdown. + * Uses the `int_block_resource_gas` API endpoint. + */ +export function useBlockResourceGas({ blockNumber }: UseBlockResourceGasOptions): UseBlockResourceGasResult { + const { currentNetwork } = useNetwork(); + + const enabled = !!currentNetwork && blockNumber !== null && !isNaN(blockNumber); + + const { data, isLoading, error } = useQuery({ + ...intBlockResourceGasServiceListOptions({ + query: { + block_number_eq: blockNumber!, + page_size: 1, + }, + }), + enabled, + staleTime: 5 * 60 * 1000, + }); + + const record = useMemo(() => { + const items = data?.int_block_resource_gas; + if (!items?.length) return null; + return items[0]; + }, [data]); + + const entries = useMemo(() => toResourceEntries(record), [record]); + const refund = useMemo(() => getResourceRefund(record), [record]); + const total = useMemo(() => getTotalResourceGas(record), [record]); + + return { + entries, + record, + refund, + total, + isLoading, + error: error as Error | null, + }; +} diff --git a/src/pages/ethereum/execution/gas-profiler/hooks/useCallFrameResourceGas.ts b/src/pages/ethereum/execution/gas-profiler/hooks/useCallFrameResourceGas.ts new file mode 100644 index 000000000..e74a8d63d --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/hooks/useCallFrameResourceGas.ts @@ -0,0 +1,88 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { intTransactionCallFrameOpcodeResourceGasServiceList } from '@/api/sdk.gen'; +import type { IntTransactionCallFrameOpcodeResourceGas } from '@/api/types.gen'; +import { useNetwork } from '@/hooks/useNetwork'; +import { fetchAllPages } from '@/utils/api-pagination'; +import { aggregateOpcodeResourceGas, toOpcodeResourceRows } from '../utils/resourceGas'; +import type { ResourceGasEntry, OpcodeResourceRow } from '../utils/resourceGas'; + +export interface UseCallFrameResourceGasOptions { + /** Transaction hash (optional — omit to fetch all transactions in the block) */ + transactionHash: string | null; + blockNumber: number | null; + /** Specific call frame ID. If null, fetches all frames. */ + callFrameId: number | null; +} + +export interface UseCallFrameResourceGasResult { + /** Aggregated resource breakdown entries */ + entries: ResourceGasEntry[]; + /** Per-opcode resource attribution rows */ + opcodeRows: OpcodeResourceRow[]; + /** Raw records */ + records: IntTransactionCallFrameOpcodeResourceGas[]; + isLoading: boolean; + error: Error | null; +} + +/** + * Hook to fetch per-opcode resource gas for a call frame, transaction, or entire block. + * Uses the `int_transaction_call_frame_opcode_resource_gas` API endpoint. + * Aggregates the per-opcode data into resource category totals. + * + * - With transactionHash + callFrameId: single call frame + * - With transactionHash only: all frames in a transaction + * - With blockNumber only: all opcodes in a block (across all transactions) + */ +export function useCallFrameResourceGas({ + transactionHash, + blockNumber, + callFrameId, +}: UseCallFrameResourceGasOptions): UseCallFrameResourceGasResult { + const { currentNetwork } = useNetwork(); + + // Requires at least a block number + const enabled = !!currentNetwork && blockNumber !== null; + + const { + data: rawRecords, + isLoading, + error, + } = useQuery({ + queryKey: ['gas-profiler', 'call-frame-resource-gas', blockNumber, transactionHash, callFrameId], + queryFn: ({ signal }) => { + const query: Record = { + block_number_eq: blockNumber!, + order_by: 'gas DESC', + page_size: 10000, + }; + if (transactionHash) { + query.transaction_hash_eq = transactionHash; + } + if (callFrameId !== null) { + query.call_frame_id_eq = callFrameId; + } + return fetchAllPages( + intTransactionCallFrameOpcodeResourceGasServiceList, + { query }, + 'int_transaction_call_frame_opcode_resource_gas', + signal + ); + }, + enabled, + staleTime: 5 * 60 * 1000, + }); + + const records = useMemo(() => rawRecords ?? [], [rawRecords]); + const entries = useMemo(() => aggregateOpcodeResourceGas(records), [records]); + const opcodeRows = useMemo(() => toOpcodeResourceRows(records), [records]); + + return { + entries, + opcodeRows, + records, + isLoading, + error: error as Error | null, + }; +} diff --git a/src/pages/ethereum/execution/gas-profiler/hooks/useTransactionResourceGas.ts b/src/pages/ethereum/execution/gas-profiler/hooks/useTransactionResourceGas.ts new file mode 100644 index 000000000..48af3cee3 --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/hooks/useTransactionResourceGas.ts @@ -0,0 +1,69 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { intTransactionResourceGasServiceListOptions } from '@/api/@tanstack/react-query.gen'; +import type { IntTransactionResourceGas } from '@/api/types.gen'; +import { useNetwork } from '@/hooks/useNetwork'; +import { toResourceEntries, getResourceRefund, getTotalResourceGas } from '../utils/resourceGas'; +import type { ResourceGasEntry } from '../utils/resourceGas'; + +export interface UseTransactionResourceGasOptions { + transactionHash: string | null; + blockNumber: number | null; +} + +export interface UseTransactionResourceGasResult { + /** Resource breakdown entries sorted by gas desc */ + entries: ResourceGasEntry[]; + /** Raw API record */ + record: IntTransactionResourceGas | null; + /** Gas refund value */ + refund: number; + /** Total resource gas (sum of 7 categories) */ + total: number; + isLoading: boolean; + error: Error | null; +} + +/** + * Hook to fetch transaction-level resource gas breakdown. + * Uses the `int_transaction_resource_gas` API endpoint. + */ +export function useTransactionResourceGas({ + transactionHash, + blockNumber, +}: UseTransactionResourceGasOptions): UseTransactionResourceGasResult { + const { currentNetwork } = useNetwork(); + + const enabled = !!currentNetwork && !!transactionHash && blockNumber !== null; + + const { data, isLoading, error } = useQuery({ + ...intTransactionResourceGasServiceListOptions({ + query: { + transaction_hash_eq: transactionHash!, + block_number_eq: blockNumber!, + page_size: 1, + }, + }), + enabled, + staleTime: 5 * 60 * 1000, + }); + + const record = useMemo(() => { + const items = data?.int_transaction_resource_gas; + if (!items?.length) return null; + return items[0]; + }, [data]); + + const entries = useMemo(() => toResourceEntries(record), [record]); + const refund = useMemo(() => getResourceRefund(record), [record]); + const total = useMemo(() => getTotalResourceGas(record), [record]); + + return { + entries, + record, + refund, + total, + isLoading, + error: error as Error | null, + }; +} diff --git a/src/pages/ethereum/execution/gas-profiler/utils/index.ts b/src/pages/ethereum/execution/gas-profiler/utils/index.ts index 7c1678a62..ab52deb00 100644 --- a/src/pages/ethereum/execution/gas-profiler/utils/index.ts +++ b/src/pages/ethereum/execution/gas-profiler/utils/index.ts @@ -10,3 +10,13 @@ export { export { addOpcodesToCallTree, isOpcodeNode, type AddOpcodesToCallTreeOptions } from './callTreeWithOpcodes'; export { getEffectiveGasRefund } from './gasRefund'; export { getEtherscanBaseUrl, isMainnet } from './explorerLinks'; +export { + RESOURCE_CATEGORIES, + RESOURCE_COLORS, + toResourceEntries, + aggregateOpcodeResourceGas, + toOpcodeResourceRows, + getResourceRefund, + getTotalResourceGas, +} from './resourceGas'; +export type { ResourceCategory, ResourceGasEntry, OpcodeResourceRow } from './resourceGas'; diff --git a/src/pages/ethereum/execution/gas-profiler/utils/resourceGas.ts b/src/pages/ethereum/execution/gas-profiler/utils/resourceGas.ts new file mode 100644 index 000000000..243c7cc53 --- /dev/null +++ b/src/pages/ethereum/execution/gas-profiler/utils/resourceGas.ts @@ -0,0 +1,187 @@ +/** + * Resource gas types, constants, and helpers. + * + * Resource categories decompose EVM gas into the system resources it pays for: + * Compute, Memory, Address Access, State Growth, History, Bloom Topics, Block Size. + * + * This is a different dimension from opcode categories which answer + * "which EVM instructions used gas?" -- resource categories answer + * "what system resources did gas pay for?" + */ + +import type { + IntBlockResourceGas, + IntTransactionResourceGas, + IntTransactionCallFrameOpcodeResourceGas, +} from '@/api/types.gen'; + +/** Canonical resource category names */ +export const RESOURCE_CATEGORIES = [ + 'Compute', + 'Memory', + 'Address Access', + 'State Growth', + 'History', + 'Bloom Topics', + 'Block Size', +] as const; + +export type ResourceCategory = (typeof RESOURCE_CATEGORIES)[number]; + +/** Color scheme deliberately distinct from opcode CATEGORY_COLORS */ +export const RESOURCE_COLORS: Record = { + Compute: '#3b82f6', // blue + Memory: '#8b5cf6', // violet + 'Address Access': '#f59e0b', // amber + 'State Growth': '#ef4444', // red + History: '#06b6d4', // cyan + 'Bloom Topics': '#ec4899', // pink + 'Block Size': '#64748b', // slate + Refund: '#22c55e', // green +}; + +/** A single resource breakdown entry for display */ +export interface ResourceGasEntry { + category: string; + gas: number; + percentage: number; + color: string; +} + +/** + * Extract resource gas fields from a block or transaction resource gas record + * into a sorted array of entries suitable for chart display. + */ +export function toResourceEntries( + record: IntBlockResourceGas | IntTransactionResourceGas | null | undefined +): ResourceGasEntry[] { + if (!record) return []; + + const raw: [string, number][] = [ + ['Compute', record.gas_compute ?? 0], + ['Memory', record.gas_memory ?? 0], + ['Address Access', record.gas_address_access ?? 0], + ['State Growth', record.gas_state_growth ?? 0], + ['History', record.gas_history ?? 0], + ['Bloom Topics', record.gas_bloom_topics ?? 0], + ['Block Size', record.gas_block_size ?? 0], + ]; + + const total = raw.reduce((sum, [, v]) => sum + v, 0); + if (total === 0) return []; + + return raw + .filter(([, v]) => v > 0) + .map(([category, gas]) => ({ + category, + gas, + percentage: (gas / total) * 100, + color: RESOURCE_COLORS[category] ?? '#9ca3af', + })) + .sort((a, b) => b.gas - a.gas); +} + +/** + * Aggregate per-opcode resource gas records into category totals. + * Used for call-frame-level resource breakdown. + */ +export function aggregateOpcodeResourceGas(records: IntTransactionCallFrameOpcodeResourceGas[]): ResourceGasEntry[] { + const totals: Record = {}; + + for (const r of records) { + totals['Compute'] = (totals['Compute'] ?? 0) + (r.gas_compute ?? 0); + totals['Memory'] = (totals['Memory'] ?? 0) + (r.gas_memory ?? 0); + totals['Address Access'] = (totals['Address Access'] ?? 0) + (r.gas_address_access ?? 0); + totals['State Growth'] = (totals['State Growth'] ?? 0) + (r.gas_state_growth ?? 0); + totals['History'] = (totals['History'] ?? 0) + (r.gas_history ?? 0); + totals['Bloom Topics'] = (totals['Bloom Topics'] ?? 0) + (r.gas_bloom_topics ?? 0); + totals['Block Size'] = (totals['Block Size'] ?? 0) + (r.gas_block_size ?? 0); + } + + const total = Object.values(totals).reduce((sum, v) => sum + v, 0); + if (total === 0) return []; + + return Object.entries(totals) + .filter(([, v]) => v > 0) + .map(([category, gas]) => ({ + category, + gas, + percentage: (gas / total) * 100, + color: RESOURCE_COLORS[category] ?? '#9ca3af', + })) + .sort((a, b) => b.gas - a.gas); +} + +/** Per-opcode resource attribution row */ +export interface OpcodeResourceRow { + opcode: string; + totalGas: number; + count: number; + compute: number; + memory: number; + addressAccess: number; + stateGrowth: number; + history: number; + bloomTopics: number; + blockSize: number; +} + +/** + * Transform per-opcode resource gas records into rows for the resource table. + * Aggregates records sharing the same opcode name (across call frames / transactions). + */ +export function toOpcodeResourceRows(records: IntTransactionCallFrameOpcodeResourceGas[]): OpcodeResourceRow[] { + const map = new Map(); + + for (const r of records) { + const opcode = r.opcode ?? 'UNKNOWN'; + const existing = map.get(opcode); + if (existing) { + existing.totalGas += r.gas ?? 0; + existing.count += r.count ?? 0; + existing.compute += r.gas_compute ?? 0; + existing.memory += r.gas_memory ?? 0; + existing.addressAccess += r.gas_address_access ?? 0; + existing.stateGrowth += r.gas_state_growth ?? 0; + existing.history += r.gas_history ?? 0; + existing.bloomTopics += r.gas_bloom_topics ?? 0; + existing.blockSize += r.gas_block_size ?? 0; + } else { + map.set(opcode, { + opcode, + totalGas: r.gas ?? 0, + count: r.count ?? 0, + compute: r.gas_compute ?? 0, + memory: r.gas_memory ?? 0, + addressAccess: r.gas_address_access ?? 0, + stateGrowth: r.gas_state_growth ?? 0, + history: r.gas_history ?? 0, + bloomTopics: r.gas_bloom_topics ?? 0, + blockSize: r.gas_block_size ?? 0, + }); + } + } + + return [...map.values()].sort((a, b) => b.totalGas - a.totalGas); +} + +/** Get the refund value from a resource gas record (block or transaction level) */ +export function getResourceRefund(record: IntBlockResourceGas | IntTransactionResourceGas | null | undefined): number { + return record?.gas_refund ?? 0; +} + +/** Get total resource gas (sum of all 7 categories, excluding refund) */ +export function getTotalResourceGas( + record: IntBlockResourceGas | IntTransactionResourceGas | null | undefined +): number { + if (!record) return 0; + return ( + (record.gas_compute ?? 0) + + (record.gas_memory ?? 0) + + (record.gas_address_access ?? 0) + + (record.gas_state_growth ?? 0) + + (record.gas_history ?? 0) + + (record.gas_bloom_topics ?? 0) + + (record.gas_block_size ?? 0) + ); +}