Commit Graph
9 Commits
Author SHA1 Message Date
grenadeandClaude Opus 5 a3eb670d11 feat: identify miners by the address they are paid at, not the preimage
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
grenadeandClaude Opus 5 057937e9cc fix(attribution): measure confidence over votes cast, not the whole window
deploy / build (push) Successful in 6m46s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 14s
Mainnet has shown no node names since launch. Every miner renders as its raw
preimage while Planck, on the same code and the same feed, names twenty.

`votes` holds one slot per block checked and a `None` for every block whose
first reporter the feed could not separate from its second. Those are
abstentions — the feed did not say who led — but confidence divided agreement
by `votes.len()`, counting them as votes against. Measured on the live feed,
mainnet resolves a clean lead on about a quarter of blocks, so the best score
any author could reach was 0.25 against a `MIN_CONFIDENCE` of 0.6. No name
could ever be held, however unanimous the votes that were actually cast. Planck
cleared the bar only because 41-87% of its blocks produced usable votes.

Confidence is now agreement among cast votes, which is what it always meant:
"of the blocks where we could tell who was first, this fraction pointed here".
`MIN_VOTES` guards the other side, so a single vote in an otherwise silent
window is not read as 100% agreement.

`a_held_name_survives_a_confidence_dip` asserted `confidence < 1.0` after five
unresolvable blocks. That assertion encoded the bug. It now asserts the
opposite for abstentions and keeps a real dip — disagreeing votes — as the case
that lowers confidence.

Two false leads are recorded in CLAUDE.md so the next person does not spend the
afternoon on them: the feed delivers a 190-node roster before any live block
traffic, so `ImportedBlock` does not appear for ~40 s after subscribing and a
short probe concludes the feed is dead; and the roster's `AddedNode` entries
carry a height-0 placeholder block with a zero hash, which inflates the
apparent count of nodes claiming to be first if counted as import reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 13:59:21 +03:00
grenadeandClaude Opus 5 2cb8f1cf21 config: the node on bob is mainnet now, not Planck
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
grenadeandClaude Opus 5 53dac3717b feat: block explorer routes, addressed by hash
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
grenadeandClaude Opus 5 0ce58094e4 feat(data): record what a reorg displaced
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
grenadeandClaude Opus 5 7e5c2de936 feat: sparkline history under the headline tiles
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
grenadeandClaude Opus 5 b552b08b10 docs: record the mesh-bind, hairpin and runner-label gotchas
deploy / build (push) Successful in 6m44s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 14s
Each of these failed a CI run this session and none of them looks like what it
is: a loopback health probe refused by a healthy daemon, a proxy that cannot
resolve its own public name, and 'cargo: command not found' on a node runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
2026-09-04 13:56:42 +03:00
grenadeandClaude Opus 5 36a10ac691 fix(api): allow every configured CORS origin, not just the last
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
grenadeandClaude Opus 5 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