feat: a network page — supply, capability and daily activity
The site answered "who is mining" thoroughly and "what is this economy doing"
not at all. Everything needed was already reachable and none of it was
assembled anywhere.
Measured against mainnet rather than assumed:
total issuance 5,682,913.17 QTC
endowed at genesis 5,670,000.00 QTC
mined since 12,913.17 QTC <- emission to date, printed nowhere else
accounts 1,925 counted, not inferred from indexed events
locks/holds/freezes/reserves 0 all four, counted
vesting schedules 48
referenda 0
reversible transfers 0
calls used 7 of 58
events fired 18 of 107
**No "circulating supply" field, deliberately.** With every immobilising map
empty it would equal total issuance exactly, and printing it as a separate
headline would assert a distinction this chain does not currently make. The page
states what was counted and lets that be read. Vesting is a count rather than a
sum for the same reason: this runtime's vesting does not touch the balances
locks, so what it holds and when it releases would be a guess.
**Signed extrinsics are separated from the total**, because three quarters of
this chain's extrinsics are inherents — 513 signed of 2,052 on the day measured
— and a single "transactions per day" line would be mostly clockwork, a number
that reads as adoption and is not.
Two kinds of certainty share the page and are kept apart. Supply and the map
sizes are chain state, read now. The daily activity is our index, and the
Activity panel prints the range it covered and says "the whole chain" or "a
partial index" — a chart whose axis claims a month over a week of data is the
failure this repository has already shipped twice.
One thing found while building it: a counter that has never moved is *absent*
from storage, not zero. `TechReferenda::ReferendumCount` and
`ReversibleTransfers::NextTransactionId` both read as nothing, and reporting
them unknown would have hidden the most interesting fact about them — that
governance and reversible transfers are shipped and have never been used.
`plain_value` falls back to the entry's declared default, which is only sound
because `StorageTarget` says whether it is `Default` or `Optional`.
Refs #17
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
@@ -515,6 +515,39 @@ impl RpcClient {
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// How many keys live under a prefix.
|
||||
///
|
||||
/// Pages through them and counts, because Substrate offers no `count` and a
|
||||
/// map's size is a real fact about a chain — how many accounts exist, how
|
||||
/// many balances are locked. At the node's 1,000-key ceiling, mainnet's
|
||||
/// 1,914 accounts is two round trips.
|
||||
///
|
||||
/// `stop_at` bounds the walk so a map nobody expected to be large cannot
|
||||
/// turn a page render into a thousand requests; the count is returned with
|
||||
/// a flag saying whether it is complete.
|
||||
pub async fn count_keys(
|
||||
&self,
|
||||
prefix: &str,
|
||||
stop_at: usize,
|
||||
) -> Result<(usize, bool), DataError> {
|
||||
let mut total = 0usize;
|
||||
let mut start: Option<String> = None;
|
||||
loop {
|
||||
let page = self
|
||||
.storage_keys_paged(prefix, MAX_KEYS_PER_PAGE, start.as_deref(), None)
|
||||
.await?;
|
||||
let got = page.len();
|
||||
total += got;
|
||||
if got < MAX_KEYS_PER_PAGE as usize {
|
||||
return Ok((total, true));
|
||||
}
|
||||
if total >= stop_at {
|
||||
return Ok((total, false));
|
||||
}
|
||||
start = page.last().cloned();
|
||||
}
|
||||
}
|
||||
|
||||
/// The runtime's own description of itself, as of `hash`.
|
||||
///
|
||||
/// The node executes `Metadata_metadata` against the runtime code in that
|
||||
|
||||
@@ -60,6 +60,32 @@ pub struct StoreConfig {
|
||||
pub max_connections: u32,
|
||||
}
|
||||
|
||||
/// One day of a chain's activity.
|
||||
///
|
||||
/// Every count is over the chain's own clock and over whatever this observer
|
||||
/// has indexed — two different limits, and the caller has to state the second.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DailyActivity {
|
||||
/// Midnight UTC beginning the day.
|
||||
pub day: DateTime<Utc>,
|
||||
/// Blocks authored.
|
||||
pub blocks: u64,
|
||||
/// Distinct miners that authored one.
|
||||
pub miners: u32,
|
||||
/// Extrinsics, inherents included.
|
||||
pub extrinsics: u64,
|
||||
/// Of those, the ones that carried a signature. The rest are inherents —
|
||||
/// on this chain most traffic is the timestamp, so a single "transactions"
|
||||
/// line would be mostly clockwork.
|
||||
pub signed: u64,
|
||||
/// Distinct accounts that signed one.
|
||||
pub signers: u32,
|
||||
/// Events emitted, of the kinds this observer indexes.
|
||||
pub events: u64,
|
||||
/// Accounts seen for the first time anywhere in the indexed record.
|
||||
pub new_accounts: u64,
|
||||
}
|
||||
|
||||
/// One node's cached telemetry, as a row.
|
||||
///
|
||||
/// The same facts as [`crate::telemetry::NodeMetadata`], with the two
|
||||
@@ -1928,6 +1954,108 @@ impl Store {
|
||||
}))
|
||||
}
|
||||
|
||||
/// One day of the chain's activity, from this observer's index.
|
||||
///
|
||||
/// Bucketed by the *chain's* clock — `chain_extrinsic.at` and `block.authored_at`
|
||||
/// — never by when this observer saw anything. A stretch caught up on carries
|
||||
/// observation times minutes apart for blocks spanning days, and a daily
|
||||
/// series built on that reports a fortnight of activity as one afternoon.
|
||||
///
|
||||
/// The scope of every figure is the indexed range, which the caller must
|
||||
/// carry to the reader: on a chain still being walked backwards these counts
|
||||
/// are of what has been read, not of what happened.
|
||||
pub async fn activity_by_day(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
days: i32,
|
||||
) -> Result<Vec<DailyActivity>, DataError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
with span as (
|
||||
select generate_series(
|
||||
date_trunc('day', now()) - make_interval(days => $2::int - 1),
|
||||
date_trunc('day', now()),
|
||||
interval '1 day'
|
||||
) as day
|
||||
),
|
||||
blocks as (
|
||||
select date_trunc('day', authored_at) as day,
|
||||
count(*) as blocks,
|
||||
count(distinct miner) as miners
|
||||
from block
|
||||
where chain = $1 and authored_at is not null
|
||||
group by 1
|
||||
),
|
||||
calls as (
|
||||
select date_trunc('day', at) as day,
|
||||
count(*) as extrinsics,
|
||||
count(*) filter (where signer is not null) as signed,
|
||||
count(distinct signer) as signers
|
||||
from chain_extrinsic
|
||||
where chain = $1 and at is not null
|
||||
group by 1
|
||||
),
|
||||
events as (
|
||||
select date_trunc('day', at) as day, count(*) as events
|
||||
from chain_event
|
||||
where chain = $1 and at is not null
|
||||
group by 1
|
||||
),
|
||||
-- An account's first appearance anywhere in the indexed record. Only
|
||||
-- honest on a chain indexed from genesis; on a partial index this is
|
||||
-- "first seen by us", which is why the caller reports the range.
|
||||
first_seen as (
|
||||
select account, min(at) as first_at
|
||||
from (
|
||||
select unnest(accounts) as account, at from chain_event
|
||||
where chain = $1 and at is not null
|
||||
union all
|
||||
select unnest(accounts) as account, at from chain_extrinsic
|
||||
where chain = $1 and at is not null
|
||||
) seen
|
||||
group by account
|
||||
),
|
||||
arrivals as (
|
||||
select date_trunc('day', first_at) as day, count(*) as new_accounts
|
||||
from first_seen
|
||||
group by 1
|
||||
)
|
||||
select span.day as "day!",
|
||||
coalesce(blocks.blocks, 0) as "blocks!",
|
||||
coalesce(blocks.miners, 0) as "miners!",
|
||||
coalesce(calls.extrinsics, 0) as "extrinsics!",
|
||||
coalesce(calls.signed, 0) as "signed!",
|
||||
coalesce(calls.signers, 0) as "signers!",
|
||||
coalesce(events.events, 0) as "events!",
|
||||
coalesce(arrivals.new_accounts, 0) as "new_accounts!"
|
||||
from span
|
||||
left join blocks on blocks.day = span.day
|
||||
left join calls on calls.day = span.day
|
||||
left join events on events.day = span.day
|
||||
left join arrivals on arrivals.day = span.day
|
||||
order by span.day asc
|
||||
"#,
|
||||
chain.as_str(),
|
||||
days,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| DailyActivity {
|
||||
day: r.day,
|
||||
blocks: r.blocks as u64,
|
||||
miners: r.miners as u32,
|
||||
extrinsics: r.extrinsics as u64,
|
||||
signed: r.signed as u64,
|
||||
signers: r.signers as u32,
|
||||
events: r.events as u64,
|
||||
new_accounts: r.new_accounts as u64,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Every node cached for a chain, most recently heard first.
|
||||
///
|
||||
/// The half of a node index the live feed cannot supply: a node announced
|
||||
|
||||
Reference in New Issue
Block a user