#!/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:/erc20: */ 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:'); 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, '').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= or PRIVATE_KEY=0x)'); 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, '').slice(0, 200)); process.exitCode = 1; } } }