diff --git a/CLAUDE.md b/CLAUDE.md index d07e436..4679972 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 14e8ea8..3102f01 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -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> = 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, + Path(chain): Path, +) -> Result, 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, +) -> Result { + 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 { + 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> = 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, + Path(chain): Path, +) -> Result, 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::().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::().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, + account_hex: &str, +) -> Vec { + 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 diff --git a/crates/blackbeard-api/src/state.rs b/crates/blackbeard-api/src/state.rs index 335b3e6..f080778 100644 --- a/crates/blackbeard-api/src/state.rs +++ b/crates/blackbeard-api/src/state.rs @@ -159,6 +159,17 @@ pub struct ChainRuntime { pub inner: RwLock, /// Fanout to connected browsers. pub events: broadcast::Sender>, + /// 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>, /// Blocks awaiting their telemetry attribution. pub pending_attributions: std::sync::Mutex>, /// 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(), } diff --git a/crates/blackbeard-core/src/runtime.rs b/crates/blackbeard-core/src/runtime.rs index f059a96..ede5580 100644 --- a/crates/blackbeard-core/src/runtime.rs +++ b/crates/blackbeard-core/src/runtime.rs @@ -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, + /// 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, /// Documentation from the runtime source. pub docs: Vec, } @@ -812,18 +816,7 @@ impl Runtime { map: &StorageMap, raw: &[u8], ) -> Result { - 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 { + 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), 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 { - 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)> { + 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"); diff --git a/crates/blackbeard-data/src/rpc.rs b/crates/blackbeard-data/src/rpc.rs index cecc08e..b650a3b 100644 --- a/crates/blackbeard-data/src/rpc.rs +++ b/crates/blackbeard-data/src/rpc.rs @@ -217,10 +217,16 @@ impl RpcClient { prefix: &str, count: u32, start: Option<&str>, + at: Option<&str>, ) -> Result, 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() diff --git a/crates/blackbeard-entities/src/account.rs b/crates/blackbeard-entities/src/account.rs index 659a2d2..920d877 100644 --- a/crates/blackbeard-entities/src/account.rs +++ b/crates/blackbeard-entities/src/account.rs @@ -36,6 +36,10 @@ pub struct AccountDetail { pub miner: Option, /// The telemetry name held for that miner, if one is. pub display: Option, + /// 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, /// 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, } /// What an account has been paid for mining. diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 8f37468..27d7965 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -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, diff --git a/crates/blackbeard-entities/src/roles.rs b/crates/blackbeard-entities/src/roles.rs new file mode 100644 index 0000000..f3a091e --- /dev/null +++ b/crates/blackbeard-entities/src/roles.rs @@ -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, + /// What it was endowed with, for a genesis role. + pub endowment: Option, +} + +/// 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, + /// 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, +} + +/// 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, + /// The named accounts. + pub accounts: Vec, + /// 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, + /// Accounts endowed in block zero, largest first. + pub endowed: Vec, + /// 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>, + /// Whether genesis state could be read. + pub readable: bool, +} diff --git a/readme.md b/readme.md index 825ca91..4478a83 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/web/src/api/generated/AccountDetail.ts b/web/src/api/generated/AccountDetail.ts index afba6df..150247b 100644 --- a/web/src/api/generated/AccountDetail.ts +++ b/web/src/api/generated/AccountDetail.ts @@ -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, /** * The account's free balance right now, read from chain state rather than * inferred from the flows below. diff --git a/web/src/api/generated/AccountRoleEntry.ts b/web/src/api/generated/AccountRoleEntry.ts new file mode 100644 index 0000000..50c5dec --- /dev/null +++ b/web/src/api/generated/AccountRoleEntry.ts @@ -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, }; diff --git a/web/src/api/generated/AccountRow.ts b/web/src/api/generated/AccountRow.ts index abf912c..40a6310 100644 --- a/web/src/api/generated/AccountRow.ts +++ b/web/src/api/generated/AccountRow.ts @@ -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, }; diff --git a/web/src/api/generated/ChainRoles.ts b/web/src/api/generated/ChainRoles.ts new file mode 100644 index 0000000..3f6eed1 --- /dev/null +++ b/web/src/api/generated/ChainRoles.ts @@ -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, +/** + * 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, }; diff --git a/web/src/api/generated/GenesisDetail.ts b/web/src/api/generated/GenesisDetail.ts new file mode 100644 index 0000000..a6cf381 --- /dev/null +++ b/web/src/api/generated/GenesisDetail.ts @@ -0,0 +1,33 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +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, +/** + * 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, }; diff --git a/web/src/api/generated/NamedAccount.ts b/web/src/api/generated/NamedAccount.ts new file mode 100644 index 0000000..ec78af3 --- /dev/null +++ b/web/src/api/generated/NamedAccount.ts @@ -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, +/** + * 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, }; diff --git a/web/src/api/generated/RoleSource.ts b/web/src/api/generated/RoleSource.ts new file mode 100644 index 0000000..bddcf80 --- /dev/null +++ b/web/src/api/generated/RoleSource.ts @@ -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"; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index bd86d6b..7911eff 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -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(`/chains/${encodeURIComponent(chain)}/reversible`, signal) } +/** Every account the chain names, and how. */ +export function fetchRoles(chain: string, signal?: AbortSignal): Promise { + return get(`/chains/${encodeURIComponent(chain)}/roles`, signal) +} + +/** Block zero, and what it handed out. */ +export function fetchGenesis(chain: string, signal?: AbortSignal): Promise { + return get(`/chains/${encodeURIComponent(chain)}/genesis`, signal) +} + /** Accounts ranked by what they have been paid, most first. */ export function fetchAccounts(chain: string, signal?: AbortSignal): Promise { return get(`/chains/${encodeURIComponent(chain)}/accounts`, signal) diff --git a/web/src/components/AccountPanel.tsx b/web/src/components/AccountPanel.tsx index 59e9694..4dea0a2 100644 --- a/web/src/components/AccountPanel.tsx +++ b/web/src/components/AccountPanel.tsx @@ -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 && ( + + + + )}
Address
@@ -114,6 +120,39 @@ export function AccountPanel({ both already do what a close button would. */} + {/* 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 && ( +
+ {detail.roles.map((r) => ( +
+ {r.source === 'constant' && ( + <> + Named by the runtime constant {r.cited} — compiled in, and fixed for + as long as this runtime is in force. + + )} + {r.source === 'state' && ( + <> + Named by {r.cited} 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 && {r.docs}} +
+ ))} +
+ )} + {error &&

{error}

} {!error && !detail &&

Reading the record…

} diff --git a/web/src/components/AccountsIndex.tsx b/web/src/components/AccountsIndex.tsx index 847cdb3..c23e218 100644 --- a/web/src/components/AccountsIndex.tsx +++ b/web/src/components/AccountsIndex.tsx @@ -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? )} + {row.attribution === 'carried' && ( + {/* 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 && ( + + )} +
{detail.parent_hash && (
diff --git a/web/src/components/GenesisPanel.tsx b/web/src/components/GenesisPanel.tsx new file mode 100644 index 0000000..6c783b8 --- /dev/null +++ b/web/src/components/GenesisPanel.tsx @@ -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(null) + const [error, setError] = useState(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 ( +
+
+
+
Genesis
+

+ Who this chain started with +

+ {detail?.hash && ( +
+
Genesis hash
+
{detail.hash}
+ {detail.started_at && ( + <> +
Started
+
{when(detail.started_at)}
+ + )} +
+ )} +
+
+ + {error &&

{error}

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

Reading block zero…

} + + {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. */ +

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

+ )} + + {detail !== null && detail.readable && ( + <> +
+
+
Endowed accounts
+
{detail.endowed.length}
+
held a balance in block zero
+
+
+
Handed out
+
+ {tokens(detail.total_endowed, decimals, 2)} +
+
{symbol} at genesis
+
+
+ + {detail.endowed.length === 0 ? ( +

No account held a balance in block zero.

+ ) : ( +
+ + + + + + + + + + + {detail.endowed.map((a) => { + const endowment = a.roles[0]?.endowment ?? null + return ( + + + + + + ) + })} + +
+ Accounts endowed at genesis, largest first +
+ Account + EndowedHolds now
+ + {shortAddress(a.address)} + + + {endowment ? `${tokens(endowment, decimals)} ${symbol}` : '—'} + + {/* Then against now. An endowed account that is empty + spent it; one that has grown has been earning. */} + {a.balance === null ? ( + gone + ) : ( + `${tokens(a.balance, decimals)} ${symbol}` + )} +
+
+ )} + +

+ Read from System::Account 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. Holds now is + the same account today, so the two columns together say what each founding account did + with what it was given. +

+ + )} +
+ ) +} diff --git a/web/src/components/RoleChips.tsx b/web/src/components/RoleChips.tsx new file mode 100644 index 0000000..aa128a4 --- /dev/null +++ b/web/src/components/RoleChips.tsx @@ -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() + const shown = roles.filter((r) => !seen.has(r.label) && seen.add(r.label)) + return ( + <> + {shown.map((r) => ( + + {r.label} + + ))} + + ) +} diff --git a/web/src/components/SectionNav.tsx b/web/src/components/SectionNav.tsx index d390794..c44010b 100644 --- a/web/src/components/SectionNav.tsx +++ b/web/src/components/SectionNav.tsx @@ -48,6 +48,17 @@ export function SectionNav({ > Standings + {/* 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. */} + + Genesis + {SECTIONS.map((s) => ( 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; +}