@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/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
|
+
}
|
|
@@ -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
|
+
}
|