@h402/cli 0.1.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.
@@ -0,0 +1,539 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { assertOk, backendErrorCode, IDEMPOTENCY_MONEY_GUIDANCE, requestJson } from "./api.js";
3
+ import { BASE_USDC_BALANCE_ASSET, BASE_USDC_BALANCE_NETWORK, getBaseUsdcBalance } from "./base-usdc-balance.js";
4
+ import { explicitShowSelection, fetchCatalogRoute, resolveProvider, selectCatalogCandidate, withProviderSelection, withProviderSelectionError } from "./catalog.js";
5
+ import { backendUrl, loadConfig, updateConfig } from "./config.js";
6
+ import { CliError } from "./errors.js";
7
+ import { createOwsWallet, getOwsWallet, listOwsWallets, signOwsMessage } from "./ows.js";
8
+ import { promptPassphrase } from "./prompt.js";
9
+ import { assertConcreteProvider, buildProxyPath, encodeRouteId, flagBoolean, flagString, isRecord, mergeH402, parseJsonFlag, parseQueryFlag, printJson, requireValue, resolveMethod } from "./utils.js";
10
+ import { createPaymentSignatureHeader, paymentRequiredFromResponse, selectBaseUsdcRequirement, X402_HEADERS } from "./x402.js";
11
+ const DEFAULT_WALLET_NAME = "h402";
12
+ function walletName(args) {
13
+ return flagString(args.flags, "name", DEFAULT_WALLET_NAME);
14
+ }
15
+ // Explicit passphrase from flags/env. Wallets are passphrase-less by default
16
+ // (the onboarding default), so this is usually undefined and signing simply
17
+ // runs without one. --no-passphrase force-skips even an exported passphrase.
18
+ function explicitPassphrase(args) {
19
+ if (flagBoolean(args.flags, "no-passphrase")) {
20
+ return undefined;
21
+ }
22
+ return flagString(args.flags, "passphrase", process.env.H402_WALLET_PASSPHRASE);
23
+ }
24
+ // OWS reports any keystore/passphrase disagreement as an AEAD decryption
25
+ // failure: a protected wallet signed without (or with the wrong) passphrase,
26
+ // or a passphrase supplied for a wallet created without one.
27
+ const PASSPHRASE_MISMATCH = /decryption failed/i;
28
+ async function promptBarePassphrase(options = { confirm: false }) {
29
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
30
+ throw new Error("Bare --passphrase prompts interactively; pass --passphrase <s> or set H402_WALLET_PASSPHRASE in non-interactive use.");
31
+ }
32
+ return promptPassphrase(options);
33
+ }
34
+ // Sign with the explicit passphrase (usually none). Only a passphrase-protected
35
+ // keystore escalates: prompt once on an interactive terminal, otherwise fail
36
+ // with the H402_WALLET_PASSPHRASE hint — that env var is only ever needed for
37
+ // wallets that opted into a passphrase at create time.
38
+ export async function signWithWalletPassphrase(args, walletName, sign) {
39
+ // Bare --passphrase = "prompt me" (kept out of shell history/env).
40
+ const explicit = flagBoolean(args.flags, "no-passphrase")
41
+ ? undefined
42
+ : args.flags.passphrase === true
43
+ ? await promptBarePassphrase()
44
+ : explicitPassphrase(args);
45
+ try {
46
+ return await sign(explicit);
47
+ }
48
+ catch (error) {
49
+ if (!(error instanceof Error) || !PASSPHRASE_MISMATCH.test(error.message)) {
50
+ throw error;
51
+ }
52
+ if (explicit !== undefined) {
53
+ throw new Error(`Wallet "${walletName}" rejected the passphrase from --passphrase / H402_WALLET_PASSPHRASE.`);
54
+ }
55
+ if (flagBoolean(args.flags, "no-passphrase")) {
56
+ throw new Error(`Wallet "${walletName}" is passphrase-protected, but --no-passphrase was passed.`);
57
+ }
58
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
59
+ throw new Error(`Wallet "${walletName}" is passphrase-protected. Set H402_WALLET_PASSPHRASE (or pass --passphrase <s>) for non-interactive use.`);
60
+ }
61
+ return sign(await promptPassphrase({ confirm: false }));
62
+ }
63
+ }
64
+ // Passphrase for `wallet create`: none by default (agents sign with zero flags);
65
+ // opt in with `--passphrase <s>` / H402_WALLET_PASSPHRASE, or bare `--passphrase`
66
+ // to be prompted with confirmation.
67
+ export async function createPassphrase(args) {
68
+ if (flagBoolean(args.flags, "no-passphrase")) {
69
+ return undefined;
70
+ }
71
+ if (args.flags.passphrase === true) {
72
+ return promptBarePassphrase({ confirm: true });
73
+ }
74
+ return explicitPassphrase(args);
75
+ }
76
+ function withIdempotencyKey(error, idempotencyKey) {
77
+ const existingDetail = error instanceof CliError ? error.detail : undefined;
78
+ const detail = isRecord(existingDetail)
79
+ ? { ...existingDetail, idempotencyKey }
80
+ : { idempotencyKey, ...(existingDetail === undefined ? {} : { detail: existingDetail }) };
81
+ const message = `${error instanceof Error ? error.message : String(error)} (idempotency-key: ${idempotencyKey})`;
82
+ return new CliError(message, detail);
83
+ }
84
+ function adoptWallet(config, name, address) {
85
+ config.wallets[name] = { address };
86
+ return { name, address };
87
+ }
88
+ function rejectExtraPositionals(args, maxPositionals, commandForHelp, hint) {
89
+ const extra = args.positional.slice(maxPositionals);
90
+ if (extra.length === 0) {
91
+ return;
92
+ }
93
+ const label = extra.length === 1 ? "Unexpected positional argument" : "Unexpected positional arguments";
94
+ const rendered = extra.map((value) => JSON.stringify(value)).join(", ");
95
+ throw new Error(`${label}: ${rendered}. ${hint ?? `Run: h402 ${commandForHelp} --help`}`);
96
+ }
97
+ function isMissingOwsWalletError(error) {
98
+ return error instanceof Error && /(?:not found|does not exist|no wallet|unknown wallet|wallet .* missing)/i.test(error.message);
99
+ }
100
+ function isExistingOwsWalletError(error) {
101
+ return error instanceof Error && /already exists/i.test(error.message);
102
+ }
103
+ async function adoptOwsWalletByName(name, config) {
104
+ try {
105
+ const wallet = await getOwsWallet(name);
106
+ const resolved = adoptWallet(config, wallet.name || name, wallet.address.toLowerCase());
107
+ await updateConfig((current) => {
108
+ adoptWallet(current, resolved.name, resolved.address);
109
+ });
110
+ return resolved;
111
+ }
112
+ catch (error) {
113
+ if (isMissingOwsWalletError(error)) {
114
+ return undefined;
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+ async function adoptOwsWalletByAddress(address, config) {
120
+ const wallets = await listOwsWallets();
121
+ const match = wallets.find((wallet) => wallet.address.toLowerCase() === address);
122
+ if (!match)
123
+ return undefined;
124
+ const resolved = adoptWallet(config, match.name, match.address.toLowerCase());
125
+ await updateConfig((current) => {
126
+ adoptWallet(current, resolved.name, resolved.address);
127
+ });
128
+ return resolved;
129
+ }
130
+ function normalizeOwsWallets(wallets) {
131
+ return wallets.map((wallet) => ({ name: wallet.name, address: wallet.address.toLowerCase() }));
132
+ }
133
+ async function restoreOwsWallets(config) {
134
+ const wallets = await listOwsWallets();
135
+ let changed = false;
136
+ const restored = normalizeOwsWallets(wallets);
137
+ for (const wallet of restored) {
138
+ if (config.wallets[wallet.name]?.address?.toLowerCase() !== wallet.address) {
139
+ adoptWallet(config, wallet.name, wallet.address);
140
+ changed = true;
141
+ }
142
+ }
143
+ if (changed) {
144
+ await updateConfig((current) => {
145
+ for (const wallet of restored) {
146
+ adoptWallet(current, wallet.name, wallet.address);
147
+ }
148
+ });
149
+ }
150
+ return restored;
151
+ }
152
+ // Resolve the wallet that will BOTH sign and own the request address, so the two
153
+ // can never silently diverge. The OWS signer is keyed by wallet *name*, so the
154
+ // address presented upstream must be that wallet's address — not an unrelated
155
+ // `--wallet` string. `--name` selects by name; `--wallet` selects the local
156
+ // wallet that owns that address; if both are given they must agree.
157
+ export async function resolveSigningWallet(args, config) {
158
+ config ??= await loadConfig();
159
+ const explicitAddress = flagString(args.flags, "wallet")?.toLowerCase();
160
+ const explicitName = flagString(args.flags, "name");
161
+ if (explicitName) {
162
+ const address = config.wallets[explicitName]?.address?.toLowerCase();
163
+ if (!address) {
164
+ const adopted = await adoptOwsWalletByName(explicitName, config);
165
+ if (adopted) {
166
+ if (explicitAddress && explicitAddress !== adopted.address) {
167
+ throw new Error(`--wallet ${explicitAddress} does not match wallet "${explicitName}" (${adopted.address}). Omit --wallet or pass the wallet that owns this address.`);
168
+ }
169
+ return adopted;
170
+ }
171
+ throw new Error(`No address known for wallet "${explicitName}". Run: h402 wallet create --name ${explicitName}, or h402 wallet restore to re-adopt existing OWS wallets.`);
172
+ }
173
+ if (explicitAddress && explicitAddress !== address) {
174
+ throw new Error(`--wallet ${explicitAddress} does not match wallet "${explicitName}" (${address}). Omit --wallet or pass the wallet that owns this address.`);
175
+ }
176
+ return { name: explicitName, address };
177
+ }
178
+ if (explicitAddress) {
179
+ const owner = Object.entries(config.wallets).find(([, wallet]) => wallet.address?.toLowerCase() === explicitAddress);
180
+ if (!owner) {
181
+ const adopted = await adoptOwsWalletByAddress(explicitAddress, config);
182
+ if (adopted)
183
+ return adopted;
184
+ throw new Error(`No local wallet owns address ${explicitAddress}. Create it (h402 wallet create), run h402 wallet restore to re-adopt existing OWS wallets, or select one with --name.`);
185
+ }
186
+ return { name: owner[0], address: explicitAddress };
187
+ }
188
+ const address = config.wallets[DEFAULT_WALLET_NAME]?.address?.toLowerCase();
189
+ if (!address) {
190
+ const adopted = await adoptOwsWalletByName(DEFAULT_WALLET_NAME, config);
191
+ if (adopted)
192
+ return adopted;
193
+ throw new Error(`No address known for wallet "${DEFAULT_WALLET_NAME}". Run: h402 wallet create --name ${DEFAULT_WALLET_NAME} (or pass --name/--wallet), or h402 wallet restore to re-adopt existing OWS wallets.`);
194
+ }
195
+ return { name: DEFAULT_WALLET_NAME, address };
196
+ }
197
+ export async function walletCommand(args) {
198
+ const subcommand = requireValue(args.positional[1], "wallet subcommand is required");
199
+ rejectExtraPositionals(args, 2, `wallet ${subcommand}`);
200
+ const name = walletName(args);
201
+ const config = await loadConfig();
202
+ if (subcommand === "create") {
203
+ let wallet;
204
+ try {
205
+ wallet = await createOwsWallet(name, await createPassphrase(args));
206
+ }
207
+ catch (error) {
208
+ if (isExistingOwsWalletError(error)) {
209
+ throw new Error(`Wallet "${name}" already exists in the OWS vault. Run: h402 wallet address --name ${name} to re-adopt and print it, or h402 wallet restore to re-adopt all OWS wallets.`);
210
+ }
211
+ throw error;
212
+ }
213
+ adoptWallet(config, name, wallet.address);
214
+ await updateConfig((current) => {
215
+ adoptWallet(current, name, wallet.address);
216
+ });
217
+ await printJson({ wallet: { name, address: wallet.address } });
218
+ return;
219
+ }
220
+ if (subcommand === "address") {
221
+ await printJson({ wallet: await resolveSigningWallet(args, config) });
222
+ return;
223
+ }
224
+ if (subcommand === "list") {
225
+ await printJson({ wallets: normalizeOwsWallets(await listOwsWallets()) });
226
+ return;
227
+ }
228
+ if (subcommand === "restore") {
229
+ await printJson({ wallets: await restoreOwsWallets(config) });
230
+ return;
231
+ }
232
+ if (subcommand === "balance") {
233
+ const { name: signingName, address } = await resolveSigningWallet(args, config);
234
+ await printJson({
235
+ wallet: { name: signingName, address },
236
+ network: BASE_USDC_BALANCE_NETWORK,
237
+ asset: BASE_USDC_BALANCE_ASSET,
238
+ balance: await getBaseUsdcBalance(address)
239
+ });
240
+ return;
241
+ }
242
+ if (subcommand === "fund") {
243
+ const { name: signingName, address } = await resolveSigningWallet(args, config);
244
+ await printJson({
245
+ wallet: { name: signingName, address },
246
+ network: "base",
247
+ token: "USDC",
248
+ instructions: `Send Base USDC to this address from an exchange, bridge, or another wallet, then run h402 wallet balance --name ${signingName}.`
249
+ });
250
+ return;
251
+ }
252
+ throw new Error(`Unknown wallet subcommand: ${subcommand}`);
253
+ }
254
+ export async function authCommand(args) {
255
+ rejectExtraPositionals(args, 1, "auth");
256
+ const config = await loadConfig();
257
+ const apiUrl = backendUrl(config, flagString(args.flags, "api-url"));
258
+ const { name, address } = await resolveSigningWallet(args, config);
259
+ const challenge = assertOk(await requestJson(apiUrl, "/api/auth/challenge", {
260
+ method: "POST",
261
+ body: JSON.stringify({ address })
262
+ })).challenge;
263
+ const signature = await signWithWalletPassphrase(args, name, (passphrase) => signOwsMessage(name, challenge.message, passphrase));
264
+ const session = assertOk(await requestJson(apiUrl, "/api/auth/verify", {
265
+ method: "POST",
266
+ body: JSON.stringify({ address, message: challenge.message, signature })
267
+ })).session;
268
+ await updateConfig((current) => {
269
+ current.backendUrl = apiUrl;
270
+ current.sessions[apiUrl] = session.token;
271
+ });
272
+ await printJson({ session: { address: session.address, expiresAt: session.expiresAt } });
273
+ }
274
+ function searchLimit(flags) {
275
+ const raw = flagString(flags, "limit", "20");
276
+ if (!/^\d+$/.test(raw) || Number(raw) < 1) {
277
+ throw new Error(`Flag --limit must be a positive integer (got "${raw}").`);
278
+ }
279
+ return raw;
280
+ }
281
+ function rejectQueryOnPost(method, query) {
282
+ if (method === "POST" && query && Object.keys(query).length > 0) {
283
+ throw new Error("Flag --query cannot be combined with POST requests; use --query for GET parameters or --json for a POST body, not both.");
284
+ }
285
+ }
286
+ function explicitProviderFlag(flags) {
287
+ const provider = flagString(flags, "provider");
288
+ if (provider === undefined) {
289
+ return undefined;
290
+ }
291
+ if (!provider) {
292
+ throw new Error("Flag --provider requires a non-empty provider slug.");
293
+ }
294
+ return assertConcreteProvider(provider);
295
+ }
296
+ async function parseProxyInvocation(args, command) {
297
+ rejectExtraPositionals(args, 2, command, `Did you forget --json for a request body or --query for URL parameters? Run: h402 ${command} --help`);
298
+ const routeId = requireValue(args.positional[1], "route id is required");
299
+ encodeRouteId(routeId);
300
+ const body = parseJsonFlag(args.flags);
301
+ const query = parseQueryFlag(args.flags);
302
+ const explicitProvider = explicitProviderFlag(args.flags);
303
+ const method = resolveMethod(args.flags, body !== undefined);
304
+ rejectQueryOnPost(method, query);
305
+ const config = await loadConfig();
306
+ const apiUrl = backendUrl(config, flagString(args.flags, "api-url"));
307
+ return { routeId, body, query, explicitProvider, method, config, apiUrl };
308
+ }
309
+ export async function searchCommand(args) {
310
+ // Validate the required query before any network work.
311
+ const query = requireValue(args.positional.slice(1).join(" ").trim() || undefined, 'search query is required (e.g. h402 search "web search")');
312
+ const config = await loadConfig();
313
+ const apiUrl = backendUrl(config, flagString(args.flags, "api-url"));
314
+ const params = new URLSearchParams({ q: query, limit: searchLimit(args.flags) });
315
+ const result = assertOk(await requestJson(apiUrl, `/api/catalog/search?${params.toString()}`));
316
+ await printJson(result);
317
+ }
318
+ export async function showCommand(args) {
319
+ rejectExtraPositionals(args, 2, "show");
320
+ const routeId = requireValue(args.positional[1], "route id is required");
321
+ encodeRouteId(routeId);
322
+ const provider = explicitProviderFlag(args.flags);
323
+ const config = await loadConfig();
324
+ const apiUrl = backendUrl(config, flagString(args.flags, "api-url"));
325
+ const { route } = await fetchCatalogRoute(apiUrl, routeId);
326
+ if (provider === undefined) {
327
+ await printJson({ route });
328
+ return;
329
+ }
330
+ const candidate = selectCatalogCandidate(route, provider);
331
+ const routeSummary = {
332
+ id: route.id,
333
+ routeKey: route.routeKey,
334
+ category: route.category,
335
+ action: route.action,
336
+ title: route.title,
337
+ summary: route.summary,
338
+ method: route.method,
339
+ tags: route.tags,
340
+ defaultProvider: route.defaultProvider,
341
+ defaultCandidateKey: route.defaultCandidateKey,
342
+ stats: route.stats,
343
+ flow: route.flow,
344
+ flowFollowUps: route.flowFollowUps
345
+ };
346
+ await printJson({
347
+ route: routeSummary,
348
+ candidate,
349
+ providerSelection: explicitShowSelection(apiUrl, routeId, provider, args.flags)
350
+ });
351
+ }
352
+ export async function creditsCommand(args) {
353
+ rejectExtraPositionals(args, 1, "credits");
354
+ const config = await loadConfig();
355
+ const apiUrl = backendUrl(config, flagString(args.flags, "api-url"));
356
+ const token = config.sessions[apiUrl];
357
+ if (!token) {
358
+ throw new Error("No session token. Run h402 auth first.");
359
+ }
360
+ await printJson(assertOk(await requestJson(apiUrl, "/api/me/credits", { token })));
361
+ }
362
+ export async function quoteCommand(args) {
363
+ const { routeId, body, query, explicitProvider, method, apiUrl } = await parseProxyInvocation(args, "quote");
364
+ const providerSelection = await resolveProvider(apiUrl, routeId, explicitProvider, "quote", args.flags);
365
+ try {
366
+ const result = await requestJson(apiUrl, buildProxyPath(routeId, providerSelection.provider, query), {
367
+ method,
368
+ body: body === undefined ? undefined : JSON.stringify(body)
369
+ });
370
+ const paymentRequired = result.status === 402 ? paymentRequiredFromResponse(result.headers, result.body) : null;
371
+ if (paymentRequired) {
372
+ await printJson(withProviderSelection({ paymentRequired }, providerSelection));
373
+ return;
374
+ }
375
+ // No challenge: a free route returns its result with a 2xx. Any non-2xx
376
+ // (404/410/500/...) is a real error and must exit non-zero, not print as a result.
377
+ await printJson(withProviderSelection(assertOk(result), providerSelection));
378
+ }
379
+ catch (error) {
380
+ throw withProviderSelectionError(error, providerSelection);
381
+ }
382
+ }
383
+ function parseUsdMicros(raw, source) {
384
+ if (!/^\d+(?:\.\d{1,6})?$/.test(raw)) {
385
+ throw new Error(`${source} must be a non-negative USD amount with at most 6 decimal places (got "${raw}").`);
386
+ }
387
+ const [whole, fractional = ""] = raw.split(".");
388
+ const micros = BigInt(whole) * 1000000n + BigInt(fractional.padEnd(6, "0"));
389
+ return micros;
390
+ }
391
+ function parseBaseUsdcMicros(raw, source) {
392
+ if (typeof raw !== "string" || !/^\d+$/.test(raw)) {
393
+ throw new Error(`${source} must be an unsigned integer amount in USDC micros (got ${JSON.stringify(raw)}).`);
394
+ }
395
+ return BigInt(raw);
396
+ }
397
+ function formatUsdMicros(micros) {
398
+ const whole = micros / 1000000n;
399
+ const fractional = (micros % 1000000n).toString().padStart(6, "0").replace(/0+$/, "");
400
+ return fractional ? `${whole}.${fractional}` : whole.toString();
401
+ }
402
+ function maxUsd(args, config) {
403
+ if (args.flags["max-usd"] === true) {
404
+ throw new Error("Flag --max-usd requires a USD amount, for example --max-usd 0.05.");
405
+ }
406
+ const raw = flagString(args.flags, "max-usd", config.maxUsd);
407
+ return raw === undefined ? undefined : { raw, micros: parseUsdMicros(raw, "--max-usd / config.maxUsd") };
408
+ }
409
+ function assertUnderMaxUsd(amount, cap) {
410
+ const amountMicros = parseBaseUsdcMicros(amount, "x402 payment amount");
411
+ if (cap && amountMicros > cap.micros) {
412
+ throw new Error(`Payment amount $${formatUsdMicros(amountMicros)} USDC exceeds --max-usd ${cap.raw}; refusing to sign.`);
413
+ }
414
+ return amountMicros;
415
+ }
416
+ function withSignedAmount(body, accepted, amountMicros) {
417
+ const signedAmount = { amount: accepted.amount, asset: "USDC", decimals: 6, usd: formatUsdMicros(amountMicros) };
418
+ return mergeH402(body, { signedAmount });
419
+ }
420
+ function authorizationClockFromResponseDate(headers) {
421
+ const date = headers.get("date");
422
+ if (!date)
423
+ return undefined;
424
+ const millis = Date.parse(date);
425
+ return Number.isFinite(millis) ? Math.floor(millis / 1000) : undefined;
426
+ }
427
+ const PAYMENT_SETTLEMENT_RETRY_DELAYS_MS = [250, 1_000, 2_000];
428
+ const REPLACEMENT_IDEMPOTENCY_KEY_HEADER = "x-h402-replacement-idempotency-key";
429
+ function isPaymentSettlementPending(response) {
430
+ return response.status === 409 && backendErrorCode(response.body) === "payment_settlement_pending";
431
+ }
432
+ function waitForPaymentSettlement(delayMs) {
433
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
434
+ }
435
+ // Once a signed request is sent, a network drop or non-terminal response can
436
+ // hide a completed settlement. Keep that uncertainty visible so callers do not
437
+ // create a second authorization accidentally.
438
+ function withSettlementRiskGuidance(error) {
439
+ const message = error instanceof Error ? error.message : String(error);
440
+ if (message.includes(IDEMPOTENCY_MONEY_GUIDANCE)) {
441
+ return error;
442
+ }
443
+ return new CliError(`${message}. ${IDEMPOTENCY_MONEY_GUIDANCE}`, error instanceof CliError ? error.detail : undefined);
444
+ }
445
+ function isPaymentSettlementFailure(error) {
446
+ return error instanceof CliError && backendErrorCode(error.detail) === "payment_settlement_failed";
447
+ }
448
+ function isConclusiveSettlementFailure(error, idempotencyKey) {
449
+ if (!isPaymentSettlementFailure(error) || !isRecord(error.detail)) {
450
+ return false;
451
+ }
452
+ const backendError = error.detail.error;
453
+ return (isRecord(backendError) &&
454
+ backendError.idempotencyKey === idempotencyKey &&
455
+ backendError.paid === false &&
456
+ backendError.safeToStartNewCall === true);
457
+ }
458
+ function isReplacementPaymentResponse(response) {
459
+ return response.headers.has(REPLACEMENT_IDEMPOTENCY_KEY_HEADER);
460
+ }
461
+ function automaticReplacementRefused(response, idempotencyKey) {
462
+ return withSettlementRiskGuidance(new CliError("Automatic replacement payment refused. This invocation did not sign a replacement authorization. Start a separate explicit h402 call only if you intentionally accept a new payment; the original settlement remains unknown.", {
463
+ code: "automatic_replacement_refused",
464
+ idempotencyKey,
465
+ settlementStatus: "unknown",
466
+ replacementAuthorizationSigned: false,
467
+ separateCallRequired: true,
468
+ url: response.url
469
+ }));
470
+ }
471
+ export async function callCommand(args) {
472
+ const { routeId, body, query, explicitProvider, method, config, apiUrl } = await parseProxyInvocation(args, "call");
473
+ const idempotencyKey = flagString(args.flags, "idempotency-key", randomUUID());
474
+ const paymentCap = maxUsd(args, config);
475
+ const token = config.sessions[apiUrl];
476
+ const providerSelection = await resolveProvider(apiUrl, routeId, explicitProvider, "call", args.flags, paymentCap?.raw);
477
+ let signedRequestSent = false;
478
+ try {
479
+ const path = buildProxyPath(routeId, providerSelection.provider, query);
480
+ const requestBody = body === undefined ? undefined : JSON.stringify(body);
481
+ const headers = {
482
+ "idempotency-key": idempotencyKey
483
+ };
484
+ if (token && !flagBoolean(args.flags, "no-credit")) {
485
+ headers.authorization = `Bearer ${token}`;
486
+ }
487
+ const first = await requestJson(apiUrl, path, {
488
+ method,
489
+ headers,
490
+ body: requestBody
491
+ });
492
+ if (isReplacementPaymentResponse(first)) {
493
+ throw automaticReplacementRefused(first, idempotencyKey);
494
+ }
495
+ const paymentRequired = first.status === 402 ? paymentRequiredFromResponse(first.headers, first.body) : null;
496
+ if (!paymentRequired) {
497
+ // A 2xx means the route answered without payment (free, or covered by credit).
498
+ // A non-2xx first response (incl. an unparseable 402) is a real error: assertOk
499
+ // exits non-zero instead of printing the error body as a successful result.
500
+ await printJson(withProviderSelection(assertOk(first), providerSelection));
501
+ return;
502
+ }
503
+ const accepted = selectBaseUsdcRequirement(paymentRequired);
504
+ const amountMicros = assertUnderMaxUsd(accepted.amount, paymentCap);
505
+ const { name, address: walletAddress } = await resolveSigningWallet(args, config);
506
+ const paymentSignature = await signWithWalletPassphrase(args, name, (passphrase) => createPaymentSignatureHeader({
507
+ paymentRequired,
508
+ walletAddress,
509
+ walletName: name,
510
+ passphrase,
511
+ authorizationNow: authorizationClockFromResponseDate(first.headers)
512
+ }));
513
+ const paidHeaders = {
514
+ "idempotency-key": idempotencyKey,
515
+ [X402_HEADERS.paymentSignature]: paymentSignature
516
+ };
517
+ const sendSignedRequest = () => requestJson(apiUrl, path, { method, headers: paidHeaders, body: requestBody });
518
+ signedRequestSent = true;
519
+ let paid = await sendSignedRequest();
520
+ if (isReplacementPaymentResponse(paid)) {
521
+ throw automaticReplacementRefused(paid, idempotencyKey);
522
+ }
523
+ for (const delayMs of PAYMENT_SETTLEMENT_RETRY_DELAYS_MS) {
524
+ if (!isPaymentSettlementPending(paid))
525
+ break;
526
+ await waitForPaymentSettlement(delayMs);
527
+ paid = await sendSignedRequest();
528
+ if (isReplacementPaymentResponse(paid)) {
529
+ throw automaticReplacementRefused(paid, idempotencyKey);
530
+ }
531
+ }
532
+ await printJson(withProviderSelection(withSignedAmount(assertOk(paid), accepted, amountMicros), providerSelection));
533
+ }
534
+ catch (error) {
535
+ const settlementRiskIsUnresolved = (signedRequestSent || isPaymentSettlementFailure(error)) && !isConclusiveSettlementFailure(error, idempotencyKey);
536
+ const guardedError = settlementRiskIsUnresolved ? withSettlementRiskGuidance(error) : error;
537
+ throw withProviderSelectionError(withIdempotencyKey(guardedError, idempotencyKey), providerSelection);
538
+ }
539
+ }