O-017: the anchor verifier ships in tools/, the Bouncy Castle licence is cited at its real path, two operator-repository scripts are declared as unpublished
This commit is contained in:
parent
e604ca0387
commit
665b86365e
@ -33,3 +33,5 @@ Unresolvable distinct paths in this repository: **18**.
|
|||||||
- `testnet-public/CITESTE-MA.md` cited in: aere-node/SPEC.md
|
- `testnet-public/CITESTE-MA.md` cited in: aere-node/SPEC.md
|
||||||
- `testnet-public/D-311-DOVADA-2026-09-02.md` cited in: aere-node/SPEC.md
|
- `testnet-public/D-311-DOVADA-2026-09-02.md` cited in: aere-node/SPEC.md
|
||||||
- `VERIFICARE-FORMALA-2026-08-24.md` cited in: aere-node/SPEC.md
|
- `VERIFICARE-FORMALA-2026-08-24.md` cited in: aere-node/SPEC.md
|
||||||
|
- `nethermind-pqc/nethermind-intree/patches/osaka-antet-besu.sh` cited in: aere-node/SPEC.md (operator repository, 2026-09-10: the Osaka header patch for the second client and its gate; not part of the node package)
|
||||||
|
- `scripts/deschise/antetul-osaka-e-al-lantului.sh` cited in: aere-node/SPEC.md (operator repository, 2026-09-10: the Osaka header patch for the second client and its gate; not part of the node package)
|
||||||
|
|||||||
@ -227,7 +227,7 @@ the pure-Java path made one anchor signature cost 1.4 to 2.7 seconds on the cons
|
|||||||
(measured 2026-09-04). Signatures are byte-identical to the original in deterministic mode, which
|
(measured 2026-09-04). Signatures are byte-identical to the original in deterministic mode, which
|
||||||
`SlhDsaFastEngineTest` pins with cross-verification in both directions. The package is regenerated
|
`SlhDsaFastEngineTest` pins with cross-verification in both directions. The package is regenerated
|
||||||
from the published Bouncy Castle sources by a script, not edited by hand, and carries the Bouncy
|
from the published Bouncy Castle sources by a script, not edited by hand, and carries the Bouncy
|
||||||
Castle licence next to it (`slhdsa/LICENSE-BouncyCastle.txt`). What is ours is the framing, the
|
Castle licence next to it (`consensus/common/src/main/java/org/hyperledger/besu/consensus/common/bft/slhdsa/LICENSE-BouncyCastle.txt`). What is ours is the framing, the
|
||||||
registry that maps a validator to its keys, the digest, the scheme schedule and the validation rules.
|
registry that maps a validator to its keys, the digest, the scheme schedule and the validation rules.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
420
tools/verify-anchor.mjs
Normal file
420
tools/verify-anchor.mjs
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// verify-anchor.mjs (v2) - independently verify Aere Network's post-quantum header anchor,
|
||||||
|
// INCLUDING the Falcon-512 signatures, from a public RPC endpoint and the published key manifest.
|
||||||
|
//
|
||||||
|
// v1 of this tool proved the BINDING: every 32nd header carries a certificate of Falcon-512 seals
|
||||||
|
// and a 32-byte digest in the hashed part of the header, and the digest equals
|
||||||
|
// keccak256(RLP["AERE-PQ-ANCHOR-1", chainId, parentNumber, parentHash, certificate]). It did NOT
|
||||||
|
// check that the seals are valid signatures; for that you had to ask the chain's own precompile,
|
||||||
|
// i.e. ask us. v2 removes that trust point. What v2 proves, in order, each leg independent:
|
||||||
|
//
|
||||||
|
// 1. BINDING as v1: strip or alter the certificate and the digest no longer matches.
|
||||||
|
// 2. KEY MANIFEST the published registry of Falcon public keys is self-consistent: every row's
|
||||||
|
// proof of possession (a Falcon signature over the row) verifies with the row's
|
||||||
|
// key, and every row's claim (an ECDSA signature by the validator's own key, EIP-191
|
||||||
|
// version 0) recovers to the row's validator address. So the manifest cannot have
|
||||||
|
// been invented by us: each row is signed by the validator's consensus key.
|
||||||
|
// 3. SEALS every Falcon-512 seal in every certificate verifies, in plain JavaScript written
|
||||||
|
// from the spec (falcon512.mjs), against the manifest key of its index, over
|
||||||
|
// keccak256(RLP["AERE-PQ-COMMIT-1", chainId, parentNumber, parentHash]).
|
||||||
|
// 4. VALIDATORS the validator address bound to each sealing key was in the QBFT validator set at
|
||||||
|
// that height (qbft_getValidatorsByBlockNumber), so the seals are the validators'.
|
||||||
|
// 5. ON-CHAIN PIN each manifest's hash, keccak256(addr||pk over rows), equals slot 0 of the immutable
|
||||||
|
// anchor contract named in the index, and that contract's code is the 11-byte
|
||||||
|
// read-only getter with no SSTORE: the manifest you hold is the one the chain pinned.
|
||||||
|
//
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
const RPC = process.env.RPC || 'https://rpc.aere.network';
|
||||||
|
const MANIFESTS = process.env.MANIFESTS || 'https://aere.network/pq/manifests';
|
||||||
|
// CHAIN_ID din mediu: 2800 (mainnet, implicit) sau 28001 (testnetul public, cu manifestele lui sub
|
||||||
|
// testnet-rpc.aere.network/sarcina/pq/manifests). Aceeasi unealta, acelasi drum de verificare.
|
||||||
|
const CHAIN_ID = Number(process.env.CHAIN_ID || 2800);
|
||||||
|
const ANCHOR_DOMAIN = 'AERE-PQ-ANCHOR-1';
|
||||||
|
// v2 (2026-09-03): the SCHEME-TAGGED certificate RLP[2, [[scheme, idx, sig], ...]] under its own domain.
|
||||||
|
// Wire tags: 0x01 falcon-512, 0x02 slh-dsa-sha2-128s. Every scheme the certificate carries must reach K, and
|
||||||
|
// every non-Falcon seal must ride with a Falcon seal of the same index (the binding both clients enforce).
|
||||||
|
const ANCHOR_DOMAIN_V2 = 'AERE-PQ-ANCHOR-2';
|
||||||
|
const SCHEME_NAMES = { 1: 'falcon-512', 2: 'slh-dsa-sha2-128s' };
|
||||||
|
let slhDsa = null;
|
||||||
|
try { slhDsa = (await import('@noble/post-quantum/slh-dsa.js')).slh_dsa_sha2_128s; } catch { slhDsa = null; }
|
||||||
|
const verifySlhDsa = (pk, msg, sig) => { if (!slhDsa) return false; try { return slhDsa.verify(new Uint8Array(sig), new Uint8Array(msg), new Uint8Array(pk)); } catch { return false; } };
|
||||||
|
const COMMIT_DOMAIN = 'AERE-PQ-COMMIT-1';
|
||||||
|
const POP_DOMAIN = 'AERE-PQ-POP-1';
|
||||||
|
const CLAIM_DOMAIN = 'AERE-PQ-CLAIM-1';
|
||||||
|
const FORMAT_VERSION = 2;
|
||||||
|
// pragul IMPUS de lant depinde de inaltime: 3 de la introducere (2026-08-14), 6 din blocul
|
||||||
|
// 14.961.456 (2026-08-21, cvorum 2f+1). MIN_SEALS din mediu suprascrie, pentru experimente.
|
||||||
|
const K6_HEIGHT = 14961456;
|
||||||
|
// orarul pragului per lant: 2800 = 3 de la introducere, 6 din K6_HEIGHT; 28001 = 0 de la 64, 3 din 128
|
||||||
|
// The chain's own threshold schedule, including the one dated exception: on 2026-09-04, after eleven minutes
|
||||||
|
// of halted chain (every node had restarted with an empty seal store), the fleet was unblocked with a step
|
||||||
|
// 17102384:0,17102416:6, so anchor 17102384 legitimately carries no SLH-DSA seal and 9 Falcon seals. A verifier
|
||||||
|
// that did not know the step would call that anchor wrong; one that hides it would be lying. MIN_SEALS_SCHEDULE
|
||||||
|
// ("h:k,h:k") overrides for experiments and for other chains.
|
||||||
|
const K_DEFAULT = CHAIN_ID === 28001 ? [[64, 0], [128, 3], [168032, 0], [168064, 3]] : [[0, 3], [K6_HEIGHT, 6], [17102384, 0], [17102416, 6]];
|
||||||
|
const K_SCHEDULE = (process.env.MIN_SEALS_SCHEDULE || '').split(',').filter(Boolean).map(p => p.split(':').map(Number)).sort((a, b) => a[0] - b[0]);
|
||||||
|
if (!K_SCHEDULE.length) K_SCHEDULE.push(...K_DEFAULT);
|
||||||
|
// D-338 (2026-09-04): the SCHEMES the certificate must carry, by height of the PARENT (the seals are made when the
|
||||||
|
// parent is committed), exactly as both clients read them. Without this the tool verified only the seals it found,
|
||||||
|
// so a certificate that dropped a whole scheme passed. SCHEME_SCHEDULE ("h:scheme+scheme,...") overrides.
|
||||||
|
const SCHEME_DEFAULT = CHAIN_ID === 28001 ? '168000:falcon-512+slh-dsa-sha2-128s' : '17047568:falcon-512+slh-dsa-sha2-128s';
|
||||||
|
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)]; }).sort((a, b) => a[0] - b[0]);
|
||||||
|
const schemesAt = (hh) => { let out = ['falcon-512']; for (const [from, list] of SCHEME_SCHEDULE) { if (from <= hh) out = list; else break; } return out; };
|
||||||
|
const minSealsAt = (hh) => Number(process.env.MIN_SEALS || K_SCHEDULE.filter(([h]) => hh >= h).map(([, k]) => k).pop());
|
||||||
|
const COUNT = Number(process.env.ANCHORS || 20);
|
||||||
|
|
||||||
|
// ---- minimal RLP ---------------------------------------------------------------------------
|
||||||
|
function rlpRead(buf, pos) {
|
||||||
|
const p = buf[pos];
|
||||||
|
if (p < 0x80) return { item: buf.subarray(pos, pos + 1), next: pos + 1, list: false };
|
||||||
|
if (p < 0xb8) { const l = p - 0x80; return { item: buf.subarray(pos + 1, pos + 1 + l), next: pos + 1 + l, list: false }; }
|
||||||
|
if (p < 0xc0) { const ll = p - 0xb7; let l = 0; for (let i = 0; i < ll; i++) l = l * 256 + buf[pos + 1 + i]; return { item: buf.subarray(pos + 1 + ll, pos + 1 + ll + l), next: pos + 1 + ll + l, list: false }; }
|
||||||
|
let ll = 0, l;
|
||||||
|
if (p < 0xf8) l = p - 0xc0; else { ll = p - 0xf7; l = 0; for (let i = 0; i < ll; i++) l = l * 256 + buf[pos + 1 + i]; }
|
||||||
|
const start = pos + 1 + ll, end = start + l, items = []; let c = start;
|
||||||
|
while (c < end) { const r = rlpRead(buf, c); items.push(r); c = r.next; }
|
||||||
|
return { item: items, next: end, list: true };
|
||||||
|
}
|
||||||
|
function rlpLen(n) { if (n < 56) return Buffer.from([n]); const b = []; let v = n; while (v > 0) { b.unshift(v & 0xff); v = Math.floor(v / 256); } return Buffer.from([b.length, ...b]); }
|
||||||
|
function rlpEncode(x) {
|
||||||
|
if (Buffer.isBuffer(x)) {
|
||||||
|
if (x.length === 1 && x[0] < 0x80) return x;
|
||||||
|
if (x.length < 56) return Buffer.concat([Buffer.from([0x80 + x.length]), x]);
|
||||||
|
const l = rlpLen(x.length); l[0] += 0xb7; return Buffer.concat([l, x]);
|
||||||
|
}
|
||||||
|
const body = Buffer.concat(x.map(rlpEncode));
|
||||||
|
if (body.length < 56) return Buffer.concat([Buffer.from([0xc0 + body.length]), body]);
|
||||||
|
const l = rlpLen(body.length); l[0] += 0xf7; return Buffer.concat([l, body]);
|
||||||
|
}
|
||||||
|
const scalar = (n) => { if (n === 0) return Buffer.alloc(0); const b = []; let v = BigInt(n); while (v > 0n) { b.unshift(Number(v & 0xffn)); v >>= 8n; } return Buffer.from(b); };
|
||||||
|
const hex = (s) => Buffer.from(s.slice(2), 'hex');
|
||||||
|
const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(n)); return b; };
|
||||||
|
const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n); return b; };
|
||||||
|
const keccak = (b) => Buffer.from(keccak_256(b));
|
||||||
|
|
||||||
|
async function rpc(method, params) {
|
||||||
|
const r = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
|
||||||
|
const j = await r.json(); if (j.error) throw new Error(method + ': ' + JSON.stringify(j.error)); return j.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the three messages, exactly as the node computes them -------------------------------------
|
||||||
|
const commitMessage = (parentNumber, parentHash) =>
|
||||||
|
keccak(rlpEncode([Buffer.from(COMMIT_DOMAIN, 'ascii'), scalar(CHAIN_ID), scalar(parentNumber), parentHash]));
|
||||||
|
// PqRegistryBinding.bindingPreimage
|
||||||
|
function bindingPreimage(bindHeight, count, index, address, publicKey) {
|
||||||
|
return Buffer.concat([Buffer.from(POP_DOMAIN, 'ascii'), Buffer.from([FORMAT_VERSION]), u64(CHAIN_ID), u64(bindHeight),
|
||||||
|
u32(count), u32(index), address, u32(publicKey.length), publicKey]);
|
||||||
|
}
|
||||||
|
const possessionDigest = (...a) => keccak(bindingPreimage(...a));
|
||||||
|
function claimDigest(...a) {
|
||||||
|
const ctx = keccak(Buffer.concat([Buffer.from(CLAIM_DOMAIN, 'ascii'), u64(CHAIN_ID)])).subarray(12, 32);
|
||||||
|
return keccak(Buffer.concat([Buffer.from([0x19, 0x00]), ctx, bindingPreimage(...a)]));
|
||||||
|
}
|
||||||
|
function recoverClaim(digest, sig65) {
|
||||||
|
if (sig65.length !== 65) return null;
|
||||||
|
let rec = sig65[64]; if (rec >= 27) rec -= 27;
|
||||||
|
if (rec !== 0 && rec !== 1) return null;
|
||||||
|
try {
|
||||||
|
const s = secp256k1.Signature.fromCompact(sig65.subarray(0, 64)).addRecoveryBit(rec);
|
||||||
|
const pub = s.recoverPublicKey(digest).toRawBytes(false); // 65 bytes, 0x04 || x || y
|
||||||
|
return keccak(Buffer.from(pub.subarray(1))).subarray(12, 32);
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- manifests: fetch or read, then prove each one self-consistent ------------------------------
|
||||||
|
async function loadText(loc) {
|
||||||
|
if (/^https?:\/\//.test(loc)) { const r = await fetch(loc); if (!r.ok) throw new Error('HTTP ' + r.status + ' for ' + loc); return r.text(); }
|
||||||
|
return fs.readFileSync(loc, 'utf8');
|
||||||
|
}
|
||||||
|
const isUrl = /^https?:\/\//.test(MANIFESTS);
|
||||||
|
const join = (a, b) => isUrl ? a.replace(/\/$/, '') + '/' + b : path.join(a, b);
|
||||||
|
|
||||||
|
let index;
|
||||||
|
try { index = JSON.parse(await loadText(join(MANIFESTS, 'index.json'))); }
|
||||||
|
catch (e) { console.error('FAIL: cannot load manifest index from ' + MANIFESTS + ': ' + e.message); process.exit(1); }
|
||||||
|
if (!index || !Array.isArray(index.manifests) || !index.manifests.length) { console.error('FAIL: manifest index has no entries'); process.exit(1); }
|
||||||
|
if (Number(index.chainId) !== CHAIN_ID) { console.error('FAIL: manifest index is for chain ' + index.chainId); process.exit(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;
|
||||||
|
for (const m of index.manifests) {
|
||||||
|
const text = await loadText(join(MANIFESTS, m.file));
|
||||||
|
const digest = Buffer.from(sha256(Buffer.from(text, 'utf8'))).toString('hex');
|
||||||
|
const man = JSON.parse(text);
|
||||||
|
const shaOk = digest === String(m.sha256).toLowerCase();
|
||||||
|
let rowsOk = 0, rowsBad = [];
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 0; i < man.count; i++) {
|
||||||
|
const row = man[String(i)];
|
||||||
|
const addr = hex(row.addr), pk = fromHex(row.pk);
|
||||||
|
const pop = fromHex(row.pop), claim = hex(row.claim.startsWith('0x') ? row.claim : '0x' + row.claim);
|
||||||
|
const popOk = verifyFalcon512(pk, possessionDigest(man.bindHeight, man.count, i, addr, Buffer.from(pk)), pop);
|
||||||
|
const rec = recoverClaim(claimDigest(man.bindHeight, man.count, i, addr, Buffer.from(pk)), claim);
|
||||||
|
const claimOk = rec !== null && rec.equals(addr);
|
||||||
|
if (popOk && claimOk) rowsOk++; else rowsBad.push(i + (popOk ? '' : ':possession') + (claimOk ? '' : ':claim'));
|
||||||
|
// v2: a row may carry extra scheme keys ({"keys": {"slh-dsa-sha2-128s": "0x.."}}); absent = Falcon only
|
||||||
|
const keys = {};
|
||||||
|
if (row.keys && typeof row.keys === 'object') for (const [id, kh] of Object.entries(row.keys)) keys[id] = fromHex(kh);
|
||||||
|
rows.push({ index: i, addr: '0x' + addr.toString('hex'), pk, keys, popOk, claimOk });
|
||||||
|
}
|
||||||
|
// leg 5: the on-chain pin. keccak256(addr20 || pk, row by row) must equal slot 0 of the immutable
|
||||||
|
// anchor contract, and that contract must be the 11-byte read-only getter (no SSTORE anywhere).
|
||||||
|
let pinOk = false, pinNote = 'no anchorContract in index';
|
||||||
|
if (!m.anchorContract && m.pin === 'genesis') {
|
||||||
|
// testnetul pinuieste manifestul in configuratia genezei (pqRegistryHash), care nu se citeste prin RPC:
|
||||||
|
// legatura e in fisierul de geneza pe care il rulezi deja, nu una pe care unealta o poate verifica aici.
|
||||||
|
pinOk = true; pinNote = 'pinned in the genesis config (pqRegistryHash), not readable over RPC';
|
||||||
|
}
|
||||||
|
if (m.anchorContract) {
|
||||||
|
const pre = Buffer.concat(rows.flatMap(r => [hex(r.addr), Buffer.from(r.pk)]));
|
||||||
|
const want = '0x' + keccak(pre).toString('hex');
|
||||||
|
try {
|
||||||
|
const slot0 = await rpc('eth_getStorageAt', [m.anchorContract, '0x0', 'latest']);
|
||||||
|
const code = await rpc('eth_getCode', [m.anchorContract, 'latest']);
|
||||||
|
const getter = code.toLowerCase() === '0x60005460005260206000f3';
|
||||||
|
pinOk = slot0.toLowerCase() === want.toLowerCase() && getter;
|
||||||
|
pinNote = (slot0.toLowerCase() === want.toLowerCase() ? 'slot 0 matches' : 'slot 0 MISMATCH (' + slot0.slice(0, 10) + ' vs ' + want.slice(0, 10) + ')') +
|
||||||
|
(getter ? ', contract is the read-only getter' : ', contract code is NOT the known read-only getter');
|
||||||
|
} catch (e) { pinNote = 'pin unmeasured: ' + e.message; }
|
||||||
|
}
|
||||||
|
const good = shaOk && pinOk && rowsOk === man.count && Number(man.chainId) === CHAIN_ID && Number(man.formatVersion) === FORMAT_VERSION;
|
||||||
|
if (!good) manifestBad++;
|
||||||
|
console.log(' bindHeight ' + String(man.bindHeight).padEnd(10) + ' ' + m.file.padEnd(26) + ' rows ' + rowsOk + '/' + man.count +
|
||||||
|
' sha256 ' + (shaOk ? 'matches index' : 'MISMATCH') + ' on-chain pin: ' + pinNote + (rowsBad.length ? ' BAD rows: ' + rowsBad.join(' ') : '') + (good ? '' : ' <-- FAIL'));
|
||||||
|
manifests.push({ bindHeight: Number(man.bindHeight), count: man.count, rows, good });
|
||||||
|
}
|
||||||
|
manifests.sort((a, b) => a.bindHeight - b.bindHeight);
|
||||||
|
const manifestAt = (h) => [...manifests].reverse().find(m => m.bindHeight <= h) || null;
|
||||||
|
|
||||||
|
// ---- anchors --------------------------------------------------------------------------------
|
||||||
|
const top = process.argv[2] ? Number(process.argv[2]) : parseInt(await rpc('eth_blockNumber', []), 16);
|
||||||
|
console.log('');
|
||||||
|
console.log('ANCHORS: ' + COUNT + ' ending at or below ' + top);
|
||||||
|
// Anchor heights are arithmetic, not guessed from the header: on 2800 every 32nd block from 13,014,000
|
||||||
|
// (h mod 32 == 16, universal from 13,889,296); on 28001 every 32nd block from 64 (h mod 32 == 0). The header
|
||||||
|
// is then required to LOOK like an anchor (32-byte digest that is not the client's vanity string), so a wrong
|
||||||
|
// schedule fails loudly instead of silently picking a non-anchor. Measured 2026-09-03: the old vanity scan chose
|
||||||
|
// 63399 on the testnet, a non-anchor, because that client's vanity never contains 'besu'.
|
||||||
|
const ANCHOR_MOD = Number(process.env.ANCHOR_MOD || (CHAIN_ID === 28001 ? 0 : 16));
|
||||||
|
const ANCHOR_FROM = Number(process.env.ANCHOR_FROM || (CHAIN_ID === 28001 ? 64 : 13014000));
|
||||||
|
// D-336 (2026-09-04): the interval can change at scheduled heights ("h:interval,h:interval", ANCHOR_INTERVAL_SCHEDULE);
|
||||||
|
// every scheduled interval is a multiple of 32 and every change height is on the new grid, so the anchors of a later
|
||||||
|
// regime are a subset of the earlier ones. Default: no change (every 32nd block). Same rule as both clients.
|
||||||
|
// D-336 (2026-09-04): the interval can change at scheduled heights ("h:interval,h:interval"); every scheduled
|
||||||
|
// interval is a multiple of 32 and every change height is on the new grid, so the anchors of a later regime are
|
||||||
|
// a subset of the earlier ones. Same rule as both clients.
|
||||||
|
//
|
||||||
|
// 2026-09-06: THE SCHEDULE IS NO LONGER SOMETHING YOU HAVE TO KNOW. Until today it came only from the
|
||||||
|
// ANCHOR_INTERVAL_SCHEDULE environment variable and defaulted to "every 32nd block". Chain 2800 moved to every
|
||||||
|
// 128th on 2026-09-05, so this tool - run the way a stranger runs it, with no variables - reported
|
||||||
|
// "PROBLEMS: 15 of 20 anchors failed" against a perfectly healthy chain: the 15 were ordinary blocks it had
|
||||||
|
// picked off the old grid. A verifier that cries wolf on a healthy chain is worse than no verifier.
|
||||||
|
//
|
||||||
|
// The grid is still ARITHMETIC, never guessed per header (a vanity scan once picked a non-anchor on the
|
||||||
|
// testnet). Only the STEP is now measured on the chain itself: walk back from the tip to the two most recent
|
||||||
|
// headers that carry a certificate element, take their distance, and require it to be a multiple of 32 that
|
||||||
|
// divides the distance from ANCHOR_FROM. From there every sampled height is arithmetic, and a height on that
|
||||||
|
// grid WITHOUT a certificate is still a loud failure. A chain that stopped anchoring cannot produce two
|
||||||
|
// regularly spaced certificates, so it cannot buy itself a green run here: it fails to derive at all.
|
||||||
|
const INTERVAL_SCHEDULE = (process.env.ANCHOR_INTERVAL_SCHEDULE || '').split(',').filter(Boolean)
|
||||||
|
.map(p => p.split(':').map(x => Number(x.trim()))).sort((a, b) => a[0] - b[0]);
|
||||||
|
for (const [h, iv] of INTERVAL_SCHEDULE) {
|
||||||
|
if (!(iv > 0 && iv % 32 === 0) || h < ANCHOR_FROM || (h - ANCHOR_FROM) % iv !== 0) { console.error('FAIL: ANCHOR_INTERVAL_SCHEDULE entry ' + h + ':' + iv + ' is not a multiple of 32 on the grid from ' + ANCHOR_FROM); process.exit(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;
|
||||||
|
try { return rlpRead(hex(b.extraData), 0).item.length >= 6; } catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let DERIVED_INTERVAL = 0;
|
||||||
|
if (!INTERVAL_SCHEDULE.length) {
|
||||||
|
const seen = [];
|
||||||
|
const LOOKBACK = Number(process.env.ANCHOR_LOOKBACK || 1100); // two 512-intervals and a margin
|
||||||
|
for (let h = top; h > top - LOOKBACK && h > ANCHOR_FROM && seen.length < 2; h--) {
|
||||||
|
if (await carriesCertificate(h)) seen.push(h);
|
||||||
|
}
|
||||||
|
if (seen.length < 2) {
|
||||||
|
console.error('FAIL: only ' + seen.length + ' certificate-carrying header(s) in the ' + LOOKBACK + ' blocks below ' + top + ',');
|
||||||
|
console.error(' so the anchor spacing cannot be measured on this chain. Either anchoring has stopped, or this');
|
||||||
|
console.error(' endpoint is not serving chain ' + CHAIN_ID + '. Pass ANCHOR_INTERVAL_SCHEDULE=h:interval to state it explicitly.');
|
||||||
|
process.exit(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.');
|
||||||
|
process.exit(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 + ')'); process.exit(1); }
|
||||||
|
{
|
||||||
|
const b = await rpc('eth_getBlockByNumber', ['0x' + anchor.toString(16), false]);
|
||||||
|
const vanity = rlpRead(hex(b.extraData), 0).item[0].item;
|
||||||
|
if (vanity.length !== 32 || Buffer.from(vanity).includes(Buffer.from('besu'))) { console.error('FAIL: block ' + anchor + ' does not look like an anchor (vanity is the client string, not a digest)'); process.exit(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatorsCache = new Map();
|
||||||
|
async function validatorsAt(h) {
|
||||||
|
const key = Math.floor(h / 1000);
|
||||||
|
if (!validatorsCache.has(key)) {
|
||||||
|
const v = await rpc('qbft_getValidatorsByBlockNumber', ['0x' + h.toString(16)]);
|
||||||
|
validatorsCache.set(key, new Set((v || []).map(a => a.toLowerCase())));
|
||||||
|
}
|
||||||
|
return validatorsCache.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = 0, bad = 0, sealsTotal = 0, sealsValid = 0;
|
||||||
|
// 2026-09-06: 'nu am putut verifica AICI' nu e 'ancora e invalida', si a fost raportat asa. Fara
|
||||||
|
// @noble/post-quantum instalat, o ancora v2 sanatoasa (9 Falcon + 9 SLH-DSA) iesea in lista drept FAIL si
|
||||||
|
// rezumatul spunea 'PROBLEMS: 20 of 20 anchors failed' - despre un lant perfect sanatos, catre un strain care
|
||||||
|
// tocmai urmase instructiunea noastra de pe site. Un NEMASURAT nemeritat e la fel de mincinos ca un verde
|
||||||
|
// nemeritat: se numara separat si iese cu alt cod.
|
||||||
|
let unmeasured = 0;
|
||||||
|
// ce scheme au fost CHIAR verificate: rezumatul final nu are voie sa numeasca o schema pe care nu a vazut-o
|
||||||
|
const schemesSeen = new Set();
|
||||||
|
let cursor = anchor;
|
||||||
|
for (let i = 0; i < COUNT; i++) {
|
||||||
|
const h = cursor; if (i > 0 && h < ANCHOR_FROM) break;
|
||||||
|
cursor = prevAnchor(h);
|
||||||
|
const b = await rpc('eth_getBlockByNumber', ['0x' + h.toString(16), false]);
|
||||||
|
const parent = await rpc('eth_getBlockByNumber', ['0x' + (h - 1).toString(16), false]);
|
||||||
|
const items = rlpRead(hex(b.extraData), 0).item;
|
||||||
|
const vanity = Buffer.from(items[0].item);
|
||||||
|
if (items.length < 6) { console.log(h + ' FAIL: no certificate element'); bad++; continue; }
|
||||||
|
const cert = items[5];
|
||||||
|
const toNested = (node) => (node.list ? node.item.map(toNested) : Buffer.from(node.item));
|
||||||
|
// v1 opens with a LIST ([idx, sig]); v2 opens with the SCALAR 2. Nothing else is accepted.
|
||||||
|
const isV2 = cert.list && cert.item.length === 2 && !cert.item[0].list;
|
||||||
|
if (isV2 && !(cert.item[0].item.length === 1 && cert.item[0].item[0] === 2)) { console.log(h + ' FAIL: certificate opens with an unknown version scalar'); bad++; continue; }
|
||||||
|
const digest = isV2
|
||||||
|
? keccak(rlpEncode([Buffer.from(ANCHOR_DOMAIN_V2, 'ascii'), scalar(CHAIN_ID), scalar(h - 1), hex(parent.hash), rlpEncode(toNested(cert))]))
|
||||||
|
: keccak(rlpEncode([Buffer.from(ANCHOR_DOMAIN, 'ascii'), scalar(CHAIN_ID), scalar(h - 1), hex(parent.hash), toNested(cert)]));
|
||||||
|
const bound = digest.equals(vanity);
|
||||||
|
|
||||||
|
const man = manifestAt(h);
|
||||||
|
const msg = commitMessage(h - 1, hex(parent.hash));
|
||||||
|
const vset = await validatorsAt(h);
|
||||||
|
let valid = 0, invalid = [], notValidator = [], unknownIndex = [], unknownScheme = [], unbound = [], noKey = [];
|
||||||
|
const perScheme = {}; // scheme name -> valid seals
|
||||||
|
const falconIdx = new Set();
|
||||||
|
const sealNodes = isV2 ? cert.item[1].item : cert.item;
|
||||||
|
const rowsOf = sealNodes.map(s => isV2
|
||||||
|
? { scheme: (s.item[0].item.length ? s.item[0].item[0] : 0), idx: (s.item[1].item.length ? s.item[1].item[0] : 0), sig: new Uint8Array(s.item[2].item) }
|
||||||
|
: { scheme: 1, idx: (s.item[0].item.length ? s.item[0].item[0] : 0), sig: new Uint8Array(s.item[1].item) });
|
||||||
|
for (const r of rowsOf) if (r.scheme === 1) falconIdx.add(r.idx);
|
||||||
|
for (const r of rowsOf) schemesSeen.add(r.scheme);
|
||||||
|
for (const { scheme, idx, sig } of rowsOf) {
|
||||||
|
sealsTotal++;
|
||||||
|
const name = SCHEME_NAMES[scheme];
|
||||||
|
if (!name) { unknownScheme.push(scheme); continue; }
|
||||||
|
const row = man && man.rows[idx];
|
||||||
|
if (!row) { unknownIndex.push(idx); continue; }
|
||||||
|
if (!vset.has(row.addr.toLowerCase())) { notValidator.push(idx); continue; }
|
||||||
|
if (scheme === 1) {
|
||||||
|
if (!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;
|
||||||
|
const shortSchemes = Object.entries(perScheme).filter(([, c]) => c < MIN_SEALS).map(([nm, c]) => nm + ':' + c);
|
||||||
|
// D-338: every scheme the schedule names at the parent's height 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.
|
||||||
|
const requiredSchemes = MIN_SEALS > 0 ? schemesAt(h - 1) : [];
|
||||||
|
const missingSchemes = requiredSchemes.filter(nm => !(perScheme[nm] > 0));
|
||||||
|
const schemesOk = falconValid >= MIN_SEALS && shortSchemes.length === 0 && missingSchemes.length === 0 && (!isV2 || slhDsa || !rowsOf.some(r => r.scheme === 2));
|
||||||
|
const good = bound && man && man.good && schemesOk && valid === n;
|
||||||
|
// blocat DE MEDIU: tot ce se putea verifica aici e in regula, si singurul lucru care lipseste e verificatorul
|
||||||
|
// SLH-DSA. Orice alt defect (digest, manifest, sigiliu invalid, prag) il scoate din categoria asta.
|
||||||
|
// ce NU se poate verifica pe masina asta: fara @noble/post-quantum, orice schema in afara de falcon-512.
|
||||||
|
// Sigiliile ei ajung in 'invalid' si schema ei in 'missingSchemes' - amandoua sunt lipsuri ale RULARII, nu
|
||||||
|
// ale lantului, si numai ele au voie sa ramana pentru ca blocul sa fie NEMASURAT in loc de ESUAT.
|
||||||
|
const unverifiableHere = (nm) => !slhDsa && nm !== 'falcon-512';
|
||||||
|
const envBlocked = !good && isV2 && !slhDsa && rowsOf.some(r => r.scheme === 2)
|
||||||
|
&& bound && man && man.good && falconValid >= MIN_SEALS
|
||||||
|
&& missingSchemes.every(unverifiableHere)
|
||||||
|
&& invalid.every(x => unverifiableHere(String(x).split('@')[0]))
|
||||||
|
&& unknownScheme.length === 0 && unknownIndex.length === 0
|
||||||
|
&& notValidator.length === 0 && noKey.length === 0 && unbound.length === 0;
|
||||||
|
const summary = Object.entries(perScheme).map(([nm, c]) => c + ' ' + nm).join(' + ');
|
||||||
|
if (good) { ok++; console.log(h + ' OK: bound under block hash' + (isV2 ? ' (v2, scheme-tagged)' : '') + ', ' + n + ' seals (' + summary + '), all valid and from validators of this height (manifest ' + man.bindHeight + ')'); }
|
||||||
|
else if (envBlocked) {
|
||||||
|
unmeasured++;
|
||||||
|
console.log(h + ' NOT VERIFIED HERE: bound under block hash, ' + falconValid + ' valid falcon-512 seals, but this'
|
||||||
|
+ ' machine has no SLH-DSA verifier, so the ' + rowsOf.filter(r => r.scheme === 2).length + ' slh-dsa seals were not checked'
|
||||||
|
+ ' (npm install @noble/post-quantum). This is a gap in THIS run, not a finding about the chain.');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
bad++;
|
||||||
|
console.log(h + ' FAIL:' + (bound ? '' : ' digest mismatch') + (man ? (man.good ? '' : ' manifest not self-consistent') : ' no manifest for this height') +
|
||||||
|
(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 ' + (h - 1) + ')' : '') +
|
||||||
|
(invalid.length ? ' invalid seals ' + invalid.join(',') : '') + (unbound.length ? ' extra seals without a Falcon seal of the same index ' + unbound.join(',') : '') +
|
||||||
|
(noKey.length ? ' manifest has no key for ' + noKey.join(',') : '') + (unknownScheme.length ? ' unknown scheme tag ' + unknownScheme.join(',') : '') +
|
||||||
|
(notValidator.length ? ' keys not in validator set at index ' + notValidator.join(',') : '') + (unknownIndex.length ? ' unknown index ' + unknownIndex.join(',') : '') +
|
||||||
|
(isV2 && !slhDsa && rowsOf.some(r => r.scheme === 2) ? ' (SLH-DSA verifier not installed: npm install @noble/post-quantum)' : ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
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;
|
||||||
|
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.'
|
||||||
|
: (bad === 0 && manifestBad === 0
|
||||||
|
? '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'
|
||||||
|
: 'PROBLEMS: ' + bad + ' of ' + COUNT + ' anchors and ' + manifestBad + ' manifests failed - do not take our word for anything; the failure list above is the finding.'
|
||||||
|
+ (unmeasured ? ' (' + unmeasured + ' more could not be checked here: npm install @noble/post-quantum)' : '')));
|
||||||
|
if (!slhDsa) console.log('note: @noble/post-quantum is not installed, so a v2 certificate carrying SLH-DSA seals cannot be verified here (npm install @noble/post-quantum).');
|
||||||
|
// three states, three exit codes: 0 verified, 1 the chain failed a check, 2 this machine could not measure.
|
||||||
|
process.exit(all ? 0 : (bad === 0 && manifestBad === 0 ? 2 : 1));
|
||||||
Loading…
Reference in New Issue
Block a user