The block page listed extrinsics and nothing else. Block 1 holds twenty-eight indexed events and the route returned none of them — visible from the outside, because an account page linked to a `Wormhole::NativeTransferred` at block 1 and block 1 showed no sign of it. `phase` and `extrinsic_index` were stored for exactly this join and had never been used. Each extrinsic now carries its own events beneath it, and the block's own events sit in their phases. Block 1 is the argument for doing it that way. As extrinsics alone it is a single `Timestamp::set`. Its events are the entire opening distribution: twenty-one `Wormhole::NativeTransferred` in `Initialization`, owned by no extrinsic at all and sent from the minting account — not block zero, and not anything a transaction did. `Vesting::LaunchMomentSet` fires there too, so vesting has been exercised despite the call index showing zero `Vesting::*` dispatched: a pallet the runtime uses looks unused from the call side alone. Closes #3 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
433 lines
17 KiB
TypeScript
433 lines
17 KiB
TypeScript
/**
|
|
* 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 { Link } from 'react-router-dom'
|
|
|
|
import type { BlockDetail } from '../api/generated/BlockDetail'
|
|
import type { BlockEvent } from '../api/generated/BlockEvent'
|
|
import { RequestFailed, fetchBlock } from '../api/rest'
|
|
import {
|
|
ago,
|
|
bigNumber,
|
|
height as fmtHeight,
|
|
seconds,
|
|
shortAddress,
|
|
shortMiner,
|
|
} from '../lib/format'
|
|
import { href } from '../lib/routes'
|
|
import { GenesisPanel } from './GenesisPanel'
|
|
import { Payload } from './Payload'
|
|
|
|
/** 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>
|
|
}
|
|
|
|
/** A run of events, each as `Pallet::Variant` and its decoded fields. */
|
|
function EventList({
|
|
events,
|
|
chain,
|
|
decimals,
|
|
symbol,
|
|
wide,
|
|
}: {
|
|
events: BlockEvent[]
|
|
chain: string
|
|
decimals: number
|
|
symbol: string
|
|
wide?: boolean
|
|
}) {
|
|
if (events.length === 0) return null
|
|
return (
|
|
<ul className={wide ? 'runtime-list' : 'runtime-list block-event-list'}>
|
|
{events.map((e) => (
|
|
<li key={e.index}>
|
|
<span className="runtime-index">{e.index}</span>
|
|
<code>
|
|
{e.pallet}::{e.variant}
|
|
</code>
|
|
<Payload value={e.fields} ctx={{ chain, addresses: e.addresses, decimals, symbol }} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
export function BlockPanel({
|
|
chain,
|
|
block,
|
|
targetSeconds,
|
|
decimals,
|
|
symbol,
|
|
onResolved,
|
|
}: {
|
|
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
|
|
/** The chain's token decimals, for rendering call arguments. */
|
|
decimals: number
|
|
/** Its symbol. */
|
|
symbol: string
|
|
/** Called with the canonical hash once a height has resolved to one. */
|
|
onResolved: (hash: string) => 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>
|
|
{/* No close button: this is a URL somebody navigated to, not a dialog
|
|
that opened over something they were reading. The back button and
|
|
the chain chip both already do what it would. */}
|
|
</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 && (
|
|
<Link to={href({ chain, block: detail.canonical_hash })}>
|
|
See the block that won →
|
|
</Link>
|
|
)}
|
|
</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 ? (
|
|
<Link to={href({ chain, miner: detail.miner as string })} title={detail.miner}>
|
|
{detail.display ?? shortMiner(detail.miner)}
|
|
</Link>
|
|
) : (
|
|
// 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>
|
|
|
|
{/* The body, decoded, with each extrinsic's own events beneath it.
|
|
An event's `phase` says whether it belongs to an extrinsic or to
|
|
the block, and `extrinsic_index` says which — that join is what
|
|
turns "what was asked" and "what happened" into one readable
|
|
block, and it is why those two columns are stored.
|
|
|
|
The count in the facts above comes from the node and goes when the
|
|
node forgets the block; these come from this observer's index and
|
|
stay. An empty list is therefore not "no extrinsics" — a block
|
|
always has at least a timestamp — it is "the index has not read
|
|
this block yet", and it says so. */}
|
|
<div className="runtime-group" style={{ padding: '14px 16px 4px' }}>
|
|
<div className="eyebrow">
|
|
Extrinsics{detail.body.length > 0 && ` (${detail.body.length})`}
|
|
</div>
|
|
{detail.body.length === 0 ? (
|
|
<p className="panel-note" style={{ border: 0, padding: '8px 0 8px' }}>
|
|
{detail.extrinsics === null
|
|
? 'Not indexed, and the node cannot answer for this block either.'
|
|
: `The node reports ${detail.extrinsics} in the body; the event index has not read this block yet. It works outward from the tip.`}
|
|
</p>
|
|
) : (
|
|
<div className="scroll-x">
|
|
<table className="board">
|
|
<caption className="visually-hidden">
|
|
This block's extrinsics, in body order
|
|
</caption>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">#</th>
|
|
<th scope="col" className="left">
|
|
Call
|
|
</th>
|
|
<th scope="col" className="left">
|
|
Signer
|
|
</th>
|
|
<th scope="col" className="left">
|
|
Arguments
|
|
</th>
|
|
<th scope="col">Signature</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{detail.body.map((x) => (
|
|
<tr key={x.index}>
|
|
<td className="numeral">{x.index}</td>
|
|
<td className="left">
|
|
<span className="miner-cell">
|
|
<code>
|
|
{x.pallet}::{x.call}
|
|
</code>
|
|
{x.success === false && (
|
|
<span className="chip chip-failed" title="The dispatch failed.">
|
|
failed
|
|
</span>
|
|
)}
|
|
</span>
|
|
</td>
|
|
<td className="left">
|
|
{x.signer_address ? (
|
|
<Link
|
|
className="numeral"
|
|
to={href({ chain, account: x.signer_address })}
|
|
title={`${x.signer_address}\n${x.signer}`}
|
|
>
|
|
{shortAddress(x.signer_address)}
|
|
</Link>
|
|
) : (
|
|
/* Not anonymous: nobody signed it. An inherent is
|
|
the block author's own, and the chain accepts it
|
|
because of where it sits, not because of a
|
|
signature. */
|
|
<span className="runtime-modifier">inherent</span>
|
|
)}
|
|
</td>
|
|
<td className="left">
|
|
<Payload
|
|
value={x.args}
|
|
ctx={{ chain, addresses: x.addresses, decimals, symbol }}
|
|
/>
|
|
{/* What this dispatch actually caused. Under the
|
|
call rather than in a table of its own, because
|
|
an event's meaning is mostly which extrinsic
|
|
produced it. */}
|
|
<EventList
|
|
events={detail.events.filter(
|
|
(e) => e.phase === 'ApplyExtrinsic' && e.extrinsic_index === x.index,
|
|
)}
|
|
chain={chain}
|
|
decimals={decimals}
|
|
symbol={symbol}
|
|
/>
|
|
</td>
|
|
<td
|
|
className="numeral"
|
|
title={
|
|
x.signature
|
|
? `${x.signature}, ${x.signature_bytes.toLocaleString('en-US')} bytes`
|
|
: undefined
|
|
}
|
|
>
|
|
{x.signature ? `${Math.round(x.signature_bytes / 1024)} KiB` : '—'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Block zero is not an ordinary block. Whoever opened it is exactly
|
|
the person who should see who the chain started with, and the
|
|
endowments exist nowhere else. Keyed on the height rather than on
|
|
the URL so it appears whether the block was reached by number or
|
|
by its genesis hash. */}
|
|
{detail.height === 0 && (
|
|
<GenesisPanel chain={chain} decimals={decimals} symbol={symbol} />
|
|
)}
|
|
|
|
{/* Events owned by the block rather than by any extrinsic. Easy to
|
|
assume these do not exist — block 1 on this chain sends its entire
|
|
opening distribution as twenty-one wormhole transfers in
|
|
`Initialization`, owned by no extrinsic at all, and a page showing
|
|
only extrinsics shows none of it. */}
|
|
{(['Initialization', 'Finalization'] as const).map((phase) => {
|
|
const events = detail.events.filter((e) => e.phase === phase)
|
|
if (events.length === 0) return null
|
|
return (
|
|
<div key={phase} className="runtime-group" style={{ padding: '14px 16px 4px' }}>
|
|
<div className="eyebrow">
|
|
{phase} ({events.length})
|
|
</div>
|
|
<EventList events={events} chain={chain} decimals={decimals} symbol={symbol} wide />
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
<div className="block-links">
|
|
{detail.parent_hash && (
|
|
<div>
|
|
<span className="eyebrow">Parent</span>{' '}
|
|
<Link className="numeral" to={href({ chain, block: detail.parent_hash })}>
|
|
{detail.parent_hash}
|
|
</Link>
|
|
</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}>
|
|
<Link className="numeral" to={href({ chain, block: h })}>
|
|
{h}
|
|
</Link>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|