From 740738034e787d4c385355cbf5755de315cae0b8 Mon Sep 17 00:00:00 2001 From: Danyal Prout Date: Tue, 2 Dec 2025 17:38:45 -0600 Subject: [PATCH 1/3] feat: show simulation details for bundle --- ui/src/app/api/bundles/all/route.ts | 15 - ui/src/app/api/bundles/route.ts | 15 - ui/src/app/bundles/[uuid]/page.tsx | 573 +++++++++++++++++++++++----- ui/src/app/bundles/page.tsx | 156 -------- ui/src/app/page.tsx | 73 +++- ui/src/lib/s3.ts | 86 +++-- ui/tsconfig.json | 2 +- 7 files changed, 611 insertions(+), 309 deletions(-) delete mode 100644 ui/src/app/api/bundles/all/route.ts delete mode 100644 ui/src/app/api/bundles/route.ts delete mode 100644 ui/src/app/bundles/page.tsx diff --git a/ui/src/app/api/bundles/all/route.ts b/ui/src/app/api/bundles/all/route.ts deleted file mode 100644 index 847cacfc..00000000 --- a/ui/src/app/api/bundles/all/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextResponse } from "next/server"; -import { listAllBundleKeys } from "@/lib/s3"; - -export async function GET() { - try { - const bundleKeys = await listAllBundleKeys(); - return NextResponse.json(bundleKeys); - } catch (error) { - console.error("Error fetching all bundle keys:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); - } -} diff --git a/ui/src/app/api/bundles/route.ts b/ui/src/app/api/bundles/route.ts deleted file mode 100644 index a927f56b..00000000 --- a/ui/src/app/api/bundles/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextResponse } from "next/server"; -import { listAllBundleKeys } from "@/lib/s3"; - -export async function GET() { - try { - const bundleKeys = await listAllBundleKeys(); - return NextResponse.json(bundleKeys); - } catch (error) { - console.error("Error fetching bundles:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); - } -} diff --git a/ui/src/app/bundles/[uuid]/page.tsx b/ui/src/app/bundles/[uuid]/page.tsx index b194f498..db05b19b 100644 --- a/ui/src/app/bundles/[uuid]/page.tsx +++ b/ui/src/app/bundles/[uuid]/page.tsx @@ -1,12 +1,376 @@ "use client"; +import Link from "next/link"; import { useEffect, useState } from "react"; import type { BundleHistoryResponse } from "@/app/api/bundle/[uuid]/route"; +import type { BundleTransaction, MeterBundleResponse } from "@/lib/s3"; + +const WEI_PER_GWEI = 10n ** 9n; +const WEI_PER_ETH = 10n ** 18n; interface PageProps { params: Promise<{ uuid: string }>; } +function formatBigInt(value: bigint, decimals: number, scale: bigint): string { + const whole = value / scale; + const frac = ((value % scale) * 10n ** BigInt(decimals)) / scale; + return `${whole}.${frac.toString().padStart(decimals, "0")}`; +} + +function formatHexValue(hex: string): string { + const value = BigInt(hex); + if (value >= WEI_PER_ETH / 10000n) { + return `${formatBigInt(value, 6, WEI_PER_ETH)} ETH`; + } + if (value >= WEI_PER_GWEI / 100n) { + return `${formatBigInt(value, 4, WEI_PER_GWEI)} Gwei`; + } + return `${value.toString()} Wei`; +} + +function formatGasPrice(hex: string): string { + const value = BigInt(hex); + return `${formatBigInt(value, 2, WEI_PER_GWEI)} Gwei`; +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} + +function Badge({ + children, + variant = "default", +}: { + children: React.ReactNode; + variant?: "default" | "success" | "warning" | "error"; +}) { + const variants = { + default: "bg-blue-50 text-blue-700 ring-blue-600/20", + success: "bg-emerald-50 text-emerald-700 ring-emerald-600/20", + warning: "bg-amber-50 text-amber-700 ring-amber-600/20", + error: "bg-red-50 text-red-700 ring-red-600/20", + }; + + return ( + + {children} + + ); +} + +function _KeyValue({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Card({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +function TransactionDetails({ + tx, + index, + isReverting, +}: { + tx: BundleTransaction; + index: number; + isReverting: boolean; +}) { + const [expanded, setExpanded] = useState(index === 0); + + return ( +
+ + + {expanded && ( + <> +
+ + + + + + + + + + + + + + + +
Hash + + {tx.hash} + + +
From + + {tx.signer} + + +
To + + {tx.to} + + +
+
+
+
+ Nonce + {parseInt(tx.nonce, 16)} +
+
+ Max Fee + + {formatGasPrice(tx.maxFeePerGas)} + +
+
+ Priority Fee + + {formatGasPrice(tx.maxPriorityFeePerGas)} + +
+
+ Type + + {tx.type === "0x2" ? "EIP-1559" : tx.type} + +
+
+ + )} +
+ ); +} + +function _MetricCard({ + label, + value, + subtext, +}: { + label: string; + value: string; + subtext?: string; +}) { + return ( +
+
{label}
+
+ {value} +
+ {subtext &&
{subtext}
} +
+ ); +} + +function SimulationCard({ meter }: { meter: MeterBundleResponse }) { + return ( + +
+
+
+
Total Gas
+
+ {meter.totalGasUsed.toLocaleString()} +
+
+
+
Execution Time
+
+ {meter.totalExecutionTimeUs}μs +
+
+
+
Gas Price
+
+ {formatGasPrice(meter.bundleGasPrice)} +
+
+
+
Coinbase Diff
+
+ {formatHexValue(meter.coinbaseDiff)} +
+
+
+
+
+
+ State Block + #{meter.stateBlockNumber} +
+
+ Gas Fees + + {formatHexValue(meter.gasFees)} + +
+
+ ETH to Coinbase + + {formatHexValue(meter.ethSentToCoinbase)} + +
+
+
+ ); +} + +function Timeline({ events }: { events: BundleHistoryResponse["history"] }) { + if (events.length === 0) return null; + + return ( +
+ {events.map((event, index) => ( +
+
+
+
+
+ {event.event} + +
+
+ ))} +
+ ); +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +

{children}

+ ); +} + export default function BundlePage({ params }: PageProps) { const [uuid, setUuid] = useState(""); const [data, setData] = useState(null); @@ -14,11 +378,7 @@ export default function BundlePage({ params }: PageProps) { const [error, setError] = useState(null); useEffect(() => { - const initializeParams = async () => { - const resolvedParams = await params; - setUuid(resolvedParams.uuid); - }; - initializeParams(); + params.then((p) => setUuid(p.uuid)); }, [params]); useEffect(() => { @@ -28,18 +388,17 @@ export default function BundlePage({ params }: PageProps) { try { const response = await fetch(`/api/bundle/${uuid}`); if (!response.ok) { - if (response.status === 404) { - setError("Bundle not found"); - } else { - setError("Failed to fetch bundle data"); - } + setError( + response.status === 404 + ? "Bundle not found" + : "Failed to fetch bundle data", + ); setData(null); return; } - const result = await response.json(); - setData(result); + setData(await response.json()); setError(null); - } catch (_err) { + } catch { setError("Failed to fetch bundle data"); setData(null); } finally { @@ -48,97 +407,135 @@ export default function BundlePage({ params }: PageProps) { }; fetchData(); - const interval = setInterval(fetchData, 400); - return () => clearInterval(interval); }, [uuid]); - if (!uuid) { + if (!uuid || (loading && !data)) { return ( -
-
Loading...
+
+
+
+ Loading bundle... +
); } + const latestBundle = data?.history + .filter((e) => e.data?.bundle) + .map((e) => e.data.bundle) + .pop(); + const revertingHashes = new Set(latestBundle?.reverting_tx_hashes || []); + return ( -
-
-

Bundle {uuid}

- {loading && ( -
Loading bundle data...
- )} +
+
+
+
+ + + Back + + + +
+ + TIPS + +
+
+ + {uuid} + + +
+
+
+ +
{error && ( -
{error}
+ +
+
+ + Error + + +
+
+

Error

+

{error}

+
+
+
)} -
- {data && ( -
- {(() => { - const transactions = new Set(); - - data.history.forEach((event) => { - event.data?.bundle?.revertingTxHashes?.forEach((tx) => { - transactions.add(tx); - }); - }); - - return transactions.size > 0 ? ( -
-

Transactions

-
    - {Array.from(transactions).map((tx) => ( -
  • {tx}
  • - ))} -
+ {data && latestBundle && ( +
+
+ Transactions +
+ {latestBundle.txs.map((tx, index) => ( + + ))}
- ) : null; - })()} - -
-

Bundle History

- - {data.history.length > 0 ? ( -
- {data.history.map((event, index) => { - return ( -
-
-
- - {event.event} - - - {event.data?.timestamp - ? new Date(event.data?.timestamp).toLocaleString() - : "No timestamp"} - -
- - Event #{index + 1} - -
-
- ); - })} -
- ) : ( -

- {loading - ? "Loading events..." - : "No events found for this bundle."} -

+
+ + {latestBundle.meter_bundle_response && ( +
+ Simulation Results + +
)} + +
+ Event History + + {data.history.length > 0 ? ( + + ) : ( +
+ No events recorded yet. +
+ )} +
+
-
- )} + )} +
); } diff --git a/ui/src/app/bundles/page.tsx b/ui/src/app/bundles/page.tsx deleted file mode 100644 index 17a42264..00000000 --- a/ui/src/app/bundles/page.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useCallback, useEffect, useRef, useState } from "react"; - -export default function BundlesPage() { - const [allBundles, setAllBundles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [searchHash, setSearchHash] = useState(""); - const [filteredAllBundles, setFilteredAllBundles] = useState([]); - const debounceTimeoutRef = useRef(null); - - const filterBundles = useCallback( - async (searchTerm: string, all: string[]) => { - if (!searchTerm.trim()) { - setFilteredAllBundles(all); - return; - } - - let allBundlesWithTx: string[] = []; - - try { - const response = await fetch(`/api/txn/${searchTerm.trim()}`); - - if (response.ok) { - const txnData = await response.json(); - const bundleIds = txnData.bundle_ids || []; - - allBundlesWithTx = all.filter((bundleId) => - bundleIds.includes(bundleId), - ); - } - } catch (err) { - console.error("Error filtering bundles:", err); - } - - setFilteredAllBundles(allBundlesWithTx); - }, - [], - ); - - useEffect(() => { - const fetchAllBundles = async () => { - try { - const response = await fetch("/api/bundles"); - if (!response.ok) { - setError("Failed to fetch bundles"); - setAllBundles([]); - return; - } - const result = await response.json(); - setAllBundles(result); - setError(null); - } catch (_err) { - setError("Failed to fetch bundles"); - setAllBundles([]); - } - }; - - const fetchData = async () => { - await fetchAllBundles(); - setLoading(false); - }; - - fetchData(); - - const interval = setInterval(fetchData, 400); - - return () => clearInterval(interval); - }, []); - - useEffect(() => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } - - if (!searchHash.trim()) { - filterBundles(searchHash, allBundles); - } else { - debounceTimeoutRef.current = setTimeout(() => { - filterBundles(searchHash, allBundles); - }, 300); - } - - return () => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } - }; - }, [searchHash, allBundles, filterBundles]); - - if (loading) { - return ( -
-

BundleStore (fka Mempool)

-
Loading bundles...
-
- ); - } - - return ( -
-
-
-

BundleStore (fka Mempool)

-
- setSearchHash(e.target.value)} - className="px-3 py-2 border rounded-lg bg-white/5 border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder-gray-500 dark:placeholder-gray-400 text-sm min-w-[300px]" - /> -
-
- {error && ( -
{error}
- )} -
- -
-
-

- All Bundles - {searchHash.trim() && ( - - ({filteredAllBundles.length} found) - - )} -

- {filteredAllBundles.length > 0 ? ( -
    - {filteredAllBundles.map((bundleId) => ( -
  • - - {bundleId} - -
  • - ))} -
- ) : ( -

- {searchHash.trim() - ? "No bundles found matching this transaction hash." - : "No bundles found."} -

- )} -
-
-
- ); -} diff --git a/ui/src/app/page.tsx b/ui/src/app/page.tsx index afe1146d..0c74aee7 100644 --- a/ui/src/app/page.tsx +++ b/ui/src/app/page.tsx @@ -1,5 +1,74 @@ -import { redirect } from "next/navigation"; +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; export default function Home() { - redirect("/bundles"); + const router = useRouter(); + const [searchHash, setSearchHash] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSearch = async (e: React.FormEvent) => { + e.preventDefault(); + const hash = searchHash.trim(); + if (!hash) return; + + setLoading(true); + setError(null); + + try { + const response = await fetch(`/api/txn/${hash}`); + if (!response.ok) { + if (response.status === 404) { + setError("Transaction not found"); + } else { + setError("Failed to fetch transaction data"); + } + return; + } + const result = await response.json(); + + if (result.bundle_ids && result.bundle_ids.length > 0) { + router.push(`/bundles/${result.bundle_ids[0]}`); + } else { + setError("No bundle found for this transaction"); + } + } catch (_err) { + setError("Failed to fetch transaction data"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

TIPS

+

+ Transaction Inclusion Prioritization Stack +

+
+ setSearchHash(e.target.value)} + className="w-full px-4 py-3 border rounded-lg bg-white/5 border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder-gray-500 dark:placeholder-gray-400 text-center" + disabled={loading} + /> + +
+ {error && ( +
{error}
+ )} +
+
+ ); } diff --git a/ui/src/lib/s3.ts b/ui/src/lib/s3.ts index 10a7b136..873d4058 100644 --- a/ui/src/lib/s3.ts +++ b/ui/src/lib/s3.ts @@ -1,6 +1,5 @@ import { GetObjectCommand, - ListObjectsV2Command, S3Client, type S3ClientConfig, } from "@aws-sdk/client-s3"; @@ -86,14 +85,65 @@ export async function getTransactionMetadataByHash( } } +export interface BundleTransaction { + signer: string; + type: string; + chainId: string; + nonce: string; + gas: string; + maxFeePerGas: string; + maxPriorityFeePerGas: string; + to: string; + value: string; + accessList: unknown[]; + input: string; + r: string; + s: string; + yParity: string; + v: string; + hash: string; +} + +export interface MeterBundleResult { + coinbaseDiff: string; + ethSentToCoinbase: string; + fromAddress: string; + gasFees: string; + gasPrice: string; + gasUsed: number; + toAddress: string; + txHash: string; + value: string; + executionTimeUs: number; +} + +export interface MeterBundleResponse { + bundleGasPrice: string; + bundleHash: string; + coinbaseDiff: string; + ethSentToCoinbase: string; + gasFees: string; + results: MeterBundleResult[]; + stateBlockNumber: number; + totalGasUsed: number; + totalExecutionTimeUs: number; +} + +export interface BundleData { + uuid: string; + txs: BundleTransaction[]; + block_number: string; + max_timestamp: number; + reverting_tx_hashes: string[]; + meter_bundle_response: MeterBundleResponse; +} + export interface BundleEvent { event: string; data: { key: string; timestamp: number; - bundle?: { - revertingTxHashes: Array; - }; + bundle?: BundleData; }; } @@ -121,31 +171,3 @@ export async function getBundleHistory( return null; } } - -export async function listAllBundleKeys(): Promise { - try { - const command = new ListObjectsV2Command({ - Bucket: BUCKET_NAME, - Prefix: "bundles/", - }); - - const response = await s3Client.send(command); - const bundleKeys: string[] = []; - - if (response.Contents) { - for (const object of response.Contents) { - if (object.Key?.startsWith("bundles/")) { - const bundleId = object.Key.replace("bundles/", ""); - if (bundleId) { - bundleKeys.push(bundleId); - } - } - } - } - - return bundleKeys; - } catch (error) { - console.error("Failed to list S3 bundle keys:", error); - return []; - } -} diff --git a/ui/tsconfig.json b/ui/tsconfig.json index c1334095..d7e05e54 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2020", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, From c56f7641fec227e0abcb77aaf28e63ca00973b29 Mon Sep 17 00:00:00 2001 From: Danyal Prout Date: Tue, 2 Dec 2025 18:38:45 -0600 Subject: [PATCH 2/3] links to block explorers --- ui/src/app/bundles/[uuid]/page.tsx | 43 +++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/ui/src/app/bundles/[uuid]/page.tsx b/ui/src/app/bundles/[uuid]/page.tsx index db05b19b..f5abe461 100644 --- a/ui/src/app/bundles/[uuid]/page.tsx +++ b/ui/src/app/bundles/[uuid]/page.tsx @@ -7,6 +7,7 @@ import type { BundleTransaction, MeterBundleResponse } from "@/lib/s3"; const WEI_PER_GWEI = 10n ** 9n; const WEI_PER_ETH = 10n ** 18n; +const BLOCK_EXPLORER_URL = process.env.NEXT_PUBLIC_BLOCK_EXPLORER_URL; interface PageProps { params: Promise<{ uuid: string }>; @@ -34,6 +35,34 @@ function formatGasPrice(hex: string): string { return `${formatBigInt(value, 2, WEI_PER_GWEI)} Gwei`; } +function ExplorerLink({ + type, + value, + children, + className = "", +}: { + type: "tx" | "address"; + value: string; + children: React.ReactNode; + className?: string; +}) { + if (!BLOCK_EXPLORER_URL) { + return {children}; + } + + const path = type === "tx" ? `/tx/${value}` : `/address/${value}`; + return ( + + {children} + + ); +} + function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); @@ -209,7 +238,9 @@ function TransactionDetails({ Hash - {tx.hash} + + {tx.hash} + @@ -218,7 +249,9 @@ function TransactionDetails({ From - {tx.signer} + + {tx.signer} + @@ -227,7 +260,9 @@ function TransactionDetails({ To - {tx.to} + + {tx.to} + @@ -407,7 +442,7 @@ export default function BundlePage({ params }: PageProps) { }; fetchData(); - const interval = setInterval(fetchData, 400); + const interval = setInterval(fetchData, 5000); return () => clearInterval(interval); }, [uuid]); From 9d621ede02683d77ad87ffb2b8490b80cecd61d0 Mon Sep 17 00:00:00 2001 From: Danyal Prout Date: Tue, 2 Dec 2025 18:47:43 -0600 Subject: [PATCH 3/3] fix lints --- .env.example | 1 + ui/src/app/bundles/[uuid]/page.tsx | 53 +++++++++--------------------- 2 files changed, 16 insertions(+), 38 deletions(-) diff --git a/.env.example b/.env.example index 66c83c57..05d227f4 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,7 @@ TIPS_AUDIT_S3_ACCESS_KEY_ID=minioadmin TIPS_AUDIT_S3_SECRET_ACCESS_KEY=minioadmin # TIPS UI +NEXT_PUBLIC_BLOCK_EXPLORER_URL=https://base.blockscout.com TIPS_UI_AWS_REGION=us-east-1 TIPS_UI_S3_BUCKET_NAME=tips TIPS_UI_S3_CONFIG_TYPE=manual diff --git a/ui/src/app/bundles/[uuid]/page.tsx b/ui/src/app/bundles/[uuid]/page.tsx index f5abe461..5cab8939 100644 --- a/ui/src/app/bundles/[uuid]/page.tsx +++ b/ui/src/app/bundles/[uuid]/page.tsx @@ -137,21 +137,6 @@ function Badge({ ); } -function _KeyValue({ - label, - value, -}: { - label: string; - value: React.ReactNode; -}) { - return ( -
-
{label}
-
{value}
-
- ); -} - function Card({ children, className = "", @@ -238,7 +223,11 @@ function TransactionDetails({ Hash - + {tx.hash} @@ -249,7 +238,11 @@ function TransactionDetails({ From - + {tx.signer} @@ -260,7 +253,11 @@ function TransactionDetails({ To - + {tx.to} @@ -300,26 +297,6 @@ function TransactionDetails({ ); } -function _MetricCard({ - label, - value, - subtext, -}: { - label: string; - value: string; - subtext?: string; -}) { - return ( -
-
{label}
-
- {value} -
- {subtext &&
{subtext}
} -
- ); -} - function SimulationCard({ meter }: { meter: MeterBundleResponse }) { return (