x402-safe-fetch 0.1.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/LICENSE +21 -0
- package/README.md +69 -0
- package/index.d.ts +47 -0
- package/index.js +165 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Frits Zwager
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# x402-safe-fetch
|
|
2
|
+
|
|
3
|
+
A `fetch` for AI agents that checks an unknown x402 endpoint before paying it.
|
|
4
|
+
|
|
5
|
+
A free endpoint is answered as is. For a paid one (HTTP 402), your agent first pays **$0.001** for an [x402 Doctor](https://x402-doctor.onrender.com) preflight and acts on it:
|
|
6
|
+
|
|
7
|
+
| Preflight | What `safeFetch` does |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `go` | Pays the endpoint, never more than `maxUsd` |
|
|
10
|
+
| `caution` | `onCaution`: `"stop"` (default, throws), `"pay"`, or your own function (e.g. ask the user) |
|
|
11
|
+
| `no_go` | Throws `SafePayError`; the endpoint is not paid |
|
|
12
|
+
|
|
13
|
+
The preflight reads the endpoint's 402 challenge and checks, among others, the price against your budget and against what the service advertises, whether the option is payable on your network (USDC, a valid payout address, a Solana payout account that exists), HTTPS, and whether the endpoint is listed in the CDP Bazaar. A verdict is reused for 10 minutes; hosts you already trust skip it.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install x402-safe-fetch @x402/fetch @x402/evm viem
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Use
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import { createSafeFetch, SafePayError } from "x402-safe-fetch";
|
|
25
|
+
import { ExactEvmScheme } from "@x402/evm/exact/client";
|
|
26
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
27
|
+
|
|
28
|
+
const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY); // a dedicated wallet with a few dollars of USDC
|
|
29
|
+
|
|
30
|
+
const safeFetch = createSafeFetch({
|
|
31
|
+
register: (client) => client.register("eip155:8453", new ExactEvmScheme(account)),
|
|
32
|
+
maxUsd: 0.05, // budget per endpoint call
|
|
33
|
+
onCaution: "stop", // or "pay", or async (preflight) => askUser(preflight.summary)
|
|
34
|
+
trusted: ["api.my-own-service.com"],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const res = await safeFetch("https://ichimoku-signal.onrender.com/signal/BTC-USDT");
|
|
39
|
+
console.log(await res.json());
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err instanceof SafePayError) console.log(err.code, err.message, err.preflight?.reasons);
|
|
42
|
+
else throw err;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Solana works the same way: `network: "solana"` and register an `ExactSvmScheme` from `@x402/svm/exact/client` with a `@solana/kit` signer.
|
|
47
|
+
|
|
48
|
+
## Options
|
|
49
|
+
|
|
50
|
+
| Option | Default | |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `register` | required | `(client) => client.register(network, scheme)`: your payment schemes; your keys stay in your code |
|
|
53
|
+
| `network` | `"base"` | `"base"`, `"solana"` or a CAIP-2 id; the preflight checks the option on this network |
|
|
54
|
+
| `maxUsd` | `0.05` | Budget per endpoint call; the payment is capped at it and the preflight says `no_go` above it |
|
|
55
|
+
| `onCaution` | `"stop"` | `"stop"`, `"pay"` or `async (preflight) => boolean` |
|
|
56
|
+
| `trusted` | `[]` | Hosts paid without a preflight |
|
|
57
|
+
| `onPreflight` | | `(preflight, { url, method, cached }) => void`, e.g. for logging |
|
|
58
|
+
| `cacheMs` | 10 minutes | How long a verdict is reused per method and URL |
|
|
59
|
+
|
|
60
|
+
The preflight itself is capped at $0.002 and paid with the same schemes. `receiptOf(response)` returns the payment receipt (transaction hash) of a paid response.
|
|
61
|
+
|
|
62
|
+
## Costs and limits
|
|
63
|
+
|
|
64
|
+
- $0.001 per preflight (once per endpoint per 10 minutes), plus the endpoint's own price.
|
|
65
|
+
- Bodies must be strings, Buffers or `URLSearchParams`: the request is sent once unpaid (to see the 402) and once paid.
|
|
66
|
+
- If Doctor is unreachable, nothing is paid (`SafePayError` with code `preflight_failed`).
|
|
67
|
+
- The preflight checks whether a payment can succeed and is sensible; it does not guarantee what the service delivers after payment.
|
|
68
|
+
|
|
69
|
+
Try it without code first: [safe-pay](../safe-pay) is a runnable script with the same logic. MIT licensed.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { x402Client } from "@x402/fetch";
|
|
2
|
+
|
|
3
|
+
export declare const DOCTOR_URL: string;
|
|
4
|
+
export declare const PREFLIGHT_CAP: string;
|
|
5
|
+
export declare const BASE: "eip155:8453";
|
|
6
|
+
export declare const SOLANA: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
7
|
+
|
|
8
|
+
/** The x402 Doctor preflight: see https://x402-doctor.onrender.com/openapi.json */
|
|
9
|
+
export interface Preflight {
|
|
10
|
+
verdict: "go" | "caution" | "no_go";
|
|
11
|
+
summary?: string;
|
|
12
|
+
reasons?: Array<{ level: string; code?: string; message: string }>;
|
|
13
|
+
signals?: Record<string, unknown>;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export declare class SafePayError extends Error {
|
|
18
|
+
code: "no_go" | "caution" | "preflight_failed" | "no_option";
|
|
19
|
+
preflight: Preflight | null;
|
|
20
|
+
url: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SafeFetchOptions {
|
|
24
|
+
/** Registers your payment schemes, e.g. (c) => c.register("eip155:8453", new ExactEvmScheme(account)). */
|
|
25
|
+
register: (client: x402Client) => unknown;
|
|
26
|
+
/** The network you pay on: "base", "solana" or a CAIP-2 id. Default Base. */
|
|
27
|
+
network?: string;
|
|
28
|
+
/** Budget per endpoint call in USD. Default 0.05. */
|
|
29
|
+
maxUsd?: number | string;
|
|
30
|
+
/** What to do on a caution verdict. Default "stop". */
|
|
31
|
+
onCaution?: "stop" | "pay" | ((preflight: Preflight) => boolean | Promise<boolean>);
|
|
32
|
+
/** Hosts you already trust: paid without a preflight. */
|
|
33
|
+
trusted?: string[];
|
|
34
|
+
onPreflight?: (preflight: Preflight, info: { url: string; method: string; cached: boolean }) => void;
|
|
35
|
+
/** How long a verdict is reused, in ms. Default 10 minutes. */
|
|
36
|
+
cacheMs?: number;
|
|
37
|
+
doctorUrl?: string;
|
|
38
|
+
fetch?: typeof globalThis.fetch;
|
|
39
|
+
/** Advanced/testing: a paying fetch capped at `cap` (e.g. "$0.05"). */
|
|
40
|
+
createPayingFetch?: (cap: string) => typeof globalThis.fetch;
|
|
41
|
+
now?: () => number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export declare function createSafeFetch(options: SafeFetchOptions): (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
45
|
+
export declare function usdCap(maxUsd: number | string): string;
|
|
46
|
+
export declare function preflightUrl(target: string, options?: { method?: string; maxUsd?: number | string; network?: string; doctorUrl?: string }): string;
|
|
47
|
+
export declare function receiptOf(response: Response): { transaction?: string; network?: string; success?: boolean; [key: string]: unknown } | null;
|
package/index.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// x402-safe-fetch: a fetch for agents that checks an unknown x402 endpoint
|
|
2
|
+
// before paying it.
|
|
3
|
+
//
|
|
4
|
+
// A free endpoint is answered as is. For a paid one (HTTP 402), the agent first
|
|
5
|
+
// pays $0.001 for an x402 Doctor preflight and acts on it:
|
|
6
|
+
// no_go → throws SafePayError (the payment would fail, is over budget, or
|
|
7
|
+
// should not be made); the endpoint is not paid
|
|
8
|
+
// caution → onCaution: "stop" (default, throws), "pay", or your own async
|
|
9
|
+
// function that decides (e.g. ask the user)
|
|
10
|
+
// go → pays the endpoint, never more than maxUsd
|
|
11
|
+
// Trusted hosts skip the preflight; a verdict is reused for 10 minutes.
|
|
12
|
+
//
|
|
13
|
+
// Payments use @x402/fetch with the schemes you register (your keys stay in
|
|
14
|
+
// your code): register: (client) => client.register("eip155:8453", new ExactEvmScheme(account))
|
|
15
|
+
|
|
16
|
+
import { wrapFetchWithPayment, x402Client, decodePaymentResponseHeader } from "@x402/fetch";
|
|
17
|
+
|
|
18
|
+
export const DOCTOR_URL = "https://x402-doctor.onrender.com";
|
|
19
|
+
export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
|
|
20
|
+
export const BASE = "eip155:8453";
|
|
21
|
+
export const SOLANA = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
22
|
+
const NETWORK_ALIASES = { base: BASE, solana: SOLANA };
|
|
23
|
+
|
|
24
|
+
export class SafePayError extends Error {
|
|
25
|
+
/** code: "no_go" | "caution" | "preflight_failed" | "no_option" */
|
|
26
|
+
constructor(message, { code, preflight = null, url = null } = {}) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "SafePayError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.preflight = preflight;
|
|
31
|
+
this.url = url;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A USD budget as the spend-control string x402 expects: 0.05 → "$0.05".
|
|
36
|
+
export function usdCap(maxUsd) {
|
|
37
|
+
const n = Number(String(maxUsd).replace(/^\$/, ""));
|
|
38
|
+
if (!(n > 0) || !Number.isFinite(n)) throw new TypeError("maxUsd must be a positive number of US dollars, e.g. 0.05");
|
|
39
|
+
return `$${Number(n.toFixed(6))}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function preflightUrl(target, { method = "GET", maxUsd, network, doctorUrl = DOCTOR_URL } = {}) {
|
|
43
|
+
const q = new URLSearchParams({ url: target, method });
|
|
44
|
+
if (maxUsd !== undefined) q.set("max_usd", String(maxUsd).replace(/^\$/, ""));
|
|
45
|
+
if (network) q.set("network", network);
|
|
46
|
+
return `${doctorUrl.replace(/\/$/, "")}/api/v1/preflight?${q}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The transaction of a paid response (PAYMENT-RESPONSE header), or null.
|
|
50
|
+
export function receiptOf(response) {
|
|
51
|
+
const header = response?.headers?.get?.("payment-response");
|
|
52
|
+
if (!header) return null;
|
|
53
|
+
try {
|
|
54
|
+
return decodePaymentResponseHeader(header);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hostOf(value) {
|
|
61
|
+
try {
|
|
62
|
+
return new URL(value.includes("://") ? value : `https://${value}`).host.toLowerCase();
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function requestOf(input, init) {
|
|
69
|
+
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
70
|
+
throw new TypeError("pass the URL and an init object, not a Request (the body is sent up to three times)");
|
|
71
|
+
}
|
|
72
|
+
const body = init?.body;
|
|
73
|
+
if (body && typeof body === "object" && (typeof body.getReader === "function" || typeof body[Symbol.asyncIterator] === "function")) {
|
|
74
|
+
throw new TypeError("use a string, Buffer or URLSearchParams body: a stream cannot be sent again after the 402");
|
|
75
|
+
}
|
|
76
|
+
return { url: String(input), method: String(init?.method ?? "GET").toUpperCase() };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {object} options
|
|
81
|
+
* @param {(client: x402Client) => unknown} options.register registers your payment schemes on a client
|
|
82
|
+
* @param {string} [options.network] the network you pay on: "base", "solana" or a CAIP-2 id (default Base)
|
|
83
|
+
* @param {number|string} [options.maxUsd] budget per endpoint call (default 0.05)
|
|
84
|
+
* @param {"stop"|"pay"|((preflight: object) => boolean|Promise<boolean>)} [options.onCaution]
|
|
85
|
+
* @param {string[]} [options.trusted] hosts you already trust: paid without a preflight
|
|
86
|
+
* @param {(preflight: object, info: {url: string, method: string, cached: boolean}) => void} [options.onPreflight]
|
|
87
|
+
* @param {number} [options.cacheMs] how long a verdict is reused (default 10 minutes)
|
|
88
|
+
* @param {string} [options.doctorUrl]
|
|
89
|
+
* @param {typeof fetch} [options.fetch] the underlying fetch (default globalThis.fetch)
|
|
90
|
+
* @param {(cap: string) => typeof fetch} [options.createPayingFetch] advanced/testing: a paying fetch capped at `cap`
|
|
91
|
+
* @param {() => number} [options.now]
|
|
92
|
+
*/
|
|
93
|
+
export function createSafeFetch({
|
|
94
|
+
register,
|
|
95
|
+
network = BASE,
|
|
96
|
+
maxUsd = 0.05,
|
|
97
|
+
onCaution = "stop",
|
|
98
|
+
trusted = [],
|
|
99
|
+
onPreflight,
|
|
100
|
+
cacheMs = 10 * 60 * 1000,
|
|
101
|
+
doctorUrl = DOCTOR_URL,
|
|
102
|
+
fetch: baseFetch = globalThis.fetch,
|
|
103
|
+
createPayingFetch,
|
|
104
|
+
now = Date.now,
|
|
105
|
+
} = {}) {
|
|
106
|
+
const payNetwork = NETWORK_ALIASES[network] ?? network;
|
|
107
|
+
const budget = usdCap(maxUsd);
|
|
108
|
+
if (!createPayingFetch && typeof register !== "function") {
|
|
109
|
+
throw new TypeError("register is required: (client) => client.register(network, scheme)");
|
|
110
|
+
}
|
|
111
|
+
if (!["stop", "pay"].includes(onCaution) && typeof onCaution !== "function") {
|
|
112
|
+
throw new TypeError('onCaution must be "stop", "pay" or a function');
|
|
113
|
+
}
|
|
114
|
+
const trustedHosts = new Set(trusted.map(hostOf).filter(Boolean));
|
|
115
|
+
|
|
116
|
+
const makePayingFetch = createPayingFetch ?? ((cap) => {
|
|
117
|
+
const client = new x402Client((_version, accepts) => {
|
|
118
|
+
const pick = accepts.find((a) => a.network === payNetwork);
|
|
119
|
+
if (!pick) throw new SafePayError(`no payment option on ${payNetwork}`, { code: "no_option" });
|
|
120
|
+
return pick;
|
|
121
|
+
}).setSpendControls({ maxAmountPerPayment: cap });
|
|
122
|
+
register(client);
|
|
123
|
+
return wrapFetchWithPayment(baseFetch, client);
|
|
124
|
+
});
|
|
125
|
+
let preflightFetch;
|
|
126
|
+
let endpointFetch;
|
|
127
|
+
const payPreflight = (...args) => (preflightFetch ??= makePayingFetch(PREFLIGHT_CAP))(...args);
|
|
128
|
+
const payEndpoint = (...args) => (endpointFetch ??= makePayingFetch(budget))(...args);
|
|
129
|
+
|
|
130
|
+
const verdicts = new Map();
|
|
131
|
+
async function preflightFor(url, method) {
|
|
132
|
+
const key = `${method} ${url}`;
|
|
133
|
+
const hit = verdicts.get(key);
|
|
134
|
+
if (hit && hit.expires > now()) return { preflight: hit.preflight, cached: true };
|
|
135
|
+
const res = await payPreflight(preflightUrl(url, { method, maxUsd: budget, network: payNetwork, doctorUrl }), { headers: { accept: "application/json" } });
|
|
136
|
+
const preflight = await res.json().catch(() => null);
|
|
137
|
+
if (!res.ok || !preflight?.verdict) {
|
|
138
|
+
throw new SafePayError(`preflight failed (HTTP ${res.status}); the endpoint was not paid`, { code: "preflight_failed", preflight, url });
|
|
139
|
+
}
|
|
140
|
+
verdicts.set(key, { preflight, expires: now() + cacheMs });
|
|
141
|
+
if (verdicts.size > 1000) verdicts.delete(verdicts.keys().next().value);
|
|
142
|
+
return { preflight, cached: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return async function safeFetch(input, init = {}) {
|
|
146
|
+
const { url, method } = requestOf(input, init);
|
|
147
|
+
const probe = await baseFetch(url, init);
|
|
148
|
+
if (probe.status !== 402) return probe;
|
|
149
|
+
try { await probe.body?.cancel(); } catch { /* already consumed */ }
|
|
150
|
+
|
|
151
|
+
if (trustedHosts.has(hostOf(url))) return payEndpoint(url, init);
|
|
152
|
+
|
|
153
|
+
const { preflight, cached } = await preflightFor(url, method);
|
|
154
|
+
onPreflight?.(preflight, { url, method, cached });
|
|
155
|
+
const why = preflight.summary || preflight.verdict;
|
|
156
|
+
if (preflight.verdict === "no_go") throw new SafePayError(`not paid: ${why}`, { code: "no_go", preflight, url });
|
|
157
|
+
if (preflight.verdict === "caution") {
|
|
158
|
+
const ok = onCaution === "pay" || (typeof onCaution === "function" && (await onCaution(preflight)) === true);
|
|
159
|
+
if (!ok) throw new SafePayError(`not paid (caution): ${why}`, { code: "caution", preflight, url });
|
|
160
|
+
} else if (preflight.verdict !== "go") {
|
|
161
|
+
throw new SafePayError(`not paid: unknown verdict ${preflight.verdict}`, { code: "preflight_failed", preflight, url });
|
|
162
|
+
}
|
|
163
|
+
return payEndpoint(url, init);
|
|
164
|
+
};
|
|
165
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "x402-safe-fetch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A fetch for AI agents that checks an unknown x402 endpoint ($0.001 x402 Doctor preflight) before paying it, and never pays above your budget.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"default": "./index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"main": "./index.js",
|
|
13
|
+
"types": "./index.d.ts",
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"index.d.ts",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test test/*.test.mjs"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@x402/fetch": "^2.27.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@x402/evm": "^2.27.0",
|
|
31
|
+
"@x402/fetch": "^2.27.0",
|
|
32
|
+
"viem": "^2.56.9"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"x402",
|
|
36
|
+
"agents",
|
|
37
|
+
"payments",
|
|
38
|
+
"fetch",
|
|
39
|
+
"preflight",
|
|
40
|
+
"usdc",
|
|
41
|
+
"base",
|
|
42
|
+
"solana"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/Fizzl13/x402-examples.git",
|
|
47
|
+
"directory": "safe-fetch"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/Fizzl13/x402-examples/tree/main/safe-fetch#readme",
|
|
50
|
+
"license": "MIT"
|
|
51
|
+
}
|