x402-safe-fetch 0.12.0 → 0.14.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 +44 -0
- package/hook.d.ts +34 -0
- package/hook.js +145 -0
- package/index.d.ts +17 -1
- package/index.js +36 -3
- package/package.json +8 -1
- package/paid-guard.js +64 -0
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 | |
|
|
@@ -103,11 +129,29 @@ When a seller offers both, the exact option is paid. The preflight and the budge
|
|
|
103
129
|
| `diagnoseOnFailure` | `false` | When a payment still fails, buy a $0.01 Doctor diagnosis of why (see below) |
|
|
104
130
|
| `shareOutcomes` | `false` | After paying, tell Doctor whether the payment worked, so its later preflights learn from it; when not paying, tell it why (see below) |
|
|
105
131
|
| `tellSellers` | `false` | When not paying, tell the seller itself why, if its 402 takes refusals (draft refusal extension, see below) |
|
|
132
|
+
| `onPaidError` | `"stop"` | The same request was paid before and answered without delivering: `"stop"` throws code `paid_no_delivery`, `"pay"` pays again, or a function decides (see below) |
|
|
133
|
+
| `paidErrorMs` | 30 minutes | How long such a request stays blocked |
|
|
106
134
|
| `onDiagnosis` | | `(report, { url, method, status, error }) => void`, e.g. for logging or alerting |
|
|
107
135
|
| `authority` | the Fizzl payout wallet | Wallet whose certificates also make a signer trusted (key rotation); `null` accepts only `doctorSigners` |
|
|
108
136
|
|
|
109
137
|
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.
|
|
110
138
|
|
|
139
|
+
## Never paid twice for nothing
|
|
140
|
+
|
|
141
|
+
A seller can take the payment and still answer with an error: a 405, a 500, or another 402 asking to pay again. Paying the same request again then usually costs a second payment for nothing. In the agent exam at [exam.fizzl.eu](https://exam.fizzl.eu) this was the most common loss: every model we examined did it at least once, with or without a preflight, because a preflight only sees the 402 before paying.
|
|
142
|
+
|
|
143
|
+
So safe-fetch remembers each paid request (method, URL and body) whose paid answer was not a success. Calling that same request again within 30 minutes throws `SafePayError` with code `paid_no_delivery` (and `error.earlier`: when it was paid and what it was answered with), without paying and without buying another preflight. A different request is not affected, and a success clears it.
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const safeFetch = createSafeFetch({
|
|
147
|
+
register,
|
|
148
|
+
onPaidError: "stop", // default; "pay" pays again, or a function (earlier, { url, method }) => boolean decides
|
|
149
|
+
paidErrorMs: 30 * 60 * 1000, // how long such a request stays blocked
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The guard is also exported on its own (`createPaidGuard`, no dependencies) for agents that pay through another client.
|
|
154
|
+
|
|
111
155
|
## When a payment still fails
|
|
112
156
|
|
|
113
157
|
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.
|
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.d.ts
CHANGED
|
@@ -41,9 +41,11 @@ export interface Diagnosis {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export declare class SafePayError extends Error {
|
|
44
|
-
code: "no_go" | "caution" | "incomplete" | "preflight_failed" | "bad_receipt" | "no_option";
|
|
44
|
+
code: "no_go" | "caution" | "incomplete" | "preflight_failed" | "bad_receipt" | "no_option" | "paid_no_delivery";
|
|
45
45
|
preflight: Preflight | null;
|
|
46
46
|
url: string | null;
|
|
47
|
+
/** With code "paid_no_delivery": when the same request was paid before, and what it was answered with. */
|
|
48
|
+
earlier?: { at: number; status: number };
|
|
47
49
|
/** Set when diagnoseOnFailure diagnosed a payment that failed. */
|
|
48
50
|
diagnosis?: Diagnosis;
|
|
49
51
|
}
|
|
@@ -78,6 +80,10 @@ export interface SafeFetchOptions {
|
|
|
78
80
|
shareOutcomes?: boolean;
|
|
79
81
|
/** When not paying, tell the seller why if its 402 names a refusal url on its own site (draft x402-foundation/x402#4087). Default false. */
|
|
80
82
|
tellSellers?: boolean;
|
|
83
|
+
/** The same request (method, URL, body) was paid before and answered without delivering (an error, or another 402): "stop" (default) throws code "paid_no_delivery" without paying, "pay" pays again, a function decides. */
|
|
84
|
+
onPaidError?: "stop" | "pay" | ((earlier: { at: number; status: number }, info: { url: string; method: string }) => boolean | Promise<boolean>);
|
|
85
|
+
/** How long such a request stays blocked, in ms. Default 30 minutes. A success clears it. */
|
|
86
|
+
paidErrorMs?: number;
|
|
81
87
|
fetch?: typeof globalThis.fetch;
|
|
82
88
|
/** Advanced/testing: a paying fetch capped at `cap` (e.g. "$0.05"). */
|
|
83
89
|
createPayingFetch?: (cap: string) => typeof globalThis.fetch;
|
|
@@ -102,3 +108,13 @@ export declare function verifyReceipt(body: unknown, options?: { signers?: strin
|
|
|
102
108
|
|
|
103
109
|
/** The reason code reported with a refused outcome: the preflight's first reason at its verdict's level. */
|
|
104
110
|
export function refusalReason(preflight: { verdict?: string; reasons?: { level?: string; code?: string }[] }): string;
|
|
111
|
+
|
|
112
|
+
/** Remembers paid requests that weren't delivered, so the same request isn't paid again (used by createSafeFetch; usable on its own). */
|
|
113
|
+
export declare function createPaidGuard(options?: { windowMs?: number; now?: () => number }): {
|
|
114
|
+
blocked(url: string, init?: { method?: string; body?: unknown }): { at: number; status: number } | null;
|
|
115
|
+
record(url: string, init: { method?: string; body?: unknown } | undefined, status: number): void;
|
|
116
|
+
clear(url: string, init?: { method?: string; body?: unknown }): void;
|
|
117
|
+
};
|
|
118
|
+
export declare function purchaseKey(url: string, init?: { method?: string; body?: unknown }): string;
|
|
119
|
+
export declare function paidTwiceMessage(url: string, earlier: { at: number; status: number }): string;
|
|
120
|
+
export declare const PAID_ERROR_MS: number;
|
package/index.js
CHANGED
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
// draft refusal extension, x402-foundation/x402#4087): the ready report from Doctor's preflight (reason code and the
|
|
25
25
|
// hash of the refused offer; no query, no keys, nothing about the buyer or this client), once a day per endpoint and
|
|
26
26
|
// reason. A screening_hit isn't sent: the draft takes it only signed. Off by default.
|
|
27
|
+
// A paid request answered without delivery (an error, or another 402) is never paid again on its own: the same
|
|
28
|
+
// method, URL and body within paidErrorMs throws SafePayError code "paid_no_delivery" (onPaidError "stop", the
|
|
29
|
+
// default). The most common loss in the agent exam (exam.fizzl.eu) was paying again after a seller took the payment
|
|
30
|
+
// and answered with an error; a preflight can't see that, it only reads the 402 before paying (paid-guard.js).
|
|
27
31
|
// Every preflight must carry Doctor's signed receipt for exactly this request
|
|
28
32
|
// (endpoint, method, budget, network), checked against Doctor's pinned signer:
|
|
29
33
|
// a missing, changed or forged verdict is never a payment (receipt.js).
|
|
@@ -35,13 +39,15 @@
|
|
|
35
39
|
|
|
36
40
|
import { wrapFetchWithPayment, x402Client, decodePaymentResponseHeader } from "@x402/fetch";
|
|
37
41
|
import { verifyReceipt, DOCTOR_SIGNERS, AUTHORITY } from "./receipt.js";
|
|
42
|
+
import { createPaidGuard, paidTwiceMessage, PAID_ERROR_MS } from "./paid-guard.js";
|
|
38
43
|
|
|
44
|
+
export { createPaidGuard, purchaseKey, paidTwiceMessage, PAID_ERROR_MS } from "./paid-guard.js";
|
|
39
45
|
export { verifyReceipt, recoverSigner, canonicalJson, inputHash, certMessage, DOCTOR_SIGNERS, AUTHORITY } from "./receipt.js";
|
|
40
46
|
|
|
41
47
|
export const DOCTOR_URL = "https://x402-doctor.fizzl.eu";
|
|
42
48
|
export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
|
|
43
49
|
export const DIAGNOSE_CAP = "$0.02"; // the diagnosis costs $0.01; never more than twice that
|
|
44
|
-
export const VERSION = "0.
|
|
50
|
+
export const VERSION = "0.14.0";
|
|
45
51
|
// Doctor sees which calls come from this package (in its usage counts), nothing more.
|
|
46
52
|
const DOCTOR_HEADERS = { accept: "application/json", "user-agent": `x402-safe-fetch/${VERSION}` };
|
|
47
53
|
export const BASE = "eip155:8453";
|
|
@@ -141,13 +147,14 @@ export function refusalReason(preflight) {
|
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
export class SafePayError extends Error {
|
|
144
|
-
/** code: "no_go" | "caution" | "preflight_failed" | "bad_receipt" | "no_option" */
|
|
145
|
-
constructor(message, { code, preflight = null, url = null } = {}) {
|
|
150
|
+
/** code: "no_go" | "caution" | "incomplete" | "preflight_failed" | "bad_receipt" | "no_option" | "paid_no_delivery" */
|
|
151
|
+
constructor(message, { code, preflight = null, url = null, earlier } = {}) {
|
|
146
152
|
super(message);
|
|
147
153
|
this.name = "SafePayError";
|
|
148
154
|
this.code = code;
|
|
149
155
|
this.preflight = preflight;
|
|
150
156
|
this.url = url;
|
|
157
|
+
if (earlier) this.earlier = earlier;
|
|
151
158
|
}
|
|
152
159
|
}
|
|
153
160
|
|
|
@@ -244,6 +251,10 @@ function sameSite(a, b) {
|
|
|
244
251
|
* @param {(report: object, info: {url: string, method: string, status: number|null, error: Error|null}) => void} [options.onDiagnosis]
|
|
245
252
|
* @param {boolean} [options.shareOutcomes] after paying, tell Doctor whether the payment worked (default false)
|
|
246
253
|
* @param {boolean} [options.tellSellers] when not paying, tell the seller why if its 402 takes refusals (default false)
|
|
254
|
+
* @param {"stop"|"pay"|((earlier: {at: number, status: number}, info: {url: string, method: string}) => boolean|Promise<boolean>)} [options.onPaidError]
|
|
255
|
+
* the same request was paid before and answered without delivering: "stop" (default) throws code "paid_no_delivery",
|
|
256
|
+
* "pay" pays again, a function decides
|
|
257
|
+
* @param {number} [options.paidErrorMs] how long such a request stays blocked (default 30 minutes)
|
|
247
258
|
* @param {(cap: string) => typeof fetch} [options.createPayingFetch] advanced/testing: a paying fetch capped at `cap`
|
|
248
259
|
* @param {() => number} [options.now]
|
|
249
260
|
*/
|
|
@@ -264,6 +275,8 @@ export function createSafeFetch({
|
|
|
264
275
|
onDiagnosis,
|
|
265
276
|
shareOutcomes = false,
|
|
266
277
|
tellSellers = false,
|
|
278
|
+
onPaidError = "stop",
|
|
279
|
+
paidErrorMs = PAID_ERROR_MS,
|
|
267
280
|
fetch: baseFetch = globalThis.fetch,
|
|
268
281
|
createPayingFetch,
|
|
269
282
|
now = Date.now,
|
|
@@ -279,6 +292,10 @@ export function createSafeFetch({
|
|
|
279
292
|
if (!["pay", "stop"].includes(onIncomplete) && typeof onIncomplete !== "function") {
|
|
280
293
|
throw new TypeError('onIncomplete must be "pay", "stop" or a function');
|
|
281
294
|
}
|
|
295
|
+
if (!["stop", "pay"].includes(onPaidError) && typeof onPaidError !== "function") {
|
|
296
|
+
throw new TypeError('onPaidError must be "stop", "pay" or a function');
|
|
297
|
+
}
|
|
298
|
+
const paidGuard = createPaidGuard({ windowMs: paidErrorMs, now });
|
|
282
299
|
if (!["require", "off"].includes(verifyReceipts)) throw new TypeError('verifyReceipts must be "require" or "off"');
|
|
283
300
|
const trustedHosts = new Set(trusted.map(hostOf).filter(Boolean));
|
|
284
301
|
|
|
@@ -422,7 +439,13 @@ export function createSafeFetch({
|
|
|
422
439
|
tellSeller(url, preflight);
|
|
423
440
|
}
|
|
424
441
|
|
|
442
|
+
// Every endpoint payment goes through here: what it was answered with is remembered for the paid-twice guard.
|
|
425
443
|
async function payAndCheck(url, init, method) {
|
|
444
|
+
const res = await payAndDiagnose(url, init, method);
|
|
445
|
+
paidGuard.record(url, { method, body: init.body }, res.status);
|
|
446
|
+
return res;
|
|
447
|
+
}
|
|
448
|
+
async function payAndDiagnose(url, init, method) {
|
|
426
449
|
if (!diagnoseOnFailure) return payEndpoint(url, init);
|
|
427
450
|
let res;
|
|
428
451
|
try {
|
|
@@ -451,6 +474,16 @@ export function createSafeFetch({
|
|
|
451
474
|
const probe = await baseFetch(url, init);
|
|
452
475
|
if (probe.status !== 402) return probe;
|
|
453
476
|
|
|
477
|
+
// Paid before and not delivered: not paid again (and no preflight bought) unless onPaidError says so.
|
|
478
|
+
const earlier = paidGuard.blocked(url, { method, body: init.body });
|
|
479
|
+
if (earlier && onPaidError !== "pay") {
|
|
480
|
+
const again = typeof onPaidError === "function" && (await onPaidError(earlier, { url, method })) === true;
|
|
481
|
+
if (!again) {
|
|
482
|
+
try { await probe.body?.cancel(); } catch { /* already consumed */ }
|
|
483
|
+
throw new SafePayError(paidTwiceMessage(url, earlier), { code: "paid_no_delivery", url, earlier });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
454
487
|
if (trustedHosts.has(hostOf(url))) {
|
|
455
488
|
try { await probe.body?.cancel(); } catch { /* already consumed */ }
|
|
456
489
|
return payAndCheck(url, init, method);
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "x402-safe-fetch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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,10 @@
|
|
|
14
18
|
"files": [
|
|
15
19
|
"index.js",
|
|
16
20
|
"receipt.js",
|
|
21
|
+
"paid-guard.js",
|
|
22
|
+
"hook.js",
|
|
17
23
|
"index.d.ts",
|
|
24
|
+
"hook.d.ts",
|
|
18
25
|
"README.md",
|
|
19
26
|
"LICENSE"
|
|
20
27
|
],
|
package/paid-guard.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Never pay twice for one purchase that wasn't delivered.
|
|
2
|
+
//
|
|
3
|
+
// The costliest mistake in the agent exam (exam.fizzl.eu): a seller takes the payment, then answers with an error
|
|
4
|
+
// (a 405, a 500, or another 402 asking to pay again), and the agent pays the same request again, and again. Every
|
|
5
|
+
// model we examined did it at least once, with or without a preflight: the preflight only sees the 402 before paying.
|
|
6
|
+
//
|
|
7
|
+
// The guard remembers each paid request (method, URL and body) whose paid answer was not a success. Paying that same
|
|
8
|
+
// request again within the window is refused, so the loss stops at one payment. A success clears it. A different
|
|
9
|
+
// request (another URL, method or body) is not affected. No dependencies: it runs anywhere fetch does.
|
|
10
|
+
|
|
11
|
+
export const PAID_ERROR_MS = 30 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
// A short, stable fingerprint of the body (FNV-1a, 32 bit): the key holds no request content.
|
|
14
|
+
function bodyKey(body) {
|
|
15
|
+
if (body === undefined || body === null) return "";
|
|
16
|
+
const text = typeof body === "string" ? body : body instanceof URLSearchParams ? body.toString() : ArrayBuffer.isView(body) ? new TextDecoder().decode(body) : body instanceof ArrayBuffer ? new TextDecoder().decode(new Uint8Array(body)) : String(body);
|
|
17
|
+
let h = 0x811c9dc5;
|
|
18
|
+
for (let i = 0; i < text.length; i++) {
|
|
19
|
+
h ^= text.charCodeAt(i);
|
|
20
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
21
|
+
}
|
|
22
|
+
return `${text.length}:${h.toString(16)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The request as the guard sees it: method, URL and a fingerprint of the body. */
|
|
26
|
+
export function purchaseKey(url, { method = "GET", body } = {}) {
|
|
27
|
+
return `${String(method).toUpperCase()} ${String(url)} ${bodyKey(body)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} [options]
|
|
32
|
+
* @param {number} [options.windowMs] how long a paid request that wasn't delivered stays blocked (default 30 minutes)
|
|
33
|
+
* @param {() => number} [options.now]
|
|
34
|
+
*/
|
|
35
|
+
export function createPaidGuard({ windowMs = PAID_ERROR_MS, now = Date.now } = {}) {
|
|
36
|
+
const failed = new Map(); // purchase key → { at, status }
|
|
37
|
+
const prune = () => {
|
|
38
|
+
const t = now();
|
|
39
|
+
for (const [k, v] of failed) if (t - v.at >= windowMs) failed.delete(k);
|
|
40
|
+
while (failed.size > 1000) failed.delete(failed.keys().next().value);
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
/** The earlier paid answer that wasn't delivered ({ at, status }), while it blocks this request; else null. */
|
|
44
|
+
blocked(url, init = {}) {
|
|
45
|
+
prune();
|
|
46
|
+
return failed.get(purchaseKey(url, init)) ?? null;
|
|
47
|
+
},
|
|
48
|
+
/** After paying: what the paid request was answered with. */
|
|
49
|
+
record(url, init = {}, status) {
|
|
50
|
+
const key = purchaseKey(url, init);
|
|
51
|
+
if (status >= 200 && status < 300) failed.delete(key);
|
|
52
|
+
else failed.set(key, { at: now(), status });
|
|
53
|
+
},
|
|
54
|
+
/** Forget a request (e.g. the owner said to pay it again). */
|
|
55
|
+
clear(url, init = {}) {
|
|
56
|
+
failed.delete(purchaseKey(url, init));
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The refusal text, for an error or a tool answer. */
|
|
62
|
+
export function paidTwiceMessage(url, earlier) {
|
|
63
|
+
return `not paid again: ${url} was already paid at ${new Date(earlier.at).toISOString()} and answered HTTP ${earlier.status} without delivering. Paying the same request again would most likely be charged again for nothing; ask the seller, or try a different request.`;
|
|
64
|
+
}
|