x402-safe-fetch 0.16.2 → 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.
Files changed (4) hide show
  1. package/README.md +13 -0
  2. package/cli.js +280 -0
  3. package/index.js +1 -1
  4. package/package.json +10 -3
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
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.js CHANGED
@@ -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.1";
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.16.2",
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",