#!/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). // 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. 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, the policy or the // inputs missing from the file, or the notarization not under an anchor yet: finality PENDING or UNMEASURED). 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 reference verifier answered PARTIAL or PENDING; a file without policy or inputs was INVALID.) 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 f = a[0]; if (!f || !fs.existsSync(f)) { console.log('usage: verifica-chitanta.mjs [--rpc URL] [--verify-proof path] [--chain id]'); process.exit(2); } const r = JSON.parse(fs.readFileSync(f, 'utf8')); const rpc = opt('--rpc'); const vp = opt('--verify-proof', process.env.AERE_VERIFY_PROOF || null); const chain = opt('--chain', r.instruction && r.instruction.chainId); const nem = []; // what was NOT measured: any entry makes the verdict PARTIAL (exit 2), never VALID let tx = null, receipt = null, lantNuStie = false; 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 const cid = Number(BigInt(await apel('eth_chainId', []))); if (r.instruction && r.instruction.chainId !== undefined && cid !== Number(r.instruction.chainId)) nem.push(`the RPC serves chain ${cid}, the receipt names chain ${r.instruction.chainId}: the transaction was not compared`); else { tx = await apel('eth_getTransactionByHash', [r.instruction.txHash]); receipt = await apel('eth_getTransactionReceipt', [r.instruction.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: r.instruction, 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; 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)) 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)); try { const p = spawnSync(process.execPath, [vp, t, '--json', ...(rpc ? ['--rpc', rpc, '--chain', String(chain)] : [])], { encoding: 'utf8', timeout: 300000 }); try { const j = JSON.parse(p.stdout); verdictPlic = j.verdict; fin = (j.levels || []).find((l) => l.level === 'finality') || null; } catch { nem.push('the reference verifier gave no JSON'); } } 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 ${r.instruction.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 if (verdictPlic && !invalid && !(fin && fin.state === 'PASSED')) nem.push(`the notarization is not under a post-quantum certified anchor yet (finality ${fin ? fin.state + ': ' + String(fin.detail).slice(0, 120) : 'absent'})`); const randuri = [`receipt: ${r.instruction.amountUsd} USD = ${r.instruction.amount} units of ${r.instruction.asset} at ${r.instruction.rate.usdPerUnit} USD/unit (${r.instruction.rate.source}), ${r.instruction.payer.slice(0, 10)}.. -> ${r.instruction.payee.slice(0, 10)}.., tx ${r.instruction.txHash.slice(0, 18)}.., risk ${r.instruction.risk.decision} under policy ${r.instruction.risk.policyHash.slice(0, 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): ${verdictPlic}; finality ${fin ? fin.state + ': ' + String(fin.detail).slice(0, 140) : 'absent'}` : 'envelope (reference verifier): not run']; 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'));