From 3364f77208cbfde89f2b89a8f7b68bef09566d3c Mon Sep 17 00:00:00 2001 From: Mati OS Date: Mon, 14 Sep 2026 20:39:03 -0300 Subject: [PATCH 1/3] test(e2e): let mocked RPC handlers return JSON-RPC errors --- e2e/fixtures/rpcMock.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/e2e/fixtures/rpcMock.ts b/e2e/fixtures/rpcMock.ts index b4700f25..9e7f21de 100644 --- a/e2e/fixtures/rpcMock.ts +++ b/e2e/fixtures/rpcMock.ts @@ -21,6 +21,26 @@ type RpcMethodHandler = | { result: unknown } | { error: { code: number; message: string } }; +const RPC_ERROR = Symbol("rpcError"); + +interface RpcErrorResponse { + [RPC_ERROR]: true; + code: number; + message: string; +} + +/** + * Return this from a function handler to answer with a JSON-RPC error instead of a result, + * e.g. `getblock: ([hash]) => (known(hash) ? block : rpcError(-8, "Block not found"))`. + */ +export function rpcError(code: number, message: string): RpcErrorResponse { + return { [RPC_ERROR]: true, code, message }; +} + +function isRpcError(value: unknown): value is RpcErrorResponse { + return typeof value === "object" && value !== null && RPC_ERROR in value; +} + export interface MockOptions { /** Return HTTP status instead of a JSON-RPC response. Overrides handlers. */ httpStatus?: number; @@ -77,7 +97,10 @@ export async function mockJsonRpc( error: { code: -32601, message: `method ${method} not mocked` }, }; } else if (typeof handler === "function") { - body = { jsonrpc: "2.0", id, result: handler(params) }; + const value = handler(params); + body = isRpcError(value) + ? { jsonrpc: "2.0", id, error: { code: value.code, message: value.message } } + : { jsonrpc: "2.0", id, result: value }; } else if ("result" in handler) { body = { jsonrpc: "2.0", id, result: handler.result }; } else { From abe8e9e2e513b989d97a326e68d25ada0e7f8e6a Mon Sep 17 00:00:00 2001 From: Mati OS Date: Mon, 14 Sep 2026 20:39:04 -0300 Subject: [PATCH 2/3] feat(search): route Zcash addresses and block hashes Zcash block hashes and txids are both 64 hex characters, so search keeps sending hashes to the transaction page, which now looks the hash up as a block and redirects when no transaction matches. --- .../pages/zcash/ZcashTransactionPage.tsx | 19 ++++++++++++++++--- src/hooks/useSearch.ts | 6 +++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/components/pages/zcash/ZcashTransactionPage.tsx b/src/components/pages/zcash/ZcashTransactionPage.tsx index 0d42ad73..cf6f6c58 100644 --- a/src/components/pages/zcash/ZcashTransactionPage.tsx +++ b/src/components/pages/zcash/ZcashTransactionPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useLocation, useParams } from "react-router-dom"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; import { getNetworkBySlug } from "../../../config/networks"; import { useDataService } from "../../../hooks/useDataService"; import { usePersistentCache } from "../../../hooks/usePersistentCache"; @@ -11,10 +11,13 @@ import Breadcrumb from "../../common/Breadcrumb"; import LoaderWithTimeout from "../../common/LoaderWithTimeout"; import ZcashTransactionDisplay, { type InputResolution } from "./ZcashTransactionDisplay"; +const HASH_PATTERN = /^[0-9a-f]{64}$/i; + export default function ZcashTransactionPage() { const { t } = useTranslation("zcash"); const { filter: txid } = useParams<{ filter?: string }>(); const location = useLocation(); + const navigate = useNavigate(); // Extract network slug from path (e.g., "/tzec/tx/..." → "tzec") const networkSlug = location.pathname.split("/")[1] || "zec"; @@ -52,7 +55,17 @@ export default function ZcashTransactionPage() { setTransaction(data); if (data.confirmations) setCached(cacheNetworkId, "transaction", txid, data); } catch (err) { - if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + // Search sends every 64-character hash here, and Zcash block hashes look like txids, + // so a hash that matches no transaction may still be a block + const block = HASH_PATTERN.test(txid) + ? await adapter.getBlock(txid).catch(() => null) + : null; + if (cancelled) return; + if (block) { + navigate(`/${networkSlug}/block/${block.data.block.hash}`, { replace: true }); + return; + } + setError(err instanceof Error ? err.message : String(err)); } finally { if (!cancelled) setLoading(false); } @@ -62,7 +75,7 @@ export default function ZcashTransactionPage() { return () => { cancelled = true; }; - }, [dataService, txid, getCached, setCached, cacheNetworkId]); + }, [dataService, txid, getCached, setCached, cacheNetworkId, navigate, networkSlug]); // Zebra omits transparent input values, so the fee is only resolved when the user asks const resolveInputs = useCallback(async () => { diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts index 490e7630..c220a84d 100644 --- a/src/hooks/useSearch.ts +++ b/src/hooks/useSearch.ts @@ -3,6 +3,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { AppContext, useNetworks } from "../context"; import { ENSService } from "../services/ENS/ENSService"; import { isEVMNetwork, resolveNetwork } from "../utils/networkResolver"; +import { isZcashAddress } from "../utils/zcashUtils"; interface UseSearchResult { searchTerm: string; @@ -95,10 +96,13 @@ export function useSearch(): UseSearchResult { const isEvmAddress = /^0x[a-fA-F0-9]{40}$/.test(term); const isBitcoinTxid = /^[a-fA-F0-9]{64}$/.test(term); const isBitcoinAddress = /^(1|3|bc1)[a-zA-Z0-9]{25,62}$/.test(term); + // Transparent, Sapling, unified, Sprout and TEX addresses. Zcash block hashes are also 64 + // hex characters: they go to the transaction page, which redirects to the matching block. + const isZcashAddressTerm = resolvedNetwork?.type === "zcash" && isZcashAddress(term); const isBlockNumber = /^\d+$/.test(term); const isTransactionHash = isEvmTransactionHash || isBitcoinTxid; - const isAddress = isEvmAddress || isBitcoinAddress; + const isAddress = isEvmAddress || isBitcoinAddress || isZcashAddressTerm; // If pattern doesn't match any valid type, show error immediately if (!isTransactionHash && !isAddress && !isBlockNumber) { From 4c187c3f715392a6ec12eb0cacea64424c7a3ce1 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Mon, 14 Sep 2026 20:39:05 -0300 Subject: [PATCH 3/3] test(e2e): add hermetic Zcash search spec --- e2e/fixtures/zcash.ts | 41 ++++++++++++- e2e/tests/shared/mocked/zcash-search.spec.ts | 62 ++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 e2e/tests/shared/mocked/zcash-search.spec.ts diff --git a/e2e/fixtures/zcash.ts b/e2e/fixtures/zcash.ts index 5e88bad2..5fa8e4bb 100644 --- a/e2e/fixtures/zcash.ts +++ b/e2e/fixtures/zcash.ts @@ -1,6 +1,6 @@ import type { Page } from "@playwright/test"; import { BLOCK_3483400 } from "../../src/services/adapters/ZcashAdapter/fixtures"; -import { mockJsonRpc } from "./rpcMock"; +import { mockJsonRpc, rpcError } from "./rpcMock"; /** The built-in Zcash RPC defaults are Tatum's gateways, so every Zcash request matches this */ export const ZCASH_RPC_PATTERN = /zcash-(mainnet|testnet)(-zebrad)?\.gateway\.tatum\.io/; @@ -44,6 +44,11 @@ const TRANSPARENT_TX = { const TIP_BLOCK = { ...BLOCK_3483400, tx: [...BLOCK_3483400.tx, TRANSPARENT_TX] }; const KNOWN_TRANSACTIONS = new Map(TIP_BLOCK.tx.map((tx) => [tx.txid, tx])); +// Transactions spent by the known transactions' transparent inputs, served for input lookups +const PREVIOUS_TXIDS = new Set( + TIP_BLOCK.tx.flatMap((tx) => tx.vin.flatMap((input) => (input.txid ? [input.txid] : []))), +); + export const ZCASH = { networkSlug: "zec", tipHeight: BLOCK_3483400.height, @@ -99,7 +104,7 @@ function blockSummary(height: number) { }; } -/** Any unknown txid is served as a previous transaction with 20 outputs of 0.0003 ZEC */ +/** A previous transaction with 20 outputs of 0.0003 ZEC */ function previousTransaction(txid: string) { return { txid, @@ -122,9 +127,34 @@ function previousTransaction(txid: string) { }; } +/** An unconfirmed transparent transaction, served for mempool txids */ +function pendingTransaction(txid: string) { + return { + txid, + version: 5, + size: 372, + locktime: 0, + expiryheight: ZCASH.tipHeight + 40, + time: Math.floor(Date.now() / 1000), + vin: [{ txid: "a1".repeat(32), vout: 2, sequence: 4294967295 }], + vout: [ + { + value: 0.0002, + valueZat: 20_000, + n: 0, + scriptPubKey: { type: "pubkeyhash", addresses: [T_ADDRESS] }, + }, + ], + vShieldedSpend: [], + vShieldedOutput: [], + vjoinsplit: [], + }; +} + /** * Serve a Zcash chain whose tip is mainnet block 3,483,400 (plus one synthetic transparent * transaction) and record every Zcash RPC call the page makes, including unmocked methods. + * Unknown transactions and block hashes fail the way Zebra does. */ export async function mockZcashRpc(page: Page): Promise { const calls: RecordedRpcCall[] = []; @@ -170,12 +200,17 @@ export async function mockZcashRpc(page: Page): Promise { if (isTip) { return verbosity === 2 ? TIP_BLOCK : { ...TIP_BLOCK, tx: TIP_BLOCK.tx.map((tx) => tx.txid) }; } + if (!/^\d+$/.test(hashOrHeight)) return rpcError(-8, "Block not found"); const summary = blockSummary(Number(hashOrHeight)); return verbosity === 2 ? { ...summary, tx: [] } : summary; }, getrawtransaction: (params) => { const [txid] = params as [string]; - return KNOWN_TRANSACTIONS.get(txid) ?? previousTransaction(txid); + const known = KNOWN_TRANSACTIONS.get(txid); + if (known) return known; + if (PREVIOUS_TXIDS.has(txid)) return previousTransaction(txid); + if (txid in MEMPOOL) return pendingTransaction(txid); + return rpcError(-5, "No such mempool or main chain transaction"); }, getrawmempool: (params) => { const [verbose] = params as [boolean | undefined]; diff --git a/e2e/tests/shared/mocked/zcash-search.spec.ts b/e2e/tests/shared/mocked/zcash-search.spec.ts new file mode 100644 index 00000000..47bd65ca --- /dev/null +++ b/e2e/tests/shared/mocked/zcash-search.spec.ts @@ -0,0 +1,62 @@ +import type { Page } from "@playwright/test"; +import { + BLOCK_3483400, + IRONWOOD_ONLY_TX, +} from "../../../../src/services/adapters/ZcashAdapter/fixtures"; +import { expect, test } from "../../../fixtures/test"; +import { mockZcashRpc, ZCASH } from "../../../fixtures/zcash"; + +/** + * Hermetic search on a Zcash network. Zcash block hashes and txids are both 64 hex characters, + * so hashes go to the transaction page, which redirects to the block when no transaction matches. + */ +async function search(page: Page, term: string) { + const input = page.locator("input.home-search-input").first(); + await input.fill(term); + await input.press("Enter"); +} + +test.describe("Zcash search", () => { + test.beforeEach(async ({ page }) => { + await mockZcashRpc(page); + await page.goto(`/#/${ZCASH.networkSlug}`); + await expect(page.locator(".network-title-name")).toHaveText("ZCASH MAINNET"); + }); + + test("goes to a block by height", async ({ page }) => { + await search(page, String(ZCASH.tipHeight)); + + await expect(page).toHaveURL(new RegExp(`/${ZCASH.networkSlug}/block/${ZCASH.tipHeight}$`)); + await expect(page.locator(".block-number")).toHaveText("#3,483,400"); + }); + + test("goes to a transaction by txid", async ({ page }) => { + await search(page, IRONWOOD_ONLY_TX.txid ?? ""); + + await expect(page).toHaveURL(new RegExp(`/${ZCASH.networkSlug}/tx/${IRONWOOD_ONLY_TX.txid}$`)); + await expect(page.getByText("Fully shielded transaction")).toBeVisible(); + }); + + test("redirects a block hash to its block page", async ({ page }) => { + await search(page, BLOCK_3483400.hash); + + await expect(page).toHaveURL(new RegExp(`/${ZCASH.networkSlug}/block/${BLOCK_3483400.hash}$`)); + await expect(page.locator(".block-number")).toHaveText("#3,483,400"); + }); + + test("reports a hash that matches no transaction or block", async ({ page }) => { + const unknownHash = "ab".repeat(32); + + await search(page, unknownHash); + + await expect(page.getByText("Couldn't load this transaction")).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/${ZCASH.networkSlug}/tx/${unknownHash}$`)); + }); + + test("rejects a term that is not a height, hash or address", async ({ page }) => { + await search(page, "not-a-zcash-term"); + + await expect(page.locator(".home-search-error").first()).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/#/${ZCASH.networkSlug}$`)); + }); +});