aere-quantum/payments/verifica-chitanta.mjs

117 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <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; 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, or the notarization not under an anchor yet: finality PENDING or
// UNMEASURED), 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.)
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.
if (verdictPlic && !invalid && altLant === null && !(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 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 <receipt.json> [--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; });