diff --git a/.gitignore b/.gitignore index 96b1c4b1..5857ac99 100644 --- a/.gitignore +++ b/.gitignore @@ -69,8 +69,19 @@ edb *.spec.js *.spec.mjs *.spec.mts +*.bench.ts +*.bench.tsx +*.bench.js +*.bench.mjs +*.bench.mts /tmp video/out .superpowers /docs -@docs \ No newline at end of file +@docs + +# Local scratch, tool caches, and nested repos (not part of the app source) +tasks/ +.codegraph/ +.vercel/ +starknet-sim/ \ No newline at end of file diff --git a/src/components/ExecutionStackTrace.tsx b/src/components/ExecutionStackTrace.tsx index f5aec7fb..6ddb4bfd 100644 --- a/src/components/ExecutionStackTrace.tsx +++ b/src/components/ExecutionStackTrace.tsx @@ -1,5 +1,6 @@ import React from "react"; import "../styles/ExecutionStackTrace.css"; +import "../styles/asset-rows.css"; import TokenMovementsPanel from "./TokenMovementsPanel"; import { CopyButton } from "./ui/copy-button"; import { Button } from "./ui/button"; @@ -11,7 +12,10 @@ import { AccordionItem, AccordionTrigger, } from "./ui/accordion"; -import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "./ui/table"; +import { formatDisplayAmount } from "./simulation-results/formatters"; +// Anchored asset-change rows share the .tm-* classes defined in TokenMovementsPanel.css +// (always bundled — this module imports TokenMovementsPanel below). +import { ArrowCircleUpRight, ArrowCircleDownLeft } from "@phosphor-icons/react"; import { extractTokenMovements } from "../utils/tokenMovements"; // Re-export types for backward compatibility @@ -218,51 +222,44 @@ const ExecutionStackTrace: React.FC = (props) => { {orderedAssetChanges.rows.length} - - - - Address - Asset - Delta Amount - - - - {orderedAssetChanges.rows.map((change: any, idx: number) => { - const direction = normalizeAssetDirection(change); - const isPositive = direction === "in"; - const isNegative = direction === "out"; - const amountClass = isPositive - ? "sim-amount--positive" - : isNegative - ? "sim-amount--negative" - : ""; - const showIncomingDivider = - idx === orderedAssetChanges.outgoingCount && - orderedAssetChanges.outgoingCount > 0 && - orderedAssetChanges.incomingCount > 0; - return ( - - {showIncomingDivider && ( - - )} - - +
+ {orderedAssetChanges.rows.map((change: any, idx: number) => { + const direction = normalizeAssetDirection(change); + const isPositive = direction === "in"; + const isNegative = direction === "out"; + const dirClass = isNegative ? "tm-out" : isPositive ? "tm-in" : "tm-neutral"; + const showIncomingDivider = + idx === orderedAssetChanges.outgoingCount && + orderedAssetChanges.outgoingCount > 0 && + orderedAssetChanges.incomingCount > 0; + const f = formatDisplayAmount(change.amount || change.rawAmount); + return ( + + {showIncomingDivider &&
+ + + + {change.symbol || "Unknown"} + + +
+ {f.display} +
+ + + ); + })} +
)} diff --git a/src/components/TokenMovementsPanel.tsx b/src/components/TokenMovementsPanel.tsx index a8745d1d..bbef6a82 100644 --- a/src/components/TokenMovementsPanel.tsx +++ b/src/components/TokenMovementsPanel.tsx @@ -7,16 +7,19 @@ import { fetchTokenPrices, fetchTokenMetadata, getTokenIconUrl, + formatMovementAmount, type TokenType, type BalanceChange, type TokenMovement, type TokenPrice, } from "../utils/tokenMovements"; import { normalizeValue } from "../utils/displayFormatters"; +import { formatDisplayAmount } from "./simulation-results/formatters"; +import { ArrowCircleUpRight, ArrowCircleDownLeft } from "@phosphor-icons/react"; import { ZERO_ADDRESS } from "../utils/addressConstants"; import { Button } from "./ui/button"; -import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "./ui/table"; import "../styles/TokenMovementsPanel.css"; +import "../styles/asset-rows.css"; interface TokenMovementsPanelProps { /** Raw event logs from trace (with address, topics, data) */ @@ -305,95 +308,54 @@ const TokenMovementsPanel: React.FC = ({ {/* Balance changes table - different columns for different token types */} {groupingMode === "address" && currentChanges.length > 0 && (
- - - - Address - Asset - {/* ERC-721 and ERC-1155 have Token ID column, ERC-20 has Value column */} - {(currentTab === "ERC-721" || currentTab === "ERC-1155") ? ( - <> - Token ID - Balance Change - - ) : ( - <> - Balance Change - Value - - )} - - - - {currentChanges.map((change, idx) => { - const prevChange = idx > 0 ? currentChanges[idx - 1] : null; - const showDivider = prevChange && prevChange.rawDelta < 0n && change.rawDelta >= 0n; - const colCount = (currentTab === "ERC-721" || currentTab === "ERC-1155") ? 4 : 4; - return ( - - {showDivider && ( - - )} - - - ); - })} - -
+
+ {currentChanges.map((change, idx) => { + const prevChange = idx > 0 ? currentChanges[idx - 1] : null; + const showDivider = prevChange && prevChange.rawDelta < 0n && change.rawDelta >= 0n; + return ( + + {showDivider &&
)} {groupingMode === "chronological" && currentMovements.length > 0 && (
- - - - From - To - Amount - Asset - Asset Type - Token ID - - - - {currentMovements.map((movement, idx) => { - const senderLower = senderAddress?.toLowerCase(); - const prevMovement = idx > 0 ? currentMovements[idx - 1] : null; - const showDivider = senderLower && prevMovement && - prevMovement.from.toLowerCase() === senderLower && - movement.from.toLowerCase() !== senderLower; - return ( - - {showDivider && ( - - )} - - - ); - })} - -
+
+ {currentMovements.map((movement, idx) => { + const senderLower = senderAddress?.toLowerCase(); + const prevMovement = idx > 0 ? currentMovements[idx - 1] : null; + const showDivider = senderLower && prevMovement && + prevMovement.from.toLowerCase() === senderLower && + movement.from.toLowerCase() !== senderLower; + return ( + + {showDivider &&
)} @@ -420,7 +382,6 @@ const TokenMovementRow: React.FC = ({ isNft = false, }) => { const isNegative = change.rawDelta < 0n; - const deltaClass = isNegative ? "delta-negative" : "delta-positive"; const [iconError, setIconError] = useState(false); // Check if we have a real symbol (not a truncated address) @@ -440,8 +401,11 @@ const TokenMovementRow: React.FC = ({ onHighlightChange(normalized)} onMouseLeave={() => onHighlightChange(null)} + onFocus={() => onHighlightChange(normalized)} + onBlur={() => onHighlightChange(null)} > {displayText} @@ -466,58 +430,45 @@ const TokenMovementRow: React.FC = ({ return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; }; + const delta = formatDisplayAmount(change.delta); + return ( - - - - {isNegative ? "↗" : "↘"} - - {renderHighlightable(change.address, formatAddress(change.address), "address-value")} +
+
+ {isNegative ? "Outgoing" : "Incoming"} + {isNegative ? ( +
+
+ {delta.display} + + {isNft ? (change.tokenId ? `#${change.tokenId}` : "—") : formatUsd(usdValue)} + +
+
); }; @@ -552,8 +503,11 @@ const TokenMovementChronologicalRow: React.FC onHighlightChange(normalized)} onMouseLeave={() => onHighlightChange(null)} + onFocus={() => onHighlightChange(normalized)} + onBlur={() => onHighlightChange(null)} > {displayText} @@ -574,27 +528,33 @@ const TokenMovementChronologicalRow: React.FC - - {renderHighlightable(movement.from, formatParty(movement.from, "from"), "address-value")} - {fromLabel && [{fromLabel}]} - - - {renderHighlightable(movement.to, formatParty(movement.to, "to"), "address-value")} - {toLabel && [{toLabel}]} - - - {movement.amount} - - - {renderHighlightable(movement.tokenAddress, displaySymbol, "token-symbol")} - - {movement.tokenType} - - {movement.tokenId ? `#${movement.tokenId}` : "—"} - -
+
+
+ {dirClass === "tm-out" ? "Outgoing" : dirClass === "tm-in" ? "Incoming" : "Transfer"} + + + {renderHighlightable(movement.tokenAddress, displaySymbol, "token-symbol")} + + + {renderHighlightable(movement.from, formatParty(movement.from, "from"), "tm-addr")} + {fromLabel && [{fromLabel}]} + + {renderHighlightable(movement.to, formatParty(movement.to, "to"), "tm-addr")} + {toLabel && [{toLabel}]} + +
+
+ + {amount.display.replace(/^\+/, "")} + + {movement.tokenType}{movement.tokenId ? ` · #${movement.tokenId}` : ""} +
+
); }; diff --git a/src/components/simulation-results/formatters.ts b/src/components/simulation-results/formatters.ts index b5c2da01..fcbe3bb7 100644 --- a/src/components/simulation-results/formatters.ts +++ b/src/components/simulation-results/formatters.ts @@ -90,6 +90,56 @@ export const formatEth = (weiValue?: string | null) => { } }; +/** + * Format an already-decimal amount string for compact display while keeping the + * full value available (e.g. for a `title` tooltip). Turns the raw float tails + * like "-0.08999999999999997" into a readable "−0.0900". + */ +export const formatDisplayAmount = ( + value?: string | null +): { display: string; full: string } => { + const full = String(value ?? "").trim(); + if (!full) return { display: "—", full: "" }; + + // Parse the decimal string directly. Going through Number() loses precision on + // large token amounts (9007199254740993 → …992) and yields scientific notation + // for tiny ones — both showed up in review. + const m = full.match(/^([+-]?)(\d+)(?:\.(\d+))?$/); + if (!m) return { display: full, full }; + + const intRaw = m[2].replace(/^0+(?=\d)/, ""); + const fracTrimmed = (m[3] ?? "").replace(/0+$/, ""); + const intIsZero = /^0+$/.test(intRaw); + if (intIsZero && !fracTrimmed) return { display: "0", full }; + + const sign = m[1] === "-" ? "−" : "+"; // U+2212 minus / + for positive + let body: string; + + if (!intIsZero) { + // Magnitude ≥ 1. + if (intRaw.length <= 15) { + // Safe for Number — round to 4dp then regroup. + const r = Math.abs(Number(full)).toFixed(4).replace(/\.?0+$/, ""); + const [ip, fp] = r.split("."); + body = ip.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + (fp ? `.${fp}` : ""); + } else { + // Too large for Number — group the integer as a string, truncate the fraction. + const frac4 = fracTrimmed.slice(0, 4).replace(/0+$/, ""); + const grouped = intRaw.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + body = frac4 ? `${grouped}.${frac4}` : grouped; + } + } else { + // Magnitude < 1 — never scientific notation. + const firstSig = fracTrimmed.search(/[1-9]/); + body = + firstSig <= 2 + ? Math.abs(Number(full)).toFixed(4) // 0.0900, 0.0010 (≥ 0.001) + : `0.${fracTrimmed.slice(0, firstSig + 3)}`; // 0.000108 (< 0.001, ~3 sig figs) + } + + return { display: `${sign}${body}`, full }; +}; + export const calculateIntrinsicGas = (calldata?: string | null): number => { const INTRINSIC_BASE = 21000; if (!calldata || calldata === "0x") return INTRINSIC_BASE; diff --git a/src/styles/SimulationResultsPage.css b/src/styles/SimulationResultsPage.css index 989934a9..85b4ece5 100644 --- a/src/styles/SimulationResultsPage.css +++ b/src/styles/SimulationResultsPage.css @@ -526,65 +526,7 @@ body:has(.sim-results-page) > #root { RETURN DATA (multi-line) ============================================ */ -/* ============================================ - BALANCE CHANGES TABLE (Flat Design) - ============================================ */ - -.sim-balance-changes__table { - width: 100%; - border-collapse: collapse; - font-size: 14px; - font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", monospace; -} - -.sim-balance-changes__table thead tr { - border-bottom: 1px solid var(--sim-border); -} - -.sim-balance-changes__table th { - padding: 10px 14px; - text-align: left; - color: var(--sim-text-muted); - font-weight: 600; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.sim-balance-changes__table th.text-right { - text-align: right; -} - -.sim-balance-changes__table td { - padding: 10px 14px; - color: var(--sim-text); - border-bottom: 1px solid var(--sim-border); -} - -.sim-balance-changes__table td.text-right { - text-align: right; -} - -.sim-balance-changes__table td.sim-address { - color: #22d3ee; -} - -.sim-balance-changes__table tr.sim-balance-changes__group-divider td { - padding: 0; - border-bottom: none; - height: 0; - border-top: 2px solid rgba(255, 255, 255, 0.2); -} - -.sim-balance-changes__table .sim-amount--positive { - color: var(--sim-success); - font-weight: 500; -} - -.sim-balance-changes__table .sim-amount--negative { - color: var(--sim-error); - font-weight: 500; -} +/* Native Token Change now uses the shared .tm-* anchored rows (TokenMovementsPanel.css) */ /* ============================================ SECTION HEADERS (Flat Design) diff --git a/src/styles/TokenMovementsPanel.css b/src/styles/TokenMovementsPanel.css index 48d2ad55..415a172e 100644 --- a/src/styles/TokenMovementsPanel.css +++ b/src/styles/TokenMovementsPanel.css @@ -92,141 +92,10 @@ overflow-x: auto; } -/* Direction group divider (between outgoing and incoming rows) */ -.token-movements-table tr.token-movements-group-divider td { - padding: 0; - border-bottom: none; - height: 0; - border-top: 2px solid rgba(255, 255, 255, 0.12); -} - -/* Table styles */ -.token-movements-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; - table-layout: fixed; -} - -.token-movements-table thead th { - text-align: left; - padding: 4px 10px; - color: #666; - font-weight: 500; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.3px; - border-bottom: 1px solid rgba(255, 255, 255, 0.04); - background: rgba(15, 15, 20, 0.3); -} - -/* Address-grouped table widths */ -.token-movements-table--address th:nth-child(1), -.token-movements-table--address td:nth-child(1) { - width: 22%; -} - -.token-movements-table--address th:nth-child(2), -.token-movements-table--address td:nth-child(2) { - width: 18%; -} - -.token-movements-table--address th:nth-child(3), -.token-movements-table--address td:nth-child(3) { - width: 38%; - text-align: right; -} - -.token-movements-table--address th:nth-child(4), -.token-movements-table--address td:nth-child(4) { - width: 22%; - text-align: right; -} - -/* Chronological table widths */ -.token-movements-table--chronological th:nth-child(1), -.token-movements-table--chronological td:nth-child(1) { - width: 18%; -} - -.token-movements-table--chronological th:nth-child(2), -.token-movements-table--chronological td:nth-child(2) { - width: 18%; -} - -.token-movements-table--chronological th:nth-child(3), -.token-movements-table--chronological td:nth-child(3) { - width: 11%; - text-align: right; -} - -.token-movements-table--chronological th:nth-child(4), -.token-movements-table--chronological td:nth-child(4) { - width: 15%; -} - -.token-movements-table--chronological th:nth-child(5), -.token-movements-table--chronological td:nth-child(5) { - width: 11%; -} - -.token-movements-table--chronological th:nth-child(6), -.token-movements-table--chronological td:nth-child(6) { - width: 10%; -} - -.token-movements-table--chronological th:nth-child(7), -.token-movements-table--chronological td:nth-child(7) { - width: 17%; -} - -.token-movements-table tbody tr { - border-bottom: 1px solid rgba(255, 255, 255, 0.03); - transition: background 0.1s ease; -} - -.token-movements-table tbody tr:hover { - background: rgba(255, 255, 255, 0.02); -} - -.token-movements-table tbody tr:last-child { - border-bottom: none; -} - -.token-movements-table td { - padding: 4px 10px; - vertical-align: middle; -} - -/* Address cell */ -.address-cell { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.address-icon { - width: 14px; - height: 14px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 3px; - font-size: 10px; - margin-right: 6px; - vertical-align: middle; -} - -.address-icon.icon-out { - background: rgba(239, 68, 68, 0.15); - color: #ef4444; -} - -.address-icon.icon-in { - background: rgba(34, 197, 94, 0.15); - color: #22c55e; -} +/* Anchored asset-change rows (.tm-*) live in the shared styles/asset-rows.css, + imported by both this panel and ExecutionStackTrace. */ +/* address-value is still emitted by resultFormatter.ts */ .address-value { color: #d4d4d4; font-family: "Fira Code", "Consolas", monospace; @@ -242,39 +111,7 @@ vertical-align: middle; } -/* Token cell */ -.token-cell { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.token-cell-content { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.token-icon-img { - width: 16px; - height: 16px; - border-radius: 50%; - object-fit: cover; - background: rgba(255, 255, 255, 0.1); - flex-shrink: 0; -} - -.token-icon-fallback { - color: #6b7280; - font-size: 15px; - flex-shrink: 0; -} - -.token-symbol { - color: #e2e8f0; - font-weight: 500; - font-size: 12px; -} +/* .token-symbol moved to styles/asset-rows.css (shared, with truncation) */ .token-address-fallback { color: #6b7280; @@ -282,53 +119,6 @@ font-size: 11px; } -/* Token ID cell (for NFTs) */ -.token-id-cell { - font-family: "Fira Code", "Consolas", monospace; - font-size: 11px; - color: #d4d4d4; - text-align: left; - white-space: nowrap; -} - -/* Delta cell */ -.delta-cell { - font-family: "Fira Code", "Consolas", monospace; - font-weight: 500; - font-size: 12px; - text-align: right; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.delta-cell.delta-negative { - color: #ef4444; -} - -.delta-cell.delta-positive { - color: #22c55e; -} - -/* USD value cell */ -.usd-cell { - font-family: "Fira Code", "Consolas", monospace; - font-size: 11px; - text-align: right; - white-space: nowrap; - color: #6b7280; -} - -.usd-cell.delta-negative { - color: #ef4444; -} - -.usd-cell.delta-positive { - color: #22c55e; -} - -/* Empty state */ - /* ====================================== Cross-reference highlighting ====================================== */ @@ -359,13 +149,4 @@ gap: 12px; align-items: flex-start; } - - .token-movements-table { - font-size: 13px; - } - - .token-movements-table td, - .token-movements-table th { - padding: 8px 12px; - } } diff --git a/src/styles/asset-rows.css b/src/styles/asset-rows.css new file mode 100644 index 00000000..8a777e93 --- /dev/null +++ b/src/styles/asset-rows.css @@ -0,0 +1,133 @@ +/* ============================================ + Asset-change anchored rows — SHARED + Imported by both TokenMovementsPanel (token movements) + and ExecutionStackTrace (native token change) so the two + sections stay pixel-identical without cross-component coupling. + + Full-width ledger rows: direction glyph + address + asset + chip on the left; delta over USD pinned to the right. + ============================================ */ +.tm-list { + display: flex; + flex-direction: column; +} +.tm-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 52px; + padding: 10px 4px; +} +.tm-row + .tm-row { + border-top: 1px solid rgba(255, 255, 255, 0.06); +} +.tm-row:hover { + background: rgba(255, 255, 255, 0.022); +} +.tm-divider { + height: 0; + border-top: 2px solid rgba(255, 255, 255, 0.12); + margin: 2px 0; +} + +.tm-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; /* let children shrink so long names can truncate */ +} +/* Direction glyph: Phosphor ArrowCircle (fill) — self-contained */ +.tm-dir { + flex: none; + display: block; +} +.tm-out .tm-dir { color: #ef4444; } +.tm-in .tm-dir { color: #22c55e; } +.tm-addr { + color: #22d3ee; + font-family: "Fira Code", "Consolas", monospace; + font-size: 13px; + white-space: nowrap; +} +.tm-asset { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; + max-width: 220px; + padding: 3px 9px 3px 5px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(255, 255, 255, 0.02); +} +.tm-asset-logo { + width: 16px; + height: 16px; + border-radius: 50%; + object-fit: cover; + background: rgba(255, 255, 255, 0.1); + flex-shrink: 0; +} +.tm-asset-logo--fallback { + display: inline-flex; + align-items: center; + justify-content: center; + color: #6b7280; + background: transparent; + font-size: 13px; +} +/* Token symbol/name — truncate long names with an ellipsis */ +.token-symbol { + color: #e2e8f0; + font-weight: 500; + font-size: 12px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tm-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + flex: none; +} +.tm-delta { + font-family: "Fira Code", "Consolas", monospace; + font-size: 16px; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} +.tm-usd { + font-family: "Fira Code", "Consolas", monospace; + font-size: 12px; + line-height: 1.1; + font-variant-numeric: tabular-nums; + color: #6b7280; +} +.tm-out .tm-delta { color: #ef4444; } +.tm-in .tm-delta { color: #22c55e; } +.tm-out .tm-usd, +.tm-in .tm-usd { opacity: 0.85; } +.tm-out .tm-usd { color: #ef4444; } +.tm-in .tm-usd { color: #22c55e; } + +/* Chronological rows: asset chip + "from → to" flow on the left */ +.tm-row--chrono .tm-left { + flex-wrap: wrap; +} +.tm-flow { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; +} +.tm-flow .tm-addr { font-size: 12px; } +.tm-arrow { + color: #6b7280; + font-size: 13px; +} +.tm-neutral .tm-delta { color: #d4d4d4; } diff --git a/src/utils/tokenMovements.ts b/src/utils/tokenMovements.ts index 6e36db19..e0219c9e 100644 --- a/src/utils/tokenMovements.ts +++ b/src/utils/tokenMovements.ts @@ -371,6 +371,25 @@ export function getCachedTokenMetadata(tokenAddress: string): TokenMetadata | nu return tokenMetadataCache.get(tokenAddress.toLowerCase()) ?? null; } +/** + * Format a movement's raw base-unit `amount` into a human-readable decimal + * string, mirroring aggregateBalanceChanges: ERC-20 divides by cached decimals + * (default 18); NFTs are whole counts. Returns an unsigned string. + */ +export function formatMovementAmount(movement: TokenMovement): string { + if (movement.formattedAmount) return movement.formattedAmount; + const raw = String(movement.amount ?? "0"); + const isNft = movement.tokenType === "ERC-721" || movement.tokenType === "ERC-1155"; + if (isNft) return raw; + try { + // Match aggregateBalanceChanges exactly: cached decimals ?? 18 (it ignores movement.decimals). + const decimals = getCachedTokenMetadata(movement.tokenAddress)?.decimals ?? 18; + return ethers.utils.formatUnits(BigInt(raw === "undefined" || raw === "" ? "0" : raw), decimals); + } catch { + return raw; + } +} + // Pre-cache common tokens (Ethereum Mainnet) setTokenMetadataCache("0xdAC17F958D2ee523a2206206994597C13D831ec7", { symbol: "USDT", name: "Tether USD", decimals: 6 }); setTokenMetadataCache("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", { symbol: "USDC", name: "USD Coin", decimals: 6 });