feat: a runtime's own page
All checks were successful
deploy / build (push) Successful in 7m12s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s

`/quantus/runtime/152` — every pallet, call, event, error, storage entry and
constant the runtime declares, with the constants' values decoded against their
own declared types. Nothing transcribed from a source tree: a source tree says
what the chain should be running, metadata says what it ran.

The set of runtimes is found rather than waited for. Decoding caches one the
moment it needs it, 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 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 — two more than the four upgrade boundaries this
approach was originally verified against.

Discovery is its own task, not a poll step. `state_getRuntimeVersion` at an old
block makes the node instantiate the runtime WASM from that block's state:
~4 s against Heisenberg's endpoint versus ~0.25 s at the tip, and a hundred of
those inside a four-second loop stalls difficulty and the summary broadcast for
minutes on every start.

Reading Heisenberg's two ends side by side is the case for the page. Between
v126 and v148 the chain dropped Referenda, ConvictionVoting, Recovery, Assets
and AssetsHolder, added Vesting and Origins, gained `WeightReclaim` and moved
`ChargeTransactionPayment` after the two Quantus-specific extensions. Every one
breaks a decoder written against the other version and none is visible from a
block.

`U256`/`U512` now render as one decimal rather than four or eight little-endian
limbs, identified by registry path exactly as `AccountId32` already was.
Difficulty as `[1189189, 0, 0, 0, 0, 0, 0, 0]` describes the bytes correctly and
tells a reader nothing. `ChainSummary` gained `spec_version`/`spec_name` so the
footer can say what the site is decoding against, and link to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
2026-09-09 17:45:28 +03:00
parent 1260cc6d64
commit b006f54cd5
25 changed files with 1846 additions and 17 deletions

View File

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

View File

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

View File

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

View File

@@ -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<ChainRuntime>, 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<ChainRuntime>, 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<ChainRuntime>, 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<ChainRuntime>, 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<ChainRuntime>, 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<ChainRuntime>,
low_height: u64,
low_spec: u32,
high_height: u64,
high_spec: u32,
found: &'a mut std::collections::BTreeMap<u32, (String, u64)>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + 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

View File

@@ -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<AppState>,
Path(chain): Path<String>,
) -> Result<Json<Vec<RuntimeSummary>>, 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<AppState>,
Path((chain, spec)): Path<(String, String)>,
) -> Result<Json<RuntimeDetail>, 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<u32, Failure> {
raw.trim_start_matches('v').parse::<u32>().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

View File

@@ -87,6 +87,10 @@ pub struct ChainInner {
pub runtimes: HashMap<u32, Arc<Runtime>>,
/// The runtime the chain is on now, as of the last poll.
pub spec_version: Option<u32>,
/// 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<String>,
/// Current difficulty.
pub difficulty: Option<U512>,
/// 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(),
}
}

View File

@@ -83,6 +83,102 @@ pub struct DecodedEvent {
pub accounts: Vec<String>,
}
/// 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<PalletDescription>,
/// 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<SignedExtensionDescription>,
/// 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<VariantDescription>,
/// Events it can emit.
pub events: Vec<VariantDescription>,
/// Errors its calls can return.
pub errors: Vec<VariantDescription>,
/// Compiled-in constants, with their values.
pub constants: Vec<ConstantDescription>,
/// Storage entries it owns.
pub storage: Vec<StorageDescription>,
}
/// 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<FieldDescription>,
/// Documentation from the runtime source.
pub docs: Vec<String>,
}
/// One field of a variant.
#[derive(Debug, Clone, PartialEq)]
pub struct FieldDescription {
/// `None` for a tuple variant's positional fields.
pub name: Option<String>,
/// 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<serde_json::Value>,
/// Documentation from the runtime source.
pub docs: Vec<String>,
}
/// 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<String>,
}
/// 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<u32, ()>,
/// 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<u32, usize>,
}
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<PalletDescription> = 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<VariantDescription> {
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<serde_json::Value> {
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<u8>` is a `Sequence` of a `u8` and `Option<AccountId32>` 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<String> = 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<String> = 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<u32>, limbs: usize) -> Option<String> {
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<u8> = vec![0];
// total *= 2^32
let scale = |digits: &mut Vec<u8>| {
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<u32>) -> Option<Vec<u8>> {
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<String> = 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

View File

@@ -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<Utc>,
/// 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<Vec<(u32, String, u64)>, DataError> {
/// Every runtime this chain has been seen running, oldest first.
pub async fn cached_runtimes(&self, chain: &ChainId) -> Result<Vec<CachedRuntime>, 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 —

View File

@@ -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<ClientVersion>,
/// 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<u32>,
/// Its `spec_name`, e.g. `quantus-runtime`.
pub spec_name: Option<String>,
/// When these numbers were computed.
pub updated_at: DateTime<Utc>,
}

View File

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

View File

@@ -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<Utc>,
/// 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<RuntimePallet>,
/// 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<RuntimeSignedExtension>,
}
/// 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<RuntimeVariant>,
/// Events it can emit.
pub events: Vec<RuntimeVariant>,
/// Errors its calls can return.
pub errors: Vec<RuntimeVariant>,
/// Constants, with the values this runtime was compiled with.
pub constants: Vec<RuntimeConstant>,
/// Storage entries it owns.
pub storage: Vec<RuntimeStorage>,
}
/// 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<RuntimeField>,
/// Documentation carried in the metadata from the runtime's source.
pub docs: Vec<String>,
}
/// 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<String>,
/// 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<serde_json::Value>,
/// Documentation carried in the metadata.
pub docs: Vec<String>,
}
/// 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<String>,
}
/// 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,
}

View File

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

View File

@@ -80,6 +80,16 @@ finalized_height: number | null,
* genuinely interesting statistic a chain with no RPC still yields.
*/
client_versions: Array<ClientVersion>,
/**
* 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.
*/

View File

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

View File

@@ -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<RuntimePallet>,
/**
* 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<RuntimeSignedExtension>, };

View File

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

View File

@@ -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<RuntimeVariant>,
/**
* Events it can emit.
*/
events: Array<RuntimeVariant>,
/**
* Errors its calls can return.
*/
errors: Array<RuntimeVariant>,
/**
* Constants, with the values this runtime was compiled with.
*/
constants: Array<RuntimeConstant>,
/**
* Storage entries it owns.
*/
storage: Array<RuntimeStorage>, };

View File

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

View File

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

View File

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

View File

@@ -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<RuntimeField>,
/**
* Documentation carried in the metadata from the runtime's source.
*/
docs: Array<string>, };

View File

@@ -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<RuntimeSummary[]> {
return get<RuntimeSummary[]>(`/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<RuntimeDetail> {
return get<RuntimeDetail>(`/chains/${encodeURIComponent(chain)}/runtimes/${spec}`, signal)
}
/** One miner's standing and history. */
export function fetchMiner(
chain: string,

View File

@@ -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 (
<div className="runtime-group">
<div className="eyebrow">
{title} ({variants.length})
</div>
<ul className="runtime-list">
{variants.map((v) => (
<li key={`${title}-${v.index}`}>
{/* The index is the byte on the wire, and the reason a call encoded
against one runtime does not decode against another. */}
<span className="runtime-index">{v.index}</span>
<code>{signature(v)}</code>
{summary(v.docs) && <span className="runtime-doc">{summary(v.docs)}</span>}
</li>
))}
</ul>
</div>
)
}
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 (
<section className="runtime-pallet">
<button className="runtime-pallet-head" aria-expanded={open} onClick={() => setOpen(!open)}>
<span className="runtime-index">{pallet.index}</span>
<span className="runtime-pallet-name">{pallet.name}</span>
<span className="runtime-counts">{counts.join(' · ') || 'nothing declared'}</span>
<span className="runtime-chevron" aria-hidden="true">
{open ? '' : '+'}
</span>
</button>
{open && (
<div className="runtime-pallet-body">
{pallet.constants.length > 0 && (
<div className="runtime-group">
{/* 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. */}
<div className="eyebrow">Constants ({pallet.constants.length})</div>
<ul className="runtime-list">
{pallet.constants.map((c) => (
<li key={c.name}>
<code>{c.name}</code>
<span className="runtime-value">
{c.value === null || c.value === undefined
? '—'
: typeof c.value === 'object'
? JSON.stringify(c.value)
: String(c.value)}
</span>
<span className="runtime-type">{c.type_name}</span>
{summary(c.docs) && <span className="runtime-doc">{summary(c.docs)}</span>}
</li>
))}
</ul>
</div>
)}
<Variants title="Calls" variants={pallet.calls} />
<Variants title="Events" variants={pallet.events} />
{pallet.storage.length > 0 && (
<div className="runtime-group">
<div className="eyebrow">Storage ({pallet.storage.length})</div>
<ul className="runtime-list">
{pallet.storage.map((st) => (
<li key={st.name}>
<code>{st.name}</code>
<span className="runtime-type">{st.shape}</span>
<span className="runtime-modifier">{st.modifier}</span>
{summary(st.docs) && <span className="runtime-doc">{summary(st.docs)}</span>}
</li>
))}
</ul>
</div>
)}
<Variants title="Errors" variants={pallet.errors} />
</div>
)}
</section>
)
}
export function RuntimePanel({ chain, spec }: { chain: string; spec: number }) {
const [detail, setDetail] = useState<RuntimeDetail | null>(null)
const [all, setAll] = useState<RuntimeSummary[]>([])
const [error, setError] = useState<string | null>(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 (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<div>
<div className="eyebrow">Runtime</div>
<h2 className="panel-title" style={{ marginTop: 4 }}>
{detail ? `${detail.summary.spec_name} v${detail.summary.spec_version}` : `v${spec}`}
</h2>
{detail && (
<dl className="identifiers">
<dt>In force from</dt>
<dd className="numeral">
<Link to={href({ chain, block: String(detail.summary.first_seen_height) })}>
#{fmtHeight(detail.summary.first_seen_height)}
</Link>
</dd>
<dt>Metadata</dt>
<dd className="numeral">
v{detail.metadata_version} · {detail.types} types ·{' '}
{Math.round(detail.summary.bytes / 1024)} KiB · read{' '}
{when(detail.summary.fetched_at)}
</dd>
</dl>
)}
</div>
</div>
{all.length > 1 && (
<nav className="runtime-strip" aria-label="Runtime version">
{all.map((r) => (
<Link
key={r.spec_version}
to={href({ chain, runtime: r.spec_version })}
className={r.spec_version === spec ? 'chain-chip is-active' : 'chain-chip'}
aria-current={r.spec_version === spec ? 'page' : undefined}
title={`In force from block ${fmtHeight(r.first_seen_height)}`}
>
<span className="chain-name">v{r.spec_version}</span>
<span className="chain-nodes">#{fmtHeight(r.first_seen_height)}</span>
</Link>
))}
</nav>
)}
{error && <p className="empty">{error}</p>}
{!error && !detail && <p className="empty">Reading the runtime</p>}
{detail && (
<>
<div className="runtime-group runtime-envelope">
{/* 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. */}
<div className="eyebrow">
Extrinsic envelope (v{detail.extrinsic_version}) {detail.signed_extensions.length}{' '}
signed extensions, in the order applied
</div>
<ol className="runtime-list runtime-extensions">
{detail.signed_extensions.map((e, i) => (
<li key={`${e.identifier}-${i}`}>
<span className="runtime-index">{i}</span>
<code>{e.identifier}</code>
{e.additional !== '()' && (
<span className="runtime-type">signs over {e.additional}</span>
)}
</li>
))}
</ol>
</div>
<div className="runtime-pallets">
{detail.pallets.map((p) => (
<Pallet key={p.index} pallet={p} />
))}
</div>
<p className="panel-note">
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.
</p>
</>
)}
</section>
)
}

View File

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

View File

@@ -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<Route>): 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}`
}