x402-safe-fetch 0.9.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 +3 -1
- package/index.d.ts +4 -1
- package/index.js +33 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -100,7 +100,7 @@ When a seller offers both, the exact option is paid. The preflight and the budge
|
|
|
100
100
|
| `verifyReceipts` | `"require"` | Check Doctor's signed receipt on every preflight; `"off"` skips it |
|
|
101
101
|
| `doctorSigners` | Doctor's published signer | Accepted signer addresses (for a self-hosted Doctor) |
|
|
102
102
|
| `diagnoseOnFailure` | `false` | When a payment still fails, buy a $0.01 Doctor diagnosis of why (see below) |
|
|
103
|
-
| `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) |
|
|
104
104
|
| `onDiagnosis` | | `(report, { url, method, status, error }) => void`, e.g. for logging or alerting |
|
|
105
105
|
| `authority` | the Fizzl payout wallet | Wallet whose certificates also make a signer trusted (key rotation); `null` accepts only `doctorSigners` |
|
|
106
106
|
|
|
@@ -138,6 +138,8 @@ With `shareOutcomes: true`, safe-fetch tells x402 Doctor what happened after it
|
|
|
138
138
|
- A report only counts with a real, paid preflight for that endpoint, once per preflight, so reports cannot be made up for free.
|
|
139
139
|
- It is sent after the answer and never delays or changes it. Off by default.
|
|
140
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
|
+
|
|
141
143
|
## Signed verdicts
|
|
142
144
|
|
|
143
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
|
@@ -68,7 +68,7 @@ export interface SafeFetchOptions {
|
|
|
68
68
|
/** When a payment still fails (402 again, or paying throws), buy a $0.01 Doctor diagnosis of why. Default false. */
|
|
69
69
|
diagnoseOnFailure?: boolean;
|
|
70
70
|
onDiagnosis?: (report: Diagnosis, info: { url: string; method: string; status: number | null; error: Error | null }) => void;
|
|
71
|
-
/** 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. */
|
|
72
72
|
shareOutcomes?: boolean;
|
|
73
73
|
fetch?: typeof globalThis.fetch;
|
|
74
74
|
/** Advanced/testing: a paying fetch capped at `cap` (e.g. "$0.05"). */
|
|
@@ -91,3 +91,6 @@ export declare function recoverSigner(message: string, signature: string): strin
|
|
|
91
91
|
export declare const AUTHORITY: string;
|
|
92
92
|
export declare function certMessage(cert: { service: string; signer: string; valid_from: string }): string;
|
|
93
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.
|
|
19
|
-
//
|
|
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.
|
|
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";
|
|
@@ -124,6 +127,15 @@ export function payableOption(accepts, network, maxUsd) {
|
|
|
124
127
|
return on.find((a) => a?.scheme === "exact") ?? on.find((a) => a?.scheme === "auth-capture") ?? on[0] ?? null;
|
|
125
128
|
}
|
|
126
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";
|
|
137
|
+
}
|
|
138
|
+
|
|
127
139
|
export class SafePayError extends Error {
|
|
128
140
|
/** code: "no_go" | "caution" | "preflight_failed" | "bad_receipt" | "no_option" */
|
|
129
141
|
constructor(message, { code, preflight = null, url = null } = {}) {
|
|
@@ -326,10 +338,8 @@ export function createSafeFetch({
|
|
|
326
338
|
|
|
327
339
|
// One outcome report per paid preflight: Doctor counts it once anyway.
|
|
328
340
|
const reported = new Set();
|
|
329
|
-
function
|
|
341
|
+
function sendOutcome(url, method, preflight, report) {
|
|
330
342
|
if (!shareOutcomes || !preflight?.receipt?.request_id || reported.has(preflight.receipt.request_id)) return;
|
|
331
|
-
const outcome = res.ok ? "paid_ok" : res.status === 402 ? "paid_failed" : res.status >= 400 ? "paid_error" : null;
|
|
332
|
-
if (!outcome) return;
|
|
333
343
|
reported.add(preflight.receipt.request_id);
|
|
334
344
|
if (reported.size > 1000) reported.delete(reported.values().next().value);
|
|
335
345
|
const query = Object.fromEntries(new URL(preflightUrl(url, { method, maxUsd: budget, network: payNetwork, doctorUrl })).searchParams);
|
|
@@ -338,11 +348,19 @@ export function createSafeFetch({
|
|
|
338
348
|
.then(() => baseFetch(`${doctorUrl.replace(/\/$/, "")}/api/v1/outcome`, {
|
|
339
349
|
method: "POST",
|
|
340
350
|
headers: { ...DOCTOR_HEADERS, "content-type": "application/json" },
|
|
341
|
-
body: JSON.stringify({
|
|
351
|
+
body: JSON.stringify({ ...report, preflight, query }),
|
|
342
352
|
}))
|
|
343
353
|
.then((r) => r?.body?.cancel?.())
|
|
344
354
|
.catch(() => {});
|
|
345
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
|
+
}
|
|
346
364
|
|
|
347
365
|
async function payAndCheck(url, init, method) {
|
|
348
366
|
if (!diagnoseOnFailure) return payEndpoint(url, init);
|
|
@@ -386,10 +404,16 @@ export function createSafeFetch({
|
|
|
386
404
|
};
|
|
387
405
|
onPreflight?.(preflight, { url, method, cached });
|
|
388
406
|
const why = preflight.summary || preflight.verdict;
|
|
389
|
-
if (preflight.verdict === "no_go")
|
|
407
|
+
if (preflight.verdict === "no_go") {
|
|
408
|
+
reportRefusal(url, method, preflight);
|
|
409
|
+
throw new SafePayError(`not paid: ${why}`, { code: "no_go", preflight, url });
|
|
410
|
+
}
|
|
390
411
|
if (preflight.verdict === "caution") {
|
|
391
412
|
const ok = onCaution === "pay" || (typeof onCaution === "function" && (await onCaution(preflight)) === true);
|
|
392
|
-
if (!ok)
|
|
413
|
+
if (!ok) {
|
|
414
|
+
reportRefusal(url, method, preflight);
|
|
415
|
+
throw new SafePayError(`not paid (caution): ${why}`, { code: "caution", preflight, url });
|
|
416
|
+
}
|
|
393
417
|
} else if (preflight.verdict !== "go") {
|
|
394
418
|
throw new SafePayError(`not paid: unknown verdict ${preflight.verdict}`, { code: "preflight_failed", preflight, url });
|
|
395
419
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "x402-safe-fetch",
|
|
3
|
-
"version": "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": {
|