707 lines
57 KiB
JavaScript
707 lines
57 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 address bound to each sealing key was in the QBFT validator set at
|
|
// that height (qbft_getValidatorsByBlockNumber), so the seals are the validators'.
|
|
// 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]];
|
|
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';
|
|
// 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;
|
|
};
|
|
|
|
const validatorsCache = new Map();
|
|
async function validatorsAt(h) {
|
|
// the identity switch changes the form of the set; one cache window must never hold both forms
|
|
const key = Math.floor(h / 1000) + (h >= IDENTITY_BLOCK ? ':id' : ':addr');
|
|
if (!validatorsCache.has(key)) {
|
|
const v = await rpc('qbft_getValidatorsByBlockNumber', ['0x' + h.toString(16)]);
|
|
validatorsCache.set(key, new Set((v || []).map(a => a.toLowerCase())));
|
|
}
|
|
return validatorsCache.get(key);
|
|
}
|
|
|
|
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;
|
|
// 2026-09-26: the most recent anchor whose certificate VERIFIED here (anchors are walked newest first). Its PARENT is the
|
|
// post-quantum finality point of the chain (AIP-15/22: the certificate signs the parent's commit message); printed in the
|
|
// summary so a stranger gets the answer to "which block is final" from this tool, not from our word. Nodes do not serve the
|
|
// finalized/safe tags on this chain yet (measured 2026-09-26: eth_getBlockByNumber("finalized") answers "Unknown block").
|
|
let newestOk = null;
|
|
// 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));
|
|
// 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);
|
|
let valid = 0, invalid = [], notValidator = [], unknownIndex = [], unknownScheme = [], unbound = [], noKey = [];
|
|
const perScheme = {}; // scheme name -> valid seals
|
|
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; }
|
|
if (!vset.has(v3k ? row.id : memberOf(row.addr.toLowerCase(), h))) { notValidator.push(idx); continue; }
|
|
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;
|
|
const summary = Object.entries(perScheme).map(([nm, c]) => c + ' ' + nm).join(' + ');
|
|
if (good) { ok++; if (newestOk === null) newestOk = h; console.log(h + ' OK: bound under block hash' + (isV2 ? ' (v2, scheme-tagged)' : '') + ', ' + n + ' seals (' + summary + '), all valid and from validators of this height (' + (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(',') : '') + (unknownIndex.length ? ' unknown index ' + unknownIndex.join(',') : '') +
|
|
(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 && 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 the validator of that height 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'
|
|
: '')
|
|
+ (propUnmeasured ? (unmeasured ? '\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)' : '')));
|
|
if (newestOk !== null) console.log('FINALIZED (post-quantum): block ' + (newestOk - 1) + ', the parent of anchor ' + newestOk + ', the most recent anchor whose certificate verified here. Nothing between anchors is final; 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).');
|
|
// 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);
|