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 <noreply@anthropic.com>
200 lines
16 KiB
JavaScript
200 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
// verify-proof.mjs - reference verifier of the AERE Proof Protocol (AIP-23).
|
|
//
|
|
// A proof is an envelope { v, kind, statement, statementHash, signature?, notarization? }. This tool checks it without trusting the
|
|
// producer and, when asked to check finality, without trusting the RPC endpoint either:
|
|
//
|
|
// 1 form the envelope has a statement (object) and a statementHash (0x + 64 hex)
|
|
// 2 integrity sha256(JSON.stringify(statement)) == statementHash
|
|
// 3 signature present ? an ml-dsa-{44,65,87} signature over the canonical statement text, checked with node:crypto
|
|
// (OpenSSL 3.5); a scheme this tool cannot check is reported UNMEASURED, never passed : absent
|
|
// 4 finality (--rpc given, or the envelope claims a notarization) the chain's most recent anchor is verified by
|
|
// verify-anchor.mjs (beside this file: every post-quantum seal of its certificate checked against the published
|
|
// manifests); the header of the anchor's parent is re-encoded and must hash to the parent hash those seals signed;
|
|
// that header's state root then anchors a Merkle proof (eth_getProof) that the AereNotary contract (its code hash
|
|
// pinned below) holds this statementHash, and the time it was first seen. The endpoint is only a courier: a header,
|
|
// a proof or a value it altered would not match the certified hash.
|
|
//
|
|
// Usage: node verify-proof.mjs <envelope.json> [--rpc <url>] [--chain 2800|28001] [--notary <address>] [--manifests <url|dir>] [--json]
|
|
// Exit: 0 every level present passed; 1 a level present failed; 2 a level present could not be measured here (or is pending).
|
|
//
|
|
// The notary stores the block TIME of the first sighting (block.timestamp), not a block number. The block that holds the first
|
|
// sighting is located through the endpoint's logs and reported as such (it is not part of what is certified).
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
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));
|
|
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';
|
|
|
|
class Stop extends Error {}
|
|
process.on('uncaughtException', (e) => { if (e instanceof Stop) return; console.error(e); process.exit(2); });
|
|
const stop = (code) => { process.exitCode = code; throw new Stop(); };
|
|
|
|
// ---------- minimal RLP and hashing ----------
|
|
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]); };
|
|
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)); };
|
|
/** 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),
|
|
hexb(b.logsBloom), scalar(b.difficulty), scalar(b.number), scalar(b.gasLimit), scalar(b.gasUsed), scalar(b.timestamp), extra,
|
|
hexb(b.mixHash), hexb(b.nonce)];
|
|
if (b.baseFeePerGas != null) f.push(scalar(b.baseFeePerGas));
|
|
if (b.withdrawalsRoot != null) f.push(hexb(b.withdrawalsRoot));
|
|
if (b.blobGasUsed != null) { f.push(scalar(b.blobGasUsed)); f.push(scalar(b.excessBlobGas)); }
|
|
if (b.parentBeaconBlockRoot != null) f.push(hexb(b.parentBeaconBlockRoot));
|
|
if (b.requestsHash != null) f.push(hexb(b.requestsHash));
|
|
if (b.balHash != null) f.push(hexb(b.balHash));
|
|
return '0x' + keccak(rlpEncode(f)).toString('hex');
|
|
}
|
|
// 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 ----------
|
|
function local(env) {
|
|
const out = [];
|
|
const formOk = env && typeof env === 'object' && env.statement && typeof env.statement === 'object'
|
|
&& typeof env.statementHash === 'string' && /^0x[0-9a-fA-F]{64}$/.test(env.statementHash);
|
|
out.push(level('form', formOk ? 'PASSED' : 'FAILED', formOk ? `kind=${env.kind || env.statement.kind || '?'}` : 'the envelope lacks {statement, statementHash 0x+64 hex}'));
|
|
if (!formOk) return { out, text: null };
|
|
const text = JSON.stringify(env.statement);
|
|
const h = '0x' + crypto.createHash('sha256').update(Buffer.from(text, 'utf8')).digest('hex');
|
|
const ok = h === env.statementHash.toLowerCase();
|
|
out.push(level('integrity', ok ? 'PASSED' : 'FAILED', ok ? `sha256(statement) == statementHash (${h.slice(0, 18)}..)` : `sha256(statement) is ${h.slice(0, 18)}.., not the statementHash ${env.statementHash.slice(0, 18)}..`));
|
|
return { out, text: ok ? text : null };
|
|
}
|
|
|
|
// ---------- level 3 ----------
|
|
function signature(env, text) {
|
|
if (!env.signature) return level('signature', 'ABSENT', 'the envelope carries no signature');
|
|
const s = env.signature; const scheme = String(s.scheme || s.alg || '').toLowerCase();
|
|
if (['ml-dsa-44', 'ml-dsa-65', 'ml-dsa-87'].includes(scheme) && s.publicKey && s.signature) {
|
|
try {
|
|
const pub = String(s.publicKey).includes('BEGIN') ? crypto.createPublicKey(s.publicKey)
|
|
: crypto.createPublicKey({ key: Buffer.from(s.publicKey, 'base64'), format: 'der', type: 'spki' });
|
|
const sig = /^[0-9a-fA-F]+$/.test(s.signature) ? Buffer.from(s.signature, 'hex') : Buffer.from(s.signature, 'base64');
|
|
const ok = crypto.verify(null, Buffer.from(text, 'utf8'), pub, sig);
|
|
return level('signature', ok ? 'PASSED' : 'FAILED', `${scheme}: ${ok ? 'verifies' : 'does not verify'} over the canonical statement text`);
|
|
} catch (e) { return level('signature', 'UNMEASURED', `${scheme} present, but node:crypto cannot check it here (${String(e.message).split('\n')[0].slice(0, 60)}); it needs OpenSSL 3.5`); }
|
|
}
|
|
return level('signature', 'UNMEASURED', `a "${scheme || '?'}" signature is not checkable by this reference verifier (the hybrid form is checked by @aere/pq-sign)`);
|
|
}
|
|
|
|
// ---------- level 4 ----------
|
|
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 }) });
|
|
const j = await r.json(); if (j.error) throw new Error(method + ': ' + (j.error.message || JSON.stringify(j.error))); return j.result;
|
|
};
|
|
// (a) the chain's most recent anchor, verified by verify-anchor.mjs, which reports the parent hash its seals signed
|
|
const report = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'aere-proof-')), 'anchor.json');
|
|
const va = await new Promise((ok) => {
|
|
const c = spawn(process.execPath, [path.join(HERE, 'verify-anchor.mjs')], { cwd: HERE,
|
|
env: { ...process.env, RPC: o.rpc, CHAIN_ID: String(o.chain), MANIFESTS: o.manifests, ANCHORS: '1', PROPOSER_HEADERS: '2', ANCHOR_REPORT: report } });
|
|
let out = ''; c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', (d) => { out += d; });
|
|
c.on('error', (e) => ok({ code: null, out: String(e.message) })); c.on('close', (code) => ok({ code, out }));
|
|
});
|
|
let rep = null; try { rep = JSON.parse(fs.readFileSync(report, 'utf8')); } catch { rep = null; }
|
|
const tail = va.out.trim().split('\n').slice(-2).join(' | ').slice(0, 220);
|
|
// exit 1 is a verdict only with verify-anchor's own verdict line: Node also exits 1 when the tool cannot start (a dependency not
|
|
// installed: ERR_MODULE_NOT_FOUND), and that is not a failure of the chain (2026-09-29)
|
|
const verdictLine = /^(FAIL:|PROBLEMS:)/m.test(va.out);
|
|
if (va.code === 1 && verdictLine) return level('finality', 'FAILED', 'the chain\'s most recent anchor did not verify: ' + tail);
|
|
if (va.code === 1) return level('finality', 'UNMEASURED', 'verify-anchor.mjs stopped without a verdict (is `npm install` done beside it?): ' + tail);
|
|
if (va.code !== 0 || !rep || !rep.parentHash || rep.chainId !== o.chain) return level('finality', 'UNMEASURED', 'the anchor could not be verified here (verify-anchor.mjs exit ' + va.code + '): ' + tail);
|
|
// (b) the parent header, bound to the certified hash by re-encoding
|
|
const X = rep.parentNumber;
|
|
let hdr; try { hdr = await rpc('eth_getBlockByNumber', ['0x' + X.toString(16), false]); } catch (e) { return level('finality', 'UNMEASURED', 'block ' + X + ' could not be read: ' + e.message); }
|
|
if (!hdr) return level('finality', 'UNMEASURED', 'the endpoint returned no header for block ' + X);
|
|
if (onchainHash(hdr) !== rep.parentHash) return level('finality', 'FAILED', `the header the endpoint served for block ${X} does not hash to ${rep.parentHash.slice(0, 18)}.., the hash anchor ${rep.anchor}'s certificate signed`);
|
|
// (c) the notary's storage at that header's state root: mapping(bytes32 => uint64) firstSeen at slot 0
|
|
const slot = '0x' + keccak(Buffer.concat([hexb(env.statementHash), Buffer.alloc(32)])).toString('hex');
|
|
let pr; try { pr = await rpc('eth_getProof', [o.notary, [slot], '0x' + X.toString(16)]); } catch (e) { return level('finality', 'UNMEASURED', 'eth_getProof at block ' + X + ' failed (the endpoint may not keep that state): ' + e.message); }
|
|
let acct, val;
|
|
try {
|
|
const a = mptGet(hdr.stateRoot, keccak(hexb(o.notary)), pr.accountProof || []);
|
|
if (!a) return level('finality', 'FAILED', `the state of block ${X} has no account at ${o.notary}`);
|
|
acct = rlpDecode(a);
|
|
if (keccak(Buffer.alloc(0)).equals(acct[3]) || acct[3].toString('hex') !== NOTARY_CODEHASH_FULL) return level('finality', 'FAILED', `the account at ${o.notary} in the state of block ${X} is not the AereNotary contract (code hash 0x${acct[3].toString('hex').slice(0, 16)}..)`);
|
|
const s = (pr.storageProof || [])[0];
|
|
if (!s) throw new Error('no storage proof returned');
|
|
const v = mptGet('0x' + acct[2].toString('hex'), keccak(hexb(slot)), s.proof || []);
|
|
val = v ? BigInt('0x' + (rlpDecode(v).toString('hex') || '0')) : 0n;
|
|
} catch (e) { return level('finality', 'FAILED', 'the Merkle proof for block ' + X + ' does not hold: ' + e.message); }
|
|
const certified = `the state of block ${X}, the parent of anchor ${rep.anchor} whose post-quantum certificate verified here`;
|
|
if (val === 0n) {
|
|
let later = 0n; try { later = BigInt(await rpc('eth_call', [{ to: o.notary, data: '0x' + keccak(Buffer.from('proofOf(bytes32)')).subarray(0, 4).toString('hex') + env.statementHash.slice(2) }, 'latest'])); } catch { later = 0n; }
|
|
if (later > 0n) return level('finality', 'PENDING', `notarized at ${new Date(Number(later) * 1000).toISOString()} (endpoint), after ${certified}; the next anchor will cover it`);
|
|
return level('finality', env.notarization ? 'FAILED' : 'ABSENT', `not notarized: ${certified} holds no entry for this statementHash`);
|
|
}
|
|
const t = Number(val);
|
|
let where = '';
|
|
try {
|
|
const topic = '0x' + keccak(Buffer.from('Notarized(bytes32,address,uint64,bool)')).toString('hex');
|
|
let lo = 0, hi = X;
|
|
while (lo < hi) { const mid = Math.floor((lo + hi) / 2); const b = await rpc('eth_getBlockByNumber', ['0x' + mid.toString(16), false]); if (Number(BigInt(b.timestamp)) >= t) hi = mid; else lo = mid + 1; }
|
|
const logs = await rpc('eth_getLogs', [{ fromBlock: '0x' + Math.max(0, lo - 8).toString(16), toBlock: '0x' + Math.min(X, lo + 8).toString(16), address: o.notary, topics: [topic, env.statementHash] }]);
|
|
const first = (logs || []).find((l) => /1$/.test(l.data)) || (logs || [])[0];
|
|
if (first) where = `; first seen in block ${Number(BigInt(first.blockNumber))}, transaction ${first.transactionHash.slice(0, 18)}.. (located through the endpoint's logs, not part of what is certified)`;
|
|
} catch { where = ''; }
|
|
return level('finality', 'PASSED', `post-quantum: ${certified} records this statementHash in AereNotary ${o.notary} as first seen at ${new Date(t * 1000).toISOString()} (${t})${where}`);
|
|
}
|
|
|
|
// ---------- 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'));
|
|
if (!file) { console.error('usage: node verify-proof.mjs <envelope.json> [--rpc <url>] [--chain 2800|28001] [--notary <address>] [--manifests <url|dir>] [--json]'); stop(2); }
|
|
let env; try { env = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { console.error('the envelope cannot be read: ' + e.message); stop(2); }
|
|
// The chain: --chain if given; otherwise the chain the envelope's notarization declares, when it is a known chain; otherwise 2800.
|
|
// (Before 2026-09-27 an envelope notarized on 28001, checked without --chain, was looked up on 2800 and reported INVALID.) The
|
|
// notary stays the known contract of that chain (or --notary): an address written in the envelope is never trusted as the notary.
|
|
const declared = env && env.notarization && Number(env.notarization.chainId);
|
|
const chain = Number(opt('--chain') || (declared && NOTARY[declared] ? declared : 2800));
|
|
if (!NOTARY[chain]) { console.error('unknown chain ' + chain + ' (2800 or 28001)'); stop(2); }
|
|
const o = { chain, rpcGiven: !!opt('--rpc'), rpc: opt('--rpc') || RPC_DEFAULT[chain], notary: (opt('--notary') || NOTARY[chain]).toLowerCase(),
|
|
manifests: opt('--manifests') || MANIFESTS_DEFAULT[chain] };
|
|
const l = local(env);
|
|
const levels = [...l.out];
|
|
if (l.text) { levels.push(signature(env, l.text)); levels.push(await finality(env, o)); }
|
|
const failed = levels.some((x) => x.state === 'FAILED');
|
|
const unmeasured = levels.some((x) => x.state === 'UNMEASURED' || x.state === 'PENDING');
|
|
const verdict = failed ? 'INVALID' : unmeasured ? 'PARTIAL' : 'VALID';
|
|
if (args.includes('--json')) console.log(JSON.stringify({ verdict, statementHash: env.statementHash, levels }, null, 1));
|
|
else {
|
|
console.log(`AERE Proof Protocol - ${path.basename(file)}`);
|
|
for (const x of levels) console.log(` [${x.state.padEnd(10)}] ${x.level}: ${x.detail}`);
|
|
console.log(`VERDICT: ${verdict}`);
|
|
}
|
|
process.exitCode = failed ? 1 : unmeasured ? 2 : 0;
|
|
}
|