57 Commits

Author SHA1 Message Date
501a9e6ad4 fix(data): re-read event history under the current keep policy
All checks were successful
deploy / build (push) Successful in 8m35s
deploy / deploy-web (push) Successful in 7s
deploy / deploy-api (push) Successful in 16s
Until 1e8869e the backfill kept only MiningRewards::* and Balances::Transfer.
The skip-list that replaced that allowlist only applied to blocks read after
the deploy, and event_scan never revisits a range it has claimed. So on
mainnet every block below 18510 has no Wormhole::* (or any other newly kept
kind) while indexed_from says 1. Planck and Heisenberg have the same hole over
whatever they had read by then.

This was found checking #22's endpoint against a mining address whose
deposits began at transfer_count 17,142. A mainnet node decodes
Wormhole::NativeTransferred in blocks 18400 and 18509, under the same spec 152
as the indexed blocks.

Migration 0008 collapses each chain's cursor to its top (low = high), so the
backfill walks back to genesis again. record_events and record_extrinsics
upsert on their primary keys, so re-reading is safe. Until the walk finishes,
indexed_from reports the real, shrinking gap. CLAUDE.md now says that keeping
more events needs a migration like this one.

Closes #23

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 17:23:34 +03:00
82a4ac9c59 feat(api): wormhole deposits by recipient
All checks were successful
deploy / build (push) Successful in 8m40s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 18s
GET /v1/chains/{chain}/wormhole/deposits?to=<addr>,…&after=<h>:<i>&limit=<n>

Every Wormhole::NativeTransferred to up to 100 addresses (SS58 or 0x hex),
oldest first, with a height:event_index cursor. A deposit carries its
recipient, its amount as a decimal string, its transfer_count and its
leaf_index: what a wallet needs to work out what it can still spend.

A wormhole address's System::Account balance is a receipt total. An exit
credits the exit account and records a nullifier, and never debits the
wormhole address. A deposit is spent when the nullifier its secret and
transfer_count derive is used. The wallet computes and checks that itself;
nothing here asks for nullifiers, because naming one pairs a deposit with its
exit.

This is for the Quantus extension's wormhole balance (quantus/extension#14),
which would otherwise ask Quantus's Subsquid and tell Quantus which addresses
are one person's.

- The query reads chain_event through the accounts GIN index, then keeps only
  rows whose `to` is one of the addresses: accounts holds the sender too, and
  a transfer from a wormhole address is not a deposit to it.
- The response carries indexed_from/indexed_to. On a chain still backfilling,
  a client can't take the sum as a balance without checking it against
  Wormhole::TransferCount.
- CORS on this route alone allows any origin, GET only, merged outside the
  site's allowlist so that allowlist can never answer a preflight for it.
  Extension origins can't be listed (Chrome ids vary by install, Firefox's are
  random per install), and the data is public.

The integration test covers the ways this is silently wrong: a deposit from an
address counted as one to it, another event kind leaking in, a cursor that
skips a same-block sibling, and another chain's rows. Unit tests cover both
address spellings, the cursor, and u128 amounts written as strings.

Closes #22

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 17:07:48 +03:00
6ed9a6c35a feat: an about route, and somewhere to say thanks
All checks were successful
deploy / build (push) Successful in 8m29s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
Adds `/:chain/about`, linked from the header just before the connection light
and the theme toggle — it is about the site rather than the chain, so it sits
there instead of in the section nav, and `header: true` on its `SECTIONS` entry
keeps it out while still letting the URL parse.

The page says what the observer is, that it and the CUDA-optimised miner are
built and freely sourced by a member of the Quantus user community rather than
by the project, and takes donations in QTC.

**The QR is generated in the browser from the address printed beside it, never
shipped as an image.** A supplied PNG next to a typed address is two
independent claims about where money goes, and nothing on the page could tell a
reader if they ever stopped agreeing — a mismatch sends funds somewhere
unrecoverable.

Verified rather than trusted. The address was put through this crate's own SS58
decoder: checksum and network prefix both hold, and it round-trips to the same
string. The QR matrix was then decoded from first principles — function-pattern
map, mask 3 unapplied, codewords de-interleaved across version 4-M's two blocks
— and reads back the address exactly. A note for whoever checks this again: an
independent encoder produces a different matrix for the same string at every
mask, because mode segmentation differs, so comparing matrices proves nothing
and only a decode does.

`the_donation_address_on_the_site_is_a_valid_quantus_address` reads the literal
out of the frontend source and validates it, so a typo fails CI rather than
costing somebody their money. Confirmed to fail on one changed character.

Also on the vesting page, from verifying it live: a "claimable now" figure and
a per-grant claimable column. Two grants began vesting at launch while the other
forty-six wait for 2027, so 68,871 QTC is already claimable and none of it has
been taken — a page that only said "first claimable" would have missed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:48:21 +03:00
2bbe8e4d5d feat: a vesting route — when 99.5% of the supply actually arrives
All checks were successful
deploy / build (push) Successful in 9m23s
deploy / deploy-web (push) Successful in 7s
deploy / deploy-api (push) Successful in 16s
The network page said 5,669,940 QTC sits in the vesting pot and stopped there,
which is the largest economic fact about this chain rendered as a single
number. `/:chain/vesting` now carries the schedules, the release curve and the
evidence.

It is the only forward-looking page on the site and it is exact rather than
projected: the runtime vests nothing before a grant's cliff, the whole grant
from its end, and linearly between, so summing that across the schedules is
arithmetic on chain state. `blackbeard-core::vesting` restates those three
branches with tests, because until 2027 nothing on chain will contradict them —
covering the cliff, the mid-window, the `end` branch that makes the final
figure exactly `total` rather than `total` less a rounding crumb, the
`start < cliff` case that unlocks a chunk at once, and a grant large enough
that `total × elapsed` would wrap a `u128` if the product were not split.

Decoded by field name against the metadata, never by offset. The figures that
justified building this were read positionally out of 89 bytes and summed to
the pot exactly — good evidence the layout was guessed right, and exactly the
shortcut this repository forbids elsewhere. A captured mainnet entry now pins
the names, so a reordered struct fails the build rather than quietly paying the
wrong address.

It also retires a weak claim. "Held in the vesting pot" rested on an eight-byte
`PalletId` spelling `qvesting` and a `type PalletId` line read from a source
tree that may not be the deployed commit. `matches_pot` replaces that with
arithmetic: the grants sum to the pot's balance to within the existential
deposit, and if a grant is ended or the pot topped up the page says the two
have parted company rather than going on describing one as the other.

Measured: 48 grants, 5,669,940.000 QTC scheduled against 5,669,940.001 held,
nothing claimed, first unlock 2027-09-09, fully vested 2030-09-08.

`Field` becomes one component instead of four. Going to add a fourth copy found
that the three had already parted company — two rendered `note` as a visible
caption, the third passed it to `title` as a hover — so the shared one takes
both as separate props rather than silently changing a page. The same lesson
`isStandings` records, found the same way.

Closes #21

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:31:53 +03:00
da7f773564 feat: how concentrated the balance is, once the treasury is set aside
All checks were successful
deploy / build (push) Successful in 7m40s
deploy / deploy-api (push) Successful in 16s
deploy / deploy-web (push) Successful in 6s
The last piece of #17. It was specified as "the share of issuance held by the
top 1, 10 and 100" plus a Gini, and done literally that produces a number that
is true and says nothing — because the top account is the vesting pot holding
99.5% of the chain. The figure would sit near 1.000 and move only when the pot
paid out, describing an undistributed reserve rather than an economy.

So the pot is reported first and then excluded, by its own storage key rather
than by being the largest: "the biggest account" is a description that would
silently start meaning something else the day the pot drains. Everything else
is computed over what is left and labelled as being outside it.

The denominator is the counted balance, not issuance. The two differ, and in
the direction that matters: a wormhole exit credits its destination without
debiting the note's source, so balances summed across the chain exceed
issuance, and shares of issuance would add up to more than everything.

Measured on mainnet: 1,949 accounts, 27,245 QTC outside the pot, top 1 at
39.6%, top 10 at 72.2%, top 100 at 92.2%, Gini 0.965. That top holder is a
mining address — keyless, its balance a cumulative receipt that cannot fall
however much its operator pays out — so the panel says in its own words that
this is partly a statement about who mined most rather than who holds most.

The Gini lives in `blackbeard-core` with its own tests, per the rule about
statistics being easy to overstate and hard to notice: pinned against the
published coefficient for 1,2,3,4, and against a dust holder beside two whales,
which is the case an `f64` accumulator would round away and report as perfect
equality between the whales.

`RpcClient::storage_values` is new — `state_queryStorageAt`, which turns two
thousand round trips into four. Chunked at 500 rather than sent whole because
nothing documents a ceiling here, and an undocumented ceiling is one that gets
discovered in production.

Nothing renders unless the account walk reached the end of the map. Every share
divides by a total, and a partial sum makes all of them wrong while looking
entirely reasonable.

Closes #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:32:11 +03:00
578a54ef87 fix: the backfill was re-announcing blocks the console had already shown
All checks were successful
deploy / build (push) Successful in 8m18s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 16s
The wormhole pulse was broadcast from inside `index_events`, which has two
callers: `record`, for a block that just arrived, and `backfill`, walking
history. So the backfill re-announced every block it re-read — and because it
walks towards the tip, the blocks it re-announced were exactly the ones a
viewer had just watched. The live console listed #43,446 three times.

This is the live/backfill confusion this repository has already been bitten by
twice, arriving by a third road, so the guard is a type rather than a comment:
`Pass::Live` or `Pass::Backfill`, and a caller cannot forget to say which.

The duplicates did more damage than repeating themselves. Console lines were
keyed `height-index`, which looked unique and was not, and React given
duplicate keys renders duplicates *and* stops honouring the list's own length
cap — twelve lines had become eighteen on screen. Lines are keyed on a
monotonic counter now, so a reorg re-recording a height cannot reproduce it.

Neither half was caught by the build, the lint, or the tests. Both were
obvious within a minute of watching the live page.

Refs #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:12:41 +03:00
ad22866d3c feat(ui): say what each mark was
All checks were successful
deploy / build (push) Successful in 7m36s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s
The stage drew a dot per note and left the reader to guess. A dot travelling
inward could be anybody paying anybody, and without text beside it the
animation is an ornament rather than a reading of the chain.

Each pulse now carries a sample of the individual notes — kind, recipient,
amount — and a console below the stage lists them newest-first, the way the
block ticker reads, which also means no auto-scroll to fight with and the line
a reader is looking for never moves.

`kind` is the third split this codebase makes on the producing extrinsic and
the first that separates all three cases: a reward has no extrinsic at all
because it is paid in block initialisation, a transfer has an ordinary one, and
an exit has a `verify_*_batch`. The recorded sender distinguishes none of them.

Two caps, and they turned out to interact. `MAX_PULSE_NOTES` bounds the wire —
a block has carried hundreds of notes and sending every one for a handful of
lines is kilobytes a block of text nobody reads. `CONSOLE_LINES_PER_BLOCK`
bounds one block's share of the console, because without it a single busy block
fills every line and twelve lines from one block look exactly like twelve
blocks of one, which loses the reader all sense of rate.

The count of what is not shown hangs on the block's first line rather than its
last: the list is newest-first, so a count on the last line is the first thing
trimmed, and a block of 260 showed four notes while saying nothing about it.
That one reached a screenshot before it was caught.

The console fills before the reduced-motion guard, so a viewer who asked for
less motion still gets the whole reading — twelve lines, no moving marks,
population still drawn, caption still live.

Refs #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:01:16 +03:00
c08face7ad feat(ui): a live wormhole, drawn from the blocks as they land
All checks were successful
deploy / build (push) Successful in 8m19s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 16s
The wormhole page described a pool that was never shown doing anything. It now
opens with a stage where a note arriving falls inward and is swallowed, and a
note leaving climbs out and escapes — driven entirely by blocks this observer
has actually decoded. Nothing is on a loop: a still stage means a still chain.

`index_events` now reports what each block did to the pool, split by the
producing extrinsic exactly as the daily flow query and `exit_cohorts` are —
a settled batch releases value, anything else commits it, and the recorded
sender cannot say because it is the same sentinel either way. The exit
extrinsics are collected inside `index_extrinsics`, the one place the body is
already decoded; finding them in a second pass would re-run `decode_extrinsic`
over Dilithium signatures at 5.3 KiB apiece.

It is broadcast after the block rather than inside it, because the counts cost
two more round trips and the site's ticker must not wait for one panel. A block
that touched no notes broadcasts nothing.

On the front end the pulse takes a side channel — `Observer.onWormhole`, apart
from the state listeners — because a pulse is an event, not a fact about the
page, and routing it through `ObserverState` would re-render the leaderboard,
the ticker and the whole tree once per block for one animation. Marks are
pooled SVG nodes mutated from a loop that starts on a pulse and ends itself.

Honest by construction: marks are capped and the caption is not, so a block
committing 900 notes draws 28 and says nine hundred. `prefers-reduced-motion`
stops the motion and keeps the information — no marks, caption still live,
aria-label still describing the last block.

The stage takes the headline stat row's place on this page. Hashrate,
difficulty, block time, miners and height are on every other page and say
nothing about the pool.

Refs #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:58:16 +03:00
864a76ffb6 feat(ui): show what goes into the wormhole and what comes out
All checks were successful
deploy / build (push) Successful in 7m33s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 15s
The wormhole page counted the pool but never drew the flow through it. It now
leads with a meter — 42% of every note ever created is still unspent — and a
per-day chart of value arriving against value leaving.

Direction is read from the producing extrinsic, never from the sender, which
is the same sentinel whichever way the value is going: anything settled by
`verify_public_batch` or `verify_private_batch` is leaving, everything else is
arriving. `entered + left` reconciles to the day's total exactly.

**The chart is diverging with a single hue.** In and out want two hues either
side of a neutral midpoint, and this site has one data colour. The two-shade
alternative was measured rather than assumed and fails outright — `#bd8829`
against `#e0a63a` scores a normal-vision ΔE of 9.9, under the 15 floor. So
direction is carried by which side of the zero rule a bar sits on, which
survives greyscale, print, forced-colors and every form of colour vision, and
leaves no categorical pair to validate.

Rendering it is what found the rest. Same hue plus touching bars drew one
continuous mark through the rule and the encoding disappeared, so a surface gap
is held open at zero. Five days across a full-width panel drew 170px slabs, so
bar width is capped and the columns space out instead. And `rx` rounds all four
corners of a rect, so bars read as detached lozenges until they became paths
with rounded data-ends only.

Ships with the hover layer, direct labels on the busiest day each way, and a
visually-hidden table so the series exists outside the picture.

Refs #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:06:57 +03:00
f81ebd4746 feat: who the wormhole exits pay, and how much of it is the protocol itself
All checks were successful
deploy / build (push) Successful in 7m37s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s
The premise was that pools are the best surface for observing wormhole
transfers, because payouts must leave a pattern in the batches. The pattern
is there. Most of it is not payouts.

Two things break the obvious reading. Batch *size* is aggregation: the pallet
documents a public batch as one segment per inner private batch and a segment
as one client, so a 120-output batch is an aggregator bundling unrelated
people. And the most recurrent recipient is the aggregator itself — one
address is credited in 91.5% of every public batch anyone submitted and in
none of the private ones, which is the rebate minted to the proof's
`aggregator_address`. QUANPOOL's mining address appears in 501 batches for
7.54 QTC total, 0.015 each: the fee share paid to whoever included the batch.

Classified, three addresses take 76% of everything the exits pay. A cohort
table that did not say so would be describing aggregator plumbing and calling
it an economy, so the share is the section's eyebrow and its first sentence.

What survives the subtraction is the finding worth shipping: among genuine
recipients, 35 addresses paid twenty or more times take two thirds of the
remaining value at twice everyone else's average credit. That is a
recurring-payment shape and the page says it is no more than that — the chain
records no sender, so a regular payee is as plausibly an exchange deposit or
one person on a schedule as a pool meeting its obligations.

Batch size is not rendered anywhere, on purpose.

Closes #20

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:49:48 +03:00
4d9d41888a 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>
2026-09-14 11:43:39 +03:00
e3c95d4945 feat: a wormhole page — what enters, what leaves, what is still inside
All checks were successful
deploy / build (push) Successful in 7m33s
deploy / deploy-api (push) Successful in 17s
deploy / deploy-web (push) Successful in 5s
The observer had nothing to say about this chain's central feature, which is
why the distribution table's outflow column could ship reading zero for every
miner and look like evidence.

A transfer inserts a commitment as a note; spending one proves a nullifier,
which the runtime records so it cannot be spent twice. That makes the pool
countable even though its movements are not:

    ZkTree::LeafCount           82,691   notes created
    Wormhole::UsedNullifiers    47,796   notes spent
    outstanding                 34,895   unspent — still inside

Outstanding is the headline. It is the part of the chain's activity that has not
moved, and every one of those notes will name a recipient and an amount and not
a sender when it does.

The routes table separates transfers by the extrinsic that produced them,
because a hidden sender means two different things. In block initialisation it
is the chain paying a miner and the "sender" is the runtime; inside
`verify_public_batch` or `verify_private_batch` it is somebody spending a note
and the origin genuinely is not recorded. Collapsing those would make mining
rewards look like anonymous payments — 23,876 of them.

Notes per day come from the `leaf_index` each transfer carries rather than from
`ZkTree::LeafInserted`, which is in `SKIPPED_EVENTS` because it fires every block
and names no account. Same fact, cheaper road.

`SegmentsDenied` and `ExitMintFailed` have never fired. Shown anyway, because a
proof rejection nobody has ever seen is a finding rather than a gap — though it
is a weaker statement than it looks, and the note says so: never refused is not
the same as never attempted.

Closes #18

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 09:32:27 +03:00
06f3ece228 feat: account flows, and a distribution table for mining rewards
Reported concern: one pool's balance is disproportionately large, and it is not
clear whether rewards are reaching miners. This makes the question answerable
from the chain rather than from impressions.

Measured on mainnet while building it, blocks 1–42,274:

    account            mined   received     sent  recipients
    QUANPOOL          10,171      5,207        0           0
    qzomrwjT…              0     11,382    4,071          90
    qzo1Pr3X…              0      3,875    7,528         986
    qzo4QjZz…              0      1,257    2,448         742
    qznbesUc…              0      1,600        0           0

Direction comes from each transfer event's own `from` and `to` — `Wormhole::
NativeTransferred` and `Balances::Transfer` both carry them — so every figure is
something the chain recorded.

**A nonce cannot answer this, and reaching for one is the obvious mistake.**
Wormhole transfers dispatch unsigned: `verify_private_batch` and
`verify_public_batch` have no signer at all. An address here can move a fortune
with a nonce of zero, and I nearly reported one as proof that nothing had ever
been sent.

The presentation reports flows and does not judge them, because the data cannot
settle what they mean. Two facts in the table above are why: several large
accounts send nothing — ordinary for a solo miner keeping what it earned — and
the accounts distributing to hundreds of recipients have never mined a block, so
payout wallets here are funded from somewhere other than mining addresses. There
is also no such thing as a "pool" on this chain: the word appears only in a name
its operator reports over telemetry, which `MinerFlow::attribution` carries so a
reader can weigh it.

What earns the feature its place is comparison. One miner's recipient count says
little; beside its neighbours' it says a great deal, and the reader draws the
conclusion rather than the site.

Refs #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-14 08:59:55 +03:00
aaf37baaca 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
2026-09-14 08:43:13 +03:00
fac73f392c feat: a nodes index, so a node need not have mined to be visible
All checks were successful
deploy / build (push) Successful in 7m23s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 16s
Reported: `baba-gorchitsa` shows its hardware on telemetry.quantus.com and
nothing here. Not a decoding failure — we held the data. Measured, the same node
on both chains at the same moment:

    quantus  attribution=carried, 0 votes, 33 blocks   on the feed: yes
    planck   attribution=telemetry, 1 vote, 2969 blocks on the feed: no

Exactly inverted. On mainnet we had the metadata and no join to it; on Planck
the join and no node. The mainnet name is *carried* from Planck, and
`Attribution::peer_id` is `None` for anything but a telemetry attribution
because there is no node behind a carried name — that reasoning stands. The
mistake was upstream: node metadata was reachable *only* through the mining
inference, and the feed hands us every node unconditionally. 288 on mainnet, of
which a handful have ever won a block.

So `/:chain/node` lists all of them and `/:chain/node/:peer` is one, neither
gated on mining. The index merges the live feed over the cache, so a node not
re-announced since our last restart still appears, faint, with its peer count
absent rather than stale — a peer count from an hour ago is not a peer count.
Live rows sort first, then by name, and the filter matches name, peer id, CPU,
version or country, because "find my node" is the question the page exists for.

The miner panel now links to the node's own page instead of being the only way
to see it.

`reported` beside the count is the feed's own `AddedChain` figure and will not
match in either direction — 284 against 288 listed, measured, because it is a
snapshot the feed refreshes on its own cadence. Shown next to our count rather
than instead of it, since the difference is a real fact about how each was
arrived at. My first comment claimed it was "usually larger"; it was smaller the
first time I looked at it.

One trap caught in the browser rather than by the type checker: "is this the
standings?" existed three times, and adding a section updated two. The redirect
in `App` judged `/quantus/node/<peer>` to be the standings and replaced it with
`/quantus/3600-blocks` — route parsed, page existed, address bar threw it away.
It is `isStandings` now, in one place.

Closes #16

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-13 19:19:58 +03:00
e7cfe42881 feat(telemetry): read what a node is doing, live off the socket
All checks were successful
deploy / build (push) Successful in 7m28s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s
Peer count, transactions in its pool, upload, download and state cache — the
volatile half of the feed, decoded from `NodeStatsUpdate`, `NodeHardware` and
`NodeIOUpdate`, plus the first values `AddedNode` itself carries so a freshly
announced node reads correctly rather than waiting for an update frame.

**Never persisted, and the shape says so.** These live in `NodeActivity`, held
in memory and attached to the miner route only when the feed is currently
reporting the node — a node answered from the cache gets its hardware and no
activity at all. A peer count from an hour ago is not a peer count, and storing
one would turn a live reading into a stale number that still looks live.

Only the newest sample of each series is kept. The feed sends rolling arrays and
already redraws them; this is a readout, not a chart. `NodeHardware` carries
three parallel series and the third is the feed's own x-axis, which is why the
test asserts the download figure is not the timestamps.

An update naming a node the feed has not introduced is dropped rather than
counted a decode error: a feed joined mid-stream sees these before the
`AddedNode` that explains them, and it will be re-sent.

Bandwidth renders in decimal units while memory and state cache render in
binary. That is not an inconsistency — a reader comparing bandwidth against a
NIC or a speed test is comparing against decimal, and one comparing memory
against what they bought is comparing against binary.

Verified against the live feed: 34 peers, 103 kB/s up, 20.5 kB/s down, 528 MiB
of state cache, read off mainnet and rendered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-13 18:33:00 +03:00
28e99bee44 feat: show the node a miner's blocks were reported from
All checks were successful
deploy / build (push) Successful in 7m37s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 17s
Caches what the telemetry feed says each node *is*, and surfaces it on the miner
route: client version, target triple, CPU, cores, memory, kernel, distribution,
whether it is virtualised, country, and how long the node process has been up.

Kept in `node_telemetry`, keyed by peer id — never by the feed's `node_id`,
which is per-connection and would accumulate a row per reconnect for the same
machine. Written every five minutes rather than every thirty seconds like the
attributions beside it: a CPU does not change, and mainnet alone has nearly
three hundred nodes. The route reads the live feed first and the cache second,
so a node the feed has not re-announced since our last restart still answers.

Nulls never overwrite. The telemetry server geolocates asynchronously, so every
reconnect re-sends every node with a null location — a naive upsert would blank
the country of every node on the site for as long as the lookups took to catch
up. The guard is in the feed state and in the `on conflict` clause both, with a
test for each.

**Location is a country and no finer.** The feed sends coordinates and a city
from an IP geolocation database, and at city resolution those are wrong often
enough that showing one would assert something nobody knows.
`blackbeard_core::geo` derives the country offline from a 508 KiB boundary set —
no network call, and nothing told where these nodes are — and the coordinates
are then dropped. The schema has no city, latitude or longitude column: a column
that does not exist cannot be rendered by mistake. The lookup returns
subdivisions before countries and `GB-ENG` is exactly the precision being
avoided, so only the two-letter code survives.

**The panel wears its uncertainty in the header, not a footnote.** A miner is
joined to a node by the same first-reporter voting that gives it a display name,
so `Attribution` now carries the peer id it settled on — `None` for a preimage
or a carried name, because there is no node behind either. The heading reads
"reported by" and the confidence and vote count sit beside it. The note says the
CPU is the node's and not necessarily what is hashing: on this network the work
is on GPUs that telemetry does not report at all.

Checked against the live feed end to end, not a fixture: real frames decoded,
cached, served and rendered. One node reports an Ubuntu userspace on a Fedora
kernel, which is a container seeing its host — the panel shows that faithfully
rather than tidying it away.

Closes #15

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-13 17:26:28 +03:00
0b3c1b2e1b feat(telemetry): decode the whole of NodeDetails, and locate nodes by country
We were reading two fields of eleven — the name and the peer id — and dropping
the rest, including the hardware every node already announces. `NodeMetadata`
now carries the slow-moving half: implementation, version, the target triple,
CPU, cores, memory, kernel, distro, whether it is virtualised, and the process
start time.

The volatile half stays out on purpose. Peer count, transaction queue,
bandwidth and state cache change every few seconds, are already a rolling series
in the feed, and would be a lot of rows for something nobody would page back
through.

`startup_time` is kept rather than derived, so node uptime becomes a fact about
the node rather than about how long this observer has been watching it.

**Location is resolved to a country and no finer.** The feed sends
`[latitude, longitude, city]` and no country, so `blackbeard_core::geo` derives
one offline from a 508 KiB boundary set — no network call, and no third party
told where these nodes are. The city is read and discarded: those coordinates
come from an IP geolocation database and at city resolution are wrong often
enough that publishing one would assert something we do not know. Nothing
downstream can render a city because nothing downstream is given one. The
lookup returns subdivisions before countries, and `GB-ENG` is exactly the extra
precision this is avoiding, so only the two-letter code survives.

`LocatedNode` is a separate frame because the telemetry server geolocates
asynchronously — `AddedNode` almost always carries a null location and the
country follows a moment later. Which means a reconnect, which re-sends every
node with a null location again, must not erase a country already resolved.
There is a test for that, because it would present as countries silently
vanishing hours after a restart.

The mapping is pinned by tests built from frames captured off the live feed
rather than from a spec: the format is positional and differs between telemetry
releases, so bytes the feed actually sent are the only honest fixture. If a
future release renumbers a field, those tests are what says so.

Refs #15

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-13 17:10:32 +03:00
380a0e138b fix: don't restore tip samples the chain's own clock contradicts
All checks were successful
deploy / build (push) Successful in 7m48s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 16s
I closed #14 on an incomplete fix. Heisenberg still reports **0.054 s/block**
while producing one block every five minutes — 5,500x too fast — with the
running code already correct.

Fixing the code did not fix the rows already written. `at_tip` in the table is
whatever the process that wrote it believed, and every process before the
receipt-stamping fix believed a drained channel was a tip observation. Those
rows are still there, and `restore_ticker_and_timing` replays them on every
start, so the bug comes back from storage each time a chain is restarted after a
catch-up. Heisenberg produces a block every five minutes, which is far too slow
to accumulate twenty fresh samples before the next restart, so it never got the
chance to correct itself.

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 span the observer
recorded should resemble the span the chain recorded 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 fresh — nominal, and labelled nominal.

The threshold is deliberately loose (half) because it only has to separate two
populations that differ by orders of magnitude, and being generous to honest
skew costs nothing: the penalty for a false negative is one window of nominal
hashrate, and for a false positive it is a headline wrong by 5,000x.

Reopens #14 — closing it again when all three chains have held a plausible
interval across a restart, rather than when the code looks right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 18:14:53 +03:00
4f9902819d fix: a miner's history chart spans its window, not a hard-coded hour
All checks were successful
deploy / build (push) Successful in 7m48s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 17s
`bucketing` mapped each window to a fixed duration — `600 blocks -> 1 hour`,
`3,600 -> 6 hours` — which is only true at a 6 s target. It was the last place
still assuming a window's block count implies a duration, which is the
assumption the rename retired.

On Planck the gap is visible: its rate fell ninefold when its miners left for
mainnet, so a 600-block window spans about five hours, and the chart underneath
was drawing one. A miner's rank was computed over one period and its history
plotted over another, with nothing on the page saying so.

The range is the window's measured span now, with buckets sized to hit
`MINER_SERIES_POINTS`, so a chart stays the same width in points however fast
the chain is running. Where no span is measured yet — only before the window has
filled — it falls back to the block count at the chain's target rate, which is
the same guess the old table encoded.

Bounded at both ends, because a measurement can be extreme in both directions: a
floor so a burst cannot collapse the chart to minutes, a ceiling so
`100800-blocks` on a slow chain cannot ask the database for a year of buckets.

Closes #14

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 17:59:49 +03:00
813e49c43c fix: time a head by when it arrived, not by when we got round to it
All checks were successful
deploy / build (push) Successful in 7m52s
deploy / deploy-api (push) Successful in 18s
deploy / deploy-web (push) Successful in 5s
The gap-fill guard fixed the coarse half of #14 — Planck went from 0.175 s/block
to 4.9 — and left the rest standing. Measured just now against a real ~30 s on
Planck and ~6 s on Heisenberg:

    quantus     9.59 s     plausible
    planck      4.919 s    six times too fast
    heisenberg  0.052 s    a hundred times too fast

`record` stamped `observed_at` with `Utc::now()`, which runs *after* two RPC
round trips and behind a 64-deep channel. On a remote endpoint those round trips
are hundreds of milliseconds, so heads queue and then drain in a burst — a batch
carrying near-identical observation times while their heights march on.
`measured_interval` divides elapsed time by height difference and reads that as
a chain producing blocks twenty times faster than it does.

`observed_at` means "when this observer saw it", so it is now stamped where the
frame is read off the socket: `subscribe_new_heads` sends a `SeenHead` carrying
that moment and `record` uses it rather than asking the clock again. Every
consumer improves at once, because the same field is the DB row, the ticker's
gap, and the tip sample.

Worth noting what this does *not* need: no threshold, no plausibility check, no
discarding of samples. The measurement was always sound; it was being given the
wrong time.

Refs #14

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 15:09:16 +03:00
d12299f242 ci: gate the API deploy on what the API is running, not on the last push
All checks were successful
deploy / build (push) Successful in 8m3s
deploy / deploy-web (push) Successful in 7s
deploy / deploy-api (push) Successful in 20s
The site is currently serving a frontend that asks for `window=3600` from an API
that only knows `six_hours`, and every step behaved as designed:

  run 51  ef367d5  rust + web   BUILD FAILED — nothing deployed
  run 52  2594190  web only     web deployed, deploy-api skipped
  run 53  49f50bd  docs only    nothing deployed

Run 52 diffed against `github.event.before`, which is run 51's commit — a commit
whose Rust half never shipped. It correctly saw only `web/src/lib/routes.ts` and
correctly skipped the API. The frontend went out alone.

The gate was asking the wrong question. "What changed since the previous push"
is only the same as "what changed since what is deployed" while every push
deploys, and a failed run breaks that quietly — the workflow's own comment warns
about "a binary on bob that does not match this commit, which nothing would
report", and this is that case arriving by a route the path list cannot see.

So ask the API. `/v1/healthz` now carries the commit it was built from, stamped
via `GIT_SHA` at build time, and the gate diffs from there. `build.rs` exists
only to make cargo notice the value changed, since `option_env!` is read at
compile time and a warm cache would otherwise keep a stale stamp.

Best effort throughout: an unreachable API, a binary too old to carry a stamp,
or a commit this clone does not have all fall back to `github.event.before`,
which is exactly today's behaviour. The gate only ever widens — a false positive
costs a slow deploy, and that trade is already written down beside it.

This commit touches `.gitea/` and `crates/`, so it deploys the API and unbreaks
the site as it lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 14:56:07 +03:00
ef367d5993 fix: stop timing the catch-up, and name windows for what they are
Some checks failed
deploy / build (push) Failing after 7m48s
deploy / deploy-web (push) Has been skipped
deploy / deploy-api (push) Has been skipped
Planck's page reported a **0.175 s** block time against a true 29.8 — a factor
of 170 — flagged *measured* rather than nominal, so the headline block time, the
network hashrate (877 GH/s on a testnet nobody mines) and the window label were
all wrong and none of them looked it.

`measured_interval` divides elapsed time by height difference, which is the
chain's rate only if every height between two samples was watched arriving. A
gap fill is proof they were not. `record()` marked every head-stream block
`at_tip: true`, including the head that landed right after `fill_gap` closed a
1,251-block gap — so the pair straddling it measured how fast this observer
caught up. `ingest` now forgets its tip samples whenever it fills a gap and
records the closing head with `at_tip: false`. The interval goes nominal until
twenty fresh samples exist, which is `MIN_TIP_SAMPLES` doing its job: nominal
and labelled nominal beats measured and wrong.

The reported symptom was smaller and had the same root. `baba-gorchitsa` showed
three blocks in Planck's "six hours" having left for mainnet a day earlier — and
it was right to: 3,600 Planck blocks currently span **29 hours**, because the
chain's rate fell ninefold when its miners left. The label was built by
multiplying the block count by the current interval, which describes the rate
now rather than the period covered. It comes from `span_seconds` instead — the
authored-time span of exactly the blocks tallied, already the denominator of
every per-miner hashrate — and carries no "~", because nothing is estimated.

So the names went too. A window is a block count, and calling one `six_hours` is
a promise the site cannot keep on a chain whose rate moves; `/planck/six_hours`
was the reason a reader believed a day-old row was current. The selector reads
600 / 3.6k / 14.4k / 100.8k and the URL carries the count. Less friendly than
`6h`, and true on every chain. The old names still parse and are never emitted,
so shared links keep working — the router rewrites them.

Closes #14

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 14:32:11 +03:00
3c6f9c94c8 chore: log what the leaderboard cache serves, for #13
All checks were successful
deploy / build (push) Successful in 8m9s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 16s
The recompute and the response disagree in production and only the recompute was
visible. With `held` now logged, mainnet's window holds 20,134 and its SixHours
recompute tallies 3,600 — correct — while the REST response for the same chain,
window and process reports 1,177, and a request for `week` triggers no recompute
at all. So the reader is served a cached board that the five-second recompute is
not replacing, and both halves need to say what they did.

`chain` on the recompute line too. Its absence made three chains' recomputes
indistinguishable, which cost an hour of reading the wrong one.

Also a test driving the real path — push a restored window, then
`recompute_leaderboard` — which passes, and so places the fault after the tally
rather than in it.

Refs #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 12:53:24 +03:00
dedee707f5 chore: say what the window actually holds, for #13
All checks were successful
deploy / build (push) Successful in 8m15s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 17s
Static reading cannot settle #13. Warm start logs restoring 19,910 blocks for
mainnet and the board it serves divides every share by 1,065, and every
explanation that would reconcile the two is ruled out in the code: the deque is
only ever pushed and popped at a capacity of 100,800, `tail(n)` yields
`min(n, len)`, `tally` counts every entry it walks, nothing filters rows, the
board recomputes every five seconds, and there is exactly one non-test
`ChainRuntime` and one `RollingWindow` per chain, shared by `Arc`.

So: log the length. `warm start: window restored` now carries `held` beside
`blocks` — the two must agree, and when they did not, every share on the site was
computed over the difference with nothing saying so. `recompute_leaderboard`
logs what it asked for, what was held and what it tallied at debug.

The new test pins the half of the contradiction that lives in
`blackbeard-core`: a window pushed 19,910 blocks holds 19,910, tallies 3,600 for
a 3,600 window, and tallies 19,910 when asked for more than it holds. That is
the arithmetic the served response contradicts, so an investigation upstream of
it does not have to re-establish that a deque holds what was pushed.

Refs #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 12:34:43 +03:00
9def0c8b6a feat: route reads that need old state to an endpoint that has it
All checks were successful
deploy / build (push) Successful in 7m53s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
Failover moved on a transport failure and never on a JSON-RPC error, both
right — but a node that has pruned the state being asked for does neither. It
answers `{"result": null}`, which is a success, so whichever endpoint happened
to be sticky decided how far back the whole site could see.

The tempting fix is wrong. Null is also the correct answer for most keys read
here: an account with no balance, an item never set, the treasury's
`System::Account` entry, absent because the treasury was never funded. Retrying
every one of those against every endpoint would multiply the load to re-derive
an answer already in hand, and still end in null. The distinction is not in the
response — it is whether the node could have known, which is a property of the
endpoint.

So `classify_depth` probes each one at startup, reading `System::Number` at
block one. That key is derived in a test rather than pasted, because a mistyped
key is absent everywhere and would classify every endpoint as pruned. An
endpoint that answers holds all state after block one; one that cannot be
reached stays `Unknown` and routes as pruned, since an unreachable host must not
become the archive of record by default.

Reads naming a block hash then go through `call_deep`. It tries the sticky
endpoint first, so the common case — mainnet's loopback node, which is an
archive — costs no extra round trip. Only a null from an endpoint never shown to
hold old state buys a second opinion, and `state_call`'s refusal is treated the
same way, because that is how difficulty is read and a node that cannot execute
against dropped state errors rather than returning null.

All seven configured endpoints probe as archives right now, so this changes no
current behaviour. It is what stops history going quietly blank the day one of
them stops being one — and it says so in the log when it happens, because an
endpoint that cannot see as far back as the site is asking is a configuration
fact worth surfacing rather than a chain fact.

Closes #9

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 12:12:11 +03:00
4fb856827c fix: a bogus length prefix aborted the daemon, and the gap fill never resumed
All checks were successful
deploy / build (push) Successful in 8m3s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 17s
Two faults, one visible symptom: both testnet leaderboards frozen for a day at
the height their gap opened at, while mainnet was fine.

The daemon had aborted 316 times in ten hours, always identically —
`memory allocation of 82014765760 bytes failed`, 76.4 GiB. That is
1,025,184,572 x 80, and 80 is `size_of::<scale_value::Value<u32>>()`:
scale-value sizes a sequence's `Vec` from the compact length in the blob before
decoding a single item, so a blob that disagrees with the registry asks for an
allocation of any size at all and Rust aborts on the failed one. The step that
would have caught the mismatch is the one that never runs, which is why
`decode_events(&blob).ok()` could not help — there was no `Err`, only SIGABRT.

Every decode now goes through `Runtime::decode_checked`, which walks the bytes
first with scale-decode's `IgnoreVisitor`. That crate has no `with_capacity`
anywhere, so an impossible length runs out of input on the first item and comes
back as an error. The regression test is the exact four-byte blob, reproduced
against the mainnet fixture before the guard went in — the abort message matched
production byte for byte.

`fill_gap` then turned a short process lifetime into no progress whatsoever: it
accumulated the whole gap and wrote once after the loop, so every interruption
discarded everything read. Each block is four RPC round trips, one a historical
state read; 1,696 blocks against a remote endpoint does not fit in the two
minutes the aborts allowed. And this is not only about the aborts —
`blackbeard-api-cert.path` restarts the service several times a day, so any gap
wider than that interval was already unfillable, logging
`filling a gap in the head stream` on every start with the same `from` and
looking like progress. It flushes every 256 blocks now, and says how many it
recorded.

Both traps are in CLAUDE.md, because neither looks broken from the outside: one
presents as a service that is merely restarting, the other as a catch-up that is
merely slow.

Closes #10
Closes #11

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 12:02:07 +03:00
1a59b2d398 feat: several endpoints per chain, so one host cannot stop it
All checks were successful
deploy / build (push) Successful in 8m19s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 16s
A chain entry held exactly one `rpc_url` and one `ws_url`, and when that host
went down everything for that chain stopped — headers, difficulty, backfill,
state reads, runtime discovery. The endpoints already existed in pairs:
`a2-planck` and `a2-heisenberg` both answer and neither was configured. We were
choosing single points of failure the network went to some trouble to avoid.

`rpc_urls` and `ws_urls` are lists now, and `rpc_url`/`ws_url` still work as a
one-element list — a config naming one endpoint is still a valid config, and
making every deployment rewrite its entry would be this feature breaking the
thing it exists to make reliable.

Endpoints are stuck to rather than balanced across, which is the design and not
laziness: a storage read at an old block hash needs a node that still holds that
block's state, and nodes prune on their own schedules, so alternating would
return a mixture of answers and absences that reads as sparse data rather than a
configuration problem. The cursor is shared across clones so a failover one task
finds is not rediscovered by every other task on the chain.

Failing over on the wrong thing was the trap worth avoiding. A JSON-RPC error is
the node answering — moving on `count exceeds maximum value` would hide a
caller's mistake behind a second node making the same complaint — so a new
`Malformed` variant separates "did not answer" from "answered, with an error".
A pruned block returns `{"result": null}`, a success, and never looks unhealthy.

The WebSocket rotates at reconnect, where the loop already was; racing
subscriptions across endpoints and deduplicating heads buys nothing, since heads
are a liveness signal and ingest fills gaps against `chain_getBlockHash` anyway.

Verified live with a dead endpoint configured first: Planck stayed `full` at its
real height, the RPC logged one `failed over` with from and to, and the head
subscription logged the loss with the endpoint count beside it — because "the
chain is unreachable" and "one of three endpoints is unreachable" are different
operational facts and used to look identical.

Closes #7

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 11:17:03 +03:00
7d081c7746 fix: map enumeration asked for more keys than the node allows
All checks were successful
deploy / build (push) Successful in 8m23s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
`/heisenberg/reversible` reported 0 pending. The chain has 2.

`state_getKeysPaged` caps `count` at 1,000 and *rejects* rather than truncates:
`PENDING_LIMIT` was 1,600, so every call errored, and `.unwrap_or_default()`
turned the error into an empty page. Nothing looked broken — it looked like a
chain with nothing in flight, which is precisely what mainnet legitimately looks
like. It would have stayed wrong indefinitely.

Caught only because `ReversibleTransfers::NextTransactionId` is a plain counter
reading 5 on Heisenberg, which contradicted the page. Every path tested during
the original work happened to be under the cap — `GENESIS_LIMIT` is 500, and the
`System::Account` check used 3 — so the machinery looked verified.

Three fixes. The clamp lives in `rpc.rs`, because 1,000 is the node's ceiling
rather than a caller's preference. The route no longer swallows the error: a
chain whose runtime lacks the pallet already returns 404, so a failure here is a
real fault and now says so. And the constant is under the cap so the common case
is one request.

Verified against Heisenberg's two real pending transfers, which also exercise
the unknown-deadline path — both were scheduled before the event index reaches,
so they render as pending with no countdown rather than being dropped.

CLAUDE.md gains the general shape: `unwrap_or_default()` on anything that talks
to a node is how a wrong answer comes to look like a right one.

Closes #4

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 10:50:14 +03:00
88086552cb fix: show a block's events, joined to the extrinsics that caused them
All checks were successful
deploy / build (push) Successful in 9m18s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 14s
The block page listed extrinsics and nothing else. Block 1 holds twenty-eight
indexed events and the route returned none of them — visible from the outside,
because an account page linked to a `Wormhole::NativeTransferred` at block 1 and
block 1 showed no sign of it.

`phase` and `extrinsic_index` were stored for exactly this join and had never
been used. Each extrinsic now carries its own events beneath it, and the block's
own events sit in their phases.

Block 1 is the argument for doing it that way. As extrinsics alone it is a
single `Timestamp::set`. Its events are the entire opening distribution:
twenty-one `Wormhole::NativeTransferred` in `Initialization`, owned by no
extrinsic at all and sent from the minting account — not block zero, and not
anything a transaction did. `Vesting::LaunchMomentSet` fires there too, so
vesting has been exercised despite the call index showing zero `Vesting::*`
dispatched: a pallet the runtime uses looks unused from the call side alone.

Closes #3

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 09:14:56 +03:00
dd8086df0d feat: mark the accounts a chain names, and make genesis unmissable
All checks were successful
deploy / build (push) Successful in 8m4s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
Some accounts are special and nothing about the address said so. The treasury
looked like any other empty account, the minting sentinel looked like a wallet,
and the twenty-one accounts endowed at genesis looked like they earned it.

No curated list. The chain names them itself, in three places that are three
different claims and are not flattened into one badge: a runtime constant
(compiled in, immutable for that spec_version), a storage value (assigned, and
changeable), and a balance in block zero (history, and not a role — an account
can be endowed and have no job). Labels derive from the chain's own name, so a
pallet added next year is labelled without an edit. The chip is the claim; the
citation beneath it is the evidence.

Two rules hold it together, both learned the hard way and both now in CLAUDE.md.
An account is told from a hash by registry path, never by shape — after
`normalise` both are `0x` and sixty-four hex characters, so `decode_typed` now
returns the account set the decoder collected while it still knew. And a storage
entry names an account only if its value *is* one: `System::Events` is full of
accounts and names none, and the first cut labelled half the chain's active
addresses `Events`.

Computing all of this walks every plain storage entry and enumerates block zero
— dozens of round trips, fine once and pathological on every account page view —
so it is cached for five minutes. Constants change with the runtime, state
assignments almost never, genesis never.

Block zero gets the endowments, because they have no extrinsic and no event and
are invisible to anyone who has not read the chainspec, and a Genesis link sits
in the nav to give somebody who would never think to look an unmissable way to.
Mainnet started with 5,670,000 QTC across 21 accounts — one holds 5,669,940 and
the other twenty got 3 each — shown beside what each holds today, so what a
founding account did with its stake is one row. When genesis state cannot be
read the page says so rather than showing an empty table: missing data and a
chain that endowed nobody are different claims.

Closes #2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 09:04:45 +03:00
c08d3c4e04 feat: pending reversible transfers, with a countdown
All checks were successful
deploy / build (push) Successful in 7m44s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s
Scheduled transfers that have not yet landed, counting down to the block they
execute at. `ReversibleTransfers` is the most distinctive thing this chain does
and the observer said nothing about it.

The only view here built from both halves, each authoritative about a different
thing. State — `PendingTransfers` — says what is still pending: a cancelled or
executed transfer is simply gone from the map, and that absence beats replaying
every event since genesis and reconstructing the set, which fails silently. The
event index says when each is due, because `TransactionScheduled` carries
`execute_at` and, contrary to what the issue assumed, the stored struct does
not. A transfer scheduled before the index reaches shows as pending with an
unknown deadline rather than being dropped for half a story.

Enumerating a map needs the prefix and `state_getKeysPaged` — there is no list,
only keys derived from the things in it — and recovering a key from a storage
key needs the hasher to have kept it. `Blake2_128Concat` and `Twox64Concat` do,
`Twox128` and friends do not, so `key_offset` is `None` there rather than a
guess. Verified against a populated map since the target one is empty:
`System::Account` enumerates, its keys decode back to accounts, and the balances
read.

`execute_at` is a `DispatchTime<BlockNumber, Moment>` — an enum, so reading the
wrong arm would show 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 so.

The page reads "nothing is in flight" today, correctly and deliberately: zero of
this pallet's six calls have been dispatched on mainnet. It exists before the
first one because catching the first one is the point.

CLAUDE.md gains the workflow this was the first of — issue first, closed by the
commit, and corrections recorded as comments on the issue rather than as
surprises in a diff.

Closes #1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 08:42:35 +03:00
6b0e02e059 feat: read chain state, decoded against the runtime
Some checks failed
deploy / build (push) Successful in 7m59s
deploy / deploy-web (push) Failing after 4s
deploy / deploy-api (push) Successful in 16s
The observer could read headers, events, extrinsics, metadata and difficulty,
and not a single pallet storage item. Answering "which address is this chain's
treasury" meant going outside it and hand-deriving twox128 prefixes, which is a
gap in the tool rather than an answer.

The treasury is the case that shows why. `set_treasury_account` reads *never* on
the call index and `TreasuryAccountUpdated` reads *never* on the event index —
both true, because the address was set at genesis, so no extrinsic ever carried
it and no event ever announced it. It exists only in state. It is
qzjsuLN7Nhu4bjvmUbjSTr2ZTeZ7oRxXpQP9fdv6PcHUCRrVR, and it has never been funded.

`Runtime::storage_key` builds a key entirely from the runtime's own description
— the pallet's storage prefix, the item name, and each key's declared hasher —
because a wrong hasher yields a key that reads as *absent* rather than as an
error, and nothing would catch it. Its test pins two keys against ones read off
the live chain by hand. Keys are SCALE-encoded against the type the entry
declares, so a map on an account, a u32 or a tuple all work without this knowing
which it is.

Absent is not zero, and only the modifier knows which: an `optional` entry
holding nothing means nothing, a `default` entry means the runtime's default,
and the state page marks the latter rather than passing it off as something the
chain wrote. `read_balance` deliberately does not apply the default —
`AccountInfo` zeroes, Substrate reaps empty accounts, and falling back would
turn "does not exist" into "holds nothing", which is the treasury's exact case.

/:chain/state lists all 40 keyless entries decoded, and an account page now
carries a real balance beside the flows it already summed. Those are different
numbers: rewards say what an account was paid, a balance says what it has, and
they differ by every transfer out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 08:33:21 +03:00
1e8869e7a1 feat: index the whole event surface, and show what has never fired
All checks were successful
deploy / build (push) Successful in 7m31s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s
The event interest list was an allowlist of two entries, which meant every
capability the chain grew was invisible until somebody remembered to add a line
— the exact failure the metadata-oracle approach exists to avoid. It is a
denylist now.

Nothing is excluded for being unused. Whether a capability is exercised is the
question an analyst is asking: a chain that ships vesting and governance nobody
touches is telling you something, and it can only tell you that if the silence
is recorded rather than filtered on the way in. 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. Three kinds both name no account and fire every block, so they are skipped:
ZkTree::LeafInserted, QPoW::DifficultyAdjusted, System::ExtrinsicSuccess. They
render as "not indexed" rather than as a zero, because a zero reads as disuse.

Balances::Minted looked like a fourth. It fired exactly as often as
MinerRewarded across a 120-block sample and for the same reason, but it names an
account, minting is not only for miners, and showing a reward twice on one page
is a presentation problem to solve on that page rather than a reason to lose the
record. Kept.

`/:chain/event` is the call index's other half — 19 of 107 kinds fired — and
`/:chain/event/:pallet/:variant` the feed behind a row. Measured on live
mainnet the widening took the index from ~1.2 to ~3.4 rows per block and bought
739 accounts' worth of wormhole transfers that were previously invisible.

Signatures also lose their associated-type ceremony: the runtime writes
`<<T as frame_system::Config>::Lookup as StaticLookup>::Source`, which is
correct and forty characters of scaffolding around one word, and three of those
in a row pushed the counts off the side of the table. Generics are untouched —
`BalanceOf<T>` is information.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 07:55:19 +03:00
6b45004907 feat: the call index — declared surface against actual use
All checks were successful
deploy / build (push) Successful in 7m46s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
Every explorer shows what happened on a chain. None shows the difference
between what a chain can do and what it has done, because none holds the
runtime's declared surface next to the activity. We already hold both, so this
is a join rather than new indexing.

    quantus    v152   6 of 58 dispatchables used
    heisenberg v148   6 of 58 — and a different six

The gap is the interesting half. 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 appears on a list of what happened. Unused calls keep their
signature and their docs, both read from the chain rather than a source tree,
and are dimmed rather than hidden.

Two chains on the same runtime family diverging is the other thing it surfaces:
mainnet has exercised the wormhole batch verifiers and nothing else, while
Heisenberg has a whole multisig lifecycle and no wormhole traffic.

`/:chain/call/:pallet/:call` is the feed behind a row — who dispatched it, with
what arguments, and whether it worked. Nothing is written per call type: the
arguments render through the same `Payload` as everything else, so a pallet
added next year gets a row on the index when the runtime declares it and a
working page the moment somebody uses it.

`BlockExtrinsic` gained `height` and `at`, which are redundant on a block's own
page and the entire point on a feed where every row is a different block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 07:44:29 +03:00
f9ea292a70 fix: a hashrate estimate now carries its error bar
All checks were successful
deploy / build (push) Successful in 7m25s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 15s
The leaderboard credited a miner with "1.55 GH/s" on the strength of one block,
last seen four hours earlier, to three significant figures. Block finding is
Poisson: k blocks carries a relative standard error of 1/√k, so one block is
±100% and it takes twenty-five to reach ±20%. The estimator was unbiased and the
presentation was not honest, which is the half that matters to a reader.

`miner_hashrate` now returns the count's uncertainty with the figure, and the
leaderboard and miner page render it — dimmed below four blocks, where the error
bar is wider than half the value.

Checked first that the arithmetic itself is sound, because the obvious
suspicion was the formula. It is the chain's own: `qpow-math::is_valid_nonce`
accepts when `hash < U512::MAX / difficulty`, `verify_nonce_internal` passes it
what `get_difficulty()` returns, and the hash is uniform — 200,000 samples of
`hash_squeeze_twice` gave 0.4995 of 2^512 against a uniform 0.5, and 12.48%
below 2^509 against 12.5%. Acceptance is 1/difficulty, so expected trials is
difficulty, exactly. The two network estimators — difficulty over tip interval,
and summed work over window span — also agree within 5% on live data.

What that leaves is recorded in CLAUDE.md and is not an observer bug: our three
miners measure 1.66 GH/s (1.39 instantaneous), while the chain credited the same
preimage 5.3 GH/s over 29 blocks. Sampling does not explain 14 sigma.
`miner_cpu_hash_rate` is 0 on all three — every hash is on GPU — and the miner's
CPU path calls `qpow_math::get_nonce_hash` directly while the CUDA kernel is a
separate implementation with its own counter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 19:48:51 +03:00
edce856263 feat: decode extrinsics, and show them
All checks were successful
deploy / build (push) Successful in 7m17s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 15s
An event is the chain's account of what happened; an extrinsic is the request
that caused it, and carries what no event does — who signed, what they called,
the nonce and tip, and whether it worked. A history built from events alone
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, including
`qp_dilithium_crypto::types::DilithiumSignatureScheme` — the part that looked
like it would need hand-written post-quantum knowledge and 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 facts about this chain's extrinsics, both now in CLAUDE.md. 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 — check the byte against the metadata and you reject every timestamp
inherent on the chain. 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 discarded, keeping only the scheme's name and the byte count.

Both halves of a block index in one pass, off calls already being made. Whether
a dispatch succeeded comes from the `ExtrinsicSuccess`/`ExtrinsicFailed` events
in that same decoded list — read, used, not stored.

The block page grows a table below the facts: call, signer, arguments, and the
signature's size. The account page merges extrinsics into the history it already
had, ordered by block with the request above the results it caused. `source` is
1 for an extrinsic and 0 for an event so that every part of the sort key
descends, which lets the paging cursor be a plain row comparison rather than a
mixed-direction one Postgres cannot express.

Nested calls are why this had to be generic. 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 they appear, that batched
transfer shows on the recipient's page tagged *received* even though they signed
nothing. Verified on mainnet 12,616: the batch renders both its transfers with
destinations and values, above the two `Balances::Transfer` events they produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 18:27:54 +03:00
efbe901646 feat: section indexes, so the routes can be found
All checks were successful
deploy / build (push) Successful in 7m33s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
Every route added lately was reachable only by already knowing an address — a
block hash, a preimage, an SS58 string, a spec_version. Fine for a link somebody
sent you, useless for discovering the pages exist. A kind with nothing after it
is now the index of that kind, and a nav under the chain chips names them.

Not the same page three times. Blocks is the record behind the live ticker,
paged by height. Accounts ranks by tokens earned over the whole indexed record,
which is a different question from the standings' blocks-per-window and here a
different answer: difficulty rose 346-fold inside mainnet's first day, so an
early block cost a three-hundredth of a current one. Runtimes is the only list
with no other home — a block does not name its runtime and neither do the
standings.

No `/:chain/miner`: the standings already are the index of miners, and a second
page of the same rows under another address would be two answers to one
question. That path redirects there, along with anything else that resolves to
the front page without being spelled like it, so the address bar and the section
nav agree about where the reader is.

Two things caught by looking at the rendered pages:

The block index's gaps came from `observed_at` and read *three milliseconds*
between blocks on a chain targeting twelve seconds — exactly the trap CLAUDE.md
records, since a gap fill writes a whole batch within one second. They come from
`authored_at` now, the chain's own clock.

The first page of that index came from the live ticker, which is ordered
oldest-first because that is the order it is pushed in, so page one climbed and
every page after it descended. The index sends a cursor for its first page too.

Accounts shows the address beside any node name: one node legitimately reports
for several payout addresses, and the first cut had two rows reading
`pearl-prover` with nothing to tell them apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 18:07:11 +03:00
289a80a7eb fix: a single-runtime chain lost the one fact worth having
All checks were successful
deploy / build (push) Successful in 7m19s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 15s
`discover_runtimes` seeded its map with two plain inserts — the runtime at
block 1 and the runtime at the tip. On a chain that has only ever run one, both
probes report the same `spec_version` and the second insert overwrote the
first, so the recorded height was the tip rather than block 1.

Mainnet therefore read "in force from #13,501", which is where this observer
started watching and not where the runtime started. Heisenberg hid it: its two
ends are different runtimes, so nothing was overwritten and all six boundaries
came out right.

Both call sites and the recursion now go through one `note` helper that keeps
the lowest height seen. That is the invariant the whole search rests on —
bisection meets a runtime inside its reign before it finds where the reign
began, so a probe can only improve the bound and none may worsen it — and it
was previously written out twice, correctly in the recursion and not at the
seed.

`lower_first_seen` takes the minimum, so the wrong row corrects itself on the
next discovery pass rather than needing a migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 17:57:02 +03:00
b006f54cd5 feat: a runtime's own page
All checks were successful
deploy / build (push) Successful in 7m12s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s
`/quantus/runtime/152` — every pallet, call, event, error, storage entry and
constant the runtime declares, with the constants' values decoded against their
own declared types. Nothing transcribed from a source tree: a source tree says
what the chain should be running, metadata says what it ran.

The set of runtimes is found rather than waited for. Decoding caches one the
moment it needs it, 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 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 — two more than the four upgrade boundaries this
approach was originally verified against.

Discovery is its own task, not a poll step. `state_getRuntimeVersion` at an old
block makes the node instantiate the runtime WASM from that block's state:
~4 s against Heisenberg's endpoint versus ~0.25 s at the tip, and a hundred of
those inside a four-second loop stalls difficulty and the summary broadcast for
minutes on every start.

Reading Heisenberg's two ends side by side is the case for the page. Between
v126 and v148 the chain dropped Referenda, ConvictionVoting, Recovery, Assets
and AssetsHolder, added Vesting and Origins, gained `WeightReclaim` and moved
`ChargeTransactionPayment` after the two Quantus-specific extensions. Every one
breaks a decoder written against the other version and none is visible from a
block.

`U256`/`U512` now render as one decimal rather than four or eight little-endian
limbs, identified by registry path exactly as `AccountId32` already was.
Difficulty as `[1189189, 0, 0, 0, 0, 0, 0, 0]` describes the bytes correctly and
tells a reader nothing. `ChainSummary` gained `spec_version`/`spec_name` so the
footer can say what the site is decoding against, and link to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 17:45:28 +03:00
d6f4c0c21e ui: only advertise chains we can actually read
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
The nav listed every chain telemetry names, with the ones we hold no RPC
endpoint for as disabled chips saying "no endpoint". The reasoning was that
they are part of the network and hiding them misrepresents its shape. In
practice it put five dead chips above the standings on every page — four of
which have never had anything behind them — and made the one live chain the
smallest thing in the row.

Nothing is blocked. `/:chain/...` still resolves for any chain the backend
knows, `/v1/chains` still reports every one of them with its `tracking`, and
promoting one is still a single `[[chains]]` entry. This is only about what the
page offers unprompted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 17:15:29 +03:00
d5a286f220 feat: account pages, built on the event index
All checks were successful
deploy / build (push) Successful in 7m5s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 16s
`/quantus/account/qzp2AxZw…` answers the question the official explorer
answers badly: what an address has actually been paid, every reward, back as far
as the index has read. Everything comes from this observer's own decoding — no
subsquid, no external indexer, nothing that stops working when the node prunes
the state behind it.

There is no case per event type anywhere in this. `chain_event` gained an
`accounts` column filled by the decoder itself, which knows which values are
`AccountId32` by registry path — the same knowledge that keeps it from
rendering a block hash as an address — so "everything involving this account"
is one containment query that already covers pallets nobody has written yet.
The frontend labels what it recognises and renders anything else as its own key
and value, so a new event type shows up as itself rather than not at all.

Events also carry the block's own timestamp now. `block.authored_at` holds the
same thing but only for blocks this process was running for, and a payment
history that renders "height 412" with no date because the observer was not
alive in March is not a history.

The preimage and the address are two ends of one identity and each page now
links to the other. Address to preimage is a lookup among preimages seen, not a
calculation: the derivation runs one way.

Verified against mainnet: 13,384 reward events indexed from genesis to the tip,
quanpool's page reconciling to 3,784.98 QTC over 12,346 blocks, and transfers
rendering their counterparties as linked addresses without a line of code that
knows what a transfer is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 17:04:45 +03:00
eb26a7bb3c feat: decode events against the runtime's own metadata
A header says who mined a block, not what they were paid: the amount is
remaining supply over an emission divisor plus fees, quantized with dust
carried forward, so only the `MinerRewarded` event knows it. Reading it means
decoding SCALE against the registry of the runtime that produced the block, and
this chain's shapes differ from vanilla Substrate because of the post-quantum
signatures.

So the chain describes itself. `state_getMetadata` at a block hash executes
`Metadata_metadata` against the runtime WASM in that block's state; the v14
registry that comes back drives a decoder that knows nothing about mining,
rewards or transfers. `INDEXED_EVENTS` is the only place a name appears —
adding transfers or anything the chain grows next is one line and a query, not
a migration, a struct and a decoder.

Metadata is cached per spec_version, because a node that starts pruning would
otherwise make history undecodable. Blocks are tried against the tip's runtime
first, which costs no extra call; `decode_events` refuses a partial read, so a
block from before an upgrade fails loudly rather than decoding into plausible
nonsense, and that failure is what asks which runtime actually produced it.

`backfill` walks outward from the tip — to the tip first, then toward genesis —
because live indexing alone leaves holes an account page would show: a restart,
an RPC blip, a gap fill that ran before the metadata was cached. Progress is a
cursor rather than `max(height)`, since a block with no indexed event and a
block never read are otherwise the same answer.

Verified on mainnet: block 13160's preimage derives, through the same Poseidon2
the chain runs, to exactly the `miner` field of its own reward event — so the
header author and the payee join with no lookup table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 16:47:10 +03:00
9eb4bee649 fix: a miner's hashrate is the work it did, not its share of today's network
All checks were successful
deploy / build (push) Successful in 6m31s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 14s
Reported against a Grafana panel showing roughly a sixth of what the site did.
Grafana was closer to the truth.

The estimate was `share of blocks in the window × current network hashrate`.
That is only correct while the network is the same size across the whole
window. Measured on mainnet's launch day it was nowhere near: difficulty rose
346-fold inside one 14,400-block window, from 96.4B to 33.4T. A miner that won
its blocks early — when a block cost a three-hundredth of what it costs now —
had that share multiplied by today's network. For this observer's own node the
site reported 25.4 GH/s against an actual 2.5 GH/s.

Difficulty is expected hashes per block by definition, so the difficulty of the
blocks a miner won, summed and divided by the time they spanned, is its
hashrate directly — with no reference to the network's size, and immune to that
size changing underneath, because every block carries the difficulty in force
when it was won. The observer has recorded per-block difficulty correctly since
the parent-state fix, so the data was already there.

`miner_series` had the same flaw and is fixed the same way: summed work per
bucket over the bucket's own duration.

The window's duration is taken from `authored_at`, not `observed_at`. A stretch
this process caught up on carries observation times seconds apart for blocks
minutes apart, and dividing real work by that would invent hashrate — the same
trap the history series was built to avoid.

The headline network figure is unchanged. It divides current difficulty by the
measured interval because it answers a different question: how hard is a block
to win right now, not what did this miner do over the last twelve hours. The
two no longer sum to each other, which is correct and is what the footer now
says.

`standings_rank_by_blocks_then_recency` asserted 400.0 from the old formula;
its third argument is now the window's duration, so it asserts the work-based
figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 15:45:34 +03:00
17ad5b1f74 fix: a restart no longer empties the ticker and the hashrate
All checks were successful
deploy / build (push) Successful in 6m30s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
Reported from the live site: the network hashrate occasionally drops to a
fraction of what it was, the live blocks table truncates to a single row and
rebuilds, and the cycle repeats on an unclear schedule.

The schedule is `blackbeard-api-cert.path`. It watches the host certificate and
runs `systemctl restart blackbeard-api` when it rotates — four times in the
last day, on top of every deploy. Nothing was crashing; `NRestarts` is 0.

What a restart lost was two pieces of memory-only state. The block ticker is
built from live blocks and has no other source, so it came back with one row
and refilled over the next minute. The tip samples behind the measured interval
were dropped outright, because the window replay pushes every row with
`at_tip: false` — correct for blocks a previous process caught up on, wrong for
the ones it genuinely watched arrive, which the `at_tip` column has recorded
since `0002`. Without them `measured_interval` returns `None` and the hashrate
falls back to the configured target: at mainnet's ~1.5s against a 12s target,
an eight-fold drop, correctly labelled nominal and wrong.

Warm start now restores both. It runs after the attributions and carried names
because the ticker rows need their display names. Restored tip samples are
capped at ten minutes old — the interval stays arithmetically valid across a
gap, since blocks kept being produced and both terms grow together, but it
stops being *current*, and a sample from an hour ago averaged with one from now
describes the hour rather than the tip.

The underlying restart is left alone: it is a certificate-rotation safeguard
and not mine to remove. It is now simply not visible, which is what it should
always have been — and worth noting the store already recycles pooled
connections hourly for exactly that rotation, so the restart is belt-and-braces
over a case already handled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 15:21:25 +03:00
a3eb670d11 feat: identify miners by the address they are paid at, not the preimage
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
A reward preimage is exact and unrecognisable — no miner knows theirs by sight.
The address is the string they configured and the one their balance shows
against, and the two are related by a derivation the chain performs anyway:
`pallets/mining-rewards` pays `qp_wormhole::derive_wormhole_address`, which is
`qp_poseidon_core::rehash_to_bytes`. Running the same function reproduces the
address exactly, with no RPC and no lookup table.

The derivation takes the preimage and nothing else — no genesis, no chain
parameters — so one preimage is one account on every Quantus chain. The unit
test pins it against a real pair (this observer's own node and the address it
was paid at), so a dependency upgrade that changed the derivation fails the
build rather than quietly relabelling every miner on the site.

Unnamed miners now render as their address. The miner panel shows all three
identifiers, because they answer different questions: the node name is an
inference and may be absent, the address is what the miner recognises, and the
preimage is what the header actually carries and the only one the chain
asserts.

SS58 is written out rather than pulled from `sp-core`, which would bring a
substrate runtime's worth of dependencies to format 36 bytes — the same trade
the readme records for declining subxt. Base58 is hand-rolled because it is
long division and nothing else; blake2 is not, because checksums are not worth
hand-rolling. Both are covered by the address test vector.

`RecentBlock` gains the attribution source: the ticker cannot tell a node name
from an address by looking, since `quanpool-payout-mainnet` and `quantus-radar`
both begin with the letter Quantus addresses do. Two tests that asserted the
old display policy now assert the new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 15:13:25 +03:00
ffcfb79cab feat(attribution): carry a miner's name across chains by reward preimage
All checks were successful
deploy / build (push) Successful in 7m6s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s
Live telemetry names the miners who win often and leaves everyone else as a
hex string. Measured on mainnet: of 22 miners, 2 were named and 14 had no name
available anywhere — but 6 already had one on Planck, under the same reward
preimage. Those 6 are exactly the small operators the naming is worst for,
because a name needs a block whose first reporter the feed could separate and
they win few blocks.

The preimage is derived from a secret only its owner holds, so the same
preimage on two chains is the same operator. That is an inference the site can
stand behind, unlike guessing from timing — and it is the only lever that works
without waiting on the feed at all.

It is a separate `AttributionSource` and renders as one. `Carried` names carry
no confidence and no votes, because they assert nothing about this chain: only
that this is who the operator was last known to be. Any live attribution here
outranks one, and the row shows an `elsewhere` chip rather than the telemetry
`node` chip so the two can never be read as the same claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 14:59:29 +03:00
bfd3470840 feat(attribution): offer a best-effort name, and say how thin the evidence is
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
A name now appears as soon as one node leads the vote, rather than waiting for
three. On a chain that resolves a first reporter for about one block in seven,
the old bar left almost every miner permanently anonymous — and since a later
block can overturn a mapping, a guess that corrects itself is more useful than
a blank that never fills in.

What is refused is a dead heat. Choosing between two equally-voted nodes would
be a coin toss wearing a telemetry badge, which is a different thing from a
thin but real lead.

The honesty moves to the presentation instead of being dropped. A percentage
alone cannot separate "100% of 1" from "86% of 7", so `Attribution` and
`LeaderboardRow` now carry the number of votes behind the name. The leaderboard
marks a mapping resting on a single block or a narrow lead with `node?` and a
dashed chip, and the tooltip states both figures and that a later block can
correct it. Dashed rather than a second colour: the accent hue is reserved for
identity and alarm, and this is neither — the same claim held more loosely.

The footer now says node names are inferred from which node reported a block to
telemetry first, never something the miner asserted, and that the reward
preimage beside them is the only identity the chain itself vouches for.

`a_single_lucky_vote_still_names_nobody` asserted the old policy and is
replaced by `one_vote_names_but_says_it_is_only_one`, which pins the new
contract: it names, and it reports `votes == 1` so the UI can mark it. A new
`a_dead_heat_names_nobody` keeps the case that is still refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 14:44:48 +03:00
ef3a4b2689 fix(attribution): a held name survives silence, and only a clear lead replaces it
Some checks failed
deploy / build (push) Successful in 6m31s
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
Reported from the live site: a name would appear and then be "often forgotten
if it doesn't mine a new block within the window".

`attribute` dropped the held key the moment the vote window contained no vote
for it. That reads silence as contradiction — the same mistake as counting
abstentions in the confidence denominator, one layer further on. The window
holds twenty observations and mainnet resolves a first reporter on roughly one
block in seven, so an incumbent's votes routinely roll out before any
replacement earns one. The name vanished at that moment and the row fell back
to the raw preimage until three fresh votes rebuilt it.

A held name is now replaced rather than dropped: it stands until a different
node leads it by `TAKEOVER_MARGIN`. The margin is the other half of the same
problem — a window carrying about three cast votes flips on a single vote under
a bare majority rule, and pools demonstrably run several nodes that all report
their blocks (this miner has answered to both `QUANPOOL - quanpool-com` and
`quanpool-payout-mainnet`). Requiring a lead of two rides that out instead of
oscillating between siblings.

Takeover after a restart or rename still works, and is still tested: a node
that genuinely owns the blocks accumulates votes and passes the margin within a
few blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 14:37:58 +03:00
337dfa0f86 fix(attribution): retry the telemetry join until the feed catches up
All checks were successful
deploy / build (push) Successful in 6m36s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s
Names were still absent after fixing the confidence denominator, because no
vote was being cast at all. The cause is that `ATTRIBUTION_SETTLE` was doing
duty for two unrelated quantities.

It was written for one: how long node reports take to spread across the network
once telemetry has the block. Eight seconds is right for that. But the lookup
also has to wait out a second quantity nobody had measured — how far behind the
chain the feed itself runs. On Planck the two coincide, because ~240 nodes at a
13 s block time is ~18 reports a second and the feed keeps up. Mainnet is ~190
nodes at ~1 s, an order of magnitude more traffic, and the feed sits about 57
blocks back. Measured against the node: chain tip 9539, highest height the feed
had reported in the preceding 110 s was 9482.

So every lookup asked who reported a block roughly fifty seconds before
telemetry had heard of it. `first_import` missed every time, every observation
became an abstention, and no author was ever named.

The join now retries every 5 s until the feed reaches the block, giving up at
three minutes and recording an honest abstention then. It adapts to whatever
the lag is rather than assuming it is zero.

Confirmed live: the top miner resolved to `QUANPOOL - quanpool-com` at
confidence 1.00 within about two minutes of restart, from 3 votes cast over 21
blocks. That 3-of-21 is also why the previous commit was necessary and not
sufficient — under the old denominator it would have scored 0.15 against a 0.6
bar and stayed nameless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 14:12:30 +03:00