@nyx5/nyx5 0.6.0 → 0.8.0
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/README.md +50 -7
- package/bin/nyx5.js +81 -2
- package/demo/edge-local.mjs +41 -0
- package/demo/x402-pagar.mjs +64 -0
- package/demo/x402-testnet.mjs +80 -0
- package/docs/ARQUITECTURA.md +354 -0
- package/docs/SPEC.md +414 -15
- package/examples/ci/nyx5-ejecutor.yml +82 -0
- package/examples/frameworks/README.md +55 -0
- package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
- package/examples/frameworks/claude-agent-sdk.mjs +48 -0
- package/examples/frameworks/crewai_nyx5.py +72 -0
- package/examples/frameworks/http.md +117 -0
- package/examples/frameworks/langgraph_nyx5.py +90 -0
- package/examples/frameworks/nyx5_http.py +255 -0
- package/examples/frameworks/openai-agents.mjs +41 -0
- package/examples/hola-mundo.mjs +4 -4
- package/migrations/0006_puente_remoto.sql +19 -0
- package/migrations/0007_indice_reputacion.sql +31 -0
- package/migrations/0008_notaria.sql +18 -0
- package/migrations/0009_contratos_estado.sql +4 -0
- package/package.json +4 -2
- package/server.json +2 -2
- package/src/correo/acciones.js +216 -0
- package/src/correo/adjuntos.js +185 -0
- package/src/correo/agente.js +370 -22
- package/src/correo/asistente.js +599 -0
- package/src/correo/ci.js +262 -0
- package/src/correo/cobro.js +164 -0
- package/src/correo/estafeta.js +1548 -58
- package/src/correo/ideas.js +154 -0
- package/src/correo/indice.js +178 -0
- package/src/correo/politica.js +280 -2
- package/src/correo/resolver.js +35 -11
- package/src/libro/contratos.js +116 -7
- package/src/libro/estado.js +150 -0
- package/src/libro/informe.js +147 -3
- package/src/libro/libro.js +83 -12
- package/src/libro/notaria.js +74 -0
- package/src/libro/verifica.js +239 -36
- package/src/nucleo/almacen-d1.js +143 -14
- package/src/nucleo/almacen.js +137 -13
- package/src/nucleo/boveda.js +37 -0
- package/src/nucleo/crypto.js +55 -12
- package/src/nucleo/keccak.js +118 -0
- package/src/nucleo/objetos.js +78 -0
- package/src/nucleo/secp256k1.js +228 -0
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +1 -1
- package/src/plataformas/iconos.js +6 -0
- package/src/plataformas/node.js +28 -4
- package/src/plataformas/spec-html.js +1 -1
- package/src/plataformas/terms-html.js +2 -0
- package/src/plataformas/worker.js +52 -7
- package/src/puentes/ejecutor.js +118 -0
- package/src/puentes/herramientas.js +315 -0
- package/src/puentes/mcp-remoto.js +71 -0
- package/src/puentes/mcp.js +5 -99
- package/src/puentes/oauth.js +361 -0
- package/src/puentes/x402-pagador.js +248 -0
- package/src/puentes/x402.js +266 -0
- package/src/version.js +4 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Nyx5/1 — Boleta del asiento y estado de cuenta (NX-501).
|
|
2
|
+
//
|
|
3
|
+
// Dos lecturas del mismo diario, sin tocar el kernel:
|
|
4
|
+
// boletaDe(asiento) -> qué parte del movimiento fue fee de la casa y qué parte comisión de
|
|
5
|
+
// referido, LEÍDO de las líneas del asiento. No se recalcula: si el
|
|
6
|
+
// asiento y una tasa discreparan, gana la línea, que es lo que movió.
|
|
7
|
+
// estadoDeCuenta(...) -> el extracto de una cuenta en un rango de fechas: saldo inicial,
|
|
8
|
+
// movimientos, saldo final y totales que cuadran. JSON o CSV (RFC 4180).
|
|
9
|
+
//
|
|
10
|
+
// Modelo de las filas: el fee y la comisión son filas PROPIAS, atribuidas a quien recibe el monto
|
|
11
|
+
// bruto. El fee sale de lo que recibe el vendedor (el comprador paga el precio, ni más ni menos).
|
|
12
|
+
// Para el vendedor de un spot de 200 con fee 10 % y comisión 15 %:
|
|
13
|
+
// in 200 (bruto, contraparte el comprador) · out 20 fee 20 (casa@) · out 30 (referidor)
|
|
14
|
+
// y siempre opening + Σin − Σout = closing. `fee` es un subconjunto de `out`: marca qué salida
|
|
15
|
+
// fue para la casa. Las filas de un mismo asiento comparten `entry`; el saldo tras la última
|
|
16
|
+
// fila del asiento es el del Libro (los intermedios son de presentación).
|
|
17
|
+
//
|
|
18
|
+
// Qué NO cubre, dicho antes de que alguien lo suponga:
|
|
19
|
+
// - Movimientos donde la cuenta no aparece en una línea. Una fianza ejecutada mueve
|
|
20
|
+
// escrow → beneficiario: el afianzado salió del diario al depositarla.
|
|
21
|
+
// - El fee de una estampilla no pasa por un recibo del Libro (viaja en la respuesta x402 como
|
|
22
|
+
// `transaction`); sí aparece en el estado de cuenta del que la cobró.
|
|
23
|
+
// - Si la cuenta es a la vez referidor y pagador (net negativo), no se desglosa: una sola fila.
|
|
24
|
+
|
|
25
|
+
import { LibroError } from './errores.js';
|
|
26
|
+
|
|
27
|
+
export const CSV_CABECERA = Object.freeze(['date', 'entry', 'concept', 'counterparty', 'in', 'out', 'fee', 'balance']);
|
|
28
|
+
|
|
29
|
+
// Fee y comisión de un asiento, leídos de sus líneas. `feeBps` (tasa de la casa) y `share` (bps
|
|
30
|
+
// del referido) son contexto para que la boleta diga la tasa; el MONTO es siempre el de la línea.
|
|
31
|
+
// `meta.fee > 0` distingue un fee de otra entrada positiva de la casa (una fianza ejecutada a su
|
|
32
|
+
// favor no es fee). Nunca lanza: corre dentro de Libro.handle después de que la op ya decidió.
|
|
33
|
+
export function boletaDe(asiento, { casa, feeBps = null, share = null } = {}) {
|
|
34
|
+
if (!asiento || !Array.isArray(asiento.lines)) return {};
|
|
35
|
+
const meta = asiento.meta || {};
|
|
36
|
+
const out = {};
|
|
37
|
+
const fee = meta.fee > 0 ? asiento.lines.find((l) => l.account === casa && l.delta > 0) : null;
|
|
38
|
+
if (fee) out.fee = { account: casa, amount: fee.delta, ...(feeBps != null ? { bps: feeBps } : {}) };
|
|
39
|
+
const com = meta.commission > 0 && meta.referrer ? asiento.lines.find((l) => l.account === meta.referrer && l.delta > 0) : null;
|
|
40
|
+
if (com) out.commission = { account: meta.referrer, amount: com.delta, ...(share != null ? { bps: share } : {}) };
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Las filas de UNA cuenta en UN asiento. [] si la cuenta no aparece en ninguna línea.
|
|
45
|
+
export function filasDe(asiento, account, casa) {
|
|
46
|
+
const mias = asiento.lines.filter((l) => l.account === account);
|
|
47
|
+
if (!mias.length) return [];
|
|
48
|
+
const propio = mias.reduce((s, l) => s + l.delta, 0);
|
|
49
|
+
const meta = asiento.meta || {};
|
|
50
|
+
const { fee, commission } = boletaDe(asiento, { casa });
|
|
51
|
+
// Contraparte: las líneas del signo contrario que no son el fee ni la comisión (salvo que la
|
|
52
|
+
// cuenta sea justamente la casa o el referidor: entonces esas líneas son las suyas).
|
|
53
|
+
const excluidas = new Set([account, ...(fee ? [casa] : []), ...(commission ? [meta.referrer] : [])]);
|
|
54
|
+
const contra = asiento.lines.filter((l) => !excluidas.has(l.account) && Math.sign(l.delta) === -Math.sign(propio)).map((l) => l.account);
|
|
55
|
+
const base = { date: asiento.at, entry: asiento.id, n: asiento.n, concept: asiento.concept, counterparty: contra.join(';') };
|
|
56
|
+
// Quien recibe el bruto: net positivo, no es la casa ni el referidor, y el asiento repartió.
|
|
57
|
+
const bruto = propio > 0 && account !== casa && account !== meta.referrer && (fee || commission);
|
|
58
|
+
if (!bruto) return [{ ...base, kind: 'movement', in: propio > 0 ? propio : 0, out: propio < 0 ? -propio : 0, fee: 0 }];
|
|
59
|
+
const filas = [{ ...base, kind: 'movement', in: propio + (fee?.amount || 0) + (commission?.amount || 0), out: 0, fee: 0 }];
|
|
60
|
+
if (fee) filas.push({ ...base, kind: 'fee', concept: 'house fee', counterparty: casa, in: 0, out: fee.amount, fee: fee.amount });
|
|
61
|
+
if (commission) filas.push({ ...base, kind: 'commission', concept: 'referral commission', counterparty: meta.referrer, in: 0, out: commission.amount, fee: 0 });
|
|
62
|
+
return filas;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Fecha ISO normalizada al formato exacto de `asiento.at` (toISOString), para que la comparación
|
|
66
|
+
// lexicográfica en el almacén sea la misma que la numérica. Una fecha sola ("2026-09-13") es la
|
|
67
|
+
// medianoche UTC de ese día: como `until` es exclusivo, `hasta=2026-09-14` cubre el 13 entero.
|
|
68
|
+
function fechaIso(v, nombre) {
|
|
69
|
+
if (v == null || v === '') return null;
|
|
70
|
+
// Sin zona horaria, Node la interpreta en la del proceso y workerd en UTC: se exige Z u offset
|
|
71
|
+
// (una fecha sola, sin hora, es UTC en las dos).
|
|
72
|
+
const t = typeof v === 'string' && (/^\d{4}-\d{2}-\d{2}$/.test(v) || /(Z|[+-]\d{2}:?\d{2})$/.test(v)) ? Date.parse(v) : NaN;
|
|
73
|
+
if (Number.isNaN(t)) throw new LibroError(400, `${nombre} must be an ISO-8601 date, got ${JSON.stringify(v)}`);
|
|
74
|
+
return new Date(t).toISOString();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// El estado de cuenta de `account` en [since, until). `limit` acota cuántos asientos se listan
|
|
78
|
+
// (se conservan los MÁS RECIENTES, como el statement de siempre); `max` es el techo que pone el
|
|
79
|
+
// llamador (200 por correo, 1000 por HTTP). Sin rango, el límite por defecto sigue siendo 20.
|
|
80
|
+
//
|
|
81
|
+
// opening_balance es el saldo justo antes del primer asiento listado (sumado de sus líneas, no
|
|
82
|
+
// leído de saldos), así que cuadra también cuando la lista está truncada. `ledger_balance` es lo
|
|
83
|
+
// que dice el estado de saldos hoy: cuando el extracto llega hasta ahora, los dos tienen que
|
|
84
|
+
// coincidir, y `reconciled` los resta. Si un día no coinciden, el diario y los saldos divergieron
|
|
85
|
+
// y esto es lo primero que lo dice.
|
|
86
|
+
export async function estadoDeCuenta(libro, account, { since = null, until = null, limit = null, max = 200 } = {}) {
|
|
87
|
+
const desde = fechaIso(since, 'since'), hasta = fechaIso(until, 'until');
|
|
88
|
+
if (desde && hasta && desde > hasta) throw new LibroError(400, `since (${desde}) is after until (${hasta})`);
|
|
89
|
+
const raw = Number(limit);
|
|
90
|
+
const n = Number.isInteger(raw) && raw > 0 ? Math.min(raw, max) : (desde || hasta ? max : 20);
|
|
91
|
+
const store = libro.store, casa = libro.casa;
|
|
92
|
+
const ledger = await libro.balance(account);
|
|
93
|
+
const { entries, total } = await store.libroStatementRange(account, { since: desde, until: hasta, limit: n });
|
|
94
|
+
const opening = entries.length
|
|
95
|
+
? await store.libroBalanceBefore(account, { n: entries[0].n })
|
|
96
|
+
: (desde ? await store.libroBalanceBefore(account, { at: desde }) : 0);
|
|
97
|
+
const totals = { in: 0, out: 0, fees: 0, commissions: 0 };
|
|
98
|
+
const rows = [];
|
|
99
|
+
let saldo = opening;
|
|
100
|
+
for (const a of entries) {
|
|
101
|
+
for (const r of filasDe(a, account, casa)) {
|
|
102
|
+
saldo += r.in - r.out;
|
|
103
|
+
totals.in += r.in; totals.out += r.out; totals.fees += r.fee;
|
|
104
|
+
if (r.kind === 'commission') totals.commissions += r.out;
|
|
105
|
+
rows.push({ ...r, balance: saldo });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Cuadre interno: lo que dicen las filas tiene que ser lo que dicen las líneas del diario.
|
|
109
|
+
const neto = entries.reduce((s, a) => s + a.lines.filter((l) => l.account === account).reduce((t, l) => t + l.delta, 0), 0);
|
|
110
|
+
if (opening + neto !== saldo) throw new LibroError(500, `statement does not reconcile with the journal (${opening} + ${neto} != ${saldo})`);
|
|
111
|
+
// Sin `until` la lista termina en el último asiento de la cuenta (se conservan los más
|
|
112
|
+
// recientes): el saldo final tiene que ser el saldo de hoy, esté o no truncada la lista.
|
|
113
|
+
const reconciled = hasta ? null : saldo === ledger;
|
|
114
|
+
return {
|
|
115
|
+
account, house: libro.domain, since: desde, until: hasta,
|
|
116
|
+
opening_balance: opening, entries: rows, closing_balance: saldo, totals,
|
|
117
|
+
rows: rows.length, entries_shown: entries.length, entries_total: total, truncated: total > entries.length,
|
|
118
|
+
ledger_balance: ledger, reconciled,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------- CSV (RFC 4180) ----------
|
|
123
|
+
// Un campo con coma, comillas o salto de línea va entre comillas, y las comillas se doblan.
|
|
124
|
+
export function campoCsv(v) {
|
|
125
|
+
const s = v == null ? '' : String(v);
|
|
126
|
+
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
127
|
+
}
|
|
128
|
+
// Texto que escribió otro agente (concepto, contraparte) y que una hoja de cálculo podría tomar
|
|
129
|
+
// por fórmula: se antepone un apóstrofo, como recomienda OWASP. Los números son nuestros.
|
|
130
|
+
function textoCsv(s) {
|
|
131
|
+
const t = s == null ? '' : String(s);
|
|
132
|
+
return /^[=+\-@\t\r]/.test(t) ? `'${t}` : t;
|
|
133
|
+
}
|
|
134
|
+
export function csvDe(estado) {
|
|
135
|
+
const num = (x) => (x ? String(x) : '');
|
|
136
|
+
const lineas = [CSV_CABECERA.join(',')];
|
|
137
|
+
for (const r of estado.entries) {
|
|
138
|
+
lineas.push([r.date, r.entry, textoCsv(r.concept), textoCsv(r.counterparty), num(r.in), num(r.out), num(r.fee), String(r.balance)].map(campoCsv).join(','));
|
|
139
|
+
}
|
|
140
|
+
return `${lineas.join('\r\n')}\r\n`;
|
|
141
|
+
}
|
|
142
|
+
// estado-<local>-<desde>-<hasta>.csv; sin rango: inicio / la fecha de hoy. Sólo caracteres seguros
|
|
143
|
+
// para un nombre de archivo en una cabecera.
|
|
144
|
+
export function nombreCsv(estado, hoy = new Date()) {
|
|
145
|
+
const [local, domain] = String(estado.account).split('@');
|
|
146
|
+
const quien = domain && domain !== estado.house ? `${local}.${domain}` : local;
|
|
147
|
+
const desde = estado.since ? estado.since.slice(0, 10) : 'inicio';
|
|
148
|
+
const hasta = estado.until ? estado.until.slice(0, 10) : hoy.toISOString().slice(0, 10);
|
|
149
|
+
return `estado-${quien}-${desde}-${hasta}.csv`.replace(/[^A-Za-z0-9._-]/g, '-');
|
|
150
|
+
}
|
package/src/libro/informe.js
CHANGED
|
@@ -51,6 +51,119 @@ export async function datosInforme(estafeta, { dias = 7 } = {}) {
|
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// ---------- Embudo (NX-801): de punta a punta, por fuente y por semana ----------
|
|
55
|
+
// Seis etapas, en el orden en que una persona las cruza. Cada una es UN evento del diario; el
|
|
56
|
+
// informe no infiere ninguna. `open_invite` cuenta visitas al enlace (una vista previa de
|
|
57
|
+
// WhatsApp también abre); las otras cinco cuentan direcciones raíz, una vez cada una.
|
|
58
|
+
export const ETAPAS = [
|
|
59
|
+
{ id: 'open_invite', label: 'Invite opened' },
|
|
60
|
+
{ id: 'join', label: 'Address created' },
|
|
61
|
+
{ id: 'claude_connected', label: 'Claude connected' },
|
|
62
|
+
{ id: 'first_message', label: 'First message sent' },
|
|
63
|
+
{ id: 'first_quote', label: 'First contract' },
|
|
64
|
+
{ id: 'mandate_created', label: 'First mandate' },
|
|
65
|
+
];
|
|
66
|
+
// `connector_authorized` existía antes de NX-801 y se emite en el mismo punto que
|
|
67
|
+
// `claude_connected`: leerlo como alias hace que los recorridos anteriores al despliegue (Basti,
|
|
68
|
+
// 11-sep) muestren la etapa 3 en vez de un hueco. Las etapas 1 y 4 no tienen alias: antes no se
|
|
69
|
+
// medían, y el informe las muestra vacías en vez de inventarlas.
|
|
70
|
+
const ALIAS = { connector_authorized: 'claude_connected' };
|
|
71
|
+
|
|
72
|
+
// Semana ISO-8601 (lunes a domingo, la semana 1 es la del primer jueves), en UTC como el diario.
|
|
73
|
+
export function semanaIso(ts) {
|
|
74
|
+
const d = new Date(ts);
|
|
75
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
76
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
77
|
+
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
|
78
|
+
const primero = Date.UTC(d.getUTCFullYear(), 0, 1);
|
|
79
|
+
return `${d.getUTCFullYear()}-W${String(Math.ceil(((d - primero) / 86_400_000 + 1) / 7)).padStart(2, '0')}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Qué lee: los `join` de siempre (son la lista de raíces y de fuentes) y todos los eventos de la
|
|
83
|
+
// ventana. Qué NO cubre: un delegado cuyo nombre no termine en `.<raíz>` de una raíz conocida queda
|
|
84
|
+
// en `sinRaiz`, contado y no atribuido; si el diario supera el tope, `truncado` lo dice.
|
|
85
|
+
export async function datosEmbudo(estafeta, { ventanaDias = 90, semanas = 8, diasRecorridos = 30, tope = 5000 } = {}) {
|
|
86
|
+
const ahora = Date.now();
|
|
87
|
+
const desde = new Date(ahora - ventanaDias * 86_400_000).toISOString();
|
|
88
|
+
const lee = async (q) => (await estafeta.store.listEvents?.(q)) || [];
|
|
89
|
+
const [eventos, joins] = await Promise.all([lee({ since: desde, limit: tope }), lee({ name: 'join', limit: tope })]);
|
|
90
|
+
|
|
91
|
+
// Raíces conocidas: quien hizo join, y quien las etapas 3 y 4 nombran (por construcción, raíces).
|
|
92
|
+
const raices = new Set(joins.map((e) => e.actor).filter(Boolean));
|
|
93
|
+
for (const e of eventos) if (['claude_connected', 'connector_authorized', 'first_message'].includes(e.name) && e.actor) raices.add(e.actor);
|
|
94
|
+
// Un delegado se llama <nombre>.<raíz>@casa: se prueba cada sufijo hasta dar con una raíz conocida.
|
|
95
|
+
const raizDe = (dir) => {
|
|
96
|
+
if (typeof dir !== 'string' || !dir.includes('@')) return null;
|
|
97
|
+
if (raices.has(dir)) return dir;
|
|
98
|
+
const [local, dominio] = dir.split('@');
|
|
99
|
+
const partes = local.split('.');
|
|
100
|
+
for (let i = 1; i < partes.length; i++) { const c = `${partes.slice(i).join('.')}@${dominio}`; if (raices.has(c)) return c; }
|
|
101
|
+
return null;
|
|
102
|
+
};
|
|
103
|
+
const recorridos = new Map();
|
|
104
|
+
const alcanza = (raiz, etapa, ts) => {
|
|
105
|
+
const r = recorridos.get(raiz) || { address: raiz, source: null, code: null, etapas: {} };
|
|
106
|
+
if (!r.etapas[etapa] || ts < r.etapas[etapa]) r.etapas[etapa] = ts;
|
|
107
|
+
recorridos.set(raiz, r);
|
|
108
|
+
return r;
|
|
109
|
+
};
|
|
110
|
+
const sinRaiz = {};
|
|
111
|
+
const aperturas = [];
|
|
112
|
+
for (const e of joins) { const r = alcanza(e.actor, 'join', e.ts); r.source = r.source || e.data?.source || null; }
|
|
113
|
+
for (const e of eventos) {
|
|
114
|
+
const etapa = ALIAS[e.name] || e.name;
|
|
115
|
+
if (etapa === 'open_invite') { aperturas.push(e); continue; }
|
|
116
|
+
if (etapa === 'join' || !ETAPAS.some((x) => x.id === etapa)) continue;
|
|
117
|
+
// Un contrato lo alcanzan las dos partes: el actor de `first_quote` es el comprador, `seller` la otra.
|
|
118
|
+
for (const p of etapa === 'first_quote' ? [e.actor, e.data?.seller] : [e.actor]) {
|
|
119
|
+
const raiz = raizDe(p);
|
|
120
|
+
if (!raiz) { if (p) sinRaiz[etapa] = (sinRaiz[etapa] || 0) + 1; continue; }
|
|
121
|
+
const r = alcanza(raiz, etapa, e.ts);
|
|
122
|
+
if (etapa === 'claude_connected' && e.data?.code && !r.code) r.code = e.data.code;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Enlace abierto -> dirección: por el código de la invitación que `claude_connected` trae. Si el
|
|
126
|
+
// join no declaró fuente, la del enlace es la atribución (así se atribuye a quien llegó por la app).
|
|
127
|
+
const porCodigo = new Map();
|
|
128
|
+
for (const a of aperturas) { const c = a.data?.code; if (c && (!porCodigo.has(c) || a.ts < porCodigo.get(c).ts)) porCodigo.set(c, a); }
|
|
129
|
+
for (const r of recorridos.values()) {
|
|
130
|
+
const a = r.code && porCodigo.get(r.code);
|
|
131
|
+
if (a) { alcanza(r.address, 'open_invite', a.ts); r.source = r.source || a.data?.source || null; }
|
|
132
|
+
}
|
|
133
|
+
const NO = '(not declared)';
|
|
134
|
+
const inc = (tabla, fila, etapa) => { const f = tabla.get(fila) || {}; f[etapa] = (f[etapa] || 0) + 1; tabla.set(fila, f); };
|
|
135
|
+
|
|
136
|
+
// Por fuente, en la ventana: aperturas por su propia fuente; direcciones cuyo join cae en la
|
|
137
|
+
// ventana (o que no tienen join registrado) por la fuente del recorrido.
|
|
138
|
+
const porFuente = new Map();
|
|
139
|
+
for (const a of aperturas) inc(porFuente, a.data?.source || NO, 'open_invite');
|
|
140
|
+
for (const r of recorridos.values()) {
|
|
141
|
+
if (r.etapas.join && r.etapas.join < desde) continue;
|
|
142
|
+
for (const etapa of Object.keys(r.etapas)) if (etapa !== 'open_invite') inc(porFuente, r.source || NO, etapa);
|
|
143
|
+
}
|
|
144
|
+
// Por semana ISO: las últimas N, la actual incluida. Cada etapa cuenta en la semana en que se alcanzó.
|
|
145
|
+
const semanasLista = [];
|
|
146
|
+
for (let i = semanas - 1; i >= 0; i--) semanasLista.push(semanaIso(ahora - i * 7 * 86_400_000));
|
|
147
|
+
const porSemana = new Map(semanasLista.map((s) => [s, {}]));
|
|
148
|
+
for (const a of aperturas) { const s = semanaIso(a.ts); if (porSemana.has(s)) inc(porSemana, s, 'open_invite'); }
|
|
149
|
+
for (const r of recorridos.values()) for (const [etapa, ts] of Object.entries(r.etapas)) { if (etapa === 'open_invite') continue; const s = semanaIso(ts); if (porSemana.has(s)) inc(porSemana, s, etapa); }
|
|
150
|
+
// Recorridos individuales: los que alcanzaron alguna etapa en los últimos N días, el más reciente primero.
|
|
151
|
+
const corte = new Date(ahora - diasRecorridos * 86_400_000).toISOString();
|
|
152
|
+
const ultima = (r) => Object.values(r.etapas).sort().at(-1) || '';
|
|
153
|
+
const recientes = [...recorridos.values()].filter((r) => ultima(r) >= corte).sort((a, b) => (ultima(a) < ultima(b) ? 1 : -1))
|
|
154
|
+
.map((r) => ({ address: r.address, source: r.source, etapas: Object.fromEntries(ETAPAS.map((x) => [x.id, r.etapas[x.id] || null])) }));
|
|
155
|
+
const fila = (t) => ETAPAS.map((x) => t[x.id] || 0);
|
|
156
|
+
return {
|
|
157
|
+
generado: new Date(ahora).toISOString(), desde, ventanaDias, semanas, diasRecorridos,
|
|
158
|
+
eventosLeidos: eventos.length, joinsLeidos: joins.length, tope, truncado: eventos.length >= tope || joins.length >= tope,
|
|
159
|
+
etapas: ETAPAS.map((x) => x.label),
|
|
160
|
+
porFuente: [...porFuente.entries()].sort((a, b) => (b[1].join || 0) - (a[1].join || 0) || (b[1].open_invite || 0) - (a[1].open_invite || 0)).map(([fuente, t]) => ({ fuente, conteos: fila(t) })),
|
|
161
|
+
porSemana: [...porSemana.entries()].map(([semana, t]) => ({ semana, conteos: fila(t) })),
|
|
162
|
+
recorridos: recientes,
|
|
163
|
+
raices: recorridos.size, sinRaiz,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
54
167
|
// Las frases que interpretan los números. Cada una puede ser mala noticia, y esa es la idea.
|
|
55
168
|
export function lecturas(d) {
|
|
56
169
|
const L = [];
|
|
@@ -67,7 +180,31 @@ export function lecturas(d) {
|
|
|
67
180
|
return L;
|
|
68
181
|
}
|
|
69
182
|
|
|
70
|
-
|
|
183
|
+
// La sección privada del embudo: tablas sin JS, cada celda escapada. Sólo se arma cuando la
|
|
184
|
+
// ruta privada la pide; /report nunca la recibe.
|
|
185
|
+
export function embudoHtml(e) {
|
|
186
|
+
const th = (xs) => `<tr>${xs.map((x) => `<th>${esc(x)}</th>`).join('')}</tr>`;
|
|
187
|
+
const tr = (cabeza, xs) => `<tr><td class="k">${esc(cabeza)}</td>${xs.map((x) => `<td class="n">${esc(x)}</td>`).join('')}</tr>`;
|
|
188
|
+
const fecha = (ts) => (ts ? ts.slice(0, 10) : '—');
|
|
189
|
+
const tabla = (cabeza, filas) => `<div class="ancha"><table class="embudo">${th(cabeza)}${filas.join('')}</table></div>`;
|
|
190
|
+
const vacio = (texto) => `<p class="rango">${esc(texto)}</p>`;
|
|
191
|
+
return `
|
|
192
|
+
<h2>Funnel</h2>
|
|
193
|
+
<p class="rango">Private: this section names addresses. Read from the events diary at load time: ${esc(e.eventosLeidos)} events in the last ${esc(e.ventanaDias)} days and ${esc(e.joinsLeidos)} joins overall (limit ${esc(e.tope)} each${e.truncado ? ', REACHED: older events were left out' : ''}). "Invite opened" counts link visits, previews included; the other five count addresses once each. A missing stage is shown as a dash, never guessed.</p>
|
|
194
|
+
|
|
195
|
+
<h3>By source, last ${esc(e.ventanaDias)} days</h3>
|
|
196
|
+
${e.porFuente.length ? tabla(['Source', ...e.etapas], e.porFuente.map((f) => tr(f.fuente, f.conteos))) : vacio('No joins and no invite opened in this window.')}
|
|
197
|
+
|
|
198
|
+
<h3>By ISO week, last ${esc(e.semanas)}</h3>
|
|
199
|
+
${tabla(['Week', ...e.etapas], e.porSemana.map((s) => tr(s.semana, s.conteos)))}
|
|
200
|
+
|
|
201
|
+
<h3>Journeys, last ${esc(e.diasRecorridos)} days</h3>
|
|
202
|
+
${e.recorridos.length ? tabla(['Address', 'Source', ...e.etapas], e.recorridos.map((r) => tr(r.address, [r.source || '(not declared)', ...ETAPAS.map((x) => fecha(r.etapas[x.id]))]))) : vacio('Nobody reached any stage in this window.')}
|
|
203
|
+
${Object.keys(e.sinRaiz).length ? `<p class="rango">Not attributed to any known root address: ${esc(Object.entries(e.sinRaiz).map(([k, v]) => `${k} ${v}`).join(', '))}.</p>` : ''}
|
|
204
|
+
`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function informeHtml(dominio, d, { embudo = null } = {}) {
|
|
71
208
|
const fila = (k, v) => `<tr><td>${esc(k)}</td><td class="n">${esc(v)}</td></tr>`;
|
|
72
209
|
const fuentes = Object.entries(d.porFuente).sort((a, b) => b[1] - a[1]);
|
|
73
210
|
return `<!doctype html>
|
|
@@ -99,6 +236,13 @@ export function informeHtml(dominio, d) {
|
|
|
99
236
|
li b { color:var(--ink); }
|
|
100
237
|
footer { color:var(--dim); font-size:.82rem; border-top:1px solid var(--line); padding-top:1.4rem; }
|
|
101
238
|
a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
|
|
239
|
+
h3 { font-size:.95rem; font-weight:600; margin:0 0 .6rem; }
|
|
240
|
+
.ancha { overflow-x:auto; margin:0 0 2rem; }
|
|
241
|
+
table.embudo { font-size:.8rem; margin:0; }
|
|
242
|
+
table.embudo th { text-align:right; font-weight:600; color:var(--dim); padding:.35rem .4rem; border-bottom:1px solid var(--line); white-space:nowrap; }
|
|
243
|
+
table.embudo th:first-child, table.embudo td.k { text-align:left; padding-left:0; }
|
|
244
|
+
table.embudo td { padding:.35rem .4rem; }
|
|
245
|
+
table.embudo td.n { font-size:.8rem; }
|
|
102
246
|
</style>
|
|
103
247
|
</head>
|
|
104
248
|
<body>
|
|
@@ -124,9 +268,9 @@ export function informeHtml(dominio, d) {
|
|
|
124
268
|
|
|
125
269
|
<h2>Where they came from</h2>
|
|
126
270
|
${fuentes.length ? `<table>${fuentes.map(([f, n]) => fila(f, n)).join('')}</table><p class="rango">Joins, not visits. A page view is not an agent.</p>` : '<p class="rango">Nobody joined, so there is nothing to attribute.</p>'}
|
|
127
|
-
|
|
271
|
+
${embudo ? embudoHtml(embudo) : ''}
|
|
128
272
|
<footer>
|
|
129
|
-
No agent names, no counterparties and no message content appear here — only how many and how much.
|
|
273
|
+
${embudo ? 'This page is private to the house and names addresses; the public <code>/report</code> does not.' : 'No agent names, no counterparties and no message content appear here — only how many and how much.'}
|
|
130
274
|
Each agent's own record is public and verifiable separately at <code>/agents/<name>/historial</code>.
|
|
131
275
|
<br><a href="/">Home</a> · <a href="/spec">Specification</a>
|
|
132
276
|
</footer>
|
package/src/libro/libro.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { signObject, verifyObject, canonical, sha256hex, uuid } from '../nucleo/crypto.js';
|
|
29
29
|
import { Resolver, parseAddress } from '../correo/resolver.js';
|
|
30
30
|
import { CONTRATOS } from './contratos.js';
|
|
31
|
+
import { boletaDe } from './estado.js';
|
|
31
32
|
import { LibroError } from './errores.js';
|
|
32
33
|
export { LibroError };
|
|
33
34
|
|
|
@@ -35,6 +36,8 @@ export const MEDIA = {
|
|
|
35
36
|
op: 'application/nyx5.libro+json',
|
|
36
37
|
cotizacion: 'application/nyx5.cotizacion+json',
|
|
37
38
|
recibo: 'application/nyx5.recibo+json',
|
|
39
|
+
// NX-305: un comprador PIDE un servicio del catálogo; el vendedor contesta con la cotización.
|
|
40
|
+
pedido: 'application/nyx5.pedido+json',
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
const iso = () => new Date().toISOString();
|
|
@@ -42,10 +45,13 @@ const iso = () => new Date().toISOString();
|
|
|
42
45
|
export class Libro {
|
|
43
46
|
// feeBps: fee de la casa en basis points enteros (1000 = 10%). `feePct` sigue aceptándose
|
|
44
47
|
// como azúcar (0.10 -> 1000) pero el cálculo es siempre entero: sin punto flotante en el dinero.
|
|
45
|
-
|
|
48
|
+
// reclaimGraceMs / reviewWindowMs (NX-503): cuánto después del plazo el comprador recupera un
|
|
49
|
+
// escrow sin entrega, y cuánto tiene para objetar una entrega antes de que se libere sola.
|
|
50
|
+
constructor({ domain, store, keys, resolver, feeBps = null, feePct = null, welcome = 0, reclaimGraceMs = 24 * 3600_000, reviewWindowMs = 72 * 3600_000, log = () => {} }) {
|
|
46
51
|
this.domain = domain; this.store = store; this.keys = keys; this.resolver = resolver;
|
|
47
52
|
this.feeBps = feeBps ?? (feePct != null ? Math.round(feePct * 10_000) : 1000);
|
|
48
53
|
this.welcome = welcome; this.log = log;
|
|
54
|
+
this.reclaimGraceMs = reclaimGraceMs; this.reviewWindowMs = reviewWindowMs;
|
|
49
55
|
this.casa = `casa@${domain}`;
|
|
50
56
|
this.address = `libro@${domain}`;
|
|
51
57
|
this.tx = null; // transacción en curso: { state, asientos, contracts: Map, mandates: Map, op }
|
|
@@ -62,11 +68,11 @@ export class Libro {
|
|
|
62
68
|
// y ambos entrarían, superando el tope.
|
|
63
69
|
async _begin(concepto = 'control', refs = {}) {
|
|
64
70
|
const base = await this.store.libroState();
|
|
65
|
-
this.tx = { base, state: null, asientos: [], contracts: new Map(), mandates: new Map(), op: null, concepto, refs };
|
|
71
|
+
this.tx = { base, state: null, asientos: [], contracts: new Map(), mandates: new Map(), sellos: [], op: null, concepto, refs };
|
|
66
72
|
}
|
|
67
73
|
_bundle() {
|
|
68
74
|
const t = this.tx;
|
|
69
|
-
return { state: t.state, base: t.base, asientos: t.asientos, contracts: [...t.contracts.values()], mandates: [...t.mandates.values()], op: t.op };
|
|
75
|
+
return { state: t.state, base: t.base, asientos: t.asientos, contracts: [...t.contracts.values()], mandates: [...t.mandates.values()], sellos: t.sellos, op: t.op };
|
|
70
76
|
}
|
|
71
77
|
async _commit() {
|
|
72
78
|
const t = this.tx;
|
|
@@ -90,6 +96,9 @@ export class Libro {
|
|
|
90
96
|
putContract(c) { if (!this.tx) throw new LibroError(500, 'putContract fuera de transacción'); this.tx.contracts.set(c.id, c); }
|
|
91
97
|
async getMandate(id) { return this.tx?.mandates.get(id) ?? await this.store.libroGetMandate(id); }
|
|
92
98
|
putMandate(m) { if (!this.tx) throw new LibroError(500, 'putMandate fuera de transacción'); this.tx.mandates.set(m.id, m); }
|
|
99
|
+
// Sello de notaría (NX-601): no mueve dinero ni consume número de asiento. Su candado contra el
|
|
100
|
+
// doble sello es el índice único (sha256, by) del almacén, que hace fallar cerrado el commit.
|
|
101
|
+
putSello(s) { if (!this.tx) throw new LibroError(500, 'putSello fuera de transacción'); this.tx.sellos.push(s); }
|
|
93
102
|
|
|
94
103
|
// ---------- consultas ----------
|
|
95
104
|
get ops() { return Object.keys(CONTRATOS.ops); }
|
|
@@ -114,13 +123,17 @@ export class Libro {
|
|
|
114
123
|
// bond released | forfeited afirmación sostenida | derribada
|
|
115
124
|
static TERMINALES = { spot: ['settled'], escrow: ['released', 'refunded'], bond: ['released', 'forfeited'], metered: [] };
|
|
116
125
|
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
// `contratos`: la lista ya leída, para que un lote de historiales (la ruta en lote, el rastreo
|
|
127
|
+
// del índice) no vuelva a leer la tabla por cada dirección.
|
|
128
|
+
async historial(address, { contratos = null } = {}) {
|
|
129
|
+
const todos = contratos ?? await this.store.libroListContracts();
|
|
119
130
|
const mios = todos.filter((c) => [c.seller, c.buyer].includes(address));
|
|
120
131
|
const cuenta = () => ({ n: 0, tokens: 0 });
|
|
121
132
|
const h = {
|
|
122
133
|
address, house: this.domain,
|
|
123
|
-
|
|
134
|
+
// entregas_por_silencio: liberadas por el reloj de la casa al vencer la ventana de revisión
|
|
135
|
+
// (NX-503). Silencio no es aceptación: se cuentan aparte, y también dentro de aceptadas.
|
|
136
|
+
vendiendo: { entregas_aceptadas: cuenta(), entregas_por_silencio: cuenta(), entregas_devueltas: cuenta(), ventas_directas: cuenta() },
|
|
124
137
|
comprando: { encargos_liberados: cuenta(), encargos_devueltos: cuenta(), compras_directas: cuenta() },
|
|
125
138
|
afirmando: { fianzas_sostenidas: cuenta(), fianzas_ejecutadas: cuenta(), fianzas_vigentes: cuenta() },
|
|
126
139
|
avalando: { avales_sostenidos: cuenta(), avales_ejecutados: cuenta() },
|
|
@@ -142,7 +155,10 @@ export class Libro {
|
|
|
142
155
|
else sumar(sostenida ? grupo.fianzas_sostenidas : grupo.fianzas_ejecutadas, monto);
|
|
143
156
|
} else if (c.seller === address) {
|
|
144
157
|
if (c.kind === 'spot') sumar(h.vendiendo.ventas_directas, monto);
|
|
145
|
-
else
|
|
158
|
+
else {
|
|
159
|
+
sumar(c.state === 'released' ? h.vendiendo.entregas_aceptadas : h.vendiendo.entregas_devueltas, monto);
|
|
160
|
+
if (c.state === 'released' && c.history?.some((x) => x.op === 'expire')) sumar(h.vendiendo.entregas_por_silencio, monto);
|
|
161
|
+
}
|
|
146
162
|
} else {
|
|
147
163
|
if (c.kind === 'spot') sumar(h.comprando.compras_directas, monto);
|
|
148
164
|
else sumar(c.state === 'released' ? h.comprando.encargos_liberados : h.comprando.encargos_devueltos, monto);
|
|
@@ -150,6 +166,24 @@ export class Libro {
|
|
|
150
166
|
}
|
|
151
167
|
// Fianzas todavía en pie: no son historial cumplido, pero sí tokens en juego ahora mismo.
|
|
152
168
|
for (const c of mios) if (c.kind === 'bond' && c.state === 'posted' && c.seller === address) sumar(h.afirmando.fianzas_vigentes, Number(c.amount) || 0);
|
|
169
|
+
// Arbitrados por la casa (NX-302): sólo los contratos en que este agente VENDÍA, el árbitro
|
|
170
|
+
// era `verifica@<casa>` y el veredicto terminal (release / refund / forfeit) lo dio ese
|
|
171
|
+
// árbitro, no una parte. Es la porción del historial que dos cómplices no pueden fabricar
|
|
172
|
+
// entre sí: para sumar aquí hay que pasar una prueba determinista que corre la casa. Un
|
|
173
|
+
// escrow liberado por el comprador, o por silencio (`expire`), queda fuera aunque el
|
|
174
|
+
// contrato nombrara a verifica@ de árbitro. Campo ADITIVO: la forma anterior no cambia.
|
|
175
|
+
const arbitro = `verifica@${this.domain}`;
|
|
176
|
+
h.arbitrados = { arbitro, liberados: cuenta(), devueltos: cuenta(), ejecutadas: cuenta() };
|
|
177
|
+
for (const c of mios) {
|
|
178
|
+
if (c.seller !== address || c.arbiter !== arbitro) continue;
|
|
179
|
+
if (!(Libro.TERMINALES[c.kind] || []).includes(c.state)) continue;
|
|
180
|
+
const veredicto = (c.history || []).filter((x) => ['release', 'refund', 'forfeit'].includes(x.op)).at(-1);
|
|
181
|
+
if (!veredicto || veredicto.by !== arbitro) continue;
|
|
182
|
+
const monto = Number(c.amount) || 0;
|
|
183
|
+
if (veredicto.op === 'release') sumar(h.arbitrados.liberados, monto);
|
|
184
|
+
else if (veredicto.op === 'refund') sumar(h.arbitrados.devueltos, monto);
|
|
185
|
+
else sumar(h.arbitrados.ejecutadas, monto);
|
|
186
|
+
}
|
|
153
187
|
// El resumen es lo que un agente lee para decidir en una línea; el detalle queda arriba.
|
|
154
188
|
const entregadas = h.vendiendo.entregas_aceptadas.n + h.vendiendo.ventas_directas.n;
|
|
155
189
|
const falladas = h.vendiendo.entregas_devueltas.n;
|
|
@@ -162,6 +196,8 @@ export class Libro {
|
|
|
162
196
|
// Sin historial no hay tasa: cero de cero no es 100%, es "todavía nada". Lo decimos así.
|
|
163
197
|
cumplimiento: entregadas + falladas > 0 ? Number((entregadas / (entregadas + falladas)).toFixed(4)) : null,
|
|
164
198
|
veracidad: afirmaciones > 0 ? Number((h.afirmando.fianzas_sostenidas.n / afirmaciones).toFixed(4)) : null,
|
|
199
|
+
// Lo que el índice federado usa para ordenar (§13): ponderado por tokens, null sin historial arbitrado.
|
|
200
|
+
puntaje_arbitrado: puntajeArbitrado(h.arbitrados),
|
|
165
201
|
};
|
|
166
202
|
return h;
|
|
167
203
|
}
|
|
@@ -177,7 +213,7 @@ export class Libro {
|
|
|
177
213
|
const next = { ...state.balances };
|
|
178
214
|
for (const l of lines) {
|
|
179
215
|
next[l.account] = (next[l.account] || 0) + l.delta;
|
|
180
|
-
if (next[l.account] < 0 && l.account !== this.casa) throw new LibroError(402, `
|
|
216
|
+
if (next[l.account] < 0 && l.account !== this.casa) throw new LibroError(402, `insufficient balance in ${l.account} (has ${state.balances[l.account] || 0}, needs ${-l.delta})`);
|
|
181
217
|
}
|
|
182
218
|
const asiento = signObject({ id: uuid(), n: state.seq + 1, at: iso(), house: this.domain, concept, lines, meta, refs }, this.keys);
|
|
183
219
|
if (this.tx) {
|
|
@@ -228,8 +264,11 @@ export class Libro {
|
|
|
228
264
|
|
|
229
265
|
// ---------- cotizaciones: documentos firmados por el vendedor ----------
|
|
230
266
|
// Una cotización viaja adentro de un sobre (cifrado si se quiere) y se presenta al Libro al aceptar.
|
|
231
|
-
static buildQuote({ seller, buyer, house, contract = 'spot', price, concept, terms = {}, expires, arbiter = null, referrer = null }, sellerKeys) {
|
|
267
|
+
static buildQuote({ seller, buyer, house, contract = 'spot', price, concept, terms = {}, expires, arbiter = null, referrer = null, service = null }, sellerKeys) {
|
|
232
268
|
if (!CONTRATOS[contract]?.quoteable) throw new LibroError(400, `contrato no cotizable: ${contract}`);
|
|
269
|
+
// `service` (NX-301): el id de un servicio publicado en la ficha del vendedor. Va firmado en la
|
|
270
|
+
// cotización y verifyQuote lo cruza con la ficha al aceptar: precio y contrato tienen que ser los publicados.
|
|
271
|
+
if (service != null && (typeof service !== 'string' || !/^[a-z0-9-]{1,40}$/.test(service))) throw new LibroError(400, 'service debe ser el id de un servicio de la ficha (letras minúsculas, dígitos y guiones)');
|
|
233
272
|
if (arbiter) parseAddress(arbiter);
|
|
234
273
|
// Comisión de referido: el vendedor firma en su cotización que le paga `share` (en basis points)
|
|
235
274
|
// a quien trajo el trato. La comisión sale de LO QUE RECIBE el vendedor, no se suma al precio:
|
|
@@ -238,7 +277,7 @@ export class Libro {
|
|
|
238
277
|
parseAddress(referrer.address);
|
|
239
278
|
if (!Number.isInteger(referrer.share) || referrer.share <= 0) throw new LibroError(400, 'referrer.share debe ser un entero de basis points > 0');
|
|
240
279
|
}
|
|
241
|
-
return signObject({ tipo: 'cotizacion', id: uuid(), house, seller, buyer, contract, price, currency: 'tok', concept, terms, arbiter, referrer: referrer || undefined, issued: iso(), expires: expires || null }, sellerKeys);
|
|
280
|
+
return signObject({ tipo: 'cotizacion', id: uuid(), house, seller, buyer, contract, price, currency: 'tok', concept, terms, arbiter, referrer: referrer || undefined, service: service || undefined, issued: iso(), expires: expires || null }, sellerKeys);
|
|
242
281
|
}
|
|
243
282
|
async verifyQuote(q, buyer) {
|
|
244
283
|
if (q?.tipo !== 'cotizacion' || !q.id || !q.seller || !q.signature) throw new LibroError(400, 'cotización malformada');
|
|
@@ -260,6 +299,19 @@ export class Libro {
|
|
|
260
299
|
let card;
|
|
261
300
|
try { card = await this.resolver.agentCardForKid(q.seller, q.signature.kid); } catch (e) { throw new LibroError(e.permanent ? 403 : 421, `no se pudo verificar al vendedor: ${e.message}`); }
|
|
262
301
|
if (!Resolver.acceptedKids(card).includes(q.signature.kid) || !verifyObject(q, q.signature.kid)) throw new LibroError(403, 'firma de la cotización inválida');
|
|
302
|
+
// Una dirección de sólo mensajes (la del Claude de un teléfono, cuya llave guarda la casa) no
|
|
303
|
+
// vende: aceptarle una cotización le movería saldo, que es justo lo que su dueño no autorizó.
|
|
304
|
+
if (card.delegation?.scope?.messages_only) throw new LibroError(403, 'the seller is a messages-only address: it cannot sell');
|
|
305
|
+
// Cotización sobre un servicio publicado (NX-301): se compara contra la ficha CERTIFICADA del
|
|
306
|
+
// vendedor tal como está hoy, nunca contra lo que la cotización dice de sí misma. Mismo espíritu
|
|
307
|
+
// que tareas.coincide: un precio o un contrato distintos del publicado son rechazo, no negociación.
|
|
308
|
+
// Si el vendedor cambió su catálogo después de cotizar, la cotización vieja ya no coincide y se rechaza.
|
|
309
|
+
if (q.service != null) {
|
|
310
|
+
if (typeof q.service !== 'string') throw new LibroError(400, 'service must be the id of a published service');
|
|
311
|
+
const s = (card.profile?.services || []).find((x) => x.id === q.service);
|
|
312
|
+
if (!s) throw new LibroError(400, `service "${q.service}" is not published in the profile of ${q.seller}`);
|
|
313
|
+
if (s.price.tokens !== q.price || s.contract !== q.contract) throw new LibroError(400, `service ${s.id} is published at ${s.price.tokens} tok as ${s.contract}; the quote says ${q.price} as ${q.contract}`);
|
|
314
|
+
}
|
|
263
315
|
if (await this.store.libroFindContractByQuote(q.id)) throw new LibroError(409, 'cotización ya aceptada');
|
|
264
316
|
return card;
|
|
265
317
|
}
|
|
@@ -273,6 +325,9 @@ export class Libro {
|
|
|
273
325
|
const body = env.content.body || {};
|
|
274
326
|
const op = CONTRATOS.ops[body.op];
|
|
275
327
|
if (!op) return { ok: false, code: 400, reason: `unknown operation: ${body.op}. Valid ones: ${Object.keys(CONTRATOS.ops).join(', ')}` };
|
|
328
|
+
// Sólo mensajes no opera el Libro, entre el sobre por donde entre: la regla vive donde se mueve
|
|
329
|
+
// el dinero, no sólo en la puerta (revisión del 11-sep-2026).
|
|
330
|
+
if (senderCard?.delegation?.scope?.messages_only) return { ok: false, code: 403, reason: 'this is a messages-only address: it cannot operate the ledger' };
|
|
276
331
|
const ctx = { libro: this, env, from: env.from, body, senderCard, opHash: sha256hex(canonical(env)), scope: senderCard?.delegation?.scope || null };
|
|
277
332
|
return this._serial(async () => {
|
|
278
333
|
await this._begin(`op ${body.op}`, { op: env.id, op_sha256: ctx.opHash });
|
|
@@ -283,7 +338,11 @@ export class Libro {
|
|
|
283
338
|
if (e instanceof LibroError) return { ok: false, code: e.code, reason: e.message };
|
|
284
339
|
throw e;
|
|
285
340
|
}
|
|
286
|
-
|
|
341
|
+
// Boleta (NX-501): todo recibo que lleva un asiento con reparto dice cuánto fue fee de la casa
|
|
342
|
+
// y cuánto comisión de referido, leído de las líneas del asiento (estado.js), en el único
|
|
343
|
+
// punto donde se arman los recibos.
|
|
344
|
+
const boleta = (b) => boletaDe(b.asiento, { casa: this.casa, feeBps: this.feeBps, share: b.contract?.referrer?.share ?? null });
|
|
345
|
+
const out = { ok: true, code: 202, result: result.result, recibos: (result.recibos || []).map((r) => ({ ...r, body: { ...r.body, ...boleta(r.body), of: env.id, op: body.op, op_sha256: ctx.opHash, from: env.from } })), avisos: result.avisos || [] };
|
|
287
346
|
this.tx.op = { id: env.id, result: out };
|
|
288
347
|
try { await this._commit(); }
|
|
289
348
|
catch (e) {
|
|
@@ -301,7 +360,7 @@ export class Libro {
|
|
|
301
360
|
// dedupe del sobre (inboundCommit) — así una reentrega jamás cobra la estampilla dos veces.
|
|
302
361
|
async stamp(env, recipient, price) {
|
|
303
362
|
const s = env.stamp;
|
|
304
|
-
if (!s || s.house !== this.domain || !Number.isInteger(s.amount) || s.amount < price) throw new LibroError(402, `
|
|
363
|
+
if (!s || s.house !== this.domain || !Number.isInteger(s.amount) || s.amount < price) throw new LibroError(402, `this mailbox requires a stamp of ${price} tok in house ${this.domain} (stamp field: {house, amount})`);
|
|
305
364
|
return this._serial(async () => {
|
|
306
365
|
await this._begin('estampilla', { envelope: env.id });
|
|
307
366
|
try {
|
|
@@ -313,3 +372,15 @@ export class Libro {
|
|
|
313
372
|
});
|
|
314
373
|
}
|
|
315
374
|
}
|
|
375
|
+
|
|
376
|
+
// Puntaje de reputación arbitrada: tokens liberados sobre tokens decididos por verifica@
|
|
377
|
+
// (liberados + devueltos + fianzas ejecutadas). Ponderado por monto, no por cuenta: una entrega
|
|
378
|
+
// de 5.000 pesa más que diez de 10. `null` cuando no hay nada decidido: cero de cero no es 100 %.
|
|
379
|
+
// UNA definición: la usan `historial` (resumen.puntaje_arbitrado) y el índice federado.
|
|
380
|
+
export function puntajeArbitrado(arbitrados) {
|
|
381
|
+
if (!arbitrados || typeof arbitrados !== 'object') return null;
|
|
382
|
+
const t = (k) => Number(arbitrados[k]?.tokens) || 0;
|
|
383
|
+
const total = t('liberados') + t('devueltos') + t('ejecutadas');
|
|
384
|
+
if (total <= 0) return null;
|
|
385
|
+
return Number((t('liberados') / total).toFixed(4));
|
|
386
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Nyx5/1 — Notaría (NX-601): la casa sella el hash de un documento con fecha y firma.
|
|
2
|
+
//
|
|
3
|
+
// Qué certifica un sello: que en el instante `at` la casa recibió un sobre firmado (hash
|
|
4
|
+
// `op_sha256`) en el que `by` declaró el hash `sha256`. Nada más. La casa no ve el documento, no
|
|
5
|
+
// afirma que exista ni qué contiene: quien lo tenga puede demostrar que ya existía a esa fecha
|
|
6
|
+
// presentando el documento (su hash coincide) y el sello (la firma de la casa verifica con la
|
|
7
|
+
// tarjeta del dominio, sin cuenta).
|
|
8
|
+
//
|
|
9
|
+
// No mueve dinero: no hay asiento. El candado contra el doble sello es el índice único
|
|
10
|
+
// (sha256, by) del almacén, no el número de asiento: dos sobres distintos del mismo agente con el
|
|
11
|
+
// mismo hash producen UN sello (el primero) y la segunda petición recibe ese mismo sello. Dos
|
|
12
|
+
// agentes distintos sí producen dos sellos: cada uno atestigua que ÉL lo declaró.
|
|
13
|
+
//
|
|
14
|
+
// Dos versiones firmadas del mismo sello, con el mismo id: `sello` (con `by`) y `anonimo` (con
|
|
15
|
+
// `by: null`). La pública sirve la primera si el agente es visible y la segunda si es secreto,
|
|
16
|
+
// para que un secreto no quede confirmado por su propio sello (NX-202) y aun así la firma
|
|
17
|
+
// verifique. Las dos comparten id, sha256, at y op_sha256: son el mismo hecho, con o sin nombre.
|
|
18
|
+
//
|
|
19
|
+
// Qué NO cubre: no prueba autoría del documento (prueba quién lo DECLARÓ), no prueba que el
|
|
20
|
+
// documento sea anterior a `at` (sólo que no es posterior), y no verifica que `media` o `name`
|
|
21
|
+
// describan al documento: son lo que el declarante dijo.
|
|
22
|
+
|
|
23
|
+
import { signObject, uuid } from '../nucleo/crypto.js';
|
|
24
|
+
import { limpio } from '../correo/politica.js';
|
|
25
|
+
import { LibroError } from './errores.js';
|
|
26
|
+
|
|
27
|
+
export const SHA256_HEX = /^[0-9a-fA-F]{64}$/;
|
|
28
|
+
export const LIMITES = Object.freeze({ name: 120, media: 100, note: 500 });
|
|
29
|
+
|
|
30
|
+
const fail = (code, msg) => { throw new LibroError(code, msg); };
|
|
31
|
+
|
|
32
|
+
// Valida el cuerpo de la op. Campos desconocidos se ignoran (invariante 7). El hash se normaliza
|
|
33
|
+
// a minúsculas: el mismo documento no puede tener dos sellos por cómo se escribió su hash.
|
|
34
|
+
export function validarNotarize(body = {}) {
|
|
35
|
+
if (typeof body.sha256 !== 'string' || !SHA256_HEX.test(body.sha256)) fail(400, 'sha256 must be the hex digest of the document: 64 hexadecimal characters');
|
|
36
|
+
const texto = (k) => {
|
|
37
|
+
if (body[k] == null) return null;
|
|
38
|
+
if (typeof body[k] !== 'string') fail(400, `${k} must be a string`);
|
|
39
|
+
return limpio(body[k], LIMITES[k]) || null;
|
|
40
|
+
};
|
|
41
|
+
return { sha256: body.sha256.toLowerCase(), name: texto('name'), media: texto('media'), note: texto('note') };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Construye el registro firmado. `keys` son las llaves del dominio (las mismas que firman recibos).
|
|
45
|
+
export function sellar({ sha256, name, media, note, by, house, at, op_sha256, secret = false }, keys) {
|
|
46
|
+
const cuerpo = { nyx5: '1', tipo: 'sello', id: uuid(), sha256, name, media, note, by, house, at, op_sha256 };
|
|
47
|
+
return {
|
|
48
|
+
id: cuerpo.id, sha256, by, at,
|
|
49
|
+
// `secret` es la visibilidad del declarante cuando selló: un agente de OTRA casa no se puede
|
|
50
|
+
// re-consultar en vivo desde aquí, así que lo que se supo al sellar es lo que manda para él.
|
|
51
|
+
secret: !!secret,
|
|
52
|
+
sello: signObject(cuerpo, keys),
|
|
53
|
+
anonimo: signObject({ ...cuerpo, by: null }, keys),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Lo que se sirve al público por un registro: la versión con nombre si el declarante es
|
|
58
|
+
// visible para «nadie» (quien pregunta sin cuenta), la anónima si no.
|
|
59
|
+
export function selloPublico(doc, visible) { return visible ? doc.sello : doc.anonimo; }
|
|
60
|
+
|
|
61
|
+
// La op del Libro. Corre dentro de la transacción de Libro.handle(): escribe con libro.putSello.
|
|
62
|
+
export async function notarize(ctx) {
|
|
63
|
+
const { libro, from, body, senderCard, opHash } = ctx;
|
|
64
|
+
const v = validarNotarize(body);
|
|
65
|
+
const previo = await libro.store.notariaFind(v.sha256, from);
|
|
66
|
+
if (previo) {
|
|
67
|
+
// El mismo declarante ya selló este hash: se le devuelve ESE sello, no se crea otro. La
|
|
68
|
+
// receta lleva el hash del sobre que la pidió (invariante 5) aunque el sello sea anterior.
|
|
69
|
+
return { result: { seal: previo.sello, existing: true }, recibos: [{ to: [from], body: { seal: previo.sello, existing: true } }] };
|
|
70
|
+
}
|
|
71
|
+
const doc = sellar({ ...v, by: from, house: libro.domain, at: new Date().toISOString(), op_sha256: opHash, secret: senderCard?.visibility === 'secret' }, libro.keys);
|
|
72
|
+
libro.putSello(doc);
|
|
73
|
+
return { result: { seal: doc.sello, existing: false }, recibos: [{ to: [from], body: { seal: doc.sello, existing: false } }] };
|
|
74
|
+
}
|