diff --git a/src/app/api/rejected/route.ts b/src/app/api/rejected/route.ts new file mode 100644 index 0000000..aacc63a --- /dev/null +++ b/src/app/api/rejected/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { + getRejectedTransaction, + listRejectedTransactions, + type RejectedTransaction, +} from "@/lib/s3"; + +export interface RejectedTransactionsResponse { + transactions: RejectedTransaction[]; +} + +export async function GET() { + try { + const summaries = await listRejectedTransactions(100); + + const transactions = ( + await Promise.all( + summaries.map((s) => getRejectedTransaction(s.blockNumber, s.txHash)), + ) + ).filter((tx): tx is RejectedTransaction => tx !== null); + + const response: RejectedTransactionsResponse = { transactions }; + return NextResponse.json(response); + } catch (error) { + console.error("Error fetching rejected transactions:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 21e257f..603a5e5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,8 +2,39 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import type { MeterBundleResponse, RejectedTransaction } from "@/lib/s3"; import type { BlockSummary, BlocksResponse } from "./api/blocks/route"; +import type { RejectedTransactionsResponse } from "./api/rejected/route"; + +type Tab = "blocks" | "rejected"; + +const WEI_PER_GWEI = 10n ** 9n; +const WEI_PER_ETH = 10n ** 18n; + +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 | undefined): string { + if (!hex) return "—"; + 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 | undefined): string { + if (!hex) return "—"; + const value = BigInt(hex); + return `${formatBigInt(value, 2, WEI_PER_GWEI)} Gwei`; +} function SearchBar({ onError }: { onError: (error: string | null) => void }) { const router = useRouter(); @@ -148,42 +179,504 @@ function Card({ ); } -export default function Home() { - const [error, setError] = useState(null); - const [blocks, setBlocks] = useState([]); +function MeteringCard({ meter }: { meter: MeterBundleResponse }) { + const executionTimeUs = meter.results.reduce( + (sum, r) => sum + r.executionTimeUs, + 0, + ); + const totalTimeUs = executionTimeUs + meter.stateRootTimeUs; + + return ( + +
+
+
+
Execution
+
+ {executionTimeUs.toLocaleString()}μs +
+
+
+
State Root
+
+ {meter.stateRootTimeUs.toLocaleString()}μs +
+
+
+
Total Time
+
+ {totalTimeUs.toLocaleString()}μs +
+
+
+ {(meter.stateRootAccountNodeCount > 0 || + meter.stateRootStorageNodeCount > 0) && ( +
+
+
+ Account Trie Nodes +
+
+ {meter.stateRootAccountNodeCount.toLocaleString()} +
+
+
+
+ Storage Trie Nodes +
+
+ {meter.stateRootStorageNodeCount.toLocaleString()} +
+
+
+ )} +
+
+
+ Total Gas + + {meter.totalGasUsed.toLocaleString()} + +
+
+ Gas Price + + {formatGasPrice(meter.bundleGasPrice)} + +
+
+ Gas Fees + + {formatHexValue(meter.gasFees)} + +
+
+ Coinbase Diff + + {formatHexValue(meter.coinbaseDiff)} + +
+
+ State Block + + #{meter.stateBlockNumber} + +
+
+
+ ); +} + +function RejectedTxRow({ + tx, + expanded, + onToggle, +}: { + tx: RejectedTransaction; + expanded: boolean; + onToggle: () => void; +}) { + const timeAgo = (() => { + const timeSince = Math.floor(Date.now() / 1000 - tx.timestamp); + if (timeSince <= 0) return "now"; + if (timeSince < 60) return `${timeSince}s ago`; + if (timeSince < 3600) return `${Math.floor(timeSince / 60)}m ago`; + if (timeSince < 86400) return `${Math.floor(timeSince / 3600)}h ago`; + return `${Math.floor(timeSince / 86400)}d ago`; + })(); + + return ( +
+ + + {expanded && ( +
+
+ + + + + + + + + + + + + + + + + + + +
Transaction + {tx.txHash} +
Block + #{tx.blockNumber.toLocaleString()} +
Reason{tx.reason}
Rejected At + {new Date(tx.timestamp * 1000).toLocaleString()} +
+
+ +
+

+ Metering Results +

+ +
+ + {tx.metering.results.length > 0 && ( +
+

+ Per-Transaction Breakdown +

+ + + + + + + + + + + + + + {tx.metering.results.map((result) => ( + + + + + + + + + ))} + +
+ Tx Hash + + From + + Gas Used + + Exec Time + + Gas Price + + Gas Fees +
+ {result.txHash.slice(0, 10)}... + {result.txHash.slice(-6)} + + {result.fromAddress.slice(0, 8)}... + {result.fromAddress.slice(-4)} + + {result.gasUsed.toLocaleString()} + + {result.executionTimeUs.toLocaleString()}μs + + {formatGasPrice(result.gasPrice)} + + {formatHexValue(result.gasFees)} +
+
+
+ )} +
+ )} +
+ ); +} + +function RejectedTransactionsTab() { + const [transactions, setTransactions] = useState([]); const [loading, setLoading] = useState(true); + const [expandedIdx, setExpandedIdx] = useState(null); + const [txHashFilter, setTxHashFilter] = useState(""); + const [blockNumberFilter, setBlockNumberFilter] = useState(""); useEffect(() => { - const fetchBlocks = async () => { + const fetchRejected = async () => { try { - const response = await fetch("/api/blocks"); + const response = await fetch("/api/rejected"); if (response.ok) { - const data: BlocksResponse = await response.json(); - setBlocks(data.blocks); + const data: RejectedTransactionsResponse = await response.json(); + setTransactions(data.transactions); } } catch { - console.error("Failed to fetch blocks"); + console.error("Failed to fetch rejected transactions"); } finally { setLoading(false); } }; + fetchRejected(); + const interval = setInterval(fetchRejected, 10000); + return () => clearInterval(interval); + }, []); + + const filteredTransactions = transactions.filter((tx) => { + if (txHashFilter) { + const query = txHashFilter.toLowerCase(); + if (!tx.txHash.toLowerCase().includes(query)) return false; + } + if (blockNumberFilter) { + const blockNum = parseInt(blockNumberFilter, 10); + if (!Number.isNaN(blockNum) && tx.blockNumber !== blockNum) return false; + } + return true; + }); + + return ( +
+ +
+ + Info + + +
+ About rejected transactions:{" "} + These are transactions that violated per-transaction resource + metering budgets (execution time or state root gas limits). They + would have never been + considered for block inclusion and were rejected during the block + building process. +
+
+
+ +
+

+ Rejected Transactions +

+
+ { + setTxHashFilter(e.target.value); + setExpandedIdx(null); + }} + className="w-52 lg:w-64 px-3 py-1.5 text-sm border rounded-lg bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent placeholder-gray-400" + /> + { + setBlockNumberFilter(e.target.value); + setExpandedIdx(null); + }} + className="w-32 px-3 py-1.5 text-sm border rounded-lg bg-white border-gray-300 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent placeholder-gray-400" + /> + {(txHashFilter || blockNumberFilter) && ( + + )} +
+
+ + + {loading ? ( +
+
+
+ + Loading rejected transactions... + +
+
+ ) : filteredTransactions.length > 0 ? ( +
+ {filteredTransactions.map((tx, index) => ( + + setExpandedIdx(expandedIdx === index ? null : index) + } + /> + ))} +
+ ) : ( +
+ + No rejected transactions + + +

+ {transactions.length > 0 + ? "No transactions match the current filters" + : "No rejected transactions found"} +

+

+ {transactions.length > 0 + ? "Try adjusting your search criteria" + : "Transactions that violate metering budgets will appear here"} +

+
+ )} + +
+ ); +} + +function getInitialTab(): Tab { + if (typeof window === "undefined") return "blocks"; + const hash = window.location.hash.replace("#", ""); + return hash === "rejected" ? "rejected" : "blocks"; +} + +export default function Home() { + const [activeTab, setActiveTab] = useState(getInitialTab); + const [error, setError] = useState(null); + const [blocks, setBlocks] = useState([]); + const [loading, setLoading] = useState(true); + + const switchTab = useCallback((tab: Tab) => { + setActiveTab(tab); + window.location.hash = tab === "blocks" ? "" : tab; + }, []); + + const fetchBlocks = useCallback(async () => { + try { + const response = await fetch("/api/blocks"); + if (response.ok) { + const data: BlocksResponse = await response.json(); + setBlocks(data.blocks); + } + } catch { + console.error("Failed to fetch blocks"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchBlocks(); const interval = setInterval(fetchBlocks, 2000); return () => clearInterval(interval); - }, []); + }, [fetchBlocks]); return (
- TIPS - +
+ + +
+ {activeTab === "blocks" && }
- {error && ( + {error && activeTab === "blocks" && (
)} -
-

- Latest Blocks -

- - - {loading ? ( -
-
-
- Loading blocks... + {activeTab === "blocks" && ( +
+

+ Latest Blocks +

+ + + {loading ? ( +
+
+
+ Loading blocks... +
-
- ) : blocks.length > 0 ? ( -
- {blocks.map((block, index) => ( - - ))} -
- ) : ( -
- No blocks available -
- )} -
-
+ ) : blocks.length > 0 ? ( +
+ {blocks.map((block, index) => ( + + ))} +
+ ) : ( +
+ No blocks available +
+ )} + + + )} + + {activeTab === "rejected" && }
); diff --git a/src/lib/s3.ts b/src/lib/s3.ts index 23630a0..0e4bcd3 100644 --- a/src/lib/s3.ts +++ b/src/lib/s3.ts @@ -1,5 +1,6 @@ import { GetObjectCommand, + ListObjectsV2Command, PutObjectCommand, S3Client, type S3ClientConfig, @@ -240,6 +241,74 @@ export async function getBlockFromCache( } } +export interface RejectedTransaction { + blockNumber: number; + txHash: string; + reason: string; + timestamp: number; + metering: MeterBundleResponse; +} + +export interface RejectedTransactionSummary { + blockNumber: number; + txHash: string; +} + +export async function listRejectedTransactions( + limit = 100, +): Promise { + try { + const command = new ListObjectsV2Command({ + Bucket: BUCKET_NAME, + Prefix: "rejected/", + MaxKeys: limit, + }); + + const response = await s3Client.send(command); + const contents = response.Contents || []; + + const summaries: RejectedTransactionSummary[] = []; + for (const obj of contents) { + if (!obj.Key) continue; + // S3 key format matches Rust S3Key::Rejected: rejected/{block_number}/{tx_hash} + const parts = obj.Key.split("/"); + if (parts.length !== 3) continue; + const blockNumber = parseInt(parts[1], 10); + const txHash = parts[2]; + if (Number.isNaN(blockNumber) || !txHash) continue; + summaries.push({ blockNumber, txHash }); + } + + summaries.sort((a, b) => b.blockNumber - a.blockNumber); + return summaries; + } catch (error) { + console.error("Failed to list rejected transactions:", error); + return []; + } +} + +export async function getRejectedTransaction( + blockNumber: number, + txHash: string, +): Promise { + const key = `rejected/${blockNumber}/${txHash}`; + const content = await getObjectContent(key); + + if (!content) { + return null; + } + + try { + return JSON.parse(content) as RejectedTransaction; + } catch (error) { + console.error( + `Failed to parse rejected transaction ${blockNumber}/${txHash}:`, + error, + ); + return null; + } +} + export async function cacheBlockData(blockData: BlockData): Promise { const key = `blocks/${blockData.hash}`;