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:
63
CLAUDE.md
63
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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
71
crates/blackbeard-core/src/pallet.rs
Normal file
71
crates/blackbeard-core/src/pallet.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
/**
|
||||
|
||||
39
web/src/api/generated/PalletPot.ts
Normal file
39
web/src/api/generated/PalletPot.ts
Normal 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, };
|
||||
@@ -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'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's history,
|
||||
the largest miner'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>
|
||||
|
||||
@@ -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'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.{' '}
|
||||
<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'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.{' '}
|
||||
</>
|
||||
)}
|
||||
<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'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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user