diff --git a/tools/verify-anchor.mjs b/tools/verify-anchor.mjs index ac3318b..ad2570f 100644 --- a/tools/verify-anchor.mjs +++ b/tools/verify-anchor.mjs @@ -33,6 +33,16 @@ // 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 @@ -83,6 +93,25 @@ 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. +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 ? null : 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; @@ -201,6 +230,13 @@ async function rpc(method, params) { // ---- 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), @@ -498,6 +534,7 @@ let lapseNotEnforced = 0; 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; @@ -528,7 +565,12 @@ for (let i = 0; i < COUNT; i++) { + ' is accepted as written; checked only that its ' + pairs.length + ' seal indices are distinct (' + (pairs.join(',') || 'no seals') + '). Not verified.'); continue; } - if (items.length < 6 || !items[5].list || items[5].item.length === 0) { console.log(h + ' FAIL: no certificate element'); bad++; continue; } + if (items.length < 6 || !items[5].list || 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 @@ -550,6 +592,139 @@ for (let i = 0; i < COUNT; i++) { 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, 02), the decoding (03), the digest (04) + 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=)' : '=' + 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')); + if (ib.length > 4) return refuse('AERE-PQ-V3-03', 'row ' + k + ': the index does not fit 32 bits'); + 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, row by row in canonical order, the binding (06) and the signature (07), an extra also + // 08; 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. + 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 row, 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); + for (const row of (v3.code ? [] : v3.rows)) { + const name = SCHEME_NAMES[row.scheme]; + sealsTotal++; schemesSeen.add(row.scheme); + 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 (row.scheme === 1) { + 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; } + 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; } @@ -558,10 +733,7 @@ for (let i = 0; i < COUNT; i++) { : keccak(rlpEncode([Buffer.from(ANCHOR_DOMAIN, 'ascii'), scalar(CHAIN_ID), scalar(h - 1), hex(parent.hash), toNested(cert)])); const bound = digest.equals(vanity); - const man = manifestAt(h); const msg = commitMessage(h - 1, hex(parent.hash)); - const vset = await validatorsAt(h - 1, pItems); - if (!vset) { bad++; console.log(h + ' FAIL: the validator list in element 1 of the header of block ' + (h - 1) + ' (the block these seals sign) is empty or malformed, so no seal can be bound to a validator'); continue; } let valid = 0, invalid = [], notValidator = [], unknownIndex = [], unknownScheme = [], unbound = [], noKey = [], duplicate = []; const perScheme = {}; // scheme name -> valid seals, each from a DISTINCT validator const countedBy = {}; // scheme name -> the validators already counted (D-477: a validator counts once per scheme, as in both clients) @@ -633,7 +805,7 @@ for (let i = 0; i < COUNT; i++) { && 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; } 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) + ')' + 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++; @@ -877,9 +1049,18 @@ console.log(nothingVerified // 2026-10-01: the tail of this line said, on every chain, that a header between anchors carries only its proposer's seal. That is true only // where AIP-22 stage 5 is active (testnet 28001 from block 3,131,000); on chain 2800 headers between anchors still carry the validators' // classical ECDSA commit seals, and this tool printed the stage-5 sentence there on every run. It now says what it measured above. -if (newestOk !== null) console.log('POST-QUANTUM CERTIFIED: block ' + (newestOk - 1) + ', the parent of anchor ' + newestOk + ', the most recent anchor whose certificate verified here:' - + ' the validators whose seals it carries sealed that block post-quantum. The seals carry no consensus round, so this does not by itself prove' - + ' that the block was decided (AIP-22 erratum, 2026-10-01). Between anchors no block carries a post-quantum certificate; ' +// 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.')); @@ -889,8 +1070,11 @@ if (!slhDsa) console.log('note: @noble/post-quantum is not installed, so a v2 ce // 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 }) + '\n'); + 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.