x402-safe-fetch 0.13.0 → 0.15.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
@@ -119,6 +119,8 @@ The hook can't see your request, only the 402, so Doctor checks the endpoint wit
119
119
  | `register` | required | `(client) => client.register(network, scheme)`: your payment schemes; your keys stay in your code |
120
120
  | `network` | `"base"` | `"base"`, `"solana"`, `"algorand"` (USDC), `"xrpl"` (RLUSD), `"stellar"` (USDC), `"xlayer"` (USD₮0 or USDC), their `-testnet` forms, or a CAIP-2 id; the preflight checks the option on this network |
121
121
  | `maxUsd` | `0.05` | Budget per endpoint call; the payment is capped at it and the preflight says `no_go` above it |
122
+ | `maxTotalUsd` | none | Budget for every payment this safeFetch makes together; make one per task or session (see below) |
123
+ | `maxPerSellerUsd` | none | The same per seller (the endpoint's host) |
122
124
  | `onCaution` | `"stop"` | `"stop"`, `"pay"` or `async (preflight) => boolean` |
123
125
  | `onIncomplete` | `"pay"` | A `go` whose `checks_complete` is `false` (a check that applies couldn't run this time, named in `not_checked`): `"pay"`, `"stop"` (throws code `incomplete`; not reported to the seller as a refusal) or `async (preflight) => boolean`. Such a verdict is reused for at most 10 minutes |
124
126
  | `trusted` | `[]` | Hosts paid without a preflight |
@@ -129,11 +131,45 @@ The hook can't see your request, only the 402, so Doctor checks the endpoint wit
129
131
  | `diagnoseOnFailure` | `false` | When a payment still fails, buy a $0.01 Doctor diagnosis of why (see below) |
130
132
  | `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
133
  | `tellSellers` | `false` | When not paying, tell the seller itself why, if its 402 takes refusals (draft refusal extension, see below) |
134
+ | `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) |
135
+ | `paidErrorMs` | 30 minutes | How long such a request stays blocked |
132
136
  | `onDiagnosis` | | `(report, { url, method, status, error }) => void`, e.g. for logging or alerting |
133
137
  | `authority` | the Fizzl payout wallet | Wallet whose certificates also make a signer trusted (key rotation); `null` accepts only `doctorSigners` |
134
138
 
135
139
  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
140
 
141
+ ## Never paid twice for nothing
142
+
143
+ 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.
144
+
145
+ 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.
146
+
147
+ ```js
148
+ const safeFetch = createSafeFetch({
149
+ register,
150
+ onPaidError: "stop", // default; "pay" pays again, or a function (earlier, { url, method }) => boolean decides
151
+ paidErrorMs: 30 * 60 * 1000, // how long such a request stays blocked
152
+ });
153
+ ```
154
+
155
+ The guard is also exported on its own (`createPaidGuard`, no dependencies) for agents that pay through another client.
156
+
157
+ ## A budget for the whole task
158
+
159
+ `maxUsd` caps each payment. A seller that splits its answer over many cheap calls (2 cents a page, "call again for the next page") stays under that cap on every call and still runs up the bill. In the agent exam this is the `page-drip` task. Give safe-fetch a total, a budget per seller, or both:
160
+
161
+ ```js
162
+ const safeFetch = createSafeFetch({
163
+ register,
164
+ maxUsd: 0.05, // each payment
165
+ maxTotalUsd: 0.10, // every payment of this safeFetch together
166
+ maxPerSellerUsd: 0.10, // every payment to one host
167
+ });
168
+ safeFetch.spent(); // { totalUsd: 0.06, bySeller: { "api.example.com": 0.06 } }
169
+ ```
170
+
171
+ A payment that would go past either one throws `SafePayError` with code `over_total_budget` or `over_seller_budget` (and `error.spent`), before the preflight is bought. Near the end, x402's own cap on the next payment shrinks to what is left. The price comes from the seller's 402; when it can't be read, a payment counts as the most it may cost. A payment answered with another 402 isn't counted; a paid error is (the money is gone). Doctor's preflights and diagnoses are not counted. The totals live as long as the safeFetch: make one per task, user or session.
172
+
137
173
  ## When a payment still fails
138
174
 
139
175
  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,13 @@ 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" | "over_total_budget" | "over_seller_budget";
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 };
49
+ /** With code "over_total_budget" or "over_seller_budget": what this safeFetch had paid. */
50
+ spent?: SpentUsd;
47
51
  /** Set when diagnoseOnFailure diagnosed a payment that failed. */
48
52
  diagnosis?: Diagnosis;
49
53
  }
@@ -55,6 +59,10 @@ export interface SafeFetchOptions {
55
59
  network?: string;
56
60
  /** Budget per endpoint call in USD. Default 0.05. */
57
61
  maxUsd?: number | string;
62
+ /** Budget in USD for every payment this safeFetch makes together (make one per task or session). Past it: code "over_total_budget". Default none. */
63
+ maxTotalUsd?: number | string;
64
+ /** Budget in USD per seller (the endpoint's host). Past it: code "over_seller_budget". Default none. */
65
+ maxPerSellerUsd?: number | string;
58
66
  /** What to do on a caution verdict. Default "stop". */
59
67
  onCaution?: "stop" | "pay" | ((preflight: Preflight) => boolean | Promise<boolean>);
60
68
  /** What to do on a "go" whose checks_complete is false (a check that applies couldn't run). Default "pay"; "stop" throws code "incomplete". */
@@ -78,13 +86,29 @@ export interface SafeFetchOptions {
78
86
  shareOutcomes?: boolean;
79
87
  /** 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
88
  tellSellers?: boolean;
89
+ /** 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. */
90
+ onPaidError?: "stop" | "pay" | ((earlier: { at: number; status: number }, info: { url: string; method: string }) => boolean | Promise<boolean>);
91
+ /** How long such a request stays blocked, in ms. Default 30 minutes. A success clears it. */
92
+ paidErrorMs?: number;
81
93
  fetch?: typeof globalThis.fetch;
82
94
  /** Advanced/testing: a paying fetch capped at `cap` (e.g. "$0.05"). */
83
95
  createPayingFetch?: (cap: string) => typeof globalThis.fetch;
84
96
  now?: () => number;
85
97
  }
86
98
 
87
- export declare function createSafeFetch(options: SafeFetchOptions): (input: string | URL, init?: RequestInit) => Promise<Response>;
99
+ /** What a safeFetch paid to sellers, in USD (Doctor's preflights and diagnoses not included). */
100
+ export interface SpentUsd {
101
+ totalUsd: number;
102
+ bySeller: Record<string, number>;
103
+ }
104
+ export interface SafeFetch {
105
+ (input: string | URL, init?: RequestInit): Promise<Response>;
106
+ /** What this safeFetch paid to sellers so far. */
107
+ spent(): SpentUsd;
108
+ }
109
+ export declare function createSafeFetch(options: SafeFetchOptions): SafeFetch;
110
+ /** What an option costs in USD on `network` (USDC and USD₮0: 6 decimals; Stellar USDC: 7; RLUSD: decimal dollars), or null. */
111
+ export declare function usdOfOption(option: { amount?: unknown; maxAmountRequired?: unknown }, network: string): number | null;
88
112
  export declare function usdCap(maxUsd: number | string): string;
89
113
  export declare function preflightUrl(target: string, options?: { method?: string; maxUsd?: number | string; network?: string; doctorUrl?: string }): string;
90
114
  export declare function diagnoseUrl(target: string, options?: { method?: string; doctorUrl?: string }): string;
@@ -102,3 +126,13 @@ export declare function verifyReceipt(body: unknown, options?: { signers?: strin
102
126
 
103
127
  /** The reason code reported with a refused outcome: the preflight's first reason at its verdict's level. */
104
128
  export function refusalReason(preflight: { verdict?: string; reasons?: { level?: string; code?: string }[] }): string;
129
+
130
+ /** Remembers paid requests that weren't delivered, so the same request isn't paid again (used by createSafeFetch; usable on its own). */
131
+ export declare function createPaidGuard(options?: { windowMs?: number; now?: () => number }): {
132
+ blocked(url: string, init?: { method?: string; body?: unknown }): { at: number; status: number } | null;
133
+ record(url: string, init: { method?: string; body?: unknown } | undefined, status: number): void;
134
+ clear(url: string, init?: { method?: string; body?: unknown }): void;
135
+ };
136
+ export declare function purchaseKey(url: string, init?: { method?: string; body?: unknown }): string;
137
+ export declare function paidTwiceMessage(url: string, earlier: { at: number; status: number }): string;
138
+ 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.13.0";
50
+ export const VERSION = "0.15.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";
@@ -92,6 +98,10 @@ export function anyAlgorandId(scheme) {
92
98
  // (not extra: invoices and nonces change per request). null when there is no x402 challenge to read (e.g. an
93
99
  // MPP-only 402). Reads and consumes the response body.
94
100
  export async function offerFingerprint(res) {
101
+ return (await readOffer(res)).fingerprint;
102
+ }
103
+ // The 402's x402 options and their fingerprint (see offerFingerprint). Reads and consumes the response body.
104
+ export async function readOffer(res) {
95
105
  let challenge = null;
96
106
  const header = res?.headers?.get?.("payment-required");
97
107
  if (header) {
@@ -100,9 +110,18 @@ export async function offerFingerprint(res) {
100
110
  } else {
101
111
  try { challenge = JSON.parse(await res.text()); } catch { /* not JSON */ }
102
112
  }
103
- if (!Array.isArray(challenge?.accepts)) return null;
113
+ if (!Array.isArray(challenge?.accepts)) return { fingerprint: null, accepts: null };
104
114
  const parts = challenge.accepts.map((a) => JSON.stringify([a?.scheme ?? null, a?.network ?? null, String(a?.asset ?? ""), String(a?.amount ?? a?.maxAmountRequired ?? ""), String(a?.payTo ?? "")]));
105
- return JSON.stringify(parts.sort());
115
+ return { fingerprint: JSON.stringify(parts.sort()), accepts: challenge.accepts };
116
+ }
117
+
118
+ // What an option paid on `network` costs in US dollars, or null when it can't be read: RLUSD amounts are decimal
119
+ // dollars, Circle's USDC on Stellar has 7 decimals, and every other token safe-fetch pays (USDC, USD₮0) has 6.
120
+ export function usdOfOption(option, network) {
121
+ const raw = String(option?.amount ?? option?.maxAmountRequired ?? "");
122
+ if (RLUSD_ISSUER[network]) return /^\d+(\.\d+)?$/.test(raw) ? Number(raw) : null;
123
+ if (!/^\d+$/.test(raw)) return null;
124
+ return Number(raw) / (STELLAR_USDC[network] ? 1e7 : 1e6);
106
125
  }
107
126
  // Without an offer to compare (no readable x402 challenge), a verdict is reused for at most 10 minutes.
108
127
  const UNREAD_OFFER_MS = 10 * 60 * 1000;
@@ -141,13 +160,15 @@ export function refusalReason(preflight) {
141
160
  }
142
161
 
143
162
  export class SafePayError extends Error {
144
- /** code: "no_go" | "caution" | "preflight_failed" | "bad_receipt" | "no_option" */
145
- constructor(message, { code, preflight = null, url = null } = {}) {
163
+ /** code: "no_go" | "caution" | "incomplete" | "preflight_failed" | "bad_receipt" | "no_option" | "paid_no_delivery" | "over_total_budget" | "over_seller_budget" */
164
+ constructor(message, { code, preflight = null, url = null, earlier, spent } = {}) {
146
165
  super(message);
147
166
  this.name = "SafePayError";
148
167
  this.code = code;
149
168
  this.preflight = preflight;
150
169
  this.url = url;
170
+ if (earlier) this.earlier = earlier;
171
+ if (spent) this.spent = spent;
151
172
  }
152
173
  }
153
174
 
@@ -229,6 +250,9 @@ function sameSite(a, b) {
229
250
  * @param {(client: x402Client) => unknown} options.register registers your payment schemes on a client
230
251
  * @param {string} [options.network] the network you pay on: "base", "solana", "algorand", "xrpl", "stellar" or a CAIP-2 id (default Base)
231
252
  * @param {number|string} [options.maxUsd] budget per endpoint call (default 0.05)
253
+ * @param {number|string} [options.maxTotalUsd] budget for every endpoint payment this safeFetch makes together (make
254
+ * one per task or session); a payment that would pass it is refused with code "over_total_budget" (default: none)
255
+ * @param {number|string} [options.maxPerSellerUsd] the same per seller (the endpoint's host); code "over_seller_budget"
232
256
  * @param {"stop"|"pay"|((preflight: object) => boolean|Promise<boolean>)} [options.onCaution]
233
257
  * @param {"pay"|"stop"|((preflight: object) => boolean|Promise<boolean>)} [options.onIncomplete] a "go" whose checks_complete
234
258
  * is false (a check that applies couldn't run): "pay" (default), "stop" throws code "incomplete", a function decides
@@ -244,6 +268,10 @@ function sameSite(a, b) {
244
268
  * @param {(report: object, info: {url: string, method: string, status: number|null, error: Error|null}) => void} [options.onDiagnosis]
245
269
  * @param {boolean} [options.shareOutcomes] after paying, tell Doctor whether the payment worked (default false)
246
270
  * @param {boolean} [options.tellSellers] when not paying, tell the seller why if its 402 takes refusals (default false)
271
+ * @param {"stop"|"pay"|((earlier: {at: number, status: number}, info: {url: string, method: string}) => boolean|Promise<boolean>)} [options.onPaidError]
272
+ * the same request was paid before and answered without delivering: "stop" (default) throws code "paid_no_delivery",
273
+ * "pay" pays again, a function decides
274
+ * @param {number} [options.paidErrorMs] how long such a request stays blocked (default 30 minutes)
247
275
  * @param {(cap: string) => typeof fetch} [options.createPayingFetch] advanced/testing: a paying fetch capped at `cap`
248
276
  * @param {() => number} [options.now]
249
277
  */
@@ -251,6 +279,8 @@ export function createSafeFetch({
251
279
  register,
252
280
  network = BASE,
253
281
  maxUsd = 0.05,
282
+ maxTotalUsd,
283
+ maxPerSellerUsd,
254
284
  onCaution = "stop",
255
285
  onIncomplete = "pay",
256
286
  trusted = [],
@@ -264,12 +294,18 @@ export function createSafeFetch({
264
294
  onDiagnosis,
265
295
  shareOutcomes = false,
266
296
  tellSellers = false,
297
+ onPaidError = "stop",
298
+ paidErrorMs = PAID_ERROR_MS,
267
299
  fetch: baseFetch = globalThis.fetch,
268
300
  createPayingFetch,
269
301
  now = Date.now,
270
302
  } = {}) {
271
303
  const payNetwork = NETWORK_ALIASES[network] ?? network;
272
304
  const budget = usdCap(maxUsd);
305
+ const perCall = Number(budget.slice(1));
306
+ const totalCap = maxTotalUsd === undefined || maxTotalUsd === null ? null : Number(usdCap(maxTotalUsd).slice(1));
307
+ const sellerCap = maxPerSellerUsd === undefined || maxPerSellerUsd === null ? null : Number(usdCap(maxPerSellerUsd).slice(1));
308
+ const limited = totalCap !== null || sellerCap !== null;
273
309
  if (!createPayingFetch && typeof register !== "function") {
274
310
  throw new TypeError("register is required: (client) => client.register(network, scheme)");
275
311
  }
@@ -279,6 +315,10 @@ export function createSafeFetch({
279
315
  if (!["pay", "stop"].includes(onIncomplete) && typeof onIncomplete !== "function") {
280
316
  throw new TypeError('onIncomplete must be "pay", "stop" or a function');
281
317
  }
318
+ if (!["stop", "pay"].includes(onPaidError) && typeof onPaidError !== "function") {
319
+ throw new TypeError('onPaidError must be "stop", "pay" or a function');
320
+ }
321
+ const paidGuard = createPaidGuard({ windowMs: paidErrorMs, now });
282
322
  if (!["require", "off"].includes(verifyReceipts)) throw new TypeError('verifyReceipts must be "require" or "off"');
283
323
  const trustedHosts = new Set(trusted.map(hostOf).filter(Boolean));
284
324
 
@@ -309,7 +349,47 @@ export function createSafeFetch({
309
349
  let diagnoseFetch;
310
350
  const payPreflight = (...args) => (preflightFetch ??= makePayingFetch(PREFLIGHT_CAP))(...args);
311
351
  const payDiagnose = (...args) => (diagnoseFetch ??= makePayingFetch(DIAGNOSE_CAP))(...args);
312
- const payEndpoint = (...args) => (endpointFetch ??= makePayingFetch(budget))(...args);
352
+ // With a total or per-seller budget, a payment is capped at what is left of it (x402's spend controls): a paying
353
+ // fetch per cap, kept for the caps in use.
354
+ const cappedFetches = new Map();
355
+ const payEndpoint = (url, init, cap = budget) => {
356
+ if (cap === budget) return (endpointFetch ??= makePayingFetch(budget))(url, init);
357
+ if (!cappedFetches.has(cap)) {
358
+ if (cappedFetches.size >= 50) cappedFetches.delete(cappedFetches.keys().next().value);
359
+ cappedFetches.set(cap, makePayingFetch(cap));
360
+ }
361
+ return cappedFetches.get(cap)(url, init);
362
+ };
363
+
364
+ // What was paid (or is being paid) to sellers, for maxTotalUsd and maxPerSellerUsd, in dollars.
365
+ const spend = { total: 0, bySeller: new Map() };
366
+ const round6 = (n) => Math.round(n * 1e6) / 1e6;
367
+ const spentAt = (host) => spend.bySeller.get(host) ?? 0;
368
+ function addSpend(host, usd) {
369
+ spend.total = round6(spend.total + usd);
370
+ spend.bySeller.set(host, round6(spentAt(host) + usd));
371
+ }
372
+ const spentNow = () => ({ totalUsd: spend.total, bySeller: Object.fromEntries(spend.bySeller) });
373
+ // What one more payment to `host` may cost: the per-call budget, or less when the total or the seller's budget is
374
+ // nearly spent. Throws when nothing is left of either, or when the price (if it can be read) is over what is left.
375
+ // A price over the per-call budget alone is left to Doctor's preflight (over_budget) and x402's cap, as before.
376
+ function allowance(url, host, price) {
377
+ const totalLeft = totalCap === null ? Infinity : round6(totalCap - spend.total);
378
+ const sellerLeft = sellerCap === null ? Infinity : round6(sellerCap - spentAt(host));
379
+ const cap = Math.min(perCall, totalLeft, sellerLeft);
380
+ const short = Math.min(totalLeft, sellerLeft);
381
+ const over = short <= 0 || (price !== null && price > short + 1e-9);
382
+ if (over) {
383
+ const byTotal = totalLeft <= sellerLeft;
384
+ const which = byTotal ? `the total budget ($${totalCap})` : `the budget for ${host} ($${sellerCap})`;
385
+ const asks = price === null ? "" : `; the endpoint asks $${round6(price)}`;
386
+ const paid = byTotal ? spend.total : spentAt(host);
387
+ throw new SafePayError(`not paid: ${which} has $${Math.max(0, short)} left${asks} (already paid $${paid})`, {
388
+ code: byTotal ? "over_total_budget" : "over_seller_budget", url, spent: spentNow(),
389
+ });
390
+ }
391
+ return { cap, price };
392
+ }
313
393
 
314
394
  const verdicts = new Map();
315
395
  async function preflightFor(url, method, offer) {
@@ -422,11 +502,37 @@ export function createSafeFetch({
422
502
  tellSeller(url, preflight);
423
503
  }
424
504
 
425
- async function payAndCheck(url, init, method) {
426
- if (!diagnoseOnFailure) return payEndpoint(url, init);
505
+ // Every endpoint payment goes through here: what it was answered with is remembered for the paid-twice guard.
506
+ // With a total or per-seller budget, the payment is capped at what is left and counted as it starts (so parallel
507
+ // calls can't both spend the rest), and taken back when nothing was paid (402 again, or paying threw).
508
+ async function payAndCheck(url, init, method, offer) {
509
+ let host = null;
510
+ let counted = 0;
511
+ let cap = budget;
512
+ if (limited) {
513
+ host = hostOf(url);
514
+ const option = offer?.accepts ? payableOption(offer.accepts, payNetwork, budget) : null;
515
+ const allowed = allowance(url, host, option ? usdOfOption(option, payNetwork) : null);
516
+ counted = allowed.price ?? allowed.cap; // an unreadable price counts as the most it may cost
517
+ if (allowed.cap < perCall) cap = usdCap(allowed.cap);
518
+ addSpend(host, counted);
519
+ }
520
+ let res;
521
+ try {
522
+ res = await payAndDiagnose(url, init, method, cap);
523
+ } catch (error) {
524
+ if (limited) addSpend(host, -counted);
525
+ throw error;
526
+ }
527
+ if (limited && res.status === 402) addSpend(host, -counted);
528
+ paidGuard.record(url, { method, body: init.body }, res.status);
529
+ return res;
530
+ }
531
+ async function payAndDiagnose(url, init, method, cap) {
532
+ if (!diagnoseOnFailure) return payEndpoint(url, init, cap);
427
533
  let res;
428
534
  try {
429
- res = await payEndpoint(url, init);
535
+ res = await payEndpoint(url, init, cap);
430
536
  } catch (error) {
431
537
  if (error instanceof SafePayError && error.code !== "no_option") throw error;
432
538
  const report = await diagnoseFor(url, method);
@@ -446,19 +552,33 @@ export function createSafeFetch({
446
552
  return res;
447
553
  }
448
554
 
449
- return async function safeFetch(input, init = {}) {
555
+ async function safeFetch(input, init = {}) {
450
556
  const { url, method } = requestOf(input, init);
451
557
  const probe = await baseFetch(url, init);
452
558
  if (probe.status !== 402) return probe;
453
559
 
454
- if (trustedHosts.has(hostOf(url))) {
455
- try { await probe.body?.cancel(); } catch { /* already consumed */ }
456
- return payAndCheck(url, init, method);
560
+ // Paid before and not delivered: not paid again (and no preflight bought) unless onPaidError says so.
561
+ const earlier = paidGuard.blocked(url, { method, body: init.body });
562
+ if (earlier && onPaidError !== "pay") {
563
+ const again = typeof onPaidError === "function" && (await onPaidError(earlier, { url, method })) === true;
564
+ if (!again) {
565
+ try { await probe.body?.cancel(); } catch { /* already consumed */ }
566
+ throw new SafePayError(paidTwiceMessage(url, earlier), { code: "paid_no_delivery", url, earlier });
567
+ }
568
+ }
569
+
570
+ const offer = await readOffer(probe);
571
+ // Already over the total or the seller's budget: refused before the preflight, so nothing is spent on it.
572
+ if (limited) {
573
+ const option = offer.accepts ? payableOption(offer.accepts, payNetwork, budget) : null;
574
+ allowance(url, hostOf(url), option ? usdOfOption(option, payNetwork) : null);
457
575
  }
458
576
 
459
- const { preflight, cached } = await preflightFor(url, method, await offerFingerprint(probe));
577
+ if (trustedHosts.has(hostOf(url))) return payAndCheck(url, init, method, offer);
578
+
579
+ const { preflight, cached } = await preflightFor(url, method, offer.fingerprint);
460
580
  const payAfterPreflight = async () => {
461
- const res = await payAndCheck(url, init, method);
581
+ const res = await payAndCheck(url, init, method, offer);
462
582
  reportOutcome(url, method, preflight, res);
463
583
  return res;
464
584
  };
@@ -484,5 +604,8 @@ export function createSafeFetch({
484
604
  if (!ok) throw new SafePayError(`not paid: go, but not every check ran (${missing}); try again in a few minutes`, { code: "incomplete", preflight, url });
485
605
  }
486
606
  return payAfterPreflight();
487
- };
607
+ }
608
+ // What this safeFetch paid to sellers so far, in US dollars (Doctor's preflights and diagnoses not included).
609
+ safeFetch.spent = spentNow;
610
+ return safeFetch;
488
611
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-safe-fetch",
3
- "version": "0.13.0",
3
+ "version": "0.15.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
+ }