aere-node/tools/verify-anchor.mjs

767 lines
63 KiB
JavaScript

#!/usr/bin/env node
// verify-anchor.mjs (v2) - independently verify Aere Network's post-quantum header anchor,
// INCLUDING the Falcon-512 signatures, from a public RPC endpoint and the published key manifest.
//
// v1 of this tool proved the BINDING: every 32nd header carries a certificate of Falcon-512 seals
// and a 32-byte digest in the hashed part of the header, and the digest equals
// keccak256(RLP["AERE-PQ-ANCHOR-1", chainId, parentNumber, parentHash, certificate]). It did NOT
// check that the seals are valid signatures; for that you had to ask the chain's own precompile,
// i.e. ask us. v2 removes that trust point. What v2 proves, in order, each leg independent:
//
// 1. BINDING as v1: strip or alter the certificate and the digest no longer matches.
// 2. KEY MANIFEST the published registry of Falcon public keys is self-consistent: every row's
// proof of possession (a Falcon signature over the row) verifies with the row's
// key, and every row's claim (an ECDSA signature by the validator's own key, EIP-191
// version 0) recovers to the row's validator address. So the manifest cannot have
// been invented by us: each row is signed by the validator's consensus key.
// 3. SEALS every Falcon-512 seal in every certificate verifies, in plain JavaScript written
// from the spec (falcon512.mjs), against the manifest key of its index, over
// keccak256(RLP["AERE-PQ-COMMIT-1", chainId, parentNumber, parentHash]).
// 4. VALIDATORS the validator bound to each sealing key was in the QBFT validator set of the PARENT block, the block the
// seals sign, read from that block's own header (bound to its hash), and each counts once per scheme, so the
// seals are those of distinct validators of that block (D-477, 2026-10-01: until then the set was asked of
// the endpoint at the anchor's height and cached per window of 1000 blocks, and duplicates counted).
// 5. ON-CHAIN PIN each manifest's hash, keccak256(addr||pk over rows), equals slot 0 of the immutable
// anchor contract named in the index, and that contract's code is the 11-byte
// read-only getter with no SSTORE: the manifest you hold is the one the chain pinned.
// 6. PROPOSER (AIP-22 stage 5, 2026-09-24) on a chain where headers carry element 6, the proposer's
// Falcon-512 seal over keccak256(RLP["AERE-PQ-PROPOSAL-1", chainId, height, round, digest])
// verifies against the v3 registry key of its index, and that index is the header's
// coinbase, or, in a block re-proposed in a round r > 0, the proposer QBFT expects for
// round r (2026-09-25, D-406), on the most recent headers (PROPOSER_HEADERS, default 20);
// and, as the nodes judge every header from H_hdr, the round is a canonical integer
// at most 1024 and the coinbase is the proposer of some round 0..r at that height.
// Where no header carries it (chain 2800 today) the tool says so and this leg does not
// change the verdict.
//
// What this still does NOT prove: who RUNS the validators (today: one operator, the Foundation; we
// publish that ourselves), or that the RPC you use is honest about chain contents - bring your own
// node and pass RPC=<your-url> to remove even that. The manifests can also be given from disk.
//
// Run it:
// npm install @noble/hashes @noble/curves
// node verify-anchor.mjs # 20 most recent anchors, manifests fetched from aere.network
// node verify-anchor.mjs 13921040 # 20 anchors ending at a chosen height
// ANCHORS=100 node verify-anchor.mjs # more anchors
// MANIFESTS=./manifeste node verify-anchor.mjs # manifests from a local directory instead of the site
// RPC=http://127.0.0.1:8545 node verify-anchor.mjs
//
// Exit 0 only when every leg holds on every anchor checked. Any failure is the finding.
import { keccak_256 } from '@noble/hashes/sha3';
import { sha256 } from '@noble/hashes/sha256';
import { secp256k1 } from '@noble/curves/secp256k1';
import fs from 'node:fs';
import path from 'node:path';
import { verifyFalcon512, fromHex } from './falcon512.mjs';
// Early refusals stop through stop(1), not process.exit(1): on Windows (Node 24), process.exit() right after a fetch aborts node
// inside libuv while the HTTP sockets close, and the caller sees exit code 127 instead of 1 (measured 2026-09-27: 8 of 8 runs; with
// stop(), 6 of 6 runs exit 1). stop() prints nothing itself, sets the code, and throws a marker that the handler below swallows, so the
// module stops where it is and node exits normally once its sockets are closed. Any OTHER uncaught error still ends the run as a
// failure at once, so that nothing that went wrong in the background can be followed by a VERIFIED line.
class StopVerify extends Error {}
process.on('uncaughtException', (e) => {
if (e instanceof StopVerify) return;
console.error(e);
process.exit(1);
});
const stop = (code) => { process.exitCode = code; throw new StopVerify(); };
const RPC = process.env.RPC || 'https://rpc.aere.network';
const MANIFESTS = process.env.MANIFESTS || 'https://aere.network/pq/manifests';
// CHAIN_ID din mediu: 2800 (mainnet, implicit) sau 28001 (testnetul public, cu manifestele lui sub
// testnet-rpc.aere.network/sarcina/pq/manifests). Aceeasi unealta, acelasi drum de verificare.
const CHAIN_ID = Number(process.env.CHAIN_ID || 2800);
const ANCHOR_DOMAIN = 'AERE-PQ-ANCHOR-1';
// v2 (2026-09-03): the SCHEME-TAGGED certificate RLP[2, [[scheme, idx, sig], ...]] under its own domain.
// Wire tags: 0x01 falcon-512, 0x02 slh-dsa-sha2-128s. Every scheme the certificate carries must reach K, and
// every non-Falcon seal must ride with a Falcon seal of the same index (the binding both clients enforce).
const ANCHOR_DOMAIN_V2 = 'AERE-PQ-ANCHOR-2';
const SCHEME_NAMES = { 1: 'falcon-512', 2: 'slh-dsa-sha2-128s' };
let slhDsa = null;
try { slhDsa = (await import('@noble/post-quantum/slh-dsa.js')).slh_dsa_sha2_128s; } catch { slhDsa = null; }
const verifySlhDsa = (pk, msg, sig) => { if (!slhDsa) return false; try { return slhDsa.verify(new Uint8Array(sig), new Uint8Array(msg), new Uint8Array(pk)); } catch { return false; } };
const COMMIT_DOMAIN = 'AERE-PQ-COMMIT-1';
const POP_DOMAIN = 'AERE-PQ-POP-1';
const CLAIM_DOMAIN = 'AERE-PQ-CLAIM-1';
const FORMAT_VERSION = 2;
// pragul IMPUS de lant depinde de inaltime: 3 de la introducere (2026-08-14), 6 din blocul
// 14.961.456 (2026-08-21, cvorum 2f+1). MIN_SEALS din mediu suprascrie, pentru experimente.
const K6_HEIGHT = 14961456;
// orarul pragului per lant: 2800 = 3 de la introducere, 6 din K6_HEIGHT; 28001 = 0 de la 64, 3 din 128
// The chain's own threshold schedule, including the one dated exception: on 2026-09-04, after eleven minutes
// of halted chain (every node had restarted with an empty seal store), the fleet was unblocked with a step
// 17102384:0,17102416:6, so anchor 17102384 legitimately carries no SLH-DSA seal and 9 Falcon seals. A verifier
// that did not know the step would call that anchor wrong; one that hides it would be lying. MIN_SEALS_SCHEDULE
// ("h:k,h:k") overrides for experiments and for other chains.
// 28001, 2026-09-22: step 2988864:4, the per-scheme minimum becomes the QBFT quorum (4 of 5), AIP-22 stage 1 rehearsal.
const K_DEFAULT = CHAIN_ID === 28001 ? [[64, 0], [128, 3], [168032, 0], [168064, 3], [2988864, 4]] : [[0, 3], [K6_HEIGHT, 6], [17102384, 0], [17102416, 6], [20715632, 7]];
const K_SCHEDULE = (process.env.MIN_SEALS_SCHEDULE || '').split(',').filter(Boolean).map(p => p.split(':').map(Number)).sort((a, b) => a[0] - b[0]);
if (!K_SCHEDULE.length) K_SCHEDULE.push(...K_DEFAULT);
// D-338 (2026-09-04): the SCHEMES the certificate must carry, by height of the PARENT (the seals are made when the
// parent is committed), exactly as both clients read them. Without this the tool verified only the seals it found,
// so a certificate that dropped a whole scheme passed. SCHEME_SCHEDULE ("h:scheme+scheme,...") overrides.
// 28001, 2026-09-23: AIP-22 stage 2 rehearsal from 3,070,656 - Falcon-512 at every anchor (every 32nd block again),
// SLH-DSA only at every 128th; chain 2800 unchanged.
const SCHEME_DEFAULT = CHAIN_ID === 28001 ? '168000:falcon-512+slh-dsa-sha2-128s,3070656:falcon-512/32+slh-dsa-sha2-128s/128' : '17047568:falcon-512+slh-dsa-sha2-128s,20746736:falcon-512/32+slh-dsa-sha2-128s/128';
// AIP-22 stage 2 (2026-09-23): a scheme may carry its own interval, "h:falcon-512/32+slh-dsa-sha2-128s/128". Such a scheme
// is REQUIRED only at anchors on its own grid ((anchor - ANCHOR_FROM) % interval == 0); at the other anchors it is still
// NAMED, so its seals, when present, are verified but need not reach K (surplus), exactly as both clients judge it. A scheme
// without "/interval" is required at every anchor (the rule before stage 2). Intervals are positive multiples of 32.
const SCHEME_SCHEDULE = (process.env.SCHEME_SCHEDULE || SCHEME_DEFAULT).split(',').filter(Boolean)
.map(p => { const [h, list] = p.split(':'); return [Number(h), list.split('+').map(x => x.trim()).filter(Boolean).map(e => { const [name, iv] = e.split('/'); return { name, interval: iv === undefined ? 0 : Number(iv) }; })]; })
.sort((a, b) => a[0] - b[0]);
for (const [h, list] of SCHEME_SCHEDULE) for (const { name, interval } of list) {
if (!(interval === 0 || (Number.isInteger(interval) && interval > 0 && interval % 32 === 0))) { console.error('FAIL: SCHEME_SCHEDULE step ' + h + ' gives ' + name + ' the interval ' + interval + ', which is not a positive multiple of 32'); stop(1); }
}
const stepAt = (hh) => { let out = [{ name: 'falcon-512', interval: 0 }]; for (const [from, list] of SCHEME_SCHEDULE) { if (from <= hh) out = list; else break; } return out; };
const schemesAt = (hh) => stepAt(hh).map(s => s.name);
// the schemes an anchor at height a must carry at K: the step in force at the PARENT (seals are made on the parent's commit),
// filtered by each scheme's own grid; the grid is counted from ANCHOR_FROM, the same anchor block both clients use
const schemesRequiredForAnchor = (a) => stepAt(a - 1).filter(s => s.interval === 0 || (a - ANCHOR_FROM) % s.interval === 0).map(s => s.name);
const minSealsAt = (hh) => Number(process.env.MIN_SEALS || K_SCHEDULE.filter(([h]) => hh >= h).map(([, k]) => k).pop());
const COUNT = Number(process.env.ANCHORS || 20);
// ---- minimal RLP ---------------------------------------------------------------------------
function rlpRead(buf, pos) {
const p = buf[pos];
if (p < 0x80) return { item: buf.subarray(pos, pos + 1), next: pos + 1, list: false };
if (p < 0xb8) { const l = p - 0x80; return { item: buf.subarray(pos + 1, pos + 1 + l), next: pos + 1 + l, list: false }; }
if (p < 0xc0) { const ll = p - 0xb7; let l = 0; for (let i = 0; i < ll; i++) l = l * 256 + buf[pos + 1 + i]; return { item: buf.subarray(pos + 1 + ll, pos + 1 + ll + l), next: pos + 1 + ll + l, list: false }; }
let ll = 0, l;
if (p < 0xf8) l = p - 0xc0; else { ll = p - 0xf7; l = 0; for (let i = 0; i < ll; i++) l = l * 256 + buf[pos + 1 + i]; }
const start = pos + 1 + ll, end = start + l, items = []; let c = start;
while (c < end) { const r = rlpRead(buf, c); items.push(r); c = r.next; }
return { item: items, next: end, list: true };
}
function rlpLen(n) { if (n < 56) return Buffer.from([n]); const b = []; let v = n; while (v > 0) { b.unshift(v & 0xff); v = Math.floor(v / 256); } return Buffer.from([b.length, ...b]); }
function rlpEncode(x) {
if (Buffer.isBuffer(x)) {
if (x.length === 1 && x[0] < 0x80) return x;
if (x.length < 56) return Buffer.concat([Buffer.from([0x80 + x.length]), x]);
const l = rlpLen(x.length); l[0] += 0xb7; return Buffer.concat([l, x]);
}
const body = Buffer.concat(x.map(rlpEncode));
if (body.length < 56) return Buffer.concat([Buffer.from([0xc0 + body.length]), body]);
const l = rlpLen(body.length); l[0] += 0xf7; return Buffer.concat([l, body]);
}
const scalar = (n) => { if (n === 0) return Buffer.alloc(0); const b = []; let v = BigInt(n); while (v > 0n) { b.unshift(Number(v & 0xffn)); v >>= 8n; } return Buffer.from(b); };
const hex = (s) => Buffer.from(s.slice(2), 'hex');
const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(n)); return b; };
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n); return b; };
const keccak = (b) => Buffer.from(keccak_256(b));
async function rpc(method, params) {
const r = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
const j = await r.json(); if (j.error) throw new Error(method + ': ' + JSON.stringify(j.error)); return j.result;
}
// ---- the three messages, exactly as the node computes them -------------------------------------
const commitMessage = (parentNumber, parentHash) =>
keccak(rlpEncode([Buffer.from(COMMIT_DOMAIN, 'ascii'), scalar(CHAIN_ID), scalar(parentNumber), parentHash]));
// PqRegistryBinding.bindingPreimage
function bindingPreimage(bindHeight, count, index, address, publicKey) {
return Buffer.concat([Buffer.from(POP_DOMAIN, 'ascii'), Buffer.from([FORMAT_VERSION]), u64(CHAIN_ID), u64(bindHeight),
u32(count), u32(index), address, u32(publicKey.length), publicKey]);
}
const possessionDigest = (...a) => keccak(bindingPreimage(...a));
function claimDigest(...a) {
const ctx = keccak(Buffer.concat([Buffer.from(CLAIM_DOMAIN, 'ascii'), u64(CHAIN_ID)])).subarray(12, 32);
return keccak(Buffer.concat([Buffer.from([0x19, 0x00]), ctx, bindingPreimage(...a)]));
}
function recoverClaim(digest, sig65) {
if (sig65.length !== 65) return null;
let rec = sig65[64]; if (rec >= 27) rec -= 27;
if (rec !== 0 && rec !== 1) return null;
try {
const s = secp256k1.Signature.fromCompact(sig65.subarray(0, 64)).addRecoveryBit(rec);
const pub = s.recoverPublicKey(digest).toRawBytes(false); // 65 bytes, 0x04 || x || y
return keccak(Buffer.from(pub.subarray(1))).subarray(12, 32);
} catch { return null; }
}
// ---- manifests: fetch or read, then prove each one self-consistent ------------------------------
async function loadText(loc) {
if (/^https?:\/\//.test(loc)) { const r = await fetch(loc); if (!r.ok) throw new Error('HTTP ' + r.status + ' for ' + loc); return r.text(); }
return fs.readFileSync(loc, 'utf8');
}
const isUrl = /^https?:\/\//.test(MANIFESTS);
const join = (a, b) => isUrl ? a.replace(/\/$/, '') + '/' + b : path.join(a, b);
let index;
try { index = JSON.parse(await loadText(join(MANIFESTS, 'index.json'))); }
catch (e) { console.error('FAIL: cannot load manifest index from ' + MANIFESTS + ': ' + e.message); stop(1); }
if (!index || !Array.isArray(index.manifests) || !index.manifests.length) { console.error('FAIL: manifest index has no entries'); stop(1); }
if (Number(index.chainId) !== CHAIN_ID) { console.error('FAIL: manifest index is for chain ' + index.chainId); stop(1); }
console.log('chain ' + CHAIN_ID + ' rpc ' + RPC + ' manifests ' + MANIFESTS);
console.log('');
console.log('KEY MANIFESTS (leg 2): every row signed by its Falcon key (possession) AND by the validator\'s ECDSA key (claim)');
const manifests = [];
let manifestBad = 0;
const v3Entries = []; // AIP-22 stage 3: registry v3 epochs live in the same index; they are verified further below
for (const m of index.manifests) {
if (String(m.formatVersion) === '3') { v3Entries.push(m); continue; }
const text = await loadText(join(MANIFESTS, m.file));
const digest = Buffer.from(sha256(Buffer.from(text, 'utf8'))).toString('hex');
const man = JSON.parse(text);
const shaOk = digest === String(m.sha256).toLowerCase();
let rowsOk = 0, rowsBad = [];
const rows = [];
for (let i = 0; i < man.count; i++) {
const row = man[String(i)];
const addr = hex(row.addr), pk = fromHex(row.pk);
const pop = fromHex(row.pop), claim = hex(row.claim.startsWith('0x') ? row.claim : '0x' + row.claim);
const popOk = verifyFalcon512(pk, possessionDigest(man.bindHeight, man.count, i, addr, Buffer.from(pk)), pop);
const rec = recoverClaim(claimDigest(man.bindHeight, man.count, i, addr, Buffer.from(pk)), claim);
const claimOk = rec !== null && rec.equals(addr);
if (popOk && claimOk) rowsOk++; else rowsBad.push(i + (popOk ? '' : ':possession') + (claimOk ? '' : ':claim'));
// v2: a row may carry extra scheme keys ({"keys": {"slh-dsa-sha2-128s": "0x.."}}); absent = Falcon only
const keys = {};
if (row.keys && typeof row.keys === 'object') for (const [id, kh] of Object.entries(row.keys)) keys[id] = fromHex(kh);
rows.push({ index: i, addr: '0x' + addr.toString('hex'), pk, keys, popOk, claimOk });
}
// leg 5: the on-chain pin. keccak256(addr20 || pk, row by row) must equal slot 0 of the immutable
// anchor contract, and that contract must be the 11-byte read-only getter (no SSTORE anywhere).
let pinOk = false, pinNote = 'no anchorContract in index';
if (!m.anchorContract && m.pin === 'genesis') {
// testnetul pinuieste manifestul in configuratia genezei (pqRegistryHash), care nu se citeste prin RPC:
// legatura e in fisierul de geneza pe care il rulezi deja, nu una pe care unealta o poate verifica aici.
pinOk = true; pinNote = 'pinned in the genesis config (pqRegistryHash), not readable over RPC';
}
if (m.anchorContract) {
const pre = Buffer.concat(rows.flatMap(r => [hex(r.addr), Buffer.from(r.pk)]));
const want = '0x' + keccak(pre).toString('hex');
try {
const slot0 = await rpc('eth_getStorageAt', [m.anchorContract, '0x0', 'latest']);
const code = await rpc('eth_getCode', [m.anchorContract, 'latest']);
const getter = code.toLowerCase() === '0x60005460005260206000f3';
pinOk = slot0.toLowerCase() === want.toLowerCase() && getter;
pinNote = (slot0.toLowerCase() === want.toLowerCase() ? 'slot 0 matches' : 'slot 0 MISMATCH (' + slot0.slice(0, 10) + ' vs ' + want.slice(0, 10) + ')') +
(getter ? ', contract is the read-only getter' : ', contract code is NOT the known read-only getter');
} catch (e) { pinNote = 'pin unmeasured: ' + e.message; }
}
const good = shaOk && pinOk && rowsOk === man.count && Number(man.chainId) === CHAIN_ID && Number(man.formatVersion) === FORMAT_VERSION;
if (!good) manifestBad++;
console.log(' bindHeight ' + String(man.bindHeight).padEnd(10) + ' ' + m.file.padEnd(26) + ' rows ' + rowsOk + '/' + man.count +
' sha256 ' + (shaOk ? 'matches index' : 'MISMATCH') + ' on-chain pin: ' + pinNote + (rowsBad.length ? ' BAD rows: ' + rowsBad.join(' ') : '') + (good ? '' : ' <-- FAIL'));
manifests.push({ bindHeight: Number(man.bindHeight), count: man.count, rows, good });
}
manifests.sort((a, b) => a.bindHeight - b.bindHeight);
const manifestAt = (h) => [...manifests].reverse().find(m => m.bindHeight <= h) || null;
// ---- anchors --------------------------------------------------------------------------------
const top = process.argv[2] ? Number(process.argv[2]) : parseInt(await rpc('eth_blockNumber', []), 16);
console.log('');
console.log('ANCHORS: ' + COUNT + ' ending at or below ' + top);
// Anchor heights are arithmetic, not guessed from the header: on 2800 every 32nd block from 13,014,000
// (h mod 32 == 16, universal from 13,889,296); on 28001 every 32nd block from 64 (h mod 32 == 0). The header
// is then required to LOOK like an anchor (32-byte digest that is not the client's vanity string), so a wrong
// schedule fails loudly instead of silently picking a non-anchor. Measured 2026-09-03: the old vanity scan chose
// 63399 on the testnet, a non-anchor, because that client's vanity never contains 'besu'.
const ANCHOR_MOD = Number(process.env.ANCHOR_MOD || (CHAIN_ID === 28001 ? 0 : 16));
const ANCHOR_FROM = Number(process.env.ANCHOR_FROM || (CHAIN_ID === 28001 ? 64 : 13014000));
// D-336 (2026-09-04): the interval can change at scheduled heights ("h:interval,h:interval", ANCHOR_INTERVAL_SCHEDULE);
// every scheduled interval is a multiple of 32 and every change height is on the new grid, so the anchors of a later
// regime are a subset of the earlier ones. Default: no change (every 32nd block). Same rule as both clients.
// D-336 (2026-09-04): the interval can change at scheduled heights ("h:interval,h:interval"); every scheduled
// interval is a multiple of 32 and every change height is on the new grid, so the anchors of a later regime are
// a subset of the earlier ones. Same rule as both clients.
//
// 2026-09-06: THE SCHEDULE IS NO LONGER SOMETHING YOU HAVE TO KNOW. Until today it came only from the
// ANCHOR_INTERVAL_SCHEDULE environment variable and defaulted to "every 32nd block". Chain 2800 moved to every
// 128th on 2026-09-05, so this tool - run the way a stranger runs it, with no variables - reported
// "PROBLEMS: 15 of 20 anchors failed" against a perfectly healthy chain: the 15 were ordinary blocks it had
// picked off the old grid. A verifier that cries wolf on a healthy chain is worse than no verifier.
//
// The grid is still ARITHMETIC, never guessed per header (a vanity scan once picked a non-anchor on the
// testnet). Only the STEP is now measured on the chain itself: walk back from the tip to the two most recent
// headers that carry a certificate element, take their distance, and require it to be a multiple of 32 that
// divides the distance from ANCHOR_FROM. From there every sampled height is arithmetic, and a height on that
// grid WITHOUT a certificate is still a loud failure. A chain that stopped anchoring cannot produce two
// regularly spaced certificates, so it cannot buy itself a green run here: it fails to derive at all.
const INTERVAL_SCHEDULE = (process.env.ANCHOR_INTERVAL_SCHEDULE || '').split(',').filter(Boolean)
.map(p => p.split(':').map(x => Number(x.trim()))).sort((a, b) => a[0] - b[0]);
for (const [h, iv] of INTERVAL_SCHEDULE) {
if (!(iv > 0 && iv % 32 === 0) || h < ANCHOR_FROM || (h - ANCHOR_FROM) % iv !== 0) { console.error('FAIL: ANCHOR_INTERVAL_SCHEDULE entry ' + h + ':' + iv + ' is not a multiple of 32 on the grid from ' + ANCHOR_FROM); stop(1); }
}
// carries a certificate element? (RLP item 6 of extraData; enough to space the grid, not to trust it)
async function carriesCertificate(h) {
if (h < ANCHOR_FROM) return false;
const b = await rpc('eth_getBlockByNumber', ['0x' + h.toString(16), false]);
if (!b || !b.extraData) return false;
// AIP-22 stage 5: from H_hdr every header has seven elements and the certificate slot (item 6) is an EMPTY list between
// anchors, so a certificate is a NON-EMPTY item 6, never just the count of elements
try { const it = rlpRead(hex(b.extraData), 0).item; return it.length >= 6 && it[5].list && it[5].item.length > 0; } catch (e) { return false; }
}
let DERIVED_INTERVAL = 0;
if (!INTERVAL_SCHEDULE.length) {
const seen = [];
const LOOKBACK = Number(process.env.ANCHOR_LOOKBACK || 1100); // two 512-intervals and a margin
for (let h = top; h > top - LOOKBACK && h > ANCHOR_FROM && seen.length < 2; h--) {
if (await carriesCertificate(h)) seen.push(h);
}
if (seen.length < 2) {
console.error('FAIL: only ' + seen.length + ' certificate-carrying header(s) in the ' + LOOKBACK + ' blocks below ' + top + ',');
console.error(' so the anchor spacing cannot be measured on this chain. Either anchoring has stopped, or this');
console.error(' endpoint is not serving chain ' + CHAIN_ID + '. Pass ANCHOR_INTERVAL_SCHEDULE=h:interval to state it explicitly.');
stop(1);
}
const step = seen[0] - seen[1];
if (!(step > 0 && step % 32 === 0) || (seen[0] - ANCHOR_FROM) % step !== 0) {
console.error('FAIL: the two most recent anchors (' + seen[0] + ', ' + seen[1] + ') are ' + step + ' apart, which is not a');
console.error(' multiple of 32 on the grid from ' + ANCHOR_FROM + '. The chain is not anchoring on a regular grid.');
stop(1);
}
DERIVED_INTERVAL = step;
INTERVAL_SCHEDULE.push([seen[0] - Math.floor((seen[0] - ANCHOR_FROM) / step) * step, step]);
}
const intervalAt = (h) => { let iv = 32; for (const [from, v] of INTERVAL_SCHEDULE) { if (from <= h) iv = v; else break; } return iv; };
const isAnchor = (h) => h >= ANCHOR_FROM && (h - ANCHOR_FROM) % intervalAt(h) === 0;
const prevAnchor = (h) => { let x = h - 1; while (x >= ANCHOR_FROM && !isAnchor(x)) x--; return x; };
let anchor = top; while (anchor >= ANCHOR_FROM && !isAnchor(anchor)) anchor--;
if (DERIVED_INTERVAL) {
console.log('anchor spacing MEASURED ON THIS CHAIN: every ' + DERIVED_INTERVAL + 'th block from ' + ANCHOR_FROM + ' (grid anchored at ' + INTERVAL_SCHEDULE[0][0] + ');');
console.log(' derived from the two most recent certificate-carrying headers, not from anything you have to take on trust.');
} else if (INTERVAL_SCHEDULE.length) {
console.log('interval schedule (given): ' + INTERVAL_SCHEDULE.map(([h, iv]) => h + ':' + iv).join(',') + ' (in force at ' + anchor + ': every ' + intervalAt(anchor) + 'th block)');
}
if (anchor < ANCHOR_FROM) { console.error('FAIL: no anchor at or below ' + top + ' (anchors start at ' + ANCHOR_FROM + ')'); stop(1); }
{
const b = await rpc('eth_getBlockByNumber', ['0x' + anchor.toString(16), false]);
const vanity = rlpRead(hex(b.extraData), 0).item[0].item;
if (vanity.length !== 32 || Buffer.from(vanity).includes(Buffer.from('besu'))) { console.error('FAIL: block ' + anchor + ' does not look like an anchor (vanity is the client string, not a digest)'); stop(1); }
}
// AIP-22 stage 3 (2026-09-23): from the identity height a chain's validator set holds post-quantum ids, not ECDSA addresses.
// The v3 registry epochs are read from the SAME manifest index (entries with formatVersion 3), so a stranger needs no variable:
// each epoch's sha256 must match the index, and each is verified in full by registry-v3.mjs (the independent JavaScript
// implementation of the registry: every proof of possession, Falcon-512 from the spec and SLH-DSA via @noble, every ecdsa link,
// the rotation chain from the epoch before, and the Merkle registry hash). In this first stage the keys that SIGN seals are still
// the ones of the key manifests above, so every v3 row must carry exactly the Falcon key (0x09 || the 896 bytes), the SLH-DSA key
// and the address of the same index in the key manifest in force at its bindHeight: otherwise its id would be a label on a key
// that does not sign, and this tool refuses. The identity height is the first v3 epoch's bindHeight. IDENTITY_BLOCK and
// REGISTRY_V3 (a v3 epoch file or URL, verified the same way) override the index, for an epoch not yet published.
// IDENTITY_BLOCK=none ignores the v3 epochs altogether (the set is read as ECDSA addresses at every height): a negative
// control, under which every anchor sealed after the identity height must fail with "keys not in validator set".
const IDENTITY_OFF = process.env.IDENTITY_BLOCK === 'none';
let IDENTITY_BLOCK = process.env.IDENTITY_BLOCK && !IDENTITY_OFF ? Number(process.env.IDENTITY_BLOCK) : Infinity;
const V3_EPOCHS = []; // [{ bindHeight, link: Map(ecdsa addr -> id) }], ascending
if (IDENTITY_OFF) console.log('identity: IDENTITY_BLOCK=none, v3 epochs ignored (negative control)');
if (!IDENTITY_OFF) {
const sources = process.env.REGISTRY_V3
? [{ loc: process.env.REGISTRY_V3, sha: null, file: process.env.REGISTRY_V3 }]
: [...v3Entries].sort((a, b) => Number(a.bindHeight) - Number(b.bindHeight)).map(m => ({ loc: join(MANIFESTS, m.file), sha: String(m.sha256).toLowerCase(), file: m.file, keysFrom: m.keysFrom === true || m.keysFrom === 'true' }));
if (sources.length) {
let reg;
try { reg = await import('./registry-v3.mjs'); }
catch (e) { console.error('FAIL: this chain publishes v3 registry epochs, and registry-v3.mjs (published beside this tool) cannot be loaded: ' + e.message); stop(1); }
let previous = null, link = new Map();
for (const s of sources) {
const text = await loadText(s.loc);
if (s.sha && Buffer.from(sha256(Buffer.from(text, 'utf8'))).toString('hex') !== s.sha) { console.error('FAIL: v3 epoch ' + s.file + ': sha256 does not match the index'); stop(1); }
const json = JSON.parse(text);
let r;
try { r = reg.load(json, CHAIN_ID, schemesAt(json.bindHeight), previous, previous === null); }
catch (e) { console.error('FAIL: v3 epoch ' + s.file + ' does not verify: ' + e.message); stop(1); }
const km = manifestAt(r.bindHeight);
// from H_k (the first epoch the index marks keysFrom) a later epoch may rotate keys under the old keys' signatures, which
// registry-v3.mjs has verified; the equality with the key manifest is required up to and including the H_k epoch
const rotating = V3_EPOCHS.some(x => x.keysFrom) || (process.env.KEYS_FROM && r.bindHeight > Number(process.env.KEYS_FROM));
if (!km || km.count !== r.count) { console.error('FAIL: v3 epoch bound at ' + r.bindHeight + ' has ' + r.count + ' rows; the key manifest in force there has ' + (km ? km.count : 'none')); stop(1); }
for (const row of (rotating ? [] : r.rows)) {
const k = km.rows[row.index];
const f = row.keys.find(x => x.name === 'falcon-512'), sl = row.keys.find(x => x.name === 'slh-dsa-sha2-128s');
const falconSame = f && Buffer.concat([Buffer.from([0x09]), Buffer.from(k.pk)]).equals(Buffer.from(f.pk));
const slhSame = !k.keys['slh-dsa-sha2-128s'] ? !sl : (sl && Buffer.from(k.keys['slh-dsa-sha2-128s']).equals(Buffer.from(sl.pk)));
const addrSame = !row.ecdsa || ('0x' + row.ecdsa.addr.toString('hex')) === k.addr.toLowerCase();
if (!(falconSame && slhSame && addrSame)) { console.error('FAIL: v3 epoch bound at ' + r.bindHeight + ', row ' + row.index + ': ' + (falconSame ? '' : 'falcon key ') + (slhSame ? '' : 'slh-dsa key ') + (addrSame ? '' : 'ecdsa address ') + 'differ from the key manifest that signs there'); stop(1); }
}
// the ecdsa link is inherited by id (a later epoch may omit it), exactly as both clients resolve a message's
// author: an id that this epoch links again drops its older address, and an id no longer registered drops its link
link = new Map(link);
const idsNow = new Set(r.rows.map(row => '0x' + row.id.toString('hex')));
for (const row of r.rows) if (row.ecdsa) { const idh = '0x' + row.id.toString('hex'); for (const [a, i] of link) if (i === idh) link.delete(a); }
for (const row of r.rows) if (row.ecdsa) link.set('0x' + row.ecdsa.addr.toString('hex'), '0x' + row.id.toString('hex'));
for (const [a, i] of link) if (!idsNow.has(i)) link.delete(a);
// AIP-22 stage 3b: the key set of this epoch by index (Falcon-512 without its 0x09 header, the other schemes by name) and the
// index's id; used for seals from KEYS_FROM on, exactly as both clients read keys from the v3 epoch in force at the parent
const byIndex = r.rows.map(row => {
const keys = {};
for (const k of row.keys) if (k.name !== 'falcon-512') keys[k.name] = new Uint8Array(k.pk);
const f = row.keys.find(k => k.name === 'falcon-512');
return { id: '0x' + row.id.toString('hex'), pk: f ? new Uint8Array(f.pk.subarray(1)) : null, keys };
});
V3_EPOCHS.push({ bindHeight: r.bindHeight, link, byIndex, keysFrom: s.keysFrom === true });
console.log(' v3 epoch ' + String(r.bindHeight).padEnd(10) + ' ' + String(s.file).padEnd(26) + ' rows ' + r.count + '/' + r.count +
' registry hash 0x' + r.hash.toString('hex').slice(0, 16) + '.. every possession proof, ecdsa link and the Merkle hash verified; keys = the key manifest of that height');
previous = r;
}
if (!Number.isFinite(IDENTITY_BLOCK)) IDENTITY_BLOCK = V3_EPOCHS[0].bindHeight;
console.log('identity: from block ' + IDENTITY_BLOCK + ' the validator set holds post-quantum ids (' + V3_EPOCHS.length + ' v3 epoch(s))');
}
}
// the height from which the keys that seal come from the v3 epochs (AIP-22 stage 3b): KEYS_FROM, or the epoch the index marks
const KEYS_FROM = process.env.KEYS_FROM ? Number(process.env.KEYS_FROM) : (V3_EPOCHS.find(e => e.keysFrom)?.bindHeight ?? Infinity);
if (Number.isFinite(KEYS_FROM)) console.log('keys: from block ' + KEYS_FROM + ' the keys that seal come from the v3 epoch in force (AIP-22 stage 3b)');
const v3EpochAt = (h) => [...V3_EPOCHS].reverse().find(x => x.bindHeight <= h) || null;
const memberOf = (addr, h) => {
if (h < IDENTITY_BLOCK || !V3_EPOCHS.length) return addr;
const e = [...V3_EPOCHS].reverse().find(x => x.bindHeight <= h) || V3_EPOCHS[0];
return e.link.get(addr) ?? addr;
};
// D-477 (2026-10-01): a certificate's seals sign M(p) for the PARENT p = h - 1, so they are judged against the validator set of
// p, as both clients judge them (the validators of the parent block). That set is read here from element 1 of p's OWN header,
// which the caller first re-encodes in its on-chain form (SPEC 2.3) to the hash the seals sign: an endpoint cannot hand this tool
// a set of its choosing. Until this date the set was asked of the endpoint at the ANCHOR's height and cached per window of 1000
// blocks, so across a set vote up to 1000 blocks were judged on the wrong set: the seal of a member removed later was refused (the
// tool accused a sound chain, the family of D-372) and the seal of a member added later was counted. There is no cache: the set is
// read for each parent. Before the identity height the list holds ECDSA addresses, from it post-quantum ids (AIP-22 stage 3),
// matched below through the same translation as before.
async function validatorsAt(p, pItems) {
const el = pItems && pItems[1];
if (!el || !el.list || el.item.length === 0 || el.item.some(x => x.list || x.item.length !== 20)) return null;
return new Set(el.item.map(x => '0x' + Buffer.from(x.item).toString('hex')));
}
let ok = 0, bad = 0, sealsTotal = 0, sealsValid = 0;
// 2026-09-06: 'nu am putut verifica AICI' nu e 'ancora e invalida', si a fost raportat asa. Fara
// @noble/post-quantum instalat, o ancora v2 sanatoasa (9 Falcon + 9 SLH-DSA) iesea in lista drept FAIL si
// rezumatul spunea 'PROBLEMS: 20 of 20 anchors failed' - despre un lant perfect sanatos, catre un strain care
// tocmai urmase instructiunea noastra de pe site. Un NEMASURAT nemeritat e la fel de mincinos ca un verde
// nemeritat: se numara separat si iese cu alt cod.
let unmeasured = 0;
// D-477: anchors whose parent header could not be read or did not re-encode to its own hash, so the set it lists was not read
let setUnmeasured = 0;
// 2026-09-26: the most recent anchor whose certificate VERIFIED here (anchors are walked newest first). Its PARENT is the
// block the certificate's seals signed (AIP-15/22: the certificate signs the parent's commit message); printed in the summary
// so a stranger reads from this tool, not from our word, which block the newest verified certificate covers. Nodes did not serve
// the finalized/safe tags on this chain then (measured 2026-09-26: eth_getBlockByNumber("finalized") answered "Unknown block").
// 2026-10-01 (AIP-22 erratum of that date): this comment and the summary line called that parent the chain's "post-quantum
// finality point" and printed it on a FINALIZED line. The commit seal carries no round, so a certificate shows that its
// signers sealed that block post-quantum, in some round, not that the block was decided; the line now says what was verified.
// The ANCHOR_REPORT keys below are unchanged (the AIP-23 verifier reads them).
let newestOk = null;
let newestParentHash = null; // the hash of block newestOk - 1 the verified seals signed (M(parent)), for ANCHOR_REPORT
// ce scheme au fost CHIAR verificate: rezumatul final nu are voie sa numeasca o schema pe care nu a vazut-o
const schemesSeen = new Set();
let cursor = anchor;
for (let i = 0; i < COUNT; i++) {
const h = cursor; if (i > 0 && h < ANCHOR_FROM) break;
cursor = prevAnchor(h);
const b = await rpc('eth_getBlockByNumber', ['0x' + h.toString(16), false]);
const parent = await rpc('eth_getBlockByNumber', ['0x' + (h - 1).toString(16), false]);
const items = rlpRead(hex(b.extraData), 0).item;
const vanity = Buffer.from(items[0].item);
if (items.length < 6 || !items[5].list || items[5].item.length === 0) { console.log(h + ' FAIL: no certificate element'); bad++; continue; }
const cert = items[5];
const toNested = (node) => (node.list ? node.item.map(toNested) : Buffer.from(node.item));
// D-477: the parent's header is the source of the set the seals are judged against, so it must be readable, must re-encode
// (on-chain form: round 0, commit seals empty, certificate and proposer seal omitted) to the hash the endpoint reports for it,
// and that hash must be the parent this anchor names. The first two are this run's gap (another endpoint may serve it); the
// third is a header that is not this anchor's parent, so the anchor is not judged on it.
let pItems = null, pSelf = false;
if (parent && parent.extraData) {
try {
pItems = rlpRead(hex(parent.extraData), 0).item;
pSelf = keccak(headerRlp(parent, rlpEncode([toNested(pItems[0]), toNested(pItems[1]), toNested(pItems[2]), Buffer.alloc(0), []]))).equals(hex(parent.hash));
} catch (e) { pSelf = false; }
}
if (!pSelf) {
setUnmeasured++;
console.log(h + ' NOT VERIFIED HERE: the header served for block ' + (h - 1) + (parent ? ' does not re-encode to the hash the endpoint reports for it'
: ' could not be read') + ', so the validator set it lists (the set these seals are judged against) cannot be read with confidence.'
+ ' This is a gap in THIS run, not a finding about the chain; try another endpoint (RPC=...).');
continue;
}
if (!hex(parent.hash).equals(hex(b.parentHash))) { bad++; console.log(h + ' FAIL: the header served for block ' + (h - 1) + ' (hash ' + parent.hash + ') is not the parent this anchor names (' + b.parentHash + ')'); continue; }
// v1 opens with a LIST ([idx, sig]); v2 opens with the SCALAR 2. Nothing else is accepted.
const isV2 = cert.list && cert.item.length === 2 && !cert.item[0].list;
if (isV2 && !(cert.item[0].item.length === 1 && cert.item[0].item[0] === 2)) { console.log(h + ' FAIL: certificate opens with an unknown version scalar'); bad++; continue; }
const digest = isV2
? keccak(rlpEncode([Buffer.from(ANCHOR_DOMAIN_V2, 'ascii'), scalar(CHAIN_ID), scalar(h - 1), hex(parent.hash), rlpEncode(toNested(cert))]))
: keccak(rlpEncode([Buffer.from(ANCHOR_DOMAIN, 'ascii'), scalar(CHAIN_ID), scalar(h - 1), hex(parent.hash), toNested(cert)]));
const bound = digest.equals(vanity);
const man = manifestAt(h);
const msg = commitMessage(h - 1, hex(parent.hash));
const vset = await validatorsAt(h - 1, pItems);
if (!vset) { bad++; console.log(h + ' FAIL: the validator list in element 1 of the header of block ' + (h - 1) + ' (the block these seals sign) is empty or malformed, so no seal can be bound to a validator'); continue; }
let valid = 0, invalid = [], notValidator = [], unknownIndex = [], unknownScheme = [], unbound = [], noKey = [], duplicate = [];
const perScheme = {}; // scheme name -> valid seals, each from a DISTINCT validator
const countedBy = {}; // scheme name -> the validators already counted (D-477: a validator counts once per scheme, as in both clients)
const falconIdx = new Set();
const sealNodes = isV2 ? cert.item[1].item : cert.item;
const rowsOf = sealNodes.map(s => isV2
? { scheme: (s.item[0].item.length ? s.item[0].item[0] : 0), idx: (s.item[1].item.length ? s.item[1].item[0] : 0), sig: new Uint8Array(s.item[2].item) }
: { scheme: 1, idx: (s.item[0].item.length ? s.item[0].item[0] : 0), sig: new Uint8Array(s.item[1].item) });
for (const r of rowsOf) if (r.scheme === 1) falconIdx.add(r.idx);
for (const r of rowsOf) schemesSeen.add(r.scheme);
for (const { scheme, idx, sig } of rowsOf) {
sealsTotal++;
const name = SCHEME_NAMES[scheme];
if (!name) { unknownScheme.push(scheme); continue; }
// AIP-22 stage 3b: from KEYS_FROM the index names the row of the v3 epoch in force at the PARENT (its id and its keys)
const v3k = h - 1 >= KEYS_FROM ? v3EpochAt(h - 1) : null;
const row = v3k ? v3k.byIndex[idx] : (man && man.rows[idx]);
if (!row) { unknownIndex.push(idx); continue; }
const member = v3k ? row.id : memberOf(row.addr.toLowerCase(), h - 1);
if (!vset.has(member)) { notValidator.push(idx); continue; }
// D-477: a second seal of the same validator in the same scheme is not counted again; the nodes refuse such a certificate
const seen = countedBy[name] || (countedBy[name] = new Set());
if (seen.has(member)) { duplicate.push(name + '@' + idx); continue; }
seen.add(member);
if (scheme === 1) {
if (!row.pk || !verifyFalcon512(row.pk, msg, sig)) { invalid.push('falcon@' + idx); continue; }
} else {
if (!falconIdx.has(idx)) { unbound.push(name + '@' + idx); continue; }
const pk = row.keys && row.keys[name];
if (!pk) { noKey.push(name + '@' + idx); continue; }
if (!verifySlhDsa(pk, msg, sig)) { invalid.push(name + '@' + idx); continue; }
}
perScheme[name] = (perScheme[name] || 0) + 1;
valid++; sealsValid++;
}
const n = rowsOf.length;
const MIN_SEALS = minSealsAt(h);
const falconValid = perScheme['falcon-512'] || 0;
// D-338: every scheme the schedule REQUIRES at this anchor must reach K on its own; a scheme with NO seal at all is named
// as missing, not silently skipped. When K is 0 at this height (a dated step) nothing is required. AIP-22 stage 2: a
// scheme that is NAMED at the parent's step but not required at this anchor (off its own grid) is SURPLUS - its seals are
// verified above like any other, but it need not reach K; a scheme present in the certificate that the step does not
// name at all is refused, as both clients refuse it.
const requiredSchemes = MIN_SEALS > 0 ? schemesRequiredForAnchor(h) : [];
const namedSchemes = schemesAt(h - 1);
const shortSchemes = requiredSchemes.filter(nm => (perScheme[nm] || 0) > 0 && perScheme[nm] < MIN_SEALS).map(nm => nm + ':' + perScheme[nm]);
const missingSchemes = requiredSchemes.filter(nm => !(perScheme[nm] > 0));
const unnamedSchemes = isV2 ? [...new Set(rowsOf.map(r => SCHEME_NAMES[r.scheme]).filter(nm => nm && !namedSchemes.includes(nm)))] : [];
const falconNeeded = requiredSchemes.includes('falcon-512') || requiredSchemes.length === 0;
const schemesOk = (!falconNeeded || falconValid >= MIN_SEALS) && shortSchemes.length === 0 && missingSchemes.length === 0
&& unnamedSchemes.length === 0 && (!isV2 || slhDsa || !rowsOf.some(r => r.scheme === 2));
const good = bound && man && man.good && schemesOk && valid === n;
// blocat DE MEDIU: tot ce se putea verifica aici e in regula, si singurul lucru care lipseste e verificatorul
// SLH-DSA. Orice alt defect (digest, manifest, sigiliu invalid, prag) il scoate din categoria asta.
// ce NU se poate verifica pe masina asta: fara @noble/post-quantum, orice schema in afara de falcon-512.
// Sigiliile ei ajung in 'invalid' si schema ei in 'missingSchemes' - amandoua sunt lipsuri ale RULARII, nu
// ale lantului, si numai ele au voie sa ramana pentru ca blocul sa fie NEMASURAT in loc de ESUAT.
const unverifiableHere = (nm) => !slhDsa && nm !== 'falcon-512';
const envBlocked = !good && isV2 && !slhDsa && rowsOf.some(r => r.scheme === 2)
&& bound && man && man.good && falconValid >= MIN_SEALS
&& missingSchemes.every(unverifiableHere)
&& invalid.every(x => unverifiableHere(String(x).split('@')[0]))
&& unknownScheme.length === 0 && unknownIndex.length === 0
&& notValidator.length === 0 && noKey.length === 0 && unbound.length === 0 && duplicate.length === 0;
const summary = Object.entries(perScheme).map(([nm, c]) => c + ' ' + nm).join(' + ');
if (good) { ok++; if (newestOk === null) { newestOk = h; newestParentHash = String(parent.hash).toLowerCase(); } console.log(h + ' OK: bound under block hash' + (isV2 ? ' (v2, scheme-tagged)' : '') + ', ' + n + ' seals (' + summary + '), all valid and from distinct validators of block ' + (h - 1) + ', the set its own header lists (' + (h - 1 >= KEYS_FROM && v3EpochAt(h - 1) ? 'keys of the registry v3 epoch ' + v3EpochAt(h - 1).bindHeight : 'manifest ' + man.bindHeight) + ')'); }
else if (envBlocked) {
unmeasured++;
console.log(h + ' NOT VERIFIED HERE: bound under block hash, ' + falconValid + ' valid falcon-512 seals, but this'
+ ' machine has no SLH-DSA verifier, so the ' + rowsOf.filter(r => r.scheme === 2).length + ' slh-dsa seals were not checked'
+ ' (npm install @noble/post-quantum). This is a gap in THIS run, not a finding about the chain.');
}
else {
bad++;
console.log(h + ' FAIL:' + (bound ? '' : ' digest mismatch') + (man ? (man.good ? '' : ' manifest not self-consistent') : ' no manifest for this height') +
(falconNeeded && falconValid < MIN_SEALS ? ' only ' + falconValid + ' valid Falcon seals (K=' + MIN_SEALS + ')' : '') + (shortSchemes.length ? ' short schemes ' + shortSchemes.join(',') : '') + (missingSchemes.length ? ' missing scheme ' + missingSchemes.join(',') + ' (required at anchor ' + h + ', step in force at ' + (h - 1) + ')' : '') +
(unnamedSchemes.length ? ' scheme(s) not named by the schedule at ' + (h - 1) + ': ' + unnamedSchemes.join(',') : '') +
(invalid.length ? ' invalid seals ' + invalid.join(',') : '') + (unbound.length ? ' extra seals without a Falcon seal of the same index ' + unbound.join(',') : '') +
(noKey.length ? ' manifest has no key for ' + noKey.join(',') : '') + (unknownScheme.length ? ' unknown scheme tag ' + unknownScheme.join(',') : '') +
(notValidator.length ? ' keys not in validator set at index ' + notValidator.join(',') + ' (the set of block ' + (h - 1) + ', the block these seals sign)' : '') + (unknownIndex.length ? ' unknown index ' + unknownIndex.join(',') : '') +
(duplicate.length ? ' the same validator sealed more than once in one scheme: ' + duplicate.join(',') + ' (counted once; the nodes refuse such a certificate)' : '') +
(isV2 && !slhDsa && rowsOf.some(r => r.scheme === 2) ? ' (SLH-DSA verifier not installed: npm install @noble/post-quantum)' : ''));
}
}
// 6. PROPOSER SEALS, AIP-22 stage 5 (added 2026-09-24). From H_hdr every header carries element 6, [index, signature]: the
// proposer's Falcon-512 seal over keccak256(RLP["AERE-PQ-PROPOSAL-1", chainId, height, round, digest]), where digest is the hash
// of the header re-encoded in the EXCLUDE_COMMIT_SEALS form (SPEC 2.3: round kept, commit seals an empty list, the certificate
// and the proposer seal omitted). The index names a row of the v3 registry epoch in force at that height, and that row's id must
// be the header's coinbase, or the proposer of the round of a re-proposal (below). Each header is first re-encoded in the on-chain
// form (round 0) and must reproduce the hash the endpoint reports: that is the control of THIS header's encoding, and a header that
// fails it is not judged at all. Headers without element 6 (every header of a chain, or of heights, before stage 5) are counted
// and named, never judged.
//
// RE-PROPOSAL (2026-09-25, D-406, measured on testnet 28001). A block PREPARED in one round and not committed before its timer is
// re-proposed UNCHANGED in a later round r by the proposer of round r (QBFT's locked value: only element 3, the round, changes; the
// coinbase stays the builder's), and the proposer seal put in the header at commit is the one of that round's proposer. Requiring
// the coinbase refused every such header: block 3,314,873 stood 128 s, rounds 1 to 4 refused by every node with "a seal cannot
// vouch for another proposer", round 5 the builder's own turn again (five validators). So a seal is admitted when its id is the
// coinbase, OR when the round is above 0 and its id is the proposer QBFT expects for that round, as both clients now judge it.
// That proposer is derived here, not taken from the endpoint: the validators are element 1 of the header itself, and the previous
// proposer is the coinbase of the PARENT, whose header must re-encode to this header's parentHash (so an endpoint cannot choose the
// parent's coinbase). A parent that cannot be read or bound is a FAIL, never an admission.
//
// And on EVERY header judged here, whoever sealed it, the tool applies the rules the nodes apply from H_hdr, so it judges exactly
// as they do: (1) the round in element 3 is a canonical RLP integer (no leading zero, at most 8 bytes, the shortest encoding;
// Besu's strict decoding refuses any other form); (2) it is at most 1024 (no QBFT round timer, which doubles every round, gets
// there, and upstream's selection computes in int); (3) the coinbase is the proposer of some round 0..min(r, n - 1) at this height,
// i.e. it could have BUILT the block (the rotation is periodic in n; the second client has required this since D-331). Rule (3)
// needs the parent's coinbase for every header; the headers judged are consecutive, so each parent is the next header down, read
// once, and only the oldest needs one header more: PROPOSER_HEADERS + 1 reads in all, none per header beyond that.
//
// expectedProposer is Besu's BftProposerSelector with changeEachBlock = true: a previous proposer that is in the list moves one
// step and then the round; one that is no longer in the list is replaced by the lowest validator above it (compared as numbers),
// or by the lowest of all when none is above it, and then moves by the round only.
// >>> expectedProposer (self-contained: verify-anchor-repropunere-proba.mjs reads it from this file and tests it)
function expectedProposer(validators, prevProposer, round) {
const n = validators.length;
if (n === 0) return null;
const at = validators.indexOf(prevProposer);
if (at >= 0) return validators[(at + 1 + round) % n];
const num = (a) => BigInt(a);
let base = null;
for (const v of validators) if (num(v) > num(prevProposer) && (base === null || num(v) < num(base))) base = v;
if (base === null) for (const v of validators) if (base === null || num(v) < num(base)) base = v;
return validators[(validators.indexOf(base) + round) % n];
}
// <<< expectedProposer
const PROPOSER_HEADERS = Number(process.env.PROPOSER_HEADERS || 20);
const PROPOSAL_DOMAIN = 'AERE-PQ-PROPOSAL-1';
const nested = (node) => (node.list ? node.item.map(nested) : Buffer.from(node.item));
function headerRlp(b, extra) {
const f = [hex(b.parentHash), hex(b.sha3Uncles), hex(b.miner), hex(b.stateRoot), hex(b.transactionsRoot), hex(b.receiptsRoot),
hex(b.logsBloom), scalar(b.difficulty), scalar(b.number), scalar(b.gasLimit), scalar(b.gasUsed), scalar(b.timestamp), extra,
hex(b.mixHash), hex(b.nonce)];
if (b.baseFeePerGas != null) f.push(scalar(b.baseFeePerGas));
if (b.withdrawalsRoot != null) f.push(hex(b.withdrawalsRoot));
if (b.blobGasUsed != null) { f.push(scalar(b.blobGasUsed)); f.push(scalar(b.excessBlobGas)); }
if (b.parentBeaconBlockRoot != null) f.push(hex(b.parentBeaconBlockRoot));
if (b.requestsHash != null) f.push(hex(b.requestsHash));
if (b.balHash != null) f.push(hex(b.balHash));
return rlpEncode(f);
}
let propOk = 0, propBad = 0, propUnmeasured = 0, propAbsent = 0, propReproposed = 0;
{
const headNow = parseInt(await rpc('eth_blockNumber', []), 16);
// each header is read ONCE: the loop reads it as the header judged, and the header above it has already read it as its parent
const got = new Map();
const fetchHeader = async (n) => {
try { const x = await rpc('eth_getBlockByNumber', ['0x' + n.toString(16), false]); return x ? { b: x } : { why: 'the endpoint returned no header' }; }
catch (e) { return { why: e.message }; }
};
const header = async (n) => { if (!got.has(n)) got.set(n, await fetchHeader(n)); return got.get(n); };
for (let k = 0; k < PROPOSER_HEADERS; k++) {
const h = headNow - 2 - k; if (h < 1) break;
const rec = await header(h);
if (!rec.b) { propUnmeasured++; console.log(h + ' PROPOSER SEAL NOT CHECKED HERE: the header could not be read (' + rec.why + ')'); continue; }
const b = rec.b;
const ex = hex(b.extraData);
const items = rlpRead(ex, 0).item;
if (items.length < 7) { propAbsent++; continue; }
const onchain = keccak(headerRlp(b, rlpEncode([nested(items[0]), nested(items[1]), nested(items[2]), Buffer.alloc(0), []])));
if (!onchain.equals(hex(b.hash))) {
propUnmeasured++;
console.log(h + ' PROPOSER SEAL NOT CHECKED HERE: this header does not re-encode to the hash the endpoint reports, so its proposal digest cannot be computed with confidence');
continue;
}
const seal = items[6];
if (!seal.list || seal.item.length !== 2 || seal.item[0].list || seal.item[1].list) { propBad++; console.log(h + ' FAIL: the proposer seal is not [index, signature]'); continue; }
const idx = seal.item[0].item.length ? parseInt(Buffer.from(seal.item[0].item).toString('hex'), 16) : 0;
const digestP = keccak(headerRlp(b, rlpEncode([nested(items[0]), nested(items[1]), nested(items[2]), nested(items[3]), []])));
const msgP = keccak(rlpEncode([Buffer.from(PROPOSAL_DOMAIN, 'ascii'), scalar(CHAIN_ID), scalar(h), Buffer.from(items[3].item), digestP]));
const ep = v3EpochAt(h);
const row = ep && ep.byIndex[idx];
if (!row) { propBad++; console.log(h + ' FAIL: proposer seal index ' + idx + ' names no row of a published v3 registry epoch in force at this height'); continue; }
const sealId = row.id, coinbase = String(b.miner).toLowerCase();
// D-406 (1): the round is a canonical RLP integer. The decoder above keeps only the bytes, so the prefix is read from the raw
// extraData, where element 3 starts right after element 2 ends; the header hash cannot see this (the round is zeroed in it)
const rb = items[3].list ? null : Buffer.from(items[3].item);
const p3 = ex[items[2].next];
const notCanonical = rb === null ? 'it is a list' : rb.length > 8 ? 'longer than 8 bytes' : rb.length && rb[0] === 0 ? 'a leading zero byte'
: (rb.length === 0 ? p3 !== 0x80 : rb.length === 1 && rb[0] < 0x80 ? p3 !== rb[0] : p3 !== 0x80 + rb.length) ? 'not the shortest RLP encoding' : null;
if (notCanonical) { propBad++; console.log(h + ' FAIL: the round in element 3 (RLP 0x' + ex.subarray(items[2].next, items[3].next).toString('hex') + ') is not a canonical RLP integer (' + notCanonical + '); the nodes refuse this form: not admitted'); continue; }
// D-406 (2): the round bound, read as a BigInt so an absurd value is named as it is, not rounded
const roundBig = rb.length ? BigInt('0x' + rb.toString('hex')) : 0n;
if (roundBig > 1024n) { propBad++; console.log(h + ' FAIL: the round ' + roundBig + ' in element 3 is above 1024 (no QBFT round timer gets there; both clients refuse it from H_hdr): not admitted'); continue; }
const round = Number(roundBig);
if (!items[1].list || items[1].item.length === 0 || items[1].item.some(x => x.list || x.item.length !== 20)) { propBad++; console.log(h + ' FAIL: the validator list in element 1 of this header is malformed, so the proposers of this height cannot be derived: not admitted'); continue; }
// D-406 (3): the builder. The parent is the next header down (already read by the loop, or read once for the oldest)
const pr = await header(h - 1);
if (!pr.b) { propBad++; console.log(h + ' FAIL: the parent header ' + (h - 1) + ' could not be read (' + pr.why + '), so the proposers of this height cannot be derived: not admitted'); continue; }
const parent = pr.b;
let parentBound = false;
try {
const pItems = rlpRead(hex(parent.extraData), 0).item;
parentBound = keccak(headerRlp(parent, rlpEncode([nested(pItems[0]), nested(pItems[1]), nested(pItems[2]), Buffer.alloc(0), []]))).equals(hex(b.parentHash));
} catch (e) { parentBound = false; }
if (!parentBound) { propBad++; console.log(h + ' FAIL: the header served for block ' + (h - 1) + ' does not re-encode to this header\'s parentHash, so its coinbase cannot be used to derive the proposers of this height: not admitted'); continue; }
const parentCoinbase = String(parent.miner).toLowerCase();
const vals = items[1].item.map(x => '0x' + Buffer.from(x.item).toString('hex'));
let builder = -1;
for (let r0 = 0; r0 <= Math.min(round, vals.length - 1); r0++) if (expectedProposer(vals, parentCoinbase, r0) === coinbase) { builder = r0; break; }
if (builder < 0) { propBad++; console.log(h + ' FAIL: the coinbase ' + b.miner + ' is not the proposer of any round 0..' + round + ' at this height (from the validators of this header and the coinbase ' + parentCoinbase + ' of block ' + (h - 1) + '), so it did not build the block: not admitted'); continue; }
let reproposal = null;
if (coinbase !== sealId) {
// D-406: only a re-proposal in a round above 0 may carry another validator's seal, and only the one of that round's proposer
const said = h + ' FAIL: proposer seal index ' + idx + ' is id ' + sealId + ', but the coinbase is ' + b.miner;
if (round === 0) { propBad++; console.log(said + ' and the round is 0 (only a block re-proposed in a later round carries the seal of that round\'s proposer): a seal cannot vouch for another proposer'); continue; }
const expected = expectedProposer(vals, parentCoinbase, round);
if (expected !== sealId) { propBad++; console.log(said + ' and the proposer of round ' + round + ' is ' + expected + ' (from the validators of this header and the coinbase ' + parentCoinbase + ' of block ' + (h - 1) + '): a seal cannot vouch for another proposer'); continue; }
reproposal = { round, parentCoinbase, builder };
}
if (!row.pk || !verifyFalcon512(row.pk, msgP, new Uint8Array(seal.item[1].item))) { propBad++; console.log(h + ' FAIL: the proposer seal of index ' + idx + ' is not a valid Falcon-512 signature over the proposal message'); continue; }
propOk++;
if (reproposal) {
propReproposed++;
console.log(h + ' OK, re-proposal: built by the coinbase ' + b.miner + ' (the proposer of round ' + reproposal.builder + '), re-proposed in round ' + reproposal.round + '; the proposer seal is index ' + idx
+ ', id ' + sealId + ', which is the proposer expected for round ' + reproposal.round + ' (from the validators of this header and the coinbase '
+ reproposal.parentCoinbase + ' of block ' + (h - 1) + '), and it is a valid Falcon-512 signature over the proposal message of that round');
}
}
}
const propChecked = propOk + propBad + propUnmeasured;
console.log('');
console.log(propChecked === 0
? 'proposer seals (AIP-22 stage 5): none of the ' + propAbsent + ' most recent headers carries one; stage 5 is not active at these heights on this chain, so there is nothing to verify'
: 'proposer seals (AIP-22 stage 5): ' + propOk + ' of ' + propChecked + ' recent headers carry a valid Falcon-512 proposal seal by the key of their coinbase,'
+ ' or of the proposer of the round that re-proposed the block' + (propReproposed ? ' (' + propReproposed + ' re-proposal(s), named above)' : '')
+ (propUnmeasured ? ', ' + propUnmeasured + ' could not be checked here' : '') + (propAbsent ? ' (' + propAbsent + ' earlier headers carry none)' : ''));
console.log('seals: ' + sealsValid + '/' + sealsTotal + ' cryptographically valid and validator-bound manifests: ' + (manifests.length - manifestBad) + '/' + manifests.length + ' self-consistent');
const all = bad === 0 && manifestBad === 0 && unmeasured === 0 && setUnmeasured === 0 && propBad === 0 && propUnmeasured === 0;
console.log(all
? 'VERIFIED: ' + ok + '/' + COUNT + ' anchor blocks carry a post-quantum certificate bound under the block hash, every seal a valid '
+ ([...schemesSeen].sort().map(x => SCHEME_NAMES[x] || ('scheme ' + x)).join(' or ') || 'post-quantum')
+ ' signature by a key that a distinct validator of the block it signs bound to itself with its own consensus key.'
+ (propOk ? ' And ' + propOk + ' of the ' + propOk + ' most recent headers checked carry a valid Falcon-512 proposal seal by the key of their coinbase,'
+ ' or of the proposer of the round that re-proposed the block (AIP-22 stage 5).' : '')
: (bad === 0 && manifestBad === 0 && propBad === 0
? (unmeasured
? 'UNMEASURED: ' + unmeasured + ' of ' + COUNT + ' anchors could not be fully checked ON THIS MACHINE because the SLH-DSA\n'
+ ' verifier is missing. Nothing here says the chain is wrong; install it and run again:\n'
+ ' npm install @noble/post-quantum'
: '')
+ (setUnmeasured ? (unmeasured ? '\n' : '') + 'UNMEASURED: ' + setUnmeasured + ' of ' + COUNT + ' anchors: the header served for the parent could not be read or did'
+ ' not re-encode to its own hash, so the validator set the seals are judged against was not read. This says nothing about the chain;'
+ ' try another endpoint (RPC=...).' : '')
+ (propUnmeasured ? (unmeasured || setUnmeasured ? '\n' : '') + 'UNMEASURED: ' + propUnmeasured + ' recent header(s) did not re-encode to the hash the endpoint reports, so their'
+ ' proposer seals were not judged. This says nothing about the chain; try another endpoint (RPC=...).' : '')
: 'PROBLEMS: ' + bad + ' of ' + COUNT + ' anchors and ' + manifestBad + ' manifests failed' + (propBad ? ', and ' + propBad + ' proposer seals' : '') + ' - do not take our word for anything; the failure list above is the finding.'
+ (unmeasured ? ' (' + unmeasured + ' more could not be checked here: npm install @noble/post-quantum)' : '')
+ (setUnmeasured ? ' (' + setUnmeasured + ' more not judged: their parent header did not re-encode to its own hash on this endpoint)' : '')));
// 2026-10-01: the tail of this line said, on every chain, that a header between anchors carries only its proposer's seal. That is true only
// where AIP-22 stage 5 is active (testnet 28001 from block 3,131,000); on chain 2800 headers between anchors still carry the validators'
// classical ECDSA commit seals, and this tool printed the stage-5 sentence there on every run. It now says what it measured above.
if (newestOk !== null) console.log('POST-QUANTUM CERTIFIED: block ' + (newestOk - 1) + ', the parent of anchor ' + newestOk + ', the most recent anchor whose certificate verified here:'
+ ' the validators whose seals it carries sealed that block post-quantum. The seals carry no consensus round, so this does not by itself prove'
+ ' that the block was decided (AIP-22 erratum, 2026-10-01). Between anchors no block carries a post-quantum certificate; '
+ (propChecked === 0
? 'a header between anchors carries the validators\' classical ECDSA commit seals (AIP-22 stage 5 is not active at these heights on this chain).'
: 'a header between anchors carries only its proposer\'s seal.'));
if (!slhDsa) console.log('note: @noble/post-quantum is not installed, so a v2 certificate carrying SLH-DSA seals cannot be verified here (npm install @noble/post-quantum).');
// ANCHOR_REPORT=<file> (2026-09-27): the most recent anchor that verified here, as JSON, for a tool that builds on it (the AIP-23 proof
// verifier). parentHash is the hash the certificate's seals were verified over, so such a tool can bind the parent header to it by
// re-encoding the header, without a second copy of the certificate check. Written whatever the verdict; a caller also needs exit code 0.
if (process.env.ANCHOR_REPORT) {
try {
fs.writeFileSync(process.env.ANCHOR_REPORT, JSON.stringify({ chainId: CHAIN_ID, anchor: newestOk, parentNumber: newestOk === null ? null : newestOk - 1,
parentHash: newestParentHash }) + '\n');
} catch (e) { console.error('note: ANCHOR_REPORT could not be written: ' + e.message); }
}
// three states, three exit codes: 0 verified, 1 the chain failed a check, 2 this machine could not measure.
// exitCode, not process.exit(): on Windows, exiting while the HTTP sockets are still closing aborts node inside libuv
// (0xC0000409), and the verdict printed above would reach the caller with a code that is neither 0, 1 nor 2.
process.exitCode = all ? 0 : (bad === 0 && manifestBad === 0 && propBad === 0 ? 2 : 1);