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 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: { accept: "application/json" } });
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 payEndpoint(url, init);
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 payEndpoint(url, init);
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.1",
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": {