AereAgentWallet2of2 holds the agent's tokens and accepts only the agent's signature followed by the policy service's, over the same digest. In the new cosign mode the wallet service signs only its half, after every check it already made, and the agent adds its half only after it recomputes the payment itself (payer, recipient, amount, the nonce of its own ledger entry, validity, digest, declared policy signer). verifica-plati.mjs requires the wallet's code on chain to be exactly the compiled contract with the two signers; recompileaza-contract.mjs recompiles the published artifact byte for byte with solc 0.8.23. Tests: co-signing 16/16, payment verifier 14/14, negative control 30/30, wallet 25/25. On the public testnet 28001 on 2026-09-29: 13/13 with the 2-of-2 wallet (the agent alone and the policy signer alone refused by the facilitator and by the token asked on chain) and 9/9 with the wallet key. Evidence in dovezi-28001/. Nothing here has been run on the Aere Network mainnet.
234 lines
19 KiB
JavaScript
234 lines
19 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.
|
|
//
|
|
// DOUA MODURI. `eoa`: portofelul E adresa cheii pe care o tine; cine tine cheia (proprietarul) poate plati si fara agent. `cosign`
|
|
// (2026-09-29, `contractWallet`): banii stau intr-un portofel-contract 2-din-2 (contracts/contracts/x402/AereAgentWallet2of2.sol,
|
|
// ERC-1271) care cere semnatura agentului SI a acestui serviciu; serviciul semneaza aici numai jumatatea lui, peste acelasi digest
|
|
// EIP-712, iar agentul isi adauga jumatatea dupa ce isi recalculeaza singur digestul (client.mjs). Nici agentul, nici proprietarul
|
|
// nu pot plati singuri.
|
|
//
|
|
// CE NU FACE: nu trimite tranzactii (decontarea o face facilitatorul x402 al serverului de resurse); nu tine alt activ decat unul
|
|
// EIP-3009 configurat.
|
|
//
|
|
// 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, contractWallet?, 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());
|
|
// in modul cosign portofelul e contractul, iar cheia tinuta aici e numai a semnatarului de politica
|
|
if (c.contractWallet != null && !ADRESA.test(String(c.contractWallet))) throw new Error('agent-wallet: contractWallet must be an address');
|
|
const cosign = c.contractWallet != null;
|
|
const platitor = cosign ? ethers.getAddress(c.contractWallet) : cont.address;
|
|
const adresa = platitor.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 semneazaDigest = async (d) => cont.signingKey.sign(d).serialized;
|
|
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: platitor, 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 digest = ethers.TypedDataEncoder.hash(domeniu, TIPURI, { ...authorization, value: BigInt(authorization.value),
|
|
validAfter: BigInt(authorization.validAfter), validBefore: BigInt(authorization.validBefore) });
|
|
// aceeasi semnatura ECDSA peste digestul EIP-712 in ambele moduri (in eoa e chiar semnatura EIP-712 a platitorului). Semnarea e
|
|
// asincrona dinadins (un semnatar din afara procesului, KMS sau HSM, se leaga aici): de aceea cererile trec prin coada de mai jos.
|
|
const semnatura = await semneazaDigest(digest);
|
|
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: cosign ? null : semnatura, authorization },
|
|
extensions: { 'aere-agent-ledger': { agentId: ledger.agentId, policyHash, session: ledger.session, entrySeq: e.seq, entryHash: e.hash } } };
|
|
return { ok: true, paymentPayload, header: cosign ? null : Buffer.from(JSON.stringify(paymentPayload), 'utf8').toString('base64'),
|
|
...(cosign ? { cosign: { digest, policySignature: semnatura, order: 'agent signature, then policy signature (65 + 65 bytes)' } } : {}),
|
|
receipt: { entrySeq: e.seq, entryHash: e.hash, nonce: authorization.nonce, from: platitor, 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, mode: cosign ? 'cosign-2of2' : 'eoa', address: platitor, ...(cosign ? { policySigner: cont.address } : {}),
|
|
network, asset: token, tokenName: String(c.token.name), tokenVersion: String(c.token.version), 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: platitor, mode: cosign ? 'cosign-2of2' : 'eoa', 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; }
|
|
}
|
|
}
|