main
22 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
ef367d5993
|
fix: stop timing the catch-up, and name windows for what they are
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 |
|||
|
1a59b2d398
|
feat: several endpoints per chain, so one host cannot stop it
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
|
|||
|
27a4f30304
|
feat: index Planck
`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 |
|||
|
88086552cb
|
fix: show a block's events, joined to the extrinsics that caused them
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 |
|||
|
dd8086df0d
|
feat: mark the accounts a chain names, and make genesis unmissable
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 |
|||
|
c08d3c4e04
|
feat: pending reversible transfers, with a countdown
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 |
|||
|
6b0e02e059
|
feat: read chain state, decoded against the runtime
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 |
|||
|
1e8869e7a1
|
feat: index the whole event surface, and show what has never fired
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 |
|||
|
6b45004907
|
feat: the call index — declared surface against actual use
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
|
|||
|
93358dd920
|
ui: the window belongs to the miner page, not just the standings
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 |
|||
|
edce856263
|
feat: decode extrinsics, and show them
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 |
|||
|
efbe901646
|
feat: section indexes, so the routes can be found
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 |
|||
|
b006f54cd5
|
feat: a runtime's own page
`/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 |
|||
|
d5a286f220
|
feat: account pages, built on the event index
`/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 |
|||
|
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 |
|||
|
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 |
|||
|
2cb8f1cf21
|
config: the node on bob is mainnet now, not Planck
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 |
|||
|
53dac3717b
|
feat: block explorer routes, addressed by hash
`#/: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
|
|||
|
0ce58094e4
|
feat(data): record what a reorg displaced
`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 |
|||
|
7e5c2de936
|
feat: sparkline history under the headline tiles
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
|
|||
|
36a10ac691
|
fix(api): allow every configured CORS origin, not just the last
`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 |
|||
|
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 |