Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions e2e/fixtures/zcash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -156,6 +177,10 @@ export async function mockZcashRpc(page: Page): Promise<RecordedRpcCall[]> {
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;
Expand Down
42 changes: 42 additions & 0 deletions e2e/tests/shared/mocked/zcash-mempool.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
LazyTxs,
LazyZcashBlock,
LazyZcashBlocks,
LazyZcashMempool,
LazyZcashNetwork,
LazyZcashTx,
LazyZcashTxs,
Expand Down Expand Up @@ -170,12 +171,16 @@ function AppContent() {
<Route path="zec/block/:filter" element={<LazyZcashBlock />} />
<Route path="zec/txs" element={<LazyZcashTxs />} />
<Route path="zec/tx/:filter" element={<LazyZcashTx />} />
<Route path="zec/mempool" element={<LazyZcashMempool />} />
<Route path="zec/mempool/:filter" element={<LazyZcashTx />} />
{/* Zcash Testnet routes */}
<Route path="tzec" element={<LazyZcashNetwork />} />
<Route path="tzec/blocks" element={<LazyZcashBlocks />} />
<Route path="tzec/block/:filter" element={<LazyZcashBlock />} />
<Route path="tzec/txs" element={<LazyZcashTxs />} />
<Route path="tzec/tx/:filter" element={<LazyZcashTx />} />
<Route path="tzec/mempool" element={<LazyZcashMempool />} />
<Route path="tzec/mempool/:filter" element={<LazyZcashTx />} />
{/* Solana Mainnet routes (must come before :networkId catch-all) */}
<Route path="sol" element={<LazySolanaNetwork />} />
<Route path="sol/slots" element={<LazySolanaSlots />} />
Expand Down
3 changes: 3 additions & 0 deletions src/components/LazyComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
11 changes: 8 additions & 3 deletions src/components/navbar/NavbarLogo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -183,8 +188,8 @@ export default function NavbarLogo() {
<span>{t("nav.txs")}</span>
</Link>

{/* Mempool link (Bitcoin networks only) */}
{isBitcoinNetwork(currentNetwork) && (
{/* Mempool link (Bitcoin and Zcash networks) */}
{(isBitcoinNetwork(currentNetwork) || isZcashNetwork(currentNetwork)) && (
<Link
to={`/${getNetworkUrlPath(currentNetwork)}/mempool`}
className="navbar-logo-dropdown-item"
Expand Down
194 changes: 194 additions & 0 deletions src/components/pages/zcash/ZcashMempoolPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { getNetworkBySlug } from "../../../config/networks";
import { MEMPOOL_PAGE_SIZE, MEMPOOL_REFRESH_MS } from "../../../config/zcashConstants";
import { useDataService } from "../../../hooks/useDataService";
import { useZcashTimeAgo } from "../../../hooks/useZcashTimeAgo";
import type { ZcashMempoolEntry } from "../../../types";
import { logger } from "../../../utils/logger";
import { formatNumber, formatSize, formatZEC, truncateHash } from "../../../utils/zcashFormatters";
import Breadcrumb from "../../common/Breadcrumb";
import LoaderWithTimeout from "../../common/LoaderWithTimeout";

export default function ZcashMempoolPage() {
const { t } = useTranslation("zcash");
const timeAgo = useZcashTimeAgo();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();

// Extract network slug from path (e.g., "/tzec/mempool" → "tzec")
const networkSlug = location.pathname.split("/")[1] || "zec";
const dataService = useDataService(networkSlug);
const network = getNetworkBySlug(networkSlug);
const networkName = network?.name ?? networkSlug;
const networkLabel = network?.shortName || networkSlug.toUpperCase();
const currency = network?.currency ?? "ZEC";

const pageParam = Number(searchParams.get("page"));
const page = Number.isInteger(pageParam) && pageParam > 1 ? pageParam : 1;

const [entries, setEntries] = useState<ZcashMempoolEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 = (
<Breadcrumb
items={[
{ label: t("breadcrumb.home"), to: "/" },
{ label: networkLabel, to: `/${networkSlug}` },
{ label: t("breadcrumb.mempool") },
]}
/>
);

if (loading && entries.length === 0) {
return (
<div className="container-wide">
{breadcrumb}
<div className="block-display-card">
<div className="blocks-header">
<span className="block-label">{t("mempool.title", { network: networkName })}</span>
</div>
<div className="card-content-loading">
<LoaderWithTimeout
text={t("mempool.loading")}
onRetry={() => window.location.reload()}
/>
</div>
</div>
</div>
);
}

return (
<div className="container-wide">
{breadcrumb}
<div className="block-display-card">
<div className="blocks-header">
<div className="blocks-header-main">
<span className="block-label">{t("mempool.title", { network: networkName })}</span>
<span className="block-header-divider">•</span>
<span className="blocks-header-info">
{t("mempool.summary", { count: entries.length, size: formatSize(totalBytes) })}
</span>
</div>
{totalPages > 1 && (
<span className="blocks-header-info">
{t("txsPage.pageInfo", { page: safePage, totalPages })}
</span>
)}
</div>

{error && (
<div className="card-content">
<p className="text-error margin-0">{t("mempool.loadError", { message: error })}</p>
</div>
)}

{entries.length === 0 ? (
!error && (
<div className="card-content">
<p className="text-muted margin-0">{t("mempool.empty")}</p>
</div>
)
) : (
<div className="table-wrapper">
<table className="dash-table">
<thead>
<tr>
<th>{t("mempool.txid")}</th>
<th className="hide-mobile">{t("mempool.age")}</th>
<th className="hide-mobile">{t("mempool.size")}</th>
<th>{t("mempool.fee")}</th>
<th className="hide-mobile">{t("mempool.feeRate")}</th>
</tr>
</thead>
<tbody>
{displayed.map((entry) => (
<tr key={entry.txid}>
<td className="table-cell-mono">
<Link
to={`/${networkSlug}/mempool/${entry.txid}`}
className="table-cell-address"
title={entry.txid}
>
{truncateHash(entry.txid, "long")}
</Link>
</td>
<td className="table-cell-text hide-mobile">{timeAgo(entry.time)}</td>
<td className="table-cell-muted hide-mobile">{formatSize(entry.size)}</td>
<td className="table-cell-text">{formatZEC(entry.feeZat, currency)}</td>
<td className="table-cell-muted hide-mobile">
{entry.size > 0
? t("tx.feeRate", {
rate: formatNumber(Number((entry.feeZat / entry.size).toFixed(2))),
})
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}

{totalPages > 1 && (
<div className="pagination-container">
<button
type="button"
onClick={() => goToPage(safePage - 1)}
disabled={safePage <= 1}
className="pagination-btn"
title={t("txsPage.prevTitle")}
>
{t("txsPage.prev")}
</button>
<span className="pagination-page-info">
{t("txsPage.pageInfo", { page: safePage, totalPages })}
</span>
<button
type="button"
onClick={() => goToPage(safePage + 1)}
disabled={safePage >= totalPages}
className="pagination-btn"
title={t("txsPage.nextTitle")}
>
{t("txsPage.next")}
</button>
</div>
)}
</div>
</div>
);
}
4 changes: 4 additions & 0 deletions src/config/zcashConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
15 changes: 14 additions & 1 deletion src/locales/en/zcash.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading