feat: index the whole event surface, and show what has never fired
All checks were successful
deploy / build (push) Successful in 7m31s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s

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
`<<T as frame_system::Config>::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<T>` is information.

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 07:55:19 +03:00
parent 6b45004907
commit 1e8869e7a1
17 changed files with 932 additions and 43 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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.

View File

@@ -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<EventSummary> = 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<CallSummary> = 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<AppState>,
Path((chain, pallet, variant)): Path<(String, String, String)>,
Query(query): Query<BeforeQuery>,
) -> Result<Json<Vec<AccountEvent>>, 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<String> = 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 `<T as frame_system::Config>::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 `<X as Y>::` form, which is the one Substrate emits. A generic like
/// `BalanceOf<T>` or `Vec<u8>` 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:
// `<<T as frame_system::Config>::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: `<T as Foo>::Bar`
// has the ` as ` and a trailing `::`, and `Vec<u8>` 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<AppState>,
@@ -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("<T as frame_system::Config>::AccountId"),
"AccountId"
);
assert_eq!(
unqualify("<<T as frame_system::Config>::Lookup as StaticLookup>::Source"),
"Source"
);
// Left alone: these are the shape of the value, not ceremony.
assert_eq!(unqualify("BalanceOf<T>"), "BalanceOf<T>");
assert_eq!(unqualify("Vec<u8>"), "Vec<u8>");
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";

View File

@@ -208,6 +208,23 @@ pub struct ActivityRow {
pub accounts: Vec<String>,
}
/// 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<DateTime<Utc>>,
}
/// 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<Vec<EventUsage>, 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<u64>,
limit: i64,
) -> Result<Vec<StoredEvent>, 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,

View File

@@ -55,6 +55,42 @@ pub struct CallSummary {
pub last_at: Option<DateTime<Utc>>,
}
/// 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<String>,
/// 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<u64>,
/// When that was.
pub last_at: Option<DateTime<Utc>>,
/// 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<u32>,
/// Every dispatchable the runtime declares, used or not.
pub calls: Vec<CallSummary>,
/// 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<EventSummary>,
/// 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")]

View File

@@ -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};

View File

@@ -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

View File

@@ -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 && <BlocksIndex chain={chain} />}
{route.index === 'call' && chain && <CallsIndex chain={chain} />}
{route.index === 'event' && chain && <EventsIndex chain={chain} />}
{route.event && chain && (
<EventPanel
chain={chain}
pallet={route.event[0]}
variant={route.event[1]}
decimals={info?.token_decimals ?? 12}
symbol={info?.token_symbol ?? ''}
/>
)}
{route.call && chain && (
<CallPanel
chain={chain}
@@ -238,6 +252,7 @@ export default function App() {
!route.block &&
route.runtime === null &&
route.call === null &&
route.event === null &&
route.index === null && (
<div className="two-col">
<section className="panel">

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 { 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<CallSummary>,
/**
* 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<EventSummary>,
/**
* 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.

View File

@@ -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, };

View File

@@ -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<CallInd
return get<CallIndex>(`/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<AccountEvent[]> {
const cursor = before === undefined ? '' : `?before=${before}`
return get<AccountEvent[]>(
`/chains/${encodeURIComponent(chain)}/events/${encodeURIComponent(pallet)}/${encodeURIComponent(variant)}${cursor}`,
signal,
)
}
/** Every dispatch of one kind of call, newest first. */
export function fetchCallFeed(
chain: string,

View File

@@ -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<AccountEvent[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [before, setBefore] = useState<number | null>(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 (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<div>
<div className="eyebrow">Event</div>
<h2 className="panel-title" style={{ marginTop: 4, textTransform: 'none' }}>
<code>
{pallet}::{variant}
</code>
</h2>
</div>
<Link className="segmented panel-button" to={href({ chain, index: 'event' })}>
All events
</Link>
</div>
{error && <p className="empty">{error}</p>}
{!error && rows === null && <p className="empty">Reading the record</p>}
{rows !== null && rows.length === 0 && (
<p className="empty">
This event has not fired in the indexed range. The runtime declares it and the chain has
not reached for it.
</p>
)}
{rows !== null && rows.length > 0 && (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
Every firing of {pallet}::{variant}, newest first
</caption>
<thead>
<tr>
<th scope="col">Block</th>
<th scope="col" className="left">
Fields
</th>
<th scope="col" className="event-when">
When
</th>
</tr>
</thead>
<tbody>
{rows.map((e) => (
<tr key={`${e.height}-${e.index}`}>
<td className="numeral">
<Link to={href({ chain, block: String(e.height) })}>
#{fmtHeight(e.height)}
</Link>
</td>
<td className="left">
<Payload
value={e.fields}
ctx={{ chain, addresses: e.addresses, decimals, symbol }}
/>
</td>
<td className="numeral event-when" style={{ color: 'var(--text-muted)' }}>
{when(e.at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="pager">
<button
className="segmented panel-button"
disabled={before === null}
onClick={() => setBefore(null)}
>
Newest
</button>
<button
className="segmented panel-button"
disabled={oldest === null || oldest <= 1}
onClick={() => setBefore(oldest)}
>
Older
</button>
</div>
</section>
)
}

View File

@@ -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<CallIndex | null>(null)
const [error, setError] = useState<string | null>(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 (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Events</h2>
{index && (
<span className="eyebrow">
{index.fired} of {index.events.length} fired
{index.spec_version !== null && ` · runtime v${index.spec_version}`}
</span>
)}
</div>
{error && <p className="empty">{error}</p>}
{!error && index === null && <p className="empty">Reading the runtime</p>}
{index !== null && (
<>
<div className="pager" style={{ borderTop: 0, borderBottom: 'var(--rule)' }}>
<button
className="segmented panel-button"
aria-pressed={showUnused}
onClick={() => setShowUnused(!showUnused)}
>
{showUnused ? '✓ Showing never fired' : 'Show never fired'}
</button>
</div>
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
Every event the runtime declares, with how often it has fired
</caption>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col" className="left">
Event
</th>
<th scope="col">Fired</th>
<th scope="col">Accounts</th>
<th scope="col" className="event-when">
Last seen
</th>
</tr>
</thead>
<tbody>
{rows.map((e) => (
<tr
key={`${e.pallet}.${e.variant}`}
className={e.emitted === 0 ? 'call-unused' : undefined}
>
<td className="numeral">
<span className="runtime-index">
{e.pallet_index}.{e.variant_index}
</span>
</td>
<td className="left">
<div className="call-cell">
{e.emitted > 0 ? (
<Link to={href({ chain, event: [e.pallet, e.variant] })}>
<code>
{e.pallet}::{e.variant}
</code>
</Link>
) : (
<code>
{e.pallet}::{e.variant}
</code>
)}
{e.signature && <span className="runtime-type">({e.signature})</span>}
{e.docs && <span className="runtime-doc">{e.docs}</span>}
</div>
</td>
<td className="numeral">
{/* 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 ? (
<span
className="runtime-modifier"
title="Fires every block and names no account, so it is deliberately not indexed — see SKIPPED_EVENTS."
>
not indexed
</span>
) : e.emitted === 0 ? (
<span className="runtime-modifier">never</span>
) : (
fmtHeight(e.emitted)
)}
</td>
<td className="numeral">
{e.skipped || e.emitted === 0 ? '—' : fmtHeight(e.accounts)}
</td>
<td className="numeral event-when" style={{ color: 'var(--text-muted)' }}>
{e.last_at
? ago(e.last_at)
: e.last_height
? `#${fmtHeight(e.last_height)}`
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="panel-note">
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 — <em>never</em> is an answer, and
for a chain this young it is the more informative one. Three kinds read{' '}
<em>not indexed</em>: they fire every block and name no account, so they are skipped on
the way in rather than counted at zero.
</p>
</>
)}
</section>
)
}

View File

@@ -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 (
<nav className="sections" aria-label="Section">
@@ -55,7 +56,11 @@ export function SectionNav({
// A call's own page belongs to the Calls section, so the nav marks
// it there rather than nowhere.
aria-current={
route.index === s.id || (s.id === 'call' && route.call !== null) ? 'page' : undefined
route.index === s.id ||
(s.id === 'call' && route.call !== null) ||
(s.id === 'event' && route.event !== null)
? 'page'
: undefined
}
>
{s.label}

View File

@@ -1307,6 +1307,10 @@ tr.mine .share-bar > i {
flex-wrap: wrap;
align-items: baseline;
gap: 3px 8px;
/* Bounded, so a long signature wraps inside its cell rather than widening
the table and pushing the counts — which are what the page is for — off
the side of the viewport. */
max-width: 60ch;
}
.call-cell .runtime-doc {

View File

@@ -42,6 +42,8 @@ export interface Route {
runtime: number | null
/** One kind of dispatchable, as `[pallet, call]`. */
call: [string, string] | null
/** One kind of event, as `[pallet, variant]`. */
event: [string, string] | null
/**
* A kind with no value: the index of everything of that kind.
*
@@ -54,7 +56,7 @@ export interface Route {
}
/** The kinds that have an index. */
export type SectionName = 'block' | 'call' | 'account' | 'runtime'
export type SectionName = 'block' | 'call' | 'event' | 'account' | 'runtime'
/**
* The sections, in the order the nav shows them.
@@ -66,6 +68,7 @@ export type SectionName = 'block' | 'call' | 'account' | 'runtime'
export const SECTIONS: { id: SectionName; label: string }[] = [
{ id: 'block', label: 'Blocks' },
{ id: 'call', label: 'Calls' },
{ id: 'event', label: 'Events' },
{ id: 'account', label: 'Accounts' },
{ id: 'runtime', label: 'Runtimes' },
]
@@ -82,6 +85,7 @@ export const EMPTY: Route = {
account: null,
runtime: null,
call: null,
event: null,
index: null,
}
@@ -154,6 +158,7 @@ export function parse(pathname: string): Route {
// as the runtime names them, because those names are the identity and any
// slug of ours would be a second vocabulary to keep in step.
if (kind === 'call' && parts[3]) return { ...EMPTY, chain, call: [value, parts[3]] }
if (kind === 'event' && parts[3]) return { ...EMPTY, chain, event: [value, parts[3]] }
}
// A kind with nothing after it — `/quantus/account` or `/quantus/account/`,
// which `filter(Boolean)` above makes the same path. The index of that kind.
@@ -179,6 +184,7 @@ export function href(route: Partial<Route>): string {
return chain ? `/${chain}/miner/${route.miner}/${window}` : `/miner/${route.miner}/${window}`
}
if (route.call) return `/${chain}/call/${route.call[0]}/${route.call[1]}`
if (route.event) return `/${chain}/event/${route.event[0]}/${route.event[1]}`
if (route.index) return `/${chain}/${route.index}`
if (route.account) return `/${chain}/account/${route.account}`
if (route.runtime !== null && route.runtime !== undefined) {