x402-safe-fetch 0.14.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 +18 -0
- package/index.d.ts +20 -2
- package/index.js +108 -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.
|
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,19 @@ 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
|
+
export declare function usdOfOption(option: { amount?: unknown; maxAmountRequired?: unknown }, network: string): number | null;
|
|
94
112
|
export declare function usdCap(maxUsd: number | string): string;
|
|
95
113
|
export declare function preflightUrl(target: string, options?: { method?: string; maxUsd?: number | string; network?: string; doctorUrl?: string }): string;
|
|
96
114
|
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.0";
|
|
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,9 +110,18 @@ 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
|
+
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);
|
|
112
125
|
}
|
|
113
126
|
// Without an offer to compare (no readable x402 challenge), a verdict is reused for at most 10 minutes.
|
|
114
127
|
const UNREAD_OFFER_MS = 10 * 60 * 1000;
|
|
@@ -147,14 +160,15 @@ export function refusalReason(preflight) {
|
|
|
147
160
|
}
|
|
148
161
|
|
|
149
162
|
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 } = {}) {
|
|
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 } = {}) {
|
|
152
165
|
super(message);
|
|
153
166
|
this.name = "SafePayError";
|
|
154
167
|
this.code = code;
|
|
155
168
|
this.preflight = preflight;
|
|
156
169
|
this.url = url;
|
|
157
170
|
if (earlier) this.earlier = earlier;
|
|
171
|
+
if (spent) this.spent = spent;
|
|
158
172
|
}
|
|
159
173
|
}
|
|
160
174
|
|
|
@@ -236,6 +250,9 @@ function sameSite(a, b) {
|
|
|
236
250
|
* @param {(client: x402Client) => unknown} options.register registers your payment schemes on a client
|
|
237
251
|
* @param {string} [options.network] the network you pay on: "base", "solana", "algorand", "xrpl", "stellar" or a CAIP-2 id (default Base)
|
|
238
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"
|
|
239
256
|
* @param {"stop"|"pay"|((preflight: object) => boolean|Promise<boolean>)} [options.onCaution]
|
|
240
257
|
* @param {"pay"|"stop"|((preflight: object) => boolean|Promise<boolean>)} [options.onIncomplete] a "go" whose checks_complete
|
|
241
258
|
* is false (a check that applies couldn't run): "pay" (default), "stop" throws code "incomplete", a function decides
|
|
@@ -262,6 +279,8 @@ export function createSafeFetch({
|
|
|
262
279
|
register,
|
|
263
280
|
network = BASE,
|
|
264
281
|
maxUsd = 0.05,
|
|
282
|
+
maxTotalUsd,
|
|
283
|
+
maxPerSellerUsd,
|
|
265
284
|
onCaution = "stop",
|
|
266
285
|
onIncomplete = "pay",
|
|
267
286
|
trusted = [],
|
|
@@ -283,6 +302,10 @@ export function createSafeFetch({
|
|
|
283
302
|
} = {}) {
|
|
284
303
|
const payNetwork = NETWORK_ALIASES[network] ?? network;
|
|
285
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;
|
|
286
309
|
if (!createPayingFetch && typeof register !== "function") {
|
|
287
310
|
throw new TypeError("register is required: (client) => client.register(network, scheme)");
|
|
288
311
|
}
|
|
@@ -326,7 +349,47 @@ export function createSafeFetch({
|
|
|
326
349
|
let diagnoseFetch;
|
|
327
350
|
const payPreflight = (...args) => (preflightFetch ??= makePayingFetch(PREFLIGHT_CAP))(...args);
|
|
328
351
|
const payDiagnose = (...args) => (diagnoseFetch ??= makePayingFetch(DIAGNOSE_CAP))(...args);
|
|
329
|
-
|
|
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
|
+
}
|
|
330
393
|
|
|
331
394
|
const verdicts = new Map();
|
|
332
395
|
async function preflightFor(url, method, offer) {
|
|
@@ -440,16 +503,36 @@ export function createSafeFetch({
|
|
|
440
503
|
}
|
|
441
504
|
|
|
442
505
|
// Every endpoint payment goes through here: what it was answered with is remembered for the paid-twice guard.
|
|
443
|
-
|
|
444
|
-
|
|
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);
|
|
445
528
|
paidGuard.record(url, { method, body: init.body }, res.status);
|
|
446
529
|
return res;
|
|
447
530
|
}
|
|
448
|
-
async function payAndDiagnose(url, init, method) {
|
|
449
|
-
if (!diagnoseOnFailure) return payEndpoint(url, init);
|
|
531
|
+
async function payAndDiagnose(url, init, method, cap) {
|
|
532
|
+
if (!diagnoseOnFailure) return payEndpoint(url, init, cap);
|
|
450
533
|
let res;
|
|
451
534
|
try {
|
|
452
|
-
res = await payEndpoint(url, init);
|
|
535
|
+
res = await payEndpoint(url, init, cap);
|
|
453
536
|
} catch (error) {
|
|
454
537
|
if (error instanceof SafePayError && error.code !== "no_option") throw error;
|
|
455
538
|
const report = await diagnoseFor(url, method);
|
|
@@ -469,7 +552,7 @@ export function createSafeFetch({
|
|
|
469
552
|
return res;
|
|
470
553
|
}
|
|
471
554
|
|
|
472
|
-
|
|
555
|
+
async function safeFetch(input, init = {}) {
|
|
473
556
|
const { url, method } = requestOf(input, init);
|
|
474
557
|
const probe = await baseFetch(url, init);
|
|
475
558
|
if (probe.status !== 402) return probe;
|
|
@@ -484,14 +567,18 @@ export function createSafeFetch({
|
|
|
484
567
|
}
|
|
485
568
|
}
|
|
486
569
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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);
|
|
490
575
|
}
|
|
491
576
|
|
|
492
|
-
|
|
577
|
+
if (trustedHosts.has(hostOf(url))) return payAndCheck(url, init, method, offer);
|
|
578
|
+
|
|
579
|
+
const { preflight, cached } = await preflightFor(url, method, offer.fingerprint);
|
|
493
580
|
const payAfterPreflight = async () => {
|
|
494
|
-
const res = await payAndCheck(url, init, method);
|
|
581
|
+
const res = await payAndCheck(url, init, method, offer);
|
|
495
582
|
reportOutcome(url, method, preflight, res);
|
|
496
583
|
return res;
|
|
497
584
|
};
|
|
@@ -517,5 +604,8 @@ export function createSafeFetch({
|
|
|
517
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 });
|
|
518
605
|
}
|
|
519
606
|
return payAfterPreflight();
|
|
520
|
-
}
|
|
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;
|
|
521
611
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "x402-safe-fetch",
|
|
3
|
-
"version": "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": {
|