@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/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.2.0",
3
+ "version": "0.5.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/sdk.test.ts && tsx test/x402.test.ts && tsx test/x402-server.test.ts && tsx test/envelope.test.ts"
49
49
  }
50
50
  }