#!/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= 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; }; 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; 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)); // 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; newestParentHash = String(parent.hash).toLowerCase(); } 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)' : ''))); // 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('FINALIZED (post-quantum): block ' + (newestOk - 1) + ', the parent of anchor ' + newestOk + ', the most recent anchor whose certificate verified here. Nothing between anchors is post-quantum final; ' + (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= (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);