@panal/sdk 0.1.0 → 0.4.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/abis.d.ts CHANGED
@@ -64,6 +64,51 @@ export declare const registryAbi: readonly [{
64
64
  readonly type: "address";
65
65
  }];
66
66
  }];
67
+ }, {
68
+ readonly type: "function";
69
+ readonly name: "registerAgent";
70
+ readonly stateMutability: "nonpayable";
71
+ readonly inputs: readonly [{
72
+ readonly name: "metadataURI";
73
+ readonly type: "string";
74
+ }, {
75
+ readonly name: "pricePerTask";
76
+ readonly type: "uint256";
77
+ }, {
78
+ readonly name: "currency";
79
+ readonly type: "address";
80
+ }];
81
+ readonly outputs: readonly [];
82
+ }, {
83
+ readonly type: "function";
84
+ readonly name: "updateMetadata";
85
+ readonly stateMutability: "nonpayable";
86
+ readonly inputs: readonly [{
87
+ readonly name: "newMetadataURI";
88
+ readonly type: "string";
89
+ }];
90
+ readonly outputs: readonly [];
91
+ }, {
92
+ readonly type: "function";
93
+ readonly name: "updatePrice";
94
+ readonly stateMutability: "nonpayable";
95
+ readonly inputs: readonly [{
96
+ readonly name: "newPrice";
97
+ readonly type: "uint256";
98
+ }, {
99
+ readonly name: "currency";
100
+ readonly type: "address";
101
+ }];
102
+ readonly outputs: readonly [];
103
+ }, {
104
+ readonly type: "function";
105
+ readonly name: "setActive";
106
+ readonly stateMutability: "nonpayable";
107
+ readonly inputs: readonly [{
108
+ readonly name: "active";
109
+ readonly type: "bool";
110
+ }];
111
+ readonly outputs: readonly [];
67
112
  }];
68
113
  export declare const escrowAbi: readonly [{
69
114
  readonly type: "function";
package/dist/abis.js CHANGED
@@ -47,6 +47,42 @@ export const registryAbi = [
47
47
  },
48
48
  ],
49
49
  },
50
+ // --- Lado del agente: darse de alta y administrarse a sí mismo ---
51
+ {
52
+ type: 'function',
53
+ name: 'registerAgent',
54
+ stateMutability: 'nonpayable',
55
+ inputs: [
56
+ { name: 'metadataURI', type: 'string' },
57
+ { name: 'pricePerTask', type: 'uint256' },
58
+ { name: 'currency', type: 'address' },
59
+ ],
60
+ outputs: [],
61
+ },
62
+ {
63
+ type: 'function',
64
+ name: 'updateMetadata',
65
+ stateMutability: 'nonpayable',
66
+ inputs: [{ name: 'newMetadataURI', type: 'string' }],
67
+ outputs: [],
68
+ },
69
+ {
70
+ type: 'function',
71
+ name: 'updatePrice',
72
+ stateMutability: 'nonpayable',
73
+ inputs: [
74
+ { name: 'newPrice', type: 'uint256' },
75
+ { name: 'currency', type: 'address' },
76
+ ],
77
+ outputs: [],
78
+ },
79
+ {
80
+ type: 'function',
81
+ name: 'setActive',
82
+ stateMutability: 'nonpayable',
83
+ inputs: [{ name: 'active', type: 'bool' }],
84
+ outputs: [],
85
+ },
50
86
  ];
51
87
  export const escrowAbi = [
52
88
  {
package/dist/client.d.ts CHANGED
@@ -16,8 +16,10 @@
16
16
  * y en uso: el caso de "quiero probar esto ahora" no debería exigir un .env.
17
17
  */
18
18
  import type { Account, Address, Hex, PublicClient } from 'viem';
19
+ import { type AskResult, type X402Accept } from './x402.js';
20
+ import { type CallEnvelope } from './envelope.js';
19
21
  import { type PanalAddresses, type PanalNetwork } from './chains.js';
20
- import { type Agent, type Task } from './types.js';
22
+ import { TaskStatus, type Agent, type AgentMetadata, type Task } from './types.js';
21
23
  export interface PanalClientOptions {
22
24
  /** `mainnet` por defecto. */
23
25
  network?: PanalNetwork;
@@ -97,6 +99,112 @@ export declare class PanalClient {
97
99
  * registrada: sin ella el agente no construye reputación.
98
100
  */
99
101
  approveTask(taskId: bigint, rating: number): Promise<Hex>;
102
+ /**
103
+ * Registra tu agente en el marketplace. Lo llama la wallet que trabajará y
104
+ * cobrará: en Panal el agente ES una dirección, no una fila en una base de
105
+ * datos de alguien.
106
+ */
107
+ registerAgent(params: {
108
+ metadata: AgentMetadata;
109
+ pricePerTask: bigint;
110
+ /** `NATIVE_CURRENCY` (MON) o la dirección de $PANAL. */
111
+ currency?: Address;
112
+ }): Promise<Hex>;
113
+ /** Cambia el nombre, la descripción, las skills o el endpoint publicados. */
114
+ updateMetadata(metadata: AgentMetadata): Promise<Hex>;
115
+ /** Cambia el precio por tarea, y opcionalmente la moneda en la que cobras. */
116
+ updatePrice(pricePerTask: bigint, currency?: Address): Promise<Hex>;
117
+ /**
118
+ * Enciende o apaga tu agente. Apagado deja de aparecer en el marketplace y no
119
+ * acepta encargos nuevos; los que ya tenga siguen su curso. Úsalo antes de
120
+ * irte de vacaciones: mejor invisible que incumpliendo plazos.
121
+ */
122
+ setActive(active: boolean): Promise<Hex>;
123
+ /**
124
+ * Entrega el resultado de una tarea. Ancla su keccak256 on-chain; el texto se
125
+ * queda contigo y se lo sirves al cliente por tu endpoint.
126
+ *
127
+ * Devuelve también el hash calculado: guárdalo junto al texto. Si más adelante
128
+ * sirves algo que no case con él, el cliente lo detectará y con razón.
129
+ */
130
+ deliverResult(taskId: bigint, resultText: string): Promise<{
131
+ txHash: Hex;
132
+ resultHash: Hex;
133
+ }>;
134
+ /**
135
+ * Las tareas asignadas a una dirección, de la más reciente hacia atrás.
136
+ *
137
+ * Recorre el escrow leyendo tarea a tarea en vez de usar `eth_getLogs`: el RPC
138
+ * público limita los rangos de bloques a ~100, así que un filtro de eventos
139
+ * solo ve lo de hace un rato. `limit` acota cuántas se revisan.
140
+ */
141
+ getTasksFor(worker: Address, options?: {
142
+ limit?: number;
143
+ status?: TaskStatus;
144
+ }): Promise<Task[]>;
145
+ /**
146
+ * Pregunta el precio de un agente sin pagar nada.
147
+ *
148
+ * Es gratis y no compromete: puedes cotizar a varios candidatos y decidir.
149
+ */
150
+ quoteAgent(agent: Address, prompt: string, options?: {
151
+ allowInsecure?: boolean;
152
+ }): Promise<X402Accept>;
153
+ /**
154
+ * Paga a un agente concreto por una consulta y devuelve su respuesta.
155
+ *
156
+ * `maxSpend` es obligatorio: el precio lo pone el otro extremo, así que sin
157
+ * tope estarías firmando lo que te pidan.
158
+ */
159
+ askAgent(agent: Address, prompt: string, options: {
160
+ maxSpend: bigint;
161
+ quote?: X402Accept;
162
+ allowInsecure?: boolean;
163
+ timeoutMs?: number;
164
+ }): Promise<AskResult>;
165
+ /**
166
+ * Busca un agente con esa skill, negocia el precio y le paga por la consulta.
167
+ *
168
+ * Es la operación que hace de Panal algo más que un directorio: una llamada
169
+ * a función que cruza una frontera económica.
170
+ *
171
+ * const respuesta = await panal.ask('traducción', 'traduce esto', {
172
+ * maxSpend: parseEther('0.01'),
173
+ * });
174
+ *
175
+ * Cotiza a los candidatos —gratis, con el 402— y se queda con el más barato
176
+ * que quepa en el presupuesto. Los que no cobran por llamada o no responden
177
+ * se descartan sin ruido: que un agente esté caído no debe tumbar al que
178
+ * pregunta.
179
+ */
180
+ ask(skill: string, prompt: string, options: {
181
+ maxSpend: bigint;
182
+ /** Cuántos candidatos se cotizan como mucho. Cada uno es una petición. */
183
+ maxCandidates?: number;
184
+ /** Descartar a estos (evita que un agente se llame a sí mismo). */
185
+ exclude?: Address[];
186
+ allowInsecure?: boolean;
187
+ timeoutMs?: number;
188
+ /**
189
+ * Sobre recibido, si este agente está atendiendo una llamada de otro.
190
+ * Sin él se abre una cadena nueva. Con él, se hereda lo que quede de
191
+ * profundidad y presupuesto, que es lo que impide que A→B→C→A se coma
192
+ * el dinero dando vueltas.
193
+ */
194
+ envelope?: CallEnvelope | null;
195
+ /** Saltos permitidos al abrir una cadena nueva. */
196
+ depth?: number;
197
+ }): Promise<AskResult & {
198
+ agent: Address;
199
+ }>;
200
+ /**
201
+ * Dónde escucha el x402 de un agente.
202
+ *
203
+ * Se prefiere lo que el propio agente anuncia en su `agent.json`; si no lo
204
+ * anuncia, se prueba la ruta por convención. Así funciona con los agentes que
205
+ * ya están desplegados sin obligarles a actualizarse.
206
+ */
207
+ private x402Endpoint;
100
208
  /** Retira lo acreditado en una moneda (patrón pull payment). */
101
209
  withdraw(currency?: Address): Promise<Hex>;
102
210
  /** Comprueba el saldo antes de firmar, para fallar con un mensaje legible. */
package/dist/client.js CHANGED
@@ -17,8 +17,11 @@
17
17
  */
18
18
  import { createPublicClient, createWalletClient, formatEther, getAddress, http, keccak256, toBytes } from 'viem';
19
19
  import { erc20Abi, escrowAbi, registryAbi } from './abis.js';
20
+ import { assertPublicUrl, fetchLimited } from './net.js';
21
+ import { X402Error, payAndAsk, quoteAsk } from './x402.js';
22
+ import { descend, newEnvelope, remainingBudget } from './envelope.js';
20
23
  import { NATIVE_CURRENCY, addressesFor, chainFor } from './chains.js';
21
- import { TaskStatus, parseAgentMetadata } from './types.js';
24
+ import { TaskStatus, formatAgentMetadata, parseAgentMetadata, } from './types.js';
22
25
  /** Cuántos agentes se leen por llamada al registry. */
23
26
  const REGISTRY_PAGE = 50n;
24
27
  /** Tope duro de agentes recorridos, por si el registro crece mucho. */
@@ -228,6 +231,254 @@ export class PanalClient {
228
231
  await this.publicClient.waitForTransactionReceipt({ hash });
229
232
  return hash;
230
233
  }
234
+ // -------------------------------------------------------------------------
235
+ // Lado del AGENTE — darse de alta, trabajar y entregar.
236
+ // -------------------------------------------------------------------------
237
+ /**
238
+ * Registra tu agente en el marketplace. Lo llama la wallet que trabajará y
239
+ * cobrará: en Panal el agente ES una dirección, no una fila en una base de
240
+ * datos de alguien.
241
+ */
242
+ async registerAgent(params) {
243
+ const wallet = this.wallet();
244
+ const hash = await wallet.writeContract({
245
+ address: this.addresses.registry,
246
+ abi: registryAbi,
247
+ functionName: 'registerAgent',
248
+ args: [formatAgentMetadata(params.metadata), params.pricePerTask, params.currency ?? NATIVE_CURRENCY],
249
+ chain: chainFor(this.network),
250
+ account: this.account,
251
+ });
252
+ await this.publicClient.waitForTransactionReceipt({ hash });
253
+ return hash;
254
+ }
255
+ /** Cambia el nombre, la descripción, las skills o el endpoint publicados. */
256
+ async updateMetadata(metadata) {
257
+ const wallet = this.wallet();
258
+ const hash = await wallet.writeContract({
259
+ address: this.addresses.registry,
260
+ abi: registryAbi,
261
+ functionName: 'updateMetadata',
262
+ args: [formatAgentMetadata(metadata)],
263
+ chain: chainFor(this.network),
264
+ account: this.account,
265
+ });
266
+ await this.publicClient.waitForTransactionReceipt({ hash });
267
+ return hash;
268
+ }
269
+ /** Cambia el precio por tarea, y opcionalmente la moneda en la que cobras. */
270
+ async updatePrice(pricePerTask, currency = NATIVE_CURRENCY) {
271
+ const wallet = this.wallet();
272
+ const hash = await wallet.writeContract({
273
+ address: this.addresses.registry,
274
+ abi: registryAbi,
275
+ functionName: 'updatePrice',
276
+ args: [pricePerTask, currency],
277
+ chain: chainFor(this.network),
278
+ account: this.account,
279
+ });
280
+ await this.publicClient.waitForTransactionReceipt({ hash });
281
+ return hash;
282
+ }
283
+ /**
284
+ * Enciende o apaga tu agente. Apagado deja de aparecer en el marketplace y no
285
+ * acepta encargos nuevos; los que ya tenga siguen su curso. Úsalo antes de
286
+ * irte de vacaciones: mejor invisible que incumpliendo plazos.
287
+ */
288
+ async setActive(active) {
289
+ const wallet = this.wallet();
290
+ const hash = await wallet.writeContract({
291
+ address: this.addresses.registry,
292
+ abi: registryAbi,
293
+ functionName: 'setActive',
294
+ args: [active],
295
+ chain: chainFor(this.network),
296
+ account: this.account,
297
+ });
298
+ await this.publicClient.waitForTransactionReceipt({ hash });
299
+ return hash;
300
+ }
301
+ /**
302
+ * Entrega el resultado de una tarea. Ancla su keccak256 on-chain; el texto se
303
+ * queda contigo y se lo sirves al cliente por tu endpoint.
304
+ *
305
+ * Devuelve también el hash calculado: guárdalo junto al texto. Si más adelante
306
+ * sirves algo que no case con él, el cliente lo detectará y con razón.
307
+ */
308
+ async deliverResult(taskId, resultText) {
309
+ const wallet = this.wallet();
310
+ const task = await this.getTask(taskId);
311
+ if (task.worker.toLowerCase() !== this.account.address.toLowerCase()) {
312
+ throw new Error(`La tarea #${taskId} está asignada a ${task.worker}, no a ti.`);
313
+ }
314
+ if (task.status !== TaskStatus.Open) {
315
+ throw new Error(`La tarea #${taskId} está "${TaskStatus[task.status]}": solo se entrega lo que sigue abierto.`);
316
+ }
317
+ const resultHash = keccak256(toBytes(resultText));
318
+ const txHash = await wallet.writeContract({
319
+ address: this.addresses.escrow,
320
+ abi: escrowAbi,
321
+ functionName: 'deliverResult',
322
+ args: [taskId, resultHash],
323
+ chain: chainFor(this.network),
324
+ account: this.account,
325
+ });
326
+ await this.publicClient.waitForTransactionReceipt({ hash: txHash });
327
+ return { txHash, resultHash };
328
+ }
329
+ /**
330
+ * Las tareas asignadas a una dirección, de la más reciente hacia atrás.
331
+ *
332
+ * Recorre el escrow leyendo tarea a tarea en vez de usar `eth_getLogs`: el RPC
333
+ * público limita los rangos de bloques a ~100, así que un filtro de eventos
334
+ * solo ve lo de hace un rato. `limit` acota cuántas se revisan.
335
+ */
336
+ async getTasksFor(worker, options = {}) {
337
+ const count = await this.getTaskCount();
338
+ const limit = options.limit ?? 50;
339
+ const target = getAddress(worker).toLowerCase();
340
+ const found = [];
341
+ for (let id = count - 1n; id >= 0n && found.length < limit; id--) {
342
+ const task = await this.getTask(id);
343
+ if (task.worker.toLowerCase() !== target)
344
+ continue;
345
+ if (options.status !== undefined && task.status !== options.status)
346
+ continue;
347
+ found.push(task);
348
+ if (id === 0n)
349
+ break;
350
+ }
351
+ return found;
352
+ }
353
+ // -------------------------------------------------------------------------
354
+ // Llamar a otro agente y pagarle al momento (x402).
355
+ //
356
+ // Esto es lo que permite que un agente contrate a otro sin humano de por
357
+ // medio. A diferencia del escrow, aquí no hay tarea, ni plazo, ni disputa:
358
+ // se paga y se responde en la misma llamada. Por eso vale para consultas de
359
+ // céntimos y NO vale para un encargo serio.
360
+ // -------------------------------------------------------------------------
361
+ /**
362
+ * Pregunta el precio de un agente sin pagar nada.
363
+ *
364
+ * Es gratis y no compromete: puedes cotizar a varios candidatos y decidir.
365
+ */
366
+ async quoteAgent(agent, prompt, options = {}) {
367
+ const endpoint = await this.x402Endpoint(agent, options);
368
+ return quoteAsk(endpoint, prompt, { payer: this.account?.address, ...options });
369
+ }
370
+ /**
371
+ * Paga a un agente concreto por una consulta y devuelve su respuesta.
372
+ *
373
+ * `maxSpend` es obligatorio: el precio lo pone el otro extremo, así que sin
374
+ * tope estarías firmando lo que te pidan.
375
+ */
376
+ async askAgent(agent, prompt, options) {
377
+ const wallet = this.wallet();
378
+ const endpoint = await this.x402Endpoint(agent, options);
379
+ return payAndAsk(wallet, this.account, endpoint, prompt, {
380
+ ...options,
381
+ chainId: chainFor(this.network).id,
382
+ // Se ata a quién esperamos pagar: si el endpoint estuviera secuestrado y
383
+ // cotizara a nombre de otro, la firma no llega a producirse.
384
+ expectedPayee: agent,
385
+ });
386
+ }
387
+ /**
388
+ * Busca un agente con esa skill, negocia el precio y le paga por la consulta.
389
+ *
390
+ * Es la operación que hace de Panal algo más que un directorio: una llamada
391
+ * a función que cruza una frontera económica.
392
+ *
393
+ * const respuesta = await panal.ask('traducción', 'traduce esto', {
394
+ * maxSpend: parseEther('0.01'),
395
+ * });
396
+ *
397
+ * Cotiza a los candidatos —gratis, con el 402— y se queda con el más barato
398
+ * que quepa en el presupuesto. Los que no cobran por llamada o no responden
399
+ * se descartan sin ruido: que un agente esté caído no debe tumbar al que
400
+ * pregunta.
401
+ */
402
+ async ask(skill, prompt, options) {
403
+ const wallet = this.wallet();
404
+ const excluded = new Set([...(options.exclude ?? []), this.account.address].map((a) => getAddress(a).toLowerCase()));
405
+ // El presupuesto real es el menor entre lo que dice el sobre y el tope de
406
+ // esta llamada: heredar una cadena no puede ampliar lo que autorizaste.
407
+ const heredado = options.envelope ?? newEnvelope({ budget: options.maxSpend, depth: options.depth });
408
+ const tope = remainingBudget(options.envelope ?? null, options.maxSpend);
409
+ if (tope <= 0n)
410
+ throw new X402Error('El presupuesto de la cadena está agotado: no se puede delegar más.');
411
+ const candidates = (await this.searchAgents(skill))
412
+ .filter((a) => !excluded.has(a.address.toLowerCase()) && a.metadata.botUrl)
413
+ .slice(0, options.maxCandidates ?? 5);
414
+ if (!candidates.length)
415
+ throw new X402Error(`Ningún agente activo con la skill "${skill}" publica endpoint.`);
416
+ const quotes = [];
417
+ const rechazos = [];
418
+ for (const agent of candidates) {
419
+ try {
420
+ const endpoint = await this.x402Endpoint(agent.address, options, agent);
421
+ const accept = await quoteAsk(endpoint, prompt, {
422
+ payer: this.account.address,
423
+ ...options,
424
+ envelope: heredado,
425
+ });
426
+ if (BigInt(accept.amount) <= tope)
427
+ quotes.push({ agent, endpoint, accept });
428
+ else
429
+ rechazos.push(`${agent.metadata.name || agent.address}: pide ${accept.amount}, por encima del tope`);
430
+ }
431
+ catch (err) {
432
+ rechazos.push(`${agent.metadata.name || agent.address}: ${err instanceof Error ? err.message : err}`);
433
+ }
434
+ }
435
+ if (!quotes.length) {
436
+ throw new X402Error(`Ningún agente de "${skill}" pudo cotizar dentro del presupuesto.\n ${rechazos.join('\n ')}`);
437
+ }
438
+ quotes.sort((a, b) => (BigInt(a.accept.amount) < BigInt(b.accept.amount) ? -1 : 1));
439
+ const elegido = quotes[0];
440
+ // Se desciende el sobre ANTES de firmar: aquí es donde se comprueba que
441
+ // quedan saltos, que hay presupuesto y que no estamos cerrando un ciclo.
442
+ const siguiente = descend(heredado, this.account.address, BigInt(elegido.accept.amount));
443
+ const result = await payAndAsk(wallet, this.account, elegido.endpoint, prompt, {
444
+ ...options,
445
+ maxSpend: tope,
446
+ chainId: chainFor(this.network).id,
447
+ expectedPayee: elegido.agent.address,
448
+ quote: elegido.accept,
449
+ envelope: siguiente,
450
+ });
451
+ return { ...result, agent: elegido.agent.address };
452
+ }
453
+ /**
454
+ * Dónde escucha el x402 de un agente.
455
+ *
456
+ * Se prefiere lo que el propio agente anuncia en su `agent.json`; si no lo
457
+ * anuncia, se prueba la ruta por convención. Así funciona con los agentes que
458
+ * ya están desplegados sin obligarles a actualizarse.
459
+ */
460
+ async x402Endpoint(agent, options = {}, known) {
461
+ const info = known ?? (await this.getAgent(agent));
462
+ const base = info.metadata.botUrl;
463
+ if (!base)
464
+ throw new X402Error(`El agente ${agent} no publica endpoint en su metadata.`);
465
+ try {
466
+ const url = await assertPublicUrl(new URL('/agent.json', base).toString(), options);
467
+ const res = await fetchLimited(url, { timeoutMs: 10_000 });
468
+ if (res.status === 200) {
469
+ const card = JSON.parse(res.text);
470
+ const anunciado = card.endpoints?.x402Ask;
471
+ if (anunciado?.url)
472
+ return anunciado.url;
473
+ if (anunciado?.path)
474
+ return new URL(anunciado.path, base).toString();
475
+ }
476
+ }
477
+ catch {
478
+ /* sin tarjeta o ilegible: se cae a la convención */
479
+ }
480
+ return new URL('/x402/ask', base).toString();
481
+ }
231
482
  /** Retira lo acreditado en una moneda (patrón pull payment). */
232
483
  async withdraw(currency = NATIVE_CURRENCY) {
233
484
  const wallet = this.wallet();
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Panal SDK — el sobre que viaja con una llamada entre agentes.
3
+ *
4
+ * Cuando un agente puede llamar a otro, y ese a otro, aparecen tres problemas
5
+ * que no existen en una llamada suelta:
6
+ *
7
+ * 1. CICLOS. A llama a B, B a C y C a A. Bucle infinito, y aquí cada vuelta
8
+ * cuesta dinero de verdad.
9
+ * 2. PROFUNDIDAD. Sin tope, una cadena se alarga sola y el que empezó paga
10
+ * saltos que nunca autorizó.
11
+ * 3. PRESUPUESTO. Si A tiene 0.01 para gastar y B subcontrata por 0.05, ¿quién
12
+ * lo paga? Sin un límite que viaje con la llamada, nadie sabe cuánto queda.
13
+ *
14
+ * El sobre resuelve los tres con cuatro cabeceras que se propagan hop a hop,
15
+ * como un trace distribuido pero con dinero dentro:
16
+ *
17
+ * X-Panal-Trace: id de la cadena entera, para poder seguirla en los logs
18
+ * X-Panal-Depth: saltos que QUEDAN. Cada agente lo decrementa al delegar
19
+ * X-Panal-Budget: wei disponibles para sub-llamadas, menos lo ya gastado
20
+ * X-Panal-Path: por dónde ha pasado ya, para detectar el ciclo
21
+ *
22
+ * Es deliberadamente sin estado: todo va en la petición. Un agente que se
23
+ * reinicia no pierde la protección, y no hace falta coordinar nada entre ellos.
24
+ *
25
+ * Sobre la confianza: un intermediario malicioso podría borrarse del `path` para
26
+ * provocar un bucle. Puede, pero el bucle lo paga él —cada salto lo abona quien
27
+ * llama—, así que el incentivo va en contra. El sobre protege de cadenas
28
+ * accidentales y de agentes mal escritos, que es de lo que hay que protegerse.
29
+ */
30
+ import type { Address } from 'viem';
31
+ export declare const ENVELOPE_HEADERS: {
32
+ readonly trace: "x-panal-trace";
33
+ readonly depth: "x-panal-depth";
34
+ readonly budget: "x-panal-budget";
35
+ readonly path: "x-panal-path";
36
+ };
37
+ /** Saltos por defecto si quien empieza la cadena no dice otra cosa. */
38
+ export declare const DEFAULT_DEPTH = 3;
39
+ /** Tope duro: ni aunque lo pidan. Acota el coste máximo de una cadena. */
40
+ export declare const MAX_DEPTH = 8;
41
+ export interface CallEnvelope {
42
+ /** Identifica la cadena entera. Solo sirve para seguirla en los logs. */
43
+ trace: string;
44
+ /** Saltos que quedan. 0 = este agente resuelve solo, sin delegar. */
45
+ depth: number;
46
+ /** Unidades mínimas disponibles para sub-llamadas. */
47
+ budget: bigint;
48
+ /** Agentes que ya han atendido esta cadena, en orden. */
49
+ path: Address[];
50
+ }
51
+ export declare class LoopDetected extends Error {
52
+ readonly me: Address;
53
+ readonly trace: string;
54
+ constructor(me: Address, trace: string);
55
+ }
56
+ export declare class DepthExhausted extends Error {
57
+ readonly trace: string;
58
+ constructor(trace: string);
59
+ }
60
+ export declare class BudgetExhausted extends Error {
61
+ readonly available: bigint;
62
+ readonly needed: bigint;
63
+ constructor(available: bigint, needed: bigint);
64
+ }
65
+ /** Abre una cadena nueva. Lo llama quien la empieza, no un intermediario. */
66
+ export declare function newEnvelope(params: {
67
+ budget: bigint;
68
+ depth?: number;
69
+ trace?: string;
70
+ }): CallEnvelope;
71
+ /** Las cabeceras a poner en la petición saliente. */
72
+ export declare function envelopeHeaders(env: CallEnvelope): Record<string, string>;
73
+ /**
74
+ * Lee el sobre de una petición entrante. Devuelve null si no viene ninguno —una
75
+ * llamada suelta de un humano, por ejemplo—, que es un caso legítimo.
76
+ *
77
+ * Nunca lanza: las cabeceras las escribe quien llama, o sea un desconocido, así
78
+ * que todo se sanea en vez de confiar. Un `depth` de un millón se recorta al
79
+ * tope y un path descomunal se trunca.
80
+ */
81
+ export declare function parseEnvelope(headers: Record<string, string | string[] | undefined> | Headers): CallEnvelope | null;
82
+ /**
83
+ * Comprueba, del lado del SERVIDOR, que se puede atender esta llamada.
84
+ *
85
+ * Se llama nada más recibir la petición y antes de trabajar: si es un ciclo,
86
+ * responder costaría dinero a alguien para nada. El código HTTP correcto para
87
+ * rechazarla es 508 Loop Detected.
88
+ */
89
+ export declare function assertCanServe(env: CallEnvelope | null, me: Address): void;
90
+ /**
91
+ * Prepara el sobre para el SIGUIENTE salto, del lado del CLIENTE.
92
+ *
93
+ * Lo llama un agente justo antes de delegar: se añade al path, gasta un salto y
94
+ * descuenta lo que va a pagar. Lanza si no queda profundidad o presupuesto, así
95
+ * que el límite se aplica antes de firmar nada.
96
+ */
97
+ export declare function descend(env: CallEnvelope, me: Address, willSpend: bigint): CallEnvelope;
98
+ /** Cuánto puede gastar este agente en sub-llamadas, según el sobre. */
99
+ export declare function remainingBudget(env: CallEnvelope | null, fallback: bigint): bigint;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Panal SDK — el sobre que viaja con una llamada entre agentes.
3
+ *
4
+ * Cuando un agente puede llamar a otro, y ese a otro, aparecen tres problemas
5
+ * que no existen en una llamada suelta:
6
+ *
7
+ * 1. CICLOS. A llama a B, B a C y C a A. Bucle infinito, y aquí cada vuelta
8
+ * cuesta dinero de verdad.
9
+ * 2. PROFUNDIDAD. Sin tope, una cadena se alarga sola y el que empezó paga
10
+ * saltos que nunca autorizó.
11
+ * 3. PRESUPUESTO. Si A tiene 0.01 para gastar y B subcontrata por 0.05, ¿quién
12
+ * lo paga? Sin un límite que viaje con la llamada, nadie sabe cuánto queda.
13
+ *
14
+ * El sobre resuelve los tres con cuatro cabeceras que se propagan hop a hop,
15
+ * como un trace distribuido pero con dinero dentro:
16
+ *
17
+ * X-Panal-Trace: id de la cadena entera, para poder seguirla en los logs
18
+ * X-Panal-Depth: saltos que QUEDAN. Cada agente lo decrementa al delegar
19
+ * X-Panal-Budget: wei disponibles para sub-llamadas, menos lo ya gastado
20
+ * X-Panal-Path: por dónde ha pasado ya, para detectar el ciclo
21
+ *
22
+ * Es deliberadamente sin estado: todo va en la petición. Un agente que se
23
+ * reinicia no pierde la protección, y no hace falta coordinar nada entre ellos.
24
+ *
25
+ * Sobre la confianza: un intermediario malicioso podría borrarse del `path` para
26
+ * provocar un bucle. Puede, pero el bucle lo paga él —cada salto lo abona quien
27
+ * llama—, así que el incentivo va en contra. El sobre protege de cadenas
28
+ * accidentales y de agentes mal escritos, que es de lo que hay que protegerse.
29
+ */
30
+ import { getAddress, isAddress } from 'viem';
31
+ export const ENVELOPE_HEADERS = {
32
+ trace: 'x-panal-trace',
33
+ depth: 'x-panal-depth',
34
+ budget: 'x-panal-budget',
35
+ path: 'x-panal-path',
36
+ };
37
+ /** Saltos por defecto si quien empieza la cadena no dice otra cosa. */
38
+ export const DEFAULT_DEPTH = 3;
39
+ /** Tope duro: ni aunque lo pidan. Acota el coste máximo de una cadena. */
40
+ export const MAX_DEPTH = 8;
41
+ /** Tope de direcciones en el path, por si llega una cabecera enorme. */
42
+ const MAX_PATH = 16;
43
+ export class LoopDetected extends Error {
44
+ me;
45
+ trace;
46
+ constructor(me, trace) {
47
+ super(`Ciclo detectado: ${me} ya atendió la cadena ${trace}. Se rechaza para no pagarla dos veces.`);
48
+ this.me = me;
49
+ this.trace = trace;
50
+ this.name = 'LoopDetected';
51
+ }
52
+ }
53
+ export class DepthExhausted extends Error {
54
+ trace;
55
+ constructor(trace) {
56
+ super(`Sin saltos disponibles en la cadena ${trace}: hay que resolver sin delegar.`);
57
+ this.trace = trace;
58
+ this.name = 'DepthExhausted';
59
+ }
60
+ }
61
+ export class BudgetExhausted extends Error {
62
+ available;
63
+ needed;
64
+ constructor(available, needed) {
65
+ super(`El presupuesto que queda (${available}) no cubre ${needed}.`);
66
+ this.available = available;
67
+ this.needed = needed;
68
+ this.name = 'BudgetExhausted';
69
+ }
70
+ }
71
+ /** Abre una cadena nueva. Lo llama quien la empieza, no un intermediario. */
72
+ export function newEnvelope(params) {
73
+ return {
74
+ trace: params.trace ?? randomTrace(),
75
+ depth: clampDepth(params.depth ?? DEFAULT_DEPTH),
76
+ budget: params.budget < 0n ? 0n : params.budget,
77
+ path: [],
78
+ };
79
+ }
80
+ /** Las cabeceras a poner en la petición saliente. */
81
+ export function envelopeHeaders(env) {
82
+ return {
83
+ [ENVELOPE_HEADERS.trace]: env.trace,
84
+ [ENVELOPE_HEADERS.depth]: String(env.depth),
85
+ [ENVELOPE_HEADERS.budget]: env.budget.toString(),
86
+ [ENVELOPE_HEADERS.path]: env.path.join(','),
87
+ };
88
+ }
89
+ /**
90
+ * Lee el sobre de una petición entrante. Devuelve null si no viene ninguno —una
91
+ * llamada suelta de un humano, por ejemplo—, que es un caso legítimo.
92
+ *
93
+ * Nunca lanza: las cabeceras las escribe quien llama, o sea un desconocido, así
94
+ * que todo se sanea en vez de confiar. Un `depth` de un millón se recorta al
95
+ * tope y un path descomunal se trunca.
96
+ */
97
+ export function parseEnvelope(headers) {
98
+ const get = (name) => {
99
+ if (typeof headers.get === 'function')
100
+ return headers.get(name) ?? undefined;
101
+ const raw = headers[name];
102
+ return Array.isArray(raw) ? raw[0] : raw;
103
+ };
104
+ const trace = get(ENVELOPE_HEADERS.trace)?.trim();
105
+ if (!trace)
106
+ return null;
107
+ let depth = Number.parseInt(get(ENVELOPE_HEADERS.depth) ?? '', 10);
108
+ if (!Number.isFinite(depth))
109
+ depth = 0;
110
+ let budget = 0n;
111
+ try {
112
+ const raw = get(ENVELOPE_HEADERS.budget)?.trim();
113
+ if (raw)
114
+ budget = BigInt(raw);
115
+ }
116
+ catch {
117
+ budget = 0n; // ilegible = sin presupuesto, que es el lado seguro
118
+ }
119
+ const path = (get(ENVELOPE_HEADERS.path) ?? '')
120
+ .split(',')
121
+ .map((s) => s.trim())
122
+ // `strict: false`: sin esto viem exige checksum y una direccion en
123
+ // minusculas —perfectamente valida, y lo que manda cualquier otra
124
+ // implementacion— se descartaria en silencio. El ciclo dejaria de
125
+ // detectarse justo con los agentes que no son nuestros.
126
+ .filter((s) => isAddress(s, { strict: false }))
127
+ .slice(0, MAX_PATH)
128
+ .map((s) => getAddress(s));
129
+ return {
130
+ trace: trace.slice(0, 128),
131
+ depth: clampDepth(depth),
132
+ budget: budget < 0n ? 0n : budget,
133
+ path,
134
+ };
135
+ }
136
+ /**
137
+ * Comprueba, del lado del SERVIDOR, que se puede atender esta llamada.
138
+ *
139
+ * Se llama nada más recibir la petición y antes de trabajar: si es un ciclo,
140
+ * responder costaría dinero a alguien para nada. El código HTTP correcto para
141
+ * rechazarla es 508 Loop Detected.
142
+ */
143
+ export function assertCanServe(env, me) {
144
+ if (!env)
145
+ return; // sin sobre no hay cadena que vigilar
146
+ const yo = getAddress(me);
147
+ if (env.path.some((a) => getAddress(a) === yo))
148
+ throw new LoopDetected(yo, env.trace);
149
+ }
150
+ /**
151
+ * Prepara el sobre para el SIGUIENTE salto, del lado del CLIENTE.
152
+ *
153
+ * Lo llama un agente justo antes de delegar: se añade al path, gasta un salto y
154
+ * descuenta lo que va a pagar. Lanza si no queda profundidad o presupuesto, así
155
+ * que el límite se aplica antes de firmar nada.
156
+ */
157
+ export function descend(env, me, willSpend) {
158
+ if (env.depth <= 0)
159
+ throw new DepthExhausted(env.trace);
160
+ if (willSpend > env.budget)
161
+ throw new BudgetExhausted(env.budget, willSpend);
162
+ const yo = getAddress(me);
163
+ if (env.path.some((a) => getAddress(a) === yo))
164
+ throw new LoopDetected(yo, env.trace);
165
+ return {
166
+ trace: env.trace,
167
+ depth: env.depth - 1,
168
+ budget: env.budget - willSpend,
169
+ path: [...env.path, yo].slice(-MAX_PATH),
170
+ };
171
+ }
172
+ /** Cuánto puede gastar este agente en sub-llamadas, según el sobre. */
173
+ export function remainingBudget(env, fallback) {
174
+ if (!env)
175
+ return fallback;
176
+ return env.budget < fallback ? env.budget : fallback;
177
+ }
178
+ function clampDepth(depth) {
179
+ if (!Number.isFinite(depth) || depth < 0)
180
+ return 0;
181
+ return Math.min(Math.floor(depth), MAX_DEPTH);
182
+ }
183
+ function randomTrace() {
184
+ const g = globalThis;
185
+ if (typeof g.crypto?.randomUUID === 'function')
186
+ return g.crypto.randomUUID();
187
+ return `panal-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
188
+ }
package/dist/index.d.ts CHANGED
@@ -26,3 +26,9 @@ export type { PanalNetwork, PanalAddresses } from './chains.js';
26
26
  export { TaskStatus, TASK_STATUS_LABEL, formatAgentMetadata, parseAgentMetadata, } from './types.js';
27
27
  export type { Agent, AgentMetadata, Task } from './types.js';
28
28
  export { erc20Abi, escrowAbi, registryAbi } from './abis.js';
29
+ export { X402_SCHEME, X402Error, payAndAsk, quoteAsk } from './x402.js';
30
+ export type { AskResult, PayAndAskOptions, PermitDomain, X402Accept, X402Quote } from './x402.js';
31
+ export { assertPublicUrl, fetchLimited, isPrivateIp } from './net.js';
32
+ export { ENVELOPE_HEADERS, DEFAULT_DEPTH, MAX_DEPTH, BudgetExhausted, DepthExhausted, LoopDetected, assertCanServe, descend, envelopeHeaders, newEnvelope, parseEnvelope, remainingBudget, } from './envelope.js';
33
+ export type { CallEnvelope } from './envelope.js';
34
+ export type { UrlGuardOptions } from './net.js';
package/dist/index.js CHANGED
@@ -23,3 +23,8 @@ export { PanalClient, createPanalClient } from './client.js';
23
23
  export { monad, monadTestnet, addressesFor, chainFor, MAINNET_ADDRESSES, TESTNET_ADDRESSES, NATIVE_CURRENCY, FEE_BPS, AUTO_RELEASE_SECONDS, DISPUTE_TIMEOUT_SECONDS, } from './chains.js';
24
24
  export { TaskStatus, TASK_STATUS_LABEL, formatAgentMetadata, parseAgentMetadata, } from './types.js';
25
25
  export { erc20Abi, escrowAbi, registryAbi } from './abis.js';
26
+ // x402: pagar a otro agente por una consulta, sin escrow y sin humano.
27
+ export { X402_SCHEME, X402Error, payAndAsk, quoteAsk } from './x402.js';
28
+ export { assertPublicUrl, fetchLimited, isPrivateIp } from './net.js';
29
+ // El sobre que viaja entre agentes: profundidad, presupuesto y detección de ciclos.
30
+ export { ENVELOPE_HEADERS, DEFAULT_DEPTH, MAX_DEPTH, BudgetExhausted, DepthExhausted, LoopDetected, assertCanServe, descend, envelopeHeaders, newEnvelope, parseEnvelope, remainingBudget, } from './envelope.js';
package/dist/net.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Panal SDK — validación de las URLs de otros agentes.
3
+ *
4
+ * El endpoint de un agente sale de su metadata on-chain, así que lo escribe un
5
+ * desconocido. Cualquiera puede registrarse con
6
+ * `bot:http://169.254.169.254/latest/meta-data/` y usar tu agente para leer las
7
+ * credenciales de la máquina donde corre. Por eso toda URL ajena pasa por aquí
8
+ * antes de que se le pida nada.
9
+ *
10
+ * Funciona en Node y en el navegador. En Node resuelve el DNS para cazar un
11
+ * dominio que apunte a una IP interna; en el navegador no hay DNS accesible, se
12
+ * queda en la validación de la URL, y tampoco importa tanto: ahí el riesgo de
13
+ * alcanzar la red privada de un servidor no existe.
14
+ */
15
+ /** ¿Esta IP apunta dentro de una red privada o reservada? */
16
+ export declare function isPrivateIp(ip: string): boolean;
17
+ export interface UrlGuardOptions {
18
+ /**
19
+ * Permitir http:// y direcciones privadas. SOLO para desarrollo local: la
20
+ * petición lleva una firma tuya, y en claro la lee cualquiera por el camino.
21
+ */
22
+ allowInsecure?: boolean;
23
+ }
24
+ /**
25
+ * Devuelve la URL si es segura de visitar, o lanza explicando por qué no.
26
+ *
27
+ * Queda una ventana de DNS rebinding —se resuelve aquí y `fetch` vuelve a
28
+ * resolver por su cuenta—. Cerrarla del todo exige un agente HTTP a medida; el
29
+ * riesgo residual es aceptable porque la respuesta nunca se ejecuta.
30
+ */
31
+ export declare function assertPublicUrl(raw: string, options?: UrlGuardOptions): Promise<URL>;
32
+ /**
33
+ * `fetch` con tope de tamaño y de tiempo. La respuesta viene de un servidor
34
+ * ajeno: sin tope, uno hostil se lleva por delante el proceso.
35
+ */
36
+ export declare function fetchLimited(url: URL | string, init?: RequestInit & {
37
+ maxBytes?: number;
38
+ timeoutMs?: number;
39
+ }): Promise<{
40
+ status: number;
41
+ headers: Headers;
42
+ text: string;
43
+ }>;
package/dist/net.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Panal SDK — validación de las URLs de otros agentes.
3
+ *
4
+ * El endpoint de un agente sale de su metadata on-chain, así que lo escribe un
5
+ * desconocido. Cualquiera puede registrarse con
6
+ * `bot:http://169.254.169.254/latest/meta-data/` y usar tu agente para leer las
7
+ * credenciales de la máquina donde corre. Por eso toda URL ajena pasa por aquí
8
+ * antes de que se le pida nada.
9
+ *
10
+ * Funciona en Node y en el navegador. En Node resuelve el DNS para cazar un
11
+ * dominio que apunte a una IP interna; en el navegador no hay DNS accesible, se
12
+ * queda en la validación de la URL, y tampoco importa tanto: ahí el riesgo de
13
+ * alcanzar la red privada de un servidor no existe.
14
+ */
15
+ /** ¿Esta IP apunta dentro de una red privada o reservada? */
16
+ export function isPrivateIp(ip) {
17
+ const v6 = ip.toLowerCase();
18
+ if (v6.includes(':')) {
19
+ if (v6 === '::1' || v6 === '::')
20
+ return true;
21
+ if (/^f[cd]/.test(v6) || v6.startsWith('fe80'))
22
+ return true;
23
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v6);
24
+ return mapped ? isPrivateIp(mapped[1]) : false;
25
+ }
26
+ const parts = ip.split('.').map(Number);
27
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
28
+ return false;
29
+ const [a = 0, b = 0] = parts;
30
+ return (a === 0 ||
31
+ a === 10 ||
32
+ a === 127 ||
33
+ (a === 169 && b === 254) || // metadatos de la nube: credenciales
34
+ (a === 172 && b >= 16 && b <= 31) ||
35
+ (a === 192 && b === 168) ||
36
+ (a === 100 && b >= 64 && b <= 127) || // CGNAT
37
+ a >= 224 // multicast y reservadas
38
+ );
39
+ }
40
+ const LOCAL_NAMES = /^(localhost|.*\.local|.*\.internal|.*\.localhost)$/i;
41
+ /**
42
+ * Devuelve la URL si es segura de visitar, o lanza explicando por qué no.
43
+ *
44
+ * Queda una ventana de DNS rebinding —se resuelve aquí y `fetch` vuelve a
45
+ * resolver por su cuenta—. Cerrarla del todo exige un agente HTTP a medida; el
46
+ * riesgo residual es aceptable porque la respuesta nunca se ejecuta.
47
+ */
48
+ export async function assertPublicUrl(raw, options = {}) {
49
+ let url;
50
+ try {
51
+ url = new URL(raw);
52
+ }
53
+ catch {
54
+ throw new Error(`No es una URL válida: ${raw}`);
55
+ }
56
+ if (url.username || url.password)
57
+ throw new Error('La URL lleva credenciales embebidas: se rechaza.');
58
+ if (url.protocol !== 'https:' && !(options.allowInsecure && url.protocol === 'http:')) {
59
+ throw new Error(`El endpoint tiene que ser https y es ${url.protocol}//`);
60
+ }
61
+ const host = url.hostname.replace(/^\[|\]$/g, '');
62
+ if (options.allowInsecure)
63
+ return url;
64
+ if (LOCAL_NAMES.test(host))
65
+ throw new Error(`La URL apunta a un nombre local (${host}): se rechaza.`);
66
+ // Si el host YA es una IP, se comprueba directamente y no hace falta DNS.
67
+ const isLiteralIp = /^[\d.]+$/.test(host) || host.includes(':');
68
+ if (isLiteralIp) {
69
+ if (isPrivateIp(host))
70
+ throw new Error(`La URL apunta a una dirección interna (${host}): se rechaza.`);
71
+ return url;
72
+ }
73
+ // Resolución DNS solo donde exista. En el navegador se omite a propósito.
74
+ const lookup = await loadDnsLookup();
75
+ if (!lookup)
76
+ return url;
77
+ let addresses;
78
+ try {
79
+ addresses = (await lookup(host, { all: true })).map((r) => r.address);
80
+ }
81
+ catch {
82
+ throw new Error(`No se pudo resolver ${host}.`);
83
+ }
84
+ if (!addresses.length)
85
+ throw new Error(`${host} no resuelve a ninguna dirección.`);
86
+ for (const ip of addresses) {
87
+ if (isPrivateIp(ip))
88
+ throw new Error(`${host} resuelve a una dirección interna (${ip}): se rechaza.`);
89
+ }
90
+ return url;
91
+ }
92
+ /** Carga node:dns si estamos en Node; devuelve null en el navegador. */
93
+ async function loadDnsLookup() {
94
+ try {
95
+ // El import va en una variable para que los empaquetadores de navegador no
96
+ // intenten resolver 'node:dns' de forma estática y fallen al construir.
97
+ const mod = 'node:dns/promises';
98
+ const dns = (await import(/* @vite-ignore */ mod));
99
+ return typeof dns.lookup === 'function' ? dns.lookup : null;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ /**
106
+ * `fetch` con tope de tamaño y de tiempo. La respuesta viene de un servidor
107
+ * ajeno: sin tope, uno hostil se lleva por delante el proceso.
108
+ */
109
+ export async function fetchLimited(url, init = {}) {
110
+ const { maxBytes = 512 * 1024, timeoutMs = 30_000, ...rest } = init;
111
+ const controller = new AbortController();
112
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
113
+ try {
114
+ const res = await fetch(url, { ...rest, signal: controller.signal });
115
+ const reader = res.body?.getReader();
116
+ if (!reader)
117
+ return { status: res.status, headers: res.headers, text: '' };
118
+ const chunks = [];
119
+ let total = 0;
120
+ for (;;) {
121
+ const { done, value } = await reader.read();
122
+ if (done)
123
+ break;
124
+ total += value.byteLength;
125
+ if (total > maxBytes) {
126
+ await reader.cancel();
127
+ throw new Error(`La respuesta pasa de ${Math.round(maxBytes / 1024)} KB: se corta.`);
128
+ }
129
+ chunks.push(value);
130
+ }
131
+ const merged = new Uint8Array(total);
132
+ let offset = 0;
133
+ for (const c of chunks) {
134
+ merged.set(c, offset);
135
+ offset += c.byteLength;
136
+ }
137
+ return { status: res.status, headers: res.headers, text: new TextDecoder().decode(merged) };
138
+ }
139
+ finally {
140
+ clearTimeout(timer);
141
+ }
142
+ }
package/dist/x402.d.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Panal SDK — el lado CLIENTE de x402: pagar a otro agente por una consulta.
3
+ *
4
+ * El bot tenía solo la mitad servidor: sabía cobrar, no pagar. Sin esta mitad,
5
+ * un agente no podía llamar a otro y liquidar al momento, que es lo que hace
6
+ * falta para que se contraten entre ellos sin un humano por medio.
7
+ *
8
+ * El flujo son dos peticiones HTTP:
9
+ *
10
+ * 1. POST sin cabecera de pago → 402 con la cotización (gratis, no compromete
11
+ * a nada). Es la propiedad más útil del protocolo: se puede preguntar el
12
+ * precio a varios candidatos sin gastar un céntimo.
13
+ * 2. Se firma un `permit` EIP-2612 y se repite el POST con `X-Payment`. El
14
+ * agente cobra, trabaja y responde en la misma llamada.
15
+ *
16
+ * El cliente NO paga gas: solo firma. La transacción la manda quien cobra.
17
+ */
18
+ import type { Account, Address, Hex, WalletClient } from 'viem';
19
+ import { type UrlGuardOptions } from './net.js';
20
+ import { type CallEnvelope } from './envelope.js';
21
+ /** El único esquema que entiende este cliente. Debe coincidir con el servidor. */
22
+ export declare const X402_SCHEME = "eip2612-permit";
23
+ export interface PermitDomain {
24
+ name: string;
25
+ version: string;
26
+ chainId: number;
27
+ verifyingContract: Address;
28
+ }
29
+ /** Una forma de pago aceptada, tal y como la publica el 402. */
30
+ export interface X402Accept {
31
+ scheme: string;
32
+ network?: string;
33
+ chainId: number;
34
+ asset: Address;
35
+ assetSymbol?: string;
36
+ amount: string;
37
+ payTo: Address;
38
+ resource?: string;
39
+ description?: string;
40
+ deadline: number;
41
+ maxTimeoutSeconds?: number;
42
+ payerNonce?: string;
43
+ domain: PermitDomain;
44
+ }
45
+ export interface X402Quote {
46
+ x402Version?: number;
47
+ accepts: X402Accept[];
48
+ hint?: string;
49
+ }
50
+ export declare class X402Error extends Error {
51
+ readonly status?: number | undefined;
52
+ constructor(message: string, status?: number | undefined);
53
+ }
54
+ /**
55
+ * Pide la cotización de un endpoint SIN pagar.
56
+ *
57
+ * @param payer Si se indica, el servidor devuelve además el nonce del pagador y
58
+ * nos ahorramos una lectura de la cadena.
59
+ */
60
+ export declare function quoteAsk(endpoint: string, prompt: string, options?: {
61
+ payer?: Address;
62
+ timeoutMs?: number;
63
+ envelope?: CallEnvelope;
64
+ } & UrlGuardOptions): Promise<X402Accept>;
65
+ export interface PayAndAskOptions extends UrlGuardOptions {
66
+ /**
67
+ * Tope de gasto para esta llamada, en unidades mínimas. OBLIGATORIO: el
68
+ * precio lo pone el otro extremo, así que sin tope estarías firmando lo que
69
+ * te pidan.
70
+ */
71
+ maxSpend: bigint;
72
+ /** Cadena esperada. Si la cotización dice otra, se aborta. */
73
+ chainId: number;
74
+ /** Token esperado. Si la cotización pide otro, se aborta. */
75
+ asset?: Address;
76
+ /** Dirección que debe cobrar. Si no coincide con `payTo`, se aborta. */
77
+ expectedPayee?: Address;
78
+ /** Cotización ya obtenida, para no pedirla dos veces. */
79
+ quote?: X402Accept;
80
+ timeoutMs?: number;
81
+ /**
82
+ * Sobre de la cadena. Va ya descendido: quien llama se ha añadido al path y
83
+ * ha gastado su salto. Ver `descend()` en envelope.ts.
84
+ */
85
+ envelope?: CallEnvelope;
86
+ }
87
+ export interface AskResult {
88
+ answer: string;
89
+ /** Lo que se ha pagado de verdad, en unidades mínimas. */
90
+ paid: bigint;
91
+ /** Quién ha cobrado. */
92
+ payee: Address;
93
+ /** Transacción del cobro, si el servidor la reporta. */
94
+ txHash?: Hex;
95
+ endpoint: string;
96
+ }
97
+ /**
98
+ * Paga una consulta a un endpoint x402 y devuelve la respuesta.
99
+ *
100
+ * Todas las comprobaciones van ANTES de firmar. Una firma de permit es una
101
+ * autorización para llevarse tu saldo: si se valida después, ya es tarde.
102
+ */
103
+ export declare function payAndAsk(wallet: WalletClient, account: Account, endpoint: string, prompt: string, options: PayAndAskOptions): Promise<AskResult>;
package/dist/x402.js ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Panal SDK — el lado CLIENTE de x402: pagar a otro agente por una consulta.
3
+ *
4
+ * El bot tenía solo la mitad servidor: sabía cobrar, no pagar. Sin esta mitad,
5
+ * un agente no podía llamar a otro y liquidar al momento, que es lo que hace
6
+ * falta para que se contraten entre ellos sin un humano por medio.
7
+ *
8
+ * El flujo son dos peticiones HTTP:
9
+ *
10
+ * 1. POST sin cabecera de pago → 402 con la cotización (gratis, no compromete
11
+ * a nada). Es la propiedad más útil del protocolo: se puede preguntar el
12
+ * precio a varios candidatos sin gastar un céntimo.
13
+ * 2. Se firma un `permit` EIP-2612 y se repite el POST con `X-Payment`. El
14
+ * agente cobra, trabaja y responde en la misma llamada.
15
+ *
16
+ * El cliente NO paga gas: solo firma. La transacción la manda quien cobra.
17
+ */
18
+ import { isAddress, getAddress } from 'viem';
19
+ import { assertPublicUrl, fetchLimited } from './net.js';
20
+ import { envelopeHeaders } from './envelope.js';
21
+ /** El único esquema que entiende este cliente. Debe coincidir con el servidor. */
22
+ export const X402_SCHEME = 'eip2612-permit';
23
+ const PERMIT_TYPES = {
24
+ Permit: [
25
+ { name: 'owner', type: 'address' },
26
+ { name: 'spender', type: 'address' },
27
+ { name: 'value', type: 'uint256' },
28
+ { name: 'nonce', type: 'uint256' },
29
+ { name: 'deadline', type: 'uint256' },
30
+ ],
31
+ };
32
+ export class X402Error extends Error {
33
+ status;
34
+ constructor(message, status) {
35
+ super(message);
36
+ this.status = status;
37
+ this.name = 'X402Error';
38
+ }
39
+ }
40
+ /**
41
+ * Pide la cotización de un endpoint SIN pagar.
42
+ *
43
+ * @param payer Si se indica, el servidor devuelve además el nonce del pagador y
44
+ * nos ahorramos una lectura de la cadena.
45
+ */
46
+ export async function quoteAsk(endpoint, prompt, options = {}) {
47
+ const url = await assertPublicUrl(endpoint, options);
48
+ const headers = { 'content-type': 'application/json' };
49
+ if (options.payer)
50
+ headers['x-payment-payer'] = options.payer;
51
+ // El sobre viaja tambien al cotizar: si esto ya es un ciclo, mejor que el
52
+ // otro extremo lo diga con un 508 antes de que nadie firme nada.
53
+ if (options.envelope)
54
+ Object.assign(headers, envelopeHeaders(options.envelope));
55
+ const res = await fetchLimited(url, {
56
+ method: 'POST',
57
+ headers,
58
+ body: JSON.stringify({ prompt }),
59
+ timeoutMs: options.timeoutMs ?? 30_000,
60
+ });
61
+ if (res.status !== 402) {
62
+ throw new X402Error(res.status === 404
63
+ ? 'Ese agente no cobra por llamada (no tiene x402 activado).'
64
+ : `Esperaba un 402 con la cotización y respondió ${res.status}.`, res.status);
65
+ }
66
+ let quote;
67
+ try {
68
+ quote = JSON.parse(res.text);
69
+ }
70
+ catch {
71
+ throw new X402Error('La cotización no es JSON válido.');
72
+ }
73
+ const accept = quote.accepts?.find((a) => a.scheme === X402_SCHEME);
74
+ if (!accept) {
75
+ const vistos = quote.accepts?.map((a) => a.scheme).join(', ') || 'ninguno';
76
+ throw new X402Error(`Ese agente no acepta "${X402_SCHEME}". Esquemas que ofrece: ${vistos}.`);
77
+ }
78
+ return accept;
79
+ }
80
+ /**
81
+ * Paga una consulta a un endpoint x402 y devuelve la respuesta.
82
+ *
83
+ * Todas las comprobaciones van ANTES de firmar. Una firma de permit es una
84
+ * autorización para llevarse tu saldo: si se valida después, ya es tarde.
85
+ */
86
+ export async function payAndAsk(wallet, account, endpoint, prompt, options) {
87
+ const url = await assertPublicUrl(endpoint, options);
88
+ const accept = options.quote ?? (await quoteAsk(endpoint, prompt, { payer: account.address, ...options }));
89
+ // ---- Lo que se comprueba antes de firmar --------------------------------
90
+ const amount = BigInt(accept.amount);
91
+ if (amount <= 0n)
92
+ throw new X402Error('La cotización pide un importe de cero o negativo.');
93
+ if (amount > options.maxSpend) {
94
+ throw new X402Error(`Pide ${amount} y tu tope es ${options.maxSpend}: no se firma.`);
95
+ }
96
+ if (accept.chainId !== options.chainId) {
97
+ throw new X402Error(`La cotización es de la cadena ${accept.chainId} y tú estás en la ${options.chainId}.`);
98
+ }
99
+ // Sin `strict: false` se rechazaria un `payTo` en minusculas, que es valido
100
+ // y es lo que devuelve cualquier servidor que no normalice a checksum.
101
+ if (!isAddress(accept.payTo, { strict: false })) {
102
+ throw new X402Error('El `payTo` de la cotización no es una dirección.');
103
+ }
104
+ if (options.expectedPayee && getAddress(accept.payTo) !== getAddress(options.expectedPayee)) {
105
+ // Sin esto, un endpoint secuestrado cobraría a nombre de otro: pagarías al
106
+ // atacante creyendo que pagas al agente que elegiste.
107
+ throw new X402Error(`La cotización cobra a ${accept.payTo} y esperabas a ${options.expectedPayee}: no se firma.`);
108
+ }
109
+ if (options.asset && getAddress(accept.asset) !== getAddress(options.asset)) {
110
+ throw new X402Error(`La cotización pide pagar en ${accept.asset} y esperabas ${options.asset}.`);
111
+ }
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.');
116
+ }
117
+ const ahora = Math.floor(Date.now() / 1000);
118
+ if (accept.deadline <= ahora + 30) {
119
+ throw new X402Error('La cotización caduca de inmediato: pide otra.');
120
+ }
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.');
123
+ }
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
+ }));
145
+ // ---- Segunda llamada: se cobra y se responde en la misma ----------------
146
+ const res = await fetchLimited(url, {
147
+ method: 'POST',
148
+ headers: {
149
+ 'content-type': 'application/json',
150
+ 'x-payment': header,
151
+ ...(options.envelope ? envelopeHeaders(options.envelope) : {}),
152
+ },
153
+ body: JSON.stringify({ prompt }),
154
+ timeoutMs: options.timeoutMs ?? (accept.maxTimeoutSeconds ?? 120) * 1000,
155
+ });
156
+ let body;
157
+ try {
158
+ body = JSON.parse(res.text);
159
+ }
160
+ catch {
161
+ throw new X402Error(`Respuesta ilegible del agente (HTTP ${res.status}).`, res.status);
162
+ }
163
+ if (res.status !== 200) {
164
+ // El 502 con `paymentTx` es el caso feo y hay que distinguirlo: te han
165
+ // 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);
168
+ }
169
+ if (res.status === 508) {
170
+ throw new X402Error(`El agente rechazó la llamada por ciclo: ya había atendido esta cadena. ${body.error ?? ''}`.trim(), 508);
171
+ }
172
+ throw new X402Error(body.error ?? `El agente respondió ${res.status}.`, res.status);
173
+ }
174
+ if (typeof body.answer !== 'string' || !body.answer) {
175
+ throw new X402Error('El agente respondió 200 pero sin `answer`.');
176
+ }
177
+ return {
178
+ answer: body.answer,
179
+ paid: amount,
180
+ payee: getAddress(accept.payTo),
181
+ txHash: body.payment?.txHash,
182
+ endpoint: url.toString(),
183
+ };
184
+ }
185
+ /** base64 sin depender de Buffer, para que valga también en el navegador. */
186
+ function toBase64(text) {
187
+ const bytes = new TextEncoder().encode(text);
188
+ let binary = '';
189
+ for (const b of bytes)
190
+ binary += String.fromCharCode(b);
191
+ return typeof btoa === 'function'
192
+ ? btoa(binary)
193
+ : // eslint-disable-next-line @typescript-eslint/no-explicit-any
194
+ globalThis.Buffer.from(text, 'utf8').toString('base64');
195
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panal/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.4.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,6 +45,6 @@
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"
48
+ "test": "tsx test/envelope.test.ts && tsx test/sdk.test.ts && tsx test/x402.test.ts"
49
49
  }
50
50
  }