feat: block explorer routes, addressed by hash
`#/: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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user