feat: a network page — supply, capability and daily activity
All checks were successful
deploy / build (push) Successful in 7m27s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 17s

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:
2026-09-14 08:43:13 +03:00
parent 3551b832a0
commit aaf37baaca
15 changed files with 1187 additions and 7 deletions

View File

@@ -23,6 +23,7 @@ import { ChainSwitcher } from './components/ChainSwitcher'
import { Leaderboard } from './components/Leaderboard'
import { MinerPanel } from './components/MinerPanel'
import { RuntimePanel } from './components/RuntimePanel'
import { NetworkPanel } from './components/NetworkPanel'
import { NodeRoute } from './components/NodeRoute'
import { NodesIndex } from './components/NodesIndex'
import { RuntimesIndex } from './components/RuntimesIndex'
@@ -268,6 +269,13 @@ export default function App() {
<RuntimesIndex chain={chain} current={state.summary?.spec_version ?? null} />
)}
{route.index === 'node' && chain && <NodesIndex chain={chain} />}
{route.index === 'network' && chain && (
<NetworkPanel
chain={chain}
decimals={info?.token_decimals ?? 12}
symbol={info?.token_symbol ?? ''}
/>
)}
{/* The standings and the live ticker belong to the standings route and
nowhere else. A page that names one subject has that subject as its

View File

@@ -0,0 +1,42 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* One day of a chain's activity, as this observer indexed it.
*/
export type DailyActivity = {
/**
* Midnight UTC beginning the day.
*/
day: string,
/**
* Blocks authored.
*/
blocks: number,
/**
* Distinct miners that authored one.
*/
miners: number,
/**
* Extrinsics, inherents included.
*/
extrinsics: number,
/**
* Of those, the ones carrying a signature.
*
* Separated from the total because on this chain most traffic is the
* timestamp inherent, and a single "transactions per day" line would be
* mostly clockwork — a number that looks like adoption and is not.
*/
signed: number,
/**
* Distinct accounts that signed one.
*/
signers: number,
/**
* Events emitted, of the kinds this observer indexes.
*/
events: number,
/**
* Accounts seen for the first time anywhere in the indexed record.
*/
new_accounts: number, };

View File

@@ -0,0 +1,22 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* How many accounts have supply immobilised, by each mechanism.
*/
export type LockCounts = {
/**
* `Balances::Locks`.
*/
locks: number,
/**
* `Balances::Holds`.
*/
holds: number,
/**
* `Balances::Freezes`.
*/
freezes: number,
/**
* `Balances::Reserves`.
*/
reserves: number, };

View File

@@ -0,0 +1,117 @@
// 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";
import type { ChainId } from "./ChainId";
import type { DailyActivity } from "./DailyActivity";
import type { LockCounts } from "./LockCounts";
/**
* The figures an analyst needs to judge an ecosystem, in one place.
*
* Two kinds of number live here and they are not equally certain. The supply
* and the map sizes are **chain state**, read now and true now. The per-day
* activity is **this observer's index**, and is only as complete as
* `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.
*/
export type NetworkSummary = {
/**
* Which chain.
*/
chain: ChainId,
/**
* The runtime these figures were read against.
*/
spec_version: number,
/**
* `Balances::TotalIssuance`, in the smallest unit.
*/
total_issuance: BigUintDec | null,
/**
* `Balances::InactiveIssuance`.
*/
inactive_issuance: BigUintDec | null,
/**
* What genesis handed out, summed from block zero's state.
*/
genesis_endowment: BigUintDec | null,
/**
* Issuance minus the endowment: everything mined since launch, which is
* the real emission to date and is not printed anywhere else.
*/
mined_since_genesis: BigUintDec | null,
/**
* `MiningRewards::CollectedFees`.
*/
collected_fees: BigUintDec | null,
/**
* `System::Account` entries — accounts that exist, counted rather than
* inferred from the events we happen to have indexed.
*/
accounts: number,
/**
* Whether that count reached the end of the map rather than a ceiling.
*/
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.
*/
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.
*/
vesting_schedules: number,
/**
* `TechReferenda::ReferendumCount` — referenda ever opened.
*/
referenda: number,
/**
* `ReversibleTransfers::NextTransactionId` — reversible transfers ever
* created.
*/
reversible_transfers: number,
/**
* `ZkTree::LeafCount` — wormhole leaves, one per mining reward.
*/
wormhole_leaves: number,
/**
* Dispatchables ever used, of those declared.
*/
calls_used: number,
/**
* Dispatchables declared by the runtime.
*/
calls_declared: number,
/**
* Event kinds ever fired, of those declared.
*/
events_fired: number,
/**
* Event kinds declared.
*/
events_declared: number,
/**
* Lowest block whose extrinsics this observer has read.
*/
indexed_from: number,
/**
* Highest.
*/
indexed_to: number,
/**
* The chain's own height, so a reader can see how much of it is indexed.
*/
height: number,
/**
* Activity per day, oldest first.
*/
days: Array<DailyActivity>, };

View File

@@ -20,6 +20,7 @@ import type { GenesisDetail } from './generated/GenesisDetail'
import type { ReversibleState } from './generated/ReversibleState'
import type { RecentBlock } from './generated/RecentBlock'
import type { MinerDetail } from './generated/MinerDetail'
import type { NetworkSummary } from './generated/NetworkSummary'
import type { NodeIndex } from './generated/NodeIndex'
import type { NodeInfo } from './generated/NodeInfo'
import type { RuntimeDetail } from './generated/RuntimeDetail'
@@ -250,3 +251,8 @@ export async function fetchNode(
): Promise<NodeInfo> {
return get<NodeInfo>(`/chains/${chain}/nodes/${encodeURIComponent(peer)}`, signal)
}
/** High-level network statistics: supply, capability and activity. */
export async function fetchNetwork(chain: string, signal?: AbortSignal): Promise<NetworkSummary> {
return get<NetworkSummary>(`/chains/${chain}/network`, signal)
}

View File

@@ -0,0 +1,296 @@
/**
* What this economy is doing, in one place.
*
* Two kinds of number live on this page and they are not equally certain, which
* is why they are in separate panels rather than one grid of tiles.
*
* **Supply and the map sizes are chain state**, read now and true now. Every one
* is a value the runtime holds, not a figure derived by this observer.
*
* **The activity is our index**, and is only as complete as the range it prints
* beside itself. On a chain indexed from genesis that is the whole history; on a
* testnet still walking backwards it is not, and a daily chart whose axis claims
* a month while the data covers a week is precisely the failure this repository
* has already shipped twice.
*/
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import type { NetworkSummary } from '../api/generated/NetworkSummary'
import { RequestFailed, fetchNetwork } from '../api/rest'
import { height as fmtHeight, tokens } from '../lib/format'
import { href } from '../lib/routes'
import { Sparkline, type SparkPoint } from './Sparkline'
function Field({
label,
value,
note,
}: {
label: string
value: string
note?: string | undefined
}) {
return (
<div className="node-field">
<div className="eyebrow">{label}</div>
<div className="node-value">{value}</div>
{note && <div className="stat-note">{note}</div>}
</div>
)
}
/** A count, or an em dash where the figure is genuinely unknown — never a zero
* standing in for "we could not read it". */
function count(value: number | null): string {
return value === null ? '—' : fmtHeight(value)
}
export function NetworkPanel({
chain,
decimals,
symbol,
}: {
chain: string
decimals: number
symbol: string
}) {
const [summary, setSummary] = useState<NetworkSummary | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
setSummary(null)
setError(null)
fetchNetwork(chain, controller.signal)
.then(setSummary)
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
})
return () => controller.abort()
}, [chain])
if (error) return <p className="empty">{error}</p>
if (!summary) return <p className="empty">Reading the chain</p>
const amount = (raw: string | null) => (raw === null ? '—' : `${tokens(raw, decimals)} ${symbol}`)
const locks = summary.locks
const nothingLocked =
locks !== null &&
locks.locks === 0 &&
locks.holds === 0 &&
locks.freezes === 0 &&
locks.reserves === 0
const spark = (pick: (d: NetworkSummary['days'][number]) => number, unit: string): SparkPoint[] =>
summary.days.map((d) => ({
value: pick(d),
label: `${fmtHeight(pick(d))} ${unit} · ${d.day.slice(0, 10)}`,
}))
// The indexed range against the chain's height, so the reader can see how
// much of the chain these daily figures actually cover.
const complete =
summary.indexed_from === 1 && summary.height !== null && summary.indexed_to !== null
? summary.indexed_to >= summary.height - 2
: false
return (
<>
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Supply</h2>
<span className="eyebrow">
read from chain state
{summary.spec_version !== null && ` · runtime v${summary.spec_version}`}
</span>
</div>
<div className="node-grid">
<Field label="Total issuance" value={amount(summary.total_issuance)} />
<Field
label="Endowed at genesis"
value={amount(summary.genesis_endowment)}
note="handed out in block zero"
/>
<Field
label="Mined since genesis"
value={amount(summary.mined_since_genesis)}
note="issuance less the endowment"
/>
<Field label="Fees collected" value={amount(summary.collected_fees)} />
<Field label="Inactive issuance" value={amount(summary.inactive_issuance)} />
<Field
label="Accounts"
value={count(summary.accounts)}
note={summary.accounts_complete ? 'counted in full' : 'count reached its ceiling'}
/>
</div>
<p className="panel-note">
{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.{' '}
</>
) : (
locks && (
<>
Supply is immobilised in {count(locks.locks)} locks, {count(locks.holds)} holds,{' '}
{count(locks.freezes)} freezes and {count(locks.reserves)} reserves.{' '}
</>
)
)}
{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.
</>
)}
</p>
</section>
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Capability</h2>
<span className="eyebrow">what is shipped, and what is used</span>
</div>
<div className="node-grid">
<Field
label="Calls used"
value={`${fmtHeight(summary.calls_used)} of ${fmtHeight(summary.calls_declared)}`}
note="dispatchables ever called"
/>
<Field
label="Events fired"
value={`${fmtHeight(summary.events_fired)} of ${fmtHeight(summary.events_declared)}`}
note="event kinds ever emitted"
/>
<Field label="Vesting schedules" value={count(summary.vesting_schedules)} />
<Field label="Referenda" value={count(summary.referenda)} note="ever opened" />
<Field
label="Reversible transfers"
value={count(summary.reversible_transfers)}
note="ever created"
/>
<Field
label="Wormhole leaves"
value={count(summary.wormhole_leaves)}
note="one per mining reward"
/>
</div>
<p className="panel-note">
A zero here is a finding, not a gap: a pallet shipped and never touched says something
about a chain that its absence would not. The usage counts are over this observer&apos;s
index rather than all of history {' '}
<Link to={href({ chain, index: 'call' })}>the call index</Link> has the detail, including
every dispatchable nobody has ever used.
</p>
</section>
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Activity</h2>
<span className="eyebrow">
{summary.indexed_from !== null && summary.indexed_to !== null
? `blocks ${fmtHeight(summary.indexed_from)}${fmtHeight(summary.indexed_to)}${
complete ? ' · the whole chain' : ' · a partial index'
}`
: 'nothing indexed yet'}
</span>
</div>
<div className="network-sparks">
{[
{
label: 'Signed extrinsics',
pick: (d: NetworkSummary['days'][number]) => d.signed,
unit: 'signed',
},
{
label: 'Distinct signers',
pick: (d: NetworkSummary['days'][number]) => d.signers,
unit: 'signers',
},
{
label: 'New accounts',
pick: (d: NetworkSummary['days'][number]) => d.new_accounts,
unit: 'accounts',
},
{
label: 'Blocks',
pick: (d: NetworkSummary['days'][number]) => d.blocks,
unit: 'blocks',
},
].map((s) => (
<div key={s.label} className="node-field">
<div className="eyebrow">{s.label}</div>
<div className="node-value">
{fmtHeight(s.pick(summary.days[summary.days.length - 1]!))}
</div>
{/* No hover readout here: each tile already prints its latest
value above the line, and five probe handlers competing over
one row of 200px tiles is a pile rather than a hover layer —
the same reason the headline tiles borrow their note line. */}
<Sparkline
points={spark(s.pick, s.unit)}
label={`${s.label} per day`}
onProbe={() => {}}
/>
</div>
))}
</div>
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">Activity per day, newest first</caption>
<thead>
<tr>
<th scope="col" className="left">
Day
</th>
<th scope="col">Blocks</th>
<th scope="col">Miners</th>
<th scope="col">Extrinsics</th>
<th scope="col">Signed</th>
<th scope="col">Signers</th>
<th scope="col">Events</th>
<th scope="col">New accounts</th>
</tr>
</thead>
<tbody>
{[...summary.days].reverse().map((d) => (
<tr key={d.day}>
<td className="left numeral">{d.day.slice(0, 10)}</td>
<td className="numeral">{fmtHeight(d.blocks)}</td>
<td className="numeral">{fmtHeight(d.miners)}</td>
<td className="numeral">{fmtHeight(d.extrinsics)}</td>
<td className="numeral">{fmtHeight(d.signed)}</td>
<td className="numeral">{fmtHeight(d.signers)}</td>
<td className="numeral">{fmtHeight(d.events)}</td>
<td className="numeral">{fmtHeight(d.new_accounts)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="panel-note">
Bucketed by the chain&apos;s own clock, 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 as one afternoon.{' '}
<strong>Signed is separated from the total</strong> because most extrinsics on this chain
are inherents the timestamp every block carries so a single transactions per day
line would be mostly clockwork, a number that looks like adoption and is not.
{!complete &&
' These counts are of what has been read, not of what happened: this chain is still being indexed backwards.'}
</p>
</section>
</>
)
}

View File

@@ -300,6 +300,18 @@ a {
overflow-wrap: anywhere;
}
.network-sparks {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1px;
background: var(--border);
border-top: var(--rule);
}
.network-sparks .node-field {
padding-bottom: 10px;
}
.node-filter {
flex: 1;
min-width: 0;

View File

@@ -106,7 +106,7 @@ export interface Route {
/** The kinds that have an index. */
export type SectionName =
'block' | 'call' | 'event' | 'account' | 'reversible' | 'state' | 'runtime' | 'node'
'block' | 'call' | 'event' | 'account' | 'reversible' | 'state' | 'runtime' | 'node' | 'network'
/**
* The sections, in the order the nav shows them.
@@ -127,6 +127,9 @@ export const SECTIONS: { id: SectionName; label: string }[] = [
// ledger — a node is a machine watching the chain rather than anything the
// chain records.
{ id: 'node', label: 'Nodes' },
// Last: the nav reads "this chain's ledger, then the machines watching it,
// then the economy over all of it".
{ id: 'network', label: 'Network' },
]
function section(name: string): SectionName | null {