tools/verify-anchor.mjs: the certificate form is judged by height, as the clients do (v1 below V, v2 from V: 17,047,600 on 2800, 176,000 on 28001; v3 from H_r), and the v1/v2 certificate rows are decoded canonically (whole scalar values, known scheme tag, index in four bytes, non-empty signature, at most 64 rows in v2); a v1 height accepts Falcon-512 only. Before this change the tool took the form from the bytes below H_r and accused 20 of 20 healthy v1 anchors of testnet 28001 (175,360 to 175,968). Measured: chain 2800 201 of 201 anchors past V verified, testnet 28001 40 of 40 past V; same verdict as the previous tool on anchor 23,452,880 of chain 2800.

This commit is contained in:
Aere Network 2026-10-19 06:54:55 +03:00
parent b1c6c6d1e9
commit 3ff53e4c05

View File

@ -43,6 +43,17 @@
// 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.
// FORM (2026-10-19, AIP-25 findings C4 and C5) the form of a certificate is the chain's, by height, as both clients read it
// (certificateFormAt): v1, RLP[[index, signature], ...] with Falcon-512 seals only, below the v2 height V; v2,
// RLP[2, [[scheme, index, signature], ...]], from V; v3 from H_r. V per chain is in ANCHOR_V2_DEFAULT below (17,047,600 on
// 2800, 176,000 on 28001; another chain has none, which both clients read as "never"); ANCHOR_V2_BLOCK=<height|none>
// overrides it. A form that does not match its height is refused by name, in both directions, and so is an EMPTY v2
// certificate at any threshold. The rows of a v1 / v2 certificate are decoded as the clients decode them: exactly two /
// three scalars per row, every scalar canonical (no leading zero byte; 0 is the empty string), the scheme tag one of the
// known ids (0x01, 0x02), the index a whole integer 0..2^31-1 in at most 4 bytes, the signature not empty, at most 64 rows
// in a v2, and the bytes carried the canonical RLP encoding of what was read. Until this date this tool read the tag and
// the index from their FIRST byte (the tag 0x0101 was Falcon-512, the index 0x0100 was 1) and took the form from the bytes
// below H_r, so an endpoint could serve an anchor that both clients refuse and this tool admitted it.
//
// 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
@ -55,6 +66,7 @@
// 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
// ANCHOR_V2_BLOCK=17047600 node verify-anchor.mjs # the v2 height V (default: the chain's own, from the table; "none" = never)
//
// Exit 0 only when every leg holds on every anchor checked. Any failure is the finding.
@ -89,6 +101,7 @@ const ANCHOR_DOMAIN = 'AERE-PQ-ANCHOR-1';
// 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' };
const MAX_V2_ROWS = 64; // PqAnchorV2.MAX_SEALS in both clients
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; } };
@ -114,8 +127,20 @@ 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);
// V per chain (2026-10-19, AIP-25 C5, D-539): aere.pq.anchorV2Block, the height from which every anchor below H_r carries the
// scheme-tagged v2 certificate. Both clients: "A v2 certificate is refused below this height and a v1 one from it"
// (PqAnchorConfig.PROPERTY_ANCHOR_V2_BLOCK; PqAnchorDigestRule / PqAnchorSealsRule and PqAnchorRule.cs judge the form by height). 2800:
// 17,047,600 (the first hybrid anchor, 2026-09-04); 28001: 176,000 (2026-09-03); the same values cloud-gateway/orar-ancore.mjs carries
// for the consumers (FORMA_V2_2800, formaV2DeLa of 28001). Another chain has none, which both clients read as never (v1 below H_r).
// ANCHOR_V2_BLOCK overrides (a height, or "none"). Until this date this tool did not know V: below H_r it took the form from the bytes.
const ANCHOR_V2_DEFAULT = CHAIN_ID === 2800 ? 17047600 : CHAIN_ID === 28001 ? 176000 : null;
const ANCHOR_V2_BLOCK = process.env.ANCHOR_V2_BLOCK === undefined ? ANCHOR_V2_DEFAULT
: (/^(none|unset|)$/i.test(process.env.ANCHOR_V2_BLOCK) ? null : Number(process.env.ANCHOR_V2_BLOCK));
if (ANCHOR_V2_BLOCK !== null && !(Number.isInteger(ANCHOR_V2_BLOCK) && ANCHOR_V2_BLOCK > 0)) { console.error('FAIL: ANCHOR_V2_BLOCK must be a positive block height or "none"'); stop(1); }
// H_r is above the v2 height on every chain that has one; both clients refuse any other configuration at startup (ROUND-01)
if (COMMIT_ROUND_BLOCK !== null && (ANCHOR_V2_BLOCK === null || COMMIT_ROUND_BLOCK <= ANCHOR_V2_BLOCK)) { console.error('FAIL: COMMIT_ROUND_BLOCK=' + COMMIT_ROUND_BLOCK + ' needs the v2 height below it (ANCHOR_V2_BLOCK is ' + (ANCHOR_V2_BLOCK === null ? 'unset' : ANCHOR_V2_BLOCK) + '); both clients refuse that configuration at startup'); stop(1); }
// the form the certificate of anchor a must have (PROIECT-1B 2.1, certificateFormAt in both clients): 3 from H_r, 2 from V, 1 below V
const certificateFormAt = (a) => (COMMIT_ROUND_BLOCK !== null && a >= COMMIT_ROUND_BLOCK ? 3 : ANCHOR_V2_BLOCK !== null && a >= ANCHOR_V2_BLOCK ? 2 : 1);
const POP_DOMAIN = 'AERE-PQ-POP-1';
const CLAIM_DOMAIN = 'AERE-PQ-CLAIM-1';
const FORMAT_VERSION = 2;
@ -239,8 +264,43 @@ 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);
// the value of a row's scheme tag or index on the v1 / v2 path: the WHOLE scalar (D-538), never its first byte; its width and form are
// judged before it is read (decodeV1V2)
const rowScalar = (b) => Number(scalarOf(b));
// 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);
// D-538 (2026-10-19, AIP-25 C4): the rows of a v1 or v2 certificate, decoded as both clients decode them: the Besu-derived client through
// its strict RLP reader (QbftExtraDataCodec: readIntScalar refuses a leading zero byte and more than 4 bytes, and the whole extraData must
// re-encode to the bytes received; PqAnchorV2.decode: version 2, at most 64 rows, scheme tag within a byte and known, index not negative),
// the second client through PqAnchor.DecodeCertificate (v1: no leading zero byte, the lone 0x00 included, index at most int.MaxValue,
// signature not empty) and PqAnchorV2.Decode (v2: exactly [2, rows], rows of exactly three scalars, the same scalar rules, known tags,
// signature not empty, the canonical round trip). `node` is element 6 as read by rlpRead, `raw` its bytes as carried. Returns
// { rows: [{ scheme, idx, sig }] } or { why }, the first refusal in the order below. The ORDER of the rows is judged by the caller, as before.
function decodeV1V2(node, isV2, raw) {
const tn = (x) => (x.list ? x.item.map(tn) : Buffer.from(x.item));
if (isV2) {
const v = Buffer.from(node.item[0].item);
const shape = !(v.length === 1 && v[0] === 2) ? 'the certificate opens with the scalar ' + (v.length ? '0x' + v.toString('hex') : '(the empty string)') + ' where a v2 certificate opens with the version 2'
: node.item.length !== 2 ? 'a v2 certificate has two elements [2, rows]; this one has ' + node.item.length
: !node.item[1].list ? 'the rows element of a v2 certificate is not a list'
: node.item[1].item.length > MAX_V2_ROWS ? node.item[1].item.length + ' rows, above ' + MAX_V2_ROWS + ', the most a v2 certificate carries' : null;
if (shape) return { why: shape };
}
const rows = [];
const lead = (x) => x !== null && x.length > 0 && x[0] === 0;
for (const [k, s] of (isV2 ? node.item[1].item : node.item).entries()) {
if (!s.list || s.item.length !== (isV2 ? 3 : 2) || s.item.some((x) => x.list)) return { why: 'row ' + k + ' is not ' + (isV2 ? '[scheme, index, signature]' : '[index, signature]') };
const sb = isV2 ? Buffer.from(s.item[0].item) : null, ib = Buffer.from(s.item[isV2 ? 1 : 0].item), sig = new Uint8Array(s.item[isV2 ? 2 : 1].item);
if (lead(sb) || lead(ib)) return { why: 'row ' + k + ': the ' + (lead(sb) ? 'scheme tag' : 'index') + ' has a leading zero byte (a canonical scalar has none; 0 is the empty string)' };
if (ib.length > 4 || scalarOf(ib) > 0x7fffffffn) return { why: 'row ' + k + ': the index ' + (ib.length > 4 ? 'does not fit 32 bits (' + ib.length + ' bytes)' : scalarOf(ib) + ' is above 2^31-1, the largest seal index') };
const scheme = isV2 ? rowScalar(sb) : 1;
if (!SCHEME_NAMES[scheme]) return { why: 'row ' + k + ': unknown scheme tag ' + (sb.length ? '0x' + sb.toString('hex') : '(the empty string, 0)') + ' (the known tags are the single bytes 0x01 falcon-512 and 0x02 slh-dsa-sha2-128s)' };
if (!sig.length) return { why: 'row ' + k + ': empty signature' };
rows.push({ scheme, idx: rowScalar(ib), sig });
}
if (!rlpEncode(tn(node)).equals(raw)) return { why: 'the certificate is not in canonical RLP form (its re-encoding differs from the bytes carried)' };
return { rows };
}
// 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),
@ -341,6 +401,12 @@ console.log('ANCHORS: ' + COUNT + ' ending at or below ' + top);
// 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));
// both clients refuse a v2 height below the anchor height at startup (the v2 form cannot take effect before the anchor rules do)
if (ANCHOR_V2_BLOCK !== null && ANCHOR_V2_BLOCK < ANCHOR_FROM) { console.error('FAIL: ANCHOR_V2_BLOCK=' + ANCHOR_V2_BLOCK + ' is below the anchor height ' + ANCHOR_FROM + '; both clients refuse that configuration at startup'); stop(1); }
// (H_r is never set without V: refused above)
console.log('certificate form by height: ' + (ANCHOR_V2_BLOCK === null
? 'v1 at every anchor (no v2 height for chain ' + CHAIN_ID + ' in this tool; pass ANCHOR_V2_BLOCK=<V> if the chain has one)'
: 'v1 below the v2 height V=' + ANCHOR_V2_BLOCK + ', v2 from V' + (COMMIT_ROUND_BLOCK === null ? '' : ', v3 from H_r=' + COMMIT_ROUND_BLOCK)));
// 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.
@ -547,7 +613,8 @@ for (let i = 0; i < COUNT; i++) {
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 ex = hex(b.extraData);
const items = rlpRead(ex, 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,
@ -555,14 +622,18 @@ for (let i = 0; i < COUNT; i++) {
// 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;
// 2026-10-19 (D-538): the pairs decoded as on every other v1 anchor (decodeV1V2: canonical scalars, an index within 0..2^31-1 in at
// most 4 bytes, a signature, the canonical RLP form), since both clients decode them so inside the lapse too; until this date an
// index was read as its bytes with a leading zero admitted. The ORDER is still not required here, only the distinctness below.
let pairs = null, whyNot = 'it is not an RLP list';
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));
if (c.list) {
const d = items.length >= 6 ? decodeV1V2(c, false, ex.subarray(items[4].next, items[5].next)) : { rows: [] };
if (d.why) whyNot = d.why; else pairs = d.rows.map(r => r.idx);
}
} 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; }
} catch (e) { pairs = null; whyNot = e.message; }
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: ' + whyNot); 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'
@ -619,7 +690,6 @@ for (let i = 0; i < COUNT; i++) {
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; };
@ -762,8 +832,28 @@ for (let i = 0; i < COUNT; i++) {
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; }
// D-539 (2026-10-19, AIP-25 C5): the form by height FIRST, in both directions, as both clients judge it (a v2 below V and a v1 from V
// are refused whatever the rest of the bytes, as R1 refuses a v3 below H_r with 01 before it decodes anything). Until this date the
// bytes chose the form below H_r, so a v1 certificate (Falcon-512 only) at an anchor at or above V was judged as v1 and admitted.
const formWanted = certificateFormAt(h);
const isV2 = !cert.item[0].list;
if ((isV2 ? 2 : 1) !== formWanted) {
bad++;
console.log(h + ' FAIL: certificate form: ' + (formWanted === 2
? 'anchor ' + h + ' is at or above the v2 height V=' + ANCHOR_V2_BLOCK + ', where every certificate below H_r is the scheme-tagged v2 RLP[2, rows]; this one is v1 (it opens with a list)'
: ANCHOR_V2_BLOCK === null
? 'the v2 height V is not set for chain ' + CHAIN_ID + ' in this tool, so every certificate below H_r is v1 (a list of [index, signature]); this one opens with a scalar (the scheme-tagged form). If the fleet has published a v2 height, pass ANCHOR_V2_BLOCK=<V>'
: 'anchor ' + h + ' is below the v2 height V=' + ANCHOR_V2_BLOCK + ', where the certificate is v1 (a list of [index, signature]); this one opens with a scalar (the scheme-tagged form)'));
continue;
}
// D-538 (2026-10-19, AIP-25 C4): the rows decoded as both clients decode them (decodeV1V2). Until this date the scheme tag and the index
// of a row were read from their FIRST byte: the tag 0x0101 was Falcon-512 and the index 0x0100 was 1, where both clients refuse the row.
const decoded = decodeV1V2(cert, isV2, ex.subarray(items[4].next, items[5].next));
if (decoded.why) { bad++; console.log(h + ' FAIL: certificate decoding (v' + (isV2 ? 2 : 1) + '): ' + decoded.why); continue; }
// D-429 in both clients: from V an EMPTY scheme-tagged certificate is refused at every threshold (the first client's codec cannot
// represent RLP[2, []], the second refuses it by name); until 2026-10-19 this tool admitted it where K is 0 (on 2800 the one such
// height at or above V is the dated step 17,102,384, whose anchor carries nine Falcon-512 seals).
if (isV2 && decoded.rows.length === 0) { bad++; console.log(h + ' FAIL: an empty scheme-tagged certificate RLP[2, []] at anchor ' + h + ' (v2 height V=' + ANCHOR_V2_BLOCK + '): refused at every threshold, K=' + minSealsAt(h) + ' here, as the nodes refuse it'); 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)]));
@ -774,10 +864,7 @@ for (let i = 0; i < COUNT; i++) {
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) });
const rowsOf = decoded.rows;
// 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)
@ -819,7 +906,11 @@ for (let i = 0; i < COUNT; i++) {
// 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) : [];
// D-539 (2026-10-19): a v1 certificate carries Falcon-512 only, and at a v1 height both clients judge Falcon-512 only; the scheme
// schedule is asked at a v2 height (PqAnchorSealsRule.judgeV2, PqAnchorRule.cs CheckV2). Until this date the schedule was asked at a v1
// height too, so where it names a second scheme below V (28001: SLH-DSA from the parent 168,000, V = 176,000) a sound v1 anchor was a
// FAIL "missing scheme" (measured 2026-10-19 on 28001: 20 of 20 anchors 175,360 .. 175,968; with this line, 20 of 20 verified).
const requiredSchemes = MIN_SEALS > 0 ? (isV2 ? schemesRequiredForAnchor(h) : ['falcon-512']) : [];
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));