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>
157 lines
9.5 KiB
JavaScript
157 lines
9.5 KiB
JavaScript
// 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)}`));
|
|
}
|
|
}
|