From d4cd91f55f036010b860fd2fbf169d147b21ef59 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:21:12 +0000 Subject: [PATCH] Light client v0: the browser verifies the latest certified checkpoint site/verify/core.js recomputes every header hash (keyed BLAKE2b, the node's field order), checks the parent links from the previous locked checkpoint, hashes each voter's G1 key to its vote_key_hash, verifies the BLS aggregate over "igneum-vote-v1/" || chain_id || 0 || index_le64 || checkpoint under the vote tag with the bitmap's keys, and applies Q3 (2/3 of active, 17/30 of total). verify.js drives it from /api/checkpoint with @noble/hashes 2.4.0 and @noble/curves 2.4.0 pinned from jsdelivr and fills the homepage card; the badge says LIVE only after a pass in the tab. site/api/checkpoint.mjs ships the data: certificate bytes, voter table with public keys, header chain. tools/observer stores every certificate a block carries (new table live_certificates, voter table read at the lock, selected-chain headers back to the previous lock, one-off backfill of the newest lock on start) and keeps header nonces exact; the FinalityLock write no longer fails on a missing votes_seen. Tested on the igneum-devnet-7 test network: checkpoint 95 verifies in Chrome in 103 ms; a flipped signature bit, a dropped voter, an altered key, an altered header and a removed header all fail with the reason named. Co-Authored-By: Claude Fable 5.1 --- site/api/checkpoint.mjs | 97 +++++++++++++++++++++ site/index.html | 15 ++-- site/verify/core.js | 157 ++++++++++++++++++++++++++++++++++ site/verify/test.html | 43 ++++++++++ site/verify/verify.js | 66 +++++++++++++++ tools/observer/observer.mjs | 163 ++++++++++++++++++++++++++++++++++-- 6 files changed, 529 insertions(+), 12 deletions(-) create mode 100644 site/api/checkpoint.mjs create mode 100644 site/verify/core.js create mode 100644 site/verify/test.html create mode 100644 site/verify/verify.js diff --git a/site/api/checkpoint.mjs b/site/api/checkpoint.mjs new file mode 100644 index 000000000..fc94a9920 --- /dev/null +++ b/site/api/checkpoint.mjs @@ -0,0 +1,97 @@ +// Igneum light client, server half. GET /api/checkpoint returns the latest certified checkpoint with everything a +// browser needs to verify it by itself (site/verify/verify.js does the verifying; this function only ships data): +// the certificate as carried in a block, the canonical voter list with public keys and weights, and the +// selected-chain headers from the previous locked checkpoint to this one. Read from what tools/observer wrote to +// Neon (table live_certificates, or fintest_live_certificates for the test network). +// +// ?source=live the live devnet (default; falls back to the test network while the live chain has no lock yet) +// ?source=test tonight's finality test network (igneum-devnet-7) +// +// Zero dependencies: Neon's HTTP SQL endpoint over Node's built-in fetch, like live.mjs. + +const MAX_HEADERS = 200; + +function neon() { + const url = process.env.DATABASE_URL; + if (!url) throw new Error('DATABASE_URL is not set'); + const host = new URL(url).hostname.replace('-pooler', ''); + return async (query, params = []) => { + const r = await fetch(`https://${host}/sql`, { + method: 'POST', + headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, params }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.message || JSON.stringify(j)); + return j.rows || []; + }; +} + +const num = v => (v === null || v === undefined ? null : Number(v)); + +async function latest(sql, table) { + // A table the observer has not created yet (the live chain before the cut-over) reads as "no certificate" + const rows = await sql(`SELECT * FROM ${table} ORDER BY index DESC LIMIT 1`).catch(() => []); + return rows[0] || null; +} + +function shape(row, source) { + const headers = (row.headers || []).slice(-MAX_HEADERS).map(h => ({ + hash: h.hash, version: num(h.version), parents_by_level: h.parentsByLevel || [], + hash_merkle_root: h.hashMerkleRoot, accepted_id_merkle_root: h.acceptedIdMerkleRoot, utxo_commitment: h.utxoCommitment, + timestamp: String(h.timestamp), bits: num(h.bits), nonce: String(h.nonce), daa_score: String(h.daaScore), + blue_work: h.blueWork, blue_score: String(h.blueScore), pruning_point: h.pruningPoint, vote_key_hash: h.voteKeyHash, + })); + const voters = (row.voters || []).map(v => ({ vote_key_hash: v.key_hash, pubkey_hex: v.pubkey, weight: num(v.weight), participation: num(v.participation) })); + return { + source, + network: row.chain_id, + chain_id: row.chain_id, + index: num(row.index), + hash: row.hash, + blue_score: num(row.blue_score), + daa_score: num(row.daa_score), + certificate: { + bytes_hex: row.certificate_hex, + voter_count: num(row.voter_count), + bitmap_hex: row.bitmap_hex, + aggregate_signature_hex: row.signature_hex, + aggregator: row.aggregator, + aggregator_proof_hex: row.aggregator_proof_hex, + carrier: row.carrier, + }, + voters, + voters_at_index: num(row.voters_index), + total_weight: num(row.total_weight), + active_weight: num(row.active_weight), + previous: row.prev_index === null || row.prev_index === undefined ? null : { index: num(row.prev_index), hash: row.prev_hash }, + headers, + headers_complete: !!row.headers_complete && (row.headers || []).length <= MAX_HEADERS, + rule: { quorum_active: '2/3', floor_total: 0.567, floor_total_exact: '17/30', vote_message: 'igneum-vote-v1/ 0x00 index_le64 checkpoint_hash', dst: 'IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_', key_hash: 'BLAKE2b-256 keyed IgneumVoteKeyHash over the 48-byte G1 key' }, + stored_at: row.created_at, + }; +} + +export default async function handler(req, res) { + res.setHeader('Cache-Control', 'public, max-age=5'); + res.setHeader('Access-Control-Allow-Origin', '*'); + if (req.method !== 'GET') { + res.setHeader('Allow', 'GET'); + return res.status(405).json({ ok: false, error: 'method not allowed' }); + } + try { + const sql = neon(); + const want = String((req.query && req.query.source) || 'live'); + let row = null, source = null; + if (want !== 'test') { row = await latest(sql, 'live_certificates'); if (row) source = 'live'; } + if (!row) { row = await latest(sql, 'fintest_live_certificates'); if (row) source = 'test'; } + if (!row) { + res.setHeader('Cache-Control', 'no-store'); + return res.status(404).json({ ok: false, error: 'no certified checkpoint stored yet' }); + } + return res.status(200).json({ ok: true, now: new Date().toISOString(), ...shape(row, source) }); + } catch (e) { + res.setHeader('Cache-Control', 'no-store'); + return res.status(500).json({ ok: false, error: String(e.message || e) }); + } +} diff --git a/site/index.html b/site/index.html index 8e22fcfdd..3a7ef526a 100644 --- a/site/index.html +++ b/site/index.html @@ -265,21 +265,22 @@ footer .wrap{padding-block:48px 32px}
This tab · light client
-
PREVIEW
+
PREVIEW
-

Your browser will check Igneum

-

A header chain, a checkpoint certificate and one execution proof, verified in this tab. It trusts the miners' votes and the proof system, nothing else. Speed unmeasured until the proving layer runs. Planned for testnet.

+

Browser checks Igneum

+

A checkpoint certificate, verified in this tab. Voter list from the node.

-
BLOCK PROOF
prototype
-
CHECKPOINT
locked by miners
-
PROOF SYSTEM
version 1
-
VERIFIED IN THIS TAB
planned, testnet
+
BLOCK PROOF
not yet
+
CHECKPOINT
checking
+
PROOF SYSTEM
BLS aggregate, version 1
+
VERIFIED IN THIS TAB
checking
+
diff --git a/site/verify/core.js b/site/verify/core.js new file mode 100644 index 000000000..894ceb04c --- /dev/null +++ b/site/verify/core.js @@ -0,0 +1,157 @@ +// Igneum light client, version zero: the checks, with no I/O and no DOM. verify.js wires this to /api/checkpoint +// and the homepage card; site/verify/test.html and a Node test call it with tampered data. +// +// What it verifies, from the node's own code (vendor/igneum-node/consensus/core/src/finality.rs, +// consensus/core/src/hashing/header.rs, crypto/hashes/src/hashers.rs): +// 1. Every header hash: BLAKE2b-256 keyed "BlockHash" over version_le16, level count_le64, per level +// (count_le64, parents), the three roots, timestamp_le64, bits_le32, nonce_le64, daa_le64, blue_score_le64, +// blue work as (len_le64, big-endian bytes without leading zeros), pruning point, vote_key_hash. +// 2. The chain: headers run from the previous locked checkpoint to the certified one, each one a direct parent +// of the next, the last one the certified checkpoint block. +// 3. Every voter's key: BLAKE2b-256 keyed "IgneumVoteKeyHash" over the 48-byte compressed G1 key equals the +// vote_key_hash the headers name; the list is in canonical order (sorted by key hash) and matches the +// certificate's voter count. +// 4. The certificate: the bitmap's public keys are summed in G1 and the 96-byte G2 aggregate signature is +// checked over `"igneum-vote-v1/" || chain_id || 0x00 || index_le64 || checkpoint_hash` under the tag +// IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_ (blst min-pubkey setting, fast_aggregate_verify). +// 5. The rule (spec 03 Q3): signed weight at least 2/3 of active weight (weight x participation) and at least +// 17/30 of total weight. +// `deps` is { blake2b, bls } from @noble/hashes and @noble/curves (pinned in verify.js). + +export const DST_VOTE = 'IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_'; +export const KEY_HASH_DOMAIN = 'IgneumVoteKeyHash'; +export const BLOCK_HASH_DOMAIN = 'BlockHash'; + +const te = new TextEncoder(); + +export function hexToBytes(h) { + if (typeof h !== 'string' || h.length % 2 || /[^0-9a-f]/i.test(h)) throw new Error('bad hex'); + const out = new Uint8Array(h.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(2 * i, 2 * i + 2), 16); + return out; +} +export function bytesToHex(b) { let s = ''; for (const x of b) s += x.toString(16).padStart(2, '0'); return s; } +const u16 = v => { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, Number(v), true); return b; }; +const u32 = v => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(v), true); return b; }; +const u64 = v => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, BigInt(v), true); return b; }; +function concat(parts) { + const n = parts.reduce((a, p) => a + p.length, 0); const m = new Uint8Array(n); let o = 0; + for (const p of parts) { m.set(p, o); o += p.length; } + return m; +} + +// The header hash, byte for byte as hash_override_nonce_time writes it +export function headerHash(h, blake2b) { + const levels = h.parents_by_level || []; + const parts = [u16(h.version), u64(levels.length)]; + for (const level of levels) { parts.push(u64(level.length)); for (const p of level) parts.push(hexToBytes(p)); } + parts.push(hexToBytes(h.hash_merkle_root), hexToBytes(h.accepted_id_merkle_root), hexToBytes(h.utxo_commitment), + u64(h.timestamp), u32(h.bits), u64(h.nonce), u64(h.daa_score), u64(h.blue_score)); + const bw = String(h.blue_work).replace(/^0+/, ''); + const bwBytes = bw.length ? hexToBytes(bw.length % 2 ? '0' + bw : bw) : new Uint8Array(0); + parts.push(u64(bwBytes.length), bwBytes, hexToBytes(h.pruning_point), hexToBytes(h.vote_key_hash)); + return bytesToHex(blake2b(concat(parts), { dkLen: 32, key: te.encode(BLOCK_HASH_DOMAIN) })); +} + +export function voteKeyHash(pubkey, blake2b) { + return bytesToHex(blake2b(pubkey, { dkLen: 32, key: te.encode(KEY_HASH_DOMAIN) })); +} + +export function voteMessage(chainId, index, checkpointHex) { + const head = te.encode('igneum-vote-v1/' + chainId); + return concat([head, new Uint8Array([0]), u64(index), hexToBytes(checkpointHex)]); +} + +export function signerPositions(bitmapHex, voterCount) { + const bm = hexToBytes(bitmapHex); const out = []; + for (let p = 0; p < voterCount; p++) if (bm[p >> 3] & (1 << (p & 7))) out.push(p); + return out; +} + +const fail = (reason, extra = {}) => ({ verified: false, reason, ...extra }); + +// data: the /api/checkpoint body. Returns { verified, reason?, index, signers, weight_fraction_active, +// weight_fraction_total, headers_checked, ms, ... }. +export function verifyCheckpoint(data, deps) { + const t0 = (typeof performance !== 'undefined' ? performance : Date).now(); + const done = r => ({ ...r, ms: Math.round(((typeof performance !== 'undefined' ? performance : Date).now() - t0) * 10) / 10 }); + const { blake2b, bls } = deps; + try { + if (!data || !data.ok) return done(fail(data && data.error ? data.error : 'no checkpoint data')); + const cert = data.certificate || {}; + const index = Number(data.index); + const voters = data.voters || []; + const headers = data.headers || []; + + // 1 and 2: header hashes and the chain links + if (!headers.length) return done(fail('no headers')); + let checked = 0; + for (let i = 0; i < headers.length; i++) { + const h = headers[i]; + const got = headerHash(h, blake2b); + if (got !== h.hash) return done(fail(`header ${i} hash does not recompute (${got.slice(0, 12)} vs ${String(h.hash).slice(0, 12)})`, { headers_checked: checked })); + if (i > 0) { + const direct = (h.parents_by_level && h.parents_by_level[0]) || []; + if (!direct.includes(headers[i - 1].hash)) return done(fail(`header ${i} does not name header ${i - 1} as a parent`, { headers_checked: checked })); + } + checked++; + } + const top = headers[headers.length - 1]; + if (top.hash !== data.hash) return done(fail('the last header is not the certified checkpoint block', { headers_checked: checked })); + if (data.previous && headers[0].hash !== data.previous.hash) return done(fail('the first header is not the previous locked checkpoint', { headers_checked: checked })); + if (BigInt(top.blue_score) < 30n * BigInt(index)) return done(fail(`checkpoint ${index} needs blue score at least ${30 * index}, header has ${top.blue_score}`, { headers_checked: checked })); + + // 3: the voter list + if (voters.length !== Number(cert.voter_count)) return done(fail(`certificate names ${cert.voter_count} voters, ${voters.length} given`, { headers_checked: checked })); + for (let i = 0; i < voters.length; i++) { + const v = voters[i]; + const pk = hexToBytes(v.pubkey_hex); + if (pk.length !== 48) return done(fail(`voter ${i} key is not 48 bytes`, { headers_checked: checked })); + if (voteKeyHash(pk, blake2b) !== v.vote_key_hash) return done(fail(`voter ${i} key does not hash to its vote_key_hash`, { headers_checked: checked })); + if (i > 0 && !(voters[i - 1].vote_key_hash < v.vote_key_hash)) return done(fail('voter list is not in canonical order', { headers_checked: checked })); + if (!(Number(v.weight) >= 0) || !(Number(v.participation) >= 0 && Number(v.participation) <= 1)) return done(fail(`voter ${i} has a bad weight or participation`, { headers_checked: checked })); + } + + // 4: the aggregate signature over the exact vote message + const positions = signerPositions(cert.bitmap_hex, voters.length); + if (!positions.length) return done(fail('certificate has no signers', { headers_checked: checked })); + const L = bls.longSignatures; + let aggPk, hm, sigOk; + try { + aggPk = L.aggregatePublicKeys(positions.map(p => hexToBytes(voters[p].pubkey_hex))); + hm = L.hash(voteMessage(data.chain_id, index, data.hash), DST_VOTE); + sigOk = L.verify(hexToBytes(cert.aggregate_signature_hex), hm, aggPk); + } catch (e) { + return done(fail(`signature check failed: ${String(e.message || e).slice(0, 80)}`, { headers_checked: checked, signers: positions.length })); + } + if (!sigOk) return done(fail('aggregate signature does not verify', { headers_checked: checked, signers: positions.length })); + + // 5: the rule + let signed = 0n, total = 0n, active = 0; + for (let i = 0; i < voters.length; i++) { + const w = BigInt(Math.round(Number(voters[i].weight))); + total += w; active += Number(w) * Number(voters[i].participation); + } + for (const p of positions) signed += BigInt(Math.round(Number(voters[p].weight))); + if (total === 0n) return done(fail('total weight is zero', { headers_checked: checked, signers: positions.length })); + const fracActive = active > 0 ? Number(signed) / active : 0; + const fracTotal = Number(signed) / Number(total); + const quorum = 3 * Number(signed) >= 2 * active; + const floor = 30n * signed >= 17n * total; + const result = { + index, hash: data.hash, source: data.source, network: data.chain_id, + signers: positions.length, voters: voters.length, + signed_weight: Number(signed), active_weight: active, total_weight: Number(total), + weight_fraction_active: Math.round(fracActive * 10000) / 10000, + weight_fraction_total: Math.round(fracTotal * 10000) / 10000, + headers_checked: checked, + weights_at_index: data.voters_at_index, + weights_exact: Number(data.voters_at_index) === index, + }; + if (!quorum) return done({ ...fail(`signed weight is ${(fracActive * 100).toFixed(1)}% of active, below 2/3`), ...result }); + if (!floor) return done({ ...fail(`signed weight is ${(fracTotal * 100).toFixed(1)}% of total, below 56.7%`), ...result }); + return done({ verified: true, ...result }); + } catch (e) { + return done(fail(`error: ${String(e.message || e).slice(0, 100)}`)); + } +} diff --git a/site/verify/test.html b/site/verify/test.html new file mode 100644 index 000000000..c1186a01e --- /dev/null +++ b/site/verify/test.html @@ -0,0 +1,43 @@ + + + + +Igneum light client test + + + + +

Igneum light client, version zero: genuine and tampered

+

Each row verifies in this tab with verify/core.js. Source:

+
caseverifiedmsreasonsignersactivetotalheaders
+

+
+
+
diff --git a/site/verify/verify.js b/site/verify/verify.js
new file mode 100644
index 000000000..e26f9ed51
--- /dev/null
+++ b/site/verify/verify.js
@@ -0,0 +1,66 @@
+// Igneum light client, version zero, browser driver. Fetches /api/checkpoint and verifies it in this tab with
+// core.js, then fills the homepage card. Everything is computed here; the server only ships data.
+// Libraries, pinned: BLAKE2b from @noble/hashes 2.4.0, BLS12-381 from @noble/curves 2.4.0 (pure JavaScript,
+// served by jsdelivr as ES modules).
+import { blake2b } from 'https://cdn.jsdelivr.net/npm/@noble/hashes@2.4.0/blake2.js/+esm';
+import { bls12_381 } from 'https://cdn.jsdelivr.net/npm/@noble/curves@2.4.0/bls12-381.js/+esm';
+import { verifyCheckpoint } from './core.js';
+
+export const LIBRARIES = { '@noble/hashes': '2.4.0', '@noble/curves': '2.4.0' };
+const deps = { blake2b, bls: bls12_381 };
+
+export async function fetchCheckpoint(url = '/api/checkpoint') {
+  const r = await fetch(url, { cache: 'no-store' });
+  let body = null;
+  try { body = await r.json(); } catch { body = null; }
+  if (!body) return { ok: false, error: `api answered ${r.status}` };
+  return body;
+}
+
+export function verify(data) { return verifyCheckpoint(data, deps); }
+
+// The homepage card. Cells carry data-lc="..." so the layout and classes stay as they are.
+const $ = sel => document.querySelector(sel);
+const pct = x => `${(x * 100).toFixed(1)}%`;
+
+export function renderCard(result, data) {
+  const badge = $('[data-lc="badge"]'), badgeText = $('[data-lc="badge-text"]');
+  const cp = $('[data-lc="checkpoint"]'), proof = $('[data-lc="proof"]'), sys = $('[data-lc="system"]'), tab = $('[data-lc="tab"]');
+  const line = $('[data-lc="line"]');
+  if (!cp) return;
+  if (proof) proof.textContent = 'not yet';
+  if (sys) sys.textContent = 'BLS aggregate, version 1';
+  if (result.verified) {
+    const count = (window.__igneumVerified = (window.__igneumVerified || 0) + 1);
+    cp.textContent = `${count} verified in this tab`;
+    cp.title = `checkpoint ${result.index} on ${result.network}, block ${String(result.hash).slice(0, 12)}, ${result.headers_checked} headers checked`;
+    tab.textContent = `${result.ms} ms · ${pct(result.weight_fraction_total)} of weight`;
+    tab.title = `${result.signers} of ${result.voters} voters signed: ${pct(result.weight_fraction_active)} of active weight, ${pct(result.weight_fraction_total)} of total${result.weights_exact ? '' : ` (weights read at checkpoint ${result.weights_at_index})`}`;
+    tab.style.color = '';
+    if (badgeText) badgeText.textContent = data.source === 'test' ? 'LIVE · test network' : 'LIVE';
+    if (badge) badge.classList.add('on');
+    if (line) line.textContent = data.source === 'test'
+      ? `Checkpoint ${result.index} of the test network, certified by ${result.signers} miners. Verified here.`
+      : `Checkpoint ${result.index}, certified by ${result.signers} miners. Verified here.`;
+  } else {
+    cp.textContent = 'could not verify';
+    cp.title = result.reason || '';
+    tab.textContent = result.reason ? result.reason : 'could not verify';
+    tab.style.color = 'var(--molten)';
+    if (badgeText) badgeText.textContent = 'PREVIEW';
+    if (badge) badge.classList.remove('on');
+  }
+}
+
+export async function run(url) {
+  const data = await fetchCheckpoint(url);
+  const result = verify(data);
+  window.__igneumLightClient = { result, data };
+  renderCard(result, data);
+  return result;
+}
+
+if (typeof document !== 'undefined' && document.querySelector('[data-lc="checkpoint"]')) {
+  const start = () => run().catch(e => renderCard({ verified: false, reason: String(e.message || e) }, {}));
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();
+}
diff --git a/tools/observer/observer.mjs b/tools/observer/observer.mjs
index 85685ae0e..420a3dfc4 100644
--- a/tools/observer/observer.mjs
+++ b/tools/observer/observer.mjs
@@ -10,9 +10,11 @@
 //   LIVE_RETAIN_HOURS  hours of blocks to keep     default 24
 //   LIVE_TABLE_PREFIX  prefix for every table name  default '' (a test observer can write fintest_live_* instead)
 //
-// Tables (created on start if missing): live_blocks, live_state, live_events, live_checkpoints. See README.md.
+// Tables (created on start if missing): live_blocks, live_state, live_events, live_checkpoints, live_certificates. See README.md.
 // Finality v2: subscribes to FinalityLock notifications and polls getFinalityCheckpoints and getFinalityWeights
 // every 2 s; writes the checkpoints table and emits "checkpoint N locked (xx% of weight)" events.
+// Light client (site/api/checkpoint.mjs, site/verify/): every certificate a block carries is stored with the voter
+// table the node reports and the header chain from the previous locked checkpoint, so a browser can verify it.
 // Zero dependencies: Node 22 WebSocket and fetch, Neon's HTTP SQL endpoint.
 
 import { readFileSync } from 'node:fs';
@@ -21,7 +23,7 @@ import { homedir } from 'node:os';
 const RPC = process.env.IGNEUM_RPC || 'ws://127.0.0.1:28610';
 const RETAIN_HOURS = Number(process.env.LIVE_RETAIN_HOURS || 24);
 const T = (process.env.LIVE_TABLE_PREFIX || '').replace(/[^a-z0-9_]/gi, '');
-const TB = `${T}live_blocks`, TS = `${T}live_state`, TE = `${T}live_events`, TC = `${T}live_checkpoints`;
+const TB = `${T}live_blocks`, TS = `${T}live_state`, TE = `${T}live_events`, TC = `${T}live_checkpoints`, TX = `${T}live_certificates`;
 const STATE_EVERY_MS = 2000;
 const FLUSH_EVERY_MS = 500;
 const PRUNE_EVERY_MS = 60_000;
@@ -101,6 +103,31 @@ async function setupSchema() {
        first_seen_at timestamptz NOT NULL DEFAULT now(),
        updated_at timestamptz NOT NULL DEFAULT now())`,
     `CREATE INDEX IF NOT EXISTS ${TC}_state ON ${TC} (state, index)`,
+    // One row per certified checkpoint: the certificate bytes as carried in a block, the voter table (canonical
+    // order: above dust, not stripped, sorted by key hash) with public keys, and the selected-chain headers from
+    // the previous locked checkpoint to this one, raw as the node's RPC gives them.
+    `CREATE TABLE IF NOT EXISTS ${TX} (
+       index bigint PRIMARY KEY,
+       hash text NOT NULL,
+       blue_score bigint,
+       daa_score bigint,
+       chain_id text NOT NULL,
+       voter_count int NOT NULL,
+       bitmap_hex text NOT NULL,
+       signature_hex text NOT NULL,
+       aggregator text NOT NULL,
+       aggregator_proof_hex text NOT NULL,
+       certificate_hex text NOT NULL,
+       carrier text NOT NULL,
+       voters jsonb NOT NULL,
+       voters_index bigint NOT NULL,
+       total_weight bigint NOT NULL,
+       active_weight double precision NOT NULL,
+       prev_index bigint,
+       prev_hash text,
+       headers jsonb NOT NULL,
+       headers_complete boolean NOT NULL DEFAULT false,
+       created_at timestamptz NOT NULL DEFAULT now())`,
   ];
   for (const s of stmts) await sql(s);
 }
@@ -175,7 +202,8 @@ class Rpc {
       const ws = new WebSocket(this.url); this.ws = ws;
       ws.onopen = () => { this.open = true; log('rpc connected', this.url); resolve(true); };
       ws.onmessage = (e) => {
-        let m; try { m = JSON.parse(e.data); } catch { return; }
+        // A header nonce is a full u64; JSON.parse would round it above 2^53, so it is kept as a string
+        let m; try { m = JSON.parse(String(e.data).replace(/"nonce":(\d+)/g, '"nonce":"$1"')); } catch { return; }
         if (m.id !== undefined && m.id !== null && this.pending.has(m.id)) {
           const p = this.pending.get(m.id); this.pending.delete(m.id);
           m.error ? p.reject(new Error(m.error.message || JSON.stringify(m.error))) : p.resolve(m.params);
@@ -262,6 +290,7 @@ function onBlock(block) {
   const now = Date.now();
   const parents = (h.parentsByLevel && h.parentsByLevel[0]) || [];
   const miner = minerFromCoinbase(block, addressPrefix);
+  for (const cert of certificatesIn(block)) pendingCerts.push({ cert, carrier: h.hash });
   const vk = h.voteKeyHash || null;
   pendingBlocks.push({
     hash: h.hash, blue_score: h.blueScore, daa_score: h.daaScore, timestamp_ms: h.timestamp,
@@ -380,8 +409,9 @@ async function upsertCheckpoint(cp) {
                  signed_weight = EXCLUDED.signed_weight, active_weight = EXCLUDED.active_weight, total_weight = EXCLUDED.total_weight,
                  fraction_active = EXCLUDED.fraction_active, fraction_total = EXCLUDED.fraction_total, votes_seen = EXCLUDED.votes_seen, voters = EXCLUDED.voters,
                  aggregators = EXCLUDED.aggregators, locked_at = COALESCE(${TC}.locked_at, EXCLUDED.locked_at), updated_at = now()`,
+      // The FinalityLock notification carries no votesSeen; the 2-s poll fills it in
       [cp.index, cp.hash, cp.blueScore, cp.daaScore, cp.state, cp.signedWeight, cp.activeWeight, cp.totalWeight, cp.fractionActive, cp.fractionTotal,
-        cp.votesSeen, cp.voters, pgArray(cp.aggregators || []), locked ? new Date().toISOString() : null]);
+        cp.votesSeen ?? 0, cp.voters ?? 0, pgArray(cp.aggregators || []), locked ? new Date().toISOString() : null]);
   } catch (e) { log('checkpoint write failed', e.message); }
 }
 
@@ -394,6 +424,8 @@ async function finalityTick(rpc) {
     return null;
   }
   finalitySupported = true;
+  lastCheckpointsReport = cps;
+  if (!certBackfillDone) backfillCertificate(rpc, cps).catch(e => log('certificate backfill failed', e.message));
   for (const cp of cps.checkpoints || []) {
     const prev = checkpointStates.get(cp.index);
     if (prev !== cp.state || prev === undefined) {
@@ -428,6 +460,125 @@ async function finalityTick(rpc) {
   };
 }
 
+// ---------- Finality v2 certificates (spec 03 C3): what the light client verifies ----------
+// A block's coinbase extra data ends with the node's finality section `items || len_le32 || "IGNF"`; items are
+// tagged 1 vote (280 bytes), 2 certificate, 3 evidence (560 bytes). A certificate is `index_le64 || checkpoint(32)
+// || voter_count_le32 || bitmap_len_le32 || bitmap || signature(96) || aggregator(32) || proof(96)`
+// (vendor/igneum-node/consensus/core/src/finality.rs, Certificate::write). Bit p of the bitmap (byte p/8, bit
+// p%8) is position p of the canonical voter list at the checkpoint.
+const pendingCerts = [];            // { cert, carrier } seen in blocks, waiting for the next flush
+const storedCerts = new Map();      // index -> checkpoint hash already in the table
+let lastCheckpointsReport = null;   // the last getFinalityCheckpoints answer: chain id and the locked list
+let certBackfillDone = false;
+let certsBusy = false;
+const MAX_CHAIN_HEADERS = 200;
+const ZERO_HASH = '0'.repeat(64);
+const hexOf = u8 => Buffer.from(u8).toString('hex');
+
+function certificatesIn(block) {
+  const tx = block.transactions && block.transactions[0];
+  if (!tx) return [];
+  const p = payloadBytes(tx.payload);
+  if (p.length < 19) return [];
+  const extra = p.subarray(19 + p[18]);
+  const n = extra.length;
+  if (n < 8 || String.fromCharCode(...extra.subarray(n - 4)) !== 'IGNF') return [];
+  const len = new DataView(extra.buffer, extra.byteOffset, n).getUint32(n - 8, true);
+  if (len + 8 > n) return [];
+  const body = extra.subarray(n - 8 - len, n - 8);
+  const out = []; let o = 0;
+  while (o < body.length) {
+    const tag = body[o];
+    if (tag === 1) o += 1 + 280;
+    else if (tag === 3) o += 1 + 560;
+    else if (tag === 2) { const c = parseCertificate(body, o + 1); if (!c) break; out.push(c); o += 1 + c.length; }
+    else break;
+  }
+  return out;
+}
+function parseCertificate(b, s) {
+  if (b.length < s + 48) return null;
+  const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
+  const voterCount = dv.getUint32(s + 40, true);
+  const bl = dv.getUint32(s + 44, true);
+  const end = s + 48 + bl + 96 + 32 + 96;
+  if (bl > 1 << 20 || b.length < end) return null;
+  return {
+    index: Number(dv.getBigUint64(s, true)), checkpoint: hexOf(b.subarray(s + 8, s + 40)), voterCount,
+    bitmap: hexOf(b.subarray(s + 48, s + 48 + bl)), signature: hexOf(b.subarray(s + 48 + bl, s + 144 + bl)),
+    aggregator: hexOf(b.subarray(s + 144 + bl, s + 176 + bl)), aggregatorProof: hexOf(b.subarray(s + 176 + bl, end)),
+    bytes: hexOf(b.subarray(s, end)), length: end - s,
+  };
+}
+
+// Stores a certificate with everything a verifier needs: the voter table and the header chain back to the previous
+// locked checkpoint. The node reports the voter table at its latest determined checkpoint; captured as the lock
+// lands (the next determination is 30 blue score away) it is the table at this checkpoint, and `voters_index`
+// records which checkpoint it was read at either way.
+async function completeCertificate(rpc, cert, carrier) {
+  if (storedCerts.get(cert.index) === cert.checkpoint) return;
+  const report = lastCheckpointsReport || await rpc.call('getFinalityCheckpoints', { last: 60 });
+  const cp = (report.checkpoints || []).find(c => Number(c.index) === cert.index);
+  if (cp && cp.hash !== cert.checkpoint) { log(`certificate at ${cert.index} is for ${short(cert.checkpoint)}, the node's checkpoint is ${short(cp.hash)}; not stored`); return; }
+  const w = await rpc.call('getFinalityWeights', {});
+  const voters = (w.keys || []).filter(k => k.voter)
+    .sort((a, b) => (a.keyHash < b.keyHash ? -1 : a.keyHash > b.keyHash ? 1 : 0))
+    .map(k => ({ key_hash: k.keyHash, pubkey: k.pubkey, weight: Number(k.blocks), participation: Number(k.participation) }));
+  if (voters.length !== cert.voterCount) log(`certificate at ${cert.index} names ${cert.voterCount} voters, the node's table at checkpoint ${w.checkpointIndex} has ${voters.length}`);
+  const prev = (report.checkpoints || []).filter(c => c.state === 'locked' && Number(c.index) < cert.index).sort((a, b) => Number(b.index) - Number(a.index))[0] || null;
+  const headers = []; let hash = cert.checkpoint; let complete = false;
+  while (headers.length < MAX_CHAIN_HEADERS) {
+    const b = (await rpc.call('getBlock', { hash, includeTransactions: false })).block;
+    headers.push(b.header);
+    if (prev && hash === prev.hash) { complete = true; break; }
+    hash = (b.verboseData && b.verboseData.selectedParentHash) || ZERO_HASH;
+    if (hash === ZERO_HASH) { complete = !prev; break; }
+  }
+  headers.reverse();
+  const top = headers[headers.length - 1];
+  try {
+    await sql(`INSERT INTO ${TX} (index, hash, blue_score, daa_score, chain_id, voter_count, bitmap_hex, signature_hex, aggregator, aggregator_proof_hex,
+                 certificate_hex, carrier, voters, voters_index, total_weight, active_weight, prev_index, prev_hash, headers, headers_complete)
+               VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $18, $19::jsonb, $20)
+               ON CONFLICT (index) DO NOTHING`,
+      [cert.index, cert.checkpoint, Number(top.blueScore), Number(top.daaScore), report.chainId || network, cert.voterCount, cert.bitmap, cert.signature,
+        cert.aggregator, cert.aggregatorProof, cert.bytes, carrier, JSON.stringify(voters), Number(w.checkpointIndex), Number(w.totalWeight), Number(w.activeWeight),
+        prev ? Number(prev.index) : null, prev ? prev.hash : null, JSON.stringify(headers), complete]);
+    storedCerts.set(cert.index, cert.checkpoint);
+    log(`certificate ${cert.index} stored: ${voters.length} voters (table at checkpoint ${w.checkpointIndex}), ${headers.length} headers${complete ? '' : ' (chain incomplete)'}, carried by ${short(carrier)}`);
+  } catch (e) { log('certificate write failed', e.message); }
+}
+
+async function flushCertificates(rpc) {
+  if (certsBusy || !pendingCerts.length) return;
+  certsBusy = true;
+  try {
+    while (pendingCerts.length) {
+      const { cert, carrier } = pendingCerts.shift();
+      try { await completeCertificate(rpc, cert, carrier); } catch (e) { log('certificate store failed', e.message); }
+    }
+  } finally { certsBusy = false; }
+}
+
+// On start: the newest locked checkpoint without a stored certificate is looked up in the blocks after it (a
+// certificate is carried by the first templates after the lock). One pass, up to three pages of getBlocks.
+async function backfillCertificate(rpc, report) {
+  certBackfillDone = true;
+  const newest = (report.checkpoints || []).filter(c => c.state === 'locked').sort((a, b) => Number(b.index) - Number(a.index))[0];
+  if (!newest || storedCerts.get(Number(newest.index)) === newest.hash) return;
+  let low = newest.hash; let seen = 0;
+  for (let page = 0; page < 3; page++) {
+    const blocks = (await rpc.call('getBlocks', { lowHash: low, includeBlocks: true, includeTransactions: true })).blocks || [];
+    for (const b of blocks) {
+      seen++;
+      for (const c of certificatesIn(b)) if (c.index === Number(newest.index)) { await completeCertificate(rpc, c, b.header.hash); return; }
+    }
+    if (blocks.length < 2) break;
+    low = blocks[blocks.length - 1].header.hash;
+  }
+  log(`no block within ${seen} of checkpoint ${newest.index} carries its certificate`);
+}
+
 async function prune() {
   try {
     await sql(`DELETE FROM ${TB} WHERE received_at < now() - ($1 || ' hours')::interval`, [String(RETAIN_HOURS)]);
@@ -445,6 +596,8 @@ async function seedFromDb() {
     const st = await sql(`SELECT difficulty FROM ${TS} WHERE id = 1`);
     const cps = await sql(`SELECT index, state FROM ${TC} WHERE updated_at > now() - interval '1 day'`);
     for (const r of cps) checkpointStates.set(Number(r.index), r.state);
+    const certs = await sql(`SELECT index, hash FROM ${TX}`);
+    for (const r of certs) storedCerts.set(Number(r.index), r.hash);
     if (st.length && st[0].difficulty) lastDifficultyEvent = Number(st[0].difficulty);
   } catch (e) { log('seed failed', e.message); }
 }
@@ -491,7 +644,7 @@ async function main() {
   rpc.onClose = () => { setTimeout(connect, 2000); };
   await connect();
 
-  setInterval(async () => { await flushBlocks(); await flushChain(); }, FLUSH_EVERY_MS);
+  setInterval(async () => { await flushBlocks(); await flushChain(); await flushCertificates(rpc); }, FLUSH_EVERY_MS);
   setInterval(() => tick(rpc), STATE_EVERY_MS);
   setInterval(prune, PRUNE_EVERY_MS);
   tick(rpc); prune();