x402-safe-fetch 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,7 +45,7 @@ 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.
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. Sellers name Algorand mainnet either by the whole genesis hash or by its CAIP-2 id (`algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73k`); both are paid, also with `@x402/avm` 2.29.0, which on its own accepts only the short id. 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
49
 
50
50
  ```js
51
51
  import { toClientAvmSigner } from "@x402/avm";
@@ -65,6 +65,27 @@ const safeFetch = createSafeFetch({
65
65
  // facilitator (PayAI) pays the gas.
66
66
  ```
67
67
 
68
+ ### Escrowed payments (auth-capture)
69
+
70
+ Some sellers on Base take payment through Coinbase's escrow instead of a direct transfer: the x402 scheme
71
+ `auth-capture`. Your wallet signs once, the USDC is held, and the seller captures it only after it has delivered
72
+ (Atom's domain checkout, for instance, captures once the domain is registered, and releases it if registration
73
+ fails). Register that scheme next to the exact one to pay such sellers (`@x402/evm` 2.27 or later):
74
+
75
+ ```js
76
+ import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/client";
77
+
78
+ const safeFetch = createSafeFetch({
79
+ register: (client) => {
80
+ client.register("eip155:8453", new ExactEvmScheme(account));
81
+ client.register("eip155:8453", new AuthCaptureEvmScheme(account));
82
+ },
83
+ maxUsd: 25, // the budget caps the most that can be captured
84
+ });
85
+ ```
86
+
87
+ When a seller offers both, the exact option is paid. The preflight and the budget apply the same way.
88
+
68
89
  ## Options
69
90
 
70
91
  | Option | Default | |
@@ -79,7 +100,7 @@ const safeFetch = createSafeFetch({
79
100
  | `verifyReceipts` | `"require"` | Check Doctor's signed receipt on every preflight; `"off"` skips it |
80
101
  | `doctorSigners` | Doctor's published signer | Accepted signer addresses (for a self-hosted Doctor) |
81
102
  | `diagnoseOnFailure` | `false` | When a payment still fails, buy a $0.01 Doctor diagnosis of why (see below) |
82
- | `shareOutcomes` | `false` | After paying, tell Doctor whether the payment worked, so its later preflights learn from it (see below) |
103
+ | `shareOutcomes` | `false` | After paying, tell Doctor whether the payment worked, so its later preflights learn from it; when not paying, tell it why (see below) |
83
104
  | `onDiagnosis` | | `(report, { url, method, status, error }) => void`, e.g. for logging or alerting |
84
105
  | `authority` | the Fizzl payout wallet | Wallet whose certificates also make a signer trusted (key rotation); `null` accepts only `doctorSigners` |
85
106
 
@@ -117,6 +138,8 @@ With `shareOutcomes: true`, safe-fetch tells x402 Doctor what happened after it
117
138
  - A report only counts with a real, paid preflight for that endpoint, once per preflight, so reports cannot be made up for free.
118
139
  - It is sent after the answer and never delays or changes it. Off by default.
119
140
 
141
+ When safe-fetch does **not** pay (a `no_go`, or a `caution` that `onCaution` stops), it reports `refused` with the reason code that stopped it (for example `payto_changed`, `price_changed` or `payee_mismatch`). That is the seller's signal back: Doctor shows sellers how often agents refused to pay their endpoint and why (in the free web check and `GET https://x402-doctor.fizzl.eu/api/v1/refusals?url=…`), as counts only, never who.
142
+
120
143
  ## Signed verdicts
121
144
 
122
145
  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:
package/index.d.ts CHANGED
@@ -16,6 +16,8 @@ export declare const XLAYER: "eip155:196";
16
16
  /** The option paid on `network`: on Algorand only USDC, on Stellar only Circle's USDC contract within maxUsd, on X Layer only USD₮0 or USDC within maxUsd, on the XRP Ledger only RLUSD from Ripple's issuer within maxUsd (in dollars); null when there is none. */
17
17
  /** 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. */
18
18
  export declare function offerFingerprint(res: Response): Promise<string | null>;
19
+ /** Wraps an Algorand scheme (e.g. @x402/avm's ExactAvmScheme) so it pays sellers that name Algorand by the whole genesis hash as well as by the CAIP-2 id. createSafeFetch does this for you. */
20
+ export declare function anyAlgorandId<S extends object>(scheme: S): S;
19
21
  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;
20
22
 
21
23
  /** The x402 Doctor preflight: see https://x402-doctor.fizzl.eu/openapi.json */
@@ -66,7 +68,7 @@ export interface SafeFetchOptions {
66
68
  /** When a payment still fails (402 again, or paying throws), buy a $0.01 Doctor diagnosis of why. Default false. */
67
69
  diagnoseOnFailure?: boolean;
68
70
  onDiagnosis?: (report: Diagnosis, info: { url: string; method: string; status: number | null; error: Error | null }) => void;
69
- /** After paying, tell Doctor whether the payment worked (with the signed preflight as proof), so later preflights learn from it. Default false. */
71
+ /** After paying, tell Doctor whether the payment worked (with the signed preflight as proof), so later preflights learn from it; when it does not pay (no_go, or a caution it stops at), report "refused" with the reason code, so the seller learns why. Default false. */
70
72
  shareOutcomes?: boolean;
71
73
  fetch?: typeof globalThis.fetch;
72
74
  /** Advanced/testing: a paying fetch capped at `cap` (e.g. "$0.05"). */
@@ -89,3 +91,6 @@ export declare function recoverSigner(message: string, signature: string): strin
89
91
  export declare const AUTHORITY: string;
90
92
  export declare function certMessage(cert: { service: string; signer: string; valid_from: string }): string;
91
93
  export declare function verifyReceipt(body: unknown, options?: { signers?: string[]; route?: string; input?: unknown; authority?: string | null; service?: string }): { valid: boolean; signer?: string; reason?: string };
94
+
95
+ /** The reason code reported with a refused outcome: the preflight's first reason at its verdict's level. */
96
+ export function refusalReason(preflight: { verdict?: string; reasons?: { level?: string; code?: string }[] }): string;
package/index.js CHANGED
@@ -15,8 +15,11 @@
15
15
  // to fix it: onDiagnosis(report), diagnosisOf(response) or error.diagnosis.
16
16
  // With shareOutcomes, after paying it tells Doctor what happened (paid_ok,
17
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.
18
+ // preflights for that endpoint learn from what agents ran into. When it does
19
+ // not pay (no_go, or a caution it stops at), it reports "refused" with the
20
+ // preflight's reason code, so the seller learns why nobody pays (Doctor shows
21
+ // sellers the counts per reason, never who). Off by default; no keys, amounts
22
+ // or response content are sent.
20
23
  // Every preflight must carry Doctor's signed receipt for exactly this request
21
24
  // (endpoint, method, budget, network), checked against Doctor's pinned signer:
22
25
  // a missing, changed or forged verdict is never a payment (receipt.js).
@@ -34,7 +37,7 @@ export { verifyReceipt, recoverSigner, canonicalJson, inputHash, certMessage, DO
34
37
  export const DOCTOR_URL = "https://x402-doctor.fizzl.eu";
35
38
  export const PREFLIGHT_CAP = "$0.002"; // the preflight costs $0.001; never more than twice that
36
39
  export const DIAGNOSE_CAP = "$0.02"; // the diagnosis costs $0.01; never more than twice that
37
- export const VERSION = "0.8.0";
40
+ export const VERSION = "0.10.0";
38
41
  // Doctor sees which calls come from this package (in its usage counts), nothing more.
39
42
  const DOCTOR_HEADERS = { accept: "application/json", "user-agent": `x402-safe-fetch/${VERSION}` };
40
43
  export const BASE = "eip155:8453";
@@ -50,7 +53,7 @@ const NETWORK_ALIASES = { base: BASE, solana: SOLANA, algorand: ALGORAND, "algor
50
53
 
51
54
  // On Algorand and the XRP Ledger only the dollar stablecoin is paid: USDC (an ASA) on Algorand, RLUSD from
52
55
  // Ripple's issuer on the XRP Ledger. Never ALGO, XRP or another token, so the dollar budget always holds.
53
- const ALGORAND_USDC = { [ALGORAND]: "31566704", [ALGORAND_TESTNET]: "10458941" };
56
+ const ALGORAND_USDC = { [algorandId(ALGORAND)]: "31566704", [algorandId(ALGORAND_TESTNET)]: "10458941" };
54
57
  const RLUSD_HEX = "524C555344000000000000000000000000000000";
55
58
  const RLUSD_ISSUER = { [XRPL]: "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De", [XRPL_TESTNET]: "rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV" };
56
59
  // On Stellar only Circle's USDC token contract (7 decimals), never XLM or another token named USDC.
@@ -62,6 +65,23 @@ const XLAYER_DOLLARS = {
62
65
  [XLAYER]: ["0x779ded0c9e1022225f8e0630b35a9b54be713736", "0x74b7f16337b8972027f6196a17a631ac6de26d22"],
63
66
  };
64
67
 
68
+ // Algorand by its CAIP-2 id: the first 32 characters of the genesis hash. Sellers name it either way (the whole hash,
69
+ // or since @x402/avm 2.29.0 only the short id), so networks are compared by this.
70
+ function algorandId(network) {
71
+ return typeof network === "string" && network.startsWith("algorand:") ? network.slice(0, 41) : network;
72
+ }
73
+
74
+ // @x402/avm 2.29.0's ExactAvmScheme refuses the whole-hash form, which most Algorand sellers still use (2.28.0 took
75
+ // both). This hands the scheme the short id; the payment still names the seller's own network (x402 puts the
76
+ // seller's option in `accepted`), so either kind of seller can be paid. createSafeFetch wraps Algorand schemes in it.
77
+ export function anyAlgorandId(scheme) {
78
+ return Object.assign(Object.create(scheme), {
79
+ createPaymentPayload: (version, requirements, context) =>
80
+ scheme.createPaymentPayload(version, { ...requirements, network: algorandId(requirements.network) }, context),
81
+ ...(typeof scheme.findDefaultAsset === "function" && { findDefaultAsset: (asset, network) => scheme.findDefaultAsset(asset, algorandId(network)) }),
82
+ });
83
+ }
84
+
65
85
  // The option to pay on `network`, or null. RLUSD prices are decimal dollars ("0.001", and "2" is two dollars),
66
86
  // which x402's spend controls would read as base units, so the budget is checked here in dollars.
67
87
  // What a 402 offers, as a stable string: per x402 option its scheme, network, asset, amount and payout address
@@ -85,8 +105,9 @@ const UNREAD_OFFER_MS = 10 * 60 * 1000;
85
105
 
86
106
  export function payableOption(accepts, network, maxUsd) {
87
107
  const cap = Number(String(maxUsd).replace(/^\$/, ""));
88
- const on = (accepts ?? []).filter((a) => a?.network === network);
89
- if (ALGORAND_USDC[network]) return on.find((a) => String(a.asset) === ALGORAND_USDC[network]) ?? null;
108
+ const on = (accepts ?? []).filter((a) => algorandId(a?.network) === algorandId(network));
109
+ const usdc = ALGORAND_USDC[algorandId(network)];
110
+ if (usdc) return on.find((a) => String(a.asset) === usdc) ?? null;
90
111
  // x402's spend controls cap Stellar USDC too; the budget is checked here as well, in dollars.
91
112
  if (STELLAR_USDC[network]) {
92
113
  return on.find((a) => a.asset === STELLAR_USDC[network] && /^\d+$/.test(String(a.amount)) && Number(a.amount) > 0 && Number(a.amount) / 1e7 <= cap) ?? null;
@@ -99,7 +120,20 @@ export function payableOption(accepts, network, maxUsd) {
99
120
  return on.find((a) => String(a.asset ?? "").toUpperCase() === RLUSD_HEX && a.extra?.issuer === RLUSD_ISSUER[network]
100
121
  && /^\d+(\.\d+)?$/.test(String(a.amount)) && Number(a.amount) > 0 && Number(a.amount) <= cap) ?? null;
101
122
  }
102
- return on[0] ?? null;
123
+ // Base and other EVM chains: "exact" first (what most clients register), then "auth-capture" (Coinbase's escrow:
124
+ // the buyer signs once, the USDC is held and only captured when the seller delivers, e.g. Atom's domain checkout).
125
+ // Either is paid only if your register() added its scheme, and both stay within the budget (x402's spend controls
126
+ // read the amount, which for auth-capture is the most that can be captured).
127
+ return on.find((a) => a?.scheme === "exact") ?? on.find((a) => a?.scheme === "auth-capture") ?? on[0] ?? null;
128
+ }
129
+
130
+ // The code that stopped the payment: the preflight's first reason at its verdict's level (no_go, then caution), as
131
+ // Doctor accepts it (a-z, 0-9, _); the verdict itself when there is none.
132
+ export function refusalReason(preflight) {
133
+ const reasons = Array.isArray(preflight?.reasons) ? preflight.reasons : [];
134
+ const pick = reasons.find((r) => r?.level === preflight?.verdict) || reasons.find((r) => r?.level === "no_go" || r?.level === "caution");
135
+ const code = String(pick?.code || preflight?.verdict || "refused").toLowerCase().replace(/[^a-z0-9_]/g, "_").slice(0, 48);
136
+ return /^[a-z][a-z0-9_]{1,47}$/.test(code) ? code : "refused";
103
137
  }
104
138
 
105
139
  export class SafePayError extends Error {
@@ -228,10 +262,23 @@ export function createSafeFetch({
228
262
  const makePayingFetch = createPayingFetch ?? ((cap) => {
229
263
  const client = new x402Client((_version, accepts) => {
230
264
  const pick = payableOption(accepts, payNetwork, cap);
231
- if (!pick) throw new SafePayError(`no payment option on ${payNetwork}${ALGORAND_USDC[payNetwork] ? " in USDC" : STELLAR_USDC[payNetwork] ? ` in USDC within ${cap}` : XLAYER_DOLLARS[payNetwork] ? ` in USD₮0 or USDC within ${cap}` : RLUSD_ISSUER[payNetwork] ? ` in RLUSD within ${cap}` : ""}`, { code: "no_option" });
265
+ if (!pick) throw new SafePayError(`no payment option on ${payNetwork}${ALGORAND_USDC[algorandId(payNetwork)] ? " in USDC" : STELLAR_USDC[payNetwork] ? ` in USDC within ${cap}` : XLAYER_DOLLARS[payNetwork] ? ` in USD₮0 or USDC within ${cap}` : RLUSD_ISSUER[payNetwork] ? ` in RLUSD within ${cap}` : ""}`, { code: "no_option" });
232
266
  return pick;
233
267
  }).setSpendControls({ maxAmountPerPayment: cap, ...xlayerAllowance(payNetwork, cap) });
234
- register(client);
268
+ // Algorand schemes get anyAlgorandId, so sellers with either network id can be paid.
269
+ const registering = new Proxy(client, {
270
+ get(target, prop) {
271
+ if (prop === "register") {
272
+ return (network, scheme) => {
273
+ target.register(network, String(network).startsWith("algorand:") ? anyAlgorandId(scheme) : scheme);
274
+ return registering;
275
+ };
276
+ }
277
+ const value = Reflect.get(target, prop);
278
+ return typeof value === "function" ? value.bind(target) : value;
279
+ },
280
+ });
281
+ register(registering);
235
282
  return wrapFetchWithPayment(baseFetch, client);
236
283
  });
237
284
  let preflightFetch;
@@ -291,10 +338,8 @@ export function createSafeFetch({
291
338
 
292
339
  // One outcome report per paid preflight: Doctor counts it once anyway.
293
340
  const reported = new Set();
294
- function reportOutcome(url, method, preflight, res) {
341
+ function sendOutcome(url, method, preflight, report) {
295
342
  if (!shareOutcomes || !preflight?.receipt?.request_id || reported.has(preflight.receipt.request_id)) return;
296
- const outcome = res.ok ? "paid_ok" : res.status === 402 ? "paid_failed" : res.status >= 400 ? "paid_error" : null;
297
- if (!outcome) return;
298
343
  reported.add(preflight.receipt.request_id);
299
344
  if (reported.size > 1000) reported.delete(reported.values().next().value);
300
345
  const query = Object.fromEntries(new URL(preflightUrl(url, { method, maxUsd: budget, network: payNetwork, doctorUrl })).searchParams);
@@ -303,11 +348,19 @@ export function createSafeFetch({
303
348
  .then(() => baseFetch(`${doctorUrl.replace(/\/$/, "")}/api/v1/outcome`, {
304
349
  method: "POST",
305
350
  headers: { ...DOCTOR_HEADERS, "content-type": "application/json" },
306
- body: JSON.stringify({ outcome, status: res.status, preflight, query }),
351
+ body: JSON.stringify({ ...report, preflight, query }),
307
352
  }))
308
353
  .then((r) => r?.body?.cancel?.())
309
354
  .catch(() => {});
310
355
  }
356
+ function reportOutcome(url, method, preflight, res) {
357
+ const outcome = res.ok ? "paid_ok" : res.status === 402 ? "paid_failed" : res.status >= 400 ? "paid_error" : null;
358
+ if (outcome) sendOutcome(url, method, preflight, { outcome, status: res.status });
359
+ }
360
+ // Not paid after a preflight: "refused", with the reason that stopped it (the first reason at the verdict's level).
361
+ function reportRefusal(url, method, preflight) {
362
+ sendOutcome(url, method, preflight, { outcome: "refused", reason: refusalReason(preflight), source: "doctor" });
363
+ }
311
364
 
312
365
  async function payAndCheck(url, init, method) {
313
366
  if (!diagnoseOnFailure) return payEndpoint(url, init);
@@ -351,10 +404,16 @@ export function createSafeFetch({
351
404
  };
352
405
  onPreflight?.(preflight, { url, method, cached });
353
406
  const why = preflight.summary || preflight.verdict;
354
- if (preflight.verdict === "no_go") throw new SafePayError(`not paid: ${why}`, { code: "no_go", preflight, url });
407
+ if (preflight.verdict === "no_go") {
408
+ reportRefusal(url, method, preflight);
409
+ throw new SafePayError(`not paid: ${why}`, { code: "no_go", preflight, url });
410
+ }
355
411
  if (preflight.verdict === "caution") {
356
412
  const ok = onCaution === "pay" || (typeof onCaution === "function" && (await onCaution(preflight)) === true);
357
- if (!ok) throw new SafePayError(`not paid (caution): ${why}`, { code: "caution", preflight, url });
413
+ if (!ok) {
414
+ reportRefusal(url, method, preflight);
415
+ throw new SafePayError(`not paid (caution): ${why}`, { code: "caution", preflight, url });
416
+ }
358
417
  } else if (preflight.verdict !== "go") {
359
418
  throw new SafePayError(`not paid: unknown verdict ${preflight.verdict}`, { code: "preflight_failed", preflight, url });
360
419
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-safe-fetch",
3
- "version": "0.8.0",
3
+ "version": "0.10.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": {
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@algorandfoundation/algokit-utils": "^10.0.0-alpha.46",
32
- "@x402/avm": "^2.28.0",
32
+ "@x402/avm": "^2.29.0",
33
33
  "@x402/evm": "^2.27.0",
34
34
  "@x402/fetch": "^2.27.0",
35
35
  "@x402/stellar": "^2.28.0",