diff --git a/e2e/fixtures/rpcMock.ts b/e2e/fixtures/rpcMock.ts index b4700f2..9e7f21d 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 { diff --git a/e2e/fixtures/zcash.ts b/e2e/fixtures/zcash.ts index 5e88bad..5fa8e4b 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 0000000..47bd65c --- /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}$`)); + }); +}); diff --git a/src/components/pages/zcash/ZcashTransactionPage.tsx b/src/components/pages/zcash/ZcashTransactionPage.tsx index 0d42ad7..cf6f6c5 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 490e763..c220a84 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) {