diff --git a/apps/explorer/src/comps/TxTraceFlamegraph.tsx b/apps/explorer/src/comps/TxTraceFlamegraph.tsx new file mode 100644 index 000000000..1c46accc5 --- /dev/null +++ b/apps/explorer/src/comps/TxTraceFlamegraph.tsx @@ -0,0 +1,404 @@ +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, prestate } = 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 storageByAddress = useMemo(() => { + if (!prestate) return null + return TxTraceFlamegraph.buildStorageMap(prestate) + }, [prestate]) + + 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]') + 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) + 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 + prestate?: PrestateDiff | null | undefined + } + + interface Span { + node: TxTraceTree.Node + 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 }) + 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 + 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, + depth, + leftPct, + widthPct, + hovered, + storageSlots, + onHover, + onZoom, + } = props + const { node } = span + + const isNarrow = widthPct < 1.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 selfGas = getSelfGas(node) + const selfPct = rootGas > 0 ? (selfGas / rootGas) * 100 : 0 + const hasStorage = + storageSlots && (storageSlots.reads > 0 || storageSlots.writes > 0) + + 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) + }, [node, hasChildren, onZoom]) + + return ( + + ) + } + + export function Details(props: { + node: TxTraceTree.Node + rootGas: number + storageSlots?: StorageInfo | undefined + }): React.JSX.Element { + 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 ?? ''})` + : node.trace.type === 'CREATE' || node.trace.type === 'CREATE2' + ? 'new()' + : 'call()' + + const hasStorage = + storageSlots && (storageSlots.reads > 0 || storageSlots.writes > 0) + + 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)}% 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/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..7e1161253 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