From 2efce6c715a7ab44162b8baba9df3c8b65c47af4 Mon Sep 17 00:00:00 2001 From: Antoine Serval Date: Fri, 16 Oct 2026 19:57:43 +0900 Subject: [PATCH 1/2] Reduce speculative explorer rendering and share fresh governance responses --- .../[address]/components/AddressGraph.tsx | 2 +- .../[address]/components/AddressHeroCard.tsx | 4 +- .../[address]/components/AddressSummary.tsx | 4 +- .../[address]/components/TransactionTable.tsx | 2 +- .../components/UnifiedAddressViewer.tsx | 2 +- app/api/governance/announcement/route.ts | 12 +- app/block/[height]/FutureBlockView.tsx | 4 +- .../[height]/components/BlockPageHeader.tsx | 2 +- .../[height]/components/BlockPageSkeleton.tsx | 9 + app/block/[height]/components/types.ts | 2 + app/block/[height]/page.tsx | 6 + app/blocks/BlocksClient.tsx | 2 +- app/finalizer/[pubkey]/page.tsx | 3 +- app/governance/HowToVote.tsx | 2 +- app/governance/nu7/NU7VoteClient.tsx | 2 +- app/name/[name]/NameClient.tsx | 6 +- app/network/NetworkClient.tsx | 2 +- app/network/page.tsx | 2 +- app/reorgs/page.tsx | 8 +- .../anchor-search/AnchorSearchClient.tsx | 2 +- app/tools/broadcast/BroadcastClient.tsx | 2 +- app/tools/decode/DecodeClient.tsx | 4 +- app/tx/[txid]/components/FlowChain.tsx | 2 +- app/tx/[txid]/components/InputsSection.tsx | 2 +- .../[txid]/components/MempoolPendingView.tsx | 2 +- app/tx/[txid]/components/OutputsSection.tsx | 2 +- app/tx/[txid]/components/TxHeroCard.tsx | 2 +- app/tx/[txid]/components/TxOverview.tsx | 2 +- app/txs/TxsClient.tsx | 4 +- components/AddressWithLabel.tsx | 4 +- components/BatchPatternCard.tsx | 2 +- components/BlockActivityChart.tsx | 4 +- components/CrosslinkChainGraph.tsx | 2 +- components/CrosslinkChainView.tsx | 8 +- components/GovernanceBanner.tsx | 37 +++- components/RecentBlocks.tsx | 2 +- components/RecentReorgs.tsx | 2 +- components/RiskyTxCard.tsx | 2 +- components/ask/AskBlocks.tsx | 2 +- components/ask/AskTransfers.tsx | 2 +- components/network/NetworkAccounting.tsx | 2 +- components/network/RecentBlocksTable.tsx | 2 +- components/ui/HashLink.tsx | 4 + lib/governance-announcement.ts | 46 ++++ package.json | 4 +- proxy.ts | 20 +- server/tests/detail-link-prefetch.test.js | 103 +++++++++ .../governance-announcement-cache.test.js | 200 ++++++++++++++++++ server/tests/next-route-cache-build.test.js | 8 + server/tests/orphan-ssr-replacement.test.js | 55 +++++ server/tests/reorg-display.test.js | 45 ++++ 51 files changed, 591 insertions(+), 65 deletions(-) create mode 100644 lib/governance-announcement.ts create mode 100644 server/tests/detail-link-prefetch.test.js create mode 100644 server/tests/governance-announcement-cache.test.js create mode 100644 server/tests/orphan-ssr-replacement.test.js diff --git a/app/address/[address]/components/AddressGraph.tsx b/app/address/[address]/components/AddressGraph.tsx index 7856efe6..d00bd67d 100644 --- a/app/address/[address]/components/AddressGraph.tsx +++ b/app/address/[address]/components/AddressGraph.tsx @@ -81,7 +81,7 @@ export function AddressGraph({ address }: { address: string }) {
Indexed transactions
{selected.peer.txCount.toLocaleString('en-US')}
: null} - Open address → + Open address → :

Inspect a connection

Select a node or choose an address below. Its details stay open while you explore.

}
diff --git a/app/address/[address]/components/AddressHeroCard.tsx b/app/address/[address]/components/AddressHeroCard.tsx index 936cd054..30ec5249 100644 --- a/app/address/[address]/components/AddressHeroCard.tsx +++ b/app/address/[address]/components/AddressHeroCard.tsx @@ -36,8 +36,8 @@ export function AddressHeroCard({ data, priceData, crossChain, totalTxCount }: {
{(funding || (crossChain && crossChain.totalSwaps > 0)) &&
- {funding &&

First funding {funding.amountZec.toLocaleString('en-US', { maximumFractionDigits: 8 })} {CURRENCY} ↗ - {funding.isCoinbase ? ' · Mining reward' : funding.funderAddress ? <> · From {funding.funderLabel || `${funding.funderAddress.slice(0, 6)}…${funding.funderAddress.slice(-4)}`} : null} + {funding &&

First funding {funding.amountZec.toLocaleString('en-US', { maximumFractionDigits: 8 })} {CURRENCY} ↗ + {funding.isCoinbase ? ' · Mining reward' : funding.funderAddress ? <> · From {funding.funderLabel || `${funding.funderAddress.slice(0, 6)}…${funding.funderAddress.slice(-4)}`} : null}

} {crossChain && crossChain.totalSwaps > 0 &&

Bridge records {crossChain.totalSwaps.toLocaleString('en-US')} · {crossChain.entryCount} in / {crossChain.exitCount} out

}
} diff --git a/app/address/[address]/components/AddressSummary.tsx b/app/address/[address]/components/AddressSummary.tsx index a8b1dced..3a48a817 100644 --- a/app/address/[address]/components/AddressSummary.tsx +++ b/app/address/[address]/components/AddressSummary.tsx @@ -36,7 +36,7 @@ export function AddressSummary({ data, totalTxCount }: AddressSummaryProps) { if (funding.funderLabel && funding.funderAddress) { return ( {funding.funderLabel} @@ -46,7 +46,7 @@ export function AddressSummary({ data, totalTxCount }: AddressSummaryProps) { if (funding.funderAddress) { return ( {shortAddr(funding.funderAddress)} diff --git a/app/address/[address]/components/TransactionTable.tsx b/app/address/[address]/components/TransactionTable.tsx index 5a5fcb48..0b3b4bb3 100644 --- a/app/address/[address]/components/TransactionTable.tsx +++ b/app/address/[address]/components/TransactionTable.tsx @@ -22,7 +22,7 @@ export function TransactionTable({ address, data, currentPage, totalPages, pageS if (other === 'shielded') return ; return other && other !== address ? : —; } }, - { id: 'block', header: 'Block', className: 'hidden md:table-cell', cell: tx => tx.blockHeight ? {tx.blockHeight.toLocaleString('en-US')} : — }, + { id: 'block', header: 'Block', className: 'hidden md:table-cell', cell: tx => tx.blockHeight ? {tx.blockHeight.toLocaleString('en-US')} : — }, { id: 'amount', header: `Balance change (${CURRENCY})`, align: 'right', cell: tx => {tx.amount === 0 ? '' : tx.type === 'received' ? '+' : '−'}{Math.abs(tx.amount).toLocaleString('en-US', { maximumFractionDigits: 8 })} }, { id: 'age', header: 'Age', align: 'right', cell: tx => }, ]; diff --git a/app/address/[address]/components/UnifiedAddressViewer.tsx b/app/address/[address]/components/UnifiedAddressViewer.tsx index 174c8f11..69d2b5d6 100644 --- a/app/address/[address]/components/UnifiedAddressViewer.tsx +++ b/app/address/[address]/components/UnifiedAddressViewer.tsx @@ -23,7 +23,7 @@ export function UnifiedAddressViewer({ uaComponents, uaLoading, copiedText, onCo
{receiver.address ?
{receiver.address}
:

Receiver present · use the Unified Address

} - {receiver.key === 'transparent' && receiver.address ? View transparent activity → :

Balance and history remain private.

} + {receiver.key === 'transparent' && receiver.address ? View transparent activity → :

Balance and history remain private.

}
)} {!receivers.length &&

No supported receiver details were returned by the decoder.

} diff --git a/app/api/governance/announcement/route.ts b/app/api/governance/announcement/route.ts index 59b41aac..e408eca8 100644 --- a/app/api/governance/announcement/route.ts +++ b/app/api/governance/announcement/route.ts @@ -1,11 +1,13 @@ import { getGovernanceCatalog } from '@/lib/governance-data'; -import { selectAnnouncement } from '@/lib/governance'; +import { announcementCacheHeaders, buildAnnouncementSnapshot } from '@/lib/governance-announcement'; import { getNetwork } from '@/lib/seo'; export async function GET() { - if (getNetwork() !== 'mainnet') return Response.json({ announcement: null }, { headers: { 'X-Robots-Tag': 'noindex' } }); + if (getNetwork() !== 'mainnet') return Response.json( + { announcement: null, generatedAt: Date.now(), nextChangeAt: null }, + { headers: announcementCacheHeaders(60) }, + ); const catalog = await getGovernanceCatalog(); - return Response.json({ announcement: selectAnnouncement(catalog, Date.now()) }, { headers: { - 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex', - } }); + const { snapshot, ttl } = buildAnnouncementSnapshot(catalog, Date.now()); + return Response.json(snapshot, { headers: announcementCacheHeaders(ttl) }); } diff --git a/app/block/[height]/FutureBlockView.tsx b/app/block/[height]/FutureBlockView.tsx index d2edb10e..c5c6219a 100644 --- a/app/block/[height]/FutureBlockView.tsx +++ b/app/block/[height]/FutureBlockView.tsx @@ -48,7 +48,7 @@ export function FutureBlockView({ )}
View Block → @@ -134,7 +134,7 @@ export function FutureBlockView({
Latest block
-
#{currentHeight.toLocaleString('en-US')}
+
#{currentHeight.toLocaleString('en-US')}
{upgrade ? 'Activation block' : 'Upcoming block'}
diff --git a/app/block/[height]/components/BlockPageHeader.tsx b/app/block/[height]/components/BlockPageHeader.tsx index 26c9df76..39915823 100644 --- a/app/block/[height]/components/BlockPageHeader.tsx +++ b/app/block/[height]/components/BlockPageHeader.tsx @@ -16,7 +16,7 @@ function BlockNavPill({ height, hash, direction }: { height: number; hash?: stri const disabled = !hash; return ( )}

+ {initialSummary?.isOrphaned && initialSummary.canonicalReplacementHeight != null && ( +

+ Canonical replacement at this height:{' '} + + Zcash block #{initialSummary.canonicalReplacementHeight.toLocaleString('en-US')} + . +

+ )} {/* Server-seeded facts from the SEO resolution fetch — real content instead of a shimmer while the full block detail loads client-side. */} {initialSummary && (initialSummary.timestamp != null || initialSummary.transactionCount != null || initialSummary.size != null) && ( diff --git a/app/block/[height]/components/types.ts b/app/block/[height]/components/types.ts index 999ee45f..5a18e9a7 100644 --- a/app/block/[height]/components/types.ts +++ b/app/block/[height]/components/types.ts @@ -60,6 +60,8 @@ export interface BlockPageSummary { hash: string; firstSeenAt?: string | null; isOrphaned: boolean; + /** Present only when the API identifies a different canonical block at this height. */ + canonicalReplacementHeight?: number | null; /** * Extra fields already available from the server-side block resolution * (`getBlockResolution` in lib/seo.ts) used for SEO metadata. Threading diff --git a/app/block/[height]/page.tsx b/app/block/[height]/page.tsx index 6c75cffc..56cdbab2 100644 --- a/app/block/[height]/page.tsx +++ b/app/block/[height]/page.tsx @@ -5,6 +5,7 @@ import { getApiUrl, getNetwork, getBlockResolution, + BLOCK_HASH_PATTERN, normalizeBlockIdentifier, type BlockRecord, } from '@/lib/seo'; @@ -92,6 +93,10 @@ export default async function BlockPage({ const height = Number(block.height); const hash = block.hash.toLowerCase(); const isOrphaned = block.isOrphaned === true; + const replacementHash = block.canonicalBlock?.hash; + const canonicalReplacementHeight = isOrphaned && typeof replacementHash === 'string' + && BLOCK_HASH_PATTERN.test(replacementHash) && replacementHash.toLowerCase() !== hash + ? height : null; const rawTransactionCount = block.transactionCount ?? block.transaction_count ?? null; const transactionCount = rawTransactionCount === null || rawTransactionCount === undefined ? null @@ -104,6 +109,7 @@ export default async function BlockPage({ height, hash, isOrphaned, + canonicalReplacementHeight, timestamp: Number.isFinite(timestamp) ? timestamp : null, transactionCount: Number.isFinite(transactionCount) ? transactionCount : null, size: Number.isFinite(size) ? size : null, diff --git a/app/blocks/BlocksClient.tsx b/app/blocks/BlocksClient.tsx index 86be0966..67ff549e 100644 --- a/app/blocks/BlocksClient.tsx +++ b/app/blocks/BlocksClient.tsx @@ -112,7 +112,7 @@ function blockColumns(blocks: Block[], trailingBlock: Block | null, schedule: Bl skeletonWidth: 'w-24', cell: (block) => (
- + {block.height.toLocaleString()} {block.finality_status === 'Finalized' && ( diff --git a/app/finalizer/[pubkey]/page.tsx b/app/finalizer/[pubkey]/page.tsx index 07a72407..4dd04657 100644 --- a/app/finalizer/[pubkey]/page.tsx +++ b/app/finalizer/[pubkey]/page.tsx @@ -384,7 +384,7 @@ export default function FinalizerPage() { - + #{a.block_height.toLocaleString()} @@ -609,6 +609,7 @@ function DelegatorsPanel({ actions }: { actions: StakeAction[] }) { #{d.lastActionHeight.toLocaleString()} diff --git a/app/governance/HowToVote.tsx b/app/governance/HowToVote.tsx index 4020603b..42fc95ea 100644 --- a/app/governance/HowToVote.tsx +++ b/app/governance/HowToVote.tsx @@ -27,7 +27,7 @@ export function HowToVote({ snapshotHeight }: { snapshotHeight?: number }) {

02Check your eligible balance

Follow the wallet’s eligibility check. Each poll uses a snapshot: an earlier point in the blockchain used to determine which funds can vote.

{snapshotHeight !== undefined && ( - + This poll’s snapshot: block {snapshotHeight.toLocaleString('en-US')} → )} diff --git a/app/governance/nu7/NU7VoteClient.tsx b/app/governance/nu7/NU7VoteClient.tsx index bbf09587..2ebcb935 100644 --- a/app/governance/nu7/NU7VoteClient.tsx +++ b/app/governance/nu7/NU7VoteClient.tsx @@ -275,7 +275,7 @@ export function NU7VoteClient({ initialData, resultsState, resultsContent, initi {countdownLabel} {phase === 'pre-snapshot' && ( - + Block #{NU7_VOTE.snapshotHeight.toLocaleString()} )} diff --git a/app/name/[name]/NameClient.tsx b/app/name/[name]/NameClient.tsx index 0b65f5c0..b6f90841 100644 --- a/app/name/[name]/NameClient.tsx +++ b/app/name/[name]/NameClient.tsx @@ -106,7 +106,7 @@ function RegisteredView({
{registration.address} @@ -128,7 +128,7 @@ function RegisteredView({
{registration.height.toLocaleString('en-US')} @@ -191,7 +191,7 @@ function RegisteredView({ {e.height.toLocaleString('en-US')} diff --git a/app/network/NetworkClient.tsx b/app/network/NetworkClient.tsx index 36bf9087..5250be16 100644 --- a/app/network/NetworkClient.tsx +++ b/app/network/NetworkClient.tsx @@ -156,7 +156,7 @@ export default function NetworkClient({ initialData }: { initialData: NetworkPag
{[ - { label: 'Latest block', value: height != null ? {height.toLocaleString()} : '—', hint: stats ? `Block timestamp · ${blockAgeLabel(stats.blockchain.latestBlockTime, now)}` : 'Awaiting chain data' }, + { label: 'Latest block', value: height != null ? {height.toLocaleString()} : '—', hint: stats ? `Block timestamp · ${blockAgeLabel(stats.blockchain.latestBlockTime, now)}` : 'Awaiting chain data' }, { label: 'Average block interval', value: stats?.mining.avgBlockTime != null ? `${stats.mining.avgBlockTime.toFixed(1)}s` : '—', hint: stats?.mining.targetBlockTime != null ? `Observed · protocol target ${stats.mining.targetBlockTime}s` : 'Observed · protocol target unavailable' }, { label: 'Transactions · 24h', value: txCount?.toLocaleString() ?? '—', hint: stats?.blockchain.tx24hExclCoinbase != null ? 'Confirmed · coinbase excluded' : 'Confirmed · includes coinbase' }, { label: 'Estimated hashrate · 24h', value: stats?.mining.hashrateEstimate ? stats.mining.networkHashrate : '—', hint: Trailing 24-hour estimate → }, diff --git a/app/network/page.tsx b/app/network/page.tsx index 9d00e9c4..0c072e88 100644 --- a/app/network/page.tsx +++ b/app/network/page.tsx @@ -70,7 +70,7 @@ export default async function NetworkPage() {

About these observations

- {nu7Height != null &&

The serving node announces NU7 at block #{nu7Height.toLocaleString('en-US')} on Zcash {network}. {nu7Active ? <>The upgrade is active; explore fee allocation and NSM accounting below. : <>View its canonical block or the estimated activation countdown.}

} + {nu7Height != null &&

The serving node announces NU7 at block #{nu7Height.toLocaleString('en-US')} on Zcash {network}. {nu7Active ? <>The upgrade is active; explore fee allocation and NSM accounting below. : <>View its canonical block or the estimated activation countdown.}

}

Chain statistics, recent blocks and node discovery are separate observations and can update at different times. Block cadence uses timestamps recorded in blocks; these are not measurements of when this explorer received them.

The map shows observed reachable nodes, not a census of every Zcash node. Peer connections and disk usage in Technical details describe this explorer’s node. Observed transaction fees describe past transactions and are not fee recommendations.

diff --git a/app/reorgs/page.tsx b/app/reorgs/page.tsx index 2cec2072..e76b8e3a 100644 --- a/app/reorgs/page.tsx +++ b/app/reorgs/page.tsx @@ -100,7 +100,7 @@ const forkColumns: DataTableColumn[] = [ id: 'height', header: 'Height', cell: (fork) => ( - + #{fork.forkHeight.toLocaleString()} ), @@ -381,7 +381,7 @@ export default function UnclesPage() {
Hash {block.hash} @@ -549,7 +549,7 @@ export default function UnclesPage() { onClick={() => setExpandedOrphan(expandedOrphan === block.id ? null : block.id)} > - e.stopPropagation()}> + e.stopPropagation()}> #{block.height.toLocaleString()} @@ -585,7 +585,7 @@ export default function UnclesPage() { — ) ) : block.minerAddress ? ( - e.stopPropagation()}> + e.stopPropagation()}> {block.minerPool} ) : ( diff --git a/app/tools/anchor-search/AnchorSearchClient.tsx b/app/tools/anchor-search/AnchorSearchClient.tsx index d307fd16..06d5f4bc 100644 --- a/app/tools/anchor-search/AnchorSearchClient.tsx +++ b/app/tools/anchor-search/AnchorSearchClient.tsx @@ -129,7 +129,7 @@ export default function AnchorSearchClient() {
{result.canonical.map((block) => (
- + #{block.height.toLocaleString()} diff --git a/app/tools/decode/DecodeClient.tsx b/app/tools/decode/DecodeClient.tsx index 2f120381..271e3d37 100644 --- a/app/tools/decode/DecodeClient.tsx +++ b/app/tools/decode/DecodeClient.tsx @@ -533,7 +533,7 @@ export default function DecodeClient() { Previous TX
- + {vin.txid} @@ -645,7 +645,7 @@ export default function DecodeClient() { To
- + {vout.scriptPubKey.address} diff --git a/app/tx/[txid]/components/FlowChain.tsx b/app/tx/[txid]/components/FlowChain.tsx index 9e20ef7a..bc24fdba 100644 --- a/app/tx/[txid]/components/FlowChain.tsx +++ b/app/tx/[txid]/components/FlowChain.tsx @@ -44,7 +44,7 @@ function FlowAddressNode({ address }: { address: string }) { const labelInfo = useAddressLabel(address); if (!labelInfo) return ; return ( - + ) : input.address ? (
- + {input.address} diff --git a/app/tx/[txid]/components/MempoolPendingView.tsx b/app/tx/[txid]/components/MempoolPendingView.tsx index 4b34c379..d0492c92 100644 --- a/app/tx/[txid]/components/MempoolPendingView.tsx +++ b/app/tx/[txid]/components/MempoolPendingView.tsx @@ -202,7 +202,7 @@ export function MempoolPendingView({ className="flex items-center justify-between py-2 px-3 rounded-lg bg-glass-2" > {out.address} diff --git a/app/tx/[txid]/components/OutputsSection.tsx b/app/tx/[txid]/components/OutputsSection.tsx index 804f52c9..db5234c8 100644 --- a/app/tx/[txid]/components/OutputsSection.tsx +++ b/app/tx/[txid]/components/OutputsSection.tsx @@ -89,7 +89,7 @@ export function OutputsSection({
{outputAddr ? (
- + {outputAddr} diff --git a/app/tx/[txid]/components/TxHeroCard.tsx b/app/tx/[txid]/components/TxHeroCard.tsx index 3eb740cf..136bbe4b 100644 --- a/app/tx/[txid]/components/TxHeroCard.tsx +++ b/app/tx/[txid]/components/TxHeroCard.tsx @@ -76,7 +76,7 @@ export function TxHeroCard({ data, classification, priceUsd }: TxHeroCardProps) {' '} Included in canonical Zcash block{' '} #{data.blockHeight.toLocaleString()} diff --git a/app/tx/[txid]/components/TxOverview.tsx b/app/tx/[txid]/components/TxOverview.tsx index f9126408..6d51ff57 100644 --- a/app/tx/[txid]/components/TxOverview.tsx +++ b/app/tx/[txid]/components/TxOverview.tsx @@ -80,7 +80,7 @@ export function TxOverview({ data }: TxOverviewProps) {
- + #{data.blockHeight.toLocaleString()} diff --git a/app/txs/TxsClient.tsx b/app/txs/TxsClient.tsx index 7eb6e2e2..ea65c097 100644 --- a/app/txs/TxsClient.tsx +++ b/app/txs/TxsClient.tsx @@ -161,7 +161,7 @@ const txColumns: DataTableColumn[] = [ align: 'right', className: 'hidden sm:table-cell', cell: (tx) => ( - + #{tx.block_height.toLocaleString()} ), @@ -228,7 +228,7 @@ const shieldedColumns: DataTableColumn[] = [ align: 'right', className: 'hidden sm:table-cell', cell: (flow) => ( - + #{flow.blockHeight.toLocaleString()} ), diff --git a/components/AddressWithLabel.tsx b/components/AddressWithLabel.tsx index 7d24e8cc..5bcf47fd 100644 --- a/components/AddressWithLabel.tsx +++ b/components/AddressWithLabel.tsx @@ -77,7 +77,7 @@ export function AddressWithLabel({ if (linkable) { return ( {content} @@ -104,7 +104,7 @@ export function AddressDisplay({ return (

The graph summarizes the batch and up to three returned recipient addresses. Grouping does not establish common ownership.

} {expanded &&

{memberCount} transaction IDs supplied for {pattern.batchCount} withdrawals. Times are shown in your local timezone.

-
{pattern.txids.slice(memberPage * 10, (memberPage + 1) * 10).map((txid, index) => { const row = memberPage * 10 + index; return ; })}
TransactionBlockObserved
{pattern.heights[row] ? {pattern.heights[row].toLocaleString()} : '—'}{pattern.times[row] ? formatTime(pattern.times[row]) : 'Unavailable'}
+
{pattern.txids.slice(memberPage * 10, (memberPage + 1) * 10).map((txid, index) => { const row = memberPage * 10 + index; return ; })}
TransactionBlockObserved
{pattern.heights[row] ? {pattern.heights[row].toLocaleString()} : '—'}{pattern.times[row] ? formatTime(pattern.times[row]) : 'Unavailable'}
{memberCount > 10 &&
{memberPage + 1} / {Math.ceil(memberCount / 10)}
}
} ; diff --git a/components/BlockActivityChart.tsx b/components/BlockActivityChart.tsx index 1202d268..4ada0bc8 100644 --- a/components/BlockActivityChart.tsx +++ b/components/BlockActivityChart.tsx @@ -184,7 +184,7 @@ export function BlockActivityChart({
{hoveredBlock ? (
@@ -283,7 +283,7 @@ function BlockBar({ return ( >) { /> diff --git a/components/CrosslinkChainView.tsx b/components/CrosslinkChainView.tsx index 6071e198..dec32499 100644 --- a/components/CrosslinkChainView.tsx +++ b/components/CrosslinkChainView.tsx @@ -293,7 +293,7 @@ function ChainRow({ return ( @@ -607,7 +607,7 @@ function DivergencePanel({ . Started {fmtAgo(openEvent.start_time)} at finalized block{' '} #{openEvent.start_finalized_height.toLocaleString()} @@ -618,7 +618,7 @@ function DivergencePanel({ <> We've diverged at finalized block{' '} #{recurringHeight[0].toLocaleString()} @@ -661,7 +661,7 @@ function DivergencePanel({ {fmtAgo(e.start_time)} #{e.start_finalized_height.toLocaleString()} diff --git a/components/GovernanceBanner.tsx b/components/GovernanceBanner.tsx index ec116f9d..8c8344ee 100644 --- a/components/GovernanceBanner.tsx +++ b/components/GovernanceBanner.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { useEffect, useRef, useState } from 'react'; import { isMainnet, isCrosslink, NETWORK } from '@/lib/config'; import type { Announcement } from '@/lib/governance'; +import type { AnnouncementSnapshot } from '@/lib/governance-announcement'; import { useApiQuery } from '@/hooks/useApiQuery'; import { readUpgradeSnapshot, estimateBlockArrival, formatUpgradeTime } from '@/lib/network-upgrades'; @@ -34,20 +35,46 @@ export function GovernanceBanner() { useEffect(() => { if (!isMainnet || isCrosslink) return; const controller = new AbortController(); + let inFlight = false; + let expiryTimer: ReturnType | undefined; const refresh = async () => { - if (document.visibilityState !== 'visible') return; + if (document.visibilityState !== 'visible' || inFlight) return; + inFlight = true; + const startedAt = performance.now(); try { const response = await fetch('/api/governance/announcement', { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(10000)]) }); if (!response.ok) throw new Error('Unavailable'); - const { announcement: next } = await response.json() as { announcement: Announcement | null }; + const { announcement: next, generatedAt, nextChangeAt } = await response.json() as AnnouncementSnapshot; if (controller.signal.aborted) return; + clearTimeout(expiryTimer); + // A CDN hit must retain the original deadline. Account for its Age and + // transfer time as well as the wall clock, so polling cannot extend a + // closing vote or a results announcement past its known window. + const age = Math.max(0, Number(response.headers.get('Age')) || 0) * 1000; + const remaining = nextChangeAt === null ? null : Math.min( + nextChangeAt - Date.now(), + nextChangeAt - generatedAt - age - (performance.now() - startedAt), + ); + if (remaining !== null && (!Number.isFinite(remaining) || remaining <= 0)) { + setAnnouncement(null); + return; + } setAnnouncement(next); - } catch { if (!controller.signal.aborted) setAnnouncement(null); } + if (remaining !== null) expiryTimer = setTimeout(() => { + setAnnouncement(null); + void refresh(); + }, Math.min(remaining, 2_147_483_647)); + } catch { + if (!controller.signal.aborted) { + clearTimeout(expiryTimer); + setAnnouncement(null); + } + } finally { inFlight = false; } }; void refresh(); const timer = setInterval(() => void refresh(), 60_000); document.addEventListener('visibilitychange', refresh); - return () => { controller.abort(); clearInterval(timer); document.removeEventListener('visibilitychange', refresh); }; + return () => { controller.abort(); clearInterval(timer); clearTimeout(expiryTimer); document.removeEventListener('visibilitychange', refresh); }; }, []); useEffect(() => { const element = ref.current; @@ -60,7 +87,7 @@ export function GovernanceBanner() { if (!visible || !displayed) return null; return
- + {pending ? <>
)}

Retrieved {utc(result.retrievedAt)} UTC. Public transaction flow amounts.{result.groups.some(group => group.rows.some(row => row.amountAuthority === 'legacy-reported-zec')) ? ' Amounts use the source’s reported ZEC values.' : ''}

diff --git a/components/network/NetworkAccounting.tsx b/components/network/NetworkAccounting.tsx index 7fedd623..5d6c2834 100644 --- a/components/network/NetworkAccounting.tsx +++ b/components/network/NetworkAccounting.tsx @@ -73,7 +73,7 @@ export function NetworkAccounting() { const bucketLabel = selected ? selected.bucketSeconds < 3600 ? `${selected.bucketSeconds/60}-minute` : selected.bucketSeconds < 86400 ? `${Number((selected.bucketSeconds/3600).toFixed(1))}-hour` : `${Number((selected.bucketSeconds/86400).toFixed(1))}-day` : ''; return
- Block #{block.height.toLocaleString('en-US')} diff --git a/components/network/RecentBlocksTable.tsx b/components/network/RecentBlocksTable.tsx index 3153a384..f3953c11 100644 --- a/components/network/RecentBlocksTable.tsx +++ b/components/network/RecentBlocksTable.tsx @@ -26,7 +26,7 @@ const columns: DataTableColumn[] = [ header: 'Block', skeletonWidth: 'w-24', cell: (b) => ( - + {b.height.toLocaleString()} ), diff --git a/components/ui/HashLink.tsx b/components/ui/HashLink.tsx index 8c641102..950d1660 100644 --- a/components/ui/HashLink.tsx +++ b/components/ui/HashLink.tsx @@ -15,6 +15,9 @@ function truncate(value: string, lead: number, tail: number): string { * link, and an optional one-click copy button. Use everywhere instead of * hand-rolled `slice(0, 8)}...{slice(-6)` so truncation and styling stay * identical across the app. + * + * Fetch linked detail pages on navigation. Lists contain many unique + * identifiers, so viewport prefetch would render pages visitors never open. */ export function HashLink({ value, @@ -71,6 +74,7 @@ export function HashLink({ const text = href ? ( diff --git a/lib/governance-announcement.ts b/lib/governance-announcement.ts new file mode 100644 index 00000000..268f06cf --- /dev/null +++ b/lib/governance-announcement.ts @@ -0,0 +1,46 @@ +import { GRANTS, catalogIsStale, isGrantsRound, selectAnnouncement, type Announcement, type Catalog } from './governance'; + +const CATALOG_REFRESH_MS = 60_000; +const CATALOG_MAX_AGE_MS = 10 * 60_000; +const RESULTS_WINDOW_MS = 5 * 86_400_000; + +export type AnnouncementSnapshot = { + announcement: Announcement | null; + generatedAt: number; + nextChangeAt: number | null; +}; + +export function buildAnnouncementSnapshot(catalog: Catalog, now: number) { + // Include every clock-driven selection boundary, including a currently empty + // banner becoming eligible. Only a new catalog may announce a vote opening. + const boundaries = [catalog.checkedAt + CATALOG_MAX_AGE_MS + 1]; + if (!catalog.votes.some(vote => isGrantsRound(vote.round))) { + const opening = Date.parse(`${GRANTS.opensOn}T00:00:00Z`); + boundaries.push(opening - 7 * 86_400_000, opening); + } + for (const vote of catalog.votes) { + if (vote.state === 'active' || vote.state === 'results') boundaries.push(vote.round.vote_end_time * 1000); + if (vote.state === 'results') boundaries.push(vote.round.vote_end_time * 1000 + RESULTS_WINDOW_MS); + } + const futureBoundaries = boundaries.filter(time => Number.isFinite(time) && time > now); + const nextChangeAt = futureBoundaries.length ? Math.min(...futureBoundaries) : null; + // Do not stack a fresh CDN minute on top of the catalog's existing minute. + // A stale catalog can trigger its own refresh but cannot seed another CDN TTL. + const ttl = catalogIsStale(catalog, now) ? 0 : Math.max(0, Math.floor(Math.min( + CATALOG_REFRESH_MS, + catalog.checkedAt + CATALOG_REFRESH_MS - now, + nextChangeAt === null ? CATALOG_REFRESH_MS : nextChangeAt - now, + ) / 1000)); + const snapshot: AnnouncementSnapshot = { announcement: selectAnnouncement(catalog, now), generatedAt: now, nextChangeAt }; + return { snapshot, ttl }; +} + +export function announcementCacheHeaders(ttl: number) { + const cdn = ttl > 0 ? `public, s-maxage=${ttl}, must-revalidate` : 'no-store'; + return { + 'Cache-Control': ttl > 0 ? 'public, max-age=0, must-revalidate' : 'no-store', + 'CDN-Cache-Control': cdn, + 'Vercel-CDN-Cache-Control': cdn, + 'X-Robots-Tag': 'noindex, follow', + }; +} diff --git a/package.json b/package.json index f7d4cfdc..356b9bfe 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "test:route-cache-build": "node --test server/tests/next-route-cache-build.test.js", "test:inbox-scan": "node --test server/tests/inbox-scan.test.js server/tests/scan-worker-pool.test.js server/api/test/scan-pipeline.test.js server/api/test/scan-cache.test.js server/api/test/scan-stream.test.js && cargo test --manifest-path wasm/Cargo.toml --lib", "test:live-refresh": "node --test server/tests/live-refresh.test.js server/tests/relative-time.test.js server/tests/api-query-freshness.test.js server/tests/network-snapshot.test.js server/tests/network-accounting-visibility.test.js server/tests/privacy-trend-dates.test.js", - "test:frontend": "node --test server/tests/crosslink-observations-ui.test.js server/tests/valuation-context.test.js server/tests/address-pagination-client.test.js server/tests/newsletter-security.test.js server/tests/name-rendering.test.js server/tests/http-status-boundaries.test.js server/tests/chart-sharing.test.js server/tests/redesign-data-boundaries.test.js server/tests/chart-catalog.test.js server/tests/block-timing.test.js server/tests/coinbase-message.test.js server/tests/network-upgrades.test.js server/tests/browser-decoder.test.js server/tests/hashrate-display.test.js server/tests/reorg-display.test.js && npm run test:live-refresh && npm run test:sitemap-security && npm run test:performance && npm run test:ttfb-probe && npm run test:isr-warmup && npm run test:revalidate-security && NODE_PATH=$PWD/server/api/node_modules node --test server/tests/address-labels-resilience.test.js && npm run test:brand && npm run test:v1-frontend && node --test server/tests/address-connections.test.js && npm run test:attestations && npm run test:nu7-results && npm run test:governance && node --test server/tests/network-issuance-ui.test.js && node --test server/tests/launch-copy-privacy.test.js", + "test:frontend": "node --test server/tests/detail-link-prefetch.test.js server/tests/orphan-ssr-replacement.test.js server/tests/crosslink-observations-ui.test.js server/tests/valuation-context.test.js server/tests/address-pagination-client.test.js server/tests/newsletter-security.test.js server/tests/name-rendering.test.js server/tests/http-status-boundaries.test.js server/tests/chart-sharing.test.js server/tests/redesign-data-boundaries.test.js server/tests/chart-catalog.test.js server/tests/block-timing.test.js server/tests/coinbase-message.test.js server/tests/network-upgrades.test.js server/tests/browser-decoder.test.js server/tests/hashrate-display.test.js server/tests/reorg-display.test.js && npm run test:live-refresh && npm run test:sitemap-security && npm run test:performance && npm run test:ttfb-probe && npm run test:isr-warmup && npm run test:revalidate-security && NODE_PATH=$PWD/server/api/node_modules node --test server/tests/address-labels-resilience.test.js && npm run test:brand && npm run test:v1-frontend && node --test server/tests/address-connections.test.js && npm run test:attestations && npm run test:nu7-results && npm run test:governance && node --test server/tests/network-issuance-ui.test.js && node --test server/tests/launch-copy-privacy.test.js", "test:server-regressions": "NODE_PATH=$PWD/server/api/node_modules node --test server/tests/crosslink-roster.test.js server/tests/hashrate.test.js server/tests/reorg-api.test.js server/tests/orphan-rates.test.js server/tests/blocks-orphan-batch.test.js server/tests/tx-detail-parallel.test.js server/tests/tx-mempool-cache.test.js server/tests/migration-activity-summary.test.js server/tests/blend-check-batch.test.js server/tests/db-pool-deadlines.test.js server/tests/safe-log.test.js server/tests/ironwood-accounting.test.js server/tests/exposure-consumers.test.js server/tests/network-halving.test.js server/tests/network-issuance.test.js server/tests/supply-history-backfill.test.js server/tests/braces-security.test.js server/tests/network-schedule.test.js server/tests/network-block-time.test.js server/tests/mining-metrics.test.js server/tests/network-issuance-curve.test.js server/tests/network-fee-history.test.js server/tests/network-readiness.test.js server/tests/network-accounting-period.test.js server/tests/rpc-cookie-rotation.test.js server/tests/peer-client.test.js server/tests/node-census.test.js server/tests/node-census-postgres.test.js server/tests/privacy-score.test.js server/tests/privacy-stats-snapshot.test.js server/tests/utxo-age.test.js server/tests/signal-telegram.test.js server/tests/analytics-history.test.js server/tests/mining-attribution-repair.test.js server/tests/analytics-history-routes.test.js server/tests/crosschain-v2.test.js server/tests/crosschain-date-pages.test.js server/tests/crosschain-maintenance-budget.test.js server/tests/near-provider-budget.test.js server/tests/address-pagination.test.js server/tests/address-cursor-postgres.test.js && npm run test:error-message-leakage", "verify:fast": "npm run lint && tsc --noEmit", "verify:frontend": "npm run verify:fast && npm run test:frontend && CIPHERSCAN_ALLOW_BUILD_UPSTREAM_FALLBACK=1 npm run build && npm run test:route-cache-build", @@ -56,7 +56,7 @@ "verify:homepage:live": "node scripts/verify-homepage-live-api.mjs", "verify:homepage": "node scripts/verify-homepage-first-render.mjs", "test:nu7-results": "node --test server/tests/nu7-results.test.js", - "test:governance": "node --test server/tests/governance.test.js", + "test:governance": "node --test server/tests/governance.test.js server/tests/governance-announcement-cache.test.js", "test:activity": "NODE_PATH=$PWD/server/api/node_modules node --test server/tests/activity-milestones.test.js server/tests/activity-postgres.test.js", "test:hashrate-postgres": "NODE_PATH=$PWD/server/api/node_modules node --test server/tests/hashrate-postgres.test.js", "test:analytics-history": "NODE_PATH=$PWD/server/api/node_modules node --test server/tests/analytics-history.test.js server/tests/analytics-history-routes.test.js", diff --git a/proxy.ts b/proxy.ts index 0de3962a..8b5763ba 100644 --- a/proxy.ts +++ b/proxy.ts @@ -117,6 +117,23 @@ export async function proxy(request: NextRequest) { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, follow' }, }); } + // One cache identity per identifier. These aliases already share canonical + // metadata, but otherwise cause separate on-demand page generations. Keep + // block-hash lookups (including persistent orphans) distinct from heights. + const detailPath = request.nextUrl.pathname.match(/^\/(tx|block)\/([^/]+)$/); + if (detailPath) { + const [, kind, identifier] = detailPath; + const isHash = /^[a-fA-F0-9]{64}$/.test(identifier); + const isHeight = kind === 'block' && /^\d+$/.test(identifier) + && Number(identifier) <= 100_000_000; + const normalized = isHash ? identifier.toLowerCase() + : isHeight ? String(Number(identifier)) : identifier; + if (normalized !== identifier) { + const url = request.nextUrl.clone(); + url.pathname = `/${kind}/${normalized}`; + return NextResponse.redirect(url, 308); + } + } // Keep filtered archives dynamic and the unfiltered list on its ISR route. // Vercel permits at most 16 has/missing conditions per static routing rule. if (request.nextUrl.pathname === '/blocks') { @@ -190,5 +207,6 @@ export async function proxy(request: NextRequest) { } export const config = { - matcher: ['/name/:path*', '/api/:path*', '/block/:path*', '/blocks', '/governance/:path*', '/charts/:slug'], + // Canonical lowercase tx URLs bypass Proxy; only uppercase aliases need it. + matcher: ['/name/:path*', '/api/:path*', '/tx/:txid((?=[a-fA-F0-9]{0,63}[A-F])[a-fA-F0-9]{64})', '/block/:path*', '/blocks', '/governance/:path*', '/charts/:slug'], }; diff --git a/server/tests/detail-link-prefetch.test.js b/server/tests/detail-link-prefetch.test.js new file mode 100644 index 00000000..5687ba61 --- /dev/null +++ b/server/tests/detail-link-prefetch.test.js @@ -0,0 +1,103 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); +const React = require('react'); +const { renderToStaticMarkup } = require('react-dom/server'); + +const root = path.resolve(__dirname, '../..'); + +function load(relativePath, dependencies) { + const exports = {}; + const source = fs.readFileSync(path.join(root, relativePath), 'utf8'); + vm.runInNewContext(ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true, + }, + }).outputText, { + exports, + require(name) { + if (name in dependencies) return dependencies[name]; + if (['react/jsx-runtime', 'next/link'].includes(name)) return require(name); + throw new Error(`Unexpected dependency ${name}`); + }, + }); + return exports; +} + +function filesUnder(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const file = path.join(directory, entry.name); + return entry.isDirectory() ? filesUnder(file) : file.endsWith('.tsx') ? [file] : []; + }); +} + +test('detail links retain normal navigation without speculative viewport requests', () => { + let checked = 0; + const violations = []; + for (const file of ['app', 'components'].flatMap(directory => filesUnder(path.join(root, directory)))) { + const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + function visit(node) { + if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && node.tagName.getText(source) === 'Link') { + const attributes = node.attributes.properties; + const href = attributes.find(attribute => ts.isJsxAttribute(attribute) && attribute.name.getText(source) === 'href'); + // Cover literal URLs, templates and conditional canonical/orphan targets. + if (href && /[`"']\/(block|tx|address)\//.test(href.getText(source))) { + checked++; + const prefetch = attributes.find(attribute => ts.isJsxAttribute(attribute) && attribute.name.getText(source) === 'prefetch'); + const disabled = prefetch?.initializer && ts.isJsxExpression(prefetch.initializer) + && prefetch.initializer.expression?.kind === ts.SyntaxKind.FalseKeyword; + if (!disabled) { + const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; + violations.push(`${path.relative(root, file)}:${line}`); + } + } + } + ts.forEachChild(node, visit); + } + visit(source); + } + assert.ok(checked > 50, 'the audit must cover actual explorer detail links'); + assert.deepEqual(violations, [], 'detail pages should be requested on navigation, not just because a list is visible'); +}); + +test('shared hash links server-render crawlable hrefs and full identifiers', () => { + const { HashLink } = load('components/ui/HashLink.tsx', { + '@/components/CopyButton': { CopyButton: () => null }, + }); + for (const [prefix, identifier] of [['tx', 'a'.repeat(64)], ['block', '3500000'], ['address', 't1full-public-address']]) { + const href = `/${prefix}/${identifier}`; + const element = HashLink({ value: identifier, href, copy: false }); + const link = element.props.children[0]; + assert.equal(link.props.prefetch, false, 'shared tables and CopyableHash use navigation-only data links'); + const html = renderToStaticMarkup(element); + assert.ok(html.includes(`href="${href}"`), html); + assert.ok(html.includes(`title="${identifier}"`), 'the complete identifier remains in server HTML'); + assert.ok(!html.includes('nofollow'), 'crawler discovery remains available'); + } + const plain = renderToStaticMarkup(React.createElement(HashLink, { value: 'b'.repeat(64), copy: false })); + assert.ok(plain.includes(' { + const { AddressWithLabel, AddressDisplay } = load('components/AddressWithLabel.tsx', { + '@/lib/address-labels': { useAddressLabel: () => ({ label: 'Public miner', description: 'Public attribution', isOfficial: true }) }, + }); + const address = 't1full-public-miner-address'; + for (const Component of [AddressWithLabel, AddressDisplay]) { + const element = Component({ address }); + assert.equal(element.props.href, `/address/${address}`); + assert.equal(element.props.prefetch, false); + const html = renderToStaticMarkup(element); + assert.ok(html.includes(`href="/address/${address}"`)); + assert.ok(html.includes('Public miner')); + } + const plain = renderToStaticMarkup(React.createElement(AddressWithLabel, { address, linkable: false })); + assert.ok(!plain.includes(' dependencies[name] ?? require(name), ...globals }); + return exports; +} +const nu7 = load('lib/nu7-vote-results.ts'); +const model = load('lib/governance.ts', { './nu7-vote-results': nu7 }); +const helpers = load('lib/governance-announcement.ts', { './governance': model }); +const now = Date.parse('2026-10-16T00:00:00Z'); +const Clock = class extends Date { static now() { return now; } }; +function vote(state, end = now + 3600_000, id = 'reviewed-vote') { + return { id, state, title: 'Reviewed vote', href: `/governance/${id}`, round: { vote_end_time: end / 1000, discussion_url: '' } }; +} +function catalog(votes = [], checkedAt = now) { return { votes, checkedAt, unavailable: false }; } +function route(network, data) { + let calls = 0; + return { + handler: load('app/api/governance/announcement/route.ts', { + '@/lib/governance-data': { getGovernanceCatalog: async () => { calls++; return data; } }, + '@/lib/governance-announcement': helpers, + '@/lib/seo': { getNetwork: () => network }, + }, { Date: Clock }).GET, + calls: () => calls, + }; +} + +test('mainnet shares fresh announcements at the CDN while browsers revalidate and APIs stay noindex', async () => { + const api = route('mainnet', catalog([vote('active')])); + const response = await api.handler(); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(api.calls(), 1); + assert.equal(body.announcement.key, 'reviewed-vote:active'); + assert.equal(body.generatedAt, now); + assert.equal(response.headers.get('Cache-Control'), 'public, max-age=0, must-revalidate'); + for (const header of ['CDN-Cache-Control', 'Vercel-CDN-Cache-Control']) { + assert.equal(response.headers.get(header), 'public, s-maxage=60, must-revalidate'); + assert.doesNotMatch(response.headers.get(header), /stale-while-revalidate|stale-if-error/); + } + assert.equal(response.headers.get('X-Robots-Tag'), 'noindex, follow'); +}); + +test('testnet and Crosslink never fetch or expose mainnet governance', async () => { + for (const network of ['testnet', 'crosslink-testnet']) { + const api = route(network, catalog([vote('active')])); + const response = await api.handler(); + assert.equal((await response.json()).announcement, null); + assert.equal(api.calls(), 0); + assert.equal(response.headers.get('X-Robots-Tag'), 'noindex, follow'); + } +}); + +test('empty successful catalogs share null, but unavailable and stale catalogs never enter the CDN', async () => { + const empty = await route('mainnet', catalog()).handler(); + assert.equal((await empty.json()).announcement, null); + assert.match(empty.headers.get('Vercel-CDN-Cache-Control'), /s-maxage=60/); + for (const data of [{ votes: [], checkedAt: 0, unavailable: true }, catalog([vote('active')], now - 600_001)]) { + const response = await route('mainnet', data).handler(); + assert.equal((await response.json()).announcement, null); + for (const header of ['Cache-Control', 'CDN-Cache-Control', 'Vercel-CDN-Cache-Control']) assert.equal(response.headers.get(header), 'no-store'); + } +}); + +test('CDN freshness uses the remaining catalog minute rather than adding another minute', () => { + assert.equal(helpers.buildAnnouncementSnapshot(catalog([], now - 45_250), now).ttl, 14); + for (const age of [60_000, 60_001, 300_000]) { + assert.equal(helpers.buildAnnouncementSnapshot(catalog([vote('active')], now - age), now).ttl, 0); + } +}); + +test('active voting closes at its deadline and CDN caching rounds down before the boundary', () => { + const closing = now + 10_250; + const data = catalog([vote('active', closing)]); + const before = helpers.buildAnnouncementSnapshot(data, now); + assert.equal(before.ttl, 10); + assert.equal(before.snapshot.nextChangeAt, closing); + assert.equal(before.snapshot.announcement.key, 'reviewed-vote:active'); + assert.equal(helpers.buildAnnouncementSnapshot(data, closing - 250).ttl, 0); + assert.equal(helpers.buildAnnouncementSnapshot(data, closing).snapshot.announcement, null); +}); + +test('results and upcoming date-only notices transition without fabricating an opening or result', () => { + const expiry = now + 10_000; + const results = catalog([vote('results', expiry - 5 * 86_400_000)]); + assert.equal(helpers.buildAnnouncementSnapshot(results, now).snapshot.nextChangeAt, expiry); + assert.equal(helpers.buildAnnouncementSnapshot(results, expiry).snapshot.announcement, null); + const opening = Date.parse(`${model.GRANTS.opensOn}T00:00:00Z`); + const start = opening - 7 * 86_400_000; + const before = helpers.buildAnnouncementSnapshot(catalog([], start - 10_000), start - 10_000); + assert.equal(before.snapshot.announcement, null); + assert.equal(before.snapshot.nextChangeAt, start); + assert.equal(before.ttl, 10); + const during = helpers.buildAnnouncementSnapshot(catalog([], opening - 10_000), opening - 10_000); + assert.equal(during.snapshot.announcement.key, `${model.GRANTS.slug}:upcoming`); + assert.equal(during.snapshot.nextChangeAt, opening); + assert.equal(helpers.buildAnnouncementSnapshot(catalog([], opening), opening).snapshot.announcement, null); +}); + +function bannerHarness(responses, initialNow = now) { + const effects = []; + const updates = []; + const timeouts = new Map(); + const intervals = new Map(); + const listeners = new Map(); + let clock = initialNow; + let nextTimer = 1; + let calls = 0; + let state = 0; + const FakeDate = class extends Date { static now() { return clock; } }; + const component = load('components/GovernanceBanner.tsx', { + react: { + useState: value => { const index = state++; return [value, next => { if (index === 0) updates.push(next); }]; }, + useRef: value => ({ current: value }), useEffect: effect => effects.push(effect), + }, + 'next/link': {}, '@/lib/config': { isMainnet: true, isCrosslink: false, NETWORK: 'mainnet' }, + '@/hooks/useApiQuery': { useApiQuery: () => ({ data: null, error: null }) }, + '@/lib/network-upgrades': { readUpgradeSnapshot: () => null }, + }, { + Date: FakeDate, performance: { now: () => clock }, AbortController, + AbortSignal: { any: signals => signals[0], timeout: () => new AbortController().signal }, + document: { visibilityState: 'visible', addEventListener: (key, fn) => listeners.set(key, fn), removeEventListener: key => listeners.delete(key) }, + setTimeout: (fn, delay) => { const id = nextTimer++; timeouts.set(id, { fn, at: clock + delay }); return id; }, + clearTimeout: id => timeouts.delete(id), + setInterval: (fn, delay) => { const id = nextTimer++; intervals.set(id, { fn, delay }); return id; }, + clearInterval: id => intervals.delete(id), + fetch: async () => { + const response = responses[Math.min(calls++, responses.length - 1)]; + if (response instanceof Error) throw response; + return response; + }, + }); + component.GovernanceBanner(); + const cleanup = effects[1](); + return { updates, timeouts, intervals, cleanup, calls: () => calls, advance: value => { clock += value; }, + tick: async () => { await new Promise(resolve => setImmediate(resolve)); } }; +} +function response(snapshot, age = '0') { + return { ok: true, headers: new Headers({ Age: age }), json: async () => snapshot }; +} + +test('a cached banner clears at the known boundary, accounts for CDN Age, and keeps its minute poll', async () => { + const active = { key: 'vote:active', href: '/governance/vote', text: 'Voting is open' }; + const initial = { announcement: active, generatedAt: now - 50_000, nextChangeAt: now + 10_000 }; + const ended = { announcement: null, generatedAt: now + 10_000, nextChangeAt: null }; + const banner = bannerHarness([response(initial, '50'), response(ended)]); + await banner.tick(); + assert.equal(banner.updates.at(-1).key, active.key); + assert.equal([...banner.intervals.values()][0].delay, 60_000); + const expiry = [...banner.timeouts.values()][0]; + assert.equal(expiry.at, now + 10_000); + banner.advance(10_000); + expiry.fn(); + assert.equal(banner.updates.at(-1), null); + await banner.tick(); + assert.equal(banner.calls(), 2); + assert.equal(banner.updates.at(-1), null); + banner.cleanup(); + assert.equal(banner.timeouts.size, 0); + assert.equal(banner.intervals.size, 0); +}); + +test('expired CDN payloads and failed refreshes cannot retain actionable announcement text', async () => { + const active = { key: 'vote:active', href: '/governance/vote', text: 'Voting is open' }; + // CDN age protects the deadline even when the browser wall clock is behind. + const expired = bannerHarness([response({ announcement: active, generatedAt: now - 60_000, nextChangeAt: now - 1 }, '61')], now - 30_000); + await expired.tick(); + assert.equal(expired.updates.at(-1), null); + assert.equal(expired.timeouts.size, 0); + expired.cleanup(); + const banner = bannerHarness([response({ announcement: active, generatedAt: now, nextChangeAt: now + 120_000 }), new Error('offline')]); + await banner.tick(); + assert.equal(banner.updates.at(-1).key, active.key); + [...banner.intervals.values()][0].fn(); + await banner.tick(); + assert.equal(banner.updates.at(-1), null); + assert.equal(banner.timeouts.size, 0); + banner.cleanup(); +}); + +test('a stale catalog with a distant deadline cannot overflow the browser timer into a refresh loop', async () => { + const stale = helpers.buildAnnouncementSnapshot(catalog([vote('active', now + 30 * 86_400_000)], now - 600_001), now); + assert.equal(stale.snapshot.announcement, null); + assert.equal(stale.ttl, 0); + const banner = bannerHarness([response(stale.snapshot)]); + await banner.tick(); + assert.equal(banner.calls(), 1); + assert.equal(banner.updates.at(-1), null); + const timer = [...banner.timeouts.values()][0]; + assert.equal(timer.at - now, 2_147_483_647, 'setTimeout must stay within the browser signed 32-bit limit'); + assert.equal([...banner.intervals.values()][0].delay, 60_000); + banner.cleanup(); +}); diff --git a/server/tests/next-route-cache-build.test.js b/server/tests/next-route-cache-build.test.js index 393c2609..dcf7c581 100644 --- a/server/tests/next-route-cache-build.test.js +++ b/server/tests/next-route-cache-build.test.js @@ -62,3 +62,11 @@ test('detail routes retain on-demand static fallbacks for runtime ISR', () => { test('network snapshot retains five-minute ISR independently of live browser polling', () => { assert.equal(manifest.routes?.['/network']?.initialRevalidateSeconds, 300); }); + +test('governance announcements compute clock boundaries at request time before entering the CDN', () => { + assert.equal( + manifest.routes?.['/api/governance/announcement'], + undefined, + 'announcement selection must not become a static ISR response that outlives a known vote boundary', + ); +}); diff --git a/server/tests/orphan-ssr-replacement.test.js b/server/tests/orphan-ssr-replacement.test.js new file mode 100644 index 00000000..56582ac6 --- /dev/null +++ b/server/tests/orphan-ssr-replacement.test.js @@ -0,0 +1,55 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const ts = require('typescript'); +const React = require('react'); +const { renderToStaticMarkup } = require('react-dom/server'); + +function load(file, dependencies = {}) { + const exports = {}; + vm.runInNewContext(ts.transpileModule(fs.readFileSync(file, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX }, + }).outputText, { exports, URL, require: name => dependencies[name] ?? require(name) }); + return exports; +} +const { BlockPageSkeleton } = load('app/block/[height]/components/BlockPageSkeleton.tsx', { + 'next/link': { default: ({ children, prefetch, ...props }) => React.createElement('a', { ...props, 'data-prefetch': String(prefetch) }, children) }, + '@/components/ui/EmptyState': { SkeletonTable: () => null }, + '@/components/ui/Skeleton': { Skeleton: () => null }, + '@/components/ui/Card': Object.fromEntries(['Card', 'CardHeader', 'CardBody'].map(name => [name, ({ children }) => React.createElement('div', null, children)])), + '@/components/ui/Badge': { Badge: ({ children }) => React.createElement('span', null, children) }, + '@/lib/utils': { formatRelativeTime: () => '2 minutes ago' }, + '@/lib/format-numbers': { formatBytesCompact: value => `${value} B` }, +}); + +async function render(block) { + const page = load('app/block/[height]/page.tsx', { + '@/lib/api-client': {}, 'next/navigation': {}, + '@/lib/seo': { getBlockResolution: async () => ({ state: 'found', block }), getBaseUrl: () => 'https://zecblock.com', + normalizeBlockIdentifier: value => value, BLOCK_HASH_PATTERN: /^[a-fA-F0-9]{64}$/ }, + '@/lib/server-fetch': {}, '@/lib/isr-fallback': {}, '@/lib/network-upgrades-server': {}, '@/lib/network-upgrades': {}, './FutureBlockView': {}, + './BlockPageClient': { default: props => React.createElement(BlockPageSkeleton, props) }, + }); + return renderToStaticMarkup(await page.default({ params: Promise.resolve({ height: block.hash }) })); +} +const orphan = { height: 100, hash: 'b'.repeat(64), isOrphaned: true, timestamp: 1792140000, transaction_count: 2, size: 1024 }; + +test('orphan server HTML includes the verified canonical replacement link and retains the orphan identifier', async () => { + const html = await render({ ...orphan, canonicalBlock: { hash: 'a'.repeat(64) } }); + assert.equal((html.match(/)/g) || []).length, 1); + assert.match(html, /Orphaned Zcash Block/); + assert.ok(html.includes(orphan.hash)); + assert.match(html, /Canonical replacement at this height/); + assert.match(html, /href="\/block\/100"[^>]*data-prefetch="false"/); + assert.match(html, /Zcash block #100/); +}); + +test('server HTML never invents replacement discovery from an absent, malformed or identical hash', async () => { + for (const canonicalBlock of [null, {}, { hash: null }, { hash: '' }, { hash: 'bad' }, { hash: orphan.hash }, { hash: orphan.hash.toUpperCase() }]) { + const html = await render({ ...orphan, canonicalBlock }); + assert.doesNotMatch(html, /Canonical replacement at this height|href="\/block\/100"/); + } + const canonical = await render({ ...orphan, isOrphaned: false, canonicalBlock: { hash: 'a'.repeat(64) } }); + assert.doesNotMatch(canonical, /Canonical replacement at this height|href="\/block\/100"/); +}); diff --git a/server/tests/reorg-display.test.js b/server/tests/reorg-display.test.js index b1157714..b0d1f0fc 100644 --- a/server/tests/reorg-display.test.js +++ b/server/tests/reorg-display.test.js @@ -108,3 +108,48 @@ test('block list routing preserves every archive filter and only rewrites unfilt } } }); + +test('identifier aliases redirect before SSR without changing network, query or orphan identity', async () => { + const { NextRequest, NextResponse } = require('next/server'); + const { unstable_doesMiddlewareMatch } = require('next/experimental/testing/server'); + const exports = {}; + vm.runInNewContext(ts.transpileModule(fs.readFileSync('proxy.ts', 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText, { exports, URL, process: { env: { NODE_ENV: 'development' } }, setInterval: () => {}, + require: name => ({ + 'next/server': { NextResponse }, + './lib/governance-request': {}, + './lib/network': {}, + })[name], + }); + for (const host of ['zecblock.com', 'testnet.zecblock.com', 'crosslink.zecblock.com']) { + for (const [alias, canonical] of [ + [`/tx/${'Ab'.repeat(32)}`, `/tx/${'ab'.repeat(32)}`], + [`/block/${'Cd'.repeat(32)}`, `/block/${'cd'.repeat(32)}`], + ['/block/0003492072', '/block/3492072'], + ['/block/000', '/block/0'], + ]) { + const url = `https://${host}${alias}?utm_source=reference`; + assert.equal(unstable_doesMiddlewareMatch({ config: exports.config, nextConfig: {}, url }), true); + const response = await exports.proxy(new NextRequest(url)); + assert.equal(response.status, 308); + assert.equal(response.headers.get('location'), `https://${host}${canonical}?utm_source=reference`); + } + const canonicalTx = `https://${host}/tx/${'ab'.repeat(32)}`; + assert.equal(unstable_doesMiddlewareMatch({ config: exports.config, nextConfig: {}, url: canonicalTx }), false, + 'ordinary transaction views must not gain a paid Proxy invocation'); + for (const identifier of ['A'.repeat(63), 'A'.repeat(65), `${'A'.repeat(64)}g`, `${'A'.repeat(8192)}g`]) { + assert.equal(unstable_doesMiddlewareMatch({ config: exports.config, nextConfig: {}, url: `https://${host}/tx/${identifier}` }), false, + 'invalid lengths and long invalid input must not invoke the alias normalizer'); + } + for (const identifier of ['3492072', 'cd'.repeat(32), '0'.repeat(64)]) { + const response = await exports.proxy(new NextRequest(`https://${host}/block/${identifier}`)); + assert.equal(response.headers.get('location'), null, 'a canonical hash, including an orphan, stays a hash lookup'); + } + for (const identifier of ['-01', '100000001', '000100000001', 'invalid']) { + const response = await exports.proxy(new NextRequest(`https://${host}/block/${identifier}`)); + assert.equal(response.status, 404, 'normalization must not rescue an invalid identifier'); + assert.equal(response.headers.get('location'), null); + } + } +}); From 25039d358fab817eeb0e447c4c9e0cabf5230042 Mon Sep 17 00:00:00 2001 From: Antoine Serval Date: Fri, 16 Oct 2026 20:02:14 +0900 Subject: [PATCH 2/2] Keep visitor quota headers out of shared announcement responses --- proxy.ts | 8 ++++-- .../governance-announcement-cache.test.js | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/proxy.ts b/proxy.ts index 8b5763ba..98541bae 100644 --- a/proxy.ts +++ b/proxy.ts @@ -25,7 +25,7 @@ setInterval(() => { } }, 5 * 60 * 1000); -function handleApiRateLimit(request: NextRequest): NextResponse { +function handleApiRateLimit(request: NextRequest, includeQuotaHeaders = true): NextResponse { const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || @@ -63,6 +63,10 @@ function handleApiRateLimit(request: NextRequest): NextResponse { } const response = NextResponse.next(); + // Shared CDN responses must not replay the first visitor's per-IP quota. + // Keep enforcement above; a rejected request still receives its own quota + // and Retry-After headers from the uncached 429 response. + if (!includeQuotaHeaders) return response; const remaining = Math.max(0, RATE_LIMIT.maxRequests - userData.count); response.headers.set('X-RateLimit-Limit', RATE_LIMIT.maxRequests.toString()); @@ -197,7 +201,7 @@ export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; if (pathname.startsWith('/api/')) { - return handleApiRateLimit(request); + return handleApiRateLimit(request, pathname !== '/api/governance/announcement'); } // Block pages take their CDN lifetime from ISR (getBlockResolution): one diff --git a/server/tests/governance-announcement-cache.test.js b/server/tests/governance-announcement-cache.test.js index d4eabdc3..a033a118 100644 --- a/server/tests/governance-announcement-cache.test.js +++ b/server/tests/governance-announcement-cache.test.js @@ -198,3 +198,30 @@ test('a stale catalog with a distant deadline cannot overflow the browser timer assert.equal([...banner.intervals.values()][0].delay, 60_000); banner.cleanup(); }); + +test('shared announcement responses omit per-IP quota headers while the same limit remains enforced', async () => { + const { NextRequest, NextResponse } = require('next/server'); + const { proxy } = load('proxy.ts', { + 'next/server': { NextResponse }, + './lib/governance-request': {}, './lib/network': {}, './lib/chart-catalog': {}, './lib/name-validation': {}, + }, { process: { env: { NODE_ENV: 'production' } }, setInterval: () => {} }); + const request = path => new NextRequest(`https://zecblock.com${path}`, { + headers: { host: 'zecblock.com', 'x-forwarded-for': '192.0.2.123' }, + }); + for (let i = 0; i < 300; i++) { + const response = await proxy(request('/api/governance/announcement')); + assert.equal(response.headers.get('x-middleware-next'), '1'); + for (const header of ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset']) { + assert.equal(response.headers.get(header), null, 'shared responses must not cache a visitor-specific quota'); + } + } + const limited = await proxy(request('/api/governance/announcement')); + assert.equal(limited.status, 429); + assert.equal(limited.headers.get('X-RateLimit-Remaining'), '0'); + assert.ok(Number(limited.headers.get('Retry-After')) > 0); + const other = await proxy(new NextRequest('https://zecblock.com/api/revalidate', { + headers: { host: 'zecblock.com', 'x-forwarded-for': '192.0.2.124' }, + })); + assert.equal(other.headers.get('X-RateLimit-Limit'), '300', 'other APIs retain their existing quota contract'); + assert.equal(other.headers.get('X-RateLimit-Remaining'), '299'); +});