x402-safe-fetch 0.16.0 → 0.16.2

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
@@ -144,7 +144,9 @@ The preflight itself is capped at $0.002 and paid with the same schemes. `receip
144
144
 
145
145
  A seller can take the payment and still answer with an error: a 405, a 500, or another 402 asking to pay again. Paying the same request again then usually costs a second payment for nothing. In the agent exam at [exam.fizzl.eu](https://exam.fizzl.eu) this was the most common loss: every model we examined did it at least once, with or without a preflight, because a preflight only sees the 402 before paying.
146
146
 
147
- So safe-fetch remembers each paid request (method, URL and body) whose paid answer was not a success. Calling that same request again within 30 minutes throws `SafePayError` with code `paid_no_delivery` (and `error.earlier`: when it was paid and what it was answered with), without paying and without buying another preflight. A different request is not affected, and a success clears it.
147
+ So safe-fetch remembers each paid endpoint (method, origin and path) whose paid answer was not a success. Calling that endpoint again within 30 minutes, with any query string or body, throws `SafePayError` with code `paid_no_delivery` (and `error.earlier`: when it was paid and what it was answered with), without paying and without buying another preflight. Another endpoint is not affected, and a success clears it.
148
+
149
+ The query and the body are left out on purpose. Since 0.16.2: in the exam, an open model that was refused added `&nonce=1`, `&nonce=2`, … to the URL, and each "new" request was paid (eight times). A seller that took your money without delivering doesn't get paid again on that endpoint, whatever the agent changes in the request.
148
150
 
149
151
  ```js
150
152
  const safeFetch = createSafeFetch({
package/index.d.ts CHANGED
@@ -152,7 +152,7 @@ export declare function verifyReceipt(body: unknown, options?: { signers?: strin
152
152
  /** The reason code reported with a refused outcome: the preflight's first reason at its verdict's level. */
153
153
  export function refusalReason(preflight: { verdict?: string; reasons?: { level?: string; code?: string }[] }): string;
154
154
 
155
- /** Remembers paid requests that weren't delivered, so the same request isn't paid again (used by createSafeFetch; usable on its own). */
155
+ /** Remembers paid endpoints (method, origin and path) that took a payment without delivering, so they aren't paid again with any query or body (used by createSafeFetch; usable on its own). */
156
156
  export declare function createPaidGuard(options?: { windowMs?: number; now?: () => number }): {
157
157
  blocked(url: string, init?: { method?: string; body?: unknown }): { at: number; status: number } | null;
158
158
  record(url: string, init: { method?: string; body?: unknown } | undefined, status: number): void;
package/index.js CHANGED
@@ -25,8 +25,8 @@
25
25
  // hash of the refused offer; no query, no keys, nothing about the buyer or this client), once a day per endpoint and
26
26
  // reason. A screening_hit isn't sent: the draft takes it only signed. Off by default.
27
27
  // A paid request answered without delivery (an error, or another 402) is never paid again on its own: the same
28
- // method, URL and body within paidErrorMs throws SafePayError code "paid_no_delivery" (onPaidError "stop", the
29
- // default). The most common loss in the agent exam (exam.fizzl.eu) was paying again after a seller took the payment
28
+ // endpoint (method, origin and path; any query or body) within paidErrorMs throws SafePayError code
29
+ // "paid_no_delivery" (onPaidError "stop", the default). The most common loss in the agent exam (exam.fizzl.eu) was paying again after a seller took the payment
30
30
  // and answered with an error; a preflight can't see that, it only reads the 402 before paying (paid-guard.js).
31
31
  // Every preflight must carry Doctor's signed receipt for exactly this request
32
32
  // (endpoint, method, budget, network), checked against Doctor's pinned signer:
@@ -51,7 +51,7 @@ export const PRESIGN_GUARD_URL = "https://presign-guard.fizzl.eu";
51
51
  export const INJECTION_CAP = "$0.02"; // presign-guard's injection check costs $0.01; never more than twice that
52
52
  // The header a checked answer carries: green, orange, red (only with onInjection "flag" or a function) or unchecked.
53
53
  export const ANSWER_CHECK_HEADER = "x-answer-check";
54
- export const VERSION = "0.16.0";
54
+ export const VERSION = "0.16.1";
55
55
  // Doctor sees which calls come from this package (in its usage counts), nothing more.
56
56
  const DOCTOR_HEADERS = { accept: "application/json", "user-agent": `x402-safe-fetch/${VERSION}` };
57
57
  export const BASE = "eip155:8453";
@@ -159,8 +159,15 @@ export function payableOption(accepts, network, maxUsd) {
159
159
  // Base and other EVM chains: "exact" first (what most clients register), then "auth-capture" (Coinbase's escrow:
160
160
  // the buyer signs once, the USDC is held and only captured when the seller delivers, e.g. Atom's domain checkout).
161
161
  // Either is paid only if your register() added its scheme, and both stay within the budget (x402's spend controls
162
- // read the amount, which for auth-capture is the most that can be captured).
163
- return on.find((a) => a?.scheme === "exact") ?? on.find((a) => a?.scheme === "auth-capture") ?? on[0] ?? null;
162
+ // read the amount, which for auth-capture is the most that can be captured). Of several options of a scheme, the
163
+ // cheapest within the budget: a 402 that lists $0.50 before $0.01 to the same seller is paid a cent, not refused
164
+ // (or overpaid) because the first one costs more.
165
+ const cheapest = (scheme) => on
166
+ .filter((a) => a?.scheme === scheme)
167
+ .map((a) => ({ a, usd: usdOfOption(a, network) }))
168
+ .filter((x) => x.usd !== null && x.usd > 0 && !(x.usd > cap + 1e-9))
169
+ .sort((x, y) => x.usd - y.usd)[0]?.a;
170
+ return cheapest("exact") ?? cheapest("auth-capture") ?? on.find((a) => a?.scheme === "exact") ?? on.find((a) => a?.scheme === "auth-capture") ?? on[0] ?? null;
164
171
  }
165
172
 
166
173
  // The code that stopped the payment: the preflight's first reason at its verdict's level (no_go, then caution), as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-safe-fetch",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
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": {
package/paid-guard.js CHANGED
@@ -4,32 +4,29 @@
4
4
  // (a 405, a 500, or another 402 asking to pay again), and the agent pays the same request again, and again. Every
5
5
  // model we examined did it at least once, with or without a preflight: the preflight only sees the 402 before paying.
6
6
  //
7
- // The guard remembers each paid request (method, URL and body) whose paid answer was not a success. Paying that same
8
- // request again within the window is refused, so the loss stops at one payment. A success clears it. A different
9
- // request (another URL, method or body) is not affected. No dependencies: it runs anywhere fetch does.
7
+ // The guard remembers each paid endpoint (method, origin and path) whose paid answer was not a success. Paying that
8
+ // endpoint again within the window is refused, so the loss stops at one payment. The query string and the body are
9
+ // left out on purpose: in the exam, a model that was refused added "&nonce=1", "&nonce=2", ... to the URL and paid
10
+ // eight more times. A success clears it. Another endpoint (another path, method or host) is not affected. No
11
+ // dependencies: it runs anywhere fetch does.
10
12
 
11
13
  export const PAID_ERROR_MS = 30 * 60 * 1000;
12
14
 
13
- // A short, stable fingerprint of the body (FNV-1a, 32 bit): the key holds no request content.
14
- function bodyKey(body) {
15
- if (body === undefined || body === null) return "";
16
- const text = typeof body === "string" ? body : body instanceof URLSearchParams ? body.toString() : ArrayBuffer.isView(body) ? new TextDecoder().decode(body) : body instanceof ArrayBuffer ? new TextDecoder().decode(new Uint8Array(body)) : String(body);
17
- let h = 0x811c9dc5;
18
- for (let i = 0; i < text.length; i++) {
19
- h ^= text.charCodeAt(i);
20
- h = Math.imul(h, 0x01000193) >>> 0;
15
+ /** The endpoint as the guard sees it: method, origin and path (no query, no body, no fragment). */
16
+ export function purchaseKey(url, { method = "GET" } = {}) {
17
+ let endpoint = String(url);
18
+ try {
19
+ const u = new URL(endpoint);
20
+ endpoint = `${u.origin}${u.pathname.replace(/\/+$/, "") || "/"}`;
21
+ } catch {
22
+ endpoint = endpoint.split(/[?#]/)[0];
21
23
  }
22
- return `${text.length}:${h.toString(16)}`;
23
- }
24
-
25
- /** The request as the guard sees it: method, URL and a fingerprint of the body. */
26
- export function purchaseKey(url, { method = "GET", body } = {}) {
27
- return `${String(method).toUpperCase()} ${String(url)} ${bodyKey(body)}`;
24
+ return `${String(method).toUpperCase()} ${endpoint}`;
28
25
  }
29
26
 
30
27
  /**
31
28
  * @param {object} [options]
32
- * @param {number} [options.windowMs] how long a paid request that wasn't delivered stays blocked (default 30 minutes)
29
+ * @param {number} [options.windowMs] how long a paid endpoint that didn't deliver stays blocked (default 30 minutes)
33
30
  * @param {() => number} [options.now]
34
31
  */
35
32
  export function createPaidGuard({ windowMs = PAID_ERROR_MS, now = Date.now } = {}) {
@@ -60,5 +57,5 @@ export function createPaidGuard({ windowMs = PAID_ERROR_MS, now = Date.now } = {
60
57
 
61
58
  /** The refusal text, for an error or a tool answer. */
62
59
  export function paidTwiceMessage(url, earlier) {
63
- return `not paid again: ${url} was already paid at ${new Date(earlier.at).toISOString()} and answered HTTP ${earlier.status} without delivering. Paying the same request again would most likely be charged again for nothing; ask the seller, or try a different request.`;
60
+ return `not paid again: ${purchaseKey(url).replace(/^GET /, "")} was already paid at ${new Date(earlier.at).toISOString()} and answered HTTP ${earlier.status} without delivering. Paying this endpoint again (with any query or body) would most likely be charged again for nothing; ask the seller, or use another seller.`;
64
61
  }