Commit Graph
4 Commits
Author SHA1 Message Date
grenadeandClaude Opus 5 9eb4bee649 fix: a miner's hashrate is the work it did, not its share of today's network
deploy / build (push) Successful in 6m31s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 14s
Reported against a Grafana panel showing roughly a sixth of what the site did.
Grafana was closer to the truth.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 15:45:34 +03:00
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 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