diff --git a/.sqlx/query-c248c9698587376f96478c61af945d52e8e2678fd1ad7127dcd27915e258c19f.json b/.sqlx/query-c248c9698587376f96478c61af945d52e8e2678fd1ad7127dcd27915e258c19f.json new file mode 100644 index 0000000..e895942 --- /dev/null +++ b/.sqlx/query-c248c9698587376f96478c61af945d52e8e2678fd1ad7127dcd27915e258c19f.json @@ -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" +} diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 3102f01..700ad65 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -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 { + 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), diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index 0d80cc6..4efeec6 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -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, 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. /// diff --git a/crates/blackbeard-entities/src/block.rs b/crates/blackbeard-entities/src/block.rs index bf103b7..6742b4c 100644 --- a/crates/blackbeard-entities/src/block.rs +++ b/crates/blackbeard-entities/src/block.rs @@ -50,6 +50,31 @@ pub struct BlockExtrinsic { pub at: Option>, } +/// 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, + /// Its fields, in the runtime's own names. + #[ts(type = "Record")] + pub fields: serde_json::Value, + /// Accounts it mentions, as `0x hex → SS58`. + #[ts(type = "Record")] + pub addresses: BTreeMap, +} + /// 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, + /// 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, /// The extrinsics themselves, decoded, in body order. /// /// From this observer's index rather than from the node, which is why they diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 27d7965..ba66b93 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -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}; diff --git a/readme.md b/readme.md index 4478a83..4fb1e83 100644 --- a/readme.md +++ b/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 diff --git a/web/src/api/generated/BlockDetail.ts b/web/src/api/generated/BlockDetail.ts index 08e91fa..1cc6fe9 100644 --- a/web/src/api/generated/BlockDetail.ts +++ b/web/src/api/generated/BlockDetail.ts @@ -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, /** * The extrinsics themselves, decoded, in body order. * diff --git a/web/src/api/generated/BlockEvent.ts b/web/src/api/generated/BlockEvent.ts new file mode 100644 index 0000000..97dc0f4 --- /dev/null +++ b/web/src/api/generated/BlockEvent.ts @@ -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, +/** + * Accounts it mentions, as `0x hex → SS58`. + */ +addresses: Record, }; diff --git a/web/src/components/BlockPanel.tsx b/web/src/components/BlockPanel.tsx index 13e5d46..38019d7 100644 --- a/web/src/components/BlockPanel.tsx +++ b/web/src/components/BlockPanel.tsx @@ -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 not recorded — {why} } +/** 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 ( +
    + {events.map((e) => ( +
  • + {e.index} + + {e.pallet}::{e.variant} + + +
  • + ))} +
+ ) +} + export function BlockPanel({ chain, block, @@ -232,11 +263,17 @@ export function BlockPanel({ - {/* 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. */}
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. */} + e.phase === 'ApplyExtrinsic' && e.extrinsic_index === x.index, + )} + 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 ( +
+
+ {phase} ({events.length}) +
+ +
+ ) + })} +
{detail.parent_hash && (
diff --git a/web/src/index.css b/web/src/index.css index cac1540..440c75b 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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); +}