aere-quantum/agents/x402/verifica-plati.mjs

141 lines
12 KiB
JavaScript

#!/usr/bin/env node
// verifica-plati.mjs: dovada ca platile de pe lant ale unui portofel de agent au fost PERMISE de politica agentului, verificata de
// oricine, din afara (2026-09-29, punctele 23, 25). Primeste o dosar-dovada {policy, policyHash, ledger, wallet, network, token,
// payments:[{entrySeq, entryHash, transaction}], fromBlock?} si un RPC, si cere:
// 1. registrul verifica fara incredere sub politica (verifyLedger);
// 2. fiecare plata numeste o intrare a registrului: plata permisa, din portofel, catre destinatar, cu suma si activul;
// 3. pe lant, tranzactia platii are status 1 si emite, din contractul activului, AuthorizationUsed(portofel, nonce) cu nonce =
// sha256(hash-ul intrarii) si Transfer(portofel, destinatar, suma) - deci plata de pe lant e chiar cea din registru;
// 4. cu --all-transfers: ORICE Transfer din portofel in intervalul de blocuri e una din platile de mai sus (nicio plata fara intrare);
// intervalul [fromBlock, capul citit o data cu eth_blockNumber] se citeste in FERESTRE de cel mult --window blocuri (implicit 5000),
// fiindca usile publice refuza un interval mare (2026-10-21, trecerea straina: un singur eth_getLogs de la 4.022.094 la varful lui
// 28001 a primit `-32005 Requested range exceeds maximum RPC range limit`; usa testnetului accepta 5.001 de blocuri si refuza 10.000).
// Un raspuns e MASURAT numai cand `result` e o lista; orice altceva (eroare JSON-RPC, transport, rezultat care nu e lista) e o fereastra
// NECITITA, numarata separat, si verificarea iese NEMASURAT, niciodata "0 transferuri" (lectia din 2026-10-15 a lui CLAUDE.md). Un
// Transfer strain gasit intr-o fereastra citita da INVALID oricum. Peste --max-requests ferestre (implicit 2000) nu se cere nimic:
// verificarea iese NEMASURAT si spune cate ar fi trebuit;
// 5. cand dosarul numeste un portofel-contract 2-din-2 (walletContract: {agentSigner, policySigner}): codul de pe lant al portofelului e
// EXACT codul compilat din AereAgentWallet2of2.sol (artefactul de langa verificator) cu cei doi semnatari in locul imutabilelor, deci
// din portofel nu a putut plati nici agentul singur, nici serviciul de politica singur.
// Ce nu dovedeste: ca serviciul cumparat a fost livrat; ca portofelul nu a semnat si autorizari nedecontate (acelea nu misca bani).
// node verifica-plati.mjs <dovada.json> --rpc <url> [--all-transfers [--window <blocuri>] [--max-requests <n>]]
// iesire 0 VALID, 1 INVALID, 2 NEMASURAT
import fs from 'node:fs';
import { pathToFileURL } from 'node:url';
import { verifyLedger } from '../agent-ledger.mjs';
import { incarcaEthers, nonceForEntry, assetId } from './wallet.mjs';
import { incarcaArtefact, codulAsteptat, amprentaSursei } from './contract-2of2.mjs';
// fereastra implicita: limita masurata a lui rpc2 (5.000, CLAUDE.md 2026-10-15) si sub cea a usii testnetului (refuza 10.000);
// plafonul: 2000 de ferestre = 10 milioane de blocuri la fereastra implicita (28001 avea 7,2 milioane pe 2026-10-21)
export const FEREASTRA_IMPLICITA = 5000, CERERI_MAXIME = 2000;
// cate ferestre se cer deodata: putine, ca usa publica sa nu le refuze pentru ritm (un refuz pentru ritm ar iesi NEMASURAT, nu fals)
const PARALEL = 4;
const hex = (n) => '0x' + n.toString(16);
// citeste un filtru de jurnale pe [from, to] in ferestre de cel mult `window` blocuri. apelBrut(method, params) intoarce raspunsul JSON
// intreg (sau arunca la transport); o fereastra e masurata NUMAI daca `result` e o lista. Intoarce { logs, windows, unreadable, first }
// sau { refused } cand intervalul cere mai mult de `maxRequests` ferestre (atunci nu se cere nimic).
export async function jurnalePaginate(apelBrut, filtru, { from, to, window = FEREASTRA_IMPLICITA, maxRequests = CERERI_MAXIME }) {
const ferestre = [];
for (let a = from; a <= to; a += window) ferestre.push([a, Math.min(a + window - 1, to)]);
if (ferestre.length > maxRequests) return { refused: `blocks ${from}..${to} need ${ferestre.length} requests of ${window} blocks, above the cap of ${maxRequests} (--max-requests); nothing was asked` };
// fiecare fereastra isi tine rezultatul pe pozitia ei, deci jurnalele ies in ordinea lantului oricum ar sosi raspunsurile
const peFereastra = new Array(ferestre.length).fill(null), erori = []; let i = 0;
const citeste = async (k) => {
const [a, b] = ferestre[k];
let j; try { j = await apelBrut('eth_getLogs', [{ ...filtru, fromBlock: hex(a), toBlock: hex(b) }]); } catch (e) { j = { transport: String(e.message || e) }; }
if (j && Array.isArray(j.result)) { peFereastra[k] = j.result; return; }
erori.push([k, `${a}..${b}: ${j && j.error ? String(j.error.message).slice(0, 80) : j && j.transport ? j.transport.slice(0, 80) : 'the result is not a list'}`]);
};
await Promise.all(Array.from({ length: Math.min(PARALEL, ferestre.length) }, async () => { while (i < ferestre.length) await citeste(i++); }));
erori.sort((x, y) => x[0] - y[0]);
return { logs: peFereastra.filter(Boolean).flat(), windows: ferestre.length, unreadable: erori.length, first: erori.length ? erori[0][1] : null };
}
export async function verificaPlati(d, { rpc, allTransfers = false, fetchImpl = fetch, window = FEREASTRA_IMPLICITA, maxRequests = CERERI_MAXIME } = {}) {
const ethers = incarcaEthers();
const rez = []; const ok = (name, pass, detail = '') => { rez.push({ name, pass, detail }); return pass; };
const apelBrut = async (method, params) => {
const r = await fetchImpl(rpc, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
return r.json();
};
const apel = async (method, params) => {
const j = await apelBrut(method, params); if (j.error) throw new Error(`${method}: ${String(j.error.message).slice(0, 80)}`); return j.result;
};
const chain = Number(String(d.network).split(':')[1]);
let lantul;
try { lantul = Number(await apel('eth_chainId', [])); } catch (e) { return { verdict: 'UNMEASURED', checks: [{ name: 'rpc', pass: null, detail: e.message }] }; }
if (!ok(`the RPC serves chain ${chain}`, lantul === chain, `it serves ${lantul}`)) return { verdict: 'UNMEASURED', checks: rez };
const v = verifyLedger(d.ledger, { policy: d.policy, policyHash: d.policyHash, revocations: d.revocations || [] });
ok(`the ledger verifies under the policy (${d.ledger.entries.length} entries)`, v.ok, v.error || '');
const token = String(d.token).toLowerCase(), wallet = String(d.wallet).toLowerCase(), asset = assetId(d.network, token);
const T_AUTH = ethers.id('AuthorizationUsed(address,bytes32)'), T_TRANSFER = ethers.id('Transfer(address,address,uint256)');
const topicAdresa = (a) => '0x' + '0'.repeat(24) + String(a).toLowerCase().slice(2);
const txPlati = new Set();
for (const p of d.payments || []) {
const e = d.ledger.entries[p.entrySeq];
if (!ok(`payment ${p.entrySeq}: the entry is in the ledger`, !!e && e.hash === p.entryHash)) continue;
const a = e.body.action;
ok(`payment ${p.entrySeq}: the entry is an allowed payment from the wallet in the asset`, e.body.decision.allowed && a.kind === 'payment' && String(a.from).toLowerCase() === wallet && a.asset === asset);
let rc;
try { rc = await apel('eth_getTransactionReceipt', [p.transaction]); } catch (x) { ok(`payment ${p.entrySeq}: receipt readable`, false, x.message); continue; }
if (!ok(`payment ${p.entrySeq}: transaction ${String(p.transaction).slice(0, 12)} is on chain with status 1`, !!rc && rc.status === '0x1')) continue;
txPlati.add(String(p.transaction).toLowerCase());
const logs = rc.logs.filter((l) => l.address.toLowerCase() === token);
const nonce = nonceForEntry(e.hash).toLowerCase();
ok(`payment ${p.entrySeq}: AuthorizationUsed(wallet, sha256(entry hash)) is emitted by the asset`, logs.some((l) => l.topics[0] === T_AUTH && l.topics[1].toLowerCase() === topicAdresa(wallet) && l.topics[2].toLowerCase() === nonce));
ok(`payment ${p.entrySeq}: Transfer(wallet, ${String(a.to).slice(0, 10)}.., ${a.amount}) is emitted by the asset`, logs.some((l) => l.topics[0] === T_TRANSFER && l.topics[1].toLowerCase() === topicAdresa(wallet)
&& l.topics[2].toLowerCase() === topicAdresa(a.to) && BigInt(l.data) === BigInt(a.amount)));
}
if (allTransfers) {
const nu = (detail) => rez.push({ name: 'all transfers out of the wallet', pass: null, detail });
try {
const from = Number(d.fromBlock || 0), to = Number(await apel('eth_blockNumber', []));
if (!Number.isSafeInteger(from) || from < 0) nu(`the evidence's fromBlock is not a block number (${d.fromBlock})`);
else if (!Number.isSafeInteger(to) || to < from) nu(`the RPC's head ${to} is not at or above the evidence's fromBlock ${from}`);
else {
const r = await jurnalePaginate(apelBrut, { address: token, topics: [T_TRANSFER, topicAdresa(wallet)] }, { from, to, window, maxRequests });
if (r.refused) nu(r.refused);
else {
const straine = r.logs.filter((l) => !txPlati.has(String(l.transactionHash).toLowerCase()));
// un Transfer strain gasit e INVALID oricum; altfel, o singura fereastra necitita face verificarea NEMASURAT, nu "goala"
if (straine.length || !r.unreadable) {
ok(`every Transfer out of the wallet from block ${from} to ${to} is a payment in the ledger (${r.logs.length} transfers, ${r.windows} windows of at most ${window} blocks${r.unreadable ? `, ${r.unreadable} unreadable` : ''})`,
straine.length === 0, straine.map((l) => String(l.transactionHash).slice(0, 12)).join(', '));
} else nu(`${r.unreadable} of ${r.windows} windows of at most ${window} blocks could not be read and are not counted as empty (first: ${r.first})`);
}
}
} catch (x) { nu(x.message); }
}
if (d.walletContract) {
const wc = d.walletContract; let art = null, asteptat = null;
try { art = incarcaArtefact(); asteptat = codulAsteptat(art, wc); } catch (x) { rez.push({ name: 'the 2-of-2 wallet artifact', pass: null, detail: x.message }); }
if (asteptat) {
try {
const cod = String(await apel('eth_getCode', [d.wallet, 'latest'])).toLowerCase();
ok(`the wallet is the 2-of-2 contract (AereAgentWallet2of2, source ${String(amprentaSursei(art)).slice(0, 12)}..): its code on chain is the compiled code with agent signer ${String(wc.agentSigner).slice(0, 10)}.. and policy signer ${String(wc.policySigner).slice(0, 10)}..`,
cod === asteptat, cod === asteptat ? '' : cod === '0x' ? 'no code at the wallet address' : `the code on chain differs: ${(cod.length - 2) / 2} bytes, expected ${(asteptat.length - 2) / 2}`);
} catch (x) { rez.push({ name: 'the wallet code on chain', pass: null, detail: x.message }); }
}
}
const verdict = rez.some((c) => c.pass === false) ? 'INVALID' : rez.some((c) => c.pass === null) ? 'UNMEASURED' : 'VALID';
return { verdict, checks: rez };
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
const a = process.argv.slice(2); const get = (f) => { const i = a.indexOf(f); return i >= 0 ? a[i + 1] : undefined; };
const valori = ['--rpc', '--window', '--max-requests'].map(get);
const f = a.find((x) => !x.startsWith('--') && !valori.includes(x));
const intreg = (x, implicit) => x === undefined ? implicit : /^[1-9][0-9]*$/.test(x) ? Number(x) : NaN;
const window = intreg(get('--window'), FEREASTRA_IMPLICITA), maxRequests = intreg(get('--max-requests'), CERERI_MAXIME);
if (!f || !get('--rpc') || !Number.isSafeInteger(window) || !Number.isSafeInteger(maxRequests)) {
console.error('usage: node verifica-plati.mjs <evidence.json> --rpc <url> [--all-transfers [--window <blocks, default 5000>] [--max-requests <n, default 2000>]]'); process.exitCode = 2;
} else {
const r = await verificaPlati(JSON.parse(fs.readFileSync(f, 'utf8')), { rpc: get('--rpc'), allTransfers: a.includes('--all-transfers'), window, maxRequests });
for (const c of r.checks) console.log(` ${c.pass === true ? 'OK ' : c.pass === false ? 'FAIL' : '-- '} ${c.name}${c.detail ? ' (' + c.detail + ')' : ''}`);
console.log(r.verdict);
process.exitCode = r.verdict === 'VALID' ? 0 : r.verdict === 'INVALID' ? 1 : 2;
}
}