feat: mark the accounts a chain names, and make genesis unmissable
All checks were successful
deploy / build (push) Successful in 8m4s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s

Some accounts are special and nothing about the address said so. The treasury
looked like any other empty account, the minting sentinel looked like a wallet,
and the twenty-one accounts endowed at genesis looked like they earned it.

No curated list. The chain names them itself, in three places that are three
different claims and are not flattened into one badge: a runtime constant
(compiled in, immutable for that spec_version), a storage value (assigned, and
changeable), and a balance in block zero (history, and not a role — an account
can be endowed and have no job). Labels derive from the chain's own name, so a
pallet added next year is labelled without an edit. The chip is the claim; the
citation beneath it is the evidence.

Two rules hold it together, both learned the hard way and both now in CLAUDE.md.
An account is told from a hash by registry path, never by shape — after
`normalise` both are `0x` and sixty-four hex characters, so `decode_typed` now
returns the account set the decoder collected while it still knew. And a storage
entry names an account only if its value *is* one: `System::Events` is full of
accounts and names none, and the first cut labelled half the chain's active
addresses `Events`.

Computing all of this walks every plain storage entry and enumerates block zero
— dozens of round trips, fine once and pathological on every account page view —
so it is cached for five minutes. Constants change with the runtime, state
assignments almost never, genesis never.

Block zero gets the endowments, because they have no extrinsic and no event and
are invisible to anyone who has not read the chainspec, and a Genesis link sits
in the nav to give somebody who would never think to look an unmissable way to.
Mainnet started with 5,670,000 QTC across 21 accounts — one holds 5,669,940 and
the other twenty got 3 each — shown beside what each holds today, so what a
founding account did with its stake is one row. When genesis state cannot be
read the page says so rather than showing an empty table: missing data and a
chain that endowed nobody are different claims.

Closes #2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
2026-09-10 09:04:45 +03:00
parent c08d3c4e04
commit dd8086df0d
24 changed files with 1087 additions and 45 deletions

View File

@@ -198,6 +198,18 @@ 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`.
**An account is told from a hash by registry path, never by shape.** After
`normalise` an `AccountId32` and an `H256` are both `0x` plus sixty-four hex
characters. `decode_typed` returns the account set the decoder collected while
it still knew, and that is the only sound source — filtering the rendered JSON
by shape claims `System::ParentHash` and `ZkTree::Root` name accounts.
**A storage entry names an account only if its value *is* one.** `System::Events`
is full of accounts and names none of them: they are a payload it holds for one
block. The first cut of the roles index treated "contains an account" as a role
and labelled half the chain's active addresses `Events`. The test is
`value` being a string that is itself in the account set.
**A wrong storage hasher reads as absent, not as an error.** A key is
`twox128(pallet prefix) ++ twox128(item) ++ hashed keys`, and each key's hash is
declared per entry — `System::Account` is `Blake2_128Concat`, other maps are

View File

@@ -16,10 +16,11 @@ use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use blackbeard_entities::{
AccountDetail, AccountEvent, AccountRow, ActivitySource, ApiError, BigUintDec, BlockDetail,
CallIndex, CallSummary, ChainInfo, ChainSeries, ChainState, ChainSummary, EventSummary,
LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, PendingTransfer, RecentBlock,
ReversibleState, RewardSummary, RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet,
AccountDetail, AccountEvent, AccountRoleEntry, AccountRow, ActivitySource, ApiError,
BigUintDec, BlockDetail, CallIndex, CallSummary, ChainInfo, ChainRoles, ChainSeries,
ChainState, ChainSummary, EventSummary, GenesisDetail, LeaderboardRow, MinerDetail, MinerId,
MinerSeriesPoint, NamedAccount, PendingTransfer, RecentBlock, ReversibleState, RewardSummary,
RoleSource, RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet,
RuntimeSignedExtension, RuntimeStorage, RuntimeSummary, RuntimeVariant, StateEntry, Window,
};
use serde::{Deserialize, Serialize};
@@ -63,6 +64,8 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router {
)
.route("/v1/chains/{chain}/state", get(chain_state))
.route("/v1/chains/{chain}/reversible", get(reversible))
.route("/v1/chains/{chain}/roles", get(roles))
.route("/v1/chains/{chain}/genesis", get(genesis))
.route("/v1/chains/{chain}/runtimes", get(runtimes))
.route("/v1/chains/{chain}/runtimes/{spec}", get(runtime))
.route("/v1/ws", get(crate::ws::handler))
@@ -703,6 +706,16 @@ async fn accounts(
.top_accounts(&runtime.id(), INDEX_PAGE)
.await
.map_err(database_unavailable)?;
// Cached, so marking every row costs one lookup rather than a walk.
let named: std::collections::HashMap<String, Vec<AccountRoleEntry>> = cached_roles(&runtime)
.await
.map(|r| {
r.accounts
.into_iter()
.map(|a| (a.account, a.roles))
.collect()
})
.unwrap_or_default();
Ok(Json(
ranked
@@ -712,6 +725,7 @@ async fn accounts(
// Only a real name. `attribute` falls back to the abbreviated
// preimage, which beside the address in the same row would be
// a second spelling of the same thing dressed as an identity.
let roles = named.get(&r.account).cloned().unwrap_or_default();
let held = r.miner.as_ref().map(|m| runtime.attribute(m));
let display = held.as_ref().and_then(|h| match h.source {
blackbeard_entities::AttributionSource::Preimage => None,
@@ -732,12 +746,320 @@ async fn accounts(
display,
blocks: r.blocks,
total: BigUintDec(r.total),
roles,
}
})
.collect(),
))
}
/// How many endowed accounts one genesis read will enumerate.
///
/// A chain can be born with a great many accounts. This is a page, not a
/// census, and the count says so when it is reached.
const GENESIS_LIMIT: u32 = 500;
/// A chip label from the chain's own name for a thing.
///
/// `TreasuryPallet::TreasuryAccount` becomes `Treasury`; `MintingAccount`
/// becomes `Minting`. Derived, never invented — the site does not get to decide
/// what an account is called, only to shorten what the runtime called it. The
/// full citation is carried beside it.
fn role_label(pallet: &str, item: &str) -> String {
let base = item.strip_suffix("Account").unwrap_or(item);
if base.is_empty() {
pallet.to_owned()
} else {
base.to_owned()
}
}
/// How long a computed set of named accounts stays good.
///
/// Constants change only with the runtime, state assignments almost never, and
/// genesis never. Recomputing walks every plain storage entry and enumerates
/// block zero — dozens of round trips — so this is the difference between an
/// account page that loads and one that does not.
const ROLES_TTL: Duration = Duration::from_secs(300);
/// Every account this chain names, and how.
async fn roles(
State(state): State<AppState>,
Path(chain): Path<String>,
) -> Result<Json<ChainRoles>, Failure> {
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
Ok(Json(cached_roles(&runtime).await?))
}
/// The named accounts, recomputed only when stale.
async fn cached_roles(
runtime: &std::sync::Arc<crate::state::ChainRuntime>,
) -> Result<ChainRoles, Failure> {
if let Some((computed, roles)) = runtime.roles.read().await.as_ref()
&& computed.elapsed() < ROLES_TTL
{
return Ok(roles.clone());
}
let fresh = compute_roles(runtime).await?;
*runtime.roles.write().await = Some((std::time::Instant::now(), fresh.clone()));
Ok(fresh)
}
/// Walk every source of specialness a chain has.
async fn compute_roles(runtime: &crate::state::ChainRuntime) -> Result<ChainRoles, Failure> {
let (parsed, spec_version) = {
let inner = runtime.read();
(inner.current_runtime(), inner.spec_version)
};
let Some(parsed) = parsed else {
return Err(Failure(
StatusCode::SERVICE_UNAVAILABLE,
ApiError::new(
"runtime_unknown",
"no runtime metadata cached for this chain yet",
),
));
};
let mut named: std::collections::BTreeMap<String, Vec<AccountRoleEntry>> = Default::default();
let described = parsed.describe();
// Named by a runtime constant. `accounts` comes from the decoder, which
// knows an `AccountId32` from an `H256` by registry path — filtering by
// shape here would claim every hash constant names an account.
for pallet in &described.pallets {
for c in &pallet.constants {
for account in &c.accounts {
named
.entry(account.clone())
.or_default()
.push(AccountRoleEntry {
source: RoleSource::Constant,
cited: format!("{}::{}", pallet.name, c.name),
label: role_label(&pallet.name, &c.name),
docs: c
.docs
.iter()
.find(|d| !d.trim().is_empty())
.map(|d| d.trim().to_owned()),
endowment: None,
});
}
}
}
// Named by a storage value — assigned rather than compiled in, and so
// changeable by whatever call the pallet provides.
for (pallet, item) in parsed.plain_storage() {
let Ok(target) = parsed.storage_key(&pallet, &item, &[]) else {
continue;
};
let key = format!("0x{}", hex::encode(&target.key));
let Ok(Some(raw)) = runtime.rpc.storage(&key, None).await else {
continue;
};
let Ok((value, accounts)) = parsed.decode_typed(parsed.storage_value_ty(&target), &raw)
else {
continue;
};
// The entry's value must *be* an account, not merely contain one.
//
// `System::Events` is full of accounts — every reward, every transfer —
// and none of them is named by it: they appear in a payload it happens
// to hold for one block. Treating "contains an account" as a role
// labelled half the chain's active addresses `Events`. A role is an
// entry whose whole value is the account, which is what
// `TreasuryPallet::TreasuryAccount` is and what a transient log is not.
let names_one = matches!(&value, serde_json::Value::String(v) if accounts.contains(v));
if !names_one {
continue;
}
let docs = described
.pallets
.iter()
.find(|p| p.name == pallet)
.and_then(|p| p.storage.iter().find(|s| s.name == item))
.and_then(|s| s.docs.iter().find(|d| !d.trim().is_empty()))
.map(|d| d.trim().to_owned());
for account in accounts {
named.entry(account).or_default().push(AccountRoleEntry {
source: RoleSource::State,
cited: format!("{pallet}::{item}"),
label: role_label(&pallet, &item),
docs: docs.clone(),
endowment: None,
});
}
}
// Endowed at genesis.
let (endowed, genesis_readable) = genesis_accounts(runtime, &parsed).await;
for (account, amount) in &endowed {
named
.entry(account.clone())
.or_default()
.push(AccountRoleEntry {
source: RoleSource::Genesis,
cited: "genesis".into(),
label: "Genesis".into(),
docs: None,
endowment: Some(BigUintDec(amount.clone())),
});
}
let mut accounts = Vec::new();
for (account, roles) in named {
let (balance, _) = read_balance(runtime, &account).await;
accounts.push(NamedAccount {
address: blackbeard_core::wormhole::ss58_of(&account)
.unwrap_or_else(|| account.clone()),
account,
roles,
balance,
});
}
// A role the runtime or state assigns before a bare endowment: having a job
// is more specific than having been given money at the start.
accounts.sort_by_key(|a| {
(
a.roles.iter().all(|r| r.source == RoleSource::Genesis),
a.address.clone(),
)
});
Ok(ChainRoles {
chain: runtime.id(),
spec_version,
accounts,
genesis_readable,
})
}
/// Block zero, and what it handed out.
async fn genesis(
State(state): State<AppState>,
Path(chain): Path<String>,
) -> Result<Json<GenesisDetail>, Failure> {
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
let Some(parsed) = runtime.read().current_runtime() else {
return Err(Failure(
StatusCode::SERVICE_UNAVAILABLE,
ApiError::new(
"runtime_unknown",
"no runtime metadata cached for this chain yet",
),
));
};
let (endowed, readable) = genesis_accounts(&runtime, &parsed).await;
let mut total = 0u128;
let mut accounts = Vec::new();
for (account, amount) in endowed {
total = total.saturating_add(amount.parse::<u128>().unwrap_or(0));
let (balance, _) = read_balance(&runtime, &account).await;
accounts.push(NamedAccount {
address: blackbeard_core::wormhole::ss58_of(&account)
.unwrap_or_else(|| account.clone()),
account,
roles: vec![AccountRoleEntry {
source: RoleSource::Genesis,
cited: "genesis".into(),
label: "Genesis".into(),
docs: None,
endowment: Some(BigUintDec(amount)),
}],
balance,
});
}
// Largest endowment first: on a page about who the chain started with, the
// size of the stake is the ordering anybody wants.
accounts.sort_by(|a, b| {
let amount = |n: &NamedAccount| {
n.roles
.first()
.and_then(|r| r.endowment.as_ref())
.and_then(|e| e.as_str().parse::<u128>().ok())
.unwrap_or(0)
};
amount(b).cmp(&amount(a))
});
let hash = runtime.rpc.genesis().await.ok().flatten();
let started_at = state
.store
.recorded_block(&runtime.id(), 0)
.await
.ok()
.flatten()
.and_then(|b| b.authored_at);
Ok(Json(GenesisDetail {
chain: runtime.id(),
hash,
total_endowed: BigUintDec(total.to_string()),
endowed: accounts,
started_at,
readable,
}))
}
/// Accounts holding a balance in block zero, with what they were given.
///
/// The endowed set exists only in genesis state, so this enumerates
/// `System::Account` *at block zero* rather than at the tip — which needs an
/// archive node. The second return says whether it could be read at all: an
/// empty list from a pruning node is missing data, not an absence of endowment,
/// and the page must be able to tell a reader which it is looking at.
async fn genesis_accounts(
runtime: &crate::state::ChainRuntime,
parsed: &blackbeard_core::runtime::Runtime,
) -> (Vec<(String, String)>, bool) {
let Ok(Some(genesis_hash)) = runtime.rpc.block_hash(0).await else {
return (Vec::new(), false);
};
let Ok(map) = parsed.storage_map("System", "Account") else {
return (Vec::new(), false);
};
let prefix = format!("0x{}", hex::encode(&map.prefix));
let Ok(keys) = runtime
.rpc
.storage_keys_paged(&prefix, GENESIS_LIMIT, None, Some(&genesis_hash))
.await
else {
return (Vec::new(), false);
};
let mut out = Vec::new();
for key in keys {
let Ok(raw_key) = hex::decode(key.trim_start_matches("0x")) else {
continue;
};
let Some(account) = parsed
.decode_map_key(&map, &raw_key)
.and_then(|v| v.as_str().map(str::to_owned))
else {
continue;
};
let Ok(Some(raw)) = runtime.rpc.storage(&key, Some(&genesis_hash)).await else {
continue;
};
let Ok(info) = parsed.decode_map_value(&map, &raw) else {
continue;
};
let free = info
.get("data")
.and_then(|d| d.get("free"))
.and_then(|f| match f {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
})
.unwrap_or_else(|| "0".into());
out.push((account, free));
}
(out, true)
}
/// How many pending transfers one request will enumerate.
///
/// `MaxPendingPerAccount` is 16, so this is a hundred accounts' worth. A cap
@@ -816,7 +1138,7 @@ async fn reversible(
let prefix = format!("0x{}", hex::encode(&map.prefix));
let keys = runtime
.rpc
.storage_keys_paged(&prefix, PENDING_LIMIT, None)
.storage_keys_paged(&prefix, PENDING_LIMIT, None, None)
.await
.unwrap_or_default();
@@ -1483,6 +1805,10 @@ async fn account(
.map_err(database_unavailable)?;
let (balance, account_state) = read_balance(&runtime, &account).await;
// Every reason this account is special. Computed the same way the roles
// index computes it, from the same sources, so a chip on this page and a
// row on that one cannot disagree.
let roles = account_roles(&runtime, &account).await;
// The name, if telemetry has resolved one, through exactly the path the
// leaderboard uses — so an account page and a standings row cannot
@@ -1506,6 +1832,7 @@ async fn account(
account,
miner,
display,
roles,
balance,
state: account_state,
rewards: RewardSummary {
@@ -1549,6 +1876,25 @@ async fn account(
}))
}
/// Why one account is special, if it is.
///
/// Runs the same sources as the roles index and filters to one account, rather
/// than keeping a second notion of what counts — a chip on an account page and
/// a row on the index disagreeing would be worse than either being absent.
async fn account_roles(
runtime: &std::sync::Arc<crate::state::ChainRuntime>,
account_hex: &str,
) -> Vec<AccountRoleEntry> {
let Ok(all) = cached_roles(runtime).await else {
return Vec::new();
};
all.accounts
.into_iter()
.find(|a| a.account == account_hex)
.map(|a| a.roles)
.unwrap_or_default()
}
/// An account's `System::Account` entry, and its free balance out of it.
///
/// Live from state, not summed from events: those are different numbers and

View File

@@ -159,6 +159,17 @@ pub struct ChainRuntime {
pub inner: RwLock<ChainInner>,
/// Fanout to connected browsers.
pub events: broadcast::Sender<Arc<Broadcast>>,
/// The chain's named accounts, and when they were last computed.
///
/// Computing them walks every plain storage entry and enumerates genesis,
/// which is dozens of RPC round trips — fine once, pathological on every
/// account page view. Constants change only with the runtime, state
/// assignments almost never, and genesis never, so a few minutes stale is
/// indistinguishable from fresh.
///
/// A tokio lock, not the `std` one `inner` uses: this is held across the
/// awaits that fill it, which is exactly what `inner` must never do.
pub roles: tokio::sync::RwLock<Option<(std::time::Instant, blackbeard_entities::ChainRoles)>>,
/// Blocks awaiting their telemetry attribution.
pub pending_attributions: std::sync::Mutex<std::collections::VecDeque<PendingAttribution>>,
/// Live subscriber count per window, so the recompute timer can skip a
@@ -201,6 +212,7 @@ impl ChainRuntime {
rpc,
telemetry: TelemetryFeed::new(),
events,
roles: tokio::sync::RwLock::new(None),
pending_attributions: std::sync::Mutex::new(std::collections::VecDeque::new()),
subscribers: Default::default(),
}

View File

@@ -194,6 +194,10 @@ pub struct ConstantDescription {
/// which would be a runtime describing itself wrongly, worth showing as a
/// gap rather than hiding.
pub value: Option<serde_json::Value>,
/// Any account ids inside that value, identified by registry path rather
/// than by looking like one. This is how `MiningRewards::MintingAccount` is
/// known to name an account while a same-shaped hash constant would not.
pub accounts: Vec<String>,
/// Documentation from the runtime source.
pub docs: Vec<String>,
}
@@ -812,18 +816,7 @@ impl Runtime {
map: &StorageMap,
raw: &[u8],
) -> Result<serde_json::Value, RuntimeError> {
let mut cursor = raw;
let value =
scale_value::scale::decode_as_type(&mut cursor, map.value_ty, &self.metadata.types)
.map_err(|e| RuntimeError::Decode(e.to_string()))?;
if !cursor.is_empty() {
return Err(RuntimeError::Decode(format!(
"{} trailing bytes in storage value",
cursor.len()
)));
}
let mut accounts = BTreeSet::new();
Ok(self.normalise(&value, &mut accounts))
Ok(self.decode_typed(map.value_ty, raw)?.0)
}
/// Decode a storage value against the type its entry declares.
@@ -836,10 +829,25 @@ impl Runtime {
target: &StorageTarget,
raw: &[u8],
) -> Result<serde_json::Value, RuntimeError> {
Ok(self.decode_typed(target.value_ty, raw)?.0)
}
/// Decode a value, and say which of it was an account.
///
/// The accounts cannot be recovered afterwards. `normalise` renders an
/// `AccountId32` and an `H256` identically — both are `0x` and sixty-four
/// hex characters — so telling them apart from the JSON is guesswork that
/// wrongly claims `System::ParentHash` and `ZkTree::Root` are accounts. The
/// registry knows, `normalise` collects them while it still does, and this
/// is how that answer gets out.
pub fn decode_typed(
&self,
ty: u32,
raw: &[u8],
) -> Result<(serde_json::Value, Vec<String>), RuntimeError> {
let mut cursor = raw;
let value =
scale_value::scale::decode_as_type(&mut cursor, target.value_ty, &self.metadata.types)
.map_err(|e| RuntimeError::Decode(e.to_string()))?;
let value = scale_value::scale::decode_as_type(&mut cursor, ty, &self.metadata.types)
.map_err(|e| RuntimeError::Decode(e.to_string()))?;
if !cursor.is_empty() {
return Err(RuntimeError::Decode(format!(
"{} trailing bytes in storage value",
@@ -847,7 +855,13 @@ impl Runtime {
)));
}
let mut accounts = BTreeSet::new();
Ok(self.normalise(&value, &mut accounts))
let rendered = self.normalise(&value, &mut accounts);
Ok((rendered, accounts.into_iter().collect()))
}
/// A storage entry's value type, for decoding a raw read.
pub fn storage_value_ty(&self, target: &StorageTarget) -> u32 {
target.value_ty
}
/// Every plain storage entry, which is everything readable without a key.
@@ -898,17 +912,21 @@ impl Runtime {
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(),
.map(|c| {
let decoded = self.decode_constant(c.ty.id, &c.value);
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: decoded.as_ref().map(|(v, _)| v.clone()),
accounts: decoded.map(|(_, a)| a).unwrap_or_default(),
docs: c.docs.clone(),
}
})
.collect(),
storage: pallet
@@ -1017,15 +1035,8 @@ impl Runtime {
}
/// 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))
fn decode_constant(&self, ty: u32, bytes: &[u8]) -> Option<(serde_json::Value, Vec<String>)> {
self.decode_typed(ty, bytes).ok()
}
/// A readable name for a type id.
@@ -1757,6 +1768,38 @@ mod tests {
);
}
/// The distinction the whole "special accounts" idea rests on. After
/// `normalise`, an `AccountId32` and an `H256` are both `0x` and
/// sixty-four hex characters — telling them apart by shape wrongly claims
/// `System::ParentHash` names an account. The registry knows; this is how
/// the answer gets out.
#[test]
fn an_account_is_distinguished_from_a_hash_by_its_type() {
let rt = Runtime::from_metadata(&metadata()).expect("parses");
let d = rt.describe();
let minting = d
.pallets
.iter()
.find(|p| p.name == "MiningRewards")
.and_then(|p| p.constants.iter().find(|c| c.name == "MintingAccount"))
.expect("declared");
assert_eq!(minting.accounts.len(), 1, "{:?}", minting.accounts);
assert_eq!(
minting.accounts[0].as_str(),
minting.value.as_ref().and_then(|v| v.as_str()).unwrap()
);
// A constant that is a number, not an account.
let target = d
.pallets
.iter()
.find(|p| p.name == "QPoW")
.and_then(|p| p.constants.iter().find(|c| c.name == "TargetBlockTime"))
.expect("declared");
assert!(target.accounts.is_empty(), "{:?}", target.accounts);
}
#[test]
fn a_plain_entry_is_not_a_map() {
let rt = Runtime::from_metadata(&metadata()).expect("parses");

View File

@@ -217,10 +217,16 @@ impl RpcClient {
prefix: &str,
count: u32,
start: Option<&str>,
at: Option<&str>,
) -> Result<Vec<String>, DataError> {
let params = match start {
Some(s) => json!([prefix, count, s]),
None => json!([prefix, count]),
// `at` is what makes genesis readable: the endowed set exists only in
// block zero's state, and enumerating it at the tip returns whatever
// the map holds now instead. Needs an archive node.
let params = match (start, at) {
(Some(s), Some(h)) => json!([prefix, count, s, h]),
(Some(s), None) => json!([prefix, count, s]),
(None, Some(h)) => json!([prefix, count, null, h]),
(None, None) => json!([prefix, count]),
};
let v = self.call("state_getKeysPaged", params).await?;
Ok(v.as_array()

View File

@@ -36,6 +36,10 @@ pub struct AccountDetail {
pub miner: Option<MinerId>,
/// The telemetry name held for that miner, if one is.
pub display: Option<String>,
/// Every reason this account is special, if any — named by a runtime
/// constant, named by a storage value, or endowed at genesis. Empty for
/// almost every account, which is why an entry here is worth marking.
pub roles: Vec<crate::AccountRoleEntry>,
/// The account's free balance right now, read from chain state rather than
/// inferred from the flows below.
///
@@ -101,6 +105,9 @@ pub struct AccountRow {
pub blocks: u64,
/// Their sum, in the chain's smallest unit.
pub total: BigUintDec,
/// Any reason the chain names this account, so a founding or role-holding
/// address is marked here as it is everywhere else.
pub roles: Vec<crate::AccountRoleEntry>,
}
/// What an account has been paid for mining.

View File

@@ -22,6 +22,7 @@ mod chain;
mod error;
mod miner;
mod reversible;
mod roles;
mod runtime;
mod series;
mod state;
@@ -34,6 +35,7 @@ pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tr
pub use error::{ApiError, EntityError};
pub use miner::{AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint};
pub use reversible::{PendingTransfer, ReversibleState};
pub use roles::{AccountRoleEntry, ChainRoles, GenesisDetail, NamedAccount, RoleSource};
pub use runtime::{
RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, RuntimeSignedExtension,
RuntimeStorage, RuntimeSummary, RuntimeVariant,

View File

@@ -0,0 +1,107 @@
//! Accounts the chain names, and how.
//!
//! Some accounts are special and nothing about the address says so. The
//! treasury looks like any other empty account; a minting sentinel looks like a
//! wallet; the accounts endowed at genesis look like they earned it.
//!
//! Nothing here is curated. The chain names these accounts itself — in a
//! runtime constant, in a storage value, or by having a balance in block zero —
//! and every label is derived from the name the chain gave. A pallet added next
//! year gets its accounts labelled without an edit.
//!
//! A role is **what the chain says, never an endorsement**. `MintingAccount` is
//! `0x0101…0101`: a sentinel with no account entry at all, not a wallet.
//! Labelling it as what the runtime calls it is a fact; implying it holds funds
//! or is trustworthy would be the site vouching for an address it knows nothing
//! about.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{BigUintDec, ChainId};
/// Where an account's specialness comes from.
///
/// Three different claims, deliberately not flattened into one badge.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "RoleSource.ts")]
pub enum RoleSource {
/// The value of a runtime constant — compiled in, and immutable for as long
/// as this `spec_version` is in force.
Constant,
/// The value of a storage entry — assigned, and changeable by whatever call
/// the pallet provides for it.
State,
/// Held a balance in block zero. Permanent history, and *not* a role: an
/// account can be endowed and have no job at all.
Genesis,
}
/// One reason an account is special.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "AccountRoleEntry.ts")]
pub struct AccountRoleEntry {
/// Which kind of claim this is.
pub source: RoleSource,
/// The chain's own name for it — `TreasuryPallet::TreasuryAccount`,
/// `MiningRewards::MintingAccount`, or `genesis`. The citation, kept whole.
pub cited: String,
/// A short label for a chip, derived from `cited` and never invented.
pub label: String,
/// The runtime's documentation for the constant or entry that names it.
pub docs: Option<String>,
/// What it was endowed with, for a genesis role.
pub endowment: Option<BigUintDec>,
}
/// One account and every reason it is special.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "NamedAccount.ts")]
pub struct NamedAccount {
/// The raw account id, `0x` hex.
pub account: String,
/// Its SS58 address.
pub address: String,
/// Every reason, most specific first.
pub roles: Vec<AccountRoleEntry>,
/// What it holds now. `null` when the account does not exist on chain —
/// which for a named role is a finding rather than a gap.
pub balance: Option<BigUintDec>,
}
/// Every account this chain names.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "ChainRoles.ts")]
pub struct ChainRoles {
/// Which chain.
pub chain: ChainId,
/// The runtime the constants and entries were read from.
#[ts(type = "number")]
pub spec_version: Option<u32>,
/// The named accounts.
pub accounts: Vec<NamedAccount>,
/// Whether genesis state could be read at all. An archive node holds block
/// zero; a pruning one does not, and the endowed accounts would then be
/// missing rather than absent.
pub genesis_readable: bool,
}
/// Block zero, and what it handed out.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "GenesisDetail.ts")]
pub struct GenesisDetail {
/// Which chain.
pub chain: ChainId,
/// The genesis hash, which is the chain's identity.
pub hash: Option<String>,
/// Accounts endowed in block zero, largest first.
pub endowed: Vec<NamedAccount>,
/// Their sum — the chain's starting issuance, as far as accounts go.
pub total_endowed: BigUintDec,
/// When the chain started, if the observer knows.
pub started_at: Option<DateTime<Utc>>,
/// Whether genesis state could be read.
pub readable: bool,
}

View File

@@ -223,6 +223,55 @@ storage key needs the hasher to have kept it. `Blake2_128Concat` and
`None` there rather than a guess, and such a map can be counted but not
attributed.
## Accounts the chain names, and block zero
Some accounts are special and nothing about the address says so. The treasury
looks like any other empty account; a minting sentinel looks like a wallet; the
accounts endowed at genesis look like they earned it.
Nothing here is curated. The chain names them itself, in three places that are
three **different claims** and are deliberately not flattened into one badge:
- **A runtime constant** — compiled in, immutable for that `spec_version`.
`MiningRewards::MintingAccount` and `Wormhole::MintingAccount`, both
`0x0101…0101`.
- **A storage value** — assigned, and changeable by whatever call the pallet
provides. `TreasuryPallet::TreasuryAccount`.
- **A balance in block zero** — permanent history, and *not* a role. An account
can be endowed and have no job.
Every label is derived from the chain's own name — `TreasuryPallet::
TreasuryAccount` becomes `Treasury` — so a pallet added next year gets its
accounts labelled without an edit. The chip is the claim and the citation
beneath it is the evidence, because "named by a constant" and "named by state"
mean different things about how permanent the arrangement is.
Two rules hold this together. **An account is told from a hash by registry path,
never by shape**: after `normalise` both are `0x` and sixty-four hex characters,
and filtering by shape claims `System::ParentHash` names an account. And **an
entry must *be* an account, not merely contain one** — `System::Events` is full
of them, none named by it, and treating "contains" as a role labelled half the
chain's active addresses `Events` on the first attempt.
A chip states what the chain says and does not endorse it. `MintingAccount` has
no `System::Account` entry at all; labelling it as what the runtime calls it is
a fact, and implying it holds funds would be the site vouching for an address.
### Block zero
Endowments have no extrinsic and no event. They are balances the chain was born
holding, invisible to anyone who has not read the chainspec — so `/:chain/block/0`
carries them, and a **Genesis** link sits in the section nav to give somebody who
would never think to look an unmissable way to.
Mainnet started with 21 accounts and 5,670,000 QTC, of which one account holds
5,669,940 and the other twenty got 3 each. The page shows the endowment beside
what each holds now, so what a founding account did with its stake is one row.
Genesis state lives only on an archive node. When it cannot be read the page
says so rather than showing an empty table, because missing data and a chain
that endowed nobody are not the same claim.
## State: what is true now
Everything else here is history — headers, events, extrinsics, a record of what

View File

@@ -1,5 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AccountEvent } from "./AccountEvent";
import type { AccountRoleEntry } from "./AccountRoleEntry";
import type { BigUintDec } from "./BigUintDec";
import type { ChainId } from "./ChainId";
import type { MinerId } from "./MinerId";
@@ -36,6 +37,12 @@ miner: MinerId | null,
* The telemetry name held for that miner, if one is.
*/
display: string | null,
/**
* Every reason this account is special, if any — named by a runtime
* constant, named by a storage value, or endowed at genesis. Empty for
* almost every account, which is why an entry here is worth marking.
*/
roles: Array<AccountRoleEntry>,
/**
* The account's free balance right now, read from chain state rather than
* inferred from the flows below.

View File

@@ -0,0 +1,29 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BigUintDec } from "./BigUintDec";
import type { RoleSource } from "./RoleSource";
/**
* One reason an account is special.
*/
export type AccountRoleEntry = {
/**
* Which kind of claim this is.
*/
source: RoleSource,
/**
* The chain's own name for it — `TreasuryPallet::TreasuryAccount`,
* `MiningRewards::MintingAccount`, or `genesis`. The citation, kept whole.
*/
cited: string,
/**
* A short label for a chip, derived from `cited` and never invented.
*/
label: string,
/**
* The runtime's documentation for the constant or entry that names it.
*/
docs: string | null,
/**
* What it was endowed with, for a genesis role.
*/
endowment: BigUintDec | null, };

View File

@@ -1,4 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AccountRoleEntry } from "./AccountRoleEntry";
import type { AttributionSource } from "./AttributionSource";
import type { BigUintDec } from "./BigUintDec";
import type { MinerId } from "./MinerId";
@@ -53,4 +54,9 @@ blocks: number,
/**
* Their sum, in the chain's smallest unit.
*/
total: BigUintDec, };
total: BigUintDec,
/**
* Any reason the chain names this account, so a founding or role-holding
* address is marked here as it is everywhere else.
*/
roles: Array<AccountRoleEntry>, };

View File

@@ -0,0 +1,26 @@
// 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 { NamedAccount } from "./NamedAccount";
/**
* Every account this chain names.
*/
export type ChainRoles = {
/**
* Which chain.
*/
chain: ChainId,
/**
* The runtime the constants and entries were read from.
*/
spec_version: number,
/**
* The named accounts.
*/
accounts: Array<NamedAccount>,
/**
* Whether genesis state could be read at all. An archive node holds block
* zero; a pruning one does not, and the endowed accounts would then be
* missing rather than absent.
*/
genesis_readable: boolean, };

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.
import type { BigUintDec } from "./BigUintDec";
import type { ChainId } from "./ChainId";
import type { NamedAccount } from "./NamedAccount";
/**
* Block zero, and what it handed out.
*/
export type GenesisDetail = {
/**
* Which chain.
*/
chain: ChainId,
/**
* The genesis hash, which is the chain's identity.
*/
hash: string | null,
/**
* Accounts endowed in block zero, largest first.
*/
endowed: Array<NamedAccount>,
/**
* Their sum — the chain's starting issuance, as far as accounts go.
*/
total_endowed: BigUintDec,
/**
* When the chain started, if the observer knows.
*/
started_at: string | null,
/**
* Whether genesis state could be read.
*/
readable: boolean, };

View File

@@ -0,0 +1,25 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AccountRoleEntry } from "./AccountRoleEntry";
import type { BigUintDec } from "./BigUintDec";
/**
* One account and every reason it is special.
*/
export type NamedAccount = {
/**
* The raw account id, `0x` hex.
*/
account: string,
/**
* Its SS58 address.
*/
address: string,
/**
* Every reason, most specific first.
*/
roles: Array<AccountRoleEntry>,
/**
* What it holds now. `null` when the account does not exist on chain —
* which for a named role is a finding rather than a gap.
*/
balance: BigUintDec | null, };

View File

@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Where an account's specialness comes from.
*
* Three different claims, deliberately not flattened into one badge.
*/
export type RoleSource = "constant" | "state" | "genesis";

View File

@@ -14,7 +14,9 @@ import type { BlockDetail } from './generated/BlockDetail'
import type { BlockExtrinsic } from './generated/BlockExtrinsic'
import type { CallIndex } from './generated/CallIndex'
import type { ChainSeries } from './generated/ChainSeries'
import type { ChainRoles } from './generated/ChainRoles'
import type { ChainState } from './generated/ChainState'
import type { GenesisDetail } from './generated/GenesisDetail'
import type { ReversibleState } from './generated/ReversibleState'
import type { RecentBlock } from './generated/RecentBlock'
import type { MinerDetail } from './generated/MinerDetail'
@@ -169,6 +171,16 @@ export function fetchReversible(chain: string, signal?: AbortSignal): Promise<Re
return get<ReversibleState>(`/chains/${encodeURIComponent(chain)}/reversible`, signal)
}
/** Every account the chain names, and how. */
export function fetchRoles(chain: string, signal?: AbortSignal): Promise<ChainRoles> {
return get<ChainRoles>(`/chains/${encodeURIComponent(chain)}/roles`, signal)
}
/** Block zero, and what it handed out. */
export function fetchGenesis(chain: string, signal?: AbortSignal): Promise<GenesisDetail> {
return get<GenesisDetail>(`/chains/${encodeURIComponent(chain)}/genesis`, signal)
}
/** Accounts ranked by what they have been paid, most first. */
export function fetchAccounts(chain: string, signal?: AbortSignal): Promise<AccountRow[]> {
return get<AccountRow[]>(`/chains/${encodeURIComponent(chain)}/accounts`, signal)

View File

@@ -23,6 +23,7 @@ import { RequestFailed, fetchAccount } from '../api/rest'
import { exactTokens, height as fmtHeight, shortAddress, tokens, when } from '../lib/format'
import { href } from '../lib/routes'
import { Payload } from './Payload'
import { RoleChips } from './RoleChips'
/** Human labels for what the site sees most. Anything else shows its own
* `Pallet::name`, which is already the runtime's answer and not a guess. */
@@ -91,6 +92,11 @@ export function AccountPanel({
style={{ marginTop: 4, textTransform: detail?.display ? undefined : 'none' }}
>
{detail?.display ?? shortAddress(detail?.address ?? address)}
{detail && detail.roles.length > 0 && (
<span className="title-chips">
<RoleChips roles={detail.roles} />
</span>
)}
</h2>
<dl className="identifiers">
<dt>Address</dt>
@@ -114,6 +120,39 @@ export function AccountPanel({
both already do what a close button would. */}
</div>
{/* The citation. The chip beside the title is the claim; this is the
evidence, and it is where the *kind* of specialness gets stated —
compiled into the runtime, assigned in state, or simply held a
balance in block zero. */}
{detail && detail.roles.length > 0 && (
<div className="banner banner-role">
{detail.roles.map((r) => (
<div key={`${r.source}-${r.cited}`}>
{r.source === 'constant' && (
<>
Named by the runtime constant <code>{r.cited}</code> compiled in, and fixed for
as long as this runtime is in force.
</>
)}
{r.source === 'state' && (
<>
Named by <code>{r.cited}</code> in chain state assigned, and changeable by
whatever call the pallet provides for it.
</>
)}
{r.source === 'genesis' && (
<>
Endowed at genesis
{r.endowment && ` with ${tokens(r.endowment, decimals, 4)} ${symbol}`}. History,
not a job an account can be endowed and have no role at all.
</>
)}
{r.docs && <span className="banner-doc">{r.docs}</span>}
</div>
))}
</div>
)}
{error && <p className="empty">{error}</p>}
{!error && !detail && <p className="empty">Reading the record</p>}

View File

@@ -16,6 +16,7 @@ import type { AccountRow } from '../api/generated/AccountRow'
import { RequestFailed, fetchAccounts } from '../api/rest'
import { exactTokens, height as fmtHeight, shortAddress, tokens } from '../lib/format'
import { href } from '../lib/routes'
import { RoleChips } from './RoleChips'
export function AccountsIndex({
chain,
@@ -116,6 +117,7 @@ export function AccountsIndex({
node?
</span>
)}
<RoleChips roles={row.roles} />
{row.attribution === 'carried' && (
<span
className="chip chip-named chip-guess"

View File

@@ -30,6 +30,7 @@ import {
shortMiner,
} from '../lib/format'
import { href } from '../lib/routes'
import { GenesisPanel } from './GenesisPanel'
import { Payload } from './Payload'
/** A label and its value, with the caveat that makes the value readable. */
@@ -324,6 +325,15 @@ export function BlockPanel({
)}
</div>
{/* Block zero is not an ordinary block. Whoever opened it is exactly
the person who should see who the chain started with, and the
endowments exist nowhere else. Keyed on the height rather than on
the URL so it appears whether the block was reached by number or
by its genesis hash. */}
{detail.height === 0 && (
<GenesisPanel chain={chain} decimals={decimals} symbol={symbol} />
)}
<div className="block-links">
{detail.parent_hash && (
<div>

View File

@@ -0,0 +1,160 @@
/**
* Block zero, and what it handed out.
*
* Endowments happen here and are invisible to anyone who has not read the
* chainspec — there is no extrinsic for them, no event, nothing in the ordinary
* record. They exist only as balances in block zero's state.
*
* Which is why this is attached to the block page rather than filed somewhere a
* reader would have to think to look: whoever opens block zero is exactly the
* person who should see who the chain started with.
*/
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import type { GenesisDetail } from '../api/generated/GenesisDetail'
import { RequestFailed, fetchGenesis } from '../api/rest'
import { exactTokens, shortAddress, tokens, when } from '../lib/format'
import { href } from '../lib/routes'
export function GenesisPanel({
chain,
decimals,
symbol,
}: {
chain: string
decimals: number
symbol: string
}) {
const [detail, setDetail] = useState<GenesisDetail | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
setDetail(null)
setError(null)
fetchGenesis(chain, 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])
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<div>
<div className="eyebrow">Genesis</div>
<h2 className="panel-title" style={{ marginTop: 4 }}>
Who this chain started with
</h2>
{detail?.hash && (
<dl className="identifiers">
<dt>Genesis hash</dt>
<dd className="numeral">{detail.hash}</dd>
{detail.started_at && (
<>
<dt>Started</dt>
<dd className="numeral">{when(detail.started_at)}</dd>
</>
)}
</dl>
)}
</div>
</div>
{error && <p className="empty">{error}</p>}
{!error && detail === null && <p className="empty">Reading block zero</p>}
{detail !== null && !detail.readable && (
/* Missing data, not an absence of endowment. Genesis state lives only
on an archive node, and a page that showed an empty table here would
be asserting something it cannot know. */
<p className="empty">
Block zero's state is not available from this node. Endowments live only there, so they
cannot be shown — this is missing data rather than a chain that endowed nobody.
</p>
)}
{detail !== null && detail.readable && (
<>
<div className="stats" style={{ margin: 0, border: 0, borderBottom: 'var(--rule)' }}>
<div className="stat">
<div className="stat-label">Endowed accounts</div>
<div className="stat-value">{detail.endowed.length}</div>
<div className="stat-note">held a balance in block zero</div>
</div>
<div className="stat">
<div className="stat-label">Handed out</div>
<div
className="stat-value"
title={`${exactTokens(detail.total_endowed, decimals)} ${symbol}`}
>
{tokens(detail.total_endowed, decimals, 2)}
</div>
<div className="stat-note">{symbol} at genesis</div>
</div>
</div>
{detail.endowed.length === 0 ? (
<p className="empty">No account held a balance in block zero.</p>
) : (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
Accounts endowed at genesis, largest first
</caption>
<thead>
<tr>
<th scope="col" className="left">
Account
</th>
<th scope="col">Endowed</th>
<th scope="col">Holds now</th>
</tr>
</thead>
<tbody>
{detail.endowed.map((a) => {
const endowment = a.roles[0]?.endowment ?? null
return (
<tr key={a.account}>
<td className="left">
<Link className="numeral" to={href({ chain, account: a.address })}>
{shortAddress(a.address)}
</Link>
</td>
<td className="numeral">
{endowment ? `${tokens(endowment, decimals)} ${symbol}` : ''}
</td>
<td className="numeral">
{/* Then against now. An endowed account that is empty
spent it; one that has grown has been earning. */}
{a.balance === null ? (
<span className="runtime-modifier">gone</span>
) : (
`${tokens(a.balance, decimals)} ${symbol}`
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<p className="panel-note">
Read from <code>System::Account</code> in block zero's own state. There is no extrinsic
for an endowment and no event it is simply a balance the chain was born holding, which
is why it is invisible to anyone who has not read the chainspec. <em>Holds now</em> is
the same account today, so the two columns together say what each founding account did
with what it was given.
</p>
</>
)}
</section>
)
}

View File

@@ -0,0 +1,47 @@
/**
* How the chain names an account.
*
* A chip states what the chain says and does not endorse it. `MintingAccount`
* is a sentinel with no account entry at all, not a wallet; labelling it as what
* the runtime calls it is a fact, and implying it holds funds or is trustworthy
* would be the site vouching for an address it knows nothing about.
*
* The title carries the citation, because the chip is the claim and the
* citation is the evidence — and the three kinds are different claims. A
* constant is compiled in and immutable for that runtime; a storage value is
* assigned and changeable; a genesis endowment is history and not a role at all.
*/
import type { AccountRoleEntry } from '../api/generated/AccountRoleEntry'
function why(role: AccountRoleEntry): string {
switch (role.source) {
case 'constant':
return `Named by the runtime constant ${role.cited} — compiled in, and fixed for as long as this runtime is in force.`
case 'state':
return `Named by ${role.cited} in chain state — assigned, and changeable by whatever call the pallet provides.`
case 'genesis':
return `Held a balance in block zero. History, not a job: an account can be endowed and have no role.`
}
}
export function RoleChips({ roles }: { roles: AccountRoleEntry[] }) {
if (roles.length === 0) return null
// One chip per label. Two constants naming the same address is two citations
// and one fact, and rendering "Minting Minting" would say otherwise.
const seen = new Set<string>()
const shown = roles.filter((r) => !seen.has(r.label) && seen.add(r.label))
return (
<>
{shown.map((r) => (
<span
key={r.label}
className={r.source === 'genesis' ? 'chip chip-genesis' : 'chip chip-role'}
title={[why(r), r.docs].filter(Boolean).join('\n\n')}
>
{r.label}
</span>
))}
</>
)
}

View File

@@ -48,6 +48,17 @@ export function SectionNav({
>
Standings
</Link>
{/* Straight to block zero. Endowments happen there and are invisible to
anyone who has not read the chainspec, so somebody who would never
think to look is given an unmissable way to. Not a `SECTIONS` entry
because it is not an index — it is one particular block. */}
<Link
to={href({ chain, block: '0' })}
className="section-link"
aria-current={route.block === '0' ? 'page' : undefined}
>
Genesis
</Link>
{SECTIONS.map((s) => (
<Link
key={s.id}

View File

@@ -1335,3 +1335,46 @@ tr.mine .share-bar > i {
align-items: baseline;
gap: 4px 10px;
}
/* A role the chain assigns. Data-hued rather than accent: it is a fact about
the account, not a warning about it, and the accent is spoken for by things
that need attention. */
.chip-role {
color: var(--data-bright);
border-color: var(--data);
background: var(--data-wash);
}
/* Endowed at genesis. Quieter than a role, because it is history rather than a
job — an account can be endowed and do nothing. */
.chip-genesis {
color: var(--text-secondary);
border-color: var(--border-strong);
}
/* Chips beside a panel title, which is a heading and therefore uppercased and
letter-spaced — neither of which a chip should inherit. */
.title-chips {
display: inline-flex;
gap: 6px;
margin-left: 10px;
vertical-align: middle;
letter-spacing: normal;
}
/* The citation under an account's title. Same shape as the chain-status
banners, in the data hue, because it explains rather than warns. */
.banner-role {
border-color: var(--data);
background: var(--data-wash);
color: var(--text-secondary);
display: grid;
gap: 6px;
}
.banner-doc {
display: block;
color: var(--text-muted);
font-size: 11px;
margin-top: 2px;
}