@panal/sdk 0.18.3 → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Panal and Kreno (founder: Gustavo Chura C.)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/client.d.ts CHANGED
@@ -152,8 +152,10 @@ export declare class PanalClient {
152
152
  * Hace falta porque viem no estima: le pide al nodo que rellene la
153
153
  * transacción (`eth_fillTransaction`), el de Monad a veces infla el límite, y
154
154
  * Monad cobra el límite ENTERO. Cinco retiradas de MON perdieron lo retirado
155
- * así entre julio y septiembre. Se pasa explícito a todo lo que firma una
156
- * cuenta local en nombre de un programa que nadie está mirando.
155
+ * así entre julio y septiembre. Se pasa explícito a TODO lo que firma una
156
+ * cuenta local en nombre de un programa que nadie está mirando: contratar,
157
+ * aprobar, entregar, disputar, darse de alta. Antes solo la retirada y el
158
+ * tablón, y un agente entrega en cada encargo.
157
159
  */
158
160
  private gasFijo;
159
161
  /**
@@ -445,6 +447,13 @@ export declare class PanalClient {
445
447
  * cuesta ni una petición.
446
448
  */
447
449
  skillsPermitidas?: string[];
450
+ /**
451
+ * En qué moneda se paga, y por tanto en qué unidades va `maxSpend`.
452
+ * Por defecto $PANAL. Solo se cotiza con quien cobre en ESTA: comparar
453
+ * precios de monedas distintas era comparar 1 USDC con 1 wei de $PANAL,
454
+ * y un tope en una moneda no dice nada de otra.
455
+ */
456
+ asset?: Address;
448
457
  }): Promise<AskResult & {
449
458
  agent: Address;
450
459
  skill: string;
package/dist/client.js CHANGED
@@ -29,6 +29,13 @@ import { TaskStatus, formatAgentMetadata, parseAgentMetadata, } from './types.js
29
29
  * $PANAL 103.511. Ver `withdraw()` para lo que pasó sin este tope.
30
30
  */
31
31
  const TOPE_GAS_RETIRADA = 300000n;
32
+ /**
33
+ * El tope para dar de alta o cambiar la ficha. Guardar texto en la cadena
34
+ * cuesta por cada 32 bytes, y una ficha con el logo dentro es grande: aquí el
35
+ * tope solo está para cortar un disparate, no para medir lo normal. Lo normal
36
+ * lo da `eth_estimateGas`, que es fiable; el que inflaba era `eth_fillTransaction`.
37
+ */
38
+ const TOPE_GAS_FICHA = 30000000n;
32
39
  /** Cuántos agentes se leen por llamada al registry. */
33
40
  const REGISTRY_PAGE = 50n;
34
41
  /** Tope duro de agentes recorridos, por si el registro crece mucho. */
@@ -174,13 +181,15 @@ export class PanalClient {
174
181
  * Hace falta porque viem no estima: le pide al nodo que rellene la
175
182
  * transacción (`eth_fillTransaction`), el de Monad a veces infla el límite, y
176
183
  * Monad cobra el límite ENTERO. Cinco retiradas de MON perdieron lo retirado
177
- * así entre julio y septiembre. Se pasa explícito a todo lo que firma una
178
- * cuenta local en nombre de un programa que nadie está mirando.
184
+ * así entre julio y septiembre. Se pasa explícito a TODO lo que firma una
185
+ * cuenta local en nombre de un programa que nadie está mirando: contratar,
186
+ * aprobar, entregar, disputar, darse de alta. Antes solo la retirada y el
187
+ * tablón, y un agente entrega en cada encargo.
179
188
  */
180
- async gasFijo(que, llamada) {
189
+ async gasFijo(que, llamada, tope = TOPE_GAS_RETIRADA) {
181
190
  const estimado = await this.publicClient.estimateContractGas({ ...llamada, account: this.account });
182
191
  const gas = (estimado * 11n + 9n) / 10n;
183
- if (gas > TOPE_GAS_RETIRADA) {
192
+ if (gas > tope) {
184
193
  throw new Error(`${que}: la estimación de gas salió en ${estimado}, muy por encima de lo normal. ` +
185
194
  'Monad cobra el límite entero: no se ha enviado nada.');
186
195
  }
@@ -475,22 +484,19 @@ export class PanalClient {
475
484
  if (!isNative) {
476
485
  // approve por el importe exacto, no infinito: si el escrow tuviera un
477
486
  // fallo, la exposición se limita a este encargo.
487
+ const aFirmar = { address: agent.currency, abi: erc20Abi, functionName: 'approve', args: [this.addresses.escrow, amount] };
478
488
  const approveHash = await wallet.writeContract({
479
- address: agent.currency,
480
- abi: erc20Abi,
481
- functionName: 'approve',
482
- args: [this.addresses.escrow, amount],
489
+ ...aFirmar,
490
+ gas: await this.gasFijo('El approve', aFirmar),
483
491
  chain: chainFor(this.network),
484
492
  account: this.account,
485
493
  });
486
494
  await this.publicClient.waitForTransactionReceipt({ hash: approveHash });
487
495
  }
496
+ const aFirmar = { address: this.addresses.escrow, abi: escrowAbi, functionName: 'createTask', args: [agent.address, taskHash, deadline, agent.currency, amount], value: isNative ? amount : 0n };
488
497
  const txHash = await wallet.writeContract({
489
- address: this.addresses.escrow,
490
- abi: escrowAbi,
491
- functionName: 'createTask',
492
- args: [agent.address, taskHash, deadline, agent.currency, amount],
493
- value: isNative ? amount : 0n,
498
+ ...aFirmar,
499
+ gas: await this.gasFijo('El encargo', aFirmar),
494
500
  chain: chainFor(this.network),
495
501
  account: this.account,
496
502
  });
@@ -517,11 +523,10 @@ export class PanalClient {
517
523
  if (task.status !== TaskStatus.Delivered) {
518
524
  throw new Error(`La tarea #${taskId} está "${TaskStatus[task.status]}": solo se aprueba lo entregado.`);
519
525
  }
526
+ const aFirmar = { address: this.addresses.escrow, abi: escrowAbi, functionName: 'approveAndRelease', args: [taskId, rating] };
520
527
  const hash = await wallet.writeContract({
521
- address: this.addresses.escrow,
522
- abi: escrowAbi,
523
- functionName: 'approveAndRelease',
524
- args: [taskId, rating],
528
+ ...aFirmar,
529
+ gas: await this.gasFijo('La aprobación', aFirmar),
525
530
  chain: chainFor(this.network),
526
531
  account: this.account,
527
532
  });
@@ -550,11 +555,10 @@ export class PanalClient {
550
555
  if (task.status !== TaskStatus.Open) {
551
556
  throw new Error(`La tarea #${taskId} está "${TaskStatus[task.status]}": solo se cancela lo que sigue abierto.`);
552
557
  }
558
+ const aFirmar = { address: this.addresses.escrow, abi: escrowAbi, functionName: 'cancelTask', args: [taskId] };
553
559
  const hash = await wallet.writeContract({
554
- address: this.addresses.escrow,
555
- abi: escrowAbi,
556
- functionName: 'cancelTask',
557
- args: [taskId],
560
+ ...aFirmar,
561
+ gas: await this.gasFijo('La cancelación', aFirmar),
558
562
  chain: chainFor(this.network),
559
563
  account: this.account,
560
564
  });
@@ -578,11 +582,10 @@ export class PanalClient {
578
582
  if (task.status !== TaskStatus.Delivered) {
579
583
  throw new Error(`La tarea #${taskId} está "${TaskStatus[task.status]}": solo se disputa lo entregado.`);
580
584
  }
585
+ const aFirmar = { address: this.addresses.escrow, abi: escrowAbi, functionName: 'openDispute', args: [taskId] };
581
586
  const hash = await wallet.writeContract({
582
- address: this.addresses.escrow,
583
- abi: escrowAbi,
584
- functionName: 'openDispute',
585
- args: [taskId],
587
+ ...aFirmar,
588
+ gas: await this.gasFijo('La disputa', aFirmar),
586
589
  chain: chainFor(this.network),
587
590
  account: this.account,
588
591
  });
@@ -610,6 +613,7 @@ export class PanalClient {
610
613
  ...llamada,
611
614
  abi: registryAbi,
612
615
  functionName: 'registerAgent',
616
+ gas: await this.gasFijo('El alta', llamada, TOPE_GAS_FICHA),
613
617
  chain: chainFor(this.network),
614
618
  account: this.account,
615
619
  });
@@ -619,11 +623,10 @@ export class PanalClient {
619
623
  /** Cambia el nombre, la descripción, las skills o el endpoint publicados. */
620
624
  async updateMetadata(metadata) {
621
625
  const wallet = this.wallet();
626
+ const aFirmar = { address: this.addresses.registry, abi: registryAbi, functionName: 'updateMetadata', args: [formatAgentMetadata(metadata)] };
622
627
  const hash = await wallet.writeContract({
623
- address: this.addresses.registry,
624
- abi: registryAbi,
625
- functionName: 'updateMetadata',
626
- args: [formatAgentMetadata(metadata)],
628
+ ...aFirmar,
629
+ gas: await this.gasFijo('El cambio de ficha', aFirmar, TOPE_GAS_FICHA),
627
630
  chain: chainFor(this.network),
628
631
  account: this.account,
629
632
  });
@@ -633,11 +636,10 @@ export class PanalClient {
633
636
  /** Cambia el precio por tarea, y opcionalmente la moneda en la que cobras. */
634
637
  async updatePrice(pricePerTask, currency = NATIVE_CURRENCY) {
635
638
  const wallet = this.wallet();
639
+ const aFirmar = { address: this.addresses.registry, abi: registryAbi, functionName: 'updatePrice', args: [pricePerTask, currency] };
636
640
  const hash = await wallet.writeContract({
637
- address: this.addresses.registry,
638
- abi: registryAbi,
639
- functionName: 'updatePrice',
640
- args: [pricePerTask, currency],
641
+ ...aFirmar,
642
+ gas: await this.gasFijo('El cambio de precio', aFirmar),
641
643
  chain: chainFor(this.network),
642
644
  account: this.account,
643
645
  });
@@ -651,11 +653,10 @@ export class PanalClient {
651
653
  */
652
654
  async setActive(active) {
653
655
  const wallet = this.wallet();
656
+ const aFirmar = { address: this.addresses.registry, abi: registryAbi, functionName: 'setActive', args: [active] };
654
657
  const hash = await wallet.writeContract({
655
- address: this.addresses.registry,
656
- abi: registryAbi,
657
- functionName: 'setActive',
658
- args: [active],
658
+ ...aFirmar,
659
+ gas: await this.gasFijo('El cambio de estado', aFirmar),
659
660
  chain: chainFor(this.network),
660
661
  account: this.account,
661
662
  });
@@ -679,11 +680,10 @@ export class PanalClient {
679
680
  throw new Error(`La tarea #${taskId} está "${TaskStatus[task.status]}": solo se entrega lo que sigue abierto.`);
680
681
  }
681
682
  const resultHash = keccak256(toBytes(resultText));
683
+ const aFirmar = { address: this.addresses.escrow, abi: escrowAbi, functionName: 'deliverResult', args: [taskId, resultHash] };
682
684
  const txHash = await wallet.writeContract({
683
- address: this.addresses.escrow,
684
- abi: escrowAbi,
685
- functionName: 'deliverResult',
686
- args: [taskId, resultHash],
685
+ ...aFirmar,
686
+ gas: await this.gasFijo('La entrega', aFirmar),
687
687
  chain: chainFor(this.network),
688
688
  account: this.account,
689
689
  });
@@ -1016,6 +1016,7 @@ export class PanalClient {
1016
1016
  ? ` Se probó también con ${variantesDeSkill(skill).slice(1).map((v) => `"${v}"`).join(' y ')}.`
1017
1017
  : ''));
1018
1018
  }
1019
+ const moneda = getAddress(options.asset ?? this.addresses.panalToken);
1019
1020
  const quotes = [];
1020
1021
  const rechazos = [];
1021
1022
  for (const agent of candidates) {
@@ -1026,7 +1027,10 @@ export class PanalClient {
1026
1027
  ...options,
1027
1028
  envelope: heredado,
1028
1029
  });
1029
- if (BigInt(accept.amount) <= tope)
1030
+ if (getAddress(accept.asset) !== moneda) {
1031
+ rechazos.push(`${agent.metadata.name || agent.address}: cobra en ${accept.asset}, no en ${moneda}`);
1032
+ }
1033
+ else if (BigInt(accept.amount) <= tope)
1030
1034
  quotes.push({ agent, endpoint, accept });
1031
1035
  else
1032
1036
  rechazos.push(`${agent.metadata.name || agent.address}: pide ${accept.amount}, por encima del tope`);
@@ -1048,6 +1052,7 @@ export class PanalClient {
1048
1052
  maxSpend: tope,
1049
1053
  chainId: chainFor(this.network).id,
1050
1054
  expectedPayee: elegido.agent.address,
1055
+ asset: moneda,
1051
1056
  quote: elegido.accept,
1052
1057
  envelope: siguiente,
1053
1058
  });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Las monedas con las que se paga una consulta x402 en Panal.
3
+ *
4
+ * POR QUÉ UNA LISTA CERRADA
5
+ *
6
+ * La cotización la escribe el agente: qué token, qué importe y cómo se llama.
7
+ * Fiarse de eso para enseñar el precio era el agujero. Un agente podía cobrar
8
+ * en un token de 6 decimales y llamarlo «$PANAL»: 1.000 USDC se pintaban
9
+ * «0,000000001 $PANAL» y la persona firmaba un permiso por 1.000 USDC creyendo
10
+ * que no pagaba nada.
11
+ *
12
+ * Aquí se fija, para cada red, qué tokens se aceptan y con qué nombre y
13
+ * decimales. El nombre y los decimales salen SIEMPRE de esta lista, nunca de
14
+ * la cotización, y un token que no está aquí no se paga. Es la misma lista
15
+ * para todas las capas —web, app, MCP y agentes— para que ninguna pueda
16
+ * enseñar un precio distinto del que se firma.
17
+ *
18
+ * Las cuatro de token cobran con `permit` (EIP-2612), comprobado en mainnet:
19
+ * firma gratis, y el gas lo pone el agente que cobra. MON, la moneda nativa,
20
+ * no tiene `permit`: quien paga MANDA una transferencia (paga él su gas, una
21
+ * fracción de céntimo) y la presenta. Ver `native-transfer` en x402-server.ts.
22
+ *
23
+ * - MON — la moneda de Monad. Nadie puede congelarla.
24
+ * - $PANAL — el token de Panal.
25
+ * - GHO — de Aave. Respaldo cripto, sin lista negra ni pausa: nadie puede
26
+ * congelarlo. La opción estable que no depende de una empresa.
27
+ * - USDC — de Circle. Respaldo en dólares; Circle puede congelar
28
+ * direcciones y pausar el token.
29
+ * - AUSD — de Agora. Respaldo en dólares; Agora puede congelar
30
+ * direcciones y pausar transferencias.
31
+ *
32
+ * En x402 que USDC y AUSD se puedan congelar pesa poco: Panal no guarda nada,
33
+ * el dinero va directo de quien paga al agente, y si alguien es congelado es
34
+ * en su propia wallet. Cada uno elige con qué paga o cobra.
35
+ */
36
+ import { type Address } from 'viem';
37
+ import type { PanalNetwork } from './chains.js';
38
+ /**
39
+ * Cómo se cobra en una moneda: con una firma de `permit` que ejecuta quien
40
+ * cobra, o con una transferencia que manda quien paga.
41
+ */
42
+ export type X402Scheme = 'eip2612-permit' | 'native-transfer';
43
+ /** La moneda nativa, como la escribe Panal en todas partes: la dirección cero. */
44
+ export declare const X402_NATIVE: Address;
45
+ export interface X402Currency {
46
+ /** Como se enseña: `$PANAL`, `GHO`, `USDC`, `AUSD`. */
47
+ symbol: string;
48
+ address: Address;
49
+ decimals: number;
50
+ scheme: X402Scheme;
51
+ /** Si su emisor puede congelar direcciones o pausar el token. */
52
+ issuerCanFreeze: boolean;
53
+ }
54
+ /** Las monedas aceptadas en una red, en el orden en que se ofrecen. */
55
+ export declare function x402Currencies(network?: PanalNetwork): readonly X402Currency[];
56
+ /** La red de un chainId de Monad, o `null` si no es ninguna de las dos. */
57
+ export declare function networkOfChain(chainId: number): PanalNetwork | null;
58
+ /**
59
+ * La moneda de un token, o `null` si Panal no acepta pagar en él.
60
+ *
61
+ * Por dirección: el nombre lo puede escribir cualquiera, la dirección no.
62
+ */
63
+ export declare function x402Currency(token: string, network?: PanalNetwork): X402Currency | null;
64
+ /**
65
+ * Lo mismo, admitiendo también el símbolo: `USDC`, `gho`, `$PANAL` o `PANAL`.
66
+ * Es para configurar un agente a mano (`X402_TOKEN=USDC`), no para leer lo que
67
+ * llega de fuera: eso va siempre por dirección.
68
+ */
69
+ export declare function x402CurrencyByName(name: string, network?: PanalNetwork): X402Currency | null;
70
+ /** Un importe en unidades mínimas, en texto legible: `0.05`. */
71
+ export declare function formatX402Amount(amount: bigint | string, currency: X402Currency): string;
72
+ /** `0.05` → unidades mínimas de esa moneda. Lanza si el texto no es un número. */
73
+ export declare function parseX402Amount(text: string, currency: X402Currency): bigint;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Las monedas con las que se paga una consulta x402 en Panal.
3
+ *
4
+ * POR QUÉ UNA LISTA CERRADA
5
+ *
6
+ * La cotización la escribe el agente: qué token, qué importe y cómo se llama.
7
+ * Fiarse de eso para enseñar el precio era el agujero. Un agente podía cobrar
8
+ * en un token de 6 decimales y llamarlo «$PANAL»: 1.000 USDC se pintaban
9
+ * «0,000000001 $PANAL» y la persona firmaba un permiso por 1.000 USDC creyendo
10
+ * que no pagaba nada.
11
+ *
12
+ * Aquí se fija, para cada red, qué tokens se aceptan y con qué nombre y
13
+ * decimales. El nombre y los decimales salen SIEMPRE de esta lista, nunca de
14
+ * la cotización, y un token que no está aquí no se paga. Es la misma lista
15
+ * para todas las capas —web, app, MCP y agentes— para que ninguna pueda
16
+ * enseñar un precio distinto del que se firma.
17
+ *
18
+ * Las cuatro de token cobran con `permit` (EIP-2612), comprobado en mainnet:
19
+ * firma gratis, y el gas lo pone el agente que cobra. MON, la moneda nativa,
20
+ * no tiene `permit`: quien paga MANDA una transferencia (paga él su gas, una
21
+ * fracción de céntimo) y la presenta. Ver `native-transfer` en x402-server.ts.
22
+ *
23
+ * - MON — la moneda de Monad. Nadie puede congelarla.
24
+ * - $PANAL — el token de Panal.
25
+ * - GHO — de Aave. Respaldo cripto, sin lista negra ni pausa: nadie puede
26
+ * congelarlo. La opción estable que no depende de una empresa.
27
+ * - USDC — de Circle. Respaldo en dólares; Circle puede congelar
28
+ * direcciones y pausar el token.
29
+ * - AUSD — de Agora. Respaldo en dólares; Agora puede congelar
30
+ * direcciones y pausar transferencias.
31
+ *
32
+ * En x402 que USDC y AUSD se puedan congelar pesa poco: Panal no guarda nada,
33
+ * el dinero va directo de quien paga al agente, y si alguien es congelado es
34
+ * en su propia wallet. Cada uno elige con qué paga o cobra.
35
+ */
36
+ import { formatUnits, getAddress, parseUnits } from 'viem';
37
+ /** La moneda nativa, como la escribe Panal en todas partes: la dirección cero. */
38
+ export const X402_NATIVE = '0x0000000000000000000000000000000000000000';
39
+ const MON = {
40
+ symbol: 'MON',
41
+ address: X402_NATIVE,
42
+ decimals: 18,
43
+ scheme: 'native-transfer',
44
+ issuerCanFreeze: false,
45
+ };
46
+ const MAINNET = [
47
+ {
48
+ symbol: '$PANAL',
49
+ address: getAddress('0x2e2e44e7fa6178822d4397299f719e89d1a67777'),
50
+ decimals: 18,
51
+ scheme: 'eip2612-permit',
52
+ issuerCanFreeze: false,
53
+ },
54
+ MON,
55
+ {
56
+ symbol: 'GHO',
57
+ address: getAddress('0xfc421ad3c883bf9e7c4f42de845c4e4405799e73'),
58
+ decimals: 18,
59
+ scheme: 'eip2612-permit',
60
+ issuerCanFreeze: false,
61
+ },
62
+ {
63
+ symbol: 'USDC',
64
+ address: getAddress('0x754704bc059f8c67012fed69bc8a327a5aafb603'),
65
+ decimals: 6,
66
+ scheme: 'eip2612-permit',
67
+ issuerCanFreeze: true,
68
+ },
69
+ {
70
+ symbol: 'AUSD',
71
+ address: getAddress('0x00000000efe302beaa2b3e6e1b18d08d69a9012a'),
72
+ decimals: 6,
73
+ scheme: 'eip2612-permit',
74
+ issuerCanFreeze: true,
75
+ },
76
+ ];
77
+ /** En testnet no hay ninguno de estos tokens, pero MON sí. */
78
+ const TESTNET = [MON];
79
+ /** Las monedas aceptadas en una red, en el orden en que se ofrecen. */
80
+ export function x402Currencies(network = 'mainnet') {
81
+ return network === 'mainnet' ? MAINNET : TESTNET;
82
+ }
83
+ /** La red de un chainId de Monad, o `null` si no es ninguna de las dos. */
84
+ export function networkOfChain(chainId) {
85
+ return chainId === 143 ? 'mainnet' : chainId === 10143 ? 'testnet' : null;
86
+ }
87
+ /**
88
+ * La moneda de un token, o `null` si Panal no acepta pagar en él.
89
+ *
90
+ * Por dirección: el nombre lo puede escribir cualquiera, la dirección no.
91
+ */
92
+ export function x402Currency(token, network = 'mainnet') {
93
+ const t = token.trim().toLowerCase();
94
+ return x402Currencies(network).find((c) => c.address.toLowerCase() === t) ?? null;
95
+ }
96
+ /**
97
+ * Lo mismo, admitiendo también el símbolo: `USDC`, `gho`, `$PANAL` o `PANAL`.
98
+ * Es para configurar un agente a mano (`X402_TOKEN=USDC`), no para leer lo que
99
+ * llega de fuera: eso va siempre por dirección.
100
+ */
101
+ export function x402CurrencyByName(name, network = 'mainnet') {
102
+ const n = name.trim().replace(/^\$/, '').toUpperCase();
103
+ return x402Currencies(network).find((c) => c.symbol.replace(/^\$/, '').toUpperCase() === n) ?? x402Currency(name, network);
104
+ }
105
+ /** Un importe en unidades mínimas, en texto legible: `0.05`. */
106
+ export function formatX402Amount(amount, currency) {
107
+ return formatUnits(BigInt(amount), currency.decimals);
108
+ }
109
+ /** `0.05` → unidades mínimas de esa moneda. Lanza si el texto no es un número. */
110
+ export function parseX402Amount(text, currency) {
111
+ return parseUnits(text.trim(), currency.decimals);
112
+ }
package/dist/index.d.ts CHANGED
@@ -27,14 +27,16 @@ export { TaskStatus, TASK_STATUS_LABEL, formatAgentMetadata, normalizeAgentLink,
27
27
  export type { Agent, AgentLinkKey, AgentLinks, AgentMetadata, Task } from './types.js';
28
28
  export { erc20Abi, escrowAbi, registryAbi } from './abis.js';
29
29
  export { X402_SCHEME, X402Error, payAndAsk, quoteAsk } from './x402.js';
30
+ export { X402_NATIVE, formatX402Amount, networkOfChain, parseX402Amount, x402Currencies, x402Currency, x402CurrencyByName, } from './currencies.js';
31
+ export type { X402Currency, X402Scheme } from './currencies.js';
30
32
  export type { AskResult, PayAndAskOptions, PermitDomain, X402Accept, X402Quote } from './x402.js';
31
33
  export { assertPublicUrl, fetchBytesLimited, fetchLimited, isPrivateIp } from './net.js';
32
34
  export { ATTACH_BLOCK, FILES_BLOCK, MAX_FILE_BYTES, FileVerificationError, appendAttachmentsManifest, appendFilesManifest, attachmentFrom, buildAttachmentsManifest, buildFilesManifest, downloadDeliveredFile, fileUrl, matchAttachment, parseAttachmentsManifest, parseFilesManifest, sanitizeFileName, stripFilesManifest, verifyFileBytes, } from './files.js';
33
35
  export type { AttachedFile, DeliveredFile, DownloadOptions, HashedFile } from './files.js';
34
36
  export { MAX_IMAGEN_BYTES, MIMES_IMAGEN, PROVEEDORES, LlmError, dialectoDe, esImagenSoportada, llmChat, resolverLlm, } from './llm.js';
35
37
  export type { LlmConfig, LlmDialecto, LlmImagen, LlmPeticion, LlmProveedor } from './llm.js';
36
- export { X402_VERSION, X402_SERVER_SCHEME, buildQuote, enqueueByPayer, parsePaymentHeader, permitNonce, permitTypedData, readPermitDomain, resourceId, splitSignature, verifyAndSettle, } from './x402-server.js';
37
- export type { SettleDeps, SettleResult, X402Payment, X402ServerAccept, X402ServerQuote, } from './x402-server.js';
38
+ export { X402_VERSION, X402_SERVER_SCHEME, X402_NATIVE_SCHEME, buildNativeQuote, buildQuote, enqueueByPayer, newQuoteSecret, parsePaymentHeader, parseX402Header, permitNonce, permitTypedData, readPermitDomain, resourceId, splitSignature, verifyAndSettle, verifyNativePayment, } from './x402-server.js';
39
+ export type { NativeReplayGuard, SettleDeps, SettleResult, X402NativeAccept, X402NativePayment, X402Payment, X402ServerAccept, X402ServerQuote, } from './x402-server.js';
38
40
  export { ENVELOPE_HEADERS, DEFAULT_DEPTH, MAX_DEPTH, BudgetExhausted, DepthExhausted, LoopDetected, assertCanServe, descend, envelopeHeaders, newEnvelope, parseEnvelope, remainingBudget, } from './envelope.js';
39
41
  export type { CallEnvelope } from './envelope.js';
40
42
  export type { UrlGuardOptions } from './net.js';
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ export { TaskStatus, TASK_STATUS_LABEL, formatAgentMetadata, normalizeAgentLink,
25
25
  export { erc20Abi, escrowAbi, registryAbi } from './abis.js';
26
26
  // x402: pagar a otro agente por una consulta, sin escrow y sin humano.
27
27
  export { X402_SCHEME, X402Error, payAndAsk, quoteAsk } from './x402.js';
28
+ export { X402_NATIVE, formatX402Amount, networkOfChain, parseX402Amount, x402Currencies, x402Currency, x402CurrencyByName, } from './currencies.js';
28
29
  export { assertPublicUrl, fetchBytesLimited, fetchLimited, isPrivateIp } from './net.js';
29
30
  // Archivos: entregar un PDF o un vídeo anclando SU hash, no el del enlace.
30
31
  // Y en la otra dirección, adjuntar una foto al encargo con la misma garantía.
@@ -34,7 +35,7 @@ export { ATTACH_BLOCK, FILES_BLOCK, MAX_FILE_BYTES, FileVerificationError, appen
34
35
  export { MAX_IMAGEN_BYTES, MIMES_IMAGEN, PROVEEDORES, LlmError, dialectoDe, esImagenSoportada, llmChat, resolverLlm, } from './llm.js';
35
36
  // x402: la otra mitad, cobrar por llamada. Portada del bot de LexPanal, donde
36
37
  // lleva meses cobrando en produccion.
37
- export { X402_VERSION, X402_SERVER_SCHEME, buildQuote, enqueueByPayer, parsePaymentHeader, permitNonce, permitTypedData, readPermitDomain, resourceId, splitSignature, verifyAndSettle, } from './x402-server.js';
38
+ export { X402_VERSION, X402_SERVER_SCHEME, X402_NATIVE_SCHEME, buildNativeQuote, buildQuote, enqueueByPayer, newQuoteSecret, parsePaymentHeader, parseX402Header, permitNonce, permitTypedData, readPermitDomain, resourceId, splitSignature, verifyAndSettle, verifyNativePayment, } from './x402-server.js';
38
39
  // El sobre que viaja entre agentes: profundidad, presupuesto y detección de ciclos.
39
40
  export { ENVELOPE_HEADERS, DEFAULT_DEPTH, MAX_DEPTH, BudgetExhausted, DepthExhausted, LoopDetected, assertCanServe, descend, envelopeHeaders, newEnvelope, parseEnvelope, remainingBudget, } from './envelope.js';
40
41
  // La ficha de GET /agent.json: un solo formato, y lectores que perdonan el
@@ -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. */
package/dist/x402.js CHANGED
@@ -16,10 +16,16 @@
16
16
  * El cliente NO paga gas: solo firma. La transacción la manda quien cobra.
17
17
  */
18
18
  import { isAddress, getAddress } from 'viem';
19
+ import { estimateGas, waitForTransactionReceipt } from 'viem/actions';
19
20
  import { assertPublicUrl, fetchLimited } from './net.js';
20
21
  import { envelopeHeaders } from './envelope.js';
21
- /** El único esquema que entiende este cliente. Debe coincidir con el servidor. */
22
+ import { networkOfChain, x402Currency } from './currencies.js';
23
+ /** El esquema de las monedas con `permit`. Debe coincidir con el servidor. */
22
24
  export const X402_SCHEME = 'eip2612-permit';
25
+ /** El de MON, que no tiene `permit`: se manda una transferencia y se presenta. */
26
+ const ESQUEMA_MON = 'native-transfer';
27
+ /** Lo más que puede valer una cotización: una hora. Ver `payAndAsk`. */
28
+ const PLAZO_MAX_S = 60 * 60;
23
29
  const PERMIT_TYPES = {
24
30
  Permit: [
25
31
  { name: 'owner', type: 'address' },
@@ -70,10 +76,10 @@ export async function quoteAsk(endpoint, prompt, options = {}) {
70
76
  catch {
71
77
  throw new X402Error('La cotización no es JSON válido.');
72
78
  }
73
- const accept = quote.accepts?.find((a) => a.scheme === X402_SCHEME);
79
+ const accept = quote.accepts?.find((a) => a.scheme === X402_SCHEME || a.scheme === ESQUEMA_MON);
74
80
  if (!accept) {
75
81
  const vistos = quote.accepts?.map((a) => a.scheme).join(', ') || 'ninguno';
76
- throw new X402Error(`Ese agente no acepta "${X402_SCHEME}". Esquemas que ofrece: ${vistos}.`);
82
+ throw new X402Error(`Ese agente no cobra con un esquema que sepamos pagar. Esquemas que ofrece: ${vistos}.`);
77
83
  }
78
84
  return accept;
79
85
  }
@@ -109,62 +115,56 @@ export async function payAndAsk(wallet, account, endpoint, prompt, options) {
109
115
  if (options.asset && getAddress(accept.asset) !== getAddress(options.asset)) {
110
116
  throw new X402Error(`La cotización pide pagar en ${accept.asset} y esperabas ${options.asset}.`);
111
117
  }
112
- if (getAddress(accept.domain.verifyingContract) !== getAddress(accept.asset)) {
113
- // El dominio EIP-712 tiene que ser el del propio token: si apunta a otro
114
- // contrato, la firma valdría para algo distinto de lo que crees.
115
- throw new X402Error('El dominio de firma no corresponde al token que se va a pagar.');
118
+ // Solo en las monedas de la lista de Panal. El nombre y los decimales que
119
+ // trae la cotización los escribe el agente, y fiarse de ellos permitía
120
+ // cobrar 1.000 USDC enseñando «0,000000001 $PANAL». Ver currencies.ts.
121
+ const moneda = x402Currency(accept.asset, networkOfChain(accept.chainId) ?? 'mainnet');
122
+ if (!moneda) {
123
+ throw new X402Error(`La cotización pide pagar en ${accept.asset}, que no es una moneda que Panal acepte: no se firma.`);
124
+ }
125
+ if (accept.scheme !== moneda.scheme) {
126
+ throw new X402Error(`${moneda.symbol} se paga con "${moneda.scheme}" y la cotización pide "${accept.scheme}".`);
116
127
  }
117
128
  const ahora = Math.floor(Date.now() / 1000);
118
129
  if (accept.deadline <= ahora + 30) {
119
130
  throw new X402Error('La cotización caduca de inmediato: pide otra.');
120
131
  }
121
- if (accept.payerNonce === undefined) {
122
- throw new X402Error('La cotización no trae el nonce del pagador. Vuelve a pedirla indicando `payer`, o léelo del token.');
132
+ // Y tampoco una que valga demasiado. El plazo lo pone el agente, y un permiso
133
+ // firmado con un plazo de años se puede cobrar cuando él quiera mientras no
134
+ // se use otro antes. Las cotizaciones de Panal duran 5 minutos.
135
+ if (accept.deadline > ahora + PLAZO_MAX_S) {
136
+ throw new X402Error(`La cotización vale hasta dentro de ${Math.round((accept.deadline - ahora) / 60)} minutos, y el máximo es una hora: no se firma.`);
123
137
  }
124
- // ---- Firma (sin gas, sin transacción) -----------------------------------
125
- const signature = await wallet.signTypedData({
126
- account,
127
- domain: accept.domain,
128
- types: PERMIT_TYPES,
129
- primaryType: 'Permit',
130
- message: {
131
- owner: account.address,
132
- spender: getAddress(accept.payTo),
133
- value: amount,
134
- nonce: BigInt(accept.payerNonce),
135
- deadline: BigInt(accept.deadline),
136
- },
137
- });
138
- const header = toBase64(JSON.stringify({
139
- scheme: X402_SCHEME,
140
- payer: account.address,
141
- value: amount.toString(),
142
- deadline: accept.deadline.toString(),
143
- signature,
144
- }));
138
+ const header = moneda.scheme === ESQUEMA_MON
139
+ ? await pagarEnMon(wallet, account, accept, amount)
140
+ : await firmarPermit(wallet, account, accept, amount);
145
141
  // ---- Segunda llamada: se cobra y se responde en la misma ----------------
146
142
  const res = await fetchLimited(url, {
147
143
  method: 'POST',
148
144
  headers: {
149
145
  'content-type': 'application/json',
150
- 'x-payment': header,
146
+ 'x-payment': header.value,
151
147
  ...(options.envelope ? envelopeHeaders(options.envelope) : {}),
152
148
  },
153
149
  body: JSON.stringify({ prompt }),
154
150
  timeoutMs: options.timeoutMs ?? (accept.maxTimeoutSeconds ?? 120) * 1000,
155
151
  });
152
+ // `paid` es como lo devuelve la plantilla de agentes; `payment`, como lo
153
+ // devolvía el bot de LexPanal. Se leen los dos para no perder el hash.
156
154
  let body;
157
155
  try {
158
156
  body = JSON.parse(res.text);
159
157
  }
160
158
  catch {
161
- throw new X402Error(`Respuesta ilegible del agente (HTTP ${res.status}).`, res.status);
159
+ throw new X402Error(`Respuesta ilegible del agente (HTTP ${res.status}).${header.txHash ? ` Tu pago: tx ${header.txHash}.` : ''}`, res.status);
162
160
  }
163
161
  if (res.status !== 200) {
164
162
  // El 502 con `paymentTx` es el caso feo y hay que distinguirlo: te han
165
163
  // cobrado y no han respondido, así que el hash es tu prueba para reclamar.
166
- if (body.paymentTx) {
167
- throw new X402Error(`El agente cobró (tx ${body.paymentTx}) pero no entregó respuesta: ${body.error ?? 'sin detalle'}`, res.status);
164
+ // En MON el pago ya salió de tu wallet al mandarlo: el hash es el tuyo.
165
+ const pagado = body.paymentTx ?? header.txHash;
166
+ if (pagado) {
167
+ throw new X402Error(`Pagaste (tx ${pagado}) y el agente no entregó respuesta: ${body.error ?? `HTTP ${res.status}`}`, res.status);
168
168
  }
169
169
  if (res.status === 508) {
170
170
  throw new X402Error(`El agente rechazó la llamada por ciclo: ya había atendido esta cadena. ${body.error ?? ''}`.trim(), 508);
@@ -177,11 +177,85 @@ export async function payAndAsk(wallet, account, endpoint, prompt, options) {
177
177
  return {
178
178
  answer: body.answer,
179
179
  paid: amount,
180
+ currency: moneda,
180
181
  payee: getAddress(accept.payTo),
181
- txHash: body.payment?.txHash,
182
+ txHash: body.payment?.txHash ?? body.paid?.txHash ?? header.txHash,
182
183
  endpoint: url.toString(),
183
184
  };
184
185
  }
186
+ /**
187
+ * Las de `permit`: una firma, sin gas ni transacción. Quien cobra la ejecuta.
188
+ */
189
+ async function firmarPermit(wallet, account, accept, amount) {
190
+ if (!accept.domain || getAddress(accept.domain.verifyingContract) !== getAddress(accept.asset)) {
191
+ // El dominio EIP-712 tiene que ser el del propio token: si apunta a otro
192
+ // contrato, la firma valdría para algo distinto de lo que crees.
193
+ throw new X402Error('El dominio de firma no corresponde al token que se va a pagar.');
194
+ }
195
+ if (accept.payerNonce === undefined) {
196
+ throw new X402Error('La cotización no trae el nonce del pagador. Vuelve a pedirla indicando `payer`, o léelo del token.');
197
+ }
198
+ // ---- Firma (sin gas, sin transacción) -----------------------------------
199
+ const signature = await wallet.signTypedData({
200
+ account,
201
+ domain: accept.domain,
202
+ types: PERMIT_TYPES,
203
+ primaryType: 'Permit',
204
+ message: {
205
+ owner: account.address,
206
+ spender: getAddress(accept.payTo),
207
+ value: amount,
208
+ nonce: BigInt(accept.payerNonce),
209
+ deadline: BigInt(accept.deadline),
210
+ },
211
+ });
212
+ return {
213
+ value: toBase64(JSON.stringify({
214
+ scheme: X402_SCHEME,
215
+ payer: account.address,
216
+ value: amount.toString(),
217
+ deadline: accept.deadline.toString(),
218
+ signature,
219
+ })),
220
+ };
221
+ }
222
+ /**
223
+ * MON: se MANDA la transferencia y se presenta su hash.
224
+ *
225
+ * El importe es exactamente el de la cotización —lleva unas unidades al azar
226
+ * que la hacen única— y el código de pago no va a la cadena: es lo que impide
227
+ * que otro presente esta transferencia como suya. Ver x402-server.ts.
228
+ *
229
+ * Aquí sí pagas tú el gas, una fracción de céntimo en Monad, y se espera a que
230
+ * entre en un bloque: el agente lo va a comprobar en la cadena.
231
+ */
232
+ async function pagarEnMon(wallet, account, accept, amount) {
233
+ if (!accept.paymentId || !/^0x[0-9a-fA-F]{64}$/.test(accept.paymentId)) {
234
+ throw new X402Error('La cotización en MON no trae su código de pago: pide otra.');
235
+ }
236
+ const to = getAddress(accept.payTo);
237
+ // Monad cobra el límite de gas entero, no el gastado: se pide lo justo. Una
238
+ // transferencia a una wallet normal son 21.000 exactos; solo se deja margen
239
+ // si la estimación dice que el destino ejecuta código.
240
+ const estimado = await estimateGas(wallet, { account, to, value: amount });
241
+ const gas = estimado > 21000n ? (estimado * 11n) / 10n : estimado;
242
+ const txHash = await wallet.sendTransaction({ account, chain: wallet.chain ?? null, to, value: amount, gas });
243
+ const recibo = await waitForTransactionReceipt(wallet, { hash: txHash, timeout: 90_000 });
244
+ if (recibo.status !== 'success') {
245
+ throw new X402Error(`La transferencia de MON se revirtió (tx ${txHash}): no se ha preguntado nada.`);
246
+ }
247
+ return {
248
+ txHash,
249
+ value: toBase64(JSON.stringify({
250
+ scheme: ESQUEMA_MON,
251
+ payer: account.address,
252
+ value: amount.toString(),
253
+ deadline: accept.deadline.toString(),
254
+ paymentId: accept.paymentId,
255
+ txHash,
256
+ })),
257
+ };
258
+ }
185
259
  /** base64 sin depender de Buffer, para que valga también en el navegador. */
186
260
  function toBase64(text) {
187
261
  const bytes = new TextEncoder().encode(text);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panal/sdk",
3
- "version": "0.18.3",
3
+ "version": "0.19.0",
4
4
  "description": "SDK de Panal: contrata agentes de IA autonomos on-chain en Monad",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  "scripts": {
46
46
  "build": "tsc -p tsconfig.json",
47
47
  "typecheck": "tsc -p tsconfig.json --noEmit",
48
- "test": "tsx test/sdk.test.ts && tsx test/x402.test.ts && tsx test/x402-server.test.ts && tsx test/envelope.test.ts && tsx test/files.test.ts && tsx test/attachments.test.ts && tsx test/llm.test.ts && tsx test/skill.test.ts && tsx test/agent-card.test.ts && tsx test/niveles.test.ts && tsx test/tablon.test.ts && tsx test/reserva.test.ts",
48
+ "test": "tsx test/sdk.test.ts && tsx test/currencies.test.ts && tsx test/x402.test.ts && tsx test/x402-server.test.ts && tsx test/envelope.test.ts && tsx test/files.test.ts && tsx test/attachments.test.ts && tsx test/llm.test.ts && tsx test/skill.test.ts && tsx test/agent-card.test.ts && tsx test/niveles.test.ts && tsx test/tablon.test.ts && tsx test/reserva.test.ts",
49
49
  "test:nombres": "tsx test/nombres.test.ts"
50
50
  }
51
51
  }