@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,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
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Nyx5/1 — Adaptador x402 (transporte HTTP v2).
|
|
2
|
+
//
|
|
3
|
+
// x402 es el estándar de "402 Payment Required" para agentes: el servidor responde 402 con lo que
|
|
4
|
+
// cuesta el recurso, el cliente vuelve con la prueba de pago, el servidor responde 200 con el
|
|
5
|
+
// recibo de liquidación. Todo el protocolo viaja en CABECERAS, en base64; el cuerpo es asunto de
|
|
6
|
+
// cada implementación.
|
|
7
|
+
//
|
|
8
|
+
// Qué hace este módulo y qué NO hace:
|
|
9
|
+
// SÍ habla el cable de x402 v2: arma `PAYMENT-REQUIRED`, lee `PAYMENT-SIGNATURE`, arma
|
|
10
|
+
// `PAYMENT-RESPONSE`, y declara capacidades en /x402/supported.
|
|
11
|
+
// NO inventa dinero nuevo. El pago sigue ocurriendo en el Libro, con un sobre firmado, como
|
|
12
|
+
// siempre. x402 es el ANUNCIO del precio y el RECIBO de la liquidación, no una vía paralela
|
|
13
|
+
// para mover tokens sin firma (eso rompería el invariante 2).
|
|
14
|
+
//
|
|
15
|
+
// Por qué `nyx5:1` y no `nyx5:<casa>`: CAIP-2 exige `namespace:reference` con
|
|
16
|
+
// namespace [-a-z0-9]{3,8} y reference [-_a-zA-Z0-9]{1,32}. Un dominio lleva puntos, y el punto
|
|
17
|
+
// NO es legal en una reference. Así que la red es el protocolo y la casa viaja en `payTo`, que ya
|
|
18
|
+
// es `agente@dominio`. Poner `nyx5:nyx5-com` sería una reference inventada que nadie sabe deshacer.
|
|
19
|
+
//
|
|
20
|
+
// Precedente de que esto es conforme sin blockchain: `cloudflare:402` en el repo oficial de x402
|
|
21
|
+
// liquida en fiat, firma con Ed25519, no emite hash de transacción y no usa facilitador externo.
|
|
22
|
+
// Ver docs/interop/x402.md, que dice también qué NO reclamamos.
|
|
23
|
+
|
|
24
|
+
export const X402_VERSION = 2;
|
|
25
|
+
export const RED = 'nyx5:1';
|
|
26
|
+
export const ACTIVO = 'NYX'; // el token de la casa: ni contrato ni ISO-4217, y se dice
|
|
27
|
+
export const CAIP2 = /^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}$/;
|
|
28
|
+
|
|
29
|
+
const b64 = (obj) => Buffer.from(JSON.stringify(obj), 'utf8').toString('base64');
|
|
30
|
+
const deB64 = (s) => JSON.parse(Buffer.from(String(s), 'base64').toString('utf8'));
|
|
31
|
+
|
|
32
|
+
// El importe va como CADENA de unidades atómicas. Un number aquí es un defecto: 2^53 y los
|
|
33
|
+
// decimales de coma flotante no son opinables cuando lo que se transporta es plata.
|
|
34
|
+
const monto = (n) => {
|
|
35
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(`x402: amount must be a non-negative integer, got ${n}`);
|
|
36
|
+
return String(n);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ---- lo que el servidor anuncia: 402 + PAYMENT-REQUIRED ----
|
|
40
|
+
export function requisitos({ url, amount, payTo, description, mimeType = 'application/json', flow = 'authorization', method = 'nyx5-envelope', error, serviceName, tags }) {
|
|
41
|
+
if (!url) throw new Error('x402: resource.url is required');
|
|
42
|
+
if (!payTo) throw new Error('x402: payTo is required');
|
|
43
|
+
const pr = {
|
|
44
|
+
x402Version: X402_VERSION,
|
|
45
|
+
error: error || 'PAYMENT-SIGNATURE header is required',
|
|
46
|
+
resource: { url, mimeType, ...(description ? { description } : {}), ...(serviceName ? { serviceName: serviceName.slice(0, 32) } : {}), ...(tags?.length ? { tags: tags.slice(0, 5).map((t) => String(t).slice(0, 32)) } : {}) },
|
|
47
|
+
accepts: [{
|
|
48
|
+
scheme: 'exact',
|
|
49
|
+
network: RED,
|
|
50
|
+
amount: monto(amount),
|
|
51
|
+
asset: ACTIVO,
|
|
52
|
+
payTo,
|
|
53
|
+
maxTimeoutSeconds: 60,
|
|
54
|
+
// `paymentFlow` y `assetTransferMethod` son claves RESERVADAS por el protocolo dentro de
|
|
55
|
+
// `extra`; el resto de `extra` es del scheme. No inventar ahí.
|
|
56
|
+
extra: { paymentFlow: flow, assetTransferMethod: method, house: payTo.split('@')[1] || null },
|
|
57
|
+
}],
|
|
58
|
+
extensions: {},
|
|
59
|
+
};
|
|
60
|
+
validarRequisitos(pr);
|
|
61
|
+
return pr;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Falla cerrado: si el objeto que vamos a publicar no cumple las reglas duras del spec, mejor
|
|
65
|
+
// romper aquí que emitir un 402 que un cliente conforme rechaza sin decir por qué.
|
|
66
|
+
export function validarRequisitos(pr) {
|
|
67
|
+
if (pr.x402Version !== X402_VERSION) throw new Error(`x402: x402Version must be ${X402_VERSION}`);
|
|
68
|
+
if (!pr.resource?.url) throw new Error('x402: resource.url is required');
|
|
69
|
+
if (!Array.isArray(pr.accepts) || !pr.accepts.length) throw new Error('x402: accepts must be a non-empty array');
|
|
70
|
+
for (const a of pr.accepts) {
|
|
71
|
+
for (const campo of ['scheme', 'network', 'amount', 'asset', 'payTo']) if (!a[campo]) throw new Error(`x402: accepts[].${campo} is required`);
|
|
72
|
+
if (typeof a.amount !== 'string' || !/^\d+$/.test(a.amount)) throw new Error('x402: amount must be a string of atomic units');
|
|
73
|
+
if (!CAIP2.test(a.network)) throw new Error(`x402: network "${a.network}" is not a CAIP-2 identifier`);
|
|
74
|
+
if (!Number.isFinite(a.maxTimeoutSeconds)) throw new Error('x402: maxTimeoutSeconds is required');
|
|
75
|
+
}
|
|
76
|
+
return pr;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const cabeceraRequerido = (pr) => b64(validarRequisitos(pr));
|
|
80
|
+
|
|
81
|
+
// ---- lo que el cliente manda: PAYMENT-SIGNATURE ----
|
|
82
|
+
// Devuelve { ok, payload } o { ok:false, code, reason }. 400 (no 402) cuando el sobre x402 está
|
|
83
|
+
// malformado: el spec separa "no pagaste" de "lo que mandaste no se entiende".
|
|
84
|
+
export function leerPago(cabecera) {
|
|
85
|
+
if (!cabecera) return { ok: false, code: 402, reason: 'PAYMENT-SIGNATURE header is required' };
|
|
86
|
+
let p;
|
|
87
|
+
try { p = deB64(cabecera); } catch { return { ok: false, code: 400, reason: 'PAYMENT-SIGNATURE is not base64-encoded JSON' }; }
|
|
88
|
+
if (p?.x402Version !== X402_VERSION) return { ok: false, code: 400, reason: `unsupported x402Version (this house speaks ${X402_VERSION})` };
|
|
89
|
+
if (!p.accepted || typeof p.accepted !== 'object') return { ok: false, code: 400, reason: 'accepted (the chosen PaymentRequirements) is required' };
|
|
90
|
+
if (!p.payload || typeof p.payload !== 'object') return { ok: false, code: 400, reason: 'payload is required' };
|
|
91
|
+
if (p.accepted.network !== RED) return { ok: false, code: 400, reason: `this house settles on ${RED}, not ${p.accepted.network}` };
|
|
92
|
+
return { ok: true, payload: p };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- lo que el servidor devuelve al liquidar: PAYMENT-RESPONSE ----
|
|
96
|
+
// `transaction` y `network` son OBLIGATORIOS aunque no haya cadena: cuando no hay hash, va cadena
|
|
97
|
+
// vacía. Aquí sí hay algo mejor que una cadena vacía — el id del asiento del Libro, que cualquiera
|
|
98
|
+
// puede pedir. Es la pieza que un explorador on-chain resuelve por hash.
|
|
99
|
+
export function liquidacion({ success = true, transaction = '', payer, amount, errorReason }) {
|
|
100
|
+
const s = { success: !!success, transaction: String(transaction || ''), network: RED };
|
|
101
|
+
if (payer) s.payer = payer;
|
|
102
|
+
if (amount != null) s.amount = monto(amount);
|
|
103
|
+
if (errorReason) s.errorReason = String(errorReason);
|
|
104
|
+
return s;
|
|
105
|
+
}
|
|
106
|
+
export const cabeceraLiquidacion = (s) => b64(s);
|
|
107
|
+
|
|
108
|
+
// ---- declaración de capacidades (el `GET /supported` del facilitador, auto-alojado) ----
|
|
109
|
+
// La casa es su propio facilitador: verifica y liquida contra su propio Libro. El spec lo permite
|
|
110
|
+
// explícitamente ("or host the endpoints themselves").
|
|
111
|
+
export function soportado(dominio, clavesCasa = []) {
|
|
112
|
+
return {
|
|
113
|
+
x402Version: X402_VERSION,
|
|
114
|
+
kinds: [{ x402Version: X402_VERSION, scheme: 'exact', network: RED, extra: { asset: ACTIVO, house: dominio, assetTransferMethod: 'nyx5-envelope' } }],
|
|
115
|
+
extensions: [],
|
|
116
|
+
signers: { [RED]: clavesCasa },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------- red real: USDC sobre una cadena EVM ----------
|
|
121
|
+
// La casa NO custodia nada. En el esquema `exact` el pagador firma una autorización EIP-3009 y el
|
|
122
|
+
// facilitador la difunde: el dinero va del pagador al que cobra, directo, y el facilitador ni
|
|
123
|
+
// siquiera puede cambiar el monto ni el destino. Nosotros sólo anunciamos el precio, preguntamos
|
|
124
|
+
// si la firma sirve, y pedimos que se liquide. Cero criptografía de cadena de nuestro lado.
|
|
125
|
+
//
|
|
126
|
+
// Por eso esto cabe en un módulo sin dependencias: todo lo que hace falta es base64, JSON y fetch.
|
|
127
|
+
export function requisitosEvm({ url, amount, payTo, network, asset, tokenName, tokenVersion, description, flow = 'authorization' }) {
|
|
128
|
+
for (const [k, v] of Object.entries({ url, payTo, network, asset, tokenName, tokenVersion })) {
|
|
129
|
+
if (!v) throw new Error(`x402 evm: falta ${k}`);
|
|
130
|
+
}
|
|
131
|
+
const pr = {
|
|
132
|
+
x402Version: X402_VERSION,
|
|
133
|
+
error: 'PAYMENT-SIGNATURE header is required',
|
|
134
|
+
resource: { url, mimeType: 'application/json', ...(description ? { description } : {}) },
|
|
135
|
+
accepts: [{
|
|
136
|
+
scheme: 'exact', network, amount: monto(amount), asset, payTo, maxTimeoutSeconds: 60,
|
|
137
|
+
// `name` y `version` son el dominio EIP-712 del CONTRATO del token, y cambian entre redes:
|
|
138
|
+
// en Base Sepolia el USDC de Circle se llama "USDC" y en Base mainnet "USD Coin". Ponerlo
|
|
139
|
+
// mal hace que la firma del pagador no valide, y el error no dice por qué.
|
|
140
|
+
extra: { paymentFlow: flow, assetTransferMethod: 'eip3009', name: tokenName, version: tokenVersion },
|
|
141
|
+
}],
|
|
142
|
+
extensions: {},
|
|
143
|
+
};
|
|
144
|
+
validarRequisitos(pr);
|
|
145
|
+
return pr;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Cliente del facilitador. Es todo el "código de cadena" que necesita un servidor de recurso.
|
|
149
|
+
export function facilitador(base, { fetchImpl = globalThis.fetch, timeoutMs = 20_000 } = {}) {
|
|
150
|
+
const raiz = String(base).replace(/\/$/, '');
|
|
151
|
+
const llamar = async (ruta, cuerpo) => {
|
|
152
|
+
const r = await fetchImpl(`${raiz}${ruta}`, {
|
|
153
|
+
method: cuerpo ? 'POST' : 'GET',
|
|
154
|
+
headers: cuerpo ? { 'content-type': 'application/json' } : {},
|
|
155
|
+
body: cuerpo ? JSON.stringify(cuerpo) : undefined,
|
|
156
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
157
|
+
});
|
|
158
|
+
const txt = await r.text();
|
|
159
|
+
let json; try { json = JSON.parse(txt); } catch { json = { _raw: txt.slice(0, 300) }; }
|
|
160
|
+
return { status: r.status, body: json };
|
|
161
|
+
};
|
|
162
|
+
const sobre = (paymentPayload, paymentRequirements) => ({ x402Version: X402_VERSION, paymentPayload, paymentRequirements });
|
|
163
|
+
return {
|
|
164
|
+
soportado: () => llamar('/supported'),
|
|
165
|
+
// `verify` es SOLO LECTURA por el spec: un isValid:true NO es dinero. Entre verificar y
|
|
166
|
+
// liquidar el saldo puede gastarse. Se usa para rechazar temprano, nunca como garantía.
|
|
167
|
+
verificar: (pago, req) => llamar('/verify', sobre(pago, req)),
|
|
168
|
+
liquidar: (pago, req) => llamar('/settle', sobre(pago, req)),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Clave de deduplicación de un pago. El servidor de referencia de x402 NO deduplica: dos
|
|
173
|
+
// peticiones simultáneas con la MISMA firma pasan las dos por `verify` (que es de solo lectura),
|
|
174
|
+
// ejecutan las dos el trabajo, y sólo una liquida. O sea, entregas dos veces y cobras una.
|
|
175
|
+
// Aquí ya tenemos el candado que hace falta (`markSeenIfNew`, invariante 4); esto sólo nombra la
|
|
176
|
+
// clave. La red pone el nonce justamente para esto: es único por autorización.
|
|
177
|
+
export function claveDePago(pago) {
|
|
178
|
+
const a = pago?.payload?.authorization;
|
|
179
|
+
const red = pago?.accepted?.network;
|
|
180
|
+
if (!red) return null;
|
|
181
|
+
if (a?.nonce) return `x402:${red}:${String(a.nonce).toLowerCase()}`;
|
|
182
|
+
// Sin nonce no hay antirreplay posible: mejor decirlo que fingir que se dedujo algo.
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Dónde vive el dólar digital en cada red. Los valores de `name`/`version` son el dominio EIP-712
|
|
187
|
+
// del CONTRATO y NO se recuerdan: están leídos del contrato (`name()` y `version()`), porque
|
|
188
|
+
// cambian entre redes y equivocarse hace que ninguna firma valide sin decir por qué.
|
|
189
|
+
// Comprobado el 9-sep-2026 contra ambas cadenas.
|
|
190
|
+
export const TOKEN_USD = Object.freeze({
|
|
191
|
+
'eip155:1': { asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', name: 'USD Coin', version: '2', decimals: 6, red: 'Ethereum', facilitador: 'https://facilitator.ultravioletadao.xyz' },
|
|
192
|
+
'eip155:8453': { asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', name: 'USD Coin', version: '2', decimals: 6, red: 'Base', facilitador: 'https://x402.dexter.cash' },
|
|
193
|
+
'eip155:137': { asset: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', name: 'USD Coin', version: '2', decimals: 6, red: 'Polygon', facilitador: 'https://x402.dexter.cash' },
|
|
194
|
+
'eip155:42161': { asset: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', name: 'USD Coin', version: '2', decimals: 6, red: 'Arbitrum', facilitador: 'https://x402.dexter.cash' },
|
|
195
|
+
'eip155:10': { asset: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', name: 'USD Coin', version: '2', decimals: 6, red: 'Optimism', facilitador: 'https://x402.dexter.cash' },
|
|
196
|
+
'eip155:43114': { asset: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E', name: 'USD Coin', version: '2', decimals: 6, red: 'Avalanche', facilitador: 'https://x402.dexter.cash' },
|
|
197
|
+
'eip155:84532': { asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', name: 'USDC', version: '2', decimals: 6, red: 'Base Sepolia', facilitador: 'https://x402.org/facilitator' },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Una billetera declarada en una tarjeta. Es sólo una dirección a la que cobrar: la casa no la
|
|
201
|
+
// controla, no la custodia y no puede mover nada de ella. Falla cerrado ante una red que no
|
|
202
|
+
// conocemos, porque anunciar un precio en una red que no sabemos liquidar es prometer de gratis.
|
|
203
|
+
// Varias billeteras: una por red. El estándar dice que `accepts` es una LISTA de formas de pago
|
|
204
|
+
// aceptables y que el cliente elige, así que ofrecer una sola red es desperdiciar el mecanismo.
|
|
205
|
+
// Se rechazan los duplicados de red: dos precios para la misma red es una ambigüedad, no una opción.
|
|
206
|
+
export function validarBilleteras(ws) {
|
|
207
|
+
if (ws == null) return null;
|
|
208
|
+
const lista = Array.isArray(ws) ? ws : [ws];
|
|
209
|
+
if (!lista.length) return null;
|
|
210
|
+
if (lista.length > 8) throw new Error('at most 8 wallets');
|
|
211
|
+
const vistas = new Set(), out = [];
|
|
212
|
+
for (const w of lista) {
|
|
213
|
+
const v = validarBilletera(w);
|
|
214
|
+
if (!v) continue;
|
|
215
|
+
if (vistas.has(v.network)) throw new Error(`two wallets for the same network (${v.network}); pick one`);
|
|
216
|
+
vistas.add(v.network); out.push(v);
|
|
217
|
+
}
|
|
218
|
+
return out.length ? out : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function validarBilletera(w) {
|
|
222
|
+
if (w == null) return null;
|
|
223
|
+
if (typeof w !== 'object' || Array.isArray(w)) throw new Error('wallet must be an object');
|
|
224
|
+
const { network, address } = w;
|
|
225
|
+
if (!TOKEN_USD[network]) throw new Error(`this house cannot settle on ${network}; it knows: ${Object.keys(TOKEN_USD).join(', ')}`);
|
|
226
|
+
if (typeof address !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(address)) throw new Error('wallet.address must be a 0x EVM address');
|
|
227
|
+
return { network, address };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------- extensión `bazaar`: quedar catalogado donde un agente busca ----------
|
|
231
|
+
// Un agente no navega: consulta directorios. La extensión `bazaar` deja que un servidor describa
|
|
232
|
+
// cómo se le llama, y el facilitador lo cataloga y lo expone en su `/discovery/resources`.
|
|
233
|
+
//
|
|
234
|
+
// Lo que se declara tiene que ser lo que de verdad se vende. Aquí es la entrega de un sobre en un
|
|
235
|
+
// buzón que cobra: se paga, el sobre entra. Describir algo más grande de lo que hacemos sería
|
|
236
|
+
// exactamente la afirmación gratis que este protocolo existe para encarecer.
|
|
237
|
+
export function bazaarBuzon({ url, direccion }) {
|
|
238
|
+
return {
|
|
239
|
+
info: {
|
|
240
|
+
input: {
|
|
241
|
+
type: 'http', method: 'POST', bodyType: 'json',
|
|
242
|
+
// El sobre va firmado; el cuerpo es el sobre Nyx5 tal cual.
|
|
243
|
+
body: { nyx5: '1', to: [direccion], type: 'message', content: { media: 'text/plain', body: '…' } },
|
|
244
|
+
},
|
|
245
|
+
output: { type: 'json', example: { ok: true, code: 202, accepted: [direccion] } },
|
|
246
|
+
},
|
|
247
|
+
schema: {
|
|
248
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
249
|
+
type: 'object',
|
|
250
|
+
properties: {
|
|
251
|
+
input: {
|
|
252
|
+
type: 'object',
|
|
253
|
+
properties: {
|
|
254
|
+
type: { type: 'string', const: 'http' },
|
|
255
|
+
method: { type: 'string', const: 'POST' },
|
|
256
|
+
bodyType: { type: 'string', const: 'json' },
|
|
257
|
+
body: { type: 'object' },
|
|
258
|
+
},
|
|
259
|
+
required: ['type', 'method', 'body'],
|
|
260
|
+
},
|
|
261
|
+
output: { type: 'object' },
|
|
262
|
+
},
|
|
263
|
+
required: ['input', 'output'],
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
package/src/version.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// La versión que el servidor declara de sí mismo. Un literal y no una lectura de package.json
|
|
2
|
+
// porque este módulo también corre en el edge, donde no hay sistema de archivos. Que no se
|
|
3
|
+
// desincronice lo cuida test/documentacion.test.js: si package.json cambia y esto no, falla.
|
|
4
|
+
export const VERSION = '0.8.0';
|