x402-safe-fetch 0.19.0 → 0.20.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 CHANGED
@@ -28,9 +28,10 @@ npx x402-safe-fetch find "bitcoin price in usd" # free: who sells it, the pr
28
28
  npx x402-safe-fetch check <url> # free: the price per network and x402 Doctor's free check; pays nothing
29
29
  npx x402-safe-fetch pay <url> --max 0.05 --daily 1
30
30
  npx x402-safe-fetch spent # what was paid today, and what is left
31
+ npx x402-safe-fetch fund # no crypto yet: buy USDC with a card (Coinbase checkout)
31
32
  ```
32
33
 
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. Then add a few dollars of USDC on Base to the address it shows (`wallet` shows it again, with the balance): no crypto yet? It also prints a link to buy $5 of USDC with a card at Coinbase, sent straight to that address (the link starts a Coinbase Onramp session through x402 Doctor; you pay Coinbase, Fizzl never holds the money). Or send USDC on Base from Coinbase, a wallet app or an exchange. `init` makes the wallet without linking it, and `EVM_PRIVATE_KEY` still overrides both.
34
+ **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. Then add a few dollars of USDC on Base to the address it shows (`wallet` shows it again, with the balance). No crypto yet? `npx x402-safe-fetch fund` (`--amount 10`) prints a Coinbase checkout to buy USDC with a card, sent straight to that address: your Fizzl wallet checks this computer's link first and asks Coinbase Onramp for it; you pay Coinbase, Fizzl never holds the money (outside the US you sign in to Coinbase). Or send USDC on Base from Coinbase, a wallet app or an exchange. `init` makes the wallet without linking it, and `EVM_PRIVATE_KEY` still overrides both.
34
35
 
35
36
  `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
37
 
package/cli.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // npx x402-safe-fetch connect a wallet for this computer, linked to your Fizzl wallet
10
10
  // npx x402-safe-fetch pay <url> --max 0.05 --daily 1
11
11
  // npx x402-safe-fetch spent what was paid today, and what is left
12
+ // npx x402-safe-fetch fund buy USDC with a card (needs connect)
12
13
  //
13
14
  // Pays USDC on Base from this computer's own wallet (init or connect make it; EVM_PRIVATE_KEY overrides it). Linked
14
15
  // to a Fizzl Agent Wallet (connect), the owner's limits and approvals apply to every payment (wallet.js). The day's spend and the
@@ -18,7 +19,7 @@
18
19
  import fs from "node:fs";
19
20
  import os from "node:os";
20
21
  import path from "node:path";
21
- import { ensureWallet, readWallet, writeWallet, pair, serverSigner, usdcBalance, buyUsdcUrl, WALLET_URL } from "./wallet.js";
22
+ import { ensureWallet, readWallet, writeWallet, pair, serverSigner, usdcBalance, onrampLink, WALLET_URL } from "./wallet.js";
22
23
  import {
23
24
  createSafeFetch, SafePayError, receiptOf, readOffer, usdOfOption, answerCheckOf, purchaseKey, paidTwiceMessage,
24
25
  PAID_ERROR_MS, DOCTOR_URL, VERSION,
@@ -31,8 +32,9 @@ const HELP = `x402-safe-fetch ${VERSION}: pay for an API call from the command l
31
32
 
32
33
  connect Start here. Makes a wallet for this computer and links it to your Fizzl wallet
33
34
  (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 its address and a link to buy USDC with a card.
35
+ init Only the wallet for this computer, not linked: shows the address to send USDC on Base to.
35
36
  wallet This computer's wallet: address, USDC balance, and whether it is linked.
37
+ fund Buy USDC with a card for this wallet (Coinbase checkout, $5; --amount <usd>). Needs connect.
36
38
  find <need> Free. Who sells it (Fizzl Buy's pick): URL, price, and how often it delivered.
37
39
  check <url> Free, pays nothing. The price per network and x402 Doctor's free check of the endpoint.
38
40
  pay <url> Pays one call in USDC on Base, only if x402 Doctor's preflight says go.
@@ -238,7 +240,7 @@ async function pay(args, { fetch, env, store, createPayingFetch, safeFetchOption
238
240
  async function init(args, { store, fetch }) {
239
241
  const { wallet, created } = await ensureWallet(store.home);
240
242
  const usdc = await usdcBalance(wallet.address, { fetch });
241
- return { ok: true, created, address: wallet.address, usdc_base: usdc, buy_usdc_url: buyUsdcUrl(wallet.address), linked: wallet.server ? { url: wallet.server.url, agent: wallet.server.agent } : null, file: path.join(store.home, "wallet.json") };
243
+ 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
244
  }
243
245
 
244
246
  async function connect(args, { store, fetch, say, sleep }) {
@@ -251,14 +253,28 @@ async function connect(args, { store, fetch, say, sleep }) {
251
253
  });
252
254
  writeWallet(store.home, { ...wallet, server: linked });
253
255
  const usdc = await usdcBalance(wallet.address, { fetch });
254
- return { ok: true, address: wallet.address, linked: { url: linked.url, agent: linked.agent }, usdc_base: usdc, buy_usdc_url: buyUsdcUrl(wallet.address) };
256
+ return { ok: true, address: wallet.address, linked: { url: linked.url, agent: linked.agent }, usdc_base: usdc };
255
257
  }
256
258
 
257
259
  async function walletInfo(args, { store, fetch }) {
258
260
  const wallet = readWallet(store.home);
259
261
  if (!wallet) return { ok: false, code: "no_wallet", message: "no wallet yet: run npx x402-safe-fetch connect (or init)" };
260
262
  const usdc = await usdcBalance(wallet.address, { fetch });
261
- return { ok: true, address: wallet.address, usdc_base: usdc, buy_usdc_url: buyUsdcUrl(wallet.address), linked: wallet.server ? { url: wallet.server.url, agent: wallet.server.agent } : null };
263
+ return { ok: true, address: wallet.address, usdc_base: usdc, linked: wallet.server ? { url: wallet.server.url, agent: wallet.server.agent } : null };
264
+ }
265
+
266
+ // A Coinbase checkout for this wallet, asked through the owner's Fizzl wallet (it checks the agent key first).
267
+ async function fund(args, { store, fetch }) {
268
+ const wallet = readWallet(store.home);
269
+ if (!wallet) return { ok: false, code: "no_wallet", message: "no wallet yet: run npx x402-safe-fetch connect first" };
270
+ if (!wallet.server) return { ok: false, code: "not_linked", message: `Buying with a card goes through your Fizzl wallet: run npx x402-safe-fetch connect first (free), then fund. Or send USDC on Base to ${wallet.address}.` };
271
+ const amount = money(args.flags.amount, "amount") ?? 5;
272
+ try {
273
+ const r = await onrampLink({ server: wallet.server, amount, fetch });
274
+ return { ok: true, url: r.url, address: r.address ?? wallet.address, amount_usd: r.amount_usd ?? amount, expires_in_s: r.expires_in_s ?? 300 };
275
+ } catch (err) {
276
+ return { ok: false, code: "fund_unavailable", message: String(err.message).replace(/^wallet server \/v1\/onramp[^:]*: /, "") };
277
+ }
262
278
  }
263
279
 
264
280
  function spent(args, { store }) {
@@ -305,11 +321,14 @@ function text(cmd, r) {
305
321
  if (!r.usdc_base) {
306
322
  lines.push(
307
323
  "To pay, add a few dollars of USDC on Base to the address above:",
308
- ` with a card (Coinbase, $5; open it in your browser): ${r.buy_usdc_url}`,
324
+ r.linked ? " with a card: npx x402-safe-fetch fund (Coinbase checkout, $5)" : " with a card: run connect first, then npx x402-safe-fetch fund",
309
325
  " or send USDC from Coinbase, a wallet app or an exchange (choose the Base network).",
310
326
  );
311
327
  }
312
328
  lines.push('Then: npx x402-safe-fetch find "what you need"');
329
+ } else if (cmd === "fund") {
330
+ if (!r.ok) return r.message;
331
+ lines.push(`Open this in your browser to buy $${r.amount_usd} of USDC with a card (works once, for ${Math.round(r.expires_in_s / 60)} minutes):`, "", ` ${r.url}`, "", `Coinbase sends the USDC to ${r.address} on Base, usually within minutes. Fizzl never holds the money.`, "Outside the US you sign in to (or create) a Coinbase account.");
313
332
  } else if (cmd === "spent") {
314
333
  lines.push(`${r.day}: $${r.spent_today_usd} paid in ${r.calls} call(s), $${r.left_usd} left of $${r.daily_limit_usd}`);
315
334
  if (r.blocked_endpoints.length) lines.push("Not paid again for now (took money without delivering):", ...r.blocked_endpoints.map((b) => ` ${b}`));
@@ -321,7 +340,7 @@ export async function main(argv, { fetch = globalThis.fetch, env = process.env,
321
340
  const args = parseArgs(argv);
322
341
  const cmd = args._[0];
323
342
  if (!cmd || args.flags.help || cmd === "help") { out(HELP); return cmd || args.flags.help ? 0 : 2; }
324
- const commands = { connect, init, wallet: walletInfo, find, check, pay, spent };
343
+ const commands = { connect, init, wallet: walletInfo, fund, find, check, pay, spent };
325
344
  if (!commands[cmd]) { err(`unknown command "${cmd}"\n\n${HELP}`); return 2; }
326
345
  try {
327
346
  const say = args.flags.json ? () => {} : (s) => err(s);
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.19.0";
54
+ export const VERSION = "0.20.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,6 +1,6 @@
1
1
  {
2
2
  "name": "x402-safe-fetch",
3
- "version": "0.19.0",
3
+ "version": "0.20.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
  "bin": {
package/wallet.js CHANGED
@@ -13,14 +13,6 @@ import { SafePayError } from "./index.js";
13
13
  export const WALLET_URL = "https://wallet.fizzl.eu";
14
14
  export const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
15
15
  const BASE_RPC = "https://mainnet.base.org";
16
- export const ONRAMP_URL = "https://x402-doctor.fizzl.eu/api/onramp";
17
-
18
- // A link to buy USDC on Base with a card at Coinbase, delivered to `address`. Each visit starts a new Coinbase session
19
- // (x402 Doctor asks Coinbase Onramp for it); the buyer pays Coinbase and Fizzl never holds the money.
20
- export function buyUsdcUrl(address, { amount = 5 } = {}) {
21
- return `${ONRAMP_URL}?${new URLSearchParams({ address, amount: String(amount) })}`;
22
- }
23
-
24
16
  export function walletFile(home) {
25
17
  return path.join(home, "wallet.json");
26
18
  }
@@ -95,6 +87,16 @@ export async function pair({ address, label = "x402-safe-fetch", url = WALLET_UR
95
87
  throw new Error("the code expired before it was approved (10 minutes); run connect again");
96
88
  }
97
89
 
90
+ /**
91
+ * A Coinbase checkout (single use, 5 minutes) to buy USDC on Base with a card, sent to this wallet's own address: the
92
+ * owner's wallet server checks the agent key and asks for it (x402 Doctor holds the CDP key). Coinbase takes the payment
93
+ * and sends the USDC; Fizzl never holds the money. Returns { url, address, amount_usd, expires_in_s }.
94
+ */
95
+ export async function onrampLink({ server, amount = 5, fetch = globalThis.fetch }) {
96
+ const base = server.url.replace(/\/$/, "");
97
+ return call(fetch, base, server.key, "GET", `/v1/onramp?amount=${encodeURIComponent(String(amount))}`);
98
+ }
99
+
98
100
  /**
99
101
  * A signer that asks the owner's wallet server before each signature (reserve), waits for the owner's approval when
100
102
  * a limit is reached, and reports what was signed afterwards (finish). Throws SafePayError wallet_denied or