@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hunt Town
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # @h402/cli
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40h402%2Fcli?label=%40h402%2Fcli)](https://www.npmjs.com/package/@h402/cli)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE)
5
+
6
+ Local, non-custodial CLI for [h402](../../README.md) — the x402 capability store for agents. Search compact summaries, inspect provider-native contracts, execute one concrete provider path, and pay from a local wallet only when challenged over x402. **Private keys never leave your machine.**
7
+
8
+ Building an AI agent? See [`SKILL.md`](../../SKILL.md) for an agent-ready walkthrough.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install -g @h402/cli
14
+ ```
15
+
16
+ > The CLI uses [Open Wallet Standard](https://github.com/open-wallet-standard) core, whose wallet and signing methods lazy-load a platform package.
17
+ >
18
+ > OWS wallet creation and signing use native bindings available only on macOS and glibc-based Linux, on x64 or arm64. Windows, musl/Alpine, and other OS/architecture combinations can still run `--help`, `search`, `quote`, and free-route `call`, but cannot create, list, restore, or auto-adopt wallets, run `h402 auth`, or sign a payable call until OWS ships a matching native binding. `wallet address`, `wallet balance`, and `wallet fund` keep working for wallets already mapped in `~/.h402/config.json` — but USDC funded from an unsupported host can only be spent by signing on a supported platform. Before creating or funding a wallet, run `h402 wallet list` as a read-only native-binding preflight.
19
+
20
+ ## Quickstart
21
+
22
+ ```bash
23
+ h402 search "web search" # compact wallet-free summaries
24
+ h402 show web/search # full route + provider contracts
25
+ h402 show web/search --provider stableenrich-exa # one full native contract
26
+ h402 quote web/search --provider stableenrich-exa --json '{"query":"agent APIs"}'
27
+ h402 call ai/news # free; omitted provider resolves defaultProvider
28
+
29
+ # Only for routes that answer with a payable 402:
30
+ h402 wallet list # read-only native-binding preflight; [] is OK
31
+ h402 wallet create --name agent # local signing wallet
32
+ h402 wallet fund --name agent # Base USDC address + instructions
33
+ h402 call web/search --provider stableenrich-exa --name agent --json '{"query":"agent APIs"}'
34
+ ```
35
+
36
+ Browsing, quoting, and free-route calls do not require a local wallet. Wallet creation creates a local signing wallet only; `h402 auth` creates the optional bonus-credit session. A funded local wallet is required only if the first response is a payable `402`.
37
+
38
+ Calls hit the production backend (`https://h402.hunt.town`) by default — override with `--api-url` or `H402_API_URL` (e.g. `http://localhost:3000` for local dev).
39
+
40
+ ## Commands
41
+
42
+ | Command | Description |
43
+ | --- | --- |
44
+ | `h402 wallet create --name <n>` | Create a local OWS signing wallet (does not create an auth session; prints its address) |
45
+ | `h402 wallet list` | List OWS wallets |
46
+ | `h402 wallet restore` | Re-adopt OWS wallets into `~/.h402/config.json` |
47
+ | `h402 wallet address --name <n>` | Print the wallet address |
48
+ | `h402 wallet balance --name <n>` | Show the wallet's structured Base USDC balance |
49
+ | `h402 wallet fund --name <n>` | Print the Base USDC deposit address and funding instructions |
50
+ | `h402 auth --name <n>` | Create an optional backend bonus-credit session with a wallet signature |
51
+ | `h402 credits` | Show the bonus-credit balance for the signed-in session |
52
+ | `h402 search <query>` | Search compact route/provider summaries |
53
+ | `h402 show <category/action> [--provider <name>]` | Fetch full route or one provider-native contract |
54
+ | `h402 quote <category/action>` | Preview the x402 `PAYMENT-REQUIRED` envelope without paying |
55
+ | `h402 call <category/action>` | Execute a route and pay if challenged; free routes need no wallet |
56
+
57
+ Run `h402 --help`, `h402 <command> --help`, or `h402 wallet <subcommand> --help` for usage and flags, and `h402 --version` for the version. Unknown flags and unknown commands fail with a non-zero exit.
58
+
59
+ ## Flags
60
+
61
+ | Flag | Applies to | Description |
62
+ | --- | --- | --- |
63
+ | `--name <wallet>` | wallet create/address/balance/fund; auth; call | Wallet to use (default `h402`) |
64
+ | `--wallet 0x...` | wallet address/balance/fund; auth; call | Sign with the local wallet that owns this address (must exist locally; must agree with `--name` if both are passed) |
65
+ | `--api-url <url>` | auth, credits, search, show, quote, call | Backend base URL override (or `H402_API_URL`; default `https://h402.hunt.town`) |
66
+ | `--json '{...}'` | quote, call | Request body (sets method to POST) |
67
+ | `--query '{...}'` | quote, call | URL query params (GET); values must be strings/numbers/booleans |
68
+ | `--provider <name>` | show, quote, call | Select a concrete provider; quote/call omission resolves the catalog default, while show omission lists all enabled providers |
69
+ | `--method GET\|POST` | quote, call | Override the method (inferred from `--json` otherwise) |
70
+ | `--passphrase [<s>]` | wallet create, auth, call | Passphrase for a passphrase-protected wallet; omit the value to be prompted (or `H402_WALLET_PASSPHRASE`) |
71
+ | `--no-passphrase` | wallet create, auth, call | Force passphrase-less signing even if `H402_WALLET_PASSPHRASE` is set (the default needs no flag) |
72
+ | `--no-credit` | call | Ignore bonus credits and pay x402 only |
73
+ | `--max-usd <usd>` | call | Optional client-side cap; refuse to sign if the quoted Base USDC amount exceeds it |
74
+ | `--idempotency-key <uuid>` | call | Stable key for safe retries (default: random) |
75
+ | `--limit <n>` | search | Max results (default `20`) |
76
+
77
+ Route ids are `category/action`, e.g. `web/search`, `maps/place-details`, `finance/stock-quote`. `--query` takes one scalar value per key (string, number, or boolean); pass arrays, nested objects, or request bodies with `--json` instead.
78
+
79
+ ## How a call works
80
+
81
+ Each call uses one concrete provider. Without `--provider`, the CLI resolves the route's current `defaultProvider` from `/api/catalog/routes/<route>` before any execution request; explicit `--provider` goes straight to that pinned path. Successes include `h402.cliProviderSelection`, and post-resolution failures include the same metadata at `error.detail.h402.cliProviderSelection`. Its `pinnedCommand` is a shell-escaped fresh-call recipe that preserves non-secret request, backend, wallet, and payment-safety flags and omits passphrases and the previous idempotency key. A `410` response is never retried automatically — inspect `error.detail.error.candidates` with `h402 show`, then start a new explicit call. Unknown routes preserve the server's `error.detail.error.recovery.command` search guidance.
82
+
83
+ ```
84
+ h402 call web/search --json '{"query":"..."}'
85
+
86
+ ├─ resolve defaultProvider from full route detail
87
+ ├─ request /routes/<provider>/web/search (before wallet resolution)
88
+ ├─ 2xx → returned directly; h402.paidBy says free or credit
89
+ └─ payable 402 → resolve wallet, sign Base USDC locally, then retry that same pinned request
90
+ ```
91
+
92
+ If a route returns a payable 402, you're charged the exact per-call price (most paid
93
+ routes are $0.001–$0.05). An initial 2xx is returned directly — `h402.paidBy` says whether it was `free` (no charge) or covered by bonus `credit` from an authenticated session. Run `h402 quote`
94
+ first to see a payable route's price without paying. Pass `--max-usd <amount>` on
95
+ `call` (or store a string `maxUsd`, such as `"0.05"`, in `~/.h402/config.json`)
96
+ to refuse signing a challenge above that USDC cap. Paid call output includes `h402.signedAmount` so agents
97
+ can record the amount they signed. The CLI uses the first 402 response's `Date` header
98
+ when building the EIP-3009 validity window, reducing client clock-skew failures on paid
99
+ calls. If you've run `h402 auth`, bonus credits are drawn before USDC unless you pass
100
+ `--no-credit`.
101
+
102
+ `--idempotency-key` is double-charge protection, not result replay. If the server reports
103
+ `payment_settlement_pending`, the running CLI resends the exact `PAYMENT-SIGNATURE`, key,
104
+ method, path, provider, and body for bounded reconciliation attempts. One CLI invocation
105
+ creates at most one payment authorization: server-issued replacement challenges are
106
+ refused, and a separate explicit call is required to create a new payment. The CLI does
107
+ not persist payment signatures, so after the process exits it cannot reconstruct the
108
+ original signed request. Pending, reconciled, network, and gateway errors after a signed
109
+ send warn that settlement may still have occurred; only a matching
110
+ `payment_settlement_failed` response with `paid: false` and `safeToStartNewCall: true`
111
+ confirms that the original authorization was not paid.
112
+
113
+ ## Agents & automation
114
+
115
+ Every command prints JSON to stdout — `search`, `show`, `quote`, `call`, `auth`, `credits`, and `wallet create`/`list`/`restore`/`address`/`balance`/`fund`.
116
+
117
+ A successful `call` is wrapped as `{ "data": <provider-native body>, "meta"?: <reserved envelope metadata>, "h402": <execution metadata> }` — `data` remains provider-native, optional `meta` remains reserved envelope metadata rather than normalized provider output, and `h402` includes the provider-pinned execution receipt plus CLI-added `cliProviderSelection`. `ledgerEntryId` is present for credit or x402-paid calls; `paymentTransaction` and CLI-added `signedAmount` are x402-payment-only fields; free calls omit all three. Optional `h402.followUp` describes async work. A failed call exits non-zero and writes `{ "error": { "message", "detail"? } }` to stderr; `detail` preserves the backend recovery body unchanged.
118
+
119
+ Async routes may return a job receipt instead of the final result. Async parent route IDs end in `-async`; a single-parent follow-up is `<parent-route>-status`, while shared multi-parent follow-ups may use a shared `*-status` name. When `h402.followUp` is present, pass its provider-native `params` object according to `method` and preserve the provider segment from `path`. Match `followUp.method` — GET params go via `--query`, POST bodies via `--json`; the CLI rejects `--query` on a POST (`<followUp.params>` means its JSON-encoded object):
120
+
121
+ ```bash
122
+ # followUp.method GET (most status polls):
123
+ h402 call <followUp.routeId> \
124
+ --provider <provider-from-followUp.path> \
125
+ --query '<followUp.params>'
126
+
127
+ # followUp.method POST (e.g. ai/music-generate-async-status):
128
+ h402 call <followUp.routeId> \
129
+ --provider <provider-from-followUp.path> \
130
+ --json '<followUp.params>'
131
+ ```
132
+
133
+ `h402 search` intentionally returns compact summaries. Fetch full schemas, request examples, and provider-native samples with `h402 show <route>` before pinning.
134
+
135
+ ```bash
136
+ h402 search "token holders" # compact JSON to stdout
137
+ h402 show crypto/token-holders --provider nansen # full native schema/sample
138
+ h402 call crypto/token-holders --provider nansen --name agent \
139
+ --json '{"chain":"ethereum","token_address":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"}' # JSON result, non-zero exit on failure
140
+ ```
141
+
142
+ Signing needs no flags for the default passphrase-less wallets. Only when a wallet was created with an opt-in passphrase, `export H402_WALLET_PASSPHRASE=...` (or pass `--passphrase <s>`) — the CLI tells you exactly this when it hits such a wallet non-interactively.
143
+
144
+ ## Environment
145
+
146
+ | Variable | Purpose |
147
+ | --- | --- |
148
+ | `H402_API_URL` | Backend base URL override (or `--api-url`; default `https://h402.hunt.town`) |
149
+ | `H402_WALLET_PASSPHRASE` | Passphrase for passphrase-protected wallets (only needed when the wallet was created with one) |
150
+
151
+ Passphrases are never stored. Wallets are passphrase-less by default; opt in at create time (`--passphrase <s>`, or bare `--passphrase` to be prompted) when a wallet guards meaningful funds. The CLI persists only the backend URL, session tokens, and known wallet addresses in `~/.h402/config.json`.
152
+
153
+ ## Contributing
154
+
155
+ ```bash
156
+ npm install
157
+ npm run -w @h402/core build
158
+ npm run -w @h402/cli typecheck
159
+ npm run -w @h402/cli lint
160
+ npm run -w @h402/cli test
161
+ ```
162
+
163
+ ## License
164
+
165
+ MIT
package/dist/api.js ADDED
@@ -0,0 +1,134 @@
1
+ import { Agent } from "undici";
2
+ import { CliError } from "./errors.js";
3
+ import { getVersion } from "./help.js";
4
+ import { isRecord } from "./utils.js";
5
+ export const H402_HTTP_TIMEOUT_MS = 450_000;
6
+ const h402FetchDispatcher = new Agent({
7
+ headersTimeout: H402_HTTP_TIMEOUT_MS,
8
+ bodyTimeout: H402_HTTP_TIMEOUT_MS
9
+ });
10
+ function networkErrorMessage(error) {
11
+ const cause = isRecord(error) ? error.cause : undefined;
12
+ if (isRecord(cause)) {
13
+ const code = cause.code;
14
+ if (typeof code === "string" && code) {
15
+ return code;
16
+ }
17
+ const message = cause.message;
18
+ if (typeof message === "string" && message) {
19
+ return message;
20
+ }
21
+ }
22
+ return error instanceof Error ? error.message : String(error);
23
+ }
24
+ export async function requestJson(backendUrl, path, init = {}) {
25
+ const headers = new Headers(init.headers);
26
+ headers.set("accept", "application/json");
27
+ if (!headers.has("user-agent")) {
28
+ headers.set("user-agent", `h402-cli/${getVersion()}`);
29
+ }
30
+ if (init.body && !headers.has("content-type")) {
31
+ headers.set("content-type", "application/json");
32
+ }
33
+ if (init.token) {
34
+ headers.set("authorization", `Bearer ${init.token}`);
35
+ }
36
+ const fetchInit = { ...init };
37
+ delete fetchInit.token;
38
+ const url = `${backendUrl}${path}`;
39
+ let response;
40
+ try {
41
+ response = await fetch(url, {
42
+ ...fetchInit,
43
+ headers,
44
+ dispatcher: h402FetchDispatcher
45
+ });
46
+ }
47
+ catch (error) {
48
+ throw new CliError(`Request to ${url} failed: ${networkErrorMessage(error)}`, { backendUrl, url });
49
+ }
50
+ const text = await response.text();
51
+ let body;
52
+ try {
53
+ body = text ? JSON.parse(text) : null;
54
+ }
55
+ catch {
56
+ // A non-JSON body (an HTML 502 page, a plain-text gateway error) must not crash the
57
+ // parse — keep the raw text so assertOk can surface it instead of throwing here.
58
+ body = text;
59
+ }
60
+ return { backendUrl, url, status: response.status, statusText: response.statusText, body, headers: response.headers };
61
+ }
62
+ function responseContext(response) {
63
+ return { backendUrl: response.backendUrl, url: response.url };
64
+ }
65
+ function responseErrorDetail(response) {
66
+ const context = responseContext(response);
67
+ const { body } = response;
68
+ if (isRecord(body)) {
69
+ return { ...body, ...context };
70
+ }
71
+ return context;
72
+ }
73
+ // Pull a human-readable message out of a backend error body — covering the common
74
+ // { error: { message } }, { error: "..." }, and { message } shapes — so the stderr
75
+ // envelope's `message` is useful even before a caller inspects `detail`.
76
+ function backendMessage(body) {
77
+ if (!isRecord(body)) {
78
+ return undefined;
79
+ }
80
+ const error = body.error;
81
+ if (typeof error === "string") {
82
+ return error;
83
+ }
84
+ if (isRecord(error) && typeof error.message === "string") {
85
+ return error.message;
86
+ }
87
+ return typeof body.message === "string" ? body.message : undefined;
88
+ }
89
+ export function backendErrorCode(body) {
90
+ if (!isRecord(body)) {
91
+ return undefined;
92
+ }
93
+ const error = body.error;
94
+ if (isRecord(error) && typeof error.code === "string") {
95
+ return error.code;
96
+ }
97
+ return typeof body.code === "string" ? body.code : undefined;
98
+ }
99
+ const MONEY_SENSITIVE_IDEMPOTENCY_CODES = new Set([
100
+ "idempotency_key_already_used",
101
+ "idempotency_key_in_progress",
102
+ "idempotency_key_conflict",
103
+ "payment_settlement_pending",
104
+ "payment_settlement_reconciled"
105
+ ]);
106
+ export const IDEMPOTENCY_MONEY_GUIDANCE = "The earlier request for this idempotency key may already be completed, charged, or still settling; do NOT sign or pay with a new idempotency key unless you intentionally accept a second charge.";
107
+ function idempotencyGuidance(body) {
108
+ const code = backendErrorCode(body);
109
+ if (!code || !MONEY_SENSITIVE_IDEMPOTENCY_CODES.has(code)) {
110
+ return undefined;
111
+ }
112
+ return IDEMPOTENCY_MONEY_GUIDANCE;
113
+ }
114
+ export function assertOk(response) {
115
+ if (response.status < 200 || response.status >= 300) {
116
+ const { body } = response;
117
+ const statusLine = `${response.status}${response.statusText ? ` ${response.statusText}` : ""}`;
118
+ // Empty body (a framework 405, an infra 502/504): status only — never the literal "null".
119
+ if (body === null || body === undefined || body === "") {
120
+ throw new CliError(`Request failed: ${statusLine}`, responseContext(response));
121
+ }
122
+ // Non-JSON string body (an HTML 502 page, a plain-text gateway error): show it verbatim.
123
+ if (typeof body === "string") {
124
+ throw new CliError(`Request failed: ${statusLine}: ${body}`, responseContext(response));
125
+ }
126
+ // Structured JSON error: summarize its message, and carry the full body as `detail`
127
+ // so the stderr error envelope stays machine-readable.
128
+ const message = backendMessage(body);
129
+ const guidance = idempotencyGuidance(body);
130
+ const summary = message ? `Request failed: ${statusLine}: ${message}` : `Request failed: ${statusLine}`;
131
+ throw new CliError(guidance ? `${summary}. ${guidance}` : summary, responseErrorDetail(response));
132
+ }
133
+ return response.body;
134
+ }
@@ -0,0 +1,161 @@
1
+ import { BASE_CHAIN_ID, BASE_USDC_ADDRESS, USDC_DECIMALS } from "@h402/core";
2
+ export const BASE_RPC_URLS = [
3
+ "https://base-rpc.publicnode.com",
4
+ "https://base.drpc.org",
5
+ "https://mainnet.base.org",
6
+ "https://1rpc.io/base"
7
+ ];
8
+ const BALANCE_OF_SELECTOR = "70a08231";
9
+ const RPC_TIMEOUT_MS = 5_000;
10
+ const EVM_ADDRESS = /^0[xX][0-9a-fA-F]{40}$/;
11
+ export const BASE_USDC_BALANCE_NETWORK = {
12
+ name: "base",
13
+ chainId: BASE_CHAIN_ID
14
+ };
15
+ export const BASE_USDC_BALANCE_ASSET = {
16
+ symbol: "USDC",
17
+ address: BASE_USDC_ADDRESS,
18
+ decimals: USDC_DECIMALS
19
+ };
20
+ function normalizeAddress(address) {
21
+ if (!EVM_ADDRESS.test(address)) {
22
+ throw new Error(`Invalid EVM address: ${address}`);
23
+ }
24
+ return address.toLowerCase();
25
+ }
26
+ export function balanceOfCalldata(address) {
27
+ const normalized = normalizeAddress(address);
28
+ return `0x${BALANCE_OF_SELECTOR}${normalized.slice(2).padStart(64, "0")}`;
29
+ }
30
+ function errorMessage(error) {
31
+ return error instanceof Error ? error.message : String(error);
32
+ }
33
+ function jsonRpcErrorMessage(error) {
34
+ if (!error || typeof error !== "object") {
35
+ return String(error);
36
+ }
37
+ const record = error;
38
+ const code = typeof record.code === "number" || typeof record.code === "string" ? `${record.code}: ` : "";
39
+ const message = typeof record.message === "string" ? record.message : JSON.stringify(error);
40
+ return `${code}${message}`;
41
+ }
42
+ function parseRpcBalance(body) {
43
+ if (!body || typeof body !== "object") {
44
+ throw new Error("RPC returned a non-object response");
45
+ }
46
+ const response = body;
47
+ if (response.error !== undefined) {
48
+ throw new Error(`RPC error: ${jsonRpcErrorMessage(response.error)}`);
49
+ }
50
+ if (typeof response.result !== "string" || !/^0x[0-9a-fA-F]*$/.test(response.result)) {
51
+ throw new Error("RPC returned an invalid balance result");
52
+ }
53
+ return BigInt(response.result === "0x" ? "0x0" : response.result);
54
+ }
55
+ async function rpcBalance(url, calldata, fetchFn, signal) {
56
+ const response = await fetchFn(url, {
57
+ method: "POST",
58
+ headers: {
59
+ accept: "application/json",
60
+ "content-type": "application/json"
61
+ },
62
+ body: JSON.stringify({
63
+ jsonrpc: "2.0",
64
+ id: 1,
65
+ method: "eth_call",
66
+ params: [
67
+ {
68
+ to: BASE_USDC_ADDRESS,
69
+ data: calldata
70
+ },
71
+ "latest"
72
+ ]
73
+ }),
74
+ signal
75
+ });
76
+ const text = await response.text();
77
+ if (!response.ok) {
78
+ throw new Error(`HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}${text ? `: ${text}` : ""}`);
79
+ }
80
+ let body;
81
+ try {
82
+ body = JSON.parse(text);
83
+ }
84
+ catch {
85
+ throw new Error("RPC returned non-JSON response");
86
+ }
87
+ return parseRpcBalance(body);
88
+ }
89
+ function quorumUnavailableError() {
90
+ return new Error("Base USDC balance is temporarily unavailable: RPC quorum failed; need two matching Base RPC responses.");
91
+ }
92
+ function firstMatchingQuorum(calls, rpcUrls) {
93
+ return new Promise((resolve, reject) => {
94
+ let settled = 0;
95
+ let finished = false;
96
+ const values = new Map();
97
+ for (const call of calls) {
98
+ call
99
+ .then((value) => {
100
+ if (finished)
101
+ return;
102
+ settled += 1;
103
+ const key = value.toString();
104
+ const entry = values.get(key) ?? { value, count: 0 };
105
+ entry.count += 1;
106
+ values.set(key, entry);
107
+ if (entry.count >= 2) {
108
+ finished = true;
109
+ resolve(entry.value);
110
+ return;
111
+ }
112
+ if (settled === rpcUrls.length) {
113
+ finished = true;
114
+ reject(quorumUnavailableError());
115
+ }
116
+ })
117
+ .catch(() => {
118
+ if (finished)
119
+ return;
120
+ settled += 1;
121
+ if (settled === rpcUrls.length) {
122
+ finished = true;
123
+ reject(quorumUnavailableError());
124
+ }
125
+ });
126
+ }
127
+ });
128
+ }
129
+ function formatUsdc(microUsdc) {
130
+ const scale = 10n ** BigInt(USDC_DECIMALS);
131
+ const whole = microUsdc / scale;
132
+ const fraction = (microUsdc % scale).toString().padStart(USDC_DECIMALS, "0");
133
+ return `${whole}.${fraction}`;
134
+ }
135
+ export async function getBaseUsdcBalance(address, options = {}) {
136
+ const rpcUrls = options.rpcUrls ?? BASE_RPC_URLS;
137
+ if (rpcUrls.length < 2) {
138
+ throw new Error("At least two Base RPC URLs are required for quorum.");
139
+ }
140
+ const fetchFn = options.fetchFn ?? fetch;
141
+ const calldata = balanceOfCalldata(address);
142
+ const timeoutMs = options.timeoutMs ?? RPC_TIMEOUT_MS;
143
+ const controllers = rpcUrls.map(() => new AbortController());
144
+ const timers = controllers.map((controller) => setTimeout(() => controller.abort(), timeoutMs));
145
+ const calls = rpcUrls.map((url, index) => rpcBalance(url, calldata, fetchFn, controllers[index].signal).catch((error) => {
146
+ throw new Error(`${url}: ${errorMessage(error)}`);
147
+ }));
148
+ try {
149
+ const microUsdc = await firstMatchingQuorum(calls, rpcUrls);
150
+ return {
151
+ microUsdc: microUsdc.toString(),
152
+ usdc: formatUsdc(microUsdc)
153
+ };
154
+ }
155
+ finally {
156
+ for (const timer of timers)
157
+ clearTimeout(timer);
158
+ for (const controller of controllers)
159
+ controller.abort();
160
+ }
161
+ }
@@ -0,0 +1,144 @@
1
+ import { assertOk, requestJson } from "./api.js";
2
+ import { CliError } from "./errors.js";
3
+ import { assertConcreteProvider, encodeRouteId, isRecord, mergeH402 } from "./utils.js";
4
+ const PINNED_COMMAND_FLAGS = {
5
+ call: ["api-url", "json", "query", "method", "name", "wallet", "no-passphrase", "no-credit", "max-usd"],
6
+ quote: ["api-url", "json", "query", "method"],
7
+ show: ["api-url"]
8
+ };
9
+ const BOOLEAN_PINNED_COMMAND_FLAGS = new Set(["no-passphrase", "no-credit"]);
10
+ function shellArg(value) {
11
+ return /^[A-Za-z0-9_./:@+-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
12
+ }
13
+ function pinnedCommand(command, routeId, provider, flags) {
14
+ const parts = ["h402", command, shellArg(routeId), "--provider", shellArg(provider)];
15
+ for (const name of PINNED_COMMAND_FLAGS[command]) {
16
+ const value = flags[name];
17
+ if (value === undefined)
18
+ continue;
19
+ if (BOOLEAN_PINNED_COMMAND_FLAGS.has(name)) {
20
+ if (value === true || value === "true")
21
+ parts.push(`--${name}`);
22
+ continue;
23
+ }
24
+ if (typeof value === "string")
25
+ parts.push(`--${name}`, shellArg(value));
26
+ }
27
+ return parts.join(" ");
28
+ }
29
+ function selection(command, routeId, provider, source, flags) {
30
+ return {
31
+ source,
32
+ provider,
33
+ pinnedCommand: pinnedCommand(command, routeId, provider, flags)
34
+ };
35
+ }
36
+ function recoveryCandidates(route) {
37
+ const encodedRoute = encodeRouteId(route.id);
38
+ return route.candidates.map((candidate) => ({
39
+ provider: candidate.provider,
40
+ ...(typeof candidate.candidateKey === "string" ? { candidateKey: candidate.candidateKey } : {}),
41
+ ...(typeof candidate.status === "string" ? { status: candidate.status } : {}),
42
+ path: `/routes/${encodeURIComponent(candidate.provider)}/${encodedRoute}`
43
+ }));
44
+ }
45
+ function invalidCatalogResponse(routeId, message, detail) {
46
+ throw new CliError(`Catalog detail for ${routeId} is invalid: ${message}`, {
47
+ error: { code: "invalid_catalog_response", message },
48
+ routeId,
49
+ ...(detail === undefined ? {} : { detail })
50
+ });
51
+ }
52
+ function parseCatalogRoute(routeId, body) {
53
+ if (!isRecord(body) || !isRecord(body.route)) {
54
+ return invalidCatalogResponse(routeId, "expected a route object");
55
+ }
56
+ const route = body.route;
57
+ if (typeof route.id !== "string" || route.id !== routeId) {
58
+ return invalidCatalogResponse(routeId, "id does not match the requested route", { id: route.id });
59
+ }
60
+ if (typeof route.defaultProvider !== "string" || !route.defaultProvider) {
61
+ return invalidCatalogResponse(routeId, "defaultProvider is missing");
62
+ }
63
+ try {
64
+ assertConcreteProvider(route.defaultProvider);
65
+ }
66
+ catch (error) {
67
+ return invalidCatalogResponse(routeId, "defaultProvider is not a concrete provider slug", {
68
+ defaultProvider: route.defaultProvider,
69
+ reason: error instanceof Error ? error.message : String(error)
70
+ });
71
+ }
72
+ if (!Array.isArray(route.candidates) || route.candidates.length === 0) {
73
+ return invalidCatalogResponse(routeId, "enabled candidates are missing");
74
+ }
75
+ const candidates = route.candidates.map((candidate, index) => {
76
+ if (!isRecord(candidate) || typeof candidate.provider !== "string" || !candidate.provider) {
77
+ return invalidCatalogResponse(routeId, `candidate ${index} lacks provider`);
78
+ }
79
+ try {
80
+ assertConcreteProvider(candidate.provider);
81
+ }
82
+ catch (error) {
83
+ return invalidCatalogResponse(routeId, `candidate ${index} has an invalid provider slug`, {
84
+ provider: candidate.provider,
85
+ reason: error instanceof Error ? error.message : String(error)
86
+ });
87
+ }
88
+ return candidate;
89
+ });
90
+ if (!candidates.some((candidate) => candidate.provider === route.defaultProvider && candidate.status === "enabled")) {
91
+ return invalidCatalogResponse(routeId, "defaultProvider is not an enabled candidate", {
92
+ defaultProvider: route.defaultProvider,
93
+ candidates: recoveryCandidates({ ...route, candidates })
94
+ });
95
+ }
96
+ return { ...route, candidates };
97
+ }
98
+ export async function fetchCatalogRoute(apiUrl, routeId) {
99
+ const body = assertOk(await requestJson(apiUrl, `/api/catalog/routes/${encodeRouteId(routeId)}`));
100
+ return { route: parseCatalogRoute(routeId, body) };
101
+ }
102
+ export async function resolveProvider(apiUrl, routeId, explicitProvider, command, flags, effectiveMaxUsd) {
103
+ const effectiveFlags = {
104
+ ...flags,
105
+ "api-url": apiUrl,
106
+ ...(effectiveMaxUsd === undefined ? {} : { "max-usd": effectiveMaxUsd })
107
+ };
108
+ if (explicitProvider !== undefined) {
109
+ return selection(command, routeId, assertConcreteProvider(explicitProvider), "explicit", effectiveFlags);
110
+ }
111
+ const { route } = await fetchCatalogRoute(apiUrl, routeId);
112
+ return selection(command, routeId, route.defaultProvider, "catalog-default", effectiveFlags);
113
+ }
114
+ export function selectCatalogCandidate(route, provider) {
115
+ const candidate = route.candidates.find((item) => item.provider === provider);
116
+ if (candidate) {
117
+ return candidate;
118
+ }
119
+ const message = `Provider "${provider}" is not enabled for ${route.id}.`;
120
+ throw new CliError(message, {
121
+ error: {
122
+ code: "provider_unavailable",
123
+ message,
124
+ routeId: route.id,
125
+ requestedProvider: provider,
126
+ defaultProvider: route.defaultProvider,
127
+ candidates: recoveryCandidates(route)
128
+ }
129
+ });
130
+ }
131
+ export function explicitShowSelection(apiUrl, routeId, provider, flags) {
132
+ return selection("show", routeId, provider, "explicit", { ...flags, "api-url": apiUrl });
133
+ }
134
+ export function withProviderSelection(body, providerSelection) {
135
+ return mergeH402(body, { cliProviderSelection: providerSelection });
136
+ }
137
+ export function withProviderSelectionError(error, providerSelection) {
138
+ const existingDetail = error instanceof CliError && isRecord(error.detail) ? error.detail : {};
139
+ const h402 = isRecord(existingDetail.h402) ? existingDetail.h402 : {};
140
+ return new CliError(error instanceof Error ? error.message : String(error), {
141
+ ...existingDetail,
142
+ h402: { ...h402, cliProviderSelection: providerSelection }
143
+ });
144
+ }