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.
Files changed (4) hide show
  1. package/README.md +36 -0
  2. package/index.d.ts +19 -0
  3. package/index.js +107 -3
  4. 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: { accept: "application/json" } });
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 payEndpoint(url, init);
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 payEndpoint(url, init);
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.1",
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": {