x402-safe-fetch 0.12.0 → 0.13.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
@@ -86,6 +86,32 @@ const safeFetch = createSafeFetch({
86
86
 
87
87
  When a seller offers both, the exact option is paid. The preflight and the budget apply the same way.
88
88
 
89
+ ## Already have an x402 client? One line
90
+
91
+ If your agent already pays with the official x402 client (`x402Client` from `@x402/core`, used by `@x402/fetch`, `@x402/axios` and the other transports), you don't have to switch to `safeFetch`. Add the preflight as a hook, and every payment that client makes is checked first:
92
+
93
+ ```js
94
+ import { x402Client } from "@x402/core/client";
95
+ import { wrapFetchWithPayment } from "@x402/fetch";
96
+ import { doctorPreflight } from "x402-safe-fetch/hook";
97
+
98
+ const client = new x402Client();
99
+ // … register your schemes as before (registerExactEvmScheme(client, { signer }), …)
100
+ client.onBeforePaymentCreation(doctorPreflight({ client, maxUsd: 0.05 }));
101
+
102
+ const fetchWithPayment = wrapFetchWithPayment(fetch, client); // unchanged
103
+ ```
104
+
105
+ Before the client signs a payment, the hook asks x402 Doctor ($0.001, paid by the same client, or 1 prepaid credit with `creditKey`). Then:
106
+
107
+ - `no_go`: the payment is aborted (`Payment creation aborted: x402 Doctor: …`). Nothing is signed.
108
+ - `caution`: aborted, unless `onCaution` is `"pay"` or your function says yes.
109
+ - `go`: the payment goes ahead. With `onIncomplete: "stop"`, a `go` whose `checks_complete` is false is aborted too.
110
+
111
+ The verdict is signed by Doctor and checked, and reused while the endpoint asks the same thing (same payTo, amount, token and network). Doctor's own 402 for the preflight is never checked again and is paid only up to $0.002. Options: `client`, `pay` or `creditKey` (how the preflight is paid), `maxUsd`, `method`, `onCaution`, `onIncomplete`, `onError` (`"stop"` by default), `trusted`, `onPreflight`, `cacheMs`.
112
+
113
+ The hook can't see your request, only the 402, so Doctor checks the endpoint with `method` (GET by default; it tries the other method too).
114
+
89
115
  ## Options
90
116
 
91
117
  | Option | Default | |
package/hook.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { Preflight } from "./index.js";
2
+
3
+ export interface DoctorPreflightOptions {
4
+ /** The x402Client the hook is added to: it pays the $0.001 preflight too (Doctor's own 402 is skipped by the hook). */
5
+ client?: unknown;
6
+ /** Or a fetch that pays x402, for the preflight. */
7
+ pay?: typeof fetch;
8
+ /** Or prepaid x402 Doctor credits (xdc_…): tried first, then pay or client. */
9
+ creditKey?: string;
10
+ /** Your budget per call in USD: Doctor says no_go above it. */
11
+ maxUsd?: number | string;
12
+ /** How your calls reach the endpoints. Default "GET"; Doctor tries the other method too. */
13
+ method?: "GET" | "POST";
14
+ /** On a caution verdict. Default "stop". */
15
+ onCaution?: "stop" | "pay" | ((preflight: Preflight) => boolean | Promise<boolean>);
16
+ /** On a go whose checks_complete is false. Default "pay". */
17
+ onIncomplete?: "pay" | "stop" | ((preflight: Preflight) => boolean | Promise<boolean>);
18
+ /** When Doctor can't be asked. Default "stop". */
19
+ onError?: "stop" | "pay";
20
+ /** Hosts paid without a preflight. */
21
+ trusted?: string[];
22
+ onPreflight?: (preflight: Preflight, info: { url: string; cached: boolean }) => void;
23
+ /** How long a verdict is reused while the endpoint asks the same thing, in ms. Default 1 hour (10 minutes for an incomplete one). */
24
+ cacheMs?: number;
25
+ doctorUrl?: string;
26
+ verifyReceipts?: "require" | "off";
27
+ doctorSigners?: string[];
28
+ authority?: string | null;
29
+ fetch?: typeof fetch;
30
+ now?: () => number;
31
+ }
32
+
33
+ /** A BeforePaymentCreationHook for any @x402/core x402Client: client.onBeforePaymentCreation(doctorPreflight({ client })). */
34
+ export function doctorPreflight(options?: DoctorPreflightOptions): (context: { paymentRequired: unknown; selectedRequirements: unknown }) => Promise<void | { abort: true; reason: string }>;
package/hook.js ADDED
@@ -0,0 +1,145 @@
1
+ // x402 Doctor's pre-payment check as a hook for any x402 client (@x402/core x402Client, which @x402/fetch,
2
+ // @x402/axios and the other x402 transports use): one line, and every payment that client makes is checked first.
3
+ //
4
+ // import { doctorPreflight } from "x402-safe-fetch/hook";
5
+ // client.onBeforePaymentCreation(doctorPreflight({ client }));
6
+ //
7
+ // Before the client signs a payment, the hook asks Doctor (GET /api/v1/preflight, $0.001, or 1 prepaid credit)
8
+ // whether the endpoint is safe to pay: no_go stops the payment, caution stops it unless onCaution says otherwise,
9
+ // go lets it through. The verdict is signed by Doctor and checked; it is reused while the endpoint asks the same
10
+ // thing. Doctor's own 402 (for the preflight itself) is never checked again, and is paid only up to $0.002.
11
+ import { wrapFetchWithPayment } from "@x402/fetch";
12
+ import { DOCTOR_URL, preflightUrl, VERSION } from "./index.js";
13
+ import { verifyReceipt, DOCTOR_SIGNERS, AUTHORITY } from "./receipt.js";
14
+
15
+ const CREDIT_HEADER = "x-credit-key";
16
+ const CREDIT_KEY = /^xdc_[A-Za-z0-9_-]{43}$/;
17
+ // The preflight costs $0.001; its own payment is refused above twice that (USDC and the other dollar tokens Doctor
18
+ // takes have 6 decimals; RLUSD on the XRP Ledger is a decimal amount).
19
+ const PREFLIGHT_MAX_ATOMIC = 2000n;
20
+ const PREFLIGHT_MAX_DECIMAL = 0.002;
21
+
22
+ const hostOf = (u) => { try { return new URL(u).host; } catch { return null; } };
23
+ const abort = (reason) => ({ abort: true, reason: String(reason).slice(0, 300) });
24
+
25
+ // Within the preflight's price: atomic units (EVM, Solana, Algorand, Stellar) or a decimal amount (XRPL RLUSD).
26
+ function withinPreflightPrice(amount) {
27
+ const s = String(amount ?? "");
28
+ if (/^\d+$/.test(s)) return BigInt(s) <= PREFLIGHT_MAX_ATOMIC;
29
+ const n = Number(s);
30
+ return Number.isFinite(n) && n > 0 && n <= PREFLIGHT_MAX_DECIMAL;
31
+ }
32
+
33
+ /**
34
+ * @param {object} options
35
+ * @param {object} [options.client] the x402Client the hook is added to: it pays the preflight too (the hook skips Doctor's own 402)
36
+ * @param {typeof fetch} [options.pay] or a fetch that pays x402, for the preflight
37
+ * @param {string} [options.creditKey] or prepaid Doctor credits (xdc_…): tried first, then pay or client
38
+ * @param {number|string} [options.maxUsd] your budget per call: Doctor says no_go above it
39
+ * @param {"GET"|"POST"} [options.method] how your calls reach the endpoints (Doctor tries the other method too)
40
+ * @param {"stop"|"pay"|((preflight: object) => boolean|Promise<boolean>)} [options.onCaution] default "stop"
41
+ * @param {"pay"|"stop"|((preflight: object) => boolean|Promise<boolean>)} [options.onIncomplete] a go whose checks_complete is false; default "pay"
42
+ * @param {"stop"|"pay"} [options.onError] when Doctor can't be asked; default "stop"
43
+ * @param {string[]} [options.trusted] hosts paid without a preflight
44
+ * @param {(preflight: object, info: {url: string, cached: boolean}) => void} [options.onPreflight]
45
+ * @param {number} [options.cacheMs] how long a verdict is reused while the endpoint asks the same thing (default 1 hour)
46
+ * @param {"require"|"off"} [options.verifyReceipts] check Doctor's signature (default "require")
47
+ * @returns {(context: {paymentRequired: object, selectedRequirements: object}) => Promise<void|{abort: true, reason: string}>}
48
+ */
49
+ export function doctorPreflight({
50
+ client,
51
+ pay,
52
+ creditKey,
53
+ maxUsd,
54
+ method = "GET",
55
+ onCaution = "stop",
56
+ onIncomplete = "pay",
57
+ onError = "stop",
58
+ trusted = [],
59
+ onPreflight,
60
+ cacheMs = 60 * 60 * 1000,
61
+ doctorUrl = DOCTOR_URL,
62
+ verifyReceipts = "require",
63
+ doctorSigners = DOCTOR_SIGNERS,
64
+ authority = AUTHORITY,
65
+ fetch: plainFetch = globalThis.fetch,
66
+ now = Date.now,
67
+ } = {}) {
68
+ if (!client && typeof pay !== "function" && !creditKey) throw new TypeError("doctorPreflight needs a way to pay the $0.001 preflight: client, pay or creditKey");
69
+ if (creditKey !== undefined && !CREDIT_KEY.test(String(creditKey))) throw new TypeError("creditKey must be an x402 Doctor credit key (xdc_…)");
70
+ if (!["stop", "pay"].includes(onCaution) && typeof onCaution !== "function") throw new TypeError('onCaution must be "stop", "pay" or a function');
71
+ if (!["pay", "stop"].includes(onIncomplete) && typeof onIncomplete !== "function") throw new TypeError('onIncomplete must be "pay", "stop" or a function');
72
+ if (!["stop", "pay"].includes(onError)) throw new TypeError('onError must be "stop" or "pay"');
73
+ if (!["require", "off"].includes(verifyReceipts)) throw new TypeError('verifyReceipts must be "require" or "off"');
74
+ const doctorHost = hostOf(doctorUrl);
75
+ const trustedHosts = new Set(trusted.map((t) => hostOf(/^https?:/.test(t) ? t : `https://${t}`)).filter(Boolean));
76
+ let payingFetch = typeof pay === "function" ? pay : null;
77
+ const payer = () => (payingFetch ??= client ? wrapFetchWithPayment(plainFetch, client) : null);
78
+ const headers = { accept: "application/json", "user-agent": `x402-safe-fetch/${VERSION} (hook)` };
79
+ const verdicts = new Map();
80
+
81
+ async function ask(url, network) {
82
+ const pfUrl = preflightUrl(url, { method, maxUsd, network, doctorUrl });
83
+ let res = null;
84
+ if (creditKey) {
85
+ res = await plainFetch(pfUrl, { headers: { ...headers, [CREDIT_HEADER]: creditKey } });
86
+ if (res.status === 402) res = null; // credits used up or unknown: pay instead, if we can
87
+ }
88
+ if (!res) {
89
+ const f = payer();
90
+ if (!f) throw new Error("no prepaid credits left and nothing to pay the preflight with");
91
+ res = await f(pfUrl, { headers });
92
+ }
93
+ const preflight = await res.json().catch(() => null);
94
+ if (!res.ok || !preflight?.verdict) throw new Error(`preflight answered HTTP ${res.status}`);
95
+ if (verifyReceipts === "require") {
96
+ const input = Object.fromEntries(new URL(pfUrl).searchParams);
97
+ const check = verifyReceipt(preflight, { signers: doctorSigners, route: "GET /api/v1/preflight", input, authority, service: "x402-doctor" });
98
+ if (!check.valid) throw new Error(`preflight not trusted (${check.reason})`);
99
+ }
100
+ return preflight;
101
+ }
102
+
103
+ return async function doctorPreflightHook({ paymentRequired, selectedRequirements } = {}) {
104
+ const url = paymentRequired?.resource?.url;
105
+ const host = hostOf(url);
106
+ // Doctor's own 402, for the preflight we are paying: never checked again, and never more than its price.
107
+ if (host && host === doctorHost) {
108
+ return withinPreflightPrice(selectedRequirements?.amount ?? selectedRequirements?.maxAmountRequired) ? undefined : abort("x402 Doctor's preflight asked more than $0.002");
109
+ }
110
+ if (!url || (host && trustedHosts.has(host))) return undefined;
111
+
112
+ const r = selectedRequirements || {};
113
+ const key = JSON.stringify([url, r.network, r.asset, String(r.amount ?? r.maxAmountRequired ?? ""), r.payTo]);
114
+ const hit = verdicts.get(key);
115
+ const ttl = hit?.preflight?.checks_complete === false ? Math.min(cacheMs, 10 * 60 * 1000) : cacheMs;
116
+ let preflight;
117
+ let cached = false;
118
+ if (hit && hit.at + ttl > now()) {
119
+ preflight = hit.preflight;
120
+ cached = true;
121
+ } else {
122
+ try {
123
+ preflight = await ask(url, r.network);
124
+ } catch (err) {
125
+ return onError === "pay" ? undefined : abort(`x402 Doctor's preflight failed (${err.message}); not paid`);
126
+ }
127
+ verdicts.set(key, { preflight, at: now() });
128
+ if (verdicts.size > 1000) verdicts.delete(verdicts.keys().next().value);
129
+ }
130
+ onPreflight?.(preflight, { url, cached });
131
+
132
+ const why = preflight.summary || preflight.verdict;
133
+ if (preflight.verdict === "no_go") return abort(`x402 Doctor: ${why}`);
134
+ if (preflight.verdict === "caution") {
135
+ const ok = onCaution === "pay" || (typeof onCaution === "function" && (await onCaution(preflight)) === true);
136
+ return ok ? undefined : abort(`x402 Doctor (caution): ${why}`);
137
+ }
138
+ if (preflight.verdict !== "go") return abort(`x402 Doctor: unknown verdict ${preflight.verdict}`);
139
+ if (preflight.checks_complete === false && onIncomplete !== "pay") {
140
+ const ok = typeof onIncomplete === "function" && (await onIncomplete(preflight)) === true;
141
+ if (!ok) return abort("x402 Doctor: go, but not every check ran; try again in a few minutes");
142
+ }
143
+ return undefined;
144
+ };
145
+ }
package/index.js CHANGED
@@ -41,7 +41,7 @@ export { verifyReceipt, recoverSigner, canonicalJson, inputHash, certMessage, DO
41
41
  export const DOCTOR_URL = "https://x402-doctor.fizzl.eu";
42
42
  export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
43
43
  export const DIAGNOSE_CAP = "$0.02"; // the diagnosis costs $0.01; never more than twice that
44
- export const VERSION = "0.12.0";
44
+ export const VERSION = "0.13.0";
45
45
  // Doctor sees which calls come from this package (in its usage counts), nothing more.
46
46
  const DOCTOR_HEADERS = { accept: "application/json", "user-agent": `x402-safe-fetch/${VERSION}` };
47
47
  export const BASE = "eip155:8453";
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "x402-safe-fetch",
3
- "version": "0.12.0",
3
+ "version": "0.13.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": {
7
7
  ".": {
8
8
  "types": "./index.d.ts",
9
9
  "default": "./index.js"
10
+ },
11
+ "./hook": {
12
+ "types": "./hook.d.ts",
13
+ "default": "./hook.js"
10
14
  }
11
15
  },
12
16
  "main": "./index.js",
@@ -14,7 +18,9 @@
14
18
  "files": [
15
19
  "index.js",
16
20
  "receipt.js",
21
+ "hook.js",
17
22
  "index.d.ts",
23
+ "hook.d.ts",
18
24
  "README.md",
19
25
  "LICENSE"
20
26
  ],