diff --git a/e2e/fixtures/zcash.ts b/e2e/fixtures/zcash.ts index 49b47a6a..5e88bad2 100644 --- a/e2e/fixtures/zcash.ts +++ b/e2e/fixtures/zcash.ts @@ -62,6 +62,27 @@ const VALUE_POOLS: Array<[string, number]> = [ ]; const CHAIN_SUPPLY_ZAT = 1_693_254_079_054_480; +function mempoolEntry(size: number, feeZec: number, secondsAgo: number) { + return { + size, + fee: feeZec, + modifiedfee: feeZec, + time: Math.floor(Date.now() / 1000) - secondsAgo, + height: ZCASH.tipHeight, + descendantcount: 1, + descendantsize: size, + descendantfees: Math.round(feeZec * 1e8), + depends: [], + }; +} + +// Pending transactions, deliberately out of fee-rate order: e3… pays the highest rate, d2… the lowest +const MEMPOOL = { + ["c1".repeat(32)]: mempoolEntry(245, 0.000113, 30), + ["d2".repeat(32)]: mempoolEntry(2_450, 0.0001, 90), + ["e3".repeat(32)]: mempoolEntry(372, 0.00025, 10), +}; + export interface RecordedRpcCall { method: string; params: unknown[]; @@ -156,6 +177,10 @@ export async function mockZcashRpc(page: Page): Promise { const [txid] = params as [string]; return KNOWN_TRANSACTIONS.get(txid) ?? previousTransaction(txid); }, + getrawmempool: (params) => { + const [verbose] = params as [boolean | undefined]; + return verbose ? MEMPOOL : Object.keys(MEMPOOL); + }, }); return calls; diff --git a/e2e/tests/shared/mocked/zcash-mempool.spec.ts b/e2e/tests/shared/mocked/zcash-mempool.spec.ts new file mode 100644 index 00000000..87006819 --- /dev/null +++ b/e2e/tests/shared/mocked/zcash-mempool.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "../../../fixtures/test"; +import { mockZcashRpc, ZCASH } from "../../../fixtures/zcash"; + +/** + * Hermetic Zcash mempool page. The mocked mempool holds three pending transactions listed out + * of fee-rate order. + */ +test.describe("Zcash mempool", () => { + test("lists pending transactions from one verbose call, highest fee rate first", async ({ + page, + }) => { + const calls = await mockZcashRpc(page); + + await page.goto(`/#/${ZCASH.networkSlug}/mempool`); + + const rows = page.locator("table.dash-table tbody tr"); + await expect(rows).toHaveCount(3); + await expect(rows.nth(0)).toContainText("e3e3e3e3e3e3"); + await expect(rows.nth(0)).toContainText("0.00025000 ZEC"); + await expect(rows.nth(2)).toContainText("d2d2d2d2d2d2"); + await expect(page.locator(".blocks-header-info").first()).toContainText( + "3 pending transactions", + ); + + const mempoolCalls = calls.filter((call) => call.method === "getrawmempool"); + expect(mempoolCalls.length).toBeGreaterThan(0); + for (const call of mempoolCalls) { + expect(call.params[0]).toBe(true); + } + expect(calls.map((call) => call.method)).not.toContain("getrawtransaction"); + }); + + test("links a pending transaction to its detail page", async ({ page }) => { + await mockZcashRpc(page); + + await page.goto(`/#/${ZCASH.networkSlug}/mempool`); + await page.locator("table.dash-table tbody tr").first().getByRole("link").click(); + + await expect(page).toHaveURL(new RegExp(`/${ZCASH.networkSlug}/mempool/${"e3".repeat(32)}`)); + await expect(page.getByText("Transaction ID:")).toBeVisible(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 21cc601d..31878813 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,6 +58,7 @@ import { LazyTxs, LazyZcashBlock, LazyZcashBlocks, + LazyZcashMempool, LazyZcashNetwork, LazyZcashTx, LazyZcashTxs, @@ -170,12 +171,16 @@ function AppContent() { } /> } /> } /> + } /> + } /> {/* Zcash Testnet routes */} } /> } /> } /> } /> } /> + } /> + } /> {/* Solana Mainnet routes (must come before :networkId catch-all) */} } /> } /> diff --git a/src/components/LazyComponents.tsx b/src/components/LazyComponents.tsx index c85f0019..b01623f8 100644 --- a/src/components/LazyComponents.tsx +++ b/src/components/LazyComponents.tsx @@ -26,6 +26,7 @@ const ZcashBlocksPage = lazy(() => import("./pages/zcash/ZcashBlocksPage")); const ZcashBlockPage = lazy(() => import("./pages/zcash/ZcashBlockPage")); const ZcashTransactionsPage = lazy(() => import("./pages/zcash/ZcashTransactionsPage")); const ZcashTransactionPage = lazy(() => import("./pages/zcash/ZcashTransactionPage")); +const ZcashMempoolPage = lazy(() => import("./pages/zcash/ZcashMempoolPage")); // Lazy load page components - Solana const SolanaNetwork = lazy(() => import("./pages/solana")); @@ -76,6 +77,7 @@ export const LazyZcashBlocks = withSuspense(ZcashBlocksPage); export const LazyZcashBlock = withSuspense(ZcashBlockPage); export const LazyZcashTxs = withSuspense(ZcashTransactionsPage); export const LazyZcashTx = withSuspense(ZcashTransactionPage); +export const LazyZcashMempool = withSuspense(ZcashMempoolPage); export const LazySolanaNetwork = withSuspense(SolanaNetwork); export const LazySolanaSlots = withSuspense(SolanaSlotsPage); export const LazySolanaSlot = withSuspense(SolanaSlotPage); @@ -127,6 +129,7 @@ export function preloadAllRoutes() { import("./pages/zcash/ZcashBlockPage"); import("./pages/zcash/ZcashTransactionsPage"); import("./pages/zcash/ZcashTransactionPage"); + import("./pages/zcash/ZcashMempoolPage"); // Solana pages import("./pages/solana"); import("./pages/solana/SolanaSlotsPage"); diff --git a/src/components/navbar/NavbarLogo.tsx b/src/components/navbar/NavbarLogo.tsx index 17b5dd56..e5a07ea6 100644 --- a/src/components/navbar/NavbarLogo.tsx +++ b/src/components/navbar/NavbarLogo.tsx @@ -3,7 +3,12 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useNetworks } from "../../context/AppContext"; import { getBaseDomainUrl, getSubdomain, getSubdomainRedirect } from "../../utils/subdomainUtils"; -import { getNetworkUrlPath, isBitcoinNetwork, resolveNetwork } from "../../utils/networkResolver"; +import { + getNetworkUrlPath, + isBitcoinNetwork, + isZcashNetwork, + resolveNetwork, +} from "../../utils/networkResolver"; import NetworkIcon from "../common/NetworkIcon"; // OpenScan cube SVG component @@ -183,8 +188,8 @@ export default function NavbarLogo() { {t("nav.txs")} - {/* Mempool link (Bitcoin networks only) */} - {isBitcoinNetwork(currentNetwork) && ( + {/* Mempool link (Bitcoin and Zcash networks) */} + {(isBitcoinNetwork(currentNetwork) || isZcashNetwork(currentNetwork)) && ( 1 ? pageParam : 1; + + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // A single verbose getrawmempool call returns every entry with its fee, so no per-transaction + // fetches are needed + const fetchMempool = useCallback(async () => { + if (!dataService || !dataService.isZcash()) return; + + try { + const { data } = await dataService.getZcashAdapter().getMempoolEntries(); + setEntries(data); + setError(null); + } catch (err) { + logger.error("Error fetching Zcash mempool:", err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [dataService]); + + useEffect(() => { + fetchMempool(); + const intervalId = setInterval(fetchMempool, MEMPOOL_REFRESH_MS); + return () => clearInterval(intervalId); + }, [fetchMempool]); + + const totalPages = Math.max(1, Math.ceil(entries.length / MEMPOOL_PAGE_SIZE)); + const safePage = Math.min(page, totalPages); + const displayed = entries.slice((safePage - 1) * MEMPOOL_PAGE_SIZE, safePage * MEMPOOL_PAGE_SIZE); + const totalBytes = entries.reduce((sum, entry) => sum + entry.size, 0); + + const goToPage = (target: number) => setSearchParams(target > 1 ? { page: String(target) } : {}); + + const breadcrumb = ( + + ); + + if (loading && entries.length === 0) { + return ( +
+ {breadcrumb} +
+
+ {t("mempool.title", { network: networkName })} +
+
+ window.location.reload()} + /> +
+
+
+ ); + } + + return ( +
+ {breadcrumb} +
+
+
+ {t("mempool.title", { network: networkName })} + + + {t("mempool.summary", { count: entries.length, size: formatSize(totalBytes) })} + +
+ {totalPages > 1 && ( + + {t("txsPage.pageInfo", { page: safePage, totalPages })} + + )} +
+ + {error && ( +
+

{t("mempool.loadError", { message: error })}

+
+ )} + + {entries.length === 0 ? ( + !error && ( +
+

{t("mempool.empty")}

+
+ ) + ) : ( +
+ + + + + + + + + + + + {displayed.map((entry) => ( + + + + + + + + ))} + +
{t("mempool.txid")}{t("mempool.age")}{t("mempool.size")}{t("mempool.fee")}{t("mempool.feeRate")}
+ + {truncateHash(entry.txid, "long")} + + {timeAgo(entry.time)}{formatSize(entry.size)}{formatZEC(entry.feeZat, currency)} + {entry.size > 0 + ? t("tx.feeRate", { + rate: formatNumber(Number((entry.feeZat / entry.size).toFixed(2))), + }) + : "—"} +
+
+ )} + + {totalPages > 1 && ( +
+ + + {t("txsPage.pageInfo", { page: safePage, totalPages })} + + +
+ )} +
+
+ ); +} diff --git a/src/config/zcashConstants.ts b/src/config/zcashConstants.ts index 574377fd..dc1f2cee 100644 --- a/src/config/zcashConstants.ts +++ b/src/config/zcashConstants.ts @@ -22,3 +22,7 @@ export const BLOCKS_PER_PAGE = 10; // Transactions list page size (paged client-side within a single block) export const TXS_PER_PAGE = 25; + +// Mempool page: one verbose getrawmempool call per refresh, paged client-side +export const MEMPOOL_PAGE_SIZE = 50; +export const MEMPOOL_REFRESH_MS = 60_000; diff --git a/src/locales/en/zcash.json b/src/locales/en/zcash.json index aead34ac..1ddcdb38 100644 --- a/src/locales/en/zcash.json +++ b/src/locales/en/zcash.json @@ -46,7 +46,20 @@ "blocks": "Blocks", "block": "Block #{{height}}", "transactions": "Transactions", - "transaction": "{{txid}}" + "transaction": "{{txid}}", + "mempool": "Mempool" + }, + "mempool": { + "title": "{{network}} Mempool", + "loading": "Loading mempool...", + "summary": "{{count}} pending transactions ({{size}})", + "empty": "No pending transactions in the mempool", + "txid": "Transaction ID", + "age": "Age", + "size": "Size", + "fee": "Fee", + "feeRate": "Fee Rate", + "loadError": "Couldn't load the mempool: {{message}}" }, "blocksPage": { "title": "{{network}} Blocks", diff --git a/src/locales/es/zcash.json b/src/locales/es/zcash.json index d346eb6e..1a4ce850 100644 --- a/src/locales/es/zcash.json +++ b/src/locales/es/zcash.json @@ -46,7 +46,20 @@ "blocks": "Bloques", "block": "Bloque #{{height}}", "transactions": "Transacciones", - "transaction": "{{txid}}" + "transaction": "{{txid}}", + "mempool": "Mempool" + }, + "mempool": { + "title": "Mempool de {{network}}", + "loading": "Cargando mempool...", + "summary": "{{count}} transacciones pendientes ({{size}})", + "empty": "No hay transacciones pendientes en la mempool", + "txid": "ID de transacción", + "age": "Antigüedad", + "size": "Tamaño", + "fee": "Comisión", + "feeRate": "Tasa de comisión", + "loadError": "No se pudo cargar la mempool: {{message}}" }, "blocksPage": { "title": "Bloques de {{network}}", diff --git a/src/locales/ja/zcash.json b/src/locales/ja/zcash.json index 3e07f70c..d8b2220f 100644 --- a/src/locales/ja/zcash.json +++ b/src/locales/ja/zcash.json @@ -46,7 +46,20 @@ "blocks": "ブロック", "block": "ブロック #{{height}}", "transactions": "トランザクション", - "transaction": "{{txid}}" + "transaction": "{{txid}}", + "mempool": "メンプール" + }, + "mempool": { + "title": "{{network}} メンプール", + "loading": "メンプールを読み込み中...", + "summary": "保留中のトランザクション {{count}} 件 ({{size}})", + "empty": "メンプールに保留中のトランザクションはありません", + "txid": "トランザクションID", + "age": "経過時間", + "size": "サイズ", + "fee": "手数料", + "feeRate": "手数料率", + "loadError": "メンプールを読み込めませんでした: {{message}}" }, "blocksPage": { "title": "{{network}} のブロック", diff --git a/src/locales/pt-BR/zcash.json b/src/locales/pt-BR/zcash.json index 74d18946..27491a84 100644 --- a/src/locales/pt-BR/zcash.json +++ b/src/locales/pt-BR/zcash.json @@ -46,7 +46,20 @@ "blocks": "Blocos", "block": "Bloco #{{height}}", "transactions": "Transações", - "transaction": "{{txid}}" + "transaction": "{{txid}}", + "mempool": "Mempool" + }, + "mempool": { + "title": "Mempool de {{network}}", + "loading": "Carregando mempool...", + "summary": "{{count}} transações pendentes ({{size}})", + "empty": "Nenhuma transação pendente na mempool", + "txid": "ID da transação", + "age": "Idade", + "size": "Tamanho", + "fee": "Taxa", + "feeRate": "Taxa por byte", + "loadError": "Não foi possível carregar a mempool: {{message}}" }, "blocksPage": { "title": "Blocos de {{network}}", diff --git a/src/locales/zh/zcash.json b/src/locales/zh/zcash.json index db5fc02d..d98818ef 100644 --- a/src/locales/zh/zcash.json +++ b/src/locales/zh/zcash.json @@ -46,7 +46,20 @@ "blocks": "区块", "block": "区块 #{{height}}", "transactions": "交易", - "transaction": "{{txid}}" + "transaction": "{{txid}}", + "mempool": "内存池" + }, + "mempool": { + "title": "{{network}} 内存池", + "loading": "正在加载内存池...", + "summary": "{{count}} 笔待处理交易({{size}})", + "empty": "内存池中没有待处理的交易", + "txid": "交易 ID", + "age": "时长", + "size": "大小", + "fee": "手续费", + "feeRate": "费率", + "loadError": "无法加载内存池: {{message}}" }, "blocksPage": { "title": "{{network}} 区块",