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
74 lines
3.6 KiB
SQL
74 lines
3.6 KiB
SQL
-- Initial schema for blackbeard.observer.
|
|
--
|
|
-- Migrations are sequentially versioned and immutable once committed
|
|
-- (architecture/generic.md §5): correct a mistake with a new file, never by
|
|
-- editing this one, or the runner's checksum diverges and it refuses to start.
|
|
|
|
-- One row per chain the observer watches. Config in `config.toml` is the source
|
|
-- of truth for which chains exist; this table holds what the *node* told us
|
|
-- about them (genesis, token) plus the observer's own bookkeeping, so a restart
|
|
-- does not have to re-discover everything before it can serve a page.
|
|
create table chain (
|
|
id text primary key,
|
|
display_name text not null,
|
|
mainnet boolean not null,
|
|
genesis text,
|
|
token_symbol text,
|
|
token_decimals smallint,
|
|
target_block_time_seconds double precision not null,
|
|
first_observed_at timestamptz not null default now(),
|
|
last_observed_at timestamptz
|
|
);
|
|
|
|
-- Every block the observer has seen, with its author.
|
|
--
|
|
-- Keyed on (chain, height) rather than on the block hash, deliberately: this is
|
|
-- a proof-of-work chain and it reorgs. An upsert on this key means a block that
|
|
-- replaces another at the same height overwrites it, which is exactly the
|
|
-- semantics wanted — the leaderboard should reflect the canonical chain, not
|
|
-- the union of every fork the observer happened to witness.
|
|
create table block (
|
|
chain text not null references chain (id) on delete cascade,
|
|
height bigint not null,
|
|
hash text not null,
|
|
miner text not null,
|
|
-- The author's own timestamp, from the block's Timestamp::set inherent. Set
|
|
-- when the author BUILDS the proposal, so it measures proposal timing.
|
|
authored_at timestamptz,
|
|
-- When this observer first saw the block. The difference between the two is
|
|
-- propagation plus poll lag when negative, a future-dated block when
|
|
-- positive, and a withheld block when large and positive-going.
|
|
observed_at timestamptz not null,
|
|
-- Expected hashes to win this block. numeric, not bigint: difficulty is a
|
|
-- U512 and routinely past 1e30, so every integer type Postgres has would
|
|
-- overflow. numeric is arbitrary-precision and orders and sums correctly.
|
|
difficulty numeric,
|
|
primary key (chain, height)
|
|
);
|
|
|
|
-- The leaderboard's hot query: the last N blocks of a chain, newest first.
|
|
create index block_chain_height_desc_idx on block (chain, height desc);
|
|
|
|
-- The per-miner history query: one miner's blocks over a time range.
|
|
create index block_chain_miner_observed_idx on block (chain, miner, observed_at desc);
|
|
|
|
-- Held telemetry attributions, so a restart does not strip every author of its
|
|
-- name for a few blocks.
|
|
--
|
|
-- That churn is not cosmetic: a row whose display flips from a node name to a
|
|
-- raw preimage and back splits the miner's series on every deploy, which is
|
|
-- precisely the continuity this site exists to provide.
|
|
create table miner_attribution (
|
|
chain text not null references chain (id) on delete cascade,
|
|
miner text not null,
|
|
-- 'peer' for a libp2p peer id (stable across restarts and renames) or
|
|
-- 'node' for a telemetry node id (per-connection, the fallback).
|
|
node_kind text not null check (node_kind in ('peer', 'node')),
|
|
node_key text not null,
|
|
node_name text,
|
|
attempts integer not null default 0,
|
|
attributed integer not null default 0,
|
|
updated_at timestamptz not null default now(),
|
|
primary key (chain, miner)
|
|
);
|