From 268740cafb0be6bec4b298637d52e975f3073ddc Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:53:44 +0000 Subject: [PATCH 1/4] feat(explorer): add flamegraph gas view for transaction traces Co-authored-by: Georgios Konstantopoulos <17802178+gakonst@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d9799-1e24-74db-a4f3-5edcae8c2f79 --- apps/explorer/src/comps/TxTraceFlamegraph.tsx | 268 ++++++++++++++++++ apps/explorer/src/comps/TxTraceTree.tsx | 10 +- apps/explorer/src/routes/_layout/tx/$hash.tsx | 75 ++++- 3 files changed, 338 insertions(+), 15 deletions(-) create mode 100644 apps/explorer/src/comps/TxTraceFlamegraph.tsx diff --git a/apps/explorer/src/comps/TxTraceFlamegraph.tsx b/apps/explorer/src/comps/TxTraceFlamegraph.tsx new file mode 100644 index 000000000..ab34566eb --- /dev/null +++ b/apps/explorer/src/comps/TxTraceFlamegraph.tsx @@ -0,0 +1,268 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { cx } from '#lib/css' +import { useCopy } from '#lib/hooks' +import type { TxTraceTree } from './TxTraceTree' +import CopyIcon from '~icons/lucide/copy' +import ZoomOutIcon from '~icons/lucide/zoom-out' + +export function TxTraceFlamegraph( + props: TxTraceFlamegraph.Props, +): React.JSX.Element | null { + const { tree } = props + const [zoomedNode, setZoomedNode] = useState(null) + const [hoveredNode, setHoveredNode] = useState(null) + const copy = useCopy() + + const traceRef = tree?.trace + // biome-ignore lint/correctness/useExhaustiveDependencies: reset zoom/hover when trace root changes + useEffect(() => { + setZoomedNode(null) + setHoveredNode(null) + }, [traceRef]) + + const root = zoomedNode ?? tree + + const rows = useMemo(() => { + if (!root) return [] + return TxTraceFlamegraph.buildRows(root) + }, [root]) + + const maxDepth = rows.length + + if (!tree || !root || maxDepth === 0) return null + + const handleCopy = () => { + const lines: string[] = [] + const walk = (node: TxTraceTree.Node, depth: number) => { + const indent = ' '.repeat(depth) + const name = node.functionName + ? `${node.contractName ?? node.trace.to ?? '??'}.${node.functionName}()` + : (node.trace.to ?? '[create]') + lines.push(`${indent}${name} — ${node.gasUsed.toLocaleString()} gas`) + for (const child of node.children) walk(child, depth + 1) + } + walk(root, 0) + copy.copy(lines.join('\n')) + } + + return ( +
+
+ + Gas Flamegraph + +
+ {zoomedNode && ( + + )} + {copy.notifying && ( + copied + )} + +
+
+ +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: mouse tracking for details panel */} +
setHoveredNode(null)} + > + {rows.map((row, depth) => ( +
+ {row.map((span) => { + const leftPct = + root.gasUsed > 0 ? (span.offset / root.gasUsed) * 100 : 0 + const widthPct = + root.gasUsed > 0 ? (span.width / root.gasUsed) * 100 : 0 + return ( + + ) + })} +
+ ))} +
+
+ + {hoveredNode && ( + + )} +
+ ) +} + +export declare namespace TxTraceFlamegraph { + interface Props { + tree: TxTraceTree.Node | null + } + + interface Span { + node: TxTraceTree.Node + offset: number + width: number + } +} + +export namespace TxTraceFlamegraph { + export function buildRows(root: TxTraceTree.Node): Span[][] { + const rows: Span[][] = [] + + function walk(node: TxTraceTree.Node, depth: number, offset: number) { + if (!rows[depth]) rows[depth] = [] + rows[depth].push({ + node, + offset, + width: node.gasUsed, + }) + let childOffset = offset + for (const child of node.children) { + walk(child, depth + 1, childOffset) + childOffset += child.gasUsed + } + } + + walk(root, 0, 0) + return rows + } + + export function Bar(props: { + span: Span + rootGas: number + leftPct: number + widthPct: number + hovered: boolean + onHover: (node: TxTraceTree.Node | null) => void + onZoom: (node: TxTraceTree.Node) => void + }): React.JSX.Element { + const { span, rootGas, leftPct, widthPct, hovered, onHover, onZoom } = props + const { node } = span + + const isNarrow = widthPct < 0.5 + const hasChildren = node.children.length > 0 + + const label = node.functionName + ? `${node.contractName ? `${node.contractName}.` : ''}${node.functionName}()` + : (node.contractName ?? node.trace.to ?? '[create]') + + const gasPct = rootGas > 0 ? (node.gasUsed / rootGas) * 100 : 0 + + const colorClass = node.hasError + ? 'bg-negative/60 hover:bg-negative/80' + : gasPct > 50 + ? 'bg-[#b45309]/70 hover:bg-[#b45309]/90' + : gasPct > 20 + ? 'bg-[#a16207]/50 hover:bg-[#a16207]/70' + : gasPct > 5 + ? 'bg-accent/40 hover:bg-accent/60' + : 'bg-accent/25 hover:bg-accent/40' + + const handleClick = useCallback(() => { + if (hasChildren) onZoom(node) + }, [node, hasChildren, onZoom]) + + return ( + + ) + } + + export function Details(props: { + node: TxTraceTree.Node + rootGas: number + }): React.JSX.Element { + const { node, rootGas } = props + const gasPct = rootGas > 0 ? (node.gasUsed / rootGas) * 100 : 0 + + const displayName = node.functionName + ? `${node.functionName}(${node.params ?? ''})` + : node.trace.type === 'CREATE' || node.trace.type === 'CREATE2' + ? 'new()' + : 'call()' + + return ( +
+
+
+
+ + {node.trace.type} + + {node.trace.to && ( + + {node.contractName + ? `${node.contractName}(${node.trace.to})` + : node.trace.to} + + )} +
+ + {displayName} + + {node.hasError && ( + + {node.trace.revertReason || node.trace.error || 'reverted'} + + )} +
+
+ + {node.gasUsed.toLocaleString()} gas + + {gasPct.toFixed(1)}% +
+
+
+ ) + } +} diff --git a/apps/explorer/src/comps/TxTraceTree.tsx b/apps/explorer/src/comps/TxTraceTree.tsx index f0ecf491e..84aa63bb7 100644 --- a/apps/explorer/src/comps/TxTraceTree.tsx +++ b/apps/explorer/src/comps/TxTraceTree.tsx @@ -20,14 +20,15 @@ import WrapIcon from '~icons/lucide/corner-down-left' import ReturnIcon from '~icons/lucide/corner-down-right' export function TxTraceTree(props: TxTraceTree.Props) { - const { trace } = props + const { trace, tree: treeProp } = props const [raw, setRaw] = useState(false) const [wrap, setWrap] = useState(true) const copy = useCopy() - const tree = useTraceTree(trace) + const builtTree = useTraceTree(treeProp ? null : trace) + const tree = treeProp ?? builtTree - if (!trace || !tree) return null + if (!tree) return null const handleCopy = () => { copy.copy(TxTraceTree.toAscii(tree, { raw })) @@ -78,7 +79,7 @@ export function TxTraceTree(props: TxTraceTree.Props) { ) } -function useTraceTree(trace: CallTrace | null): TxTraceTree.Node | null { +export function useTraceTree(trace: CallTrace | null): TxTraceTree.Node | null { const { addresses, selectors } = useMemo(() => { if (!trace) return { addresses: [] as `0x${string}`[], selectors: [] as Hex[] } @@ -229,6 +230,7 @@ function useTraceTree(trace: CallTrace | null): TxTraceTree.Node | null { export namespace TxTraceTree { export interface Props { trace: CallTrace | null + tree?: Node | null | undefined } export interface Node { diff --git a/apps/explorer/src/routes/_layout/tx/$hash.tsx b/apps/explorer/src/routes/_layout/tx/$hash.tsx index 38cebb335..755be15bf 100644 --- a/apps/explorer/src/routes/_layout/tx/$hash.tsx +++ b/apps/explorer/src/routes/_layout/tx/$hash.tsx @@ -28,7 +28,8 @@ import { TxDecodedTopics } from '#comps/TxDecodedTopics' import { TxEventDescription } from '#comps/TxEventDescription' import { TxRawTransaction } from '#comps/TxRawTransaction' import { TxStateDiff } from '#comps/TxStateDiff' -import { TxTraceTree } from '#comps/TxTraceTree' +import { TxTraceFlamegraph } from '#comps/TxTraceFlamegraph' +import { TxTraceTree, useTraceTree } from '#comps/TxTraceTree' import { TxTransactionCard } from '#comps/TxTransactionCard' import { cx } from '#lib/css' import { apostrophe } from '#lib/chars' @@ -248,16 +249,13 @@ function RouteComponent() { title: 'Trace', itemsLabel: 'views', content: ( -
- - -
+ ), }) } @@ -593,6 +591,61 @@ function BalanceChangesOverview(props: { data: BalanceChangesData }) { ) } +function TraceSection(props: { + trace: import('#lib/queries/trace').CallTrace | null + prestate: import('#lib/queries/trace').PrestateDiff | null + receipt: TransactionReceipt + logs: Log[] + tokenMetadata: Record +}): React.JSX.Element { + const { trace, prestate, receipt, logs, tokenMetadata } = props + const [view, setView] = React.useState<'tree' | 'flamegraph'>('tree') + const tree = useTraceTree(trace) + + return ( +
+
+ + +
+ {view === 'tree' ? ( + + ) : ( + + )} + +
+ ) +} + function CallsSection(props: { calls: ReadonlyArray<{ to?: OxAddress.Address | null From 4598d8c49a6e6ad0fd12d14ec1aca5c36cb7a09f Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:21:23 +0000 Subject: [PATCH 2/4] fix(explorer): ensure flamegraph rows take full width Co-Authored-By: Georgios Konstantopoulos <17802178+gakonst@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d9799-1e24-74db-a4f3-5edcae8c2f79 --- apps/explorer/src/comps/TxTraceFlamegraph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/explorer/src/comps/TxTraceFlamegraph.tsx b/apps/explorer/src/comps/TxTraceFlamegraph.tsx index ab34566eb..aba903682 100644 --- a/apps/explorer/src/comps/TxTraceFlamegraph.tsx +++ b/apps/explorer/src/comps/TxTraceFlamegraph.tsx @@ -83,7 +83,7 @@ export function TxTraceFlamegraph( onMouseLeave={() => setHoveredNode(null)} > {rows.map((row, depth) => ( -
+
{row.map((span) => { const leftPct = root.gasUsed > 0 ? (span.offset / root.gasUsed) * 100 : 0 From fc2be0ecbebac8e01a1aa22ae168fa4f7aef0ac3 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:32:02 +0000 Subject: [PATCH 3/4] feat(explorer): redesign flamegraph with flame colors, self-gas, and storage annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Warm flame color palette (orange → amber → yellow → lime → teal → blue by depth) - Thicker 32px bars with text shadow for contrast - Show self-gas percentage on bars and in details panel - Derive SSTORE/SLOAD counts from prestate diff, show database icon on bars - Details panel shows total gas, self gas breakdown, and storage operations Co-Authored-By: Georgios Konstantopoulos <17802178+gakonst@users.noreply.github.com> --- apps/explorer/src/comps/TxTraceFlamegraph.tsx | 200 +++++++++++++++--- apps/explorer/src/routes/_layout/tx/$hash.tsx | 2 +- 2 files changed, 169 insertions(+), 33 deletions(-) diff --git a/apps/explorer/src/comps/TxTraceFlamegraph.tsx b/apps/explorer/src/comps/TxTraceFlamegraph.tsx index aba903682..01e1220d9 100644 --- a/apps/explorer/src/comps/TxTraceFlamegraph.tsx +++ b/apps/explorer/src/comps/TxTraceFlamegraph.tsx @@ -1,14 +1,33 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { cx } from '#lib/css' import { useCopy } from '#lib/hooks' +import type { PrestateDiff } from '#lib/queries' import type { TxTraceTree } from './TxTraceTree' import CopyIcon from '~icons/lucide/copy' +import DatabaseIcon from '~icons/lucide/database' import ZoomOutIcon from '~icons/lucide/zoom-out' +const BAR_HEIGHT = 32 +const MIN_WIDTH_PX = 2 + +// Warm flame palette: depth 0 = hottest (orange-red), deeper = cooler (yellow → teal) +const FLAME_COLORS = [ + { bg: 'rgba(234, 88, 12, 0.8)', hover: 'rgba(234, 88, 12, 1)' }, // orange-600 + { bg: 'rgba(217, 119, 6, 0.75)', hover: 'rgba(217, 119, 6, 0.95)' }, // amber-600 + { bg: 'rgba(202, 138, 4, 0.7)', hover: 'rgba(202, 138, 4, 0.9)' }, // yellow-600 + { bg: 'rgba(101, 163, 13, 0.6)', hover: 'rgba(101, 163, 13, 0.8)' }, // lime-600 + { bg: 'rgba(13, 148, 136, 0.55)', hover: 'rgba(13, 148, 136, 0.75)' }, // teal-600 + { bg: 'rgba(59, 130, 246, 0.5)', hover: 'rgba(59, 130, 246, 0.7)' }, // blue-500 +] as const + +function getFlameColor(depth: number) { + return FLAME_COLORS[Math.min(depth, FLAME_COLORS.length - 1)] +} + export function TxTraceFlamegraph( props: TxTraceFlamegraph.Props, ): React.JSX.Element | null { - const { tree } = props + const { tree, prestate } = props const [zoomedNode, setZoomedNode] = useState(null) const [hoveredNode, setHoveredNode] = useState(null) const copy = useCopy() @@ -27,6 +46,11 @@ export function TxTraceFlamegraph( return TxTraceFlamegraph.buildRows(root) }, [root]) + const storageByAddress = useMemo(() => { + if (!prestate) return null + return TxTraceFlamegraph.buildStorageMap(prestate) + }, [prestate]) + const maxDepth = rows.length if (!tree || !root || maxDepth === 0) return null @@ -38,7 +62,10 @@ export function TxTraceFlamegraph( const name = node.functionName ? `${node.contractName ?? node.trace.to ?? '??'}.${node.functionName}()` : (node.trace.to ?? '[create]') - lines.push(`${indent}${name} — ${node.gasUsed.toLocaleString()} gas`) + const selfGas = TxTraceFlamegraph.getSelfGas(node) + lines.push( + `${indent}${name} — ${node.gasUsed.toLocaleString()} gas (self: ${selfGas.toLocaleString()})`, + ) for (const child of node.children) walk(child, depth + 1) } walk(root, 0) @@ -79,11 +106,15 @@ export function TxTraceFlamegraph(
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse tracking for details panel */}
setHoveredNode(null)} > {rows.map((row, depth) => ( -
+
{row.map((span) => { const leftPct = root.gasUsed > 0 ? (span.offset / root.gasUsed) * 100 : 0 @@ -94,9 +125,17 @@ export function TxTraceFlamegraph( key={`${span.node.trace.to}-${span.offset}`} span={span} rootGas={root.gasUsed} + depth={depth} leftPct={leftPct} widthPct={widthPct} hovered={hoveredNode === span.node} + storageSlots={ + span.node.trace.to + ? storageByAddress?.get( + span.node.trace.to.toLowerCase(), + ) + : undefined + } onHover={setHoveredNode} onZoom={setZoomedNode} /> @@ -108,7 +147,15 @@ export function TxTraceFlamegraph(
{hoveredNode && ( - + )}
) @@ -117,6 +164,7 @@ export function TxTraceFlamegraph( export declare namespace TxTraceFlamegraph { interface Props { tree: TxTraceTree.Node | null + prestate?: PrestateDiff | null | undefined } interface Span { @@ -124,19 +172,55 @@ export declare namespace TxTraceFlamegraph { offset: number width: number } + + interface StorageInfo { + reads: number + writes: number + } } export namespace TxTraceFlamegraph { + export function getSelfGas(node: TxTraceTree.Node): number { + const childGas = node.children.reduce((sum, c) => sum + c.gasUsed, 0) + return Math.max(0, node.gasUsed - childGas) + } + + export function buildStorageMap( + prestate: PrestateDiff, + ): Map { + const map = new Map() + const allAddrs = new Set([ + ...Object.keys(prestate.pre), + ...Object.keys(prestate.post), + ]) + for (const addr of allAddrs) { + const pre = prestate.pre[addr as `0x${string}`] + const post = prestate.post[addr as `0x${string}`] + const preSlots = Object.keys(pre?.storage ?? {}) + const postSlots = Object.keys(post?.storage ?? {}) + const allSlots = new Set([...preSlots, ...postSlots]) + if (allSlots.size === 0) continue + + let writes = 0 + let reads = 0 + for (const slot of allSlots) { + const preVal = pre?.storage?.[slot as `0x${string}`] + const postVal = post?.storage?.[slot as `0x${string}`] + if (preVal !== postVal) writes++ + else reads++ + } + + map.set(addr.toLowerCase(), { reads, writes }) + } + return map + } + export function buildRows(root: TxTraceTree.Node): Span[][] { const rows: Span[][] = [] function walk(node: TxTraceTree.Node, depth: number, offset: number) { if (!rows[depth]) rows[depth] = [] - rows[depth].push({ - node, - offset, - width: node.gasUsed, - }) + rows[depth].push({ node, offset, width: node.gasUsed }) let childOffset = offset for (const child of node.children) { walk(child, depth + 1, childOffset) @@ -151,16 +235,28 @@ export namespace TxTraceFlamegraph { export function Bar(props: { span: Span rootGas: number + depth: number leftPct: number widthPct: number hovered: boolean + storageSlots?: StorageInfo | undefined onHover: (node: TxTraceTree.Node | null) => void onZoom: (node: TxTraceTree.Node) => void }): React.JSX.Element { - const { span, rootGas, leftPct, widthPct, hovered, onHover, onZoom } = props + const { + span, + rootGas, + depth, + leftPct, + widthPct, + hovered, + storageSlots, + onHover, + onZoom, + } = props const { node } = span - const isNarrow = widthPct < 0.5 + const isNarrow = widthPct < 1.5 const hasChildren = node.children.length > 0 const label = node.functionName @@ -168,16 +264,14 @@ export namespace TxTraceFlamegraph { : (node.contractName ?? node.trace.to ?? '[create]') const gasPct = rootGas > 0 ? (node.gasUsed / rootGas) * 100 : 0 + const selfGas = getSelfGas(node) + const selfPct = rootGas > 0 ? (selfGas / rootGas) * 100 : 0 + const hasStorage = + storageSlots && (storageSlots.reads > 0 || storageSlots.writes > 0) - const colorClass = node.hasError - ? 'bg-negative/60 hover:bg-negative/80' - : gasPct > 50 - ? 'bg-[#b45309]/70 hover:bg-[#b45309]/90' - : gasPct > 20 - ? 'bg-[#a16207]/50 hover:bg-[#a16207]/70' - : gasPct > 5 - ? 'bg-accent/40 hover:bg-accent/60' - : 'bg-accent/25 hover:bg-accent/40' + const color = node.hasError + ? { bg: 'rgba(239, 68, 68, 0.7)', hover: 'rgba(239, 68, 68, 0.9)' } + : getFlameColor(depth) const handleClick = useCallback(() => { if (hasChildren) onZoom(node) @@ -187,23 +281,43 @@ export namespace TxTraceFlamegraph { @@ -213,9 +327,12 @@ export namespace TxTraceFlamegraph { export function Details(props: { node: TxTraceTree.Node rootGas: number + storageSlots?: StorageInfo | undefined }): React.JSX.Element { - const { node, rootGas } = props + const { node, rootGas, storageSlots } = props const gasPct = rootGas > 0 ? (node.gasUsed / rootGas) * 100 : 0 + const selfGas = getSelfGas(node) + const selfPct = rootGas > 0 ? (selfGas / rootGas) * 100 : 0 const displayName = node.functionName ? `${node.functionName}(${node.params ?? ''})` @@ -223,10 +340,13 @@ export namespace TxTraceFlamegraph { ? 'new()' : 'call()' + const hasStorage = + storageSlots && (storageSlots.reads > 0 || storageSlots.writes > 0) + return (
-
-
+
+
{node.gasUsed.toLocaleString()} gas - {gasPct.toFixed(1)}% + {gasPct.toFixed(1)}% total + {node.children.length > 0 && ( + + {selfGas.toLocaleString()} self ({selfPct.toFixed(1)}%) + + )} + {hasStorage && ( + + + {storageSlots.writes > 0 && ( + {storageSlots.writes} SSTORE + )} + {storageSlots.reads > 0 && ( + {storageSlots.reads} SLOAD + )} + + )}
diff --git a/apps/explorer/src/routes/_layout/tx/$hash.tsx b/apps/explorer/src/routes/_layout/tx/$hash.tsx index 755be15bf..7e1161253 100644 --- a/apps/explorer/src/routes/_layout/tx/$hash.tsx +++ b/apps/explorer/src/routes/_layout/tx/$hash.tsx @@ -633,7 +633,7 @@ function TraceSection(props: { {view === 'tree' ? ( ) : ( - + )} Date: Thu, 16 Apr 2026 19:38:30 +0000 Subject: [PATCH 4/4] fix(explorer): fix flamegraph bar label overflow hiding percentage and storage icon Co-Authored-By: Georgios Konstantopoulos <17802178+gakonst@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d9799-1e24-74db-a4f3-5edcae8c2f79 --- apps/explorer/src/comps/TxTraceFlamegraph.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/explorer/src/comps/TxTraceFlamegraph.tsx b/apps/explorer/src/comps/TxTraceFlamegraph.tsx index 01e1220d9..1c46accc5 100644 --- a/apps/explorer/src/comps/TxTraceFlamegraph.tsx +++ b/apps/explorer/src/comps/TxTraceFlamegraph.tsx @@ -296,9 +296,9 @@ export namespace TxTraceFlamegraph { title={`${label} — ${node.gasUsed.toLocaleString()} gas (${gasPct.toFixed(1)}%)${hasStorage ? ` · ${storageSlots.writes} SSTORE, ${storageSlots.reads} SLOAD` : ''}`} > {!isNarrow && ( - +