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
+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}