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 && (
+
+ )}
{/* 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 }) {
02 Check 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.
-
Transaction Block Observed {pattern.txids.slice(memberPage * 10, (memberPage + 1) * 10).map((txid, index) => { const row = memberPage * 10 + index; return {pattern.heights[row] ? {pattern.heights[row].toLocaleString()} : '—'} {pattern.times[row] ? formatTime(pattern.times[row]) : 'Unavailable'} ; })}
+
Transaction Block Observed {pattern.txids.slice(memberPage * 10, (memberPage + 1) * 10).map((txid, index) => { const row = memberPage * 10 + index; return {pattern.heights[row] ? {pattern.heights[row].toLocaleString()} : '—'} {pattern.times[row] ? formatTime(pattern.times[row]) : 'Unavailable'} ; })}
{memberCount > 10 &&
setMemberPage(page => page - 1)}>Previous members {memberPage + 1} / {Math.ceil(memberCount / 10)} = memberCount} onClick={() => setMemberPage(page => page + 1)}>Next members
}
}
;
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 ? <>
diff --git a/components/RecentBlocks.tsx b/components/RecentBlocks.tsx
index 8bf001af..67e7c837 100644
--- a/components/RecentBlocks.tsx
+++ b/components/RecentBlocks.tsx
@@ -36,7 +36,7 @@ export const RecentBlocks = memo(function RecentBlocks({ footer }: { footer?: Re
style={{ animationDelay: `${i * 30}ms` }}
>
-
+
#{block.height.toLocaleString()}
diff --git a/components/RecentReorgs.tsx b/components/RecentReorgs.tsx
index 5464180a..22bb5aca 100644
--- a/components/RecentReorgs.tsx
+++ b/components/RecentReorgs.tsx
@@ -114,7 +114,7 @@ export const RecentReorgs = memo(function RecentReorgs({ footer }: { footer?: Re
style={{ animationDelay: `${i * 30}ms` }}
>
-
+
#{fork.forkHeight.toLocaleString()}
diff --git a/components/RiskyTxCard.tsx b/components/RiskyTxCard.tsx
index 7f13b406..c154fd68 100644
--- a/components/RiskyTxCard.tsx
+++ b/components/RiskyTxCard.tsx
@@ -15,7 +15,7 @@ export function RiskyTxCard({ tx }: { tx: RiskyTransaction }) {
Amounts describe each event’s public pool flow, not a verified payment between the listed addresses.
- Shielding block {tx.shieldHeight.toLocaleString()} Deshielding block {tx.deshieldHeight.toLocaleString()}
+ Shielding block {tx.shieldHeight.toLocaleString()} Deshielding block {tx.deshieldHeight.toLocaleString()}
;
}
diff --git a/components/ask/AskBlocks.tsx b/components/ask/AskBlocks.tsx
index 9db78589..61d0f421 100644
--- a/components/ask/AskBlocks.tsx
+++ b/components/ask/AskBlocks.tsx
@@ -3,7 +3,7 @@ import type { AskBlocks as BlockResult } from '@/lib/ask/chat';
export function AskBlocks({ result, expanded = false }: { result?: BlockResult; expanded?: boolean }) {
if (!result) return null;
- if (result.status === 'orphaned' && result.query.identifier) return
Inspect orphaned block ↗;
+ if (result.status === 'orphaned' && result.query.identifier) return
Inspect orphaned block ↗;
if (!result.rows.length) return null;
return
{result.rows.length === 1 ? 'Block details' : `${result.rows.length} latest indexed blocks · unfiltered`}
diff --git a/components/ask/AskTransfers.tsx b/components/ask/AskTransfers.tsx
index 65e96a56..31a64856 100644
--- a/components/ask/AskTransfers.tsx
+++ b/components/ask/AskTransfers.tsx
@@ -15,7 +15,7 @@ export function AskTransfers({ result }: { result?: TransferResult }) {
{group.kind === 'largest' && !result.complete ? The full window could not be checked. No largest ranking is shown.
: !group.rows.length ? No matching indexed transactions in this window.
:
{group.kind} public {result.direction} transactions in the stated UTC window
Transaction ZEC Time (UTC) Pool
- {group.rows.map(row => {row.txid.slice(0, 8)}…{row.txid.slice(-6)}{amount(row.amountZat)} {utc(row.blockTime)} {row.pools.join(', ')} )}
+ {group.rows.map(row => {row.txid.slice(0, 8)}…{row.txid.slice(-6)}{amount(row.amountZat)} {utc(row.blockTime)} {row.pools.join(', ')} )}
}
)}
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..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());
@@ -117,6 +121,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') {
@@ -180,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
@@ -190,5 +211,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();
+});
+
+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');
+});
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);
+ }
+ }
+});