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.
85 lines
7.2 KiB
JavaScript
85 lines
7.2 KiB
JavaScript
// Clientul x402 al unui agent AI (2026-09-29, punctele 23, 25): cumpara o resursa HTTP care cere plata (402), scriind INTAI plata in
|
|
// registrul agentului, apoi cerand portofelului (wallet.mjs) semnatura, apoi reluand cererea cu antetul PAYMENT-SIGNATURE. Numele
|
|
// antetelor si forma lor sunt ale specificatiei x402 v2 (transportul HTTP): PAYMENT-REQUIRED, PAYMENT-SIGNATURE, PAYMENT-RESPONSE, toate
|
|
// JSON in base64. Daca politica refuza plata, clientul se opreste inainte de portofel (si portofelul ar refuza oricum).
|
|
import { x402Action, nonceForEntry, incarcaEthers, EIP3009_TYPES } from './wallet.mjs';
|
|
|
|
export const b64json = (o) => Buffer.from(JSON.stringify(o), 'utf8').toString('base64');
|
|
export function dinB64json(s) { try { return JSON.parse(Buffer.from(String(s), 'base64').toString('utf8')); } catch { return null; } }
|
|
|
|
/**
|
|
* Modul cosign (portofel-contract 2-din-2): agentul isi adauga jumatatea de semnatura NUMAI dupa ce recalculeaza singur ce semneaza.
|
|
* Un serviciu de politica compromis nu poate face agentul sa semneze alta plata decat cea din intrarea lui: se cer platitorul (contractul),
|
|
* destinatarul si suma din cerinta, nonce-ul = sha256(hash-ul intrarii), un termen marginit, digestul recalculat local, si jumatatea
|
|
* serviciului recuperata la semnatarul de politica declarat.
|
|
*/
|
|
export function completeazaCosemnarea({ payload, req, status, entryHash, agentEvmKey, cosign, now = Math.floor(Date.now() / 1000) }) {
|
|
const ethers = incarcaEthers();
|
|
const a = payload && payload.payload && payload.payload.authorization;
|
|
if (!a) return { ok: false, error: 'the wallet returned no authorization' };
|
|
const eq = (x, y) => String(x).toLowerCase() === String(y).toLowerCase();
|
|
if (!eq(a.from, status.address)) return { ok: false, error: 'the authorization is not from the 2-of-2 wallet' };
|
|
if (!eq(a.to, req.payTo) || String(a.value) !== String(req.amount)) return { ok: false, error: 'the authorization pays another recipient or amount than the purchase' };
|
|
if (!eq(a.nonce, nonceForEntry(entryHash))) return { ok: false, error: "the authorization nonce is not the agent's ledger entry" };
|
|
if (!(Number(a.validBefore) > now && Number(a.validBefore) <= now + Number(req.maxTimeoutSeconds) + 60)) return { ok: false, error: 'the authorization validity is not the requirement timeout' };
|
|
const domeniu = { name: (req.extra && req.extra.name) || status.tokenName, version: (req.extra && req.extra.version) || status.tokenVersion,
|
|
chainId: Number(String(req.network).split(':')[1]), verifyingContract: ethers.getAddress(req.asset) };
|
|
const digest = ethers.TypedDataEncoder.hash(domeniu, EIP3009_TYPES, { ...a, value: BigInt(a.value), validAfter: BigInt(a.validAfter), validBefore: BigInt(a.validBefore) });
|
|
if (!cosign || digest !== cosign.digest) return { ok: false, error: 'the digest the wallet signed is not the one the agent computes' };
|
|
let semnatarPolitica = null; try { semnatarPolitica = ethers.recoverAddress(digest, cosign.policySignature); } catch { semnatarPolitica = null; }
|
|
if (!semnatarPolitica || !eq(semnatarPolitica, status.policySigner)) return { ok: false, error: 'the policy half is not signed by the declared policy signer' };
|
|
const agent = typeof agentEvmKey === 'string' ? new ethers.Wallet(agentEvmKey) : agentEvmKey;
|
|
const signature = ethers.concat([agent.signingKey.sign(digest).serialized, cosign.policySignature]);
|
|
const complet = { ...payload, payload: { ...payload.payload, signature } };
|
|
return { ok: true, header: Buffer.from(JSON.stringify(complet), 'utf8').toString('base64'), paymentPayload: complet };
|
|
}
|
|
|
|
/** portofelul prin HTTP (serviciul wallet.mjs serve), cu aceeasi forma ca obiectul din createWallet */
|
|
export function walletOverHttp(baseUrl, fetchImpl = fetch) {
|
|
const b = String(baseUrl).replace(/\/+$/, '');
|
|
return {
|
|
async status() { const r = await fetchImpl(b + '/status'); return r.json(); },
|
|
async authorize(body) { const r = await fetchImpl(b + '/authorize', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); return r.json(); },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {object} o
|
|
* @param {string} o.url resursa
|
|
* @param {object} o.ledger registrul agentului (openLedger / resumeLedger)
|
|
* @param {object} o.wallet portofelul (createWallet sau walletOverHttp)
|
|
* @param {Array} [o.approvals] aprobari umane pentru aceasta plata, cand politica le cere
|
|
* @param {string|object} [o.agentEvmKey] cheia secp256k1 a agentului, ceruta numai de un portofel-contract 2-din-2 (modul cosign)
|
|
* @param {Function} [o.fetchImpl]
|
|
* @returns {Promise<{paid:boolean, status:number, reason?:string, body?:string, settlement?:object, entry?:object, receipt?:object, decision?:object}>}
|
|
*/
|
|
export async function payWithAgent({ url, ledger, wallet, approvals = [], agentEvmKey = null, fetchImpl = fetch }) {
|
|
const r0 = await fetchImpl(url);
|
|
if (r0.status !== 402) return { paid: false, status: r0.status, body: await r0.text(), reason: r0.ok ? 'no payment was required' : `the resource answered ${r0.status}` };
|
|
const pr = dinB64json(r0.headers.get('payment-required')) || (await r0.json().catch(() => null));
|
|
if (!pr || pr.x402Version !== 2 || !Array.isArray(pr.accepts)) return { paid: false, status: 402, reason: 'the 402 response carries no x402 v2 PaymentRequired' };
|
|
const w = await wallet.status();
|
|
const req = pr.accepts.find((a) => a.scheme === 'exact' && a.network === w.network && String(a.asset).toLowerCase() === String(w.asset).toLowerCase());
|
|
if (!req) return { paid: false, status: 402, reason: `no accepted payment is in this wallet's asset on ${w.network}` };
|
|
// 1. plata, in registru, inainte de orice semnatura
|
|
const r = ledger.record(x402Action(w.address, pr.resource || null, req), { approvals });
|
|
if (!r.allowed) return { paid: false, status: 402, reason: `the agent's policy refused the payment: ${r.reason}`, entry: r.entry };
|
|
// 2. semnatura portofelului, care judeca din nou tot registrul
|
|
const a = await wallet.authorize({ requirements: req, resource: pr.resource || null, ledger: ledger.export() });
|
|
if (!a.ok) return { paid: false, status: 402, reason: `the wallet refused: ${a.error}`, entry: r.entry, equivocation: a.equivocation };
|
|
// 2b. portofelul-contract 2-din-2: agentul verifica si isi adauga jumatatea
|
|
let header = a.header;
|
|
if (a.cosign) {
|
|
if (!agentEvmKey) return { paid: false, status: 402, reason: "the wallet is a 2-of-2 contract and needs the agent's signature: agentEvmKey is missing", entry: r.entry };
|
|
const c = completeazaCosemnarea({ payload: a.paymentPayload, req, status: w, entryHash: r.entry.hash, agentEvmKey, cosign: a.cosign });
|
|
if (!c.ok) return { paid: false, status: 402, reason: `the agent refused to co-sign: ${c.error}`, entry: r.entry };
|
|
header = c.header;
|
|
}
|
|
// 3. cererea reluata, cu plata
|
|
const r1 = await fetchImpl(url, { headers: { 'PAYMENT-SIGNATURE': header } });
|
|
const settlement = dinB64json(r1.headers.get('payment-response'));
|
|
const body = await r1.text();
|
|
return { paid: r1.ok && !!(settlement && settlement.success), status: r1.status, body, settlement, entry: r.entry, receipt: a.receipt, decision: a.decision,
|
|
...(r1.ok ? {} : { reason: `the resource answered ${r1.status} after payment: ${body.slice(0, 160)}` }) };
|
|
}
|