x402-safe-fetch 0.16.2 → 0.18.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 -0
- package/cli.js +333 -0
- package/index.js +1 -1
- package/package.json +11 -3
- package/wallet.js +137 -0
package/README.md
CHANGED
|
@@ -18,6 +18,22 @@ 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 connect # once: a wallet for this computer, linked to your Fizzl wallet
|
|
27
|
+
npx x402-safe-fetch find "bitcoin price in usd" # free: who sells it, the price, how often it delivered
|
|
28
|
+
npx x402-safe-fetch check <url> # free: the price per network and x402 Doctor's free check; pays nothing
|
|
29
|
+
npx x402-safe-fetch pay <url> --max 0.05 --daily 1
|
|
30
|
+
npx x402-safe-fetch spent # what was paid today, and what is left
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**No key to copy.** `connect` makes a wallet for this computer (its key stays in `~/.x402-safe-fetch/wallet.json`, readable by you only) and shows a link and a code: open it on your phone, sign in to [wallet.fizzl.eu](https://wallet.fizzl.eu) and approve. From then on every payment is first reserved on your Fizzl wallet: your limits there apply, and above them you get Approve / Deny on your phone (Telegram or the dashboard) before anything is signed; the key never leaves this computer. Send a few dollars of USDC on Base to the address it shows (`wallet` shows it again, with the balance). `init` makes the wallet without linking it, and `EVM_PRIVATE_KEY` still overrides both.
|
|
34
|
+
|
|
35
|
+
`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.
|
|
36
|
+
|
|
21
37
|
## Use
|
|
22
38
|
|
|
23
39
|
```js
|
package/cli.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
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
|
+
// npx x402-safe-fetch connect a wallet for this computer, linked to your Fizzl wallet
|
|
10
|
+
// npx x402-safe-fetch pay <url> --max 0.05 --daily 1
|
|
11
|
+
// npx x402-safe-fetch spent what was paid today, and what is left
|
|
12
|
+
//
|
|
13
|
+
// Pays USDC on Base from this computer's own wallet (init or connect make it; EVM_PRIVATE_KEY overrides it). Linked
|
|
14
|
+
// to a Fizzl Agent Wallet (connect), the owner's limits and approvals apply to every payment (wallet.js). The day's spend and the
|
|
15
|
+
// endpoints that took money without delivering are kept in ~/.x402-safe-fetch/state.json (X402_SAFE_FETCH_HOME
|
|
16
|
+
// moves it), so the limits and the paid-twice guard hold across commands.
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { ensureWallet, readWallet, writeWallet, pair, serverSigner, usdcBalance, WALLET_URL } from "./wallet.js";
|
|
22
|
+
import {
|
|
23
|
+
createSafeFetch, SafePayError, receiptOf, readOffer, usdOfOption, answerCheckOf, purchaseKey, paidTwiceMessage,
|
|
24
|
+
PAID_ERROR_MS, DOCTOR_URL, VERSION,
|
|
25
|
+
} from "./index.js";
|
|
26
|
+
|
|
27
|
+
export const BUY_URL = "https://buy.fizzl.eu";
|
|
28
|
+
const PREFLIGHT_USD = 0.001;
|
|
29
|
+
|
|
30
|
+
const HELP = `x402-safe-fetch ${VERSION}: pay for an API call from the command line, checked first.
|
|
31
|
+
|
|
32
|
+
connect Start here. Makes a wallet for this computer and links it to your Fizzl wallet
|
|
33
|
+
(wallet.fizzl.eu): approve the code on your phone. Your limits and approvals then apply.
|
|
34
|
+
init Only the wallet for this computer, not linked: shows the address to send USDC on Base to.
|
|
35
|
+
wallet This computer's wallet: address, USDC balance, and whether it is linked.
|
|
36
|
+
find <need> Free. Who sells it (Fizzl Buy's pick): URL, price, and how often it delivered.
|
|
37
|
+
check <url> Free, pays nothing. The price per network and x402 Doctor's free check of the endpoint.
|
|
38
|
+
pay <url> Pays one call in USDC on Base, only if x402 Doctor's preflight says go.
|
|
39
|
+
spent What was paid today and what is left of the day's limit.
|
|
40
|
+
|
|
41
|
+
Options for pay (check takes --method and --data too):
|
|
42
|
+
--max <usd> Most one call may cost (default 0.05).
|
|
43
|
+
--daily <usd> Most all calls together may cost per UTC day, preflights included (default 1).
|
|
44
|
+
--method <GET|POST> HTTP method (default GET, or POST with --data).
|
|
45
|
+
--data <json> Request body (sent as application/json).
|
|
46
|
+
--caution <stop|pay> What to do when the preflight says caution (default stop).
|
|
47
|
+
--dry-run Same as check: show the price and the free check, pay nothing.
|
|
48
|
+
--no-answer-check Don't check the paid answer for instructions aimed at your agent.
|
|
49
|
+
--json One line of JSON instead of text.
|
|
50
|
+
|
|
51
|
+
Exit codes: 0 done, 1 not paid (a check said no, or a limit), 2 usage or setup error.`;
|
|
52
|
+
|
|
53
|
+
export function parseArgs(argv) {
|
|
54
|
+
const out = { _: [], flags: {} };
|
|
55
|
+
const bool = new Set(["dry-run", "json", "no-answer-check", "help"]);
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const a = argv[i];
|
|
58
|
+
if (a === "-h") out.flags.help = true;
|
|
59
|
+
else if (a.startsWith("--")) {
|
|
60
|
+
const [k, v] = a.slice(2).split(/=(.*)/s);
|
|
61
|
+
if (bool.has(k)) out.flags[k] = true;
|
|
62
|
+
else out.flags[k] = v !== undefined ? v : argv[++i];
|
|
63
|
+
} else out._.push(a);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function money(v, name) {
|
|
69
|
+
if (v === undefined) return undefined;
|
|
70
|
+
const n = Number(String(v).replace(/^\$/, ""));
|
|
71
|
+
if (!Number.isFinite(n) || n <= 0) throw new UsageError(`--${name} must be a positive dollar amount, like 0.05`);
|
|
72
|
+
return n;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class UsageError extends Error {}
|
|
76
|
+
|
|
77
|
+
// The state kept between commands: today's spend and the endpoints that took money without delivering.
|
|
78
|
+
export function stateStore({ home = process.env.X402_SAFE_FETCH_HOME || path.join(os.homedir(), ".x402-safe-fetch"), now = Date.now } = {}) {
|
|
79
|
+
const file = path.join(home, "state.json");
|
|
80
|
+
const today = () => new Date(now()).toISOString().slice(0, 10);
|
|
81
|
+
function read() {
|
|
82
|
+
let s = {};
|
|
83
|
+
try { s = JSON.parse(fs.readFileSync(file, "utf8")); } catch { /* none yet */ }
|
|
84
|
+
if (s.day !== today()) s = { day: today(), spentUsd: 0, calls: 0, failed: s.failed || {} };
|
|
85
|
+
for (const [k, v] of Object.entries(s.failed || {})) if (now() - v.at >= PAID_ERROR_MS) delete s.failed[k];
|
|
86
|
+
s.failed ||= {};
|
|
87
|
+
return s;
|
|
88
|
+
}
|
|
89
|
+
function write(s) {
|
|
90
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
91
|
+
fs.writeFileSync(file, JSON.stringify(s, null, 2), { mode: 0o600 });
|
|
92
|
+
}
|
|
93
|
+
return { home, file, read, write, now };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requestInit(flags) {
|
|
97
|
+
const data = flags.data;
|
|
98
|
+
const method = String(flags.method || (data !== undefined ? "POST" : "GET")).toUpperCase();
|
|
99
|
+
const init = { method };
|
|
100
|
+
if (data !== undefined) {
|
|
101
|
+
try { JSON.parse(data); } catch { throw new UsageError("--data must be JSON"); }
|
|
102
|
+
init.body = data;
|
|
103
|
+
init.headers = { "content-type": "application/json" };
|
|
104
|
+
}
|
|
105
|
+
return init;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function targetUrl(args) {
|
|
109
|
+
const url = args._[1];
|
|
110
|
+
if (!url || !/^https?:\/\//.test(url)) throw new UsageError("give the endpoint's URL, starting with https://");
|
|
111
|
+
return url;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function find(args, { fetch }) {
|
|
115
|
+
const need = args._.slice(1).join(" ").trim();
|
|
116
|
+
if (!need) throw new UsageError('say what you need, like: find "bitcoin price in usd"');
|
|
117
|
+
const res = await fetch(`${BUY_URL}/v1/pick?need=${encodeURIComponent(need)}`, { headers: { "user-agent": `x402-safe-fetch-cli/${VERSION}` } });
|
|
118
|
+
const body = await res.json().catch(() => null);
|
|
119
|
+
if (!res.ok || !body) return { ok: false, code: "pick_failed", message: `Fizzl Buy answered HTTP ${res.status}` };
|
|
120
|
+
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 };
|
|
121
|
+
return { ok: Boolean(body.pick), need, pick: row(body.pick), alternatives: (body.alternatives || []).map(row), ...(body.pick ? {} : { message: "no seller found for that" }) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function check(args, { fetch }) {
|
|
125
|
+
const url = targetUrl(args);
|
|
126
|
+
const init = requestInit(args.flags);
|
|
127
|
+
const res = await fetch(url, init);
|
|
128
|
+
if (res.status !== 402) return { ok: true, url, status: res.status, paid: false, message: `HTTP ${res.status}: not a paid endpoint (nothing to pay)` };
|
|
129
|
+
const { accepts } = await readOffer(res);
|
|
130
|
+
const options = (accepts || []).map((o) => ({ network: o.network, scheme: o.scheme, price_usd: usdOfOption(o, o.network), pay_to: o.payTo }));
|
|
131
|
+
let doctor = null;
|
|
132
|
+
try {
|
|
133
|
+
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 }) });
|
|
134
|
+
const j = await d.json();
|
|
135
|
+
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 };
|
|
136
|
+
else doctor = { overall: "unavailable", message: j?.error || `HTTP ${d.status}` };
|
|
137
|
+
} catch (err) {
|
|
138
|
+
doctor = { overall: "unavailable", message: err.message };
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, url, status: 402, paid: false, options, doctor };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function pay(args, { fetch, env, store, createPayingFetch, safeFetchOptions = {}, say = () => {} }) {
|
|
144
|
+
if (args.flags["dry-run"]) return check(args, { fetch });
|
|
145
|
+
const url = targetUrl(args);
|
|
146
|
+
const init = requestInit(args.flags);
|
|
147
|
+
const maxUsd = money(args.flags.max, "max") ?? 0.05;
|
|
148
|
+
const daily = money(args.flags.daily, "daily") ?? 1;
|
|
149
|
+
const caution = args.flags.caution ?? "stop";
|
|
150
|
+
if (!["stop", "pay"].includes(caution)) throw new UsageError("--caution is stop or pay");
|
|
151
|
+
|
|
152
|
+
const state = store.read();
|
|
153
|
+
const left = Math.round((daily - state.spentUsd) * 1e6) / 1e6;
|
|
154
|
+
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 };
|
|
155
|
+
const key = purchaseKey(url, init);
|
|
156
|
+
const earlier = state.failed[key];
|
|
157
|
+
if (earlier) return { ok: false, url, code: "paid_no_delivery", message: paidTwiceMessage(url, earlier), earlier };
|
|
158
|
+
|
|
159
|
+
let register;
|
|
160
|
+
let linked = null; // the Fizzl wallet's signer, when this computer's wallet is linked
|
|
161
|
+
if (!createPayingFetch) {
|
|
162
|
+
const envKey = String(env.EVM_PRIVATE_KEY || "").trim();
|
|
163
|
+
const own = envKey ? null : readWallet(store.home);
|
|
164
|
+
const raw = envKey || own?.privateKey;
|
|
165
|
+
if (!raw) throw new UsageError("no wallet yet: run npx x402-safe-fetch connect (or init), or set EVM_PRIVATE_KEY");
|
|
166
|
+
let ExactEvmScheme, privateKeyToAccount;
|
|
167
|
+
try {
|
|
168
|
+
({ ExactEvmScheme } = await import("@x402/evm/exact/client"));
|
|
169
|
+
({ privateKeyToAccount } = await import("viem/accounts"));
|
|
170
|
+
} catch {
|
|
171
|
+
throw new UsageError("paying needs @x402/evm and viem: npx -p x402-safe-fetch -p @x402/evm -p viem x402-safe-fetch pay <url>");
|
|
172
|
+
}
|
|
173
|
+
const account = privateKeyToAccount(raw.startsWith("0x") ? raw : `0x${raw}`);
|
|
174
|
+
if (own?.server?.key) {
|
|
175
|
+
linked = serverSigner({ account, server: own.server, fetch, purchase: { url, description: "x402-safe-fetch pay" }, onWait: (summary) => say(`Waiting for your approval (${summary}): check Telegram or ${own.server.url}`) });
|
|
176
|
+
}
|
|
177
|
+
register = (client) => client.register("eip155:8453", new ExactEvmScheme(linked ?? account));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let preflights = 0;
|
|
181
|
+
let preflight = null;
|
|
182
|
+
const safeFetch = createSafeFetch({
|
|
183
|
+
register, createPayingFetch, fetch,
|
|
184
|
+
maxUsd, maxTotalUsd: Math.round((left - PREFLIGHT_USD) * 1e6) / 1e6, // the day's limit, less this call's preflight
|
|
185
|
+
onCaution: caution,
|
|
186
|
+
checkAnswers: args.flags["no-answer-check"] ? false : "quick",
|
|
187
|
+
onPreflight: (p, { cached }) => { preflight = p; if (!cached) preflights++; },
|
|
188
|
+
...safeFetchOptions,
|
|
189
|
+
});
|
|
190
|
+
// What one payment to the endpoint cost, as the preflight read it from the 402 (else the most it may cost).
|
|
191
|
+
const priceOf = () => {
|
|
192
|
+
const o = preflight?.options?.[preflight?.recommended_option];
|
|
193
|
+
return typeof o?.usd === "number" ? o.usd : maxUsd;
|
|
194
|
+
};
|
|
195
|
+
const settle = (extra) => {
|
|
196
|
+
// A seller that answers 402 again after taking the payment shows as "nothing paid" to safe-fetch's budget, which
|
|
197
|
+
// can't tell; for the day's limit it counts as paid, so a loop of such calls can't run past it.
|
|
198
|
+
const endpointUsd = extra?.failed && safeFetch.spent().totalUsd === 0 ? priceOf() : safeFetch.spent().totalUsd;
|
|
199
|
+
const paidUsd = Math.round((endpointUsd + preflights * PREFLIGHT_USD) * 1e6) / 1e6;
|
|
200
|
+
state.spentUsd = Math.round((state.spentUsd + paidUsd) * 1e6) / 1e6;
|
|
201
|
+
if (paidUsd > 0) state.calls = (state.calls || 0) + 1;
|
|
202
|
+
if (extra?.failed) state.failed[key] = { at: store.now(), status: extra.failed };
|
|
203
|
+
if (extra?.delivered) delete state.failed[key];
|
|
204
|
+
store.write(state);
|
|
205
|
+
return { paid_usd: paidUsd, spent_today_usd: state.spentUsd, daily_limit_usd: daily };
|
|
206
|
+
};
|
|
207
|
+
const verdict = () => preflight && { verdict: preflight.verdict, summary: preflight.summary };
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const res = await safeFetch(url, init).finally(() => linked?.finish("x402-safe-fetch"));
|
|
211
|
+
const text = await res.text();
|
|
212
|
+
const receipt = receiptOf(res);
|
|
213
|
+
// A response after a preflight means the endpoint was paid (or a payment was sent): a 2xx clears it, anything else
|
|
214
|
+
// blocks this endpoint for 30 minutes, also for the next command.
|
|
215
|
+
const attempted = preflight !== null;
|
|
216
|
+
const delivered = res.status >= 200 && res.status < 300;
|
|
217
|
+
const totals = settle(attempted ? (delivered ? { delivered: true } : { failed: res.status }) : null);
|
|
218
|
+
let body = text;
|
|
219
|
+
try { body = JSON.parse(text); } catch { /* text */ }
|
|
220
|
+
return {
|
|
221
|
+
ok: delivered, url, status: res.status, ...totals, preflight: verdict(),
|
|
222
|
+
transaction: receipt?.transaction || null, network: receipt?.network || null,
|
|
223
|
+
answer_check: answerCheckOf(res), body,
|
|
224
|
+
...(delivered ? {} : { code: "not_delivered", message: `paid, but the endpoint answered HTTP ${res.status}; it won't be paid again for 30 minutes` }),
|
|
225
|
+
};
|
|
226
|
+
} catch (err) {
|
|
227
|
+
const refused = linked?.state.denied;
|
|
228
|
+
if (refused) {
|
|
229
|
+
const totals = settle(null);
|
|
230
|
+
return { ok: false, url, code: refused.code, message: `not paid: your Fizzl wallet says no: ${refused.summary}`, preflight: verdict(), ...totals };
|
|
231
|
+
}
|
|
232
|
+
if (!(err instanceof SafePayError)) throw err;
|
|
233
|
+
const totals = settle(null);
|
|
234
|
+
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 };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function init(args, { store, fetch }) {
|
|
239
|
+
const { wallet, created } = await ensureWallet(store.home);
|
|
240
|
+
const usdc = await usdcBalance(wallet.address, { fetch });
|
|
241
|
+
return { ok: true, created, address: wallet.address, usdc_base: usdc, linked: wallet.server ? { url: wallet.server.url, agent: wallet.server.agent } : null, file: path.join(store.home, "wallet.json") };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function connect(args, { store, fetch, say, sleep }) {
|
|
245
|
+
const { wallet } = await ensureWallet(store.home);
|
|
246
|
+
const url = String(args.flags.server || WALLET_URL);
|
|
247
|
+
const label = String(args.flags.name || `x402-safe-fetch on ${os.hostname()}`).slice(0, 40);
|
|
248
|
+
const linked = await pair({
|
|
249
|
+
address: wallet.address, label, url, fetch, sleep,
|
|
250
|
+
onCode: ({ code, url: link }) => say(`Open this on your phone and approve:\n\n ${link}\n\n code ${code} (valid 10 minutes)\n\nNo Fizzl wallet yet? One is made when you sign in. Waiting...`),
|
|
251
|
+
});
|
|
252
|
+
writeWallet(store.home, { ...wallet, server: linked });
|
|
253
|
+
const usdc = await usdcBalance(wallet.address, { fetch });
|
|
254
|
+
return { ok: true, address: wallet.address, linked: { url: linked.url, agent: linked.agent }, usdc_base: usdc };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function walletInfo(args, { store, fetch }) {
|
|
258
|
+
const wallet = readWallet(store.home);
|
|
259
|
+
if (!wallet) return { ok: false, code: "no_wallet", message: "no wallet yet: run npx x402-safe-fetch connect (or init)" };
|
|
260
|
+
const usdc = await usdcBalance(wallet.address, { fetch });
|
|
261
|
+
return { ok: true, address: wallet.address, usdc_base: usdc, linked: wallet.server ? { url: wallet.server.url, agent: wallet.server.agent } : null };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function spent(args, { store }) {
|
|
265
|
+
const s = store.read();
|
|
266
|
+
const daily = money(args.flags.daily, "daily") ?? 1;
|
|
267
|
+
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 };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function text(cmd, r) {
|
|
271
|
+
const lines = [];
|
|
272
|
+
if (cmd === "find") {
|
|
273
|
+
if (!r.pick) return r.message || "Nothing found.";
|
|
274
|
+
const p = r.pick;
|
|
275
|
+
lines.push(`${p.method} ${p.url}`, ` $${p.price_usd} per call · delivered ${p.delivered ?? "?"} · ${p.description || ""}`.trimEnd());
|
|
276
|
+
if (r.alternatives?.length) lines.push("Also:", ...r.alternatives.map((a) => ` $${a.price_usd} ${a.method} ${a.url}`));
|
|
277
|
+
lines.push(`Pay it: npx x402-safe-fetch pay "${p.url}"`);
|
|
278
|
+
} else if (cmd === "check") {
|
|
279
|
+
lines.push(r.message || `HTTP 402 at ${r.url}`);
|
|
280
|
+
for (const o of r.options || []) lines.push(` ${o.network}: ${o.price_usd === null ? "?" : `$${o.price_usd}`} to ${o.pay_to}`);
|
|
281
|
+
if (r.doctor) {
|
|
282
|
+
lines.push(`x402 Doctor's free check: ${String(r.doctor.overall).toUpperCase()}${r.doctor.message ? ` (${r.doctor.message})` : ""}`);
|
|
283
|
+
for (const p of (r.doctor.problems || []).slice(0, 6)) lines.push(` ${p.status}: ${p.message}`);
|
|
284
|
+
if (r.doctor.report) lines.push(` ${r.doctor.report}`);
|
|
285
|
+
}
|
|
286
|
+
if (r.status === 402) lines.push("Nothing was paid. The paid preflight (go / caution / no-go, $0.001) runs with pay.");
|
|
287
|
+
} else if (cmd === "pay") {
|
|
288
|
+
if (r.preflight) lines.push(`x402 Doctor: ${String(r.preflight.verdict).toUpperCase()}: ${r.preflight.summary || ""}`.trimEnd());
|
|
289
|
+
if (r.ok) {
|
|
290
|
+
lines.push(`Paid $${r.paid_usd} (preflight included)${r.transaction ? ` · tx ${r.transaction}` : ""}`);
|
|
291
|
+
if (r.answer_check) lines.push(`Answer check: ${r.answer_check}`);
|
|
292
|
+
lines.push(typeof r.body === "string" ? r.body : JSON.stringify(r.body, null, 2));
|
|
293
|
+
} else {
|
|
294
|
+
lines.push(`Not done (${r.code}): ${r.message}`);
|
|
295
|
+
for (const x of r.reasons || []) lines.push(` ${x.level}: ${x.message}`);
|
|
296
|
+
if (r.status && r.body !== undefined) lines.push(typeof r.body === "string" ? r.body.slice(0, 600) : JSON.stringify(r.body).slice(0, 600));
|
|
297
|
+
}
|
|
298
|
+
if (r.spent_today_usd !== undefined) lines.push(`Today: $${r.spent_today_usd} of $${r.daily_limit_usd}`);
|
|
299
|
+
} else if (cmd === "init" || cmd === "connect" || cmd === "wallet") {
|
|
300
|
+
if (!r.ok) return r.message;
|
|
301
|
+
if (cmd === "init" && r.created) lines.push("Made a wallet for this computer (the key stays here, readable by you only).");
|
|
302
|
+
if (cmd === "connect") lines.push(`Linked to your Fizzl wallet${r.linked?.agent ? ` as "${r.linked.agent}"` : ""}: your limits and approvals now apply.`);
|
|
303
|
+
lines.push(`Address: ${r.address}`, `USDC on Base: ${r.usdc_base === null ? "?" : `$${r.usdc_base}`}`);
|
|
304
|
+
if (cmd !== "connect") lines.push(r.linked ? `Linked to ${r.linked.url}${r.linked.agent ? ` as "${r.linked.agent}"` : ""}` : "Not linked to a Fizzl wallet (run connect for limits and phone approvals).");
|
|
305
|
+
if (!r.usdc_base) lines.push("To pay, send a few dollars of USDC on Base to the address above (from Coinbase, a wallet app or an exchange; choose the Base network).");
|
|
306
|
+
lines.push('Then: npx x402-safe-fetch find "what you need"');
|
|
307
|
+
} else if (cmd === "spent") {
|
|
308
|
+
lines.push(`${r.day}: $${r.spent_today_usd} paid in ${r.calls} call(s), $${r.left_usd} left of $${r.daily_limit_usd}`);
|
|
309
|
+
if (r.blocked_endpoints.length) lines.push("Not paid again for now (took money without delivering):", ...r.blocked_endpoints.map((b) => ` ${b}`));
|
|
310
|
+
}
|
|
311
|
+
return lines.join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function main(argv, { fetch = globalThis.fetch, env = process.env, store = stateStore(), createPayingFetch, safeFetchOptions, sleep, out = console.log, err = console.error } = {}) {
|
|
315
|
+
const args = parseArgs(argv);
|
|
316
|
+
const cmd = args._[0];
|
|
317
|
+
if (!cmd || args.flags.help || cmd === "help") { out(HELP); return cmd || args.flags.help ? 0 : 2; }
|
|
318
|
+
const commands = { connect, init, wallet: walletInfo, find, check, pay, spent };
|
|
319
|
+
if (!commands[cmd]) { err(`unknown command "${cmd}"\n\n${HELP}`); return 2; }
|
|
320
|
+
try {
|
|
321
|
+
const say = args.flags.json ? () => {} : (s) => err(s);
|
|
322
|
+
const r = await commands[cmd](args, { fetch, env, store, createPayingFetch, safeFetchOptions, say, sleep });
|
|
323
|
+
out(args.flags.json ? JSON.stringify(r) : text(cmd === "pay" && args.flags["dry-run"] ? "check" : cmd, r));
|
|
324
|
+
return r.ok ? 0 : 1;
|
|
325
|
+
} catch (e) {
|
|
326
|
+
if (e instanceof UsageError) { err(e.message); return 2; }
|
|
327
|
+
err(`error: ${e.message}`);
|
|
328
|
+
return 2;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const invoked = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(new URL(import.meta.url).pathname);
|
|
333
|
+
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.
|
|
54
|
+
export const VERSION = "0.18.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.18.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,8 @@
|
|
|
17
20
|
"types": "./index.d.ts",
|
|
18
21
|
"files": [
|
|
19
22
|
"index.js",
|
|
23
|
+
"cli.js",
|
|
24
|
+
"wallet.js",
|
|
20
25
|
"receipt.js",
|
|
21
26
|
"paid-guard.js",
|
|
22
27
|
"hook.js",
|
|
@@ -32,7 +37,9 @@
|
|
|
32
37
|
"test": "node --test test/*.test.mjs"
|
|
33
38
|
},
|
|
34
39
|
"peerDependencies": {
|
|
35
|
-
"@x402/fetch": "^2.27.0"
|
|
40
|
+
"@x402/fetch": "^2.27.0",
|
|
41
|
+
"@x402/evm": "^2.27.0",
|
|
42
|
+
"viem": "^2.0.0"
|
|
36
43
|
},
|
|
37
44
|
"devDependencies": {
|
|
38
45
|
"@algorandfoundation/algokit-utils": "^10.0.0-alpha.46",
|
|
@@ -56,7 +63,8 @@
|
|
|
56
63
|
"xrpl",
|
|
57
64
|
"rlusd",
|
|
58
65
|
"signed-receipts",
|
|
59
|
-
"stellar"
|
|
66
|
+
"stellar",
|
|
67
|
+
"cli"
|
|
60
68
|
],
|
|
61
69
|
"repository": {
|
|
62
70
|
"type": "git",
|
package/wallet.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The command line's own wallet, and its link to a Fizzl Agent Wallet (wallet.fizzl.eu).
|
|
2
|
+
//
|
|
3
|
+
// init makes a dedicated wallet for the command line: a new key in ~/.x402-safe-fetch/wallet.json (readable by you
|
|
4
|
+
// only), so nobody has to paste a private key. connect links that wallet to the owner's Fizzl Agent Wallet with a
|
|
5
|
+
// code the owner approves on their phone (the same link Claude Desktop's wallet uses: /v1/pair). Once linked, every
|
|
6
|
+
// payment is first reserved on the wallet server: the owner's limits apply, and above them the owner is asked
|
|
7
|
+
// (Telegram or the dashboard) before anything is signed. The key never leaves this computer.
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { SafePayError } from "./index.js";
|
|
12
|
+
|
|
13
|
+
export const WALLET_URL = "https://wallet.fizzl.eu";
|
|
14
|
+
export const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
15
|
+
const BASE_RPC = "https://mainnet.base.org";
|
|
16
|
+
|
|
17
|
+
export function walletFile(home) {
|
|
18
|
+
return path.join(home, "wallet.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readWallet(home) {
|
|
22
|
+
try {
|
|
23
|
+
const w = JSON.parse(fs.readFileSync(walletFile(home), "utf8"));
|
|
24
|
+
return /^0x[0-9a-fA-F]{64}$/.test(w?.privateKey ?? "") ? w : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writeWallet(home, wallet) {
|
|
31
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
32
|
+
const file = walletFile(home);
|
|
33
|
+
fs.writeFileSync(file, JSON.stringify(wallet, null, 2), { mode: 0o600 });
|
|
34
|
+
try { fs.chmodSync(file, 0o600); } catch { /* not on every file system */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A wallet for this computer: the existing one, or a new key. Needs viem (a peer dependency of x402-safe-fetch).
|
|
38
|
+
export async function ensureWallet(home) {
|
|
39
|
+
const existing = readWallet(home);
|
|
40
|
+
if (existing) return { wallet: existing, created: false };
|
|
41
|
+
const { generatePrivateKey, privateKeyToAccount } = await import("viem/accounts");
|
|
42
|
+
const privateKey = generatePrivateKey();
|
|
43
|
+
const wallet = { address: privateKeyToAccount(privateKey).address, privateKey, createdAt: new Date().toISOString() };
|
|
44
|
+
writeWallet(home, wallet);
|
|
45
|
+
return { wallet, created: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// USDC on Base held by `address`, in dollars (null when the network can't be read).
|
|
49
|
+
export async function usdcBalance(address, { fetch = globalThis.fetch, rpc = BASE_RPC } = {}) {
|
|
50
|
+
try {
|
|
51
|
+
const data = `0x70a08231${address.slice(2).toLowerCase().padStart(64, "0")}`;
|
|
52
|
+
const res = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to: BASE_USDC, data }, "latest"] }) });
|
|
53
|
+
const j = await res.json();
|
|
54
|
+
return typeof j?.result === "string" && j.result.startsWith("0x") ? Number(BigInt(j.result)) / 1e6 : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function call(fetch, base, key, method, p, body) {
|
|
61
|
+
const res = await fetch(`${base}${p}`, {
|
|
62
|
+
method,
|
|
63
|
+
headers: { ...(key ? { authorization: `Bearer ${key}` } : {}), "content-type": "application/json", accept: "application/json" },
|
|
64
|
+
body: body === undefined ? undefined : JSON.stringify(body, (_k, v) => (typeof v === "bigint" ? v.toString() : v)),
|
|
65
|
+
});
|
|
66
|
+
const data = await res.json().catch(() => null);
|
|
67
|
+
if (!res.ok) throw new Error(`wallet server ${p}: ${data?.message ?? data?.error ?? `HTTP ${res.status}`}`);
|
|
68
|
+
return data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Links this wallet to a Fizzl Agent Wallet: asks for a code, shows it (onCode), and waits until the owner approves it
|
|
73
|
+
* where they are signed in. Returns { url, key, agent }. The code is useless without the token kept here.
|
|
74
|
+
*/
|
|
75
|
+
export async function pair({ address, label = "x402-safe-fetch", url = WALLET_URL, fetch = globalThis.fetch, onCode, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now } = {}) {
|
|
76
|
+
const base = url.replace(/\/$/, "");
|
|
77
|
+
const start = await call(fetch, base, null, "POST", "/v1/pair/start", { address, label });
|
|
78
|
+
if (!start?.code || !start?.token) throw new Error("wallet server: no pairing code");
|
|
79
|
+
const link = start.url || `${base}/pair?code=${start.code}`;
|
|
80
|
+
onCode?.({ code: start.code, url: link, expiresIn: start.expiresIn ?? 600 });
|
|
81
|
+
const until = now() + (Number(start.expiresIn) || 600) * 1000;
|
|
82
|
+
const every = Math.max(1, Number(start.interval) || 3) * 1000;
|
|
83
|
+
while (now() < until) {
|
|
84
|
+
await sleep(every);
|
|
85
|
+
const p = await call(fetch, base, null, "POST", "/v1/pair/poll", { token: start.token });
|
|
86
|
+
if (p?.status === "linked" && typeof p.key === "string") return { url: base, key: p.key, agent: p.agent ?? null };
|
|
87
|
+
}
|
|
88
|
+
throw new Error("the code expired before it was approved (10 minutes); run connect again");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A signer that asks the owner's wallet server before each signature (reserve), waits for the owner's approval when
|
|
93
|
+
* a limit is reached, and reports what was signed afterwards (finish). Throws SafePayError wallet_denied or
|
|
94
|
+
* wallet_paused when the server says no; `denied` keeps the last refusal for the caller.
|
|
95
|
+
*/
|
|
96
|
+
export function serverSigner({ account, server, fetch = globalThis.fetch, purchase, onWait, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now }) {
|
|
97
|
+
const base = server.url.replace(/\/$/, "");
|
|
98
|
+
const reserved = [];
|
|
99
|
+
const state = { denied: null };
|
|
100
|
+
const deny = (code, summary) => {
|
|
101
|
+
state.denied = { code, summary };
|
|
102
|
+
throw new SafePayError(`not paid: your Fizzl wallet says no: ${summary}`, { code });
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
address: account.address,
|
|
106
|
+
state,
|
|
107
|
+
async signTypedData(typedData) {
|
|
108
|
+
const { domain, types, primaryType, message } = typedData;
|
|
109
|
+
const request = { type: "signature", chainId: Number(domain?.chainId ?? 8453), typedData: { domain, types, primaryType, message } };
|
|
110
|
+
let r = await call(fetch, base, server.key, "POST", "/v1/reserve", { method: "signTypedData", request, verdict: null, purchase });
|
|
111
|
+
if (r.status === "pending") {
|
|
112
|
+
onWait?.(r.summary);
|
|
113
|
+
const deadline = (r.expiresAt ?? now() + 600_000) + 10_000;
|
|
114
|
+
let a = null;
|
|
115
|
+
while (now() < deadline) {
|
|
116
|
+
a = await call(fetch, base, server.key, "GET", `/v1/approvals/${encodeURIComponent(r.approvalId)}?wait=25`);
|
|
117
|
+
if (a.status !== "pending") break;
|
|
118
|
+
await sleep(1000);
|
|
119
|
+
}
|
|
120
|
+
if (a?.status === "approved") r = { status: "ok", entries: a.entries ?? [], purchaseId: a.purchaseId ?? null };
|
|
121
|
+
else deny("wallet_denied", `${r.summary} (${a?.status === "denied" ? "denied by the owner" : "no answer in time"})`);
|
|
122
|
+
}
|
|
123
|
+
if (r.status === "paused") deny("wallet_paused", r.summary || "paused");
|
|
124
|
+
if (r.status === "denied") deny("wallet_denied", r.summary || "over a hard limit");
|
|
125
|
+
if (r.status !== "ok") throw new Error(`wallet server: unexpected answer ${r.status}`);
|
|
126
|
+
reserved.push({ entries: r.entries ?? [], purchaseId: r.purchaseId ?? null });
|
|
127
|
+
return account.signTypedData(typedData);
|
|
128
|
+
},
|
|
129
|
+
// After the call: every signed payment counts as spent (a signed authorization can be settled by the seller).
|
|
130
|
+
async finish(result) {
|
|
131
|
+
for (const r of reserved.splice(0)) {
|
|
132
|
+
if (!r.entries.length) continue;
|
|
133
|
+
await call(fetch, base, server.key, "POST", "/v1/spent", { entries: r.entries, result: String(result ?? ""), ...(r.purchaseId ? { purchaseId: r.purchaseId } : {}) }).catch(() => {});
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|