x402-safe-fetch 0.14.0 → 0.15.1

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.
Files changed (4) hide show
  1. package/README.md +19 -0
  2. package/index.d.ts +23 -2
  3. package/index.js +118 -18
  4. package/package.json +1 -1
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 |
@@ -152,6 +154,22 @@ const safeFetch = createSafeFetch({
152
154
 
153
155
  The guard is also exported on its own (`createPaidGuard`, no dependencies) for agents that pay through another client.
154
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
+
155
173
  ## When a payment still fails
156
174
 
157
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.
@@ -211,6 +229,7 @@ x402 Doctor signs every paid preflight (EIP-191 over canonical JSON, with the re
211
229
  - With `diagnoseOnFailure`: $0.01 per diagnosis, only when a payment fails (once per endpoint per `cacheMs`).
212
230
  - Bodies must be strings, Buffers or `URLSearchParams`: the request is sent once unpaid (to see the 402) and once paid.
213
231
  - If Doctor is unreachable, nothing is paid (`SafePayError` with code `preflight_failed`).
232
+ - Bitcoin Lightning (`lnbtc`) isn't paid: its price is in BTC, so the dollar budget can't hold. `createSafeFetch({ network: "lnbtc:…" })` throws, and a Lightning option is never picked. x402 Doctor does check Lightning 402s (the invoice's amount, network, expiry, signer and request hash).
214
233
  - The preflight checks whether a payment can succeed and is sensible; it does not guarantee what the service delivers after payment.
215
234
 
216
235
  Try it without code first: [safe-pay](../safe-pay) is a runnable script with the same logic. MIT licensed.
package/index.d.ts CHANGED
@@ -41,11 +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" | "paid_no_delivery";
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
47
  /** With code "paid_no_delivery": when the same request was paid before, and what it was answered with. */
48
48
  earlier?: { at: number; status: number };
49
+ /** With code "over_total_budget" or "over_seller_budget": what this safeFetch had paid. */
50
+ spent?: SpentUsd;
49
51
  /** Set when diagnoseOnFailure diagnosed a payment that failed. */
50
52
  diagnosis?: Diagnosis;
51
53
  }
@@ -57,6 +59,10 @@ export interface SafeFetchOptions {
57
59
  network?: string;
58
60
  /** Budget per endpoint call in USD. Default 0.05. */
59
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;
60
66
  /** What to do on a caution verdict. Default "stop". */
61
67
  onCaution?: "stop" | "pay" | ((preflight: Preflight) => boolean | Promise<boolean>);
62
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". */
@@ -90,7 +96,22 @@ export interface SafeFetchOptions {
90
96
  now?: () => number;
91
97
  }
92
98
 
93
- 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
+ /** Why createSafeFetch refuses a Bitcoin Lightning (lnbtc) network: its price is in BTC, so the dollar budget can't hold. */
112
+ export declare const LIGHTNING_UNSUPPORTED: string;
113
+ /** What an option costs in USD on `network` (Lightning: null). */
114
+ export declare function usdOfOption(option: { amount?: unknown; maxAmountRequired?: unknown }, network: string): number | null;
94
115
  export declare function usdCap(maxUsd: number | string): string;
95
116
  export declare function preflightUrl(target: string, options?: { method?: string; maxUsd?: number | string; network?: string; doctorUrl?: string }): string;
96
117
  export declare function diagnoseUrl(target: string, options?: { method?: string; doctorUrl?: string }): string;
package/index.js CHANGED
@@ -47,7 +47,7 @@ export { verifyReceipt, recoverSigner, canonicalJson, inputHash, certMessage, DO
47
47
  export const DOCTOR_URL = "https://x402-doctor.fizzl.eu";
48
48
  export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
49
49
  export const DIAGNOSE_CAP = "$0.02"; // the diagnosis costs $0.01; never more than twice that
50
- export const VERSION = "0.14.0";
50
+ export const VERSION = "0.15.1";
51
51
  // Doctor sees which calls come from this package (in its usage counts), nothing more.
52
52
  const DOCTOR_HEADERS = { accept: "application/json", "user-agent": `x402-safe-fetch/${VERSION}` };
53
53
  export const BASE = "eip155:8453";
@@ -98,6 +98,10 @@ export function anyAlgorandId(scheme) {
98
98
  // (not extra: invoices and nonces change per request). null when there is no x402 challenge to read (e.g. an
99
99
  // MPP-only 402). Reads and consumes the response body.
100
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) {
101
105
  let challenge = null;
102
106
  const header = res?.headers?.get?.("payment-required");
103
107
  if (header) {
@@ -106,14 +110,32 @@ export async function offerFingerprint(res) {
106
110
  } else {
107
111
  try { challenge = JSON.parse(await res.text()); } catch { /* not JSON */ }
108
112
  }
109
- if (!Array.isArray(challenge?.accepts)) return null;
113
+ if (!Array.isArray(challenge?.accepts)) return { fingerprint: null, accepts: null };
110
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 ?? "")]));
111
- 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
+ if (isLightning(network) || isLightning(option?.network)) return null; // millisatoshis: a BTC price, not dollars
122
+ const raw = String(option?.amount ?? option?.maxAmountRequired ?? "");
123
+ if (RLUSD_ISSUER[network]) return /^\d+(\.\d+)?$/.test(raw) ? Number(raw) : null;
124
+ if (!/^\d+$/.test(raw)) return null;
125
+ return Number(raw) / (STELLAR_USDC[network] ? 1e7 : 1e6);
112
126
  }
113
127
  // Without an offer to compare (no readable x402 challenge), a verdict is reused for at most 10 minutes.
114
128
  const UNREAD_OFFER_MS = 10 * 60 * 1000;
115
129
 
130
+ // Bitcoin Lightning (x402 "exact" on lnbtc: a BOLT11 invoice, priced in millisatoshis). Not paid by safe-fetch: the
131
+ // budget is in dollars and x402's spend controls have no dollar token there, so neither cap would hold.
132
+ export const LIGHTNING_UNSUPPORTED = "Bitcoin Lightning (lnbtc) isn't paid by x402-safe-fetch: its price is in BTC, so the dollar budget can't hold. Pay a USDC option on another network, or check the endpoint with x402 Doctor (it checks Lightning invoices) and pay with your Lightning client.";
133
+ function isLightning(network) {
134
+ return String(network ?? "").startsWith("lnbtc:");
135
+ }
136
+
116
137
  export function payableOption(accepts, network, maxUsd) {
138
+ if (isLightning(network)) return null;
117
139
  const cap = Number(String(maxUsd).replace(/^\$/, ""));
118
140
  const on = (accepts ?? []).filter((a) => algorandId(a?.network) === algorandId(network));
119
141
  const usdc = ALGORAND_USDC[algorandId(network)];
@@ -147,14 +169,15 @@ export function refusalReason(preflight) {
147
169
  }
148
170
 
149
171
  export class SafePayError extends Error {
150
- /** code: "no_go" | "caution" | "incomplete" | "preflight_failed" | "bad_receipt" | "no_option" | "paid_no_delivery" */
151
- constructor(message, { code, preflight = null, url = null, earlier } = {}) {
172
+ /** code: "no_go" | "caution" | "incomplete" | "preflight_failed" | "bad_receipt" | "no_option" | "paid_no_delivery" | "over_total_budget" | "over_seller_budget" */
173
+ constructor(message, { code, preflight = null, url = null, earlier, spent } = {}) {
152
174
  super(message);
153
175
  this.name = "SafePayError";
154
176
  this.code = code;
155
177
  this.preflight = preflight;
156
178
  this.url = url;
157
179
  if (earlier) this.earlier = earlier;
180
+ if (spent) this.spent = spent;
158
181
  }
159
182
  }
160
183
 
@@ -236,6 +259,9 @@ function sameSite(a, b) {
236
259
  * @param {(client: x402Client) => unknown} options.register registers your payment schemes on a client
237
260
  * @param {string} [options.network] the network you pay on: "base", "solana", "algorand", "xrpl", "stellar" or a CAIP-2 id (default Base)
238
261
  * @param {number|string} [options.maxUsd] budget per endpoint call (default 0.05)
262
+ * @param {number|string} [options.maxTotalUsd] budget for every endpoint payment this safeFetch makes together (make
263
+ * one per task or session); a payment that would pass it is refused with code "over_total_budget" (default: none)
264
+ * @param {number|string} [options.maxPerSellerUsd] the same per seller (the endpoint's host); code "over_seller_budget"
239
265
  * @param {"stop"|"pay"|((preflight: object) => boolean|Promise<boolean>)} [options.onCaution]
240
266
  * @param {"pay"|"stop"|((preflight: object) => boolean|Promise<boolean>)} [options.onIncomplete] a "go" whose checks_complete
241
267
  * is false (a check that applies couldn't run): "pay" (default), "stop" throws code "incomplete", a function decides
@@ -262,6 +288,8 @@ export function createSafeFetch({
262
288
  register,
263
289
  network = BASE,
264
290
  maxUsd = 0.05,
291
+ maxTotalUsd,
292
+ maxPerSellerUsd,
265
293
  onCaution = "stop",
266
294
  onIncomplete = "pay",
267
295
  trusted = [],
@@ -282,7 +310,12 @@ export function createSafeFetch({
282
310
  now = Date.now,
283
311
  } = {}) {
284
312
  const payNetwork = NETWORK_ALIASES[network] ?? network;
313
+ if (isLightning(payNetwork)) throw new TypeError(LIGHTNING_UNSUPPORTED);
285
314
  const budget = usdCap(maxUsd);
315
+ const perCall = Number(budget.slice(1));
316
+ const totalCap = maxTotalUsd === undefined || maxTotalUsd === null ? null : Number(usdCap(maxTotalUsd).slice(1));
317
+ const sellerCap = maxPerSellerUsd === undefined || maxPerSellerUsd === null ? null : Number(usdCap(maxPerSellerUsd).slice(1));
318
+ const limited = totalCap !== null || sellerCap !== null;
286
319
  if (!createPayingFetch && typeof register !== "function") {
287
320
  throw new TypeError("register is required: (client) => client.register(network, scheme)");
288
321
  }
@@ -326,7 +359,47 @@ export function createSafeFetch({
326
359
  let diagnoseFetch;
327
360
  const payPreflight = (...args) => (preflightFetch ??= makePayingFetch(PREFLIGHT_CAP))(...args);
328
361
  const payDiagnose = (...args) => (diagnoseFetch ??= makePayingFetch(DIAGNOSE_CAP))(...args);
329
- const payEndpoint = (...args) => (endpointFetch ??= makePayingFetch(budget))(...args);
362
+ // With a total or per-seller budget, a payment is capped at what is left of it (x402's spend controls): a paying
363
+ // fetch per cap, kept for the caps in use.
364
+ const cappedFetches = new Map();
365
+ const payEndpoint = (url, init, cap = budget) => {
366
+ if (cap === budget) return (endpointFetch ??= makePayingFetch(budget))(url, init);
367
+ if (!cappedFetches.has(cap)) {
368
+ if (cappedFetches.size >= 50) cappedFetches.delete(cappedFetches.keys().next().value);
369
+ cappedFetches.set(cap, makePayingFetch(cap));
370
+ }
371
+ return cappedFetches.get(cap)(url, init);
372
+ };
373
+
374
+ // What was paid (or is being paid) to sellers, for maxTotalUsd and maxPerSellerUsd, in dollars.
375
+ const spend = { total: 0, bySeller: new Map() };
376
+ const round6 = (n) => Math.round(n * 1e6) / 1e6;
377
+ const spentAt = (host) => spend.bySeller.get(host) ?? 0;
378
+ function addSpend(host, usd) {
379
+ spend.total = round6(spend.total + usd);
380
+ spend.bySeller.set(host, round6(spentAt(host) + usd));
381
+ }
382
+ const spentNow = () => ({ totalUsd: spend.total, bySeller: Object.fromEntries(spend.bySeller) });
383
+ // What one more payment to `host` may cost: the per-call budget, or less when the total or the seller's budget is
384
+ // nearly spent. Throws when nothing is left of either, or when the price (if it can be read) is over what is left.
385
+ // A price over the per-call budget alone is left to Doctor's preflight (over_budget) and x402's cap, as before.
386
+ function allowance(url, host, price) {
387
+ const totalLeft = totalCap === null ? Infinity : round6(totalCap - spend.total);
388
+ const sellerLeft = sellerCap === null ? Infinity : round6(sellerCap - spentAt(host));
389
+ const cap = Math.min(perCall, totalLeft, sellerLeft);
390
+ const short = Math.min(totalLeft, sellerLeft);
391
+ const over = short <= 0 || (price !== null && price > short + 1e-9);
392
+ if (over) {
393
+ const byTotal = totalLeft <= sellerLeft;
394
+ const which = byTotal ? `the total budget ($${totalCap})` : `the budget for ${host} ($${sellerCap})`;
395
+ const asks = price === null ? "" : `; the endpoint asks $${round6(price)}`;
396
+ const paid = byTotal ? spend.total : spentAt(host);
397
+ throw new SafePayError(`not paid: ${which} has $${Math.max(0, short)} left${asks} (already paid $${paid})`, {
398
+ code: byTotal ? "over_total_budget" : "over_seller_budget", url, spent: spentNow(),
399
+ });
400
+ }
401
+ return { cap, price };
402
+ }
330
403
 
331
404
  const verdicts = new Map();
332
405
  async function preflightFor(url, method, offer) {
@@ -440,16 +513,36 @@ export function createSafeFetch({
440
513
  }
441
514
 
442
515
  // Every endpoint payment goes through here: what it was answered with is remembered for the paid-twice guard.
443
- async function payAndCheck(url, init, method) {
444
- const res = await payAndDiagnose(url, init, method);
516
+ // With a total or per-seller budget, the payment is capped at what is left and counted as it starts (so parallel
517
+ // calls can't both spend the rest), and taken back when nothing was paid (402 again, or paying threw).
518
+ async function payAndCheck(url, init, method, offer) {
519
+ let host = null;
520
+ let counted = 0;
521
+ let cap = budget;
522
+ if (limited) {
523
+ host = hostOf(url);
524
+ const option = offer?.accepts ? payableOption(offer.accepts, payNetwork, budget) : null;
525
+ const allowed = allowance(url, host, option ? usdOfOption(option, payNetwork) : null);
526
+ counted = allowed.price ?? allowed.cap; // an unreadable price counts as the most it may cost
527
+ if (allowed.cap < perCall) cap = usdCap(allowed.cap);
528
+ addSpend(host, counted);
529
+ }
530
+ let res;
531
+ try {
532
+ res = await payAndDiagnose(url, init, method, cap);
533
+ } catch (error) {
534
+ if (limited) addSpend(host, -counted);
535
+ throw error;
536
+ }
537
+ if (limited && res.status === 402) addSpend(host, -counted);
445
538
  paidGuard.record(url, { method, body: init.body }, res.status);
446
539
  return res;
447
540
  }
448
- async function payAndDiagnose(url, init, method) {
449
- if (!diagnoseOnFailure) return payEndpoint(url, init);
541
+ async function payAndDiagnose(url, init, method, cap) {
542
+ if (!diagnoseOnFailure) return payEndpoint(url, init, cap);
450
543
  let res;
451
544
  try {
452
- res = await payEndpoint(url, init);
545
+ res = await payEndpoint(url, init, cap);
453
546
  } catch (error) {
454
547
  if (error instanceof SafePayError && error.code !== "no_option") throw error;
455
548
  const report = await diagnoseFor(url, method);
@@ -469,7 +562,7 @@ export function createSafeFetch({
469
562
  return res;
470
563
  }
471
564
 
472
- return async function safeFetch(input, init = {}) {
565
+ async function safeFetch(input, init = {}) {
473
566
  const { url, method } = requestOf(input, init);
474
567
  const probe = await baseFetch(url, init);
475
568
  if (probe.status !== 402) return probe;
@@ -484,14 +577,18 @@ export function createSafeFetch({
484
577
  }
485
578
  }
486
579
 
487
- if (trustedHosts.has(hostOf(url))) {
488
- try { await probe.body?.cancel(); } catch { /* already consumed */ }
489
- return payAndCheck(url, init, method);
580
+ const offer = await readOffer(probe);
581
+ // Already over the total or the seller's budget: refused before the preflight, so nothing is spent on it.
582
+ if (limited) {
583
+ const option = offer.accepts ? payableOption(offer.accepts, payNetwork, budget) : null;
584
+ allowance(url, hostOf(url), option ? usdOfOption(option, payNetwork) : null);
490
585
  }
491
586
 
492
- const { preflight, cached } = await preflightFor(url, method, await offerFingerprint(probe));
587
+ if (trustedHosts.has(hostOf(url))) return payAndCheck(url, init, method, offer);
588
+
589
+ const { preflight, cached } = await preflightFor(url, method, offer.fingerprint);
493
590
  const payAfterPreflight = async () => {
494
- const res = await payAndCheck(url, init, method);
591
+ const res = await payAndCheck(url, init, method, offer);
495
592
  reportOutcome(url, method, preflight, res);
496
593
  return res;
497
594
  };
@@ -517,5 +614,8 @@ export function createSafeFetch({
517
614
  if (!ok) throw new SafePayError(`not paid: go, but not every check ran (${missing}); try again in a few minutes`, { code: "incomplete", preflight, url });
518
615
  }
519
616
  return payAfterPreflight();
520
- };
617
+ }
618
+ // What this safeFetch paid to sellers so far, in US dollars (Doctor's preflights and diagnoses not included).
619
+ safeFetch.spent = spentNow;
620
+ return safeFetch;
521
621
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-safe-fetch",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
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": {