aere-quantum/payments/verifica-chitanta.mjs

70 lines
7.2 KiB
JavaScript

#!/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 <receipt.json> [--rpc URL] [--verify-proof <verify-proof.mjs>] [--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 <receipt.json> [--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'));