Files
observer/web/src/components/BlockPanel.tsx
T
grenadeandClaude Opus 5 88086552cb
deploy / build (push) Successful in 9m18s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 14s
fix: show a block's events, joined to the extrinsics that caused them
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
2026-09-10 09:14:56 +03:00

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