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
54 changes: 52 additions & 2 deletions e2e/fixtures/zcash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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/;

const T_ADDRESS = "t1KAuVfS2r2hc7RwSh6GTuyfqCGMFEpwkZ2";
export const T_ADDRESS = "t1KAuVfS2r2hc7RwSh6GTuyfqCGMFEpwkZ2";
const PREVIOUS_OUTPUT_ZAT = 30_000;

// Synthetic transparent transaction added to the tip block: two inputs, each resolving to a
Expand Down Expand Up @@ -88,11 +88,26 @@ const MEMPOOL = {
["e3".repeat(32)]: mempoolEntry(372, 0.00025, 10),
};

// Receivers of the unified address used in tests; its transparent receiver is T_ADDRESS
const UNIFIED_RECEIVERS = {
orchard: `u1${"p".repeat(80)}`,
sapling: `zs1${"q".repeat(75)}`,
p2pkh: T_ADDRESS,
};

export interface RecordedRpcCall {
method: string;
params: unknown[];
}

export interface MockZcashOptions {
/**
* Serve Zebra's address index, as a self-hosted node does. By default the address index and
* z_listunifiedreceivers fail with -32601, as they do on hosted gateways.
*/
addressIndex?: boolean;
}

/** Earlier blocks reuse the tip's header with their own height and hash */
function blockSummary(height: number) {
return {
Expand Down Expand Up @@ -151,12 +166,20 @@ function pendingTransaction(txid: string) {
};
}

/** Tatum's response to a blocked method */
const methodNotFound = (method: string) => ({
error: { code: -32601, message: `Method not found: ${method}` },
});

/**
* 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[]> {
export async function mockZcashRpc(
page: Page,
options: MockZcashOptions = {},
): Promise<RecordedRpcCall[]> {
const calls: RecordedRpcCall[] = [];
page.on("request", (request) => {
if (request.method() !== "POST" || !ZCASH_RPC_PATTERN.test(request.url())) return;
Expand All @@ -168,6 +191,32 @@ export async function mockZcashRpc(page: Page): Promise<RecordedRpcCall[]> {
}
});

const addressIndexHandlers = options.addressIndex
? {
getaddressbalance: { result: { balance: 9_514_810, received: 19_029_620 } },
getaddressutxos: {
result: [
{
address: T_ADDRESS,
txid: TRANSPARENT_TX.txid,
outputIndex: 0,
script: "76a914",
satoshis: 50_000,
height: ZCASH.tipHeight,
},
],
},
// Chain order, oldest first
getaddresstxids: { result: ["a1".repeat(32), TRANSPARENT_TX.txid] },
z_listunifiedreceivers: { result: UNIFIED_RECEIVERS },
}
: {
getaddressbalance: methodNotFound("getaddressbalance"),
getaddressutxos: methodNotFound("getaddressutxos"),
getaddresstxids: methodNotFound("getaddresstxids"),
z_listunifiedreceivers: methodNotFound("z_listunifiedreceivers"),
};

await mockJsonRpc(page, ZCASH_RPC_PATTERN, {
getblockcount: { result: ZCASH.tipHeight },
getblockchaininfo: {
Expand Down Expand Up @@ -216,6 +265,7 @@ export async function mockZcashRpc(page: Page): Promise<RecordedRpcCall[]> {
const [verbose] = params as [boolean | undefined];
return verbose ? MEMPOOL : Object.keys(MEMPOOL);
},
...addressIndexHandlers,
});

return calls;
Expand Down
91 changes: 91 additions & 0 deletions e2e/tests/shared/mocked/zcash-address.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { expect, test } from "../../../fixtures/test";
import { mockZcashRpc, T_ADDRESS, ZCASH } from "../../../fixtures/zcash";

/**
* Hermetic Zcash address page. By default the mocked gateway blocks Zebra's address index, as
* Tatum does; `addressIndex: true` serves it like a self-hosted node.
*/
const SAPLING_ADDRESS = `zs1${"q".repeat(75)}`;
const UNIFIED_ADDRESS = `u1${"q".repeat(100)}`;

const addressUrl = (address: string) => `/#/${ZCASH.networkSlug}/address/${address}`;

test.describe("Zcash address", () => {
test("search opens a transparent address and explains that the gateway blocks the index", async ({
page,
}) => {
const calls = await mockZcashRpc(page);
await page.goto(`/#/${ZCASH.networkSlug}`);
await expect(page.locator(".network-title-name")).toHaveText("ZCASH MAINNET");

const input = page.locator("input.home-search-input").first();
await input.fill(T_ADDRESS);
await input.press("Enter");

await expect(page).toHaveURL(new RegExp(`/${ZCASH.networkSlug}/address/${T_ADDRESS}$`));
await expect(page.getByText("doesn't provide Zebra's address index")).toBeVisible();
await expect(page.getByRole("link", { name: "Configure RPC endpoints" })).toHaveAttribute(
"href",
/settings/,
);
expect(calls.map((call) => call.method)).toEqual(
expect.arrayContaining(["getaddressbalance", "getaddressutxos", "getaddresstxids"]),
);
});

test("shows balance, unspent outputs and history when the node serves the address index", async ({
page,
}) => {
await mockZcashRpc(page, { addressIndex: true });

await page.goto(addressUrl(T_ADDRESS));

await expect(page.locator(".tx-row").filter({ hasText: "Balance:" })).toContainText(
"0.09514810 ZEC",
);
await expect(page.locator(".tx-row").filter({ hasText: "Total Received:" })).toContainText(
"0.19029620 ZEC",
);

await page.getByRole("button", { name: /Unspent Outputs \(1\)/ }).click();
await expect(page.locator(".btc-utxo-list")).toContainText("0.00050000 ZEC");

await page.getByRole("button", { name: /Transactions \(2\)/ }).click();
await expect(page.locator(".btc-tx-list .btc-tx-list-item").first()).toContainText(
ZCASH.transparentTxid,
);
});

test("lists a unified address's receivers and looks up its transparent receiver", async ({
page,
}) => {
const calls = await mockZcashRpc(page, { addressIndex: true });

await page.goto(addressUrl(UNIFIED_ADDRESS));

const receivers = page.locator(".tx-row").filter({ hasText: "Receivers:" });
await expect(receivers).toContainText("Orchard");
await expect(receivers).toContainText(T_ADDRESS);
await expect(page.locator(".tx-row").filter({ hasText: "Balance:" })).toContainText(
"0.09514810 ZEC",
);

const balanceCall = calls.find((call) => call.method === "getaddressbalance");
expect(balanceCall?.params[0]).toEqual({ addresses: [T_ADDRESS] });
});

test("explains that a Sapling address stays private without querying the index", async ({
page,
}) => {
const calls = await mockZcashRpc(page);

await page.goto(addressUrl(SAPLING_ADDRESS));

await expect(page.getByText("Shielded addresses don't reveal balances")).toBeVisible();
expect(
calls.some(
(call) => call.method.startsWith("getaddress") || call.method === "z_listunifiedreceivers",
),
).toBe(false);
});
});
3 changes: 3 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
LazyTokenDetails,
LazyTx,
LazyTxs,
LazyZcashAddress,
LazyZcashBlock,
LazyZcashBlocks,
LazyZcashMempool,
Expand Down Expand Up @@ -173,6 +174,7 @@ function AppContent() {
<Route path="zec/tx/:filter" element={<LazyZcashTx />} />
<Route path="zec/mempool" element={<LazyZcashMempool />} />
<Route path="zec/mempool/:filter" element={<LazyZcashTx />} />
<Route path="zec/address/:address" element={<LazyZcashAddress />} />
{/* Zcash Testnet routes */}
<Route path="tzec" element={<LazyZcashNetwork />} />
<Route path="tzec/blocks" element={<LazyZcashBlocks />} />
Expand All @@ -181,6 +183,7 @@ function AppContent() {
<Route path="tzec/tx/:filter" element={<LazyZcashTx />} />
<Route path="tzec/mempool" element={<LazyZcashMempool />} />
<Route path="tzec/mempool/:filter" element={<LazyZcashTx />} />
<Route path="tzec/address/:address" element={<LazyZcashAddress />} />
{/* 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 @@ -27,6 +27,7 @@ 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"));
const ZcashAddressPage = lazy(() => import("./pages/zcash/ZcashAddressPage"));

// Lazy load page components - Solana
const SolanaNetwork = lazy(() => import("./pages/solana"));
Expand Down Expand Up @@ -78,6 +79,7 @@ export const LazyZcashBlock = withSuspense(ZcashBlockPage);
export const LazyZcashTxs = withSuspense(ZcashTransactionsPage);
export const LazyZcashTx = withSuspense(ZcashTransactionPage);
export const LazyZcashMempool = withSuspense(ZcashMempoolPage);
export const LazyZcashAddress = withSuspense(ZcashAddressPage);
export const LazySolanaNetwork = withSuspense(SolanaNetwork);
export const LazySolanaSlots = withSuspense(SolanaSlotsPage);
export const LazySolanaSlot = withSuspense(SolanaSlotPage);
Expand Down Expand Up @@ -130,6 +132,7 @@ export function preloadAllRoutes() {
import("./pages/zcash/ZcashTransactionsPage");
import("./pages/zcash/ZcashTransactionPage");
import("./pages/zcash/ZcashMempoolPage");
import("./pages/zcash/ZcashAddressPage");
// Solana pages
import("./pages/solana");
import("./pages/solana/SolanaSlotsPage");
Expand Down
Loading
Loading