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 bea917a01..6c9da4d86 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 (
<>
@@ -411,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 (
+
+
+
+
+ );
+ }
+
+ 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 (
+ <>
+
+
+
+ >
+ );
+}
+
+/** 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 => (
+
+ | {row.opcode} |
+ {row.count.toLocaleString()} |
+
+ {row.totalGas.toLocaleString()}
+ |
+ {visibleColumns.map(col => (
+
+ {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/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
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)
+ );
+}