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
77 changes: 74 additions & 3 deletions e2e/fixtures/zcash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,50 @@ import { mockJsonRpc } 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";
const PREVIOUS_OUTPUT_ZAT = 30_000;

// Synthetic transparent transaction added to the tip block: two inputs, each resolving to a
// 0.0003 ZEC previous output, into one 0.0005 ZEC output, so its fee is 0.0001 ZEC
const TRANSPARENT_TX = {
txid: "7".repeat(64),
version: 4,
versiongroupid: "892f2085",
locktime: 0,
expiryheight: 3483440,
size: 372,
height: BLOCK_3483400.height,
confirmations: BLOCK_3483400.confirmations,
time: BLOCK_3483400.time,
blocktime: BLOCK_3483400.time,
blockhash: BLOCK_3483400.hash,
valueBalance: 0,
valueBalanceZat: 0,
vin: [
{ txid: "a1".repeat(32), vout: 0, sequence: 4294967295 },
{ txid: "b2".repeat(32), vout: 1, sequence: 4294967295 },
],
vout: [
{
value: 0.0005,
valueZat: 50_000,
n: 0,
scriptPubKey: { type: "pubkeyhash", addresses: [T_ADDRESS] },
},
],
vShieldedSpend: [],
vShieldedOutput: [],
vjoinsplit: [],
};

const TIP_BLOCK = { ...BLOCK_3483400, tx: [...BLOCK_3483400.tx, TRANSPARENT_TX] };
const KNOWN_TRANSACTIONS = new Map(TIP_BLOCK.tx.map((tx) => [tx.txid, tx]));

export const ZCASH = {
networkSlug: "zec",
tipHeight: BLOCK_3483400.height,
mempoolSize: 9,
transparentTxid: TRANSPARENT_TX.txid,
} as const;

// Mainnet value pools at block 3,483,461, in zatoshis
Expand Down Expand Up @@ -38,9 +78,32 @@ function blockSummary(height: number) {
};
}

/** Any unknown txid is served as a previous transaction with 20 outputs of 0.0003 ZEC */
function previousTransaction(txid: string) {
return {
txid,
version: 4,
size: 225,
locktime: 0,
height: ZCASH.tipHeight - 400,
confirmations: 401,
blockhash: "0".repeat(64),
vin: [],
vout: Array.from({ length: 20 }, (_, n) => ({
value: PREVIOUS_OUTPUT_ZAT / 1e8,
valueZat: PREVIOUS_OUTPUT_ZAT,
n,
scriptPubKey: { type: "pubkeyhash", addresses: [T_ADDRESS] },
})),
vShieldedSpend: [],
vShieldedOutput: [],
vjoinsplit: [],
};
}

/**
* Serve a Zcash chain whose tip is mainnet block 3,483,400 and record every Zcash RPC call
* the page makes, including methods that are not mocked.
* 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.
*/
export async function mockZcashRpc(page: Page): Promise<RecordedRpcCall[]> {
const calls: RecordedRpcCall[] = [];
Expand Down Expand Up @@ -83,7 +146,15 @@ export async function mockZcashRpc(page: Page): Promise<RecordedRpcCall[]> {
const [hashOrHeight, verbosity] = params as [string, number];
const isTip =
hashOrHeight === String(ZCASH.tipHeight) || hashOrHeight === BLOCK_3483400.hash;
return isTip && verbosity === 2 ? BLOCK_3483400 : blockSummary(Number(hashOrHeight));
if (isTip) {
return verbosity === 2 ? TIP_BLOCK : { ...TIP_BLOCK, tx: TIP_BLOCK.tx.map((tx) => tx.txid) };
}
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);
},
});

Expand Down
100 changes: 100 additions & 0 deletions e2e/tests/shared/mocked/zcash-transactions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {
IRONWOOD_ONLY_TX,
SAPLING_IRONWOOD_TX,
SHIELDING_TX,
} from "../../../../src/services/adapters/ZcashAdapter/fixtures";
import { expect, test } from "../../../fixtures/test";
import { type RecordedRpcCall, mockZcashRpc, ZCASH } from "../../../fixtures/zcash";

/**
* Hermetic Zcash transactions list and detail pages, served from mainnet block 3,483,400.
*/
const txUrl = (txid: string | undefined) => `/#/${ZCASH.networkSlug}/tx/${txid}`;

const inputLookups = (calls: RecordedRpcCall[], txid: string | undefined) =>
calls.filter((call) => call.method === "getrawtransaction" && call.params[0] !== txid);

test.describe("Zcash transactions", () => {
test("renders a fully shielded transaction without empty transparent columns", async ({
page,
}) => {
await mockZcashRpc(page);

await page.goto(txUrl(IRONWOOD_ONLY_TX.txid));

const shielded = page.getByTestId("zcash-shielded-bundles");
await expect(shielded).toContainText("Fully shielded transaction");
await expect(shielded).toContainText("2 actions");
await expect(shielded).toContainText("0.00010000 ZEC left the pool");
await expect(page.locator(".tx-row").filter({ hasText: "Fee:" })).toContainText(
"0.00010000 ZEC",
);
await expect(page.locator(".btc-io-columns")).toHaveCount(0);
});

test("derives the fee from Sapling and Ironwood value balances", async ({ page }) => {
await mockZcashRpc(page);

await page.goto(txUrl(SAPLING_IRONWOOD_TX.txid));

await expect(page.locator(".tx-row").filter({ hasText: "Fee:" })).toContainText(
"0.00020000 ZEC",
);
const shielded = page.getByTestId("zcash-shielded-bundles");
await expect(shielded).toContainText("0 spends · 2 outputs");
await expect(shielded).toContainText("0.03000000 ZEC entered the pool");
await expect(shielded).toContainText("0.03020000 ZEC left the pool");
});

test("looks up transparent input values only when asked", async ({ page }) => {
const calls = await mockZcashRpc(page);

await page.goto(txUrl(ZCASH.transparentTxid));

const feeRow = page.locator(".tx-row").filter({ hasText: "Fee:" });
const lookUp = feeRow.getByRole("button", { name: "Look up input values" });
await expect(lookUp).toBeVisible();
// A transparent transaction's type badge isn't repeated as a pool chip
await expect(page.locator(".zec-kind-row .zec-pool-chip")).toHaveCount(0);
expect(inputLookups(calls, ZCASH.transparentTxid)).toHaveLength(0);

await lookUp.click();

await expect(feeRow).toContainText("0.00010000 ZEC");
expect(inputLookups(calls, ZCASH.transparentTxid)).toHaveLength(2);
await expect(page.locator(".btc-io-columns")).toContainText("0.00030000 ZEC");
});

test("caps input lookups at ten previous transactions", async ({ page }) => {
const calls = await mockZcashRpc(page);

await page.goto(txUrl(SHIELDING_TX.txid));

const feeRow = page.locator(".tx-row").filter({ hasText: "Fee:" });
await expect(feeRow).toContainText("Looks up the first 10 of 15 previous transactions");
await expect(page.getByTestId("zcash-shielded-bundles")).toContainText(
"18.77663138 ZEC entered the pool",
);

await feeRow.getByRole("button", { name: "Look up input values" }).click();

await expect(feeRow).toContainText("Unavailable: found 10 of 15 previous transactions");
expect(inputLookups(calls, SHIELDING_TX.txid)).toHaveLength(10);
});

test("lists the transactions of the latest block", async ({ page }) => {
await mockZcashRpc(page);

await page.goto(`/#/${ZCASH.networkSlug}/txs`);

const rows = page.locator("table.dash-table tbody");
await expect(rows.locator("tr")).toHaveCount(5);
await expect(rows).toContainText("Coinbase");
await expect(rows).toContainText("Fully shielded");
await expect(rows).toContainText("Transparent");
await expect(page.locator(".blocks-header-info")).toContainText(
"of 5 in the latest block #3,483,400",
);
await expect(page.getByRole("button", { name: "↑ Newer Block" }).first()).toBeDisabled();
});
});
6 changes: 6 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import {
LazyZcashBlock,
LazyZcashBlocks,
LazyZcashNetwork,
LazyZcashTx,
LazyZcashTxs,
preloadAllRoutes,
} from "./components/LazyComponents";
import { SettingsProvider, useSettings, useTheme } from "./context/SettingsContext";
Expand Down Expand Up @@ -166,10 +168,14 @@ function AppContent() {
<Route path="zec" element={<LazyZcashNetwork />} />
<Route path="zec/blocks" element={<LazyZcashBlocks />} />
<Route path="zec/block/:filter" element={<LazyZcashBlock />} />
<Route path="zec/txs" element={<LazyZcashTxs />} />
<Route path="zec/tx/:filter" element={<LazyZcashTx />} />
{/* Zcash Testnet routes */}
<Route path="tzec" element={<LazyZcashNetwork />} />
<Route path="tzec/blocks" element={<LazyZcashBlocks />} />
<Route path="tzec/block/:filter" element={<LazyZcashBlock />} />
<Route path="tzec/txs" element={<LazyZcashTxs />} />
<Route path="tzec/tx/:filter" element={<LazyZcashTx />} />
{/* Solana Mainnet routes (must come before :networkId catch-all) */}
<Route path="sol" element={<LazySolanaNetwork />} />
<Route path="sol/slots" element={<LazySolanaSlots />} />
Expand Down
6 changes: 6 additions & 0 deletions src/components/LazyComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const BitcoinMempoolPage = lazy(() => import("./pages/bitcoin/BitcoinMempoolPage
const ZcashNetwork = lazy(() => import("./pages/zcash"));
const ZcashBlocksPage = lazy(() => import("./pages/zcash/ZcashBlocksPage"));
const ZcashBlockPage = lazy(() => import("./pages/zcash/ZcashBlockPage"));
const ZcashTransactionsPage = lazy(() => import("./pages/zcash/ZcashTransactionsPage"));
const ZcashTransactionPage = lazy(() => import("./pages/zcash/ZcashTransactionPage"));

// Lazy load page components - Solana
const SolanaNetwork = lazy(() => import("./pages/solana"));
Expand Down Expand Up @@ -72,6 +74,8 @@ export const LazyBitcoinMempool = withSuspense(BitcoinMempoolPage);
export const LazyZcashNetwork = withSuspense(ZcashNetwork);
export const LazyZcashBlocks = withSuspense(ZcashBlocksPage);
export const LazyZcashBlock = withSuspense(ZcashBlockPage);
export const LazyZcashTxs = withSuspense(ZcashTransactionsPage);
export const LazyZcashTx = withSuspense(ZcashTransactionPage);
export const LazySolanaNetwork = withSuspense(SolanaNetwork);
export const LazySolanaSlots = withSuspense(SolanaSlotsPage);
export const LazySolanaSlot = withSuspense(SolanaSlotPage);
Expand Down Expand Up @@ -121,6 +125,8 @@ export function preloadAllRoutes() {
import("./pages/zcash");
import("./pages/zcash/ZcashBlocksPage");
import("./pages/zcash/ZcashBlockPage");
import("./pages/zcash/ZcashTransactionsPage");
import("./pages/zcash/ZcashTransactionPage");
// Solana pages
import("./pages/solana");
import("./pages/solana/SolanaSlotsPage");
Expand Down
15 changes: 6 additions & 9 deletions src/components/pages/zcash/ZcashBlockDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,12 @@ import {
import CopyButton from "../../common/CopyButton";
import FieldLabel from "../../common/FieldLabel";
import HelperTooltip from "../../common/HelperTooltip";
import { VALUE_POOL_LABEL_KEYS } from "./labels";
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<ZcashShieldedPool, string>;

interface DetailRowProps {
label: string;
tooltip?: string;
Expand Down Expand Up @@ -71,7 +65,7 @@ const ZcashBlockDisplay: React.FC<ZcashBlockDisplayProps> = React.memo(
const size = block.trees?.[pool];
return size === undefined
? []
: [t("block.treeSize", { pool: t(POOL_NAME_KEYS[pool]), size: formatNumber(size) })];
: [t("block.treeSize", { pool: t(VALUE_POOL_LABEL_KEYS[pool]), size: formatNumber(size) })];
});

return (
Expand Down Expand Up @@ -156,7 +150,10 @@ const ZcashBlockDisplay: React.FC<ZcashBlockDisplayProps> = React.memo(
<span className="zec-pool-chips">
{poolCounts.map(({ pool, count }) => (
<span key={pool} className={`zec-pool-chip zec-pool-${pool}`}>
{t("block.poolTxCount", { count, pool: t(POOL_NAME_KEYS[pool]) })}
{t("block.poolTxCount", {
count,
pool: t(VALUE_POOL_LABEL_KEYS[pool]),
})}
</span>
))}
</span>
Expand Down
90 changes: 90 additions & 0 deletions src/components/pages/zcash/ZcashShieldedBundles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type React from "react";
import { useTranslation } from "react-i18next";
import { useSettings } from "../../../context/SettingsContext";
import type { ZcashActionBundle, ZcashTransaction } from "../../../types";
import { formatZEC } from "../../../utils/zcashFormatters";
import { classifyTransaction } from "../../../utils/zcashUtils";
import HelperTooltip from "../../common/HelperTooltip";
import { VALUE_POOL_LABEL_KEYS } from "./labels";

interface ZcashShieldedBundlesProps {
transaction: ZcashTransaction;
currency: string;
}

const ZcashShieldedBundles: React.FC<ZcashShieldedBundlesProps> = ({ transaction, currency }) => {
const { t } = useTranslation("zcash");
const { t: tTooltips } = useTranslation("tooltips");
const { settings } = useSettings();
const showTooltips = settings.showHelperTooltips !== false;
const { sprout, sapling, orchard, ironwood } = transaction.shielded;

if (!sprout && !sapling && !orchard && !ironwood) return null;

const isFullyShielded = classifyTransaction(transaction) === "shielded";

// A positive value balance leaves the pool, a negative one enters it
const describeBalance = (valueBalanceZat: number) => {
if (valueBalanceZat > 0) {
return t("shielded.valueLeft", { amount: formatZEC(valueBalanceZat, currency) });
}
if (valueBalanceZat < 0) {
return t("shielded.valueEntered", { amount: formatZEC(-valueBalanceZat, currency) });
}
return t("shielded.valueNone");
};

const actionCard = (pool: "orchard" | "ironwood", bundle: ZcashActionBundle) => (
<div className={`zec-bundle-card zec-pool-${pool}`}>
<div className="zec-bundle-title">{t(VALUE_POOL_LABEL_KEYS[pool])}</div>
<div className="zec-bundle-counts">{t("shielded.actions", { count: bundle.actions })}</div>
<div className="zec-bundle-balance">{describeBalance(bundle.valueBalanceZat)}</div>
</div>
);

return (
<section className="zec-shielded" data-testid="zcash-shielded-bundles">
<div className="zec-value-pools-header">
<h3 className="dashboard-table-title">{t("shielded.title")}</h3>
{showTooltips && <HelperTooltip content={tTooltips("zcash.tx.shieldedActivity")} />}
</div>

{isFullyShielded && (
<div className="zec-fully-shielded">
<strong>{t("shielded.fullyShieldedTitle")}</strong>
<p>{t("shielded.fullyShieldedBody")}</p>
</div>
)}

<div className="zec-bundle-grid">
{sprout && (
<div className="zec-bundle-card zec-pool-sprout">
<div className="zec-bundle-title">{t(VALUE_POOL_LABEL_KEYS.sprout)}</div>
<div className="zec-bundle-counts">
{t("shielded.joinSplits", { count: sprout.joinSplits })}
</div>
<div className="zec-bundle-balance">
{t("shielded.sproutIn", { amount: formatZEC(sprout.vpubOldZat, currency) })}
</div>
<div className="zec-bundle-balance">
{t("shielded.sproutOut", { amount: formatZEC(sprout.vpubNewZat, currency) })}
</div>
</div>
)}
{sapling && (
<div className="zec-bundle-card zec-pool-sapling">
<div className="zec-bundle-title">{t(VALUE_POOL_LABEL_KEYS.sapling)}</div>
<div className="zec-bundle-counts">
{t("shielded.spendsOutputs", { spends: sapling.spends, outputs: sapling.outputs })}
</div>
<div className="zec-bundle-balance">{describeBalance(sapling.valueBalanceZat)}</div>
</div>
)}
{orchard && actionCard("orchard", orchard)}
{ironwood && actionCard("ironwood", ironwood)}
</div>
</section>
);
};

export default ZcashShieldedBundles;
Loading
Loading