verify-anchor.mjs: step 6 admits a re-proposed block sealed by its round's proposer (AIP-22 section 5 as corrected 2026-09-25, now running on testnet 28001); builder rule, round bound and canonical round on every header; exit code set, not process.exit
This commit is contained in:
parent
846f437724
commit
0b36247051
@ -25,8 +25,12 @@
|
|||||||
// 6. PROPOSER (AIP-22 stage 5, 2026-09-24) on a chain where headers carry element 6, the proposer's
|
// 6. PROPOSER (AIP-22 stage 5, 2026-09-24) on a chain where headers carry element 6, the proposer's
|
||||||
// Falcon-512 seal over keccak256(RLP["AERE-PQ-PROPOSAL-1", chainId, height, round, digest])
|
// Falcon-512 seal over keccak256(RLP["AERE-PQ-PROPOSAL-1", chainId, height, round, digest])
|
||||||
// verifies against the v3 registry key of its index, and that index is the header's
|
// verifies against the v3 registry key of its index, and that index is the header's
|
||||||
// coinbase, on the most recent headers (PROPOSER_HEADERS, default 20). Where no header
|
// coinbase, or, in a block re-proposed in a round r > 0, the proposer QBFT expects for
|
||||||
// carries it (chain 2800 today) the tool says so and this leg does not change the verdict.
|
// round r (2026-09-25, D-406), on the most recent headers (PROPOSER_HEADERS, default 20);
|
||||||
|
// and, as the nodes judge every header from H_hdr, the round is a canonical integer
|
||||||
|
// at most 1024 and the coinbase is the proposer of some round 0..r at that height.
|
||||||
|
// Where no header carries it (chain 2800 today) the tool says so and this leg does not
|
||||||
|
// change the verdict.
|
||||||
//
|
//
|
||||||
// What this still does NOT prove: who RUNS the validators (today: one operator, the Foundation; we
|
// 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
|
// publish that ourselves), or that the RPC you use is honest about chain contents - bring your own
|
||||||
@ -524,9 +528,45 @@ for (let i = 0; i < COUNT; i++) {
|
|||||||
// proposer's Falcon-512 seal over keccak256(RLP["AERE-PQ-PROPOSAL-1", chainId, height, round, digest]), where digest is the hash
|
// proposer's Falcon-512 seal over keccak256(RLP["AERE-PQ-PROPOSAL-1", chainId, height, round, digest]), where digest is the hash
|
||||||
// of the header re-encoded in the EXCLUDE_COMMIT_SEALS form (SPEC 2.3: round kept, commit seals an empty list, the certificate
|
// of the header re-encoded in the EXCLUDE_COMMIT_SEALS form (SPEC 2.3: round kept, commit seals an empty list, the certificate
|
||||||
// and the proposer seal omitted). The index names a row of the v3 registry epoch in force at that height, and that row's id must
|
// and the proposer seal omitted). The index names a row of the v3 registry epoch in force at that height, and that row's id must
|
||||||
// be the header's coinbase. Each header is first re-encoded in the on-chain form (round 0) and must reproduce the hash the
|
// be the header's coinbase, or the proposer of the round of a re-proposal (below). Each header is first re-encoded in the on-chain
|
||||||
// endpoint reports: that is the control of THIS header's encoding, and a header that fails it is not judged at all. Headers
|
// form (round 0) and must reproduce the hash the endpoint reports: that is the control of THIS header's encoding, and a header that
|
||||||
// without element 6 (every header of a chain, or of heights, before stage 5) are counted and named, never judged.
|
// fails it is not judged at all. Headers without element 6 (every header of a chain, or of heights, before stage 5) are counted
|
||||||
|
// and named, never judged.
|
||||||
|
//
|
||||||
|
// RE-PROPOSAL (2026-09-25, D-406, measured on testnet 28001). A block PREPARED in one round and not committed before its timer is
|
||||||
|
// re-proposed UNCHANGED in a later round r by the proposer of round r (QBFT's locked value: only element 3, the round, changes; the
|
||||||
|
// coinbase stays the builder's), and the proposer seal put in the header at commit is the one of that round's proposer. Requiring
|
||||||
|
// the coinbase refused every such header: block 3,314,873 stood 128 s, rounds 1 to 4 refused by every node with "a seal cannot
|
||||||
|
// vouch for another proposer", round 5 the builder's own turn again (five validators). So a seal is admitted when its id is the
|
||||||
|
// coinbase, OR when the round is above 0 and its id is the proposer QBFT expects for that round, as both clients now judge it.
|
||||||
|
// That proposer is derived here, not taken from the endpoint: the validators are element 1 of the header itself, and the previous
|
||||||
|
// proposer is the coinbase of the PARENT, whose header must re-encode to this header's parentHash (so an endpoint cannot choose the
|
||||||
|
// parent's coinbase). A parent that cannot be read or bound is a FAIL, never an admission.
|
||||||
|
//
|
||||||
|
// And on EVERY header judged here, whoever sealed it, the tool applies the rules the nodes apply from H_hdr, so it judges exactly
|
||||||
|
// as they do: (1) the round in element 3 is a canonical RLP integer (no leading zero, at most 8 bytes, the shortest encoding;
|
||||||
|
// Besu's strict decoding refuses any other form); (2) it is at most 1024 (no QBFT round timer, which doubles every round, gets
|
||||||
|
// there, and upstream's selection computes in int); (3) the coinbase is the proposer of some round 0..min(r, n - 1) at this height,
|
||||||
|
// i.e. it could have BUILT the block (the rotation is periodic in n; the second client has required this since D-331). Rule (3)
|
||||||
|
// needs the parent's coinbase for every header; the headers judged are consecutive, so each parent is the next header down, read
|
||||||
|
// once, and only the oldest needs one header more: PROPOSER_HEADERS + 1 reads in all, none per header beyond that.
|
||||||
|
//
|
||||||
|
// expectedProposer is Besu's BftProposerSelector with changeEachBlock = true: a previous proposer that is in the list moves one
|
||||||
|
// step and then the round; one that is no longer in the list is replaced by the lowest validator above it (compared as numbers),
|
||||||
|
// or by the lowest of all when none is above it, and then moves by the round only.
|
||||||
|
// >>> expectedProposer (self-contained: verify-anchor-repropunere-proba.mjs reads it from this file and tests it)
|
||||||
|
function expectedProposer(validators, prevProposer, round) {
|
||||||
|
const n = validators.length;
|
||||||
|
if (n === 0) return null;
|
||||||
|
const at = validators.indexOf(prevProposer);
|
||||||
|
if (at >= 0) return validators[(at + 1 + round) % n];
|
||||||
|
const num = (a) => BigInt(a);
|
||||||
|
let base = null;
|
||||||
|
for (const v of validators) if (num(v) > num(prevProposer) && (base === null || num(v) < num(base))) base = v;
|
||||||
|
if (base === null) for (const v of validators) if (base === null || num(v) < num(base)) base = v;
|
||||||
|
return validators[(validators.indexOf(base) + round) % n];
|
||||||
|
}
|
||||||
|
// <<< expectedProposer
|
||||||
const PROPOSER_HEADERS = Number(process.env.PROPOSER_HEADERS || 20);
|
const PROPOSER_HEADERS = Number(process.env.PROPOSER_HEADERS || 20);
|
||||||
const PROPOSAL_DOMAIN = 'AERE-PQ-PROPOSAL-1';
|
const PROPOSAL_DOMAIN = 'AERE-PQ-PROPOSAL-1';
|
||||||
const nested = (node) => (node.list ? node.item.map(nested) : Buffer.from(node.item));
|
const nested = (node) => (node.list ? node.item.map(nested) : Buffer.from(node.item));
|
||||||
@ -542,13 +582,23 @@ function headerRlp(b, extra) {
|
|||||||
if (b.balHash != null) f.push(hex(b.balHash));
|
if (b.balHash != null) f.push(hex(b.balHash));
|
||||||
return rlpEncode(f);
|
return rlpEncode(f);
|
||||||
}
|
}
|
||||||
let propOk = 0, propBad = 0, propUnmeasured = 0, propAbsent = 0;
|
let propOk = 0, propBad = 0, propUnmeasured = 0, propAbsent = 0, propReproposed = 0;
|
||||||
{
|
{
|
||||||
const headNow = parseInt(await rpc('eth_blockNumber', []), 16);
|
const headNow = parseInt(await rpc('eth_blockNumber', []), 16);
|
||||||
|
// each header is read ONCE: the loop reads it as the header judged, and the header above it has already read it as its parent
|
||||||
|
const got = new Map();
|
||||||
|
const fetchHeader = async (n) => {
|
||||||
|
try { const x = await rpc('eth_getBlockByNumber', ['0x' + n.toString(16), false]); return x ? { b: x } : { why: 'the endpoint returned no header' }; }
|
||||||
|
catch (e) { return { why: e.message }; }
|
||||||
|
};
|
||||||
|
const header = async (n) => { if (!got.has(n)) got.set(n, await fetchHeader(n)); return got.get(n); };
|
||||||
for (let k = 0; k < PROPOSER_HEADERS; k++) {
|
for (let k = 0; k < PROPOSER_HEADERS; k++) {
|
||||||
const h = headNow - 2 - k; if (h < 1) break;
|
const h = headNow - 2 - k; if (h < 1) break;
|
||||||
const b = await rpc('eth_getBlockByNumber', ['0x' + h.toString(16), false]);
|
const rec = await header(h);
|
||||||
const items = rlpRead(hex(b.extraData), 0).item;
|
if (!rec.b) { propUnmeasured++; console.log(h + ' PROPOSER SEAL NOT CHECKED HERE: the header could not be read (' + rec.why + ')'); continue; }
|
||||||
|
const b = rec.b;
|
||||||
|
const ex = hex(b.extraData);
|
||||||
|
const items = rlpRead(ex, 0).item;
|
||||||
if (items.length < 7) { propAbsent++; continue; }
|
if (items.length < 7) { propAbsent++; continue; }
|
||||||
const onchain = keccak(headerRlp(b, rlpEncode([nested(items[0]), nested(items[1]), nested(items[2]), Buffer.alloc(0), []])));
|
const onchain = keccak(headerRlp(b, rlpEncode([nested(items[0]), nested(items[1]), nested(items[2]), Buffer.alloc(0), []])));
|
||||||
if (!onchain.equals(hex(b.hash))) {
|
if (!onchain.equals(hex(b.hash))) {
|
||||||
@ -564,16 +614,59 @@ let propOk = 0, propBad = 0, propUnmeasured = 0, propAbsent = 0;
|
|||||||
const ep = v3EpochAt(h);
|
const ep = v3EpochAt(h);
|
||||||
const row = ep && ep.byIndex[idx];
|
const row = ep && ep.byIndex[idx];
|
||||||
if (!row) { propBad++; console.log(h + ' FAIL: proposer seal index ' + idx + ' names no row of a published v3 registry epoch in force at this height'); continue; }
|
if (!row) { propBad++; console.log(h + ' FAIL: proposer seal index ' + idx + ' names no row of a published v3 registry epoch in force at this height'); continue; }
|
||||||
if (String(b.miner).toLowerCase() !== row.id) { propBad++; console.log(h + ' FAIL: proposer seal index ' + idx + ' is id ' + row.id + ', but the coinbase is ' + b.miner); continue; }
|
const sealId = row.id, coinbase = String(b.miner).toLowerCase();
|
||||||
|
// D-406 (1): the round is a canonical RLP integer. The decoder above keeps only the bytes, so the prefix is read from the raw
|
||||||
|
// extraData, where element 3 starts right after element 2 ends; the header hash cannot see this (the round is zeroed in it)
|
||||||
|
const rb = items[3].list ? null : Buffer.from(items[3].item);
|
||||||
|
const p3 = ex[items[2].next];
|
||||||
|
const notCanonical = rb === null ? 'it is a list' : rb.length > 8 ? 'longer than 8 bytes' : rb.length && rb[0] === 0 ? 'a leading zero byte'
|
||||||
|
: (rb.length === 0 ? p3 !== 0x80 : rb.length === 1 && rb[0] < 0x80 ? p3 !== rb[0] : p3 !== 0x80 + rb.length) ? 'not the shortest RLP encoding' : null;
|
||||||
|
if (notCanonical) { propBad++; console.log(h + ' FAIL: the round in element 3 (RLP 0x' + ex.subarray(items[2].next, items[3].next).toString('hex') + ') is not a canonical RLP integer (' + notCanonical + '); the nodes refuse this form: not admitted'); continue; }
|
||||||
|
// D-406 (2): the round bound, read as a BigInt so an absurd value is named as it is, not rounded
|
||||||
|
const roundBig = rb.length ? BigInt('0x' + rb.toString('hex')) : 0n;
|
||||||
|
if (roundBig > 1024n) { propBad++; console.log(h + ' FAIL: the round ' + roundBig + ' in element 3 is above 1024 (no QBFT round timer gets there; both clients refuse it from H_hdr): not admitted'); continue; }
|
||||||
|
const round = Number(roundBig);
|
||||||
|
if (!items[1].list || items[1].item.length === 0 || items[1].item.some(x => x.list || x.item.length !== 20)) { propBad++; console.log(h + ' FAIL: the validator list in element 1 of this header is malformed, so the proposers of this height cannot be derived: not admitted'); continue; }
|
||||||
|
// D-406 (3): the builder. The parent is the next header down (already read by the loop, or read once for the oldest)
|
||||||
|
const pr = await header(h - 1);
|
||||||
|
if (!pr.b) { propBad++; console.log(h + ' FAIL: the parent header ' + (h - 1) + ' could not be read (' + pr.why + '), so the proposers of this height cannot be derived: not admitted'); continue; }
|
||||||
|
const parent = pr.b;
|
||||||
|
let parentBound = false;
|
||||||
|
try {
|
||||||
|
const pItems = rlpRead(hex(parent.extraData), 0).item;
|
||||||
|
parentBound = keccak(headerRlp(parent, rlpEncode([nested(pItems[0]), nested(pItems[1]), nested(pItems[2]), Buffer.alloc(0), []]))).equals(hex(b.parentHash));
|
||||||
|
} catch (e) { parentBound = false; }
|
||||||
|
if (!parentBound) { propBad++; console.log(h + ' FAIL: the header served for block ' + (h - 1) + ' does not re-encode to this header\'s parentHash, so its coinbase cannot be used to derive the proposers of this height: not admitted'); continue; }
|
||||||
|
const parentCoinbase = String(parent.miner).toLowerCase();
|
||||||
|
const vals = items[1].item.map(x => '0x' + Buffer.from(x.item).toString('hex'));
|
||||||
|
let builder = -1;
|
||||||
|
for (let r0 = 0; r0 <= Math.min(round, vals.length - 1); r0++) if (expectedProposer(vals, parentCoinbase, r0) === coinbase) { builder = r0; break; }
|
||||||
|
if (builder < 0) { propBad++; console.log(h + ' FAIL: the coinbase ' + b.miner + ' is not the proposer of any round 0..' + round + ' at this height (from the validators of this header and the coinbase ' + parentCoinbase + ' of block ' + (h - 1) + '), so it did not build the block: not admitted'); continue; }
|
||||||
|
let reproposal = null;
|
||||||
|
if (coinbase !== sealId) {
|
||||||
|
// D-406: only a re-proposal in a round above 0 may carry another validator's seal, and only the one of that round's proposer
|
||||||
|
const said = h + ' FAIL: proposer seal index ' + idx + ' is id ' + sealId + ', but the coinbase is ' + b.miner;
|
||||||
|
if (round === 0) { propBad++; console.log(said + ' and the round is 0 (only a block re-proposed in a later round carries the seal of that round\'s proposer): a seal cannot vouch for another proposer'); continue; }
|
||||||
|
const expected = expectedProposer(vals, parentCoinbase, round);
|
||||||
|
if (expected !== sealId) { propBad++; console.log(said + ' and the proposer of round ' + round + ' is ' + expected + ' (from the validators of this header and the coinbase ' + parentCoinbase + ' of block ' + (h - 1) + '): a seal cannot vouch for another proposer'); continue; }
|
||||||
|
reproposal = { round, parentCoinbase, builder };
|
||||||
|
}
|
||||||
if (!row.pk || !verifyFalcon512(row.pk, msgP, new Uint8Array(seal.item[1].item))) { propBad++; console.log(h + ' FAIL: the proposer seal of index ' + idx + ' is not a valid Falcon-512 signature over the proposal message'); continue; }
|
if (!row.pk || !verifyFalcon512(row.pk, msgP, new Uint8Array(seal.item[1].item))) { propBad++; console.log(h + ' FAIL: the proposer seal of index ' + idx + ' is not a valid Falcon-512 signature over the proposal message'); continue; }
|
||||||
propOk++;
|
propOk++;
|
||||||
|
if (reproposal) {
|
||||||
|
propReproposed++;
|
||||||
|
console.log(h + ' OK, re-proposal: built by the coinbase ' + b.miner + ' (the proposer of round ' + reproposal.builder + '), re-proposed in round ' + reproposal.round + '; the proposer seal is index ' + idx
|
||||||
|
+ ', id ' + sealId + ', which is the proposer expected for round ' + reproposal.round + ' (from the validators of this header and the coinbase '
|
||||||
|
+ reproposal.parentCoinbase + ' of block ' + (h - 1) + '), and it is a valid Falcon-512 signature over the proposal message of that round');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const propChecked = propOk + propBad + propUnmeasured;
|
const propChecked = propOk + propBad + propUnmeasured;
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(propChecked === 0
|
console.log(propChecked === 0
|
||||||
? 'proposer seals (AIP-22 stage 5): none of the ' + propAbsent + ' most recent headers carries one; stage 5 is not active at these heights on this chain, so there is nothing to verify'
|
? 'proposer seals (AIP-22 stage 5): none of the ' + propAbsent + ' most recent headers carries one; stage 5 is not active at these heights on this chain, so there is nothing to verify'
|
||||||
: 'proposer seals (AIP-22 stage 5): ' + propOk + ' of ' + propChecked + ' recent headers carry a valid Falcon-512 proposal seal by the key of their coinbase'
|
: 'proposer seals (AIP-22 stage 5): ' + propOk + ' of ' + propChecked + ' recent headers carry a valid Falcon-512 proposal seal by the key of their coinbase,'
|
||||||
|
+ ' or of the proposer of the round that re-proposed the block' + (propReproposed ? ' (' + propReproposed + ' re-proposal(s), named above)' : '')
|
||||||
+ (propUnmeasured ? ', ' + propUnmeasured + ' could not be checked here' : '') + (propAbsent ? ' (' + propAbsent + ' earlier headers carry none)' : ''));
|
+ (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');
|
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 && propBad === 0 && propUnmeasured === 0;
|
||||||
@ -581,7 +674,8 @@ console.log(all
|
|||||||
? 'VERIFIED: ' + ok + '/' + COUNT + ' anchor blocks carry a post-quantum certificate bound under the block hash, every seal a valid '
|
? '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')
|
+ ([...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 the validator of that height 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 (AIP-22 stage 5).' : '')
|
+ (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
|
: (bad === 0 && manifestBad === 0 && propBad === 0
|
||||||
? (unmeasured
|
? (unmeasured
|
||||||
? 'UNMEASURED: ' + unmeasured + ' of ' + COUNT + ' anchors could not be fully checked ON THIS MACHINE because the SLH-DSA\n'
|
? 'UNMEASURED: ' + unmeasured + ' of ' + COUNT + ' anchors could not be fully checked ON THIS MACHINE because the SLH-DSA\n'
|
||||||
@ -595,4 +689,6 @@ console.log(all
|
|||||||
if (newestOk !== null) console.log('FINALIZED (post-quantum): block ' + (newestOk - 1) + ', the parent of anchor ' + newestOk + ', the most recent anchor whose certificate verified here. Nothing between anchors is final; a header between anchors carries only its proposer\'s seal.');
|
if (newestOk !== null) console.log('FINALIZED (post-quantum): block ' + (newestOk - 1) + ', the parent of anchor ' + newestOk + ', the most recent anchor whose certificate verified here. Nothing between anchors is final; a header between anchors carries only its proposer\'s seal.');
|
||||||
if (!slhDsa) console.log('note: @noble/post-quantum is not installed, so a v2 certificate carrying SLH-DSA seals cannot be verified here (npm install @noble/post-quantum).');
|
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.
|
// 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 && propBad === 0 ? 2 : 1));
|
// exitCode, not process.exit(): on Windows, exiting while the HTTP sockets are still closing aborts node inside libuv
|
||||||
|
// (0xC0000409), and the verdict printed above would reach the caller with a code that is neither 0, 1 nor 2.
|
||||||
|
process.exitCode = all ? 0 : (bad === 0 && manifestBad === 0 && propBad === 0 ? 2 : 1);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user