#!/usr/bin/env node // verifica-chitanta.mjs: a reader's check of a payment receipt (roadmap point 25), without ethers: the receipt file holds the // instruction, the settlement envelope, the policy and the inputs the issuer judged (the `receiptFile` that POST /v1/payments/receipt // returns is exactly this file); with --rpc the transaction and its receipt are read from the chain by plain JSON-RPC and compared; // with --verify-proof the AIP-23 reference verifier judges the envelope and, with --rpc, its notarization under a post-quantum // certified anchor (finality). The reference verifier is verify-proof.mjs as served at aere.network/tools, with verify-anchor.mjs, // mpt.mjs, falcon512.mjs, registry-v3.mjs and package.json from the same place beside it and `npm install` done there; this reader // itself needs only Node.js. // node verifica-chitanta.mjs [--rpc URL] [--verify-proof ] [--chain 28001] // Exit 0 VALID: every rule holds, the transaction on the chain is this payment, and the reference verifier finds the envelope's // notarization under a post-quantum certified anchor (finality PASSED). Exit 1 INVALID, with the reasons; a file that is not a receipt // (not JSON, or no instruction and settlement envelope objects) is INVALID by name. Exit 2 PARTIAL: something was not measured (no // --rpc, no chain answer, an RPC of another chain, no --verify-proof or no verifier at that path, a verifier that ran and gave no // verdict, the policy or the inputs missing from the file, the notarization not under an anchor yet: finality PENDING, or the // notarization not measured: finality UNMEASURED or absent), or the reader itself stopped before a verdict. These are the verdicts of POST /v1/payments/verify on the same rules. // (Corrected 2026-10-27: until this date the tool wrote VALID and exited 0 without --verify-proof, and when the notarization was PENDING // or UNMEASURED; a file without policy or inputs was INVALID. An envelope that the reference verifier calls PARTIAL on ANOTHER level, // the Bitcoin witness or a signature scheme it cannot check, with the finality of its notarization PASSED, is VALID here as on the // route, which decides on finality. Corrected again the same day: an RPC of another chain was handed on to the reference verifier, // which answered FAILED, so an authentic receipt came out INVALID; it is now judged offline and the verdict is PARTIAL. A file that // was not a receipt ended in an exception with the exit code of INVALID; it is now refused by name. Corrected 2026-10-28: with the // finality UNMEASURED, for example a chain that did not answer, the reader said that the notarization is not under a post-quantum // certified anchor yet, a statement about the chain that nothing had measured; it now says the notarization was not measured, and // keeps "not under an anchor yet" for PENDING.) import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { verificaChitanta } from './chitanta.mjs'; const a = process.argv.slice(2); const opt = (k, d = null) => { const i = a.indexOf(k); return i >= 0 && i + 1 < a.length ? a[i + 1] : d; }; const obj = (x) => !!x && typeof x === 'object' && !Array.isArray(x); const ce = (x) => (x === null ? 'null' : Array.isArray(x) ? 'a list' : typeof x === 'object' ? 'an object' : 'a ' + typeof x); // what a verifier that did not answer printed: its error line, not the "Node.js vNN" line or a stack frame; long hex strings are cut const randUtil = (p) => { const L = String((p.stderr || '') + '\n' + (p.stdout || '')).replace(/\r/g, '').split('\n').map((x) => x.trim()).filter(Boolean); const r = L.find((x) => /(^|\W)(Error|\w+Error)\b[\s\[:]/.test(x)) || L.filter((x) => !/^(Node\.js v|at |[{}]|code:)/.test(x)).pop() || (p.error ? String(p.error.message) : 'nothing on its output'); return r.replace(/0x[0-9a-fA-F]{40,}/g, '0x..').slice(0, 160); }; async function citeste(f) { // the file: refused BY NAME when it is not a receipt (2026-10-27, R9-S9: until then {} or a text that is not JSON ended in an exception // with exit 1, the code of INVALID, so the exit code did not tell a forged receipt from a reader that fell) const nuE = (m) => { console.log('INVALID: the file is not a receipt: ' + m); process.exitCode = 1; }; let r; try { r = JSON.parse(fs.readFileSync(f, 'utf8').replace(/^/, '')); } catch (e) { return nuE('it is not JSON (' + String(e && e.message).split('\n')[0].slice(0, 100) + ')'); } if (!obj(r)) return nuE('it holds ' + ce(r) + ', not an object {instruction, envelope}'); if (!obj(r.instruction) || !obj(r.envelope)) return nuE('it has no instruction object and settlement envelope object'); const ins = r.instruction; const rpc = opt('--rpc'); const vp = opt('--verify-proof', process.env.AERE_VERIFY_PROOF || null); const chain = opt('--chain', ins.chainId); const nem = []; // what was NOT measured: any entry makes the verdict PARTIAL (exit 2), never VALID let tx = null, receipt = null, lantNuStie = false, altLant = null; if (rpc) { const apel = async (method, params) => { const x = await fetch(rpc, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(20000) }); const j = await x.json(); if (j.error) throw new Error(method + ': ' + j.error.message); return j.result; }; try { // an RPC of another chain does not know the transaction, and that is not a verdict about the receipt; nor is anything it would // tell the reference verifier about the notarization (2026-10-27, R9-S1: it is not handed on, the envelope is judged offline) const cid = Number(BigInt(await apel('eth_chainId', []))); if (ins.chainId !== undefined && cid !== Number(ins.chainId)) { altLant = cid; nem.push(`the RPC serves chain ${cid}, the receipt names chain ${ins.chainId}: the transaction and the notarization were not compared there`); } else { tx = await apel('eth_getTransactionByHash', [ins.txHash]); receipt = await apel('eth_getTransactionReceipt', [ins.txHash]); if (!tx || !receipt) { lantNuStie = true; tx = null; receipt = null; } } } catch (e) { tx = null; receipt = null; nem.push('the chain did not answer: ' + e.message); } } else nem.push('the chain was not consulted (no --rpc): the transaction was not compared'); // the policy and the inputs are what reproduces the risk verdict; a file without them cannot be judged on those rules (PARTIAL, not // INVALID): the module refuses them by name (politica, risc), and those two refusals are set aside here, every other rule still counts const lipsa = [!r.policy && 'policy', !r.inputs && 'inputs'].filter(Boolean); const absente = new Set(lipsa.length ? (r.policy ? ['risc'] : ['politica']) : []); if (lipsa.length) nem.push(`the file carries no ${lipsa.join(' and ')}: the risk verdict was not reproduced (the receiptFile that /v1/payments/receipt returns carries both)`); const v = verificaChitanta({ instruction: ins, envelope: r.envelope }, { policy: r.policy, inputs: r.inputs, tx, receipt }); const reguliRele = (v.reguli || []).filter((x) => !absente.has(x)); let fin = null, verdictPlic = null, plicStare = 'not run'; if (!vp) nem.push('the envelope was not judged (no --verify-proof): its notarization under a post-quantum certified anchor was not checked'); else if (!fs.existsSync(vp)) { plicStare = 'not run (no verifier at ' + vp + ')'; nem.push('the reference verifier is not at ' + vp); } else { const t = path.join(os.tmpdir(), 'chitanta-' + process.pid + '.json'); fs.writeFileSync(t, JSON.stringify(r.envelope)); const cu = altLant !== null ? ['--offline', '--chain', String(chain)] : rpc ? ['--rpc', rpc, '--chain', String(chain)] : []; try { const p = spawnSync(process.execPath, [vp, t, '--json', ...cu], { encoding: 'utf8', timeout: 300000 }); let j = null; try { j = JSON.parse(p.stdout); } catch { j = null; } if (j && typeof j.verdict === 'string') { verdictPlic = j.verdict; fin = (j.levels || []).find((l) => l.level === 'finality') || null; } else { // 2026-10-27 (R9-S3): it RAN and gave no verdict (until then this line said "not run"); its own error line is printed, and the // usual cause named: the verifier alone, without the files it imports beside it or without npm install plicStare = 'ran, gave no JSON (exit ' + p.status + '): ' + randUtil(p); nem.push('the reference verifier ' + plicStare + '; it needs verify-anchor.mjs, mpt.mjs, falcon512.mjs, registry-v3.mjs and package.json beside it, with npm install done there'); } } finally { fs.rmSync(t, { force: true }); } } const motiveRele = [...(reguliRele.length ? v.motive.filter((m) => !/^no (policy|inputs) given/.test(m)) : []), ...(lantNuStie ? [`the chain does not know transaction ${ins.txHash}`] : []), ...(verdictPlic === 'INVALID' ? ['the reference verifier finds the envelope INVALID'] : []), ...(fin && fin.state === 'FAILED' ? ['the notarization FAILED under the reference verifier: ' + String(fin.detail).slice(0, 140)] : [])]; const invalid = motiveRele.length > 0; // as POST /v1/payments/verify: VALID only with the finality of the notarization PASSED (and the chain compared); PENDING, UNMEASURED or // no finality level at all is PARTIAL. On an RPC of another chain the envelope was judged offline, and the reason is already named. // 2026-10-28 (R10-STRAIN-05): "not under an anchor yet" is a fact about the chain and is said only for PENDING; UNMEASURED or no finality // level is a gap of this run, said as such (the same receipt read through an endpoint that answers can come out PASSED) if (verdictPlic && !invalid && altLant === null && !(fin && fin.state === 'PASSED')) nem.push(fin && fin.state === 'PENDING' ? `the notarization is not under a post-quantum certified anchor yet (finality PENDING: ${String(fin.detail).slice(0, 120)})` : `the notarization was not measured under a post-quantum certified anchor (finality ${fin ? fin.state + ': ' + String(fin.detail).slice(0, 120) : 'absent'})`); const s = (x, n) => (typeof x === 'string' ? x.slice(0, n) : '?'); const rate = obj(ins.rate) ? ins.rate : {}, risk = obj(ins.risk) ? ins.risk : {}; const randuri = [`receipt: ${ins.amountUsd ?? '?'} USD = ${ins.amount ?? '?'} units of ${ins.asset ?? '?'} at ${rate.usdPerUnit ?? '?'} USD/unit (${rate.source ?? '?'}), ${s(ins.payer, 10)}.. -> ${s(ins.payee, 10)}.., tx ${s(ins.txHash, 18)}.., risk ${risk.decision ?? '?'} under policy ${s(risk.policyHash, 18)}..`, reguliRele.length || !lipsa.length ? `rules: ${v.verdict}${v.motive.length ? ': ' + v.motive.join('; ') : ''}${v.chainChecked ? ' (transaction compared on chain)' : ' (chain not consulted)'}` : `rules: not all judged (no ${lipsa.join(' and ')} in the file); the others hold${v.chainChecked ? ' (transaction compared on chain)' : ' (chain not consulted)'}`, verdictPlic ? `envelope (reference verifier${altLant !== null ? ', offline: the RPC serves another chain' : ''}): ${verdictPlic}; finality ${fin ? fin.state + ': ' + String(fin.detail).slice(0, 140) : 'absent'}` : 'envelope (reference verifier): ' + plicStare]; console.log(randuri.join('\n')); const valid = !invalid && !nem.length && v.chainChecked && fin && fin.state === 'PASSED'; process.exitCode = invalid ? 1 : valid ? 0 : 2; if (invalid) console.log('INVALID: ' + motiveRele.join('; ')); else if (valid) console.log('VALID, notarized under a post-quantum certified anchor'); else console.log('PARTIAL: ' + (nem.length ? nem.join('; ') : 'the transaction was not compared on chain')); } const f = a[0]; if (!f || !fs.existsSync(f)) { console.log('usage: verifica-chitanta.mjs [--rpc URL] [--verify-proof path] [--chain id]'); process.exitCode = 2; } // a reader that falls is not a verdict: an exception it did not foresee ends the run PARTIAL (exit 2), named, never with the code of // INVALID (2026-10-27, R9-S9) else await citeste(f).catch((e) => { console.log('PARTIAL: the reader stopped before a verdict: ' + String(e && e.message || e).split('\n')[0].slice(0, 160)); process.exitCode = 2; });