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
This commit is contained in:
71
.sqlx/query-c248c9698587376f96478c61af945d52e8e2678fd1ad7127dcd27915e258c19f.json
generated
Normal file
71
.sqlx/query-c248c9698587376f96478c61af945d52e8e2678fd1ad7127dcd27915e258c19f.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
35
readme.md
35
readme.md
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
35
web/src/api/generated/BlockEvent.ts
Normal file
35
web/src/api/generated/BlockEvent.ts
Normal 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>, };
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user