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: 24 additions & 1 deletion e2e/fixtures/rpcMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
41 changes: 38 additions & 3 deletions e2e/fixtures/zcash.ts
Original file line number Diff line number Diff line change
@@ -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/;
Expand Down Expand Up @@ -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<string>(
TIP_BLOCK.tx.flatMap((tx) => tx.vin.flatMap((input) => (input.txid ? [input.txid] : []))),
);

export const ZCASH = {
networkSlug: "zec",
tipHeight: BLOCK_3483400.height,
Expand Down Expand Up @@ -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,
Expand All @@ -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<RecordedRpcCall[]> {
const calls: RecordedRpcCall[] = [];
Expand Down Expand Up @@ -170,12 +200,17 @@ export async function mockZcashRpc(page: Page): Promise<RecordedRpcCall[]> {
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];
Expand Down
62 changes: 62 additions & 0 deletions e2e/tests/shared/mocked/zcash-search.spec.ts
Original file line number Diff line number Diff line change
@@ -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}$`));
});
});
19 changes: 16 additions & 3 deletions src/components/pages/zcash/ZcashTransactionPage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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);
}
Expand All @@ -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 () => {
Expand Down
6 changes: 5 additions & 1 deletion src/hooks/useSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Loading