diff --git a/e2e/tests/shared/mocked/zcash-blocks.spec.ts b/e2e/tests/shared/mocked/zcash-blocks.spec.ts new file mode 100644 index 0000000..4196273 --- /dev/null +++ b/e2e/tests/shared/mocked/zcash-blocks.spec.ts @@ -0,0 +1,56 @@ +import { BLOCK_3483400 } from "../../../../src/services/adapters/ZcashAdapter/fixtures"; +import { expect, test } from "../../../fixtures/test"; +import { mockZcashRpc, ZCASH } from "../../../fixtures/zcash"; + +/** + * Hermetic Zcash blocks list and block detail pages, served from mainnet block 3,483,400. + */ +test.describe("Zcash blocks", () => { + test("lists the latest blocks one call per block and pages to older blocks", async ({ + page, + }) => { + const calls = await mockZcashRpc(page); + + await page.goto(`/#/${ZCASH.networkSlug}/blocks`); + + const table = page.locator("table.blocks-table"); + await expect(table).toContainText("3,483,400"); + await expect(table).toContainText("3,483,391"); + await expect(table.locator("tbody tr")).toHaveCount(10); + + const summaryHeights = calls + .filter((call) => call.method === "getblock" && call.params[1] === 1) + .map((call) => call.params[0]); + expect(summaryHeights).toContain("3483391"); + expect(calls.map((call) => call.method)).not.toContain("getblockhash"); + + await page.getByRole("button", { name: "Older →" }).click(); + await expect(page).toHaveURL(/fromBlock=3483390/); + await expect(table).toContainText("3,483,381"); + }); + + test("shows Zcash-specific block details", async ({ page }) => { + const calls = await mockZcashRpc(page); + + await page.goto(`/#/${ZCASH.networkSlug}/block/${ZCASH.tipHeight}`); + + await expect(page.locator(".block-number")).toHaveText("#3,483,400"); + await expect(page.getByText(BLOCK_3483400.hash)).toBeVisible(); + await expect(page.getByText("t1SqwRAAdSig6dE4EBPLonAait219VmkUjP")).toBeVisible(); + await expect(page.getByText("2 Sapling")).toBeVisible(); + await expect(page.getByText("2 Ironwood")).toBeVisible(); + await expect(page.getByTestId("zcash-value-pools")).toContainText("Value Pools After This Block"); + + await page.getByRole("button", { name: "+ Show More Details" }).click(); + await expect(page.getByText(BLOCK_3483400.nonce)).toBeVisible(); + await expect(page.getByText("Equihash Solution:")).toBeVisible(); + await expect(page.getByText("Ironwood 318,976")).toBeVisible(); + + // Only the requested block is fetched with full transactions, by height string. The dev + // server runs React StrictMode, which can issue the same request twice. + const verboseBlockCalls = calls.filter( + (call) => call.method === "getblock" && call.params[1] === 2, + ); + expect(new Set(verboseBlockCalls.map((call) => call.params[0]))).toEqual(new Set(["3483400"])); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index b6294f3..018d08e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,6 +56,8 @@ import { LazyTokenDetails, LazyTx, LazyTxs, + LazyZcashBlock, + LazyZcashBlocks, LazyZcashNetwork, preloadAllRoutes, } from "./components/LazyComponents"; @@ -162,8 +164,12 @@ function AppContent() { } /> {/* Zcash Mainnet routes (must come before :networkId catch-all) */} } /> + } /> + } /> {/* 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 80b7025..0a5bf25 100644 --- a/src/components/LazyComponents.tsx +++ b/src/components/LazyComponents.tsx @@ -22,6 +22,8 @@ const BitcoinMempoolPage = lazy(() => import("./pages/bitcoin/BitcoinMempoolPage // Lazy load page components - Zcash const ZcashNetwork = lazy(() => import("./pages/zcash")); +const ZcashBlocksPage = lazy(() => import("./pages/zcash/ZcashBlocksPage")); +const ZcashBlockPage = lazy(() => import("./pages/zcash/ZcashBlockPage")); // Lazy load page components - Solana const SolanaNetwork = lazy(() => import("./pages/solana")); @@ -68,6 +70,8 @@ export const LazyBitcoinTxs = withSuspense(BitcoinTransactionsPage); export const LazyBitcoinAddress = withSuspense(BitcoinAddressPage); export const LazyBitcoinMempool = withSuspense(BitcoinMempoolPage); export const LazyZcashNetwork = withSuspense(ZcashNetwork); +export const LazyZcashBlocks = withSuspense(ZcashBlocksPage); +export const LazyZcashBlock = withSuspense(ZcashBlockPage); export const LazySolanaNetwork = withSuspense(SolanaNetwork); export const LazySolanaSlots = withSuspense(SolanaSlotsPage); export const LazySolanaSlot = withSuspense(SolanaSlotPage); @@ -115,6 +119,8 @@ export function preloadAllRoutes() { import("./pages/bitcoin/BitcoinMempoolPage"); // Zcash pages import("./pages/zcash"); + import("./pages/zcash/ZcashBlocksPage"); + import("./pages/zcash/ZcashBlockPage"); // Solana pages import("./pages/solana"); import("./pages/solana/SolanaSlotsPage"); diff --git a/src/components/pages/zcash/ZcashBlockDisplay.tsx b/src/components/pages/zcash/ZcashBlockDisplay.tsx new file mode 100644 index 0000000..a68bd7e --- /dev/null +++ b/src/components/pages/zcash/ZcashBlockDisplay.tsx @@ -0,0 +1,399 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { useSettings } from "../../../context/SettingsContext"; +import { useZcashTimeAgo } from "../../../hooks/useZcashTimeAgo"; +import type { ZcashBlock, ZcashShieldedPool } from "../../../types"; +import { + formatDifficulty, + formatNumber, + formatSize, + formatTimestamp, + formatZEC, + truncateBlockHash, +} from "../../../utils/zcashFormatters"; +import CopyButton from "../../common/CopyButton"; +import FieldLabel from "../../common/FieldLabel"; +import HelperTooltip from "../../common/HelperTooltip"; +import ZcashValuePools from "./ZcashValuePools"; + +const SHIELDED_POOLS: ZcashShieldedPool[] = ["sprout", "sapling", "orchard", "ironwood"]; +const TREE_POOLS = ["sapling", "orchard", "ironwood"] as const; + +const POOL_NAME_KEYS = { + sprout: "valuePools.pools.sprout", + sapling: "valuePools.pools.sapling", + orchard: "valuePools.pools.orchard", + ironwood: "valuePools.pools.ironwood", +} as const satisfies Record; + +interface DetailRowProps { + label: string; + tooltip?: string; + mono?: boolean; + children: React.ReactNode; +} + +const DetailRow: React.FC = ({ label, tooltip, mono, children }) => ( +
+ + {label} + {tooltip && } + + {children} +
+); + +interface ZcashBlockDisplayProps { + block: ZcashBlock; + networkId: string; + currency: string; +} + +const ZcashBlockDisplay: React.FC = React.memo( + ({ block, networkId, currency }) => { + const { t } = useTranslation("zcash"); + const { t: tTooltips } = useTranslation("tooltips"); + const { settings } = useSettings(); + const timeAgo = useZcashTimeAgo(); + const [showMoreDetails, setShowMoreDetails] = useState(false); + const [showTransactions, setShowTransactions] = useState(false); + const showTooltips = settings.showHelperTooltips !== false; + + const coinbaseOutputs = block.coinbaseOutputs ?? []; + const rewardZat = coinbaseOutputs.reduce((sum, output) => sum + output.valueZat, 0); + const txids = block.txids ?? []; + const poolCounts = SHIELDED_POOLS.flatMap((pool) => { + const count = block.shieldedTxCounts?.[pool]; + return count ? [{ pool, count }] : []; + }); + const treeSizes = TREE_POOLS.flatMap((pool) => { + const size = block.trees?.[pool]; + return size === undefined + ? [] + : [t("block.treeSize", { pool: t(POOL_NAME_KEYS[pool]), size: formatNumber(size) })]; + }); + + return ( +
+
+
+ {block.height > 0 && ( + + ← + + )} +
+ {t("block.title")} + #{block.height.toLocaleString()} +
+ {block.nextBlockHash && ( + + → + + )} + + + {timeAgo(block.time)} + ({formatTimestamp(block.time)}) + +
+ {block.confirmations !== undefined && ( + + {t("block.confirmations", { count: block.confirmations })} + {showTooltips && ( + + )} + + )} +
+ +
+
+ + + {block.hash} + + +
+ +
+
+ {coinbaseOutputs.length > 0 && ( +
+ + + {formatZEC(rewardZat, currency)} + +
+ )} + +
+ + + {t("block.txCount", { count: block.nTx })} + {poolCounts.length > 0 && ( + + {poolCounts.map(({ pool, count }) => ( + + {t("block.poolTxCount", { count, pool: t(POOL_NAME_KEYS[pool]) })} + + ))} + + )} + +
+ + {block.transparentOutputZat !== undefined && ( +
+ + + {formatZEC(block.transparentOutputZat, currency)} + +
+ )} +
+ +
+
+ + {formatDifficulty(block.difficulty)} +
+ +
+ + {formatSize(block.size)} +
+ + {block.previousBlockHash && ( +
+ + + + {truncateBlockHash(block.previousBlockHash)} + + +
+ )} + + {block.nextBlockHash && ( +
+ + + + {truncateBlockHash(block.nextBlockHash)} + + +
+ )} +
+
+ + {coinbaseOutputs.length > 0 && ( +
+ +
    + {coinbaseOutputs.map((output, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: outputs keep their on-chain order +
  • + {output.address ? ( + + {output.address} + + ) : ( + {t("block.noAddress")} + )} + + {formatZEC(output.valueZat, currency)} + +
  • + ))} +
+
+ )} + +
+ + + {showMoreDetails && ( +
+ + {block.merkleRoot} + + {block.blockCommitments && ( + + {block.blockCommitments} + + )} + {block.finalSaplingRoot && ( + + {block.finalSaplingRoot} + + )} + {block.finalOrchardRoot && ( + + {block.finalOrchardRoot} + + )} + + {block.version} + + + {block.bits} + + + {block.nonce} + + {block.solution && ( + + + {block.solution.length > 64 + ? `${block.solution.slice(0, 32)}…${block.solution.slice(-32)}` + : block.solution} + + + + )} + {treeSizes.length > 0 && ( + + {treeSizes.join(" · ")} + + )} +
+ )} +
+ + {txids.length > 0 && ( +
+ + + {showTransactions && ( +
+
+ {txids.map((txid, index) => ( +
+ {index} + + + {txid} + + +
+ ))} +
+
+ )} +
+ )} +
+ + {block.valuePools && block.valuePools.length > 0 && ( + + )} +
+ ); + }, +); + +ZcashBlockDisplay.displayName = "ZcashBlockDisplay"; +export default ZcashBlockDisplay; diff --git a/src/components/pages/zcash/ZcashBlockPage.tsx b/src/components/pages/zcash/ZcashBlockPage.tsx new file mode 100644 index 0000000..e0021f4 --- /dev/null +++ b/src/components/pages/zcash/ZcashBlockPage.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import { usePersistentCache } from "../../../hooks/usePersistentCache"; +import type { ZcashBlock } from "../../../types"; +import Breadcrumb from "../../common/Breadcrumb"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; +import ZcashBlockDisplay from "./ZcashBlockDisplay"; + +export default function ZcashBlockPage() { + const { t } = useTranslation("zcash"); + const { filter } = useParams<{ filter?: string }>(); + const location = useLocation(); + + // Extract network slug from path (e.g., "/tzec/block/123" → "tzec") + const networkSlug = location.pathname.split("/")[1] || "zec"; + const dataService = useDataService(networkSlug); + const network = getNetworkBySlug(networkSlug); + const networkLabel = network?.shortName || networkSlug.toUpperCase(); + const cacheNetworkId = network?.networkId ?? networkSlug; + const { getCached, setCached } = usePersistentCache(); + + const [block, setBlock] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isZcash() || !filter) return; + + let cancelled = false; + const adapter = dataService.getZcashAdapter(); + + const fetchBlock = async () => { + setLoading(true); + setError(null); + + try { + let blockId: string | number = filter; + if (filter === "latest") { + blockId = await adapter.getLatestBlockNumber(); + } else if (/^\d+$/.test(filter)) { + const cached = getCached(cacheNetworkId, "block", filter); + if (cached) { + if (!cancelled) setBlock(cached); + return; + } + } + + // A single getblock call returns the header, transactions and value pools + const { data } = await adapter.getBlock(blockId); + if (cancelled) return; + setBlock(data.block); + setCached(cacheNetworkId, "block", String(data.block.height), data.block); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchBlock(); + return () => { + cancelled = true; + }; + }, [dataService, filter, getCached, setCached, cacheNetworkId]); + + if (loading) { + return ( +
+
+
+ {t("block.title")} + {filter} +
+
+ window.location.reload()} /> +
+
+
+ ); + } + + if (error) { + return ( +
+
+
+ {t("block.title")} +
+
+

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

+
+
+
+ ); + } + + return ( +
+ + {block ? ( + + ) : ( +
+
+

{t("block.notFound")}

+
+
+ )} +
+ ); +} diff --git a/src/components/pages/zcash/ZcashBlocksPage.tsx b/src/components/pages/zcash/ZcashBlocksPage.tsx new file mode 100644 index 0000000..c47ad49 --- /dev/null +++ b/src/components/pages/zcash/ZcashBlocksPage.tsx @@ -0,0 +1,227 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { BLOCKS_PER_PAGE } from "../../../config/zcashConstants"; +import { useDataService } from "../../../hooks/useDataService"; +import { useZcashTimeAgo } from "../../../hooks/useZcashTimeAgo"; +import type { ZcashBlock } from "../../../types"; +import { logger } from "../../../utils/logger"; +import { + formatNumber, + formatSize, + formatTimestamp, + truncateBlockHash, +} from "../../../utils/zcashFormatters"; +import Breadcrumb from "../../common/Breadcrumb"; + +const skeletonCell = (width: string) => ( + +); + +export default function ZcashBlocksPage() { + const { t } = useTranslation("zcash"); + const timeAgo = useZcashTimeAgo(); + const location = useLocation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + // Extract network slug from path (e.g., "/tzec/blocks" → "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 fromBlockParam = searchParams.get("fromBlock"); + const fromBlock = + fromBlockParam !== null && /^\d+$/.test(fromBlockParam) ? Number(fromBlockParam) : null; + + const [blocks, setBlocks] = useState([]); + const [latestHeight, setLatestHeight] = useState(null); + const [fetching, setFetching] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isZcash()) return; + + let cancelled = false; + const adapter = dataService.getZcashAdapter(); + + const fetchBlocks = async () => { + setFetching(true); + setError(null); + setBlocks([]); + + try { + const tipHeight = await adapter.getLatestBlockNumber(); + if (cancelled) return; + setLatestHeight(tipHeight); + + const start = Math.min(fromBlock ?? tipHeight, tipHeight); + for (let height = start; height > start - BLOCKS_PER_PAGE && height >= 0; height--) { + // One block per call, so rows appear as they arrive on rate-limited endpoints + const [block] = await adapter.getBlockSummaries([height]); + if (cancelled) return; + if (block) setBlocks((prev) => [...prev, block]); + } + } catch (err) { + logger.error("Error fetching Zcash blocks:", err); + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!cancelled) setFetching(false); + } + }; + + fetchBlocks(); + return () => { + cancelled = true; + }; + }, [dataService, fromBlock]); + + const startHeight = + latestHeight === null ? null : Math.min(fromBlock ?? latestHeight, latestHeight); + const oldestHeight = startHeight === null ? null : Math.max(startHeight - BLOCKS_PER_PAGE + 1, 0); + const isAtLatest = fromBlock === null || (latestHeight !== null && fromBlock >= latestHeight); + const canGoNewer = !isAtLatest && startHeight !== null; + const canGoOlder = startHeight !== null && startHeight - BLOCKS_PER_PAGE >= 0; + + const expectedRows = + startHeight === null ? BLOCKS_PER_PAGE : Math.min(BLOCKS_PER_PAGE, startHeight + 1); + const skeletonRows = fetching ? Math.max(expectedRows - blocks.length, 0) : 0; + + const goToLatest = () => navigate(`/${networkSlug}/blocks`); + + const goToNewer = () => { + if (startHeight === null || latestHeight === null) return; + const next = startHeight + BLOCKS_PER_PAGE; + navigate( + next >= latestHeight ? `/${networkSlug}/blocks` : `/${networkSlug}/blocks?fromBlock=${next}`, + ); + }; + + const goToOlder = () => { + if (startHeight === null) return; + navigate(`/${networkSlug}/blocks?fromBlock=${startHeight - BLOCKS_PER_PAGE}`); + }; + + return ( +
+ +
+
+
+ {t("blocksPage.title", { network: networkName })} + {startHeight !== null && oldestHeight !== null && ( + <> + + + {isAtLatest + ? t("blocksPage.showingLatest", { count: BLOCKS_PER_PAGE }) + : t("blocksPage.showingRange", { + from: formatNumber(oldestHeight), + to: formatNumber(startHeight), + })} + + + )} +
+
+ + {error ? ( +
+

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

+
+ ) : ( +
+ + + + + + + + + + + + {blocks.map((block) => ( + + + + + + + + ))} + {Array.from({ length: skeletonRows }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder + + + + + + + + ))} + +
{t("blocksPage.height")}{t("blocksPage.hash")}{t("blocksPage.time")}{t("blocksPage.txns")}{t("blocksPage.size")}
+ + {block.height.toLocaleString()} + + + + {truncateBlockHash(block.hash)} + + + {timeAgo(block.time)} + {block.nTx.toLocaleString()}{formatSize(block.size)}
{skeletonCell("70px")}{skeletonCell("120px")}{skeletonCell("60px")}{skeletonCell("40px")}{skeletonCell("80px")}
+
+ )} + +
+ + + +
+
+
+ ); +} diff --git a/src/components/pages/zcash/ZcashValuePools.tsx b/src/components/pages/zcash/ZcashValuePools.tsx index 55b7cab..9cc63b2 100644 --- a/src/components/pages/zcash/ZcashValuePools.tsx +++ b/src/components/pages/zcash/ZcashValuePools.tsx @@ -1,7 +1,7 @@ import type React from "react"; import { useTranslation } from "react-i18next"; import { useSettings } from "../../../context/SettingsContext"; -import type { ZcashNetworkStats, ZcashValuePoolId } from "../../../types"; +import type { ZcashValuePool, ZcashValuePoolId } from "../../../types"; import { formatZECCompact } from "../../../utils/zcashFormatters"; import HelperTooltip from "../../common/HelperTooltip"; @@ -24,32 +24,37 @@ const POOL_TOOLTIP_KEYS = { } as const satisfies Record; interface ZcashValuePoolsProps { - stats: ZcashNetworkStats | null; + valuePools: ZcashValuePool[]; + chainSupplyZat?: number; currency: string; + title?: string; } -const ZcashValuePools: React.FC = ({ stats, currency }) => { +const ZcashValuePools: React.FC = ({ + valuePools, + chainSupplyZat, + currency, + title, +}) => { const { t } = useTranslation("zcash"); const { t: tTooltips } = useTranslation("tooltips"); const { settings } = useSettings(); const showTooltips = settings.showHelperTooltips !== false; - const pools = stats?.valuePools ?? []; - const totalZat = - stats?.chainSupplyZat || pools.reduce((sum, pool) => sum + pool.valueZat, 0) || 0; - if (pools.length === 0 || totalZat <= 0) return null; + const totalZat = chainSupplyZat || valuePools.reduce((sum, pool) => sum + pool.valueZat, 0); + if (valuePools.length === 0 || totalZat <= 0) return null; const sharePercent = (valueZat: number) => (valueZat / totalZat) * 100; return (
-

{t("valuePools.title")}

+

{title ?? t("valuePools.title")}

{showTooltips && }
- {pools.map((pool) => ( + {valuePools.map((pool) => ( = ({ stats, currency }) =>
    - {pools.map((pool) => ( + {valuePools.map((pool) => (
  • {t(POOL_LABEL_KEYS[pool.id])} diff --git a/src/components/pages/zcash/index.tsx b/src/components/pages/zcash/index.tsx index 263420f..a4f7e5f 100644 --- a/src/components/pages/zcash/index.tsx +++ b/src/components/pages/zcash/index.tsx @@ -63,7 +63,11 @@ export default function ZcashNetwork() { loading={dashboard.loading} /> - +