Live miner leaderboard and network hashrate for the Quantus
blockchain and its Planck testnet, at https://blackbeard.observer
(blackbeard.internal from the mesh).
The point of it: a miner can find their own row, see what share of the network they are actually winning, and watch it move — against everyone else, in real time. Mining is a competition that normally gives its participants no scoreboard. This is the scoreboard.
Every block header carries its author's 32-byte wormhole reward preimage in a
PreRuntime digest stamped with the engine id pow_. The node writes it there so
the mining-rewards pallet can read it back and derive the payout address
on-chain — and as a side effect, authorship for every miner on the network is
derivable from headers alone.
That is the whole mechanism, and its consequences are worth stating plainly:
lair/quantus's doc/wormhole-rewards.md has the full
analysis.Difficulty comes from the QPoWApi_get_difficulty runtime call. Difficulty is
the expected number of hashes to win a block, so network hashrate is difficulty
divided by the block interval — and a miner's implied hashrate is their share of
recent blocks times that.
The mechanics were first worked out in lair/quantus's Prometheus arena
exporter (asset/arena/quantus-arena-exporter.py), which remains the reference
for anyone checking this implementation against a second one.
The chain gives us preimages; a board of 64-character hex strings is honest and unreadable. substrate-telemetry closes the gap indirectly.
The feed reports block imports with a propagation time, stamping the first
reporter of a hash with 0. A node imports its own block before announcing it,
so the author — if it is on telemetry at all — is nearly always first. One block
proves nothing (measured on Planck, an author's lead over the second reporter is
50–620 ms, which a well-peered bystander can produce), so the observer votes
over a rolling window and only shows a name once enough blocks agree.
An attributed name is therefore an inference, never a claim the miner made and never an identity check. The UI marks it as such, carries the confidence in a tooltip, and falls back to the abbreviated preimage rather than guessing. Where two miners resolve to the same node name — one operator with several preimages, or two people who never renamed their node — every member of the colliding group gets a preimage suffix, so no row is silently privileged as "the real one".
Cargo workspace plus a Vite frontend, per ~/git/architecture/generic.md §1.
crates/
blackbeard-entities/ domain types + the browser wire protocol; ts-rs exports
the TypeScript the frontend consumes. No I/O.
blackbeard-core/ header/SCALE decoding, hashrate maths, the rolling
window, telemetry attribution voting. Pure — no I/O,
no clock, no sockets, exercised entirely by unit tests.
blackbeard-data/ chain JSON-RPC, the telemetry feed, Postgres.
blackbeard-api/ the daemon: ingest tasks, REST, WebSocket fanout.
blackbeard-cli/ operator tools: probe, backfill, standings.
web/ Vite + React + SWC + TS. Static build, served by nginx.
asset/ systemd, firewalld, nginx, config template, bootstrap SQL.
script/infra-setup.sh one-time host provisioning, operator-run.
The frontend's types are generated from the Rust. cargo test -p blackbeard-entities writes web/src/api/generated/; CI fails the build if the
committed output is stale. Edit the Rust DTO, never the TypeScript — otherwise
the two compile cleanly and disagree at runtime.
One WebSocket per browser tab. On subscribe the client gets a full snapshot;
after that only deltas — a block on every block, a summary when the headline
numbers move, a leaderboard when the standings change. The page never polls and
never refetches.
The head stream is itself a push: chain_subscribeNewHeads over the node's
WebSocket, so the observer learns of a block the moment the node imports it. The
only polling anywhere is difficulty and sync state, on a four-second timer.
RxJS was considered and not used. This needs one stream, one reducer and one
subscriber list; useSyncExternalStore is React's own contract for exactly that,
gets concurrent rendering right, and costs no dependency and no idiom in every
component. The store is web/src/api/socket.ts — about 200 lines including the
reconnect and resubscribe logic.
Server-side, messages are serialised once per broadcast and every socket
writes the same Arc<str>; leaderboards are recomputed only for windows that
actually have a subscriber.
Every window on the site is a block count — 600, 3 600, 14 400, 100 800 —
and since those are what is windowed on, they are also what the selector says
and what the URL carries: /quantus/3600, not /quantus/six_hours.
The duration names came off because they were a promise the site could not keep.
A window is a fixed number of blocks, so how far back it reaches is whatever the
chain's rate makes it. When Planck's miners left for mainnet its rate fell
ninefold, and /planck/six_hours went on saying six hours over a window that
spanned twenty-nine — with a miner that had not touched the chain in a day
sitting in the standings looking current. The name in the address bar was the
reason a reader believed it.
The duration is still shown, next to the count, but it is now measured: the
authored-time span of exactly the blocks tallied, which is the same figure that
is already the denominator of every per-miner hashrate. It carries no "",
because nothing is being estimated. Only the windows that are not being
displayed still show an estimate from the current interval, and those keep the
"". The old URLs still resolve — the router rewrites them to the block count —
so links already shared keep working.
This is not pedantry. A duration is meaningless while a node is catching up: it imports historical blocks at disk speed, so "the last hour" can contain half a million blocks. The same reasoning is why a measured block interval is only used when the node is at the tip; while syncing, the configured target is used and every hashrate on the page is labelled nominal.
The sparklines under the headline tiles hold the same line. They are bucketed by height, not by time: every point is the same number of blocks, so every point carries the same statistical weight and a stretch where nothing was recorded stays the same width on the axis as one where everything was. A bucket is the window divided by fifty, which is why every window is a multiple of it — a full window is then exactly fifty buckets, and the rolling miner count at the last point is the same number printed in the tile above it.
Their block interval comes from the author's own timestamp inherent rather than from when this observer saw the block, for the same reason the measured interval only counts tip observations: a batch of blocks fetched from history all carry very nearly the same observation time, and dividing by that reads as a chain producing hundreds of blocks a second. Each point is a moving average over three buckets, because the mean of one bucket's gaps still carries enough Poisson noise to draw a chain that looks like it is changing size every few minutes.
/quantus/block/1069799 is a position. Because block is keyed on
(chain, height) and a reorg overwrites, a link to a height quietly comes to mean
a different block the moment the chain forks there. So opening one resolves it
and rewrites the address to /quantus/block/0x…, which names one block for good
— including after it has lost, which is the only way an orphan is linkable at
all.
The page is assembled from both sources because neither is sufficient. The node
holds the block and forgets it: blocks-pruning defaults to
archive-canonical, so a losing fork's body goes once finality passes it, and
difficulty is a state read behind a 256-block window. The observer holds what
the node never had — when the block was seen, and whether that sighting was at
the tip — and what it has since forgotten, the difficulty recorded while the
state still existed. Anything neither can answer says so in words rather than
going blank, because a gap on a block page reads as a zero.
/quantus/runtime/152 is every pallet, call, event, error, storage entry and
constant the runtime declares — with the constants' values, decoded against
their own declared types. Nothing on that page is transcribed from a source
tree, which is the distinction worth having: a source tree says what the chain
should be running, and metadata says what it ran, at a height, possibly months
after it was upgraded past.
The set of runtimes is found rather than waited for. Decoding caches a runtime
the moment it needs one, so left alone the cache is "whatever the backfill has
walked past" — days, on a chain a million blocks deep. spec_version only
increases, so the upgrade boundaries are a sorted sequence and bisection finds
each in log₂(height) probes. On Heisenberg that turned up six runtimes and
the block each took over at: v126 from 1, v128 from 132, v131 from 342,813,
v136 from 669,129, v144 from 812,055, v148 from 977,079.
It runs as its own task, not in the poll loop, and the reason is worth
recording: state_getRuntimeVersion at an old block makes the node load and
instantiate the runtime WASM out of that block's state, which measured four
seconds against Heisenberg's public endpoint against a quarter of a second at
the tip. A hundred of those inside a four-second poll loop would stall
difficulty, the summary broadcast and the telemetry attach for minutes on every
start.
Reading the two ends of Heisenberg's history side by side is the case for the
page existing. Between v126 and v148 the chain dropped Referenda,
ConvictionVoting, Recovery, Assets and AssetsHolder, added Vesting and
Origins, gained a WeightReclaim signed extension and moved
ChargeTransactionPayment after the two Quantus-specific ones. Every one of
those is a change that breaks a decoder written against the other version, and
none of them is discoverable from a block.
Two renderings are not what the registry literally says, both for the same
reason and both identified by registry path rather than by shape:
AccountId32 becomes 0x hex rather than thirty-two numbers, and U256/U512
become one decimal rather than four or eight little-endian limbs. Difficulty as
[1189189, 0, 0, 0, 0, 0, 0, 0] is a correct description of the bytes and tells
a reader nothing.
/quantus/reversible shows scheduled transfers that have not yet landed,
counting down to the block they execute at. ReversibleTransfers is the most
distinctive thing this chain does — a transfer waits out a delay, default 7,200
blocks, during which its sender or a nominated guardian can call it back — and
no other explorer can show it, because no other chain has the pallet.
It is the only view here built from both halves of the observer, each authoritative about a different thing:
PendingTransfers — says what is still pending. A cancelled or
executed transfer is simply gone from the map, and that absence is more
trustworthy than replaying every event since genesis and reconstructing the
set, which fails silently when it fails.TransactionScheduled
carries execute_at and the stored struct does not. A transfer scheduled
before the index reaches shows as pending with an unknown deadline rather
than being dropped for lacking half its story.execute_at is a DispatchTime<BlockNumber, Moment> — an enum, so it names a
block on one arm and a timestamp on another, and reading the wrong arm would
present a millisecond timestamp as a height. Time remaining is an estimate from
a block interval that moves; the block count is the fact, and the page says
which is which.
Enumerating a map needs state_getKeysPaged on the entry's prefix — there is no
list, only keys derived from the things in it — and recovering each key from its
storage key needs the hasher to have kept it. Blake2_128Concat and
Twox64Concat do; Twox128 and the rest do not, so StorageMap::key_offset is
None there rather than a guess, and such a map can be counted but not
attributed.
The block page lists its extrinsics with each one's own events beneath it,
and the block's own events in their phases. An event's phase says whether it
belongs to an extrinsic or to the block, and extrinsic_index says which — that
join is why both columns are stored, and it turns a block from two lists into
one account of what it did.
Block 1 on this chain is the argument for it. Showing only extrinsics, it is a
single Timestamp::set and nothing else. Its events are the whole opening
distribution:
EXTRINSICS (1)
0 Timestamp::set 1788943917807
42 Vesting::LaunchMomentSet 1788943917807
INITIALIZATION (21)
1 Wormhole::NativeTransferred 3 QTC → qzmtKfCX…z6HW
…
35 Wormhole::NativeTransferred 5,669,940 QTC → qzmviwoP…nxW7
…
FINALIZATION (6)
45 System::NewAccount · Balances::Endowed · Balances::Minted
49 Wormhole::NativeTransferred 0.3 QTC
50 MiningRewards::MinerRewarded 0.3 QTC
51 ZkTree::TreeGrew 3
Two things only visible this way. The chain's opening distribution is
twenty-one wormhole transfers in Initialization, owned by no extrinsic at
all and sent from the minting account — not block zero, and not anything a
transaction did. And Vesting::LaunchMomentSet fires here, so vesting has been
exercised even though the call index shows zero Vesting::* dispatched: a
pallet the runtime uses looks unused from the call side alone.
Some accounts are special and nothing about the address says so. The treasury looks like any other empty account; a minting sentinel looks like a wallet; the accounts endowed at genesis look like they earned it.
Nothing here is curated. The chain names them itself, in three places that are three different claims and are deliberately not flattened into one badge:
spec_version.
MiningRewards::MintingAccount and Wormhole::MintingAccount, both
0x0101…0101.TreasuryPallet::TreasuryAccount.Every label is derived from the chain's own name — TreasuryPallet:: TreasuryAccount becomes Treasury — so a pallet added next year gets its
accounts labelled without an edit. The chip is the claim and the citation
beneath it is the evidence, because "named by a constant" and "named by state"
mean different things about how permanent the arrangement is.
Two rules hold this together. An account is told from a hash by registry path,
never by shape: after normalise both are 0x and sixty-four hex characters,
and filtering by shape claims System::ParentHash names an account. And an
entry must be an account, not merely contain one — System::Events is full
of them, none named by it, and treating "contains" as a role labelled half the
chain's active addresses Events on the first attempt.
A chip states what the chain says and does not endorse it. MintingAccount has
no System::Account entry at all; labelling it as what the runtime calls it is
a fact, and implying it holds funds would be the site vouching for an address.
Endowments have no extrinsic and no event. They are balances the chain was born
holding, invisible to anyone who has not read the chainspec — so /:chain/block/0
carries them, and a Genesis link sits in the section nav to give somebody who
would never think to look an unmissable way to.
Mainnet started with 21 accounts and 5,670,000 QTC, of which one account holds 5,669,940 and the other twenty got 3 each. The page shows the endowment beside what each holds now, so what a founding account did with its stake is one row.
Genesis state lives only on an archive node. When it cannot be read the page says so rather than showing an empty table, because missing data and a chain that endowed nobody are not the same claim.
Everything else here is history — headers, events, extrinsics, a record of what
happened. /quantus/state is the other half, and it exists because of a
question history could not answer.
Which address is this chain's treasury? TreasuryPallet::set_treasury_account
reads never on the call index and TreasuryAccountUpdated reads never on
the event index. Both are true: the address was set at genesis, so no extrinsic
ever carried it and no event ever announced it. It exists only in state, and
until the observer could read state the honest answer was that we did not know.
It is qzjsuLN7Nhu4bjvmUbjSTr2ZTeZ7oRxXpQP9fdv6PcHUCRrVR, and it has never
been funded — no System::Account entry at all.
A storage key is twox128(pallet prefix) ++ twox128(item) ++ hashed keys, and
which hash each key uses is declared per entry: System::Account is
Blake2_128Concat, another map is Twox64Concat. Picking wrong yields a key
that reads as absent rather than as an error, which is why
Runtime::storage_key computes every part of it from the runtime's own
description — including the pallet's storage prefix, which is usually the pallet
name and is not required to be — and why its test pins two keys against ones
read off the live chain by hand.
Absent is not zero, and the metadata is what knows the difference. An optional
entry holding nothing means nothing; a default entry holding nothing means the
value the runtime supplies, which the page marks as default rather than
passing off as something the chain wrote. The same distinction is why an
unfunded account shows — and "no account on chain" rather than a balance of
zero: Substrate reaps an empty account, so a missing entry means it does not
exist, and a zero would say it had been funded and spent.
Read live on every request. State is what is true now, and a cached copy of now is a copy of some earlier now wearing the same face.
/quantus/event is its other half, and the one a chain analyst reads for
narrative: 19 of 107 event kinds have ever fired. A dispatchable says what
somebody can ask for; an event says what the runtime does, including the parts
nobody dispatches directly — a vesting schedule ending, an account reaped, a
proof verified. Vesting::VestingCompleted at zero and System::CodeUpdated at
zero are two different statements about a chain's life, and neither is visible
on an explorer that lists only what happened.
Which is why nothing is excluded from the index for being unused. The event
interest list is a denylist, so a pallet the chain grows next year is indexed
the day it ships rather than the day somebody remembers to add a line. The rule
for exclusion is narrower than volume and narrower than usefulness: an event
carrying no account can never answer "everything involving this account",
which is what the index is for, so the three that both name no account and fire
every block are skipped — ZkTree::LeafInserted, QPoW::DifficultyAdjusted,
System::ExtrinsicSuccess. They show as not indexed rather than as a zero,
because a zero would read as disuse.
Balances::Minted looked like a fourth candidate — it fired exactly as often as
MiningRewards::MinerRewarded across a 120-block sample, and for the same
reason. It is kept: it names an account, minting is not only for miners, and
"who received newly issued tokens" is worth being able to ask even in a month
where the answer is only the miners.
That widening took the index from ~1.2 to ~3.4 rows per block measured on live mainnet, and bought 739 accounts' worth of wormhole transfers that were previously invisible.
/quantus/call is the one page here that does not exist elsewhere. Every
explorer shows activity; this shows activity against the declared surface,
because the runtime describes all 58 of its dispatchables in the same metadata
the decoder already reads. The join is free and the gap is the interesting half:
quantus v152 6 of 58 calls used
heisenberg v148 6 of 58 — and a different six
ReversibleTransfers, TechReferenda, Vesting and Preimage are shipped,
documented, and untouched. "This chain has governance nobody has used" is a
different statement from "this chain has no governance", and only one of them is
visible on an explorer that lists what happened. The unused rows keep their
signature and their documentation, both straight from the chain rather than from
a source tree.
Two chains diverging on the same runtime family is the other thing it shows: mainnet has exercised the wormhole batch verifiers and nothing else, while Heisenberg has a multisig lifecycle — create, propose, approve, execute — and no wormhole traffic at all.
Nothing on the page is written per call. /quantus/call/Utility/batch_all feeds
from the same generic index and renders its arguments through the same
Payload, so a pallet added next year has a working page the moment somebody
uses it — and a row on the index the moment the runtime declares it.
An event is the chain's account of what happened. An extrinsic is the request that caused it, and it carries what no event does: who signed, what they actually called, the nonce and tip they set, and whether it worked. A history built from events alone silently omits every failed attempt and never names a submitter.
The same oracle does both. The metadata's extrinsic type carries four
parameters and the registry names all of them — Address is a MultiAddress,
Call is RuntimeCall, Extra is the twelve signed extensions, and
Signature is qp_dilithium_crypto::types::DilithiumSignatureScheme. That last
one is the part that would otherwise need hand-written post-quantum knowledge,
and it does not: the chain describes its own signature scheme, so
decode_extrinsic reads a Quantus transaction without a line of code that knows
Quantus exists.
Two things about this chain's extrinsics are worth knowing before touching them.
The first byte is not the version — the top two bits are a type tag, and
mainnet carries 0x84 (signed, v4) and 0x05 (bare, v5) in the same block while
the metadata declares version 4. And a signature is 5.3 KiB, two orders of
magnitude larger than the call it authorises; it is decoded to find where the
call begins and then thrown away, with only the scheme's name and the byte count
kept.
Both halves of a block are indexed in one pass, because both come from calls
already being made: the events blob for chain_event, the body for
chain_extrinsic and the block's own timestamp. Whether a dispatch succeeded
comes from the System::ExtrinsicSuccess and ExtrinsicFailed events in that
same decoded list — read, used, and not stored, because two rows per block
forever for one boolean each is not a trade worth making.
An account's page merges the two into one history ordered by block, with the
extrinsic above the events it caused. source is 1 for an extrinsic and 0 for an
event precisely so every part of the sort key descends, which is what lets the
paging cursor be a plain three-part row comparison rather than a
mixed-direction one Postgres cannot express.
Nested calls are the reason this is worth doing generically. A
Utility::batch_all carries whole calls in its arguments, so a transfer's
recipient can be three levels down — and because the decoder collects account
ids wherever it finds them, that batched transfer appears on the recipient's page
tagged received even though they signed nothing.
/quantus/account/qzp2AxZw… answers what the official explorer does not: what
an address has actually been paid, every reward, back as far as the index has
read. The address is converted to a raw account id at the edge, so a typo is
rejected as malformed rather than answered with an empty history — which reads
exactly like a real account that has never been paid — and a Polkadot address is
rejected too: same thirty-two bytes, different network, and answering for it
would show someone activity that is not theirs.
The activity table has no case per event type. Every chain_event row carries
the accounts it mentions in a column the decoder filled — it knows which
values are AccountId32 by their registry path, which is also how it avoids
rendering a block hash as an address — so accounts @> array[…] finds
everything, including from pallets that do not exist yet. The frontend labels
and formats what it recognises and shows anything else as its own key and value,
so a new event type appears as itself rather than not at all.
The preimage and the address are the two ends of one identity, and each page links to the other: the miner page counts blocks, the account page counts tokens, and the derivation joins them. Going the other way — address to preimage — is a lookup among preimages already seen, not a calculation, because the Poseidon2 derivation runs one way only.
Every route here was reachable only by already knowing an address: a block hash,
a preimage, an SS58 string, a spec_version. That is fine for a link somebody
sent you and useless for finding out the pages exist. A kind with nothing after
it is now the index of that kind — /quantus/block, /quantus/account,
/quantus/runtime — and a section nav under the chain chips names all of them.
There is deliberately no /quantus/miner. The standings are the index of
miners, and a second page listing the same rows under a different address would
be two answers to one question; that path redirects there, as does anything else
that resolves to the front page but is not spelled like it.
The three indexes are not the same page three times:
authored_at, the
chain's own clock, not from observed_at: a gap fill writes a whole batch
within the same second, and the first cut of this page reported three
milliseconds between blocks on a chain targeting twelve seconds.pearl-prover with nothing to tell them apart.Paths, not a hash: /quantus/day, /quantus/block/13160,
/quantus/miner/0x…. A fragment is never sent to a server, so a hash URL cannot
be given a title, cannot be crawled, and cannot be resolved by anything but the
page itself — which was a fine trade while the whole surface was a chain and a
window, and stopped being one once a block and a miner each became a page worth
sending someone. Both vhosts already answer any path with index.html, so
nothing outside the client changed. lib/routes holds the grammar, and
fromLegacyHash rewrites the old form on load, so every #/… link already
published still lands where it meant to.
Route segments are named — /quantus/block/13160 rather than
/quantus/13160 — rather than told apart by shape. Shape works right up until
two kinds of thing can look alike, and an SS58 address is arbitrary base58 with
no rule keeping it clear of a window name forever.
A miner's page carries the window too: /quantus/miner/0x…/day. Its rank and
hashrate are of a window, so a link without one means something different from
what the sender was looking at — and until it was in the path, changing the
window on a miner's page could only navigate back to the standings, because
there was no URL for "this miner, over a day". The window control appears on
exactly the two pages where it changes something, the standings and a miner;
a block happened once and a balance has no span, and a control that does nothing
where it sits teaches a reader to distrust it everywhere else.
Everything navigable is an <a>, including the chain chips and the window
control. That is what "real URLs" has to mean in practice: middle-click opens
the 24-hour view of another chain in a tab, right-click copies a link to a
miner, and the back button walks the panels the way it walks pages. The one
deliberate exception is a height resolving to its hash, which replaces rather
than pushes — it is the same block under a better name, not a second place the
reader has been.
A [[chains]] entry takes rpc_urls and ws_urls, tried in order. rpc_url
and ws_url still work as a one-element list, because a config naming one
endpoint is still a valid config.
They are stuck to, not balanced across, and that is the whole design. A storage read at an old block hash needs a node that still holds that block's state; nodes prune on their own schedules, so alternating between them would return a mixture of answers and absences that reads as sparse data rather than as a configuration problem. Requests stay on one endpoint until it fails, and the cursor is shared across every task on the chain so a failover one of them discovers is not rediscovered by the rest.
Failing over on the wrong thing is the trap. A JSON-RPC error is the node
answering — count exceeds maximum value is a caller's mistake, and moving on
it would hide that behind a second node making the same complaint. A pruned
block is a success: state_getStorage returns {"result": null}, and treating
that as a failure would walk every endpoint asking a question none of them can
answer. Only transport failures and malformed responses move.
The WebSocket rotates at reconnect, where the loop already was. Holding
subscriptions to every endpoint and deduplicating heads buys nothing: heads are
a liveness signal and ingest fills gaps against chain_getBlockHash anyway, so
a few seconds on the next endpoint costs a reconnect rather than data.
Verified against a chain configured with a dead endpoint first: Planck stayed
full at its live height, and the failover logged once — from, to, and how many
endpoints the chain has, because "the chain is unreachable" and "one of three
endpoints is unreachable" are different operational facts.
Three chains walking their history at once — mainnet, Heisenberg and Planck's
million blocks — costs about 5% of one core, holds five of eight Postgres
connections with one active, and does not move API latency: summary at 7 ms
p95, block at 13 ms. The walk is bound by RPC round trips, not by anything
here, which is why it needs no deployable of its own.
It is also separable if it ever does. backfill only reads in-memory state —
the tip height and the current runtime — and writes exclusively to Postgres, so
it contends with nothing the API serves. Live ingest is the half that could
not move: it feeds the window, the ticker and the attributions that the API
answers from.
Progress survives a restart because event_scan is a table rather than a field.
That is load-bearing rather than tidy: blackbeard-api-cert.path restarts the
service several times a day when the host certificate rotates, and a cursor in
memory would mean Planck never finishing.
Postgres on magrathea.kosherinata.internal, mTLS and passwordless — the host's
own certificate is the credential and pg_ident.conf maps its CN to the role.
There is no password anywhere in this repo and nowhere in the config to put one.
Blocks are keyed on (chain, height), not on the block hash, deliberately: this
is a proof-of-work chain and it reorgs. An upsert on that key means a block
replacing another at the same height overwrites it, so the standings reflect the
canonical chain rather than the union of every fork the observer witnessed.
What that overwrites is copied into block_displacement on the way past, so a
contested height leaves a trace of who lost it. The node will not answer for the
losing block for long — it keeps only canonical bodies once finality passes
them, and difficulty is a state read behind a 256-block window — so the row
written at the moment of the reorg is the only record there will be. It is not
an orphan store: the block itself is gone, and only what the observer had
already seen of it survives.
The database is what lets the site keep promises a rolling window cannot: "how did I do last week", a hashrate line older than the process, and a leaderboard that survives a deploy. On start the daemon replays the window and the held attribution names out of it, so a restart does not serve an empty site.
A header tells you who mined a block. It does not tell you what they were paid:
the amount is remaining supply over an emission divisor plus collected fees,
quantized with the dust carried into the next block, so it is only knowable from
the MiningRewards::MinerRewarded event the runtime emits. Reading that means
decoding SCALE against the type registry of the runtime that produced the block
— and this chain's encodings differ from vanilla Substrate, because the
post-quantum signature scheme changes the shapes.
Rather than transcribe those shapes into Rust and re-transcribe them after every
upgrade, the observer asks the chain to describe itself. state_getMetadata at a
block hash makes the node execute Metadata_metadata against the runtime WASM in
that block's state, which returns metadata v14: every pallet, every event, every
field, as a portable scale-info registry. blackbeard-core::runtime decodes
against that registry and knows nothing about mining, rewards or transfers —
INDEXED_EVENTS in ingest.rs is the only place a name appears, and adding
transfers or multisig or whatever the chain grows next is one line there and a
query, not a decoder.
Metadata is cached per spec_version in runtime_metadata, because a node that
starts pruning would otherwise make every historical block undecodable. Blocks
are decoded against the tip's runtime first — which costs no extra RPC call and
is right for all but a handful of blocks — and decode_events refuses a partial
read, so a block from before an upgrade fails rather than decoding into
plausible nonsense. That failure is what triggers asking which runtime actually
produced it. It is the property the whole approach rests on.
Events land in one table, chain_event, in the runtime's own vocabulary:
pallet, variant, and the fields as jsonb. Account ids are normalised to 0x
hex on the way in, and integers past 2^53 to strings — balances here are u128 and
a JSON number would be silently rounded by the browser. A jsonb_path_ops GIN
index over the fields is what makes "everything involving this account" a query
rather than a table per event type.
ingest::backfill walks the chain outward from the tip: to the tip first, then
toward genesis, one contiguous interval recorded in event_scan. Live indexing
alone is not enough for an account page — a restart, a brief RPC failure or a gap
fill that ran before the metadata was cached each leave a hole that nothing would
return to. Progress is a cursor rather than max(height) from chain_event,
because a block with no indexed event and a block never looked at are otherwise
indistinguishable, and the first is the common case.
A miner's pow_ preimage and their reward event agree without a lookup table:
wormhole::reward_address runs the same Poseidon2 derivation the chain does, and
its output is exactly the miner field of MinerRewarded. Verified on mainnet
block 13160 — preimage 0x02dacf59…, address qzowWAgb…, account
0xc6801725…, which is the event's own field.
# A throwaway Postgres for the dev loop and for the sqlx query cache.
podman run -d --rm --name bb-pg -e POSTGRES_PASSWORD=dev \
-e POSTGRES_DB=blackbeard -p 55432:5432 docker.io/library/postgres:18-alpine
export DATABASE_URL='postgres://postgres:[email protected]:55432/blackbeard'
export TEST_DATABASE_URL="$DATABASE_URL"
cargo fmt --all && cargo clippy --all-targets -- -D warnings && cargo test --all
# The daemon, against a real node. BLACKBEARD_DEV_DATABASE_URL is the
# development-only path that skips mTLS; the systemd unit never sets it.
BLACKBEARD_DEV_DATABASE_URL="$DATABASE_URL" \
cargo run -p blackbeard-api -- --config dev-config.toml
cd web && pnpm install && pnpm dev # proxies /v1 to 127.0.0.1:25864
cargo test skips the database integration tests when TEST_DATABASE_URL is
unset, so it stays useful on a machine with no Postgres.
Before adding a chain, probe it. Every failure the probe reports is one the
deployed daemon would hit silently — an RPC without the QPoW runtime API gives a
site with no hashrate, and headers without a pow_ digest give an empty
leaderboard, both of which look like a working deployment:
cargo run -p blackbeard-cli -- probe --rpc-url http://bob.hanzalova.internal:9944
CI-driven (architecture/deployment-gitea-actions.md): push to main, or run
the workflow from the Actions UI. Hosts, ports and paths live in
.gitea/workflows/deploy.yaml and nowhere else.
One-time host provisioning is operator-run, from a workstation with full sudo:
./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub
Re-run it whenever the deploy gains a new file to ship — each deploy job preflights the target's sudoers against the grants in that script and fails up front naming what is missing, rather than dying partway through an rsync.
Two things it deliberately does not automate, both because they touch shared
infrastructure: the public Let's Encrypt certificate (external-tls.md) and the
split-horizon blackbeard.internal record, which must be added to both site
routers' Unbound or it NXDOMAINs everywhere but one site.
| Port | What | Where |
|---|---|---|
25864 | blackbeard-api REST + WebSocket | bob (hanzalova), mesh address only, plain HTTP behind nginx |
The edge proxy is oolon (kosherinata) and the API is on bob
(hanzalova), so /v1 is a cross-site hop over the mesh. That is deliberate —
the API has to sit beside the node whose loopback RPC it reads — and it is the
one hop a loopback health probe cannot see, so the deploy checks it explicitly.
Registered in architecture/port-allocations.md §5; derived from the service
name per §3.
Quantus mainnet is not yet live. It is a commented-out [[chains]] block in
asset/config/config.toml.tmpl, waiting on a published chain spec and an
endpoint.
A chain may be configured before it launches: the observer reports it as
awaiting, the UI says so plainly, and it comes alive on its own the moment the
node starts answering. No redeploy, no restart. Adding it is a config change and
a probe run.
runs-on: infra for the deploy jobs, not fedora-43. The targets are
mesh-only .internal names and the fedora runners have no route to the mesh.
Same reasoning as lair/quantus and lair/mail.generic.md says nothing about themes, but the dataviz
conventions expect a selected dark mode alongside light. This site commits to
one look: it is a scoreboard for a proof-of-work chain, and a light variant
would be a second palette to validate for a context this content does not have.BLACKBEARD_DEV_DATABASE_URL bypasses the mandated mTLS Postgres
connection. It exists so cargo run works on a workstation with no
pg_ident mapping on the fleet cluster. It is an environment variable rather
than a config key precisely so it cannot be reached by editing a deployed
config, and taking it logs a warning loud enough to spot in the journal.lair/quantus issue #1 tracks the same gap from the exporter side.lair/quantus — the node and miner deployment, the arena exporter these
mechanics come from, and the measured hashrate numbers behind the fleet.~/git/architecture — the house conventions this project follows.134 activities
2bbe8e4 feat: a vesting route — when 99.5% of the supply actually arrivesda7f773 feat: how concentrated the balance is, once the treasury is set asideBuilding it now. The spec says "the share of issuance held by the top 1, 10 and 100" plus a Gini. Two measurements since this issue was written mean that, done literally, it produces a number that is true and says nothing.
The top account is the vesting pot, holding 99.54%. modlqvesting is the
vesting pallet's own PalletId account. "Top 1 holds 99.5%" is a fact about an
undistributed reserve, not about how concentrated the economy is, and a Gini
computed with it in measures the same thing: it would sit near 0.99 forever and
move only when the pot pays out.
The denominator cannot be issuance. The sum of account balances exceeds
TotalIssuance — by 13,427 QTC at block 42,400 and growing — because a wormhole
exit credits its destination without debiting the note's source. Shares of
issuance would therefore sum to more than 100%.
A large part of the tail is keyless. Mining rewards are paid to
derive_wormhole_address(preimage), which nothing can sign for, so those
balances are cumulative receipt totals that can only rise (#19). Concentration
across them partly measures who mined most, not who holds most.
Cached on a TTL like cached_roles, fed by a new state_queryStorageAt in
RpcClient — the client does not have one yet, which is why this was always
the second phase.
67e7b2e fix: the waiting counter was never once visiblea245cae feat(ui): count the wait instead of describing itee575ed feat(ui): two columns for the stage, and a console that links out578a54e fix: the backfill was re-announcing blocks the console had already shownad22866 feat(ui): say what each mark was4f59af9 feat(ui): the wormhole holds what it has swallowedc08face feat(ui): a live wormhole, drawn from the blocks as they land864a76f feat(ui): show what goes into the wormhole and what comes outf81ebd4 feat: who the wormhole exits pay, and how much of it is the protocol itself4d9d418 fix: a miner's balance is a receipt total, and the supply figures were wrongThe shipped page says, and CLAUDE.md records, that there is no circulating-supply
figure because Balances::Locks, Holds, Freezes and Reserves are all empty, so
circulating would equal total issuance exactly and printing it would assert a
distinction the chain does not make.
The counting was right. The conclusion was wrong. The chain makes that distinction — it just does not make it with locks.
Measured on mainnet at block 42,400 (rpc1-mainnet.quantus.com), the single largest
account holds 99.54% of every unit of balance on the chain:
0x6d6f646c7176657374696e6700…00 5,669,940.001 QTC nonce 0 99.54%
6d6f646c is ASCII modl; the account is modlqvesting, which is
PalletId(*b"qvesting") — the vesting pallet's own pot
(runtime/src/configs/mod.rs:606, impl pallet_vesting::Config for Runtime at :669).
This runtime's vesting is pot-based, not lock-based: pallets/vesting holds
undistributed funds in its PalletId account and pays them out, rather than issuing them
to beneficiaries and freezing them with a Balances lock. That is precisely why the
lock/hold/freeze maps read empty — and why reading "empty" as "nothing is withheld" got
it exactly backwards.
So:
| | QTC |
|---|---|
| Balances::TotalIssuance | 5,682,968.964 |
| held in the vesting pot | 5,669,940.001 |
| issuance outside the pot | 13,028.963 |
The page currently invites the reader to treat 5.68M as the supply in play. The number in play is about 13,000 — a factor of 436. The "48 vesting schedules, a count not a sum" note is the right instinct pointed at the wrong quantity: the sum that matters is not the schedules, it is the pot, and the pot is a single storage read.
The sum of all account free balances exceeds TotalIssuance, and the gap grows:
block 0 issuance 5,670,000.001 Σ free 5,670,000.001 delta +0.000
block 4,000 issuance 5,671,226.351 Σ free 5,671,226.351 delta +0.000
block 4,800 issuance 5,671,471.??? Σ free … delta +111.830 <- first
block 10,000 issuance 5,673,063.441 Σ free 5,674,840.771 delta +1,777.330
block 20,000 issuance 5,676,120.137 Σ free 5,682,439.327 delta +6,319.190
block 30,000 issuance 5,679,174.300 Σ free 5,688,926.540 delta +9,752.240
block 42,400 issuance 5,682,958.266 Σ free 5,696,380.896 delta +13,422.630
Genesis reconciles to the unit. Bisection puts the first divergence at block 4,800,
whose one non-inherent extrinsic is a 223 KiB bare call to pallet 20 call 3 —
Wormhole::verify_public_batch.
The cause is deliberate and documented in the runtime. Wormhole::credit_and_record
(pallets/wormhole/src/lib.rs:1348) credits an exit with
<T::Currency as Unbalanced<_>>::increase_balance, not Mutate::mint_into. Only
mint_into follows the credit with set_total_issuance; increase_balance writes the
account and leaves issuance alone. The pallet's own comment says why it must stay that
way — mint_into would emit Minted, and WormholeProofRecorderExtension would
double-record the leaf. The only set_total_issuance in the pallet is a subtraction,
for the fee burn (:1001).
So a wormhole exit credits a spendable balance while the note's original account keeps
its balance, and issuance moves for neither. TotalIssuance is the honest supply
figure; a sum of account balances is not, and the two diverge by the cumulative
value ever exited. Nothing on the site should ever add balances up and call the result
supply.
Verified single blocks reconcile (42401→42402 and 42405→42406 both match issuance delta to balance delta exactly), so this is not a steady per-block leak — it is specific to exit settlement.
monday, september 14, 2026 — 06:48:41 utce3c95d4 feat: a wormhole page — what enters, what leaves, what is still inside365cd7a fix(ui): "sent nothing" was a claim this chain cannot supportdf23066 feat(ui): show where the mining rewards went06f3ece feat: account flows, and a distribution table for mining rewardsaaf37ba feat: a network page — supply, capability and daily activity3551b83 fix(web): the standings do not belong under a nodefac73f3 feat: a nodes index, so a node need not have mined to be visiblee7cfe42 feat(telemetry): read what a node is doing, live off the socket28e99be feat: show the node a miner's blocks were reported from0b3c1b2 feat(telemetry): decode the whole of NodeDetails, and locate nodes by countryClosing on measurement, as promised.
A circuit breaker tripped at kosherinata, taking out both the database host and the edge proxy. blackbeard-api on bob failed its startup connection and looped 378 times before the power came back — which handed me exactly the test this issue was held open for: a genuine cold start on the chain most exposed to the restore path.
Heisenberg is that chain. It produces roughly a block every five minutes, far too slowly to accumulate MIN_TIP_SAMPLES fresh observations before the next restart, so its interval comes entirely from whatever restore_ticker_and_timing replays out of the table. That is why it was the last one still wrong.
| chain | before the guard | after a real restart | target | |---|---|---|---| | quantus | 13.3 s | 12.85 s | 12 s | | planck | 45.8 s | 29.6 s | 6 s (running far slower) | | heisenberg | 0.054 s | 41.6 s | 6 s (running far slower) |
All three measured rather than nominal, all three plausible against a chain that is producing. Heisenberg moved by a factor of 770.
The label half is verified too — Planck's 3,600-block window now reads 37.1 h, up from 32 h earlier today, because the chain keeps slowing and the label keeps tracking it instead of asserting six hours.
Four causes behind one symptom, and the first three each looked like the whole thing:
observed_at was stamped where the head was processed, behind two RPC round trips and a 64-deep channel, so a drained backlog carried near-identical times while heights marched on.at_tip in the table is whatever the writing process believed, and the warm start replayed those beliefs on every boot.blocks x current interval rather than the window's own authored span — which was wrong even once the interval was right.Plus the rename that came out of it, and the miner history chart, which was the last place still assuming a block count implies a duration.
The lesson worth keeping is (3): a fix to a pipeline does not fix the data the broken pipeline wrote, and a warm start is a pipeline that reads it back. Two of these fixes were verified as "the number looks right now" and were not — the only check that settled it was a restart on the slowest chain.
Every one of these was an artefact of observer-side timing. The chain's own authored_at is immune to all four, and the chain series already uses it for that reason. Deriving the headline interval from it would delete the class rather than guarding instances — but it changes what the site's most prominent number means, and CLAUDE.md records the current choice deliberately, so it deserves its own decision rather than being folded into a bug fix.
Reopened. I closed this on an incomplete fix and the evidence says so.
The label half is done and verified in a browser against the live chain:
/quantus/six_hours -> /quantus/3600-blocks last 3,600 blocks · 11 h
/planck/3600-blocks -> unchanged last 3,600 blocks · 32 h
Plus the rename (600-blocks … 100800-blocks, with the old duration names still resolving), and the miner history chart, which was the last place still assuming a block count implies a duration.
The interval half took three passes, and the first two each looked complete:
| | planck | heisenberg | true | |---|---|---|---| | before | 0.175 s | — | ~30 s / ~300 s | | after the gap-fill guard | 4.9 s | 0.052 s | | | after receipt-time stamping | 45.8 s ✓ | 0.054 s | |
Three distinct causes, not one.
observed_at was stamped where the head was processed. Each head costs two RPC round trips, so the 64-deep channel backs up and drains in a burst — a batch carrying near-identical times while heights march on. Stamped at receipt now.at_tip in the table is whatever the writing process believed, and every process before (2) believed a drained channel was a tip observation. restore_ticker_and_timing replays those rows on every start, so the bug comes back from storage. Heisenberg makes a block every five minutes — far too slow to accumulate twenty fresh samples before the next restart — so it never got the chance to correct itself, and reported 0.054 s/block with the running code already correct.(3) is the one worth remembering: a fix to a pipeline does not fix the data the broken pipeline wrote, and a warm start is a pipeline that reads it back.
Restored samples are now checked against the one clock the observer cannot fake. At the tip a block is seen roughly when it is authored, so the observer's span should resemble the chain's span for the same blocks. Propagation and skew are seconds; a catch-up is a factor. A batch that fails is left unrestored and timing starts nominal.
All three chains holding a plausible interval across a restart — not just now, and not because the code looks right. Heisenberg is the useful case: it is slow enough that every restart depends on the restore path rather than on fresh samples.
Every one of these three was an artefact of observer-side timing. The chain's own authored_at is immune to all of them, and the chain series already uses it for exactly that reason (readme.md: "their block interval comes from the author's own timestamp inherent rather than from when this observer saw the block"). Deriving the headline interval from authored_at over recent blocks would delete this entire class of bug rather than guarding against each instance.
I have not done that, because CLAUDE.md records a deliberate decision that the headline uses the measured observation interval, and changing what the site's most prominent number means is not a thing to slip into a bug fix. Filing it as its own question if it is wanted.
380a0e1 fix: don't restore tip samples the chain's own clock contradicts4f99028 fix: a miner's history chart spans its window, not a hard-coded hour7ec7dda fix(web): a window segment names its unit, or it is just a height813e49c fix: time a head by when it arrived, not by when we got round to itd12299f ci: gate the API deploy on what the API is running, not on the last push49f50bd docs: name the gate, since running a lookalike is what broke the build2594190 fix(web): an empty path segment is not a windowef367d5 fix: stop timing the catch-up, and name windows for what they areInvalid — I measured the wrong process. There is no bug. Closing.
The deployed API, asked on bob itself:
| chain | window_blocks | observed_blocks | rows | |---|---|---|---| | quantus | 3600 | 3600 | 30 | | planck | 3600 | 3600 | 95 | | heisenberg | 3600 | 3600 | 2 |
A full window on every chain. The shares divide by 3,600 exactly as designed.
Every number in the issue came from curl http://127.0.0.1:25864/... on my workstation, through what I believed was an SSH tunnel to bob. It was not:
LISTEN 127.0.0.1:25864 users:(("blackbeard-api",pid=970552)) <- a local debug build
LISTEN [::1]:25864 users:(("ssh",pid=1018647)) <- the tunnel
A local ./target/debug/blackbeard-api from earlier in the session already held IPv4 loopback, so ssh -L fell back to binding IPv6 only — with ExitOnForwardFailure=yes satisfied, because one of the two binds succeeded. curl 127.0.0.1 prefers IPv4, so every request went to the local process. It had been up 99 minutes with a window filling from live blocks, which is precisely the "window far short of 3,600" signature I then attributed to production.
The tell was in the issue and I read past it: the log line said tallied: 3600 at the same instant the response said 1,177. Two irreconcilable answers from one process is not a subtle bug, it is a sign you are talking to two processes. I kept looking for a mechanism inside the code instead.
held beside blocks on warm start: window restored — a real invariant, cheap, and it is what proved the restore lands.chain on the recompute line. Its absence made three chains' recomputes indistinguishable and cost real time.a_restored_window_tallies_the_whole_requested_span in core, and the_board_divides_by_the_whole_restored_window driving push → recompute_leaderboard. They pin behaviour that was previously only assumed, and they passed throughout — which was itself evidence the fault was not where I was looking.The temporary debug drop-in on bob has been removed and the stray local process stopped.
The claim in "Not new" — that the pre-fix mainnet screenshot showed 828 blocks at 83%, implying a ~998 denominator — was the same measurement error. That screenshot was taken through the same tunnel, against the same local process. It was never evidence of anything about production.
thursday, september 10, 2026 — 09:55:24 utc3c6f9c9 chore: log what the leaderboard cache serves, for #13dedee70 chore: say what the window actually holds, for #134774b8c feat(ui): a light theme, following the browser, with a toggle in the mastheadImplemented in 9def0c8, with one deliberate departure from what this issue proposed.
The description said "route historical reads only to the archival ones". That is worse than what landed. It would have taken every historical read off the sticky endpoint — including mainnet's, which is our own loopback node and pays no network hop — and sent it over the wire to a public node, on every read, to defend against a case that is not currently true of any endpoint.
What landed instead: call_deep tries the sticky endpoint first and only buys a second opinion when a non-archive endpoint returned null. A null from an archive is the answer and stops there. So the common case costs nothing at all, and the extra round trip is paid only in the situation this issue exists for.
One thing the description missed. It framed the whole problem as null-that-looks-like-an-answer. But state_call — which is how difficulty_for_child_of reads difficulty — does not return null when it cannot execute against dropped state. It refuses, with a JSON-RPC error. call is right not to fail over on that (an error is the node answering), so the archive would never have been asked, and the most depth-sensitive read on the site would have been the one still broken. call_deep treats an Rpc error from a non-archive endpoint the same as a null.
Probe. System::Number at block one — always written, four bytes. The key is derived from twox_128 in a test rather than pasted, because a mistyped storage key is absent everywhere and would have classified every endpoint as pruned. :code would also work and is megabytes; state_getRuntimeVersion would work and takes ~4 s per endpoint.
Measured against all seven configured endpoints today, every one returns 0x01000000 — block one's number, so all seven are archives:
| endpoint | depth |
|---|---|
| http://127.0.0.1:9944 (ours) | archive |
| rpc1-mainnet.quantus.com, rpc2- | archive |
| a1-heisenberg.quantus.cat, a2- | archive |
| a1-planck.quantus.cat, a2- | archive |
So this changes no current behaviour and is dormant insurance, which is what it was filed as. An endpoint that cannot be probed stays Unknown and routes as pruned — depth is demonstrated, never assumed, or an unreachable host silently becomes the archive of record.
Option (3) from the description — recording and surfacing the pruning boundary — is not implemented. call_deep logs when an archive answers what the current endpoint could not, which is the operational signal; showing a boundary on the block route is a separate piece of work and nobody needs it while every endpoint is an archive.
Both CLAUDE.md paragraphs the earlier comment asked to protect are still there, with the new behaviour written as a third alongside them.
9def0c8 feat: route reads that need old state to an endpoint that has it4fb8568 fix: a bogus length prefix aborted the daemon, and the gap fill never resumedWorth pinning before anyone picks this up: CLAUDE.md already documents null-on-pruned as deliberate, under "A pruned block is a success, not an unhealthy endpoint" — treating it as a failure would walk the whole endpoint list asking a question none of them can answer, and turn a legitimate absence into an outage. The neighbouring note, "Endpoints are stuck to, never round-robined", is the other half: alternating between nodes that prune on their own schedules returns a mixture of answers and absences that reads as sparse data rather than as a configuration problem.
So this issue is not "make null fail over". Both of those decisions stand. What is missing is the third thing neither covers: knowing, before the read, which endpoints could possibly hold the state — so a historical read can be routed to an archival node rather than retried across all of them. Option (1) in the description is the load-bearing part; (2) is only useful once (1) exists.
If a fix here ever ends up deleting either of those CLAUDE.md paragraphs, it has gone wrong.
db9ee14 config: give mainnet the two public archive nodes as fallbacks1a59b2d feat: several endpoints per chain, so one host cannot stop it27a4f30 feat: index Planckadaf00e ui: separate mainnet from testnets in the chain nav7d081c7 fix: map enumeration asked for more keys than the node allows8808655 fix: show a block's events, joined to the extrinsics that caused themdd8086d feat: mark the accounts a chain names, and make genesis unmissablec08d3c4 feat: pending reversible transfers, with a countdownCorrection to the scope above, found while reading the pallet.
PendingTransfer does not carry execute_at. The stored struct is:
pub struct PendingTransfer<AccountId, Balance, AssetId> {
pub from: AccountId,
pub to: AccountId,
pub guardian: AccountId,
pub asset_id: Option<AssetId>,
pub amount: Balance,
}
The execution time lives with the Scheduler, not with the pending transfer.
ReversibleTransfers::TransactionScheduled carries execute_at as an event
field, and nothing in PendingTransfers does.
So the countdown is a join of both halves, which is a better shape than either alone:
PendingTransfers — is authoritative about what is still
pending. A transfer that was cancelled or executed is gone from the map, and
no amount of event replay is as trustworthy as its absence.TransactionScheduled, keyed by tx_id — supply when it is due
and when it was scheduled.A row that appears in state with no matching event is possible (the event index reaches back only as far as it has read) and must render as pending with an unknown deadline rather than being dropped.
This also means the DispatchTime note above still stands but applies to the
event field rather than to a storage value.
6b0e02e feat: read chain state, decoded against the runtime1e8869e feat: index the whole event surface, and show what has never fired6b45004 feat: the call index — declared surface against actual use93358dd ui: the window belongs to the miner page, not just the standingsf9ea292 fix: a hashrate estimate now carries its error baredce856 feat: decode extrinsics, and show themefbe901 feat: section indexes, so the routes can be found289a80a fix: a single-runtime chain lost the one fact worth having1410d3d ci: skip the Rust build and API deploy on frontend-only pushese330d2b ui: a miner and an account are pages, not dialogsd6f4c0c ui: only advertise chains we can actually readd5a286f feat: account pages, built on the event index49c266a ui: real URLs for every vieweb26a7b feat: decode events against the runtime's own metadata6a29250 feat(ui): link the source repository from the footer9eb4bee fix: a miner's hashrate is the work it did, not its share of today's network17ad5b1 fix: a restart no longer empties the ticker and the hashrate18a99dd fix(ui): head the miner panel with the best identifier, not the preimagea3eb670 feat: identify miners by the address they are paid at, not the preimageffcfb79 feat(attribution): carry a miner's name across chains by reward preimagea177e80 fix(ui): say what a held name with no current votes actually means992237b fix(ui): pluralise the verb, not just the noun, in the attribution tooltipbfd3470 feat(attribution): offer a best-effort name, and say how thin the evidence isef3a4b2 fix(attribution): a held name survives silence, and only a clear lead replaces it337dfa0 fix(attribution): retry the telemetry join until the feed catches up057937e fix(attribution): measure confidence over votes cast, not the whole window2cb8f1c config: the node on bob is mainnet now, not Planck53dac37 feat: block explorer routes, addressed by hash0ce5809 feat(data): record what a reorg displaced337d1ef ci: format the frontend with prettier and gate it9cbc2cc ui: put the miner count before the height tile7e5c2de feat: sparkline history under the headline tilesb552b08 docs: record the mesh-bind, hairpin and runner-label gotchas3db86cf fix(ci): health-probe the address the API actually binds, not loopback151a30a ci: run the build on the rust runner, drop corepack, pin the site check to loopback36a10ac fix(api): allow every configured CORS origin, not just the last6a72539 fix(infra-setup): escape backticks in the closing message; report db verification honestly