igneum/site/api/checkpoint.mjs
igneum-labs d4cd91f55f 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>
2026-10-03 22:21:12 +00:00

97 lines
4.6 KiB
JavaScript

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