x402-safe-fetch 0.3.1 → 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/README.md +36 -0
- package/index.d.ts +19 -0
- package/index.js +107 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,10 +58,45 @@ 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
|
+
| `shareOutcomes` | `false` | After paying, tell Doctor whether the payment worked, so its later preflights learn from it (see below) |
|
|
63
|
+
| `onDiagnosis` | | `(report, { url, method, status, error }) => void`, e.g. for logging or alerting |
|
|
61
64
|
| `authority` | the Fizzl payout wallet | Wallet whose certificates also make a signer trusted (key rotation); `null` accepts only `doctorSigners` |
|
|
62
65
|
|
|
63
66
|
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
67
|
|
|
68
|
+
## When a payment still fails
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
import { createSafeFetch, diagnosisOf } from "x402-safe-fetch";
|
|
74
|
+
|
|
75
|
+
const safeFetch = createSafeFetch({ register, maxUsd: 0.05, diagnoseOnFailure: true });
|
|
76
|
+
|
|
77
|
+
const res = await safeFetch("https://api.example.com/paid");
|
|
78
|
+
if (res.status === 402) {
|
|
79
|
+
const report = diagnosisOf(res); // { overall: "fail", checks: [{ id, status, message }, …] }
|
|
80
|
+
console.log(report?.checks.filter((c) => c.status === "fail").map((c) => c.message));
|
|
81
|
+
}
|
|
82
|
+
// When paying throws, the error carries it: err.diagnosis
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- One diagnosis per endpoint per `cacheMs`, capped at $0.02 and paid with the same schemes.
|
|
86
|
+
- Like the preflight, the diagnosis must carry Doctor's signed receipt for exactly this endpoint; an unsigned or forged one is dropped, never shown.
|
|
87
|
+
- A diagnosis that fails never changes the outcome: you get the original response or error.
|
|
88
|
+
- Off by default: nothing extra is paid unless you turn it on.
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
## Helping the preflight learn
|
|
93
|
+
|
|
94
|
+
With `shareOutcomes: true`, safe-fetch tells x402 Doctor what happened after it paid an endpoint that a preflight approved: `paid_ok` (2xx), `paid_failed` (402 again: the payment was not accepted) or `paid_error` (another error). Doctor counts these reports per endpoint over 30 days, from every agent that shares them. When most payments to an endpoint failed, reported by at least three different wallets, later preflights for it answer `caution` (`payments_fail_after_preflight`), so the next agent does not pay into the same wall.
|
|
95
|
+
|
|
96
|
+
- What is sent: the outcome, the HTTP status, the signed preflight you already received and its query (endpoint, method, budget, network). No keys, no response content.
|
|
97
|
+
- A report only counts with a real, paid preflight for that endpoint, once per preflight, so reports cannot be made up for free.
|
|
98
|
+
- It is sent after the answer and never delays or changes it. Off by default.
|
|
99
|
+
|
|
65
100
|
## Signed verdicts
|
|
66
101
|
|
|
67
102
|
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 +112,7 @@ x402 Doctor signs every paid preflight (EIP-191 over canonical JSON, with the re
|
|
|
77
112
|
## Costs and limits
|
|
78
113
|
|
|
79
114
|
- $0.001 per preflight (once per endpoint per 10 minutes), plus the endpoint's own price.
|
|
115
|
+
- With `diagnoseOnFailure`: $0.01 per diagnosis, only when a payment fails (once per endpoint per 10 minutes).
|
|
80
116
|
- Bodies must be strings, Buffers or `URLSearchParams`: the request is sent once unpaid (to see the 402) and once paid.
|
|
81
117
|
- If Doctor is unreachable, nothing is paid (`SafePayError` with code `preflight_failed`).
|
|
82
118
|
- 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,11 @@ 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;
|
|
58
|
+
/** After paying, tell Doctor whether the payment worked (with the signed preflight as proof), so later preflights learn from it. Default false. */
|
|
59
|
+
shareOutcomes?: boolean;
|
|
44
60
|
fetch?: typeof globalThis.fetch;
|
|
45
61
|
/** Advanced/testing: a paying fetch capped at `cap` (e.g. "$0.05"). */
|
|
46
62
|
createPayingFetch?: (cap: string) => typeof globalThis.fetch;
|
|
@@ -50,6 +66,9 @@ export interface SafeFetchOptions {
|
|
|
50
66
|
export declare function createSafeFetch(options: SafeFetchOptions): (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
51
67
|
export declare function usdCap(maxUsd: number | string): string;
|
|
52
68
|
export declare function preflightUrl(target: string, options?: { method?: string; maxUsd?: number | string; network?: string; doctorUrl?: string }): string;
|
|
69
|
+
export declare function diagnoseUrl(target: string, options?: { method?: string; doctorUrl?: string }): string;
|
|
70
|
+
/** The Doctor diagnosis attached to a response whose payment failed (diagnoseOnFailure), or null. */
|
|
71
|
+
export declare function diagnosisOf(response: Response): Diagnosis | null;
|
|
53
72
|
export declare function receiptOf(response: Response): { transaction?: string; network?: string; success?: boolean; [key: string]: unknown } | null;
|
|
54
73
|
|
|
55
74
|
export declare const DOCTOR_SIGNERS: string[];
|
package/index.js
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
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.
|
|
15
|
+
// With shareOutcomes, after paying it tells Doctor what happened (paid_ok,
|
|
16
|
+
// paid_failed, paid_error), with the signed preflight as proof, so later
|
|
17
|
+
// preflights for that endpoint learn from what agents ran into. Off by
|
|
18
|
+
// default; no keys, amounts or response content are sent.
|
|
12
19
|
// Every preflight must carry Doctor's signed receipt for exactly this request
|
|
13
20
|
// (endpoint, method, budget, network), checked against Doctor's pinned signer:
|
|
14
21
|
// a missing, changed or forged verdict is never a payment (receipt.js).
|
|
@@ -23,6 +30,10 @@ export { verifyReceipt, recoverSigner, canonicalJson, inputHash, certMessage, DO
|
|
|
23
30
|
|
|
24
31
|
export const DOCTOR_URL = "https://x402-doctor.fizzl.eu";
|
|
25
32
|
export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
|
|
33
|
+
export const DIAGNOSE_CAP = "$0.02"; // the diagnosis costs $0.01; never more than twice that
|
|
34
|
+
export const VERSION = "0.5.0";
|
|
35
|
+
// Doctor sees which calls come from this package (in its usage counts), nothing more.
|
|
36
|
+
const DOCTOR_HEADERS = { accept: "application/json", "user-agent": `x402-safe-fetch/${VERSION}` };
|
|
26
37
|
export const BASE = "eip155:8453";
|
|
27
38
|
export const SOLANA = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
28
39
|
const NETWORK_ALIASES = { base: BASE, solana: SOLANA };
|
|
@@ -45,6 +56,17 @@ export function usdCap(maxUsd) {
|
|
|
45
56
|
return `$${Number(n.toFixed(6))}`;
|
|
46
57
|
}
|
|
47
58
|
|
|
59
|
+
export function diagnoseUrl(target, { method = "GET", doctorUrl = DOCTOR_URL } = {}) {
|
|
60
|
+
const q = new URLSearchParams({ url: target, method });
|
|
61
|
+
return `${doctorUrl.replace(/\/$/, "")}/api/v1/diagnose?${q}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The Doctor diagnosis attached to a response whose payment failed, or null.
|
|
65
|
+
const diagnoses = new WeakMap();
|
|
66
|
+
export function diagnosisOf(response) {
|
|
67
|
+
return (response && diagnoses.get(response)) || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
export function preflightUrl(target, { method = "GET", maxUsd, network, doctorUrl = DOCTOR_URL } = {}) {
|
|
49
71
|
const q = new URLSearchParams({ url: target, method });
|
|
50
72
|
if (maxUsd !== undefined) q.set("max_usd", String(maxUsd).replace(/^\$/, ""));
|
|
@@ -96,6 +118,9 @@ function requestOf(input, init) {
|
|
|
96
118
|
* @param {string[]} [options.doctorSigners] accepted Doctor signer addresses (default: the published signer)
|
|
97
119
|
* @param {string|null} [options.authority] wallet whose certificates also make a signer trusted (default: the Fizzl payout wallet; null to accept only doctorSigners)
|
|
98
120
|
* @param {typeof fetch} [options.fetch] the underlying fetch (default globalThis.fetch)
|
|
121
|
+
* @param {boolean} [options.diagnoseOnFailure] when a payment still fails, buy a $0.01 Doctor diagnosis of why (default false)
|
|
122
|
+
* @param {(report: object, info: {url: string, method: string, status: number|null, error: Error|null}) => void} [options.onDiagnosis]
|
|
123
|
+
* @param {boolean} [options.shareOutcomes] after paying, tell Doctor whether the payment worked (default false)
|
|
99
124
|
* @param {(cap: string) => typeof fetch} [options.createPayingFetch] advanced/testing: a paying fetch capped at `cap`
|
|
100
125
|
* @param {() => number} [options.now]
|
|
101
126
|
*/
|
|
@@ -111,6 +136,9 @@ export function createSafeFetch({
|
|
|
111
136
|
verifyReceipts = "require",
|
|
112
137
|
doctorSigners = DOCTOR_SIGNERS,
|
|
113
138
|
authority = AUTHORITY,
|
|
139
|
+
diagnoseOnFailure = false,
|
|
140
|
+
onDiagnosis,
|
|
141
|
+
shareOutcomes = false,
|
|
114
142
|
fetch: baseFetch = globalThis.fetch,
|
|
115
143
|
createPayingFetch,
|
|
116
144
|
now = Date.now,
|
|
@@ -137,7 +165,9 @@ export function createSafeFetch({
|
|
|
137
165
|
});
|
|
138
166
|
let preflightFetch;
|
|
139
167
|
let endpointFetch;
|
|
168
|
+
let diagnoseFetch;
|
|
140
169
|
const payPreflight = (...args) => (preflightFetch ??= makePayingFetch(PREFLIGHT_CAP))(...args);
|
|
170
|
+
const payDiagnose = (...args) => (diagnoseFetch ??= makePayingFetch(DIAGNOSE_CAP))(...args);
|
|
141
171
|
const payEndpoint = (...args) => (endpointFetch ??= makePayingFetch(budget))(...args);
|
|
142
172
|
|
|
143
173
|
const verdicts = new Map();
|
|
@@ -146,7 +176,7 @@ export function createSafeFetch({
|
|
|
146
176
|
const hit = verdicts.get(key);
|
|
147
177
|
if (hit && hit.expires > now()) return { preflight: hit.preflight, cached: true };
|
|
148
178
|
const pfUrl = preflightUrl(url, { method, maxUsd: budget, network: payNetwork, doctorUrl });
|
|
149
|
-
const res = await payPreflight(pfUrl, { headers:
|
|
179
|
+
const res = await payPreflight(pfUrl, { headers: DOCTOR_HEADERS });
|
|
150
180
|
const preflight = await res.json().catch(() => null);
|
|
151
181
|
if (!res.ok || !preflight?.verdict) {
|
|
152
182
|
throw new SafePayError(`preflight failed (HTTP ${res.status}); the endpoint was not paid`, { code: "preflight_failed", preflight, url });
|
|
@@ -162,15 +192,89 @@ export function createSafeFetch({
|
|
|
162
192
|
return { preflight, cached: false };
|
|
163
193
|
}
|
|
164
194
|
|
|
195
|
+
// Why did a payment fail? One paid diagnosis per method and URL per cacheMs;
|
|
196
|
+
// a diagnosis that fails or is not signed by Doctor is dropped, never thrown.
|
|
197
|
+
const diagnosed = new Map();
|
|
198
|
+
async function diagnoseFor(url, method) {
|
|
199
|
+
const key = `${method} ${url}`;
|
|
200
|
+
const hit = diagnosed.get(key);
|
|
201
|
+
if (hit && hit.expires > now()) return hit.report;
|
|
202
|
+
let report = null;
|
|
203
|
+
try {
|
|
204
|
+
const dUrl = diagnoseUrl(url, { method, doctorUrl });
|
|
205
|
+
const res = await payDiagnose(dUrl, { headers: DOCTOR_HEADERS });
|
|
206
|
+
const body = await res.json().catch(() => null);
|
|
207
|
+
if (res.ok && body?.checks) {
|
|
208
|
+
const input = Object.fromEntries(new URL(dUrl).searchParams);
|
|
209
|
+
const check = verifyReceipts === "require" ? verifyReceipt(body, { signers: doctorSigners, route: "GET /api/v1/diagnose", input, authority, service: "x402-doctor" }) : { valid: true };
|
|
210
|
+
if (check.valid) report = body;
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// the original outcome stands; no diagnosis
|
|
214
|
+
}
|
|
215
|
+
diagnosed.set(key, { report, expires: now() + cacheMs });
|
|
216
|
+
if (diagnosed.size > 1000) diagnosed.delete(diagnosed.keys().next().value);
|
|
217
|
+
return report;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// One outcome report per paid preflight: Doctor counts it once anyway.
|
|
221
|
+
const reported = new Set();
|
|
222
|
+
function reportOutcome(url, method, preflight, res) {
|
|
223
|
+
if (!shareOutcomes || !preflight?.receipt?.request_id || reported.has(preflight.receipt.request_id)) return;
|
|
224
|
+
const outcome = res.ok ? "paid_ok" : res.status === 402 ? "paid_failed" : res.status >= 400 ? "paid_error" : null;
|
|
225
|
+
if (!outcome) return;
|
|
226
|
+
reported.add(preflight.receipt.request_id);
|
|
227
|
+
if (reported.size > 1000) reported.delete(reported.values().next().value);
|
|
228
|
+
const query = Object.fromEntries(new URL(preflightUrl(url, { method, maxUsd: budget, network: payNetwork, doctorUrl })).searchParams);
|
|
229
|
+
// Fire and forget: the report never delays or changes the answer.
|
|
230
|
+
Promise.resolve()
|
|
231
|
+
.then(() => baseFetch(`${doctorUrl.replace(/\/$/, "")}/api/v1/outcome`, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { ...DOCTOR_HEADERS, "content-type": "application/json" },
|
|
234
|
+
body: JSON.stringify({ outcome, status: res.status, preflight, query }),
|
|
235
|
+
}))
|
|
236
|
+
.then((r) => r?.body?.cancel?.())
|
|
237
|
+
.catch(() => {});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function payAndCheck(url, init, method) {
|
|
241
|
+
if (!diagnoseOnFailure) return payEndpoint(url, init);
|
|
242
|
+
let res;
|
|
243
|
+
try {
|
|
244
|
+
res = await payEndpoint(url, init);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error instanceof SafePayError && error.code !== "no_option") throw error;
|
|
247
|
+
const report = await diagnoseFor(url, method);
|
|
248
|
+
if (report) {
|
|
249
|
+
try { error.diagnosis = report; } catch { /* frozen error */ }
|
|
250
|
+
onDiagnosis?.(report, { url, method, status: null, error });
|
|
251
|
+
}
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
if (res.status === 402) {
|
|
255
|
+
const report = await diagnoseFor(url, method);
|
|
256
|
+
if (report) {
|
|
257
|
+
diagnoses.set(res, report);
|
|
258
|
+
onDiagnosis?.(report, { url, method, status: 402, error: null });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return res;
|
|
262
|
+
}
|
|
263
|
+
|
|
165
264
|
return async function safeFetch(input, init = {}) {
|
|
166
265
|
const { url, method } = requestOf(input, init);
|
|
167
266
|
const probe = await baseFetch(url, init);
|
|
168
267
|
if (probe.status !== 402) return probe;
|
|
169
268
|
try { await probe.body?.cancel(); } catch { /* already consumed */ }
|
|
170
269
|
|
|
171
|
-
if (trustedHosts.has(hostOf(url))) return
|
|
270
|
+
if (trustedHosts.has(hostOf(url))) return payAndCheck(url, init, method);
|
|
172
271
|
|
|
173
272
|
const { preflight, cached } = await preflightFor(url, method);
|
|
273
|
+
const payAfterPreflight = async () => {
|
|
274
|
+
const res = await payAndCheck(url, init, method);
|
|
275
|
+
reportOutcome(url, method, preflight, res);
|
|
276
|
+
return res;
|
|
277
|
+
};
|
|
174
278
|
onPreflight?.(preflight, { url, method, cached });
|
|
175
279
|
const why = preflight.summary || preflight.verdict;
|
|
176
280
|
if (preflight.verdict === "no_go") throw new SafePayError(`not paid: ${why}`, { code: "no_go", preflight, url });
|
|
@@ -180,6 +284,6 @@ export function createSafeFetch({
|
|
|
180
284
|
} else if (preflight.verdict !== "go") {
|
|
181
285
|
throw new SafePayError(`not paid: unknown verdict ${preflight.verdict}`, { code: "preflight_failed", preflight, url });
|
|
182
286
|
}
|
|
183
|
-
return
|
|
287
|
+
return payAfterPreflight();
|
|
184
288
|
};
|
|
185
289
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "x402-safe-fetch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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": {
|