Files
observer/crates/blackbeard-data/migrations/0004_runtime_and_events.sql
T
grenadeandClaude Opus 5 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
2026-09-09 16:47:10 +03:00

97 lines
5.0 KiB
SQL

-- The chain's description of itself, and what it said happened.
--
-- ## Why the metadata is stored rather than fetched when needed
--
-- `state_getMetadata` takes a block hash and makes the node execute
-- `Metadata_metadata` against the runtime code in that block's state, so the
-- runtime WASM is the oracle and the node does the executing. That is exactly
-- what is wanted — decoding a block against the runtime that produced it — but
-- it only works while the node still holds state for the block. On this fleet
-- that is currently every block back to genesis; it is not a guarantee, and a
-- node that starts pruning would make every historical block undecodable.
--
-- One row per runtime version removes that dependency permanently. Metadata is
-- ~100-150 KiB and changes only on a runtime upgrade — Heisenberg has had four
-- in a million blocks — so this table stays in single figures for years while
-- making decoding reproducible long after the state behind it is gone.
create table runtime_metadata (
chain text not null references chain (id) on delete cascade,
spec_version bigint not null,
spec_name text not null,
-- Exactly the bytes `state_getMetadata` returned, unparsed. Storing the
-- parsed form would bake in this decoder's understanding of v14; storing
-- the blob means a later version of the code can reinterpret history.
metadata bytea not null,
first_seen_height bigint not null,
fetched_at timestamptz not null default now(),
primary key (chain, spec_version)
);
-- Decoded events, in the runtime's own vocabulary.
--
-- ## Why one table rather than one per thing we care about
--
-- A `miner_reward` table would need a sibling for transfers, another for
-- multisig, another for whatever the chain grows next — each a migration, a
-- struct and a query. The runtime already names everything it emits, and
-- metadata v14 describes the shape of every field, so nothing here needs to
-- know what a reward *is*: `pallet`, `variant` and the fields as jsonb hold any
-- event the chain has or will have. Adding transfers to the site is a query
-- and an interest-list entry, not a schema change.
--
-- Fields are normalised on the way in (see `blackbeard-core::runtime`):
-- account ids as `0x` hex rather than thirty-two separate numbers, and integers
-- past 2^53 as strings, because balances here are u128 and a JSON number would
-- be silently rounded by the browser — the same trap `BigUintDec` exists for.
create table chain_event (
chain text not null references chain (id) on delete cascade,
height bigint not null,
-- Position within the block's event list. With height it is the natural
-- key, and it makes re-indexing a block idempotent.
event_index integer not null,
pallet text not null,
variant text not null,
-- `ApplyExtrinsic`, `Finalization`, `Initialization`. Kept because "which
-- extrinsic caused this" is the join a transaction view needs, and the
-- decoder gets it for free.
phase text not null,
extrinsic_index integer,
fields jsonb not null,
primary key (chain, height, event_index)
);
-- "Every MinerRewarded, newest first" — the shape every feed on the site wants.
create index chain_event_kind_idx
on chain_event (chain, pallet, variant, height desc);
-- "Everything involving this account". `jsonb_path_ops` rather than the default
-- operator class: it indexes only containment, which is the single operator
-- these queries use, and is markedly smaller and faster for it. This is what
-- lets an account page work without a per-event-type table.
create index chain_event_fields_idx
on chain_event using gin (fields jsonb_path_ops);
-- How much of a chain's history has actually been read.
--
-- ## Why a cursor rather than inferring it from `chain_event`
--
-- "The highest height with an event" is not the same question. A block with no
-- indexed event is indistinguishable from one never looked at, and both are
-- ordinary: the interest list is deliberately narrow, so most blocks on most
-- chains will contribute nothing. Inferring progress from the rows would make
-- the backfill re-read the same empty stretch forever.
--
-- One contiguous interval rather than a set of ranges: the backfill extends
-- `high` toward the tip and `low` toward genesis, one batch at a time, and a
-- single interval cannot develop the holes a per-batch record would need
-- coalescing to avoid. Blocks indexed opportunistically by live ingest outside
-- the interval are simply re-read when the interval reaches them, which costs
-- one RPC call and writes nothing new — `chain_event` is keyed on
-- `(chain, height, event_index)`, so re-indexing a block is idempotent.
create table event_scan (
chain text primary key references chain (id) on delete cascade,
low bigint not null,
high bigint not null,
updated_at timestamptz not null default now()
);