1120 lines
99 KiB
JavaScript
1120 lines
99 KiB
JavaScript
#!/usr/bin/env node
|
|
// verify-anchor.mjs (v2) - independently verify Aere Network's post-quantum header anchor,
|
|
// INCLUDING the Falcon-512 signatures, from a public RPC endpoint and the published key manifest.
|
|
//
|
|
// v1 of this tool proved the BINDING: every 32nd header carries a certificate of Falcon-512 seals
|
|
// and a 32-byte digest in the hashed part of the header, and the digest equals
|
|
// keccak256(RLP["AERE-PQ-ANCHOR-1", chainId, parentNumber, parentHash, certificate]). It did NOT
|
|
// check that the seals are valid signatures; for that you had to ask the chain's own precompile,
|
|
// i.e. ask us. v2 removes that trust point. What v2 proves, in order, each leg independent:
|
|
//
|
|
// 1. BINDING as v1: strip or alter the certificate and the digest no longer matches.
|
|
// 2. KEY MANIFEST the published registry of Falcon public keys is self-consistent: every row's
|
|
// proof of possession (a Falcon signature over the row) verifies with the row's
|
|
// key, and every row's claim (an ECDSA signature by the validator's own key, EIP-191
|
|
// version 0) recovers to the row's validator address. So the manifest cannot have
|
|
// been invented by us: each row is signed by the validator's consensus key.
|
|
// 3. SEALS every Falcon-512 seal in every certificate verifies, in plain JavaScript written
|
|
// from the spec (falcon512.mjs), against the manifest key of its index, over
|
|
// keccak256(RLP["AERE-PQ-COMMIT-1", chainId, parentNumber, parentHash]).
|
|
// 4. VALIDATORS the validator bound to each sealing key was in the QBFT validator set of the PARENT block, the block the
|
|
// seals sign, read from that block's own header (bound to its hash), and each counts once per scheme, so the
|
|
// seals are those of distinct validators of that block (D-477, 2026-10-01: until then the set was asked of
|
|
// the endpoint at the anchor's height and cached per window of 1000 blocks, and duplicates counted).
|
|
// 5. ON-CHAIN PIN each manifest's hash, keccak256(addr||pk over rows), equals slot 0 of the immutable
|
|
// anchor contract named in the index, and that contract's code is the 11-byte
|
|
// read-only getter with no SSTORE: the manifest you hold is the one the chain pinned.
|
|
// 6. PROPOSER (AIP-22 stage 5, 2026-09-24) on a chain where headers carry element 6, the proposer's
|
|
// Falcon-512 seal over keccak256(RLP["AERE-PQ-PROPOSAL-1", chainId, height, round, digest])
|
|
// verifies against the v3 registry key of its index, and that index is the header's
|
|
// coinbase, or, in a block re-proposed in a round r > 0, the proposer QBFT expects for
|
|
// round r (2026-09-25, D-406), on the most recent headers (PROPOSER_HEADERS, default 20);
|
|
// and, as the nodes judge every header from H_hdr, the round is a canonical integer
|
|
// at most 1024 and the coinbase is the proposer of some round 0..r at that height.
|
|
// Where no header carries it (chain 2800 today) the tool says so and this leg does not
|
|
// change the verdict.
|
|
// 7. ROUND (1(b), D-470, learned 2026-10-07, active on no chain yet) from a chain's round height H_r the certificate is
|
|
// v3, RLP[3, round, [[scheme, idx, sig], ...]] under "AERE-PQ-ANCHOR-3", and every seal in it is over
|
|
// keccak256(RLP["AERE-PQ-COMMIT-2", chainId, parentNumber, round, parentHash]) for the ONE round it names, so a
|
|
// seal made in another round, or over another block, does not verify. The tool dispatches on the certificate's
|
|
// first element (a list: v1; the scalar 2: v2; the scalar 3: v3), admits only v3 at or above H_r and refuses
|
|
// v3 below it, and names every refusal with the code both clients name (AERE-PQ-V3-01..08). It reports how many
|
|
// distinct validators of the parent's set sealed in that round, against that set's QBFT quorum: at or above the
|
|
// quorum the line says "PARENT DECIDED IN ROUND r (m members of n, quorum Q)", which is what it measured (a QBFT
|
|
// quorum of commit seals for one round IS the decision), below it "CERTIFIED (participation)". It claims nothing
|
|
// further: "finality" is the chain's rule, not a measurement of this tool.
|
|
//
|
|
// 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';
|
|
import { accountAt, slotAt } from './mpt.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';
|
|
// v3 (1(b), D-470, PROIECT-1B 1.1/1.3/1.4, learned 2026-10-07): from a chain's round height H_r the commit seal is ROUND-BOUND,
|
|
// keccak256(RLP["AERE-PQ-COMMIT-2", chainId, n, r, hash(n)]), and the anchor certificate is RLP[3, r, [[scheme, idx, sig], ...]] with
|
|
// the round written once, under its own domain "AERE-PQ-ANCHOR-3". One certificate, one round: every seal in it is over M_r of the
|
|
// PARENT for that r. The tool dispatches on the first element of the certificate: a LIST is v1, the scalar 2 is v2, the scalar 3 is
|
|
// v3; at an anchor at or above H_r only v3 is admitted, below H_r a v3 is refused, and every refusal of a v3 anchor names the code
|
|
// both clients name (AERE-PQ-V3-01..08, in the fixed order of PROIECT-1B 1.4), so a stranger's verdict can be compared with theirs.
|
|
// 2026-10-09: three margins of the v3 rule, each with its note where it applies: R2 in two passes (every Falcon row, then every extra
|
|
// row), a row index above 2^31-1 refused 03 at the decoding, and an element 6 that is present but is not a list (03 from H_r).
|
|
// They follow the fixed order of the codes named above (R1: the form by height 01 / 02, then the decoding 03, then the digest 04; R2: the
|
|
// Falcon rows, then the extra rows). This tool runs neither client: how a client binary answers these margins is not measured here.
|
|
const COMMIT_DOMAIN_2 = 'AERE-PQ-COMMIT-2';
|
|
const ANCHOR_DOMAIN_V3 = 'AERE-PQ-ANCHOR-3';
|
|
const MAX_ROUND = 1024; // PqAnchorV3.MAX_ROUND in both clients; no QBFT round timer gets there
|
|
const MAX_V3_ROWS = 64;
|
|
// H_r per chain: UNSET on both chains at the time of writing (no v3 anchor exists on any live chain). When the fleet publishes H_r
|
|
// for a chain it is written here, in the same hour, before H_r (D-372: the new verifier is published BEFORE the first v3 anchor).
|
|
// COMMIT_ROUND_BLOCK overrides (a height, or "none"); cloud-gateway/orar-ancore.mjs carries the same value for the consumers.
|
|
const COMMIT_ROUND_DEFAULT = CHAIN_ID === 28001 ? 6157024 : null;
|
|
const COMMIT_ROUND_BLOCK = process.env.COMMIT_ROUND_BLOCK === undefined ? COMMIT_ROUND_DEFAULT
|
|
: (/^(none|unset|)$/i.test(process.env.COMMIT_ROUND_BLOCK) ? null : Number(process.env.COMMIT_ROUND_BLOCK));
|
|
if (COMMIT_ROUND_BLOCK !== null && !(Number.isInteger(COMMIT_ROUND_BLOCK) && COMMIT_ROUND_BLOCK > 0)) { console.error('FAIL: COMMIT_ROUND_BLOCK must be a positive block height or "none"'); stop(1); }
|
|
// the form the certificate of anchor a must have (PROIECT-1B 2.1, certificateFormAt): v3 from H_r; below it the bytes decide (v1 or v2)
|
|
const certificateFormAt = (a) => (COMMIT_ROUND_BLOCK !== null && a >= COMMIT_ROUND_BLOCK ? 3 : null);
|
|
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.
|
|
// 2800, corrected 2026-10-01 (D-478): the schedule is the fleet's own, transcribed from the published follower configuration
|
|
// (RUN-A-NODE.md and SPEC.md: -Daere.pq.anchorMinSeals=13014000:0,13034000:3,14961456:6,17102384:0,17102416:6,20715632:7). Until this
|
|
// date the tool asked for 3 from the first anchor, so the warm-up anchors 13,014,000 .. 13,033,999, where the fleet required no seal
|
|
// (K=0; some carry two), came out FAIL: a false accusation of the same family as the two dated windows below.
|
|
const K_DEFAULT = CHAIN_ID === 28001 ? [[64, 0], [128, 3], [168032, 0], [168064, 3], [2988864, 4]] : [[13014000, 0], [13034000, 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);
|
|
// D-478 (2026-10-01): the two DATED historical windows of chain 2800 in which the anchor rules were not (fully) in force, as the
|
|
// production client names them. TRANSCRIBED, not deduced, from the Besu-derived client's PqAnchorLapse (published in the node package:
|
|
// anchor/consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/PqAnchorLapse.java, the WINDOWS list at lines
|
|
// 235-259, CHAIN_ID at :82, the spacing at :85, isDisarmed at :333, historicMinSeals at :357; the index rules it relaxes are
|
|
// PqAnchor.java hasAcceptableIndices :528, hasStrictlyIncreasingIndices :439, hasDistinctNonNegativeIndices :484):
|
|
// 1. [13,267,824, 13,268,944], 36 anchor heights, NOTHING in force (2026-08-10: the rules were switched off on every validator to
|
|
// restart a stopped chain; vanityData carries the client's version string, the indices are in arrival order). The nodes accept
|
|
// such a header as written, after one check that is not relaxed at any height: the certificate decodes and its indices are
|
|
// non-negative and DISTINCT. This tool says so: NOT ENFORCED, neither verified nor failed.
|
|
// 2. [13,268,976, 13,890,544], 19,425 anchor heights, the THRESHOLD ONLY lowered to 1 (the emergency ceiling then in force); the
|
|
// digest binding and the strictly increasing indices were in force and are required here exactly as elsewhere.
|
|
// Bound to chain 2800 (PqAnchorLapse.CHAIN_ID): another chain, a testnet included, inherits neither window.
|
|
const LAPSE_CHAIN = 2800;
|
|
const LAPSE_WINDOWS = [
|
|
{ first: 13267824, last: 13268944, anchors: 36, relax: 'everything', minSeals: 0, date: '2026-08-10' },
|
|
{ first: 13268976, last: 13890544, anchors: 19425, relax: 'threshold-only', minSeals: 1, date: '2026-08-10' },
|
|
];
|
|
// the same self-check the production class runs at load: ordered and disjoint, the span agrees with the bounds at spacing 32,
|
|
// no threshold where nothing was in force, at least 1 where only the threshold was lowered
|
|
for (let i = 0; i < LAPSE_WINDOWS.length; i++) {
|
|
const w = LAPSE_WINDOWS[i], prev = LAPSE_WINDOWS[i - 1];
|
|
if ((prev && w.first <= prev.last) || (w.last - w.first) / 32 + 1 !== w.anchors || (w.relax === 'everything') !== (w.minSeals === 0) || (w.relax === 'threshold-only' && w.minSeals < 1)) {
|
|
console.error('FAIL: the dated lapse table of this tool contradicts itself at [' + w.first + ', ' + w.last + ']'); stop(1);
|
|
}
|
|
}
|
|
const lapseAt = (hh) => (CHAIN_ID === LAPSE_CHAIN ? LAPSE_WINDOWS.find(w => hh >= w.first && hh <= w.last) || null : null);
|
|
const lapseOff = (hh) => { const w = lapseAt(hh); return !!w && w.relax === 'everything'; };
|
|
// for SPACING the grid only: the window's bounds are anchor heights, and the blocks between them and the neighbouring unaffected anchors
|
|
// (up to 31 on each side, the spacing in force: PqAnchorLapse.ANCHOR_SPACING) also carry a certificate element (measured 2026-10-01:
|
|
// 13,268,959 and 13,268,960 both do), so the lookback steps over [first - 31, last + 31] as well; no anchor is judged differently by this
|
|
const lapseOffForGrid = (hh) => CHAIN_ID === LAPSE_CHAIN && LAPSE_WINDOWS.some(w => w.relax === 'everything' && hh >= w.first - 31 && hh <= w.last + 31);
|
|
// the threshold an anchor is judged by: the schedule, or, inside a threshold-only window, the lower threshold then in force
|
|
// (the lower of the two, as the production rule takes it)
|
|
const minSealsAt = (hh) => {
|
|
const k = Number(process.env.MIN_SEALS || K_SCHEDULE.filter(([h]) => hh >= h).map(([, k]) => k).pop());
|
|
const w = lapseAt(hh);
|
|
return w && w.relax === 'threshold-only' && w.minSeals < k ? w.minSeals : k;
|
|
};
|
|
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]));
|
|
// M_r(b) = keccak256(RLP["AERE-PQ-COMMIT-2", chainId, n, r, hash(b)]) (PROIECT-1B 1.1; the round as a minimal scalar, 0 = the empty string)
|
|
const commitMessageRound = (parentNumber, round, parentHash) =>
|
|
keccak(rlpEncode([Buffer.from(COMMIT_DOMAIN_2, 'ascii'), scalar(CHAIN_ID), scalar(parentNumber), scalar(round), parentHash]));
|
|
// a scalar read from its RLP bytes as an unsigned integer (the canonical-form check is the round trip of the whole certificate below)
|
|
const scalarOf = (b) => (b.length ? BigInt('0x' + Buffer.from(b).toString('hex')) : 0n);
|
|
// QBFT quorum of a set of n validators, as both clients compute it (BftHelpers.calculateRequiredValidatorQuorum: ceil(2n/3))
|
|
const quorumOf = (n) => Math.floor((2 * n + 2) / 3);
|
|
// 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).
|
|
// 2026-10-02 (AIP-24 C4 (b)): the slot is no longer read at 'latest' from the endpoint (eth_getStorageAt / eth_getCode, which an
|
|
// endpoint can answer as it likes). It is PROVEN below, after the anchors: eth_getProof of the anchor contract under the state root
|
|
// of the parent of the newest VERIFIED anchor, the header the certificate's seals signed, so a lying endpoint cannot hand this tool
|
|
// a pin of its choosing. Here the manifest is judged on what does not need the chain: sha256, rows, chain, format; `pin` is filled
|
|
// in below (true / false / null when it could not be measured).
|
|
let pinOk = null, pinNote = 'no anchorContract in index', want = null;
|
|
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)]));
|
|
want = '0x' + keccak(pre).toString('hex');
|
|
pinNote = 'slot 0 of ' + m.anchorContract + ' judged below, under the state of the certified parent of the newest verified anchor';
|
|
}
|
|
const good = shaOk && 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, file: m.file, anchorContract: m.anchorContract || null, want, pin: pinOk });
|
|
}
|
|
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
|
|
// D-478: inside the dated lapse where nothing was in force (chain 2800 only) headers carry a certificate element on blocks off
|
|
// the grid too, so they cannot space it; those heights are stepped over and do not count toward the lookback
|
|
// and two headers on the two sides of such a range do not measure the spacing (their distance spans the range), so a header found
|
|
// above it is dropped when the lookback crosses it, and the spacing is taken from two consecutive headers on one side
|
|
let scanned = 0;
|
|
for (let h = top; scanned < LOOKBACK && h > ANCHOR_FROM && seen.length < 2; h--) {
|
|
if (lapseOffForGrid(h)) { if (seen.length === 1) seen.length = 0; continue; }
|
|
scanned++;
|
|
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;
|
|
// D-478: inside the dated lapse where nothing was in force the vanity IS the client string, as the production client accepts it
|
|
if (lapseOff(anchor)) console.log('anchor ' + anchor + ' falls in a dated lapse of chain 2800 (' + lapseAt(anchor).date + ', ' + lapseAt(anchor).first + '..' + lapseAt(anchor).last + '): the anchor rules were not in force there');
|
|
else if (vanity.length !== 32 || Buffer.from(vanity).includes(Buffer.from('besu'))) { console.error('FAIL: block ' + anchor + ' does not look like an anchor (vanity is the client string, not a digest)'); stop(1); }
|
|
}
|
|
|
|
// AIP-22 stage 3 (2026-09-23): from the identity height a chain's validator set holds post-quantum ids, not ECDSA addresses.
|
|
// The v3 registry epochs are read from the SAME manifest index (entries with formatVersion 3), so a stranger needs no variable:
|
|
// each epoch's sha256 must match the index, and each is verified in full by registry-v3.mjs (the independent JavaScript
|
|
// implementation of the registry: every proof of possession, Falcon-512 from the spec and SLH-DSA via @noble, every ecdsa link,
|
|
// the rotation chain from the epoch before, and the Merkle registry hash). In this first stage the keys that SIGN seals are still
|
|
// the ones of the key manifests above, so every v3 row must carry exactly the Falcon key (0x09 || the 896 bytes), the SLH-DSA key
|
|
// and the address of the same index in the key manifest in force at its bindHeight: otherwise its id would be a label on a key
|
|
// that does not sign, and this tool refuses. The identity height is the first v3 epoch's bindHeight. IDENTITY_BLOCK and
|
|
// REGISTRY_V3 (a v3 epoch file or URL, verified the same way) override the index, for an epoch not yet published.
|
|
// IDENTITY_BLOCK=none ignores the v3 epochs altogether (the set is read as ECDSA addresses at every height): a negative
|
|
// control, under which every anchor sealed after the identity height must fail with "keys not in validator set".
|
|
const IDENTITY_OFF = process.env.IDENTITY_BLOCK === 'none';
|
|
let IDENTITY_BLOCK = process.env.IDENTITY_BLOCK && !IDENTITY_OFF ? Number(process.env.IDENTITY_BLOCK) : Infinity;
|
|
const V3_EPOCHS = []; // [{ bindHeight, link: Map(ecdsa addr -> id) }], ascending
|
|
if (IDENTITY_OFF) console.log('identity: IDENTITY_BLOCK=none, v3 epochs ignored (negative control)');
|
|
if (!IDENTITY_OFF) {
|
|
const sources = process.env.REGISTRY_V3
|
|
? [{ loc: process.env.REGISTRY_V3, sha: null, file: process.env.REGISTRY_V3 }]
|
|
: [...v3Entries].sort((a, b) => Number(a.bindHeight) - Number(b.bindHeight)).map(m => ({ loc: join(MANIFESTS, m.file), sha: String(m.sha256).toLowerCase(), file: m.file, keysFrom: m.keysFrom === true || m.keysFrom === 'true' }));
|
|
if (sources.length) {
|
|
let reg;
|
|
try { reg = await import('./registry-v3.mjs'); }
|
|
catch (e) { console.error('FAIL: this chain publishes v3 registry epochs, and registry-v3.mjs (published beside this tool) cannot be loaded: ' + e.message); stop(1); }
|
|
let previous = null, link = new Map();
|
|
for (const s of sources) {
|
|
const text = await loadText(s.loc);
|
|
if (s.sha && Buffer.from(sha256(Buffer.from(text, 'utf8'))).toString('hex') !== s.sha) { console.error('FAIL: v3 epoch ' + s.file + ': sha256 does not match the index'); stop(1); }
|
|
const json = JSON.parse(text);
|
|
let r;
|
|
try { r = reg.load(json, CHAIN_ID, schemesAt(json.bindHeight), previous, previous === null); }
|
|
catch (e) { console.error('FAIL: v3 epoch ' + s.file + ' does not verify: ' + e.message); stop(1); }
|
|
const km = manifestAt(r.bindHeight);
|
|
// from H_k (the first epoch the index marks keysFrom) a later epoch may rotate keys under the old keys' signatures, which
|
|
// registry-v3.mjs has verified; the equality with the key manifest is required up to and including the H_k epoch
|
|
const rotating = V3_EPOCHS.some(x => x.keysFrom) || (process.env.KEYS_FROM && r.bindHeight > Number(process.env.KEYS_FROM));
|
|
if (!km || km.count !== r.count) { console.error('FAIL: v3 epoch bound at ' + r.bindHeight + ' has ' + r.count + ' rows; the key manifest in force there has ' + (km ? km.count : 'none')); stop(1); }
|
|
for (const row of (rotating ? [] : r.rows)) {
|
|
const k = km.rows[row.index];
|
|
const f = row.keys.find(x => x.name === 'falcon-512'), sl = row.keys.find(x => x.name === 'slh-dsa-sha2-128s');
|
|
const falconSame = f && Buffer.concat([Buffer.from([0x09]), Buffer.from(k.pk)]).equals(Buffer.from(f.pk));
|
|
const slhSame = !k.keys['slh-dsa-sha2-128s'] ? !sl : (sl && Buffer.from(k.keys['slh-dsa-sha2-128s']).equals(Buffer.from(sl.pk)));
|
|
const addrSame = !row.ecdsa || ('0x' + row.ecdsa.addr.toString('hex')) === k.addr.toLowerCase();
|
|
if (!(falconSame && slhSame && addrSame)) { console.error('FAIL: v3 epoch bound at ' + r.bindHeight + ', row ' + row.index + ': ' + (falconSame ? '' : 'falcon key ') + (slhSame ? '' : 'slh-dsa key ') + (addrSame ? '' : 'ecdsa address ') + 'differ from the key manifest that signs there'); stop(1); }
|
|
}
|
|
// the ecdsa link is inherited by id (a later epoch may omit it), exactly as both clients resolve a message's
|
|
// author: an id that this epoch links again drops its older address, and an id no longer registered drops its link
|
|
link = new Map(link);
|
|
const idsNow = new Set(r.rows.map(row => '0x' + row.id.toString('hex')));
|
|
for (const row of r.rows) if (row.ecdsa) { const idh = '0x' + row.id.toString('hex'); for (const [a, i] of link) if (i === idh) link.delete(a); }
|
|
for (const row of r.rows) if (row.ecdsa) link.set('0x' + row.ecdsa.addr.toString('hex'), '0x' + row.id.toString('hex'));
|
|
for (const [a, i] of link) if (!idsNow.has(i)) link.delete(a);
|
|
// AIP-22 stage 3b: the key set of this epoch by index (Falcon-512 without its 0x09 header, the other schemes by name) and the
|
|
// index's id; used for seals from KEYS_FROM on, exactly as both clients read keys from the v3 epoch in force at the parent
|
|
const byIndex = r.rows.map(row => {
|
|
const keys = {};
|
|
for (const k of row.keys) if (k.name !== 'falcon-512') keys[k.name] = new Uint8Array(k.pk);
|
|
const f = row.keys.find(k => k.name === 'falcon-512');
|
|
return { id: '0x' + row.id.toString('hex'), pk: f ? new Uint8Array(f.pk.subarray(1)) : null, keys };
|
|
});
|
|
V3_EPOCHS.push({ bindHeight: r.bindHeight, link, byIndex, keysFrom: s.keysFrom === true });
|
|
console.log(' v3 epoch ' + String(r.bindHeight).padEnd(10) + ' ' + String(s.file).padEnd(26) + ' rows ' + r.count + '/' + r.count +
|
|
' registry hash 0x' + r.hash.toString('hex').slice(0, 16) + '.. every possession proof, ecdsa link and the Merkle hash verified; keys = the key manifest of that height');
|
|
previous = r;
|
|
}
|
|
if (!Number.isFinite(IDENTITY_BLOCK)) IDENTITY_BLOCK = V3_EPOCHS[0].bindHeight;
|
|
console.log('identity: from block ' + IDENTITY_BLOCK + ' the validator set holds post-quantum ids (' + V3_EPOCHS.length + ' v3 epoch(s))');
|
|
}
|
|
}
|
|
// the height from which the keys that seal come from the v3 epochs (AIP-22 stage 3b): KEYS_FROM, or the epoch the index marks
|
|
const KEYS_FROM = process.env.KEYS_FROM ? Number(process.env.KEYS_FROM) : (V3_EPOCHS.find(e => e.keysFrom)?.bindHeight ?? Infinity);
|
|
if (Number.isFinite(KEYS_FROM)) console.log('keys: from block ' + KEYS_FROM + ' the keys that seal come from the v3 epoch in force (AIP-22 stage 3b)');
|
|
const v3EpochAt = (h) => [...V3_EPOCHS].reverse().find(x => x.bindHeight <= h) || null;
|
|
const memberOf = (addr, h) => {
|
|
if (h < IDENTITY_BLOCK || !V3_EPOCHS.length) return addr;
|
|
const e = [...V3_EPOCHS].reverse().find(x => x.bindHeight <= h) || V3_EPOCHS[0];
|
|
return e.link.get(addr) ?? addr;
|
|
};
|
|
|
|
// D-477 (2026-10-01): a certificate's seals sign M(p) for the PARENT p = h - 1, so they are judged against the validator set of
|
|
// p, as both clients judge them (the validators of the parent block). That set is read here from element 1 of p's OWN header,
|
|
// which the caller first re-encodes in its on-chain form (SPEC 2.3) to the hash the seals sign: an endpoint cannot hand this tool
|
|
// a set of its choosing. Until this date the set was asked of the endpoint at the ANCHOR's height and cached per window of 1000
|
|
// blocks, so across a set vote up to 1000 blocks were judged on the wrong set: the seal of a member removed later was refused (the
|
|
// tool accused a sound chain, the family of D-372) and the seal of a member added later was counted. There is no cache: the set is
|
|
// read for each parent. Before the identity height the list holds ECDSA addresses, from it post-quantum ids (AIP-22 stage 3),
|
|
// matched below through the same translation as before.
|
|
async function validatorsAt(p, pItems) {
|
|
const el = pItems && pItems[1];
|
|
if (!el || !el.list || el.item.length === 0 || el.item.some(x => x.list || x.item.length !== 20)) return null;
|
|
return new Set(el.item.map(x => '0x' + Buffer.from(x.item).toString('hex')));
|
|
}
|
|
|
|
let ok = 0, bad = 0, sealsTotal = 0, sealsValid = 0;
|
|
// 2026-09-06: 'nu am putut verifica AICI' nu e 'ancora e invalida', si a fost raportat asa. Fara
|
|
// @noble/post-quantum instalat, o ancora v2 sanatoasa (9 Falcon + 9 SLH-DSA) iesea in lista drept FAIL si
|
|
// rezumatul spunea 'PROBLEMS: 20 of 20 anchors failed' - despre un lant perfect sanatos, catre un strain care
|
|
// tocmai urmase instructiunea noastra de pe site. Un NEMASURAT nemeritat e la fel de mincinos ca un verde
|
|
// nemeritat: se numara separat si iese cu alt cod.
|
|
let unmeasured = 0;
|
|
// D-477: anchors whose parent header could not be read or did not re-encode to its own hash, so the set it lists was not read
|
|
let setUnmeasured = 0;
|
|
// D-478: anchors in the dated lapse where nothing was in force: judged only by what the nodes still require there, never VERIFIED
|
|
let lapseNotEnforced = 0;
|
|
// 2026-09-26: the most recent anchor whose certificate VERIFIED here (anchors are walked newest first). Its PARENT is the
|
|
// block the certificate's seals signed (AIP-15/22: the certificate signs the parent's commit message); printed in the summary
|
|
// so a stranger reads from this tool, not from our word, which block the newest verified certificate covers. Nodes did not serve
|
|
// the finalized/safe tags on this chain then (measured 2026-09-26: eth_getBlockByNumber("finalized") answered "Unknown block").
|
|
// 2026-10-01 (AIP-22 erratum of that date): this comment and the summary line called that parent the chain's "post-quantum
|
|
// finality point" and printed it on a FINALIZED line. The commit seal carries no round, so a certificate shows that its
|
|
// signers sealed that block post-quantum, in some round, not that the block was decided; the line now says what was verified.
|
|
// The ANCHOR_REPORT keys below are unchanged (the AIP-23 verifier reads them).
|
|
let newestOk = null;
|
|
let newestParentHash = null; // the hash of block newestOk - 1 the verified seals signed (M(parent)), for ANCHOR_REPORT
|
|
let newestParent = null; // that parent's header as served, re-encoded above to the certified hash: its stateRoot is bound to the seals
|
|
let newestForm = null; // the form of that certificate: { version: 1|2|3, round (v3 only), members, setSize, quorum }, named in the last line
|
|
// 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);
|
|
// D-478, the lapse where nothing was in force (chain 2800, 2026-08-10): the nodes accept the header as written after ONE check that
|
|
// is not relaxed at any height, that the certificate (possibly absent: empty) decodes as [index, signature] pairs with non-negative,
|
|
// pairwise DISTINCT indices (PqAnchorSealsRule.acceptAsWrittenInsideLapse, PqAnchor.hasDistinctNonNegativeIndices). Nothing else
|
|
// is judged, so nothing is verified: the answer is NOT ENFORCED, counted apart from VERIFIED and from FAIL.
|
|
if (lapseOff(h)) {
|
|
const w = lapseAt(h);
|
|
let pairs = null;
|
|
try {
|
|
const c = items.length >= 6 ? items[5] : { list: true, item: [] };
|
|
if (c.list && c.item.every(s => s.list && s.item.length === 2 && !s.item[0].list && !s.item[1].list && s.item[0].item.length <= 4)) {
|
|
pairs = c.item.map(s => (s.item[0].item.length ? parseInt(Buffer.from(s.item[0].item).toString('hex'), 16) : 0));
|
|
}
|
|
} catch (e) { pairs = null; }
|
|
if (pairs === null) { bad++; console.log(h + ' FAIL: inside the dated lapse of ' + w.date + ' the certificate must still decode as [index, signature] pairs, and it does not'); continue; }
|
|
if (new Set(pairs).size !== pairs.length) { bad++; console.log(h + ' FAIL: inside the dated lapse of ' + w.date + ' the certificate repeats a validator index (' + pairs.join(',') + '); distinct indices are required at every height'); continue; }
|
|
lapseNotEnforced++;
|
|
console.log(h + ' NOT ENFORCED (dated lapse ' + w.date + '): the anchor rules were not in force at ' + w.first + '..' + w.last + ' on chain 2800, so this header'
|
|
+ ' is accepted as written; checked only that its ' + pairs.length + ' seal indices are distinct (' + (pairs.join(',') || 'no seals') + '). Not verified.');
|
|
continue;
|
|
}
|
|
// 2026-10-09: an element 6 that is PRESENT but is not an RLP list (a byte string, or the empty string 0x80) is not "no certificate":
|
|
// it is a certificate slot that does not decode, so from H_r this tool refuses it with 03 (the decoding), not 02. Until this date it
|
|
// was named 02 ("no certificate element"). Below H_r this tool names no v3 code for it: it stays a FAIL without a code, named for what
|
|
// it is. Which code, if any, a client binary names for it is not measured here.
|
|
if (items.length >= 6 && !items[5].list) {
|
|
if (certificateFormAt(h) === 3) console.log(h + ' FAIL: AERE-PQ-V3-03 (certificate v3): element 6 of extraData is present but is not an RLP list (' + items[5].item.length + ' byte string), so it does not decode as a certificate');
|
|
else console.log(h + ' FAIL: element 6 of extraData (the certificate) is present but is not an RLP list (' + items[5].item.length + ' byte string)');
|
|
bad++; continue;
|
|
}
|
|
if (items.length < 6 || items[5].item.length === 0) {
|
|
// 1(b): at or above H_r the absence of a certificate is the named refusal 02 (PROIECT-1B 1.4: "at a >= H_r the anchor carries no v3")
|
|
if (certificateFormAt(h) === 3) console.log(h + ' FAIL: AERE-PQ-V3-02 (certificate v3): no certificate element; from H_r=' + COMMIT_ROUND_BLOCK + ' every anchor must carry a v3 certificate');
|
|
else console.log(h + ' FAIL: no certificate element');
|
|
bad++; continue;
|
|
}
|
|
const cert = items[5];
|
|
const toNested = (node) => (node.list ? node.item.map(toNested) : Buffer.from(node.item));
|
|
// D-477: the parent's header is the source of the set the seals are judged against, so it must be readable, must re-encode
|
|
// (on-chain form: round 0, commit seals empty, certificate and proposer seal omitted) to the hash the endpoint reports for it,
|
|
// and that hash must be the parent this anchor names. The first two are this run's gap (another endpoint may serve it); the
|
|
// third is a header that is not this anchor's parent, so the anchor is not judged on it.
|
|
let pItems = null, pSelf = false;
|
|
if (parent && parent.extraData) {
|
|
try {
|
|
pItems = rlpRead(hex(parent.extraData), 0).item;
|
|
pSelf = keccak(headerRlp(parent, rlpEncode([toNested(pItems[0]), toNested(pItems[1]), toNested(pItems[2]), Buffer.alloc(0), []]))).equals(hex(parent.hash));
|
|
} catch (e) { pSelf = false; }
|
|
}
|
|
if (!pSelf) {
|
|
setUnmeasured++;
|
|
console.log(h + ' NOT VERIFIED HERE: the header served for block ' + (h - 1) + (parent ? ' does not re-encode to the hash the endpoint reports for it'
|
|
: ' could not be read') + ', so the validator set it lists (the set these seals are judged against) cannot be read with confidence.'
|
|
+ ' This is a gap in THIS run, not a finding about the chain; try another endpoint (RPC=...).');
|
|
continue;
|
|
}
|
|
if (!hex(parent.hash).equals(hex(b.parentHash))) { bad++; console.log(h + ' FAIL: the header served for block ' + (h - 1) + ' (hash ' + parent.hash + ') is not the parent this anchor names (' + b.parentHash + ')'); continue; }
|
|
const man = manifestAt(h);
|
|
const vset = await validatorsAt(h - 1, pItems);
|
|
// ---- the certificate's form, from its FIRST element: a list is v1, the scalar 2 is v2, the scalar 3 is v3 (1(b)) ----
|
|
// v3 is judged in the block below, with the codes of PROIECT-1B 1.4; v1 and v2 are judged further down exactly as before 1(b).
|
|
const versionScalar = !cert.item[0].list && cert.item[0].item.length === 1 ? cert.item[0].item[0] : null;
|
|
const version = cert.item[0].list ? 1 : versionScalar === 2 ? 2 : versionScalar === 3 ? 3 : 0;
|
|
const judgeV3 = certificateFormAt(h) === 3 || version === 3;
|
|
// An unreadable parent set: under v3 it is the named refusal 06 ("the set of the parent could not be resolved"), given in R2 in
|
|
// the order of PROIECT-1B 1.4 (after R1 and after the count 05, at the first Falcon row, as PqAnchorV3.judgeSeals does); for
|
|
// v1 / v2 it is the plain FAIL it always was. (2026-10-07, review of step 8: until then a v3 anchor failed here without a code.)
|
|
if (!vset && !judgeV3) { bad++; console.log(h + ' FAIL: the validator list in element 1 of the header of block ' + (h - 1) + ' (the block these seals sign) is empty or malformed, so no seal can be bound to a validator'); continue; }
|
|
if (judgeV3) {
|
|
// The bytes of element 6 as carried (element 6 starts where element 5 ends): D3 is over these bytes, and the canonical round trip
|
|
// below requires them to be exactly the re-encoding of what was read, so no second RLP form of the same certificate is admitted.
|
|
const ex = hex(b.extraData);
|
|
const rawCert = ex.subarray(items[4].next, items[5].next);
|
|
const v3 = { code: null, why: null, round: null, rows: [] };
|
|
const refuse = (code, why) => { v3.code = code; v3.why = why; return false; };
|
|
// R1: the form by height (01: a v3 below H_r; 02: a v1 / v2 at or above H_r), then the decoding (03), then the digest (04). The form
|
|
// by height comes first: a certificate whose first element is the version scalar 3 at an anchor below H_r is 01 whatever the rest of
|
|
// its bytes, malformed or not.
|
|
// 2026-10-10: this is the order the specification fixes (the first failure returns its code; the form by height before the
|
|
// decoding), and both clients now give 01 here too, measured on the shared test vectors ([3, rows] and [3, [], rows] below H_r). For
|
|
// a few days an unpublished working copy of this tool gave the decoding first (03); every published copy kept this order. Not the same
|
|
// as the clients: this tool reads RLP leniently, so a version scalar written in a non-canonical form (0x8103 for 3) counts as 3 here,
|
|
// while both clients' strict readers refuse that framing first and give no code. A header the chain carries never has that form.
|
|
const parseV3 = () => {
|
|
if (certificateFormAt(h) !== 3) return refuse('AERE-PQ-V3-01', 'a v3 certificate at anchor ' + h + ', below the round height H_r'
|
|
+ (COMMIT_ROUND_BLOCK === null ? ' (H_r is not set for chain ' + CHAIN_ID + ' in this tool, so no v3 anchor is expected on it; if the fleet has published H_r, pass COMMIT_ROUND_BLOCK=<H_r>)' : '=' + COMMIT_ROUND_BLOCK));
|
|
if (version === 1 || version === 2) return refuse('AERE-PQ-V3-02', 'from H_r=' + COMMIT_ROUND_BLOCK + ' the certificate must be v3 (RLP[3, round, rows]); this one is ' + (version === 1 ? 'v1 (it opens with a list)' : 'v2 (it opens with the scalar 2)'));
|
|
if (version !== 3) return refuse('AERE-PQ-V3-03', 'the certificate opens with an unreadable version scalar (0x' + Buffer.from(cert.item[0].item).toString('hex') + ')');
|
|
if (cert.item.length !== 3) return refuse('AERE-PQ-V3-03', 'a v3 certificate has three elements [3, round, rows]; this one has ' + cert.item.length);
|
|
const rNode = cert.item[1], rowsNode = cert.item[2];
|
|
if (rNode.list) return refuse('AERE-PQ-V3-03', 'the round is a list, not a scalar');
|
|
const rb = Buffer.from(rNode.item);
|
|
if (rb.length && rb[0] === 0) return refuse('AERE-PQ-V3-03', 'the round has a leading zero byte (not a canonical scalar)');
|
|
if (rb.length > 2 || scalarOf(rb) > BigInt(MAX_ROUND)) return refuse('AERE-PQ-V3-03', 'round ' + (rb.length > 8 ? '0x' + rb.toString('hex') : scalarOf(rb)) + ' is above ' + MAX_ROUND);
|
|
const round = Number(scalarOf(rb));
|
|
if (!rowsNode.list) return refuse('AERE-PQ-V3-03', 'the rows element is not a list');
|
|
if (rowsNode.item.length > MAX_V3_ROWS) return refuse('AERE-PQ-V3-03', rowsNode.item.length + ' rows, above ' + MAX_V3_ROWS);
|
|
if (rowsNode.item.length === 0 && round !== 0) return refuse('AERE-PQ-V3-03', 'an empty certificate must name round 0, this one names ' + round);
|
|
const rows = [];
|
|
for (const [k, s] of rowsNode.item.entries()) {
|
|
if (!s.list || s.item.length !== 3 || s.item.some(x => x.list)) return refuse('AERE-PQ-V3-03', 'row ' + k + ' is not [scheme, index, signature]');
|
|
const sb = Buffer.from(s.item[0].item), ib = Buffer.from(s.item[1].item), sig = new Uint8Array(s.item[2].item);
|
|
if ((sb.length && sb[0] === 0) || (ib.length && ib[0] === 0)) return refuse('AERE-PQ-V3-03', 'row ' + k + ': a scalar with a leading zero byte');
|
|
if (sb.length > 1 || !SCHEME_NAMES[Number(scalarOf(sb))]) return refuse('AERE-PQ-V3-03', 'row ' + k + ': unknown scheme tag 0x' + (sb.toString('hex') || '00'));
|
|
// 2026-10-09: a seal index is an integer 0..2^31-1 (a signed 32-bit integer) in at most 4 bytes, and anything else is refused
|
|
// with 03 at the decoding. Until this date only "at most 4 bytes" was required, so 0x80000000..0xffffffff passed the form and were
|
|
// refused only later, at the binding (06) or at the digest (04).
|
|
if (ib.length > 4) return refuse('AERE-PQ-V3-03', 'row ' + k + ': the index does not fit 32 bits');
|
|
if (scalarOf(ib) > 0x7fffffffn) return refuse('AERE-PQ-V3-03', 'row ' + k + ': the index ' + scalarOf(ib) + ' is above 2^31-1, the largest seal index (a signed 32-bit integer)');
|
|
if (sig.length === 0) return refuse('AERE-PQ-V3-03', 'row ' + k + ': empty signature');
|
|
const row = { scheme: Number(scalarOf(sb)), idx: Number(scalarOf(ib)), sig };
|
|
const prev = rows[rows.length - 1];
|
|
if (prev && !(row.idx > prev.idx || (row.idx === prev.idx && row.scheme > prev.scheme))) return refuse('AERE-PQ-V3-03', 'rows not strictly increasing by (index, scheme): ' + rows.concat(row).map(x => x.idx + '/' + x.scheme).join(','));
|
|
rows.push(row);
|
|
}
|
|
const canon = rlpEncode([scalar(3), scalar(round), rows.map(x => [scalar(x.scheme), scalar(x.idx), Buffer.from(x.sig)])]);
|
|
if (!canon.equals(rawCert)) return refuse('AERE-PQ-V3-03', 'the certificate is not in canonical RLP form (its re-encoding differs from the bytes carried)');
|
|
v3.round = round; v3.rows = rows;
|
|
return true;
|
|
};
|
|
if (parseV3()) {
|
|
const digestV3 = keccak(rlpEncode([Buffer.from(ANCHOR_DOMAIN_V3, 'ascii'), scalar(CHAIN_ID), scalar(h - 1), hex(parent.hash), rawCert]));
|
|
if (!digestV3.equals(vanity)) refuse('AERE-PQ-V3-04', 'the digest in vanityData is not keccak256(RLP["AERE-PQ-ANCHOR-3", chainId, ' + (h - 1) + ', parentHash, certificate]) of the certificate carried (round ' + v3.round + ')');
|
|
}
|
|
// R2: the Falcon rows against K (05); then EVERY FALCON ROW in canonical order, the binding (06) and the signature (07); then
|
|
// every extra row in canonical order, the scheme (08), its Falcon companion (08), its key (06) and its signature (07); at the end
|
|
// every required scheme (05). All or nothing: the first failure names the code. Every seal is over M_r of the PARENT for the ONE
|
|
// round the certificate names, with the keys in force at the parent (the v3 epoch from KEYS_FROM, else the key manifest); the
|
|
// validator of a row must be in the set of the PARENT (D-477) and counts once per scheme.
|
|
// 2026-10-09: TWO PASSES, as the fixed order R2 says (every Falcon row, then every extra row). Until this date there was one pass over
|
|
// the rows in wire order (index, scheme): a certificate with two defects, a bad extra row at a LOW index and a bad or non-member Falcon
|
|
// seal at a HIGH index, was 08 here instead of 07 or 06. Same verdict, different code. The doors to the parent (its set, its keys)
|
|
// are opened at the first FALCON row, on the Falcon branch only; on the extra branch they were already opened by the Falcon pass (an
|
|
// extra row without a Falcon row of the same index is 08 first).
|
|
const MIN_SEALS = minSealsAt(h);
|
|
const msgR = v3.round === null ? null : commitMessageRound(h - 1, v3.round, hex(parent.hash));
|
|
const v3k = h - 1 >= KEYS_FROM ? v3EpochAt(h - 1) : null; // v3: the epoch in force at the parent
|
|
const falconRows = v3.rows.filter(x => x.scheme === 1);
|
|
const falconIdx3 = new Set(falconRows.map(x => x.idx));
|
|
const requiredSchemes = MIN_SEALS > 0 ? schemesRequiredForAnchor(h) : [];
|
|
const namedSchemes = schemesAt(h - 1);
|
|
const perScheme = {}, taken = {};
|
|
const slhMissing = []; // rows of a scheme this machine cannot verify (no @noble/post-quantum): a gap of this run, never a refusal
|
|
if (!v3.code && falconRows.length < MIN_SEALS) refuse('AERE-PQ-V3-05', 'only ' + falconRows.length + ' Falcon-512 row(s) at K=' + MIN_SEALS + (falconRows.length === 0 ? ' (an empty certificate is valid only where K is 0)' : ''));
|
|
const keySource = v3k ? 'the registry v3 epoch ' + v3k.bindHeight : 'the key manifest ' + (man ? man.bindHeight : '?');
|
|
// The two doors to the parent (its set, its keys) are opened at the FIRST Falcon row (until 2026-10-09: at the first row of any
|
|
// scheme), after the count 05, as PqAnchorV3.judgeSeals does: an empty certificate at K=0 is valid without either, and a
|
|
// certificate short of K is refused 05 before 06 (2026-10-07, review of step 8: until then "no manifest" was refused 06 before 05,
|
|
// and on the empty certificate too).
|
|
const keysBound = v3k || (man && man.good);
|
|
// the order of R2: every Falcon row (canonical order), then every extra row (canonical order)
|
|
const ordineR2 = v3.code ? [] : [...falconRows, ...v3.rows.filter(x => x.scheme !== 1)];
|
|
for (const row of ordineR2) {
|
|
const name = SCHEME_NAMES[row.scheme];
|
|
sealsTotal++; schemesSeen.add(row.scheme);
|
|
if (row.scheme === 1) {
|
|
if (!vset) { refuse('AERE-PQ-V3-06', 'the validator set of the parent ' + (h - 1) + ' could not be resolved: the validator list in element 1 of its header is empty or malformed, so no seal can be bound to a validator'); break; }
|
|
if (!keysBound) { refuse('AERE-PQ-V3-06', man ? 'the key manifest in force at block ' + (h - 1) + ' (bindHeight ' + man.bindHeight + ') is not self-consistent, so no index can be bound to a key' : 'no key manifest is in force at block ' + (h - 1)); break; }
|
|
const src = v3k ? v3k.byIndex[row.idx] : man.rows[row.idx];
|
|
if (!src) { refuse('AERE-PQ-V3-06', 'index ' + row.idx + ' names no row of ' + keySource + ' (in force at block ' + (h - 1) + ')'); break; }
|
|
const member = v3k ? src.id : memberOf(src.addr.toLowerCase(), h - 1);
|
|
if (!vset.has(member)) { refuse('AERE-PQ-V3-06', 'the Falcon-512 seal of index ' + row.idx + ' is bound to ' + member + ', which is not in the validator set of block ' + (h - 1) + ', the block the seals sign'); break; }
|
|
const been = taken[name] || (taken[name] = new Set());
|
|
if (been.has(member)) { refuse('AERE-PQ-V3-06', 'two indices of this certificate are bound to the same validator ' + member + ' (the second is index ' + row.idx + ')'); break; }
|
|
been.add(member);
|
|
if (!src.pk || !verifyFalcon512(src.pk, msgR, row.sig)) { refuse('AERE-PQ-V3-07', 'the Falcon-512 seal of index ' + row.idx + ' does not verify over M_r(' + (h - 1) + ') for round ' + v3.round + ' (AERE-PQ-COMMIT-2) with the key of ' + keySource); break; }
|
|
} else {
|
|
if (!namedSchemes.includes(name)) { refuse('AERE-PQ-V3-08', 'a ' + name + ' seal at index ' + row.idx + ', a scheme the schedule does not name at block ' + (h - 1)); break; }
|
|
if (!falconIdx3.has(row.idx)) { refuse('AERE-PQ-V3-08', 'the ' + name + ' seal of index ' + row.idx + ' rides without a Falcon-512 seal of the same index'); break; }
|
|
// the doors were opened by the Falcon pass (this row has a Falcon companion that passed them); kept as a guard
|
|
if (!keysBound) { refuse('AERE-PQ-V3-06', 'no key set is in force at block ' + (h - 1) + ' for the ' + name + ' seal of index ' + row.idx); break; }
|
|
const src = v3k ? v3k.byIndex[row.idx] : man.rows[row.idx];
|
|
if (!src) { refuse('AERE-PQ-V3-06', 'index ' + row.idx + ' names no row of ' + keySource); break; }
|
|
const pk = src.keys && src.keys[name];
|
|
if (!pk) { refuse('AERE-PQ-V3-06', keySource + ' has no ' + name + ' key for index ' + row.idx); break; }
|
|
if (!slhDsa) { slhMissing.push(name + '@' + row.idx); continue; }
|
|
if (!verifySlhDsa(pk, msgR, row.sig)) { refuse('AERE-PQ-V3-07', 'the ' + name + ' seal of index ' + row.idx + ' does not verify over M_r(' + (h - 1) + ') for round ' + v3.round + ' (AERE-PQ-COMMIT-2)'); break; }
|
|
}
|
|
perScheme[name] = (perScheme[name] || 0) + 1; sealsValid++;
|
|
}
|
|
if (!v3.code) for (const nm of requiredSchemes) {
|
|
if (nm !== 'falcon-512' && !slhDsa) continue; // reported below as NOT VERIFIED HERE, not as a finding
|
|
if ((perScheme[nm] || 0) < MIN_SEALS) { refuse('AERE-PQ-V3-05', 'scheme ' + nm + ' has ' + (perScheme[nm] || 0) + ' valid seal(s), below K=' + MIN_SEALS + ' required at anchor ' + h + ' (step in force at ' + (h - 1) + ')'); break; }
|
|
}
|
|
const n3 = v3.rows.length, members = perScheme['falcon-512'] || 0, setSize = vset ? vset.size : 0, Q = setSize ? quorumOf(setSize) : null;
|
|
const summary3 = Object.entries(perScheme).map(([nm, c]) => c + ' ' + nm).join(' + ') || 'no seals';
|
|
if (!v3.code && slhMissing.length) {
|
|
unmeasured++;
|
|
console.log(h + ' NOT VERIFIED HERE: v3 certificate (round ' + v3.round + ') bound under block hash, ' + members + ' valid falcon-512 seals, but this'
|
|
+ ' machine has no SLH-DSA verifier, so the ' + slhMissing.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.');
|
|
continue;
|
|
}
|
|
if (!v3.code) {
|
|
ok++;
|
|
// PROIECT-1B 4.2 #1: "PARENT DECIDED IN ROUND r" only when at least the QBFT quorum of the PARENT's set sealed it in that one
|
|
// round (every seal verified above, over M_r, each member counted once), which is what QBFT calls a decision; below the quorum
|
|
// the line says CERTIFIED (participation). The machine-readable form is fixed (the collector of the live gate D-470,
|
|
// consensus-pqc/d412-reparatie/r1b/aduna-fapte-vii-2800.mjs, parses exactly it): "PARENT DECIDED IN ROUND r (m members of n,
|
|
// quorum Q)". It is still not a finality claim: finality is the chain's rule, not a measurement of this tool.
|
|
const decided = n3 > 0 && Q !== null && members >= Q;
|
|
if (newestOk === null) { newestOk = h; newestParentHash = String(parent.hash).toLowerCase(); newestParent = parent; newestForm = { version: 3, round: v3.round, members, setSize, quorum: Q, decided }; }
|
|
console.log(h + ' OK: bound under block hash (v3, round ' + v3.round + ', AERE-PQ-ANCHOR-3), ' + n3 + ' seals (' + summary3 + ')'
|
|
+ (n3 ? ', all valid over M_r of block ' + (h - 1) + ' for round ' + v3.round + ' and from distinct validators of that block, the set its own header lists (' + keySource + '); '
|
|
+ (decided
|
|
? 'PARENT DECIDED IN ROUND ' + v3.round + ' (' + members + ' members of ' + setSize + ', quorum ' + Q + '): a QBFT quorum of that set sealed block ' + (h - 1) + ' in that one round'
|
|
: 'CERTIFIED (participation): ' + members + ' members of ' + setSize + ' sealed it in round ' + v3.round + ', below the QBFT quorum ' + Q + ' of that set, so this does not show that the block was decided')
|
|
: ': the empty certificate, which is valid only where K is 0 (K=' + MIN_SEALS + ' here) and proves nothing about block ' + (h - 1)));
|
|
continue;
|
|
}
|
|
bad++;
|
|
console.log(h + ' FAIL: ' + v3.code + ' (certificate v3' + (v3.round !== null ? ', round ' + v3.round : '') + '): ' + v3.why);
|
|
continue;
|
|
}
|
|
// v1 opens with a LIST ([idx, sig]); v2 opens with the SCALAR 2. Nothing else is accepted.
|
|
const isV2 = cert.list && cert.item.length === 2 && !cert.item[0].list;
|
|
if (isV2 && !(cert.item[0].item.length === 1 && cert.item[0].item[0] === 2)) { console.log(h + ' FAIL: certificate opens with an unknown version scalar'); bad++; continue; }
|
|
const digest = isV2
|
|
? keccak(rlpEncode([Buffer.from(ANCHOR_DOMAIN_V2, 'ascii'), scalar(CHAIN_ID), scalar(h - 1), hex(parent.hash), rlpEncode(toNested(cert))]))
|
|
: keccak(rlpEncode([Buffer.from(ANCHOR_DOMAIN, 'ascii'), scalar(CHAIN_ID), scalar(h - 1), hex(parent.hash), toNested(cert)]));
|
|
const bound = digest.equals(vanity);
|
|
|
|
const msg = commitMessage(h - 1, hex(parent.hash));
|
|
let valid = 0, invalid = [], notValidator = [], unknownIndex = [], unknownScheme = [], unbound = [], noKey = [], duplicate = [];
|
|
const perScheme = {}; // scheme name -> valid seals, each from a DISTINCT validator
|
|
const countedBy = {}; // scheme name -> the validators already counted (D-477: a validator counts once per scheme, as in both clients)
|
|
const falconIdx = new Set();
|
|
const sealNodes = isV2 ? cert.item[1].item : cert.item;
|
|
const rowsOf = sealNodes.map(s => isV2
|
|
? { scheme: (s.item[0].item.length ? s.item[0].item[0] : 0), idx: (s.item[1].item.length ? s.item[1].item[0] : 0), sig: new Uint8Array(s.item[2].item) }
|
|
: { scheme: 1, idx: (s.item[0].item.length ? s.item[0].item[0] : 0), sig: new Uint8Array(s.item[1].item) });
|
|
// D-478: the one order the production client accepts, at every height outside the lapse where nothing was in force (the window
|
|
// where only the threshold was lowered included): v1, indices strictly increasing (PqAnchor.hasStrictlyIncreasingIndices, which also
|
|
// makes a repeated index unrepresentable); v2, (index, scheme) strictly increasing (PqAnchorV2.CANONICAL)
|
|
const orderOk = rowsOf.every((r, i) => i === 0 || (isV2
|
|
? (r.idx > rowsOf[i - 1].idx || (r.idx === rowsOf[i - 1].idx && r.scheme > rowsOf[i - 1].scheme))
|
|
: r.idx > rowsOf[i - 1].idx));
|
|
for (const r of rowsOf) if (r.scheme === 1) falconIdx.add(r.idx);
|
|
for (const r of rowsOf) schemesSeen.add(r.scheme);
|
|
for (const { scheme, idx, sig } of rowsOf) {
|
|
sealsTotal++;
|
|
const name = SCHEME_NAMES[scheme];
|
|
if (!name) { unknownScheme.push(scheme); continue; }
|
|
// AIP-22 stage 3b: from KEYS_FROM the index names the row of the v3 epoch in force at the PARENT (its id and its keys)
|
|
const v3k = h - 1 >= KEYS_FROM ? v3EpochAt(h - 1) : null;
|
|
const row = v3k ? v3k.byIndex[idx] : (man && man.rows[idx]);
|
|
if (!row) { unknownIndex.push(idx); continue; }
|
|
const member = v3k ? row.id : memberOf(row.addr.toLowerCase(), h - 1);
|
|
if (!vset.has(member)) { notValidator.push(idx); continue; }
|
|
// D-477: a second seal of the same validator in the same scheme is not counted again; the nodes refuse such a certificate
|
|
const seen = countedBy[name] || (countedBy[name] = new Set());
|
|
if (seen.has(member)) { duplicate.push(name + '@' + idx); continue; }
|
|
seen.add(member);
|
|
if (scheme === 1) {
|
|
if (!row.pk || !verifyFalcon512(row.pk, msg, sig)) { invalid.push('falcon@' + idx); continue; }
|
|
} else {
|
|
if (!falconIdx.has(idx)) { unbound.push(name + '@' + idx); continue; }
|
|
const pk = row.keys && row.keys[name];
|
|
if (!pk) { noKey.push(name + '@' + idx); continue; }
|
|
if (!verifySlhDsa(pk, msg, sig)) { invalid.push(name + '@' + idx); continue; }
|
|
}
|
|
perScheme[name] = (perScheme[name] || 0) + 1;
|
|
valid++; sealsValid++;
|
|
}
|
|
const n = rowsOf.length;
|
|
const MIN_SEALS = minSealsAt(h);
|
|
const falconValid = perScheme['falcon-512'] || 0;
|
|
// D-338: every scheme the schedule REQUIRES at this anchor must reach K on its own; a scheme with NO seal at all is named
|
|
// as missing, not silently skipped. When K is 0 at this height (a dated step) nothing is required. AIP-22 stage 2: a
|
|
// scheme that is NAMED at the parent's step but not required at this anchor (off its own grid) is SURPLUS - its seals are
|
|
// verified above like any other, but it need not reach K; a scheme present in the certificate that the step does not
|
|
// name at all is refused, as both clients refuse it.
|
|
const requiredSchemes = MIN_SEALS > 0 ? schemesRequiredForAnchor(h) : [];
|
|
const namedSchemes = schemesAt(h - 1);
|
|
const shortSchemes = requiredSchemes.filter(nm => (perScheme[nm] || 0) > 0 && perScheme[nm] < MIN_SEALS).map(nm => nm + ':' + perScheme[nm]);
|
|
const missingSchemes = requiredSchemes.filter(nm => !(perScheme[nm] > 0));
|
|
const unnamedSchemes = isV2 ? [...new Set(rowsOf.map(r => SCHEME_NAMES[r.scheme]).filter(nm => nm && !namedSchemes.includes(nm)))] : [];
|
|
const falconNeeded = requiredSchemes.includes('falcon-512') || requiredSchemes.length === 0;
|
|
const schemesOk = (!falconNeeded || falconValid >= MIN_SEALS) && shortSchemes.length === 0 && missingSchemes.length === 0
|
|
&& unnamedSchemes.length === 0 && (!isV2 || slhDsa || !rowsOf.some(r => r.scheme === 2));
|
|
const good = bound && man && man.good && schemesOk && valid === n && orderOk;
|
|
// 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 && orderOk && isV2 && !slhDsa && rowsOf.some(r => r.scheme === 2)
|
|
&& bound && man && man.good && falconValid >= MIN_SEALS
|
|
&& missingSchemes.every(unverifiableHere)
|
|
&& invalid.every(x => unverifiableHere(String(x).split('@')[0]))
|
|
&& unknownScheme.length === 0 && unknownIndex.length === 0
|
|
&& notValidator.length === 0 && noKey.length === 0 && unbound.length === 0 && duplicate.length === 0;
|
|
const summary = Object.entries(perScheme).map(([nm, c]) => c + ' ' + nm).join(' + ');
|
|
if (good) { ok++; if (newestOk === null) { newestOk = h; newestParentHash = String(parent.hash).toLowerCase(); newestParent = parent; newestForm = { version: isV2 ? 2 : 1, round: null }; } console.log(h + ' OK: bound under block hash' + (isV2 ? ' (v2, scheme-tagged)' : '') + ', ' + n + ' seals (' + summary + '), all valid and from distinct validators of block ' + (h - 1) + ', the set its own header lists (' + (h - 1 >= KEYS_FROM && v3EpochAt(h - 1) ? 'keys of the registry v3 epoch ' + v3EpochAt(h - 1).bindHeight : 'manifest ' + man.bindHeight) + ')'
|
|
+ (lapseAt(h) ? '; judged at K=' + MIN_SEALS + ', the threshold in force in the dated window ' + lapseAt(h).first + '..' + lapseAt(h).last + ' (' + lapseAt(h).date + '), digest and order required as everywhere' : '')); }
|
|
else if (envBlocked) {
|
|
unmeasured++;
|
|
console.log(h + ' NOT VERIFIED HERE: bound under block hash, ' + falconValid + ' valid falcon-512 seals, but this'
|
|
+ ' machine has no SLH-DSA verifier, so the ' + rowsOf.filter(r => r.scheme === 2).length + ' slh-dsa seals were not checked'
|
|
+ ' (npm install @noble/post-quantum). This is a gap in THIS run, not a finding about the chain.');
|
|
}
|
|
else {
|
|
bad++;
|
|
console.log(h + ' FAIL:' + (bound ? '' : ' digest mismatch') + (man ? (man.good ? '' : ' manifest not self-consistent') : ' no manifest for this height') +
|
|
(falconNeeded && falconValid < MIN_SEALS ? ' only ' + falconValid + ' valid Falcon seals (K=' + MIN_SEALS + ')' : '') + (shortSchemes.length ? ' short schemes ' + shortSchemes.join(',') : '') + (missingSchemes.length ? ' missing scheme ' + missingSchemes.join(',') + ' (required at anchor ' + h + ', step in force at ' + (h - 1) + ')' : '') +
|
|
(unnamedSchemes.length ? ' scheme(s) not named by the schedule at ' + (h - 1) + ': ' + unnamedSchemes.join(',') : '') +
|
|
(invalid.length ? ' invalid seals ' + invalid.join(',') : '') + (unbound.length ? ' extra seals without a Falcon seal of the same index ' + unbound.join(',') : '') +
|
|
(noKey.length ? ' manifest has no key for ' + noKey.join(',') : '') + (unknownScheme.length ? ' unknown scheme tag ' + unknownScheme.join(',') : '') +
|
|
(notValidator.length ? ' keys not in validator set at index ' + notValidator.join(',') + ' (the set of block ' + (h - 1) + ', the block these seals sign)' : '') + (unknownIndex.length ? ' unknown index ' + unknownIndex.join(',') : '') +
|
|
(duplicate.length ? ' the same validator sealed more than once in one scheme: ' + duplicate.join(',') + ' (counted once; the nodes refuse such a certificate)' : '') +
|
|
(orderOk ? '' : ' seals not in the one accepted order (' + (isV2 ? '(index, scheme)' : 'index') + ' strictly increasing): ' + rowsOf.map(r => (isV2 ? r.idx + '/' + r.scheme : r.idx)).join(',')) +
|
|
(lapseAt(h) ? ' (inside the dated window ' + lapseAt(h).first + '..' + lapseAt(h).last + ' only the threshold was lowered, to ' + lapseAt(h).minSeals + ')' : '') +
|
|
(isV2 && !slhDsa && rowsOf.some(r => r.scheme === 2) ? ' (SLH-DSA verifier not installed: npm install @noble/post-quantum)' : ''));
|
|
}
|
|
}
|
|
|
|
// 5. ON-CHAIN PIN, proven under the certified state (2026-10-02, AIP-24 C4 (b)). For every key manifest the index binds to an anchor
|
|
// contract, eth_getProof of that contract at the PARENT of the newest verified anchor: the account must exist under that header's
|
|
// state root (the header re-encodes to the hash the seals signed, so the root is certified), its code hash must be the 11-byte
|
|
// read-only getter, and slot 0, proven under the account's storage root, must hold keccak256(addr || pk over the rows). An endpoint
|
|
// that keeps no state for that block makes the pin UNMEASURED (exit 2), never VERIFIED; a proof that does not hold, or a value that
|
|
// is not the manifest's hash, is a FAIL of that manifest.
|
|
const GETTER_CODEHASH = keccak(Buffer.from('60005460005260206000f3', 'hex')).toString('hex');
|
|
let pinUnmeasured = 0;
|
|
{
|
|
const judged = manifests.filter(mm => mm.anchorContract);
|
|
if (judged.length && newestOk === null) {
|
|
for (const mm of judged) { pinUnmeasured++; mm.pin = null; }
|
|
console.log('on-chain pin: not judged, no anchor verified in this run (the pin is proven under the state of a certified parent)');
|
|
}
|
|
for (const mm of judged) {
|
|
if (newestOk === null) break;
|
|
const X = newestOk - 1;
|
|
let note, okPin = false;
|
|
try {
|
|
const pr = await rpc('eth_getProof', [mm.anchorContract, ['0x0000000000000000000000000000000000000000000000000000000000000000'], '0x' + X.toString(16)]);
|
|
const acct = accountAt(newestParent.stateRoot, hex(mm.anchorContract.toLowerCase()), pr && pr.accountProof);
|
|
if (!acct) { note = 'FAIL: the state of block ' + X + ' has no account at ' + mm.anchorContract; }
|
|
else if (acct[3].toString('hex') !== GETTER_CODEHASH) { note = 'FAIL: the account at ' + mm.anchorContract + ' in the state of block ' + X + ' is not the read-only getter (code hash 0x' + acct[3].toString('hex').slice(0, 16) + '..)'; }
|
|
else {
|
|
const sp = (pr.storageProof || [])[0];
|
|
const v = slotAt('0x' + acct[2].toString('hex'), Buffer.alloc(32), sp && sp.proof);
|
|
const got = '0x' + (v ? Buffer.from(v).toString('hex').padStart(64, '0') : '0'.repeat(64));
|
|
okPin = got === mm.want.toLowerCase() /*PAZA:pin-under-certified-state*/;
|
|
note = okPin ? 'slot 0 matches, proven under the state of block ' + X + ' (the certified parent of anchor ' + newestOk + '), contract is the read-only getter'
|
|
: 'FAIL: slot 0 MISMATCH under the state of block ' + X + ' (' + got.slice(0, 10) + ' vs ' + mm.want.slice(0, 10) + ')';
|
|
}
|
|
mm.pin = okPin;
|
|
} catch (e) {
|
|
const text = String(e.message);
|
|
// a proof the endpoint served that does not hold is a finding; an endpoint that cannot serve the proof is this run's gap
|
|
if (/missing the node|does not hold|not a list|hex-prefix|branch child|extension child|deeper than any key|RLP/.test(text)) { mm.pin = false; note = 'FAIL: the proof served for ' + mm.anchorContract + ' at block ' + X + ' does not hold: ' + text; }
|
|
else { mm.pin = null; pinUnmeasured++; note = 'UNMEASURED: eth_getProof at block ' + X + ' failed (the endpoint may not keep that state): ' + text.slice(0, 120); }
|
|
}
|
|
if (mm.pin === false) { manifestBad++; mm.good = false; }
|
|
console.log(' pin ' + mm.file.padEnd(26) + ' ' + note);
|
|
}
|
|
}
|
|
|
|
// 6. PROPOSER SEALS, AIP-22 stage 5 (added 2026-09-24). From H_hdr every header carries element 6, [index, signature]: the
|
|
// proposer's Falcon-512 seal over keccak256(RLP["AERE-PQ-PROPOSAL-1", chainId, height, round, digest]), where digest is the hash
|
|
// of the header re-encoded in the EXCLUDE_COMMIT_SEALS form (SPEC 2.3: round kept, commit seals an empty list, the certificate
|
|
// and the proposer seal omitted). The index names a row of the v3 registry epoch in force at that height, and that row's id must
|
|
// be the header's coinbase, or the proposer of the round of a re-proposal (below). Each header is first re-encoded in the on-chain
|
|
// form (round 0) and must reproduce the hash the endpoint reports: that is the control of THIS header's encoding, and a header that
|
|
// fails it is not judged at all. Headers without element 6 (every header of a chain, or of heights, before stage 5) are counted
|
|
// and named, never judged.
|
|
//
|
|
// RE-PROPOSAL (2026-09-25, D-406, measured on testnet 28001). A block PREPARED in one round and not committed before its timer is
|
|
// re-proposed UNCHANGED in a later round r by the proposer of round r (QBFT's locked value: only element 3, the round, changes; the
|
|
// coinbase stays the builder's), and the proposer seal put in the header at commit is the one of that round's proposer. Requiring
|
|
// the coinbase refused every such header: block 3,314,873 stood 128 s, rounds 1 to 4 refused by every node with "a seal cannot
|
|
// vouch for another proposer", round 5 the builder's own turn again (five validators). So a seal is admitted when its id is the
|
|
// coinbase, OR when the round is above 0 and its id is the proposer QBFT expects for that round, as both clients now judge it.
|
|
// That proposer is derived here, not taken from the endpoint: the validators are element 1 of the header itself, and the previous
|
|
// proposer is the coinbase of the PARENT, whose header must re-encode to this header's parentHash (so an endpoint cannot choose the
|
|
// parent's coinbase). A parent that cannot be read or bound is a FAIL, never an admission.
|
|
//
|
|
// And on EVERY header judged here, whoever sealed it, the tool applies the rules the nodes apply from H_hdr, so it judges exactly
|
|
// as they do: (1) the round in element 3 is a canonical RLP integer (no leading zero, at most 8 bytes, the shortest encoding;
|
|
// Besu's strict decoding refuses any other form); (2) it is at most 1024 (no QBFT round timer, which doubles every round, gets
|
|
// there, and upstream's selection computes in int); (3) the coinbase is the proposer of some round 0..min(r, n - 1) at this height,
|
|
// i.e. it could have BUILT the block (the rotation is periodic in n; the second client has required this since D-331). Rule (3)
|
|
// needs the parent's coinbase for every header; the headers judged are consecutive, so each parent is the next header down, read
|
|
// once, and only the oldest needs one header more: PROPOSER_HEADERS + 1 reads in all, none per header beyond that.
|
|
//
|
|
// expectedProposer is Besu's BftProposerSelector with changeEachBlock = true: a previous proposer that is in the list moves one
|
|
// step and then the round; one that is no longer in the list is replaced by the lowest validator above it (compared as numbers),
|
|
// or by the lowest of all when none is above it, and then moves by the round only.
|
|
// >>> expectedProposer (self-contained: verify-anchor-repropunere-proba.mjs reads it from this file and tests it)
|
|
function expectedProposer(validators, prevProposer, round) {
|
|
const n = validators.length;
|
|
if (n === 0) return null;
|
|
const at = validators.indexOf(prevProposer);
|
|
if (at >= 0) return validators[(at + 1 + round) % n];
|
|
const num = (a) => BigInt(a);
|
|
let base = null;
|
|
for (const v of validators) if (num(v) > num(prevProposer) && (base === null || num(v) < num(base))) base = v;
|
|
if (base === null) for (const v of validators) if (base === null || num(v) < num(base)) base = v;
|
|
return validators[(validators.indexOf(base) + round) % n];
|
|
}
|
|
// <<< expectedProposer
|
|
const PROPOSER_HEADERS = Number(process.env.PROPOSER_HEADERS || 20);
|
|
const PROPOSAL_DOMAIN = 'AERE-PQ-PROPOSAL-1';
|
|
const nested = (node) => (node.list ? node.item.map(nested) : Buffer.from(node.item));
|
|
function headerRlp(b, extra) {
|
|
const f = [hex(b.parentHash), hex(b.sha3Uncles), hex(b.miner), hex(b.stateRoot), hex(b.transactionsRoot), hex(b.receiptsRoot),
|
|
hex(b.logsBloom), scalar(b.difficulty), scalar(b.number), scalar(b.gasLimit), scalar(b.gasUsed), scalar(b.timestamp), extra,
|
|
hex(b.mixHash), hex(b.nonce)];
|
|
if (b.baseFeePerGas != null) f.push(scalar(b.baseFeePerGas));
|
|
if (b.withdrawalsRoot != null) f.push(hex(b.withdrawalsRoot));
|
|
if (b.blobGasUsed != null) { f.push(scalar(b.blobGasUsed)); f.push(scalar(b.excessBlobGas)); }
|
|
if (b.parentBeaconBlockRoot != null) f.push(hex(b.parentBeaconBlockRoot));
|
|
if (b.requestsHash != null) f.push(hex(b.requestsHash));
|
|
if (b.balHash != null) f.push(hex(b.balHash));
|
|
return rlpEncode(f);
|
|
}
|
|
let propOk = 0, propBad = 0, propUnmeasured = 0, propAbsent = 0, propReproposed = 0;
|
|
{
|
|
const headNow = parseInt(await rpc('eth_blockNumber', []), 16);
|
|
// each header is read ONCE: the loop reads it as the header judged, and the header above it has already read it as its parent
|
|
const got = new Map();
|
|
const fetchHeader = async (n) => {
|
|
try { const x = await rpc('eth_getBlockByNumber', ['0x' + n.toString(16), false]); return x ? { b: x } : { why: 'the endpoint returned no header' }; }
|
|
catch (e) { return { why: e.message }; }
|
|
};
|
|
const header = async (n) => { if (!got.has(n)) got.set(n, await fetchHeader(n)); return got.get(n); };
|
|
for (let k = 0; k < PROPOSER_HEADERS; k++) {
|
|
const h = headNow - 2 - k; if (h < 1) break;
|
|
const rec = await header(h);
|
|
if (!rec.b) { propUnmeasured++; console.log(h + ' PROPOSER SEAL NOT CHECKED HERE: the header could not be read (' + rec.why + ')'); continue; }
|
|
const b = rec.b;
|
|
const ex = hex(b.extraData);
|
|
const items = rlpRead(ex, 0).item;
|
|
if (items.length < 7) { propAbsent++; continue; }
|
|
const onchain = keccak(headerRlp(b, rlpEncode([nested(items[0]), nested(items[1]), nested(items[2]), Buffer.alloc(0), []])));
|
|
if (!onchain.equals(hex(b.hash))) {
|
|
propUnmeasured++;
|
|
console.log(h + ' PROPOSER SEAL NOT CHECKED HERE: this header does not re-encode to the hash the endpoint reports, so its proposal digest cannot be computed with confidence');
|
|
continue;
|
|
}
|
|
const seal = items[6];
|
|
if (!seal.list || seal.item.length !== 2 || seal.item[0].list || seal.item[1].list) { propBad++; console.log(h + ' FAIL: the proposer seal is not [index, signature]'); continue; }
|
|
const idx = seal.item[0].item.length ? parseInt(Buffer.from(seal.item[0].item).toString('hex'), 16) : 0;
|
|
const digestP = keccak(headerRlp(b, rlpEncode([nested(items[0]), nested(items[1]), nested(items[2]), nested(items[3]), []])));
|
|
const msgP = keccak(rlpEncode([Buffer.from(PROPOSAL_DOMAIN, 'ascii'), scalar(CHAIN_ID), scalar(h), Buffer.from(items[3].item), digestP]));
|
|
const ep = v3EpochAt(h);
|
|
const row = ep && ep.byIndex[idx];
|
|
if (!row) { propBad++; console.log(h + ' FAIL: proposer seal index ' + idx + ' names no row of a published v3 registry epoch in force at this height'); continue; }
|
|
const sealId = row.id, coinbase = String(b.miner).toLowerCase();
|
|
// D-406 (1): the round is a canonical RLP integer. The decoder above keeps only the bytes, so the prefix is read from the raw
|
|
// extraData, where element 3 starts right after element 2 ends; the header hash cannot see this (the round is zeroed in it)
|
|
const rb = items[3].list ? null : Buffer.from(items[3].item);
|
|
const p3 = ex[items[2].next];
|
|
const notCanonical = rb === null ? 'it is a list' : rb.length > 8 ? 'longer than 8 bytes' : rb.length && rb[0] === 0 ? 'a leading zero byte'
|
|
: (rb.length === 0 ? p3 !== 0x80 : rb.length === 1 && rb[0] < 0x80 ? p3 !== rb[0] : p3 !== 0x80 + rb.length) ? 'not the shortest RLP encoding' : null;
|
|
if (notCanonical) { propBad++; console.log(h + ' FAIL: the round in element 3 (RLP 0x' + ex.subarray(items[2].next, items[3].next).toString('hex') + ') is not a canonical RLP integer (' + notCanonical + '); the nodes refuse this form: not admitted'); continue; }
|
|
// D-406 (2): the round bound, read as a BigInt so an absurd value is named as it is, not rounded
|
|
const roundBig = rb.length ? BigInt('0x' + rb.toString('hex')) : 0n;
|
|
if (roundBig > 1024n) { propBad++; console.log(h + ' FAIL: the round ' + roundBig + ' in element 3 is above 1024 (no QBFT round timer gets there; both clients refuse it from H_hdr): not admitted'); continue; }
|
|
const round = Number(roundBig);
|
|
if (!items[1].list || items[1].item.length === 0 || items[1].item.some(x => x.list || x.item.length !== 20)) { propBad++; console.log(h + ' FAIL: the validator list in element 1 of this header is malformed, so the proposers of this height cannot be derived: not admitted'); continue; }
|
|
// D-406 (3): the builder. The parent is the next header down (already read by the loop, or read once for the oldest)
|
|
const pr = await header(h - 1);
|
|
if (!pr.b) { propBad++; console.log(h + ' FAIL: the parent header ' + (h - 1) + ' could not be read (' + pr.why + '), so the proposers of this height cannot be derived: not admitted'); continue; }
|
|
const parent = pr.b;
|
|
let parentBound = false;
|
|
try {
|
|
const pItems = rlpRead(hex(parent.extraData), 0).item;
|
|
parentBound = keccak(headerRlp(parent, rlpEncode([nested(pItems[0]), nested(pItems[1]), nested(pItems[2]), Buffer.alloc(0), []]))).equals(hex(b.parentHash));
|
|
} catch (e) { parentBound = false; }
|
|
if (!parentBound) { propBad++; console.log(h + ' FAIL: the header served for block ' + (h - 1) + ' does not re-encode to this header\'s parentHash, so its coinbase cannot be used to derive the proposers of this height: not admitted'); continue; }
|
|
const parentCoinbase = String(parent.miner).toLowerCase();
|
|
const vals = items[1].item.map(x => '0x' + Buffer.from(x.item).toString('hex'));
|
|
let builder = -1;
|
|
for (let r0 = 0; r0 <= Math.min(round, vals.length - 1); r0++) if (expectedProposer(vals, parentCoinbase, r0) === coinbase) { builder = r0; break; }
|
|
if (builder < 0) { propBad++; console.log(h + ' FAIL: the coinbase ' + b.miner + ' is not the proposer of any round 0..' + round + ' at this height (from the validators of this header and the coinbase ' + parentCoinbase + ' of block ' + (h - 1) + '), so it did not build the block: not admitted'); continue; }
|
|
let reproposal = null;
|
|
if (coinbase !== sealId) {
|
|
// D-406: only a re-proposal in a round above 0 may carry another validator's seal, and only the one of that round's proposer
|
|
const said = h + ' FAIL: proposer seal index ' + idx + ' is id ' + sealId + ', but the coinbase is ' + b.miner;
|
|
if (round === 0) { propBad++; console.log(said + ' and the round is 0 (only a block re-proposed in a later round carries the seal of that round\'s proposer): a seal cannot vouch for another proposer'); continue; }
|
|
const expected = expectedProposer(vals, parentCoinbase, round);
|
|
if (expected !== sealId) { propBad++; console.log(said + ' and the proposer of round ' + round + ' is ' + expected + ' (from the validators of this header and the coinbase ' + parentCoinbase + ' of block ' + (h - 1) + '): a seal cannot vouch for another proposer'); continue; }
|
|
reproposal = { round, parentCoinbase, builder };
|
|
}
|
|
if (!row.pk || !verifyFalcon512(row.pk, msgP, new Uint8Array(seal.item[1].item))) { propBad++; console.log(h + ' FAIL: the proposer seal of index ' + idx + ' is not a valid Falcon-512 signature over the proposal message'); continue; }
|
|
propOk++;
|
|
if (reproposal) {
|
|
propReproposed++;
|
|
console.log(h + ' OK, re-proposal: built by the coinbase ' + b.miner + ' (the proposer of round ' + reproposal.builder + '), re-proposed in round ' + reproposal.round + '; the proposer seal is index ' + idx
|
|
+ ', id ' + sealId + ', which is the proposer expected for round ' + reproposal.round + ' (from the validators of this header and the coinbase '
|
|
+ reproposal.parentCoinbase + ' of block ' + (h - 1) + '), and it is a valid Falcon-512 signature over the proposal message of that round');
|
|
}
|
|
}
|
|
}
|
|
const propChecked = propOk + propBad + propUnmeasured;
|
|
console.log('');
|
|
console.log(propChecked === 0
|
|
? 'proposer seals (AIP-22 stage 5): none of the ' + propAbsent + ' most recent headers carries one; stage 5 is not active at these heights on this chain, so there is nothing to verify'
|
|
: 'proposer seals (AIP-22 stage 5): ' + propOk + ' of ' + propChecked + ' recent headers carry a valid Falcon-512 proposal seal by the key of their coinbase,'
|
|
+ ' or of the proposer of the round that re-proposed the block' + (propReproposed ? ' (' + propReproposed + ' re-proposal(s), named above)' : '')
|
|
+ (propUnmeasured ? ', ' + propUnmeasured + ' could not be checked here' : '') + (propAbsent ? ' (' + propAbsent + ' earlier headers carry none)' : ''));
|
|
console.log('seals: ' + sealsValid + '/' + sealsTotal + ' cryptographically valid and validator-bound manifests: ' + (manifests.length - manifestBad) + '/' + manifests.length + ' self-consistent');
|
|
const all = bad === 0 && manifestBad === 0 && unmeasured === 0 && setUnmeasured === 0 && propBad === 0 && propUnmeasured === 0 && pinUnmeasured === 0;
|
|
if (pinUnmeasured) console.log('UNMEASURED: the on-chain pin of ' + pinUnmeasured + ' manifest(s) could not be proven here (no verified anchor in this run, or the endpoint served no state proof for the certified parent). This says nothing about the chain; try another endpoint (RPC=...).');
|
|
// D-478: an anchor in the dated lapse where nothing was in force is NOT counted as verified. The VERIFIED line counts only the anchors
|
|
// actually judged, the lapse ones get their own line, and a run in which every anchor read fell in the lapse verified nothing: it says
|
|
// so and exits 2 (nothing measured), never 0 with a VERIFIED line over anchors where nothing was checked.
|
|
const nothingVerified = all && ok === 0 && lapseNotEnforced > 0;
|
|
if (lapseNotEnforced) console.log('LAPSE: ' + lapseNotEnforced + ' anchor(s) in a dated lapse of chain 2800 (2026-08-10), not verified: the anchor rules were not in force there,'
|
|
+ ' as the production client names it (PqAnchorLapse); only their seal indices were checked distinct. They are not counted below.');
|
|
console.log(nothingVerified
|
|
? 'NOT VERIFIED: all ' + lapseNotEnforced + ' anchor(s) read fall in the dated lapse; there was nothing in force to verify. Run below or above it.'
|
|
: all
|
|
? 'VERIFIED: ' + ok + '/' + (COUNT - lapseNotEnforced) + ' anchor blocks carry a post-quantum certificate bound under the block hash, every seal a valid '
|
|
+ ([...schemesSeen].sort().map(x => SCHEME_NAMES[x] || ('scheme ' + x)).join(' or ') || 'post-quantum')
|
|
+ ' signature by a key that a distinct validator of the block it signs bound to itself with its own consensus key.'
|
|
+ (propOk ? ' And ' + propOk + ' of the ' + propOk + ' most recent headers checked carry a valid Falcon-512 proposal seal by the key of their coinbase,'
|
|
+ ' or of the proposer of the round that re-proposed the block (AIP-22 stage 5).' : '')
|
|
: (bad === 0 && manifestBad === 0 && propBad === 0
|
|
? (unmeasured
|
|
? 'UNMEASURED: ' + unmeasured + ' of ' + COUNT + ' anchors could not be fully checked ON THIS MACHINE because the SLH-DSA\n'
|
|
+ ' verifier is missing. Nothing here says the chain is wrong; install it and run again:\n'
|
|
+ ' npm install @noble/post-quantum'
|
|
: '')
|
|
+ (setUnmeasured ? (unmeasured ? '\n' : '') + 'UNMEASURED: ' + setUnmeasured + ' of ' + COUNT + ' anchors: the header served for the parent could not be read or did'
|
|
+ ' not re-encode to its own hash, so the validator set the seals are judged against was not read. This says nothing about the chain;'
|
|
+ ' try another endpoint (RPC=...).' : '')
|
|
+ (propUnmeasured ? (unmeasured || setUnmeasured ? '\n' : '') + 'UNMEASURED: ' + propUnmeasured + ' recent header(s) did not re-encode to the hash the endpoint reports, so their'
|
|
+ ' proposer seals were not judged. This says nothing about the chain; try another endpoint (RPC=...).' : '')
|
|
: 'PROBLEMS: ' + bad + ' of ' + COUNT + ' anchors and ' + manifestBad + ' manifests failed' + (propBad ? ', and ' + propBad + ' proposer seals' : '') + ' - do not take our word for anything; the failure list above is the finding.'
|
|
+ (unmeasured ? ' (' + unmeasured + ' more could not be checked here: npm install @noble/post-quantum)' : '')
|
|
+ (setUnmeasured ? ' (' + setUnmeasured + ' more not judged: their parent header did not re-encode to its own hash on this endpoint)' : '')));
|
|
// 2026-10-01: the tail of this line said, on every chain, that a header between anchors carries only its proposer's seal. That is true only
|
|
// where AIP-22 stage 5 is active (testnet 28001 from block 3,131,000); on chain 2800 headers between anchors still carry the validators'
|
|
// classical ECDSA commit seals, and this tool printed the stage-5 sentence there on every run. It now says what it measured above.
|
|
// 2026-10-07 (1(b)): the line names the FORM of the newest verified certificate. For v1 / v2 it says what it said: the seals carry no
|
|
// round. For v3 it says what was verified: the seals are bound to one consensus round, and how many distinct members of the parent's
|
|
// set sealed it in that round against that set's QBFT quorum. It still certifies what it measured and pronounces no finality.
|
|
if (newestOk !== null) console.log('POST-QUANTUM CERTIFIED: block ' + (newestOk - 1) + ', the parent of anchor ' + newestOk + ', the most recent anchor whose certificate verified here'
|
|
+ (newestForm && newestForm.version === 3 ? ' (certificate v3, round ' + newestForm.round + '):' : ':')
|
|
+ ' the validators whose seals it carries sealed that block post-quantum. '
|
|
+ (newestForm && newestForm.version === 3
|
|
? 'Every seal is bound to consensus round ' + newestForm.round + ' (AERE-PQ-COMMIT-2), and ' + newestForm.members + ' of the ' + newestForm.setSize + ' validators of that block sealed it in that round'
|
|
+ ' (QBFT quorum of that set: ' + newestForm.quorum + ')' + (newestForm.decided ? ': PARENT DECIDED IN ROUND ' + newestForm.round + '.' : ': below the quorum, so not shown decided.')
|
|
+ ' This tool certifies what it verified; it pronounces no finality.'
|
|
: 'The seals carry no consensus round, so this does not by itself prove that the block was decided (AIP-22 erratum, 2026-10-01).')
|
|
+ ' Between anchors no block carries a post-quantum certificate; '
|
|
+ (propChecked === 0
|
|
? 'a header between anchors carries the validators\' classical ECDSA commit seals (AIP-22 stage 5 is not active at these heights on this chain).'
|
|
: 'a header between anchors carries only its proposer\'s seal.'));
|
|
if (!slhDsa) console.log('note: @noble/post-quantum is not installed, so a v2 certificate carrying SLH-DSA seals cannot be verified here (npm install @noble/post-quantum).');
|
|
// ANCHOR_REPORT=<file> (2026-09-27): the most recent anchor that verified here, as JSON, for a tool that builds on it (the AIP-23 proof
|
|
// verifier). parentHash is the hash the certificate's seals were verified over, so such a tool can bind the parent header to it by
|
|
// re-encoding the header, without a second copy of the certificate check. Written whatever the verdict; a caller also needs exit code 0.
|
|
if (process.env.ANCHOR_REPORT) {
|
|
try {
|
|
// 1(b): `certificate` names the form of the newest verified certificate (v1 / v2 / v3 with its round, and for v3 the verified
|
|
// members of the parent's set, the set size, its quorum and `decided` = members >= quorum); the keys before it are unchanged
|
|
fs.writeFileSync(process.env.ANCHOR_REPORT, JSON.stringify({ chainId: CHAIN_ID, anchor: newestOk, parentNumber: newestOk === null ? null : newestOk - 1,
|
|
parentHash: newestParentHash, certificate: newestForm === null ? null : { version: newestForm.version, round: newestForm.round,
|
|
...(newestForm.version === 3 ? { members: newestForm.members, setSize: newestForm.setSize, quorum: newestForm.quorum, decided: newestForm.decided } : {}) } }) + '\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 ? (nothingVerified ? 2 : 0) : (bad === 0 && manifestBad === 0 && propBad === 0 ? 2 : 1);
|