x402-safe-fetch 0.13.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 +18 -0
- package/index.d.ts +17 -1
- package/index.js +36 -3
- package/package.json +2 -1
- package/paid-guard.js +64 -0
package/README.md
CHANGED
|
@@ -129,11 +129,29 @@ The hook can't see your request, only the 402, so Doctor checks the endpoint wit
|
|
|
129
129
|
| `diagnoseOnFailure` | `false` | When a payment still fails, buy a $0.01 Doctor diagnosis of why (see below) |
|
|
130
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) |
|
|
131
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 |
|
|
132
134
|
| `onDiagnosis` | | `(report, { url, method, status, error }) => void`, e.g. for logging or alerting |
|
|
133
135
|
| `authority` | the Fizzl payout wallet | Wallet whose certificates also make a signer trusted (key rotation); `null` accepts only `doctorSigners` |
|
|
134
136
|
|
|
135
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.
|
|
136
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
|
+
|
|
137
155
|
## When a payment still fails
|
|
138
156
|
|
|
139
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/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,6 +1,6 @@
|
|
|
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": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"files": [
|
|
19
19
|
"index.js",
|
|
20
20
|
"receipt.js",
|
|
21
|
+
"paid-guard.js",
|
|
21
22
|
"hook.js",
|
|
22
23
|
"index.d.ts",
|
|
23
24
|
"hook.d.ts",
|
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
|
+
}
|