diff --git a/e2e/fixtures/zcash.ts b/e2e/fixtures/zcash.ts index 5fa8e4bb..1460cfea 100644 --- a/e2e/fixtures/zcash.ts +++ b/e2e/fixtures/zcash.ts @@ -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 @@ -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 { @@ -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 { +export async function mockZcashRpc( + page: Page, + options: MockZcashOptions = {}, +): Promise { const calls: RecordedRpcCall[] = []; page.on("request", (request) => { if (request.method() !== "POST" || !ZCASH_RPC_PATTERN.test(request.url())) return; @@ -168,6 +191,32 @@ export async function mockZcashRpc(page: Page): Promise { } }); + 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: { @@ -216,6 +265,7 @@ export async function mockZcashRpc(page: Page): Promise { const [verbose] = params as [boolean | undefined]; return verbose ? MEMPOOL : Object.keys(MEMPOOL); }, + ...addressIndexHandlers, }); return calls; diff --git a/e2e/tests/shared/mocked/zcash-address.spec.ts b/e2e/tests/shared/mocked/zcash-address.spec.ts new file mode 100644 index 00000000..17bdda38 --- /dev/null +++ b/e2e/tests/shared/mocked/zcash-address.spec.ts @@ -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); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 31878813..4e57c56b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,6 +56,7 @@ import { LazyTokenDetails, LazyTx, LazyTxs, + LazyZcashAddress, LazyZcashBlock, LazyZcashBlocks, LazyZcashMempool, @@ -173,6 +174,7 @@ function AppContent() { } /> } /> } /> + } /> {/* Zcash Testnet routes */} } /> } /> @@ -181,6 +183,7 @@ function AppContent() { } /> } /> } /> + } /> {/* Solana Mainnet routes (must come before :networkId catch-all) */} } /> } /> diff --git a/src/components/LazyComponents.tsx b/src/components/LazyComponents.tsx index b01623f8..7f0902d6 100644 --- a/src/components/LazyComponents.tsx +++ b/src/components/LazyComponents.tsx @@ -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")); @@ -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); @@ -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"); diff --git a/src/components/pages/zcash/ZcashAddressDisplay.tsx b/src/components/pages/zcash/ZcashAddressDisplay.tsx new file mode 100644 index 00000000..ec82f92f --- /dev/null +++ b/src/components/pages/zcash/ZcashAddressDisplay.tsx @@ -0,0 +1,292 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import type { ZcashAddress, ZcashRpcAvailability, ZcashUnifiedReceivers } from "../../../types"; +import { formatNumber, formatZEC, truncateHash } from "../../../utils/zcashFormatters"; +import CopyButton from "../../common/CopyButton"; +import FieldLabel from "../../common/FieldLabel"; +import { ADDRESS_TYPE_LABEL_KEYS } from "./labels"; + +const RECEIVER_ORDER = ["orchard", "sapling", "p2pkh", "p2sh"] as const; + +const RECEIVER_LABEL_KEYS = { + orchard: "address.receiverTypes.orchard", + sapling: "address.receiverTypes.sapling", + p2pkh: "address.receiverTypes.p2pkh", + p2sh: "address.receiverTypes.p2sh", +} as const satisfies Record; + +const MAX_LISTED_TXIDS = 50; + +interface ZcashAddressDisplayProps { + address: ZcashAddress; + networkId: string; + currency: string; + onRetry: () => void; +} + +const ZcashAddressDisplay: React.FC = React.memo( + ({ address, networkId, currency, onRetry }) => { + const { t } = useTranslation("zcash"); + const [showUtxos, setShowUtxos] = useState(false); + const [showTransactions, setShowTransactions] = useState(false); + + const { type, receivers, balance, utxos, txids, transparentAddress } = address; + const indexAvailability = [balance, utxos, txids].flatMap((section) => + section ? [section.availability] : [], + ); + // Hosted gateways block the whole address index, so one notice replaces three empty rows + const indexUnsupported = + indexAvailability.length > 0 && indexAvailability.every((a) => a === "unsupported"); + const hasError = [receivers, balance, utxos, txids].some((s) => s?.availability === "error"); + const isShielded = type === "sapling" || type === "sprout"; + const unifiedWithoutTransparent = + type === "unified" && receivers?.availability === "ok" && !transparentAddress; + // getaddresstxids returns transactions in chain order, oldest first + const recentTxids = txids?.data ? [...txids.data].reverse() : []; + + const unavailable = (availability: ZcashRpcAvailability) => ( + + {availability === "unsupported" ? t("address.unavailable") : t("address.sectionError")} + + ); + + return ( +
+
+ {t("address.title")} + + {t(ADDRESS_TYPE_LABEL_KEYS[type])} + +
+ + {type === "unknown" && ( +
+ {t("address.unknownNotice")} +
+ )} + {isShielded && ( +
{t("address.shieldedNotice")}
+ )} + {unifiedWithoutTransparent && ( +
{t("address.unifiedShieldedNotice")}
+ )} + {type === "tex" && ( +
{t("address.texNotice")}
+ )} + {indexUnsupported && ( +
+

{t("address.indexUnsupported")}

+ + {t("address.configureRpc")} + +
+ )} + +
+
+ + + {address.address} + + +
+ + {receivers && ( +
+ + {receivers.availability === "ok" && receivers.data ? ( +
    + {RECEIVER_ORDER.flatMap((kind) => { + const value = receivers.data?.[kind]; + if (!value) return []; + const isTransparent = kind === "p2pkh" || kind === "p2sh"; + return [ +
  • + {t(RECEIVER_LABEL_KEYS[kind])} + {isTransparent ? ( + + {value} + + ) : ( + + {truncateHash(value, "long")} + + )} + +
  • , + ]; + })} +
+ ) : ( + unavailable(receivers.availability) + )} +
+ )} + + {balance && !indexUnsupported && ( +
+ + {balance.availability === "ok" && balance.data ? ( + + + {formatZEC(balance.data.balanceZat, currency)} + + {type === "unified" && transparentAddress && ( + + {" "} + ( + {t("address.transparentReceiverNote", { + address: truncateHash(transparentAddress, "short"), + })} + ) + + )} + + ) : ( + unavailable(balance.availability) + )} +
+ )} + + {balance?.availability === "ok" && balance.data?.receivedZat !== undefined && ( +
+ + {formatZEC(balance.data.receivedZat, currency)} +
+ )} + + {utxos && !indexUnsupported && utxos.availability !== "ok" && ( +
+ + {unavailable(utxos.availability)} +
+ )} + + {txids && !indexUnsupported && txids.availability !== "ok" && ( +
+ + {unavailable(txids.availability)} +
+ )} + + {hasError && ( +
+ +
+ )} +
+ + {utxos?.availability === "ok" && utxos.data && ( +
+
+ +
+ {showUtxos && + (utxos.data.length === 0 ? ( +

{t("address.noUtxos")}

+ ) : ( +
+ {utxos.data.map((utxo, index) => ( +
+ {index} +
+ + + {truncateHash(utxo.txid, "long")}:{utxo.outputIndex} + + + + {formatZEC(utxo.valueZat, currency)} + + + + {t("address.atHeight", { height: formatNumber(utxo.height) })} + + +
+
+ ))} +
+ ))} +
+ )} + + {txids?.availability === "ok" && recentTxids.length > 0 && ( +
+
+ +
+ {showTransactions && ( +
+ {recentTxids.slice(0, MAX_LISTED_TXIDS).map((txid, index) => ( +
+ {index} + + + {txid} + + +
+ ))} + {recentTxids.length > MAX_LISTED_TXIDS && ( +
+ {t("address.moreTransactions", { + count: recentTxids.length - MAX_LISTED_TXIDS, + })} +
+ )} +
+ )} +
+ )} +
+ ); + }, +); + +ZcashAddressDisplay.displayName = "ZcashAddressDisplay"; +export default ZcashAddressDisplay; diff --git a/src/components/pages/zcash/ZcashAddressPage.tsx b/src/components/pages/zcash/ZcashAddressPage.tsx new file mode 100644 index 00000000..126c833b --- /dev/null +++ b/src/components/pages/zcash/ZcashAddressPage.tsx @@ -0,0 +1,105 @@ +import { 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 type { ZcashAddress } from "../../../types"; +import { truncateHash } from "../../../utils/zcashFormatters"; +import Breadcrumb from "../../common/Breadcrumb"; +import LoaderWithTimeout from "../../common/LoaderWithTimeout"; +import ZcashAddressDisplay from "./ZcashAddressDisplay"; + +export default function ZcashAddressPage() { + const { t } = useTranslation("zcash"); + const { address } = useParams<{ address?: string }>(); + const location = useLocation(); + + // Extract network slug from path (e.g., "/tzec/address/..." → "tzec") + const networkSlug = location.pathname.split("/")[1] || "zec"; + const dataService = useDataService(networkSlug); + const network = getNetworkBySlug(networkSlug); + const networkLabel = network?.shortName || networkSlug.toUpperCase(); + + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + + // Each section of the result carries its own availability, so a gateway that blocks the + // address index still resolves; only unexpected failures reject + // biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey re-runs the lookup on retry + useEffect(() => { + if (!dataService || !dataService.isZcash() || !address) return; + + let cancelled = false; + setLoading(true); + setError(null); + + dataService + .getZcashAdapter() + .getAddress(address) + .then((data) => { + if (!cancelled) setResult(data); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [dataService, address, reloadKey]); + + if (loading) { + return ( +
+
+
+ {t("address.title")} +
+
+ window.location.reload()} + /> +
+
+
+ ); + } + + if (error) { + return ( +
+
+
+

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

+
+
+
+ ); + } + + return ( +
+ + {result && ( + setReloadKey((key) => key + 1)} + /> + )} +
+ ); +} diff --git a/src/components/pages/zcash/labels.ts b/src/components/pages/zcash/labels.ts index 4fbcb1bf..51172f4e 100644 --- a/src/components/pages/zcash/labels.ts +++ b/src/components/pages/zcash/labels.ts @@ -1,4 +1,4 @@ -import type { ZcashTxKind, ZcashValuePoolId } from "../../../types"; +import type { ZcashAddressType, ZcashTxKind, ZcashValuePoolId } from "../../../types"; // i18n keys in the zcash namespace, keyed by domain value so lookups stay type-checked @@ -11,6 +11,16 @@ export const VALUE_POOL_LABEL_KEYS = { ironwood: "valuePools.pools.ironwood", } as const satisfies Record; +export const ADDRESS_TYPE_LABEL_KEYS = { + p2pkh: "address.types.p2pkh", + p2sh: "address.types.p2sh", + tex: "address.types.tex", + sprout: "address.types.sprout", + sapling: "address.types.sapling", + unified: "address.types.unified", + unknown: "address.types.unknown", +} as const satisfies Record; + export const TX_KIND_LABEL_KEYS = { coinbase: "transactions.kinds.coinbase", transparent: "transactions.kinds.transparent", diff --git a/src/locales/en/tooltips.json b/src/locales/en/tooltips.json index 6f3b6d68..e53c7c76 100644 --- a/src/locales/en/tooltips.json +++ b/src/locales/en/tooltips.json @@ -195,6 +195,12 @@ "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." + }, + "address": { + "address": "A Zcash address. Transparent addresses (t1, t3) are public like Bitcoin; shielded (zs) and unified (u1) addresses keep amounts and history private.", + "receivers": "A unified address bundles several receivers. Senders pay the most private receiver their wallet supports.", + "balance": "Sum of this address's unspent transparent outputs, from Zebra's address index.", + "received": "Total transparent value this address has received." } }, "network": { diff --git a/src/locales/en/zcash.json b/src/locales/en/zcash.json index 1ddcdb38..ac27325f 100644 --- a/src/locales/en/zcash.json +++ b/src/locales/en/zcash.json @@ -47,7 +47,49 @@ "block": "Block #{{height}}", "transactions": "Transactions", "transaction": "{{txid}}", - "mempool": "Mempool" + "mempool": "Mempool", + "address": "{{address}}" + }, + "address": { + "title": "Address", + "loading": "Loading address data...", + "loadError": "Couldn't load this address: {{message}}", + "address": "Address:", + "types": { + "p2pkh": "Transparent (P2PKH)", + "p2sh": "Transparent (P2SH)", + "tex": "TEX", + "sprout": "Sprout", + "sapling": "Sapling", + "unified": "Unified", + "unknown": "Unknown" + }, + "receivers": "Receivers:", + "receiverTypes": { + "orchard": "Orchard", + "sapling": "Sapling", + "p2pkh": "Transparent (P2PKH)", + "p2sh": "Transparent (P2SH)" + }, + "transparentReceiverNote": "transparent receiver {{address}}", + "balance": "Balance:", + "received": "Total Received:", + "unspentOutputsLabel": "Unspent Outputs:", + "transactionsLabel": "Transactions:", + "unspentOutputs": "Unspent Outputs ({{count}})", + "transactions": "Transactions ({{count}})", + "moreTransactions": "…and {{count}} more", + "noUtxos": "No unspent outputs", + "atHeight": "block #{{height}}", + "unavailable": "Not available from this RPC endpoint", + "sectionError": "Couldn't load this data.", + "retry": "Retry", + "shieldedNotice": "Shielded addresses don't reveal balances or transaction history on-chain. Only the owner's viewing key can see them.", + "unifiedShieldedNotice": "This unified address has no transparent receiver, so its balance and history stay private.", + "texNotice": "TEX addresses only receive transparent funds. Balance and history lookups aren't available for them yet.", + "unknownNotice": "This doesn't look like a Zcash address.", + "indexUnsupported": "This RPC endpoint doesn't provide Zebra's address index, so balance, unspent outputs and history can't be shown. Hosted gateways block these methods; add a self-hosted Zebra node to see them.", + "configureRpc": "Configure RPC endpoints" }, "mempool": { "title": "{{network}} Mempool", diff --git a/src/locales/es/tooltips.json b/src/locales/es/tooltips.json index ab580e44..ae1a0141 100644 --- a/src/locales/es/tooltips.json +++ b/src/locales/es/tooltips.json @@ -195,6 +195,12 @@ "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." + }, + "address": { + "address": "Una dirección de Zcash. Las transparentes (t1, t3) son públicas como en Bitcoin; las blindadas (zs) y unificadas (u1) mantienen privados los montos y el historial.", + "receivers": "Una dirección unificada agrupa varios receptores. Quien envía paga al receptor más privado que admita su billetera.", + "balance": "Suma de las salidas transparentes no gastadas de esta dirección, según el índice de direcciones de Zebra.", + "received": "Valor transparente total que ha recibido esta dirección." } }, "network": { diff --git a/src/locales/es/zcash.json b/src/locales/es/zcash.json index 1a4ce850..31f4056c 100644 --- a/src/locales/es/zcash.json +++ b/src/locales/es/zcash.json @@ -47,7 +47,49 @@ "block": "Bloque #{{height}}", "transactions": "Transacciones", "transaction": "{{txid}}", - "mempool": "Mempool" + "mempool": "Mempool", + "address": "{{address}}" + }, + "address": { + "title": "Dirección", + "loading": "Cargando datos de la dirección...", + "loadError": "No se pudo cargar esta dirección: {{message}}", + "address": "Dirección:", + "types": { + "p2pkh": "Transparente (P2PKH)", + "p2sh": "Transparente (P2SH)", + "tex": "TEX", + "sprout": "Sprout", + "sapling": "Sapling", + "unified": "Unificada", + "unknown": "Desconocida" + }, + "receivers": "Receptores:", + "receiverTypes": { + "orchard": "Orchard", + "sapling": "Sapling", + "p2pkh": "Transparente (P2PKH)", + "p2sh": "Transparente (P2SH)" + }, + "transparentReceiverNote": "receptor transparente {{address}}", + "balance": "Saldo:", + "received": "Total recibido:", + "unspentOutputsLabel": "Salidas no gastadas:", + "transactionsLabel": "Transacciones:", + "unspentOutputs": "Salidas no gastadas ({{count}})", + "transactions": "Transacciones ({{count}})", + "moreTransactions": "…y {{count}} más", + "noUtxos": "Sin salidas no gastadas", + "atHeight": "bloque #{{height}}", + "unavailable": "No disponible en este endpoint RPC", + "sectionError": "No se pudieron cargar estos datos.", + "retry": "Reintentar", + "shieldedNotice": "Las direcciones blindadas no revelan saldos ni historial en la cadena. Solo la clave de visualización del propietario puede verlos.", + "unifiedShieldedNotice": "Esta dirección unificada no tiene receptor transparente, así que su saldo e historial son privados.", + "texNotice": "Las direcciones TEX solo reciben fondos transparentes. Aún no se pueden consultar su saldo ni su historial.", + "unknownNotice": "Esto no parece una dirección de Zcash.", + "indexUnsupported": "Este endpoint RPC no ofrece el índice de direcciones de Zebra, así que no se pueden mostrar el saldo, las salidas no gastadas ni el historial. Los gateways alojados bloquean estos métodos; agrega un nodo Zebra propio para verlos.", + "configureRpc": "Configurar endpoints RPC" }, "mempool": { "title": "Mempool de {{network}}", diff --git a/src/locales/ja/tooltips.json b/src/locales/ja/tooltips.json index e06fb893..6ede50db 100644 --- a/src/locales/ja/tooltips.json +++ b/src/locales/ja/tooltips.json @@ -195,6 +195,12 @@ "expiryHeight": "このブロック高を過ぎると、トランザクションはマイニングできなくなります。", "locktime": "このトランザクションをマイニングできる最も早いブロック高または時刻。", "shieldedActivity": "Zcashのシールドプールを通過する価値。金額と当事者は暗号化されたままで、公開されるのは各プールの正味の残高変化のみです。" + }, + "address": { + "address": "Zcashアドレス。トランスペアレントアドレス(t1、t3)はBitcoinと同様に公開され、シールド(zs)とユニファイド(u1)アドレスは金額と履歴を非公開に保ちます。", + "receivers": "ユニファイドアドレスは複数のレシーバーをまとめたものです。送金者のウォレットは対応する中で最もプライベートなレシーバーに支払います。", + "balance": "Zebraのアドレスインデックスによる、このアドレスの未使用トランスペアレント出力の合計。", + "received": "このアドレスが受け取ったトランスペアレントな価値の合計。" } }, "network": { diff --git a/src/locales/ja/zcash.json b/src/locales/ja/zcash.json index d8b2220f..57df12ef 100644 --- a/src/locales/ja/zcash.json +++ b/src/locales/ja/zcash.json @@ -47,7 +47,49 @@ "block": "ブロック #{{height}}", "transactions": "トランザクション", "transaction": "{{txid}}", - "mempool": "メンプール" + "mempool": "メンプール", + "address": "{{address}}" + }, + "address": { + "title": "アドレス", + "loading": "アドレスデータを読み込み中...", + "loadError": "このアドレスを読み込めませんでした: {{message}}", + "address": "アドレス:", + "types": { + "p2pkh": "トランスペアレント (P2PKH)", + "p2sh": "トランスペアレント (P2SH)", + "tex": "TEX", + "sprout": "Sprout", + "sapling": "Sapling", + "unified": "ユニファイド", + "unknown": "不明" + }, + "receivers": "レシーバー:", + "receiverTypes": { + "orchard": "Orchard", + "sapling": "Sapling", + "p2pkh": "トランスペアレント (P2PKH)", + "p2sh": "トランスペアレント (P2SH)" + }, + "transparentReceiverNote": "トランスペアレントレシーバー {{address}}", + "balance": "残高:", + "received": "総受取額:", + "unspentOutputsLabel": "未使用出力:", + "transactionsLabel": "トランザクション:", + "unspentOutputs": "未使用出力 ({{count}})", + "transactions": "トランザクション ({{count}})", + "moreTransactions": "…他 {{count}} 件", + "noUtxos": "未使用出力はありません", + "atHeight": "ブロック #{{height}}", + "unavailable": "このRPCエンドポイントでは利用できません", + "sectionError": "このデータを読み込めませんでした。", + "retry": "再試行", + "shieldedNotice": "シールドアドレスはオンチェーンで残高や取引履歴を公開しません。所有者の閲覧キーでのみ確認できます。", + "unifiedShieldedNotice": "このユニファイドアドレスにはトランスペアレントレシーバーがないため、残高と履歴は非公開です。", + "texNotice": "TEXアドレスはトランスペアレントな資金のみを受け取ります。残高と履歴の照会はまだ利用できません。", + "unknownNotice": "これはZcashアドレスではないようです。", + "indexUnsupported": "このRPCエンドポイントはZebraのアドレスインデックスを提供していないため、残高・未使用出力・履歴を表示できません。ホスト型ゲートウェイはこれらのメソッドをブロックしています。表示するには自前のZebraノードを追加してください。", + "configureRpc": "RPCエンドポイントを設定" }, "mempool": { "title": "{{network}} メンプール", diff --git a/src/locales/pt-BR/tooltips.json b/src/locales/pt-BR/tooltips.json index 003779b2..9a7a3c3e 100644 --- a/src/locales/pt-BR/tooltips.json +++ b/src/locales/pt-BR/tooltips.json @@ -195,6 +195,12 @@ "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." + }, + "address": { + "address": "Um endereço Zcash. Endereços transparentes (t1, t3) são públicos como no Bitcoin; blindados (zs) e unificados (u1) mantêm valores e histórico privados.", + "receivers": "Um endereço unificado agrupa vários receptores. Quem envia paga ao receptor mais privado que sua carteira suporta.", + "balance": "Soma das saídas transparentes não gastas deste endereço, segundo o índice de endereços do Zebra.", + "received": "Valor transparente total recebido por este endereço." } }, "network": { diff --git a/src/locales/pt-BR/zcash.json b/src/locales/pt-BR/zcash.json index 27491a84..974e5ce1 100644 --- a/src/locales/pt-BR/zcash.json +++ b/src/locales/pt-BR/zcash.json @@ -47,7 +47,49 @@ "block": "Bloco #{{height}}", "transactions": "Transações", "transaction": "{{txid}}", - "mempool": "Mempool" + "mempool": "Mempool", + "address": "{{address}}" + }, + "address": { + "title": "Endereço", + "loading": "Carregando dados do endereço...", + "loadError": "Não foi possível carregar este endereço: {{message}}", + "address": "Endereço:", + "types": { + "p2pkh": "Transparente (P2PKH)", + "p2sh": "Transparente (P2SH)", + "tex": "TEX", + "sprout": "Sprout", + "sapling": "Sapling", + "unified": "Unificado", + "unknown": "Desconhecido" + }, + "receivers": "Receptores:", + "receiverTypes": { + "orchard": "Orchard", + "sapling": "Sapling", + "p2pkh": "Transparente (P2PKH)", + "p2sh": "Transparente (P2SH)" + }, + "transparentReceiverNote": "receptor transparente {{address}}", + "balance": "Saldo:", + "received": "Total recebido:", + "unspentOutputsLabel": "Saídas não gastas:", + "transactionsLabel": "Transações:", + "unspentOutputs": "Saídas não gastas ({{count}})", + "transactions": "Transações ({{count}})", + "moreTransactions": "…e mais {{count}}", + "noUtxos": "Nenhuma saída não gasta", + "atHeight": "bloco #{{height}}", + "unavailable": "Indisponível neste endpoint RPC", + "sectionError": "Não foi possível carregar estes dados.", + "retry": "Tentar novamente", + "shieldedNotice": "Endereços blindados não revelam saldos nem histórico na blockchain. Só a chave de visualização do dono consegue vê-los.", + "unifiedShieldedNotice": "Este endereço unificado não tem receptor transparente, então seu saldo e histórico permanecem privados.", + "texNotice": "Endereços TEX só recebem fundos transparentes. Consultas de saldo e histórico ainda não estão disponíveis para eles.", + "unknownNotice": "Isto não parece um endereço Zcash.", + "indexUnsupported": "Este endpoint RPC não oferece o índice de endereços do Zebra, então saldo, saídas não gastas e histórico não podem ser exibidos. Gateways hospedados bloqueiam esses métodos; adicione um nó Zebra próprio para vê-los.", + "configureRpc": "Configurar endpoints RPC" }, "mempool": { "title": "Mempool de {{network}}", diff --git a/src/locales/zh/tooltips.json b/src/locales/zh/tooltips.json index 57647109..000f4555 100644 --- a/src/locales/zh/tooltips.json +++ b/src/locales/zh/tooltips.json @@ -195,6 +195,12 @@ "expiryHeight": "超过此区块高度后,交易将无法再被打包。", "locktime": "此交易可被打包的最早区块高度或时间。", "shieldedActivity": "在 Zcash 屏蔽池中流动的价值。金额和参与方保持加密;只有各池的净值余额是公开的。" + }, + "address": { + "address": "Zcash 地址。透明地址(t1、t3)像比特币一样公开;屏蔽地址(zs)和统一地址(u1)会保持金额和历史私密。", + "receivers": "统一地址包含多个接收器。发送方会向其钱包支持的最私密的接收器付款。", + "balance": "根据 Zebra 地址索引,此地址未花费透明输出的总和。", + "received": "此地址累计收到的透明价值。" } }, "network": { diff --git a/src/locales/zh/zcash.json b/src/locales/zh/zcash.json index d98818ef..95921987 100644 --- a/src/locales/zh/zcash.json +++ b/src/locales/zh/zcash.json @@ -47,7 +47,49 @@ "block": "区块 #{{height}}", "transactions": "交易", "transaction": "{{txid}}", - "mempool": "内存池" + "mempool": "内存池", + "address": "{{address}}" + }, + "address": { + "title": "地址", + "loading": "正在加载地址数据...", + "loadError": "无法加载此地址: {{message}}", + "address": "地址:", + "types": { + "p2pkh": "透明 (P2PKH)", + "p2sh": "透明 (P2SH)", + "tex": "TEX", + "sprout": "Sprout", + "sapling": "Sapling", + "unified": "统一地址", + "unknown": "未知" + }, + "receivers": "接收器:", + "receiverTypes": { + "orchard": "Orchard", + "sapling": "Sapling", + "p2pkh": "透明 (P2PKH)", + "p2sh": "透明 (P2SH)" + }, + "transparentReceiverNote": "透明接收器 {{address}}", + "balance": "余额:", + "received": "累计接收:", + "unspentOutputsLabel": "未花费输出:", + "transactionsLabel": "交易:", + "unspentOutputs": "未花费输出 ({{count}})", + "transactions": "交易 ({{count}})", + "moreTransactions": "…还有 {{count}} 笔", + "noUtxos": "没有未花费输出", + "atHeight": "区块 #{{height}}", + "unavailable": "此 RPC 端点不提供该数据", + "sectionError": "无法加载此数据。", + "retry": "重试", + "shieldedNotice": "屏蔽地址不会在链上公开余额或交易历史,只有所有者的查看密钥才能看到。", + "unifiedShieldedNotice": "此统一地址没有透明接收器,因此其余额和历史保持私密。", + "texNotice": "TEX 地址只接收透明资金,暂不支持查询其余额和历史。", + "unknownNotice": "这看起来不是 Zcash 地址。", + "indexUnsupported": "此 RPC 端点不提供 Zebra 地址索引,因此无法显示余额、未花费输出和历史。托管网关会屏蔽这些方法;请添加自建的 Zebra 节点以查看。", + "configureRpc": "配置 RPC 端点" }, "mempool": { "title": "{{network}} 内存池", diff --git a/src/styles/components.css b/src/styles/components.css index 7ae0657d..3cb0fc9d 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -6611,6 +6611,42 @@ button.tx-section-header-toggle { font-variant-numeric: tabular-nums; } +/* Zcash address page */ +.zec-address-notice { + margin: 12px 0; +} + +.zec-address-notice p { + margin: 0 0 6px; +} + +.zec-address-value { + word-break: break-all; +} + +.zec-receivers { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.zec-receivers li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-width: 0; +} + +.zec-receiver-kind { + min-width: 9em; + font-size: 0.85rem; + color: var(--text-secondary); +} + /* Clickable stat label */ .dashboard-stat-label-link { display: block;