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/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..5cab8939 100644
--- a/ui/src/app/bundles/[uuid]/page.tsx
+++ b/ui/src/app/bundles/[uuid]/page.tsx
@@ -1,12 +1,388 @@
"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;
+const BLOCK_EXPLORER_URL = process.env.NEXT_PUBLIC_BLOCK_EXPLORER_URL;
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 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);
+
+ 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 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 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 +390,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 +400,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 +419,135 @@ export default function BundlePage({ params }: PageProps) {
};
fetchData();
-
- const interval = setInterval(fetchData, 400);
-
+ const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval);
}, [uuid]);
- if (!uuid) {
+ if (!uuid || (loading && !data)) {
return (
-
-
Loading...
+
);
}
+ 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...
- )}
+
+
+
+
+
+
+
+
+
+ TIPS
+
+
+
+
+ {uuid}
+
+
+
+
+
+
+
{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}
- ))}
-
-
- ) : 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}
-
-
-
- );
- })}
+ {data && latestBundle && (
+
+
+ Transactions
+
+ {latestBundle.txs.map((tx, index) => (
+
+ ))}
- ) : (
-
- {loading
- ? "Loading events..."
- : "No events found for this bundle."}
-
+
+
+ {latestBundle.meter_bundle_response && (
+
)}
+
+
+ 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
+
+
+ {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,