x402-safe-fetch 0.4.0 → 0.6.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.
Files changed (4) hide show
  1. package/README.md +28 -5
  2. package/index.d.ts +12 -2
  3. package/index.js +93 -15
  4. package/package.json +7 -1
package/README.md CHANGED
@@ -10,7 +10,7 @@ A free endpoint is answered as is. For a paid one (HTTP 402), your agent first p
10
10
  | `caution` | `onCaution`: `"stop"` (default, throws), `"pay"`, or your own function (e.g. ask the user) |
11
11
  | `no_go` | Throws `SafePayError`; the endpoint is not paid |
12
12
 
13
- The preflight reads the endpoint's 402 challenge and checks, among others, the price against your budget and against what the service advertises, whether the option is payable on your network (USDC, a valid payout address, a Solana payout account that exists), HTTPS, and whether the endpoint is listed in the CDP Bazaar. A verdict is reused for 10 minutes; hosts you already trust skip it.
13
+ The preflight reads the endpoint's 402 challenge and checks, among others, the price against your budget and against what the service advertises, whether the option is payable on your network (USDC, a valid payout address, a Solana payout account that exists, an Algorand payout opted in to USDC), HTTPS, and whether the endpoint is listed in the CDP Bazaar. A verdict is reused while the endpoint's 402 offers the same thing (up to an hour); a changed price, payout address, token or network is checked again right away. Hosts you already trust skip it.
14
14
 
15
15
  ## Install
16
16
 
@@ -45,20 +45,35 @@ try {
45
45
 
46
46
  Solana works the same way: `network: "solana"` and register an `ExactSvmScheme` from `@x402/svm/exact/client` with a `@solana/kit` signer.
47
47
 
48
+ Algorand and the XRP Ledger work the same way, and only their dollar stablecoin is ever paid. On Algorand that is USDC (ASA 31566704), never ALGO or another ASA. On the XRP Ledger it is RLUSD from Ripple's issuer, never XRP or a look-alike token. RLUSD prices are decimal dollars, so the budget is checked in dollars. The Doctor preflight is paid on the same network.
49
+
50
+ ```js
51
+ import { toClientAvmSigner } from "@x402/avm";
52
+ import { ExactAvmScheme } from "@x402/avm/exact/client";
53
+
54
+ const safeFetch = createSafeFetch({
55
+ network: "algorand", // your account must be opted in to USDC
56
+ register: (client) => client.register("algorand:*", new ExactAvmScheme(toClientAvmSigner(process.env.AVM_PRIVATE_KEY))),
57
+ });
58
+ // XRP Ledger: network: "xrpl" and an ExactXrplScheme from @x402/xrpl/exact/client
59
+ // (createXrplWalletSigner(Wallet.fromSeed(process.env.XRPL_SEED))); needs an RLUSD trust line.
60
+ ```
61
+
48
62
  ## Options
49
63
 
50
64
  | Option | Default | |
51
65
  |---|---|---|
52
66
  | `register` | required | `(client) => client.register(network, scheme)`: your payment schemes; your keys stay in your code |
53
- | `network` | `"base"` | `"base"`, `"solana"` or a CAIP-2 id; the preflight checks the option on this network |
67
+ | `network` | `"base"` | `"base"`, `"solana"`, `"algorand"` (USDC), `"xrpl"` (RLUSD), their `-testnet` forms, or a CAIP-2 id; the preflight checks the option on this network |
54
68
  | `maxUsd` | `0.05` | Budget per endpoint call; the payment is capped at it and the preflight says `no_go` above it |
55
69
  | `onCaution` | `"stop"` | `"stop"`, `"pay"` or `async (preflight) => boolean` |
56
70
  | `trusted` | `[]` | Hosts paid without a preflight |
57
71
  | `onPreflight` | | `(preflight, { url, method, cached }) => void`, e.g. for logging |
58
- | `cacheMs` | 10 minutes | How long a verdict is reused per method and URL |
72
+ | `cacheMs` | 1 hour | How long a verdict is reused per method and URL while the 402 offers the same thing (at most 10 minutes when the offer can't be read); any change in price, payout address, token or network means a new preflight |
59
73
  | `verifyReceipts` | `"require"` | Check Doctor's signed receipt on every preflight; `"off"` skips it |
60
74
  | `doctorSigners` | Doctor's published signer | Accepted signer addresses (for a self-hosted Doctor) |
61
75
  | `diagnoseOnFailure` | `false` | When a payment still fails, buy a $0.01 Doctor diagnosis of why (see below) |
76
+ | `shareOutcomes` | `false` | After paying, tell Doctor whether the payment worked, so its later preflights learn from it (see below) |
62
77
  | `onDiagnosis` | | `(report, { url, method, status, error }) => void`, e.g. for logging or alerting |
63
78
  | `authority` | the Fizzl payout wallet | Wallet whose certificates also make a signer trusted (key rotation); `null` accepts only `doctorSigners` |
64
79
 
@@ -88,6 +103,14 @@ if (res.status === 402) {
88
103
 
89
104
  Doctor's requests carry `user-agent: x402-safe-fetch/<version>` so the service can count how often the package is used; nothing about your agent or wallet is sent beyond the payment itself.
90
105
 
106
+ ## Helping the preflight learn
107
+
108
+ With `shareOutcomes: true`, safe-fetch tells x402 Doctor what happened after it paid an endpoint that a preflight approved: `paid_ok` (2xx), `paid_failed` (402 again: the payment was not accepted) or `paid_error` (another error). Doctor counts these reports per endpoint over 30 days, from every agent that shares them. When most payments to an endpoint failed, reported by at least three different wallets, later preflights for it answer `caution` (`payments_fail_after_preflight`), so the next agent does not pay into the same wall.
109
+
110
+ - What is sent: the outcome, the HTTP status, the signed preflight you already received and its query (endpoint, method, budget, network). No keys, no response content.
111
+ - A report only counts with a real, paid preflight for that endpoint, once per preflight, so reports cannot be made up for free.
112
+ - It is sent after the answer and never delays or changes it. Off by default.
113
+
91
114
  ## Signed verdicts
92
115
 
93
116
  x402 Doctor signs every paid preflight (EIP-191 over canonical JSON, with the request inside the signed body; see [Signed verdicts](https://github.com/Fizzl13/x402-doctor#signed-verdicts)). safe-fetch checks that signature before it acts on a verdict:
@@ -102,8 +125,8 @@ x402 Doctor signs every paid preflight (EIP-191 over canonical JSON, with the re
102
125
 
103
126
  ## Costs and limits
104
127
 
105
- - $0.001 per preflight (once per endpoint per 10 minutes), plus the endpoint's own price.
106
- - With `diagnoseOnFailure`: $0.01 per diagnosis, only when a payment fails (once per endpoint per 10 minutes).
128
+ - $0.001 per preflight (once per endpoint per hour while its offer stays the same, again at once when it changes), plus the endpoint's own price.
129
+ - With `diagnoseOnFailure`: $0.01 per diagnosis, only when a payment fails (once per endpoint per `cacheMs`).
107
130
  - Bodies must be strings, Buffers or `URLSearchParams`: the request is sent once unpaid (to see the 402) and once paid.
108
131
  - If Doctor is unreachable, nothing is paid (`SafePayError` with code `preflight_failed`).
109
132
  - The preflight checks whether a payment can succeed and is sensible; it does not guarantee what the service delivers after payment.
package/index.d.ts CHANGED
@@ -6,6 +6,14 @@ export declare const DIAGNOSE_CAP: string;
6
6
  export declare const VERSION: string;
7
7
  export declare const BASE: "eip155:8453";
8
8
  export declare const SOLANA: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
9
+ export declare const ALGORAND: "algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=";
10
+ export declare const ALGORAND_TESTNET: "algorand:SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=";
11
+ export declare const XRPL: "xrpl:0";
12
+ export declare const XRPL_TESTNET: "xrpl:1";
13
+ /** The option paid on `network`: on Algorand only USDC, on the XRP Ledger only RLUSD from Ripple's issuer within maxUsd (in dollars); null when there is none. */
14
+ /** What a 402 offers (per x402 option: scheme, network, asset, amount, payTo) as a stable string; null without a readable x402 challenge. Consumes the body. */
15
+ export declare function offerFingerprint(res: Response): Promise<string | null>;
16
+ export declare function payableOption<A extends { network: string; asset?: unknown; amount?: unknown; extra?: Record<string, unknown> }>(accepts: A[], network: string, maxUsd: number | string): A | null;
9
17
 
10
18
  /** The x402 Doctor preflight: see https://x402-doctor.fizzl.eu/openapi.json */
11
19
  export interface Preflight {
@@ -34,7 +42,7 @@ export declare class SafePayError extends Error {
34
42
  export interface SafeFetchOptions {
35
43
  /** Registers your payment schemes, e.g. (c) => c.register("eip155:8453", new ExactEvmScheme(account)). */
36
44
  register: (client: x402Client) => unknown;
37
- /** The network you pay on: "base", "solana" or a CAIP-2 id. Default Base. */
45
+ /** The network you pay on: "base", "solana", "algorand" (USDC), "xrpl" (RLUSD), their "-testnet" forms, or a CAIP-2 id. Default Base. */
38
46
  network?: string;
39
47
  /** Budget per endpoint call in USD. Default 0.05. */
40
48
  maxUsd?: number | string;
@@ -43,7 +51,7 @@ export interface SafeFetchOptions {
43
51
  /** Hosts you already trust: paid without a preflight. */
44
52
  trusted?: string[];
45
53
  onPreflight?: (preflight: Preflight, info: { url: string; method: string; cached: boolean }) => void;
46
- /** How long a verdict is reused, in ms. Default 10 minutes. */
54
+ /** How long a verdict is reused while the endpoint's 402 offers the same thing, in ms. Default 1 hour; a changed price, payout address, token or network is checked again at once; at most 10 minutes when the offer can't be read. */
47
55
  cacheMs?: number;
48
56
  doctorUrl?: string;
49
57
  /** Check Doctor's signed receipt on every preflight (signer, and bound to this request). Default "require". */
@@ -55,6 +63,8 @@ export interface SafeFetchOptions {
55
63
  /** When a payment still fails (402 again, or paying throws), buy a $0.01 Doctor diagnosis of why. Default false. */
56
64
  diagnoseOnFailure?: boolean;
57
65
  onDiagnosis?: (report: Diagnosis, info: { url: string; method: string; status: number | null; error: Error | null }) => void;
66
+ /** After paying, tell Doctor whether the payment worked (with the signed preflight as proof), so later preflights learn from it. Default false. */
67
+ shareOutcomes?: boolean;
58
68
  fetch?: typeof globalThis.fetch;
59
69
  /** Advanced/testing: a paying fetch capped at `cap` (e.g. "$0.05"). */
60
70
  createPayingFetch?: (cap: string) => typeof globalThis.fetch;
package/index.js CHANGED
@@ -8,16 +8,22 @@
8
8
  // caution → onCaution: "stop" (default, throws), "pay", or your own async
9
9
  // function that decides (e.g. ask the user)
10
10
  // go → pays the endpoint, never more than maxUsd
11
- // Trusted hosts skip the preflight; a verdict is reused for 10 minutes.
11
+ // Trusted hosts skip the preflight. A verdict is reused while the endpoint's 402 offers the same thing
12
+ // (up to an hour); a changed price, payout address, token or network is checked again at once.
12
13
  // With diagnoseOnFailure, a payment that still fails (the endpoint answers 402
13
14
  // again, or paying throws) gets a $0.01 Doctor diagnosis that says why and how
14
15
  // to fix it: onDiagnosis(report), diagnosisOf(response) or error.diagnosis.
16
+ // With shareOutcomes, after paying it tells Doctor what happened (paid_ok,
17
+ // paid_failed, paid_error), with the signed preflight as proof, so later
18
+ // preflights for that endpoint learn from what agents ran into. Off by
19
+ // default; no keys, amounts or response content are sent.
15
20
  // Every preflight must carry Doctor's signed receipt for exactly this request
16
21
  // (endpoint, method, budget, network), checked against Doctor's pinned signer:
17
22
  // a missing, changed or forged verdict is never a payment (receipt.js).
18
23
  //
19
24
  // Payments use @x402/fetch with the schemes you register (your keys stay in
20
25
  // your code): register: (client) => client.register("eip155:8453", new ExactEvmScheme(account))
26
+ // On Algorand (USDC) and the XRP Ledger (RLUSD) only the dollar stablecoin is paid.
21
27
 
22
28
  import { wrapFetchWithPayment, x402Client, decodePaymentResponseHeader } from "@x402/fetch";
23
29
  import { verifyReceipt, DOCTOR_SIGNERS, AUTHORITY } from "./receipt.js";
@@ -27,12 +33,54 @@ export { verifyReceipt, recoverSigner, canonicalJson, inputHash, certMessage, DO
27
33
  export const DOCTOR_URL = "https://x402-doctor.fizzl.eu";
28
34
  export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
29
35
  export const DIAGNOSE_CAP = "$0.02"; // the diagnosis costs $0.01; never more than twice that
30
- export const VERSION = "0.4.0";
36
+ export const VERSION = "0.6.0";
31
37
  // Doctor sees which calls come from this package (in its usage counts), nothing more.
32
38
  const DOCTOR_HEADERS = { accept: "application/json", "user-agent": `x402-safe-fetch/${VERSION}` };
33
39
  export const BASE = "eip155:8453";
34
40
  export const SOLANA = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
35
- const NETWORK_ALIASES = { base: BASE, solana: SOLANA };
41
+ export const ALGORAND = "algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=";
42
+ export const ALGORAND_TESTNET = "algorand:SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=";
43
+ export const XRPL = "xrpl:0";
44
+ export const XRPL_TESTNET = "xrpl:1";
45
+ const NETWORK_ALIASES = { base: BASE, solana: SOLANA, algorand: ALGORAND, "algorand-testnet": ALGORAND_TESTNET, xrpl: XRPL, "xrpl-testnet": XRPL_TESTNET };
46
+
47
+ // On Algorand and the XRP Ledger only the dollar stablecoin is paid: USDC (an ASA) on Algorand, RLUSD from
48
+ // Ripple's issuer on the XRP Ledger. Never ALGO, XRP or another token, so the dollar budget always holds.
49
+ const ALGORAND_USDC = { [ALGORAND]: "31566704", [ALGORAND_TESTNET]: "10458941" };
50
+ const RLUSD_HEX = "524C555344000000000000000000000000000000";
51
+ const RLUSD_ISSUER = { [XRPL]: "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", [XRPL_TESTNET]: "rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV" };
52
+
53
+ // The option to pay on `network`, or null. RLUSD prices are decimal dollars ("0.001", and "2" is two dollars),
54
+ // which x402's spend controls would read as base units, so the budget is checked here in dollars.
55
+ // What a 402 offers, as a stable string: per x402 option its scheme, network, asset, amount and payout address
56
+ // (not extra: invoices and nonces change per request). null when there is no x402 challenge to read (e.g. an
57
+ // MPP-only 402). Reads and consumes the response body.
58
+ export async function offerFingerprint(res) {
59
+ let challenge = null;
60
+ const header = res?.headers?.get?.("payment-required");
61
+ if (header) {
62
+ try { challenge = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(header), (c) => c.charCodeAt(0)))); } catch { /* not base64 JSON */ }
63
+ try { await res.body?.cancel(); } catch { /* already consumed */ }
64
+ } else {
65
+ try { challenge = JSON.parse(await res.text()); } catch { /* not JSON */ }
66
+ }
67
+ if (!Array.isArray(challenge?.accepts)) return null;
68
+ 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 ?? "")]));
69
+ return JSON.stringify(parts.sort());
70
+ }
71
+ // Without an offer to compare (no readable x402 challenge), a verdict is reused for at most 10 minutes.
72
+ const UNREAD_OFFER_MS = 10 * 60 * 1000;
73
+
74
+ export function payableOption(accepts, network, maxUsd) {
75
+ const cap = Number(String(maxUsd).replace(/^\$/, ""));
76
+ const on = (accepts ?? []).filter((a) => a?.network === network);
77
+ if (ALGORAND_USDC[network]) return on.find((a) => String(a.asset) === ALGORAND_USDC[network]) ?? null;
78
+ if (RLUSD_ISSUER[network]) {
79
+ return on.find((a) => String(a.asset ?? "").toUpperCase() === RLUSD_HEX && a.extra?.issuer === RLUSD_ISSUER[network]
80
+ && /^\d+(\.\d+)?$/.test(String(a.amount)) && Number(a.amount) > 0 && Number(a.amount) <= cap) ?? null;
81
+ }
82
+ return on[0] ?? null;
83
+ }
36
84
 
37
85
  export class SafePayError extends Error {
38
86
  /** code: "no_go" | "caution" | "preflight_failed" | "bad_receipt" | "no_option" */
@@ -103,12 +151,12 @@ function requestOf(input, init) {
103
151
  /**
104
152
  * @param {object} options
105
153
  * @param {(client: x402Client) => unknown} options.register registers your payment schemes on a client
106
- * @param {string} [options.network] the network you pay on: "base", "solana" or a CAIP-2 id (default Base)
154
+ * @param {string} [options.network] the network you pay on: "base", "solana", "algorand", "xrpl" or a CAIP-2 id (default Base)
107
155
  * @param {number|string} [options.maxUsd] budget per endpoint call (default 0.05)
108
156
  * @param {"stop"|"pay"|((preflight: object) => boolean|Promise<boolean>)} [options.onCaution]
109
157
  * @param {string[]} [options.trusted] hosts you already trust: paid without a preflight
110
158
  * @param {(preflight: object, info: {url: string, method: string, cached: boolean}) => void} [options.onPreflight]
111
- * @param {number} [options.cacheMs] how long a verdict is reused (default 10 minutes)
159
+ * @param {number} [options.cacheMs] how long a verdict is reused while the 402 offers the same thing (default 1 hour; at most 10 minutes when the offer can't be read)
112
160
  * @param {string} [options.doctorUrl]
113
161
  * @param {"require"|"off"} [options.verifyReceipts] check Doctor's signature on every preflight (default "require")
114
162
  * @param {string[]} [options.doctorSigners] accepted Doctor signer addresses (default: the published signer)
@@ -116,6 +164,7 @@ function requestOf(input, init) {
116
164
  * @param {typeof fetch} [options.fetch] the underlying fetch (default globalThis.fetch)
117
165
  * @param {boolean} [options.diagnoseOnFailure] when a payment still fails, buy a $0.01 Doctor diagnosis of why (default false)
118
166
  * @param {(report: object, info: {url: string, method: string, status: number|null, error: Error|null}) => void} [options.onDiagnosis]
167
+ * @param {boolean} [options.shareOutcomes] after paying, tell Doctor whether the payment worked (default false)
119
168
  * @param {(cap: string) => typeof fetch} [options.createPayingFetch] advanced/testing: a paying fetch capped at `cap`
120
169
  * @param {() => number} [options.now]
121
170
  */
@@ -126,13 +175,14 @@ export function createSafeFetch({
126
175
  onCaution = "stop",
127
176
  trusted = [],
128
177
  onPreflight,
129
- cacheMs = 10 * 60 * 1000,
178
+ cacheMs = 60 * 60 * 1000,
130
179
  doctorUrl = DOCTOR_URL,
131
180
  verifyReceipts = "require",
132
181
  doctorSigners = DOCTOR_SIGNERS,
133
182
  authority = AUTHORITY,
134
183
  diagnoseOnFailure = false,
135
184
  onDiagnosis,
185
+ shareOutcomes = false,
136
186
  fetch: baseFetch = globalThis.fetch,
137
187
  createPayingFetch,
138
188
  now = Date.now,
@@ -150,8 +200,8 @@ export function createSafeFetch({
150
200
 
151
201
  const makePayingFetch = createPayingFetch ?? ((cap) => {
152
202
  const client = new x402Client((_version, accepts) => {
153
- const pick = accepts.find((a) => a.network === payNetwork);
154
- if (!pick) throw new SafePayError(`no payment option on ${payNetwork}`, { code: "no_option" });
203
+ const pick = payableOption(accepts, payNetwork, cap);
204
+ if (!pick) throw new SafePayError(`no payment option on ${payNetwork}${ALGORAND_USDC[payNetwork] ? " in USDC" : RLUSD_ISSUER[payNetwork] ? ` in RLUSD within ${cap}` : ""}`, { code: "no_option" });
155
205
  return pick;
156
206
  }).setSpendControls({ maxAmountPerPayment: cap });
157
207
  register(client);
@@ -165,10 +215,11 @@ export function createSafeFetch({
165
215
  const payEndpoint = (...args) => (endpointFetch ??= makePayingFetch(budget))(...args);
166
216
 
167
217
  const verdicts = new Map();
168
- async function preflightFor(url, method) {
218
+ async function preflightFor(url, method, offer) {
169
219
  const key = `${method} ${url}`;
170
220
  const hit = verdicts.get(key);
171
- if (hit && hit.expires > now()) return { preflight: hit.preflight, cached: true };
221
+ const ttl = offer === null ? Math.min(cacheMs, UNREAD_OFFER_MS) : cacheMs;
222
+ if (hit && hit.offer === offer && hit.at + ttl > now()) return { preflight: hit.preflight, cached: true };
172
223
  const pfUrl = preflightUrl(url, { method, maxUsd: budget, network: payNetwork, doctorUrl });
173
224
  const res = await payPreflight(pfUrl, { headers: DOCTOR_HEADERS });
174
225
  const preflight = await res.json().catch(() => null);
@@ -181,7 +232,7 @@ export function createSafeFetch({
181
232
  const check = verifyReceipt(preflight, { signers: doctorSigners, route: "GET /api/v1/preflight", input, authority, service: "x402-doctor" });
182
233
  if (!check.valid) throw new SafePayError(`preflight not trusted (${check.reason}); the endpoint was not paid`, { code: "bad_receipt", preflight, url });
183
234
  }
184
- verdicts.set(key, { preflight, expires: now() + cacheMs });
235
+ verdicts.set(key, { preflight, offer, at: now() });
185
236
  if (verdicts.size > 1000) verdicts.delete(verdicts.keys().next().value);
186
237
  return { preflight, cached: false };
187
238
  }
@@ -211,6 +262,26 @@ export function createSafeFetch({
211
262
  return report;
212
263
  }
213
264
 
265
+ // One outcome report per paid preflight: Doctor counts it once anyway.
266
+ const reported = new Set();
267
+ function reportOutcome(url, method, preflight, res) {
268
+ if (!shareOutcomes || !preflight?.receipt?.request_id || reported.has(preflight.receipt.request_id)) return;
269
+ const outcome = res.ok ? "paid_ok" : res.status === 402 ? "paid_failed" : res.status >= 400 ? "paid_error" : null;
270
+ if (!outcome) return;
271
+ reported.add(preflight.receipt.request_id);
272
+ if (reported.size > 1000) reported.delete(reported.values().next().value);
273
+ const query = Object.fromEntries(new URL(preflightUrl(url, { method, maxUsd: budget, network: payNetwork, doctorUrl })).searchParams);
274
+ // Fire and forget: the report never delays or changes the answer.
275
+ Promise.resolve()
276
+ .then(() => baseFetch(`${doctorUrl.replace(/\/$/, "")}/api/v1/outcome`, {
277
+ method: "POST",
278
+ headers: { ...DOCTOR_HEADERS, "content-type": "application/json" },
279
+ body: JSON.stringify({ outcome, status: res.status, preflight, query }),
280
+ }))
281
+ .then((r) => r?.body?.cancel?.())
282
+ .catch(() => {});
283
+ }
284
+
214
285
  async function payAndCheck(url, init, method) {
215
286
  if (!diagnoseOnFailure) return payEndpoint(url, init);
216
287
  let res;
@@ -239,11 +310,18 @@ export function createSafeFetch({
239
310
  const { url, method } = requestOf(input, init);
240
311
  const probe = await baseFetch(url, init);
241
312
  if (probe.status !== 402) return probe;
242
- try { await probe.body?.cancel(); } catch { /* already consumed */ }
243
313
 
244
- if (trustedHosts.has(hostOf(url))) return payAndCheck(url, init, method);
314
+ if (trustedHosts.has(hostOf(url))) {
315
+ try { await probe.body?.cancel(); } catch { /* already consumed */ }
316
+ return payAndCheck(url, init, method);
317
+ }
245
318
 
246
- const { preflight, cached } = await preflightFor(url, method);
319
+ const { preflight, cached } = await preflightFor(url, method, await offerFingerprint(probe));
320
+ const payAfterPreflight = async () => {
321
+ const res = await payAndCheck(url, init, method);
322
+ reportOutcome(url, method, preflight, res);
323
+ return res;
324
+ };
247
325
  onPreflight?.(preflight, { url, method, cached });
248
326
  const why = preflight.summary || preflight.verdict;
249
327
  if (preflight.verdict === "no_go") throw new SafePayError(`not paid: ${why}`, { code: "no_go", preflight, url });
@@ -253,6 +331,6 @@ export function createSafeFetch({
253
331
  } else if (preflight.verdict !== "go") {
254
332
  throw new SafePayError(`not paid: unknown verdict ${preflight.verdict}`, { code: "preflight_failed", preflight, url });
255
333
  }
256
- return payAndCheck(url, init, method);
334
+ return payAfterPreflight();
257
335
  };
258
336
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-safe-fetch",
3
- "version": "0.4.0",
3
+ "version": "0.6.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": {
@@ -28,8 +28,11 @@
28
28
  "@x402/fetch": "^2.27.0"
29
29
  },
30
30
  "devDependencies": {
31
+ "@algorandfoundation/algokit-utils": "^10.0.0-alpha.46",
32
+ "@x402/avm": "^2.28.0",
31
33
  "@x402/evm": "^2.27.0",
32
34
  "@x402/fetch": "^2.27.0",
35
+ "@x402/xrpl": "^2.28.0",
33
36
  "viem": "^2.56.9"
34
37
  },
35
38
  "keywords": [
@@ -41,6 +44,9 @@
41
44
  "usdc",
42
45
  "base",
43
46
  "solana",
47
+ "algorand",
48
+ "xrpl",
49
+ "rlusd",
44
50
  "signed-receipts"
45
51
  ],
46
52
  "repository": {