x402-safe-fetch 0.3.1 → 0.4.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/README.md +27 -0
- package/index.d.ts +17 -0
- package/index.js +76 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,10 +58,36 @@ Solana works the same way: `network: "solana"` and register an `ExactSvmScheme`
|
|
|
58
58
|
| `cacheMs` | 10 minutes | How long a verdict is reused per method and URL |
|
|
59
59
|
| `verifyReceipts` | `"require"` | Check Doctor's signed receipt on every preflight; `"off"` skips it |
|
|
60
60
|
| `doctorSigners` | Doctor's published signer | Accepted signer addresses (for a self-hosted Doctor) |
|
|
61
|
+
| `diagnoseOnFailure` | `false` | When a payment still fails, buy a $0.01 Doctor diagnosis of why (see below) |
|
|
62
|
+
| `onDiagnosis` | | `(report, { url, method, status, error }) => void`, e.g. for logging or alerting |
|
|
61
63
|
| `authority` | the Fizzl payout wallet | Wallet whose certificates also make a signer trusted (key rotation); `null` accepts only `doctorSigners` |
|
|
62
64
|
|
|
63
65
|
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.
|
|
64
66
|
|
|
67
|
+
## When a payment still fails
|
|
68
|
+
|
|
69
|
+
Sometimes the preflight says `go` and the payment still fails: the endpoint answers 402 again, or paying throws (a facilitator that rejects it, a wrong network). With `diagnoseOnFailure: true`, safe-fetch then buys one **$0.01** [x402 Doctor diagnosis](https://github.com/Fizzl13/x402-doctor#paid-api-for-agents-x402) of that endpoint: every check of its payment flow, with what is wrong and how to fix it.
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
import { createSafeFetch, diagnosisOf } from "x402-safe-fetch";
|
|
73
|
+
|
|
74
|
+
const safeFetch = createSafeFetch({ register, maxUsd: 0.05, diagnoseOnFailure: true });
|
|
75
|
+
|
|
76
|
+
const res = await safeFetch("https://api.example.com/paid");
|
|
77
|
+
if (res.status === 402) {
|
|
78
|
+
const report = diagnosisOf(res); // { overall: "fail", checks: [{ id, status, message }, …] }
|
|
79
|
+
console.log(report?.checks.filter((c) => c.status === "fail").map((c) => c.message));
|
|
80
|
+
}
|
|
81
|
+
// When paying throws, the error carries it: err.diagnosis
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- One diagnosis per endpoint per `cacheMs`, capped at $0.02 and paid with the same schemes.
|
|
85
|
+
- Like the preflight, the diagnosis must carry Doctor's signed receipt for exactly this endpoint; an unsigned or forged one is dropped, never shown.
|
|
86
|
+
- A diagnosis that fails never changes the outcome: you get the original response or error.
|
|
87
|
+
- Off by default: nothing extra is paid unless you turn it on.
|
|
88
|
+
|
|
89
|
+
Doctor's requests carry `user-agent: x402-safe-fetch/<version>` so the service can count how often the package is used; nothing about your agent or wallet is sent beyond the payment itself.
|
|
90
|
+
|
|
65
91
|
## Signed verdicts
|
|
66
92
|
|
|
67
93
|
x402 Doctor signs every paid preflight (EIP-191 over canonical JSON, with the request inside the signed body; see [Signed verdicts](https://github.com/Fizzl13/x402-doctor#signed-verdicts)). safe-fetch checks that signature before it acts on a verdict:
|
|
@@ -77,6 +103,7 @@ x402 Doctor signs every paid preflight (EIP-191 over canonical JSON, with the re
|
|
|
77
103
|
## Costs and limits
|
|
78
104
|
|
|
79
105
|
- $0.001 per preflight (once per endpoint per 10 minutes), plus the endpoint's own price.
|
|
106
|
+
- With `diagnoseOnFailure`: $0.01 per diagnosis, only when a payment fails (once per endpoint per 10 minutes).
|
|
80
107
|
- Bodies must be strings, Buffers or `URLSearchParams`: the request is sent once unpaid (to see the 402) and once paid.
|
|
81
108
|
- If Doctor is unreachable, nothing is paid (`SafePayError` with code `preflight_failed`).
|
|
82
109
|
- The preflight checks whether a payment can succeed and is sensible; it does not guarantee what the service delivers after payment.
|
package/index.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { x402Client } from "@x402/fetch";
|
|
|
2
2
|
|
|
3
3
|
export declare const DOCTOR_URL: string;
|
|
4
4
|
export declare const PREFLIGHT_CAP: string;
|
|
5
|
+
export declare const DIAGNOSE_CAP: string;
|
|
6
|
+
export declare const VERSION: string;
|
|
5
7
|
export declare const BASE: "eip155:8453";
|
|
6
8
|
export declare const SOLANA: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
7
9
|
|
|
@@ -14,10 +16,19 @@ export interface Preflight {
|
|
|
14
16
|
[key: string]: unknown;
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
/** The x402 Doctor diagnosis of an endpoint: every check with a fix hint (GET /api/v1/diagnose). */
|
|
20
|
+
export interface Diagnosis {
|
|
21
|
+
overall: "pass" | "warn" | "fail";
|
|
22
|
+
checks: Array<{ id: string; status: "pass" | "warn" | "fail" | "info"; message: string; [key: string]: unknown }>;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
export declare class SafePayError extends Error {
|
|
18
27
|
code: "no_go" | "caution" | "preflight_failed" | "bad_receipt" | "no_option";
|
|
19
28
|
preflight: Preflight | null;
|
|
20
29
|
url: string | null;
|
|
30
|
+
/** Set when diagnoseOnFailure diagnosed a payment that failed. */
|
|
31
|
+
diagnosis?: Diagnosis;
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
export interface SafeFetchOptions {
|
|
@@ -41,6 +52,9 @@ export interface SafeFetchOptions {
|
|
|
41
52
|
doctorSigners?: string[];
|
|
42
53
|
/** Wallet whose certificates (receipt.cert) also make a signer trusted, so a rotated Doctor key keeps working. Default: the Fizzl payout wallet; null accepts only doctorSigners. */
|
|
43
54
|
authority?: string | null;
|
|
55
|
+
/** When a payment still fails (402 again, or paying throws), buy a $0.01 Doctor diagnosis of why. Default false. */
|
|
56
|
+
diagnoseOnFailure?: boolean;
|
|
57
|
+
onDiagnosis?: (report: Diagnosis, info: { url: string; method: string; status: number | null; error: Error | null }) => void;
|
|
44
58
|
fetch?: typeof globalThis.fetch;
|
|
45
59
|
/** Advanced/testing: a paying fetch capped at `cap` (e.g. "$0.05"). */
|
|
46
60
|
createPayingFetch?: (cap: string) => typeof globalThis.fetch;
|
|
@@ -50,6 +64,9 @@ export interface SafeFetchOptions {
|
|
|
50
64
|
export declare function createSafeFetch(options: SafeFetchOptions): (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
51
65
|
export declare function usdCap(maxUsd: number | string): string;
|
|
52
66
|
export declare function preflightUrl(target: string, options?: { method?: string; maxUsd?: number | string; network?: string; doctorUrl?: string }): string;
|
|
67
|
+
export declare function diagnoseUrl(target: string, options?: { method?: string; doctorUrl?: string }): string;
|
|
68
|
+
/** The Doctor diagnosis attached to a response whose payment failed (diagnoseOnFailure), or null. */
|
|
69
|
+
export declare function diagnosisOf(response: Response): Diagnosis | null;
|
|
53
70
|
export declare function receiptOf(response: Response): { transaction?: string; network?: string; success?: boolean; [key: string]: unknown } | null;
|
|
54
71
|
|
|
55
72
|
export declare const DOCTOR_SIGNERS: string[];
|
package/index.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
// function that decides (e.g. ask the user)
|
|
10
10
|
// go → pays the endpoint, never more than maxUsd
|
|
11
11
|
// Trusted hosts skip the preflight; a verdict is reused for 10 minutes.
|
|
12
|
+
// With diagnoseOnFailure, a payment that still fails (the endpoint answers 402
|
|
13
|
+
// again, or paying throws) gets a $0.01 Doctor diagnosis that says why and how
|
|
14
|
+
// to fix it: onDiagnosis(report), diagnosisOf(response) or error.diagnosis.
|
|
12
15
|
// Every preflight must carry Doctor's signed receipt for exactly this request
|
|
13
16
|
// (endpoint, method, budget, network), checked against Doctor's pinned signer:
|
|
14
17
|
// a missing, changed or forged verdict is never a payment (receipt.js).
|
|
@@ -23,6 +26,10 @@ export { verifyReceipt, recoverSigner, canonicalJson, inputHash, certMessage, DO
|
|
|
23
26
|
|
|
24
27
|
export const DOCTOR_URL = "https://x402-doctor.fizzl.eu";
|
|
25
28
|
export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
|
|
29
|
+
export const DIAGNOSE_CAP = "$0.02"; // the diagnosis costs $0.01; never more than twice that
|
|
30
|
+
export const VERSION = "0.4.0";
|
|
31
|
+
// Doctor sees which calls come from this package (in its usage counts), nothing more.
|
|
32
|
+
const DOCTOR_HEADERS = { accept: "application/json", "user-agent": `x402-safe-fetch/${VERSION}` };
|
|
26
33
|
export const BASE = "eip155:8453";
|
|
27
34
|
export const SOLANA = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
28
35
|
const NETWORK_ALIASES = { base: BASE, solana: SOLANA };
|
|
@@ -45,6 +52,17 @@ export function usdCap(maxUsd) {
|
|
|
45
52
|
return `$${Number(n.toFixed(6))}`;
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
export function diagnoseUrl(target, { method = "GET", doctorUrl = DOCTOR_URL } = {}) {
|
|
56
|
+
const q = new URLSearchParams({ url: target, method });
|
|
57
|
+
return `${doctorUrl.replace(/\/$/, "")}/api/v1/diagnose?${q}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The Doctor diagnosis attached to a response whose payment failed, or null.
|
|
61
|
+
const diagnoses = new WeakMap();
|
|
62
|
+
export function diagnosisOf(response) {
|
|
63
|
+
return (response && diagnoses.get(response)) || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
48
66
|
export function preflightUrl(target, { method = "GET", maxUsd, network, doctorUrl = DOCTOR_URL } = {}) {
|
|
49
67
|
const q = new URLSearchParams({ url: target, method });
|
|
50
68
|
if (maxUsd !== undefined) q.set("max_usd", String(maxUsd).replace(/^\$/, ""));
|
|
@@ -96,6 +114,8 @@ function requestOf(input, init) {
|
|
|
96
114
|
* @param {string[]} [options.doctorSigners] accepted Doctor signer addresses (default: the published signer)
|
|
97
115
|
* @param {string|null} [options.authority] wallet whose certificates also make a signer trusted (default: the Fizzl payout wallet; null to accept only doctorSigners)
|
|
98
116
|
* @param {typeof fetch} [options.fetch] the underlying fetch (default globalThis.fetch)
|
|
117
|
+
* @param {boolean} [options.diagnoseOnFailure] when a payment still fails, buy a $0.01 Doctor diagnosis of why (default false)
|
|
118
|
+
* @param {(report: object, info: {url: string, method: string, status: number|null, error: Error|null}) => void} [options.onDiagnosis]
|
|
99
119
|
* @param {(cap: string) => typeof fetch} [options.createPayingFetch] advanced/testing: a paying fetch capped at `cap`
|
|
100
120
|
* @param {() => number} [options.now]
|
|
101
121
|
*/
|
|
@@ -111,6 +131,8 @@ export function createSafeFetch({
|
|
|
111
131
|
verifyReceipts = "require",
|
|
112
132
|
doctorSigners = DOCTOR_SIGNERS,
|
|
113
133
|
authority = AUTHORITY,
|
|
134
|
+
diagnoseOnFailure = false,
|
|
135
|
+
onDiagnosis,
|
|
114
136
|
fetch: baseFetch = globalThis.fetch,
|
|
115
137
|
createPayingFetch,
|
|
116
138
|
now = Date.now,
|
|
@@ -137,7 +159,9 @@ export function createSafeFetch({
|
|
|
137
159
|
});
|
|
138
160
|
let preflightFetch;
|
|
139
161
|
let endpointFetch;
|
|
162
|
+
let diagnoseFetch;
|
|
140
163
|
const payPreflight = (...args) => (preflightFetch ??= makePayingFetch(PREFLIGHT_CAP))(...args);
|
|
164
|
+
const payDiagnose = (...args) => (diagnoseFetch ??= makePayingFetch(DIAGNOSE_CAP))(...args);
|
|
141
165
|
const payEndpoint = (...args) => (endpointFetch ??= makePayingFetch(budget))(...args);
|
|
142
166
|
|
|
143
167
|
const verdicts = new Map();
|
|
@@ -146,7 +170,7 @@ export function createSafeFetch({
|
|
|
146
170
|
const hit = verdicts.get(key);
|
|
147
171
|
if (hit && hit.expires > now()) return { preflight: hit.preflight, cached: true };
|
|
148
172
|
const pfUrl = preflightUrl(url, { method, maxUsd: budget, network: payNetwork, doctorUrl });
|
|
149
|
-
const res = await payPreflight(pfUrl, { headers:
|
|
173
|
+
const res = await payPreflight(pfUrl, { headers: DOCTOR_HEADERS });
|
|
150
174
|
const preflight = await res.json().catch(() => null);
|
|
151
175
|
if (!res.ok || !preflight?.verdict) {
|
|
152
176
|
throw new SafePayError(`preflight failed (HTTP ${res.status}); the endpoint was not paid`, { code: "preflight_failed", preflight, url });
|
|
@@ -162,13 +186,62 @@ export function createSafeFetch({
|
|
|
162
186
|
return { preflight, cached: false };
|
|
163
187
|
}
|
|
164
188
|
|
|
189
|
+
// Why did a payment fail? One paid diagnosis per method and URL per cacheMs;
|
|
190
|
+
// a diagnosis that fails or is not signed by Doctor is dropped, never thrown.
|
|
191
|
+
const diagnosed = new Map();
|
|
192
|
+
async function diagnoseFor(url, method) {
|
|
193
|
+
const key = `${method} ${url}`;
|
|
194
|
+
const hit = diagnosed.get(key);
|
|
195
|
+
if (hit && hit.expires > now()) return hit.report;
|
|
196
|
+
let report = null;
|
|
197
|
+
try {
|
|
198
|
+
const dUrl = diagnoseUrl(url, { method, doctorUrl });
|
|
199
|
+
const res = await payDiagnose(dUrl, { headers: DOCTOR_HEADERS });
|
|
200
|
+
const body = await res.json().catch(() => null);
|
|
201
|
+
if (res.ok && body?.checks) {
|
|
202
|
+
const input = Object.fromEntries(new URL(dUrl).searchParams);
|
|
203
|
+
const check = verifyReceipts === "require" ? verifyReceipt(body, { signers: doctorSigners, route: "GET /api/v1/diagnose", input, authority, service: "x402-doctor" }) : { valid: true };
|
|
204
|
+
if (check.valid) report = body;
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// the original outcome stands; no diagnosis
|
|
208
|
+
}
|
|
209
|
+
diagnosed.set(key, { report, expires: now() + cacheMs });
|
|
210
|
+
if (diagnosed.size > 1000) diagnosed.delete(diagnosed.keys().next().value);
|
|
211
|
+
return report;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function payAndCheck(url, init, method) {
|
|
215
|
+
if (!diagnoseOnFailure) return payEndpoint(url, init);
|
|
216
|
+
let res;
|
|
217
|
+
try {
|
|
218
|
+
res = await payEndpoint(url, init);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error instanceof SafePayError && error.code !== "no_option") throw error;
|
|
221
|
+
const report = await diagnoseFor(url, method);
|
|
222
|
+
if (report) {
|
|
223
|
+
try { error.diagnosis = report; } catch { /* frozen error */ }
|
|
224
|
+
onDiagnosis?.(report, { url, method, status: null, error });
|
|
225
|
+
}
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
if (res.status === 402) {
|
|
229
|
+
const report = await diagnoseFor(url, method);
|
|
230
|
+
if (report) {
|
|
231
|
+
diagnoses.set(res, report);
|
|
232
|
+
onDiagnosis?.(report, { url, method, status: 402, error: null });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return res;
|
|
236
|
+
}
|
|
237
|
+
|
|
165
238
|
return async function safeFetch(input, init = {}) {
|
|
166
239
|
const { url, method } = requestOf(input, init);
|
|
167
240
|
const probe = await baseFetch(url, init);
|
|
168
241
|
if (probe.status !== 402) return probe;
|
|
169
242
|
try { await probe.body?.cancel(); } catch { /* already consumed */ }
|
|
170
243
|
|
|
171
|
-
if (trustedHosts.has(hostOf(url))) return
|
|
244
|
+
if (trustedHosts.has(hostOf(url))) return payAndCheck(url, init, method);
|
|
172
245
|
|
|
173
246
|
const { preflight, cached } = await preflightFor(url, method);
|
|
174
247
|
onPreflight?.(preflight, { url, method, cached });
|
|
@@ -180,6 +253,6 @@ export function createSafeFetch({
|
|
|
180
253
|
} else if (preflight.verdict !== "go") {
|
|
181
254
|
throw new SafePayError(`not paid: unknown verdict ${preflight.verdict}`, { code: "preflight_failed", preflight, url });
|
|
182
255
|
}
|
|
183
|
-
return
|
|
256
|
+
return payAndCheck(url, init, method);
|
|
184
257
|
};
|
|
185
258
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "x402-safe-fetch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"exports": {
|