From 121785536877a751e64aed1c5873cd3ca51faa65 Mon Sep 17 00:00:00 2001 From: Raymond Abiola Date: Fri, 26 Jun 2026 13:01:48 +0100 Subject: [PATCH] feat: add mempool and gas fee auction simulator --- frontend/src/app/globals.css | 16 ++ frontend/src/app/mempool-auction/page.tsx | 169 ++++++++++++++++++ .../mempool-auction/BlockHistory.tsx | 78 ++++++++ .../mempool-auction/MempoolGrid.tsx | 138 ++++++++++++++ frontend/src/hooks/useMempoolSimulator.ts | 148 +++++++++++++++ frontend/src/lib/mempool.ts | 119 ++++++++++++ frontend/src/lib/site-data.ts | 12 ++ 7 files changed, 680 insertions(+) create mode 100644 frontend/src/app/mempool-auction/page.tsx create mode 100644 frontend/src/components/mempool-auction/BlockHistory.tsx create mode 100644 frontend/src/components/mempool-auction/MempoolGrid.tsx create mode 100644 frontend/src/hooks/useMempoolSimulator.ts create mode 100644 frontend/src/lib/mempool.ts diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index f4e3a688..2e3a9e60 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -323,3 +323,19 @@ textarea { .lesson-viewer .token.url { color: var(--prism-operator); } .lesson-viewer .token.punctuation { color: var(--prism-punctuation); } + +/* Thin themed scrollbar shared by simulator-style panels */ +.custom-scrollbar::-webkit-scrollbar { + width: 4px; + height: 4px; +} +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} +.custom-scrollbar::-webkit-scrollbar-thumb { + background: #333; + border-radius: 10px; +} +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: #ef4444; +} diff --git a/frontend/src/app/mempool-auction/page.tsx b/frontend/src/app/mempool-auction/page.tsx new file mode 100644 index 00000000..edc8621b --- /dev/null +++ b/frontend/src/app/mempool-auction/page.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { BlockHistory } from '@/components/mempool-auction/BlockHistory'; +import { MempoolGrid } from '@/components/mempool-auction/MempoolGrid'; +import { useMempoolSimulator } from '@/hooks/useMempoolSimulator'; +import { MAX_FEE_BID, MIN_FEE_BID, selectForBlock, totalGas } from '@/lib/mempool'; +import { useMemo } from 'react'; + +const GAS_LIMIT_MIN = 500_000; +const GAS_LIMIT_MAX = 5_000_000; + +export default function MempoolAuctionPage() { + const { + pool, + blocks, + settings, + autoFlow, + addTransaction, + removeTransaction, + setFeeBid, + updateSettings, + mineBlock, + reset, + setAutoFlow, + } = useMempoolSimulator(); + + // Preview which transactions the next block would settle, so the grid can + // highlight winning bids live as parameters change. + const nextBlock = useMemo( + () => selectForBlock(pool, settings.gasLimit, settings.baseFee), + [pool, settings.gasLimit, settings.baseFee], + ); + const nextBlockIds = useMemo(() => new Set(nextBlock.map((tx) => tx.id)), [nextBlock]); + const projectedFill = Math.round((totalGas(nextBlock) / settings.gasLimit) * 100); + + return ( +
+ {/* Background grid accent */} +
+ +
+ {/* Header */} +
+
+

+ Gas Fee Auction +

+

+ Mempool Simulator — Highest Bidder Settles First +

+
+
+ + + + +
+
+ + {/* Network parameters */} +
+
+
+ Base Fee + {settings.baseFee} gwei +
+ updateSettings({ baseFee: Number(e.target.value) })} + className="w-full accent-red-600" + aria-label="Network base fee in gwei" + /> +

Bids below the base fee are excluded.

+
+ +
+
+ Block Gas Limit + + {settings.gasLimit.toLocaleString()} + +
+ updateSettings({ gasLimit: Number(e.target.value) })} + className="w-full accent-red-600" + aria-label="Block gas limit" + /> +

Caps how many bids fit per block.

+
+ +
+
+ Next Block + + {nextBlock.length} tx · {projectedFill}% + +
+
+
+
+

+ Highest bidders that clear the base fee and fit the gas limit. +

+
+
+ + {/* Auction view */} +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/mempool-auction/BlockHistory.tsx b/frontend/src/components/mempool-auction/BlockHistory.tsx new file mode 100644 index 00000000..25bd05cc --- /dev/null +++ b/frontend/src/components/mempool-auction/BlockHistory.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { MinedBlock } from '@/lib/mempool'; + +interface BlockHistoryProps { + blocks: MinedBlock[]; +} + +export function BlockHistory({ blocks }: BlockHistoryProps) { + return ( +
+

+ Mined Blocks + History [{blocks.length}] +

+ +
+ {blocks.length === 0 ? ( +

+ No blocks yet — mine one to settle the top bids. +

+ ) : ( + blocks.map((block) => { + const fill = Math.round((block.gasUsed / block.gasLimit) * 100); + return ( +
+
+ #{block.height} + + {new Date(block.minedAt).toLocaleTimeString()} + +
+ +
+
+

Txs

+

{block.transactions.length}

+
+
+

Base

+

{block.baseFee} gwei

+
+
+

Reward

+

+ {(block.totalFees / 1e9).toFixed(4)} Ξ +

+
+
+ +
+
+
+

+ {block.gasUsed.toLocaleString()} / {block.gasLimit.toLocaleString()} gas ({fill}%) +

+
+ ); + }) + )} +
+
+ ); +} diff --git a/frontend/src/components/mempool-auction/MempoolGrid.tsx b/frontend/src/components/mempool-auction/MempoolGrid.tsx new file mode 100644 index 00000000..6a8b428b --- /dev/null +++ b/frontend/src/components/mempool-auction/MempoolGrid.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { + MAX_FEE_BID, + MIN_FEE_BID, + PendingTx, + sortByFee, +} from '@/lib/mempool'; +import { useMemo } from 'react'; + +interface MempoolGridProps { + pool: PendingTx[]; + baseFee: number; + /** Ids the next block would include — used to highlight winning bids. */ + nextBlockIds: Set; + onFeeBid: (id: string, feeBid: number) => void; + onRemove: (id: string) => void; +} + +const typeColor: Record = { + TRANSFER: 'text-sky-400', + SWAP: 'text-violet-400', + MINT: 'text-amber-400', + CONTRACT: 'text-emerald-400', +}; + +export function MempoolGrid({ + pool, + baseFee, + nextBlockIds, + onFeeBid, + onRemove, +}: MempoolGridProps) { + const sorted = useMemo(() => sortByFee(pool), [pool]); + + return ( +
+

+ Mempool + + Pending [{sorted.length}] + +

+ +
+ {sorted.length === 0 ? ( +

+ Pool is empty — broadcast a transaction to start the auction. +

+ ) : ( +
    + {sorted.map((tx, index) => { + const winning = nextBlockIds.has(tx.id); + const underpriced = tx.feeBid < baseFee; + return ( +
  • +
    +
    + + #{index + 1} + + + {tx.id} + + + {tx.type} + +
    +
    + {winning && ( + + NEXT BLOCK + + )} + {underpriced && !winning && ( + + UNDERPRICED + + )} + +
    +
    + +
    +
    +
    + Fee Bid + + {tx.feeBid} gwei + +
    + onFeeBid(tx.id, Number(e.target.value))} + className="w-full accent-red-600" + aria-label={`Fee bid for transaction ${tx.id}, in gwei`} + /> +
    +
    +

    Gas

    +

    + {tx.gasUnits.toLocaleString()} +

    +
    +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/hooks/useMempoolSimulator.ts b/frontend/src/hooks/useMempoolSimulator.ts new file mode 100644 index 00000000..7e215ecc --- /dev/null +++ b/frontend/src/hooks/useMempoolSimulator.ts @@ -0,0 +1,148 @@ +'use client'; + +import { getItem, setItem } from '@/lib/localStorage'; +import { + MAX_FEE_BID, + MIN_FEE_BID, + MinedBlock, + PendingTx, + randomTx, + selectForBlock, + totalGas, + txPriorityFee, +} from '@/lib/mempool'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +const STORAGE_KEY = 'mempool_auction_state'; + +export interface MempoolSettings { + /** Gas ceiling per block. */ + gasLimit: number; + /** Network base fee (gwei); bids below it are ineligible. */ + baseFee: number; +} + +interface PersistedState { + pool: PendingTx[]; + blocks: MinedBlock[]; + settings: MempoolSettings; + nextHeight: number; +} + +export const DEFAULT_SETTINGS: MempoolSettings = { + gasLimit: 1_500_000, + baseFee: 12, +}; + +function defaultState(): PersistedState { + return { pool: [], blocks: [], settings: DEFAULT_SETTINGS, nextHeight: 1_000_001 }; +} + +function clampFee(value: number): number { + return Math.min(MAX_FEE_BID, Math.max(MIN_FEE_BID, Math.round(value))); +} + +export function useMempoolSimulator() { + const [pool, setPool] = useState([]); + const [blocks, setBlocks] = useState([]); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [autoFlow, setAutoFlow] = useState(false); + const [hydrated, setHydrated] = useState(false); + const nextHeight = useRef(defaultState().nextHeight); + + // Load persisted state once on mount (client only). + useEffect(() => { + const stored = getItem(STORAGE_KEY, defaultState()); + setPool(stored.pool); + setBlocks(stored.blocks); + setSettings({ ...DEFAULT_SETTINGS, ...stored.settings }); + nextHeight.current = stored.nextHeight ?? defaultState().nextHeight; + setHydrated(true); + }, []); + + // Persist whenever the simulation changes (after hydration to avoid clobbering). + useEffect(() => { + if (!hydrated) return; + setItem(STORAGE_KEY, { + pool, + blocks, + settings, + nextHeight: nextHeight.current, + }); + }, [hydrated, pool, blocks, settings]); + + const addTransaction = useCallback((tx?: PendingTx) => { + setPool((prev) => [tx ?? randomTx(Date.now()), ...prev].slice(0, 60)); + }, []); + + const removeTransaction = useCallback((id: string) => { + setPool((prev) => prev.filter((tx) => tx.id !== id)); + }, []); + + /** Update a single transaction's fee bid — the lever that re-sorts the pool. */ + const setFeeBid = useCallback((id: string, feeBid: number) => { + setPool((prev) => + prev.map((tx) => (tx.id === id ? { ...tx, feeBid: clampFee(feeBid) } : tx)), + ); + }, []); + + const updateSettings = useCallback((patch: Partial) => { + setSettings((prev) => ({ ...prev, ...patch })); + }, []); + + /** Build the next block: pack the highest bidders that fit, then drop them. */ + const mineBlock = useCallback(() => { + setPool((prevPool) => { + const included = selectForBlock(prevPool, settings.gasLimit, settings.baseFee); + if (included.length === 0) return prevPool; + + const includedIds = new Set(included.map((tx) => tx.id)); + const block: MinedBlock = { + height: nextHeight.current, + transactions: included, + gasUsed: totalGas(included), + gasLimit: settings.gasLimit, + baseFee: settings.baseFee, + totalFees: included.reduce((sum, tx) => sum + txPriorityFee(tx, settings.baseFee), 0), + minedAt: Date.now(), + }; + nextHeight.current += 1; + setBlocks((prev) => [block, ...prev].slice(0, 12)); + + return prevPool.filter((tx) => !includedIds.has(tx.id)); + }); + }, [settings.gasLimit, settings.baseFee]); + + const reset = useCallback(() => { + const fresh = defaultState(); + setPool(fresh.pool); + setBlocks(fresh.blocks); + setSettings(fresh.settings); + nextHeight.current = fresh.nextHeight; + setAutoFlow(false); + }, []); + + // Auto-flow: simulate organic mempool influx. + useEffect(() => { + if (!autoFlow) return; + const interval = setInterval(() => { + if (Math.random() > 0.4) addTransaction(); + }, 1200); + return () => clearInterval(interval); + }, [autoFlow, addTransaction]); + + return { + pool, + blocks, + settings, + autoFlow, + hydrated, + addTransaction, + removeTransaction, + setFeeBid, + updateSettings, + mineBlock, + reset, + setAutoFlow, + }; +} diff --git a/frontend/src/lib/mempool.ts b/frontend/src/lib/mempool.ts new file mode 100644 index 00000000..f82ae9f9 --- /dev/null +++ b/frontend/src/lib/mempool.ts @@ -0,0 +1,119 @@ +/** + * Mempool & Gas Fee Auction — domain logic + * + * Educational model of an Ethereum-style fee market. Pending transactions sit in + * a mempool and bid a priority fee (gwei per unit of gas). When a block is built, + * the highest bidders are packed greedily until the block's gas limit is reached, + * which is exactly the "auction" learners are meant to observe. + * + * Everything here is a pure function so it can be unit-tested and reused by both + * the simulator hook and the visual components. + */ + +export type TxType = 'TRANSFER' | 'SWAP' | 'MINT' | 'CONTRACT'; + +export interface PendingTx { + id: string; + /** Shortened pseudo address of the sender. */ + from: string; + /** Operation kind — drives the gas estimate. */ + type: TxType; + /** Gas units the transaction consumes when executed. */ + gasUnits: number; + /** Priority fee bid in gwei per gas unit. The lever users tune. */ + feeBid: number; + /** Epoch millis when the tx entered the pool — tiebreaker for equal bids. */ + addedAt: number; +} + +export interface MinedBlock { + /** Block height. */ + height: number; + /** Transactions included, in execution order (highest bid first). */ + transactions: PendingTx[]; + /** Gas actually consumed by the included transactions. */ + gasUsed: number; + /** Block gas ceiling at mining time. */ + gasLimit: number; + /** Base fee (gwei) that bids had to clear to be eligible. */ + baseFee: number; + /** Total priority fees paid to the validator, in gwei. */ + totalFees: number; + minedAt: number; +} + +/** Typical gas footprint per operation kind. */ +export const GAS_BY_TYPE: Record = { + TRANSFER: 21_000, + SWAP: 180_000, + MINT: 90_000, + CONTRACT: 250_000, +}; + +export const TX_TYPES: TxType[] = ['TRANSFER', 'SWAP', 'MINT', 'CONTRACT']; + +/** Bounds for the fee-bid control, in gwei. */ +export const MIN_FEE_BID = 1; +export const MAX_FEE_BID = 200; + +/** + * Priority fee a transaction pays the validator if mined, in gwei. + * (priority bid above the base fee) × gas consumed. + */ +export function txPriorityFee(tx: PendingTx, baseFee: number): number { + return Math.max(0, tx.feeBid - baseFee) * tx.gasUnits; +} + +/** + * Order the pool by the auction rule: highest bid wins, earliest arrival breaks + * ties. Returns a new array; the input is left untouched. + */ +export function sortByFee(pool: PendingTx[]): PendingTx[] { + return [...pool].sort((a, b) => b.feeBid - a.feeBid || a.addedAt - b.addedAt); +} + +/** + * Greedily select the transactions a validator would include: walk the pool from + * the highest bid down, skipping anything that fails to clear the base fee or no + * longer fits under the remaining gas. Mirrors a simple block builder. + */ +export function selectForBlock( + pool: PendingTx[], + gasLimit: number, + baseFee: number, +): PendingTx[] { + const included: PendingTx[] = []; + let gasLeft = gasLimit; + + for (const tx of sortByFee(pool)) { + if (tx.feeBid < baseFee) continue; + if (tx.gasUnits > gasLeft) continue; + included.push(tx); + gasLeft -= tx.gasUnits; + } + + return included; +} + +let txCounter = 0; + +/** Build a pseudo-random pending transaction for the simulated network. */ +export function randomTx(now: number): PendingTx { + const type = TX_TYPES[Math.floor(Math.random() * TX_TYPES.length)]; + const gasJitter = Math.floor((Math.random() - 0.5) * 10_000); + txCounter += 1; + + return { + id: `0x${(now.toString(16) + txCounter.toString(16)).slice(-10)}`, + from: `0x${Math.random().toString(16).slice(2, 6)}…${Math.random().toString(16).slice(2, 6)}`, + type, + gasUnits: Math.max(21_000, GAS_BY_TYPE[type] + gasJitter), + feeBid: Math.floor(Math.random() * 60) + MIN_FEE_BID, + addedAt: now, + }; +} + +/** Sum the gas of a set of transactions. */ +export function totalGas(txs: PendingTx[]): number { + return txs.reduce((sum, tx) => sum + tx.gasUnits, 0); +} diff --git a/frontend/src/lib/site-data.ts b/frontend/src/lib/site-data.ts index 031e6e4c..5d1ebe19 100644 --- a/frontend/src/lib/site-data.ts +++ b/frontend/src/lib/site-data.ts @@ -3,6 +3,7 @@ import { BrainCircuit, CheckCircle2, Compass, + Fuel, LayoutDashboard, Rocket, Calculator, @@ -35,6 +36,11 @@ export const primaryNav: NavItem[] = [ href: '/yield-calculator', description: 'Estimate compounding yield returns based on APY, frequency, and lock-up terms.', }, + { + label: 'Gas Auction', + href: '/mempool-auction', + description: 'Simulate a mempool fee market where the highest bidders are mined first.', + }, { label: 'Dashboard', href: '/dashboard', @@ -61,6 +67,12 @@ export const spotlightTools = [ summary: 'Estimate compounding yield returns based on APY, frequency, and lock-up terms.', icon: Calculator, }, + { + title: 'Gas Fee Auction', + href: '/mempool-auction', + summary: 'Watch a live mempool sort by fee bids and mine the highest bidders into blocks.', + icon: Fuel, + }, { title: 'Verification Center', href: '/verify',