aere-quantum/agents/martor-agenti-serviciu.mjs

246 lines
22 KiB
JavaScript

#!/usr/bin/env node
// martor-agenti-serviciu.mjs: SERVICIUL martorului gazduit al agentilor (B3, 2026-10-19), sandbox catre testnetul 28001. Un proces pe loopback
// (Node 24: ML-DSA-65 din node:crypto), in spatele gateway-ului Aere Cloud (/v1/agents/witness, cum serveste adunatorul scutului /v1/shield/*):
// POST /v1/agents/witness un cap de registru semnat de agent (martor-agenti.mjs cerereMartor); 202 primit, cu
// promisiunea semnata de martor; 200 deja vazut; 409 echivocare (dovada intoarsa, si
// pastrata: una pe agent si politica), cap vechi, sau cap care nu continua ultimul; 4xx
// refuz numit; 429 ritm; 503 bugetul jurnalului pe ora (log_busy, cu Retry-After)
// GET /v1/agents/witness/0x<id> starea capului: pending | checkpointed | notarized, cu chitanta (plicul AIP-23)
// GET /v1/agents/witness/aere-agent:<40 hex>/heads?session=<32 hex>[&from=<seq>][&limit<=100] o pagina a capetelor unei sesiuni, cu
// pagina SEMNATA de martor (`signedPage`, cu momentul ei), singura pe care o crede verificatorul
// GET /v1/agents/witness/aere-agent:<40 hex>/heads[?after=<session>] sesiunile agentului (cel mult 100)
// GET /v1/agents/witness/equivocation/0x<id> o dovada de echivocare pastrata
// GET /v1/agents/witness/checkpoint[?shard=<k>][&from=<size>] ultimul cap de jurnal al ciobului curent (sau al ciobului pastrat `k`, si al
// unuia inghetat: cel final) si cel notarizat, cu dovada de consistenta in acel ciob de la `from`
// GET /v1/agents/witness/health starea martorului si a limitatoarelor (chei tinute, scoase la plafon)
// La fiecare `--interval` minute: un cap de jurnal nou daca au venit capete, si notarizarea capetelor de jurnal nenotarizate (ultimul al fiecarui
// ciob pastrat) pe AereNotary 28001, cu o cheie de PROBA a testnetului (nascuta in starea serviciului daca lipseste, alimentata din robinet daca
// e dat). Orice alt lant decat 28001 e refuzat inainte de cheie: mainnet-ul cere tranzactiile fondatorului ("sandbox: 28001; mainnet: at activation").
// Ce tine jurnalul de cereri: momentul, ruta (fara id-uri) si statusul; o eroare interna merge INTREAGA numai aici, raspunsul public poarta un cod
// (revizia B3, constatarea 18). Limitele de ritm stau in memorie, cu cheile sarate pe proces, fiecare cu un plafon de chei.
//
// node martor-agenti-serviciu.mjs --stare <dir> [--port 8802] [--interval 10] [--rpc https://testnet-rpc.aere.network] [--cheie <f>]
// [--robinet <robinet.mjs>] [--log aere-agent-witness/28001] [--merge-max 1800] [--fara-lant] [--o-data]
// --fara-lant laborator: capete de jurnal construite si semnate, nicio notarizare (chitantele raman `checkpointed`)
// --o-data un singur tur (cap de jurnal + notarizare), apoi iesire: 0 notarizat sau nimic de facut, 2 notarizarea n-a reusit
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import http from 'node:http';
import crypto from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { deschideMartor, CERERE_MAX_OCTETI, SANDBOX, LANT, CHECKPOINT_KIND, PLAFON_FRUNZE, PAGINA_MAX } from './martor-agenti.mjs';
import { notarizeEnvelope, cheiaDinFisier, NOTARI } from './agent-ancora.mjs';
const AICI = path.dirname(fileURLToPath(import.meta.url));
export const PORT_IMPLICIT = 8802;
export const RPC_28001 = 'https://testnet-rpc.aere.network';
// limitele, pe fereastra de 10 minute: un agent cinstit trimite un cap la cateva minute; GET-urile sunt ieftine dar nu nelimitate. Bugetul
// jurnalului (capete NOI primite pe ora, de la toti) e DERIVAT din plafonul ciobului: PLAFON_FRUNZE / 24 pe ora, deci un ciob nu se umple in mai
// putin de o zi, iar un cap al lui se serveste cel putin o zi dupa ce ciobul e inghetat (martorul tine ciobul curent si pe cel de dinainte).
export const LIMITE = { postPeClient: { cereri: 60, ms: 600_000 }, postPeAgent: { cereri: 30, ms: 600_000 }, getPeClient: { cereri: 1200, ms: 600_000 },
acceptatePeOra: { cereri: Math.floor(PLAFON_FRUNZE / 24), ms: 3_600_000 } };
// plafonul de chei al unui limitator, DERIVAT din masuratoare (2026-10-19, README "Bounds"): 198 de octeti pe cheie (100.000 de chei = 19,8 MB pe
// limitator, deci ~60 MB pentru cele trei). La plafon (dupa curatarea cheilor expirate) se scoate cheia cu fereastra cea mai VECHE si clientul nou
// intra (revizia B3 a doua, R2-03): prima reparatie limita aici orice client NOU, deci cine umplea 100.000 de chei (de pe /64-uri reale) tinea
// afara pe toti clientii noi cat le reimprospata. Un client scos asa primeste doar o fereastra noua; un atacator care are deja 100.000 de chei are
// deja 100.000 de cote. Memoria nu creste cu adresele (pana la prima reparatie: 293 de octeti pe adresa, pe viata procesului, si niciun plafon).
export const LIMITATOR_MAX_CHEI = 100_000;
const taie = (s) => String(s ?? '').replace(/(0x)?[0-9a-fA-F]{40,}/g, '<hex>').slice(0, 300);
/**
* Cheia unui client pentru limite (revizia B3, constatarea 13): o adresa IPv6 pe prefixul ei /64 (un client IPv6 primeste de obicei un /64 intreg,
* deci pe adresa intreaga ar fi avut 2^64 de chei), o adresa IPv4 mapata in IPv6 ca IPv4, o adresa IPv4 intreaga; orice altceva (o proba) ca atare.
*/
export function cheieClient(a) {
let s = String(a ?? '').trim().toLowerCase();
if (s.startsWith('[') && s.includes(']')) s = s.slice(1, s.indexOf(']'));
const pct = s.indexOf('%'); if (pct >= 0) s = s.slice(0, pct);
const m4 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(s); if (m4 && net.isIPv4(m4[1])) return m4[1];
if (net.isIPv4(s)) return s;
if (net.isIPv6(s)) {
let [cap, coada = ''] = s.split('::'); const g = (t) => (t ? t.split(':') : []);
let a1 = g(cap), a2 = g(coada);
const v4 = (l) => { const u = l[l.length - 1]; if (u && u.includes('.')) { const o = u.split('.').map(Number); l.splice(-1, 1, ((o[0] << 8) | o[1]).toString(16), ((o[2] << 8) | o[3]).toString(16)); } };
if (s.includes('::')) v4(a2); else v4(a1);
const tot = s.includes('::') ? [...a1, ...Array(Math.max(0, 8 - a1.length - a2.length)).fill('0'), ...a2] : a1;
return tot.slice(0, 4).map((x) => (parseInt(x, 16) || 0).toString(16)).join(':') + '::/64';
}
return 'altul:' + s.slice(0, 64);
}
/**
* Un limitator pe fereastra fixa (de la prima cerere a cheii), cu cheile sarate pe proces (nicio adresa sau id nu se tine in clar), cu cheile
* expirate CURATATE (la 1024 de apeluri, si cand plafonul e atins, dar cel mult o data pe secunda) si cu un PLAFON de chei: pana la reparatie
* nicio cheie nu se stergea si fiecare adresa noua lasa ~293 de octeti pe viata procesului (revizia B3, constatarea 13). La plafon se scoate cheia
* cu fereastra cea mai veche (harta e in ordinea inceputului ferestrei: o fereastra noua se muta la coada), numarata (`scoase`), in loc sa fie
* limitat clientul nou (revizia B3 a doua, R2-03). `ar(cheie)`: ar fi limitata cheia acum? fara sa consume nimic (bugetul jurnalului, R2-13).
*/
export function limitator({ cereri, ms }, { maxChei = LIMITATOR_MAX_CHEI, ceas = Date.now } = {}) {
const sare = crypto.randomBytes(16); const m = new Map(); let apeluri = 0, ultimaCuratare = 0, scoase = 0;
const cheieDe = (cheie) => crypto.createHash('sha256').update(sare).update(String(cheie)).digest('base64url').slice(0, 16);
const curata = (acum) => { ultimaCuratare = acum; for (const [k, v] of m) if (acum - v[0] >= ms) m.delete(k); };
const f = (cheie) => {
const k = cheieDe(cheie);
const acum = ceas();
if (++apeluri % 1024 === 0) curata(acum);
let v = m.get(k);
if (v && acum - v[0] >= ms) { m.delete(k); v[0] = acum; v[1] = 0; m.set(k, v); }
if (!v) {
if (m.size >= maxChei && acum - ultimaCuratare >= 1000) curata(acum);
if (!(m.size < maxChei /*PAZA-MARTOR:ritm-plafon*/)) { m.delete(m.keys().next().value); scoase++; }
v = [acum, 0]; m.set(k, v);
}
if (v[1] >= cereri) return true;
v[1]++; return false;
};
f.ar = (cheie) => { const v = m.get(cheieDe(cheie)); return !!v && ceas() - v[0] < ms && v[1] >= cereri; };
// cat mai are de asteptat cheia pana i se deschide o fereastra noua (ms; 0 daca nu e limitata), pentru Retry-After (revizia B3 a cincea, R5-06)
f.ramasMs = (cheie) => { const v = m.get(cheieDe(cheie)); return v && v[1] >= cereri ? Math.max(0, ms - (ceas() - v[0])) : 0; };
f.marime = () => m.size;
f.scoase = () => scoase;
return f;
}
const loopback = (a) => /^(127\.|::1$|::ffff:127\.)/.test(String(a || ''));
/** Serverul HTTP peste starea deschisa `m` (deschideMartor). Clientul real e X-Real-IP numai cand cererea vine de pe loopback (gateway-ul il pune). */
export function porneste(m, { port = 0, limite = LIMITE, jurnal = () => {}, maxChei = LIMITATOR_MAX_CHEI } = {}) {
const peClient = limitator(limite.postPeClient, { maxChei }), peAgent = limitator(limite.postPeAgent, { maxChei }), citiri = limitator(limite.getPeClient, { maxChei });
const acceptate = limite.acceptatePeOra ? limitator(limite.acceptatePeOra, { maxChei: 1 }) : null;
// bugetul jurnalului: martorul il CITESTE intai (`ar`, fara sa consume) si il consuma abia dupa cota agentului (R2-13)
const limitaGlobala = acceptate ? Object.assign(() => acceptate('jurnal'), { ar: () => acceptate.ar('jurnal') /*PAZA-MARTOR:buget-citit*/ }) : null;
// starea limitatoarelor, in /health: cate chei tin si cate au scos la plafon (o inundatie din multe adrese se vede, nu se ghiceste)
const stareLimite = () => Object.fromEntries([['postPerClient', peClient], ['postPerAgent', peAgent], ['getPerClient', citiri]].map(([n, l]) => [n, { keys: l.marime(), maxKeys: maxChei, droppedAtCap: l.scoase() }]));
const raspunde = (req, res, cod, obiect, antete = {}) => {
const text = Buffer.from(JSON.stringify(obiect));
res.writeHead(cod, { 'content-type': 'application/json; charset=utf-8', 'content-length': text.length, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...antete });
res.end(text);
jurnal(`${req.method} ${String(req.url).replace(/0x[0-9a-fA-F]{64}/g, '0x..').replace(/aere-agent:[0-9a-f]{40}/g, 'aere-agent:..').replace(/\?.*$/, '').slice(0, 60)} ${cod}`);
};
// o eroare interna: INTREAGA numai in jurnalul local; public un cod (fara cai, fara numele starii)
const interna = (req, res, e) => { jurnal(`internal error on ${req.method} ${String(req.url).replace(/\?.*$/, '').slice(0, 60)}: ${taie(e && (e.stack || e.message))}`); if (!res.headersSent) raspunde(req, res, 500, { error: 'witness_error', code: e && /^E[A-Z]+$/.test(String(e.code)) ? 'io_error' : 'internal', sandbox: SANDBOX }); };
const RUTE = ['POST /v1/agents/witness', 'GET /v1/agents/witness/0x<head id>', 'GET /v1/agents/witness/aere-agent:<40 hex>/heads[?session=<32 hex>&from=<seq>&limit=<1..100>][?after=<session>]', 'GET /v1/agents/witness/equivocation/0x<id>', 'GET /v1/agents/witness/checkpoint[?shard=<k>][&from=<size>]', 'GET /v1/agents/witness/health'];
const intreg = (s, d) => (s === null ? d : /^\d{1,15}$/.test(s) ? Number(s) : NaN);
const handler = (req, res) => {
try {
const url = new URL(req.url, 'http://x'); const cale = url.pathname;
const client = cheieClient(loopback(req.socket.remoteAddress) && req.headers['x-real-ip'] ? String(req.headers['x-real-ip']) : String(req.socket.remoteAddress || ''));
if (req.method === 'POST' && cale === '/v1/agents/witness') {
if (peClient(client) /*PAZA-MARTOR:ritm*/) return raspunde(req, res, 429, { error: 'rate_limited', hint: `${limite.postPeClient.cereri} heads per ${limite.postPeClient.ms / 60000} minutes per client`, sandbox: SANDBOX });
const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
const parti = []; let n = 0, prea = false;
req.on('data', (d) => { n += d.length; if (n > CERERE_MAX_OCTETI) { if (!prea) { prea = true; raspunde(req, res, 413, { error: 'body_too_large', max: CERERE_MAX_OCTETI, hint: 'a signed head is about 8 KB; the witness never takes the ledger', sandbox: SANDBOX }); req.destroy(); } } else parti.push(d); });
req.on('end', () => {
if (prea) return;
try {
if (ct !== 'application/json') return raspunde(req, res, 415, { error: 'bad_content_type', hint: 'application/json: the signed head (martor-agenti.mjs request)', sandbox: SANDBOX });
let env; try { env = JSON.parse(Buffer.concat(parti).toString('utf8')); } catch { return raspunde(req, res, 400, { error: 'bad_json', sandbox: SANDBOX }); }
const r = m.primeste(env, { limitaAgent: peAgent, limitaGlobala });
return raspunde(req, res, r.cod, r.corp, r.corp && r.corp.retryAfterS ? { 'retry-after': String(r.corp.retryAfterS) } : {});
} catch (e) { return interna(req, res, e); }
});
req.on('error', () => {});
return;
}
if (req.method !== 'GET') return raspunde(req, res, 405, { error: 'method_not_allowed', routes: RUTE, sandbox: SANDBOX });
// o citire peste limita spune si cand se poate reincerca (Retry-After, secundele pana la fereastra noua a clientului): verificatorul aduce
// cate o chitanta pe cap tinut si asteapta cat spune antetul (revizia B3 a cincea, R5-06)
if (citiri(client)) { const ra = Math.max(1, Math.ceil(citiri.ramasMs(client) / 1000)); return raspunde(req, res, 429, { error: 'rate_limited', retryAfterS: ra, sandbox: SANDBOX }, { 'retry-after': String(ra) } /*PAZA-MARTOR:get-retry-after*/); }
if (cale === '/v1/agents/witness/health') return raspunde(req, res, 200, { ...m.sanatate(), limiters: stareLimite() });
if (cale === '/v1/agents/witness/checkpoint') {
const sh = url.searchParams.get('shard'); if (sh !== null && !/^\d{1,9}$/.test(sh)) return raspunde(req, res, 400, { error: 'bad_shard', sandbox: SANDBOX });
const r = m.capulJurnalului(url.searchParams.get('from'), sh === null ? null : Number(sh)); return raspunde(req, res, r.cod, r.corp);
}
const me = /^\/v1\/agents\/witness\/equivocation\/(0x[0-9a-fA-F]{64})$/.exec(cale);
if (me) { const r = m.dovadaEchivocarii(me[1]); return raspunde(req, res, r.cod, r.corp); }
const mi = /^\/v1\/agents\/witness\/(0x[0-9a-fA-F]{64})$/.exec(cale);
if (mi) { const r = m.stareaCapului(mi[1]); return raspunde(req, res, r.cod, r.corp); }
const ma = /^\/v1\/agents\/witness\/(aere-agent:[0-9a-f]{40})\/heads$/.exec(cale);
if (ma) {
const s = url.searchParams.get('session'), after = url.searchParams.get('after');
const from = intreg(url.searchParams.get('from'), 0), limit = intreg(url.searchParams.get('limit'), PAGINA_MAX);
if (s !== null && !/^[0-9a-f]{32}$/.test(s)) return raspunde(req, res, 400, { error: 'bad_session', sandbox: SANDBOX });
if (after !== null && !/^[0-9a-f]{32}$/.test(after)) return raspunde(req, res, 400, { error: 'bad_after', sandbox: SANDBOX });
if (!(Number.isSafeInteger(from) && from >= 0 && Number.isSafeInteger(limit) && limit >= 1 && limit <= PAGINA_MAX)) return raspunde(req, res, 400, { error: 'bad_page', hint: `from >= 0, 1 <= limit <= ${PAGINA_MAX}`, sandbox: SANDBOX });
return raspunde(req, res, 200, m.capeteAgent(ma[1], s, { from, limit, after }));
}
return raspunde(req, res, 404, { error: 'not_found', routes: RUTE, sandbox: SANDBOX });
} catch (e) { return interna(req, res, e); }
};
const server = http.createServer(handler);
return new Promise((ok) => server.listen(port, '127.0.0.1', () => ok({ server, port: server.address().port, close: () => new Promise((g) => server.close(() => g())) })));
}
/**
* Notarizatorul real: capul de jurnal pe AereNotary, NUMAI pe testnetul 28001 (lantul citit de la RPC, refuzat altfel inainte de cheie), cu o
* cheie de proba din fisier (nascuta acolo, 0600, daca lipseste; niciodata tiparita), alimentata din robinetul testnetului cand soldul nu
* ajunge si cand `robinet` numeste clientul lui (tools/aere-quantum-shield/robinet.mjs; fara el, `unfunded`, spus). Intoarce {ok, notarization}
* sau {ok: false, cod, motiv}: `cod` e cel public (unfunded, chain_refused, ...), `motiv` merge numai in jurnalul local.
*/
export function notarizatorReal({ rpc = RPC_28001, cheieFisier, robinet = null, ethers = null }) {
return async (env) => {
let E = ethers;
if (!E) { try { const { incarcaEthers } = await import(pathToFileURL(path.join(AICI, 'x402', 'wallet.mjs')).href); E = incarcaEthers(); } catch (e) { return { ok: false, cod: 'ethers_missing', motiv: 'ethers is not here: ' + taie(e.message) }; } }
try {
const provider = new E.JsonRpcProvider(rpc);
const chainId = Number((await provider.getNetwork()).chainId);
if (!(chainId === LANT /*PAZA-MARTOR:sandbox*/)) return { ok: false, cod: 'chain_refused', motiv: `REFUSED: this RPC serves chain ${chainId}; the agents witness notarizes on the public testnet ${LANT} only (mainnet at activation, with the founder's transactions)` };
if (!fs.existsSync(cheieFisier)) {
const w = E.Wallet.createRandom();
fs.mkdirSync(path.dirname(cheieFisier), { recursive: true });
fs.writeFileSync(cheieFisier, `# AERE-SINTETIC: test key of the agents witness for testnet ${LANT}, made ${new Date().toISOString()}; funds only from the faucet\nd=${w.privateKey.slice(2)}\n`, { mode: 0o600, flag: 'wx' });
}
const wallet = new E.Wallet(cheiaDinFisier(cheieFisier), provider);
const min = E.parseEther('0.01');
if ((await provider.getBalance(wallet.address)) < min) {
if (!robinet) return { ok: false, cod: 'unfunded', motiv: 'unfunded: the test key has no testnet balance and no faucet client was given (--robinet)' };
const { cereRobinet } = await import(pathToFileURL(path.resolve(robinet)).href);
const c = await cereRobinet(wallet.address);
if (!c.ok) return { ok: false, cod: 'unfunded', motiv: 'unfunded: the faucet refused: ' + taie(JSON.stringify(c.claim || c.motiv)) };
let sold = 0n; for (let i = 0; i < 40 && sold < min; i++) { await new Promise((g) => setTimeout(g, 1500)); sold = await provider.getBalance(wallet.address); }
if (sold < min) return { ok: false, cod: 'unfunded', motiv: 'unfunded: the faucet transaction did not land in 60 s' };
}
const r = await notarizeEnvelope({ envelope: env, kind: CHECKPOINT_KIND, rpc, keyFile: cheieFisier, ethers: E, notary: NOTARI[LANT], confirmMainnet: false });
return r.ok ? { ok: true, notarization: { ...r.envelope.notarization, submitter: wallet.address } } : { ok: false, cod: 'notarization_failed', motiv: taie(r.motiv) };
} catch (e) { return { ok: false, cod: 'notarization_failed', motiv: 'the notarization failed: ' + taie(e.shortMessage || e.message) }; }
};
}
/** Un tur: un cap de jurnal nou (daca au venit capete), apoi notarizarea fiecarui cap de jurnal nenotarizat (cel final al unui ciob inghetat intai). */
export async function tur(m, { notarizeaza = null } = {}) {
const nou = m.construiestePunct();
const deFacut = m.deNotarizate();
if (!deFacut.length) return { built: nou ? nou.n : null, notarized: null, note: nou ? 'built' : 'nothing new' };
if (!notarizeaza) return { built: nou ? nou.n : null, notarized: null, note: 'laboratory: no notarization' };
let ultim = null;
for (const p of deFacut) {
const r = await notarizeaza(m.plicPunct(p));
m.inregistreazaNotarizarea(p, r);
if (!r.ok) return { built: nou ? nou.n : null, notarized: ultim ? ultim.n : null, shard: p.k, firstSeen: null, error: r.motiv, code: r.cod || 'notarization_failed' };
ultim = { n: p.n, k: p.k, firstSeen: r.notarization.firstSeen };
}
return { built: nou ? nou.n : null, notarized: ultim.n, shard: ultim.k, firstSeen: ultim.firstSeen, error: null };
}
// ------------------------------------------------------------------------------------------------ CLI
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
async function main() {
const arg = (n, d) => { const i = process.argv.indexOf(n); return i > 0 ? process.argv[i + 1] : d; };
const stare = arg('--stare', null);
if (!stare) { console.error('usage: martor-agenti-serviciu.mjs --stare <dir> [--port 8802] [--interval 10] [--rpc url] [--cheie f] [--robinet robinet.mjs] [--log name] [--merge-max s] [--fara-lant] [--o-data]'); process.exitCode = 2; return; }
const scrieJurnal = (l) => console.log(new Date().toISOString() + ' ' + l);
let m;
const faraLant = process.argv.includes('--fara-lant');
// laboratorul (--fara-lant) nu notarizeaza nimic: rotirea nu asteapta notarizarea capului de jurnal final al ciobului scos (R2-07)
try { m = deschideMartor(stare, { log: arg('--log', undefined), maxMergeDelay: arg('--merge-max', null) != null ? Number(arg('--merge-max')) : undefined, jurnal: scrieJurnal, faraLant }); }
catch (e) { console.error('cannot open the state: ' + taie(e.message)); process.exitCode = 2; return; }
const notarizeaza = faraLant ? null : notarizatorReal({ rpc: arg('--rpc', RPC_28001), cheieFisier: arg('--cheie', path.join(stare, 'cheie-de-proba-28001.key')), robinet: arg('--robinet', null) });
const t = async () => { try { const r = await tur(m, { notarizeaza }); console.log(new Date().toISOString() + ' round: ' + JSON.stringify(r).slice(0, 300)); return r; } catch (e) { console.log(new Date().toISOString() + ' round UNMEASURED: ' + taie(e.message)); return { error: e.message }; } };
if (process.argv.includes('--o-data')) { const r = await t(); m.inchide(); process.exitCode = r.error ? 2 : 0; return; }
const srv = await porneste(m, { port: Number(arg('--port', PORT_IMPLICIT)), jurnal: scrieJurnal });
console.log(`agents witness ${m.witnessId} on 127.0.0.1:${srv.port} (log ${m.log}, state ${stare}, ${faraLant ? 'laboratory: no chain' : 'notarizing on chain ' + LANT}); ${SANDBOX}`);
const opreste = () => { m.inchide(); srv.close().then(() => process.exit(0)); };
process.on('SIGTERM', opreste); process.on('SIGINT', opreste);
await t(); setInterval(t, Number(arg('--interval', 10)) * 60_000);
}