@panal/sdk 0.2.0 → 0.5.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/client.d.ts +72 -2
- package/dist/client.js +137 -0
- package/dist/envelope.d.ts +99 -0
- package/dist/envelope.js +188 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/net.d.ts +43 -0
- package/dist/net.js +142 -0
- package/dist/x402-server.d.ts +171 -0
- package/dist/x402-server.js +348 -0
- package/dist/x402.d.ts +103 -0
- package/dist/x402.js +195 -0
- package/package.json +2 -2
package/dist/client.d.ts
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* Sin configuración apunta a Monad mainnet, que es donde Panal está desplegado
|
|
16
16
|
* y en uso: el caso de "quiero probar esto ahora" no debería exigir un .env.
|
|
17
17
|
*/
|
|
18
|
-
import type { Account, Address, Hex, PublicClient } from 'viem';
|
|
18
|
+
import type { Account, Address, Hex, PublicClient, WalletClient } 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
22
|
import { TaskStatus, type Agent, type AgentMetadata, type Task } from './types.js';
|
|
21
23
|
export interface PanalClientOptions {
|
|
@@ -54,7 +56,12 @@ export declare class PanalClient {
|
|
|
54
56
|
readonly addresses: PanalAddresses;
|
|
55
57
|
readonly publicClient: PublicClient;
|
|
56
58
|
readonly account?: Account;
|
|
57
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Público a propósito: quien monte la mitad servidor de x402 lo necesita para
|
|
61
|
+
* ejecutar el `permit` y el `transferFrom` del cobro. Es undefined cuando el
|
|
62
|
+
* cliente se creó sin cuenta, o sea en modo solo lectura.
|
|
63
|
+
*/
|
|
64
|
+
readonly walletClient?: WalletClient;
|
|
58
65
|
constructor(options?: PanalClientOptions);
|
|
59
66
|
/** El wallet client, o un error que dice exactamente qué falta. */
|
|
60
67
|
private wallet;
|
|
@@ -140,6 +147,69 @@ export declare class PanalClient {
|
|
|
140
147
|
limit?: number;
|
|
141
148
|
status?: TaskStatus;
|
|
142
149
|
}): Promise<Task[]>;
|
|
150
|
+
/**
|
|
151
|
+
* Pregunta el precio de un agente sin pagar nada.
|
|
152
|
+
*
|
|
153
|
+
* Es gratis y no compromete: puedes cotizar a varios candidatos y decidir.
|
|
154
|
+
*/
|
|
155
|
+
quoteAgent(agent: Address, prompt: string, options?: {
|
|
156
|
+
allowInsecure?: boolean;
|
|
157
|
+
}): Promise<X402Accept>;
|
|
158
|
+
/**
|
|
159
|
+
* Paga a un agente concreto por una consulta y devuelve su respuesta.
|
|
160
|
+
*
|
|
161
|
+
* `maxSpend` es obligatorio: el precio lo pone el otro extremo, así que sin
|
|
162
|
+
* tope estarías firmando lo que te pidan.
|
|
163
|
+
*/
|
|
164
|
+
askAgent(agent: Address, prompt: string, options: {
|
|
165
|
+
maxSpend: bigint;
|
|
166
|
+
quote?: X402Accept;
|
|
167
|
+
allowInsecure?: boolean;
|
|
168
|
+
timeoutMs?: number;
|
|
169
|
+
}): Promise<AskResult>;
|
|
170
|
+
/**
|
|
171
|
+
* Busca un agente con esa skill, negocia el precio y le paga por la consulta.
|
|
172
|
+
*
|
|
173
|
+
* Es la operación que hace de Panal algo más que un directorio: una llamada
|
|
174
|
+
* a función que cruza una frontera económica.
|
|
175
|
+
*
|
|
176
|
+
* const respuesta = await panal.ask('traducción', 'traduce esto', {
|
|
177
|
+
* maxSpend: parseEther('0.01'),
|
|
178
|
+
* });
|
|
179
|
+
*
|
|
180
|
+
* Cotiza a los candidatos —gratis, con el 402— y se queda con el más barato
|
|
181
|
+
* que quepa en el presupuesto. Los que no cobran por llamada o no responden
|
|
182
|
+
* se descartan sin ruido: que un agente esté caído no debe tumbar al que
|
|
183
|
+
* pregunta.
|
|
184
|
+
*/
|
|
185
|
+
ask(skill: string, prompt: string, options: {
|
|
186
|
+
maxSpend: bigint;
|
|
187
|
+
/** Cuántos candidatos se cotizan como mucho. Cada uno es una petición. */
|
|
188
|
+
maxCandidates?: number;
|
|
189
|
+
/** Descartar a estos (evita que un agente se llame a sí mismo). */
|
|
190
|
+
exclude?: Address[];
|
|
191
|
+
allowInsecure?: boolean;
|
|
192
|
+
timeoutMs?: number;
|
|
193
|
+
/**
|
|
194
|
+
* Sobre recibido, si este agente está atendiendo una llamada de otro.
|
|
195
|
+
* Sin él se abre una cadena nueva. Con él, se hereda lo que quede de
|
|
196
|
+
* profundidad y presupuesto, que es lo que impide que A→B→C→A se coma
|
|
197
|
+
* el dinero dando vueltas.
|
|
198
|
+
*/
|
|
199
|
+
envelope?: CallEnvelope | null;
|
|
200
|
+
/** Saltos permitidos al abrir una cadena nueva. */
|
|
201
|
+
depth?: number;
|
|
202
|
+
}): Promise<AskResult & {
|
|
203
|
+
agent: Address;
|
|
204
|
+
}>;
|
|
205
|
+
/**
|
|
206
|
+
* Dónde escucha el x402 de un agente.
|
|
207
|
+
*
|
|
208
|
+
* Se prefiere lo que el propio agente anuncia en su `agent.json`; si no lo
|
|
209
|
+
* anuncia, se prueba la ruta por convención. Así funciona con los agentes que
|
|
210
|
+
* ya están desplegados sin obligarles a actualizarse.
|
|
211
|
+
*/
|
|
212
|
+
private x402Endpoint;
|
|
143
213
|
/** Retira lo acreditado en una moneda (patrón pull payment). */
|
|
144
214
|
withdraw(currency?: Address): Promise<Hex>;
|
|
145
215
|
/** Comprueba el saldo antes de firmar, para fallar con un mensaje legible. */
|
package/dist/client.js
CHANGED
|
@@ -17,6 +17,9 @@
|
|
|
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
24
|
import { TaskStatus, formatAgentMetadata, parseAgentMetadata, } from './types.js';
|
|
22
25
|
/** Cuántos agentes se leen por llamada al registry. */
|
|
@@ -28,6 +31,11 @@ export class PanalClient {
|
|
|
28
31
|
addresses;
|
|
29
32
|
publicClient;
|
|
30
33
|
account;
|
|
34
|
+
/**
|
|
35
|
+
* Público a propósito: quien monte la mitad servidor de x402 lo necesita para
|
|
36
|
+
* ejecutar el `permit` y el `transferFrom` del cobro. Es undefined cuando el
|
|
37
|
+
* cliente se creó sin cuenta, o sea en modo solo lectura.
|
|
38
|
+
*/
|
|
31
39
|
walletClient;
|
|
32
40
|
constructor(options = {}) {
|
|
33
41
|
this.network = options.network ?? 'mainnet';
|
|
@@ -347,6 +355,135 @@ export class PanalClient {
|
|
|
347
355
|
}
|
|
348
356
|
return found;
|
|
349
357
|
}
|
|
358
|
+
// -------------------------------------------------------------------------
|
|
359
|
+
// Llamar a otro agente y pagarle al momento (x402).
|
|
360
|
+
//
|
|
361
|
+
// Esto es lo que permite que un agente contrate a otro sin humano de por
|
|
362
|
+
// medio. A diferencia del escrow, aquí no hay tarea, ni plazo, ni disputa:
|
|
363
|
+
// se paga y se responde en la misma llamada. Por eso vale para consultas de
|
|
364
|
+
// céntimos y NO vale para un encargo serio.
|
|
365
|
+
// -------------------------------------------------------------------------
|
|
366
|
+
/**
|
|
367
|
+
* Pregunta el precio de un agente sin pagar nada.
|
|
368
|
+
*
|
|
369
|
+
* Es gratis y no compromete: puedes cotizar a varios candidatos y decidir.
|
|
370
|
+
*/
|
|
371
|
+
async quoteAgent(agent, prompt, options = {}) {
|
|
372
|
+
const endpoint = await this.x402Endpoint(agent, options);
|
|
373
|
+
return quoteAsk(endpoint, prompt, { payer: this.account?.address, ...options });
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Paga a un agente concreto por una consulta y devuelve su respuesta.
|
|
377
|
+
*
|
|
378
|
+
* `maxSpend` es obligatorio: el precio lo pone el otro extremo, así que sin
|
|
379
|
+
* tope estarías firmando lo que te pidan.
|
|
380
|
+
*/
|
|
381
|
+
async askAgent(agent, prompt, options) {
|
|
382
|
+
const wallet = this.wallet();
|
|
383
|
+
const endpoint = await this.x402Endpoint(agent, options);
|
|
384
|
+
return payAndAsk(wallet, this.account, endpoint, prompt, {
|
|
385
|
+
...options,
|
|
386
|
+
chainId: chainFor(this.network).id,
|
|
387
|
+
// Se ata a quién esperamos pagar: si el endpoint estuviera secuestrado y
|
|
388
|
+
// cotizara a nombre de otro, la firma no llega a producirse.
|
|
389
|
+
expectedPayee: agent,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Busca un agente con esa skill, negocia el precio y le paga por la consulta.
|
|
394
|
+
*
|
|
395
|
+
* Es la operación que hace de Panal algo más que un directorio: una llamada
|
|
396
|
+
* a función que cruza una frontera económica.
|
|
397
|
+
*
|
|
398
|
+
* const respuesta = await panal.ask('traducción', 'traduce esto', {
|
|
399
|
+
* maxSpend: parseEther('0.01'),
|
|
400
|
+
* });
|
|
401
|
+
*
|
|
402
|
+
* Cotiza a los candidatos —gratis, con el 402— y se queda con el más barato
|
|
403
|
+
* que quepa en el presupuesto. Los que no cobran por llamada o no responden
|
|
404
|
+
* se descartan sin ruido: que un agente esté caído no debe tumbar al que
|
|
405
|
+
* pregunta.
|
|
406
|
+
*/
|
|
407
|
+
async ask(skill, prompt, options) {
|
|
408
|
+
const wallet = this.wallet();
|
|
409
|
+
const excluded = new Set([...(options.exclude ?? []), this.account.address].map((a) => getAddress(a).toLowerCase()));
|
|
410
|
+
// El presupuesto real es el menor entre lo que dice el sobre y el tope de
|
|
411
|
+
// esta llamada: heredar una cadena no puede ampliar lo que autorizaste.
|
|
412
|
+
const heredado = options.envelope ?? newEnvelope({ budget: options.maxSpend, depth: options.depth });
|
|
413
|
+
const tope = remainingBudget(options.envelope ?? null, options.maxSpend);
|
|
414
|
+
if (tope <= 0n)
|
|
415
|
+
throw new X402Error('El presupuesto de la cadena está agotado: no se puede delegar más.');
|
|
416
|
+
const candidates = (await this.searchAgents(skill))
|
|
417
|
+
.filter((a) => !excluded.has(a.address.toLowerCase()) && a.metadata.botUrl)
|
|
418
|
+
.slice(0, options.maxCandidates ?? 5);
|
|
419
|
+
if (!candidates.length)
|
|
420
|
+
throw new X402Error(`Ningún agente activo con la skill "${skill}" publica endpoint.`);
|
|
421
|
+
const quotes = [];
|
|
422
|
+
const rechazos = [];
|
|
423
|
+
for (const agent of candidates) {
|
|
424
|
+
try {
|
|
425
|
+
const endpoint = await this.x402Endpoint(agent.address, options, agent);
|
|
426
|
+
const accept = await quoteAsk(endpoint, prompt, {
|
|
427
|
+
payer: this.account.address,
|
|
428
|
+
...options,
|
|
429
|
+
envelope: heredado,
|
|
430
|
+
});
|
|
431
|
+
if (BigInt(accept.amount) <= tope)
|
|
432
|
+
quotes.push({ agent, endpoint, accept });
|
|
433
|
+
else
|
|
434
|
+
rechazos.push(`${agent.metadata.name || agent.address}: pide ${accept.amount}, por encima del tope`);
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
rechazos.push(`${agent.metadata.name || agent.address}: ${err instanceof Error ? err.message : err}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (!quotes.length) {
|
|
441
|
+
throw new X402Error(`Ningún agente de "${skill}" pudo cotizar dentro del presupuesto.\n ${rechazos.join('\n ')}`);
|
|
442
|
+
}
|
|
443
|
+
quotes.sort((a, b) => (BigInt(a.accept.amount) < BigInt(b.accept.amount) ? -1 : 1));
|
|
444
|
+
const elegido = quotes[0];
|
|
445
|
+
// Se desciende el sobre ANTES de firmar: aquí es donde se comprueba que
|
|
446
|
+
// quedan saltos, que hay presupuesto y que no estamos cerrando un ciclo.
|
|
447
|
+
const siguiente = descend(heredado, this.account.address, BigInt(elegido.accept.amount));
|
|
448
|
+
const result = await payAndAsk(wallet, this.account, elegido.endpoint, prompt, {
|
|
449
|
+
...options,
|
|
450
|
+
maxSpend: tope,
|
|
451
|
+
chainId: chainFor(this.network).id,
|
|
452
|
+
expectedPayee: elegido.agent.address,
|
|
453
|
+
quote: elegido.accept,
|
|
454
|
+
envelope: siguiente,
|
|
455
|
+
});
|
|
456
|
+
return { ...result, agent: elegido.agent.address };
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Dónde escucha el x402 de un agente.
|
|
460
|
+
*
|
|
461
|
+
* Se prefiere lo que el propio agente anuncia en su `agent.json`; si no lo
|
|
462
|
+
* anuncia, se prueba la ruta por convención. Así funciona con los agentes que
|
|
463
|
+
* ya están desplegados sin obligarles a actualizarse.
|
|
464
|
+
*/
|
|
465
|
+
async x402Endpoint(agent, options = {}, known) {
|
|
466
|
+
const info = known ?? (await this.getAgent(agent));
|
|
467
|
+
const base = info.metadata.botUrl;
|
|
468
|
+
if (!base)
|
|
469
|
+
throw new X402Error(`El agente ${agent} no publica endpoint en su metadata.`);
|
|
470
|
+
try {
|
|
471
|
+
const url = await assertPublicUrl(new URL('/agent.json', base).toString(), options);
|
|
472
|
+
const res = await fetchLimited(url, { timeoutMs: 10_000 });
|
|
473
|
+
if (res.status === 200) {
|
|
474
|
+
const card = JSON.parse(res.text);
|
|
475
|
+
const anunciado = card.endpoints?.x402Ask;
|
|
476
|
+
if (anunciado?.url)
|
|
477
|
+
return anunciado.url;
|
|
478
|
+
if (anunciado?.path)
|
|
479
|
+
return new URL(anunciado.path, base).toString();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
/* sin tarjeta o ilegible: se cae a la convención */
|
|
484
|
+
}
|
|
485
|
+
return new URL('/x402/ask', base).toString();
|
|
486
|
+
}
|
|
350
487
|
/** Retira lo acreditado en una moneda (patrón pull payment). */
|
|
351
488
|
async withdraw(currency = NATIVE_CURRENCY) {
|
|
352
489
|
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;
|
package/dist/envelope.js
ADDED
|
@@ -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,11 @@ 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 { X402_VERSION, X402_SERVER_SCHEME, buildQuote, enqueueByPayer, parsePaymentHeader, permitNonce, permitTypedData, readPermitDomain, resourceId, splitSignature, verifyAndSettle, } from './x402-server.js';
|
|
33
|
+
export type { SettleDeps, SettleResult, X402Payment, X402ServerAccept, X402ServerQuote, } from './x402-server.js';
|
|
34
|
+
export { ENVELOPE_HEADERS, DEFAULT_DEPTH, MAX_DEPTH, BudgetExhausted, DepthExhausted, LoopDetected, assertCanServe, descend, envelopeHeaders, newEnvelope, parseEnvelope, remainingBudget, } from './envelope.js';
|
|
35
|
+
export type { CallEnvelope } from './envelope.js';
|
|
36
|
+
export type { UrlGuardOptions } from './net.js';
|
package/dist/index.js
CHANGED
|
@@ -23,3 +23,11 @@ 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
|
+
// x402: la otra mitad, cobrar por llamada. Portada del bot de LexPanal, donde
|
|
30
|
+
// lleva meses cobrando en produccion.
|
|
31
|
+
export { X402_VERSION, X402_SERVER_SCHEME, buildQuote, enqueueByPayer, parsePaymentHeader, permitNonce, permitTypedData, readPermitDomain, resourceId, splitSignature, verifyAndSettle, } from './x402-server.js';
|
|
32
|
+
// El sobre que viaja entre agentes: profundidad, presupuesto y detección de ciclos.
|
|
33
|
+
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
|
+
}>;
|