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
97 lines
5.0 KiB
SQL
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()
|
|
);
|