#!/usr/bin/env node // agent-ancora.mjs: LANTUL ca martor al registrului unui agent (punctul 22, pista B, 2026-09-30). // // Ce ramasese deschis dupa B-17 (spus in README): momentul unei intrari e declaratia celui care tine cheia agentului; verificatorul il // margineste de SUS cu ceasul lui, dar de JOS numai daca primeste ancore de la un martor (`anchors` in verifyLedger), iar fara martor // cine tine cheia poate antedata intrari. Aici martorul e AereNotary: capul registrului (seq, hash) devine un plic AIP-23 // (`aere-agent-ledger-head`), se noteaza pe lant, iar momentul `firstSeen` pe care il tine contractul, citit prin verificatorul AIP-23 de // referinta sub un certificat post-cuantic de ancora (dovada Merkle in starea certificata), devine ancora {seq, hash, at}. De aici: // - o intrare de DUPA cap nu poate declara un moment sub firstSeen - toleranta (antedatarea e prinsa); // - o intrare de PANA LA cap nu poate declara un moment peste firstSeen + toleranta; // - un registru care nu contine capul notarizat (alta ramura a aceluiasi agent) nu verifica cu aceasta ancora. // Momentul nu il alege nici agentul, nici cine noteaza: il scrie lantul. Cine noteaza poate numai sa INTARZIE notarizarea (atunci // marginea e mai slaba), nu sa o mute inainte. Nimic din registru (actiuni, sume) nu ajunge pe lant: numai hash-ul declaratiei capului. // // Fisierul de ancore il produce VERIFICATORUL, cu RPC-ul si verificatorul AIP-23 alese de el; o lista de ancore primita de la cel // verificat nu dovedeste nimic (ar fi declaratia lui). // // node agent-ancora.mjs head --ledger l.json [--seq N] [--out head.json] the head as an AIP-23 envelope (no network) // node agent-ancora.mjs notarize --head head.json --rpc URL --key-file f [--notary 0x..] [--out notarized.json] // refuses chain 2800 (mainnet: a real transaction paid by the key's account) unless AERE_CONFIRM_MAINNET=yes // node agent-ancora.mjs anchors --ledger l.json --head n1.json [--head n2.json ...] --rpc URL [--verify-proof verify-proof.mjs] // [--chain 28001] [--out anchors.json] anchors for `agent-cli.mjs verify --anchors`, each read through the AIP-23 verifier // Iesiri: 0 da; 1 nu (un cap nu e al registrului, notarizarea sau finalitatea pica); 2 folosire gresita sau NEMASURAT (finalitate // in asteptare, verificator lipsa). import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import { spawn } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; export const HEAD_KIND = 'aere-agent-ledger-head'; export const NOTARI = { 2800: '0x4aB392c4Aca7D9D4C16c0b60a9514c5025bd58c7', 28001: '0x70099E62735500AA2F85B60C518551a57B202d54' }; const AICI = path.dirname(fileURLToPath(import.meta.url)); const sha = (s) => '0x' + crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex'); const H32 = /^0x[0-9a-f]{64}$/; const HASH_INTRARE = /^[0-9a-f]{64}$/; // hash-ul unei intrari de registru: 64 hex, fara 0x (agent-ledger.mjs) /** Capul registrului la `seq` (implicit ultima intrare), ca plic AIP-23. Declaratia numeste agentul, sesiunea, politica, seq si hash-ul. */ export function ledgerHeadEnvelope(ledger, { seq = null, createdAt = new Date().toISOString() } = {}) { if (!ledger || !Array.isArray(ledger.entries) || !ledger.entries.length) throw new Error('agent-ancora: a ledger with at least one entry is required'); const s = seq == null ? ledger.entries.length - 1 : Number(seq); const e = ledger.entries[s]; if (!Number.isInteger(s) || !e || e.seq !== s || !HASH_INTRARE.test(String(e.hash))) throw new Error(`agent-ancora: the ledger has no entry ${seq}`); const statement = { v: 1, kind: HEAD_KIND, agentId: ledger.agentId, session: ledger.session, policyHash: String(ledger.policyHash).toLowerCase(), seq: s, hash: e.hash, createdAt }; return { v: 1, kind: HEAD_KIND + '-attestation', statement, statementHash: sha(JSON.stringify(statement)) }; } /** Plicul e un cap de registru intreg (statementHash se reface) si numeste o intrare a ACESTUI registru? */ export function headMatchesLedger(env, ledger) { const st = env && env.statement; if (!st || env.kind !== HEAD_KIND + '-attestation' || st.kind !== HEAD_KIND) return { ok: false, motiv: `not an ${HEAD_KIND} attestation` }; if (!H32.test(String(env.statementHash)) || sha(JSON.stringify(st)) !== env.statementHash) return { ok: false, motiv: 'the statementHash does not match the statement (modified envelope)' }; if (st.agentId !== ledger.agentId || st.session !== ledger.session || String(st.policyHash).toLowerCase() !== String(ledger.policyHash).toLowerCase()) return { ok: false, motiv: 'the head names another agent, session or policy' }; const e = Number.isInteger(st.seq) ? ledger.entries[st.seq] : null; if (!e || e.hash !== st.hash) return { ok: false, motiv: `the head names seq ${st.seq} with a hash this ledger does not have there (another branch or another ledger)` }; return { ok: true, seq: st.seq, hash: st.hash }; } /** Cheia secp256k1 dintr-un fisier: hex singur (cu sau fara 0x) sau un rand `d=` / `PRIVATE_KEY=`; nu se tipareste niciodata. */ export function cheiaDinFisier(f) { const randuri = fs.readFileSync(f, 'utf8').split(/\r?\n/).map((l) => l.trim()).filter(Boolean); const r = randuri.find((l) => /^(d|PRIVATE_KEY)=/.test(l)) || (randuri.length === 1 ? randuri[0] : ''); const hex = r.replace(/^(d|PRIVATE_KEY)=/, '').replace(/^0x/, '').trim(); if (!/^[0-9a-fA-F]{1,64}$/.test(hex)) throw new Error('the key file holds no secp256k1 key (a hex key, or a d= / PRIVATE_KEY= line)'); return '0x' + hex.padStart(64, '0'); } /** Pune statementHash-ul capului pe AereNotary. Lantul se citeste de pe RPC, nu se crede din argument; cheia din fisier, netiparita. */ export async function notarizeHead({ envelope, rpc, keyFile, ethers, notary = null, confirmMainnet = process.env.AERE_CONFIRM_MAINNET === 'yes' }) { const taie = (s) => String(s ?? '').replace(/(0x)?[0-9a-fA-F]{40,}/g, '').slice(0, 300); const st = envelope && envelope.statement; if (!st || envelope.kind !== HEAD_KIND + '-attestation' || st.kind !== HEAD_KIND || !H32.test(String(envelope.statementHash))) return { ok: false, cod: 2, motiv: `REFUSED: not an ${HEAD_KIND} attestation with a 32-byte statementHash` }; if (sha(JSON.stringify(st)) !== envelope.statementHash) return { ok: false, cod: 1, motiv: 'REFUSED: statementHash does not match the statement (modified envelope)' }; try { const provider = new ethers.JsonRpcProvider(rpc); const chainId = Number((await provider.getNetwork()).chainId); if (chainId === 2800 && !confirmMainnet) return { ok: false, cod: 3, motiv: 'REFUSED: this RPC serves chain 2800 (Aere Network mainnet); a notarization there is a real transaction paid by the key\'s account. Set AERE_CONFIRM_MAINNET=yes to send it.' }; const adr = notary || NOTARI[chainId]; if (!adr || !/^0x[0-9a-fA-F]{40}$/.test(adr)) return { ok: false, cod: 2, motiv: `REFUSED: no known notary on chain ${chainId}; pass --notary` }; const cod = await provider.getCode(adr); if (!cod || cod === '0x') return { ok: false, cod: 1, motiv: `REFUSED: there is no contract at ${adr} on chain ${chainId}` }; const wallet = new ethers.Wallet(cheiaDinFisier(keyFile), provider); const c = new ethers.Contract(adr, ['function notarize(bytes32 h) external', 'function firstSeen(bytes32) view returns (uint64)'], wallet); let txHash = null, block = null; if (!(Number(await c.firstSeen(envelope.statementHash)) > 0)) { const tx = await c.notarize(envelope.statementHash); const rc = await tx.wait(1, 180000); if (!rc || rc.status !== 1) return { ok: false, cod: 1, motiv: 'the notarization transaction failed' }; txHash = rc.hash; block = rc.blockNumber; } const firstSeen = Number(await c.firstSeen(envelope.statementHash)); if (!(firstSeen > 0)) return { ok: false, cod: 1, motiv: 'firstSeen is still 0 after the notarization' }; return { ok: true, envelope: { ...envelope, notarization: { chainId, notary: adr, firstSeen, ...(txHash ? { txHash, block } : { already: true }) } } }; } catch (e) { return { ok: false, cod: 1, motiv: 'the notarization failed: ' + taie(e.shortMessage || e.message) }; } } // verificatorul AIP-23 intoarce momentul in detaliul nivelului de finalitate, "as first seen at ()"; il citim de acolo si // cerem ca cele doua forme sa fie acelasi moment. Daca forma detaliului se schimba, raspunsul e NEMASURAT, nu o ancora ghicita. const PRIMA_VEDERE = /as first seen at (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z) \((\d+)\)/; export function firstSeenFromVerdict(v) { const f = v && Array.isArray(v.levels) ? v.levels.find((x) => x.level === 'finality') : null; if (!f) return { stare: 'NEMASURAT', motiv: 'the verifier gave no finality level' }; if (f.state !== 'PASSED') return { stare: f.state === 'FAILED' ? 'FAILED' : 'NEMASURAT', motiv: `finality ${f.state}: ${String(f.detail).slice(0, 200)}` }; const m = PRIMA_VEDERE.exec(String(f.detail)); if (!m || Math.floor(Date.parse(m[1]) / 1000) !== Number(m[2])) return { stare: 'NEMASURAT', motiv: 'finality PASSED, but its detail does not state the first-seen time in the form this tool reads' }; return { stare: 'PASSED', at: Number(m[2]) }; } /** Ruleaza verificatorul AIP-23 pe un plic si intoarce JSON-ul lui (sau null). */ export function ruleazaVerificatorul(verifyProof, envFile, { rpc, chain }) { return new Promise((ok) => { const args = [verifyProof, envFile, '--json', ...(rpc ? ['--rpc', rpc] : []), ...(chain ? ['--chain', String(chain)] : [])]; const c = spawn(process.execPath, args); let out = ''; c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', () => {}); const ceas = setTimeout(() => c.kill(), 300000); c.on('error', () => { clearTimeout(ceas); ok(null); }); c.on('close', () => { clearTimeout(ceas); try { ok(JSON.parse(out)); } catch { ok(null); } }); }); } /** * Ancorele registrului din capete notarizate: fiecare cap trebuie sa fie al registrului si sa aiba finalitate post-cuantica PASSED * la verificatorul AIP-23 (rulat de `ruleaza`, implicit verify-proof.mjs). Intoarce {stare: 'OK'|'FAILED'|'NEMASURAT', anchors, randuri}. */ export async function anchorsFromNotarizedHeads(ledger, envelopes, { verifyProof, rpc, chain = null, ruleaza = ruleazaVerificatorul, tmpDir = null } = {}) { const anchors = [], randuri = []; let stare = 'OK'; const dir = fs.mkdtempSync(path.join(tmpDir || os.tmpdir(), 'aere-agent-ancora-')); try { for (const env of envelopes) { const m = headMatchesLedger(env, ledger); if (!m.ok) { randuri.push({ head: env && env.statementHash, pass: false, detail: m.motiv }); stare = 'FAILED'; continue; } const f = path.join(dir, `${m.seq}.json`); fs.writeFileSync(f, JSON.stringify(env)); // lantul: cel cerut de verificator; altfel cel declarat de plic (spus in ancora, ca un timp de testnet sa nu treaca drept unul de 2800) const lant = chain || (env.notarization && Number(env.notarization.chainId)) || null; const v = await ruleaza(verifyProof, f, { rpc, chain: lant }); if (!v) { randuri.push({ head: env.statementHash, seq: m.seq, pass: null, detail: 'the AIP-23 verifier did not answer' }); if (stare === 'OK') stare = 'NEMASURAT'; continue; } if (v.statementHash && String(v.statementHash).toLowerCase() !== env.statementHash) { randuri.push({ head: env.statementHash, seq: m.seq, pass: false, detail: 'the verifier judged another statementHash' }); stare = 'FAILED'; continue; } const fs2 = firstSeenFromVerdict(v); if (fs2.stare === 'FAILED') { randuri.push({ head: env.statementHash, seq: m.seq, pass: false, detail: fs2.motiv }); stare = 'FAILED'; continue; } if (fs2.stare !== 'PASSED') { randuri.push({ head: env.statementHash, seq: m.seq, pass: null, detail: fs2.motiv }); if (stare === 'OK') stare = 'NEMASURAT'; continue; } anchors.push({ seq: m.seq, hash: m.hash, at: fs2.at, statementHash: env.statementHash, chainId: lant, chainAsked: !!chain, witness: 'AereNotary first-seen under a post-quantum certified anchor' }); randuri.push({ head: env.statementHash, seq: m.seq, pass: true, detail: `seq ${m.seq} first seen on chain ${lant}${chain ? '' : ' (the chain the envelope declares; pass --chain to require one)'} at ${new Date(fs2.at * 1000).toISOString()}` }); } } finally { fs.rmSync(dir, { recursive: true, force: true }); } return { stare, anchors: anchors.sort((a, b) => a.seq - b.seq), randuri }; } // ---------------------------------------------------------------- linia de comanda class Folosire extends Error {} async function main(argv) { const [cmd, ...rest] = argv; const get = (f) => { const i = rest.indexOf(f); return i >= 0 ? rest[i + 1] : undefined; }; const toate = (f) => rest.flatMap((a, i) => (a === f && rest[i + 1] != null ? [rest[i + 1]] : [])); const citeste = (f, ce) => { if (!f) throw new Folosire(`--${ce} is required`); try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { throw new Folosire(`cannot read ${ce} file ${f}: ${e.message}`); } }; const scrie = (o, f) => { const s = JSON.stringify(o, null, 1) + '\n'; if (f) fs.writeFileSync(f, s); else process.stdout.write(s); }; if (cmd === 'head') { const env = ledgerHeadEnvelope(citeste(get('--ledger'), 'ledger'), { seq: get('--seq') != null ? Number(get('--seq')) : null }); scrie(env, get('--out')); if (get('--out')) console.log(`head seq ${env.statement.seq}: ${env.statementHash}`); return 0; } if (cmd === 'notarize') { if (!get('--rpc') || !get('--key-file')) throw new Folosire('notarize --head head.json --rpc URL --key-file f [--notary 0x..] [--out f]'); const { incarcaEthers } = await import(pathToFileURL(path.join(AICI, 'x402', 'wallet.mjs')).href); let ethers; try { ethers = incarcaEthers(); } catch (e) { console.error(e.message); return 2; } const r = await notarizeHead({ envelope: citeste(get('--head'), 'head'), rpc: get('--rpc'), keyFile: get('--key-file'), ethers, notary: get('--notary') || null }); if (!r.ok) { console.error(r.motiv); return r.cod; } scrie(r.envelope, get('--out')); const n = r.envelope.notarization; console.log(`notarized on chain ${n.chainId} at ${n.notary}: firstSeen ${n.firstSeen}${n.txHash ? `, block ${n.block}` : ' (already notarized)'}`); return 0; } if (cmd === 'anchors') { const verifyProof = get('--verify-proof') || process.env.AERE_VERIFY_PROOF; if (!verifyProof || !fs.existsSync(verifyProof)) { console.error('NEMASURAT: the AIP-23 reference verifier is needed (--verify-proof or AERE_VERIFY_PROOF)'); return 2; } if (!get('--rpc')) throw new Folosire('anchors needs --rpc : the chain is read, not trusted from the envelope'); const heads = toate('--head'); if (!heads.length) throw new Folosire('anchors needs at least one --head notarized.json'); const r = await anchorsFromNotarizedHeads(citeste(get('--ledger'), 'ledger'), heads.map((f) => citeste(f, 'head')), { verifyProof, rpc: get('--rpc'), chain: get('--chain') ? Number(get('--chain')) : null }); for (const x of r.randuri) console.error(`${x.pass === true ? 'ok' : x.pass === false ? 'FAIL' : '--'} ${x.detail}`); if (r.stare === 'OK') scrie(r.anchors, get('--out')); console.error(r.stare === 'OK' ? `${r.anchors.length} anchor(s): pass them to agent-cli.mjs verify --anchors` : r.stare === 'FAILED' ? 'FAILED: no anchors written' : 'NOT MEASURED: no anchors written (finality pending or the verifier did not answer)'); return r.stare === 'OK' ? 0 : r.stare === 'FAILED' ? 1 : 2; } throw new Folosire('usage: agent-ancora.mjs head|notarize|anchors ... (see README.md)'); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main(process.argv.slice(2)).then((c) => { process.exitCode = c; }, (e) => { console.error(`agent-ancora: ${e.message}`); process.exitCode = e instanceof Folosire ? 2 : 1; }); }