Files
observer/crates/blackbeard-data/migrations/0006_extrinsics.sql
T
grenadeandClaude Opus 5 edce856263
deploy / build (push) Successful in 7m17s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 15s
feat: decode extrinsics, and show them
An event is the chain's account of what happened; an extrinsic is the request
that caused it, and carries what no event does — who signed, what they called,
the nonce and tip, and whether it worked. A history built from events alone
omits every failed attempt and never names a submitter.

The same oracle does both. The metadata's extrinsic type carries four
parameters and the registry names all of them, including
`qp_dilithium_crypto::types::DilithiumSignatureScheme` — the part that looked
like it would need hand-written post-quantum knowledge and does not. The chain
describes its own signature scheme, so `decode_extrinsic` reads a Quantus
transaction without a line of code that knows Quantus exists.

Two facts about this chain's extrinsics, both now in CLAUDE.md. The first byte
is not the version: the top two bits are a type tag, and mainnet carries `0x84`
(signed, v4) and `0x05` (bare, v5) in the same block while the metadata declares
version 4 — check the byte against the metadata and you reject every timestamp
inherent on the chain. And a signature is 5.3 KiB, two orders of magnitude
larger than the call it authorises; it is decoded to find where the call begins
and then discarded, keeping only the scheme's name and the byte count.

Both halves of a block index in one pass, off calls already being made. Whether
a dispatch succeeded comes from the `ExtrinsicSuccess`/`ExtrinsicFailed` events
in that same decoded list — read, used, not stored.

The block page grows a table below the facts: call, signer, arguments, and the
signature's size. The account page merges extrinsics into the history it already
had, ordered by block with the request above the results it caused. `source` is
1 for an extrinsic and 0 for an event so that every part of the sort key
descends, which lets the paging cursor be a plain row comparison rather than a
mixed-direction one Postgres cannot express.

Nested calls are why this had to be generic. A `Utility::batch_all` carries whole
calls in its arguments, so a transfer's recipient can be three levels down — and
because the decoder collects account ids wherever they appear, that batched
transfer shows on the recipient's page tagged *received* even though they signed
nothing. Verified on mainnet 12,616: the batch renders both its transfers with
destinations and values, above the two `Balances::Transfer` events they produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
2026-09-09 18:27:54 +03:00

62 lines
3.3 KiB
SQL

-- What people asked the chain to do, as opposed to what it then reported.
--
-- ## Why this is a second table rather than more rows in `chain_event`
--
-- An event is the chain's account of what happened; an extrinsic is the
-- request that caused it. They answer different questions and have different
-- shapes — an extrinsic has a signer, a nonce, a tip and a fee, and an event
-- has none of those — so folding them together would mean a table where half
-- the columns are null for half the rows.
--
-- They share the parts that make a per-account view work: `accounts` filled by
-- the decoder, and a `jsonb` payload in the runtime's own vocabulary. That is
-- what lets one query merge both into a single history.
create table chain_extrinsic (
chain text not null references chain (id) on delete cascade,
height bigint not null,
-- Position in the block's body. With the height it is the natural key, it
-- makes re-indexing idempotent, and it is what an event's `ApplyExtrinsic`
-- phase points at — the join between the two tables.
extrinsic_index integer not null,
pallet text not null,
call text not null,
-- Null for an inherent: the block author inserted it and nobody signed it.
signer text,
-- The signature *scheme*, by name, never the signature.
--
-- A Dilithium signature is 5.3 KiB. At a block a second that is gigabytes a
-- month of bytes no page renders and no query reads. It is decoded during
-- indexing because that is the only way to find where the call begins, and
-- then thrown away; the size is kept because it is the single most
-- surprising fact about a transaction on this chain.
signature text,
signature_bytes integer not null default 0,
-- Call arguments in the runtime's own names. Nested: a `Utility::batch_all`
-- carries entire calls in here, which is why an account can be the
-- recipient of a transfer that is not its own top-level extrinsic.
args jsonb not null,
-- Mortality, nonce, tip — the signed extensions as submitted. Null for an
-- inherent, which signs nothing.
extra jsonb,
-- Whether the dispatch succeeded, from the `System::ExtrinsicSuccess` or
-- `ExtrinsicFailed` event with this extrinsic's index. Read from the events
-- already decoded for the block rather than stored as rows of their own —
-- two per block, every block, for one boolean.
success boolean,
accounts text[] not null default '{}',
at timestamptz,
primary key (chain, height, extrinsic_index)
);
-- "Everything involving this account", the same shape as the event index, so
-- the merged history is two index scans rather than a scan of either table.
create index chain_extrinsic_accounts_idx on chain_extrinsic using gin (accounts);
-- "This account's activity, newest first", and "this block's extrinsics".
create index chain_extrinsic_recent_idx
on chain_extrinsic (chain, height desc, extrinsic_index desc);
-- "Every transfer", "every batch" — the call-kind feed, matching the event
-- index's equivalent.
create index chain_extrinsic_kind_idx on chain_extrinsic (chain, pallet, call, height desc);