117 lines
11 KiB
JavaScript
117 lines
11 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). 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; });
|