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({ Share Mined - Holds - Retained + Received {fmtShare(m.share)} {amount(m.mined)} - {amount(m.balance)} - {retained(m.mined, m.balance)} + {amount(m.received)} {amount(m.flows?.sent)} {recipients === null ? '—' : fmtHeight(recipients)} @@ -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.