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.
- package/README.md +19 -0
- package/index.d.ts +23 -2
- package/index.js +118 -18
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|