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
289 lines
15 KiB
Markdown
289 lines
15 KiB
Markdown
# blackbeard.observer
|
||
|
||
Live miner leaderboard and network hashrate for the [Quantus](https://quantus.com)
|
||
blockchain and its Planck testnet, at **<https://blackbeard.observer>**
|
||
(`blackbeard.internal` from the mesh).
|
||
|
||
The point of it: a miner can find their own row, see what share of the network
|
||
they are actually winning, and watch it move — against everyone else, in real
|
||
time. Mining is a competition that normally gives its participants no scoreboard.
|
||
This is the scoreboard.
|
||
|
||
## How it knows who mined what
|
||
|
||
Every block header carries its author's 32-byte wormhole **reward preimage** in a
|
||
`PreRuntime` digest stamped with the engine id `pow_`. The node writes it there so
|
||
the `mining-rewards` pallet can read it back and derive the payout address
|
||
on-chain — and as a side effect, authorship for **every miner on the network** is
|
||
derivable from headers alone.
|
||
|
||
That is the whole mechanism, and its consequences are worth stating plainly:
|
||
|
||
- **No registration, no opt-in, no way to be left out.** Every miner appears the
|
||
moment they win a block. Nothing here depends on anyone's cooperation.
|
||
- **The preimage is public, not secret.** It is published from a miner's first
|
||
authored block onward. Spending the rewards needs a plonky2 proof of knowledge
|
||
of the underlying secret, which the preimage does not reveal — but it does make
|
||
mining income permanently attributable. The privacy in this design is at the
|
||
*exit*, not at receipt. `lair/quantus`'s `doc/wormhole-rewards.md` has the full
|
||
analysis.
|
||
- **A leaderboard row is a preimage, not a person.** One operator may run several;
|
||
the count of distinct preimages is a *lower bound* on miner count.
|
||
|
||
Difficulty comes from the `QPoWApi_get_difficulty` runtime call. Difficulty *is*
|
||
the expected number of hashes to win a block, so network hashrate is difficulty
|
||
divided by the block interval — and a miner's implied hashrate is their share of
|
||
recent blocks times that.
|
||
|
||
The mechanics were first worked out in `lair/quantus`'s Prometheus arena
|
||
exporter (`asset/arena/quantus-arena-exporter.py`), which remains the reference
|
||
for anyone checking this implementation against a second one.
|
||
|
||
## Names
|
||
|
||
The chain gives us preimages; a board of 64-character hex strings is honest and
|
||
unreadable. substrate-telemetry closes the gap indirectly.
|
||
|
||
The feed reports block imports with a propagation time, stamping the **first**
|
||
reporter of a hash with `0`. A node imports its own block before announcing it,
|
||
so the author — if it is on telemetry at all — is nearly always first. One block
|
||
proves nothing (measured on Planck, an author's lead over the second reporter is
|
||
50–620 ms, which a well-peered bystander can produce), so the observer **votes
|
||
over a rolling window** and only shows a name once enough blocks agree.
|
||
|
||
An attributed name is therefore **an inference, never a claim the miner made and
|
||
never an identity check**. The UI marks it as such, carries the confidence in a
|
||
tooltip, and falls back to the abbreviated preimage rather than guessing. Where
|
||
two miners resolve to the same node name — one operator with several preimages,
|
||
or two people who never renamed their node — every member of the colliding group
|
||
gets a preimage suffix, so no row is silently privileged as "the real one".
|
||
|
||
## Layout
|
||
|
||
Cargo workspace plus a Vite frontend, per `~/git/architecture/generic.md` §1.
|
||
|
||
```
|
||
crates/
|
||
blackbeard-entities/ domain types + the browser wire protocol; ts-rs exports
|
||
the TypeScript the frontend consumes. No I/O.
|
||
blackbeard-core/ header/SCALE decoding, hashrate maths, the rolling
|
||
window, telemetry attribution voting. Pure — no I/O,
|
||
no clock, no sockets, exercised entirely by unit tests.
|
||
blackbeard-data/ chain JSON-RPC, the telemetry feed, Postgres.
|
||
blackbeard-api/ the daemon: ingest tasks, REST, WebSocket fanout.
|
||
blackbeard-cli/ operator tools: probe, backfill, standings.
|
||
web/ Vite + React + SWC + TS. Static build, served by nginx.
|
||
asset/ systemd, firewalld, nginx, config template, bootstrap SQL.
|
||
script/infra-setup.sh one-time host provisioning, operator-run.
|
||
```
|
||
|
||
**The frontend's types are generated from the Rust.** `cargo test -p
|
||
blackbeard-entities` writes `web/src/api/generated/`; CI fails the build if the
|
||
committed output is stale. Edit the Rust DTO, never the TypeScript — otherwise
|
||
the two compile cleanly and disagree at runtime.
|
||
|
||
## Real time, without observables
|
||
|
||
One WebSocket per browser tab. On subscribe the client gets a full snapshot;
|
||
after that only deltas — a `block` on every block, a `summary` when the headline
|
||
numbers move, a `leaderboard` when the standings change. The page never polls and
|
||
never refetches.
|
||
|
||
The head stream is itself a push: `chain_subscribeNewHeads` over the node's
|
||
WebSocket, so the observer learns of a block the moment the node imports it. The
|
||
only polling anywhere is difficulty and sync state, on a four-second timer.
|
||
|
||
RxJS was considered and not used. This needs one stream, one reducer and one
|
||
subscriber list; `useSyncExternalStore` is React's own contract for exactly that,
|
||
gets concurrent rendering right, and costs no dependency and no idiom in every
|
||
component. The store is `web/src/api/socket.ts` — about 200 lines including the
|
||
reconnect and resubscribe logic.
|
||
|
||
Server-side, messages are serialised **once** per broadcast and every socket
|
||
writes the same `Arc<str>`; leaderboards are recomputed only for windows that
|
||
actually have a subscriber.
|
||
|
||
## Windows are block counts, not durations
|
||
|
||
Every window on the site — 1h, 6h, 24h, 7d — is a **block count** (600, 3 600,
|
||
14 400, 100 800). The labels are the approximate duration at the measured
|
||
interval, and the UI says "~".
|
||
|
||
This is not pedantry. A duration is meaningless while a node is catching up: it
|
||
imports historical blocks at disk speed, so "the last hour" can contain half a
|
||
million blocks. The same reasoning is why a *measured* block interval is only
|
||
used when the node is at the tip; while syncing, the configured target is used
|
||
and every hashrate on the page is labelled **nominal**.
|
||
|
||
The sparklines under the headline tiles hold the same line. They are bucketed
|
||
by **height**, not by time: every point is the same number of blocks, so every
|
||
point carries the same statistical weight and a stretch where nothing was
|
||
recorded stays the same width on the axis as one where everything was. A bucket
|
||
is the window divided by fifty, which is why every window is a multiple of it —
|
||
a full window is then exactly fifty buckets, and the rolling miner count at the
|
||
last point is the same number printed in the tile above it.
|
||
|
||
Their block interval comes from the author's own timestamp inherent rather than
|
||
from when this observer saw the block, for the same reason the *measured*
|
||
interval only counts tip observations: a batch of blocks fetched from history
|
||
all carry very nearly the same observation time, and dividing by that reads as
|
||
a chain producing hundreds of blocks a second. Each point is a moving average
|
||
over three buckets, because the mean of one bucket's gaps still carries enough
|
||
Poisson noise to draw a chain that looks like it is changing size every few
|
||
minutes.
|
||
|
||
## A block has two addresses, and only one of them is stable
|
||
|
||
`#/quantus/1069799` is a *position*. Because `block` is keyed on (chain, height)
|
||
and a reorg overwrites, a link to a height quietly comes to mean a different
|
||
block the moment the chain forks there. So opening one resolves it and rewrites
|
||
the address to `#/quantus/0x…`, which names one block for good — including after
|
||
it has lost, which is the only way an orphan is linkable at all.
|
||
|
||
The page is assembled from both sources because neither is sufficient. The node
|
||
holds the block and forgets it: `blocks-pruning` defaults to
|
||
`archive-canonical`, so a losing fork's body goes once finality passes it, and
|
||
difficulty is a state read behind a 256-block window. The observer holds what
|
||
the node never had — when the block was seen, and whether that sighting was at
|
||
the tip — and what it has since forgotten, the difficulty recorded while the
|
||
state still existed. Anything neither can answer says so in words rather than
|
||
going blank, because a gap on a block page reads as a zero.
|
||
|
||
## Data
|
||
|
||
Postgres on `magrathea.kosherinata.internal`, mTLS and passwordless — the host's
|
||
own certificate is the credential and `pg_ident.conf` maps its CN to the role.
|
||
There is no password anywhere in this repo and nowhere in the config to put one.
|
||
|
||
Blocks are keyed on `(chain, height)`, not on the block hash, deliberately: this
|
||
is a proof-of-work chain and it reorgs. An upsert on that key means a block
|
||
replacing another at the same height overwrites it, so the standings reflect the
|
||
canonical chain rather than the union of every fork the observer witnessed.
|
||
|
||
What that overwrites is copied into `block_displacement` on the way past, so a
|
||
contested height leaves a trace of who lost it. The node will not answer for the
|
||
losing block for long — it keeps only canonical bodies once finality passes
|
||
them, and difficulty is a state read behind a 256-block window — so the row
|
||
written at the moment of the reorg is the only record there will be. It is not
|
||
an orphan store: the block itself is gone, and only what the observer had
|
||
already seen of it survives.
|
||
|
||
The database is what lets the site keep promises a rolling window cannot: "how
|
||
did I do last week", a hashrate line older than the process, and a leaderboard
|
||
that survives a deploy. On start the daemon replays the window and the held
|
||
attribution names out of it, so a restart does not serve an empty site.
|
||
|
||
## Build and run
|
||
|
||
```sh
|
||
# A throwaway Postgres for the dev loop and for the sqlx query cache.
|
||
podman run -d --rm --name bb-pg -e POSTGRES_PASSWORD=dev \
|
||
-e POSTGRES_DB=blackbeard -p 55432:5432 docker.io/library/postgres:18-alpine
|
||
|
||
export DATABASE_URL='postgres://postgres:dev@127.0.0.1:55432/blackbeard'
|
||
export TEST_DATABASE_URL="$DATABASE_URL"
|
||
|
||
cargo fmt --all && cargo clippy --all-targets -- -D warnings && cargo test --all
|
||
|
||
# The daemon, against a real node. BLACKBEARD_DEV_DATABASE_URL is the
|
||
# development-only path that skips mTLS; the systemd unit never sets it.
|
||
BLACKBEARD_DEV_DATABASE_URL="$DATABASE_URL" \
|
||
cargo run -p blackbeard-api -- --config dev-config.toml
|
||
|
||
cd web && pnpm install && pnpm dev # proxies /v1 to 127.0.0.1:25864
|
||
```
|
||
|
||
`cargo test` skips the database integration tests when `TEST_DATABASE_URL` is
|
||
unset, so it stays useful on a machine with no Postgres.
|
||
|
||
**Before adding a chain, probe it.** Every failure the probe reports is one the
|
||
deployed daemon would hit silently — an RPC without the QPoW runtime API gives a
|
||
site with no hashrate, and headers without a `pow_` digest give an empty
|
||
leaderboard, both of which look like a working deployment:
|
||
|
||
```sh
|
||
cargo run -p blackbeard-cli -- probe --rpc-url http://bob.hanzalova.internal:9944
|
||
```
|
||
|
||
## Deploy
|
||
|
||
CI-driven (`architecture/deployment-gitea-actions.md`): push to `main`, or run
|
||
the workflow from the Actions UI. Hosts, ports and paths live in
|
||
`.gitea/workflows/deploy.yaml` and nowhere else.
|
||
|
||
One-time host provisioning is operator-run, from a workstation with full sudo:
|
||
|
||
```sh
|
||
./script/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub
|
||
```
|
||
|
||
Re-run it whenever the deploy gains a new file to ship — each deploy job
|
||
preflights the target's sudoers against the grants in that script and fails up
|
||
front naming what is missing, rather than dying partway through an rsync.
|
||
|
||
Two things it deliberately does not automate, both because they touch shared
|
||
infrastructure: the public Let's Encrypt certificate (`external-tls.md`) and the
|
||
split-horizon `blackbeard.internal` record, which must be added to **both** site
|
||
routers' Unbound or it `NXDOMAIN`s everywhere but one site.
|
||
|
||
| Port | What | Where |
|
||
| --- | --- | --- |
|
||
| `25864` | `blackbeard-api` REST + WebSocket | `bob` (hanzalova), mesh address only, plain HTTP behind nginx |
|
||
|
||
The edge proxy is **`oolon`** (kosherinata) and the API is on **`bob`**
|
||
(hanzalova), so `/v1` is a cross-site hop over the mesh. That is deliberate —
|
||
the API has to sit beside the node whose loopback RPC it reads — and it is the
|
||
one hop a loopback health probe cannot see, so the deploy checks it explicitly.
|
||
|
||
Registered in `architecture/port-allocations.md` §5; derived from the service
|
||
name per §3.
|
||
|
||
## Mainnet
|
||
|
||
Quantus mainnet is not yet live. It is a commented-out `[[chains]]` block in
|
||
`asset/config/config.toml.tmpl`, waiting on a published chain spec and an
|
||
endpoint.
|
||
|
||
A chain may be configured **before it launches**: the observer reports it as
|
||
`awaiting`, the UI says so plainly, and it comes alive on its own the moment the
|
||
node starts answering. No redeploy, no restart. Adding it is a config change and
|
||
a `probe` run.
|
||
|
||
## Deviations from house convention
|
||
|
||
- **`runs-on: infra` for the deploy jobs, not `fedora-43`.** The targets are
|
||
mesh-only `.internal` names and the fedora runners have no route to the mesh.
|
||
Same reasoning as `lair/quantus` and `lair/mail`.
|
||
- **Dark theme only.** `generic.md` says nothing about themes, but the dataviz
|
||
conventions expect a selected dark mode alongside light. This site commits to
|
||
one look: it is a scoreboard for a proof-of-work chain, and a light variant
|
||
would be a second palette to validate for a context this content does not have.
|
||
- **`BLACKBEARD_DEV_DATABASE_URL`** bypasses the mandated mTLS Postgres
|
||
connection. It exists so `cargo run` works on a workstation with no
|
||
`pg_ident` mapping on the fleet cluster. It is an environment variable rather
|
||
than a config key precisely so it cannot be reached by editing a deployed
|
||
config, and taking it logs a warning loud enough to spot in the journal.
|
||
|
||
## What this cannot tell you
|
||
|
||
- **Hashrate figures are estimates, not measurements.** A miner's implied
|
||
hashrate is their share of blocks won over a finite window times the network
|
||
estimate. Winning blocks is a Poisson process: a miner holding 1% of the
|
||
network will quite ordinarily show anywhere from 2 to 11 blocks in a
|
||
600-block window. Short windows flatter the lucky and libel the unlucky.
|
||
- **Distinct miners is a lower bound.** One operator, several preimages.
|
||
- **Orphan and stale-work loss is not measured.** Answering "do I win the share
|
||
of blocks my hashrate predicts?" honestly needs a hashrate the observer cannot
|
||
see. `lair/quantus` issue #1 tracks the same gap from the exporter side.
|
||
- **A telemetry name is an inference.** See *Names* above.
|
||
- **History has holes, and shows them.** Difficulty is a state read, so a
|
||
pruning node cannot say what it was at a height fetched long after the fact,
|
||
and a stretch the observer was not running for was never recorded at all.
|
||
Both are gaps in the sparkline rather than a line drawn across them.
|
||
|
||
## Related
|
||
|
||
- `lair/quantus` — the node and miner deployment, the arena exporter these
|
||
mechanics come from, and the measured hashrate numbers behind the fleet.
|
||
- `~/git/architecture` — the house conventions this project follows.
|