x402-safe-fetch 0.16.1 → 0.17.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 +16 -1
- package/cli.js +280 -0
- package/index.d.ts +1 -1
- package/index.js +3 -3
- package/package.json +10 -3
- package/paid-guard.js +16 -19
package/README.md
CHANGED
|
@@ -18,6 +18,19 @@ The preflight reads the endpoint's 402 challenge and checks, among others, the p
|
|
|
18
18
|
npm install x402-safe-fetch @x402/fetch @x402/evm viem
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
## Command line
|
|
22
|
+
|
|
23
|
+
An agent that works in a terminal (or you) can pay for one API call with one command, with the same checks:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npx x402-safe-fetch find "bitcoin price in usd" # free: who sells it, the price, how often it delivered
|
|
27
|
+
npx x402-safe-fetch check <url> # free: the price per network and x402 Doctor's free check; pays nothing
|
|
28
|
+
EVM_PRIVATE_KEY=0x… npx x402-safe-fetch pay <url> --max 0.05 --daily 1
|
|
29
|
+
npx x402-safe-fetch spent # what was paid today, and what is left
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`pay` pays USDC on Base, only when x402 Doctor's preflight says go: the cheapest option within `--max`, all calls of the UTC day together within `--daily` (preflights included), and the paid answer checked for instructions aimed at your agent (`--no-answer-check` turns that off). An endpoint that took a payment without delivering isn't paid again for 30 minutes, also by the next command and whatever is added to its URL. `--method POST --data '{"q":1}'` sends a body, `--dry-run` is `check`, and `--json` prints one line of JSON (with the answer, the transaction hash and the day's spend). Exit code 0 is done, 1 is not paid (a check said no, or a limit), 2 is a usage error. The day's spend is kept in `~/.x402-safe-fetch/state.json` (`X402_SAFE_FETCH_HOME` moves it). Use a dedicated wallet with a few dollars.
|
|
33
|
+
|
|
21
34
|
## Use
|
|
22
35
|
|
|
23
36
|
```js
|
|
@@ -144,7 +157,9 @@ The preflight itself is capped at $0.002 and paid with the same schemes. `receip
|
|
|
144
157
|
|
|
145
158
|
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
159
|
|
|
147
|
-
So safe-fetch remembers each paid
|
|
160
|
+
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.
|
|
161
|
+
|
|
162
|
+
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
163
|
|
|
149
164
|
```js
|
|
150
165
|
const safeFetch = createSafeFetch({
|
package/cli.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// x402-safe-fetch on the command line: an AI agent (or you) pays for one API call with one command, and safe-fetch's
|
|
3
|
+
// rules decide whether it is paid: x402 Doctor's preflight first, the cheapest option within the budget, a budget per
|
|
4
|
+
// call and per day, never paying an endpoint again after it took money without delivering, and the paid answer
|
|
5
|
+
// checked for instructions aimed at the agent.
|
|
6
|
+
//
|
|
7
|
+
// npx x402-safe-fetch find "bitcoin price in usd" free: who sells it, at what price, and whether it delivers
|
|
8
|
+
// npx x402-safe-fetch check <url> free: the price and x402 Doctor's free check; pays nothing
|
|
9
|
+
// EVM_PRIVATE_KEY=0x… npx x402-safe-fetch pay <url> --max 0.05 --daily 1
|
|
10
|
+
// npx x402-safe-fetch spent what was paid today, and what is left
|
|
11
|
+
//
|
|
12
|
+
// Pays USDC on Base from EVM_PRIVATE_KEY (use a dedicated wallet with a few dollars). The day's spend and the
|
|
13
|
+
// endpoints that took money without delivering are kept in ~/.x402-safe-fetch/state.json (X402_SAFE_FETCH_HOME
|
|
14
|
+
// moves it), so the limits and the paid-twice guard hold across commands.
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
createSafeFetch, SafePayError, receiptOf, readOffer, usdOfOption, answerCheckOf, purchaseKey, paidTwiceMessage,
|
|
21
|
+
PAID_ERROR_MS, DOCTOR_URL, VERSION,
|
|
22
|
+
} from "./index.js";
|
|
23
|
+
|
|
24
|
+
export const BUY_URL = "https://buy.fizzl.eu";
|
|
25
|
+
const PREFLIGHT_USD = 0.001;
|
|
26
|
+
|
|
27
|
+
const HELP = `x402-safe-fetch ${VERSION}: pay for an API call from the command line, checked first.
|
|
28
|
+
|
|
29
|
+
find <need> Free. Who sells it (Fizzl Buy's pick): URL, price, and how often it delivered.
|
|
30
|
+
check <url> Free, pays nothing. The price per network and x402 Doctor's free check of the endpoint.
|
|
31
|
+
pay <url> Pays one call in USDC on Base from EVM_PRIVATE_KEY, only if x402 Doctor's preflight says go.
|
|
32
|
+
spent What was paid today and what is left of the day's limit.
|
|
33
|
+
|
|
34
|
+
Options for pay (check takes --method and --data too):
|
|
35
|
+
--max <usd> Most one call may cost (default 0.05).
|
|
36
|
+
--daily <usd> Most all calls together may cost per UTC day, preflights included (default 1).
|
|
37
|
+
--method <GET|POST> HTTP method (default GET, or POST with --data).
|
|
38
|
+
--data <json> Request body (sent as application/json).
|
|
39
|
+
--caution <stop|pay> What to do when the preflight says caution (default stop).
|
|
40
|
+
--dry-run Same as check: show the price and the free check, pay nothing.
|
|
41
|
+
--no-answer-check Don't check the paid answer for instructions aimed at your agent.
|
|
42
|
+
--json One line of JSON instead of text.
|
|
43
|
+
|
|
44
|
+
Exit codes: 0 done, 1 not paid (a check said no, or a limit), 2 usage or setup error.`;
|
|
45
|
+
|
|
46
|
+
export function parseArgs(argv) {
|
|
47
|
+
const out = { _: [], flags: {} };
|
|
48
|
+
const bool = new Set(["dry-run", "json", "no-answer-check", "help"]);
|
|
49
|
+
for (let i = 0; i < argv.length; i++) {
|
|
50
|
+
const a = argv[i];
|
|
51
|
+
if (a === "-h") out.flags.help = true;
|
|
52
|
+
else if (a.startsWith("--")) {
|
|
53
|
+
const [k, v] = a.slice(2).split(/=(.*)/s);
|
|
54
|
+
if (bool.has(k)) out.flags[k] = true;
|
|
55
|
+
else out.flags[k] = v !== undefined ? v : argv[++i];
|
|
56
|
+
} else out._.push(a);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function money(v, name) {
|
|
62
|
+
if (v === undefined) return undefined;
|
|
63
|
+
const n = Number(String(v).replace(/^\$/, ""));
|
|
64
|
+
if (!Number.isFinite(n) || n <= 0) throw new UsageError(`--${name} must be a positive dollar amount, like 0.05`);
|
|
65
|
+
return n;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class UsageError extends Error {}
|
|
69
|
+
|
|
70
|
+
// The state kept between commands: today's spend and the endpoints that took money without delivering.
|
|
71
|
+
export function stateStore({ home = process.env.X402_SAFE_FETCH_HOME || path.join(os.homedir(), ".x402-safe-fetch"), now = Date.now } = {}) {
|
|
72
|
+
const file = path.join(home, "state.json");
|
|
73
|
+
const today = () => new Date(now()).toISOString().slice(0, 10);
|
|
74
|
+
function read() {
|
|
75
|
+
let s = {};
|
|
76
|
+
try { s = JSON.parse(fs.readFileSync(file, "utf8")); } catch { /* none yet */ }
|
|
77
|
+
if (s.day !== today()) s = { day: today(), spentUsd: 0, calls: 0, failed: s.failed || {} };
|
|
78
|
+
for (const [k, v] of Object.entries(s.failed || {})) if (now() - v.at >= PAID_ERROR_MS) delete s.failed[k];
|
|
79
|
+
s.failed ||= {};
|
|
80
|
+
return s;
|
|
81
|
+
}
|
|
82
|
+
function write(s) {
|
|
83
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
84
|
+
fs.writeFileSync(file, JSON.stringify(s, null, 2), { mode: 0o600 });
|
|
85
|
+
}
|
|
86
|
+
return { file, read, write, now };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function requestInit(flags) {
|
|
90
|
+
const data = flags.data;
|
|
91
|
+
const method = String(flags.method || (data !== undefined ? "POST" : "GET")).toUpperCase();
|
|
92
|
+
const init = { method };
|
|
93
|
+
if (data !== undefined) {
|
|
94
|
+
try { JSON.parse(data); } catch { throw new UsageError("--data must be JSON"); }
|
|
95
|
+
init.body = data;
|
|
96
|
+
init.headers = { "content-type": "application/json" };
|
|
97
|
+
}
|
|
98
|
+
return init;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function targetUrl(args) {
|
|
102
|
+
const url = args._[1];
|
|
103
|
+
if (!url || !/^https?:\/\//.test(url)) throw new UsageError("give the endpoint's URL, starting with https://");
|
|
104
|
+
return url;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function find(args, { fetch }) {
|
|
108
|
+
const need = args._.slice(1).join(" ").trim();
|
|
109
|
+
if (!need) throw new UsageError('say what you need, like: find "bitcoin price in usd"');
|
|
110
|
+
const res = await fetch(`${BUY_URL}/v1/pick?need=${encodeURIComponent(need)}`, { headers: { "user-agent": `x402-safe-fetch-cli/${VERSION}` } });
|
|
111
|
+
const body = await res.json().catch(() => null);
|
|
112
|
+
if (!res.ok || !body) return { ok: false, code: "pick_failed", message: `Fizzl Buy answered HTTP ${res.status}` };
|
|
113
|
+
const row = (p) => p && { url: p.url, method: p.method, price_usd: p.price_usd, description: p.description, delivered: p.record ? `${p.record.payableDays} of ${p.record.days} days` : null, example: p.example, example_in: p.example_in };
|
|
114
|
+
return { ok: Boolean(body.pick), need, pick: row(body.pick), alternatives: (body.alternatives || []).map(row), ...(body.pick ? {} : { message: "no seller found for that" }) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function check(args, { fetch }) {
|
|
118
|
+
const url = targetUrl(args);
|
|
119
|
+
const init = requestInit(args.flags);
|
|
120
|
+
const res = await fetch(url, init);
|
|
121
|
+
if (res.status !== 402) return { ok: true, url, status: res.status, paid: false, message: `HTTP ${res.status}: not a paid endpoint (nothing to pay)` };
|
|
122
|
+
const { accepts } = await readOffer(res);
|
|
123
|
+
const options = (accepts || []).map((o) => ({ network: o.network, scheme: o.scheme, price_usd: usdOfOption(o, o.network), pay_to: o.payTo }));
|
|
124
|
+
let doctor = null;
|
|
125
|
+
try {
|
|
126
|
+
const d = await fetch(`${DOCTOR_URL}/api/diagnose`, { method: "POST", headers: { "content-type": "application/json", "user-agent": `x402-safe-fetch-cli/${VERSION}` }, body: JSON.stringify({ url, method: init.method }) });
|
|
127
|
+
const j = await d.json();
|
|
128
|
+
if (d.ok && j?.overall) doctor = { overall: j.overall, problems: (j.checks || []).filter((c) => c.status === "fail" || c.status === "warn").map((c) => ({ status: c.status, id: c.id, message: c.message })), report: j.share_url || null };
|
|
129
|
+
else doctor = { overall: "unavailable", message: j?.error || `HTTP ${d.status}` };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
doctor = { overall: "unavailable", message: err.message };
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, url, status: 402, paid: false, options, doctor };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function pay(args, { fetch, env, store, createPayingFetch, safeFetchOptions = {} }) {
|
|
137
|
+
if (args.flags["dry-run"]) return check(args, { fetch });
|
|
138
|
+
const url = targetUrl(args);
|
|
139
|
+
const init = requestInit(args.flags);
|
|
140
|
+
const maxUsd = money(args.flags.max, "max") ?? 0.05;
|
|
141
|
+
const daily = money(args.flags.daily, "daily") ?? 1;
|
|
142
|
+
const caution = args.flags.caution ?? "stop";
|
|
143
|
+
if (!["stop", "pay"].includes(caution)) throw new UsageError("--caution is stop or pay");
|
|
144
|
+
|
|
145
|
+
const state = store.read();
|
|
146
|
+
const left = Math.round((daily - state.spentUsd) * 1e6) / 1e6;
|
|
147
|
+
if (left <= PREFLIGHT_USD) return { ok: false, url, code: "over_daily_limit", message: `not paid: today's limit of $${daily} is spent ($${state.spentUsd} paid)`, spent_today_usd: state.spentUsd };
|
|
148
|
+
const key = purchaseKey(url, init);
|
|
149
|
+
const earlier = state.failed[key];
|
|
150
|
+
if (earlier) return { ok: false, url, code: "paid_no_delivery", message: paidTwiceMessage(url, earlier), earlier };
|
|
151
|
+
|
|
152
|
+
let register;
|
|
153
|
+
if (!createPayingFetch) {
|
|
154
|
+
const raw = String(env.EVM_PRIVATE_KEY || "").trim();
|
|
155
|
+
if (!raw) throw new UsageError("set EVM_PRIVATE_KEY: a dedicated wallet with a few dollars of USDC on Base");
|
|
156
|
+
let ExactEvmScheme, privateKeyToAccount;
|
|
157
|
+
try {
|
|
158
|
+
({ ExactEvmScheme } = await import("@x402/evm/exact/client"));
|
|
159
|
+
({ privateKeyToAccount } = await import("viem/accounts"));
|
|
160
|
+
} catch {
|
|
161
|
+
throw new UsageError("paying needs @x402/evm and viem: npx -p x402-safe-fetch -p @x402/evm -p viem x402-safe-fetch pay <url>");
|
|
162
|
+
}
|
|
163
|
+
const account = privateKeyToAccount(raw.startsWith("0x") ? raw : `0x${raw}`);
|
|
164
|
+
register = (client) => client.register("eip155:8453", new ExactEvmScheme(account));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let preflights = 0;
|
|
168
|
+
let preflight = null;
|
|
169
|
+
const safeFetch = createSafeFetch({
|
|
170
|
+
register, createPayingFetch, fetch,
|
|
171
|
+
maxUsd, maxTotalUsd: Math.round((left - PREFLIGHT_USD) * 1e6) / 1e6, // the day's limit, less this call's preflight
|
|
172
|
+
onCaution: caution,
|
|
173
|
+
checkAnswers: args.flags["no-answer-check"] ? false : "quick",
|
|
174
|
+
onPreflight: (p, { cached }) => { preflight = p; if (!cached) preflights++; },
|
|
175
|
+
...safeFetchOptions,
|
|
176
|
+
});
|
|
177
|
+
// What one payment to the endpoint cost, as the preflight read it from the 402 (else the most it may cost).
|
|
178
|
+
const priceOf = () => {
|
|
179
|
+
const o = preflight?.options?.[preflight?.recommended_option];
|
|
180
|
+
return typeof o?.usd === "number" ? o.usd : maxUsd;
|
|
181
|
+
};
|
|
182
|
+
const settle = (extra) => {
|
|
183
|
+
// A seller that answers 402 again after taking the payment shows as "nothing paid" to safe-fetch's budget, which
|
|
184
|
+
// can't tell; for the day's limit it counts as paid, so a loop of such calls can't run past it.
|
|
185
|
+
const endpointUsd = extra?.failed && safeFetch.spent().totalUsd === 0 ? priceOf() : safeFetch.spent().totalUsd;
|
|
186
|
+
const paidUsd = Math.round((endpointUsd + preflights * PREFLIGHT_USD) * 1e6) / 1e6;
|
|
187
|
+
state.spentUsd = Math.round((state.spentUsd + paidUsd) * 1e6) / 1e6;
|
|
188
|
+
if (paidUsd > 0) state.calls = (state.calls || 0) + 1;
|
|
189
|
+
if (extra?.failed) state.failed[key] = { at: store.now(), status: extra.failed };
|
|
190
|
+
if (extra?.delivered) delete state.failed[key];
|
|
191
|
+
store.write(state);
|
|
192
|
+
return { paid_usd: paidUsd, spent_today_usd: state.spentUsd, daily_limit_usd: daily };
|
|
193
|
+
};
|
|
194
|
+
const verdict = () => preflight && { verdict: preflight.verdict, summary: preflight.summary };
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const res = await safeFetch(url, init);
|
|
198
|
+
const text = await res.text();
|
|
199
|
+
const receipt = receiptOf(res);
|
|
200
|
+
// A response after a preflight means the endpoint was paid (or a payment was sent): a 2xx clears it, anything else
|
|
201
|
+
// blocks this endpoint for 30 minutes, also for the next command.
|
|
202
|
+
const attempted = preflight !== null;
|
|
203
|
+
const delivered = res.status >= 200 && res.status < 300;
|
|
204
|
+
const totals = settle(attempted ? (delivered ? { delivered: true } : { failed: res.status }) : null);
|
|
205
|
+
let body = text;
|
|
206
|
+
try { body = JSON.parse(text); } catch { /* text */ }
|
|
207
|
+
return {
|
|
208
|
+
ok: delivered, url, status: res.status, ...totals, preflight: verdict(),
|
|
209
|
+
transaction: receipt?.transaction || null, network: receipt?.network || null,
|
|
210
|
+
answer_check: answerCheckOf(res), body,
|
|
211
|
+
...(delivered ? {} : { code: "not_delivered", message: `paid, but the endpoint answered HTTP ${res.status}; it won't be paid again for 30 minutes` }),
|
|
212
|
+
};
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (!(err instanceof SafePayError)) throw err;
|
|
215
|
+
const totals = settle(null);
|
|
216
|
+
return { ok: false, url, code: err.code, message: err.message, preflight: verdict() ?? (err.preflight ? { verdict: err.preflight.verdict, summary: err.preflight.summary } : null), reasons: (err.preflight?.reasons || []).filter((r) => r.level !== "info").map((r) => ({ level: r.level, code: r.code, message: r.message })), ...totals };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function spent(args, { store }) {
|
|
221
|
+
const s = store.read();
|
|
222
|
+
const daily = money(args.flags.daily, "daily") ?? 1;
|
|
223
|
+
return { ok: true, day: s.day, spent_today_usd: s.spentUsd, calls: s.calls || 0, daily_limit_usd: daily, left_usd: Math.max(0, Math.round((daily - s.spentUsd) * 1e6) / 1e6), blocked_endpoints: Object.keys(s.failed).map((k) => k.replace(/^GET /, "")), state: store.file };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function text(cmd, r) {
|
|
227
|
+
const lines = [];
|
|
228
|
+
if (cmd === "find") {
|
|
229
|
+
if (!r.pick) return r.message || "Nothing found.";
|
|
230
|
+
const p = r.pick;
|
|
231
|
+
lines.push(`${p.method} ${p.url}`, ` $${p.price_usd} per call · delivered ${p.delivered ?? "?"} · ${p.description || ""}`.trimEnd());
|
|
232
|
+
if (r.alternatives?.length) lines.push("Also:", ...r.alternatives.map((a) => ` $${a.price_usd} ${a.method} ${a.url}`));
|
|
233
|
+
lines.push(`Pay it: npx x402-safe-fetch pay "${p.url}"`);
|
|
234
|
+
} else if (cmd === "check") {
|
|
235
|
+
lines.push(r.message || `HTTP 402 at ${r.url}`);
|
|
236
|
+
for (const o of r.options || []) lines.push(` ${o.network}: ${o.price_usd === null ? "?" : `$${o.price_usd}`} to ${o.pay_to}`);
|
|
237
|
+
if (r.doctor) {
|
|
238
|
+
lines.push(`x402 Doctor's free check: ${String(r.doctor.overall).toUpperCase()}${r.doctor.message ? ` (${r.doctor.message})` : ""}`);
|
|
239
|
+
for (const p of (r.doctor.problems || []).slice(0, 6)) lines.push(` ${p.status}: ${p.message}`);
|
|
240
|
+
if (r.doctor.report) lines.push(` ${r.doctor.report}`);
|
|
241
|
+
}
|
|
242
|
+
if (r.status === 402) lines.push("Nothing was paid. The paid preflight (go / caution / no-go, $0.001) runs with pay.");
|
|
243
|
+
} else if (cmd === "pay") {
|
|
244
|
+
if (r.preflight) lines.push(`x402 Doctor: ${String(r.preflight.verdict).toUpperCase()}: ${r.preflight.summary || ""}`.trimEnd());
|
|
245
|
+
if (r.ok) {
|
|
246
|
+
lines.push(`Paid $${r.paid_usd} (preflight included)${r.transaction ? ` · tx ${r.transaction}` : ""}`);
|
|
247
|
+
if (r.answer_check) lines.push(`Answer check: ${r.answer_check}`);
|
|
248
|
+
lines.push(typeof r.body === "string" ? r.body : JSON.stringify(r.body, null, 2));
|
|
249
|
+
} else {
|
|
250
|
+
lines.push(`Not done (${r.code}): ${r.message}`);
|
|
251
|
+
for (const x of r.reasons || []) lines.push(` ${x.level}: ${x.message}`);
|
|
252
|
+
if (r.status && r.body !== undefined) lines.push(typeof r.body === "string" ? r.body.slice(0, 600) : JSON.stringify(r.body).slice(0, 600));
|
|
253
|
+
}
|
|
254
|
+
if (r.spent_today_usd !== undefined) lines.push(`Today: $${r.spent_today_usd} of $${r.daily_limit_usd}`);
|
|
255
|
+
} else if (cmd === "spent") {
|
|
256
|
+
lines.push(`${r.day}: $${r.spent_today_usd} paid in ${r.calls} call(s), $${r.left_usd} left of $${r.daily_limit_usd}`);
|
|
257
|
+
if (r.blocked_endpoints.length) lines.push("Not paid again for now (took money without delivering):", ...r.blocked_endpoints.map((b) => ` ${b}`));
|
|
258
|
+
}
|
|
259
|
+
return lines.join("\n");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function main(argv, { fetch = globalThis.fetch, env = process.env, store = stateStore(), createPayingFetch, safeFetchOptions, out = console.log, err = console.error } = {}) {
|
|
263
|
+
const args = parseArgs(argv);
|
|
264
|
+
const cmd = args._[0];
|
|
265
|
+
if (!cmd || args.flags.help || cmd === "help") { out(HELP); return cmd || args.flags.help ? 0 : 2; }
|
|
266
|
+
const commands = { find, check, pay, spent };
|
|
267
|
+
if (!commands[cmd]) { err(`unknown command "${cmd}"\n\n${HELP}`); return 2; }
|
|
268
|
+
try {
|
|
269
|
+
const r = await commands[cmd](args, { fetch, env, store, createPayingFetch, safeFetchOptions });
|
|
270
|
+
out(args.flags.json ? JSON.stringify(r) : text(cmd === "pay" && args.flags["dry-run"] ? "check" : cmd, r));
|
|
271
|
+
return r.ok ? 0 : 1;
|
|
272
|
+
} catch (e) {
|
|
273
|
+
if (e instanceof UsageError) { err(e.message); return 2; }
|
|
274
|
+
err(`error: ${e.message}`);
|
|
275
|
+
return 2;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const invoked = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(new URL(import.meta.url).pathname);
|
|
280
|
+
if (invoked) main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
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
|
|
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,
|
|
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.
|
|
54
|
+
export const VERSION = "0.17.0";
|
|
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";
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "x402-safe-fetch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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
|
+
"bin": {
|
|
7
|
+
"x402-safe-fetch": "cli.js"
|
|
8
|
+
},
|
|
6
9
|
"exports": {
|
|
7
10
|
".": {
|
|
8
11
|
"types": "./index.d.ts",
|
|
@@ -17,6 +20,7 @@
|
|
|
17
20
|
"types": "./index.d.ts",
|
|
18
21
|
"files": [
|
|
19
22
|
"index.js",
|
|
23
|
+
"cli.js",
|
|
20
24
|
"receipt.js",
|
|
21
25
|
"paid-guard.js",
|
|
22
26
|
"hook.js",
|
|
@@ -32,7 +36,9 @@
|
|
|
32
36
|
"test": "node --test test/*.test.mjs"
|
|
33
37
|
},
|
|
34
38
|
"peerDependencies": {
|
|
35
|
-
"@x402/fetch": "^2.27.0"
|
|
39
|
+
"@x402/fetch": "^2.27.0",
|
|
40
|
+
"@x402/evm": "^2.27.0",
|
|
41
|
+
"viem": "^2.0.0"
|
|
36
42
|
},
|
|
37
43
|
"devDependencies": {
|
|
38
44
|
"@algorandfoundation/algokit-utils": "^10.0.0-alpha.46",
|
|
@@ -56,7 +62,8 @@
|
|
|
56
62
|
"xrpl",
|
|
57
63
|
"rlusd",
|
|
58
64
|
"signed-receipts",
|
|
59
|
-
"stellar"
|
|
65
|
+
"stellar",
|
|
66
|
+
"cli"
|
|
60
67
|
],
|
|
61
68
|
"repository": {
|
|
62
69
|
"type": "git",
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
|
|
14
|
-
function
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 `${
|
|
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
|
|
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
|
|
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
|
}
|