From d56a120462cbda533e128bf7a91a10d8223df86e Mon Sep 17 00:00:00 2001 From: Aere Network Date: Thu, 1 Oct 2026 20:03:55 +0300 Subject: [PATCH] Anchor verifier: the validator set is read from the parent header the certificate seals (bound to its hash), with no 1000-block cache; distinct members counted once per scheme --- tools/verify-anchor.mjs | 82 ++++++++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/tools/verify-anchor.mjs b/tools/verify-anchor.mjs index 4fbda95..c943dc9 100644 --- a/tools/verify-anchor.mjs +++ b/tools/verify-anchor.mjs @@ -17,8 +17,10 @@ // 3. SEALS every Falcon-512 seal in every certificate verifies, in plain JavaScript written // from the spec (falcon512.mjs), against the manifest key of its index, over // keccak256(RLP["AERE-PQ-COMMIT-1", chainId, parentNumber, parentHash]). -// 4. VALIDATORS the validator address bound to each sealing key was in the QBFT validator set at -// that height (qbft_getValidatorsByBlockNumber), so the seals are the validators'. +// 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. @@ -411,15 +413,18 @@ const memberOf = (addr, h) => { return e.link.get(addr) ?? addr; }; -const validatorsCache = new Map(); -async function validatorsAt(h) { - // the identity switch changes the form of the set; one cache window must never hold both forms - const key = Math.floor(h / 1000) + (h >= IDENTITY_BLOCK ? ':id' : ':addr'); - if (!validatorsCache.has(key)) { - const v = await rpc('qbft_getValidatorsByBlockNumber', ['0x' + h.toString(16)]); - validatorsCache.set(key, new Set((v || []).map(a => a.toLowerCase()))); - } - return validatorsCache.get(key); +// 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; @@ -429,6 +434,8 @@ let ok = 0, bad = 0, sealsTotal = 0, sealsValid = 0; // tocmai urmase instructiunea noastra de pe site. Un NEMASURAT nemeritat e la fel de mincinos ca un verde // nemeritat: se numara separat si iese cu alt cod. let unmeasured = 0; +// D-477: anchors whose parent header could not be read or did not re-encode to its own hash, so the set it lists was not read +let setUnmeasured = 0; // 2026-09-26: the most recent anchor whose certificate VERIFIED here (anchors are walked newest first). Its PARENT is the // block the certificate's seals signed (AIP-15/22: the certificate signs the parent's commit message); printed in the summary // so a stranger reads from this tool, not from our word, which block the newest verified certificate covers. Nodes did not serve @@ -452,6 +459,25 @@ for (let i = 0; i < COUNT; i++) { if (items.length < 6 || !items[5].list || items[5].item.length === 0) { console.log(h + ' FAIL: no certificate element'); bad++; continue; } const cert = items[5]; const toNested = (node) => (node.list ? node.item.map(toNested) : Buffer.from(node.item)); + // D-477: the parent's header is the source of the set the seals are judged against, so it must be readable, must re-encode + // (on-chain form: round 0, commit seals empty, certificate and proposer seal omitted) to the hash the endpoint reports for it, + // and that hash must be the parent this anchor names. The first two are this run's gap (another endpoint may serve it); the + // third is a header that is not this anchor's parent, so the anchor is not judged on it. + let pItems = null, pSelf = false; + if (parent && parent.extraData) { + try { + pItems = rlpRead(hex(parent.extraData), 0).item; + pSelf = keccak(headerRlp(parent, rlpEncode([toNested(pItems[0]), toNested(pItems[1]), toNested(pItems[2]), Buffer.alloc(0), []]))).equals(hex(parent.hash)); + } catch (e) { pSelf = false; } + } + if (!pSelf) { + setUnmeasured++; + console.log(h + ' NOT VERIFIED HERE: the header served for block ' + (h - 1) + (parent ? ' does not re-encode to the hash the endpoint reports for it' + : ' could not be read') + ', so the validator set it lists (the set these seals are judged against) cannot be read with confidence.' + + ' This is a gap in THIS run, not a finding about the chain; try another endpoint (RPC=...).'); + continue; + } + if (!hex(parent.hash).equals(hex(b.parentHash))) { bad++; console.log(h + ' FAIL: the header served for block ' + (h - 1) + ' (hash ' + parent.hash + ') is not the parent this anchor names (' + b.parentHash + ')'); continue; } // v1 opens with a LIST ([idx, sig]); v2 opens with the SCALAR 2. Nothing else is accepted. const isV2 = cert.list && cert.item.length === 2 && !cert.item[0].list; if (isV2 && !(cert.item[0].item.length === 1 && cert.item[0].item[0] === 2)) { console.log(h + ' FAIL: certificate opens with an unknown version scalar'); bad++; continue; } @@ -462,9 +488,11 @@ for (let i = 0; i < COUNT; i++) { const man = manifestAt(h); const msg = commitMessage(h - 1, hex(parent.hash)); - const vset = await validatorsAt(h); - let valid = 0, invalid = [], notValidator = [], unknownIndex = [], unknownScheme = [], unbound = [], noKey = []; - const perScheme = {}; // scheme name -> valid seals + const vset = await validatorsAt(h - 1, pItems); + if (!vset) { bad++; console.log(h + ' FAIL: the validator list in element 1 of the header of block ' + (h - 1) + ' (the block these seals sign) is empty or malformed, so no seal can be bound to a validator'); continue; } + let valid = 0, invalid = [], notValidator = [], unknownIndex = [], unknownScheme = [], unbound = [], noKey = [], duplicate = []; + const perScheme = {}; // scheme name -> valid seals, each from a DISTINCT validator + const countedBy = {}; // scheme name -> the validators already counted (D-477: a validator counts once per scheme, as in both clients) const falconIdx = new Set(); const sealNodes = isV2 ? cert.item[1].item : cert.item; const rowsOf = sealNodes.map(s => isV2 @@ -480,7 +508,12 @@ for (let i = 0; i < COUNT; i++) { const v3k = h - 1 >= KEYS_FROM ? v3EpochAt(h - 1) : null; const row = v3k ? v3k.byIndex[idx] : (man && man.rows[idx]); if (!row) { unknownIndex.push(idx); continue; } - if (!vset.has(v3k ? row.id : memberOf(row.addr.toLowerCase(), h))) { notValidator.push(idx); continue; } + 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 { @@ -520,9 +553,9 @@ for (let i = 0; i < COUNT; i++) { && 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; + && notValidator.length === 0 && noKey.length === 0 && unbound.length === 0 && duplicate.length === 0; const summary = Object.entries(perScheme).map(([nm, c]) => c + ' ' + nm).join(' + '); - if (good) { ok++; if (newestOk === null) { newestOk = h; newestParentHash = String(parent.hash).toLowerCase(); } console.log(h + ' OK: bound under block hash' + (isV2 ? ' (v2, scheme-tagged)' : '') + ', ' + n + ' seals (' + summary + '), all valid and from validators of this height (' + (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(); } console.log(h + ' OK: bound under block hash' + (isV2 ? ' (v2, scheme-tagged)' : '') + ', ' + n + ' seals (' + summary + '), all valid and from distinct validators of block ' + (h - 1) + ', the set its own header lists (' + (h - 1 >= KEYS_FROM && v3EpochAt(h - 1) ? 'keys of the registry v3 epoch ' + v3EpochAt(h - 1).bindHeight : 'manifest ' + man.bindHeight) + ')'); } else if (envBlocked) { unmeasured++; console.log(h + ' NOT VERIFIED HERE: bound under block hash, ' + falconValid + ' valid falcon-512 seals, but this' @@ -536,7 +569,8 @@ for (let i = 0; i < COUNT; i++) { (unnamedSchemes.length ? ' scheme(s) not named by the schedule at ' + (h - 1) + ': ' + unnamedSchemes.join(',') : '') + (invalid.length ? ' invalid seals ' + invalid.join(',') : '') + (unbound.length ? ' extra seals without a Falcon seal of the same index ' + unbound.join(',') : '') + (noKey.length ? ' manifest has no key for ' + noKey.join(',') : '') + (unknownScheme.length ? ' unknown scheme tag ' + unknownScheme.join(',') : '') + - (notValidator.length ? ' keys not in validator set at index ' + notValidator.join(',') : '') + (unknownIndex.length ? ' unknown index ' + unknownIndex.join(',') : '') + + (notValidator.length ? ' keys not in validator set at index ' + notValidator.join(',') + ' (the set of block ' + (h - 1) + ', the block these seals sign)' : '') + (unknownIndex.length ? ' unknown index ' + unknownIndex.join(',') : '') + + (duplicate.length ? ' the same validator sealed more than once in one scheme: ' + duplicate.join(',') + ' (counted once; the nodes refuse such a certificate)' : '') + (isV2 && !slhDsa && rowsOf.some(r => r.scheme === 2) ? ' (SLH-DSA verifier not installed: npm install @noble/post-quantum)' : '')); } } @@ -686,11 +720,11 @@ console.log(propChecked === 0 + ' or of the proposer of the round that re-proposed the block' + (propReproposed ? ' (' + propReproposed + ' re-proposal(s), named above)' : '') + (propUnmeasured ? ', ' + propUnmeasured + ' could not be checked here' : '') + (propAbsent ? ' (' + propAbsent + ' earlier headers carry none)' : '')); console.log('seals: ' + sealsValid + '/' + sealsTotal + ' cryptographically valid and validator-bound manifests: ' + (manifests.length - manifestBad) + '/' + manifests.length + ' self-consistent'); -const all = bad === 0 && manifestBad === 0 && unmeasured === 0 && propBad === 0 && propUnmeasured === 0; +const all = bad === 0 && manifestBad === 0 && unmeasured === 0 && setUnmeasured === 0 && propBad === 0 && propUnmeasured === 0; console.log(all ? 'VERIFIED: ' + ok + '/' + COUNT + ' anchor blocks carry a post-quantum certificate bound under the block hash, every seal a valid ' + ([...schemesSeen].sort().map(x => SCHEME_NAMES[x] || ('scheme ' + x)).join(' or ') || 'post-quantum') - + ' signature by a key that the validator of that height bound to itself with its own consensus key.' + + ' 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 @@ -699,10 +733,14 @@ console.log(all + ' verifier is missing. Nothing here says the chain is wrong; install it and run again:\n' + ' npm install @noble/post-quantum' : '') - + (propUnmeasured ? (unmeasured ? '\n' : '') + 'UNMEASURED: ' + propUnmeasured + ' recent header(s) did not re-encode to the hash the endpoint reports, so their' + + (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)' : ''))); + + (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.