aere-quantum/agents/x402/wallet.mjs
Aere Network 2293c1da86 agents/x402: an agent wallet that pays over x402 only what the agent's ledger records and its policy allows, run on the public testnet
The agent does not hold the payment key: the wallet holds it for the owner and signs an EIP-3009 authorization only for a payment the
agent wrote into its signed ledger, verified without trusting the agent under the policy the owner pinned and against the ledger heads
the wallet itself saw (a branch is refused with a proof of equivocation, a backdated entry is refused), naming exactly this purchase,
written now, and within the limit judged also against what the wallet itself has signed. The authorization nonce is sha256(entry hash),
so the on-chain payment names the ledger entry. The policy gains an optional `wallet` field. Also: an x402 v2 client, a minimal resource
server, a local facilitator for tests, and verifica-plati.mjs, which proves from outside that a wallet's on-chain payments were allowed
by the agent's policy (with --all-transfers, that no payment left the wallet without a ledger entry).

Tests: wallet 25/25 and payment verifier 10/10 without a network (the verifier on chain responses recorded on testnet 28001), negative
control 21/21; policy 27/27, agents control 26/26. On the public testnet 28001 through its x402 facilitator: 9/9, with the evidence in
agents/x402/dovezi-28001/. Needs ethers (npm install in agents/x402).
2026-09-30 00:01:34 +03:00

219 lines
17 KiB
JavaScript

#!/usr/bin/env node
// Aere Agent Wallet (roadmap punctele 23, 25, 26, 2026-09-29): portofelul de plati x402 al unui agent AI, care semneaza o plata NUMAI
// daca agentul a inregistrat-o in registrul lui (agent-ledger.mjs) si politica proprietarului o permite. Agentul NU tine cheia de
// plata: o tine portofelul (serviciul proprietarului). Agentul are numai cheia lui ML-DSA-65 cu care isi semneaza registrul; cererea
// catre portofel e autentificata chiar de intrarea noua din registru, pe care numai agentul o poate semna.
//
// CE VERIFICA, la fiecare cerere (authorize):
// 1. cerinta x402 (schema exact, reteaua si activul portofelului, payTo, suma intreaga, termenul);
// 2. registrul intreg, fara incredere (verifyLedger): politica FIXATA de proprietar la pornire (hash-ul ei), identitatea, fiecare
// semnatura, lantul, decizia re-rulata, revocarile primite de portofel de la proprietar, si ANCORELE: capetele pe care portofelul
// le-a vazut el insusi, cu ora lui. Portofelul e martorul: un registru care nu continua ce a vazut e o ramura (si daca a pastrat
// intrarea, scoate dovada de echivocare), iar o intrare scrisa dupa un cap nu poate declara o ora dinaintea lui;
// 3. ultima intrare e o plata NOUA, permisa, din portofelul acesta, catre payTo, cu suma si activul cerute, legata de cumparatura prin
// `ref` (hash-ul cerintei), scrisa ACUM dupa ceasul portofelului;
// 4. limita, a doua oara, fata de ce a SEMNAT portofelul (nu numai fata de ce spune registrul): chiar un registru rescris sau ramificat
// nu poate scoate din portofel mai mult decat permite politica.
// Apoi semneaza o autorizare EIP-3009 (TransferWithAuthorization) al carei nonce e sha256(hash-ul intrarii): plata de pe lant se leaga
// de intrarea din registru, si aceeasi intrare nu poate plati de doua ori (nici la portofel, nici pe lant). Intoarce PaymentPayload-ul
// x402 v2, de pus de agent in antetul PAYMENT-SIGNATURE, si un plic AIP-23 cu decizia.
//
// CE NU FACE: nu trimite tranzactii (decontarea o face facilitatorul x402 al serverului de resurse); nu tine alt activ decat unul
// EIP-3009 configurat; nu e custodie fara incredere: cine tine cheia portofelului (proprietarul) poate plati fara agent. Un portofel-
// contract 2-din-2 (agentul + politica, ERC-1271) ar scoate si increderea asta; nu e facut.
//
// node wallet.mjs serve --config wallet.json [--port 8793] serviciul HTTP (POST /authorize, POST /revocations, GET /status)
// wallet.json: { policy, policyHash, network, token:{address,name,version}, evmKeyFile, stateFile, toleranceSeconds?, maxValiditySeconds? }
import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import crypto from 'node:crypto';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { hashPolicy, checkAction, decisionEnvelope, canonical } from '../agent-policy.mjs';
import { verifyLedger, verifyEquivocation, sessionId } from '../agent-ledger.mjs';
import { verifyApproval, verifyRevocation } from '../agent-aprobare.mjs';
export const VERSION = 'aere-agent-wallet/1 (2026-09-29)';
const AICI = path.dirname(fileURLToPath(import.meta.url));
/** ethers: AERE_ETHERS (o cale, pentru copiile controlului negativ), langa portofel (npm install aici), sau din contracts/node_modules */
export function incarcaEthers() {
const req = createRequire(import.meta.url);
if (process.env.AERE_ETHERS) return req(process.env.AERE_ETHERS);
try { return req('ethers'); } catch { /* nu e langa portofel */ }
try { return req(path.resolve(AICI, '..', '..', '..', 'contracts', 'node_modules', 'ethers')); } catch { /* nici in depozitul de dezvoltare */ }
throw new Error('the wallet needs the ethers package: run `npm install` in this directory');
}
const sha = (s) => '0x' + crypto.createHash('sha256').update(typeof s === 'string' ? Buffer.from(s, 'utf8') : s).digest('hex');
const ADRESA = /^0x[0-9a-fA-F]{40}$/;
const INTREG = /^[0-9]+$/;
const TIPURI = { TransferWithAuthorization: [
{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'value', type: 'uint256' },
{ name: 'validAfter', type: 'uint256' }, { name: 'validBefore', type: 'uint256' }, { name: 'nonce', type: 'bytes32' }] };
export { TIPURI as EIP3009_TYPES };
/** activul, ca identificator CAIP-19: eip155:<chain>/erc20:<adresa cu litere mici> */
export const assetId = (network, token) => `${network}/erc20:${String(token).toLowerCase()}`;
/** legatura intrarii din registru cu cumparatura: hash-ul canonic al resursei si al cerintei x402 */
export function purchaseRef(resource, req) {
return sha(canonical({ resource: resource && resource.url ? String(resource.url) : null, scheme: String(req.scheme), network: String(req.network),
asset: String(req.asset).toLowerCase(), payTo: String(req.payTo).toLowerCase(), amount: String(req.amount) }));
}
/** actiunea pe care agentul o scrie in registru inainte sa ceara plata (acelasi calcul la agent si la portofel) */
export function x402Action(walletAddress, resource, req) {
return { kind: 'payment', from: String(walletAddress).toLowerCase(), to: String(req.payTo).toLowerCase(), amount: String(req.amount),
asset: assetId(req.network, req.asset), ref: purchaseRef(resource, req) };
}
/** nonce-ul EIP-3009 al platii: sha256(hash-ul intrarii), deci plata de pe lant numeste intrarea */
export const nonceForEntry = (entryHash) => sha(Buffer.from(String(entryHash), 'utf8'));
/**
* @param {object} c { policy, policyHash, evmPrivateKey, network, token:{address,name,version}, state?, saveState?, now?, toleranceSeconds?, maxValiditySeconds? }
*/
export function createWallet(c) {
const ethers = incarcaEthers();
const { policy, policyHash } = hashPolicy(c.policy);
if (c.policyHash != null && String(c.policyHash).toLowerCase() !== policyHash) throw new Error('agent-wallet: the policy does not hash to the pinned policyHash');
const cont = new ethers.Wallet(String(c.evmPrivateKey || '').trim());
const adresa = cont.address.toLowerCase();
if (policy.wallet !== adresa) throw new Error('agent-wallet: the policy does not name this wallet (policy.wallet must be its address)');
if (!/^eip155:[0-9]+$/.test(String(c.network))) throw new Error('agent-wallet: network must be eip155:<chainId>');
if (!c.token || !ADRESA.test(String(c.token.address)) || !c.token.name || !c.token.version) throw new Error('agent-wallet: token needs address, name and version (its EIP-712 domain)');
const network = String(c.network), token = String(c.token.address).toLowerCase();
const domeniu = { name: String(c.token.name), version: String(c.token.version), chainId: Number(network.split(':')[1]), verifyingContract: ethers.getAddress(token) };
// un portofel fara limita de cheltuiala in activul lui ar semna orice suma pe care o scrie agentul: nu porneste
if (!policy.spend || policy.spend.asset !== assetId(network, token)) throw new Error(`agent-wallet: the policy needs a spending limit in this wallet's asset (spend.asset = ${assetId(network, token)})`);
const now = c.now || (() => Math.floor(Date.now() / 1000));
const TOL = Number(c.toleranceSeconds ?? 120);
const VALID = Number(c.maxValiditySeconds ?? 300);
// starea: pe SESIUNE (o sesiune = agentul sub o politica) capetele vazute (ancore) si ultima intrare (pentru dovada de echivocare);
// pe AGENT platile SEMNATE si revocarile primite. Platile semnate raman peste o schimbare de politica: un registru nou (politica noua,
// sesiune noua) incepe de la zero cheltuiala, portofelul nu. Se salveaza INAINTE de a intoarce semnatura.
const stare = c.state || { v: 1, agentId: policy.agentId, sessions: {}, signed: [], revocations: [] };
if (stare.agentId !== policy.agentId) throw new Error('agent-wallet: the saved state belongs to another agent');
const sesiune = sessionId(policy.agentId, policyHash);
const S = stare.sessions[sesiune] || (stare.sessions[sesiune] = { anchors: [], last: null, lastEntry: null });
const salveaza = () => { if (c.saveState) c.saveState(stare); };
const refuz = (error, extra = {}) => ({ ok: false, error, ...extra });
async function autorizeaza({ requirements: req, resource = null, ledger } = {}) {
const t = Number(now());
// 1. cerinta
if (!req || req.scheme !== 'exact') return refuz('only the exact scheme is supported');
if (req.network !== network) return refuz(`the requirement is for network ${String(req.network).slice(0, 40)}, this wallet pays on ${network}`);
if (String(req.asset || '').toLowerCase() !== token) return refuz('the requirement is for another asset than this wallet holds');
if (!ADRESA.test(String(req.payTo || ''))) return refuz('payTo is not an address');
if (!INTREG.test(String(req.amount || '')) || BigInt(req.amount) === 0n) return refuz('amount is not a positive integer');
const timeout = Number(req.maxTimeoutSeconds);
if (!Number.isInteger(timeout) || timeout < 10) return refuz('maxTimeoutSeconds is missing or below 10 seconds');
// 2. registrul, fara incredere, fata de ce a vazut portofelul
if (!ledger || !Array.isArray(ledger.entries) || !ledger.entries.length) return refuz('a ledger with the payment entry is required');
const v = verifyLedger(ledger, { policy, policyHash, maxTime: t + 5, revocations: stare.revocations, anchors: S.anchors, anchorTolerance: TOL });
if (!v.ok) {
const r = refuz(`the ledger does not verify${v.seq != null ? ` at seq ${v.seq}` : ''}: ${v.error}`);
// o ramura: intrarea pastrata de la ultimul cap si cea de acum, la aceeasi pozitie, semnate amandoua de agent, sunt dovada
const acum = S.lastEntry && ledger.entries[S.lastEntry.seq];
if (acum && acum.hash !== S.lastEntry.hash) {
const p = { v: 1, kind: 'aere-agent-equivocation', agentId: ledger.agentId, session: ledger.session, publicKeyPem: ledger.publicKeyPem,
seq: S.lastEntry.seq, a: S.lastEntry, b: acum };
if (p.agentId === policy.agentId && verifyEquivocation(p).ok) r.equivocation = p;
}
return r;
}
// 3. ultima intrare: plata noua, permisa, legata de cumparatura, scrisa acum
const e = ledger.entries[ledger.entries.length - 1];
if (S.last && e.seq <= S.last.seq) return refuz(`the last entry (seq ${e.seq}) was already seen; each payment needs a new entry`);
const b = e.body, a = b.action;
if (!b.decision.allowed || a.kind !== 'payment') return refuz('the last entry is not an allowed payment');
const asteptat = x402Action(adresa, resource, req);
for (const k of ['from', 'to', 'amount', 'asset', 'ref']) if (String(a[k] ?? '').toLowerCase() !== String(asteptat[k]).toLowerCase()) return refuz(`the payment entry names another ${k} than this purchase`);
if (!(Number(b.at) >= t - TOL && Number(b.at) <= t + 5)) return refuz(`the payment entry is dated ${b.at}, not now (${t}) by this wallet's clock`);
// 4. limita, fata de ce a SEMNAT portofelul (aprobarile intrarii, verificate; refolosirea unei aprobari o prinde deja verifyLedger,
// iar o aprobare e legata de politica, deci nu trece intr-o sesiune noua)
const aprobatori = [];
for (const ap of b.approvals || []) { const r = verifyApproval(ap, { policy, policyHash, action: a, at: Number(b.at) }); if (r.ok) aprobatori.push(r.humanId); }
// (revocarile nu se mai dau aici: verifyLedger le-a judecat deja, iar o revocare e legata de politica, deci nu e alta multime)
const d = checkAction(policy, { ...a, at: Number(b.at) }, stare.signed.map((s) => ({ amount: s.amount, at: s.at })), { approvers: aprobatori });
if (!d.allowed) return refuz(`by what this wallet has signed: ${d.reason}`);
// semnatura EIP-3009
const authorization = { from: cont.address, to: ethers.getAddress(req.payTo), value: String(req.amount), validAfter: String(t - 5),
validBefore: String(t + Math.min(timeout, VALID)), nonce: nonceForEntry(e.hash) };
const signature = await cont.signTypedData(domeniu, TIPURI, { ...authorization, value: BigInt(authorization.value),
validAfter: BigInt(authorization.validAfter), validBefore: BigInt(authorization.validBefore) });
S.anchors.push({ seq: e.seq, hash: e.hash, at: t });
S.last = { seq: e.seq, hash: e.hash }; S.lastEntry = e;
stare.signed.push({ seq: e.seq, entryHash: e.hash, amount: String(req.amount), at: Number(b.at), payTo: authorization.to, nonce: authorization.nonce });
salveaza();
const paymentPayload = { x402Version: 2, ...(resource ? { resource } : {}), accepted: req, payload: { signature, authorization },
extensions: { 'aere-agent-ledger': { agentId: ledger.agentId, policyHash, session: ledger.session, entrySeq: e.seq, entryHash: e.hash } } };
return { ok: true, paymentPayload, header: Buffer.from(JSON.stringify(paymentPayload), 'utf8').toString('base64'),
receipt: { entrySeq: e.seq, entryHash: e.hash, nonce: authorization.nonce, from: cont.address, to: authorization.to, value: authorization.value },
decision: decisionEnvelope({ policyHash, action: a, decision: d, createdAt: new Date(t * 1000).toISOString() }) };
}
// cererile se judeca UNA CATE UNA: verificarea si semnatura au un `await` intre ele, iar doua cereri deodata ar trece amandoua de
// limita inainte ca vreuna sa fie scrisa in stare
let coada = Promise.resolve();
function authorize(x) { const r = coada.then(() => autorizeaza(x)); coada = r.catch(() => {}); return r; }
function addRevocation(rv) {
const r = verifyRevocation(rv, { policy, policyHash });
if (!r.ok) return refuz(`revocation refused: ${r.error}`);
stare.revocations.push(rv); salveaza();
return { ok: true, revokedAt: r.revokedAt };
}
const status = () => ({ version: VERSION, address: cont.address, network, asset: token, agentId: policy.agentId, policyHash,
lastSeq: S.last ? S.last.seq : null, signed: stare.signed.length, revocations: stare.revocations.length });
// starea, ca sa fie pastrata peste o schimbare de politica (acelasi agent, acelasi portofel): o copie, nu referinta
const exportState = () => JSON.parse(JSON.stringify(stare));
return { address: cont.address, network, token, policyHash, authorize, addRevocation, status, exportState };
}
// ---------------------------------------------------------------- serviciul HTTP (numai pe 127.0.0.1 implicit)
export function serve(wallet, { port = 8793, host = '127.0.0.1', limit = 8 << 20 } = {}) {
const trimite = (res, cod, o) => { const b = JSON.stringify(o); res.writeHead(cod, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(b) }); res.end(b); };
const citeste = (req) => new Promise((resolve) => { const parti = []; let n = 0;
req.on('data', (x) => { n += x.length; if (n > limit) { resolve(null); req.destroy(); } else parti.push(x); });
req.on('end', () => { try { resolve(JSON.parse(Buffer.concat(parti).toString('utf8'))); } catch { resolve(null); } }); });
const srv = http.createServer(async (req, res) => {
const url = (req.url || '/').split('?')[0];
try {
if (req.method === 'GET' && url === '/status') return trimite(res, 200, wallet.status());
if (req.method === 'POST' && (url === '/authorize' || url === '/revocations')) {
const body = await citeste(req);
if (!body) return trimite(res, 400, { ok: false, error: 'invalid or too large JSON' });
const r = url === '/authorize' ? await wallet.authorize(body) : wallet.addRevocation(body);
return trimite(res, r.ok ? 200 : 403, r);
}
trimite(res, 404, { ok: false, error: 'not found' });
} catch (e) { trimite(res, 500, { ok: false, error: String(e.message || e).replace(/(0x)?[0-9a-fA-F]{40,}/g, '<hex>').slice(0, 200) }); }
});
return new Promise((resolve) => srv.listen(port, host, () => resolve(srv)));
}
/** un portofel din fisierul de configurare: cheia EVM citita dintr-un fisier (un singur rand hex sau d=/PRIVATE_KEY=), starea intr-un fisier */
export function walletFromConfig(file) {
const c = JSON.parse(fs.readFileSync(file, 'utf8'));
const brut = fs.readFileSync(path.resolve(path.dirname(file), c.evmKeyFile), 'utf8').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const rand = brut.find((l) => /^(d|PRIVATE_KEY)=/.test(l)) || brut[0] || '';
const hex = rand.replace(/^(d|PRIVATE_KEY)=/, '').replace(/^0x/, '').trim();
if (!/^[0-9a-fA-F]{1,64}$/.test(hex)) throw new Error('the EVM key file has no hex key (a line of hex, d=<hex> or PRIVATE_KEY=0x<hex>)');
const sf = c.stateFile ? path.resolve(path.dirname(file), c.stateFile) : null;
const state = sf && fs.existsSync(sf) ? JSON.parse(fs.readFileSync(sf, 'utf8')) : undefined;
const saveState = sf ? (s) => { const tmp = `${sf}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(s)); fs.renameSync(tmp, sf); } : null;
return createWallet({ ...c, evmPrivateKey: '0x' + hex.padStart(64, '0'), state, saveState });
}
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; };
if (a[0] !== 'serve' || !get('--config')) { console.error('usage: node wallet.mjs serve --config wallet.json [--port 8793]'); process.exitCode = 2; }
else {
try {
const w = walletFromConfig(get('--config'));
const port = Number(get('--port') || 8793);
await serve(w, { port });
const s = w.status();
console.log(`agent wallet on 127.0.0.1:${port}: ${s.address} pays ${s.asset} on ${s.network} for ${s.agentId} under policy ${s.policyHash}`);
} catch (e) { console.error('agent-wallet: ' + String(e.message || e).replace(/(0x)?[0-9a-fA-F]{40,}/g, '<hex>').slice(0, 200)); process.exitCode = 1; }
}
}