feat: mark the accounts a chain names, and make genesis unmissable
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:
12
CLAUDE.md
12
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
107
crates/blackbeard-entities/src/roles.rs
Normal file
107
crates/blackbeard-entities/src/roles.rs
Normal 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,
|
||||
}
|
||||
49
readme.md
49
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
29
web/src/api/generated/AccountRoleEntry.ts
Normal file
29
web/src/api/generated/AccountRoleEntry.ts
Normal 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, };
|
||||
@@ -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>, };
|
||||
|
||||
26
web/src/api/generated/ChainRoles.ts
Normal file
26
web/src/api/generated/ChainRoles.ts
Normal 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, };
|
||||
33
web/src/api/generated/GenesisDetail.ts
Normal file
33
web/src/api/generated/GenesisDetail.ts
Normal 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, };
|
||||
25
web/src/api/generated/NamedAccount.ts
Normal file
25
web/src/api/generated/NamedAccount.ts
Normal 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, };
|
||||
8
web/src/api/generated/RoleSource.ts
Normal file
8
web/src/api/generated/RoleSource.ts
Normal 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";
|
||||
@@ -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)
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
160
web/src/components/GenesisPanel.tsx
Normal file
160
web/src/components/GenesisPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
web/src/components/RoleChips.tsx
Normal file
47
web/src/components/RoleChips.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user