@panal/sdk 0.4.0 → 0.6.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 CHANGED
@@ -15,7 +15,7 @@
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
19
  import { type AskResult, type X402Accept } from './x402.js';
20
20
  import { type CallEnvelope } from './envelope.js';
21
21
  import { type PanalAddresses, type PanalNetwork } from './chains.js';
@@ -56,7 +56,12 @@ export declare class PanalClient {
56
56
  readonly addresses: PanalAddresses;
57
57
  readonly publicClient: PublicClient;
58
58
  readonly account?: Account;
59
- private readonly walletClient?;
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;
60
65
  constructor(options?: PanalClientOptions);
61
66
  /** El wallet client, o un error que dice exactamente qué falta. */
62
67
  private wallet;
package/dist/client.js CHANGED
@@ -31,6 +31,11 @@ export class PanalClient {
31
31
  addresses;
32
32
  publicClient;
33
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
+ */
34
39
  walletClient;
35
40
  constructor(options = {}) {
36
41
  this.network = options.network ?? 'mainnet';
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Panal SDK — entregar archivos sin renunciar a la garantía de la cadena.
3
+ *
4
+ * El escrow ancla `keccak256(texto de la entrega)` y nada más. Eso funciona
5
+ * para un informe escrito, pero no para un PDF, un audio o un vídeo: no caben
6
+ * en un string y meterlos en la cadena costaría más que el trabajo.
7
+ *
8
+ * La salida fácil sería entregar un enlace. Es una trampa: el hash anclado
9
+ * cubriría el enlace, no el archivo. Quien lo aloja puede cambiar el PDF
10
+ * después de cobrar y el cliente no tiene con qué demostrar nada. Un enlace
11
+ * pelado convierte el escrow en una promesa.
12
+ *
13
+ * Lo que se hace aquí es anclar el HASH DEL ARCHIVO dentro del texto:
14
+ *
15
+ * Aquí tienes el informe que pediste.
16
+ *
17
+ * [panal-files/1]
18
+ * name: informe.pdf
19
+ * size: 184320
20
+ * mime: application/pdf
21
+ * hash: 0x8f3a…
22
+ * path: /files/31/informe.pdf
23
+ *
24
+ * Ese texto entero es lo que se ancla. La cadena de custodia queda cerrada:
25
+ *
26
+ * resultHash on-chain → texto de la entrega → hash del archivo → bytes
27
+ *
28
+ * Cada eslabón se comprueba sin fiarse de nadie. El servidor que sirve el
29
+ * archivo deja de ser de confianza: puede ser el agente, un S3 o un CDN, y si
30
+ * los bytes no dan el hash pactado, la descarga falla.
31
+ *
32
+ * `path` se resuelve contra el `botUrl` que el agente tiene REGISTRADO
33
+ * on-chain, no contra algo que venga en el texto. Así un agente no puede
34
+ * mandar a su cliente a un tercero. `url` absoluta existe para quien aloja
35
+ * fuera, y es igual de segura porque la garantía la da el hash, no el sitio.
36
+ */
37
+ import type { Hex } from 'viem';
38
+ import { type UrlGuardOptions } from './net.js';
39
+ /** Cabecera del bloque. Lleva versión porque el formato se ancla en la cadena. */
40
+ export declare const FILES_BLOCK = "[panal-files/1]";
41
+ /** Tope por defecto de una descarga: 25 MB. */
42
+ export declare const MAX_FILE_BYTES: number;
43
+ /** Un archivo anunciado en la entrega. */
44
+ export interface DeliveredFile {
45
+ /** Nombre del archivo, sin rutas. */
46
+ name: string;
47
+ /** Tamaño en bytes. Se comprueba junto al hash. */
48
+ size: number;
49
+ /** Tipo MIME, si el agente lo declaró. */
50
+ mime?: string;
51
+ /** keccak256 de los bytes. Es la garantía; todo lo demás es logística. */
52
+ hash: Hex;
53
+ /** Ruta relativa, a resolver contra el botUrl registrado del agente. */
54
+ path?: string;
55
+ /** URL absoluta, para quien aloja fuera de su propio servidor. */
56
+ url?: string;
57
+ }
58
+ export declare class FileVerificationError extends Error {
59
+ readonly file: string;
60
+ constructor(message: string, file: string);
61
+ }
62
+ /**
63
+ * Limpia un nombre de archivo para que no pueda salirse de su carpeta.
64
+ *
65
+ * El nombre viaja en el texto de la entrega y acaba usado como ruta en disco y
66
+ * en una URL. Sin esto, un `../../.env` como nombre convierte una entrega en
67
+ * una lectura arbitraria del servidor del agente.
68
+ */
69
+ export declare function sanitizeFileName(name: string): string;
70
+ /**
71
+ * Construye el bloque del manifiesto.
72
+ *
73
+ * El orden de las claves es fijo y las líneas van con `\n`: este texto se
74
+ * anclará en la cadena, así que dos ejecuciones con los mismos archivos tienen
75
+ * que dar exactamente los mismos bytes.
76
+ */
77
+ export declare function buildFilesManifest(files: DeliveredFile[]): string;
78
+ /** El texto de la entrega con el manifiesto pegado al final. */
79
+ export declare function appendFilesManifest(text: string, files: DeliveredFile[]): string;
80
+ /**
81
+ * Lee los archivos anunciados en el texto de una entrega.
82
+ *
83
+ * Nunca lanza por un bloque mal formado: devuelve los que sí se entienden. Un
84
+ * manifiesto roto no puede impedirle al cliente leer la parte escrita de su
85
+ * entrega, que ya pagó.
86
+ */
87
+ export declare function parseFilesManifest(text: string): DeliveredFile[];
88
+ /**
89
+ * El texto de la entrega sin los bloques del manifiesto.
90
+ *
91
+ * Para enseñárselo a una persona: el manifiesto es para la máquina, y en un
92
+ * chat de Telegram no aporta más que ruido.
93
+ */
94
+ export declare function stripFilesManifest(text: string): string;
95
+ /** Comprueba unos bytes contra lo que el manifiesto prometía. Lanza si no cuadra. */
96
+ export declare function verifyFileBytes(file: DeliveredFile, bytes: Uint8Array): void;
97
+ /**
98
+ * De dónde se baja un archivo.
99
+ *
100
+ * `path` se resuelve contra el `botUrl` que el agente publica EN EL REGISTRY,
101
+ * no contra nada que venga en el texto: si el agente pudiera elegir el host, un
102
+ * agente comprometido mandaría a su cliente donde quisiera. La URL absoluta se
103
+ * permite porque el hash la vigila igual, pero pasa por el guardia de SSRF.
104
+ */
105
+ export declare function fileUrl(file: DeliveredFile, baseUrl: string | undefined): string;
106
+ export interface DownloadOptions extends UrlGuardOptions {
107
+ /** Base para las rutas relativas: el botUrl registrado del agente. */
108
+ baseUrl?: string;
109
+ /** Wallet del cliente; el agente solo entrega a quien pagó. */
110
+ address?: string;
111
+ /** Firma de `Panal resultado #<taskId>`, la misma que abre `/result/:id`. */
112
+ signature?: string;
113
+ maxBytes?: number;
114
+ timeoutMs?: number;
115
+ }
116
+ /**
117
+ * Baja un archivo de una entrega y lo verifica contra el hash anclado.
118
+ *
119
+ * Si los bytes no dan el hash, lanza en vez de devolverlos. Devolver un archivo
120
+ * que no cuadra "avisando" no serviría de nada: quien llama lo guardaría igual.
121
+ */
122
+ export declare function downloadDeliveredFile(file: DeliveredFile, options?: DownloadOptions): Promise<Uint8Array>;
package/dist/files.js ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Panal SDK — entregar archivos sin renunciar a la garantía de la cadena.
3
+ *
4
+ * El escrow ancla `keccak256(texto de la entrega)` y nada más. Eso funciona
5
+ * para un informe escrito, pero no para un PDF, un audio o un vídeo: no caben
6
+ * en un string y meterlos en la cadena costaría más que el trabajo.
7
+ *
8
+ * La salida fácil sería entregar un enlace. Es una trampa: el hash anclado
9
+ * cubriría el enlace, no el archivo. Quien lo aloja puede cambiar el PDF
10
+ * después de cobrar y el cliente no tiene con qué demostrar nada. Un enlace
11
+ * pelado convierte el escrow en una promesa.
12
+ *
13
+ * Lo que se hace aquí es anclar el HASH DEL ARCHIVO dentro del texto:
14
+ *
15
+ * Aquí tienes el informe que pediste.
16
+ *
17
+ * [panal-files/1]
18
+ * name: informe.pdf
19
+ * size: 184320
20
+ * mime: application/pdf
21
+ * hash: 0x8f3a…
22
+ * path: /files/31/informe.pdf
23
+ *
24
+ * Ese texto entero es lo que se ancla. La cadena de custodia queda cerrada:
25
+ *
26
+ * resultHash on-chain → texto de la entrega → hash del archivo → bytes
27
+ *
28
+ * Cada eslabón se comprueba sin fiarse de nadie. El servidor que sirve el
29
+ * archivo deja de ser de confianza: puede ser el agente, un S3 o un CDN, y si
30
+ * los bytes no dan el hash pactado, la descarga falla.
31
+ *
32
+ * `path` se resuelve contra el `botUrl` que el agente tiene REGISTRADO
33
+ * on-chain, no contra algo que venga en el texto. Así un agente no puede
34
+ * mandar a su cliente a un tercero. `url` absoluta existe para quien aloja
35
+ * fuera, y es igual de segura porque la garantía la da el hash, no el sitio.
36
+ */
37
+ import { keccak256 } from 'viem';
38
+ import { assertPublicUrl, fetchBytesLimited } from './net.js';
39
+ /** Cabecera del bloque. Lleva versión porque el formato se ancla en la cadena. */
40
+ export const FILES_BLOCK = '[panal-files/1]';
41
+ /** Tope por defecto de una descarga: 25 MB. */
42
+ export const MAX_FILE_BYTES = 25 * 1024 * 1024;
43
+ export class FileVerificationError extends Error {
44
+ file;
45
+ constructor(message, file) {
46
+ super(message);
47
+ this.file = file;
48
+ this.name = 'FileVerificationError';
49
+ }
50
+ }
51
+ /**
52
+ * Limpia un nombre de archivo para que no pueda salirse de su carpeta.
53
+ *
54
+ * El nombre viaja en el texto de la entrega y acaba usado como ruta en disco y
55
+ * en una URL. Sin esto, un `../../.env` como nombre convierte una entrega en
56
+ * una lectura arbitraria del servidor del agente.
57
+ */
58
+ export function sanitizeFileName(name) {
59
+ // Se corta por cualquier separador y se coge el último tramo: así `a/b/c.pdf`
60
+ // y `..\\..\\c.pdf` acaban los dos en `c.pdf`.
61
+ const base = name.split(/[/\\]/).pop() ?? '';
62
+ const limpio = base
63
+ .replace(/[\u0000-\u001f\u007f]/g, '') // caracteres de control
64
+ .replace(/^\.+/, '') // nada de nombres que empiezan por punto: '..' incluido
65
+ .trim();
66
+ if (!limpio)
67
+ throw new Error(`Nombre de archivo inservible: "${name}"`);
68
+ return limpio.slice(0, 120);
69
+ }
70
+ /**
71
+ * Construye el bloque del manifiesto.
72
+ *
73
+ * El orden de las claves es fijo y las líneas van con `\n`: este texto se
74
+ * anclará en la cadena, así que dos ejecuciones con los mismos archivos tienen
75
+ * que dar exactamente los mismos bytes.
76
+ */
77
+ export function buildFilesManifest(files) {
78
+ return files
79
+ .map((f) => {
80
+ const lineas = [FILES_BLOCK, `name: ${sanitizeFileName(f.name)}`, `size: ${f.size}`];
81
+ if (f.mime)
82
+ lineas.push(`mime: ${f.mime}`);
83
+ lineas.push(`hash: ${f.hash}`);
84
+ if (f.path)
85
+ lineas.push(`path: ${f.path}`);
86
+ if (f.url)
87
+ lineas.push(`url: ${f.url}`);
88
+ return lineas.join('\n');
89
+ })
90
+ .join('\n\n');
91
+ }
92
+ /** El texto de la entrega con el manifiesto pegado al final. */
93
+ export function appendFilesManifest(text, files) {
94
+ if (files.length === 0)
95
+ return text;
96
+ const cuerpo = text.trimEnd();
97
+ return `${cuerpo}\n\n${buildFilesManifest(files)}\n`;
98
+ }
99
+ /**
100
+ * Lee los archivos anunciados en el texto de una entrega.
101
+ *
102
+ * Nunca lanza por un bloque mal formado: devuelve los que sí se entienden. Un
103
+ * manifiesto roto no puede impedirle al cliente leer la parte escrita de su
104
+ * entrega, que ya pagó.
105
+ */
106
+ export function parseFilesManifest(text) {
107
+ const out = [];
108
+ const lineas = text.split(/\r?\n/);
109
+ for (let i = 0; i < lineas.length; i++) {
110
+ if (lineas[i].trim() !== FILES_BLOCK)
111
+ continue;
112
+ const campos = {};
113
+ for (let j = i + 1; j < lineas.length; j++) {
114
+ const linea = lineas[j];
115
+ if (!linea.trim() || linea.trim() === FILES_BLOCK)
116
+ break;
117
+ const sep = linea.indexOf(':');
118
+ if (sep === -1)
119
+ break;
120
+ campos[linea.slice(0, sep).trim().toLowerCase()] = linea.slice(sep + 1).trim();
121
+ }
122
+ const { name, size, hash, mime, path, url } = campos;
123
+ if (!name || !hash || !/^0x[0-9a-fA-F]{64}$/.test(hash))
124
+ continue;
125
+ const bytes = Number(size);
126
+ if (!Number.isInteger(bytes) || bytes < 0)
127
+ continue;
128
+ if (!path && !url)
129
+ continue;
130
+ try {
131
+ out.push({
132
+ name: sanitizeFileName(name),
133
+ size: bytes,
134
+ hash: hash.toLowerCase(),
135
+ ...(mime ? { mime } : {}),
136
+ ...(path ? { path } : {}),
137
+ ...(url ? { url } : {}),
138
+ });
139
+ }
140
+ catch {
141
+ // Nombre inservible: se descarta ese archivo, no la entrega entera.
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ /**
147
+ * El texto de la entrega sin los bloques del manifiesto.
148
+ *
149
+ * Para enseñárselo a una persona: el manifiesto es para la máquina, y en un
150
+ * chat de Telegram no aporta más que ruido.
151
+ */
152
+ export function stripFilesManifest(text) {
153
+ const lineas = text.split(/\r?\n/);
154
+ const fuera = [];
155
+ let dentro = false;
156
+ for (const linea of lineas) {
157
+ if (linea.trim() === FILES_BLOCK) {
158
+ dentro = true;
159
+ continue;
160
+ }
161
+ if (dentro) {
162
+ // El bloque termina en la primera línea que ya no es `clave: valor`.
163
+ if (linea.trim() && linea.includes(':'))
164
+ continue;
165
+ dentro = false;
166
+ }
167
+ fuera.push(linea);
168
+ }
169
+ return fuera.join('\n').trimEnd();
170
+ }
171
+ /** Comprueba unos bytes contra lo que el manifiesto prometía. Lanza si no cuadra. */
172
+ export function verifyFileBytes(file, bytes) {
173
+ if (bytes.byteLength !== file.size) {
174
+ throw new FileVerificationError(`"${file.name}" mide ${bytes.byteLength} bytes y la entrega anunciaba ${file.size}.`, file.name);
175
+ }
176
+ const real = keccak256(bytes);
177
+ if (real.toLowerCase() !== file.hash.toLowerCase()) {
178
+ throw new FileVerificationError(`"${file.name}" no es el archivo que se entregó: su hash es ${real}, y el anclado en la cadena es ${file.hash}.`, file.name);
179
+ }
180
+ }
181
+ /**
182
+ * De dónde se baja un archivo.
183
+ *
184
+ * `path` se resuelve contra el `botUrl` que el agente publica EN EL REGISTRY,
185
+ * no contra nada que venga en el texto: si el agente pudiera elegir el host, un
186
+ * agente comprometido mandaría a su cliente donde quisiera. La URL absoluta se
187
+ * permite porque el hash la vigila igual, pero pasa por el guardia de SSRF.
188
+ */
189
+ export function fileUrl(file, baseUrl) {
190
+ if (file.url)
191
+ return file.url;
192
+ if (!file.path)
193
+ throw new Error(`La entrega anuncia "${file.name}" sin decir de dónde bajarlo.`);
194
+ if (!baseUrl) {
195
+ throw new Error(`"${file.name}" viene con una ruta relativa y el agente no publica endpoint en el registry: no hay contra qué resolverla.`);
196
+ }
197
+ return new URL(file.path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
198
+ }
199
+ /**
200
+ * Baja un archivo de una entrega y lo verifica contra el hash anclado.
201
+ *
202
+ * Si los bytes no dan el hash, lanza en vez de devolverlos. Devolver un archivo
203
+ * que no cuadra "avisando" no serviría de nada: quien llama lo guardaría igual.
204
+ */
205
+ export async function downloadDeliveredFile(file, options = {}) {
206
+ const destino = new URL(fileUrl(file, options.baseUrl));
207
+ if (options.address)
208
+ destino.searchParams.set('address', options.address);
209
+ if (options.signature)
210
+ destino.searchParams.set('signature', options.signature);
211
+ await assertPublicUrl(destino.toString(), options);
212
+ // El tope se ata al tamaño ANUNCIADO, no al de por defecto: si el manifiesto
213
+ // dice 2 MB, no hay razón para dejar que lleguen 25.
214
+ const tope = Math.min(options.maxBytes ?? MAX_FILE_BYTES, Math.max(file.size, 1) + 1024);
215
+ const { status, bytes } = await fetchBytesLimited(destino, {
216
+ maxBytes: tope,
217
+ timeoutMs: options.timeoutMs ?? 120_000,
218
+ redirect: 'error',
219
+ });
220
+ if (status !== 200) {
221
+ throw new FileVerificationError(`El agente respondió ${status} al pedirle "${file.name}".`, file.name);
222
+ }
223
+ verifyFileBytes(file, bytes);
224
+ return bytes;
225
+ }
package/dist/index.d.ts CHANGED
@@ -28,7 +28,11 @@ export type { Agent, AgentMetadata, Task } from './types.js';
28
28
  export { erc20Abi, escrowAbi, registryAbi } from './abis.js';
29
29
  export { X402_SCHEME, X402Error, payAndAsk, quoteAsk } from './x402.js';
30
30
  export type { AskResult, PayAndAskOptions, PermitDomain, X402Accept, X402Quote } from './x402.js';
31
- export { assertPublicUrl, fetchLimited, isPrivateIp } from './net.js';
31
+ export { assertPublicUrl, fetchBytesLimited, fetchLimited, isPrivateIp } from './net.js';
32
+ export { FILES_BLOCK, MAX_FILE_BYTES, FileVerificationError, appendFilesManifest, buildFilesManifest, downloadDeliveredFile, fileUrl, parseFilesManifest, sanitizeFileName, stripFilesManifest, verifyFileBytes, } from './files.js';
33
+ export type { DeliveredFile, DownloadOptions } from './files.js';
34
+ export { X402_VERSION, X402_SERVER_SCHEME, buildQuote, enqueueByPayer, parsePaymentHeader, permitNonce, permitTypedData, readPermitDomain, resourceId, splitSignature, verifyAndSettle, } from './x402-server.js';
35
+ export type { SettleDeps, SettleResult, X402Payment, X402ServerAccept, X402ServerQuote, } from './x402-server.js';
32
36
  export { ENVELOPE_HEADERS, DEFAULT_DEPTH, MAX_DEPTH, BudgetExhausted, DepthExhausted, LoopDetected, assertCanServe, descend, envelopeHeaders, newEnvelope, parseEnvelope, remainingBudget, } from './envelope.js';
33
37
  export type { CallEnvelope } from './envelope.js';
34
38
  export type { UrlGuardOptions } from './net.js';
package/dist/index.js CHANGED
@@ -25,6 +25,11 @@ export { TaskStatus, TASK_STATUS_LABEL, formatAgentMetadata, parseAgentMetadata,
25
25
  export { erc20Abi, escrowAbi, registryAbi } from './abis.js';
26
26
  // x402: pagar a otro agente por una consulta, sin escrow y sin humano.
27
27
  export { X402_SCHEME, X402Error, payAndAsk, quoteAsk } from './x402.js';
28
- export { assertPublicUrl, fetchLimited, isPrivateIp } from './net.js';
28
+ export { assertPublicUrl, fetchBytesLimited, fetchLimited, isPrivateIp } from './net.js';
29
+ // Archivos: entregar un PDF o un vídeo anclando SU hash, no el del enlace.
30
+ export { FILES_BLOCK, MAX_FILE_BYTES, FileVerificationError, appendFilesManifest, buildFilesManifest, downloadDeliveredFile, fileUrl, parseFilesManifest, sanitizeFileName, stripFilesManifest, verifyFileBytes, } from './files.js';
31
+ // x402: la otra mitad, cobrar por llamada. Portada del bot de LexPanal, donde
32
+ // lleva meses cobrando en produccion.
33
+ export { X402_VERSION, X402_SERVER_SCHEME, buildQuote, enqueueByPayer, parsePaymentHeader, permitNonce, permitTypedData, readPermitDomain, resourceId, splitSignature, verifyAndSettle, } from './x402-server.js';
29
34
  // El sobre que viaja entre agentes: profundidad, presupuesto y detección de ciclos.
30
35
  export { ENVELOPE_HEADERS, DEFAULT_DEPTH, MAX_DEPTH, BudgetExhausted, DepthExhausted, LoopDetected, assertCanServe, descend, envelopeHeaders, newEnvelope, parseEnvelope, remainingBudget, } from './envelope.js';
package/dist/net.d.ts CHANGED
@@ -30,9 +30,21 @@ export interface UrlGuardOptions {
30
30
  */
31
31
  export declare function assertPublicUrl(raw: string, options?: UrlGuardOptions): Promise<URL>;
32
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.
33
+ * `fetch` con tope de tamaño y de tiempo, devolviendo los bytes crudos.
34
+ *
35
+ * La respuesta viene de un servidor ajeno: sin tope, uno hostil se lleva por
36
+ * delante el proceso. El tope se aplica MIENTRAS se lee, no al final, para que
37
+ * un cuerpo infinito se corte en cuanto pasa del límite y no cuando ya no cabe.
35
38
  */
39
+ export declare function fetchBytesLimited(url: URL | string, init?: RequestInit & {
40
+ maxBytes?: number;
41
+ timeoutMs?: number;
42
+ }): Promise<{
43
+ status: number;
44
+ headers: Headers;
45
+ bytes: Uint8Array;
46
+ }>;
47
+ /** Lo mismo, decodificando el cuerpo como texto UTF-8. */
36
48
  export declare function fetchLimited(url: URL | string, init?: RequestInit & {
37
49
  maxBytes?: number;
38
50
  timeoutMs?: number;
package/dist/net.js CHANGED
@@ -103,10 +103,13 @@ async function loadDnsLookup() {
103
103
  }
104
104
  }
105
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.
106
+ * `fetch` con tope de tamaño y de tiempo, devolviendo los bytes crudos.
107
+ *
108
+ * La respuesta viene de un servidor ajeno: sin tope, uno hostil se lleva por
109
+ * delante el proceso. El tope se aplica MIENTRAS se lee, no al final, para que
110
+ * un cuerpo infinito se corte en cuanto pasa del límite y no cuando ya no cabe.
108
111
  */
109
- export async function fetchLimited(url, init = {}) {
112
+ export async function fetchBytesLimited(url, init = {}) {
110
113
  const { maxBytes = 512 * 1024, timeoutMs = 30_000, ...rest } = init;
111
114
  const controller = new AbortController();
112
115
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -114,7 +117,7 @@ export async function fetchLimited(url, init = {}) {
114
117
  const res = await fetch(url, { ...rest, signal: controller.signal });
115
118
  const reader = res.body?.getReader();
116
119
  if (!reader)
117
- return { status: res.status, headers: res.headers, text: '' };
120
+ return { status: res.status, headers: res.headers, bytes: new Uint8Array(0) };
118
121
  const chunks = [];
119
122
  let total = 0;
120
123
  for (;;) {
@@ -134,9 +137,14 @@ export async function fetchLimited(url, init = {}) {
134
137
  merged.set(c, offset);
135
138
  offset += c.byteLength;
136
139
  }
137
- return { status: res.status, headers: res.headers, text: new TextDecoder().decode(merged) };
140
+ return { status: res.status, headers: res.headers, bytes: merged };
138
141
  }
139
142
  finally {
140
143
  clearTimeout(timer);
141
144
  }
142
145
  }
146
+ /** Lo mismo, decodificando el cuerpo como texto UTF-8. */
147
+ export async function fetchLimited(url, init = {}) {
148
+ const { status, headers, bytes } = await fetchBytesLimited(url, init);
149
+ return { status, headers, text: new TextDecoder().decode(bytes) };
150
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * x402 — la mitad SERVIDOR: cobrar por llamada HTTP.
3
+ *
4
+ * El otro archivo, `x402.ts`, es la mitad cliente: pide presupuesto y paga.
5
+ * Este es el lado del que cobra.
6
+ *
7
+ * 1. Llega una petición sin pagar. POST /x402/ask
8
+ * 2. Se responde 402 con un presupuesto legible por máquina. (buildQuote)
9
+ * 3. El cliente FIRMA una autorización de pago, sin gas ni transacción.
10
+ * 4. Repite la petición con la firma en la cabecera X-Payment.
11
+ * 5. Se cobra on-chain y se sirve el recurso en esa misma llamada. (verifyAndSettle)
12
+ *
13
+ * Sin alta, sin API key, sin tarjeta: **el pago es la autenticación**. Un
14
+ * desconocido puede usar tu servicio y pagarlo sin que ninguno de los dos sepa
15
+ * quién es el otro.
16
+ *
17
+ * POR QUÉ EXIGE UNA CADENA
18
+ * Cobrar dos milésimas por llamada es imposible con tarjeta: la comisión fija
19
+ * (~0,30 $) multiplica por cien el importe. En Monad la comisión es una
20
+ * fracción de céntimo, así que el micropago sale. Es la única pieza de Panal
21
+ * donde la cadena es requisito y no decoración.
22
+ *
23
+ * ESQUEMA: eip2612-permit
24
+ * El cliente autoriza el cobro con una firma off-chain —gratis e instantánea— y
25
+ * el agente paga el gas de ejecutarla. Sin `permit` harían falta dos
26
+ * transacciones del cliente y el modelo entero se cae. Por eso este raíl solo
27
+ * funciona con un ERC-20 que implemente EIP-2612, no con la moneda nativa.
28
+ *
29
+ * SE COBRA ANTES DE SERVIR. El orden es deliberado: si se sirviera primero y el
30
+ * cobro fallara, el recurso se habría regalado.
31
+ *
32
+ * Los textos que salen por la red van en inglés a propósito: los lee un
33
+ * desconocido de cualquier parte, no el operador del agente.
34
+ *
35
+ * Portado del bot de LexPanal, donde llevaba meses cobrando en producción.
36
+ */
37
+ import { type Address, type Hex, type PublicClient, type WalletClient } from 'viem';
38
+ import { type PanalNetwork } from './chains.js';
39
+ import type { PermitDomain } from './x402.js';
40
+ export type { PermitDomain };
41
+ export declare const X402_VERSION = 1;
42
+ export declare const X402_SERVER_SCHEME = "eip2612-permit";
43
+ export declare function permitTypedData(domain: PermitDomain, message: {
44
+ owner: Address;
45
+ spender: Address;
46
+ value: bigint;
47
+ nonce: bigint;
48
+ deadline: bigint;
49
+ }): {
50
+ domain: PermitDomain;
51
+ types: {
52
+ readonly Permit: readonly [{
53
+ readonly name: "owner";
54
+ readonly type: "address";
55
+ }, {
56
+ readonly name: "spender";
57
+ readonly type: "address";
58
+ }, {
59
+ readonly name: "value";
60
+ readonly type: "uint256";
61
+ }, {
62
+ readonly name: "nonce";
63
+ readonly type: "uint256";
64
+ }, {
65
+ readonly name: "deadline";
66
+ readonly type: "uint256";
67
+ }];
68
+ };
69
+ primaryType: "Permit";
70
+ message: {
71
+ owner: Address;
72
+ spender: Address;
73
+ value: bigint;
74
+ nonce: bigint;
75
+ deadline: bigint;
76
+ };
77
+ };
78
+ /**
79
+ * Lee el dominio EIP-712 del token en cadena (ERC-5267), con respaldo a
80
+ * `name()` + version "1", que es lo que usan las implementaciones habituales.
81
+ *
82
+ * Se lee de la cadena en vez de escribirlo a mano porque un dominio mal
83
+ * construido produce firmas que verifican en local y revierten al llegar al
84
+ * contrato: el fallo aparecería solo en producción y con dinero de por medio.
85
+ */
86
+ export declare function readPermitDomain(publicClient: PublicClient, token: Address, network?: PanalNetwork): Promise<PermitDomain>;
87
+ /** Nonce de permit actual del pagador. Cambia con cada pago consumido. */
88
+ export declare function permitNonce(publicClient: PublicClient, token: Address, owner: Address): Promise<bigint>;
89
+ export interface X402ServerAccept {
90
+ scheme: typeof X402_SERVER_SCHEME;
91
+ network: string;
92
+ chainId: number;
93
+ asset: Address;
94
+ assetSymbol: string;
95
+ amount: string;
96
+ payTo: Address;
97
+ resource: string;
98
+ description: string;
99
+ deadline: number;
100
+ maxTimeoutSeconds: number;
101
+ /** Nonce del pagador, si dijo quién era con la cabecera X-Payment-Payer. */
102
+ payerNonce?: string;
103
+ /** Dominio EIP-712 con el que firmar, para no obligar al cliente a leerlo. */
104
+ domain: PermitDomain;
105
+ }
106
+ export interface X402ServerQuote {
107
+ x402Version: typeof X402_VERSION;
108
+ accepts: X402ServerAccept[];
109
+ /** Ayuda para quien lo lea a mano; los clientes usan `accepts`. */
110
+ hint: string;
111
+ }
112
+ export declare function buildQuote(params: {
113
+ asset: Address;
114
+ assetSymbol: string;
115
+ amount: bigint;
116
+ payTo: Address;
117
+ resource: string;
118
+ description: string;
119
+ domain: PermitDomain;
120
+ payerNonce?: bigint;
121
+ network?: PanalNetwork;
122
+ nowS?: number;
123
+ }): X402ServerQuote;
124
+ export interface X402Payment {
125
+ scheme: string;
126
+ payer: Address;
127
+ value: bigint;
128
+ deadline: bigint;
129
+ signature: Hex;
130
+ }
131
+ /** Descodifica y valida la forma de X-Payment. Nunca lanza: devuelve el motivo. */
132
+ export declare function parsePaymentHeader(header: string): {
133
+ ok: true;
134
+ payment: X402Payment;
135
+ } | {
136
+ ok: false;
137
+ error: string;
138
+ };
139
+ /** Parte la firma de 65 bytes en (v, r, s) para pasársela a `permit`. */
140
+ export declare function splitSignature(signature: Hex): {
141
+ v: number;
142
+ r: Hex;
143
+ s: Hex;
144
+ };
145
+ export interface SettleDeps {
146
+ publicClient: PublicClient;
147
+ walletClient: WalletClient | null;
148
+ token: Address;
149
+ domain: PermitDomain;
150
+ /** Quien cobra: la wallet del agente, que es también el spender del permit. */
151
+ payee: Address;
152
+ network?: PanalNetwork;
153
+ }
154
+ export type SettleResult = {
155
+ ok: true;
156
+ txHash: Hex;
157
+ amount: bigint;
158
+ } | {
159
+ ok: false;
160
+ status: number;
161
+ error: string;
162
+ };
163
+ /** Exportada para poder probar la serialización directamente. */
164
+ export declare function enqueueByPayer<T>(payer: Address, fn: () => Promise<T>): Promise<T>;
165
+ /**
166
+ * Verifica la firma y cobra on-chain. Devuelve el hash de la transacción.
167
+ * El recurso NO debe servirse hasta que esto salga bien.
168
+ */
169
+ export declare function verifyAndSettle(deps: SettleDeps, payment: X402Payment, price: bigint): Promise<SettleResult>;
170
+ /** Identificador estable del recurso pagado, para trazas y recibos. */
171
+ export declare function resourceId(method: string, path: string, body: string): Hex;
@@ -0,0 +1,348 @@
1
+ /**
2
+ * x402 — la mitad SERVIDOR: cobrar por llamada HTTP.
3
+ *
4
+ * El otro archivo, `x402.ts`, es la mitad cliente: pide presupuesto y paga.
5
+ * Este es el lado del que cobra.
6
+ *
7
+ * 1. Llega una petición sin pagar. POST /x402/ask
8
+ * 2. Se responde 402 con un presupuesto legible por máquina. (buildQuote)
9
+ * 3. El cliente FIRMA una autorización de pago, sin gas ni transacción.
10
+ * 4. Repite la petición con la firma en la cabecera X-Payment.
11
+ * 5. Se cobra on-chain y se sirve el recurso en esa misma llamada. (verifyAndSettle)
12
+ *
13
+ * Sin alta, sin API key, sin tarjeta: **el pago es la autenticación**. Un
14
+ * desconocido puede usar tu servicio y pagarlo sin que ninguno de los dos sepa
15
+ * quién es el otro.
16
+ *
17
+ * POR QUÉ EXIGE UNA CADENA
18
+ * Cobrar dos milésimas por llamada es imposible con tarjeta: la comisión fija
19
+ * (~0,30 $) multiplica por cien el importe. En Monad la comisión es una
20
+ * fracción de céntimo, así que el micropago sale. Es la única pieza de Panal
21
+ * donde la cadena es requisito y no decoración.
22
+ *
23
+ * ESQUEMA: eip2612-permit
24
+ * El cliente autoriza el cobro con una firma off-chain —gratis e instantánea— y
25
+ * el agente paga el gas de ejecutarla. Sin `permit` harían falta dos
26
+ * transacciones del cliente y el modelo entero se cae. Por eso este raíl solo
27
+ * funciona con un ERC-20 que implemente EIP-2612, no con la moneda nativa.
28
+ *
29
+ * SE COBRA ANTES DE SERVIR. El orden es deliberado: si se sirviera primero y el
30
+ * cobro fallara, el recurso se habría regalado.
31
+ *
32
+ * Los textos que salen por la red van en inglés a propósito: los lee un
33
+ * desconocido de cualquier parte, no el operador del agente.
34
+ *
35
+ * Portado del bot de LexPanal, donde llevaba meses cobrando en producción.
36
+ */
37
+ import { getAddress, hexToNumber, isAddress, isHex, keccak256, slice, toHex, verifyTypedData, } from 'viem';
38
+ import { chainFor } from './chains.js';
39
+ import { erc20Abi } from './abis.js';
40
+ export const X402_VERSION = 1;
41
+ export const X402_SERVER_SCHEME = 'eip2612-permit';
42
+ /** Margen mínimo de vigencia que se exige a la firma al llegar. */
43
+ const MIN_DEADLINE_MARGIN_S = 30;
44
+ /** Vigencia que se sugiere en el presupuesto. */
45
+ const QUOTE_TTL_S = 300;
46
+ // ---------------------------------------------------------------------------
47
+ // EIP-2612: datos tipados de `permit`.
48
+ // ---------------------------------------------------------------------------
49
+ const PERMIT_TYPES = {
50
+ Permit: [
51
+ { name: 'owner', type: 'address' },
52
+ { name: 'spender', type: 'address' },
53
+ { name: 'value', type: 'uint256' },
54
+ { name: 'nonce', type: 'uint256' },
55
+ { name: 'deadline', type: 'uint256' },
56
+ ],
57
+ };
58
+ export function permitTypedData(domain, message) {
59
+ return { domain, types: PERMIT_TYPES, primaryType: 'Permit', message };
60
+ }
61
+ /**
62
+ * Lee el dominio EIP-712 del token en cadena (ERC-5267), con respaldo a
63
+ * `name()` + version "1", que es lo que usan las implementaciones habituales.
64
+ *
65
+ * Se lee de la cadena en vez de escribirlo a mano porque un dominio mal
66
+ * construido produce firmas que verifican en local y revierten al llegar al
67
+ * contrato: el fallo aparecería solo en producción y con dinero de por medio.
68
+ */
69
+ export async function readPermitDomain(publicClient, token, network = 'mainnet') {
70
+ try {
71
+ const d = (await publicClient.readContract({
72
+ address: token,
73
+ abi: [
74
+ {
75
+ type: 'function',
76
+ name: 'eip712Domain',
77
+ stateMutability: 'view',
78
+ inputs: [],
79
+ outputs: [
80
+ { name: 'fields', type: 'bytes1' },
81
+ { name: 'name', type: 'string' },
82
+ { name: 'version', type: 'string' },
83
+ { name: 'chainId', type: 'uint256' },
84
+ { name: 'verifyingContract', type: 'address' },
85
+ { name: 'salt', type: 'bytes32' },
86
+ { name: 'extensions', type: 'uint256[]' },
87
+ ],
88
+ },
89
+ ],
90
+ functionName: 'eip712Domain',
91
+ }));
92
+ return { name: d[1], version: d[2], chainId: Number(d[3]), verifyingContract: getAddress(d[4]) };
93
+ }
94
+ catch {
95
+ const name = (await publicClient.readContract({
96
+ address: token,
97
+ abi: tokenExtraAbi,
98
+ functionName: 'name',
99
+ }));
100
+ return { name, version: '1', chainId: chainFor(network).id, verifyingContract: getAddress(token) };
101
+ }
102
+ }
103
+ /** Nonce de permit actual del pagador. Cambia con cada pago consumido. */
104
+ export async function permitNonce(publicClient, token, owner) {
105
+ return publicClient.readContract({
106
+ address: token,
107
+ abi: [
108
+ {
109
+ type: 'function',
110
+ name: 'nonces',
111
+ stateMutability: 'view',
112
+ inputs: [{ name: 'owner', type: 'address' }],
113
+ outputs: [{ name: '', type: 'uint256' }],
114
+ },
115
+ ],
116
+ functionName: 'nonces',
117
+ args: [owner],
118
+ });
119
+ }
120
+ export function buildQuote(params) {
121
+ const now = params.nowS ?? Math.floor(Date.now() / 1000);
122
+ return {
123
+ x402Version: X402_VERSION,
124
+ accepts: [
125
+ {
126
+ scheme: X402_SERVER_SCHEME,
127
+ network: 'monad',
128
+ chainId: chainFor(params.network ?? 'mainnet').id,
129
+ asset: params.asset,
130
+ assetSymbol: params.assetSymbol,
131
+ amount: params.amount.toString(),
132
+ payTo: params.payTo,
133
+ resource: params.resource,
134
+ description: params.description,
135
+ deadline: now + QUOTE_TTL_S,
136
+ maxTimeoutSeconds: 120,
137
+ payerNonce: params.payerNonce?.toString(),
138
+ domain: params.domain,
139
+ },
140
+ ],
141
+ hint: 'Sign an EIP-2612 permit with the domain and fields of accepts[0] (spender = payTo, ' +
142
+ 'value = amount, nonce = your current token nonce, deadline <= the one given) and repeat the ' +
143
+ 'request with the header X-Payment: base64({scheme,payer,value,deadline,signature}).',
144
+ };
145
+ }
146
+ /** Descodifica y valida la forma de X-Payment. Nunca lanza: devuelve el motivo. */
147
+ export function parsePaymentHeader(header) {
148
+ // Validación explícita: Buffer.from(…, 'base64') NO lanza con entrada
149
+ // inválida, se limita a ignorar los caracteres que no reconoce. Sin esta
150
+ // comprobación, una cabecera basura llegaba al JSON.parse y el error que se
151
+ // devolvía culpaba al JSON en vez de al base64.
152
+ const encoded = header.trim();
153
+ if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(encoded)) {
154
+ return { ok: false, error: 'the X-Payment header is not base64' };
155
+ }
156
+ const json = Buffer.from(encoded, 'base64').toString('utf8');
157
+ let raw;
158
+ try {
159
+ raw = JSON.parse(json);
160
+ }
161
+ catch {
162
+ return { ok: false, error: 'the contents of X-Payment are not JSON' };
163
+ }
164
+ const { scheme, payer, value, deadline, signature } = raw;
165
+ if (typeof scheme !== 'string' || scheme !== X402_SERVER_SCHEME) {
166
+ return { ok: false, error: `unsupported scheme (expected "${X402_SERVER_SCHEME}")` };
167
+ }
168
+ if (typeof payer !== 'string' || !isAddress(payer))
169
+ return { ok: false, error: 'payer is not an address' };
170
+ if (typeof signature !== 'string' || !isHex(signature) || signature.length !== 132) {
171
+ return { ok: false, error: 'signature must be a 65-byte hex signature' };
172
+ }
173
+ let valueBig;
174
+ let deadlineBig;
175
+ try {
176
+ valueBig = BigInt(String(value));
177
+ deadlineBig = BigInt(String(deadline));
178
+ }
179
+ catch {
180
+ return { ok: false, error: 'value and deadline must be integers' };
181
+ }
182
+ if (valueBig <= 0n)
183
+ return { ok: false, error: 'value must be greater than zero' };
184
+ return {
185
+ ok: true,
186
+ payment: { scheme, payer: getAddress(payer), value: valueBig, deadline: deadlineBig, signature },
187
+ };
188
+ }
189
+ /** Parte la firma de 65 bytes en (v, r, s) para pasársela a `permit`. */
190
+ export function splitSignature(signature) {
191
+ const r = slice(signature, 0, 32);
192
+ const s = slice(signature, 32, 64);
193
+ let v = hexToNumber(slice(signature, 64, 65));
194
+ if (v < 27)
195
+ v += 27; // algunas wallets firman con 0/1
196
+ return { v, r, s };
197
+ }
198
+ /**
199
+ * Cola por pagador.
200
+ *
201
+ * ESTA ES LA TRAMPA DEL ESQUEMA. El nonce de EIP-2612 es SECUENCIAL por
202
+ * dirección: si el mismo cliente lanza dos llamadas en paralelo, ambas firman
203
+ * con el nonce N y solo una puede consumirse; la otra revierte en cadena —y
204
+ * para entonces ya le habríamos servido el recurso—. Serializando por pagador,
205
+ * cada cobro lee el nonce actualizado y firma sobre él.
206
+ *
207
+ * Los pagadores distintos siguen yendo en paralelo: la cola es por dirección.
208
+ */
209
+ const payerQueues = new Map();
210
+ /** Exportada para poder probar la serialización directamente. */
211
+ export function enqueueByPayer(payer, fn) {
212
+ const key = payer.toLowerCase();
213
+ const prev = payerQueues.get(key) ?? Promise.resolve();
214
+ const next = prev.then(fn, fn);
215
+ // La cola guarda la promesa "apagada" para que un fallo no la rompa.
216
+ payerQueues.set(key, next.then(() => undefined, () => undefined));
217
+ void next.catch(() => undefined);
218
+ return next;
219
+ }
220
+ /**
221
+ * Verifica la firma y cobra on-chain. Devuelve el hash de la transacción.
222
+ * El recurso NO debe servirse hasta que esto salga bien.
223
+ */
224
+ export async function verifyAndSettle(deps, payment, price) {
225
+ if (payment.value < price) {
226
+ return { ok: false, status: 402, error: `the payment (${payment.value}) is less than the price (${price})` };
227
+ }
228
+ const nowS = BigInt(Math.floor(Date.now() / 1000));
229
+ if (payment.deadline < nowS + BigInt(MIN_DEADLINE_MARGIN_S)) {
230
+ return { ok: false, status: 402, error: 'the payment authorization has expired or has too little margin left' };
231
+ }
232
+ if (!deps.walletClient) {
233
+ return { ok: false, status: 503, error: 'the agent has no wallet to execute the charge' };
234
+ }
235
+ const chain = chainFor(deps.network ?? 'mainnet');
236
+ return enqueueByPayer(payment.payer, async () => {
237
+ // El nonce se lee DENTRO de la cola: si otro pago del mismo pagador acaba
238
+ // de consumirse, aquí ya se ve el valor nuevo.
239
+ const nonce = await permitNonce(deps.publicClient, deps.token, payment.payer);
240
+ const valid = await verifyTypedData({
241
+ address: payment.payer,
242
+ ...permitTypedData(deps.domain, {
243
+ owner: payment.payer,
244
+ spender: deps.payee,
245
+ value: payment.value,
246
+ nonce,
247
+ deadline: payment.deadline,
248
+ }),
249
+ signature: payment.signature,
250
+ }).catch(() => false);
251
+ if (!valid) {
252
+ return {
253
+ ok: false,
254
+ status: 402,
255
+ error: `the signature is not a valid permit from ${payment.payer} ` +
256
+ `(current nonce ${nonce}; if you signed with another, ask for a new quote)`,
257
+ };
258
+ }
259
+ const balance = (await deps.publicClient.readContract({
260
+ address: deps.token,
261
+ abi: erc20Abi,
262
+ functionName: 'balanceOf',
263
+ args: [payment.payer],
264
+ }));
265
+ if (balance < payment.value) {
266
+ return {
267
+ ok: false,
268
+ status: 402,
269
+ error: `payer balance is not enough (${balance} < ${payment.value})`,
270
+ };
271
+ }
272
+ const { v, r, s } = splitSignature(payment.signature);
273
+ const wallet = deps.walletClient;
274
+ // permit + transferFrom. Se simula antes para no quemar gas en una
275
+ // transacción condenada (firma consumida, deadline pasado, saldo movido).
276
+ await deps.publicClient.simulateContract({
277
+ address: deps.token,
278
+ abi: permitAbi,
279
+ functionName: 'permit',
280
+ args: [payment.payer, deps.payee, payment.value, payment.deadline, v, r, s],
281
+ account: wallet.account,
282
+ });
283
+ const permitTx = await wallet.writeContract({
284
+ address: deps.token,
285
+ abi: permitAbi,
286
+ functionName: 'permit',
287
+ args: [payment.payer, deps.payee, payment.value, payment.deadline, v, r, s],
288
+ account: wallet.account,
289
+ chain,
290
+ });
291
+ await deps.publicClient.waitForTransactionReceipt({ hash: permitTx });
292
+ const transferTx = await wallet.writeContract({
293
+ address: deps.token,
294
+ abi: tokenExtraAbi,
295
+ functionName: 'transferFrom',
296
+ args: [payment.payer, deps.payee, payment.value],
297
+ account: wallet.account,
298
+ chain,
299
+ });
300
+ const receipt = await deps.publicClient.waitForTransactionReceipt({ hash: transferTx });
301
+ if (receipt.status !== 'success') {
302
+ return { ok: false, status: 502, error: 'the transfer reverted on chain' };
303
+ }
304
+ return { ok: true, txHash: transferTx, amount: payment.value };
305
+ });
306
+ }
307
+ /** Trozos del ERC-20 que no están en el `erc20Abi` del SDK. */
308
+ const tokenExtraAbi = [
309
+ {
310
+ type: 'function',
311
+ name: 'name',
312
+ stateMutability: 'view',
313
+ inputs: [],
314
+ outputs: [{ name: '', type: 'string' }],
315
+ },
316
+ {
317
+ type: 'function',
318
+ name: 'transferFrom',
319
+ stateMutability: 'nonpayable',
320
+ inputs: [
321
+ { name: 'from', type: 'address' },
322
+ { name: 'to', type: 'address' },
323
+ { name: 'amount', type: 'uint256' },
324
+ ],
325
+ outputs: [{ name: '', type: 'bool' }],
326
+ },
327
+ ];
328
+ const permitAbi = [
329
+ {
330
+ type: 'function',
331
+ name: 'permit',
332
+ stateMutability: 'nonpayable',
333
+ inputs: [
334
+ { name: 'owner', type: 'address' },
335
+ { name: 'spender', type: 'address' },
336
+ { name: 'value', type: 'uint256' },
337
+ { name: 'deadline', type: 'uint256' },
338
+ { name: 'v', type: 'uint8' },
339
+ { name: 'r', type: 'bytes32' },
340
+ { name: 's', type: 'bytes32' },
341
+ ],
342
+ outputs: [],
343
+ },
344
+ ];
345
+ /** Identificador estable del recurso pagado, para trazas y recibos. */
346
+ export function resourceId(method, path, body) {
347
+ return keccak256(toHex(`${method} ${path}\n${body}`));
348
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panal/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.6.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/envelope.test.ts && tsx test/sdk.test.ts && tsx test/x402.test.ts"
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"
49
49
  }
50
50
  }