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 <noreply@anthropic.com>
This commit is contained in:
igneum-josh 2026-10-03 22:21:12 +00:00
parent 394030c9da
commit f874f804e0
6 changed files with 529 additions and 12 deletions

97
site/api/checkpoint.mjs Normal file
View file

@ -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/<chain_id> 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) });
}
}

View file

@ -265,21 +265,22 @@ footer .wrap{padding-block:48px 32px}
<div class="card verify reveal">
<div class="verify-head">
<div class="eyebrow">This tab · light client</div>
<div class="live"><span class="dot"></span>PREVIEW</div>
<div class="live" data-lc="badge"><span class="dot"></span><span data-lc="badge-text">PREVIEW</span></div>
</div>
<div class="verify-row">
<div class="tick"><svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="#0C0C0E" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7"></path></svg></div>
<div>
<h3 style="font-size:20px">Your browser will check Igneum</h3>
<p style="font-size:14px;color:var(--ash)">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.</p>
<h3 style="font-size:20px">Browser checks Igneum</h3>
<p style="font-size:14px;color:var(--ash)" data-lc="line">A checkpoint certificate, verified in this tab. Voter list from the node.</p>
</div>
</div>
<div class="verify-grid">
<div class="cell"><div class="k">BLOCK PROOF</div><div class="v">prototype</div></div>
<div class="cell"><div class="k">CHECKPOINT</div><div class="v">locked by miners</div></div>
<div class="cell"><div class="k">PROOF SYSTEM</div><div class="v">version 1</div></div>
<div class="cell"><div class="k">VERIFIED IN THIS TAB</div><div class="v" style="color:var(--molten)">planned, testnet</div></div>
<div class="cell"><div class="k">BLOCK PROOF</div><div class="v" data-lc="proof" title="Execution proofs do not exist yet">not yet</div></div>
<div class="cell"><div class="k">CHECKPOINT</div><div class="v" data-lc="checkpoint">checking</div></div>
<div class="cell"><div class="k">PROOF SYSTEM</div><div class="v" data-lc="system">BLS aggregate, version 1</div></div>
<div class="cell"><div class="k">VERIFIED IN THIS TAB</div><div class="v" style="color:var(--molten)" data-lc="tab">checking</div></div>
</div>
<script type="module" src="/verify/verify.js"></script>
</div>
</div>
</header>

157
site/verify/core.js Normal file
View file

@ -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)}`));
}
}

43
site/verify/test.html Normal file
View file

@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Igneum light client test</title>
<meta name="robots" content="noindex">
<style>body{font-family:ui-monospace,Menlo,monospace;font-size:13px;background:#0C0C0E;color:#E8E4DA;padding:24px;max-width:960px}h1{font-size:16px}td{padding:4px 10px;border-bottom:1px solid #222;vertical-align:top}.ok{color:#7ED957}.bad{color:#F2541B}</style>
</head>
<body>
<h1>Igneum light client, version zero: genuine and tampered</h1>
<p>Each row verifies in this tab with <code>verify/core.js</code>. Source: <code id="src"></code></p>
<table id="t"><thead><tr><td>case</td><td>verified</td><td>ms</td><td>reason</td><td>signers</td><td>active</td><td>total</td><td>headers</td></tr></thead><tbody></tbody></table>
<pre id="done"></pre>
<script type="module">
import { fetchCheckpoint, verify, LIBRARIES } from './verify.js';
const q = new URLSearchParams(location.search);
const url = q.get('api') || '/api/checkpoint' + (q.get('source') ? `?source=${q.get('source')}` : '');
const data = await fetchCheckpoint(url);
document.getElementById('src').textContent = `${url} (index ${data.index}, ${data.source}, ${data.chain_id}) libraries ${JSON.stringify(LIBRARIES)}`;
const clone = () => JSON.parse(JSON.stringify(data));
const cases = [];
cases.push(['genuine', clone()]);
cases.push(['genuine again (warm)', clone()]);
{ const d = clone(); const s = d.certificate.aggregate_signature_hex; const b = (parseInt(s.slice(20, 22), 16) ^ 1).toString(16).padStart(2, '0'); d.certificate.aggregate_signature_hex = s.slice(0, 20) + b + s.slice(22); cases.push(['signature, one bit flipped', d]); }
{ const d = clone(); const bm = parseInt(d.certificate.bitmap_hex.slice(0, 2), 16); let p = 0; while (!(bm & (1 << p))) p++; d.certificate.bitmap_hex = (bm & ~(1 << p)).toString(16).padStart(2, '0') + d.certificate.bitmap_hex.slice(2); cases.push([`one voter dropped from the bitmap (position ${p})`, d]); }
{ const d = clone(); const i = d.voters.findIndex((v, k) => d.certificate.bitmap_hex && (parseInt(d.certificate.bitmap_hex.slice(0, 2), 16) & (1 << k))); const pk = d.voters[i].pubkey_hex; d.voters[i].pubkey_hex = pk.slice(0, 10) + ((parseInt(pk.slice(10, 12), 16) ^ 4).toString(16).padStart(2, '0')) + pk.slice(12); cases.push(['a signer\'s public key altered', d]); }
{ const d = clone(); d.hash = d.hash.slice(0, 62) + ((parseInt(d.hash.slice(62), 16) ^ 1).toString(16).padStart(2, '0')); d.headers[d.headers.length - 1].hash = d.hash; cases.push(['checkpoint hash altered', d]); }
{ const d = clone(); const h = d.headers[Math.floor(d.headers.length / 2)]; h.nonce = String(BigInt(h.nonce) ^ 1n); cases.push(['a header nonce altered', d]); }
{ const d = clone(); d.headers.splice(Math.floor(d.headers.length / 2), 1); cases.push(['a header removed from the chain', d]); }
{ const d = clone(); const bm = parseInt(d.certificate.bitmap_hex.slice(0, 2), 16); const quiet = d.voters.filter((v, k) => !(bm & (1 << k))); if (quiet.length) { for (const v of quiet) v.weight = Math.round(v.weight * 10); cases.push(['a non-signer\'s weight raised 10x (floor)', d]); } else { for (const v of d.voters) v.weight = 0; cases.push(['every weight zeroed', d]); } }
const tb = document.querySelector('#t tbody');
const out = [];
for (const [name, d] of cases) {
const r = verify(d);
out.push({ name, ...r });
const tr = document.createElement('tr');
tr.innerHTML = `<td>${name}</td><td class="${r.verified ? 'ok' : 'bad'}">${r.verified}</td><td>${r.ms}</td><td>${r.reason || ''}</td><td>${r.signers ?? ''}</td><td>${r.weight_fraction_active ?? ''}</td><td>${r.weight_fraction_total ?? ''}</td><td>${r.headers_checked ?? ''}</td>`;
tb.appendChild(tr);
}
document.getElementById('done').textContent = 'RESULTS ' + JSON.stringify(out);
</script>
</body>
</html>

66
site/verify/verify.js Normal file
View file

@ -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();
}

View file

@ -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();