fix: show a block's events, joined to the extrinsics that caused them
All checks were successful
deploy / build (push) Successful in 9m18s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 14s

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
This commit is contained in:
2026-09-10 09:14:56 +03:00
parent dd8086df0d
commit 88086552cb
10 changed files with 337 additions and 6 deletions

View File

@@ -0,0 +1,71 @@
{
"db_name": "PostgreSQL",
"query": "\n select height, event_index, pallet, variant, phase, extrinsic_index,\n fields as \"fields!\", at, accounts as \"accounts!\"\n from chain_event\n where chain = $1 and height = $2\n order by event_index asc\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "height",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "event_index",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "pallet",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "variant",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "phase",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "extrinsic_index",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "fields!",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"name": "at",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "accounts!",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
false,
true,
false
]
},
"hash": "c248c9698587376f96478c61af945d52e8e2678fd1ad7127dcd27915e258c19f"
}

View File

@@ -428,6 +428,7 @@ async fn from_hash(
seconds_since_parent,
extrinsics: body.map(|b| b.extrinsics.len() as u32),
body: block_body(state, &id, height).await,
events: block_events(state, &id, height).await,
canonical,
canonical_hash,
also_seen: other_hashes(state, &id, height, hash).await,
@@ -475,6 +476,32 @@ fn into_block_extrinsic(
}
}
/// A block's events out of the index.
async fn block_events(
state: &AppState,
chain: &blackbeard_entities::ChainId,
height: u64,
) -> Vec<blackbeard_entities::BlockEvent> {
let Ok(rows) = state.store.events_at(chain, height).await else {
return Vec::new();
};
rows.into_iter()
.map(|e| blackbeard_entities::BlockEvent {
index: e.event_index,
pallet: e.pallet,
variant: e.variant,
phase: e.phase,
extrinsic_index: e.extrinsic_index,
addresses: e
.accounts
.into_iter()
.filter_map(|id| Some((id.clone(), blackbeard_core::wormhole::ss58_of(&id)?)))
.collect(),
fields: e.fields,
})
.collect()
}
/// Every account id anywhere in a decoded payload, as `0x hex → SS58`.
///
/// Walks the whole tree rather than the top level: a `Utility::batch_all`
@@ -511,6 +538,7 @@ async fn from_record(
let id = runtime.id();
let attribution = runtime.attribute(&recorded.miner);
let body = block_body(state, &id, recorded.height).await;
let events = block_events(state, &id, recorded.height).await;
BlockDetail {
chain: id.clone(),
height: recorded.height,
@@ -521,6 +549,7 @@ async fn from_record(
seconds_since_parent: None,
extrinsics: None,
body,
events,
display: Some(attribution.display),
attribution: Some(attribution.source),
confidence: Some(attribution.confidence),

View File

@@ -1517,6 +1517,46 @@ impl Store {
.collect())
}
/// One block's events, in the order they fired.
///
/// The counterpart to `extrinsics_at`. `phase` and `extrinsic_index` come
/// back with them because they are the join that turns "what was asked" and
/// "what happened" into one readable block, which is what they were stored
/// for.
pub async fn events_at(
&self,
chain: &ChainId,
height: u64,
) -> Result<Vec<StoredEvent>, DataError> {
let rows = sqlx::query!(
r#"
select height, event_index, pallet, variant, phase, extrinsic_index,
fields as "fields!", at, accounts as "accounts!"
from chain_event
where chain = $1 and height = $2
order by event_index asc
"#,
chain.as_str(),
height as i64,
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| StoredEvent {
height: r.height.max(0) as u64,
event_index: r.event_index.max(0) as u32,
pallet: r.pallet,
variant: r.variant,
phase: r.phase,
extrinsic_index: r.extrinsic_index.map(|i| i.max(0) as u32),
fields: r.fields,
at: r.at,
accounts: r.accounts,
})
.collect())
}
/// Everything involving one account, newest first — requests and results
/// together.
///

View File

@@ -50,6 +50,31 @@ pub struct BlockExtrinsic {
pub at: Option<DateTime<Utc>>,
}
/// One event in a block, as the runtime named it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "BlockEvent.ts")]
pub struct BlockEvent {
/// Position in the block's event list.
#[ts(type = "number")]
pub index: u32,
/// Emitting pallet.
pub pallet: String,
/// The variant.
pub variant: String,
/// `ApplyExtrinsic`, `Initialization` or `Finalization` — whether this
/// belongs to an extrinsic or to the block itself.
pub phase: String,
/// Which extrinsic caused it, when one did.
#[ts(type = "number")]
pub extrinsic_index: Option<u32>,
/// Its fields, in the runtime's own names.
#[ts(type = "Record<string, unknown>")]
pub fields: serde_json::Value,
/// Accounts it mentions, as `0x hex → SS58`.
#[ts(type = "Record<string, string>")]
pub addresses: BTreeMap<String, String>,
}
/// A block as the observer recorded it.
///
/// Two timestamps, deliberately: `authored_at` comes from the block's own
@@ -164,6 +189,15 @@ pub struct BlockDetail {
/// Extrinsics in the body, inherents included. `None` once the body has
/// been pruned.
pub extrinsics: Option<u32>,
/// The block's events, in the order they fired.
///
/// Grouped by a reader rather than here: `phase` says whether an event
/// belongs to an extrinsic or to the block itself, and `extrinsic_index`
/// says which extrinsic. A block's opening distribution can arrive entirely
/// in `Initialization`, owned by no extrinsic at all — which is exactly
/// what block 1 on this chain does, and what a page showing only extrinsics
/// misses.
pub events: Vec<BlockEvent>,
/// The extrinsics themselves, decoded, in body order.
///
/// From this observer's index rather than from the node, which is why they

View File

@@ -29,7 +29,7 @@ mod state;
mod ws;
pub use account::{AccountDetail, AccountEvent, AccountRow, ActivitySource, RewardSummary};
pub use block::{BlockDetail, BlockExtrinsic, BlockObservation, RecentBlock};
pub use block::{BlockDetail, BlockEvent, BlockExtrinsic, BlockObservation, RecentBlock};
pub use call::{CallIndex, CallSummary, EventSummary};
pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tracking};
pub use error::{ApiError, EntityError};

View File

@@ -223,6 +223,41 @@ storage key needs the hasher to have kept it. `Blake2_128Concat` and
`None` there rather than a guess, and such a map can be counted but not
attributed.
## A block is what was asked and what happened
The block page lists its extrinsics with **each one's own events beneath it**,
and the block's own events in their phases. An event's `phase` says whether it
belongs to an extrinsic or to the block, and `extrinsic_index` says which — that
join is why both columns are stored, and it turns a block from two lists into
one account of what it did.
Block 1 on this chain is the argument for it. Showing only extrinsics, it is a
single `Timestamp::set` and nothing else. Its events are the whole opening
distribution:
EXTRINSICS (1)
0 Timestamp::set 1788943917807
42 Vesting::LaunchMomentSet 1788943917807
INITIALIZATION (21)
1 Wormhole::NativeTransferred 3 QTC → qzmtKfCX…z6HW
35 Wormhole::NativeTransferred 5,669,940 QTC → qzmviwoP…nxW7
FINALIZATION (6)
45 System::NewAccount · Balances::Endowed · Balances::Minted
49 Wormhole::NativeTransferred 0.3 QTC
50 MiningRewards::MinerRewarded 0.3 QTC
51 ZkTree::TreeGrew 3
Two things only visible this way. The chain's opening distribution is
**twenty-one wormhole transfers in `Initialization`**, owned by no extrinsic at
all and sent from the minting account — not block zero, and not anything a
transaction did. And `Vesting::LaunchMomentSet` fires here, so vesting has been
exercised even though the call index shows zero `Vesting::*` dispatched: a
pallet the *runtime* uses looks unused from the call side alone.
## Accounts the chain names, and block zero
Some accounts are special and nothing about the address says so. The treasury

View File

@@ -1,6 +1,7 @@
// 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 { BlockEvent } from "./BlockEvent";
import type { BlockExtrinsic } from "./BlockExtrinsic";
import type { ChainId } from "./ChainId";
import type { MinerId } from "./MinerId";
@@ -89,6 +90,17 @@ seconds_since_parent: number | null,
* been pruned.
*/
extrinsics: number | null,
/**
* The block's events, in the order they fired.
*
* Grouped by a reader rather than here: `phase` says whether an event
* belongs to an extrinsic or to the block itself, and `extrinsic_index`
* says which extrinsic. A block's opening distribution can arrive entirely
* in `Initialization`, owned by no extrinsic at all — which is exactly
* what block 1 on this chain does, and what a page showing only extrinsics
* misses.
*/
events: Array<BlockEvent>,
/**
* The extrinsics themselves, decoded, in body order.
*

View File

@@ -0,0 +1,35 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* One event in a block, as the runtime named it.
*/
export type BlockEvent = {
/**
* Position in the block's event list.
*/
index: number,
/**
* Emitting pallet.
*/
pallet: string,
/**
* The variant.
*/
variant: string,
/**
* `ApplyExtrinsic`, `Initialization` or `Finalization` — whether this
* belongs to an extrinsic or to the block itself.
*/
phase: string,
/**
* Which extrinsic caused it, when one did.
*/
extrinsic_index: number,
/**
* Its fields, in the runtime's own names.
*/
fields: Record<string, unknown>,
/**
* Accounts it mentions, as `0x hex → SS58`.
*/
addresses: Record<string, string>, };

View File

@@ -20,6 +20,7 @@ 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,
@@ -60,6 +61,36 @@ 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,
@@ -232,11 +263,17 @@ export function BlockPanel({
</Fact>
</div>
{/* The body, decoded. 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. */}
{/* 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})`}
@@ -306,6 +343,18 @@ export function BlockPanel({
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"
@@ -334,6 +383,24 @@ export function BlockPanel({
<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>

View File

@@ -1378,3 +1378,11 @@ tr.mine .share-bar > i {
font-size: 11px;
margin-top: 2px;
}
/* Events nested under the extrinsic that caused them. Indented and quieter
than the call above: they are its consequences, not siblings of it. */
.block-event-list {
margin-top: 6px;
padding-left: 10px;
border-left: 1px solid var(--border);
}