diff --git a/CLAUDE.md b/CLAUDE.md
index cc47cf2..7c43ecd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -575,10 +575,25 @@ this — block initialisation with a hidden sender is a mining reward, while
Balances::transfer_allow_death visible 6,941 651
Wormhole::verify_private_batch hidden 1,758 116
-**`retained` is the one measure privacy cannot hide.** Balance over what the
-chain has paid in. A payment debits the account whichever route it takes, so a
-balance that has never fallen is a balance nothing has left — private or
-otherwise. Over 100% means the address received from somewhere besides mining.
+**A mining address is keyless, so its balance is a receipt total and can only
+rise.** This was once written here as "`retained` is the one measure privacy
+cannot hide", on the reasoning that a payment debits the account whichever route
+it takes. **That reasoning is wrong on this chain and the column is gone.**
+
+`pallets/mining-rewards` pays `derive_wormhole_address(preimage)`, a Poseidon2
+hash no Dilithium keypair signs for, so nothing can transfer out. The only way to
+move a reward is a wormhole exit, and `Wormhole::credit_and_record`
+(`pallets/wormhole/src/lib.rs:1348`) credits the destination with
+`Unbalanced::increase_balance` and **never debits the note's source**. So the
+balance never falls, for anybody. Measured across all of mainnet — QUANPOOL at
+twelve samples from block 1,000 to 42,400, rising at every one, nonce 0
+throughout.
+
+Which means `retained` read ~100% for every miner *because it had to*, and a
+number identical for everyone reads as an accusation pointed at whoever is top of
+the table. A pool paying out in full and one paying nothing produce the same row.
+The balance column is `received`, never `holds` or `balance`: those words assert
+control that does not exist.
**What the numbers cannot settle**, and what the page therefore says out loud:
an operator may hold several addresses; may settle off-chain; or may be a solo
@@ -592,8 +607,10 @@ and the table calls the name column its weakest. A site that flagged entities as
delinquent on the strength of a name they chose themselves is one bad match away
from libelling someone.
-What earns the table its place is **comparison**, and the comparison has to be
-like for like. Measured over the full index, *every one* of the top 25 miners
+What earns the table its place is the shape of **who wins blocks**, and nothing
+more — the payout question belongs on the wormhole page, which is the only place
+any of it is observable. What comparison it does support has to be like for
+like. Measured over the full index, *every one* of the top 25 miners
shows zero visible outflow and holds almost exactly what it mined — including a
second address calling itself `quantus-mainnet-pool`. Zero outflow is universal
among mining addresses here, so it distinguishes nobody. The accounts that do
@@ -602,8 +619,8 @@ funded from the genesis endowment, so setting a miner beside them is not a
comparison, it is a category error. That mistake was made in this repository
before the numbers came in.
-**Two columns are not measured the same way.** `holds` is chain state, true now;
-`mined`, `sent on` and `recipients` are over the indexed range. On a chain read
+**Two columns are not measured the same way.** `received` is chain state, true
+now; `mined`, `sent on` and `recipients` are over the indexed range. On a chain read
from genesis they agree; on one still being indexed backwards a miner appears to
hold far more than it ever mined. The note under the table says so, because the
discrepancy looks exactly like the thing the table is for.
@@ -656,12 +673,30 @@ Three specifics worth keeping:
- **Signed extrinsics are separated from the total.** Most traffic on this chain
is the timestamp inherent, so one "transactions per day" line would be mostly
clockwork — a number that reads as adoption and is not.
-- **There is no "circulating supply" figure.** `Balances::Locks`, `Holds`,
- `Freezes` and `Reserves` are all empty — counted, not assumed — so circulating
- would equal total issuance exactly, and printing it would assert a distinction
- the chain does not make. The page states what was counted instead. The 48
- vesting schedules are a *count*, not a sum, because this runtime's vesting does
- not touch the balances locks and its release schedule would be a guess.
+- **Four empty lock maps are not "nothing is withheld".** `Balances::Locks`,
+ `Holds`, `Freezes` and `Reserves` are all empty — counted, not assumed — and
+ this file once drew the conclusion that circulating therefore equals total
+ issuance. **Wrong by a factor of 436.** This runtime's vesting is *pot-based,
+ not lock-based*: `pallets/vesting` parks undistributed funds in the account its
+ `PalletId` owns and pays out from there, which is exactly why no lock records
+ them. `modlqvesting` held **5,669,940 QTC — 99.54%** of every unit of balance
+ at block 42,400, against issuance of 5,682,969. The page reports the pot and
+ issuance-minus-pot; it still declines to say "circulating", because funds leave
+ the pot on a schedule this observer does not read. The pot address comes from
+ `Runtime::constant_bytes("Vesting", "PalletId")` through
+ `blackbeard_core::pallet::pallet_account`, never from a hard-coded `qvesting` —
+ the address is a consequence of a runtime constant, and a runtime that changed
+ it would leave the site reporting an empty pot while looking perfectly healthy.
+ Both steps are pinned by tests against the real mainnet metadata fixture.
+
+- **Never sum account balances and call it supply.** They *exceed*
+ `TotalIssuance` and the gap grows — +13,427 QTC by block 42,400, exactly zero
+ at genesis. Bisected to block 4,800, whose only non-inherent extrinsic is a
+ `Wormhole::verify_public_batch`: an exit credits its destination without
+ debiting the source and without moving issuance, so privately-moved value is
+ 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.
- **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.
diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs
index ffe0d96..aec4f99 100644
--- a/crates/blackbeard-api/src/routes.rs
+++ b/crates/blackbeard-api/src/routes.rs
@@ -20,10 +20,10 @@ use blackbeard_entities::{
ApiError, BigUintDec, BlockDetail, CallIndex, CallSummary, ChainInfo, ChainRoles, ChainSeries,
ChainState, ChainSummary, DailyActivity, DailyWormhole, Distribution, EventSummary,
GenesisDetail, LeaderboardRow, LockCounts, MinerDetail, MinerFlow, MinerId, MinerSeriesPoint,
- NamedAccount, NetworkSummary, NodeActivity, NodeIndex, NodeInfo, NodeRow, PendingTransfer,
- RecentBlock, ReversibleState, RewardSummary, RoleSource, RuntimeConstant, RuntimeDetail,
- RuntimeField, RuntimePallet, RuntimeSignedExtension, RuntimeStorage, RuntimeSummary,
- RuntimeVariant, StateEntry, TransferRoute, Window, WormholeSummary,
+ 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;
@@ -964,7 +964,11 @@ async fn distribution(
.map(|r| BigUintDec(r.total)),
None => None,
};
- let balance = match &account {
+ // Everything ever credited, which for a mining address is all this
+ // number can mean: it is keyless, so nothing transfers out, and a
+ // wormhole exit credits its destination without debiting here. Called
+ // `received` all the way to the UI so nobody reads it as a holding.
+ let received = match &account {
Some(account) => read_balance(&runtime, account).await.0,
None => None,
};
@@ -974,7 +978,7 @@ async fn distribution(
blocks: row.blocks,
share: row.share,
mined,
- balance,
+ received,
flows: account
.as_ref()
.and_then(|a| flows.get(a))
@@ -1136,6 +1140,8 @@ async fn network(
accounts: None,
accounts_complete: false,
locks: None,
+ vesting_pot: None,
+ issuance_outside_pot: None,
vesting_schedules: None,
referenda: None,
reversible_transfers: None,
@@ -1199,6 +1205,54 @@ async fn network(
_ => None,
};
+ // 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
+ // 99.5% of it. Derived from the `PalletId` constant in metadata, never from
+ // a hard-coded `qvesting`: the address is a consequence of that constant,
+ // and a runtime that changed it would leave this reporting an empty pot
+ // while looking entirely healthy.
+ let vesting_pot = match parsed
+ .constant_bytes("Vesting", "PalletId")
+ .and_then(blackbeard_core::pallet::pallet_account)
+ {
+ Some(account) => {
+ let hex = hex::encode(account);
+ let (balance, _) = read_balance(&runtime, &hex).await;
+ let share_bps = balance
+ .as_ref()
+ .and_then(|b| b.0.parse::().ok())
+ .zip(
+ total_issuance
+ .as_ref()
+ .and_then(|i| i.0.parse::().ok()),
+ )
+ .filter(|(_, issued)| *issued > 0)
+ .map(|(held, issued)| (held.saturating_mul(10_000) / issued) as u32);
+ blackbeard_core::wormhole::ss58_of(&hex).map(|address| PalletPot {
+ pallet: "Vesting".into(),
+ address,
+ balance,
+ share_bps,
+ })
+ }
+ None => None,
+ };
+
+ // Saturating, and only when both are known. A pot larger than issuance
+ // would mean one of the two reads is wrong, and a huge positive number is a
+ // worse answer than none at all.
+ let issuance_outside_pot = total_issuance
+ .as_ref()
+ .and_then(|i| i.0.parse::().ok())
+ .zip(
+ vesting_pot
+ .as_ref()
+ .and_then(|p| p.balance.as_ref())
+ .and_then(|b| b.0.parse::().ok()),
+ )
+ .map(|(issued, pot)| BigUintDec(issued.saturating_sub(pot).to_string()));
+
// What genesis handed out. The same walk `/genesis` makes, and the reason
// emission-to-date can be stated at all: issuance alone cannot distinguish
// what was minted from what was granted.
@@ -1253,6 +1307,8 @@ async fn network(
accounts,
accounts_complete,
locks,
+ vesting_pot,
+ issuance_outside_pot,
vesting_schedules,
referenda,
reversible_transfers,
diff --git a/crates/blackbeard-core/src/lib.rs b/crates/blackbeard-core/src/lib.rs
index 417c163..4223678 100644
--- a/crates/blackbeard-core/src/lib.rs
+++ b/crates/blackbeard-core/src/lib.rs
@@ -17,6 +17,7 @@ pub mod attribution;
pub mod digest;
pub mod geo;
pub mod hashrate;
+pub mod pallet;
pub mod runtime;
pub mod scale;
pub mod series;
diff --git a/crates/blackbeard-core/src/pallet.rs b/crates/blackbeard-core/src/pallet.rs
new file mode 100644
index 0000000..9ddc061
--- /dev/null
+++ b/crates/blackbeard-core/src/pallet.rs
@@ -0,0 +1,71 @@
+//! The sovereign account a `PalletId` owns.
+//!
+//! Some pallets hold funds in an account nobody has a key for, derived from an
+//! eight-byte identifier the runtime compiles in. On this chain that is where
+//! almost all of the money is: the vesting pot holds 99.5% of every unit of
+//! balance, so a supply figure that does not know about it describes a chain
+//! nobody is living on.
+
+/// The four-byte type tag Substrate prefixes a `PalletId` account with.
+///
+/// From `AccountIdConversion`, which encodes `(b"modl", self)` and reads the
+/// result back through a zero-padding input. Written out rather than pulled
+/// from `frame-support` for the reason the readme gives for declining `subxt`:
+/// four bytes of constant are not worth a runtime's worth of dependencies.
+const TYPE_ID: &[u8; 4] = b"modl";
+
+/// The 32-byte account a `PalletId` owns, or `None` if `id` is not eight bytes.
+///
+/// `modl` ++ the identifier ++ zeros, which is `into_account_truncating` with
+/// the truncation unreachable: four plus eight is twelve, so the padding always
+/// wins and nothing is ever cut. Substrate's version silently truncates a longer
+/// seed; this one refuses a wrong-sized one instead, because the only way to get
+/// here with the wrong length is to have read the wrong constant, and a derived
+/// address that is quietly not the pallet's is indistinguishable from a pallet
+/// holding nothing.
+pub fn pallet_account(id: &[u8]) -> Option<[u8; 32]> {
+ if id.len() != 8 {
+ return None;
+ }
+ let mut account = [0u8; 32];
+ account[..4].copy_from_slice(TYPE_ID);
+ account[4..12].copy_from_slice(id);
+ Some(account)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Pinned against the account read off mainnet by hand, the same way
+ /// `storage_key`'s test is: this is a derivation nothing else would catch.
+ /// At block 42,400 this account held 5,669,940.001 QTC — 99.54% of every
+ /// unit of balance on the chain.
+ #[test]
+ fn the_vesting_pot_derives_to_the_account_mainnet_actually_holds() {
+ let account = pallet_account(b"qvesting").expect("eight bytes");
+ assert_eq!(
+ hex::encode(account),
+ "6d6f646c7176657374696e670000000000000000000000000000000000000000"
+ );
+ }
+
+ /// `modl` is literal, and the identifier is ASCII in practice, so the whole
+ /// account reads back as text. Worth pinning: it is how the account was
+ /// recognised in the first place.
+ #[test]
+ fn a_pallet_account_reads_as_its_own_name() {
+ let account = pallet_account(b"py/trsry").expect("eight bytes");
+ assert_eq!(&account[..12], b"modlpy/trsry");
+ assert!(account[12..].iter().all(|b| *b == 0));
+ }
+
+ /// The wrong constant is the realistic way to arrive here, and a wrong
+ /// answer would look exactly like a pallet holding nothing.
+ #[test]
+ fn a_seed_that_is_not_eight_bytes_is_refused_rather_than_padded() {
+ assert_eq!(pallet_account(b"short"), None);
+ assert_eq!(pallet_account(b"far too long to be a pallet id"), None);
+ assert_eq!(pallet_account(&[]), None);
+ }
+}
diff --git a/crates/blackbeard-core/src/runtime.rs b/crates/blackbeard-core/src/runtime.rs
index dc8d2f7..0cae671 100644
--- a/crates/blackbeard-core/src/runtime.rs
+++ b/crates/blackbeard-core/src/runtime.rs
@@ -791,6 +791,30 @@ impl Runtime {
/// Whether the key is *recoverable* from a storage key depends on the
/// hasher. The `Concat` variants keep the key after its hash, so it can be
/// read back; the plain ones do not, and such a map can be enumerated but
+ /// A pallet constant's value exactly as the runtime compiled it, undecoded.
+ ///
+ /// `describe` renders constants for display; this hands back the bytes,
+ /// because some of them are inputs to a derivation rather than something to
+ /// show. `Vesting::PalletId` is the case that forced it: the eight bytes are
+ /// meaningless to a reader and are the whole of what
+ /// [`crate::pallet_account`] needs.
+ ///
+ /// Reading it from metadata rather than hard-coding `qvesting` is the point.
+ /// The pot address is a consequence of a runtime constant, so a runtime that
+ /// changed it would move the chain's largest balance somewhere this observer
+ /// was still not looking, and the page would report a pot of nothing with
+ /// every appearance of working.
+ pub fn constant_bytes(&self, pallet: &str, name: &str) -> Option<&[u8]> {
+ self.metadata
+ .pallets
+ .iter()
+ .find(|p| p.name == pallet)?
+ .constants
+ .iter()
+ .find(|c| c.name == name)
+ .map(|c| c.value.as_slice())
+ }
+
/// not attributed. `key_offset` says which, and is `None` for the second
/// case rather than a guess.
pub fn storage_map(&self, pallet: &str, item: &str) -> Result {
@@ -1487,6 +1511,38 @@ mod tests {
hex::decode(hex.trim().trim_start_matches("0x")).expect("fixture is hex")
}
+ /// The whole path the network page takes to the chain's largest balance:
+ /// metadata constant -> eight bytes -> pallet account -> the address
+ /// mainnet actually holds 99.5% of everything in. Pinned against the real
+ /// runtime rather than a constructed one, because every step of it is a
+ /// convention that a runtime upgrade could change under us — and the
+ /// failure mode is a pot reported as empty, which looks like a healthy
+ /// chain rather than a broken read.
+ #[test]
+ fn the_vesting_pot_address_is_reachable_from_metadata_alone() {
+ let rt = Runtime::from_metadata(&metadata()).expect("parses");
+ let id = rt
+ .constant_bytes("Vesting", "PalletId")
+ .expect("this runtime declares Vesting::PalletId");
+ assert_eq!(id, b"qvesting", "the constant is the raw eight bytes");
+
+ let account = crate::pallet::pallet_account(id).expect("eight bytes");
+ assert_eq!(
+ hex::encode(account),
+ "6d6f646c7176657374696e670000000000000000000000000000000000000000"
+ );
+ }
+
+ /// A constant that is not there comes back absent rather than as something
+ /// plausible. The pot read is `and_then`ed off this, so a wrong pallet name
+ /// must not produce an address at all.
+ #[test]
+ fn an_absent_constant_is_absent() {
+ let rt = Runtime::from_metadata(&metadata()).expect("parses");
+ assert_eq!(rt.constant_bytes("Vesting", "NotAConstant"), None);
+ assert_eq!(rt.constant_bytes("NoSuchPallet", "PalletId"), None);
+ }
+
#[test]
fn a_runtime_describes_its_own_pallets() {
let rt = Runtime::from_metadata(&metadata()).expect("parses");
diff --git a/crates/blackbeard-entities/src/chain.rs b/crates/blackbeard-entities/src/chain.rs
index 96b0f59..f799f0e 100644
--- a/crates/blackbeard-entities/src/chain.rs
+++ b/crates/blackbeard-entities/src/chain.rs
@@ -196,6 +196,13 @@ pub struct ChainSummary {
/// `indexed_from`..`indexed_to` — which on a chain still being walked backwards
/// is not the whole chain. Anything rendering these has to say which range it
/// covered, or it describes a period its axis does not.
+///
+/// One figure is deliberately absent: **the sum of account balances**. On this
+/// chain it is not supply and exceeds `total_issuance` by the cumulative value
+/// ever taken out of the wormhole, because an exit credits its destination
+/// without debiting the note's source and without moving issuance. Adding
+/// balances up and calling the result supply overstated it by 13,427 QTC at
+/// block 42,400, growing. `total_issuance` is the honest figure.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "NetworkSummary.ts")]
pub struct NetworkSummary {
@@ -227,16 +234,31 @@ pub struct NetworkSummary {
/// Entries in `Balances::Locks`, `Holds`, `Freezes` and `Reserves`.
///
/// All four are counted because all four are ways supply can be immobile,
- /// and the useful statement on this chain is that every one of them is
- /// empty. **That is why there is no "circulating supply" field**: with
- /// nothing locked it would equal total issuance exactly, and printing it as
- /// a separate headline would assert a distinction the chain does not
- /// currently make.
+ /// and on this chain every one of them is empty. **That does not mean
+ /// nothing is withheld** — see `vesting_pot`. Reading four empty maps as
+ /// "everything is circulating" is exactly the mistake this field was once
+ /// used to justify, and it was wrong by a factor of 436.
pub locks: Option,
- /// `Vesting::Schedules` entries. Counted, not summed: this runtime's
- /// vesting does not touch `Balances::Locks`, so what it holds and when it
- /// releases would be a guess without reading the pallet's own maths.
+ /// The vesting pallet's own account, and what it holds.
+ ///
+ /// This runtime's vesting is **pot-based, not lock-based**: undistributed
+ /// funds sit in the account its `PalletId` owns and are paid out from
+ /// there, rather than being issued to beneficiaries and frozen. That is why
+ /// `locks` reads empty, and why it says nothing about how much supply is
+ /// actually in play.
+ pub vesting_pot: Option,
+
+ /// Issuance that is not sitting in the vesting pot.
+ ///
+ /// Deliberately not called "circulating": funds leave the pot on a schedule
+ /// this observer does not read, and an address outside it may still be
+ /// unspendable. What can be said is that the pot's contents have not been
+ /// distributed to anyone, so this is the ceiling on what has.
+ pub issuance_outside_pot: Option,
+
+ /// `Vesting::Schedules` entries. Counted, not summed: the sum that matters
+ /// is the pot, which is a single storage read and is reported above.
#[ts(type = "number")]
pub vesting_schedules: Option,
/// `TechReferenda::ReferendumCount` — referenda ever opened.
@@ -277,6 +299,37 @@ pub struct NetworkSummary {
pub days: Vec,
}
+/// A pallet's sovereign account and what it holds.
+///
+/// The address is derived from a `PalletId` the runtime compiles in, read out
+/// of metadata rather than hard-coded — a runtime that changed it would
+/// otherwise move the chain's largest balance somewhere this observer was still
+/// not looking, and the pot would report as empty with every appearance of
+/// working.
+#[derive(Debug, Clone, Serialize, Deserialize, TS)]
+#[ts(export, export_to = "PalletPot.ts")]
+pub struct PalletPot {
+ /// Which pallet owns it.
+ pub pallet: String,
+ /// Its SS58 address.
+ pub address: String,
+ /// Free balance, in the smallest unit. `None` if the account does not
+ /// exist — which is not the same as holding nothing.
+ pub balance: Option,
+ /// Its share of `total_issuance`, in parts per ten thousand, so a reader
+ /// does not have to divide two eighteen-digit strings to see that one
+ /// account holds almost all of it.
+ ///
+ /// Declared `number | null` rather than the bare `number` its neighbours
+ /// use. The override exists because `serde_json` writes a u64 as a JSON
+ /// number while ts-rs would call it a `bigint`; it is not licence to also
+ /// drop the `null`, which the wire really does send when either side of the
+ /// division is unknown. The older fields understate this and the frontend
+ /// guards them anyway.
+ #[ts(type = "number | null")]
+ pub share_bps: Option,
+}
+
/// How many accounts have supply immobilised, by each mechanism.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
#[ts(export, export_to = "LockCounts.ts")]
@@ -385,8 +438,21 @@ pub struct MinerFlow {
pub share: f64,
/// What the chain has paid it in rewards, over the indexed range.
pub mined: Option,
- /// What it holds now, read from chain state.
- pub balance: Option,
+ /// Everything ever credited to the address, read from chain state.
+ ///
+ /// **This is a receipt total, not a holding, and it can only ever rise.** A
+ /// mining reward is paid to `derive_wormhole_address(preimage)`, a Poseidon2
+ /// hash that no keypair signs for, so nothing can transfer out of it. The
+ /// only way to move the value is a wormhole exit, and
+ /// `Wormhole::credit_and_record` credits the destination without debiting
+ /// the note's source. The balance therefore never falls, for any miner, no
+ /// matter what its operator does.
+ ///
+ /// Named `received` rather than `balance` for that reason: a reader who
+ /// sees "balance" concludes the operator still has it, and on this chain
+ /// that does not follow. Verified over all of mainnet's history — QUANPOOL
+ /// rose monotonically at every one of twelve samples, nonce 0 throughout.
+ pub received: Option,
/// Flows in and out.
pub flows: Option,
}
@@ -395,6 +461,13 @@ pub struct MinerFlow {
///
/// Assembled so the comparison is possible at all: one miner's recipient count
/// means little alone, and means a great deal beside its neighbours'.
+///
+/// **It cannot answer whether a miner is paying out, and must not imply that it
+/// can.** A mining address is keyless: rewards can only leave as wormhole
+/// exits, which record no sender and do not debit the source. A pool paying out
+/// in full and one paying out nothing produce identical rows here. What the
+/// table is for is the shape of who wins blocks — the payout question belongs
+/// on the wormhole page, which is the only place any of it is observable.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "Distribution.ts")]
pub struct Distribution {
diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs
index 80f56fb..6248d0f 100644
--- a/crates/blackbeard-entities/src/lib.rs
+++ b/crates/blackbeard-entities/src/lib.rs
@@ -33,8 +33,8 @@ pub use block::{BlockDetail, BlockEvent, BlockExtrinsic, BlockObservation, Recen
pub use call::{CallIndex, CallSummary, EventSummary};
pub use chain::{
AccountFlows, ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, DailyActivity,
- DailyWormhole, Distribution, LockCounts, MinerFlow, NetworkSummary, Tracking, TransferRoute,
- WormholeSummary,
+ DailyWormhole, Distribution, LockCounts, MinerFlow, NetworkSummary, PalletPot, Tracking,
+ TransferRoute, WormholeSummary,
};
pub use error::{ApiError, EntityError};
pub use miner::{
diff --git a/web/src/api/generated/Distribution.ts b/web/src/api/generated/Distribution.ts
index 41307b0..78a6cef 100644
--- a/web/src/api/generated/Distribution.ts
+++ b/web/src/api/generated/Distribution.ts
@@ -7,6 +7,13 @@ import type { MinerFlow } from "./MinerFlow";
*
* Assembled so the comparison is possible at all: one miner's recipient count
* means little alone, and means a great deal beside its neighbours'.
+ *
+ * **It cannot answer whether a miner is paying out, and must not imply that it
+ * can.** A mining address is keyless: rewards can only leave as wormhole
+ * exits, which record no sender and do not debit the source. A pool paying out
+ * in full and one paying out nothing produce identical rows here. What the
+ * table is for is the shape of who wins blocks — the payout question belongs
+ * on the wormhole page, which is the only place any of it is observable.
*/
export type Distribution = {
/**
diff --git a/web/src/api/generated/MinerFlow.ts b/web/src/api/generated/MinerFlow.ts
index 3e78884..fcf0f46 100644
--- a/web/src/api/generated/MinerFlow.ts
+++ b/web/src/api/generated/MinerFlow.ts
@@ -39,9 +39,22 @@ share: number,
*/
mined: BigUintDec | null,
/**
- * What it holds now, read from chain state.
+ * Everything ever credited to the address, read from chain state.
+ *
+ * **This is a receipt total, not a holding, and it can only ever rise.** A
+ * mining reward is paid to `derive_wormhole_address(preimage)`, a Poseidon2
+ * hash that no keypair signs for, so nothing can transfer out of it. The
+ * only way to move the value is a wormhole exit, and
+ * `Wormhole::credit_and_record` credits the destination without debiting
+ * the note's source. The balance therefore never falls, for any miner, no
+ * matter what its operator does.
+ *
+ * Named `received` rather than `balance` for that reason: a reader who
+ * sees "balance" concludes the operator still has it, and on this chain
+ * that does not follow. Verified over all of mainnet's history — QUANPOOL
+ * rose monotonically at every one of twelve samples, nonce 0 throughout.
*/
-balance: BigUintDec | null,
+received: BigUintDec | null,
/**
* Flows in and out.
*/
diff --git a/web/src/api/generated/NetworkSummary.ts b/web/src/api/generated/NetworkSummary.ts
index e441d25..4f3249c 100644
--- a/web/src/api/generated/NetworkSummary.ts
+++ b/web/src/api/generated/NetworkSummary.ts
@@ -3,6 +3,7 @@ import type { BigUintDec } from "./BigUintDec";
import type { ChainId } from "./ChainId";
import type { DailyActivity } from "./DailyActivity";
import type { LockCounts } from "./LockCounts";
+import type { PalletPot } from "./PalletPot";
/**
* The figures an analyst needs to judge an ecosystem, in one place.
@@ -13,6 +14,13 @@ import type { LockCounts } from "./LockCounts";
* `indexed_from`..`indexed_to` — which on a chain still being walked backwards
* is not the whole chain. Anything rendering these has to say which range it
* covered, or it describes a period its axis does not.
+ *
+ * One figure is deliberately absent: **the sum of account balances**. On this
+ * chain it is not supply and exceeds `total_issuance` by the cumulative value
+ * ever taken out of the wormhole, because an exit credits its destination
+ * without debiting the note's source and without moving issuance. Adding
+ * balances up and calling the result supply overstated it by 13,427 QTC at
+ * block 42,400, growing. `total_issuance` is the honest figure.
*/
export type NetworkSummary = {
/**
@@ -57,17 +65,34 @@ accounts_complete: boolean,
* Entries in `Balances::Locks`, `Holds`, `Freezes` and `Reserves`.
*
* All four are counted because all four are ways supply can be immobile,
- * and the useful statement on this chain is that every one of them is
- * empty. **That is why there is no "circulating supply" field**: with
- * nothing locked it would equal total issuance exactly, and printing it as
- * a separate headline would assert a distinction the chain does not
- * currently make.
+ * and on this chain every one of them is empty. **That does not mean
+ * nothing is withheld** — see `vesting_pot`. Reading four empty maps as
+ * "everything is circulating" is exactly the mistake this field was once
+ * used to justify, and it was wrong by a factor of 436.
*/
locks: LockCounts | null,
/**
- * `Vesting::Schedules` entries. Counted, not summed: this runtime's
- * vesting does not touch `Balances::Locks`, so what it holds and when it
- * releases would be a guess without reading the pallet's own maths.
+ * The vesting pallet's own account, and what it holds.
+ *
+ * This runtime's vesting is **pot-based, not lock-based**: undistributed
+ * funds sit in the account its `PalletId` owns and are paid out from
+ * there, rather than being issued to beneficiaries and frozen. That is why
+ * `locks` reads empty, and why it says nothing about how much supply is
+ * actually in play.
+ */
+vesting_pot: PalletPot | null,
+/**
+ * Issuance that is not sitting in the vesting pot.
+ *
+ * Deliberately not called "circulating": funds leave the pot on a schedule
+ * this observer does not read, and an address outside it may still be
+ * unspendable. What can be said is that the pot's contents have not been
+ * distributed to anyone, so this is the ceiling on what has.
+ */
+issuance_outside_pot: BigUintDec | null,
+/**
+ * `Vesting::Schedules` entries. Counted, not summed: the sum that matters
+ * is the pot, which is a single storage read and is reported above.
*/
vesting_schedules: number,
/**
diff --git a/web/src/api/generated/PalletPot.ts b/web/src/api/generated/PalletPot.ts
new file mode 100644
index 0000000..f4adaa7
--- /dev/null
+++ b/web/src/api/generated/PalletPot.ts
@@ -0,0 +1,39 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { BigUintDec } from "./BigUintDec";
+
+/**
+ * A pallet's sovereign account and what it holds.
+ *
+ * The address is derived from a `PalletId` the runtime compiles in, read out
+ * of metadata rather than hard-coded — a runtime that changed it would
+ * otherwise move the chain's largest balance somewhere this observer was still
+ * not looking, and the pot would report as empty with every appearance of
+ * working.
+ */
+export type PalletPot = {
+/**
+ * Which pallet owns it.
+ */
+pallet: string,
+/**
+ * Its SS58 address.
+ */
+address: string,
+/**
+ * Free balance, in the smallest unit. `None` if the account does not
+ * exist — which is not the same as holding nothing.
+ */
+balance: BigUintDec | null,
+/**
+ * Its share of `total_issuance`, in parts per ten thousand, so a reader
+ * does not have to divide two eighteen-digit strings to see that one
+ * account holds almost all of it.
+ *
+ * Declared `number | null` rather than the bare `number` its neighbours
+ * use. The override exists because `serde_json` writes a u64 as a JSON
+ * number while ts-rs would call it a `bigint`; it is not licence to also
+ * drop the `null`, which the wire really does send when either side of the
+ * division is unknown. The older fields understate this and the frontend
+ * guards them anyway.
+ */
+share_bps: number | null, };
diff --git a/web/src/components/DistributionPanel.tsx b/web/src/components/DistributionPanel.tsx
index 359be74..8fcfb4d 100644
--- a/web/src/components/DistributionPanel.tsx
+++ b/web/src/components/DistributionPanel.tsx
@@ -30,34 +30,6 @@ import { height as fmtHeight, share as fmtShare, shortAddress, tokens } from '..
import { href } from '../lib/routes'
import type { Window as WindowName } from '../api/generated/Window'
-/**
- * Balance as a percentage of everything the chain has paid an address.
- *
- * **The one measure here that privacy cannot hide.** A wormhole transfer does
- * not record its sender, so `sent visibly` is blind to the very mechanism a
- * wormhole-held balance would use to pay out — 9,894 such transfers reached 654
- * recipients on mainnet with no sender recorded at all. But a payment still
- * debits the account, so a balance that has not fallen is a balance nothing has
- * left, by any route, private or otherwise.
- *
- * Above 100% means the address also received from somewhere besides mining.
- */
-function retained(mined: string | null, balance: string | null): string {
- if (mined === null || balance === null) return '—'
- let held: bigint
- let paid: bigint
- try {
- held = BigInt(balance)
- paid = BigInt(mined)
- } catch {
- return '—'
- }
- // Nothing mined is not zero retention, it is a ratio with no denominator —
- // and most accounts that distribute widely on this chain mined nothing at all.
- if (paid === 0n) return '—'
- return `${Number((held * 1000n) / paid) / 10}%`
-}
-
export function DistributionPanel({
chain,
window: windowName,
@@ -117,12 +89,11 @@ export function DistributionPanel({
@@ -174,17 +144,30 @@ export function DistributionPanel({
transactions, and the privacy is a feature of the chain rather than a gap in this table.
- Retained is the column privacy cannot hide. A payment debits the account whichever
- route it takes, so a balance that has not fallen below what the chain paid in is a balance
- nothing has left. Above 100% means the address also received from somewhere other than
- mining. It still cannot tell you whether an operator pays from a different wallet, settles
- off-chain, or is simply a solo miner keeping what it earned — which is the ordinary case,
- and which every mining address on this chain currently resembles.
+
+ There is no column here that can tell you whether a miner is paying out, and there cannot
+ be.
+ {' '}
+ The chain pays a mining reward to an address derived from the miner's own reward
+ preimage — a Poseidon hash that no key signs for. Nothing can transfer out of it. The only
+ way to move the value is a wormhole exit, which credits its destination{' '}
+ without debiting the source. So received is a running total of everything
+ ever credited, it rises and never falls, and it does that identically for an operator who
+ pays out in full and one who pays out nothing. Measured over all of mainnet's history,
+ the largest miner's balance rose at every sample and fell at none.
+
+
+ That is also why this table no longer carries a “retained” percentage. It read close to 100%
+ for every miner on the chain, because it had to, and a number that is the same for everyone
+ reads as an accusation aimed at whoever happens to be at the top — which here is simply
+ whoever mined the most. The place any of this is observable is the{' '}
+ wormhole page, which counts what has
+ gone in and come out without claiming to know who moved it.
The names are the weakest column and come from telemetry, not the chain — an operator
chooses what its node calls itself, so a name containing “pool” is a claim rather than a
- fact. Holds is chain state and is true now, while mined,{' '}
+ fact. Received is chain state and is true now, while mined,{' '}
sent visibly and recipients are counted over the indexed range above; on a
chain read from genesis those agree, on one still being indexed backwards they do not.
diff --git a/web/src/components/NetworkPanel.tsx b/web/src/components/NetworkPanel.tsx
index 1895355..f0db4ae 100644
--- a/web/src/components/NetworkPanel.tsx
+++ b/web/src/components/NetworkPanel.tsx
@@ -85,6 +85,15 @@ export function NetworkPanel({
locks.freezes === 0 &&
locks.reserves === 0
+ // 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.
+ const pot = summary.vesting_pot
+ const potShare =
+ pot && pot.share_bps !== null
+ ? `${(pot.share_bps / 100).toFixed(pot.share_bps > 9900 ? 2 : 1)}%`
+ : null
+
const spark = (pick: (d: NetworkSummary['days'][number]) => number, unit: string): SparkPoint[] =>
summary.days.map((d) => ({
value: pick(d),
@@ -120,6 +129,20 @@ export function NetworkPanel({
value={amount(summary.mined_since_genesis)}
note="issuance less the endowment"
/>
+
+
+ {pot && potShare !== null && (
+ <>
+ {potShare} of all issuance sits in one account — {pot.address}, which
+ the vesting pallet owns and nobody holds a key to. This runtime vests from a pot
+ rather than with locks: undistributed funds wait there and are paid out from it.{' '}
+ >
+ )}
{nothingLocked ? (
<>
- Nothing is locked. All four of the balances pallet's
- immobilising maps — locks, holds, freezes and reserves — are empty, counted rather
- than assumed. Which is why there is no separate “circulating supply” figure here: it
- would equal total issuance exactly, and printing it would assert a distinction this
- chain does not currently make.{' '}
+ All four of the balances pallet's immobilising maps — locks, holds, freezes and
+ reserves — are empty, counted rather than assumed.{' '}
+ That is not the same as nothing being withheld: this chain withholds
+ with the pot above, which no lock records. There is still no “circulating supply”
+ figure here, because funds leave the pot on a schedule this observer does not read and
+ an address outside it may be unspendable for other reasons — but issuance alone would
+ overstate what is in play by a factor of several hundred.{' '}
>
) : (
locks && (
@@ -147,12 +179,14 @@ export function NetworkPanel({
)}
{summary.vesting_schedules !== null && summary.vesting_schedules > 0 && (
<>
- There are {count(summary.vesting_schedules)} vesting schedules, shown as a count
- rather than a sum: this runtime's vesting does not touch the balances
- pallet's locks, so what it holds and when it releases would be a guess without
- reading the pallet's own arithmetic.
+ The {count(summary.vesting_schedules)} vesting schedules are a count rather than a
+ sum; the sum that matters is the pot, and it is read directly.{' '}
>
)}
+ Account balances are not added up anywhere on this site. On this chain
+ they exceed total issuance and the gap grows: a wormhole exit credits its destination
+ without debiting the note's source, so value that moves privately appears twice in
+ any such total. Total issuance is the figure that counts each unit once.