x402-safe-fetch 0.17.0 → 0.19.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 +4 -1
- package/cli.js +71 -12
- package/index.js +1 -1
- package/package.json +2 -1
- package/wallet.js +144 -0
package/README.md
CHANGED
|
@@ -23,12 +23,15 @@ npm install x402-safe-fetch @x402/fetch @x402/evm viem
|
|
|
23
23
|
An agent that works in a terminal (or you) can pay for one API call with one command, with the same checks:
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
+
npx x402-safe-fetch connect # once: a wallet for this computer, linked to your Fizzl wallet
|
|
26
27
|
npx x402-safe-fetch find "bitcoin price in usd" # free: who sells it, the price, how often it delivered
|
|
27
28
|
npx x402-safe-fetch check <url> # free: the price per network and x402 Doctor's free check; pays nothing
|
|
28
|
-
|
|
29
|
+
npx x402-safe-fetch pay <url> --max 0.05 --daily 1
|
|
29
30
|
npx x402-safe-fetch spent # what was paid today, and what is left
|
|
30
31
|
```
|
|
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. 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
|
+
|
|
32
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.
|
|
33
36
|
|
|
34
37
|
## Use
|
package/cli.js
CHANGED
|
@@ -6,16 +6,19 @@
|
|
|
6
6
|
//
|
|
7
7
|
// npx x402-safe-fetch find "bitcoin price in usd" free: who sells it, at what price, and whether it delivers
|
|
8
8
|
// npx x402-safe-fetch check <url> free: the price and x402 Doctor's free check; pays nothing
|
|
9
|
-
//
|
|
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
|
|
10
11
|
// npx x402-safe-fetch spent what was paid today, and what is left
|
|
11
12
|
//
|
|
12
|
-
// Pays USDC on Base from
|
|
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
|
|
13
15
|
// endpoints that took money without delivering are kept in ~/.x402-safe-fetch/state.json (X402_SAFE_FETCH_HOME
|
|
14
16
|
// moves it), so the limits and the paid-twice guard hold across commands.
|
|
15
17
|
|
|
16
18
|
import fs from "node:fs";
|
|
17
19
|
import os from "node:os";
|
|
18
20
|
import path from "node:path";
|
|
21
|
+
import { ensureWallet, readWallet, writeWallet, pair, serverSigner, usdcBalance, buyUsdcUrl, WALLET_URL } from "./wallet.js";
|
|
19
22
|
import {
|
|
20
23
|
createSafeFetch, SafePayError, receiptOf, readOffer, usdOfOption, answerCheckOf, purchaseKey, paidTwiceMessage,
|
|
21
24
|
PAID_ERROR_MS, DOCTOR_URL, VERSION,
|
|
@@ -26,9 +29,13 @@ const PREFLIGHT_USD = 0.001;
|
|
|
26
29
|
|
|
27
30
|
const HELP = `x402-safe-fetch ${VERSION}: pay for an API call from the command line, checked first.
|
|
28
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 its address and a link to buy USDC with a card.
|
|
35
|
+
wallet This computer's wallet: address, USDC balance, and whether it is linked.
|
|
29
36
|
find <need> Free. Who sells it (Fizzl Buy's pick): URL, price, and how often it delivered.
|
|
30
37
|
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
|
|
38
|
+
pay <url> Pays one call in USDC on Base, only if x402 Doctor's preflight says go.
|
|
32
39
|
spent What was paid today and what is left of the day's limit.
|
|
33
40
|
|
|
34
41
|
Options for pay (check takes --method and --data too):
|
|
@@ -83,7 +90,7 @@ export function stateStore({ home = process.env.X402_SAFE_FETCH_HOME || path.joi
|
|
|
83
90
|
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
84
91
|
fs.writeFileSync(file, JSON.stringify(s, null, 2), { mode: 0o600 });
|
|
85
92
|
}
|
|
86
|
-
return { file, read, write, now };
|
|
93
|
+
return { home, file, read, write, now };
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
function requestInit(flags) {
|
|
@@ -133,7 +140,7 @@ async function check(args, { fetch }) {
|
|
|
133
140
|
return { ok: true, url, status: 402, paid: false, options, doctor };
|
|
134
141
|
}
|
|
135
142
|
|
|
136
|
-
async function pay(args, { fetch, env, store, createPayingFetch, safeFetchOptions = {} }) {
|
|
143
|
+
async function pay(args, { fetch, env, store, createPayingFetch, safeFetchOptions = {}, say = () => {} }) {
|
|
137
144
|
if (args.flags["dry-run"]) return check(args, { fetch });
|
|
138
145
|
const url = targetUrl(args);
|
|
139
146
|
const init = requestInit(args.flags);
|
|
@@ -150,9 +157,12 @@ async function pay(args, { fetch, env, store, createPayingFetch, safeFetchOption
|
|
|
150
157
|
if (earlier) return { ok: false, url, code: "paid_no_delivery", message: paidTwiceMessage(url, earlier), earlier };
|
|
151
158
|
|
|
152
159
|
let register;
|
|
160
|
+
let linked = null; // the Fizzl wallet's signer, when this computer's wallet is linked
|
|
153
161
|
if (!createPayingFetch) {
|
|
154
|
-
const
|
|
155
|
-
|
|
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");
|
|
156
166
|
let ExactEvmScheme, privateKeyToAccount;
|
|
157
167
|
try {
|
|
158
168
|
({ ExactEvmScheme } = await import("@x402/evm/exact/client"));
|
|
@@ -161,7 +171,10 @@ async function pay(args, { fetch, env, store, createPayingFetch, safeFetchOption
|
|
|
161
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>");
|
|
162
172
|
}
|
|
163
173
|
const account = privateKeyToAccount(raw.startsWith("0x") ? raw : `0x${raw}`);
|
|
164
|
-
|
|
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));
|
|
165
178
|
}
|
|
166
179
|
|
|
167
180
|
let preflights = 0;
|
|
@@ -194,7 +207,7 @@ async function pay(args, { fetch, env, store, createPayingFetch, safeFetchOption
|
|
|
194
207
|
const verdict = () => preflight && { verdict: preflight.verdict, summary: preflight.summary };
|
|
195
208
|
|
|
196
209
|
try {
|
|
197
|
-
const res = await safeFetch(url, init);
|
|
210
|
+
const res = await safeFetch(url, init).finally(() => linked?.finish("x402-safe-fetch"));
|
|
198
211
|
const text = await res.text();
|
|
199
212
|
const receipt = receiptOf(res);
|
|
200
213
|
// A response after a preflight means the endpoint was paid (or a payment was sent): a 2xx clears it, anything else
|
|
@@ -211,12 +224,43 @@ async function pay(args, { fetch, env, store, createPayingFetch, safeFetchOption
|
|
|
211
224
|
...(delivered ? {} : { code: "not_delivered", message: `paid, but the endpoint answered HTTP ${res.status}; it won't be paid again for 30 minutes` }),
|
|
212
225
|
};
|
|
213
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
|
+
}
|
|
214
232
|
if (!(err instanceof SafePayError)) throw err;
|
|
215
233
|
const totals = settle(null);
|
|
216
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 };
|
|
217
235
|
}
|
|
218
236
|
}
|
|
219
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, 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") };
|
|
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, buy_usdc_url: buyUsdcUrl(wallet.address) };
|
|
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, buy_usdc_url: buyUsdcUrl(wallet.address), linked: wallet.server ? { url: wallet.server.url, agent: wallet.server.agent } : null };
|
|
262
|
+
}
|
|
263
|
+
|
|
220
264
|
function spent(args, { store }) {
|
|
221
265
|
const s = store.read();
|
|
222
266
|
const daily = money(args.flags.daily, "daily") ?? 1;
|
|
@@ -252,6 +296,20 @@ function text(cmd, r) {
|
|
|
252
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));
|
|
253
297
|
}
|
|
254
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) {
|
|
306
|
+
lines.push(
|
|
307
|
+
"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}`,
|
|
309
|
+
" or send USDC from Coinbase, a wallet app or an exchange (choose the Base network).",
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
lines.push('Then: npx x402-safe-fetch find "what you need"');
|
|
255
313
|
} else if (cmd === "spent") {
|
|
256
314
|
lines.push(`${r.day}: $${r.spent_today_usd} paid in ${r.calls} call(s), $${r.left_usd} left of $${r.daily_limit_usd}`);
|
|
257
315
|
if (r.blocked_endpoints.length) lines.push("Not paid again for now (took money without delivering):", ...r.blocked_endpoints.map((b) => ` ${b}`));
|
|
@@ -259,14 +317,15 @@ function text(cmd, r) {
|
|
|
259
317
|
return lines.join("\n");
|
|
260
318
|
}
|
|
261
319
|
|
|
262
|
-
export async function main(argv, { fetch = globalThis.fetch, env = process.env, store = stateStore(), createPayingFetch, safeFetchOptions, out = console.log, err = console.error } = {}) {
|
|
320
|
+
export async function main(argv, { fetch = globalThis.fetch, env = process.env, store = stateStore(), createPayingFetch, safeFetchOptions, sleep, out = console.log, err = console.error } = {}) {
|
|
263
321
|
const args = parseArgs(argv);
|
|
264
322
|
const cmd = args._[0];
|
|
265
323
|
if (!cmd || args.flags.help || cmd === "help") { out(HELP); return cmd || args.flags.help ? 0 : 2; }
|
|
266
|
-
const commands = { find, check, pay, spent };
|
|
324
|
+
const commands = { connect, init, wallet: walletInfo, find, check, pay, spent };
|
|
267
325
|
if (!commands[cmd]) { err(`unknown command "${cmd}"\n\n${HELP}`); return 2; }
|
|
268
326
|
try {
|
|
269
|
-
const
|
|
327
|
+
const say = args.flags.json ? () => {} : (s) => err(s);
|
|
328
|
+
const r = await commands[cmd](args, { fetch, env, store, createPayingFetch, safeFetchOptions, say, sleep });
|
|
270
329
|
out(args.flags.json ? JSON.stringify(r) : text(cmd === "pay" && args.flags["dry-run"] ? "check" : cmd, r));
|
|
271
330
|
return r.ok ? 0 : 1;
|
|
272
331
|
} catch (e) {
|
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.19.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.
|
|
3
|
+
"version": "0.19.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": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"files": [
|
|
22
22
|
"index.js",
|
|
23
23
|
"cli.js",
|
|
24
|
+
"wallet.js",
|
|
24
25
|
"receipt.js",
|
|
25
26
|
"paid-guard.js",
|
|
26
27
|
"hook.js",
|
package/wallet.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
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
|
+
export function walletFile(home) {
|
|
25
|
+
return path.join(home, "wallet.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readWallet(home) {
|
|
29
|
+
try {
|
|
30
|
+
const w = JSON.parse(fs.readFileSync(walletFile(home), "utf8"));
|
|
31
|
+
return /^0x[0-9a-fA-F]{64}$/.test(w?.privateKey ?? "") ? w : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeWallet(home, wallet) {
|
|
38
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
39
|
+
const file = walletFile(home);
|
|
40
|
+
fs.writeFileSync(file, JSON.stringify(wallet, null, 2), { mode: 0o600 });
|
|
41
|
+
try { fs.chmodSync(file, 0o600); } catch { /* not on every file system */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A wallet for this computer: the existing one, or a new key. Needs viem (a peer dependency of x402-safe-fetch).
|
|
45
|
+
export async function ensureWallet(home) {
|
|
46
|
+
const existing = readWallet(home);
|
|
47
|
+
if (existing) return { wallet: existing, created: false };
|
|
48
|
+
const { generatePrivateKey, privateKeyToAccount } = await import("viem/accounts");
|
|
49
|
+
const privateKey = generatePrivateKey();
|
|
50
|
+
const wallet = { address: privateKeyToAccount(privateKey).address, privateKey, createdAt: new Date().toISOString() };
|
|
51
|
+
writeWallet(home, wallet);
|
|
52
|
+
return { wallet, created: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// USDC on Base held by `address`, in dollars (null when the network can't be read).
|
|
56
|
+
export async function usdcBalance(address, { fetch = globalThis.fetch, rpc = BASE_RPC } = {}) {
|
|
57
|
+
try {
|
|
58
|
+
const data = `0x70a08231${address.slice(2).toLowerCase().padStart(64, "0")}`;
|
|
59
|
+
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"] }) });
|
|
60
|
+
const j = await res.json();
|
|
61
|
+
return typeof j?.result === "string" && j.result.startsWith("0x") ? Number(BigInt(j.result)) / 1e6 : null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function call(fetch, base, key, method, p, body) {
|
|
68
|
+
const res = await fetch(`${base}${p}`, {
|
|
69
|
+
method,
|
|
70
|
+
headers: { ...(key ? { authorization: `Bearer ${key}` } : {}), "content-type": "application/json", accept: "application/json" },
|
|
71
|
+
body: body === undefined ? undefined : JSON.stringify(body, (_k, v) => (typeof v === "bigint" ? v.toString() : v)),
|
|
72
|
+
});
|
|
73
|
+
const data = await res.json().catch(() => null);
|
|
74
|
+
if (!res.ok) throw new Error(`wallet server ${p}: ${data?.message ?? data?.error ?? `HTTP ${res.status}`}`);
|
|
75
|
+
return data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Links this wallet to a Fizzl Agent Wallet: asks for a code, shows it (onCode), and waits until the owner approves it
|
|
80
|
+
* where they are signed in. Returns { url, key, agent }. The code is useless without the token kept here.
|
|
81
|
+
*/
|
|
82
|
+
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 } = {}) {
|
|
83
|
+
const base = url.replace(/\/$/, "");
|
|
84
|
+
const start = await call(fetch, base, null, "POST", "/v1/pair/start", { address, label });
|
|
85
|
+
if (!start?.code || !start?.token) throw new Error("wallet server: no pairing code");
|
|
86
|
+
const link = start.url || `${base}/pair?code=${start.code}`;
|
|
87
|
+
onCode?.({ code: start.code, url: link, expiresIn: start.expiresIn ?? 600 });
|
|
88
|
+
const until = now() + (Number(start.expiresIn) || 600) * 1000;
|
|
89
|
+
const every = Math.max(1, Number(start.interval) || 3) * 1000;
|
|
90
|
+
while (now() < until) {
|
|
91
|
+
await sleep(every);
|
|
92
|
+
const p = await call(fetch, base, null, "POST", "/v1/pair/poll", { token: start.token });
|
|
93
|
+
if (p?.status === "linked" && typeof p.key === "string") return { url: base, key: p.key, agent: p.agent ?? null };
|
|
94
|
+
}
|
|
95
|
+
throw new Error("the code expired before it was approved (10 minutes); run connect again");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A signer that asks the owner's wallet server before each signature (reserve), waits for the owner's approval when
|
|
100
|
+
* a limit is reached, and reports what was signed afterwards (finish). Throws SafePayError wallet_denied or
|
|
101
|
+
* wallet_paused when the server says no; `denied` keeps the last refusal for the caller.
|
|
102
|
+
*/
|
|
103
|
+
export function serverSigner({ account, server, fetch = globalThis.fetch, purchase, onWait, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now }) {
|
|
104
|
+
const base = server.url.replace(/\/$/, "");
|
|
105
|
+
const reserved = [];
|
|
106
|
+
const state = { denied: null };
|
|
107
|
+
const deny = (code, summary) => {
|
|
108
|
+
state.denied = { code, summary };
|
|
109
|
+
throw new SafePayError(`not paid: your Fizzl wallet says no: ${summary}`, { code });
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
address: account.address,
|
|
113
|
+
state,
|
|
114
|
+
async signTypedData(typedData) {
|
|
115
|
+
const { domain, types, primaryType, message } = typedData;
|
|
116
|
+
const request = { type: "signature", chainId: Number(domain?.chainId ?? 8453), typedData: { domain, types, primaryType, message } };
|
|
117
|
+
let r = await call(fetch, base, server.key, "POST", "/v1/reserve", { method: "signTypedData", request, verdict: null, purchase });
|
|
118
|
+
if (r.status === "pending") {
|
|
119
|
+
onWait?.(r.summary);
|
|
120
|
+
const deadline = (r.expiresAt ?? now() + 600_000) + 10_000;
|
|
121
|
+
let a = null;
|
|
122
|
+
while (now() < deadline) {
|
|
123
|
+
a = await call(fetch, base, server.key, "GET", `/v1/approvals/${encodeURIComponent(r.approvalId)}?wait=25`);
|
|
124
|
+
if (a.status !== "pending") break;
|
|
125
|
+
await sleep(1000);
|
|
126
|
+
}
|
|
127
|
+
if (a?.status === "approved") r = { status: "ok", entries: a.entries ?? [], purchaseId: a.purchaseId ?? null };
|
|
128
|
+
else deny("wallet_denied", `${r.summary} (${a?.status === "denied" ? "denied by the owner" : "no answer in time"})`);
|
|
129
|
+
}
|
|
130
|
+
if (r.status === "paused") deny("wallet_paused", r.summary || "paused");
|
|
131
|
+
if (r.status === "denied") deny("wallet_denied", r.summary || "over a hard limit");
|
|
132
|
+
if (r.status !== "ok") throw new Error(`wallet server: unexpected answer ${r.status}`);
|
|
133
|
+
reserved.push({ entries: r.entries ?? [], purchaseId: r.purchaseId ?? null });
|
|
134
|
+
return account.signTypedData(typedData);
|
|
135
|
+
},
|
|
136
|
+
// After the call: every signed payment counts as spent (a signed authorization can be settled by the seller).
|
|
137
|
+
async finish(result) {
|
|
138
|
+
for (const r of reserved.splice(0)) {
|
|
139
|
+
if (!r.entries.length) continue;
|
|
140
|
+
await call(fetch, base, server.key, "POST", "/v1/spent", { entries: r.entries, result: String(result ?? ""), ...(r.purchaseId ? { purchaseId: r.purchaseId } : {}) }).catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|