@panal/sdk 0.18.2 → 0.19.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.
@@ -40,6 +40,8 @@ import type { PermitDomain } from './x402.js';
40
40
  export type { PermitDomain };
41
41
  export declare const X402_VERSION = 1;
42
42
  export declare const X402_SERVER_SCHEME = "eip2612-permit";
43
+ /** El esquema de MON: quien paga manda una transferencia y la presenta. */
44
+ export declare const X402_NATIVE_SCHEME = "native-transfer";
43
45
  export declare function permitTypedData(domain: PermitDomain, message: {
44
46
  owner: Address;
45
47
  spender: Address;
@@ -76,12 +78,18 @@ export declare function permitTypedData(domain: PermitDomain, message: {
76
78
  };
77
79
  };
78
80
  /**
79
- * Lee el dominio EIP-712 del token en cadena (ERC-5267), con respaldo a
80
- * `name()` + version "1", que es lo que usan las implementaciones habituales.
81
+ * Lee el dominio EIP-712 del token en cadena.
81
82
  *
82
83
  * Se lee de la cadena en vez de escribirlo a mano porque un dominio mal
83
84
  * construido produce firmas que verifican en local y revierten al llegar al
84
85
  * contrato: el fallo aparecería solo en producción y con dinero de por medio.
86
+ *
87
+ * Y no basta con una sola fuente. ERC-5267 (`eip712Domain()`) lo tienen
88
+ * $PANAL y AUSD, pero no GHO ni USDC; y la versión no es siempre "1": USDC usa
89
+ * "2". Antes se suponía "1" si faltaba `eip712Domain()`, y TODAS las firmas de
90
+ * USDC salían inválidas. Ahora se reúnen los candidatos —ERC-5267, `name()`
91
+ * con `version()`, con "1" y con "2"— y se elige el que da exactamente el
92
+ * `DOMAIN_SEPARATOR()` del token. Si el token no lo expone, se usa el primero.
85
93
  */
86
94
  export declare function readPermitDomain(publicClient: PublicClient, token: Address, network?: PanalNetwork): Promise<PermitDomain>;
87
95
  /** Nonce de permit actual del pagador. Cambia con cada pago consumido. */
@@ -122,7 +130,7 @@ export declare function buildQuote(params: {
122
130
  nowS?: number;
123
131
  }): X402ServerQuote;
124
132
  export interface X402Payment {
125
- scheme: string;
133
+ scheme: typeof X402_SERVER_SCHEME;
126
134
  payer: Address;
127
135
  value: bigint;
128
136
  deadline: bigint;
@@ -169,3 +177,86 @@ export declare function enqueueByPayer<T>(payer: Address, fn: () => Promise<T>):
169
177
  export declare function verifyAndSettle(deps: SettleDeps, payment: X402Payment, price: bigint): Promise<SettleResult>;
170
178
  /** Identificador estable del recurso pagado, para trazas y recibos. */
171
179
  export declare function resourceId(method: string, path: string, body: string): Hex;
180
+ /** Un secreto nuevo para firmar cotizaciones. Uno por arranque basta. */
181
+ export declare function newQuoteSecret(): Hex;
182
+ export interface X402NativeAccept {
183
+ scheme: typeof X402_NATIVE_SCHEME;
184
+ network: string;
185
+ chainId: number;
186
+ asset: Address;
187
+ assetSymbol: 'MON';
188
+ /** El precio más unas unidades al azar: la transferencia tiene que ser de ESTO exactamente. */
189
+ amount: string;
190
+ payTo: Address;
191
+ resource: string;
192
+ description: string;
193
+ deadline: number;
194
+ maxTimeoutSeconds: number;
195
+ /** Secreto de esta cotización: se presenta con el pago, nunca va a la cadena. */
196
+ paymentId: Hex;
197
+ }
198
+ /**
199
+ * La cotización de una pregunta en MON.
200
+ *
201
+ * @param resource `resourceId()` de la petición: ata el pago a ESTA pregunta.
202
+ * @param payer Quien dijo que iba a pagar (cabecera X-Payment-Payer). Sin
203
+ * él, la cotización vale para cualquier pagador, y lo que la
204
+ * protege es el importe único y el secreto.
205
+ */
206
+ export declare function buildNativeQuote(params: {
207
+ secret: Hex;
208
+ price: bigint;
209
+ payTo: Address;
210
+ resource: Hex;
211
+ description: string;
212
+ payer?: Address | null;
213
+ network?: PanalNetwork;
214
+ nowS?: number;
215
+ }): {
216
+ x402Version: typeof X402_VERSION;
217
+ accepts: X402NativeAccept[];
218
+ hint: string;
219
+ };
220
+ export interface X402NativePayment {
221
+ scheme: typeof X402_NATIVE_SCHEME;
222
+ payer: Address;
223
+ value: bigint;
224
+ deadline: bigint;
225
+ paymentId: Hex;
226
+ txHash: Hex;
227
+ }
228
+ /**
229
+ * Lee X-Payment de los dos esquemas. Nunca lanza: devuelve el motivo.
230
+ *
231
+ * `parsePaymentHeader` sigue aceptando solo el permit, como antes, para que un
232
+ * agente que no sabe cobrar en MON no reciba un pago que no sabe comprobar.
233
+ */
234
+ export declare function parseX402Header(header: string): {
235
+ ok: true;
236
+ payment: X402Payment | X402NativePayment;
237
+ } | {
238
+ ok: false;
239
+ error: string;
240
+ };
241
+ /** Las transacciones ya cobradas. Basta en memoria: una cotización caduca a los 5 minutos. */
242
+ export interface NativeReplayGuard {
243
+ has(txHash: string): boolean;
244
+ add(txHash: string): void;
245
+ }
246
+ /**
247
+ * Comprueba en la cadena un pago en MON. El recurso NO debe servirse hasta
248
+ * que esto salga bien. No hace falta wallet: el pago ya lo mandó el cliente.
249
+ */
250
+ export declare function verifyNativePayment(deps: {
251
+ publicClient: PublicClient;
252
+ payee: Address;
253
+ secret: Hex;
254
+ used: NativeReplayGuard;
255
+ /** Para las pruebas. */
256
+ nowS?: number;
257
+ /** Cuánto esperar a que el nodo vea la transacción. */
258
+ waitMs?: number;
259
+ }, payment: X402NativePayment, params: {
260
+ price: bigint;
261
+ resource: Hex;
262
+ }): Promise<SettleResult>;
@@ -34,11 +34,13 @@
34
34
  *
35
35
  * Portado del bot de LexPanal, donde llevaba meses cobrando en producción.
36
36
  */
37
- import { getAddress, hexToNumber, isAddress, isHex, keccak256, slice, toHex, verifyTypedData, } from 'viem';
37
+ import { encodeAbiParameters, getAddress, hashDomain, hexToNumber, isAddress, isAddressEqual, isHex, keccak256, slice, toHex, verifyTypedData, } from 'viem';
38
38
  import { chainFor } from './chains.js';
39
39
  import { erc20Abi } from './abis.js';
40
40
  export const X402_VERSION = 1;
41
41
  export const X402_SERVER_SCHEME = 'eip2612-permit';
42
+ /** El esquema de MON: quien paga manda una transferencia y la presenta. */
43
+ export const X402_NATIVE_SCHEME = 'native-transfer';
42
44
  /** Margen mínimo de vigencia que se exige a la firma al llegar. */
43
45
  const MIN_DEADLINE_MARGIN_S = 30;
44
46
  /** Vigencia que se sugiere en el presupuesto. */
@@ -59,47 +61,81 @@ export function permitTypedData(domain, message) {
59
61
  return { domain, types: PERMIT_TYPES, primaryType: 'Permit', message };
60
62
  }
61
63
  /**
62
- * Lee el dominio EIP-712 del token en cadena (ERC-5267), con respaldo a
63
- * `name()` + version "1", que es lo que usan las implementaciones habituales.
64
+ * Lee el dominio EIP-712 del token en cadena.
64
65
  *
65
66
  * Se lee de la cadena en vez de escribirlo a mano porque un dominio mal
66
67
  * construido produce firmas que verifican en local y revierten al llegar al
67
68
  * contrato: el fallo aparecería solo en producción y con dinero de por medio.
69
+ *
70
+ * Y no basta con una sola fuente. ERC-5267 (`eip712Domain()`) lo tienen
71
+ * $PANAL y AUSD, pero no GHO ni USDC; y la versión no es siempre "1": USDC usa
72
+ * "2". Antes se suponía "1" si faltaba `eip712Domain()`, y TODAS las firmas de
73
+ * USDC salían inválidas. Ahora se reúnen los candidatos —ERC-5267, `name()`
74
+ * con `version()`, con "1" y con "2"— y se elige el que da exactamente el
75
+ * `DOMAIN_SEPARATOR()` del token. Si el token no lo expone, se usa el primero.
68
76
  */
69
77
  export async function readPermitDomain(publicClient, token, network = 'mainnet') {
70
- try {
71
- const d = (await publicClient.readContract({
72
- address: token,
73
- abi: [
74
- {
75
- type: 'function',
76
- name: 'eip712Domain',
77
- stateMutability: 'view',
78
- inputs: [],
79
- outputs: [
80
- { name: 'fields', type: 'bytes1' },
81
- { name: 'name', type: 'string' },
82
- { name: 'version', type: 'string' },
83
- { name: 'chainId', type: 'uint256' },
84
- { name: 'verifyingContract', type: 'address' },
85
- { name: 'salt', type: 'bytes32' },
86
- { name: 'extensions', type: 'uint256[]' },
87
- ],
88
- },
89
- ],
90
- functionName: 'eip712Domain',
91
- }));
92
- return { name: d[1], version: d[2], chainId: Number(d[3]), verifyingContract: getAddress(d[4]) };
78
+ const leer = (functionName) => publicClient
79
+ .readContract({ address: token, abi: dominioAbi, functionName: functionName })
80
+ .then((v) => v, () => null);
81
+ const [erc5267, nombre, version, separador] = await Promise.all([
82
+ leer('eip712Domain'),
83
+ leer('name'),
84
+ leer('version'),
85
+ leer('DOMAIN_SEPARATOR'),
86
+ ]);
87
+ const chainId = chainFor(network).id;
88
+ const verifyingContract = getAddress(token);
89
+ const candidatos = [];
90
+ if (erc5267) {
91
+ candidatos.push({ name: erc5267[1], version: erc5267[2], chainId: Number(erc5267[3]), verifyingContract: getAddress(erc5267[4]) });
93
92
  }
94
- catch {
95
- const name = (await publicClient.readContract({
96
- address: token,
97
- abi: tokenExtraAbi,
98
- functionName: 'name',
99
- }));
100
- return { name, version: '1', chainId: chainFor(network).id, verifyingContract: getAddress(token) };
93
+ if (nombre !== null) {
94
+ for (const v of [version, '1', '2']) {
95
+ if (v !== null)
96
+ candidatos.push({ name: nombre, version: v, chainId, verifyingContract });
97
+ }
98
+ }
99
+ if (!candidatos.length)
100
+ throw new Error(`no se pudo leer el dominio EIP-712 de ${token}`);
101
+ if (!separador)
102
+ return candidatos[0];
103
+ const bueno = candidatos.find((d) => hashDomain({
104
+ domain: { ...d, chainId: BigInt(d.chainId) },
105
+ types: {
106
+ EIP712Domain: [
107
+ { name: 'name', type: 'string' },
108
+ { name: 'version', type: 'string' },
109
+ { name: 'chainId', type: 'uint256' },
110
+ { name: 'verifyingContract', type: 'address' },
111
+ ],
112
+ },
113
+ }) === separador);
114
+ if (!bueno) {
115
+ throw new Error(`ningún dominio candidato de ${token} coincide con su DOMAIN_SEPARATOR: no se puede firmar con seguridad`);
101
116
  }
117
+ return bueno;
102
118
  }
119
+ const dominioAbi = [
120
+ {
121
+ type: 'function',
122
+ name: 'eip712Domain',
123
+ stateMutability: 'view',
124
+ inputs: [],
125
+ outputs: [
126
+ { name: 'fields', type: 'bytes1' },
127
+ { name: 'name', type: 'string' },
128
+ { name: 'version', type: 'string' },
129
+ { name: 'chainId', type: 'uint256' },
130
+ { name: 'verifyingContract', type: 'address' },
131
+ { name: 'salt', type: 'bytes32' },
132
+ { name: 'extensions', type: 'uint256[]' },
133
+ ],
134
+ },
135
+ { type: 'function', name: 'name', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'string' }] },
136
+ { type: 'function', name: 'version', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'string' }] },
137
+ { type: 'function', name: 'DOMAIN_SEPARATOR', stateMutability: 'view', inputs: [], outputs: [{ name: '', type: 'bytes32' }] },
138
+ ];
103
139
  /** Nonce de permit actual del pagador. Cambia con cada pago consumido. */
104
140
  export async function permitNonce(publicClient, token, owner) {
105
141
  return publicClient.readContract({
@@ -273,28 +309,34 @@ export async function verifyAndSettle(deps, payment, price) {
273
309
  const wallet = deps.walletClient;
274
310
  // permit + transferFrom. Se simula antes para no quemar gas en una
275
311
  // transacción condenada (firma consumida, deadline pasado, saldo movido).
276
- await deps.publicClient.simulateContract({
312
+ const permitCall = {
277
313
  address: deps.token,
278
314
  abi: permitAbi,
279
315
  functionName: 'permit',
280
316
  args: [payment.payer, deps.payee, payment.value, payment.deadline, v, r, s],
281
317
  account: wallet.account,
282
- });
318
+ };
319
+ await deps.publicClient.simulateContract(permitCall);
320
+ // Con el gas FIJADO: Monad cobra el límite entero, y si se deja a viem lo
321
+ // rellena con `eth_fillTransaction`, que el nodo a veces infla. Así se
322
+ // perdieron retiradas enteras; aquí sería el agente pagando de más en
323
+ // cada cobro.
283
324
  const permitTx = await wallet.writeContract({
284
- address: deps.token,
285
- abi: permitAbi,
286
- functionName: 'permit',
287
- args: [payment.payer, deps.payee, payment.value, payment.deadline, v, r, s],
288
- account: wallet.account,
325
+ ...permitCall,
326
+ gas: await gasDeCobro(deps.publicClient, permitCall),
289
327
  chain,
290
328
  });
291
329
  await deps.publicClient.waitForTransactionReceipt({ hash: permitTx });
292
- const transferTx = await wallet.writeContract({
330
+ const transferCall = {
293
331
  address: deps.token,
294
332
  abi: tokenExtraAbi,
295
333
  functionName: 'transferFrom',
296
334
  args: [payment.payer, deps.payee, payment.value],
297
335
  account: wallet.account,
336
+ };
337
+ const transferTx = await wallet.writeContract({
338
+ ...transferCall,
339
+ gas: await gasDeCobro(deps.publicClient, transferCall),
298
340
  chain,
299
341
  });
300
342
  const receipt = await deps.publicClient.waitForTransactionReceipt({ hash: transferTx });
@@ -304,6 +346,18 @@ export async function verifyAndSettle(deps, payment, price) {
304
346
  return { ok: true, txHash: transferTx, amount: payment.value };
305
347
  });
306
348
  }
349
+ /**
350
+ * El gas de un paso del cobro: `eth_estimateGas` + 10 %. Un permit o un
351
+ * transferFrom gastan unos 50.000–90.000; por encima de 300.000 algo va mal y
352
+ * no se manda, en vez de pagarlo entero.
353
+ */
354
+ async function gasDeCobro(publicClient, llamada) {
355
+ const estimado = await publicClient.estimateContractGas(llamada);
356
+ const gas = (estimado * 11n + 9n) / 10n;
357
+ if (gas > 300000n)
358
+ throw new Error(`el gas estimado para cobrar (${estimado}) es anormal: no se manda`);
359
+ return gas;
360
+ }
307
361
  /** Trozos del ERC-20 que no están en el `erc20Abi` del SDK. */
308
362
  const tokenExtraAbi = [
309
363
  {
@@ -346,3 +400,198 @@ const permitAbi = [
346
400
  export function resourceId(method, path, body) {
347
401
  return keccak256(toHex(`${method} ${path}\n${body}`));
348
402
  }
403
+ // ---------------------------------------------------------------------------
404
+ // MON: el esquema `native-transfer`.
405
+ //
406
+ // La moneda nativa no tiene `permit`, así que no se puede cobrar con una firma.
407
+ // Quien paga MANDA el MON al agente (paga él su gas: una fracción de céntimo
408
+ // en Monad) y repite la petición con el hash de esa transacción.
409
+ //
410
+ // Lo difícil es que una transferencia es pública: cualquiera la ve en la
411
+ // cadena en cuanto entra. Sin cuidado, otro podría presentarla como suya y
412
+ // llevarse la respuesta que pagó un tercero. Lo impiden dos cosas:
413
+ //
414
+ // 1. EL IMPORTE ES ÚNICO. La cotización pide el precio más unas pocas
415
+ // unidades al azar (menos de una millonésima de céntimo). La transferencia
416
+ // tiene que ser EXACTAMENTE de ese importe, así que solo vale para la
417
+ // cotización que lo pidió: quien pida otra recibe otro importe.
418
+ // 2. EL CÓDIGO DE PAGO ES SECRETO. La cotización lleva un `paymentId` que
419
+ // solo conoce quien la pidió (va por HTTPS, no por la cadena) y que ata
420
+ // pagador, agente, importe, caducidad y la pregunta exacta. Sin él, una
421
+ // transferencia vista en la cadena no se puede presentar.
422
+ //
423
+ // El `paymentId` es un keccak256 de un secreto del agente con esos datos: el
424
+ // agente no tiene que guardar las cotizaciones que da, solo rehacer la cuenta.
425
+ // Y cada transacción se acepta UNA vez.
426
+ // ---------------------------------------------------------------------------
427
+ /** Un secreto nuevo para firmar cotizaciones. Uno por arranque basta. */
428
+ export function newQuoteSecret() {
429
+ return toHex(crypto.getRandomValues(new Uint8Array(32)));
430
+ }
431
+ /** Margen tras la caducidad para que la transferencia llegue a minarse. */
432
+ const NATIVE_GRACE_S = 120;
433
+ function nativePaymentId(secret, q) {
434
+ return keccak256(encodeAbiParameters([
435
+ { type: 'bytes32' },
436
+ { type: 'address' },
437
+ { type: 'address' },
438
+ { type: 'uint256' },
439
+ { type: 'uint256' },
440
+ { type: 'bytes32' },
441
+ ], [secret, getAddress(q.payer), getAddress(q.payTo), q.amount, BigInt(q.deadline), q.resource]));
442
+ }
443
+ /**
444
+ * La cotización de una pregunta en MON.
445
+ *
446
+ * @param resource `resourceId()` de la petición: ata el pago a ESTA pregunta.
447
+ * @param payer Quien dijo que iba a pagar (cabecera X-Payment-Payer). Sin
448
+ * él, la cotización vale para cualquier pagador, y lo que la
449
+ * protege es el importe único y el secreto.
450
+ */
451
+ export function buildNativeQuote(params) {
452
+ const now = params.nowS ?? Math.floor(Date.now() / 1000);
453
+ const deadline = now + QUOTE_TTL_S;
454
+ // De 1 a 1.048.576 unidades (2²⁰): menos de una millonésima de céntimo, y
455
+ // basta para que dos cotizaciones no pidan el mismo importe.
456
+ //
457
+ // Con una máscara y no con `%`: el rango es una potencia de dos, así que
458
+ // cada valor sale con la misma probabilidad. Con `% 999999` unos salían un
459
+ // poco más que otros (lo marcó CodeQL); aquí no importaba, porque solo hace
460
+ // falta que no se repita ni se adivine, pero sin sesgo no hay que pensarlo.
461
+ const azar = BigInt(1 + (crypto.getRandomValues(new Uint32Array(1))[0] & 0xfffff));
462
+ const amount = params.price + azar;
463
+ const payer = params.payer ? getAddress(params.payer) : ZERO;
464
+ return {
465
+ x402Version: X402_VERSION,
466
+ accepts: [
467
+ {
468
+ scheme: X402_NATIVE_SCHEME,
469
+ network: 'monad',
470
+ chainId: chainFor(params.network ?? 'mainnet').id,
471
+ asset: ZERO,
472
+ assetSymbol: 'MON',
473
+ amount: amount.toString(),
474
+ payTo: getAddress(params.payTo),
475
+ resource: params.resource,
476
+ description: params.description,
477
+ deadline,
478
+ maxTimeoutSeconds: 120,
479
+ paymentId: nativePaymentId(params.secret, { payer, payTo: params.payTo, amount, deadline, resource: params.resource }),
480
+ },
481
+ ],
482
+ hint: 'Send EXACTLY `amount` MON to `payTo` before `deadline`, then repeat the request with the header ' +
483
+ 'X-Payment: base64({scheme:"native-transfer",payer,value,deadline,paymentId,txHash}).',
484
+ };
485
+ }
486
+ const ZERO = '0x0000000000000000000000000000000000000000';
487
+ /**
488
+ * Lee X-Payment de los dos esquemas. Nunca lanza: devuelve el motivo.
489
+ *
490
+ * `parsePaymentHeader` sigue aceptando solo el permit, como antes, para que un
491
+ * agente que no sabe cobrar en MON no reciba un pago que no sabe comprobar.
492
+ */
493
+ export function parseX402Header(header) {
494
+ const encoded = header.trim();
495
+ if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(encoded))
496
+ return { ok: false, error: 'the X-Payment header is not base64' };
497
+ let raw;
498
+ try {
499
+ raw = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
500
+ }
501
+ catch {
502
+ return { ok: false, error: 'the contents of X-Payment are not JSON' };
503
+ }
504
+ if (raw.scheme !== X402_NATIVE_SCHEME)
505
+ return parsePaymentHeader(header);
506
+ const { payer, value, deadline, paymentId, txHash } = raw;
507
+ if (typeof payer !== 'string' || !isAddress(payer))
508
+ return { ok: false, error: 'payer is not an address' };
509
+ const es32 = (x) => typeof x === 'string' && isHex(x) && x.length === 66;
510
+ if (!es32(paymentId))
511
+ return { ok: false, error: 'paymentId must be the 32-byte hex from the quote' };
512
+ if (!es32(txHash))
513
+ return { ok: false, error: 'txHash must be a 32-byte transaction hash' };
514
+ let valueBig;
515
+ let deadlineBig;
516
+ try {
517
+ valueBig = BigInt(String(value));
518
+ deadlineBig = BigInt(String(deadline));
519
+ }
520
+ catch {
521
+ return { ok: false, error: 'value and deadline must be integers' };
522
+ }
523
+ if (valueBig <= 0n)
524
+ return { ok: false, error: 'value must be greater than zero' };
525
+ return {
526
+ ok: true,
527
+ payment: {
528
+ scheme: X402_NATIVE_SCHEME,
529
+ payer: getAddress(payer),
530
+ value: valueBig,
531
+ deadline: deadlineBig,
532
+ paymentId: paymentId.toLowerCase(),
533
+ txHash: txHash.toLowerCase(),
534
+ },
535
+ };
536
+ }
537
+ /**
538
+ * Comprueba en la cadena un pago en MON. El recurso NO debe servirse hasta
539
+ * que esto salga bien. No hace falta wallet: el pago ya lo mandó el cliente.
540
+ */
541
+ export async function verifyNativePayment(deps, payment, params) {
542
+ if (payment.value < params.price) {
543
+ return { ok: false, status: 402, error: `the payment (${payment.value}) is less than the price (${params.price})` };
544
+ }
545
+ // El código de pago se rehace con lo que dice el pago. Si alguien cambia el
546
+ // importe, la caducidad o el pagador, o presenta una cotización de otra
547
+ // pregunta, no cuadra. Se prueba también sin pagador: la cotización se pudo
548
+ // pedir sin decir quién pagaría.
549
+ const datos = { payTo: deps.payee, amount: payment.value, deadline: Number(payment.deadline), resource: params.resource };
550
+ const esperado = [payment.payer, ZERO].map((payer) => nativePaymentId(deps.secret, { ...datos, payer }));
551
+ if (!esperado.includes(payment.paymentId.toLowerCase())) {
552
+ return { ok: false, status: 402, error: 'unknown or altered quote: ask for a new one and pay exactly what it says' };
553
+ }
554
+ const nowS = deps.nowS ?? Math.floor(Date.now() / 1000);
555
+ if (BigInt(nowS) > payment.deadline + BigInt(NATIVE_GRACE_S)) {
556
+ return { ok: false, status: 402, error: 'the quote has expired' };
557
+ }
558
+ if (deps.used.has(payment.txHash)) {
559
+ return { ok: false, status: 409, error: 'this payment was already used' };
560
+ }
561
+ // El nodo puede tardar un instante en ver una transacción recién minada.
562
+ const hasta = Date.now() + (deps.waitMs ?? 6_000);
563
+ let recibo = null;
564
+ for (;;) {
565
+ recibo = await deps.publicClient.getTransactionReceipt({ hash: payment.txHash }).catch(() => null);
566
+ if (recibo || Date.now() >= hasta)
567
+ break;
568
+ await new Promise((r) => setTimeout(r, 750));
569
+ }
570
+ if (!recibo)
571
+ return { ok: false, status: 402, error: 'the payment transaction is not on chain yet' };
572
+ if (recibo.status !== 'success')
573
+ return { ok: false, status: 402, error: 'the payment transaction reverted' };
574
+ const tx = await deps.publicClient.getTransaction({ hash: payment.txHash });
575
+ if (!isAddressEqual(tx.from, payment.payer)) {
576
+ return { ok: false, status: 402, error: 'the payment was sent by someone else' };
577
+ }
578
+ if (!tx.to || !isAddressEqual(tx.to, deps.payee)) {
579
+ return { ok: false, status: 402, error: 'the payment was not sent to this agent' };
580
+ }
581
+ if (tx.value !== payment.value) {
582
+ return { ok: false, status: 402, error: `the payment sent ${tx.value}, and the quote asked for exactly ${payment.value}` };
583
+ }
584
+ const bloque = await deps.publicClient.getBlock({ blockNumber: recibo.blockNumber });
585
+ if (bloque.timestamp > payment.deadline) {
586
+ return { ok: false, status: 402, error: 'the payment arrived after the quote expired' };
587
+ }
588
+ if (bloque.timestamp + BigInt(QUOTE_TTL_S + 60) < payment.deadline) {
589
+ return { ok: false, status: 402, error: 'the payment is older than the quote' };
590
+ }
591
+ // Se marca ANTES de servir: dos peticiones con la misma transacción a la
592
+ // vez no pueden pasar las dos.
593
+ if (deps.used.has(payment.txHash))
594
+ return { ok: false, status: 409, error: 'this payment was already used' };
595
+ deps.used.add(payment.txHash);
596
+ return { ok: true, txHash: payment.txHash, amount: tx.value };
597
+ }
package/dist/x402.d.ts CHANGED
@@ -18,7 +18,8 @@
18
18
  import type { Account, Address, Hex, WalletClient } from 'viem';
19
19
  import { type UrlGuardOptions } from './net.js';
20
20
  import { type CallEnvelope } from './envelope.js';
21
- /** El único esquema que entiende este cliente. Debe coincidir con el servidor. */
21
+ import { type X402Currency } from './currencies.js';
22
+ /** El esquema de las monedas con `permit`. Debe coincidir con el servidor. */
22
23
  export declare const X402_SCHEME = "eip2612-permit";
23
24
  export interface PermitDomain {
24
25
  name: string;
@@ -40,7 +41,10 @@ export interface X402Accept {
40
41
  deadline: number;
41
42
  maxTimeoutSeconds?: number;
42
43
  payerNonce?: string;
43
- domain: PermitDomain;
44
+ /** Solo en las de `permit`: el dominio con el que firmar. */
45
+ domain?: PermitDomain;
46
+ /** Solo en MON: el código de esta cotización, que se presenta con el pago. */
47
+ paymentId?: Hex;
44
48
  }
45
49
  export interface X402Quote {
46
50
  x402Version?: number;
@@ -88,6 +92,8 @@ export interface AskResult {
88
92
  answer: string;
89
93
  /** Lo que se ha pagado de verdad, en unidades mínimas. */
90
94
  paid: bigint;
95
+ /** En qué, con su nombre y decimales de la lista de Panal (no de la cotización). */
96
+ currency: X402Currency;
91
97
  /** Quién ha cobrado. */
92
98
  payee: Address;
93
99
  /** Transacción del cobro, si el servidor la reporta. */