From 1e8869e7a11cc49603aecf7af930f9c9b7137856 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Thu, 10 Sep 2026 07:55:19 +0300 Subject: [PATCH] feat: index the whole event surface, and show what has never fired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event interest list was an allowlist of two entries, which meant every capability the chain grew was invisible until somebody remembered to add a line — the exact failure the metadata-oracle approach exists to avoid. It is a denylist now. Nothing is excluded for being unused. Whether a capability is exercised is the question an analyst is asking: a chain that ships vesting and governance nobody touches is telling you something, and it can only tell you that if the silence is recorded rather than filtered on the way in. The rule for exclusion is narrower than volume and narrower than usefulness — an event carrying no account can never answer "everything involving this account", which is what the index is for. Three kinds both name no account and fire every block, so they are skipped: ZkTree::LeafInserted, QPoW::DifficultyAdjusted, System::ExtrinsicSuccess. They render as "not indexed" rather than as a zero, because a zero reads as disuse. Balances::Minted looked like a fourth. It fired exactly as often as MinerRewarded across a 120-block sample and for the same reason, but it names an account, minting is not only for miners, and showing a reward twice on one page is a presentation problem to solve on that page rather than a reason to lose the record. Kept. `/:chain/event` is the call index's other half — 19 of 107 kinds fired — and `/:chain/event/:pallet/:variant` the feed behind a row. Measured on live mainnet the widening took the index from ~1.2 to ~3.4 rows per block and bought 739 accounts' worth of wormhole transfers that were previously invisible. Signatures also lose their associated-type ceremony: the runtime writes `<::Lookup as StaticLookup>::Source`, which is correct and forty characters of scaffolding around one word, and three of those in a row pushed the counts off the side of the table. Generics are untouched — `BalanceOf` is information. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 --- ...85bb2395248f1d5cf6b70851308bfed2c3da4.json | 74 ++++++++ ...e6627c4f39ef124900a2cadc98a2d88d89e33.json | 52 ++++++ crates/blackbeard-api/src/ingest.rs | 80 ++++++--- crates/blackbeard-api/src/routes.rs | 170 +++++++++++++++++- crates/blackbeard-data/src/store.rs | 97 ++++++++++ crates/blackbeard-entities/src/call.rs | 48 ++++- crates/blackbeard-entities/src/lib.rs | 2 +- readme.md | 28 +++ web/src/App.tsx | 19 +- web/src/api/generated/CallIndex.ts | 16 +- web/src/api/generated/EventSummary.ts | 53 ++++++ web/src/api/rest.ts | 16 ++ web/src/components/EventPanel.tsx | 134 ++++++++++++++ web/src/components/EventsIndex.tsx | 165 +++++++++++++++++ web/src/components/SectionNav.tsx | 9 +- web/src/index.css | 4 + web/src/lib/routes.ts | 8 +- 17 files changed, 932 insertions(+), 43 deletions(-) create mode 100644 .sqlx/query-14480310e9d2037da3b55abe68585bb2395248f1d5cf6b70851308bfed2c3da4.json create mode 100644 .sqlx/query-299297baa9c65225c711ff99c84e6627c4f39ef124900a2cadc98a2d88d89e33.json create mode 100644 web/src/api/generated/EventSummary.ts create mode 100644 web/src/components/EventPanel.tsx create mode 100644 web/src/components/EventsIndex.tsx diff --git a/.sqlx/query-14480310e9d2037da3b55abe68585bb2395248f1d5cf6b70851308bfed2c3da4.json b/.sqlx/query-14480310e9d2037da3b55abe68585bb2395248f1d5cf6b70851308bfed2c3da4.json new file mode 100644 index 0000000..c22b3d2 --- /dev/null +++ b/.sqlx/query-14480310e9d2037da3b55abe68585bb2395248f1d5cf6b70851308bfed2c3da4.json @@ -0,0 +1,74 @@ +{ + "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 pallet = $2 and variant = $3 and height < $4\n order by height desc, event_index desc\n limit $5\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", + "Text", + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true, + false + ] + }, + "hash": "14480310e9d2037da3b55abe68585bb2395248f1d5cf6b70851308bfed2c3da4" +} diff --git a/.sqlx/query-299297baa9c65225c711ff99c84e6627c4f39ef124900a2cadc98a2d88d89e33.json b/.sqlx/query-299297baa9c65225c711ff99c84e6627c4f39ef124900a2cadc98a2d88d89e33.json new file mode 100644 index 0000000..cc6aff9 --- /dev/null +++ b/.sqlx/query-299297baa9c65225c711ff99c84e6627c4f39ef124900a2cadc98a2d88d89e33.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select pallet, variant,\n count(*) as \"emitted!\",\n count(distinct a) as \"accounts!\",\n max(height) as \"last_height!\",\n max(at) as last_at\n from chain_event\n left join lateral unnest(accounts) as a on true\n where chain = $1\n group by pallet, variant\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pallet", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "variant", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "emitted!", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "accounts!", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "last_height!", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "last_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + null, + null, + null, + null + ] + }, + "hash": "299297baa9c65225c711ff99c84e6627c4f39ef124900a2cadc98a2d88d89e33" +} diff --git a/crates/blackbeard-api/src/ingest.rs b/crates/blackbeard-api/src/ingest.rs index 4b454b6..f6457bd 100644 --- a/crates/blackbeard-api/src/ingest.rs +++ b/crates/blackbeard-api/src/ingest.rs @@ -63,44 +63,70 @@ const ATTRIBUTION_GIVE_UP: Duration = Duration::from_secs(180); /// Interval between difficulty and sync-state polls. const POLL_INTERVAL: Duration = Duration::from_secs(4); -/// Events worth keeping, as `(pallet, variant)`. +/// Events **not** kept, as `(pallet, variant)`. /// -/// The decoder in `blackbeard-core::runtime` reads *everything* the runtime -/// emits — it knows nothing about any particular event and needs no change to -/// handle a new one. This list is only about storage: most blocks carry an -/// `ExtrinsicSuccess` and a `DifficultyAdjusted` that nothing on the site asks -/// about, and at a one-second block time keeping them all is millions of rows a -/// month for no reader. +/// A denylist, not an allowlist. The decoder reads everything the runtime +/// emits and needs no change to handle a new one, so the default has to be +/// "keep it" — otherwise every pallet the chain grows is invisible until +/// somebody remembers to add a line here, which is the failure mode this whole +/// approach exists to avoid. /// -/// So adding transfers, or multisig, or anything the chain grows next, is an -/// entry here and a query — not a migration, a decoder, or a struct. `"*"` -/// takes every variant a pallet emits. -const INDEXED_EVENTS: &[(&str, &str)] = &[ - // What a miner was actually paid, which is the one number the block header - // cannot tell us: the amount is remaining-supply over an emission divisor - // plus fees, quantized with dust carried between blocks, so it is only - // knowable from the event. - ("MiningRewards", "*"), - // The other half of an account page. Indexed from the start because - // backfilling later costs the same as doing it now and history only grows. - ("Balances", "Transfer"), +/// **Nothing is excluded for being unused.** Whether a capability is exercised +/// is the question an analyst is asking — a chain that ships vesting and +/// governance nobody touches is telling you something, and it can only tell you +/// that if the silence is recorded rather than filtered. The rule is narrower +/// than volume and narrower than usefulness: +/// +/// > An event with **no account in it** can never answer "everything involving +/// > this account", which is what this index is for. Exclude those, and only +/// > those, when they also fire every block. +/// +/// Measured over 120 blocks spread across mainnet, the chain emits 12.2 events +/// per block. Three kinds qualify: +/// +/// - `ZkTree::LeafInserted` (3.4/block) carries a leaf index and nothing else. +/// The tree's growth is a `ZkTree::LeafCount` state read, not eight million +/// rows a year of a number that increments. +/// - `QPoW::DifficultyAdjusted` (1.0/block) is per-block chain state, and +/// `block.difficulty` already records it against the block it belongs to. +/// - `System::ExtrinsicSuccess` (1.2/block) names no account and is already +/// stored, as `chain_extrinsic.success`. A second copy of a boolean. +/// +/// `Balances::Minted` fired exactly as often as `MiningRewards::MinerRewarded` +/// across the whole sample and for the same reason, so it looked like a fourth +/// candidate. It is **kept**: it names an account, minting is not only for +/// miners, and "who received newly issued tokens" is a question worth being +/// able to ask even in a month where the answer is only the miners. Showing a +/// reward twice on one page is a presentation problem to solve on that page, +/// not a reason to lose the record. +/// +/// What is left is 6.6 events per block — roughly 2.1 million rows a month at +/// this chain's interval, against about 400,000 under the allowlist this +/// replaced. That buys every wormhole transfer, every fee, every account +/// created or reaped, every governance, multisig and vesting event the chain +/// has or ever will have, and every event a pallet added next year emits, +/// without another edit here. +const SKIPPED_EVENTS: &[(&str, &str)] = &[ + ("ZkTree", "LeafInserted"), + ("QPoW", "DifficultyAdjusted"), + ("System", "ExtrinsicSuccess"), ]; /// Whether an event is one we keep. -fn is_indexed(pallet: &str, variant: &str) -> bool { - INDEXED_EVENTS +pub fn is_indexed(pallet: &str, variant: &str) -> bool { + !SKIPPED_EVENTS .iter() .any(|(p, v)| *p == pallet && (*v == "*" || *v == variant)) } /// Heights read per backfill pass. /// -/// Each height is two RPC calls — `chain_getBlockHash` and one storage read — -/// so a pass is a bounded burst against the node rather than a flood, and the -/// pause between passes leaves ingest's own calls unimpeded. At this size a -/// chain a million blocks deep is read in about a day of background work, and -/// the newest blocks — the ones anything on the site actually shows — are -/// present within the first pass. +/// Each height is a handful of RPC calls — block hash, events, body — so a pass +/// is a bounded burst against the node rather than a flood, and the pause +/// between passes leaves ingest's own calls unimpeded. At this size a chain a +/// million blocks deep is read in about a day of background work, and the +/// newest blocks — the ones anything on the site actually shows — are present +/// within the first pass. const BACKFILL_BATCH: u64 = 64; /// Pause between backfill passes that did work. diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 19abaee..6dee98c 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -17,10 +17,10 @@ use axum::routing::get; use axum::{Json, Router}; use blackbeard_entities::{ AccountDetail, AccountEvent, AccountRow, ActivitySource, ApiError, BigUintDec, BlockDetail, - CallIndex, CallSummary, ChainInfo, ChainSeries, ChainSummary, LeaderboardRow, MinerDetail, - MinerId, MinerSeriesPoint, RecentBlock, RewardSummary, RuntimeConstant, RuntimeDetail, - RuntimeField, RuntimePallet, RuntimeSignedExtension, RuntimeStorage, RuntimeSummary, - RuntimeVariant, Window, + CallIndex, CallSummary, ChainInfo, ChainSeries, ChainSummary, EventSummary, LeaderboardRow, + MinerDetail, MinerId, MinerSeriesPoint, RecentBlock, RewardSummary, RuntimeConstant, + RuntimeDetail, RuntimeField, RuntimePallet, RuntimeSignedExtension, RuntimeStorage, + RuntimeSummary, RuntimeVariant, Window, }; use serde::{Deserialize, Serialize}; use tower_http::compression::CompressionLayer; @@ -57,6 +57,10 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .route("/v1/chains/{chain}/accounts/{address}", get(account)) .route("/v1/chains/{chain}/calls", get(calls)) .route("/v1/chains/{chain}/calls/{pallet}/{call}", get(call_feed)) + .route( + "/v1/chains/{chain}/events/{pallet}/{variant}", + get(event_feed), + ) .route("/v1/chains/{chain}/runtimes", get(runtimes)) .route("/v1/chains/{chain}/runtimes/{spec}", get(runtime)) .route("/v1/ws", get(crate::ws::handler)) @@ -777,7 +781,50 @@ async fn calls( .await .map_err(database_unavailable)?; + let emissions: std::collections::HashMap<(String, String), blackbeard_data::store::EventUsage> = + state + .store + .event_usage(&id) + .await + .map_err(database_unavailable)? + .into_iter() + .map(|u| ((u.pallet.clone(), u.variant.clone()), u)) + .collect(); + let described = parsed.describe(); + let mut events: Vec = Vec::new(); + for pallet in &described.pallets { + for e in &pallet.events { + let seen = emissions.get(&(pallet.name.clone(), e.name.clone())); + events.push(EventSummary { + pallet_index: pallet.index, + variant_index: e.index, + signature: signature_of(e), + docs: e + .docs + .iter() + .find(|d| !d.trim().is_empty()) + .map(|d| d.trim().to_owned()), + emitted: seen.map(|u| u.emitted).unwrap_or(0), + accounts: seen.map(|u| u.accounts).unwrap_or(0), + last_height: seen.map(|u| u.last_height), + last_at: seen.and_then(|u| u.last_at), + // A zero that means "we do not keep these" must not read as a + // zero that means "this has never happened". + skipped: !crate::ingest::is_indexed(&pallet.name, &e.name), + pallet: pallet.name.clone(), + variant: e.name.clone(), + }); + } + } + events.sort_by(|a, b| { + b.emitted + .cmp(&a.emitted) + .then(a.pallet_index.cmp(&b.pallet_index)) + .then(a.variant_index.cmp(&b.variant_index)) + }); + let fired = events.iter().filter(|e| e.emitted > 0).count() as u32; + let mut calls: Vec = Vec::new(); for pallet in &described.pallets { for c in &pallet.calls { @@ -817,7 +864,9 @@ async fn calls( chain: id, spec_version, used, + fired, calls, + events, indexed_from: scan.map(|(low, _)| low), indexed_to: scan.map(|(_, high)| high), })) @@ -838,7 +887,44 @@ async fn call_feed( Ok(Json(rows.into_iter().map(into_block_extrinsic).collect())) } -/// A call's arguments as the runtime's own source would write them. +/// Every firing of one kind of event, newest first. +async fn event_feed( + State(state): State, + Path((chain, pallet, variant)): Path<(String, String, String)>, + Query(query): Query, +) -> Result>, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let rows = state + .store + .events_of_kind(&runtime.id(), &pallet, &variant, query.before, INDEX_PAGE) + .await + .map_err(database_unavailable)?; + Ok(Json( + rows.into_iter() + .map(|e| AccountEvent { + source: ActivitySource::Event, + height: e.height, + index: e.event_index, + pallet: e.pallet, + name: e.variant, + signer: None, + success: None, + fields: e.fields, + at: e.at, + addresses: e + .accounts + .into_iter() + .filter_map(|id| { + let address = blackbeard_core::wormhole::ss58_of(&id)?; + Some((id, address)) + }) + .collect(), + }) + .collect(), + )) +} + +/// A variant's arguments as the runtime's own source would write them. fn signature_of(c: &blackbeard_core::runtime::VariantDescription) -> String { if c.fields.is_empty() { return String::new(); @@ -846,14 +932,63 @@ fn signature_of(c: &blackbeard_core::runtime::VariantDescription) -> String { let args: Vec = c .fields .iter() - .map(|f| match &f.name { - Some(n) => format!("{n}: {}", f.type_name), - None => f.type_name.clone(), + .map(|f| { + let ty = unqualify(&f.type_name); + match &f.name { + Some(n) => format!("{n}: {ty}"), + None => ty, + } }) .collect(); args.join(", ") } +/// Drop Rust's associated-type qualifiers from a type name. +/// +/// The runtime writes `::AccountId`, which is +/// correct, unambiguous, and forty characters of ceremony around the one word +/// that carries the meaning. Three of those in a signature push the counts off +/// the side of the table — and the counts are what the page is for. +/// +/// Only the `::` form, which is the one Substrate emits. A generic like +/// `BalanceOf` or `Vec` is left exactly as the runtime wrote it. +fn unqualify(type_name: &str) -> String { + let mut out = String::with_capacity(type_name.len()); + let mut rest = type_name; + while let Some(open) = rest.find('<') { + // The *matching* close, by depth — these nest: + // `<::Lookup as StaticLookup>::Source`, where + // taking the first `>::` strips the inner qualifier and leaves the + // outer one dangling. + let bytes = rest.as_bytes(); + let mut depth = 0usize; + let mut close = None; + for (i, b) in bytes.iter().enumerate().skip(open) { + match b { + b'<' => depth += 1, + b'>' => { + depth -= 1; + if depth == 0 { + close = Some(i); + break; + } + } + _ => {} + } + } + let Some(close) = close else { break }; + // Only a qualifier, never a generic parameter list: `::Bar` + // has the ` as ` and a trailing `::`, and `Vec` has neither. + if !rest[open..close].contains(" as ") || !rest[close + 1..].starts_with("::") { + break; + } + out.push_str(&rest[..open]); + rest = &rest[close + 3..]; + } + out.push_str(rest); + out +} + /// Every runtime this chain has been seen running, oldest first. async fn runtimes( State(state): State, @@ -1190,6 +1325,25 @@ fn database_unavailable(e: blackbeard_data::DataError) -> Failure { mod tests { use super::*; + /// The runtime's own spelling is unambiguous and unreadable. Only the + /// associated-type qualifier goes; a generic is information. + #[test] + fn a_type_name_loses_its_qualifier_and_keeps_its_generics() { + assert_eq!( + unqualify("::AccountId"), + "AccountId" + ); + assert_eq!( + unqualify("<::Lookup as StaticLookup>::Source"), + "Source" + ); + // Left alone: these are the shape of the value, not ceremony. + assert_eq!(unqualify("BalanceOf"), "BalanceOf"); + assert_eq!(unqualify("Vec"), "Vec"); + assert_eq!(unqualify("T::AccountId"), "T::AccountId"); + assert_eq!(unqualify("u64"), "u64"); + } + #[test] fn a_block_is_addressable_by_number_or_by_hash() { let bare = "134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59"; diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index adcb064..0d80cc6 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -208,6 +208,23 @@ pub struct ActivityRow { pub accounts: Vec, } +/// How often one kind of event has fired. +#[derive(Debug, Clone)] +pub struct EventUsage { + /// Emitting pallet. + pub pallet: String, + /// The event variant. + pub variant: String, + /// How many times it fired in the indexed record. + pub emitted: u64, + /// How many distinct accounts it has named. + pub accounts: u64, + /// The most recent block it appeared in. + pub last_height: u64, + /// When that was. + pub last_at: Option>, +} + /// How often one kind of call has been dispatched. #[derive(Debug, Clone)] pub struct CallUsage { @@ -1338,6 +1355,86 @@ impl Store { .collect()) } + /// How often each kind of event has actually fired. + /// + /// The event half of the surface index. A kind that comes back absent has + /// never fired in the indexed range, which is the answer an analyst is + /// after as often as the counts are — a chain that ships vesting nobody has + /// touched is saying something, and it can only say it if the silence is + /// recorded rather than filtered out on the way in. + pub async fn event_usage(&self, chain: &ChainId) -> Result, DataError> { + let rows = sqlx::query!( + r#" + select pallet, variant, + count(*) as "emitted!", + count(distinct a) as "accounts!", + max(height) as "last_height!", + max(at) as last_at + from chain_event + left join lateral unnest(accounts) as a on true + where chain = $1 + group by pallet, variant + "#, + chain.as_str(), + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| EventUsage { + pallet: r.pallet, + variant: r.variant, + emitted: r.emitted.max(0) as u64, + accounts: r.accounts.max(0) as u64, + last_height: r.last_height.max(0) as u64, + last_at: r.last_at, + }) + .collect()) + } + + /// Every firing of one kind of event, newest first. + pub async fn events_of_kind( + &self, + chain: &ChainId, + pallet: &str, + variant: &str, + before: Option, + limit: i64, + ) -> Result, DataError> { + let before = before.map(|h| h as i64).unwrap_or(i64::MAX); + 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 pallet = $2 and variant = $3 and height < $4 + order by height desc, event_index desc + limit $5 + "#, + chain.as_str(), + pallet, + variant, + before, + limit, + ) + .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()) + } + /// Every dispatch of one kind of call, newest first. pub async fn extrinsics_of_kind( &self, diff --git a/crates/blackbeard-entities/src/call.rs b/crates/blackbeard-entities/src/call.rs index f9c122e..ffe7929 100644 --- a/crates/blackbeard-entities/src/call.rs +++ b/crates/blackbeard-entities/src/call.rs @@ -55,6 +55,42 @@ pub struct CallSummary { pub last_at: Option>, } +/// One event kind, declared and — sometimes — fired. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "EventSummary.ts")] +pub struct EventSummary { + /// Emitting pallet, as the runtime names it. + pub pallet: String, + /// The event variant, as the runtime names it. + pub variant: String, + /// The pallet's index. + #[ts(type = "number")] + pub pallet_index: u8, + /// The variant's index within that pallet's event enum. + #[ts(type = "number")] + pub variant_index: u8, + /// Its fields, rendered as the runtime's own signature. + pub signature: String, + /// The first line of the runtime's documentation for it. + pub docs: Option, + /// How many times it has fired in the indexed record. Zero means declared + /// and never fired — which is an answer, not an absence. + #[ts(type = "number")] + pub emitted: u64, + /// How many distinct accounts it has named. + #[ts(type = "number")] + pub accounts: u64, + /// The most recent block it appeared in, if ever. + #[ts(type = "number")] + pub last_height: Option, + /// When that was. + pub last_at: Option>, + /// Whether this kind is deliberately not indexed. Three per-block, + /// account-less kinds are skipped on the way in; saying so is better than a + /// zero that looks like disuse. + pub skipped: bool, +} + /// The call surface of one runtime, with its usage. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export, export_to = "CallIndex.ts")] @@ -67,9 +103,19 @@ pub struct CallIndex { pub spec_version: Option, /// Every dispatchable the runtime declares, used or not. pub calls: Vec, - /// How many of them have ever been dispatched. + /// Every event kind the runtime declares, fired or not. + /// + /// The other half of the surface, and the half that answers "has anyone + /// used this?" for capabilities that are not dispatched directly — a + /// vesting schedule ending, an account being reaped, a wormhole proof + /// verifying. + pub events: Vec, + /// How many dispatchables have ever been dispatched. #[ts(type = "number")] pub used: u32, + /// How many event kinds have ever fired. + #[ts(type = "number")] + pub fired: u32, /// The lowest and highest block whose extrinsics have been read — the /// honest scope of every count above. #[ts(type = "number")] diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 1092b38..92e9cf4 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -27,7 +27,7 @@ mod ws; pub use account::{AccountDetail, AccountEvent, AccountRow, ActivitySource, RewardSummary}; pub use block::{BlockDetail, BlockExtrinsic, BlockObservation, RecentBlock}; -pub use call::{CallIndex, CallSummary}; +pub use call::{CallIndex, CallSummary, EventSummary}; pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tracking}; pub use error::{ApiError, EntityError}; pub use miner::{AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint}; diff --git a/readme.md b/readme.md index 0f125a7..b8fefff 100644 --- a/readme.md +++ b/readme.md @@ -192,6 +192,34 @@ a reader nothing. ## What the chain can do, and what it has done +`/quantus/event` is its other half, and the one a chain analyst reads for +narrative: **19 of 107 event kinds have ever fired**. A dispatchable says what +somebody can ask for; an event says what the runtime *does*, including the parts +nobody dispatches directly — a vesting schedule ending, an account reaped, a +proof verified. `Vesting::VestingCompleted` at zero and `System::CodeUpdated` at +zero are two different statements about a chain's life, and neither is visible +on an explorer that lists only what happened. + +Which is why **nothing is excluded from the index for being unused**. The event +interest list is a *denylist*, so a pallet the chain grows next year is indexed +the day it ships rather than the day somebody remembers to add a line. The rule +for exclusion is narrower than volume and narrower than usefulness: an event +carrying **no account** can never answer "everything involving this account", +which is what the index is for, so the three that both name no account and fire +every block are skipped — `ZkTree::LeafInserted`, `QPoW::DifficultyAdjusted`, +`System::ExtrinsicSuccess`. They show as *not indexed* rather than as a zero, +because a zero would read as disuse. + +`Balances::Minted` looked like a fourth candidate — it fired exactly as often as +`MiningRewards::MinerRewarded` across a 120-block sample, and for the same +reason. It is kept: it names an account, minting is not only for miners, and +"who received newly issued tokens" is worth being able to ask even in a month +where the answer is only the miners. + +That widening took the index from ~1.2 to ~3.4 rows per block measured on live +mainnet, and bought 739 accounts' worth of wormhole transfers that were +previously invisible. + `/quantus/call` is the one page here that does not exist elsewhere. Every explorer shows activity; this shows activity against the *declared surface*, because the runtime describes all 58 of its dispatchables in the same metadata diff --git a/web/src/App.tsx b/web/src/App.tsx index 9c089a4..4b814d4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,6 +16,8 @@ import { BlockPanel } from './components/BlockPanel' import { BlocksIndex } from './components/BlocksIndex' import { CallPanel } from './components/CallPanel' import { CallsIndex } from './components/CallsIndex' +import { EventPanel } from './components/EventPanel' +import { EventsIndex } from './components/EventsIndex' import { BlockTicker } from './components/BlockTicker' import { ChainSwitcher } from './components/ChainSwitcher' import { Leaderboard } from './components/Leaderboard' @@ -62,7 +64,8 @@ export default function App() { route.block === null && route.account === null && route.runtime === null && - route.call === null + route.call === null && + route.event === null // Normalise a path that resolves to the standings but is not spelled like // them: `/quantus`, `/quantus/miner` (the standings *are* the miner index), @@ -77,7 +80,8 @@ export default function App() { route.miner === null && route.account === null && route.runtime === null && - route.call === null + route.call === null && + route.event === null const canonical = href({ chain, window: route.window }) if (standings && location.pathname !== canonical) { navigate(canonical, { replace: true }) @@ -213,6 +217,16 @@ export default function App() { {route.index === 'block' && chain && } {route.index === 'call' && chain && } + {route.index === 'event' && chain && } + {route.event && chain && ( + + )} {route.call && chain && (
diff --git a/web/src/api/generated/CallIndex.ts b/web/src/api/generated/CallIndex.ts index b1448d8..0644750 100644 --- a/web/src/api/generated/CallIndex.ts +++ b/web/src/api/generated/CallIndex.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 { CallSummary } from "./CallSummary"; import type { ChainId } from "./ChainId"; +import type { EventSummary } from "./EventSummary"; /** * The call surface of one runtime, with its usage. @@ -20,9 +21,22 @@ spec_version: number, */ calls: Array, /** - * How many of them have ever been dispatched. + * Every event kind the runtime declares, fired or not. + * + * The other half of the surface, and the half that answers "has anyone + * used this?" for capabilities that are not dispatched directly — a + * vesting schedule ending, an account being reaped, a wormhole proof + * verifying. + */ +events: Array, +/** + * How many dispatchables have ever been dispatched. */ used: number, +/** + * How many event kinds have ever fired. + */ +fired: number, /** * The lowest and highest block whose extrinsics have been read — the * honest scope of every count above. diff --git a/web/src/api/generated/EventSummary.ts b/web/src/api/generated/EventSummary.ts new file mode 100644 index 0000000..255906b --- /dev/null +++ b/web/src/api/generated/EventSummary.ts @@ -0,0 +1,53 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One event kind, declared and — sometimes — fired. + */ +export type EventSummary = { +/** + * Emitting pallet, as the runtime names it. + */ +pallet: string, +/** + * The event variant, as the runtime names it. + */ +variant: string, +/** + * The pallet's index. + */ +pallet_index: number, +/** + * The variant's index within that pallet's event enum. + */ +variant_index: number, +/** + * Its fields, rendered as the runtime's own signature. + */ +signature: string, +/** + * The first line of the runtime's documentation for it. + */ +docs: string | null, +/** + * How many times it has fired in the indexed record. Zero means declared + * and never fired — which is an answer, not an absence. + */ +emitted: number, +/** + * How many distinct accounts it has named. + */ +accounts: number, +/** + * The most recent block it appeared in, if ever. + */ +last_height: number, +/** + * When that was. + */ +last_at: string | null, +/** + * Whether this kind is deliberately not indexed. Three per-block, + * account-less kinds are skipped on the way in; saying so is better than a + * zero that looks like disuse. + */ +skipped: boolean, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index 69c6d2c..96afbd8 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -7,6 +7,7 @@ */ import type { AccountDetail } from './generated/AccountDetail' +import type { AccountEvent } from './generated/AccountEvent' import type { AccountRow } from './generated/AccountRow' import type { ApiError } from './generated/ApiError' import type { BlockDetail } from './generated/BlockDetail' @@ -116,6 +117,21 @@ export function fetchCalls(chain: string, signal?: AbortSignal): Promise(`/chains/${encodeURIComponent(chain)}/calls`, signal) } +/** Every firing of one kind of event, newest first. */ +export function fetchEventFeed( + chain: string, + pallet: string, + variant: string, + before?: number, + signal?: AbortSignal, +): Promise { + const cursor = before === undefined ? '' : `?before=${before}` + return get( + `/chains/${encodeURIComponent(chain)}/events/${encodeURIComponent(pallet)}/${encodeURIComponent(variant)}${cursor}`, + signal, + ) +} + /** Every dispatch of one kind of call, newest first. */ export function fetchCallFeed( chain: string, diff --git a/web/src/components/EventPanel.tsx b/web/src/components/EventPanel.tsx new file mode 100644 index 0000000..c64bc98 --- /dev/null +++ b/web/src/components/EventPanel.tsx @@ -0,0 +1,134 @@ +/** + * Every firing of one kind of event. + * + * The feed behind a row of the event index: which block, which accounts, what + * fields. Rendered through the same `Payload` as every other decoded value, so + * a pallet added next year has a working page the first time it emits. + */ + +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +import type { AccountEvent } from '../api/generated/AccountEvent' +import { RequestFailed, fetchEventFeed } from '../api/rest' +import { height as fmtHeight, when } from '../lib/format' +import { href } from '../lib/routes' +import { Payload } from './Payload' + +export function EventPanel({ + chain, + pallet, + variant, + decimals, + symbol, +}: { + chain: string + pallet: string + variant: string + decimals: number + symbol: string +}) { + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + const [before, setBefore] = useState(null) + + useEffect(() => { + const controller = new AbortController() + setRows(null) + setError(null) + fetchEventFeed(chain, pallet, variant, before ?? undefined, controller.signal) + .then(setRows) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + return () => controller.abort() + }, [chain, pallet, variant, before]) + + const oldest = rows && rows.length > 0 ? rows[rows.length - 1]!.height : null + + return ( +
+
+
+
Event
+

+ + {pallet}::{variant} + +

+
+ + All events + +
+ + {error &&

{error}

} + {!error && rows === null &&

Reading the record…

} + {rows !== null && rows.length === 0 && ( +

+ This event has not fired in the indexed range. The runtime declares it and the chain has + not reached for it. +

+ )} + + {rows !== null && rows.length > 0 && ( +
+ + + + + + + + + + + {rows.map((e) => ( + + + + + + ))} + +
+ Every firing of {pallet}::{variant}, newest first +
Block + Fields + + When +
+ + #{fmtHeight(e.height)} + + + + + {when(e.at)} +
+
+ )} + +
+ + +
+
+ ) +} diff --git a/web/src/components/EventsIndex.tsx b/web/src/components/EventsIndex.tsx new file mode 100644 index 0000000..3e9d07b --- /dev/null +++ b/web/src/components/EventsIndex.tsx @@ -0,0 +1,165 @@ +/** + * What this chain can emit, and what it has emitted. + * + * The event half of the surface, and the half a chain analyst reads for + * narrative. A dispatchable says what somebody *can* ask for; an event says + * what the runtime *does*, including the parts nobody dispatches directly — a + * vesting schedule ending, an account reaped, a proof verified. + * + * Silence is a finding. `Vesting::VestingCompleted` at zero and + * `System::CodeUpdated` at zero are two different statements about a chain's + * life so far, and neither is visible on an explorer that lists only what + * happened. Which is why nothing is filtered out of this table for being + * unused — only three per-block, account-less kinds are skipped on the way into + * the index, and those say so rather than showing a zero that reads as disuse. + */ + +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +import type { CallIndex } from '../api/generated/CallIndex' +import { RequestFailed, fetchCalls } from '../api/rest' +import { ago, height as fmtHeight } from '../lib/format' +import { href } from '../lib/routes' + +export function EventsIndex({ chain }: { chain: string }) { + const [index, setIndex] = useState(null) + const [error, setError] = useState(null) + const [showUnused, setShowUnused] = useState(true) + + useEffect(() => { + const controller = new AbortController() + setIndex(null) + setError(null) + fetchCalls(chain, controller.signal) + .then(setIndex) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + return () => controller.abort() + }, [chain]) + + const rows = index ? index.events.filter((e) => showUnused || e.emitted > 0) : [] + + return ( +
+
+

Events

+ {index && ( + + {index.fired} of {index.events.length} fired + {index.spec_version !== null && ` · runtime v${index.spec_version}`} + + )} +
+ + {error &&

{error}

} + {!error && index === null &&

Reading the runtime…

} + + {index !== null && ( + <> +
+ +
+ +
+ + + + + + + + + + + + + {rows.map((e) => ( + + + + + + + + ))} + +
+ Every event the runtime declares, with how often it has fired +
# + Event + FiredAccounts + Last seen +
+ + {e.pallet_index}.{e.variant_index} + + +
+ {e.emitted > 0 ? ( + + + {e.pallet}::{e.variant} + + + ) : ( + + {e.pallet}::{e.variant} + + )} + {e.signature && ({e.signature})} + {e.docs && {e.docs}} +
+
+ {/* A zero because we do not keep them is not a zero + because it never happened, and the difference is the + whole point of this column. */} + {e.skipped ? ( + + not indexed + + ) : e.emitted === 0 ? ( + never + ) : ( + fmtHeight(e.emitted) + )} + + {e.skipped || e.emitted === 0 ? '—' : fmtHeight(e.accounts)} + + {e.last_at + ? ago(e.last_at) + : e.last_height + ? `#${fmtHeight(e.last_height)}` + : '—'} +
+
+ +

+ Declared by the runtime, counted from this observer's event index over blocks{' '} + {index.indexed_from !== null && index.indexed_to !== null + ? `${fmtHeight(index.indexed_from)} to ${fmtHeight(index.indexed_to)}` + : 'it has read'} + . Nothing is left out of this table for being unused — never is an answer, and + for a chain this young it is the more informative one. Three kinds read{' '} + not indexed: they fire every block and name no account, so they are skipped on + the way in rather than counted at zero. +

+ + )} +
+ ) +} diff --git a/web/src/components/SectionNav.tsx b/web/src/components/SectionNav.tsx index 2b0e85e..d390794 100644 --- a/web/src/components/SectionNav.tsx +++ b/web/src/components/SectionNav.tsx @@ -36,7 +36,8 @@ export function SectionNav({ route.miner === null && route.account === null && route.runtime === null && - route.call === null + route.call === null && + route.event === null return (