@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.
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.2",
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
  }