diff --git a/e2e/fixtures/zcash.ts b/e2e/fixtures/zcash.ts index abbceb6d..49b47a6a 100644 --- a/e2e/fixtures/zcash.ts +++ b/e2e/fixtures/zcash.ts @@ -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 @@ -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 { const calls: RecordedRpcCall[] = []; @@ -83,7 +146,15 @@ export async function mockZcashRpc(page: Page): Promise { 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); }, }); diff --git a/e2e/tests/shared/mocked/zcash-transactions.spec.ts b/e2e/tests/shared/mocked/zcash-transactions.spec.ts new file mode 100644 index 00000000..646d2168 --- /dev/null +++ b/e2e/tests/shared/mocked/zcash-transactions.spec.ts @@ -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(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 018d08e9..21cc601d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,6 +59,8 @@ import { LazyZcashBlock, LazyZcashBlocks, LazyZcashNetwork, + LazyZcashTx, + LazyZcashTxs, preloadAllRoutes, } from "./components/LazyComponents"; import { SettingsProvider, useSettings, useTheme } from "./context/SettingsContext"; @@ -166,10 +168,14 @@ function AppContent() { } /> } /> } /> + } /> + } /> {/* Zcash Testnet routes */} } /> } /> } /> + } /> + } /> {/* Solana Mainnet routes (must come before :networkId catch-all) */} } /> } /> diff --git a/src/components/LazyComponents.tsx b/src/components/LazyComponents.tsx index 0a5bf25a..c85f0019 100644 --- a/src/components/LazyComponents.tsx +++ b/src/components/LazyComponents.tsx @@ -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")); @@ -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); @@ -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"); diff --git a/src/components/pages/zcash/ZcashBlockDisplay.tsx b/src/components/pages/zcash/ZcashBlockDisplay.tsx index a68bd7e7..730adad8 100644 --- a/src/components/pages/zcash/ZcashBlockDisplay.tsx +++ b/src/components/pages/zcash/ZcashBlockDisplay.tsx @@ -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; - interface DetailRowProps { label: string; tooltip?: string; @@ -71,7 +65,7 @@ const ZcashBlockDisplay: React.FC = 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 ( @@ -156,7 +150,10 @@ const ZcashBlockDisplay: React.FC = React.memo( {poolCounts.map(({ pool, count }) => ( - {t("block.poolTxCount", { count, pool: t(POOL_NAME_KEYS[pool]) })} + {t("block.poolTxCount", { + count, + pool: t(VALUE_POOL_LABEL_KEYS[pool]), + })} ))} diff --git a/src/components/pages/zcash/ZcashShieldedBundles.tsx b/src/components/pages/zcash/ZcashShieldedBundles.tsx new file mode 100644 index 00000000..1e5ca9fe --- /dev/null +++ b/src/components/pages/zcash/ZcashShieldedBundles.tsx @@ -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 = ({ 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) => ( +
+
{t(VALUE_POOL_LABEL_KEYS[pool])}
+
{t("shielded.actions", { count: bundle.actions })}
+
{describeBalance(bundle.valueBalanceZat)}
+
+ ); + + return ( +
+
+

{t("shielded.title")}

+ {showTooltips && } +
+ + {isFullyShielded && ( +
+ {t("shielded.fullyShieldedTitle")} +

{t("shielded.fullyShieldedBody")}

+
+ )} + +
+ {sprout && ( +
+
{t(VALUE_POOL_LABEL_KEYS.sprout)}
+
+ {t("shielded.joinSplits", { count: sprout.joinSplits })} +
+
+ {t("shielded.sproutIn", { amount: formatZEC(sprout.vpubOldZat, currency) })} +
+
+ {t("shielded.sproutOut", { amount: formatZEC(sprout.vpubNewZat, currency) })} +
+
+ )} + {sapling && ( +
+
{t(VALUE_POOL_LABEL_KEYS.sapling)}
+
+ {t("shielded.spendsOutputs", { spends: sapling.spends, outputs: sapling.outputs })} +
+
{describeBalance(sapling.valueBalanceZat)}
+
+ )} + {orchard && actionCard("orchard", orchard)} + {ironwood && actionCard("ironwood", ironwood)} +
+
+ ); +}; + +export default ZcashShieldedBundles; diff --git a/src/components/pages/zcash/ZcashTransactionDisplay.tsx b/src/components/pages/zcash/ZcashTransactionDisplay.tsx new file mode 100644 index 00000000..bf04d4be --- /dev/null +++ b/src/components/pages/zcash/ZcashTransactionDisplay.tsx @@ -0,0 +1,425 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { MAX_INPUT_LOOKUPS } from "../../../config/zcashConstants"; +import { useSettings } from "../../../context/SettingsContext"; +import { useZcashTimeAgo } from "../../../hooks/useZcashTimeAgo"; +import type { ZcashTransaction } from "../../../types"; +import { + formatNumber, + formatSize, + formatTimestamp, + formatZEC, + truncateBlockHash, + truncateHash, +} from "../../../utils/zcashFormatters"; +import { + classifyTransaction, + computeFeeZat, + countPreviousTransactions, + getTransactionPools, + getTransparentOutputZat, + isCoinbaseTransaction, +} from "../../../utils/zcashUtils"; +import CopyButton from "../../common/CopyButton"; +import FieldLabel from "../../common/FieldLabel"; +import HelperTooltip from "../../common/HelperTooltip"; +import { TX_KIND_LABEL_KEYS, VALUE_POOL_LABEL_KEYS } from "./labels"; +import ZcashShieldedBundles from "./ZcashShieldedBundles"; + +/** Progress of the on-demand transparent input lookup */ +export type InputResolution = + | { status: "idle" } + | { status: "resolving"; resolved: number; total: number } + | { status: "done"; resolved: number; total: number } + | { status: "error"; message: string }; + +interface ZcashTransactionDisplayProps { + transaction: ZcashTransaction; + networkId: string; + currency: string; + resolution: InputResolution; + onResolveInputs: () => void; +} + +const ZcashTransactionDisplay: React.FC = React.memo( + ({ transaction, networkId, currency, resolution, onResolveInputs }) => { + const { t } = useTranslation("zcash"); + const { t: tTooltips } = useTranslation("tooltips"); + const { settings } = useSettings(); + const timeAgo = useZcashTimeAgo(); + const showTooltips = settings.showHelperTooltips !== false; + + const kind = classifyTransaction(transaction); + // The kind badge already covers transactions that only use the transparent pool + const pools = + kind === "transparent" || kind === "coinbase" ? [] : getTransactionPools(transaction); + const isCoinbase = isCoinbaseTransaction(transaction); + const feeZat = computeFeeZat(transaction); + const previousCount = countPreviousTransactions(transaction); + const inputTotalZat = + !isCoinbase && transaction.vin.every((input) => input.valueZat !== undefined) + ? transaction.vin.reduce((sum, input) => sum + (input.valueZat ?? 0), 0) + : null; + const outputTotalZat = getTransparentOutputZat(transaction); + const isUnconfirmed = !transaction.blockhash || !transaction.confirmations; + const hasTransparent = transaction.vin.length > 0 || transaction.vout.length > 0; + + const renderFee = () => { + if (feeZat !== null) { + return ( + + {formatZEC(feeZat, currency)} + {transaction.size > 0 && ( + + {t("tx.feeRate", { rate: (feeZat / transaction.size).toFixed(2) })} + + )} + + ); + } + if (resolution.status === "resolving") { + return ( + + {t("tx.resolving", { resolved: resolution.resolved, total: resolution.total })} + + ); + } + if (resolution.status === "done") { + return ( + + {t("tx.feeUnavailable", { resolved: resolution.resolved, total: resolution.total })} + + ); + } + return ( + + + + {resolution.status === "error" + ? t("tx.resolveError", { message: resolution.message }) + : previousCount > MAX_INPUT_LOOKUPS + ? t("tx.lookUpCapped", { max: MAX_INPUT_LOOKUPS, count: previousCount }) + : t("tx.feeNeedsInputs", { count: previousCount })} + + + ); + }; + + return ( +
+
+ {t("tx.title")} + {isUnconfirmed ? ( + {t("tx.inMempool")} + ) : ( + + {t("tx.confirmations", { count: transaction.confirmations ?? 0 })} + {showTooltips && ( + + )} + + )} +
+ +
+
+ + + {transaction.txid} + + +
+ + {transaction.blockhash && ( +
+ + + + {transaction.blockheight !== undefined + ? `#${formatNumber(transaction.blockheight)}` + : truncateBlockHash(transaction.blockhash)} + + +
+ )} + +
+
+
+ + + + {t(TX_KIND_LABEL_KEYS[kind])} + + {pools.map((pool) => ( + + {t(VALUE_POOL_LABEL_KEYS[pool])} + + ))} + +
+ + {isUnconfirmed ? ( +
+ + + {t("tx.waiting")} + +
+ ) : ( + transaction.blocktime !== undefined && ( + <> +
+ + {formatTimestamp(transaction.blocktime)} +
+
+ + {timeAgo(transaction.blocktime)} +
+ + ) + )} + + {hasTransparent && ( + <> +
+ + + {transaction.vin.length} + {inputTotalZat !== null && inputTotalZat > 0 && ( + + {" "} + ({formatZEC(inputTotalZat, currency)}) + + )} + +
+
+ + + {transaction.vout.length} + {outputTotalZat > 0 && ( + + {" "} + ({formatZEC(outputTotalZat, currency)}) + + )} + +
+ + )} + + {!isCoinbase && ( +
+ + {renderFee()} +
+ )} +
+ +
+
+ + {formatSize(transaction.size)} +
+
+ + {transaction.version} +
+ {transaction.versionGroupId && ( +
+ + {transaction.versionGroupId} +
+ )} + {transaction.expiryHeight !== undefined && transaction.expiryHeight > 0 && ( +
+ + {formatNumber(transaction.expiryHeight)} +
+ )} +
+ + {formatNumber(transaction.locktime)} +
+
+
+
+ + + + {hasTransparent && ( +
+
+
+ + {t("tx.inputsColumn", { count: transaction.vin.length })} + + {inputTotalZat !== null && inputTotalZat > 0 && ( +
+ + {formatZEC(inputTotalZat, currency)} + +
+ )} +
+
+ {transaction.vin.length === 0 && ( +
{t("tx.noTransparentInputs")}
+ )} + {transaction.vin.map((input, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: inputs keep their on-chain order +
+
{index}
+
+ {input.coinbase !== undefined ? ( +
{t("tx.newCoins")}
+ ) : ( + <> +
+ {input.address ? ( + <> + + {input.address} + + + + ) : ( + {t("tx.unknownAddress")} + )} +
+
+ + {input.valueZat !== undefined + ? formatZEC(input.valueZat, currency) + : t("tx.unknownValue")} + + {input.txid && ( + + + {t("tx.spends", { + outpoint: `${truncateHash(input.txid, "short")}:${input.vout}`, + })} + + + )} +
+ + )} +
+
+ ))} +
+
+ +
+ + {t("tx.flowArrow")} + + +
+ +
+
+ + {t("tx.outputsColumn", { count: transaction.vout.length })} + + {outputTotalZat > 0 && ( +
+ + {formatZEC(outputTotalZat, currency)} + +
+ )} +
+
+ {transaction.vout.length === 0 && ( +
{t("tx.noTransparentOutputs")}
+ )} + {transaction.vout.map((output) => ( +
+
{output.n}
+
+
+ {output.address ? ( + <> + + {output.address} + + + + ) : ( + {t("tx.nonStandard")} + )} +
+
+ + {formatZEC(output.valueZat, currency)} + + {output.scriptType} +
+
+
+ ))} +
+
+
+ )} +
+ ); + }, +); + +ZcashTransactionDisplay.displayName = "ZcashTransactionDisplay"; +export default ZcashTransactionDisplay; diff --git a/src/components/pages/zcash/ZcashTransactionPage.tsx b/src/components/pages/zcash/ZcashTransactionPage.tsx new file mode 100644 index 00000000..0d42ad73 --- /dev/null +++ b/src/components/pages/zcash/ZcashTransactionPage.tsx @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { useDataService } from "../../../hooks/useDataService"; +import { usePersistentCache } from "../../../hooks/usePersistentCache"; +import type { ZcashTransaction } from "../../../types"; +import { truncateHash } from "../../../utils/zcashFormatters"; +import { countPreviousTransactions } from "../../../utils/zcashUtils"; +import Breadcrumb from "../../common/Breadcrumb"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; +import ZcashTransactionDisplay, { type InputResolution } from "./ZcashTransactionDisplay"; + +export default function ZcashTransactionPage() { + const { t } = useTranslation("zcash"); + const { filter: txid } = useParams<{ filter?: string }>(); + const location = useLocation(); + + // Extract network slug from path (e.g., "/tzec/tx/..." → "tzec") + const networkSlug = location.pathname.split("/")[1] || "zec"; + const dataService = useDataService(networkSlug); + const network = getNetworkBySlug(networkSlug); + const networkLabel = network?.shortName || networkSlug.toUpperCase(); + const cacheNetworkId = network?.networkId ?? networkSlug; + const { getCached, setCached } = usePersistentCache(); + + const [transaction, setTransaction] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [resolution, setResolution] = useState({ status: "idle" }); + + useEffect(() => { + if (!dataService || !dataService.isZcash() || !txid) return; + + let cancelled = false; + const adapter = dataService.getZcashAdapter(); + + const fetchTransaction = async () => { + setLoading(true); + setError(null); + setResolution({ status: "idle" }); + + try { + const cached = getCached(cacheNetworkId, "transaction", txid); + if (cached) { + if (!cancelled) setTransaction(cached); + return; + } + + const { data } = await adapter.getTransaction(txid); + if (cancelled) return; + setTransaction(data); + if (data.confirmations) setCached(cacheNetworkId, "transaction", txid, data); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchTransaction(); + return () => { + cancelled = true; + }; + }, [dataService, txid, getCached, setCached, cacheNetworkId]); + + // Zebra omits transparent input values, so the fee is only resolved when the user asks + const resolveInputs = useCallback(async () => { + if (!dataService?.isZcash() || !transaction) return; + + const adapter = dataService.getZcashAdapter(); + setResolution({ + status: "resolving", + resolved: 0, + total: countPreviousTransactions(transaction), + }); + + try { + const result = await adapter.resolveTransparentInputs(transaction, { + onProgress: (resolved, total) => setResolution({ status: "resolving", resolved, total }), + }); + setTransaction(result.transaction); + setResolution({ status: "done", resolved: result.resolved, total: result.total }); + if (result.transaction.confirmations && txid) { + setCached(cacheNetworkId, "transaction", txid, result.transaction); + } + } catch (err) { + setResolution({ status: "error", message: err instanceof Error ? err.message : String(err) }); + } + }, [dataService, transaction, txid, setCached, cacheNetworkId]); + + if (loading) { + return ( +
+
+
+ {t("tx.title")} +
+
+ window.location.reload()} /> +
+
+
+ ); + } + + if (error) { + return ( +
+
+
+

{t("tx.loadError", { message: error })}

+
+
+
+ ); + } + + return ( +
+ + {transaction ? ( + + ) : ( +
+
+

{t("tx.notFound")}

+
+
+ )} +
+ ); +} diff --git a/src/components/pages/zcash/ZcashTransactionsPage.tsx b/src/components/pages/zcash/ZcashTransactionsPage.tsx new file mode 100644 index 00000000..ca4e673f --- /dev/null +++ b/src/components/pages/zcash/ZcashTransactionsPage.tsx @@ -0,0 +1,307 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { getNetworkBySlug } from "../../../config/networks"; +import { TXS_PER_PAGE } from "../../../config/zcashConstants"; +import { useDataService } from "../../../hooks/useDataService"; +import type { ZcashBlock, ZcashTransaction } from "../../../types"; +import { logger } from "../../../utils/logger"; +import { formatNumber, formatSize, formatZEC, truncateHash } from "../../../utils/zcashFormatters"; +import { classifyTransaction, getTransparentOutputZat } from "../../../utils/zcashUtils"; +import Breadcrumb from "../../common/Breadcrumb"; +import { TX_KIND_LABEL_KEYS } from "./labels"; + +const SKELETON_ROWS = 10; + +const skeletonCell = (width: string) => ( + +); + +export default function ZcashTransactionsPage() { + const { t } = useTranslation("zcash"); + const location = useLocation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + // Extract network slug from path (e.g., "/tzec/txs" → "tzec") + const networkSlug = location.pathname.split("/")[1] || "zec"; + const dataService = useDataService(networkSlug); + const network = getNetworkBySlug(networkSlug); + const networkName = network?.name ?? networkSlug; + const networkLabel = network?.shortName || networkSlug.toUpperCase(); + const currency = network?.currency ?? "ZEC"; + + const blockParam = searchParams.get("block"); + const requestedBlock = + blockParam !== null && /^\d+$/.test(blockParam) ? Number(blockParam) : null; + const pageParam = Number(searchParams.get("page")); + const page = Number.isInteger(pageParam) && pageParam > 1 ? pageParam : 1; + + const [block, setBlock] = useState(null); + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataService || !dataService.isZcash()) return; + + let cancelled = false; + const adapter = dataService.getZcashAdapter(); + + const fetchTransactions = async () => { + setLoading(true); + setError(null); + + try { + const height = requestedBlock ?? (await adapter.getLatestBlockNumber()); + const { data } = await adapter.getBlock(height); + if (cancelled) return; + setBlock(data.block); + setTransactions(data.transactions); + } catch (err) { + logger.error("Error fetching Zcash transactions:", err); + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!cancelled) setLoading(false); + } + }; + + fetchTransactions(); + return () => { + cancelled = true; + }; + }, [dataService, requestedBlock]); + + const totalPages = Math.max(1, Math.ceil(transactions.length / TXS_PER_PAGE)); + const safePage = Math.min(page, totalPages); + const startIndex = (safePage - 1) * TXS_PER_PAGE; + const displayed = transactions.slice(startIndex, startIndex + TXS_PER_PAGE); + + const currentHeight = block?.height ?? null; + // The tip height follows from the block's confirmations, so paging costs no extra call + const latestHeight = block?.confirmations ? block.height + block.confirmations - 1 : null; + const isAtLatest = requestedBlock === null; + const canGoNewer = + !isAtLatest && currentHeight !== null && latestHeight !== null && currentHeight < latestHeight; + const canGoOlder = currentHeight !== null && currentHeight > 0; + + const buildUrl = (height: number | null, targetPage = 1) => { + const params = new URLSearchParams(); + if (height !== null) params.set("block", String(height)); + if (targetPage > 1) params.set("page", String(targetPage)); + const query = params.toString(); + return `/${networkSlug}/txs${query ? `?${query}` : ""}`; + }; + + const goToNewerBlock = () => { + if (currentHeight === null || latestHeight === null) return; + const next = currentHeight + 1; + navigate(buildUrl(next >= latestHeight ? null : next)); + }; + + const goToOlderBlock = () => { + if (currentHeight !== null) navigate(buildUrl(currentHeight - 1)); + }; + + const breadcrumb = ( + + ); + + const header = (message?: string) => ( +
+
+ {t("txsPage.title", { network: networkName })} + {message && ( + <> + • + {message} + + )} +
+
+ ); + + const tableHead = ( + + + {t("txsPage.txid")} + {t("txsPage.kind")} + {t("txsPage.transparentIo")} + {t("txsPage.value")} + {t("txsPage.size")} + + + ); + + if (loading) { + return ( +
+ {breadcrumb} +
+ {header()} +
+ + {tableHead} + + {Array.from({ length: SKELETON_ROWS }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholder + + + + + + + + ))} + +
{skeletonCell("120px")}{skeletonCell("70px")}{skeletonCell("60px")}{skeletonCell("80px")}{skeletonCell("60px")}
+
+
+
+ ); + } + + if (error) { + return ( +
+ {breadcrumb} +
+ {header()} +
+

{t("txsPage.loadError", { message: error })}

+
+
+
+ ); + } + + const messageValues = { + from: formatNumber(transactions.length > 0 ? startIndex + 1 : 0), + to: formatNumber(startIndex + displayed.length), + total: formatNumber(transactions.length), + block: formatNumber(currentHeight), + }; + + const pagination = ( +
+ + + + {totalPages > 1 && ( + <> + | + + + {t("txsPage.pageInfo", { page: safePage, totalPages })} + + + + )} +
+ ); + + return ( +
+ {breadcrumb} +
+ {header(t(isAtLatest ? "txsPage.latest" : "txsPage.range", messageValues))} + {pagination} + + {displayed.length === 0 ? ( +

{t("txsPage.empty")}

+ ) : ( +
+ + {tableHead} + + {displayed.map((tx) => { + const kind = classifyTransaction(tx); + const transparentOutputZat = getTransparentOutputZat(tx); + return ( + + + + + + + + ); + })} + +
+ + {truncateHash(tx.txid, "long")} + + + + {t(TX_KIND_LABEL_KEYS[kind])} + + + {t("txsPage.ioCount", { inputs: tx.vin.length, outputs: tx.vout.length })} + + {transparentOutputZat > 0 ? ( + + {formatZEC(transparentOutputZat, currency)} + + ) : ( + {t("transactions.shieldedValue")} + )} + {formatSize(tx.size)}
+
+ )} + + {pagination} +
+
+ ); +} diff --git a/src/components/pages/zcash/ZcashTransactionsTable.tsx b/src/components/pages/zcash/ZcashTransactionsTable.tsx index 5f3517e0..bb96984c 100644 --- a/src/components/pages/zcash/ZcashTransactionsTable.tsx +++ b/src/components/pages/zcash/ZcashTransactionsTable.tsx @@ -1,18 +1,10 @@ import type React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import type { ZcashTransaction, ZcashTxKind } from "../../../types"; +import type { ZcashTransaction } from "../../../types"; import { formatZEC, truncateHash } from "../../../utils/zcashFormatters"; import { classifyTransaction, getTransparentOutputZat } from "../../../utils/zcashUtils"; - -const KIND_LABEL_KEYS = { - coinbase: "transactions.kinds.coinbase", - transparent: "transactions.kinds.transparent", - shielding: "transactions.kinds.shielding", - deshielding: "transactions.kinds.deshielding", - shielded: "transactions.kinds.shielded", - mixed: "transactions.kinds.mixed", -} as const satisfies Record; +import { TX_KIND_LABEL_KEYS } from "./labels"; interface ZcashTransactionsTableProps { transactions: ZcashTransaction[]; @@ -87,7 +79,7 @@ const ZcashTransactionsTable: React.FC = ({
- {t(KIND_LABEL_KEYS[kind])} + {t(TX_KIND_LABEL_KEYS[kind])}
diff --git a/src/components/pages/zcash/ZcashValuePools.tsx b/src/components/pages/zcash/ZcashValuePools.tsx index 9cc63b2d..e5bf0932 100644 --- a/src/components/pages/zcash/ZcashValuePools.tsx +++ b/src/components/pages/zcash/ZcashValuePools.tsx @@ -4,15 +4,7 @@ import { useSettings } from "../../../context/SettingsContext"; import type { ZcashValuePool, ZcashValuePoolId } from "../../../types"; import { formatZECCompact } from "../../../utils/zcashFormatters"; import HelperTooltip from "../../common/HelperTooltip"; - -const POOL_LABEL_KEYS = { - transparent: "valuePools.pools.transparent", - sprout: "valuePools.pools.sprout", - sapling: "valuePools.pools.sapling", - orchard: "valuePools.pools.orchard", - lockbox: "valuePools.pools.lockbox", - ironwood: "valuePools.pools.ironwood", -} as const satisfies Record; +import { VALUE_POOL_LABEL_KEYS } from "./labels"; const POOL_TOOLTIP_KEYS = { transparent: "zcash.pools.transparent", @@ -67,7 +59,7 @@ const ZcashValuePools: React.FC = ({ {valuePools.map((pool) => (
  • - {t(POOL_LABEL_KEYS[pool.id])} + {t(VALUE_POOL_LABEL_KEYS[pool.id])} {showTooltips && } {formatZECCompact(pool.valueZat, currency)} {sharePercent(pool.valueZat).toFixed(1)}% diff --git a/src/components/pages/zcash/labels.ts b/src/components/pages/zcash/labels.ts new file mode 100644 index 00000000..4fbcb1bf --- /dev/null +++ b/src/components/pages/zcash/labels.ts @@ -0,0 +1,21 @@ +import type { ZcashTxKind, ZcashValuePoolId } from "../../../types"; + +// i18n keys in the zcash namespace, keyed by domain value so lookups stay type-checked + +export const VALUE_POOL_LABEL_KEYS = { + transparent: "valuePools.pools.transparent", + sprout: "valuePools.pools.sprout", + sapling: "valuePools.pools.sapling", + orchard: "valuePools.pools.orchard", + lockbox: "valuePools.pools.lockbox", + ironwood: "valuePools.pools.ironwood", +} as const satisfies Record; + +export const TX_KIND_LABEL_KEYS = { + coinbase: "transactions.kinds.coinbase", + transparent: "transactions.kinds.transparent", + shielding: "transactions.kinds.shielding", + deshielding: "transactions.kinds.deshielding", + shielded: "transactions.kinds.shielded", + mixed: "transactions.kinds.mixed", +} as const satisfies Record; diff --git a/src/config/zcashConstants.ts b/src/config/zcashConstants.ts index 2ad29639..574377fd 100644 --- a/src/config/zcashConstants.ts +++ b/src/config/zcashConstants.ts @@ -19,3 +19,6 @@ export const REFRESH_INTERVAL_MS = 90_000; // Blocks list page size (each block costs one getblock call) export const BLOCKS_PER_PAGE = 10; + +// Transactions list page size (paged client-side within a single block) +export const TXS_PER_PAGE = 25; diff --git a/src/locales/en/tooltips.json b/src/locales/en/tooltips.json index ad226cba..6f3b6d68 100644 --- a/src/locales/en/tooltips.json +++ b/src/locales/en/tooltips.json @@ -182,6 +182,19 @@ "nonce": "32-byte value miners vary while searching for a valid Equihash solution.", "solution": "The Equihash proof-of-work solution for this block.", "trees": "Number of notes committed to each shielded pool's note commitment tree as of this block." + }, + "tx": { + "txid": "A unique identifier for this transaction.", + "confirmations": "Blocks mined since the block containing this transaction. More confirmations means higher finality.", + "kind": "How value moves: between transparent addresses, into shielded pools (shielding), out of them (deshielding), entirely inside them (fully shielded), or a mix.", + "transparentInputs": "Previous transparent outputs being spent. Zebra doesn't return their values, so they are looked up on request.", + "transparentOutputs": "New transparent outputs. Like Bitcoin, their addresses and amounts are public.", + "fee": "Paid to the miner: transparent inputs plus value leaving the shielded pools, minus transparent outputs.", + "size": "Serialized transaction size, including shielded proofs.", + "versionGroupId": "Identifies which network upgrade's transaction format this transaction uses.", + "expiryHeight": "The transaction can no longer be mined after this block height.", + "locktime": "Earliest block height or time at which this transaction can be mined.", + "shieldedActivity": "Value moving through Zcash's shielded pools. Amounts and parties stay encrypted; only each pool's net value balance is public." } }, "network": { diff --git a/src/locales/en/zcash.json b/src/locales/en/zcash.json index dbb3394b..aead34ac 100644 --- a/src/locales/en/zcash.json +++ b/src/locales/en/zcash.json @@ -44,7 +44,9 @@ "breadcrumb": { "home": "Home", "blocks": "Blocks", - "block": "Block #{{height}}" + "block": "Block #{{height}}", + "transactions": "Transactions", + "transaction": "{{txid}}" }, "blocksPage": { "title": "{{network}} Blocks", @@ -99,6 +101,84 @@ "showTransactions": "+ Show Transactions ({{count}})", "hideTransactions": "− Hide Transactions ({{count}})" }, + "txsPage": { + "title": "{{network}} Transactions", + "latest": "Showing transactions {{from}}–{{to}} of {{total}} in the latest block #{{block}}", + "range": "Showing transactions {{from}}–{{to}} of {{total}} in block #{{block}}", + "empty": "No transactions found in this block", + "txid": "Transaction ID", + "kind": "Type", + "transparentIo": "Transparent I/O", + "value": "Transparent Value", + "size": "Size", + "ioCount": "{{inputs}} in / {{outputs}} out", + "latestButton": "Latest", + "latestTitle": "Go to the latest block", + "newerBlock": "↑ Newer Block", + "newerBlockTitle": "View the newer block", + "olderBlock": "↓ Older Block", + "olderBlockTitle": "View the older block", + "prev": "← Prev", + "prevTitle": "Previous page", + "next": "Next →", + "nextTitle": "Next page", + "pageInfo": "Page {{page}} of {{totalPages}}", + "loadError": "Couldn't load transactions: {{message}}" + }, + "tx": { + "title": "Transaction", + "loading": "Loading transaction...", + "loadError": "Couldn't load this transaction: {{message}}", + "notFound": "Transaction not found", + "inMempool": "In Mempool (Unconfirmed)", + "confirmations": "{{count}} Confirmations", + "txid": "Transaction ID:", + "block": "Block:", + "kind": "Type:", + "status": "Status:", + "waiting": "Waiting for confirmation in the mempool", + "time": "Time:", + "age": "Age:", + "transparentInputs": "Transparent Inputs:", + "transparentOutputs": "Transparent Outputs:", + "fee": "Fee:", + "feeRate": "{{rate}} zat/B", + "feeNeedsInputs": "Needs input values from {{count}} previous transactions", + "lookUpCapped": "Looks up the first {{max}} of {{count}} previous transactions", + "lookUpInputs": "Look up input values", + "resolving": "Looking up inputs… {{resolved}} / {{total}}", + "feeUnavailable": "Unavailable: found {{resolved}} of {{total}} previous transactions", + "resolveError": "Couldn't look up input values: {{message}}", + "retry": "Retry", + "size": "Size:", + "version": "Version:", + "versionGroupId": "Version Group ID:", + "expiryHeight": "Expiry Height:", + "locktime": "Locktime:", + "inputsColumn": "Transparent Inputs ({{count}})", + "outputsColumn": "Transparent Outputs ({{count}})", + "newCoins": "Newly generated coins", + "unknownAddress": "Unknown address", + "unknownValue": "Value unknown", + "spends": "from {{outpoint}}", + "nonStandard": "Non-standard script", + "noTransparentInputs": "None: funded from shielded pools", + "noTransparentOutputs": "None: sent to shielded pools", + "flowArrow": "Flow of value" + }, + "shielded": { + "title": "Shielded Activity", + "fullyShieldedTitle": "Fully shielded transaction", + "fullyShieldedBody": "Senders, recipients and amounts are encrypted. Only the net value moving in or out of each pool is public, which is how the fee is derived.", + "spendsOutputs": "{{spends}} spends · {{outputs}} outputs", + "actions": "{{count}} actions", + "joinSplits": "{{count}} JoinSplits", + "valueLeft": "{{amount}} left the pool", + "valueEntered": "{{amount}} entered the pool", + "valueNone": "No net value change", + "sproutIn": "{{amount}} entered Sprout", + "sproutOut": "{{amount}} left Sprout" + }, "common": { "secondsAgo": "{{count}}s ago", "minutesAgo": "{{count}}m ago", diff --git a/src/locales/es/tooltips.json b/src/locales/es/tooltips.json index 4097de05..ab580e44 100644 --- a/src/locales/es/tooltips.json +++ b/src/locales/es/tooltips.json @@ -182,6 +182,19 @@ "nonce": "Valor de 32 bytes que los mineros varían al buscar una solución Equihash válida.", "solution": "La solución de prueba de trabajo Equihash de este bloque.", "trees": "Cantidad de notas comprometidas en el árbol de compromisos de cada pool blindado hasta este bloque." + }, + "tx": { + "txid": "Identificador único de esta transacción.", + "confirmations": "Bloques minados desde el bloque que contiene esta transacción. Más confirmaciones significa mayor finalidad.", + "kind": "Cómo se mueve el valor: entre direcciones transparentes, hacia pools blindados (blindaje), fuera de ellos (desblindaje), completamente dentro de ellos (totalmente blindada) o una combinación.", + "transparentInputs": "Salidas transparentes anteriores que se gastan. Zebra no devuelve sus valores, así que se consultan a pedido.", + "transparentOutputs": "Nuevas salidas transparentes. Como en Bitcoin, sus direcciones y montos son públicos.", + "fee": "Pagada al minero: entradas transparentes más el valor que sale de los pools blindados, menos las salidas transparentes.", + "size": "Tamaño de la transacción serializada, incluidas las pruebas blindadas.", + "versionGroupId": "Identifica qué formato de transacción de actualización de red usa esta transacción.", + "expiryHeight": "La transacción ya no puede minarse después de esta altura de bloque.", + "locktime": "Altura de bloque o momento más temprano en que puede minarse esta transacción.", + "shieldedActivity": "Valor que se mueve por los pools blindados de Zcash. Montos y participantes permanecen cifrados; solo es público el balance neto de cada pool." } }, "network": { diff --git a/src/locales/es/zcash.json b/src/locales/es/zcash.json index 99edaa69..d346eb6e 100644 --- a/src/locales/es/zcash.json +++ b/src/locales/es/zcash.json @@ -44,7 +44,9 @@ "breadcrumb": { "home": "Inicio", "blocks": "Bloques", - "block": "Bloque #{{height}}" + "block": "Bloque #{{height}}", + "transactions": "Transacciones", + "transaction": "{{txid}}" }, "blocksPage": { "title": "Bloques de {{network}}", @@ -99,6 +101,84 @@ "showTransactions": "+ Mostrar transacciones ({{count}})", "hideTransactions": "− Ocultar transacciones ({{count}})" }, + "txsPage": { + "title": "Transacciones de {{network}}", + "latest": "Mostrando transacciones {{from}}–{{to}} de {{total}} en el último bloque #{{block}}", + "range": "Mostrando transacciones {{from}}–{{to}} de {{total}} en el bloque #{{block}}", + "empty": "No se encontraron transacciones en este bloque", + "txid": "ID de transacción", + "kind": "Tipo", + "transparentIo": "E/S transparentes", + "value": "Valor transparente", + "size": "Tamaño", + "ioCount": "{{inputs}} entradas / {{outputs}} salidas", + "latestButton": "Último", + "latestTitle": "Ir al último bloque", + "newerBlock": "↑ Bloque más reciente", + "newerBlockTitle": "Ver el bloque más reciente", + "olderBlock": "↓ Bloque anterior", + "olderBlockTitle": "Ver el bloque anterior", + "prev": "← Anterior", + "prevTitle": "Página anterior", + "next": "Siguiente →", + "nextTitle": "Página siguiente", + "pageInfo": "Página {{page}} de {{totalPages}}", + "loadError": "No se pudieron cargar las transacciones: {{message}}" + }, + "tx": { + "title": "Transacción", + "loading": "Cargando transacción...", + "loadError": "No se pudo cargar esta transacción: {{message}}", + "notFound": "Transacción no encontrada", + "inMempool": "En mempool (sin confirmar)", + "confirmations": "{{count}} confirmaciones", + "txid": "ID de transacción:", + "block": "Bloque:", + "kind": "Tipo:", + "status": "Estado:", + "waiting": "Esperando confirmación en la mempool", + "time": "Hora:", + "age": "Antigüedad:", + "transparentInputs": "Entradas transparentes:", + "transparentOutputs": "Salidas transparentes:", + "fee": "Comisión:", + "feeRate": "{{rate}} zat/B", + "feeNeedsInputs": "Requiere los valores de {{count}} transacciones anteriores", + "lookUpCapped": "Consulta las primeras {{max}} de {{count}} transacciones anteriores", + "lookUpInputs": "Consultar valores de entrada", + "resolving": "Consultando entradas… {{resolved}} / {{total}}", + "feeUnavailable": "No disponible: se encontraron {{resolved}} de {{total}} transacciones anteriores", + "resolveError": "No se pudieron consultar los valores de entrada: {{message}}", + "retry": "Reintentar", + "size": "Tamaño:", + "version": "Versión:", + "versionGroupId": "ID de grupo de versión:", + "expiryHeight": "Altura de expiración:", + "locktime": "Locktime:", + "inputsColumn": "Entradas transparentes ({{count}})", + "outputsColumn": "Salidas transparentes ({{count}})", + "newCoins": "Monedas recién generadas", + "unknownAddress": "Dirección desconocida", + "unknownValue": "Valor desconocido", + "spends": "de {{outpoint}}", + "nonStandard": "Script no estándar", + "noTransparentInputs": "Ninguna: financiada desde pools blindados", + "noTransparentOutputs": "Ninguna: enviada a pools blindados", + "flowArrow": "Flujo de valor" + }, + "shielded": { + "title": "Actividad blindada", + "fullyShieldedTitle": "Transacción totalmente blindada", + "fullyShieldedBody": "Remitentes, destinatarios y montos están cifrados. Solo es público el valor neto que entra o sale de cada pool, y de ahí se deriva la comisión.", + "spendsOutputs": "{{spends}} gastos · {{outputs}} salidas", + "actions": "{{count}} acciones", + "joinSplits": "{{count}} JoinSplits", + "valueLeft": "{{amount}} salió del pool", + "valueEntered": "{{amount}} entró al pool", + "valueNone": "Sin cambio neto de valor", + "sproutIn": "{{amount}} entró a Sprout", + "sproutOut": "{{amount}} salió de Sprout" + }, "common": { "secondsAgo": "hace {{count}} s", "minutesAgo": "hace {{count}} min", diff --git a/src/locales/ja/tooltips.json b/src/locales/ja/tooltips.json index 5875807d..e06fb893 100644 --- a/src/locales/ja/tooltips.json +++ b/src/locales/ja/tooltips.json @@ -182,6 +182,19 @@ "nonce": "マイナーが有効なEquihashソリューションを探す際に変化させる32バイトの値。", "solution": "このブロックのEquihashプルーフ・オブ・ワークのソリューション。", "trees": "このブロック時点で各シールドプールのノートコミットメントツリーにコミットされたノート数。" + }, + "tx": { + "txid": "このトランザクションの一意の識別子。", + "confirmations": "このトランザクションを含むブロック以降にマイニングされたブロック数。承認数が多いほどファイナリティが高くなります。", + "kind": "価値の移動方法:トランスペアレントアドレス間、シールドプールへ(シールド化)、シールドプールから(アンシールド化)、シールドプール内のみ(完全シールド)、またはその組み合わせ。", + "transparentInputs": "使用される以前のトランスペアレント出力。Zebraはその金額を返さないため、リクエストに応じて照会します。", + "transparentOutputs": "新しいトランスペアレント出力。Bitcoinと同様に、アドレスと金額は公開されます。", + "fee": "マイナーに支払われる手数料:トランスペアレント入力とシールドプールから出る金額の合計から、トランスペアレント出力を引いた額。", + "size": "シールド証明を含む、シリアライズされたトランザクションのサイズ。", + "versionGroupId": "このトランザクションがどのネットワークアップグレードのトランザクション形式を使用しているかを示します。", + "expiryHeight": "このブロック高を過ぎると、トランザクションはマイニングできなくなります。", + "locktime": "このトランザクションをマイニングできる最も早いブロック高または時刻。", + "shieldedActivity": "Zcashのシールドプールを通過する価値。金額と当事者は暗号化されたままで、公開されるのは各プールの正味の残高変化のみです。" } }, "network": { diff --git a/src/locales/ja/zcash.json b/src/locales/ja/zcash.json index 7fb8fa8d..3e07f70c 100644 --- a/src/locales/ja/zcash.json +++ b/src/locales/ja/zcash.json @@ -44,7 +44,9 @@ "breadcrumb": { "home": "ホーム", "blocks": "ブロック", - "block": "ブロック #{{height}}" + "block": "ブロック #{{height}}", + "transactions": "トランザクション", + "transaction": "{{txid}}" }, "blocksPage": { "title": "{{network}} のブロック", @@ -99,6 +101,84 @@ "showTransactions": "+ トランザクションを表示 ({{count}})", "hideTransactions": "− トランザクションを隠す ({{count}})" }, + "txsPage": { + "title": "{{network}} のトランザクション", + "latest": "最新ブロック #{{block}} の {{total}} 件中 {{from}}–{{to}} 件を表示", + "range": "ブロック #{{block}} の {{total}} 件中 {{from}}–{{to}} 件を表示", + "empty": "このブロックにトランザクションはありません", + "txid": "トランザクションID", + "kind": "種類", + "transparentIo": "トランスペアレント入出力", + "value": "トランスペアレント金額", + "size": "サイズ", + "ioCount": "入力 {{inputs}} / 出力 {{outputs}}", + "latestButton": "最新", + "latestTitle": "最新ブロックへ移動", + "newerBlock": "↑ 新しいブロック", + "newerBlockTitle": "新しいブロックを表示", + "olderBlock": "↓ 古いブロック", + "olderBlockTitle": "古いブロックを表示", + "prev": "← 前へ", + "prevTitle": "前のページ", + "next": "次へ →", + "nextTitle": "次のページ", + "pageInfo": "{{totalPages}} ページ中 {{page}} ページ", + "loadError": "トランザクションを読み込めませんでした: {{message}}" + }, + "tx": { + "title": "トランザクション", + "loading": "トランザクションを読み込み中...", + "loadError": "このトランザクションを読み込めませんでした: {{message}}", + "notFound": "トランザクションが見つかりません", + "inMempool": "メンプール内(未承認)", + "confirmations": "{{count}} 承認", + "txid": "トランザクションID:", + "block": "ブロック:", + "kind": "種類:", + "status": "ステータス:", + "waiting": "メンプールで承認待ち", + "time": "時刻:", + "age": "経過時間:", + "transparentInputs": "トランスペアレント入力:", + "transparentOutputs": "トランスペアレント出力:", + "fee": "手数料:", + "feeRate": "{{rate}} zat/B", + "feeNeedsInputs": "{{count}} 件の前のトランザクションの入力値が必要です", + "lookUpCapped": "前のトランザクション {{count}} 件のうち最初の {{max}} 件を照会します", + "lookUpInputs": "入力値を照会", + "resolving": "入力を照会中… {{resolved}} / {{total}}", + "feeUnavailable": "利用不可: 前のトランザクション {{total}} 件中 {{resolved}} 件を取得", + "resolveError": "入力値を照会できませんでした: {{message}}", + "retry": "再試行", + "size": "サイズ:", + "version": "バージョン:", + "versionGroupId": "バージョングループID:", + "expiryHeight": "有効期限の高さ:", + "locktime": "ロックタイム:", + "inputsColumn": "トランスペアレント入力 ({{count}})", + "outputsColumn": "トランスペアレント出力 ({{count}})", + "newCoins": "新規発行コイン", + "unknownAddress": "不明なアドレス", + "unknownValue": "金額不明", + "spends": "{{outpoint}} から", + "nonStandard": "非標準スクリプト", + "noTransparentInputs": "なし:シールドプールから資金供給", + "noTransparentOutputs": "なし:シールドプールへ送金", + "flowArrow": "価値の流れ" + }, + "shielded": { + "title": "シールドアクティビティ", + "fullyShieldedTitle": "完全シールドトランザクション", + "fullyShieldedBody": "送信者、受信者、金額は暗号化されています。公開されるのは各プールに出入りする正味の金額のみで、手数料はそこから算出されます。", + "spendsOutputs": "スペンド {{spends}} · 出力 {{outputs}}", + "actions": "アクション {{count}} 件", + "joinSplits": "JoinSplit {{count}} 件", + "valueLeft": "{{amount}} がプールから出ました", + "valueEntered": "{{amount}} がプールに入りました", + "valueNone": "正味の金額変化なし", + "sproutIn": "{{amount}} が Sprout に入りました", + "sproutOut": "{{amount}} が Sprout から出ました" + }, "common": { "secondsAgo": "{{count}}秒前", "minutesAgo": "{{count}}分前", diff --git a/src/locales/pt-BR/tooltips.json b/src/locales/pt-BR/tooltips.json index 5fbfa73e..003779b2 100644 --- a/src/locales/pt-BR/tooltips.json +++ b/src/locales/pt-BR/tooltips.json @@ -182,6 +182,19 @@ "nonce": "Valor de 32 bytes que os mineradores variam ao buscar uma solução Equihash válida.", "solution": "A solução de prova de trabalho Equihash deste bloco.", "trees": "Número de notas comprometidas na árvore de compromissos de cada pool blindado até este bloco." + }, + "tx": { + "txid": "Identificador único desta transação.", + "confirmations": "Blocos minerados desde o bloco que contém esta transação. Mais confirmações significa maior finalidade.", + "kind": "Como o valor se move: entre endereços transparentes, para pools blindados (blindagem), para fora deles (desblindagem), inteiramente dentro deles (totalmente blindada) ou uma combinação.", + "transparentInputs": "Saídas transparentes anteriores sendo gastas. O Zebra não retorna seus valores, então eles são consultados sob demanda.", + "transparentOutputs": "Novas saídas transparentes. Como no Bitcoin, seus endereços e valores são públicos.", + "fee": "Paga ao minerador: entradas transparentes mais o valor que sai dos pools blindados, menos as saídas transparentes.", + "size": "Tamanho da transação serializada, incluindo as provas blindadas.", + "versionGroupId": "Identifica qual formato de transação de atualização de rede esta transação usa.", + "expiryHeight": "A transação não pode mais ser minerada após esta altura de bloco.", + "locktime": "Altura de bloco ou momento mais cedo em que esta transação pode ser minerada.", + "shieldedActivity": "Valor circulando pelos pools blindados do Zcash. Valores e participantes permanecem criptografados; só o saldo líquido de cada pool é público." } }, "network": { diff --git a/src/locales/pt-BR/zcash.json b/src/locales/pt-BR/zcash.json index 11470feb..74d18946 100644 --- a/src/locales/pt-BR/zcash.json +++ b/src/locales/pt-BR/zcash.json @@ -44,7 +44,9 @@ "breadcrumb": { "home": "Início", "blocks": "Blocos", - "block": "Bloco #{{height}}" + "block": "Bloco #{{height}}", + "transactions": "Transações", + "transaction": "{{txid}}" }, "blocksPage": { "title": "Blocos de {{network}}", @@ -99,6 +101,84 @@ "showTransactions": "+ Mostrar transações ({{count}})", "hideTransactions": "− Ocultar transações ({{count}})" }, + "txsPage": { + "title": "Transações de {{network}}", + "latest": "Mostrando transações {{from}}–{{to}} de {{total}} no bloco mais recente #{{block}}", + "range": "Mostrando transações {{from}}–{{to}} de {{total}} no bloco #{{block}}", + "empty": "Nenhuma transação encontrada neste bloco", + "txid": "ID da transação", + "kind": "Tipo", + "transparentIo": "E/S transparentes", + "value": "Valor transparente", + "size": "Tamanho", + "ioCount": "{{inputs}} entradas / {{outputs}} saídas", + "latestButton": "Mais recente", + "latestTitle": "Ir para o bloco mais recente", + "newerBlock": "↑ Bloco mais novo", + "newerBlockTitle": "Ver o bloco mais novo", + "olderBlock": "↓ Bloco anterior", + "olderBlockTitle": "Ver o bloco anterior", + "prev": "← Anterior", + "prevTitle": "Página anterior", + "next": "Próxima →", + "nextTitle": "Próxima página", + "pageInfo": "Página {{page}} de {{totalPages}}", + "loadError": "Não foi possível carregar as transações: {{message}}" + }, + "tx": { + "title": "Transação", + "loading": "Carregando transação...", + "loadError": "Não foi possível carregar esta transação: {{message}}", + "notFound": "Transação não encontrada", + "inMempool": "Na mempool (não confirmada)", + "confirmations": "{{count}} confirmações", + "txid": "ID da transação:", + "block": "Bloco:", + "kind": "Tipo:", + "status": "Status:", + "waiting": "Aguardando confirmação na mempool", + "time": "Hora:", + "age": "Idade:", + "transparentInputs": "Entradas transparentes:", + "transparentOutputs": "Saídas transparentes:", + "fee": "Taxa:", + "feeRate": "{{rate}} zat/B", + "feeNeedsInputs": "Requer os valores de {{count}} transações anteriores", + "lookUpCapped": "Consulta as primeiras {{max}} de {{count}} transações anteriores", + "lookUpInputs": "Consultar valores de entrada", + "resolving": "Consultando entradas… {{resolved}} / {{total}}", + "feeUnavailable": "Indisponível: {{resolved}} de {{total}} transações anteriores encontradas", + "resolveError": "Não foi possível consultar os valores de entrada: {{message}}", + "retry": "Tentar novamente", + "size": "Tamanho:", + "version": "Versão:", + "versionGroupId": "ID do grupo de versão:", + "expiryHeight": "Altura de expiração:", + "locktime": "Locktime:", + "inputsColumn": "Entradas transparentes ({{count}})", + "outputsColumn": "Saídas transparentes ({{count}})", + "newCoins": "Moedas recém-geradas", + "unknownAddress": "Endereço desconhecido", + "unknownValue": "Valor desconhecido", + "spends": "de {{outpoint}}", + "nonStandard": "Script não padrão", + "noTransparentInputs": "Nenhuma: financiada a partir de pools blindados", + "noTransparentOutputs": "Nenhuma: enviada para pools blindados", + "flowArrow": "Fluxo de valor" + }, + "shielded": { + "title": "Atividade blindada", + "fullyShieldedTitle": "Transação totalmente blindada", + "fullyShieldedBody": "Remetentes, destinatários e valores são criptografados. Só o valor líquido que entra ou sai de cada pool é público, e é dele que a taxa é derivada.", + "spendsOutputs": "{{spends}} gastos · {{outputs}} saídas", + "actions": "{{count}} ações", + "joinSplits": "{{count}} JoinSplits", + "valueLeft": "{{amount}} saiu do pool", + "valueEntered": "{{amount}} entrou no pool", + "valueNone": "Sem variação líquida de valor", + "sproutIn": "{{amount}} entrou no Sprout", + "sproutOut": "{{amount}} saiu do Sprout" + }, "common": { "secondsAgo": "há {{count}} s", "minutesAgo": "há {{count}} min", diff --git a/src/locales/zh/tooltips.json b/src/locales/zh/tooltips.json index 5c62967c..57647109 100644 --- a/src/locales/zh/tooltips.json +++ b/src/locales/zh/tooltips.json @@ -182,6 +182,19 @@ "nonce": "矿工在寻找有效 Equihash 解时改变的 32 字节值。", "solution": "此区块的 Equihash 工作量证明解。", "trees": "截至此区块,各屏蔽池票据承诺树中已提交的票据数量。" + }, + "tx": { + "txid": "此交易的唯一标识符。", + "confirmations": "自包含此交易的区块以来挖出的区块数。确认越多,最终性越高。", + "kind": "价值的流动方式:在透明地址之间、进入屏蔽池(屏蔽)、离开屏蔽池(解除屏蔽)、完全在屏蔽池内(完全屏蔽)或混合方式。", + "transparentInputs": "正在花费的先前透明输出。Zebra 不返回其金额,因此需按需查询。", + "transparentOutputs": "新的透明输出。与比特币一样,其地址和金额是公开的。", + "fee": "支付给矿工的费用:透明输入加上离开屏蔽池的价值,减去透明输出。", + "size": "序列化交易的大小,包括屏蔽证明。", + "versionGroupId": "标识此交易所使用的网络升级交易格式。", + "expiryHeight": "超过此区块高度后,交易将无法再被打包。", + "locktime": "此交易可被打包的最早区块高度或时间。", + "shieldedActivity": "在 Zcash 屏蔽池中流动的价值。金额和参与方保持加密;只有各池的净值余额是公开的。" } }, "network": { diff --git a/src/locales/zh/zcash.json b/src/locales/zh/zcash.json index 690e6e35..db5fc02d 100644 --- a/src/locales/zh/zcash.json +++ b/src/locales/zh/zcash.json @@ -44,7 +44,9 @@ "breadcrumb": { "home": "首页", "blocks": "区块", - "block": "区块 #{{height}}" + "block": "区块 #{{height}}", + "transactions": "交易", + "transaction": "{{txid}}" }, "blocksPage": { "title": "{{network}} 区块", @@ -99,6 +101,84 @@ "showTransactions": "+ 显示交易 ({{count}})", "hideTransactions": "− 隐藏交易 ({{count}})" }, + "txsPage": { + "title": "{{network}} 交易", + "latest": "显示最新区块 #{{block}} 中 {{total}} 笔交易的第 {{from}}–{{to}} 笔", + "range": "显示区块 #{{block}} 中 {{total}} 笔交易的第 {{from}}–{{to}} 笔", + "empty": "此区块中未找到交易", + "txid": "交易 ID", + "kind": "类型", + "transparentIo": "透明输入/输出", + "value": "透明金额", + "size": "大小", + "ioCount": "{{inputs}} 输入 / {{outputs}} 输出", + "latestButton": "最新", + "latestTitle": "前往最新区块", + "newerBlock": "↑ 较新区块", + "newerBlockTitle": "查看较新的区块", + "olderBlock": "↓ 较旧区块", + "olderBlockTitle": "查看较旧的区块", + "prev": "← 上一页", + "prevTitle": "上一页", + "next": "下一页 →", + "nextTitle": "下一页", + "pageInfo": "第 {{page}} 页,共 {{totalPages}} 页", + "loadError": "无法加载交易: {{message}}" + }, + "tx": { + "title": "交易", + "loading": "正在加载交易...", + "loadError": "无法加载此交易: {{message}}", + "notFound": "未找到交易", + "inMempool": "在内存池中(未确认)", + "confirmations": "{{count}} 个确认", + "txid": "交易 ID:", + "block": "区块:", + "kind": "类型:", + "status": "状态:", + "waiting": "正在内存池中等待确认", + "time": "时间:", + "age": "时长:", + "transparentInputs": "透明输入:", + "transparentOutputs": "透明输出:", + "fee": "手续费:", + "feeRate": "{{rate}} zat/B", + "feeNeedsInputs": "需要 {{count}} 笔先前交易的输入金额", + "lookUpCapped": "将查询 {{count}} 笔先前交易中的前 {{max}} 笔", + "lookUpInputs": "查询输入金额", + "resolving": "正在查询输入… {{resolved}} / {{total}}", + "feeUnavailable": "不可用:已找到 {{total}} 笔先前交易中的 {{resolved}} 笔", + "resolveError": "无法查询输入金额: {{message}}", + "retry": "重试", + "size": "大小:", + "version": "版本:", + "versionGroupId": "版本组 ID:", + "expiryHeight": "过期高度:", + "locktime": "锁定时间:", + "inputsColumn": "透明输入 ({{count}})", + "outputsColumn": "透明输出 ({{count}})", + "newCoins": "新生成的币", + "unknownAddress": "未知地址", + "unknownValue": "金额未知", + "spends": "来自 {{outpoint}}", + "nonStandard": "非标准脚本", + "noTransparentInputs": "无:资金来自屏蔽池", + "noTransparentOutputs": "无:发送至屏蔽池", + "flowArrow": "价值流向" + }, + "shielded": { + "title": "屏蔽活动", + "fullyShieldedTitle": "完全屏蔽的交易", + "fullyShieldedBody": "发送方、接收方和金额均已加密。只有进出各池的净值是公开的,手续费由此得出。", + "spendsOutputs": "{{spends}} 笔花费 · {{outputs}} 笔输出", + "actions": "{{count}} 个操作", + "joinSplits": "{{count}} 个 JoinSplit", + "valueLeft": "{{amount}} 流出该池", + "valueEntered": "{{amount}} 流入该池", + "valueNone": "无净值变化", + "sproutIn": "{{amount}} 流入 Sprout", + "sproutOut": "{{amount}} 流出 Sprout" + }, "common": { "secondsAgo": "{{count}}秒前", "minutesAgo": "{{count}}分钟前", diff --git a/src/styles/components.css b/src/styles/components.css index 3f05774d..7ae0657d 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -6539,6 +6539,78 @@ button.tx-section-header-toggle { font-variant-numeric: tabular-nums; } +/* Zcash transaction details */ +.zec-kind-row { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.zec-fee-rate { + margin-left: 8px; + font-weight: 400; + color: var(--text-secondary); +} + +.zec-fee-resolve { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.zec-shielded { + background: var(--bg-secondary); + border: 1px solid var(--overlay-light-8); + border-radius: 12px; + padding: 16px 20px; + margin: 16px 0; +} + +.zec-fully-shielded { + padding: 12px 16px; + margin-bottom: 12px; + border: 1px solid var(--color-primary-alpha-20); + border-radius: 8px; + background: var(--color-primary-alpha-8); + color: var(--text-primary); +} + +.zec-fully-shielded p { + margin: 4px 0 0; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.zec-bundle-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} + +.zec-bundle-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 14px; + border-left: 3px solid var(--pool-color); + border-radius: 6px; + background: var(--overlay-light-3); +} + +.zec-bundle-title { + font-weight: 600; + color: var(--text-primary); +} + +.zec-bundle-counts, +.zec-bundle-balance { + font-size: 0.85rem; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + /* Clickable stat label */ .dashboard-stat-label-link { display: block; diff --git a/src/utils/zcashUtils.test.ts b/src/utils/zcashUtils.test.ts index f0c5d528..d02ffd38 100644 --- a/src/utils/zcashUtils.test.ts +++ b/src/utils/zcashUtils.test.ts @@ -4,6 +4,7 @@ import { classifyRpcErrors, classifyTransaction, computeFeeZat, + countPreviousTransactions, detectZcashAddressType, getShieldedSupplyPercent, getTransactionPools, @@ -195,3 +196,19 @@ describe("getShieldedSupplyPercent", () => { expect(getShieldedSupplyPercent({ chainSupplyZat: undefined, valuePools: [] })).toBeNull(); }); }); + +describe("countPreviousTransactions", () => { + it("counts each spent transaction once and ignores coinbase inputs", () => { + const spending = tx({ + vin: [ + { txid: "c".repeat(64), vout: 0, sequence: 0 }, + { txid: "c".repeat(64), vout: 1, sequence: 0 }, + { txid: "d".repeat(64), vout: 0, sequence: 0 }, + ], + }); + const coinbase = tx({ vin: [{ coinbase: "03", sequence: 0 }] }); + + expect(countPreviousTransactions(spending)).toBe(2); + expect(countPreviousTransactions(coinbase)).toBe(0); + }); +}); diff --git a/src/utils/zcashUtils.ts b/src/utils/zcashUtils.ts index 373d1ed9..dc53d490 100644 --- a/src/utils/zcashUtils.ts +++ b/src/utils/zcashUtils.ts @@ -75,6 +75,13 @@ export function classifyTransaction(tx: ZcashTransaction): ZcashTxKind { return "shielded"; } +/** + * Number of distinct previous transactions spent by the transparent inputs + */ +export function countPreviousTransactions(tx: ZcashTransaction): number { + return new Set(tx.vin.flatMap((input) => (input.txid ? [input.txid] : []))).size; +} + export function getTransparentOutputZat(tx: ZcashTransaction): number { return tx.vout.reduce((sum, output) => sum + output.valueZat, 0); }