feat: how concentrated the balance is, once the treasury is set aside
deploy / build (push) Successful in 7m40s
deploy / deploy-api (push) Successful in 16s
deploy / deploy-web (push) Successful in 6s

The last piece of #17. It was specified as "the share of issuance held by the
top 1, 10 and 100" plus a Gini, and done literally that produces a number that
is true and says nothing — because the top account is the vesting pot holding
99.5% of the chain. The figure would sit near 1.000 and move only when the pot
paid out, describing an undistributed reserve rather than an economy.

So the pot is reported first and then excluded, by its own storage key rather
than by being the largest: "the biggest account" is a description that would
silently start meaning something else the day the pot drains. Everything else
is computed over what is left and labelled as being outside it.

The denominator is the counted balance, not issuance. The two differ, and in
the direction that matters: a wormhole exit credits its destination without
debiting the note's source, so balances summed across the chain exceed
issuance, and shares of issuance would add up to more than everything.

Measured on mainnet: 1,949 accounts, 27,245 QTC outside the pot, top 1 at
39.6%, top 10 at 72.2%, top 100 at 92.2%, Gini 0.965. That top holder is a
mining address — keyless, its balance a cumulative receipt that cannot fall
however much its operator pays out — so the panel says in its own words that
this is partly a statement about who mined most rather than who holds most.

The Gini lives in `blackbeard-core` with its own tests, per the rule about
statistics being easy to overstate and hard to notice: pinned against the
published coefficient for 1,2,3,4, and against a dust holder beside two whales,
which is the case an `f64` accumulator would round away and report as perfect
equality between the whales.

`RpcClient::storage_values` is new — `state_queryStorageAt`, which turns two
thousand round trips into four. Chunked at 500 rather than sent whole because
nothing documents a ceiling here, and an undocumented ceiling is one that gets
discovered in production.

Nothing renders unless the account walk reached the end of the map. Every share
divides by a total, and a partial sum makes all of them wrong while looking
entirely reasonable.

Closes #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 15:32:11 +03:00
co-authored by Claude Opus 5
parent 67e7b2ec24
commit da7f773564
11 changed files with 575 additions and 9 deletions
+23
View File
@@ -729,6 +729,29 @@ Three specifics worth keeping:
counted twice by any such total. `TotalIssuance` counts each unit once and is
the honest figure. Single blocks reconcile exactly, so this shows up only over
history — a spot check of one block will say everything is fine.
- **Concentration excludes the vesting pot, and says so everywhere it appears.**
The pot holds 99.5% of every unit, so a top-1 share or a Gini computed with it
in describes an undistributed reserve: it would sit near 1.000 and move only
when the pot pays out. `compute_concentration` drops it **by identity** — its
own storage key, derived from `Vesting::PalletId` — never by being the
largest, because "the biggest account" is a description that would silently
start meaning something else the day the pot drains. Measured outside it: top
1 **39.6%**, top 10 72.2%, top 100 92.2%, Gini **0.965**.
The denominator is the **counted balance**, not issuance, for the reason two
paragraphs up: balances summed across the chain exceed issuance, so shares of
issuance would total more than everything.
And the tail is read with care. That 39.6% top holder is a *mining address* —
keyless, its balance a cumulative receipt that cannot fall however much its
operator pays out. Concentration here is partly a statement about who mined
most rather than who holds most, and the panel says so in its own words rather
than leaving a reader to infer it.
Nothing renders unless the key walk reached the end of the map
(`complete`): every share divides by a total, and a partial sum makes all of
them wrong while looking entirely reasonable.
- **Days bucket on the chain's clock**, `authored_at` and `chain_extrinsic.at`,
never on when this observer saw anything. A stretch caught up on carries
observation times minutes apart for blocks spanning days.
+177 -6
View File
@@ -18,12 +18,13 @@ use axum::{Json, Router};
use blackbeard_entities::{
AccountDetail, AccountEvent, AccountFlows, AccountRoleEntry, AccountRow, ActivitySource,
ApiError, BigUintDec, BlockDetail, CallIndex, CallSummary, ChainInfo, ChainRoles, ChainSeries,
ChainState, ChainSummary, DailyActivity, DailyWormhole, Distribution, EventSummary, ExitCohort,
GenesisDetail, LeaderboardRow, LockCounts, MinerDetail, MinerFlow, MinerId, MinerSeriesPoint,
NamedAccount, NetworkSummary, NodeActivity, NodeIndex, NodeInfo, NodeRow, PalletPot,
PendingTransfer, RecentBlock, ReversibleState, RewardSummary, RoleSource, RuntimeConstant,
RuntimeDetail, RuntimeField, RuntimePallet, RuntimeSignedExtension, RuntimeStorage,
RuntimeSummary, RuntimeVariant, StateEntry, TransferRoute, Window, WormholeSummary,
ChainState, ChainSummary, Concentration, DailyActivity, DailyWormhole, Distribution,
EventSummary, ExitCohort, GenesisDetail, LeaderboardRow, LockCounts, MinerDetail, MinerFlow,
MinerId, MinerSeriesPoint, NamedAccount, NetworkSummary, NodeActivity, NodeIndex, NodeInfo,
NodeRow, PalletPot, PendingTransfer, RecentBlock, ReversibleState, RewardSummary, RoleSource,
RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, RuntimeSignedExtension,
RuntimeStorage, RuntimeSummary, RuntimeVariant, StateEntry, TransferRoute, Window,
WormholeSummary,
};
use serde::{Deserialize, Serialize};
use tower_http::compression::CompressionLayer;
@@ -1157,6 +1158,7 @@ async fn network(
accounts: None,
accounts_complete: false,
locks: None,
concentration: None,
vesting_pot: None,
issuance_outside_pot: None,
vesting_schedules: None,
@@ -1222,6 +1224,10 @@ async fn network(
_ => None,
};
// Cached: this walks every account on the chain. `None` while it has never
// succeeded — a page that renders the rest is better than one that fails.
let concentration = cached_concentration(&runtime).await;
// Where almost all of the money actually is. This runtime's vesting is
// pot-based rather than lock-based, so the four empty maps above say
// nothing about how much supply is in play — the pot does, and it holds
@@ -1324,6 +1330,7 @@ async fn network(
accounts,
accounts_complete,
locks,
concentration,
vesting_pot,
issuance_outside_pot,
vesting_schedules,
@@ -1633,6 +1640,170 @@ async fn roles(
}
/// The named accounts, recomputed only when stale.
/// How long a concentration reading is reused.
///
/// Longer than the roles TTL: this walks every account on the chain, and the
/// shape of who holds what does not move in five minutes. A mining reward
/// changes one balance by a third of a token against a total in the millions.
const CONCENTRATION_TTL: Duration = Duration::from_secs(900);
/// Accounts to read before giving up on reaching the end of the map.
///
/// Mainnet holds about two thousand. This is a ceiling on work, and reaching it
/// sets `complete: false` — every share and the Gini are computed against a
/// denominator that would then be a partial sum, so the UI renders nothing
/// rather than a figure that is quietly wrong.
const CONCENTRATION_MAX_ACCOUNTS: usize = 50_000;
async fn cached_concentration(
runtime: &std::sync::Arc<crate::state::ChainRuntime>,
) -> Option<Concentration> {
if let Some((computed, cached)) = runtime.concentration.read().await.as_ref()
&& computed.elapsed() < CONCENTRATION_TTL
{
return Some(cached.clone());
}
let fresh = compute_concentration(runtime).await?;
*runtime.concentration.write().await = Some((std::time::Instant::now(), fresh.clone()));
Some(fresh)
}
/// Walk every account and measure how unevenly the balance outside the pot sits.
///
/// The pot is excluded before anything is computed, not subtracted afterwards.
/// It holds 99.5% of the chain, so a top-1 share or a Gini that included it
/// would describe an undistributed reserve and would barely move for months —
/// see [`Concentration`] for the whole reasoning, which the page repeats to the
/// reader rather than keeping to itself.
async fn compute_concentration(
runtime: &std::sync::Arc<crate::state::ChainRuntime>,
) -> Option<Concentration> {
let parsed = runtime.read().current_runtime()?;
let map = parsed.storage_map("System", "Account").ok()?;
let prefix = format!("0x{}", hex::encode(&map.prefix));
// Every key first, then the values in bulk. One round trip per thousand
// keys and one per five hundred values, against one per account if these
// were read individually.
let mut keys: Vec<String> = Vec::new();
let mut start: Option<String> = None;
let complete = loop {
let page = runtime
.rpc
.storage_keys_paged(
&prefix,
blackbeard_data::rpc::MAX_KEYS_PER_PAGE,
start.as_deref(),
None,
)
.await
.ok()?;
let got = page.len();
keys.extend(page.iter().cloned());
if got < blackbeard_data::rpc::MAX_KEYS_PER_PAGE as usize {
break true;
}
if keys.len() >= CONCENTRATION_MAX_ACCOUNTS {
break false;
}
start = page.last().cloned();
};
let values = runtime.rpc.storage_values(&keys).await.ok()?;
// The pot's key, so it can be left out by identity rather than by being the
// biggest — "the largest account" is a description that would silently
// start meaning something else the day the pot drains.
let pot_key = parsed
.constant_bytes("Vesting", "PalletId")
.and_then(blackbeard_core::pallet::pallet_account)
.and_then(|account| {
parsed
.storage_key(
"System",
"Account",
&[scale_value::Value::from_bytes(account)],
)
.ok()
.map(|t| (account, format!("0x{}", hex::encode(&t.key))))
});
let mut balances: Vec<u128> = Vec::with_capacity(values.len());
let mut counted: u128 = 0;
let mut pot_balance: u128 = 0;
let mut holders = 0u32;
for (key, value) in &values {
let Some(raw) = value else { continue };
let Ok(decoded) = parsed.decode_map_value(&map, raw) else {
continue;
};
let free = decoded
.get("data")
.and_then(|d| d.get("free"))
.and_then(|f| match f {
serde_json::Value::String(s) => s.parse::<u128>().ok(),
serde_json::Value::Number(n) => n.as_u128(),
_ => None,
})
.unwrap_or(0);
counted = counted.saturating_add(free);
if free > 0 {
holders += 1;
}
if pot_key.as_ref().is_some_and(|(_, k)| k == key) {
pot_balance = free;
continue;
}
balances.push(free);
}
balances.sort_unstable();
let outside: u128 = balances.iter().copied().fold(0u128, u128::saturating_add);
// Shares of what is outside the pot. `None` rather than zero where there is
// nothing to divide by: a chain with an empty economy has no concentration,
// which is not the same as a perfectly even one.
let share = |n: usize| -> Option<u32> {
if outside == 0 || balances.is_empty() {
return None;
}
let top: u128 = balances
.iter()
.rev()
.take(n)
.copied()
.fold(0u128, u128::saturating_add);
Some(((top.saturating_mul(10_000)) / outside) as u32)
};
let pot = pot_key.and_then(|(account, _)| {
let hex = hex::encode(account);
blackbeard_core::wormhole::ss58_of(&hex).map(|address| PalletPot {
pallet: "Vesting".into(),
address,
balance: Some(BigUintDec(pot_balance.to_string())),
share_bps: (counted > 0)
.then(|| ((pot_balance.saturating_mul(10_000)) / counted) as u32),
})
});
Some(Concentration {
accounts: keys.len() as u32,
holders,
complete,
counted: BigUintDec(counted.to_string()),
pot,
outside_pot: BigUintDec(outside.to_string()),
top1_bps: share(1),
top10_bps: share(10),
top100_bps: share(100),
gini: blackbeard_core::concentration::gini(&balances),
computed_at: chrono::Utc::now(),
})
}
async fn cached_roles(
runtime: &std::sync::Arc<crate::state::ChainRuntime>,
) -> Result<ChainRoles, Failure> {
+6
View File
@@ -170,6 +170,11 @@ pub struct ChainRuntime {
/// 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)>>,
/// Concentration, cached the same way and for the same reason: computing it
/// walks every account on the chain, which is four round trips rather than
/// one and has no business happening on a page load.
pub concentration:
tokio::sync::RwLock<Option<(std::time::Instant, blackbeard_entities::Concentration)>>,
/// 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
@@ -213,6 +218,7 @@ impl ChainRuntime {
telemetry: TelemetryFeed::new(),
events,
roles: tokio::sync::RwLock::new(None),
concentration: tokio::sync::RwLock::new(None),
pending_attributions: std::sync::Mutex::new(std::collections::VecDeque::new()),
subscribers: Default::default(),
}
+103
View File
@@ -0,0 +1,103 @@
//! How unevenly a set of balances is shared.
//!
//! Here rather than beside its caller because it is arithmetic with no I/O in
//! it, and because a concentration figure is the kind of number that is wrong
//! by a factor and still looks entirely plausible on a page.
/// Gini coefficient over **ascending** values.
///
/// `G = 2·Σ(i·xᵢ) / (n·Σxᵢ) − (n+1)/n`, the standard discrete form with `i`
/// one-based. 0 is an even share; 1 is one holder with everything.
///
/// The sums stay in `u128` and only the final ratio becomes a float. Balances
/// on this chain run to 10^16 and `Σ(i·xᵢ)` over a couple of thousand of them
/// passes 10^19, which is past where an `f64` counts integers exactly —
/// accumulating in one would lose the low end of the distribution, and the low
/// end is the half this coefficient is most sensitive to.
///
/// `None` for an empty set or one that holds nothing at all: there is no
/// concentration to report, and returning zero would say "perfectly equal",
/// which is a different and much stronger claim.
///
/// The caller must sort. Doing it here would mean copying the slice or taking
/// it mutably, and every caller has just sorted anyway to take its top-N.
pub fn gini(ascending: &[u128]) -> Option<f64> {
let n = ascending.len();
if n == 0 {
return None;
}
let total: u128 = ascending.iter().copied().fold(0u128, u128::saturating_add);
if total == 0 {
return None;
}
let weighted: u128 = ascending.iter().enumerate().fold(0u128, |acc, (i, x)| {
acc.saturating_add((i as u128 + 1).saturating_mul(*x))
});
let n_f = n as f64;
let g = 2.0 * (weighted as f64) / (n_f * total as f64) - (n_f + 1.0) / n_f;
// The arithmetic cannot leave [0, 1] for non-negative inputs, but a float
// landing on -0.0000001 would render as "-0.000".
Some(g.clamp(0.0, 1.0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_even_share_is_zero() {
assert_eq!(gini(&[1, 1, 1, 1]), Some(0.0));
assert_eq!(gini(&[7_000, 7_000]), Some(0.0));
}
/// One holder among `n` gives `(n-1)/n`, never quite 1 — the coefficient
/// cannot reach 1 for a finite population, and a test expecting it to would
/// be wrong about the measure rather than about the code.
#[test]
fn one_holder_among_four_is_three_quarters() {
let g = gini(&[0, 0, 0, 4]).expect("non-empty, non-zero");
assert!((g - 0.75).abs() < 1e-12, "{g}");
}
/// Pinned against the textbook value for 1,2,3,4.
#[test]
fn a_known_distribution_matches_its_published_coefficient() {
let g = gini(&[1, 2, 3, 4]).expect("non-empty");
assert!((g - 0.25).abs() < 1e-12, "{g}");
}
/// Nothing to measure is not perfect equality. Reporting zero here would
/// put "0.000 — an even share" on a page describing an empty chain.
#[test]
fn nothing_to_measure_is_absent_rather_than_even() {
assert_eq!(gini(&[]), None);
assert_eq!(gini(&[0, 0, 0]), None);
}
/// The reason the sums are not `f64`. These balances are past 2^53, so an
/// accumulator that lost the small holder would report a perfectly even
/// share of two identical whales instead of the real concentration.
#[test]
fn a_dust_holder_beside_whales_survives_the_arithmetic() {
let whales = 5_000_000_000_000_000_000u128;
let g = gini(&[1, whales, whales]).expect("non-empty");
assert!(g > 0.3, "the dust holder was rounded away: {g}");
assert!(g < 0.34, "{g}");
}
/// Scaling every balance by the same factor cannot change how evenly they
/// are shared. Catches an accumulator overflowing or a denominator drifting.
#[test]
fn the_coefficient_is_scale_invariant() {
let small = gini(&[1, 2, 3, 4, 90]).expect("non-empty");
let large = gini(&[
1_000_000_000_000,
2_000_000_000_000,
3_000_000_000_000,
4_000_000_000_000,
90_000_000_000_000,
])
.expect("non-empty");
assert!((small - large).abs() < 1e-9, "{small} vs {large}");
}
}
+1
View File
@@ -14,6 +14,7 @@
#![deny(missing_docs)]
pub mod attribution;
pub mod concentration;
pub mod digest;
pub mod geo;
pub mod hashrate;
+62
View File
@@ -121,6 +121,14 @@ pub struct ChainProperties {
/// looks exactly like a right one.
pub const MAX_KEYS_PER_PAGE: u32 = 1000;
/// Keys per `state_queryStorageAt` call.
///
/// Measured to work against mainnet's public endpoints rather than read off a
/// spec: unlike `MAX_KEYS_PER_PAGE`, whose 1,000 is the node's own documented
/// refusal, nothing says where the ceiling is here. Half of that leaves room
/// for one endpoint to be stricter than another.
const MAX_KEYS_PER_QUERY: usize = 500;
/// An HTTP JSON-RPC client for one chain, across however many nodes serve it.
///
/// ## Failover, not round-robin
@@ -548,6 +556,60 @@ impl RpcClient {
}
}
/// Read many storage values in one round trip.
///
/// `state_getStorage` is one key per call, which for the account map is
/// nearly two thousand round trips and minutes of wall clock. This is the
/// same answer in four.
///
/// Chunked at [`MAX_KEYS_PER_QUERY`] rather than sent whole: unlike
/// `state_getKeysPaged` the node does not document a ceiling here, and an
/// undocumented ceiling is one that gets discovered in production.
///
/// A key the chain holds nothing at comes back as `None` — an answer, the
/// same as it is for `storage`, not an absence of one.
pub async fn storage_values(
&self,
keys: &[String],
) -> Result<Vec<(String, Option<Vec<u8>>)>, DataError> {
let mut out = Vec::with_capacity(keys.len());
for chunk in keys.chunks(MAX_KEYS_PER_QUERY) {
let v = self.call("state_queryStorageAt", json!([chunk])).await?;
// `[{ block, changes: [[key, value | null], ...] }]` — one element
// per block queried, and this asks about one.
let blocks = v.as_array().ok_or_else(|| DataError::Rpc {
method: "state_queryStorageAt".into(),
message: "expected an array of changesets".into(),
})?;
for block in blocks {
let Some(changes) = block.get("changes").and_then(|c| c.as_array()) else {
continue;
};
for change in changes {
let Some(pair) = change.as_array() else {
continue;
};
let Some(key) = pair.first().and_then(|k| k.as_str()) else {
continue;
};
let value = match pair.get(1).and_then(|v| v.as_str()) {
Some(hex) => {
Some(hex::decode(hex.strip_prefix("0x").unwrap_or(hex)).map_err(
|e| DataError::Rpc {
method: "state_queryStorageAt".into(),
message: format!("value was not hex: {e}"),
},
)?)
}
None => None,
};
out.push((key.to_owned(), value));
}
}
}
Ok(out)
}
/// The runtime's own description of itself, as of `hash`.
///
/// The node executes `Metadata_metadata` against the runtime code in that
+59
View File
@@ -249,6 +249,10 @@ pub struct NetworkSummary {
/// actually in play.
pub vesting_pot: Option<PalletPot>,
/// How concentrated the balance outside the pot is. Cached on a TTL rather
/// than read per request: it walks every account.
pub concentration: Option<Concentration>,
/// Issuance that is not sitting in the vesting pot.
///
/// Deliberately not called "circulating": funds leave the pot on a schedule
@@ -299,6 +303,61 @@ pub struct NetworkSummary {
pub days: Vec<DailyActivity>,
}
/// How concentrated the balance outside the vesting pot is.
///
/// Three things make the obvious version of this figure meaningless on this
/// chain, and all three are why it reads the way it does:
///
/// - **The largest account is the vesting pot**, holding 99.5% of every unit.
/// A top-1 share or a Gini computed with it in describes an undistributed
/// reserve — it would sit near 0.99 and move only when the pot pays out. So
/// the pot is reported separately and then excluded, and every figure here is
/// *outside the pot*.
/// - **The denominator is the counted balance, not issuance.** The sum of
/// account balances exceeds `TotalIssuance`, because a wormhole exit credits
/// its destination without debiting the note's source, so shares of issuance
/// would total more than 100%.
/// - **Much of the tail is keyless.** A mining reward is paid to an address
/// nothing can sign for, so those balances are cumulative receipts that can
/// only rise. Concentration across them is partly a statement about who mined
/// most rather than who holds most, and the page says so.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "Concentration.ts")]
pub struct Concentration {
/// Accounts the map holds.
#[ts(type = "number")]
pub accounts: u32,
/// Of those, how many hold anything at all.
#[ts(type = "number")]
pub holders: u32,
/// Whether the walk reached the end of the map rather than a ceiling.
/// Every figure here is wrong if it did not, so nothing renders without it.
pub complete: bool,
/// Every free balance summed, smallest unit. Named "counted" rather than
/// "supply" deliberately — see the type's documentation.
pub counted: BigUintDec,
/// What the pot holds, and its share.
pub pot: Option<PalletPot>,
/// Balance outside the pot, smallest unit — the denominator of the shares.
pub outside_pot: BigUintDec,
/// Share held by the single largest non-pot account, in parts per ten
/// thousand of `outside_pot`.
#[ts(type = "number | null")]
pub top1_bps: Option<u32>,
/// By the largest ten.
#[ts(type = "number | null")]
pub top10_bps: Option<u32>,
/// By the largest hundred.
#[ts(type = "number | null")]
pub top100_bps: Option<u32>,
/// Gini coefficient over the non-pot accounts, 0 (even) to 1 (one holder).
/// `None` where there is nothing to measure.
#[ts(type = "number | null")]
pub gini: Option<f64>,
/// When this was computed. It is cached, not read per request.
pub computed_at: DateTime<Utc>,
}
/// A pallet's sovereign account and what it holds.
///
/// The address is derived from a `PalletId` the runtime compiles in, read out
+3 -3
View File
@@ -32,9 +32,9 @@ pub use account::{AccountDetail, AccountEvent, AccountRow, ActivitySource, Rewar
pub use block::{BlockDetail, BlockEvent, BlockExtrinsic, BlockObservation, RecentBlock};
pub use call::{CallIndex, CallSummary, EventSummary};
pub use chain::{
AccountFlows, ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, DailyActivity,
DailyWormhole, Distribution, ExitCohort, LockCounts, MinerFlow, NetworkSummary, PalletPot,
Tracking, TransferRoute, WormholeSummary,
AccountFlows, ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Concentration,
DailyActivity, DailyWormhole, Distribution, ExitCohort, LockCounts, MinerFlow, NetworkSummary,
PalletPot, Tracking, TransferRoute, WormholeSummary,
};
pub use error::{ApiError, EntityError};
pub use miner::{
+73
View File
@@ -0,0 +1,73 @@
// 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 { PalletPot } from "./PalletPot";
/**
* How concentrated the balance outside the vesting pot is.
*
* Three things make the obvious version of this figure meaningless on this
* chain, and all three are why it reads the way it does:
*
* - **The largest account is the vesting pot**, holding 99.5% of every unit.
* A top-1 share or a Gini computed with it in describes an undistributed
* reserve — it would sit near 0.99 and move only when the pot pays out. So
* the pot is reported separately and then excluded, and every figure here is
* *outside the pot*.
* - **The denominator is the counted balance, not issuance.** The sum of
* account balances exceeds `TotalIssuance`, because a wormhole exit credits
* its destination without debiting the note's source, so shares of issuance
* would total more than 100%.
* - **Much of the tail is keyless.** A mining reward is paid to an address
* nothing can sign for, so those balances are cumulative receipts that can
* only rise. Concentration across them is partly a statement about who mined
* most rather than who holds most, and the page says so.
*/
export type Concentration = {
/**
* Accounts the map holds.
*/
accounts: number,
/**
* Of those, how many hold anything at all.
*/
holders: number,
/**
* Whether the walk reached the end of the map rather than a ceiling.
* Every figure here is wrong if it did not, so nothing renders without it.
*/
complete: boolean,
/**
* Every free balance summed, smallest unit. Named "counted" rather than
* "supply" deliberately — see the type's documentation.
*/
counted: BigUintDec,
/**
* What the pot holds, and its share.
*/
pot: PalletPot | null,
/**
* Balance outside the pot, smallest unit — the denominator of the shares.
*/
outside_pot: BigUintDec,
/**
* Share held by the single largest non-pot account, in parts per ten
* thousand of `outside_pot`.
*/
top1_bps: number | null,
/**
* By the largest ten.
*/
top10_bps: number | null,
/**
* By the largest hundred.
*/
top100_bps: number | null,
/**
* Gini coefficient over the non-pot accounts, 0 (even) to 1 (one holder).
* `None` where there is nothing to measure.
*/
gini: number | null,
/**
* When this was computed. It is cached, not read per request.
*/
computed_at: string, };
+6
View File
@@ -1,6 +1,7 @@
// 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 { Concentration } from "./Concentration";
import type { DailyActivity } from "./DailyActivity";
import type { LockCounts } from "./LockCounts";
import type { PalletPot } from "./PalletPot";
@@ -81,6 +82,11 @@ locks: LockCounts | null,
* actually in play.
*/
vesting_pot: PalletPot | null,
/**
* How concentrated the balance outside the pot is. Cached on a TTL rather
* than read per request: it walks every account.
*/
concentration: Concentration | null,
/**
* Issuance that is not sitting in the vesting pot.
*
+62
View File
@@ -88,6 +88,13 @@ export function NetworkPanel({
// The vesting pot, which on this chain holds almost everything. Rendered as a
// percentage because two eighteen-digit strings do not compare at a glance,
// and 99.5% is the whole point.
// Only rendered when the account walk reached the end of the map: every share
// here divides by a total, and a partial sum would make all of them wrong
// while looking entirely reasonable.
const conc = summary.concentration
/** Parts per ten thousand as a percentage, or an em dash. */
const bps = (v: number | null) => (v === null ? '—' : `${(v / 100).toFixed(v > 9900 ? 2 : 1)}%`)
const pot = summary.vesting_pot
const potShare =
pot && pot.share_bps !== null
@@ -190,6 +197,61 @@ export function NetworkPanel({
</p>
</section>
{conc !== null && conc.complete && (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Concentration</h2>
<span className="eyebrow">outside the vesting pot</span>
</div>
<div className="node-grid">
<Field
label="Accounts"
value={count(conc.accounts)}
note={`${count(conc.holders)} hold anything`}
/>
<Field
label="Largest holder"
value={bps(conc.top1_bps)}
note="of the balance outside the pot"
/>
<Field label="Largest ten" value={bps(conc.top10_bps)} />
<Field label="Largest hundred" value={bps(conc.top100_bps)} />
<Field
label="Gini"
value={conc.gini === null ? '—' : conc.gini.toFixed(3)}
note="0 even, 1 one holder"
/>
<Field
label="Outside the pot"
value={amount(conc.outside_pot)}
note="what the shares divide"
/>
</div>
<p className="panel-note">
<strong>The vesting pot is excluded from every figure above.</strong> It holds{' '}
{conc.pot?.share_bps === null || conc.pot === null
? 'almost all'
: bps(conc.pot.share_bps)}{' '}
of the chain&apos;s counted balance, and a concentration measure that included it would
describe an undistributed reserve — it would sit near 1.000 and move only when the pot
pays out. These figures divide by what is outside it.
</p>
<p className="panel-note">
The denominator is the <em>counted balance</em>, not total issuance. The two differ: a
wormhole exit credits its destination without debiting the note&apos;s source, so
balances summed across the chain exceed issuance by however much has ever been taken
out. Shares of issuance would add up to more than everything.
</p>
<p className="panel-note">
<strong>Read the tail carefully.</strong> A mining reward is paid to an address derived
from the miner&apos;s own preimage, which nothing can sign for — so those balances are
cumulative receipts that can only rise, and never fall however much their operator pays
out. Concentration across them is in part a statement about who has mined the most
rather than about who holds the most.
</p>
</section>
)}
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Capability</h2>