@nyx5/nyx5 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +3 -3
- package/README.es.md +201 -0
- package/README.md +176 -73
- package/SECURITY.md +1 -1
- package/agents.md +73 -0
- package/bin/nyx5.js +128 -8
- package/demo/contratos.js +1 -1
- package/demo/e2e.js +1 -1
- package/demo/offline.js +1 -1
- package/demo/piloto-d4.mjs +1 -1
- package/demo/spam.js +1 -1
- package/docs/SPEC.en.md +489 -0
- package/docs/SPEC.md +94 -4
- package/examples/hola-mundo.mjs +1 -1
- package/migrations/0005_eventos.sql +16 -0
- package/package.json +36 -17
- package/src/correo/agente.js +11 -2
- package/src/correo/estafeta.js +186 -12
- package/src/correo/unirse.js +117 -0
- package/src/libro/contratos.js +23 -0
- package/src/libro/libro.js +66 -1
- package/src/libro/tareas.js +92 -0
- package/src/libro/verifica.js +113 -0
- package/src/nucleo/almacen-d1.js +9 -0
- package/src/nucleo/almacen.js +10 -1
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +2 -1
- package/src/plataformas/spec-html.js +4 -3
- package/src/plataformas/worker.js +55 -15
- package/src/puentes/mcp.js +22 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Instrumentación mínima del sprint join/mandate/verifica. Sin esto, la distribución es ciega:
|
|
2
|
+
-- no se sabe si llegan agentes (join), si alguien les pone presupuesto (mandate_created), ni
|
|
3
|
+
-- si el mecanismo se ejerce de verdad (escrow_released, bond_forfeited).
|
|
4
|
+
--
|
|
5
|
+
-- Qué NO se guarda, a propósito: contenido de sobres, direcciones de correo humanas, IPs.
|
|
6
|
+
-- Un evento lleva el nombre, la fecha, el actor (dirección del agente, que ya es pública) y
|
|
7
|
+
-- un puñado de datos numéricos. Sirve para contar, no para perfilar.
|
|
8
|
+
CREATE TABLE IF NOT EXISTS nyx5_eventos (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
name TEXT NOT NULL,
|
|
11
|
+
ts TEXT NOT NULL,
|
|
12
|
+
actor TEXT,
|
|
13
|
+
data TEXT NOT NULL DEFAULT '{}'
|
|
14
|
+
);
|
|
15
|
+
CREATE INDEX IF NOT EXISTS nyx5_eventos_name_ts ON nyx5_eventos (name, ts);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS nyx5_eventos_ts ON nyx5_eventos (ts);
|
package/package.json
CHANGED
|
@@ -1,38 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyx5/nyx5",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Nyx5/1 —
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Nyx5/1 — mail and ledger for AI agents: agent@domain addresses, a store-and-forward mailbox, and a ledger where a claim costs something. One command to join, escrow released only against a deterministic check, and reputation that is just a query on the ledger. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"homepage": "https://nyx5.com",
|
|
8
|
-
"repository": {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Nicoiakl/nyx5.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Nicoiakl/nyx5/issues"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"nyx5": "bin/nyx5.js"
|
|
20
|
+
},
|
|
12
21
|
"files": [
|
|
13
|
-
"
|
|
22
|
+
"CONTRIBUTING.md",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"README.es.md",
|
|
25
|
+
"README.md",
|
|
26
|
+
"SECURITY.md",
|
|
27
|
+
"agents.md",
|
|
14
28
|
"bin/",
|
|
15
|
-
"migrations/",
|
|
16
29
|
"demo/",
|
|
17
|
-
"examples/",
|
|
18
|
-
"docs/SPEC.md",
|
|
19
30
|
"docs/ARQUITECTURA.md",
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
31
|
+
"docs/SPEC.en.md",
|
|
32
|
+
"docs/SPEC.md",
|
|
33
|
+
"examples/",
|
|
34
|
+
"migrations/",
|
|
35
|
+
"src/"
|
|
24
36
|
],
|
|
25
37
|
"keywords": [
|
|
26
38
|
"agents",
|
|
39
|
+
"ai-agents",
|
|
27
40
|
"agent-communication",
|
|
28
41
|
"protocol",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
42
|
+
"mcp",
|
|
43
|
+
"model-context-protocol",
|
|
44
|
+
"a2a",
|
|
31
45
|
"escrow",
|
|
46
|
+
"reputation",
|
|
47
|
+
"agent-identity",
|
|
48
|
+
"ledger",
|
|
32
49
|
"ed25519",
|
|
33
50
|
"end-to-end-encryption",
|
|
34
51
|
"cloudflare-workers",
|
|
35
|
-
"
|
|
52
|
+
"x402",
|
|
53
|
+
"agent-payments",
|
|
54
|
+
"verification"
|
|
36
55
|
],
|
|
37
56
|
"scripts": {
|
|
38
57
|
"demo": "node demo/e2e.js",
|
package/src/correo/agente.js
CHANGED
|
@@ -45,8 +45,8 @@ export class Agent {
|
|
|
45
45
|
// ---------- registro ----------
|
|
46
46
|
// Tres caminos: adminToken (la casa inscribe), invite (código de la casa) o abierto si la casa lo permite.
|
|
47
47
|
// Sin adminToken y sin estar registrado, el cuerpo va firmado con la propia clave (prueba de posesión).
|
|
48
|
-
async register({ adminToken, invite, capabilities, inbox, webhook, valid_until } = {}) {
|
|
49
|
-
const body = { local: this.local, sig: this.keys.sig, enc: this.keys.enc, capabilities, inbox, webhook, valid_until };
|
|
48
|
+
async register({ adminToken, invite, capabilities, inbox, webhook, notify_email, valid_until } = {}) {
|
|
49
|
+
const body = { local: this.local, sig: this.keys.sig, enc: this.keys.enc, capabilities, inbox, webhook, notify_email, valid_until };
|
|
50
50
|
if (adminToken) this.card = await this._call('POST', '/agents', body, { admin: adminToken });
|
|
51
51
|
else if (this.card) this.card = await this._call('POST', '/agents', body);
|
|
52
52
|
else this.card = await this._call('POST', '/agents', signObject({ ...body, invite: invite || undefined, ts: iso() }, this.keys), { noAuth: true });
|
|
@@ -168,6 +168,15 @@ export class Agent {
|
|
|
168
168
|
// Lecturas directas (sin pasar por correo) en la casa indicada; por defecto, la propia estafeta.
|
|
169
169
|
async balance(house) { return this._callAt(house, 'GET', `/libro/cuenta/${encodeURIComponent(this.address)}`); }
|
|
170
170
|
async contract(house, id) { return this._callAt(house, 'GET', `/libro/contrato/${encodeURIComponent(id)}`); }
|
|
171
|
+
// Historial público de cualquier agente (por defecto, el propio): la reputación es el libro.
|
|
172
|
+
async historial(address = this.address) {
|
|
173
|
+
const { local, domain } = parseAddress(address);
|
|
174
|
+
const base = domain === this.domain ? this.estafeta : (await this.resolver.domainCard(domain))._estafeta;
|
|
175
|
+
const res = await this.fetch(`${base}/agents/${encodeURIComponent(local)}/historial`, { signal: AbortSignal.timeout(10_000) });
|
|
176
|
+
const json = await res.json().catch(() => ({}));
|
|
177
|
+
if (!res.ok) throw Object.assign(new Error(json.reason || `HTTP ${res.status}`), { status: res.status });
|
|
178
|
+
return json;
|
|
179
|
+
}
|
|
171
180
|
async _callAt(house, method, path) {
|
|
172
181
|
if (!house || house === this.domain) return this._call(method, path);
|
|
173
182
|
const dc = await this.resolver.domainCard(house);
|
package/src/correo/estafeta.js
CHANGED
|
@@ -23,9 +23,12 @@ import { Resolver, parseAddress } from './resolver.js';
|
|
|
23
23
|
import { validateEnvelope, applyInboxPolicy, RateLimiter } from './politica.js';
|
|
24
24
|
import { generateSigningKeys, signObject, verifyObject, signBytes, verifyBytes, canonical, uuid, unb64u, sha256hex } from '../nucleo/crypto.js';
|
|
25
25
|
import { Libro, MEDIA, LibroError } from '../libro/libro.js';
|
|
26
|
+
import { veredicto, pruebasDe, pruebasDisponibles } from '../libro/verifica.js';
|
|
27
|
+
import { contratoPublico, ACP } from '../libro/contratos.js';
|
|
28
|
+
import { Tareas } from '../libro/tareas.js';
|
|
26
29
|
import { APP_HTML } from '../plataformas/app-html.js';
|
|
27
|
-
import { SPEC_HTML, LLMS_TXT } from '../plataformas/spec-html.js';
|
|
28
|
-
import { HOME_HTML } from '../plataformas/home-html.js';
|
|
30
|
+
import { SPEC_HTML, SPEC_HTML_ES, LLMS_TXT } from '../plataformas/spec-html.js';
|
|
31
|
+
import { HOME_HTML, HOME_HTML_ES } from '../plataformas/home-html.js';
|
|
29
32
|
import { inboundEnvelope, outboundPayload, isEmailAddress } from '../puentes/email.js';
|
|
30
33
|
|
|
31
34
|
const now = () => Date.now();
|
|
@@ -38,7 +41,7 @@ export class Estafeta {
|
|
|
38
41
|
port = 4000, host = '127.0.0.1', publicUrl,
|
|
39
42
|
hosts = {}, fetchImpl = globalThis.fetch,
|
|
40
43
|
policy = {}, retry = {}, workerIntervalMs = 1000, libro = {},
|
|
41
|
-
index = {}, email = {},
|
|
44
|
+
index = {}, email = {}, verifica = {}, eventos = true, tareas = {},
|
|
42
45
|
extensions = null,
|
|
43
46
|
log = (...a) => console.log(`[estafeta ${domain}]`, ...a),
|
|
44
47
|
}) {
|
|
@@ -55,6 +58,12 @@ export class Estafeta {
|
|
|
55
58
|
this.retry = { baseMs: 1000, maxMs: 60_000, giveUpMs: 3 * 24 * 3600 * 1000, ...retry };
|
|
56
59
|
this.workerIntervalMs = workerIntervalMs;
|
|
57
60
|
this.index = { enabled: false, crawlMinutes: 15, maxHouses: 500, ...index };
|
|
61
|
+
// verifica@: el evaluador de referencia. `enabled` lo enciende; `maxPorTick` acota lo que
|
|
62
|
+
// el cron hace en una pasada para no comerse el minuto entero verificando.
|
|
63
|
+
this.verifica = { enabled: true, maxPorTick: 10, timeoutMs: 10_000, ...verifica };
|
|
64
|
+
this.eventos = eventos !== false;
|
|
65
|
+
// Trabajo sembrado: la casa es el primer comprador. Sin catálogo, apagado.
|
|
66
|
+
this.tareas = new Tareas(tareas);
|
|
58
67
|
// Puente de correo: entrada siempre disponible si la casa la enciende; salida solo si hay proveedor.
|
|
59
68
|
// `email.provider` es una función async(payload) (ver src/puentes/email.js); sin ella, la salida queda pendiente.
|
|
60
69
|
this.email = { enabled: !!(email.enabled || email.provider), provider: email.provider || null };
|
|
@@ -114,9 +123,27 @@ export class Estafeta {
|
|
|
114
123
|
// postmaster@ -> avisos de entrega y rebotes libro@ -> operaciones y recibos del Libro
|
|
115
124
|
if (!await this.store.getAgent('postmaster')) await this.registerAgent({ local: 'postmaster', sig: this.keys.sig, capabilities: { accepts: [] }, inbox: { policy: 'allowlist', allowlist: [] } });
|
|
116
125
|
if (!await this.store.getAgent('libro')) await this.registerAgent({ local: 'libro', sig: this.keys.sig, capabilities: { accepts: [MEDIA.op], libro: { fee_bps: this.libro.feeBps, ops: this.libro.ops } }, inbox: { policy: 'open' } });
|
|
126
|
+
// verifica@ — el evaluador de referencia de la casa. Tres pruebas deterministas y nada
|
|
127
|
+
// más: un verificador que se equivoca castiga inocentes. Declara en su tarjeta cuáles
|
|
128
|
+
// puede correr en ESTE runtime (en el edge no hay shell), para no prometer lo que no hace.
|
|
129
|
+
if (this.verifica.enabled && !await this.store.getAgent('verifica')) {
|
|
130
|
+
await this.registerAgent({ local: 'verifica', sig: this.keys.sig, capabilities: { accepts: [MEDIA.op], verifica: { pruebas: pruebasDisponibles() }, listed: true }, inbox: { policy: 'open' } });
|
|
131
|
+
// tareas@ — el mostrador del trabajo sembrado. Existe solo si la casa publicó tareas.
|
|
132
|
+
if (this.tareas.enabled && !await this.store.getAgent('tareas')) {
|
|
133
|
+
await this.registerAgent({ local: 'tareas', sig: this.keys.sig, capabilities: { accepts: [MEDIA.cotizacion], tareas: { por_agente_dia: this.tareas.porAgenteDia }, listed: true }, inbox: { policy: 'open' } });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
117
136
|
}
|
|
118
|
-
|
|
119
|
-
|
|
137
|
+
// Instrumentación: nombre, fecha, actor y números. Nunca contenido de sobres ni datos del
|
|
138
|
+
// humano. Un fallo al registrar un evento JAMÁS puede voltear la operación que lo produjo:
|
|
139
|
+
// medir es secundario, mover tokens no.
|
|
140
|
+
async _evento(name, actor, data = {}) {
|
|
141
|
+
if (!this.eventos || !this.store.putEvent) return;
|
|
142
|
+
try { await this.store.putEvent({ id: uuid(), name, ts: iso(), actor: actor || null, data }); }
|
|
143
|
+
catch (e) { this.log(`evento ${name} no registrado: ${e.message}`); }
|
|
144
|
+
}
|
|
145
|
+
isSystem(local) { return ['postmaster', 'libro', 'verifica', 'tareas'].includes(local); }
|
|
146
|
+
static RESERVED = new Set(['postmaster', 'libro', 'verifica', 'tareas', 'casa', 'admin', 'root', 'abuse', 'security', 'hostmaster', 'noreply', 'no-reply', 'support', 'estafeta', 'nyx5', 'indice']);
|
|
120
147
|
|
|
121
148
|
// ---------- servicio de registro ----------
|
|
122
149
|
// Invitaciones: la casa emite códigos con usos y vencimiento; un agente los presenta al inscribirse.
|
|
@@ -167,7 +194,7 @@ export class Estafeta {
|
|
|
167
194
|
}
|
|
168
195
|
|
|
169
196
|
// ---------- agentes ----------
|
|
170
|
-
async registerAgent({ local, sig, enc = null, capabilities = {}, inbox = { policy: 'open' }, webhook = null, valid_until = null, delegation = null, welcome = null }) {
|
|
197
|
+
async registerAgent({ local, sig, enc = null, capabilities = {}, inbox = { policy: 'open' }, webhook = null, notify_email = null, valid_until = null, delegation = null, welcome = null }) {
|
|
171
198
|
local = String(local).toLowerCase();
|
|
172
199
|
const address = `${local}@${this.domain}`;
|
|
173
200
|
parseAddress(address);
|
|
@@ -214,11 +241,11 @@ export class Estafeta {
|
|
|
214
241
|
let creado = true;
|
|
215
242
|
if (!prev) {
|
|
216
243
|
creado = await (this.store.putAgentIfAbsent
|
|
217
|
-
? this.store.putAgentIfAbsent(local, { ...card, webhook })
|
|
218
|
-
: (this.store.putAgent(local, { ...card, webhook }), true));
|
|
244
|
+
? this.store.putAgentIfAbsent(local, { ...card, webhook, notify_email })
|
|
245
|
+
: (this.store.putAgent(local, { ...card, webhook, notify_email }), true));
|
|
219
246
|
if (!creado) throw Object.assign(new Error('ese nombre acaba de ser tomado; solo su dueño o la casa pueden actualizarlo'), { status: 409 });
|
|
220
247
|
} else {
|
|
221
|
-
await this.store.putAgent(local, { ...card, webhook });
|
|
248
|
+
await this.store.putAgent(local, { ...card, webhook, notify_email });
|
|
222
249
|
}
|
|
223
250
|
const gift = welcome ?? this.libro.welcome;
|
|
224
251
|
if (creado && !prev && !this.isSystem(local) && !delegation && gift > 0) await this.libro.topup(address, gift, 'regalo de bienvenida', { agent: address });
|
|
@@ -233,7 +260,7 @@ export class Estafeta {
|
|
|
233
260
|
if (!Estafeta.validLocal(local)) return null;
|
|
234
261
|
const rec = await this.store.getAgent(local);
|
|
235
262
|
if (!rec) return null;
|
|
236
|
-
const { webhook, ...card } = rec;
|
|
263
|
+
const { webhook, notify_email, ...card } = rec;
|
|
237
264
|
return card;
|
|
238
265
|
}
|
|
239
266
|
|
|
@@ -302,6 +329,7 @@ export class Estafeta {
|
|
|
302
329
|
for (const job of due) await this._deliver(job);
|
|
303
330
|
await this.store.pruneNonces?.(now() - 600_000);
|
|
304
331
|
if (this.index.enabled) await this._indexCrawlIfDue();
|
|
332
|
+
if (this.verifica.enabled) await this._verificarPendientes();
|
|
305
333
|
await this.flushPushes();
|
|
306
334
|
}
|
|
307
335
|
async _deliver(job) {
|
|
@@ -375,6 +403,40 @@ export class Estafeta {
|
|
|
375
403
|
await this.store.removeJob(job.id); await this._outbox(job, { delivered: delivered.length, failed: failed.length });
|
|
376
404
|
}
|
|
377
405
|
|
|
406
|
+
// ----- verifica@: escrows que declararon prueba y nombraron árbitro a la casa -----
|
|
407
|
+
// El asiento no se mueve por lo que alguien dijo, sino por lo que la prueba devolvió. Y si
|
|
408
|
+
// la prueba no pudo correr (red caída, timeout), NO se decide: el escrow queda como estaba.
|
|
409
|
+
async _verificarPendientes() {
|
|
410
|
+
const arbitro = `verifica@${this.domain}`;
|
|
411
|
+
let contratos;
|
|
412
|
+
try { contratos = await this.store.libroListContracts(); } catch { return; }
|
|
413
|
+
const candidatos = contratos.filter((c) => c.kind === 'escrow' && ['held', 'delivered'].includes(c.state) && c.arbiter === arbitro && pruebasDe(c));
|
|
414
|
+
for (const c of candidatos.slice(0, this.verifica.maxPorTick)) {
|
|
415
|
+
// Solo se verifica lo que ya se declaró entregado, salvo que el contrato pida verificar
|
|
416
|
+
// desde el arranque (terms.verify_on: 'accept'), útil para un endpoint que ya debía estar en pie.
|
|
417
|
+
if (c.state === 'held' && (c.terms?.verify_on || 'deliver') !== 'accept') continue;
|
|
418
|
+
const v = await veredicto(pruebasDe(c), { fetchImpl: this.fetch, timeoutMs: this.verifica.timeoutMs, entregado: c.evidence ?? null, entregadoSha256: c.evidence_sha256 ?? null });
|
|
419
|
+
if (v.indeciso) { this.log(`verifica ${c.id}: sin veredicto (${v.razon})`); continue; }
|
|
420
|
+
// La decisión viaja como sobre firmado a libro@ y entra por `inbound`, la MISMA puerta
|
|
421
|
+
// que usa cualquier agente (invariante 2). No se toca el Libro por dentro: si la firma
|
|
422
|
+
// de verifica@ no verifica, la casa se rechaza a sí misma. El veredicto va en el
|
|
423
|
+
// contenido, así que el porqué queda escrito y auditable en el contrato.
|
|
424
|
+
const env = signObject({
|
|
425
|
+
nyx5: '1', id: uuid(), from: `verifica@${this.domain}`, to: [`libro@${this.domain}`],
|
|
426
|
+
created: iso(), expires: null, thread: c.id, in_reply_to: null, type: 'task',
|
|
427
|
+
content: { media: MEDIA.op, body: { op: v.pasa ? 'release' : 'refund', contract: c.id, note: v.razon, veredicto: { pasa: v.pasa, razon: v.razon, resultados: v.resultados } } },
|
|
428
|
+
}, this.keys);
|
|
429
|
+
// Se espera el resultado antes de mirar el siguiente: así el estado ya cambió y este
|
|
430
|
+
// contrato no vuelve a elegirse en el mismo tick ni en el siguiente.
|
|
431
|
+
const r = await this.inbound(env);
|
|
432
|
+
if (!r.ok) this.log(`verifica ${c.id}: la casa rechazó su propia decisión (${r.code}): ${r.reason}`);
|
|
433
|
+
else {
|
|
434
|
+
this.log(`verifica ${c.id}: ${v.pasa ? 'libera' : 'devuelve'} — ${v.razon}`);
|
|
435
|
+
await this._evento('verificado', `verifica@${this.domain}`, { contract: c.id, pasa: v.pasa, pruebas: (pruebasDe(c) || []).map((x) => x.type), amount: c.amount });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
378
440
|
// Sobres emitidos por los agentes de sistema (postmaster@, libro@), firmados con la clave del dominio.
|
|
379
441
|
// Destinatarios locales: directo al buzón. Remotos: por la cola, como cualquier envío.
|
|
380
442
|
async _systemSend(fromLocal, to, { type = 'receipt', content, thread = null, in_reply_to = null, deliverAfter = null }) {
|
|
@@ -394,6 +456,55 @@ export class Estafeta {
|
|
|
394
456
|
return env;
|
|
395
457
|
}
|
|
396
458
|
|
|
459
|
+
// ----- tareas@: la casa toma el lado comprador de una tarea sembrada -----
|
|
460
|
+
// El agente manda su cotización firmada; la casa la compara contra el catálogo publicado y,
|
|
461
|
+
// si coincide y hay cupo, la acepta operando el Libro como cualquier comprador. La casa NO
|
|
462
|
+
// firma por el agente: el vendedor de ese escrow es él, con su propia llave.
|
|
463
|
+
async _tomarTarea(env, senderCard) {
|
|
464
|
+
const q = env.content?.body;
|
|
465
|
+
if (env.content?.media !== MEDIA.cotizacion || q?.tipo !== 'cotizacion') {
|
|
466
|
+
return { ok: false, code: 400, reason: `tareas@ acepta una cotización (${MEDIA.cotizacion}) por la tarea publicada; mira GET /tareas` };
|
|
467
|
+
}
|
|
468
|
+
const tarea = this.tareas.tarea(q.terms?.seed_task);
|
|
469
|
+
if (!tarea) return { ok: false, code: 404, reason: `no hay una tarea sembrada con id ${q.terms?.seed_task}. Las publicadas están en GET /tareas` };
|
|
470
|
+
if (q.seller !== env.from) return { ok: false, code: 403, reason: 'el vendedor de la cotización debe ser quien la manda' };
|
|
471
|
+
if (q.buyer !== `tareas@${this.domain}`) return { ok: false, code: 400, reason: `la cotización debe ir dirigida a tareas@${this.domain}` };
|
|
472
|
+
const encaja = this.tareas.coincide(q, tarea, { arbitro: `verifica@${this.domain}` });
|
|
473
|
+
if (!encaja.ok) return { ok: false, code: 409, reason: encaja.reason };
|
|
474
|
+
const contratos = await this.store.libroListContracts();
|
|
475
|
+
const cupo = this.tareas.cupo(tarea, env.from, contratos);
|
|
476
|
+
// 409, no 429: un 429 es transitorio y la estafeta lo reintentaría durante tres días, así
|
|
477
|
+
// que el agente se quedaría esperando sin saber por qué. Sin cupo, se le dice ahora.
|
|
478
|
+
if (!cupo.ok) return { ok: false, code: 409, reason: cupo.reason };
|
|
479
|
+
|
|
480
|
+
// La casa acepta con un sobre firmado a libro@, por la misma puerta que todos.
|
|
481
|
+
const aceptacion = signObject({
|
|
482
|
+
nyx5: '1', id: uuid(), from: `tareas@${this.domain}`, to: [`libro@${this.domain}`],
|
|
483
|
+
created: iso(), expires: null, thread: q.id, in_reply_to: env.id, type: 'task',
|
|
484
|
+
content: { media: MEDIA.op, body: { op: 'accept', quote: q } },
|
|
485
|
+
}, this.keys);
|
|
486
|
+
const r = await this.inbound(aceptacion);
|
|
487
|
+
if (!r.ok) return { ok: false, code: r.code || 409, reason: `la casa no pudo tomar la tarea: ${r.reason}` };
|
|
488
|
+
const contrato = Object.values(r.results || {})[0]?.contract || null;
|
|
489
|
+
this.log(`tarea ${tarea.id} tomada por ${env.from} (contrato ${contrato?.id})`);
|
|
490
|
+
await this._evento('seed_task_taken', env.from, { task: tarea.id, contract: contrato?.id || null, price: tarea.price });
|
|
491
|
+
return { ok: true, code: 202, result: { contract: contratoPublico(contrato), task: tarea.id }, recibos: [] };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Los cinco eventos que dicen si el mecanismo se está ejerciendo de verdad, leídos del
|
|
495
|
+
// resultado de la op (no de lo que alguien dijo que iba a pasar).
|
|
496
|
+
async _eventoDeOp(env, result) {
|
|
497
|
+
if (!this.eventos) return;
|
|
498
|
+
const op = env.content?.body?.op;
|
|
499
|
+
const c = result?.contract;
|
|
500
|
+
const m = result?.mandate;
|
|
501
|
+
if (op === 'mandate' && m) return this._evento('mandate_created', m.grantor, { mandate: m.id, grantee: m.grantee, cap: m.cap, parent: m.parent || null });
|
|
502
|
+
if (op === 'accept' && c) return this._evento('first_quote', c.buyer, { contract: c.id, kind: c.kind, amount: c.amount, seller: c.seller });
|
|
503
|
+
if (op === 'release' && c?.kind === 'escrow') return this._evento('escrow_released', c.seller, { contract: c.id, amount: c.amount, by: env.from, arbitrado: env.from === `verifica@${this.domain}` });
|
|
504
|
+
if (op === 'refund' && c) return this._evento('escrow_refunded', c.buyer, { contract: c.id, amount: c.amount, by: env.from });
|
|
505
|
+
if (op === 'forfeit' && c) return this._evento('bond_forfeited', c.seller, { contract: c.id, amount: c.amount, by: env.from, vouchee: c.vouchee || null });
|
|
506
|
+
}
|
|
507
|
+
|
|
397
508
|
// Avisos del postmaster al remitente (rebotes y acuses de entrega). Llevan el hash del sobre original.
|
|
398
509
|
async _notify(job, to, status, reason) {
|
|
399
510
|
await this._systemSend('postmaster', [job.envelope.from], {
|
|
@@ -457,6 +568,14 @@ export class Estafeta {
|
|
|
457
568
|
for (const rc of r.recibos || []) recibosPendientes.push(rc);
|
|
458
569
|
for (const av of r.avisos || []) avisosPendientes.push(av);
|
|
459
570
|
results[to] = r.result;
|
|
571
|
+
if (!r.duplicate) await this._eventoDeOp(env, r.result);
|
|
572
|
+
} else if (local === 'tareas' && this.tareas.enabled) {
|
|
573
|
+
// Mostrador del trabajo sembrado: el agente cotiza la tarea publicada y la casa la
|
|
574
|
+
// acepta si coincide EXACTAMENTE con el catálogo y hay cupo. Nada se negocia aquí.
|
|
575
|
+
const r = await this._tomarTarea(env, senderCard);
|
|
576
|
+
if (!r.ok) { rejected.push({ to, code: r.code, reason: r.reason }); continue; }
|
|
577
|
+
for (const rc of r.recibos || []) recibosPendientes.push(rc);
|
|
578
|
+
results[to] = r.result;
|
|
460
579
|
} else if (p.stamp) {
|
|
461
580
|
// Una estampilla paga UN buzón: el sobre declara un monto, no un monto por destinatario.
|
|
462
581
|
if (stampUsed) { rejected.push({ to, code: 402, reason: 'la estampilla del sobre ya se usó en otro destinatario' }); continue; }
|
|
@@ -497,7 +616,7 @@ export class Estafeta {
|
|
|
497
616
|
seen: { id: env.id, rec: { from: env.from, accepted: union } },
|
|
498
617
|
mails, libro: libroBundles,
|
|
499
618
|
});
|
|
500
|
-
for (const m of mails) this._push(m.local, env);
|
|
619
|
+
for (const m of mails) { this._push(m.local, env); this._notifyEmail(m.local, env); }
|
|
501
620
|
}
|
|
502
621
|
for (const rc of recibosPendientes) await this._systemSend('libro', rc.to, { in_reply_to: env.id, thread: rc.thread || env.thread || null, content: { media: MEDIA.recibo, body: rc.body } });
|
|
503
622
|
// Avisos de plazo: sobres del Libro programados para el futuro (un escrow que llega a su
|
|
@@ -520,6 +639,25 @@ export class Estafeta {
|
|
|
520
639
|
this._pushes.push(pendiente);
|
|
521
640
|
return pendiente;
|
|
522
641
|
}
|
|
642
|
+
// Aviso por email: si el agente registró `notify_email` y la casa tiene proveedor de salida, le
|
|
643
|
+
// llega un correo cuando alguien le escribe un mensaje real. No avisa por recibos del Libro ni
|
|
644
|
+
// rebotes del postmaster (ruido), ni por lo que el propio destinatario se manda por el puente.
|
|
645
|
+
_notifyEmail(local, env) {
|
|
646
|
+
if (!this.email.provider) return;
|
|
647
|
+
const pendiente = Promise.resolve(this.store.getAgent(local)).then(async (rec) => {
|
|
648
|
+
if (!rec?.notify_email) return;
|
|
649
|
+
let fromLocal = ''; try { fromLocal = parseAddress(env.from).local; } catch { /* remitente de pasarela */ }
|
|
650
|
+
if (env.type === 'receipt' || fromLocal === 'libro' || fromLocal === 'postmaster') return;
|
|
651
|
+
const emailOrig = env.extensions?.['urn:nyx5:ext:email']?.from;
|
|
652
|
+
if (emailOrig && emailOrig === rec.notify_email) return; // no te avises de tu propio correo
|
|
653
|
+
const quien = emailOrig || env.from;
|
|
654
|
+
await this.emailOut({ fromAgent: `${local}@${this.domain}`, to: rec.notify_email,
|
|
655
|
+
subject: `Tienes un mensaje nuevo en Nyx5 de ${quien}`,
|
|
656
|
+
text: `${quien} le escribió a ${local}@${this.domain}.\n\nÁbrelo en tu buzón: https://${this.domain}/app\n\n(Este es un aviso automático; el contenido está en tu buzón, no en este correo.)` });
|
|
657
|
+
}).catch((e) => this.log(`notify_email ${local} falló: ${e.message}`));
|
|
658
|
+
this._pushes.push(pendiente);
|
|
659
|
+
return pendiente;
|
|
660
|
+
}
|
|
523
661
|
// Espera los avisos en vuelo y vacía la lista.
|
|
524
662
|
flushPushes() { const p = this._pushes; this._pushes = []; return Promise.allSettled(p); }
|
|
525
663
|
|
|
@@ -621,13 +759,26 @@ export class Estafeta {
|
|
|
621
759
|
if (rx.method === 'GET' && path === '/health') return send(200, { ok: true, domain: this.domain, agents: (await this.store.listAgents()).length, queue: (await this.store.listQueue()).length });
|
|
622
760
|
// Cliente web para personas: se sirve desde la propia casa (mismo origen, sin CORS).
|
|
623
761
|
// El HTML genera las llaves en el navegador del usuario; la casa nunca las ve.
|
|
762
|
+
// Inglés primario en la portada; el español vive en /es-home. Ambas se enlazan con hreflang.
|
|
624
763
|
if (rx.method === 'GET' && path === '/') return { status: 200, body: HOME_HTML, contentType: 'text/html; charset=utf-8' };
|
|
764
|
+
if (rx.method === 'GET' && (path === '/es-home' || path === '/es-home/')) return { status: 200, body: HOME_HTML_ES, contentType: 'text/html; charset=utf-8' };
|
|
625
765
|
if (rx.method === 'GET' && (path === '/app' || path === '/app/')) return { status: 200, body: APP_HTML, contentType: 'text/html; charset=utf-8' };
|
|
626
766
|
// La especificación en una página, indexable. Se genera desde docs/SPEC.md (build:spec).
|
|
767
|
+
// El inglés es la versión canónica de la spec (es donde busca quien integra un protocolo);
|
|
768
|
+
// el español vive en /es y ambas se enlazan entre sí con hreflang.
|
|
627
769
|
if (rx.method === 'GET' && (path === '/spec' || path === '/spec/')) return { status: 200, body: SPEC_HTML, contentType: 'text/html; charset=utf-8' };
|
|
770
|
+
if (rx.method === 'GET' && (path === '/es' || path === '/es/' || path === '/spec/es')) return { status: 200, body: SPEC_HTML_ES, contentType: 'text/html; charset=utf-8' };
|
|
628
771
|
if (rx.method === 'GET' && path === '/llms.txt') return { status: 200, body: LLMS_TXT, contentType: 'text/plain; charset=utf-8' };
|
|
629
772
|
if (rx.method === 'GET' && path === '/.well-known/nyx5.json') return send(200, await this.domainCard());
|
|
630
773
|
let m;
|
|
774
|
+
// Reputación = una consulta al libro. Es PÚBLICA a propósito: sirve justamente para que
|
|
775
|
+
// un desconocido decida antes de contratar, igual que la tarjeta. No expone contenido ni
|
|
776
|
+
// contrapartes: solo cuántas entregas, cuántas fianzas y cuántos tokens se movieron.
|
|
777
|
+
if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)\/historial$/.exec(path))) {
|
|
778
|
+
const local = decodeURIComponent(m[1]).toLowerCase();
|
|
779
|
+
if (!(await this.store.getAgent(local))) return send(404, { reason: 'agente inexistente' });
|
|
780
|
+
return send(200, await this.libro.historial(`${local}@${this.domain}`));
|
|
781
|
+
}
|
|
631
782
|
if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)$/.exec(path))) {
|
|
632
783
|
const card = await this.agentCard(decodeURIComponent(m[1]).toLowerCase());
|
|
633
784
|
return card ? send(200, card) : send(404, { reason: 'agente inexistente' });
|
|
@@ -661,6 +812,7 @@ export class Estafeta {
|
|
|
661
812
|
const { signature: _s, ts: _t, invite: _i, _welcome, ...clean } = body;
|
|
662
813
|
const card = await this.registerAgent({ ...clean, welcome: _welcome });
|
|
663
814
|
this.log(`registro ${card.address} via ${via}`);
|
|
815
|
+
if (!card.delegation) await this._evento('join', card.address, { via, listed: card.capabilities?.listed === true });
|
|
664
816
|
return send(201, { ...card, registered_via: via });
|
|
665
817
|
}
|
|
666
818
|
if (rx.method === 'POST' && path === '/invitations') {
|
|
@@ -718,13 +870,35 @@ export class Estafeta {
|
|
|
718
870
|
const c = await this.store.libroGetContract(decodeURIComponent(m[1]));
|
|
719
871
|
if (!c) return send(404, { reason: 'contrato inexistente' });
|
|
720
872
|
if (![c.seller, c.buyer, c.verifier, c.arbiter].includes(who.address)) return send(403, { reason: 'no eres parte' });
|
|
721
|
-
return send(200, c);
|
|
873
|
+
return send(200, contratoPublico(c));
|
|
722
874
|
}
|
|
723
875
|
if (rx.method === 'POST' && path === '/libro/topup') {
|
|
724
876
|
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'solo la casa carga saldo' });
|
|
725
877
|
const { account, amount, concept } = rx.body || {};
|
|
726
878
|
return send(201, await this.libro.topup(account, amount, concept || 'carga de la casa'));
|
|
727
879
|
}
|
|
880
|
+
// Los eventos son de la casa: dicen cuántos agentes llegaron y si el mecanismo se ejerce.
|
|
881
|
+
// No son públicos porque juntos dibujan la actividad de la casa; el historial de cada
|
|
882
|
+
// agente, que es lo que un desconocido necesita, sí lo es.
|
|
883
|
+
// El catálogo es público: un agente que acaba de unirse tiene que poder leer qué hay
|
|
884
|
+
// que hacer, cuánto paga y con qué prueba se comprueba, sin autenticarse.
|
|
885
|
+
if (rx.method === 'GET' && path === '/tareas') {
|
|
886
|
+
if (!this.tareas.enabled) return send(404, { reason: 'esta casa no siembra trabajo' });
|
|
887
|
+
return send(200, {
|
|
888
|
+
mostrador: `tareas@${this.domain}`, arbitro: `verifica@${this.domain}`,
|
|
889
|
+
por_agente_dia: this.tareas.porAgenteDia,
|
|
890
|
+
como: `cotiza la tarea a tareas@${this.domain} como escrow, con arbiter=verifica@${this.domain} y terms exactamente iguales a los publicados`,
|
|
891
|
+
tareas: this.tareas.publicadas().map((t) => ({ ...t, terms: this.tareas.terminosDe(this.tareas.tarea(t.id)) })),
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
if (rx.method === 'GET' && path === '/eventos') {
|
|
895
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'solo la casa lee sus eventos' });
|
|
896
|
+
const p = Object.fromEntries(rx.query);
|
|
897
|
+
const eventos = (await this.store.listEvents?.({ name: p.name || null, since: p.since || null, limit: Number(p.limit || 500) })) || [];
|
|
898
|
+
const conteo = {};
|
|
899
|
+
for (const e of eventos) conteo[e.name] = (conteo[e.name] || 0) + 1;
|
|
900
|
+
return send(200, { conteo, eventos });
|
|
901
|
+
}
|
|
728
902
|
if (rx.method === 'GET' && path === '/libro/diario') {
|
|
729
903
|
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'solo la casa lee el diario completo' });
|
|
730
904
|
return send(200, { balances: (await this.store.libroState()).balances, journal: await this.libro.journal() });
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Nyx5/1 — Unirse: un agente entra a una casa en un solo paso, sin humano.
|
|
2
|
+
//
|
|
3
|
+
// `join` acuña la llave en la máquina del agente, descubre la casa por DNS/well-known,
|
|
4
|
+
// registra el nombre (prueba de posesión), deja un primer sobre en el propio buzón y
|
|
5
|
+
// devuelve todo lo que un agente necesita para seguir solo: dirección, archivo de llaves,
|
|
6
|
+
// bloque MCP, saldo y el historial que otros van a leer.
|
|
7
|
+
//
|
|
8
|
+
// No es una cuenta: no hay correo, ni contraseña, ni nada del humano. La única fricción
|
|
9
|
+
// deliberada del sistema aparece después, en `mandate`, cuando alguien pone presupuesto.
|
|
10
|
+
|
|
11
|
+
import { Agent } from './agente.js';
|
|
12
|
+
import { Resolver, parseAddress } from './resolver.js';
|
|
13
|
+
import { Estafeta } from './estafeta.js';
|
|
14
|
+
|
|
15
|
+
// Sufijo corto y legible; con 4 bytes hay 4.294.967.296 combinaciones por raíz.
|
|
16
|
+
const sufijo = () => Buffer.from(crypto.getRandomValues(new Uint8Array(4))).toString('hex');
|
|
17
|
+
const NOMBRE_OK = /^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])?$/;
|
|
18
|
+
|
|
19
|
+
// Un nombre por defecto que no colisiona y no miente sobre quién es: la raíz viene del
|
|
20
|
+
// runtime si se conoce (claude, cursor…), y el sufijo la vuelve única.
|
|
21
|
+
export function nombreSugerido(raiz = 'agente') {
|
|
22
|
+
const base = String(raiz).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 20) || 'agente';
|
|
23
|
+
return `${base}-${sufijo()}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function bloqueMcp({ address, keyfile, comando = 'npx', args = ['-y', '@nyx5/nyx5'] }) {
|
|
27
|
+
return {
|
|
28
|
+
mcpServers: {
|
|
29
|
+
nyx5: { command: comando, args: [...args, 'mcp', '--agent', keyfile], env: {} },
|
|
30
|
+
},
|
|
31
|
+
_agent: address,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Une un agente a una casa en un paso.
|
|
37
|
+
* @param {object} opts
|
|
38
|
+
* @param {string} [opts.house='nyx5.com'] dominio de la casa
|
|
39
|
+
* @param {string} [opts.name] nombre local; si falta, se sugiere uno único
|
|
40
|
+
* @param {string} [opts.runtime] raíz del nombre sugerido (claude, cursor…)
|
|
41
|
+
* @param {string} [opts.invite] código, si la casa no está abierta
|
|
42
|
+
* @param {boolean} [opts.listed=false] aparecer en el directorio (opt-in, invariante del proyecto)
|
|
43
|
+
* @param {object} [opts.capabilities] { mcp, a2a, skills… } para la tarjeta
|
|
44
|
+
* @param {number} [opts.intentos=5] reintentos ante colisión de nombre
|
|
45
|
+
* @returns {Promise<object>} { address, keys, card, balance, historial, mcp, first }
|
|
46
|
+
*/
|
|
47
|
+
export async function join({
|
|
48
|
+
house = 'nyx5.com', name = null, runtime = 'agente', invite = null, listed = false,
|
|
49
|
+
capabilities = {}, intentos = 5, keyfile = null, resolver = null, hosts = {}, fetchImpl = globalThis.fetch,
|
|
50
|
+
} = {}) {
|
|
51
|
+
const r = resolver || new Resolver({ hosts, fetchImpl });
|
|
52
|
+
// 1. Dónde vive la casa. DNS `_nyx5.<dominio>` manda; well-known es el respaldo.
|
|
53
|
+
const dc = await r.domainCard(house);
|
|
54
|
+
const estafeta = dc._estafeta;
|
|
55
|
+
|
|
56
|
+
// 2. Nombre. Uno explícito se respeta y falla ruidoso si está tomado; uno sugerido reintenta.
|
|
57
|
+
let ultimo = null;
|
|
58
|
+
for (let i = 0; i < (name ? 1 : intentos); i++) {
|
|
59
|
+
const local = name || nombreSugerido(runtime);
|
|
60
|
+
if (!NOMBRE_OK.test(local)) throw new Error(`nombre inválido: ${local} (minúsculas, números y guiones, 2 a 32)`);
|
|
61
|
+
if (Estafeta.RESERVED.has(local)) throw new Error(`nombre reservado por el protocolo: ${local}`);
|
|
62
|
+
const agente = Agent.create(`${local}@${house}`, estafeta, { resolver: r, fetchImpl });
|
|
63
|
+
try {
|
|
64
|
+
const card = await agente.register({
|
|
65
|
+
invite: invite || undefined,
|
|
66
|
+
capabilities: { ...capabilities, listed: listed === true },
|
|
67
|
+
});
|
|
68
|
+
return await _despues(agente, card, { keyfile, house });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
// 409 = nombre tomado. Con nombre sugerido se reintenta; con nombre pedido, se informa.
|
|
71
|
+
ultimo = e;
|
|
72
|
+
if (e.status !== 409 || name) throw e;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw ultimo || new Error('no se pudo elegir un nombre libre');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function _despues(agente, card, { keyfile, house }) {
|
|
79
|
+
const archivo = keyfile || `~/.nyx5/${agente.local}.json`;
|
|
80
|
+
// 3. Primer sobre: un auto-mensaje. Sirve de prueba viva de que el buzón funciona y le
|
|
81
|
+
// deja al agente algo que leer con `inbox` sin molestar a nadie.
|
|
82
|
+
let first = null;
|
|
83
|
+
try {
|
|
84
|
+
first = await agente.send({
|
|
85
|
+
to: agente.address, type: 'message',
|
|
86
|
+
body: `Bienvenido a Nyx5. Esta es tu dirección: ${agente.address}. Lo que afirmes aquí puede llevar fianza, y lo que entregues puede cobrarse contra prueba.`,
|
|
87
|
+
});
|
|
88
|
+
} catch (e) { first = { error: e.message }; }
|
|
89
|
+
// 4. Lo que otros van a leer de él: saldo e historial (que hoy está vacío, y eso también informa).
|
|
90
|
+
const [balance, historial] = await Promise.all([
|
|
91
|
+
agente.balance().catch((e) => ({ error: e.message })),
|
|
92
|
+
agente.historial().catch((e) => ({ error: e.message })),
|
|
93
|
+
]);
|
|
94
|
+
return {
|
|
95
|
+
address: agente.address, house, estafeta: agente.estafeta,
|
|
96
|
+
keys: agente.keys, keyfile: archivo, card,
|
|
97
|
+
balance, historial,
|
|
98
|
+
mcp: bloqueMcp({ address: agente.address, keyfile: archivo }),
|
|
99
|
+
first,
|
|
100
|
+
_agente: agente,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Crea un mandato: el humano fija tope y ámbito UNA vez; dentro de eso el agente contrata
|
|
106
|
+
* solo, y fuera la casa rechaza. Delegable hacia abajo, nunca hacia arriba.
|
|
107
|
+
*/
|
|
108
|
+
export async function mandate(agente, { grantee, cap, scope = null, expires = null, house = null } = {}) {
|
|
109
|
+
if (!grantee) throw new Error('falta a quién se le da el mandato (--grantee)');
|
|
110
|
+
const tope = Number(cap);
|
|
111
|
+
if (!Number.isInteger(tope) || tope <= 0) throw new Error('el mandato necesita un tope entero y positivo (--cap)');
|
|
112
|
+
parseAddress(grantee);
|
|
113
|
+
// El recibo del Libro es la prueba: no se declara creado hasta que el asiento vuelve firmado.
|
|
114
|
+
const enviado = await agente.mandate(house || agente.domain, { grantee, cap: tope, scope: scope || undefined, expires: expires || undefined });
|
|
115
|
+
const recibo = await agente.awaitReceipt(enviado.id).catch(() => null);
|
|
116
|
+
return { sent: enviado, receipt: recibo?.receipt || null, mandate: recibo?.receipt?.mandate || null };
|
|
117
|
+
}
|
package/src/libro/contratos.js
CHANGED
|
@@ -245,3 +245,26 @@ export const CONTRATOS = {
|
|
|
245
245
|
// Bond (fianza) no se cotiza: se deposita directamente con la operación `bond`.
|
|
246
246
|
bond: { quoteable: false },
|
|
247
247
|
};
|
|
248
|
+
|
|
249
|
+
// ---------- vocabulario público: el ciclo de trabajo de ERC-8183 (ACP), sin la cadena ----------
|
|
250
|
+
// Los estados internos no cambian (romperlos rompería contratos vivos). Lo que se publica hacia
|
|
251
|
+
// afuera habla el vocabulario que ya existe, para que quien integró ACP entienda esto sin traducir.
|
|
252
|
+
//
|
|
253
|
+
// Open el trato existe pero el dinero todavía no se movió
|
|
254
|
+
// Funded hay tokens retenidos o comprometidos a nombre del trabajo
|
|
255
|
+
// Submitted el vendedor entregó y espera evaluación
|
|
256
|
+
// Terminal se acabó: liberado, devuelto, pagado o ejecutado
|
|
257
|
+
export const ACP = { accepted: 'Open', held: 'Funded', posted: 'Funded', active: 'Funded', delivered: 'Submitted', released: 'Terminal', refunded: 'Terminal', settled: 'Terminal', forfeited: 'Terminal' };
|
|
258
|
+
// Cómo terminó, para no perder información al agrupar en Terminal.
|
|
259
|
+
export const ACP_DESENLACE = { released: 'accepted', refunded: 'returned', settled: 'paid', forfeited: 'forfeited' };
|
|
260
|
+
|
|
261
|
+
export function estadoACP(contrato) {
|
|
262
|
+
const s = contrato?.state;
|
|
263
|
+
const fase = ACP[s] || 'Open';
|
|
264
|
+
return { phase: fase, outcome: ACP_DESENLACE[s] || null, state: s };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Vista pública de un contrato: lo mismo que hay, más el vocabulario ACP. No agrega ni oculta.
|
|
268
|
+
export function contratoPublico(contrato) {
|
|
269
|
+
return contrato ? { ...contrato, acp: estadoACP(contrato) } : contrato;
|
|
270
|
+
}
|