fix: a miner's balance is a receipt total, and the supply figures were wrong

Two claims this repository shipped, both disproved by measuring mainnet.

**A mining address is keyless, so its balance can only rise.** `retained`
was documented here as "the one measure privacy cannot hide", reasoning that
a payment debits the account whichever route it takes. It does not.
`mining-rewards` pays `derive_wormhole_address(preimage)`, which no Dilithium
keypair signs for, and `Wormhole::credit_and_record` credits an exit's
destination with `Unbalanced::increase_balance` while never debiting the
note's source. QUANPOOL's balance rose at all twelve samples between blocks
1,000 and 42,400 and fell at none, nonce 0 throughout — as every miner's must.

So `retained` read ~100% for everyone because it had to, and a number
identical for all reads as an accusation aimed at whoever mined most. The
column is gone, `balance` is `received`, and the table says plainly that a
pool paying out in full and one paying nothing produce the same row.

**Four empty lock maps are not "nothing is withheld".** Locks, Holds,
Freezes and Reserves are all empty — counted, not assumed — and the network
page concluded circulating equals total issuance. Wrong by a factor of 436:
this runtime vests from a *pot*, not with locks, and `modlqvesting` holds
5,669,940 QTC, 99.54% of every unit of balance on the chain. The page now
reports the pot and issuance-minus-pot, and still declines the word
"circulating" because the release schedule is not read here.

The pot address is derived from `Vesting::PalletId` in metadata rather than
a hard-coded `qvesting`: it 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 against the real mainnet
metadata fixture.

Also recorded: never sum account balances and call it supply. They exceed
`TotalIssuance` by 13,427 QTC at block 42,400 and by exactly zero at genesis,
bisecting to block 4,800 and a `verify_public_batch` — private value is
counted twice by any such total. Single blocks reconcile exactly, so a spot
check says everything is fine.

Refs #17
Closes #19

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:43:39 +03:00
parent e3c95d4945
commit 4d9d41888a
13 changed files with 484 additions and 91 deletions

View File

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

View File

@@ -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::<u128>().ok())
.zip(
total_issuance
.as_ref()
.and_then(|i| i.0.parse::<u128>().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::<u128>().ok())
.zip(
vesting_pot
.as_ref()
.and_then(|p| p.balance.as_ref())
.and_then(|b| b.0.parse::<u128>().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,

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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<StorageMap, RuntimeError> {
@@ -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");

View File

@@ -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<LockCounts>,
/// `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<PalletPot>,
/// 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<BigUintDec>,
/// `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<u32>,
/// `TechReferenda::ReferendumCount` — referenda ever opened.
@@ -277,6 +299,37 @@ pub struct NetworkSummary {
pub days: Vec<DailyActivity>,
}
/// 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<BigUintDec>,
/// 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<u32>,
}
/// 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<BigUintDec>,
/// What it holds now, read from chain state.
pub balance: Option<BigUintDec>,
/// 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<BigUintDec>,
/// Flows in and out.
pub flows: Option<AccountFlows>,
}
@@ -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 {

View File

@@ -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::{

View File

@@ -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 = {
/**

View File

@@ -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.
*/

View File

@@ -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,
/**

View File

@@ -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, };

View File

@@ -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({
</th>
<th scope="col">Share</th>
<th scope="col">Mined</th>
<th scope="col">Holds</th>
<th
scope="col"
title="Balance as a percentage of everything the chain has paid this address. The one column privacy cannot hide: a payout debits the account whether or not its sender was recorded."
title="Everything ever credited to this address. A mining address is keyless, so nothing can transfer out of it and this number can only rise — it is a receipt total, not a holding."
>
Retained
Received
</th>
<th
scope="col"
@@ -153,8 +124,7 @@ export function DistributionPanel({
</td>
<td className="numeral">{fmtShare(m.share)}</td>
<td className="numeral">{amount(m.mined)}</td>
<td className="numeral">{amount(m.balance)}</td>
<td className="numeral">{retained(m.mined, m.balance)}</td>
<td className="numeral">{amount(m.received)}</td>
<td className="numeral">{amount(m.flows?.sent)}</td>
<td className="numeral">{recipients === null ? '—' : fmtHeight(recipients)}</td>
</tr>
@@ -174,17 +144,30 @@ export function DistributionPanel({
transactions, and the privacy is a feature of the chain rather than a gap in this table.
</p>
<p className="panel-note">
<em>Retained</em> 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.
<strong>
There is no column here that can tell you whether a miner is paying out, and there cannot
be.
</strong>{' '}
The chain pays a mining reward to an address derived from the miner&apos;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{' '}
<em>without debiting the source</em>. So <em>received</em> 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&apos;s history,
the largest miner&apos;s balance rose at every sample and fell at none.
</p>
<p className="panel-note">
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{' '}
<Link to={href({ chain, index: 'wormhole' })}>wormhole page</Link>, which counts what has
gone in and come out without claiming to know who moved it.
</p>
<p className="panel-note">
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. <em>Holds</em> is chain state and is true now, while <em>mined</em>,{' '}
fact. <em>Received</em> is chain state and is true now, while <em>mined</em>,{' '}
<em>sent visibly</em> and <em>recipients</em> are counted over the indexed range above; on a
chain read from genesis those agree, on one still being indexed backwards they do not.
</p>

View File

@@ -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"
/>
<Field
label="Held in the vesting pot"
value={pot ? amount(pot.balance) : '—'}
note={
potShare === null
? 'the vesting pallet\u2019s own account'
: `${potShare} of issuance`
}
/>
<Field
label="Issuance outside the pot"
value={amount(summary.issuance_outside_pot)}
note="the ceiling on what has been distributed"
/>
<Field label="Fees collected" value={amount(summary.collected_fees)} />
<Field label="Inactive issuance" value={amount(summary.inactive_issuance)} />
<Field
@@ -129,13 +152,22 @@ export function NetworkPanel({
/>
</div>
<p className="panel-note">
{pot && potShare !== null && (
<>
<strong>{potShare} of all issuance sits in one account</strong> {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 ? (
<>
<strong>Nothing is locked.</strong> All four of the balances pallet&apos;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&apos;s immobilising maps locks, holds, freezes and
reserves are empty, counted rather than assumed.{' '}
<strong>That is not the same as nothing being withheld</strong>: 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&apos;s vesting does not touch the balances
pallet&apos;s locks, so what it holds and when it releases would be a guess without
reading the pallet&apos;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.{' '}
</>
)}
<strong>Account balances are not added up anywhere on this site.</strong> On this chain
they exceed total issuance and the gap grows: a wormhole exit credits its destination
without debiting the note&apos;s source, so value that moves privately appears twice in
any such total. Total issuance is the figure that counts each unit once.
</p>
</section>