@nyx5/nyx5 0.7.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.
Files changed (47) hide show
  1. package/README.md +15 -3
  2. package/bin/nyx5.js +72 -0
  3. package/demo/x402-pagar.mjs +64 -0
  4. package/docs/ARQUITECTURA.md +352 -0
  5. package/docs/SPEC.md +337 -6
  6. package/examples/ci/nyx5-ejecutor.yml +82 -0
  7. package/examples/frameworks/README.md +55 -0
  8. package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
  9. package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
  10. package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
  11. package/examples/frameworks/claude-agent-sdk.mjs +48 -0
  12. package/examples/frameworks/crewai_nyx5.py +72 -0
  13. package/examples/frameworks/http.md +117 -0
  14. package/examples/frameworks/langgraph_nyx5.py +90 -0
  15. package/examples/frameworks/nyx5_http.py +255 -0
  16. package/examples/frameworks/openai-agents.mjs +41 -0
  17. package/examples/hola-mundo.mjs +4 -4
  18. package/migrations/0009_contratos_estado.sql +4 -0
  19. package/package.json +4 -2
  20. package/server.json +2 -2
  21. package/src/correo/acciones.js +216 -0
  22. package/src/correo/adjuntos.js +185 -0
  23. package/src/correo/agente.js +247 -13
  24. package/src/correo/asistente.js +502 -77
  25. package/src/correo/ci.js +262 -0
  26. package/src/correo/cobro.js +164 -0
  27. package/src/correo/estafeta.js +558 -59
  28. package/src/correo/ideas.js +154 -0
  29. package/src/correo/politica.js +92 -3
  30. package/src/libro/libro.js +6 -2
  31. package/src/libro/verifica.js +239 -36
  32. package/src/nucleo/almacen-d1.js +24 -5
  33. package/src/nucleo/almacen.js +31 -4
  34. package/src/nucleo/crypto.js +36 -0
  35. package/src/nucleo/keccak.js +118 -0
  36. package/src/nucleo/objetos.js +78 -0
  37. package/src/nucleo/secp256k1.js +228 -0
  38. package/src/plataformas/app-html.js +1 -1
  39. package/src/plataformas/home-html.js +1 -1
  40. package/src/plataformas/node.js +18 -1
  41. package/src/plataformas/spec-html.js +1 -1
  42. package/src/plataformas/terms-html.js +2 -0
  43. package/src/plataformas/worker.js +21 -1
  44. package/src/puentes/ejecutor.js +118 -0
  45. package/src/puentes/herramientas.js +137 -12
  46. package/src/puentes/x402-pagador.js +248 -0
  47. package/src/version.js +1 -1
@@ -0,0 +1,248 @@
1
+ // Nyx5/1 — Pagador x402: que un agente PAGUE un recurso que cobra en USDC sobre una red EVM.
2
+ //
3
+ // La casa ya sabía cobrar (src/puentes/x402.js): anunciar el precio en un 402, preguntarle al
4
+ // facilitador si la firma sirve y pedir que liquide. Eso no necesita criptografía de cadena.
5
+ // Pagar sí: el pagador firma una autorización EIP-3009 (`TransferWithAuthorization`) sobre el
6
+ // contrato del token, con tipado EIP-712, y esa firma exige keccak256 y secp256k1 con
7
+ // recuperación, que Node no trae. Ahora están en src/nucleo/keccak.js y src/nucleo/secp256k1.js.
8
+ //
9
+ // Cómo se protege el que paga, porque una firma es dinero:
10
+ // - `tope` es OBLIGATORIO y va en unidades atómicas. Si el 402 pide más, no se firma. Es el
11
+ // `max_per_charge` de un mandato, pero del lado de la cadena: el agente no decide cuánto vale
12
+ // el recurso, decide cuánto está dispuesto a perder si el servidor miente.
13
+ // - El nonce son 32 bytes aleatorios por autorización. El contrato de USDC rechaza un nonce
14
+ // repetido, así que una autorización sólo puede liquidarse UNA vez, aunque el servidor la
15
+ // reintente o la filtre.
16
+ // - `validBefore` nunca pasa de ahora + 5 minutos. Una autorización firmada es un cheque al
17
+ // portador hasta que vence; cuanto menos viva, menos vale robarla.
18
+ // - El dominio EIP-712 (name, version, chainId, contrato) sale de `TOKEN_USD`, la tabla que ya
19
+ // está LEÍDA de cada contrato. No se copia ni se acepta lo que diga el 402: si el servidor
20
+ // anuncia un `asset` o un `name` que no coincide con el USDC de esa red, se rechaza ANTES de
21
+ // firmar. Firmar contra un dominio que dicta el cobrador es firmar lo que él quiera.
22
+ // - `from` tiene que ser la dirección de la llave. Firmar una autorización con otro `from` no
23
+ // mueve la plata del otro; sólo produce una firma inválida, y el error no diría por qué.
24
+ //
25
+ // Este módulo NO habla con ninguna red ni facilitador: firma y manda cabeceras HTTP al servidor
26
+ // del recurso. Quien difunde la transacción y paga el gas es el facilitador del cobrador.
27
+
28
+ import { randomBytes } from 'node:crypto';
29
+ import { keccak256, aHex, deHex } from '../nucleo/keccak.js';
30
+ import { firmar, direccionDe, recuperarDireccion } from '../nucleo/secp256k1.js';
31
+ import { TOKEN_USD, X402_VERSION } from './x402.js';
32
+
33
+ export const TIPO_DOMINIO = 'EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)';
34
+ export const TIPO_TRANSFER = 'TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)';
35
+ export const VIGENCIA_MAX_S = 300; // validBefore ≤ ahora + 5 min
36
+ export const TOLERANCIA_RELOJ_S = 600; // validAfter = ahora − 10 min, como el cliente de referencia de x402
37
+
38
+ const DIRECCION = /^0x[0-9a-fA-F]{40}$/;
39
+ const NONCE = /^0x[0-9a-fA-F]{64}$/;
40
+ const ENTERO = /^\d+$/;
41
+
42
+ // ---- codificación EIP-712 ----
43
+ const concat = (...partes) => {
44
+ const total = partes.reduce((n, p) => n + p.length, 0);
45
+ const out = new Uint8Array(total);
46
+ let i = 0;
47
+ for (const p of partes) { out.set(p, i); i += p.length; }
48
+ return out;
49
+ };
50
+ const uint256 = (n) => deHex(BigInt(n).toString(16).padStart(64, '0'));
51
+ const direccion32 = (a) => {
52
+ if (!DIRECCION.test(String(a))) throw new Error(`x402 pagador: dirección inválida "${String(a).slice(0, 20)}"`);
53
+ return deHex(String(a).slice(2).padStart(64, '0'));
54
+ };
55
+ const entero = (v, nombre) => {
56
+ const s = typeof v === 'bigint' ? v.toString() : String(v);
57
+ if (!ENTERO.test(s)) throw new Error(`x402 pagador: ${nombre} debe ser un entero sin signo en unidades atómicas`);
58
+ return s;
59
+ };
60
+
61
+ // La entrada de TOKEN_USD para una red, o null. `Object.hasOwn`: una red llamada `constructor` o
62
+ // `__proto__` no es una red, y `TOKEN_USD[red]` la encontraría en el prototipo.
63
+ const tokenDe = (network) => (typeof network === 'string' && Object.hasOwn(TOKEN_USD, network)) ? TOKEN_USD[network] : null;
64
+
65
+ // El dominio EIP-712 del contrato de USDC en una red. Sale de la tabla leída de los contratos.
66
+ export function dominioDe(network) {
67
+ const t = tokenDe(network);
68
+ if (!t) throw new Error(`x402 pagador: no sé pagar en ${network}; conozco: ${Object.keys(TOKEN_USD).join(', ')}`);
69
+ const m = /^eip155:(\d+)$/.exec(network);
70
+ if (!m) throw new Error(`x402 pagador: ${network} no es una red EVM`);
71
+ return { name: t.name, version: t.version, chainId: BigInt(m[1]), verifyingContract: t.asset };
72
+ }
73
+
74
+ export function separadorDeDominio({ name, version, chainId, verifyingContract }) {
75
+ return keccak256(concat(
76
+ keccak256(TIPO_DOMINIO),
77
+ keccak256(String(name)),
78
+ keccak256(String(version)),
79
+ uint256(chainId),
80
+ direccion32(verifyingContract),
81
+ ));
82
+ }
83
+
84
+ export function hashAutorizacion({ from, to, value, validAfter, validBefore, nonce }) {
85
+ if (!NONCE.test(String(nonce))) throw new Error('x402 pagador: nonce debe ser 0x + 32 bytes en hex');
86
+ return keccak256(concat(
87
+ keccak256(TIPO_TRANSFER),
88
+ direccion32(from),
89
+ direccion32(to),
90
+ uint256(entero(value, 'value')),
91
+ uint256(entero(validAfter, 'validAfter')),
92
+ uint256(entero(validBefore, 'validBefore')),
93
+ deHex(nonce),
94
+ ));
95
+ }
96
+
97
+ // El digest que firma el pagador: keccak256(0x19 0x01 ‖ dominio ‖ hashStruct(mensaje)).
98
+ export function digestAutorizacion({ network, dominio, ...auth }) {
99
+ const dom = dominio || dominioDe(network);
100
+ return keccak256(concat(new Uint8Array([0x19, 0x01]), separadorDeDominio(dom), hashAutorizacion(auth)));
101
+ }
102
+
103
+ // ---- firmar ----
104
+ // Devuelve { authorization, signature, digest }, con `authorization` en el formato exacto que el
105
+ // esquema `exact` de x402 sobre EVM pone en `payload.authorization` (todo cadenas).
106
+ export function firmarAutorizacion({ network, from, to, value, validAfter, validBefore, nonce, privKey }) {
107
+ if (!privKey) throw new Error('x402 pagador: falta privKey');
108
+ const propia = direccionDe(privKey);
109
+ if (String(from).toLowerCase() !== propia.toLowerCase()) throw new Error(`x402 pagador: from (${from}) no es la dirección de la llave (${propia})`);
110
+ const authorization = {
111
+ from: propia, to: String(to),
112
+ value: entero(value, 'value'),
113
+ validAfter: entero(validAfter, 'validAfter'),
114
+ validBefore: entero(validBefore, 'validBefore'),
115
+ nonce: String(nonce).toLowerCase(),
116
+ };
117
+ if (BigInt(authorization.validBefore) <= BigInt(authorization.validAfter)) throw new Error('x402 pagador: validBefore debe ser mayor que validAfter');
118
+ const digest = digestAutorizacion({ network, ...authorization });
119
+ const { firmaHex } = firmar(digest, privKey);
120
+ return { authorization, signature: firmaHex, digest: '0x' + aHex(digest) };
121
+ }
122
+
123
+ // La contraparte de firmar: qué dirección produjo esta autorización en esta red. Es lo que hace
124
+ // un facilitador en `/verify`, y lo que usa la prueba para demostrar que la firma es de `from`.
125
+ export function recuperarPagador({ network, authorization, signature }) {
126
+ return recuperarDireccion(digestAutorizacion({ network, ...authorization }), signature);
127
+ }
128
+
129
+ // ---- elegir qué pagar ----
130
+ // De un PAYMENT-REQUIRED, la primera entrada `exact` en una red EVM que sabemos pagar (y que esté
131
+ // en `redes`, si se acotó). Se comprueba que el 402 describa el USDC REAL de esa red: `asset` y el
132
+ // dominio del token (`extra.name`/`extra.version`) tienen que coincidir con la tabla leída de los
133
+ // contratos. Un servidor que anuncie otro contrato u otro nombre no consigue una firma.
134
+ export function elegirRequisito(pr, { redes } = {}) {
135
+ if (pr?.x402Version !== X402_VERSION) throw new Error(`x402 pagador: x402Version ${pr?.x402Version}, se esperaba ${X402_VERSION}`);
136
+ if (!Array.isArray(pr.accepts) || !pr.accepts.length) throw new Error('x402 pagador: el 402 no trae accepts');
137
+ const permitidas = redes ? new Set(redes) : null;
138
+ const motivos = [];
139
+ for (const a of pr.accepts) {
140
+ if (a?.scheme !== 'exact') { motivos.push(`${a?.network}: scheme ${a?.scheme}`); continue; }
141
+ const t = tokenDe(a.network);
142
+ if (!t) { motivos.push(`${a.network}: red que no sé pagar`); continue; }
143
+ if (permitidas && !permitidas.has(a.network)) { motivos.push(`${a.network}: fuera de las redes permitidas`); continue; }
144
+ if (String(a.asset).toLowerCase() !== t.asset.toLowerCase()) { motivos.push(`${a.network}: asset ${a.asset} no es el USDC de esa red`); continue; }
145
+ const nombre = a.extra?.name, version = a.extra?.version;
146
+ if ((nombre != null && nombre !== t.name) || (version != null && String(version) !== t.version)) { motivos.push(`${a.network}: dominio del token ${nombre}/${version} no es ${t.name}/${t.version}`); continue; }
147
+ if (!DIRECCION.test(String(a.payTo))) { motivos.push(`${a.network}: payTo no es una dirección EVM`); continue; }
148
+ if (typeof a.amount !== 'string' || !ENTERO.test(a.amount)) { motivos.push(`${a.network}: amount no es una cadena de unidades atómicas`); continue; }
149
+ return a;
150
+ }
151
+ throw new Error(`x402 pagador: ninguna forma de pago sirve: ${motivos.join('; ') || 'accepts vacío'}`);
152
+ }
153
+
154
+ // ---- armar el PAYMENT-SIGNATURE ----
155
+ // `tope` es obligatorio: la decisión de cuánto se está dispuesto a pagar es del agente, no del 402.
156
+ export function armarPago({ requisito, privKey, tope, ahora = Math.floor(Date.now() / 1000), nonce }) {
157
+ if (tope == null) throw new Error('x402 pagador: tope (unidades atómicas) es obligatorio');
158
+ const limite = BigInt(entero(tope, 'tope'));
159
+ const monto = BigInt(entero(requisito?.amount, 'amount'));
160
+ if (monto > limite) throw new Error(`x402 pagador: el precio (${monto}) supera el tope (${limite}); no se firma`);
161
+ if (!tokenDe(requisito.network)) throw new Error(`x402 pagador: no sé pagar en ${requisito.network}`);
162
+ // La vigencia que pide el servidor sólo puede ACORTAR los 5 minutos: un `maxTimeoutSeconds`
163
+ // negativo, no numérico o fraccionario no se obedece (un negativo firmaba un cheque ya vencido).
164
+ const pedida = Number(requisito.maxTimeoutSeconds);
165
+ const vigencia = Number.isFinite(pedida) && pedida >= 1 ? Math.min(Math.floor(pedida), VIGENCIA_MAX_S) : VIGENCIA_MAX_S;
166
+ const from = direccionDe(privKey);
167
+ const { authorization, signature } = firmarAutorizacion({
168
+ network: requisito.network,
169
+ from, to: requisito.payTo, value: monto,
170
+ validAfter: Math.max(0, ahora - TOLERANCIA_RELOJ_S),
171
+ validBefore: ahora + vigencia,
172
+ nonce: nonce || '0x' + randomBytes(32).toString('hex'),
173
+ privKey,
174
+ });
175
+ // `accepted` viaja tal cual llegó: el servidor compara contra lo que publicó.
176
+ return { x402Version: X402_VERSION, accepted: requisito, payload: { signature, authorization } };
177
+ }
178
+
179
+ const b64 = (obj) => Buffer.from(JSON.stringify(obj), 'utf8').toString('base64');
180
+ // El mismo pago con la forma de la v1 de x402 (scheme y network en la raíz): lo que leen los servidores
181
+ // que sólo miran X-PAYMENT. `accepted` viaja igual: un lector v2 lo encuentra donde lo espera.
182
+ export const cabeceraV1 = (pago) => ({ x402Version: 1, scheme: pago.accepted.scheme, network: pago.accepted.network, payload: pago.payload, accepted: pago.accepted });
183
+ const deB64 = (s) => JSON.parse(Buffer.from(String(s), 'base64').toString('utf8'));
184
+
185
+ async function leerCuerpo(r) {
186
+ const txt = await r.text();
187
+ try { return JSON.parse(txt); } catch { return txt; }
188
+ }
189
+
190
+ // ---- pagar ----
191
+ // GET url → 402 con PAYMENT-REQUIRED → elegir → firmar → GET con PAYMENT-SIGNATURE → cuerpo y
192
+ // PAYMENT-RESPONSE. Si la primera respuesta no es 402, se devuelve tal cual sin firmar nada.
193
+ //
194
+ // Se firma UNA vez, contra el primer 402. Si el servidor contesta con otro 402 (otro `payTo`, otro
195
+ // precio, "sin fondos"), no se vuelve a firmar: se lanza. Y como la firma ya salió del proceso, es
196
+ // un cheque que el servidor puede cobrar hasta que venza aunque haya dicho que no: todo error que
197
+ // ocurra DESPUÉS de entregarla lleva `firmado: true`, `nonce`, `red`, `monto`, `destinatario` y
198
+ // `pagador`, para que quien paga lo anote y concilie. `alFirmar(entregado)` se llama ANTES de
199
+ // mandar la firma: es el sitio para escribir el registro antes del paso, no después.
200
+ export async function pagar({ url, privKey, tope, fetch: fetchImpl = globalThis.fetch, redes, method = 'GET', headers = {}, body, timeoutMs = 30_000, alFirmar = null }) {
201
+ if (!url) throw new Error('x402 pagador: falta url');
202
+ if (tope == null) throw new Error('x402 pagador: tope (unidades atómicas) es obligatorio');
203
+ const pedir = (extra = {}) => fetchImpl(url, { method, headers: { ...headers, ...extra }, body, signal: AbortSignal.timeout(timeoutMs) });
204
+
205
+ const primera = await pedir();
206
+ if (primera.status !== 402) return { pagado: false, status: primera.status, body: await leerCuerpo(primera) };
207
+
208
+ const cabecera = primera.headers.get('payment-required');
209
+ if (!cabecera) throw new Error('x402 pagador: 402 sin cabecera PAYMENT-REQUIRED');
210
+ let pr;
211
+ try { pr = deB64(cabecera); } catch { throw new Error('x402 pagador: PAYMENT-REQUIRED no es JSON en base64'); }
212
+ const requisito = elegirRequisito(pr, { redes });
213
+ const pago = armarPago({ requisito, privKey, tope });
214
+ const entregado = {
215
+ firmado: true,
216
+ red: requisito.network,
217
+ monto: requisito.amount,
218
+ destinatario: requisito.payTo,
219
+ pagador: pago.payload.authorization.from,
220
+ nonce: pago.payload.authorization.nonce,
221
+ validBefore: pago.payload.authorization.validBefore,
222
+ };
223
+ if (alFirmar) await alFirmar(entregado);
224
+
225
+ try {
226
+ // Dos cabeceras con el mismo cheque: PAYMENT-SIGNATURE (v2, con accepted) y X-PAYMENT con el cuerpo de
227
+ // la v1 (scheme y network arriba). Medido el 14-sep-2026 contra 402milly: un servidor que lee X-PAYMENT
228
+ // contesta el MISMO 402 sin motivo si sólo va la v2. Mandar las dos no cuesta nada y la firma es una.
229
+ const segunda = await pedir({ 'PAYMENT-SIGNATURE': b64(pago), 'X-PAYMENT': b64(cabeceraV1(pago)) });
230
+ const cuerpo = await leerCuerpo(segunda);
231
+ let liquidacion = null;
232
+ const respuesta = segunda.headers.get('payment-response');
233
+ if (respuesta) { try { liquidacion = deB64(respuesta); } catch { liquidacion = null; } }
234
+ if (segunda.status === 402) {
235
+ let motivo = '';
236
+ try { motivo = deB64(segunda.headers.get('payment-required') || '').error || ''; } catch { /* sin motivo */ }
237
+ // El motivo puede venir en el cuerpo JSON (details/error), no en la cabecera: 402milly lo hace así.
238
+ // El cuerpo con `details` manda: la cabecera suele repetir el genérico «Payment required».
239
+ const detalle = cuerpo && typeof cuerpo === 'object' ? [cuerpo.error, cuerpo.details].filter((x) => typeof x === 'string' && x).join(': ') : '';
240
+ if (detalle && (typeof cuerpo.details === 'string' || !motivo)) motivo = detalle;
241
+ throw new Error(`x402 pagador: el servidor rechazó el pago${motivo ? `: ${motivo}` : ''}`);
242
+ }
243
+ return { pagado: true, status: segunda.status, body: cuerpo, liquidacion, ...entregado };
244
+ } catch (e) {
245
+ const err = e instanceof Error ? e : new Error(String(e));
246
+ throw Object.assign(err, entregado);
247
+ }
248
+ }
package/src/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // La versión que el servidor declara de sí mismo. Un literal y no una lectura de package.json
2
2
  // porque este módulo también corre en el edge, donde no hay sistema de archivos. Que no se
3
3
  // desincronice lo cuida test/documentacion.test.js: si package.json cambia y esto no, falla.
4
- export const VERSION = '0.7.0';
4
+ export const VERSION = '0.8.0';