feat: block explorer routes, addressed by hash
deploy / build (push) Successful in 6m33s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s

`#/:chain/:height` opens a block and then rewrites the address to
`#/:chain/:hash`. The redirect is the point, not tidiness: `block` is keyed on
(chain, height) and a reorg overwrites, so a height names a *position* and a
link to one comes to mean a different block the moment the chain forks there. A
hash names one block for good — including after it has lost, which is the only
way an orphan is linkable at all.

`GET /v1/chains/{chain}/blocks/{ref}` takes either spelling; a decimal number
and 32 bytes of hex cannot be confused, so a caller holding a number is not made
to guess which one this API wanted. The same reasoning puts block refs in the
second path segment beside the window: no window name is all digits or 0x plus
64 hex characters.

The answer is assembled from two sources because neither is sufficient, and
they fail at different times:

- The node holds the block and forgets it. Non-canonical bodies go once finality
  passes them (blocks-pruning defaults to archive-canonical, and Planck finalises
  ~100 blocks back), and difficulty is a state read behind a 256-block default.
- The observer holds what the node never had — when the block was seen, whether
  that sighting was at the tip — and what it has since forgotten: the difficulty
  read while the state behind it still existed.

So resolution degrades in a stated order rather than failing. Node first; a
header it cannot serve falls back to `block_displacement`, which is what makes
an orphan describable at all; a node that is away falls back to the recorded
row. `from_node` tells the reader which they are looking at, and every value
neither source has is spelled out in words — "the body is pruned", "this
observer never saw this block" — because a blank on a block page reads as zero.

Verified against the live chain: a tip block carries difficulty, one extrinsic,
a 1.9 s gap and 473 ms of propagation; height 1000000 keeps its body and loses
difficulty to state pruning; genesis has no author digest and does not panic; a
seeded displacement serves as an orphan with the winner linked, and the winner
lists it in `also_seen`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
2026-09-08 16:30:41 +03:00
co-authored by Claude Opus 5
parent 0ce58094e4
commit 53dac3717b
17 changed files with 1270 additions and 22 deletions
+66 -8
View File
@@ -10,6 +10,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { Window as WindowName } from './api/generated/Window'
import { BlockPanel } from './components/BlockPanel'
import { BlockTicker } from './components/BlockTicker'
import { ChainSwitcher } from './components/ChainSwitcher'
import { Leaderboard } from './components/Leaderboard'
@@ -29,15 +30,33 @@ interface Route {
chain: string | null
window: WindowName
miner: string | null
block: string | null
}
/**
* A block number, or a 32-byte hash.
*
* The second path segment is a window (`day`, `six_hours`, …) or a block, and
* the two can never be confused: no window name is all digits or `0x` followed
* by 64 hex characters. That is what lets `#/planck/day` and
* `#/planck/1069799` share a shape without a discriminating prefix.
*/
function isBlockRef(segment: string): boolean {
return /^\d+$/.test(segment) || /^0x[0-9a-f]{64}$/i.test(segment)
}
function parseHash(hash: string): Route {
const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean)
if (parts[0] === 'miner' && parts[1]) {
return { chain: null, window: 'six_hours', miner: parts[1].toLowerCase() }
return { chain: null, window: 'six_hours', miner: parts[1].toLowerCase(), block: null }
}
const window = WINDOWS.find((w) => w.id === parts[1])?.id ?? 'six_hours'
return { chain: parts[0] ?? null, window, miner: null }
const chain = parts[0] ?? null
const second = parts[1]
if (second && isBlockRef(second)) {
return { chain, window: 'six_hours', miner: null, block: second.toLowerCase() }
}
const window = WINDOWS.find((w) => w.id === second)?.id ?? 'six_hours'
return { chain, window, miner: null, block: null }
}
export default function App() {
@@ -67,15 +86,38 @@ export default function App() {
const merged = { ...parseHash(window.location.hash), ...next }
window.location.hash = merged.miner
? `/miner/${merged.miner}`
: `/${merged.chain ?? ''}/${merged.window}`
: merged.block
? `/${merged.chain ?? ''}/${merged.block}`
: `/${merged.chain ?? ''}/${merged.window}`
}, [])
const selectMiner = useCallback((miner: string) => {
// Keep the chain and window; the miner panel opens above the board rather
// than replacing the page, so the standings stay visible behind it.
setRoute((r) => ({ ...r, miner }))
setRoute((r) => ({ ...r, miner, block: null }))
}, [])
const selectBlock = useCallback((block: string) => {
setRoute((r) => ({ ...r, block, miner: null }))
}, [])
/**
* A height has resolved to its hash; put the hash in the address bar.
*
* `replaceState`, not a navigation: it is the same block under the name that
* will still mean this block after a reorg, so it belongs in place of the
* height rather than on top of it in the back stack. Route state is left
* alone deliberately — rewriting it would re-render the panel with a new
* `block` prop and fetch the same block a second time.
*/
const blockResolved = useCallback(
(hash: string) => {
if (!chain) return
window.history.replaceState(null, '', `#/${chain}/${hash}`)
},
[chain],
)
const interval =
state.summary?.block_interval_seconds ?? state.summary?.target_block_time_seconds ?? 6
const activeWindow = WINDOWS.find((w) => w.id === route.window) ?? WINDOWS[1]!
@@ -100,7 +142,7 @@ export default function App() {
key={w.id}
aria-pressed={w.id === route.window}
title={`${w.blocks.toLocaleString('en-US')} blocks — ${windowSpan(w.blocks, interval)}`}
onClick={() => go({ window: w.id, miner: null })}
onClick={() => go({ window: w.id, miner: null, block: null })}
>
{w.label}
</button>
@@ -117,7 +159,7 @@ export default function App() {
<ChainSwitcher
chains={state.chains}
active={chain}
onSelect={(id) => go({ chain: id, miner: null })}
onSelect={(id) => go({ chain: id, miner: null, block: null })}
/>
{info?.status === 'awaiting' && (
@@ -141,6 +183,17 @@ export default function App() {
<StatBar summary={state.summary} windowName={route.window} />
{route.block && chain && (
<BlockPanel
chain={chain}
block={route.block}
targetSeconds={info?.target_block_time_seconds ?? 6}
onResolved={blockResolved}
onSelectMiner={selectMiner}
onClose={() => go({ window: route.window, miner: null, block: null })}
/>
)}
{route.miner && chain && (
<MinerPanel
chain={chain}
@@ -172,7 +225,12 @@ export default function App() {
: 'measuring'}
</span>
</div>
<BlockTicker blocks={state.blocks} onSelect={selectMiner} />
<BlockTicker
blocks={state.blocks}
chain={chain}
onSelect={selectMiner}
onSelectBlock={selectBlock}
/>
</section>
</div>
+111
View File
@@ -0,0 +1,111 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AttributionSource } from "./AttributionSource";
import type { BigUintDec } from "./BigUintDec";
import type { ChainId } from "./ChainId";
import type { MinerId } from "./MinerId";
/**
* One block, as completely as it can still be described.
*
* Assembled from two sources that know different things, because neither is
* sufficient on its own:
*
* - The **node** holds the block itself — parent, body, and whether this hash
* still holds its height. It forgets: `blocks-pruning` defaults to
* `archive-canonical`, so a non-canonical body goes once finality passes it,
* and difficulty is a state read behind a 256-block window.
* - The **observer** holds what the node never had — when we first saw the
* block, whether that sighting was at the tip — and what it has since
* forgotten, the difficulty recorded while the state still existed.
*
* Every field the pair cannot supply is `None` rather than a substituted
* plausible value. A block page that quietly filled in the current difficulty
* for one it could not look up would be the same lie the ingest path was fixed
* to stop telling.
*/
export type BlockDetail = {
/**
* Which chain.
*/
chain: ChainId,
/**
* Block number.
*/
height: number,
/**
* This block's hash — the identity the page is addressed by, so that a
* link keeps meaning the same block after the height has moved on.
*/
hash: string,
/**
* Parent hash, from the header.
*/
parent_hash: string | null,
/**
* Author's reward preimage from the `pow_` PreRuntime digest.
*/
miner: MinerId | null,
/**
* What to call the author: a telemetry node name, or the abbreviated
* preimage. `None` where the author could not be decoded at all.
*/
display: string | null,
/**
* Whether `display` is an inferred name or the preimage.
*/
attribution: AttributionSource | null,
/**
* How much of the attribution vote window agrees, 0.0–1.0.
*/
confidence: number | null,
/**
* Expected hashes to win this block: the difficulty in force at its
* parent, which is the figure the miner actually had to meet.
*/
difficulty: BigUintDec | null,
/**
* The author's own timestamp, from the block's `Timestamp::set` inherent.
*/
authored_at: string | null,
/**
* When this observer first saw the block. `None` for a block it never saw
* at all — an orphan on a fork it missed, or history before it started.
*/
observed_at: string | null,
/**
* Whether `observed_at` was a tip sighting. When false, the difference
* between the timestamps measures when the observer got round to looking
* and nothing about the network, so the UI must not read it as propagation.
*/
at_tip: boolean | null,
/**
* Seconds between this block's timestamp and its parent's — how long this
* block took to find, against the chain's target.
*/
seconds_since_parent: number | null,
/**
* Extrinsics in the body, inherents included. `None` once the body has
* been pruned.
*/
extrinsics: number | null,
/**
* Whether this hash is the one that currently holds this height.
*/
canonical: boolean,
/**
* The hash that currently holds this height. Equal to `hash` when
* canonical; on an orphan page it is the link to the block that won.
*/
canonical_hash: string | null,
/**
* Other hashes this observer has recorded at this height — the losing
* sides of past reorgs. Navigable, which is the whole reason the
* displacement is written down.
*/
also_seen: Array<string>,
/**
* False when the node could not be reached or no longer holds the block,
* so everything here came from what the observer had already recorded.
* The UI owes the reader that distinction rather than showing gaps.
*/
from_node: boolean, };
+19
View File
@@ -7,6 +7,7 @@
*/
import type { ApiError } from './generated/ApiError'
import type { BlockDetail } from './generated/BlockDetail'
import type { ChainSeries } from './generated/ChainSeries'
import type { MinerDetail } from './generated/MinerDetail'
import type { Window as WindowName } from './generated/Window'
@@ -59,6 +60,24 @@ export function fetchSeries(
)
}
/**
* One block, by height or by hash.
*
* A height names whatever currently holds it and a hash names one block for
* good, so the caller redirects to the hash once the height resolves — see
* `BlockPanel`. The API accepts either spelling at the same path.
*/
export function fetchBlock(
chain: string,
block: string,
signal?: AbortSignal,
): Promise<BlockDetail> {
return get<BlockDetail>(
`/chains/${encodeURIComponent(chain)}/blocks/${encodeURIComponent(block)}`,
signal,
)
}
/** One miner's standing and history. */
export function fetchMiner(
chain: string,
+267
View File
@@ -0,0 +1,267 @@
/**
* One block.
*
* Opened from a height — the number in the ticker, or a URL somebody typed —
* and then it rewrites the address to the block's hash. That is not tidiness:
* `block` is keyed on (chain, height) and a reorg overwrites, so a height names
* a *position* and a link to one quietly comes to mean a different block the
* moment the chain forks there. A hash names one block for good, including
* after it has lost.
*
* The page says where each fact came from, because the two sources fail in
* different ways and at different times. The node holds the block and forgets
* it — non-canonical bodies go once finality passes them, and difficulty is a
* state read behind a 256-block window. The observer holds what the node never
* had, the sighting times, and what it has since forgotten. A blank here means
* neither could answer, never that the value is zero.
*/
import { useEffect, useState } from 'react'
import type { BlockDetail } from '../api/generated/BlockDetail'
import { RequestFailed, fetchBlock } from '../api/rest'
import { ago, bigNumber, height as fmtHeight, seconds, shortMiner } from '../lib/format'
/** A label and its value, with the caveat that makes the value readable. */
function Fact({
label,
children,
note,
}: {
label: string
children: React.ReactNode
// `| undefined` explicitly: the project sets `exactOptionalPropertyTypes`, so
// an optional prop does not otherwise accept a conditional that may be
// undefined — which is exactly how every caller here computes its caveat.
note?: string | undefined
}) {
return (
<div className="fact">
<div className="fact-label">{label}</div>
<div className="fact-value">{children}</div>
{note && <div className="fact-note">{note}</div>}
</div>
)
}
/** A value the observer does not have, said plainly rather than left blank. */
function Unknown({ why }: { why: string }) {
return <span className="fact-unknown">not recorded — {why}</span>
}
export function BlockPanel({
chain,
block,
targetSeconds,
onResolved,
onSelectMiner,
onClose,
}: {
chain: string
/** Height or hash, as it appeared in the URL. */
block: string
/** The chain's target block time, for reading the gap against. */
targetSeconds: number
/** Called with the canonical hash once a height has resolved to one. */
onResolved: (hash: string) => void
onSelectMiner: (miner: string) => void
onClose: () => void
}) {
const [detail, setDetail] = useState<BlockDetail | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
setDetail(null)
setError(null)
fetchBlock(chain, block, controller.signal)
.then((d) => {
setDetail(d)
// Swap the address for the immutable one. Only ever a replace: this is
// the same block under a better name, not somewhere the reader went.
if (d.hash !== block) onResolved(d.hash)
})
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
})
return () => controller.abort()
}, [chain, block, onResolved])
// Propagation: the author stamps its timestamp when it *builds* the proposal,
// so this is that moment to the moment we saw the block. Only a number at all
// when the sighting was at the tip — on a gap fill it measures when the
// observer got round to looking, which is a fact about us.
const propagationMs =
detail?.at_tip && detail.authored_at && detail.observed_at
? new Date(detail.observed_at).getTime() - new Date(detail.authored_at).getTime()
: null
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<div>
<div className="eyebrow">Block</div>
<h2 className="panel-title" style={{ marginTop: 4 }}>
{detail
? `#${fmtHeight(detail.height)}`
: block.startsWith('0x')
? 'Block'
: `#${block}`}
{detail && !detail.canonical && <span className="badge badge-warn">replaced</span>}
</h2>
<div
className="numeral"
style={{
fontSize: 11,
color: 'var(--text-muted)',
marginTop: 4,
overflowWrap: 'anywhere',
}}
>
{detail?.hash ?? ''}
</div>
</div>
<button
className="segmented"
style={{
padding: '7px 13px',
font: 'inherit',
fontSize: 12,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: 'var(--text-secondary)',
cursor: 'pointer',
}}
onClick={onClose}
>
Close
</button>
</div>
{error && <p className="empty">{error}</p>}
{!error && !detail && <p className="empty">Reading the block…</p>}
{detail && (
<>
{!detail.canonical && (
<div className="banner banner-warn" style={{ margin: 0, borderWidth: '0 0 1px' }}>
This block lost its height to a reorg. It is kept here because the observer saw it —
the chain itself no longer serves losing blocks for long.{' '}
{detail.canonical_hash && (
<a href={`#/${chain}/${detail.canonical_hash}`}>See the block that won →</a>
)}
</div>
)}
{!detail.from_node && (
<div className="banner banner-warn" style={{ margin: 0, borderWidth: '0 0 1px' }}>
The node could not answer for this block, so everything below is what the observer
recorded at the time. The body, the parent and the timing against it are missing
because only the chain ever had them.
</div>
)}
<div className="facts">
<Fact label="Author" note={detail.confidence ? undefined : 'no telemetry name held'}>
{detail.miner ? (
<a
href={`#/miner/${detail.miner}`}
title={detail.miner}
onClick={(e) => {
e.preventDefault()
onSelectMiner(detail.miner as string)
}}
>
{detail.display ?? shortMiner(detail.miner)}
</a>
) : (
// Genesis carries no `pow_` digest, and neither would a header
// shape this decoder does not know.
<Unknown why="this header carries no author digest" />
)}
</Fact>
<Fact label="Difficulty" note="expected hashes to win it">
{detail.difficulty ? (
<span title={detail.difficulty}>{bigNumber(detail.difficulty)}</span>
) : (
<Unknown why="the node's state for this height is pruned" />
)}
</Fact>
<Fact
label="Time to find"
note={
detail.seconds_since_parent != null ? `target ${seconds(targetSeconds)}` : undefined
}
>
{detail.seconds_since_parent != null ? (
seconds(detail.seconds_since_parent)
) : (
<Unknown why="the parent's timestamp is unavailable" />
)}
</Fact>
<Fact label="Extrinsics" note="inherents included">
{detail.extrinsics != null ? (
String(detail.extrinsics)
) : (
<Unknown why="the body is pruned" />
)}
</Fact>
<Fact label="Authored" note={detail.authored_at ? "the author's own clock" : undefined}>
{detail.authored_at ? (
<span title={detail.authored_at}>{ago(detail.authored_at)}</span>
) : (
<Unknown why="no timestamp inherent was decoded" />
)}
</Fact>
<Fact
label="Seen here"
note={
propagationMs != null
? `${propagationMs} ms after it was authored`
: detail.observed_at
? 'caught up from history, not seen live'
: undefined
}
>
{detail.observed_at ? (
<span title={detail.observed_at}>{ago(detail.observed_at)}</span>
) : (
<Unknown why="this observer never saw this block" />
)}
</Fact>
</div>
<div className="block-links">
{detail.parent_hash && (
<div>
<span className="eyebrow">Parent</span>{' '}
<a className="numeral" href={`#/${chain}/${detail.parent_hash}`}>
{detail.parent_hash}
</a>
</div>
)}
{detail.also_seen.length > 0 && (
<div>
<span className="eyebrow">
Also seen at this height ({detail.also_seen.length})
</span>
{detail.also_seen.map((h) => (
<div key={h}>
<a className="numeral" href={`#/${chain}/${h}`}>
{h}
</a>
</div>
))}
</div>
)}
</div>
</>
)}
</section>
)
}
+19 -1
View File
@@ -15,10 +15,15 @@ import { usePinnedMiners } from '../lib/store'
export function BlockTicker({
blocks,
chain,
onSelect,
onSelectBlock,
}: {
blocks: RecentBlock[]
/** Needed to address a block; null before the first chain has resolved. */
chain: string | null
onSelect: (miner: string) => void
onSelectBlock: (block: string) => void
}) {
const { isPinned } = usePinnedMiners()
const seen = useRef<Set<number>>(new Set())
@@ -54,7 +59,20 @@ export function BlockTicker({
.filter(Boolean)
.join(' ')}
>
<span className="ticker-height">#{height(block.height)}</span>
{chain ? (
<a
className="ticker-height"
href={`#/${chain}/${block.height}`}
onClick={(e) => {
e.preventDefault()
onSelectBlock(String(block.height))
}}
>
#{height(block.height)}
</a>
) : (
<span className="ticker-height">#{height(block.height)}</span>
)}
<a
href={`#/miner/${block.miner}`}
title={block.miner}
+105
View File
@@ -469,6 +469,111 @@ a {
stroke-width: 1;
}
/* ---- block facts --------------------------------------------------------- */
/* The same grid as the stat tiles: this is the same kind of content — a handful
of single values with a caveat each — and giving it a second look would say
the two are different sorts of fact when they are not. */
.facts {
display: grid;
/* Three, not auto-fit. There are exactly six facts, and auto-fit resolves to
five at this width — leaving one on a row of its own beside four empty
cells that read as a grid that failed to load. Six divides evenly by three
and by two, so every breakpoint below is a full rectangle. A seventh fact
would need this revisited, which is the right amount of friction. */
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: var(--border);
border-bottom: var(--rule);
}
@media (max-width: 760px) {
.facts {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 460px) {
.facts {
grid-template-columns: 1fr;
}
}
.fact {
background: var(--surface-1);
padding: 14px 18px 15px;
min-width: 0;
}
.fact-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text-muted);
}
.fact-value {
font-family: var(--font-mono);
font-size: 17px;
margin-top: 7px;
overflow-wrap: anywhere;
}
.fact-note {
font-size: 11px;
color: var(--text-muted);
margin-top: 5px;
}
/* A value nobody has. Muted and in the body face so it does not read as data —
it is a sentence explaining an absence, and formatting it like a figure would
invite it to be compared with one. */
.fact-unknown {
font-family: var(--font-body);
font-size: 12px;
color: var(--text-muted);
}
.block-links {
padding: 15px 18px 18px;
display: grid;
gap: 12px;
font-size: 12px;
}
.block-links a {
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.block-links a:hover {
color: var(--data-bright);
}
/* The one place the accent hue belongs: not a series, a state. A block that
lost its height is the single most important thing its page can say. */
.badge {
margin-left: 10px;
padding: 2px 7px;
font-family: var(--font-body);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
vertical-align: middle;
}
.badge-warn {
background: var(--accent-wash);
color: var(--accent-bright);
border: 1px solid var(--accent);
}
a.ticker-height:hover {
color: var(--data-bright);
}
/* ---- leaderboard --------------------------------------------------------- */
.board {