diff --git a/.sqlx/query-4777ff33fa3c39f2bad9d5c4182460c0d32eb157d92bdb6f836ac4f097cc7a01.json b/.sqlx/query-19a2efd4f573d2fc407b5db4b6fcfd585db063b0d9f02c6f2e3f19896cf2af8e.json similarity index 53% rename from .sqlx/query-4777ff33fa3c39f2bad9d5c4182460c0d32eb157d92bdb6f836ac4f097cc7a01.json rename to .sqlx/query-19a2efd4f573d2fc407b5db4b6fcfd585db063b0d9f02c6f2e3f19896cf2af8e.json index 4c81866..c22b1a6 100644 --- a/.sqlx/query-4777ff33fa3c39f2bad9d5c4182460c0d32eb157d92bdb6f836ac4f097cc7a01.json +++ b/.sqlx/query-19a2efd4f573d2fc407b5db4b6fcfd585db063b0d9f02c6f2e3f19896cf2af8e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n select spec_version, spec_name, first_seen_height\n from runtime_metadata where chain = $1 order by spec_version asc\n ", + "query": "\n select spec_version, spec_name, first_seen_height, fetched_at,\n octet_length(metadata) as \"bytes!\"\n from runtime_metadata where chain = $1 order by spec_version asc\n ", "describe": { "columns": [ { @@ -17,6 +17,16 @@ "ordinal": 2, "name": "first_seen_height", "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "fetched_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "bytes!", + "type_info": "Int4" } ], "parameters": { @@ -27,8 +37,10 @@ "nullable": [ false, false, - false + false, + false, + null ] }, - "hash": "4777ff33fa3c39f2bad9d5c4182460c0d32eb157d92bdb6f836ac4f097cc7a01" + "hash": "19a2efd4f573d2fc407b5db4b6fcfd585db063b0d9f02c6f2e3f19896cf2af8e" } diff --git a/.sqlx/query-9d4ef01721039a1c9d5d9ad5774f55563a5bf525dfbdc2881b3a60763c42a083.json b/.sqlx/query-9d4ef01721039a1c9d5d9ad5774f55563a5bf525dfbdc2881b3a60763c42a083.json new file mode 100644 index 0000000..7a5de53 --- /dev/null +++ b/.sqlx/query-9d4ef01721039a1c9d5d9ad5774f55563a5bf525dfbdc2881b3a60763c42a083.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n update runtime_metadata\n set first_seen_height = case\n when first_seen_height = 0 then $3\n else least(first_seen_height, $3)\n end\n where chain = $1 and spec_version = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "9d4ef01721039a1c9d5d9ad5774f55563a5bf525dfbdc2881b3a60763c42a083" +} diff --git a/CLAUDE.md b/CLAUDE.md index cbd0e27..94d9af6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,21 @@ the direction that keeps it contiguous if it stops early: upward when extending unread remainder *between* what was just read and `low`, which the cursor cannot express and nothing would ever notice. +**Historical `state_getRuntimeVersion` costs seconds, not milliseconds.** It +makes the node load and instantiate the runtime WASM out of that block's state: +measured at ~4 s against Heisenberg's public endpoint versus ~0.25 s at the tip. +`discover_runtimes` is its own task for exactly this reason — a bisection is +~100 of those calls, and inside the four-second poll loop it stalls difficulty, +the summary broadcast and the telemetry attach for minutes on every start. The +10 s `RPC_TIMEOUT` is what makes those calls succeed at all; shortening it would +make runtime history quietly stop working while everything else stayed fine. + +**`first_seen_height = 0` means "not known", not genesis.** A runtime cached on +the very first poll is recorded before the header subscription has delivered a +height. `lower_first_seen` uses `least`, so a zero left in place would win +forever and claim every runtime began at block 0 — it special-cases zero for +that reason. Do not simplify it back to a plain `least`. + ## Facts established by measurement Taken from the live **Planck** chain, 2026-09-04. Don't re-derive or contradict diff --git a/crates/blackbeard-api/src/ingest.rs b/crates/blackbeard-api/src/ingest.rs index 974d147..d5ca56d 100644 --- a/crates/blackbeard-api/src/ingest.rs +++ b/crates/blackbeard-api/src/ingest.rs @@ -110,6 +110,9 @@ const BACKFILL_TICK: Duration = Duration::from_millis(500); /// read, no runtime is known yet, or the node is not answering. const BACKFILL_IDLE: Duration = Duration::from_secs(30); +/// How often runtime discovery re-checks whether the chain is ready for it. +const DISCOVERY_WAIT: Duration = Duration::from_secs(5); + /// Interval between attribution persists. const PERSIST_INTERVAL: Duration = Duration::from_secs(30); @@ -127,6 +130,7 @@ pub fn spawn(chain: Arc, store: Store, config: &crate::config::Con config.telemetry.url.clone(), )); tokio::spawn(backfill(Arc::clone(&chain), store.clone())); + tokio::spawn(discover_runtimes(Arc::clone(&chain), store.clone())); tokio::spawn(housekeeping(chain, store, refresh)); } @@ -915,7 +919,10 @@ async fn ensure_runtime(chain: &Arc, store: &Store) { return; } if load_runtime(chain, store, &version, None).await.is_some() { - chain.write().spec_version = Some(version.spec_version); + let mut inner = chain.write(); + inner.spec_version = Some(version.spec_version); + inner.spec_name = Some(version.spec_name.clone()); + drop(inner); tracing::info!( chain = %chain.id(), spec = version.spec_version, spec_name = %version.spec_name, "decoding against runtime" @@ -923,6 +930,139 @@ async fn ensure_runtime(chain: &Arc, store: &Store) { } } +/// Find every runtime this chain has run, and cache each one's metadata. +/// +/// The event index caches a runtime the moment it needs one, which means the +/// set we hold is "whatever the backfill has walked past". On a chain a million +/// blocks deep that is days of waiting for a page that could be complete in a +/// minute — the upgrade boundaries are findable directly. +/// +/// `state_getRuntimeVersion(at)` is one call and `spec_version` only ever +/// increases, so the boundaries are a sorted sequence and bisection finds each +/// one in log₂(height) calls. Four upgrades over a million blocks is about +/// eighty calls, once, against a node we already talk to every four seconds. +/// +/// Runs after the first successful poll and then never again: a chain gains a +/// runtime by upgrading, and `ensure_runtime` catches that within four seconds +/// of it happening. +/// +/// Its own task rather than a step in the poll loop, because it is slow in a +/// way nothing else here is: `state_getRuntimeVersion` at an old block makes +/// the node load and instantiate the runtime WASM out of that block's state, +/// which measured **four seconds** against Heisenberg's public endpoint — +/// against a quarter of a second at the tip. A hundred of those inside the +/// four-second poll loop would stall difficulty, the summary broadcast and the +/// telemetry attach for several minutes on every start. +async fn discover_runtimes(chain: Arc, store: Store) { + let id = chain.id(); + + // Wait for a tip and a runtime. The first means the header subscription has + // delivered; the second means the chain row exists, which the metadata + // cache references. + let tip = loop { + let (height, ready) = { + let inner = chain.read(); + (inner.height, inner.spec_version.is_some()) + }; + match height { + Some(h) if ready => break h, + _ => tokio::time::sleep(DISCOVERY_WAIT).await, + } + }; + + // Genesis has no runtime version of its own worth asking for — block 1 is + // the first state a runtime produced. + let (Some(low), Some(high)) = (version_at(&chain, 1).await, version_at(&chain, tip).await) + else { + tracing::debug!(chain = %id, "runtime discovery skipped: version unavailable"); + return; + }; + + let mut found = std::collections::BTreeMap::new(); + found.insert(low.0, (low.1.clone(), 1u64)); + found.insert(high.0, (high.1.clone(), tip)); + if low.0 != high.0 { + tracing::info!(chain = %id, from = low.0, to = high.0, tip, + "searching for runtime upgrade boundaries"); + } + bisect(&chain, 1, low.0, tip, high.0, &mut found).await; + + let count = found.len(); + for (spec_version, (spec_name, height)) in found { + // The metadata may already be cached from decoding; this only needs to + // fetch what is missing, and to record where each runtime began. + let cached = store + .runtime_metadata(&id, spec_version) + .await + .ok() + .flatten() + .is_some(); + if !cached { + let Ok(hash) = chain.rpc.block_hash(height).await else { + continue; + }; + let Some(hash) = hash else { continue }; + match chain.rpc.metadata_at(Some(&hash)).await { + Ok(bytes) => { + let _ = store + .cache_runtime_metadata(&id, spec_version, &spec_name, &bytes, height) + .await; + tracing::info!( + chain = %id, spec = spec_version, height, bytes = bytes.len(), + "runtime discovered" + ); + } + Err(e) => { + // A pruning node cannot answer for state this old. Nothing + // to do about it and nothing broken by it. + tracing::debug!(chain = %id, spec = spec_version, height, error = %e, + "runtime metadata unavailable at its first block"); + continue; + } + } + } + let _ = store.lower_first_seen(&id, spec_version, height).await; + } + tracing::info!(chain = %id, runtimes = count, "runtime history complete"); +} + +/// `(spec_version, spec_name)` at one height. +async fn version_at(chain: &Arc, height: u64) -> Option<(u32, String)> { + let hash = chain.rpc.block_hash(height).await.ok()??; + let v = chain.rpc.runtime_version(Some(&hash)).await.ok()?; + Some((v.spec_version, v.spec_name)) +} + +/// Narrow `(low, high)` until every distinct runtime between them is known. +/// +/// Recorded against the *lowest* height each version was seen at, which after +/// the recursion has closed every interval is the block the upgrade took effect +/// — the first block the new runtime produced. +fn bisect<'a>( + chain: &'a Arc, + low_height: u64, + low_spec: u32, + high_height: u64, + high_spec: u32, + found: &'a mut std::collections::BTreeMap, +) -> std::pin::Pin + Send + 'a>> { + Box::pin(async move { + // Same runtime at both ends, or adjacent heights: nothing between them + // to find. The second case is the boundary itself. + if low_spec == high_spec || high_height <= low_height + 1 { + return; + } + let mid = low_height + (high_height - low_height) / 2; + let Some((spec, name)) = version_at(chain, mid).await else { + return; + }; + let entry = found.entry(spec).or_insert((name, mid)); + entry.1 = entry.1.min(mid); + bisect(chain, low_height, low_spec, mid, spec, found).await; + bisect(chain, mid, spec, high_height, high_spec, found).await; + }) +} + /// The runtime that produced a particular block. /// /// One extra RPC call to learn which version that is, so it is only reached diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 9fdca71..42907d1 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -18,7 +18,8 @@ use axum::{Json, Router}; use blackbeard_entities::{ AccountDetail, AccountEvent, ApiError, BigUintDec, BlockDetail, ChainInfo, ChainSeries, ChainSummary, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, RecentBlock, - RewardSummary, Window, + RewardSummary, RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, + RuntimeSignedExtension, RuntimeStorage, RuntimeSummary, RuntimeVariant, Window, }; use serde::{Deserialize, Serialize}; use tower_http::compression::CompressionLayer; @@ -52,6 +53,8 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .route("/v1/chains/{chain}/blocks/{block}", get(block)) .route("/v1/chains/{chain}/miners/{miner}", get(miner)) .route("/v1/chains/{chain}/accounts/{address}", get(account)) + .route("/v1/chains/{chain}/runtimes", get(runtimes)) + .route("/v1/chains/{chain}/runtimes/{spec}", get(runtime)) .route("/v1/ws", get(crate::ws::handler)) // Leaderboards are repetitive JSON and compress to a fraction of their // size; the snapshot a browser fetches on first paint is the largest @@ -556,6 +559,167 @@ async fn miner( })) } +/// Every runtime this chain has been seen running, oldest first. +async fn runtimes( + State(state): State, + Path(chain): Path, +) -> Result>, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let cached = state + .store + .cached_runtimes(&runtime.id()) + .await + .map_err(database_unavailable)?; + Ok(Json(cached.into_iter().map(summarise).collect())) +} + +/// One runtime, described by itself. +/// +/// Served from the cached metadata rather than from the node, deliberately: the +/// point of caching it was that a runtime's description outlives the state the +/// node would need to regenerate it. A runtime that has been pruned past is +/// still fully readable here. +async fn runtime( + State(state): State, + Path((chain, spec)): Path<(String, String)>, +) -> Result, Failure> { + let chain_runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let id = chain_runtime.id(); + let spec_version = parse_spec(&spec)?; + + let cached = state + .store + .cached_runtimes(&id) + .await + .map_err(database_unavailable)?; + let summary = cached + .into_iter() + .find(|r| r.spec_version == spec_version) + .ok_or_else(|| { + Failure( + StatusCode::NOT_FOUND, + ApiError::new( + "unknown_runtime", + format!("no metadata cached for {chain} spec_version {spec_version}"), + ), + ) + })?; + + let raw = state + .store + .runtime_metadata(&id, spec_version) + .await + .map_err(database_unavailable)? + .ok_or_else(|| { + Failure( + StatusCode::NOT_FOUND, + ApiError::new("unknown_runtime", "the metadata row has gone"), + ) + })?; + + let parsed = blackbeard_core::runtime::Runtime::from_metadata(&raw).map_err(|e| { + // A blob that decoded once and does not now means this decoder changed + // under a cache it did not write. Worth saying so rather than a 500. + tracing::warn!(chain = %id, spec = spec_version, error = %e, "cached metadata did not parse"); + Failure( + StatusCode::INTERNAL_SERVER_ERROR, + ApiError::new("metadata_unreadable", e.to_string()), + ) + })?; + let described = parsed.describe(); + + Ok(Json(RuntimeDetail { + chain: id, + summary: summarise(summary), + // The only version this decoder reads; `from_metadata` refuses anything + // else, so reaching here means it was 14. + metadata_version: 14, + extrinsic_version: described.extrinsic_version, + types: described.types as u32, + pallets: described + .pallets + .into_iter() + .map(|p| RuntimePallet { + index: p.index, + name: p.name, + calls: p.calls.into_iter().map(variant).collect(), + events: p.events.into_iter().map(variant).collect(), + errors: p.errors.into_iter().map(variant).collect(), + constants: p + .constants + .into_iter() + .map(|c| RuntimeConstant { + name: c.name, + type_name: c.type_name, + value: c.value, + docs: c.docs, + }) + .collect(), + storage: p + .storage + .into_iter() + .map(|st| RuntimeStorage { + name: st.name, + shape: st.shape, + modifier: st.modifier, + docs: st.docs, + }) + .collect(), + }) + .collect(), + signed_extensions: described + .signed_extensions + .into_iter() + .map(|e| RuntimeSignedExtension { + identifier: e.identifier, + type_name: e.type_name, + additional: e.additional, + }) + .collect(), + })) +} + +fn summarise(r: blackbeard_data::store::CachedRuntime) -> RuntimeSummary { + RuntimeSummary { + spec_version: r.spec_version, + spec_name: r.spec_name, + first_seen_height: r.first_seen_height, + fetched_at: r.fetched_at, + bytes: r.bytes, + } +} + +fn variant(v: blackbeard_core::runtime::VariantDescription) -> RuntimeVariant { + RuntimeVariant { + index: v.index, + name: v.name, + fields: v + .fields + .into_iter() + .map(|f| RuntimeField { + name: f.name, + type_name: f.type_name, + }) + .collect(), + docs: v.docs, + } +} + +/// A `spec_version` is a bare number, optionally written the way the site shows +/// it. Validated so a typo answers "that is not a spec version" rather than +/// "no such runtime", which reads like the chain never ran it. +fn parse_spec(raw: &str) -> Result { + raw.trim_start_matches('v').parse::().map_err(|_| { + Failure( + StatusCode::BAD_REQUEST, + ApiError::new( + "malformed_spec_version", + format!("`{raw}` is not a spec_version"), + ), + ) + }) +} + /// How many activity rows one page of an account's history carries. /// /// Enough that the common case — an account that has mined for a day — is one diff --git a/crates/blackbeard-api/src/state.rs b/crates/blackbeard-api/src/state.rs index 4707ea9..335b3e6 100644 --- a/crates/blackbeard-api/src/state.rs +++ b/crates/blackbeard-api/src/state.rs @@ -87,6 +87,10 @@ pub struct ChainInner { pub runtimes: HashMap>, /// The runtime the chain is on now, as of the last poll. pub spec_version: Option, + /// Its `spec_name`. Carried rather than assumed: the whole approach here is + /// that the runtime names itself, and hardcoding `quantus-runtime` would be + /// the one line in this crate that knows which chain it is looking at. + pub spec_name: Option, /// Current difficulty. pub difficulty: Option, /// Ceiling difficulty. @@ -183,6 +187,7 @@ impl ChainRuntime { token_decimals: None, runtimes: HashMap::new(), spec_version: None, + spec_name: None, difficulty: None, max_difficulty: None, syncing: false, @@ -333,6 +338,8 @@ impl ChainRuntime { .into_iter() .map(|(version, nodes)| ClientVersion { version, nodes }) .collect(), + spec_version: inner.spec_version, + spec_name: inner.spec_name.clone(), updated_at: Utc::now(), } } diff --git a/crates/blackbeard-core/src/runtime.rs b/crates/blackbeard-core/src/runtime.rs index e65a877..61bf0d1 100644 --- a/crates/blackbeard-core/src/runtime.rs +++ b/crates/blackbeard-core/src/runtime.rs @@ -83,6 +83,102 @@ pub struct DecodedEvent { pub accounts: Vec, } +/// What a runtime says about itself, read out of its own type registry. +#[derive(Debug, Clone, PartialEq)] +pub struct RuntimeDescription { + /// Every pallet, in declaration order. + pub pallets: Vec, + /// The extrinsic envelope's signed extensions, in the order applied. This + /// is where a chain's deviations from vanilla Substrate live. + pub signed_extensions: Vec, + /// Extrinsic format version — 4 on every Quantus runtime seen so far. + pub extrinsic_version: u8, + /// How many distinct types the registry holds. A rough measure of how much + /// runtime there is, and the one number that moves visibly across upgrades. + pub types: usize, +} + +/// One pallet, and everything it declares. +#[derive(Debug, Clone, PartialEq)] +pub struct PalletDescription { + /// The pallet's index, which is what its encodings depend on. + pub index: u8, + /// Its name, as the runtime declares it. + pub name: String, + /// Dispatchable calls. + pub calls: Vec, + /// Events it can emit. + pub events: Vec, + /// Errors its calls can return. + pub errors: Vec, + /// Compiled-in constants, with their values. + pub constants: Vec, + /// Storage entries it owns. + pub storage: Vec, +} + +/// One variant of a call, event or error enum. +#[derive(Debug, Clone, PartialEq)] +pub struct VariantDescription { + /// Index within the enum, which is the byte on the wire. + pub index: u8, + /// Variant name. + pub name: String, + /// Its fields, named where the runtime names them. + pub fields: Vec, + /// Documentation from the runtime source. + pub docs: Vec, +} + +/// One field of a variant. +#[derive(Debug, Clone, PartialEq)] +pub struct FieldDescription { + /// `None` for a tuple variant's positional fields. + pub name: Option, + /// The type, as the runtime wrote it where it says so, else as rendered + /// from the registry. + pub type_name: String, +} + +/// A constant, and the value this runtime was built with. +#[derive(Debug, Clone, PartialEq)] +pub struct ConstantDescription { + /// Constant name. + pub name: String, + /// Its type. + pub type_name: String, + /// The decoded value, or `None` if it did not decode against its own type — + /// which would be a runtime describing itself wrongly, worth showing as a + /// gap rather than hiding. + pub value: Option, + /// Documentation from the runtime source. + pub docs: Vec, +} + +/// One storage entry. +#[derive(Debug, Clone, PartialEq)] +pub struct StorageDescription { + /// Entry name. + pub name: String, + /// `Value` for a plain entry, `Key → Value` for a map. + pub shape: String, + /// `optional` or `default`. + pub modifier: String, + /// Documentation from the runtime source. + pub docs: Vec, +} + +/// One signed extension in the extrinsic envelope. +#[derive(Debug, Clone, PartialEq)] +pub struct SignedExtensionDescription { + /// The extension's identifier, e.g. `CheckMortality`. + pub identifier: String, + /// What it contributes to the extrinsic. + pub type_name: String, + /// What it contributes to the signed payload but not to the extrinsic. + pub additional: String, +} + /// A runtime's description of itself, ready to decode with. pub struct Runtime { metadata: RuntimeMetadataV14, @@ -90,6 +186,10 @@ pub struct Runtime { /// Type ids whose registry path ends in a name worth rendering specially. /// Resolved once here rather than looked up per value. account_tys: BTreeMap, + /// Type ids of the big-integer newtypes, with how many `u64` limbs each + /// carries. Same trick as `account_tys` and for the same reason: the + /// registry describes `U512` as eight numbers, which is true and useless. + bigint_tys: BTreeMap, } impl std::fmt::Debug for Runtime { @@ -145,10 +245,28 @@ impl Runtime { .map(|t| (t.id, ())) .collect(); + // `U256` and `U512` are `[u64; N]`, little-endian — the same encoding + // `digest::u512_le` exists for, and the same trap: rendered as the + // array the registry describes, difficulty reads as + // `[1189189, 0, 0, 0, 0, 0, 0, 0]`, which is a correct description of + // the bytes and tells a reader nothing. Found by path, not by shape, + // so an ordinary `[u64; 8]` is left alone. + let bigint_tys = metadata + .types + .types + .iter() + .filter_map(|t| match t.ty.path.segments.last().map(String::as_str) { + Some("U256") => Some((t.id, 4)), + Some("U512") => Some((t.id, 8)), + _ => None, + }) + .collect(); + Ok(Self { metadata, events_ty, account_tys, + bigint_tys, }) } @@ -160,6 +278,242 @@ impl Runtime { .map(|p| (p.index, p.name.as_str())) } + /// Everything this runtime says about itself. + /// + /// The metadata is already in memory for decoding; this is the same + /// registry read for description rather than for data. Nothing here is + /// specific to Quantus — a runtime that grew a pallet last week describes + /// it the same way as one that has had `System` since genesis. + pub fn describe(&self) -> RuntimeDescription { + let mut pallets: Vec = self + .metadata + .pallets + .iter() + .map(|pallet| PalletDescription { + index: pallet.index, + name: pallet.name.clone(), + calls: pallet + .calls + .as_ref() + .map(|c| self.variants_of(c.ty.id)) + .unwrap_or_default(), + events: pallet + .event + .as_ref() + .map(|e| self.variants_of(e.ty.id)) + .unwrap_or_default(), + errors: pallet + .error + .as_ref() + .map(|e| self.variants_of(e.ty.id)) + .unwrap_or_default(), + constants: pallet + .constants + .iter() + .map(|c| ConstantDescription { + name: c.name.clone(), + type_name: self.type_name(c.ty.id), + // The value as the runtime compiled it in, decoded + // against its own type. This is the half a reader + // actually came for: `ExistentialDeposit` is a number + // somebody chose, and reading it out of the chain beats + // reading it out of a source tree that may not be the + // one this runtime was built from. + value: self.decode_constant(c.ty.id, &c.value), + docs: c.docs.clone(), + }) + .collect(), + storage: pallet + .storage + .as_ref() + .map(|s| { + s.entries + .iter() + .map(|e| StorageDescription { + name: e.name.clone(), + // `Plain` or the hashers of a map, which is the + // difference between "one value" and "a value + // per key" and the first thing anyone asks. + shape: match &e.ty { + frame_metadata::v14::StorageEntryType::Plain(t) => { + self.type_name(t.id) + } + frame_metadata::v14::StorageEntryType::Map { + key, + value, + .. + } => { + format!( + "{} → {}", + self.type_name(key.id), + self.type_name(value.id) + ) + } + }, + modifier: match e.modifier { + frame_metadata::v14::StorageEntryModifier::Optional => { + "optional" + } + frame_metadata::v14::StorageEntryModifier::Default => "default", + } + .to_owned(), + docs: e.docs.clone(), + }) + .collect() + }) + .unwrap_or_default(), + }) + .collect(); + // By index, which is the order the runtime declares them in and the + // order their encodings depend on — not alphabetical, which would hide + // that a pallet was inserted rather than appended. + pallets.sort_by_key(|p| p.index); + + RuntimeDescription { + // Every signed extension in the order they are applied. This is the + // extrinsic envelope, and the reason a transaction decoder written + // against one runtime breaks on another: Quantus carries + // `ReversibleTransactionExtension` and `WormholeProofRecorder` + // here, which vanilla Substrate does not. + signed_extensions: self + .metadata + .extrinsic + .signed_extensions + .iter() + .map(|e| SignedExtensionDescription { + identifier: e.identifier.clone(), + type_name: self.type_name(e.ty.id), + additional: self.type_name(e.additional_signed.id), + }) + .collect(), + extrinsic_version: self.metadata.extrinsic.version, + types: self.metadata.types.types.len(), + pallets, + } + } + + /// The variants of an enum type — a pallet's calls, events or errors. + /// + /// Empty when the type is not an enum, which is not an error: a pallet with + /// no calls simply has no call type, and one whose call type is something + /// else is a runtime doing something this does not model, which is worth + /// showing as "none" rather than refusing to render the page. + fn variants_of(&self, ty: u32) -> Vec { + let Some(entry) = self.metadata.types.resolve(ty) else { + return Vec::new(); + }; + let scale_info::TypeDef::Variant(v) = &entry.type_def else { + return Vec::new(); + }; + v.variants + .iter() + .map(|variant| VariantDescription { + index: variant.index, + name: variant.name.clone(), + fields: variant + .fields + .iter() + .map(|f| FieldDescription { + // Unnamed for a tuple variant, which is normal and + // renders as a position rather than a name. + name: f.name.clone(), + type_name: f + .type_name + .clone() + .unwrap_or_else(|| self.type_name(f.ty.id)), + }) + .collect(), + docs: variant.docs.clone(), + }) + .collect() + } + + /// A constant's compiled-in value, rendered the same way event fields are. + fn decode_constant(&self, ty: u32, bytes: &[u8]) -> Option { + let mut cursor = bytes; + let value = + scale_value::scale::decode_as_type(&mut cursor, ty, &self.metadata.types).ok()?; + if !cursor.is_empty() { + return None; + } + let mut accounts = BTreeSet::new(); + Some(self.normalise(&value, &mut accounts)) + } + + /// A readable name for a type id. + /// + /// The registry stores a path and parameters, not a rendered name, so + /// `Vec` is a `Sequence` of a `u8` and `Option` is a + /// variant type whose path ends in `Option`. Rendering it back is what + /// turns a page of type ids into something a person can read. + /// + /// Depth-limited: the registry is a graph and a recursive type — which + /// `Call` is, because a call can contain a call — would otherwise not + /// terminate. + pub fn type_name(&self, ty: u32) -> String { + self.render_type(ty, 0) + } + + fn render_type(&self, ty: u32, depth: u8) -> String { + if depth > 4 { + return "…".to_owned(); + } + let Some(entry) = self.metadata.types.resolve(ty) else { + return format!("#{ty}"); + }; + use scale_info::TypeDef; + match &entry.type_def { + TypeDef::Primitive(p) => format!("{p:?}").to_lowercase(), + TypeDef::Sequence(s) => { + format!("Vec<{}>", self.render_type(s.type_param.id, depth + 1)) + } + TypeDef::Array(a) => { + format!( + "[{}; {}]", + self.render_type(a.type_param.id, depth + 1), + a.len + ) + } + TypeDef::Tuple(t) => { + if t.fields.is_empty() { + "()".to_owned() + } else { + let inner: Vec = t + .fields + .iter() + .map(|f| self.render_type(f.id, depth + 1)) + .collect(); + format!("({})", inner.join(", ")) + } + } + TypeDef::Compact(c) => { + format!("Compact<{}>", self.render_type(c.type_param.id, depth + 1)) + } + TypeDef::BitSequence(_) => "BitVec".to_owned(), + // A named type: the last path segment, with its generic parameters + // if it has any. `pallet_balances::pallet::Call` reads as `Call`, + // which with the pallet name beside it is what anyone means. + TypeDef::Composite(_) | TypeDef::Variant(_) => { + let base = entry + .path + .segments + .last() + .cloned() + .unwrap_or_else(|| format!("#{ty}")); + let params: Vec = entry + .type_params + .iter() + .filter_map(|p| p.ty.map(|t| self.render_type(t.id, depth + 1))) + .collect(); + if params.is_empty() { + base + } else { + format!("{base}<{}>", params.join(", ")) + } + } + } + } + /// Decode a `System::Events` blob into named events. /// /// Takes the whole blob and refuses a partial read: bytes left over mean @@ -261,6 +615,11 @@ impl Runtime { use scale_value::{Composite, Primitive, ValueDef}; match &value.value { ValueDef::Composite(c) => { + if let Some(limbs) = self.bigint_tys.get(&value.context) + && let Some(decimal) = as_bigint(c, *limbs) + { + return serde_json::Value::String(decimal); + } if self.account_tys.contains_key(&value.context) && let Some(bytes) = as_bytes(c) { @@ -339,6 +698,69 @@ fn big(n: u128) -> serde_json::Value { } } +/// A little-endian `[u64; N]` big integer as a decimal string. +/// +/// Long multiplication on decimal digits rather than a bignum dependency: +/// `BigUintDec` already establishes that these cross every boundary as decimal +/// strings, and this is the only place that has to produce one. Each limb is +/// folded in as `total = total * 2^64 + limb`, done as two multiplications by +/// 2^32 so a digit times the multiplier cannot overflow the `u128` accumulator. +fn as_bigint(c: &scale_value::Composite, limbs: usize) -> Option { + use scale_value::{Composite, Primitive, ValueDef}; + // A newtype around the array: `U512(pub [u64; 8])`. + let inner = match c { + Composite::Unnamed(v) if v.len() == 1 => &v[0], + _ => return None, + }; + let ValueDef::Composite(Composite::Unnamed(words)) = &inner.value else { + return None; + }; + if words.len() != limbs { + return None; + } + + let mut digits: Vec = vec![0]; + // total *= 2^32 + let scale = |digits: &mut Vec| { + let mut carry = 0u128; + for d in digits.iter_mut() { + let v = ((*d as u128) << 32) + carry; + *d = (v % 10) as u8; + carry = v / 10; + } + while carry > 0 { + digits.push((carry % 10) as u8); + carry /= 10; + } + }; + // Most significant limb first: the array is little-endian, so read it back. + for word in words.iter().rev() { + let ValueDef::Primitive(Primitive::U128(n)) = &word.value else { + return None; + }; + let n = u64::try_from(*n).ok()?; + scale(&mut digits); + scale(&mut digits); + let mut carry = n as u128; + for d in digits.iter_mut() { + if carry == 0 { + break; + } + let v = *d as u128 + carry; + *d = (v % 10) as u8; + carry = v / 10; + } + while carry > 0 { + digits.push((carry % 10) as u8); + carry /= 10; + } + } + while digits.len() > 1 && *digits.last()? == 0 { + digits.pop(); + } + Some(digits.iter().rev().map(|d| (b'0' + d) as char).collect()) +} + /// A composite that is really a byte string. fn as_bytes(c: &scale_value::Composite) -> Option> { let items = match c { @@ -496,6 +918,101 @@ mod tests { } } + /// The runtime describes itself down to the values it was compiled with, + /// and nothing in this crate needs to know what any of them are. + #[test] + fn a_runtime_describes_its_pallets_constants_and_envelope() { + let rt = Runtime::from_metadata(&metadata()).expect("parses"); + let d = rt.describe(); + + assert_eq!(d.extrinsic_version, 4); + assert!(d.types > 100, "{}", d.types); + + let system = d + .pallets + .iter() + .find(|p| p.name == "System") + .expect("every runtime has System"); + assert!(system.calls.iter().any(|c| c.name == "remark")); + assert!(system.storage.iter().any(|s| s.name == "Events")); + + // A constant's value comes out as the runtime compiled it, decoded + // against its own declared type. + let block_hash_count = system + .constants + .iter() + .find(|c| c.name == "BlockHashCount") + .expect("System declares BlockHashCount"); + assert!(block_hash_count.value.is_some(), "{block_hash_count:?}"); + + // The envelope is where this chain differs from vanilla Substrate, and + // it is the reason a hand-written extrinsic decoder does not survive an + // upgrade. Reading it out of the metadata is the whole point. + let names: Vec<&str> = d + .signed_extensions + .iter() + .map(|e| e.identifier.as_str()) + .collect(); + assert!(names.contains(&"CheckNonce"), "{names:?}"); + } + + /// Type ids are useless on a page. These are the shapes that have to render + /// back into something a person reads. + #[test] + fn types_render_back_into_readable_names() { + let rt = Runtime::from_metadata(&metadata()).expect("parses"); + let d = rt.describe(); + let rendered: Vec = d + .pallets + .iter() + .flat_map(|p| p.storage.iter().map(|s| s.shape.clone())) + .collect(); + assert!( + rendered.iter().any(|s| s.contains("Vec<")), + "no sequence rendered: {rendered:?}" + ); + // Nothing may fall through to a bare type id, and nothing may recurse + // forever — `Call` contains `Call`. + for shape in &rendered { + assert!(!shape.starts_with('#'), "unresolved type: {shape}"); + } + } + + /// Difficulty on this chain is a U512 and the registry describes it as + /// eight numbers. Rendered as that array it is a correct description of the + /// bytes and useless to a reader, so it comes out as one decimal — the same + /// form `BigUintDec` carries it in everywhere else. + #[test] + fn a_u512_constant_renders_as_one_number() { + let rt = Runtime::from_metadata(&metadata()).expect("parses"); + let d = rt.describe(); + let qpow = d + .pallets + .iter() + .find(|p| p.name == "QPoW") + .expect("mainnet runs QPoW"); + let initial = qpow + .constants + .iter() + .find(|c| c.name == "InitialDifficulty") + .expect("QPoW declares InitialDifficulty"); + assert_eq!( + initial.value.as_ref().and_then(|v| v.as_str()), + Some("99999999999"), + "{initial:?}" + ); + + // And the chain's own answer to the one figure a wrong value would + // publish a wrong hashrate from. CLAUDE.md records 12 s for mainnet; + // this is where that came from. + let target = qpow + .constants + .iter() + .find(|c| c.name == "TargetBlockTime") + .expect("QPoW declares TargetBlockTime"); + assert_eq!(target.value.as_ref().and_then(|v| v.as_u64()), Some(12_000)); + } + #[test] fn a_block_decoded_against_the_wrong_runtime_fails_loudly() { // Trailing bytes mean the registry and the data disagree. Storing a diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index 0fd87ba..858203f 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -180,6 +180,23 @@ pub struct StoredEvent { pub fields: serde_json::Value, } +/// One runtime version this chain has run, as cached. +#[derive(Debug, Clone)] +pub struct CachedRuntime { + /// The runtime's `spec_version`. + pub spec_version: u32, + /// Its `spec_name`, e.g. `quantus-runtime`. + pub spec_name: String, + /// The lowest block this observer has seen it in force at. A *lower bound* + /// on where it took over, not the upgrade height, unless discovery found + /// the boundary. + pub first_seen_height: u64, + /// When the metadata was fetched. + pub fetched_at: DateTime, + /// Size of the metadata blob. + pub bytes: u64, +} + /// Totals over an account's mining rewards. #[derive(Debug, Clone)] pub struct RewardTotals { @@ -812,14 +829,12 @@ impl Store { Ok(result.rows_affected() > 0) } - /// Every runtime version cached for a chain, oldest first. - pub async fn cached_runtimes( - &self, - chain: &ChainId, - ) -> Result, DataError> { + /// Every runtime this chain has been seen running, oldest first. + pub async fn cached_runtimes(&self, chain: &ChainId) -> Result, DataError> { let rows = sqlx::query!( r#" - select spec_version, spec_name, first_seen_height + select spec_version, spec_name, first_seen_height, fetched_at, + octet_length(metadata) as "bytes!" from runtime_metadata where chain = $1 order by spec_version asc "#, chain.as_str(), @@ -828,16 +843,51 @@ impl Store { .await?; Ok(rows .into_iter() - .map(|r| { - ( - r.spec_version.max(0) as u32, - r.spec_name, - r.first_seen_height.max(0) as u64, - ) + .map(|r| CachedRuntime { + spec_version: r.spec_version.max(0) as u32, + spec_name: r.spec_name, + first_seen_height: r.first_seen_height.max(0) as u64, + fetched_at: r.fetched_at, + bytes: r.bytes.max(0) as u64, }) .collect()) } + /// Lower the height a runtime is recorded as first seen at. + /// + /// Discovery finds the boundaries out of order — it bisects, so it meets a + /// runtime somewhere in the middle of its reign before it finds where that + /// reign began. `least` means a later, better answer wins and an earlier, + /// worse one cannot undo it. + /// + /// Zero is the exception, and means "not known": a runtime cached on the + /// very first poll is recorded before the header subscription has delivered + /// a height, and `least` would otherwise let that placeholder win forever + /// and claim every runtime began at genesis. + pub async fn lower_first_seen( + &self, + chain: &ChainId, + spec_version: u32, + height: u64, + ) -> Result<(), DataError> { + sqlx::query!( + r#" + update runtime_metadata + set first_seen_height = case + when first_seen_height = 0 then $3 + else least(first_seen_height, $3) + end + where chain = $1 and spec_version = $2 + "#, + chain.as_str(), + i64::from(spec_version), + height as i64, + ) + .execute(&self.pool) + .await?; + Ok(()) + } + /// Store a block's decoded events. /// /// Idempotent on `(chain, height, event_index)`, so re-indexing a block — diff --git a/crates/blackbeard-entities/src/chain.rs b/crates/blackbeard-entities/src/chain.rs index 2c21ee7..2163b34 100644 --- a/crates/blackbeard-entities/src/chain.rs +++ b/crates/blackbeard-entities/src/chain.rs @@ -177,6 +177,13 @@ pub struct ChainSummary { /// Client versions across the chain's nodes, most common first. The one /// genuinely interesting statistic a chain with no RPC still yields. pub client_versions: Vec, + /// The `spec_version` of the runtime currently in force, when this observer + /// has read one. Everything on the site is decoded against it, so it is the + /// answer to "decoded how?" and the way in to the runtime's own page. + #[ts(type = "number")] + pub spec_version: Option, + /// Its `spec_name`, e.g. `quantus-runtime`. + pub spec_name: Option, /// When these numbers were computed. pub updated_at: DateTime, } diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 7d55ba9..0504a34 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -20,6 +20,7 @@ mod block; mod chain; mod error; mod miner; +mod runtime; mod series; mod ws; @@ -28,6 +29,10 @@ pub use block::{BlockDetail, BlockObservation, RecentBlock}; pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tracking}; pub use error::{ApiError, EntityError}; pub use miner::{AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint}; +pub use runtime::{ + RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, RuntimeSignedExtension, + RuntimeStorage, RuntimeSummary, RuntimeVariant, +}; pub use series::{ChainSeries, ChainSeriesPoint}; pub use ws::{ClientMessage, ServerMessage, Window}; diff --git a/crates/blackbeard-entities/src/runtime.rs b/crates/blackbeard-entities/src/runtime.rs new file mode 100644 index 0000000..0514586 --- /dev/null +++ b/crates/blackbeard-entities/src/runtime.rs @@ -0,0 +1,162 @@ +//! What a runtime says about itself. +//! +//! Every field here is read out of metadata the chain produced — the type +//! registry `state_getMetadata` returns, which the node generates by executing +//! the runtime WASM in a block's own state. Nothing is transcribed from a +//! source tree, which matters because a source tree is a claim about what the +//! chain *should* be running and this is what it *is*. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::ChainId; + +/// One runtime version, as a row in a list. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "RuntimeSummary.ts")] +pub struct RuntimeSummary { + /// The runtime's `spec_version` — the number that has to change for a + /// runtime upgrade to take effect, and the key everything here is filed + /// under. + #[ts(type = "number")] + pub spec_version: u32, + /// Its `spec_name`, e.g. `quantus-runtime`. + pub spec_name: String, + /// The first block this runtime is known to have been in force at. + /// + /// The upgrade height when discovery bisected the boundary, and otherwise a + /// *lower bound* — the earliest block the observer happened to decode + /// against it. The distinction is worth keeping: one is a fact about the + /// chain and the other is a fact about this observer. + #[ts(type = "number")] + pub first_seen_height: u64, + /// When the metadata was fetched and cached. + pub fetched_at: DateTime, + /// Size of the metadata blob. + #[ts(type = "number")] + pub bytes: u64, +} + +/// Everything discoverable about one runtime. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "RuntimeDetail.ts")] +pub struct RuntimeDetail { + /// Which chain it was cached from. + pub chain: ChainId, + /// The row this expands. + pub summary: RuntimeSummary, + /// Metadata format version — 14 on every Quantus runtime seen so far. + #[ts(type = "number")] + pub metadata_version: u8, + /// Extrinsic format version — 4, the standard envelope. + #[ts(type = "number")] + pub extrinsic_version: u8, + /// How many distinct types the registry describes. The one number that + /// moves visibly between upgrades, and a rough measure of how much runtime + /// there is. + #[ts(type = "number")] + pub types: u32, + /// Every pallet, in declaration order. + pub pallets: Vec, + /// The extrinsic envelope's signed extensions, in the order applied. + /// + /// The reason a hand-written transaction decoder does not survive an + /// upgrade, and where this chain's deviations from vanilla Substrate are + /// visible: `ReversibleTransactionExtension` and + /// `WormholeProofRecorderExtension` sit in this list and nowhere else. + pub signed_extensions: Vec, +} + +/// One pallet and everything it declares. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "RuntimePallet.ts")] +pub struct RuntimePallet { + /// The pallet's index, which is the first byte of every call and event it + /// encodes — so a pallet inserted rather than appended renumbers the ones + /// after it and breaks anything holding the old numbers. + #[ts(type = "number")] + pub index: u8, + /// Its name, as the runtime declares it. + pub name: String, + /// Dispatchable calls. + pub calls: Vec, + /// Events it can emit. + pub events: Vec, + /// Errors its calls can return. + pub errors: Vec, + /// Constants, with the values this runtime was compiled with. + pub constants: Vec, + /// Storage entries it owns. + pub storage: Vec, +} + +/// One call, event or error variant. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "RuntimeVariant.ts")] +pub struct RuntimeVariant { + /// Index within its enum, which is the byte on the wire. + #[ts(type = "number")] + pub index: u8, + /// Variant name. + pub name: String, + /// Its fields, named where the runtime names them. + pub fields: Vec, + /// Documentation carried in the metadata from the runtime's source. + pub docs: Vec, +} + +/// One field of a variant. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "RuntimeField.ts")] +pub struct RuntimeField { + /// `null` for a tuple variant's positional fields. + pub name: Option, + /// The type, as the runtime wrote it where it says so, else rendered from + /// the registry. + pub type_name: String, +} + +/// A constant and the value the runtime carries for it. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "RuntimeConstant.ts")] +pub struct RuntimeConstant { + /// Constant name. + pub name: String, + /// Its type. + pub type_name: String, + /// The decoded value. `null` if it did not decode against its own declared + /// type, which would be a runtime describing itself wrongly and is worth + /// showing as a gap rather than hiding. + #[ts(type = "unknown")] + pub value: Option, + /// Documentation carried in the metadata. + pub docs: Vec, +} + +/// One storage entry. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "RuntimeStorage.ts")] +pub struct RuntimeStorage { + /// Entry name. + pub name: String, + /// The value's type for a plain entry, `Key → Value` for a map. + pub shape: String, + /// `optional` or `default`. + pub modifier: String, + /// Documentation carried in the metadata. + pub docs: Vec, +} + +/// One signed extension in the extrinsic envelope. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "RuntimeSignedExtension.ts")] +pub struct RuntimeSignedExtension { + /// The extension's identifier, e.g. `CheckMortality`. + pub identifier: String, + /// What it contributes to the extrinsic itself. + pub type_name: String, + /// What it contributes to the signed payload but not to the extrinsic — + /// `()` for most, a genesis hash or a spec version for the checks. + pub additional: String, +} diff --git a/readme.md b/readme.md index 466cf41..813e04e 100644 --- a/readme.md +++ b/readme.md @@ -150,6 +150,46 @@ the tip — and what it has since forgotten, the difficulty recorded while the state still existed. Anything neither can answer says so in words rather than going blank, because a gap on a block page reads as a zero. +## A runtime describes itself + +`/quantus/runtime/152` is every pallet, call, event, error, storage entry and +constant the runtime declares — with the constants' *values*, decoded against +their own declared types. Nothing on that page is transcribed from a source +tree, which is the distinction worth having: a source tree says what the chain +should be running, and metadata says what it ran, at a height, possibly months +after it was upgraded past. + +The set of runtimes is found rather than waited for. Decoding caches a runtime +the moment it needs one, so left alone the cache is "whatever the backfill has +walked past" — days, on a chain a million blocks deep. `spec_version` only +increases, so the upgrade boundaries are a sorted sequence and bisection finds +each in log₂(height) probes. On Heisenberg that turned up **six** runtimes and +the block each took over at: v126 from 1, v128 from 132, v131 from 342,813, +v136 from 669,129, v144 from 812,055, v148 from 977,079. + +It runs as its own task, not in the poll loop, and the reason is worth +recording: `state_getRuntimeVersion` at an old block makes the node load and +instantiate the runtime WASM out of that block's state, which measured **four +seconds** against Heisenberg's public endpoint against a quarter of a second at +the tip. A hundred of those inside a four-second poll loop would stall +difficulty, the summary broadcast and the telemetry attach for minutes on every +start. + +Reading the two ends of Heisenberg's history side by side is the case for the +page existing. Between v126 and v148 the chain **dropped** Referenda, +ConvictionVoting, Recovery, Assets and AssetsHolder, **added** Vesting and +Origins, gained a `WeightReclaim` signed extension and moved +`ChargeTransactionPayment` after the two Quantus-specific ones. Every one of +those is a change that breaks a decoder written against the other version, and +none of them is discoverable from a block. + +Two renderings are not what the registry literally says, both for the same +reason and both identified by registry *path* rather than by shape: +`AccountId32` becomes `0x` hex rather than thirty-two numbers, and `U256`/`U512` +become one decimal rather than four or eight little-endian limbs. Difficulty as +`[1189189, 0, 0, 0, 0, 0, 0, 0]` is a correct description of the bytes and tells +a reader nothing. + ## One account, from our own index `/quantus/account/qzp2AxZw…` answers what the official explorer does not: what diff --git a/web/src/api/generated/ChainSummary.ts b/web/src/api/generated/ChainSummary.ts index f3eae2c..4fb68c5 100644 --- a/web/src/api/generated/ChainSummary.ts +++ b/web/src/api/generated/ChainSummary.ts @@ -80,6 +80,16 @@ finalized_height: number | null, * genuinely interesting statistic a chain with no RPC still yields. */ client_versions: Array, +/** + * The `spec_version` of the runtime currently in force, when this observer + * has read one. Everything on the site is decoded against it, so it is the + * answer to "decoded how?" and the way in to the runtime's own page. + */ +spec_version: number, +/** + * Its `spec_name`, e.g. `quantus-runtime`. + */ +spec_name: string | null, /** * When these numbers were computed. */ diff --git a/web/src/api/generated/RuntimeConstant.ts b/web/src/api/generated/RuntimeConstant.ts new file mode 100644 index 0000000..bfe24f3 --- /dev/null +++ b/web/src/api/generated/RuntimeConstant.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A constant and the value the runtime carries for it. + */ +export type RuntimeConstant = { +/** + * Constant name. + */ +name: string, +/** + * Its type. + */ +type_name: string, +/** + * The decoded value. `null` if it did not decode against its own declared + * type, which would be a runtime describing itself wrongly and is worth + * showing as a gap rather than hiding. + */ +value: unknown, +/** + * Documentation carried in the metadata. + */ +docs: Array, }; diff --git a/web/src/api/generated/RuntimeDetail.ts b/web/src/api/generated/RuntimeDetail.ts new file mode 100644 index 0000000..8969cab --- /dev/null +++ b/web/src/api/generated/RuntimeDetail.ts @@ -0,0 +1,45 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChainId } from "./ChainId"; +import type { RuntimePallet } from "./RuntimePallet"; +import type { RuntimeSignedExtension } from "./RuntimeSignedExtension"; +import type { RuntimeSummary } from "./RuntimeSummary"; + +/** + * Everything discoverable about one runtime. + */ +export type RuntimeDetail = { +/** + * Which chain it was cached from. + */ +chain: ChainId, +/** + * The row this expands. + */ +summary: RuntimeSummary, +/** + * Metadata format version — 14 on every Quantus runtime seen so far. + */ +metadata_version: number, +/** + * Extrinsic format version — 4, the standard envelope. + */ +extrinsic_version: number, +/** + * How many distinct types the registry describes. The one number that + * moves visibly between upgrades, and a rough measure of how much runtime + * there is. + */ +types: number, +/** + * Every pallet, in declaration order. + */ +pallets: Array, +/** + * The extrinsic envelope's signed extensions, in the order applied. + * + * The reason a hand-written transaction decoder does not survive an + * upgrade, and where this chain's deviations from vanilla Substrate are + * visible: `ReversibleTransactionExtension` and + * `WormholeProofRecorderExtension` sit in this list and nowhere else. + */ +signed_extensions: Array, }; diff --git a/web/src/api/generated/RuntimeField.ts b/web/src/api/generated/RuntimeField.ts new file mode 100644 index 0000000..448e8bd --- /dev/null +++ b/web/src/api/generated/RuntimeField.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One field of a variant. + */ +export type RuntimeField = { +/** + * `null` for a tuple variant's positional fields. + */ +name: string | null, +/** + * The type, as the runtime wrote it where it says so, else rendered from + * the registry. + */ +type_name: string, }; diff --git a/web/src/api/generated/RuntimePallet.ts b/web/src/api/generated/RuntimePallet.ts new file mode 100644 index 0000000..2d630f9 --- /dev/null +++ b/web/src/api/generated/RuntimePallet.ts @@ -0,0 +1,39 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RuntimeConstant } from "./RuntimeConstant"; +import type { RuntimeStorage } from "./RuntimeStorage"; +import type { RuntimeVariant } from "./RuntimeVariant"; + +/** + * One pallet and everything it declares. + */ +export type RuntimePallet = { +/** + * The pallet's index, which is the first byte of every call and event it + * encodes — so a pallet inserted rather than appended renumbers the ones + * after it and breaks anything holding the old numbers. + */ +index: number, +/** + * Its name, as the runtime declares it. + */ +name: string, +/** + * Dispatchable calls. + */ +calls: Array, +/** + * Events it can emit. + */ +events: Array, +/** + * Errors its calls can return. + */ +errors: Array, +/** + * Constants, with the values this runtime was compiled with. + */ +constants: Array, +/** + * Storage entries it owns. + */ +storage: Array, }; diff --git a/web/src/api/generated/RuntimeSignedExtension.ts b/web/src/api/generated/RuntimeSignedExtension.ts new file mode 100644 index 0000000..de1ccf8 --- /dev/null +++ b/web/src/api/generated/RuntimeSignedExtension.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One signed extension in the extrinsic envelope. + */ +export type RuntimeSignedExtension = { +/** + * The extension's identifier, e.g. `CheckMortality`. + */ +identifier: string, +/** + * What it contributes to the extrinsic itself. + */ +type_name: string, +/** + * What it contributes to the signed payload but not to the extrinsic — + * `()` for most, a genesis hash or a spec version for the checks. + */ +additional: string, }; diff --git a/web/src/api/generated/RuntimeStorage.ts b/web/src/api/generated/RuntimeStorage.ts new file mode 100644 index 0000000..ffec4da --- /dev/null +++ b/web/src/api/generated/RuntimeStorage.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One storage entry. + */ +export type RuntimeStorage = { +/** + * Entry name. + */ +name: string, +/** + * The value's type for a plain entry, `Key → Value` for a map. + */ +shape: string, +/** + * `optional` or `default`. + */ +modifier: string, +/** + * Documentation carried in the metadata. + */ +docs: Array, }; diff --git a/web/src/api/generated/RuntimeSummary.ts b/web/src/api/generated/RuntimeSummary.ts new file mode 100644 index 0000000..a892e79 --- /dev/null +++ b/web/src/api/generated/RuntimeSummary.ts @@ -0,0 +1,33 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One runtime version, as a row in a list. + */ +export type RuntimeSummary = { +/** + * The runtime's `spec_version` — the number that has to change for a + * runtime upgrade to take effect, and the key everything here is filed + * under. + */ +spec_version: number, +/** + * Its `spec_name`, e.g. `quantus-runtime`. + */ +spec_name: string, +/** + * The first block this runtime is known to have been in force at. + * + * The upgrade height when discovery bisected the boundary, and otherwise a + * *lower bound* — the earliest block the observer happened to decode + * against it. The distinction is worth keeping: one is a fact about the + * chain and the other is a fact about this observer. + */ +first_seen_height: number, +/** + * When the metadata was fetched and cached. + */ +fetched_at: string, +/** + * Size of the metadata blob. + */ +bytes: number, }; diff --git a/web/src/api/generated/RuntimeVariant.ts b/web/src/api/generated/RuntimeVariant.ts new file mode 100644 index 0000000..4f0931c --- /dev/null +++ b/web/src/api/generated/RuntimeVariant.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RuntimeField } from "./RuntimeField"; + +/** + * One call, event or error variant. + */ +export type RuntimeVariant = { +/** + * Index within its enum, which is the byte on the wire. + */ +index: number, +/** + * Variant name. + */ +name: string, +/** + * Its fields, named where the runtime names them. + */ +fields: Array, +/** + * Documentation carried in the metadata from the runtime's source. + */ +docs: Array, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index 6682037..d32af0b 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -11,6 +11,8 @@ import type { ApiError } from './generated/ApiError' import type { BlockDetail } from './generated/BlockDetail' import type { ChainSeries } from './generated/ChainSeries' import type { MinerDetail } from './generated/MinerDetail' +import type { RuntimeDetail } from './generated/RuntimeDetail' +import type { RuntimeSummary } from './generated/RuntimeSummary' import type { Window as WindowName } from './generated/Window' const BASE = import.meta.env.VITE_API_BASE_URL ?? '/v1' @@ -100,6 +102,26 @@ export function fetchAccount( ) } +/** Every runtime this chain has been seen running, oldest first. */ +export function fetchRuntimes(chain: string, signal?: AbortSignal): Promise { + return get(`/chains/${encodeURIComponent(chain)}/runtimes`, signal) +} + +/** + * One runtime, described by itself. + * + * Served from the cached metadata rather than the node — which is the whole + * reason it was cached: a runtime's description outlives the state the node + * would need to regenerate it. + */ +export function fetchRuntime( + chain: string, + spec: number, + signal?: AbortSignal, +): Promise { + return get(`/chains/${encodeURIComponent(chain)}/runtimes/${spec}`, signal) +} + /** One miner's standing and history. */ export function fetchMiner( chain: string, diff --git a/web/src/components/RuntimePanel.tsx b/web/src/components/RuntimePanel.tsx new file mode 100644 index 0000000..791aaef --- /dev/null +++ b/web/src/components/RuntimePanel.tsx @@ -0,0 +1,266 @@ +/** + * One runtime, described by itself. + * + * Everything on this page came out of metadata the chain produced — + * `state_getMetadata` executed against the runtime WASM in a block's own state + * — and none of it is transcribed from a source tree. That is the distinction + * worth having: a source tree says what the chain *should* be running, and this + * says what it *was*, at a height, for a runtime that may have been upgraded + * past months ago. + * + * Nothing here is written per pallet. A runtime that grew a pallet last week + * renders the same way as `System`, because both describe themselves in the + * same registry. + */ + +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +import type { RuntimeDetail } from '../api/generated/RuntimeDetail' +import type { RuntimePallet } from '../api/generated/RuntimePallet' +import type { RuntimeSummary } from '../api/generated/RuntimeSummary' +import type { RuntimeVariant } from '../api/generated/RuntimeVariant' +import { RequestFailed, fetchRuntime, fetchRuntimes } from '../api/rest' +import { height as fmtHeight, when } from '../lib/format' +import { href } from '../lib/routes' + +/** `3 calls`, `1 call`, or nothing at all for none. */ +function count(n: number, noun: string): string | null { + if (n === 0) return null + return `${n} ${noun}${n === 1 ? '' : 's'}` +} + +/** A variant's signature, the way the runtime's own source would write it. */ +function signature(v: RuntimeVariant): string { + if (v.fields.length === 0) return v.name + const args = v.fields.map((f) => (f.name ? `${f.name}: ${f.type_name}` : f.type_name)) + return `${v.name}(${args.join(', ')})` +} + +/** The first line of a doc comment, which is the summary line by convention. */ +function summary(docs: string[]): string | null { + const first = docs.find((d) => d.trim().length > 0) + return first ? first.trim() : null +} + +function Variants({ title, variants }: { title: string; variants: RuntimeVariant[] }) { + if (variants.length === 0) return null + return ( +
+
+ {title} ({variants.length}) +
+
    + {variants.map((v) => ( +
  • + {/* The index is the byte on the wire, and the reason a call encoded + against one runtime does not decode against another. */} + {v.index} + {signature(v)} + {summary(v.docs) && {summary(v.docs)}} +
  • + ))} +
+
+ ) +} + +function Pallet({ pallet }: { pallet: RuntimePallet }) { + const [open, setOpen] = useState(false) + const counts = [ + count(pallet.calls.length, 'call'), + count(pallet.events.length, 'event'), + count(pallet.errors.length, 'error'), + count(pallet.constants.length, 'constant'), + // "storage" is already the plural of itself here — one storage entry, five + // storage entries — so it takes the noun rather than an s. + pallet.storage.length ? `${pallet.storage.length} storage` : null, + ].filter(Boolean) + + return ( +
+ + + {open && ( +
+ {pallet.constants.length > 0 && ( +
+ {/* The half most worth reading: these are numbers somebody chose, + compiled into the runtime, and reading them out of the chain + beats reading them out of a source tree that may not be the + one this runtime was built from. */} +
Constants ({pallet.constants.length})
+
    + {pallet.constants.map((c) => ( +
  • + {c.name} + + {c.value === null || c.value === undefined + ? '—' + : typeof c.value === 'object' + ? JSON.stringify(c.value) + : String(c.value)} + + {c.type_name} + {summary(c.docs) && {summary(c.docs)}} +
  • + ))} +
+
+ )} + + + + + {pallet.storage.length > 0 && ( +
+
Storage ({pallet.storage.length})
+
    + {pallet.storage.map((st) => ( +
  • + {st.name} + {st.shape} + {st.modifier} + {summary(st.docs) && {summary(st.docs)}} +
  • + ))} +
+
+ )} + + +
+ )} +
+ ) +} + +export function RuntimePanel({ chain, spec }: { chain: string; spec: number }) { + const [detail, setDetail] = useState(null) + const [all, setAll] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + const controller = new AbortController() + setDetail(null) + setError(null) + fetchRuntime(chain, spec, controller.signal) + .then(setDetail) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + return () => controller.abort() + }, [chain, spec]) + + // The list is a separate request because it does not change when the + // selected runtime does — the strip stays put while the body swaps. + useEffect(() => { + const controller = new AbortController() + fetchRuntimes(chain, controller.signal) + .then(setAll) + .catch(() => { + // The strip is navigation, not content. Its absence costs nothing the + // page below does not still say. + }) + return () => controller.abort() + }, [chain]) + + return ( +
+
+
+
Runtime
+

+ {detail ? `${detail.summary.spec_name} v${detail.summary.spec_version}` : `v${spec}`} +

+ {detail && ( +
+
In force from
+
+ + #{fmtHeight(detail.summary.first_seen_height)} + +
+
Metadata
+
+ v{detail.metadata_version} · {detail.types} types ·{' '} + {Math.round(detail.summary.bytes / 1024)} KiB · read{' '} + {when(detail.summary.fetched_at)} +
+
+ )} +
+
+ + {all.length > 1 && ( + + )} + + {error &&

{error}

} + {!error && !detail &&

Reading the runtime…

} + + {detail && ( + <> +
+ {/* The extrinsic envelope, which is where this chain differs from + vanilla Substrate and the reason a hand-written transaction + decoder does not survive an upgrade. Two of these exist nowhere + in Substrate: ReversibleTransactionExtension and + WormholeProofRecorderExtension. */} +
+ Extrinsic envelope (v{detail.extrinsic_version}) — {detail.signed_extensions.length}{' '} + signed extensions, in the order applied +
+
    + {detail.signed_extensions.map((e, i) => ( +
  1. + {i} + {e.identifier} + {e.additional !== '()' && ( + signs over {e.additional} + )} +
  2. + ))} +
+
+ +
+ {detail.pallets.map((p) => ( + + ))} +
+ +

+ Read from the runtime's own metadata — the type registry the node produces by executing + this runtime's WASM against a block in its own state. Nothing here is transcribed from a + source tree, so it describes what the chain ran rather than what a repository says it + should have. Pallet and variant indexes are the bytes on the wire: a pallet inserted + rather than appended renumbers the ones after it, which is what makes a decoder written + against one runtime wrong about another. +

+ + )} +
+ ) +} diff --git a/web/src/index.css b/web/src/index.css index c730f2a..98872a5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1039,3 +1039,155 @@ tr.mine .share-bar > i { font-size: 11px; line-height: 1.6; } + +/* ---- runtime ------------------------------------------------------------- */ + +/* The version strip. Reuses the chain chip: it is the same gesture — a row of + short labels where one is current — and a second visual language for it + would say the two are different sorts of choice. */ +.runtime-strip { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 14px 16px; + border-bottom: var(--rule); +} + +.runtime-pallet { + border-bottom: var(--rule); +} + +.runtime-pallets > .runtime-pallet:last-child { + border-bottom: 0; +} + +/* A whole-row target. A pallet's name is not the only part of the row worth + clicking — the counts beside it are what tells you whether opening it is + worth doing. */ +.runtime-pallet-head { + display: grid; + grid-template-columns: 34px 1fr auto 20px; + align-items: center; + gap: 12px; + width: 100%; + appearance: none; + border: 0; + background: transparent; + color: var(--text-primary); + font: inherit; + text-align: left; + padding: 11px 16px; + cursor: pointer; +} + +.runtime-pallet-head:hover { + background: var(--surface-2); +} + +.runtime-pallet-name { + font-weight: 600; +} + +/* The pallet index, and the variant indexes inside. Monospace and tabular + because they are the bytes on the wire, not decoration: reading down the + column is how you see that a pallet was inserted rather than appended. */ +.runtime-index { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 11px; + color: var(--text-muted); + background: var(--surface-3); + padding: 1px 6px; + text-align: right; + min-width: 26px; +} + +.runtime-counts { + font-size: 11px; + color: var(--text-muted); +} + +.runtime-chevron { + font-family: var(--font-mono); + color: var(--text-muted); + text-align: center; +} + +.runtime-pallet-body { + padding: 4px 16px 18px; + background: var(--surface-1); +} + +.runtime-group { + padding: 12px 0 4px; +} + +.runtime-envelope { + padding: 14px 16px; + border-bottom: var(--rule); +} + +.runtime-list { + list-style: none; + margin: 8px 0 0; + padding: 0; + display: grid; + gap: 5px; +} + +/* Flow rather than a grid: the parts of a line are a name, sometimes a value, + sometimes a type and sometimes a sentence, and a column per possible part + would be four columns mostly empty. */ +.runtime-list li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + font-size: 12px; + line-height: 1.5; +} + +.runtime-list code { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + overflow-wrap: anywhere; +} + +/* The compiled-in value, in the data hue: it is the one thing on the line that + is a fact about this runtime rather than about its shape. */ +.runtime-value { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--data-bright); + overflow-wrap: anywhere; +} + +.runtime-type { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + overflow-wrap: anywhere; +} + +/* `optional` or `default`, which is a property of the entry and belongs on its + line rather than under it. */ +.runtime-modifier { + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + +/* Documentation the runtime carries. Given the whole remaining width so it + wraps as prose, because that is what it is. */ +.runtime-doc { + flex-basis: 100%; + color: var(--text-secondary); + font-size: 11px; + padding-left: 36px; +} + +.runtime-extensions { + counter-reset: none; +} diff --git a/web/src/lib/routes.ts b/web/src/lib/routes.ts index 7631f97..bf86d83 100644 --- a/web/src/lib/routes.ts +++ b/web/src/lib/routes.ts @@ -38,6 +38,8 @@ export interface Route { miner: string | null /** An SS58 address. */ account: string | null + /** A `spec_version`. */ + runtime: number | null } export const EMPTY: Route = { @@ -46,12 +48,25 @@ export const EMPTY: Route = { block: null, miner: null, account: null, + runtime: null, } function asWindow(segment: string | undefined): WindowName | null { return WINDOWS.find((w) => w.id === segment)?.id ?? null } +/** + * A `spec_version`, which is a bare number. + * + * `null` rather than `NaN` for anything else, so a mistyped runtime falls + * through to the front page like every other unrecognised path rather than + * rendering a panel that asks the API about `NaN`. + */ +function spec(segment: string): number | null { + const n = Number(segment.replace(/^v/, '')) + return Number.isInteger(n) && n >= 0 ? n : null +} + /** A block number, or a 32-byte hash. */ export function isBlockRef(segment: string): boolean { return /^\d+$/.test(segment) || /^0x[0-9a-f]{64}$/i.test(segment) @@ -74,6 +89,11 @@ export function parse(pathname: string): Route { if (parts[0] === 'miner' && parts[1]) { return { ...EMPTY, miner: parts[1].toLowerCase() } } + // Likewise a bare runtime: a spec_version is only meaningful against a chain, + // and the default chain is the one a link without one means. + if (parts[0] === 'runtime' && parts[1]) { + return { ...EMPTY, runtime: spec(parts[1]) } + } const chain = parts[0] ?? null if (!chain) return EMPTY @@ -84,6 +104,7 @@ export function parse(pathname: string): Route { if (kind === 'block') return { ...EMPTY, chain, block: value.toLowerCase() } if (kind === 'miner') return { ...EMPTY, chain, miner: value.toLowerCase() } if (kind === 'account') return { ...EMPTY, chain, account: value } + if (kind === 'runtime') return { ...EMPTY, chain, runtime: spec(value) } } // A bare height or hash as the second segment: the shape the site used // before this module existed. Recognised so links already sent stay good. @@ -98,6 +119,9 @@ export function href(route: Partial): string { if (route.block) return `/${chain}/block/${route.block}` if (route.miner) return chain ? `/${chain}/miner/${route.miner}` : `/miner/${route.miner}` if (route.account) return `/${chain}/account/${route.account}` + if (route.runtime !== null && route.runtime !== undefined) { + return chain ? `/${chain}/runtime/${route.runtime}` : `/runtime/${route.runtime}` + } return `/${chain}/${route.window ?? DEFAULT_WINDOW}` }