22 Commits

Author SHA1 Message Date
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
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
27a4f30304 feat: index Planck
Some checks failed
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled
`a1-planck.quantus.cat` answers, and has all along. The config comment claimed
Planck "publishes no RPC endpoint we can find" — true when written, and never
rechecked after the node we ran for it was switched to mainnet. A million blocks
of history and 2.38M zk-tree leaves were sitting there unasked for.

Measured before committing to it. Three chains backfilling at once cost 4.8% of
one core, five of eight Postgres connections with one active, and no movement in
API latency — summary 7 ms p95, block 13 ms, account 49 ms. The walk is bound by
RPC round trips rather than by anything local, so it needs no deployable of its
own; and `backfill` only reads in-memory state while writing solely to Postgres,
so it contends with nothing the API serves.

Unthrottled deliberately: ~46 hours at roughly 20 requests a second against a
public node. Leaning on public decentralised testnet infrastructure exercises
what it is there for.

Restart resilience confirmed by killing the process mid-walk — `event_scan.low`
stayed put rather than jumping back to the tip. That is load-bearing here rather
than tidy, because the cert-rotation path restarts this service several times a
day and a cursor in memory would mean Planck never finishing.

`a2-planck` answers too, and is noted for when a chain can hold more than one
endpoint.

Closes #6

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 11:10:03 +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
93358dd920 ui: the window belongs to the miner page, not just the standings
All checks were successful
deploy / build (push) Successful in 1m14s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 6s
Choosing a window on a miner's page navigated back to the standings. Not a
mis-wired link: there was no URL for "this miner, over a day", so the only thing
the control could do was go somewhere that had one.

So the miner route carries it: `/quantus/miner/0x…/day`. That is the right place
for it regardless — a miner's rank and hashrate are *of* a window, and a link
sent without one shows the recipient something other than what the sender was
looking at. In the path rather than a query string, so it reads the way the
standings' own window already does. The bare `/quantus/miner/0x…` still resolves,
at the default window, so existing links keep working.

And the control now appears only where it changes something: the standings, and
a miner's page, which is one row of those same standings. A block happened once,
a balance has no span, a runtime is not a rate, and the three indexes are not
ranked — on all of those it was a live control that did nothing, which teaches a
reader to distrust it on the two pages where it works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-10 06:23:38 +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
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
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
49c266a905 ui: real URLs for every view
The hash router was the right size for a chain and a window. It stopped being
once a block, a miner and — shortly — an account each became a page worth
sending someone: a fragment never reaches a server, so those URLs cannot carry a
title, be crawled, or be resolved by anything but the page itself.

So paths, via react-router, which was already a dependency and unused. Both
vhosts already answer any path with `index.html`, so nothing outside the client
changed. `lib/routes` holds the grammar in one place and `fromLegacyHash`
rewrites the old form on load, so every `#/…` link already published still lands
where it meant to.

Segments are named — `/quantus/block/13160`, not `/quantus/13160` — rather than
told apart by shape. Shape works until two kinds of thing can look alike, and an
SS58 address is arbitrary base58 with no rule keeping it clear of a window name
forever. Six characters buys the whole class of problem away before accounts
arrive.

Everything navigable is an anchor now, chain chips and the window control
included, which is what "real URLs" has to mean in practice: middle-click opens
another chain's 24h view in a tab, and the back button walks the panels. The
miner panel had no address at all before this — opening one changed the page and
not the URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 16:53:41 +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
2cb8f1cf21 config: the node on bob is mainnet now, not Planck
All checks were successful
deploy / build (push) Successful in 7m34s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s
Mainnet launched today and the node was switched to it. The observer reads
whatever the node is on, so the `[[chains]]` entry pointing at 127.0.0.1:9944
had to follow it — until now the daemon was filing mainnet's blocks under the id
`planck`, with Planck's genesis and PLK still attached to them.

Read from the node, not assumed: `system_chain` "Quantus", genesis
`0xfb5487c0…626fba`, token QTC. `mainnet = true` is asserted here rather than
inferred, which is the field's whole purpose — telemetry also carries a chain
calling itself "Quantus Staging Mainnet", and it is not this one.

The target block time is **12 s, not the 6 s Planck used**.
`TARGET_BLOCK_TIME_MS` is 12_000 in the runtime the node actually runs —
checked at commit b017e642, the `0.11.1-b017e6420aa` its `system_version`
reports, rather than at the tip of the chain repo. It is the denominator for
every hashrate published before twenty tip samples exist, which on a chain
hours old is all of them, so carrying 6.0 across would have put out a mainnet
hashrate at exactly twice its true value — labelled "nominal", not broken.

Planck keeps its 268 telemetry nodes and stays in the site's navigation as
`no_endpoint`: still listed, no longer navigable, because authorship comes from
block headers and we no longer run a node that serves Planck's. That is an
ordinary state here, not an outage.

Nothing about the node itself is touched by this; every command run against it
was a read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 12:07:21 +03:00
53dac3717b feat: block explorer routes, addressed by hash
All checks were successful
deploy / build (push) Successful in 6m33s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s
`#/:chain/:height` opens a block and then rewrites the address to
`#/:chain/:hash`. The redirect is the point, not tidiness: `block` is keyed on
(chain, height) and a reorg overwrites, so a height names a *position* and a
link to one comes to mean a different block the moment the chain forks there. A
hash names one block for good — including after it has lost, which is the only
way an orphan is linkable at all.

`GET /v1/chains/{chain}/blocks/{ref}` takes either spelling; a decimal number
and 32 bytes of hex cannot be confused, so a caller holding a number is not made
to guess which one this API wanted. The same reasoning puts block refs in the
second path segment beside the window: no window name is all digits or 0x plus
64 hex characters.

The answer is assembled from two sources because neither is sufficient, and
they fail at different times:

- The node holds the block and forgets it. Non-canonical bodies go once finality
  passes them (blocks-pruning defaults to archive-canonical, and Planck finalises
  ~100 blocks back), and difficulty is a state read behind a 256-block default.
- The observer holds what the node never had — when the block was seen, whether
  that sighting was at the tip — and what it has since forgotten: the difficulty
  read while the state behind it still existed.

So resolution degrades in a stated order rather than failing. Node first; a
header it cannot serve falls back to `block_displacement`, which is what makes
an orphan describable at all; a node that is away falls back to the recorded
row. `from_node` tells the reader which they are looking at, and every value
neither source has is spelled out in words — "the body is pruned", "this
observer never saw this block" — because a blank on a block page reads as zero.

Verified against the live chain: a tip block carries difficulty, one extrinsic,
a 1.9 s gap and 473 ms of propagation; height 1000000 keeps its body and loses
difficulty to state pruning; genesis has no author digest and does not panic; a
seeded displacement serves as an orphan with the winner linked, and the winner
lists it in `also_seen`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-08 16:30:41 +03:00
0ce58094e4 feat(data): record what a reorg displaced
All checks were successful
deploy / build (push) Successful in 6m31s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 18s
`block` is keyed on (chain, height) so a replacement overwrites rather than
accumulates — a schema keyed on the hash would inflate the losing fork's author
forever. The cost is that the losing block vanishes without a trace, and with it
any record that the height was ever contested. `block_displacement` is that
trace: one row per displacement, holding the whole row that was about to be
overwritten.

It cannot be reconstructed later. `blocks-pruning` defaults to
archive-canonical, which discards non-canonical bodies once finality passes them
— about a hundred blocks behind the tip on Planck — and difficulty is a state
read against a 256-block default. Minutes after a reorg the chain itself can no
longer answer for the block, so what is captured at the moment of the swap is
all there will ever be. That is why the row holds the author and the difficulty
rather than just the hash: the author is the interesting field, it says who lost
the race, and it costs a table that gains a row only when the chain forks.

The capture is a CTE ahead of the upsert, in the same statement. Every
data-modifying CTE sees the same snapshot, so it reads the pre-update row even
though the insert below it is replacing that row in the same breath, and a
failure rolls back both — which two statements without a transaction would not.
A trigger would do the same thing invisibly to anyone reading the query.

Nothing reads the table yet, deliberately. The read shape belongs with the
block route that will use it; what matters now is that the history exists by
the time that lands, which it cannot do retroactively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-08 15:55:07 +03:00
7e5c2de936 feat: sparkline history under the headline tiles
All checks were successful
deploy / build (push) Successful in 6m36s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 16s
Adds `GET /v1/chains/{chain}/series` and a sparkline under network hashrate,
difficulty, block time and miners. Height gets none — it only goes up, and a
straight diagonal reports nothing.

The chart is bucketed by height, not by time: every point is the same number of
blocks, so every point carries equal statistical weight and a stretch nobody
recorded stays the same width on the axis as one that was. A bucket is the
window over fifty, which makes a full window exactly fifty buckets — that is
what lets the rolling miner count at the last point be the same number printed
in the tile above it, rather than a per-bucket count that would sit well below
it. Buckets are anchored to absolute height for the same reason `miner_series`
bins from the epoch: anchored to the tip instead, every boundary slides by one
on every block and the chart shifts under a reader watching it.

Interval comes from `authored_at`, never `observed_at`. A gap fill writes a
whole batch of observation times within the same second, so deltas taken from
it read as a chain producing hundreds of blocks a second — the trap
`RollingWindow`'s `at_tip` flag exists to avoid, which until now lived only in
memory. It is a column as of `0002`, so the distinction survives a restart and
`observed_at` is interpretable at all.

Each point's interval is a three-bucket moving average. The mean of one
bucket's gaps still carries real Poisson error — 72 blocks of a 15 s interval
lands at ±1.8 s — and across fifty points that draws a chain that appears to
change size every few minutes and does not.

Fixes a wrong number found on the way in: `pallet_qpow` retargets in
`on_finalize`, so `QPoWApi_get_difficulty` at the latest state is the
difficulty for the *next* block, not the one just seen. Every stored difficulty
was off by one block — invisible, because a gradual retarget still looks
plausible. It is now asked at `header.parent_hash`, the same call the miner
made when it built the block. A node that has pruned that state gets `None`
rather than the current value: substituting it is exactly how a gap fill would
stamp today's difficulty across a stretch of old heights. The CLI backfill had
the same bug at greater scale, copying one reading across an entire range.

Unknowns are drawn as breaks in the line, never interpolated: an outage, a
difficulty a pruned node cannot supply, a point without a full window of
authors behind it. A chart that guesses across what was never recorded is the
same class of lie as a hashrate divided by a sync speed, and just as hard to
catch afterwards.

Read against the dataviz skill first, per CLAUDE.md. Single series, one hue;
emphasis on the newest point is `--data-bright`, a lighter step of the same
bronze, because the usual "current period in the accent" convention is the one
change that would break this palette. The hover readout borrows each tile's
note line — five floating tooltips across a row of 180px tiles is a pile, not a
hover layer — and the same readout is on keyboard focus and in the aria-label,
since the line does not start at zero and its height is not a quantity anyone
should be estimating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-08 14:39:27 +03:00
36a10ac691 fix(api): allow every configured CORS origin, not just the last
Some checks failed
deploy / build (push) Failing after 5s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped
`CorsLayer::allow_origin` replaces rather than appends, so folding over the
configured list left only `blackbeard.internal` allowed and silently refused
`blackbeard.observer` — the site's own public origin. It broke nothing, because
the frontend is served same-origin and never consults CORS, which is precisely
why it would have gone unnoticed until something else called the API.

Verified on the deployed vhost: both configured origins are now echoed back and
an unlisted one is refused.

Also records the deployment gotchas this session turned up (exact-argument
sudoers matching, the runas spec for the config check, the cross-site hop the
loopback probe cannot see, and why a WebSocket upgrade test needs --http1.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
2026-09-04 13:21:13 +03:00
110fbc3631 feat: blackbeard.observer — live Quantus mining leaderboard
Cargo workspace plus a Vite frontend, following ~/git/architecture/generic.md.

Every block header carries its author's wormhole reward preimage in a `pow_`
PreRuntime digest, so authorship for the whole network is derivable from headers
alone — no indexer, no registration, no way for a miner to be left out. That
decoding, the hashrate maths and the telemetry name attribution live in
blackbeard-core with no I/O at all, so the parts that are easy to get subtly
wrong are exercised by unit tests rather than only against a live chain.

The browser holds one WebSocket: snapshot on subscribe, deltas thereafter. The
head stream is itself a push (chain_subscribeNewHeads), so a block reaches the
page the moment the node imports it. Messages are serialised once per broadcast,
and leaderboards are recomputed only for windows a socket is actually watching.
No RxJS — useSyncExternalStore is React's own contract for this.

Verified against the live Planck testnet: 12/12 headers decoded, telemetry names
attributed (quanpool-planck, baba-gorchitsa, …), warm start restoring 84 blocks
and 5 held names across a restart.

Three findings worth recording, all in CLAUDE.md:

- substrate-telemetry sends its JSON in *binary* frames. A text-only client
  connects, subscribes, reports healthy and receives nothing at all — and a
  Python probe hides it, because json.loads accepts bytes.
- Difficulty is a little-endian U512; decoding it big-endian gives a number
  wrong by ~10^150 that still renders fine.
- Planck's real block interval is ~13-15s against a 6s target with enormous
  variance, so a measured interval needs 20 tip samples before it is publishable.

Deploy assets, the Gitea Actions workflow and script/infra-setup.sh are included;
port 25864 is registered in architecture/port-allocations.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
2026-09-04 12:33:54 +03:00