From 88a87df694c7230b916c1e07d719ddffa7ae531b Mon Sep 17 00:00:00 2001 From: Aere Network Date: Fri, 2 Oct 2026 06:23:56 +0300 Subject: [PATCH] tools: the on-chain pin proven under the certified state (mpt.mjs), shared by verify-anchor.mjs and verify-proof.mjs Copies of aerenew/verificator-falcon at 2026-10-02 (AIP-24 C4 (b)): slot 0 of every anchor contract is proven with eth_getProof under the state root of the certified parent of the newest verified anchor; nothing is read at `latest` any more. mpt.mjs is the Merkle-Patricia proof walker both tools import: it must sit next to them. Co-Authored-By: Claude Fable 5.1 --- tools/mpt.mjs | 81 +++++++++++++++++++++++++++++++++++++++ tools/verify-anchor.mjs | 72 ++++++++++++++++++++++++++++------- tools/verify-proof.mjs | 84 +++++++++-------------------------------- 3 files changed, 157 insertions(+), 80 deletions(-) create mode 100644 tools/mpt.mjs diff --git a/tools/mpt.mjs b/tools/mpt.mjs new file mode 100644 index 0000000..3e64aee --- /dev/null +++ b/tools/mpt.mjs @@ -0,0 +1,81 @@ +// mpt.mjs - a Merkle-Patricia proof walker (eth_getProof) and the RLP decoder it needs, in ONE place for the two tools that prove +// state against a certified header: verify-proof.mjs (the AereNotary entry, AIP-23 section 4) and verify-anchor.mjs (slot 0 of the +// anchor contract under the state of the certified parent, AIP-24 C4 (b), 2026-10-02). Until this date each tool had its own copy, or +// read the slot at 'latest' from the endpoint. +// +// Every node of a proof is looked up by its own keccak hash, starting from the root, so a node the endpoint added or changed is simply +// never reached: a proof is accepted only when it leads, hash by hash, from the root the certified header states to the value. +import { keccak_256 } from '@noble/hashes/sha3'; + +export const hexb = (h) => Buffer.from(String(h).replace(/^0x/, '').replace(/^(.(..)*)$/, '0$1'), 'hex'); +export const keccak = (b) => Buffer.from(keccak_256(b)); + +/** strict RLP decoding: nested lists become arrays, strings become Buffers; trailing bytes are an error */ +export function rlpDecode(buf) { + const [v, end] = dec(buf, 0); + if (end !== buf.length) throw new Error('trailing bytes after an RLP item'); + return v; + function dec(b, o) { + const p = b[o]; + if (p === undefined) throw new Error('RLP runs past its input'); + if (p < 0x80) return [b.subarray(o, o + 1), o + 1]; + if (p < 0xb8) return [b.subarray(o + 1, o + 1 + p - 0x80), o + 1 + p - 0x80]; + if (p < 0xc0) { const l = p - 0xb7; const n = Number(BigInt('0x' + b.subarray(o + 1, o + 1 + l).toString('hex'))); return [b.subarray(o + 1 + l, o + 1 + l + n), o + 1 + l + n]; } + let n, s; + if (p < 0xf8) { n = p - 0xc0; s = o + 1; } else { const l = p - 0xf7; n = Number(BigInt('0x' + b.subarray(o + 1, o + 1 + l).toString('hex'))); s = o + 1 + l; } + const out = []; let q = s; + while (q < s + n) { const [x, r] = dec(b, q); out.push(x); q = r; } + if (q !== s + n) throw new Error('RLP list length mismatch'); + return [out, s + n]; + } +} + +/** the value at keccak(key) under root, or null when the proof shows it absent; throws when the proof does not hold */ +export function mptGet(root, hashedKey, proof) { + const byHash = new Map(proof.map((p) => { const b = hexb(p); return [keccak(b).toString('hex'), b]; })); + const nib = (buf) => { const o = []; for (const x of buf) o.push(x >> 4, x & 15); return o; }; + let key = nib(hashedKey), ref = hexb(root); + for (let depth = 0; depth < 70; depth++) { + let node; + if (Buffer.isBuffer(ref)) { + const raw = byHash.get(ref.toString('hex')); + if (!raw) throw new Error('the proof is missing the node ' + ref.toString('hex').slice(0, 16) + '..'); + node = rlpDecode(raw); + } else node = ref; + if (!Array.isArray(node)) throw new Error('a proof node is not a list'); + if (node.length === 17) { + if (key.length === 0) return node[16].length ? node[16] : null; + const next = node[key[0]]; key = key.slice(1); + if (Array.isArray(next)) { ref = next; continue; } + if (next.length === 0) return null; + if (next.length !== 32) throw new Error('a branch child is neither a hash nor an inline node'); + ref = next; continue; + } + if (node.length === 2) { + const enc = nib(node[0]); const flag = enc[0]; + if (flag > 3) throw new Error('bad hex-prefix flag'); + const p = enc.slice(flag % 2 === 1 ? 1 : 2); + const matches = key.length >= p.length && p.every((n, i) => key[i] === n); + if (flag >= 2) return matches && key.length === p.length ? node[1] : null; + if (!matches) return null; + key = key.slice(p.length); + const next = node[1]; + if (Array.isArray(next)) { ref = next; continue; } + if (next.length !== 32) throw new Error('an extension child is not a hash'); + ref = next; continue; + } + throw new Error('a proof node with ' + node.length + ' items'); + } + throw new Error('the proof is deeper than any key'); +} + +/** an account's [nonce, balance, storageRoot, codeHash] proven under a state root, or null when the account is absent */ +export function accountAt(stateRoot, address20, accountProof) { + const a = mptGet(stateRoot, keccak(address20), accountProof || []); + return a ? rlpDecode(a) : null; +} +/** a storage slot's value (bytes, RLP-decoded, leading zeros stripped by the client) proven under an account's storage root */ +export function slotAt(storageRoot, slot32, storageProof) { + const v = mptGet(storageRoot, keccak(slot32), storageProof || []); + return v ? rlpDecode(v) : null; +} diff --git a/tools/verify-anchor.mjs b/tools/verify-anchor.mjs index fe5ad1f..ac3318b 100644 --- a/tools/verify-anchor.mjs +++ b/tools/verify-anchor.mjs @@ -54,6 +54,7 @@ import { secp256k1 } from '@noble/curves/secp256k1'; import fs from 'node:fs'; import path from 'node:path'; import { verifyFalcon512, fromHex } from './falcon512.mjs'; +import { accountAt, slotAt } from './mpt.mjs'; // Early refusals stop through stop(1), not process.exit(1): on Windows (Node 24), process.exit() right after a fetch aborts node // inside libuv while the HTTP sockets close, and the caller sees exit code 127 instead of 1 (measured 2026-09-27: 8 of 8 runs; with // stop(), 6 of 6 runs exit 1). stop() prints nothing itself, sets the code, and throws a marker that the handler below swallows, so the @@ -264,7 +265,12 @@ for (const m of index.manifests) { } // 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'; + // 2026-10-02 (AIP-24 C4 (b)): the slot is no longer read at 'latest' from the endpoint (eth_getStorageAt / eth_getCode, which an + // endpoint can answer as it likes). It is PROVEN below, after the anchors: eth_getProof of the anchor contract under the state root + // of the parent of the newest VERIFIED anchor, the header the certificate's seals signed, so a lying endpoint cannot hand this tool + // a pin of its choosing. Here the manifest is judged on what does not need the chain: sha256, rows, chain, format; `pin` is filled + // in below (true / false / null when it could not be measured). + let pinOk = null, pinNote = 'no anchorContract in index', want = null; 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. @@ -272,21 +278,14 @@ for (const m of index.manifests) { } 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; } + want = '0x' + keccak(pre).toString('hex'); + pinNote = 'slot 0 of ' + m.anchorContract + ' judged below, under the state of the certified parent of the newest verified anchor'; } - const good = shaOk && pinOk && rowsOk === man.count && Number(man.chainId) === CHAIN_ID && Number(man.formatVersion) === FORMAT_VERSION; + const good = shaOk && 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.push({ bindHeight: Number(man.bindHeight), count: man.count, rows, good, file: m.file, anchorContract: m.anchorContract || null, want, pin: pinOk }); } manifests.sort((a, b) => a.bindHeight - b.bindHeight); const manifestAt = (h) => [...manifests].reverse().find(m => m.bindHeight <= h) || null; @@ -498,6 +497,7 @@ let lapseNotEnforced = 0; // The ANCHOR_REPORT keys below are unchanged (the AIP-23 verifier reads them). let newestOk = null; let newestParentHash = null; // the hash of block newestOk - 1 the verified seals signed (M(parent)), for ANCHOR_REPORT +let newestParent = null; // that parent's header as served, re-encoded above to the certified hash: its stateRoot is bound to the seals // 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; @@ -633,7 +633,7 @@ for (let i = 0; i < COUNT; i++) { && unknownScheme.length === 0 && unknownIndex.length === 0 && notValidator.length === 0 && noKey.length === 0 && unbound.length === 0 && duplicate.length === 0; const summary = Object.entries(perScheme).map(([nm, c]) => c + ' ' + nm).join(' + '); - if (good) { ok++; if (newestOk === null) { newestOk = h; newestParentHash = String(parent.hash).toLowerCase(); } console.log(h + ' OK: bound under block hash' + (isV2 ? ' (v2, scheme-tagged)' : '') + ', ' + n + ' seals (' + summary + '), all valid and from distinct validators of block ' + (h - 1) + ', the set its own header lists (' + (h - 1 >= KEYS_FROM && v3EpochAt(h - 1) ? 'keys of the registry v3 epoch ' + v3EpochAt(h - 1).bindHeight : 'manifest ' + man.bindHeight) + ')' + if (good) { ok++; if (newestOk === null) { newestOk = h; newestParentHash = String(parent.hash).toLowerCase(); newestParent = parent; } console.log(h + ' OK: bound under block hash' + (isV2 ? ' (v2, scheme-tagged)' : '') + ', ' + n + ' seals (' + summary + '), all valid and from distinct validators of block ' + (h - 1) + ', the set its own header lists (' + (h - 1 >= KEYS_FROM && v3EpochAt(h - 1) ? 'keys of the registry v3 epoch ' + v3EpochAt(h - 1).bindHeight : 'manifest ' + man.bindHeight) + ')' + (lapseAt(h) ? '; judged at K=' + MIN_SEALS + ', the threshold in force in the dated window ' + lapseAt(h).first + '..' + lapseAt(h).last + ' (' + lapseAt(h).date + '), digest and order required as everywhere' : '')); } else if (envBlocked) { unmeasured++; @@ -656,6 +656,49 @@ for (let i = 0; i < COUNT; i++) { } } +// 5. ON-CHAIN PIN, proven under the certified state (2026-10-02, AIP-24 C4 (b)). For every key manifest the index binds to an anchor +// contract, eth_getProof of that contract at the PARENT of the newest verified anchor: the account must exist under that header's +// state root (the header re-encodes to the hash the seals signed, so the root is certified), its code hash must be the 11-byte +// read-only getter, and slot 0, proven under the account's storage root, must hold keccak256(addr || pk over the rows). An endpoint +// that keeps no state for that block makes the pin UNMEASURED (exit 2), never VERIFIED; a proof that does not hold, or a value that +// is not the manifest's hash, is a FAIL of that manifest. +const GETTER_CODEHASH = keccak(Buffer.from('60005460005260206000f3', 'hex')).toString('hex'); +let pinUnmeasured = 0; +{ + const judged = manifests.filter(mm => mm.anchorContract); + if (judged.length && newestOk === null) { + for (const mm of judged) { pinUnmeasured++; mm.pin = null; } + console.log('on-chain pin: not judged, no anchor verified in this run (the pin is proven under the state of a certified parent)'); + } + for (const mm of judged) { + if (newestOk === null) break; + const X = newestOk - 1; + let note, okPin = false; + try { + const pr = await rpc('eth_getProof', [mm.anchorContract, ['0x0000000000000000000000000000000000000000000000000000000000000000'], '0x' + X.toString(16)]); + const acct = accountAt(newestParent.stateRoot, hex(mm.anchorContract.toLowerCase()), pr && pr.accountProof); + if (!acct) { note = 'FAIL: the state of block ' + X + ' has no account at ' + mm.anchorContract; } + else if (acct[3].toString('hex') !== GETTER_CODEHASH) { note = 'FAIL: the account at ' + mm.anchorContract + ' in the state of block ' + X + ' is not the read-only getter (code hash 0x' + acct[3].toString('hex').slice(0, 16) + '..)'; } + else { + const sp = (pr.storageProof || [])[0]; + const v = slotAt('0x' + acct[2].toString('hex'), Buffer.alloc(32), sp && sp.proof); + const got = '0x' + (v ? Buffer.from(v).toString('hex').padStart(64, '0') : '0'.repeat(64)); + okPin = got === mm.want.toLowerCase() /*PAZA:pin-under-certified-state*/; + note = okPin ? 'slot 0 matches, proven under the state of block ' + X + ' (the certified parent of anchor ' + newestOk + '), contract is the read-only getter' + : 'FAIL: slot 0 MISMATCH under the state of block ' + X + ' (' + got.slice(0, 10) + ' vs ' + mm.want.slice(0, 10) + ')'; + } + mm.pin = okPin; + } catch (e) { + const text = String(e.message); + // a proof the endpoint served that does not hold is a finding; an endpoint that cannot serve the proof is this run's gap + if (/missing the node|does not hold|not a list|hex-prefix|branch child|extension child|deeper than any key|RLP/.test(text)) { mm.pin = false; note = 'FAIL: the proof served for ' + mm.anchorContract + ' at block ' + X + ' does not hold: ' + text; } + else { mm.pin = null; pinUnmeasured++; note = 'UNMEASURED: eth_getProof at block ' + X + ' failed (the endpoint may not keep that state): ' + text.slice(0, 120); } + } + if (mm.pin === false) { manifestBad++; mm.good = false; } + console.log(' pin ' + mm.file.padEnd(26) + ' ' + note); + } +} + // 6. PROPOSER SEALS, AIP-22 stage 5 (added 2026-09-24). From H_hdr every header carries element 6, [index, signature]: the // 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 @@ -801,7 +844,8 @@ 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 && setUnmeasured === 0 && propBad === 0 && propUnmeasured === 0; +const all = bad === 0 && manifestBad === 0 && unmeasured === 0 && setUnmeasured === 0 && propBad === 0 && propUnmeasured === 0 && pinUnmeasured === 0; +if (pinUnmeasured) console.log('UNMEASURED: the on-chain pin of ' + pinUnmeasured + ' manifest(s) could not be proven here (no verified anchor in this run, or the endpoint served no state proof for the certified parent). This says nothing about the chain; try another endpoint (RPC=...).'); // D-478: an anchor in the dated lapse where nothing was in force is NOT counted as verified. The VERIFIED line counts only the anchors // actually judged, the lapse ones get their own line, and a run in which every anchor read fell in the lapse verified nothing: it says // so and exits 2 (nothing measured), never 0 with a VERIFIED line over anchors where nothing was checked. diff --git a/tools/verify-proof.mjs b/tools/verify-proof.mjs index 4621ed1..daa0bfd 100644 --- a/tools/verify-proof.mjs +++ b/tools/verify-proof.mjs @@ -27,11 +27,13 @@ import crypto from 'node:crypto'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { keccak_256 } from '@noble/hashes/sha3'; +import { pathToFileURL } from 'node:url'; +import { rlpDecode, mptGet } from './mpt.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); -const NOTARY = { 2800: '0x4aB392c4Aca7D9D4C16c0b60a9514c5025bd58c7', 28001: '0x70099E62735500AA2F85B60C518551a57B202d54' }; -const RPC_DEFAULT = { 2800: 'https://rpc.aere.network', 28001: 'https://testnet-rpc.aere.network' }; -const MANIFESTS_DEFAULT = { 2800: 'https://aere.network/pq/manifests', 28001: 'https://testnet-rpc.aere.network/pq/manifests' }; +export const NOTARY = { 2800: '0x4aB392c4Aca7D9D4C16c0b60a9514c5025bd58c7', 28001: '0x70099E62735500AA2F85B60C518551a57B202d54' }; +export const RPC_DEFAULT = { 2800: 'https://rpc.aere.network', 28001: 'https://testnet-rpc.aere.network' }; +export const MANIFESTS_DEFAULT = { 2800: 'https://aere.network/pq/manifests', 28001: 'https://testnet-rpc.aere.network/pq/manifests' }; // keccak256 of the AereNotary runtime code (the same bytes on 2800 and 28001): an address whose account proves another code hash // is not the notary, whatever the endpoint says const NOTARY_CODEHASH_FULL = '8eaa924081e9718fcee05ae64fadaa1815437ebbbcef5b87317c5879050452ce'; @@ -44,35 +46,15 @@ const stop = (code) => { process.exitCode = code; throw new Stop(); }; const hexb = (h) => Buffer.from(String(h).replace(/^0x/, '').replace(/^(.(..)*)$/, '0$1'), 'hex'); const keccak = (b) => Buffer.from(keccak_256(b)); const encLen = (n, off) => { if (n < 56) return Buffer.from([off + n]); const l = Buffer.from(n.toString(16).replace(/^(.(..)*)$/, '0$1'), 'hex'); return Buffer.concat([Buffer.from([off + 55 + l.length]), l]); }; -function rlpEncode(x) { +export function rlpEncode(x) { if (Array.isArray(x)) { const body = Buffer.concat(x.map(rlpEncode)); return Buffer.concat([encLen(body.length, 0xc0), body]); } const b = Buffer.from(x); if (b.length === 1 && b[0] < 0x80) return b; return Buffer.concat([encLen(b.length, 0x80), b]); } const scalar = (h) => { const n = BigInt(h); return n === 0n ? Buffer.alloc(0) : hexb(n.toString(16)); }; -function rlpDecode(buf) { - const [v, end] = dec(buf, 0); - if (end !== buf.length) throw new Error('trailing bytes after an RLP item'); - return v; - function dec(b, o) { - const p = b[o]; - if (p === undefined) throw new Error('RLP runs past its input'); - if (p < 0x80) return [b.subarray(o, o + 1), o + 1]; - if (p < 0xb8) return [b.subarray(o + 1, o + 1 + p - 0x80), o + 1 + p - 0x80]; - if (p < 0xc0) { const l = p - 0xb7; const n = Number(BigInt('0x' + b.subarray(o + 1, o + 1 + l).toString('hex'))); return [b.subarray(o + 1 + l, o + 1 + l + n), o + 1 + l + n]; } - let n, s; - if (p < 0xf8) { n = p - 0xc0; s = o + 1; } else { const l = p - 0xf7; n = Number(BigInt('0x' + b.subarray(o + 1, o + 1 + l).toString('hex'))); s = o + 1 + l; } - const out = []; let q = s; - while (q < s + n) { const [x, r] = dec(b, q); out.push(x); q = r; } - if (q !== s + n) throw new Error('RLP list length mismatch'); - return [out, s + n]; - } -} -// The on-chain hash of a QBFT header: extraData re-encoded as [vanity, validators, vote, round 0, no commit seals], the certificate -// and the proposer seal left out (they are outside the hash). This mirrors the re-encoding in verify-anchor.mjs; if the two ever -// drifted, the check below would FAIL (the hash would not match), never pass. -function onchainHash(b) { +/** the hash the chain gives a header: QBFT strips the committed seals and zeroes the round before hashing (CLAUDE.md s.2) */ +export function onchainHash(b) { const e = rlpDecode(hexb(b.extraData)); const extra = rlpEncode([e[0], e[1], e[2], Buffer.alloc(0), []]); const f = [hexb(b.parentHash), hexb(b.sha3Uncles), hexb(b.miner), hexb(b.stateRoot), hexb(b.transactionsRoot), hexb(b.receiptsRoot), @@ -86,45 +68,9 @@ function onchainHash(b) { if (b.balHash != null) f.push(hexb(b.balHash)); return '0x' + keccak(rlpEncode(f)).toString('hex'); } -// A Merkle-Patricia proof (eth_getProof): the value at keccak(key) under root, or null when the proof shows it absent. Every node -// is looked up by its own hash, starting from the root, so a node the endpoint added or changed is simply never reached. -function mptGet(root, hashedKey, proof) { - const byHash = new Map(proof.map((p) => { const b = hexb(p); return [keccak(b).toString('hex'), b]; })); - const nib = (buf) => { const o = []; for (const x of buf) o.push(x >> 4, x & 15); return o; }; - let key = nib(hashedKey), ref = hexb(root); - for (let depth = 0; depth < 70; depth++) { - let node; - if (Buffer.isBuffer(ref)) { - const raw = byHash.get(ref.toString('hex')); - if (!raw) throw new Error('the proof is missing the node ' + ref.toString('hex').slice(0, 16) + '..'); - node = rlpDecode(raw); - } else node = ref; - if (!Array.isArray(node)) throw new Error('a proof node is not a list'); - if (node.length === 17) { - if (key.length === 0) return node[16].length ? node[16] : null; - const next = node[key[0]]; key = key.slice(1); - if (Array.isArray(next)) { ref = next; continue; } - if (next.length === 0) return null; - if (next.length !== 32) throw new Error('a branch child is neither a hash nor an inline node'); - ref = next; continue; - } - if (node.length === 2) { - const enc = nib(node[0]); const flag = enc[0]; - if (flag > 3) throw new Error('bad hex-prefix flag'); - const p = enc.slice(flag % 2 === 1 ? 1 : 2); - const matches = key.length >= p.length && p.every((n, i) => key[i] === n); - if (flag >= 2) return matches && key.length === p.length ? node[1] : null; - if (!matches) return null; - key = key.slice(p.length); - const next = node[1]; - if (Array.isArray(next)) { ref = next; continue; } - if (next.length !== 32) throw new Error('an extension child is not a hash'); - ref = next; continue; - } - throw new Error('a proof node with ' + node.length + ' items'); - } - throw new Error('the proof is deeper than any key'); -} +// rlpDecode and mptGet live in mpt.mjs since 2026-10-02 (shared with verify-anchor.mjs, which proves the anchor contract's slot 0 +// under the certified parent state, AIP-24 C4 (b)); re-exported here so that nothing that imported them from this file changes. +export { rlpDecode, mptGet }; const level = (name, state, detail) => ({ level: name, state, detail }); // ---------- levels 1 and 2 ---------- @@ -158,7 +104,7 @@ function signature(env, text) { } // ---------- level 4 ---------- -async function finality(env, o) { +export async function finality(env, o) { if (!o.rpcGiven && !env.notarization) return level('finality', 'ABSENT', 'no notarization claimed and no --rpc given'); const rpc = async (method, params) => { const r = await fetch(o.rpc, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'aere-verify-proof/1' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) }); @@ -219,6 +165,11 @@ async function finality(env, o) { } // ---------- main ---------- +// 2026-10-02 (AIP-24, L1): the finality path and its helpers are exported so that verify-shield.mjs (tools/aere-quantum-shield) +// judges a period head H_n through the SAME function, on a live endpoint or on a captured package replayed locally, instead of a +// second copy of this logic. Run as a program, this file behaves exactly as before; imported, it runs nothing. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main(); +async function main() { const args = process.argv.slice(2); const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; }; const file = args.find((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1].startsWith('--') && args[i - 1] !== '--json')); @@ -245,3 +196,4 @@ else { console.log(`VERDICT: ${verdict}`); } process.exitCode = failed ? 1 : unmeasured ? 2 : 0; +}