diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e8ac552 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: cli-ci + +on: + pull_request: + push: + branches: [main] + tags: ["v*", "test-v*"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20.11" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=high + + - name: Verify packed CLI + run: npm run test:package diff --git a/README.md b/README.md index e6e7686..c1cc473 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,25 @@ TypeScript command-line client for BankofAI x402 payments. This version uses the npm TypeScript SDK packages only: -- `@bankofai/x402-core@1.0.0` -- `@bankofai/x402-evm@1.0.0` -- `@bankofai/x402-tron@1.0.0` +- `@bankofai/x402-core@1.0.1` +- `@bankofai/x402-evm@1.0.1` +- `@bankofai/x402-tron@1.0.1` -Stablecoin payments use `scheme=exact` with -`extra.assetTransferMethod=permit2`. +Stablecoin payments support `scheme=exact` and TRON `scheme=exact_gasfree`. +The GasFree flow lets the relayer pay network energy while deducting its fee +from the payment token, so the payer does not need TRX. ## Install +Install the CLI package: + +```bash +npm install -g @bankofai/x402-cli@1.0.1 +x402-cli --version +``` + +For repository development: + ```bash npm install npm run build @@ -20,7 +30,7 @@ npm run build Run from source during development: ```bash -npm run dev -- serve --pay-to --amount 0.0001 --network tron:nile --token USDT +npm run dev -- serve --pay-to --amount 0.0001 --network tron:0xcd8690dc --token USDT ``` Run the compiled CLI: @@ -48,10 +58,10 @@ envelope with `ok`, `command`, `result`, or structured `error` fields. Start a local x402 paywall endpoint: ```bash -node dist/cli.js serve \ +x402-cli serve \ --pay-to \ --amount 0.0001 \ - --network tron:nile \ + --network tron:0xcd8690dc \ --token USDT \ --port 4020 ``` @@ -60,8 +70,8 @@ The server exposes: - `GET /health` - `GET /.well-known/x402` -- `GET /pay` returns `402 Payment Required` -- `POST /pay` verifies and settles with the facilitator +- `/pay` returns `402 Payment Required` without a payment signature +- The signed retry uses the same HTTP method, then verifies and settles with the facilitator ### Pay @@ -69,12 +79,53 @@ Pay an x402-protected URL: ```bash TRON_PRIVATE_KEY= \ -node dist/cli.js pay http://127.0.0.1:4020/pay \ - --network tron:nile \ +x402-cli pay http://127.0.0.1:4020/pay \ + --network tron:0xcd8690dc \ --token USDT ``` +For automated or unfamiliar endpoints, set `--max-amount` or +`--max-raw-amount` before allowing the CLI to sign a payment. + +Pay a TRON GasFree endpoint (the CLI normally selects this automatically from +the server challenge): + +```bash +TRON_PRIVATE_KEY= \ +x402-cli pay https://api.example.com/pay \ + --network tron:0xcd8690dc \ + --token USDT \ + --scheme exact_gasfree +``` + +Use `--gasfree-api-url ` or `X402_GASFREE_API_URL` to override the SDK's +default relayer endpoint. + +GasFree fees are separate from the advertised payment amount. Set a fee limit +so the CLI estimates the relayer fee and rejects the payment before signing if +the estimate is too high: + +```bash +x402-cli pay https://api.example.com/pay \ + --scheme exact_gasfree \ + --max-amount 0.01 \ + --max-gasfree-fee 0.5 \ + --json +``` + +Use `--max-gasfree-fee-raw` to express the fee limit in the token's smallest +unit. Successful and failed paid responses distinguish `settled` (payment +completed) from `delivered` (HTTP business response succeeded). A settled +upstream failure has `paid=true`, `settled=true`, and `delivered=false` and +includes its transaction information. + For EVM networks use `EVM_PRIVATE_KEY` or `PRIVATE_KEY`. +Prefer environment variables over `--private-key` in shared environments, +because command-line arguments may be visible to other local processes. + +If the gateway settles a payment but the upstream request fails, JSON error +output includes `error.details.paymentResponse` for reconciliation. Do not retry +such a request blindly; inspect the transaction and provider behavior first. ### Roundtrip @@ -82,10 +133,10 @@ Start a temporary local server and immediately pay it: ```bash TRON_PRIVATE_KEY= \ -node dist/cli.js roundtrip \ +x402-cli roundtrip \ --pay-to \ --amount 0.0001 \ - --network tron:nile \ + --network tron:0xcd8690dc \ --token USDT ``` @@ -93,16 +144,16 @@ node dist/cli.js roundtrip \ Supported built-in token registry: -- `tron:mainnet` USDT, USDD -- `tron:nile` USDT, USDD -- `tron:shasta` USDT +- `tron:0x2b6653dc` USDT, USDD +- `tron:0xcd8690dc` USDT, USDD +- `tron:0x94a9059e` USDT - `eip155:56` USDT - `eip155:97` USDT, USDC -Aliases accepted: +Non-CAIP TRON aliases are rejected. Use the canonical TRON IDs above. + +EVM convenience aliases accepted: -- `tron-mainnet` -> `tron:mainnet` -- `tron-nile` -> `tron:nile` - `bsc-mainnet` -> `eip155:56` - `bsc-testnet` -> `eip155:97` @@ -114,5 +165,6 @@ Pass a facilitator URL when needed: x402-cli serve --facilitator-url https://facilitator.bankofai.io ... ``` -CLI payment challenges and payload selection always emit `scheme: "exact"` for -the SDK 1.0 Permit2 path. +`serve --scheme exact_gasfree` advertises a TRON GasFree requirement. The +configured facilitator must advertise and settle `exact_gasfree` for that +network and token. diff --git a/package-lock.json b/package-lock.json index cfd193e..932f181 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,19 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { - "@bankofai/x402-core": "1.0.0", - "@bankofai/x402-evm": "1.0.0", - "@bankofai/x402-gateway": "^1.0.0", - "@bankofai/x402-tron": "1.0.0", + "@bankofai/x402-core": "1.0.1", + "@bankofai/x402-evm": "1.0.1", + "@bankofai/x402-gateway": "1.0.1", + "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", - "viem": "^2.55.0", - "yaml": "^2.8.2" + "viem": "^2.55.0" }, "bin": { "x402-cli": "dist/cli.js" @@ -47,33 +46,31 @@ } }, "node_modules/@bankofai/x402-core": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.0.0.tgz", - "integrity": "sha512-uVvvXCGfk/HqusxcKmjeTgBlJBMTR7QPZH2IDZlqoliv5O36YASaHSgSo/JOKDvVzPoXlY+5VmxEG6EdWA+wGA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.0.1.tgz", + "integrity": "sha512-2D4W1dTIlaHFrK+C6tazk12iERAcofHMHwG9qFuUhC4Dubhma4YJfpDsbbXQm5QXOsHDMOCTItpdp7nvZoLCeQ==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.2" } }, "node_modules/@bankofai/x402-evm": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-evm/-/x402-evm-1.0.0.tgz", - "integrity": "sha512-j0T88Y5ngItIS9FTWXtbvoITePISzhuurU640dDL9075uQxBiLzYt20rkPebM7gi9D00HTbyJXbHnCq0zajCBQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-evm/-/x402-evm-1.0.1.tgz", + "integrity": "sha512-4JSElA0vdxdDhkqHfXMT0Q3QH7II7CrHfct8ZV714N42PrxCWdGglHSsSeC2ZFBdracozBZyFxPWZKDSy6CTxg==", "license": "Apache-2.0", "dependencies": { - "@bankofai/x402-core": "~1.0.0", + "@bankofai/x402-core": "~1.0.1", "viem": "^2.48.11", "zod": "^3.24.2" } }, "node_modules/@bankofai/x402-gateway": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.0.tgz", - "integrity": "sha512-GStr4jTzeCC4P1wM/16mPPsMtT4+ECDGcLnYTqjlF6mPniF/l2gWZGQp1SznAIeVSyXDJw5b40275If76bzosw==", + "version": "1.0.1", "dependencies": { - "@bankofai/x402-core": "1.0.0", - "@bankofai/x402-evm": "1.0.0", - "@bankofai/x402-tron": "1.0.0", + "@bankofai/x402-core": "1.0.1", + "@bankofai/x402-evm": "1.0.1", + "@bankofai/x402-tron": "1.0.1", "yaml": "^2.8.2" }, "bin": { @@ -84,12 +81,12 @@ } }, "node_modules/@bankofai/x402-tron": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.0.0.tgz", - "integrity": "sha512-ycq+I1jMFLoQdMP8AzgXEUkSjzQFPrMkRAehcfiFACzrtAciq8Ev3uB0N8+2TXIC55l9fcTxrm+EB/C75T9xVg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.0.1.tgz", + "integrity": "sha512-dsjoOSOJ97dk/o8vpOPJ/FIYAJt2N06SonN9H51WwKdqGoiK5w5fz0n5P36drny/d+Zvtxsy7EMI/g09pRxJUA==", "license": "Apache-2.0", "dependencies": { - "@bankofai/x402-core": "~1.0.0", + "@bankofai/x402-core": "~1.0.1", "tronweb": "^6.1.0" } }, @@ -1429,7 +1426,7 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/viem/node_modules/ws": { + "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", @@ -1450,27 +1447,6 @@ } } }, - "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index e544834..3601bf7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "files": [ "dist" @@ -9,10 +9,12 @@ "x402-cli": "dist/cli.js" }, "scripts": { - "build": "tsc -p tsconfig.json", + "clean": "node scripts/clean.mjs", + "build": "npm run clean && tsc -p tsconfig.json", "bundle:gateway": "node scripts/bundle-gateway.mjs", "prepack": "npm run build && npm run bundle:gateway", "test": "npm run build && node --test tests/*.test.mjs", + "test:package": "node scripts/test-package.mjs", "start": "node dist/cli.js", "dev": "tsx src/cli.ts" }, @@ -20,17 +22,19 @@ "node": ">=20" }, "dependencies": { - "@bankofai/x402-core": "1.0.0", - "@bankofai/x402-evm": "1.0.0", - "@bankofai/x402-gateway": "^1.0.0", - "@bankofai/x402-tron": "1.0.0", + "@bankofai/x402-core": "1.0.1", + "@bankofai/x402-evm": "1.0.1", + "@bankofai/x402-gateway": "1.0.1", + "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", - "viem": "^2.55.0", - "yaml": "^2.8.2" + "viem": "^2.55.0" }, "devDependencies": { "@types/node": "^24.10.1", "tsx": "^4.20.6", "typescript": "^5.9.3" + }, + "overrides": { + "ws": "8.21.0" } } diff --git a/scripts/bundle-gateway.mjs b/scripts/bundle-gateway.mjs index 3bf3279..9457f81 100644 --- a/scripts/bundle-gateway.mjs +++ b/scripts/bundle-gateway.mjs @@ -8,7 +8,7 @@ const require = createRequire(import.meta.url); function gatewayPackageDist() { try { - return path.dirname(require.resolve("@bankofai/x402-gateway/dist/cli.js")); + return path.join(path.dirname(require.resolve("@bankofai/x402-gateway/package.json")), "dist"); } catch { return undefined; } @@ -16,11 +16,11 @@ function gatewayPackageDist() { const source = process.env.X402_GATEWAY_DIST ? path.resolve(process.env.X402_GATEWAY_DIST) - : gatewayPackageDist() ?? path.resolve(root, "..", "x402-gateway", "dist"); + : gatewayPackageDist(); const target = path.join(root, "dist", "gateway"); -if (!fs.existsSync(path.join(source, "cli.js"))) { - throw new Error(`gateway dist not found at ${source}; install @bankofai/x402-gateway, run npm run build in x402-gateway, or set X402_GATEWAY_DIST`); +if (!source || !fs.existsSync(path.join(source, "cli.js"))) { + throw new Error("gateway dist not found in @bankofai/x402-gateway; install dependencies or explicitly set X402_GATEWAY_DIST for development"); } fs.rmSync(target, { recursive: true, force: true }); diff --git a/scripts/clean.mjs b/scripts/clean.mjs new file mode 100644 index 0000000..72a12b9 --- /dev/null +++ b/scripts/clean.mjs @@ -0,0 +1,6 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +fs.rmSync(path.join(root, "dist"), { recursive: true, force: true }); diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs new file mode 100644 index 0000000..bb59760 --- /dev/null +++ b/scripts/test-package.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, ".."); +const { version: expectedVersion } = JSON.parse( + readFileSync(path.join(root, "package.json"), "utf8"), +); +const temp = mkdtempSync(path.join(os.tmpdir(), "x402-cli-pack-")); +try { + const packOutput = execFileSync("npm", ["pack", "--json", "--silent"], { cwd: root, encoding: "utf8" }); + const jsonStart = packOutput.indexOf("[\n"); + assert.notEqual(jsonStart, -1, `npm pack did not return JSON: ${packOutput.slice(0, 200)}`); + const packed = JSON.parse(packOutput.slice(jsonStart)); + const tarball = path.join(root, packed[0].filename); + execFileSync("npm", ["init", "-y"], { cwd: temp, stdio: "ignore" }); + execFileSync("npm", ["install", "--ignore-scripts", tarball], { cwd: temp, stdio: "ignore" }); + const cli = path.join(temp, "node_modules", ".bin", "x402-cli"); + const version = execFileSync(cli, ["--version"], { encoding: "utf8" }).trim(); + assert.equal(version, expectedVersion); + const gatewayHelp = execFileSync(cli, ["gateway", "--help"], { encoding: "utf8" }); + assert.match(gatewayHelp, /gateway/iu); + rmSync(tarball, { force: true }); + process.stdout.write(`verified packed CLI ${version}\n`); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/src/args.ts b/src/args.ts new file mode 100644 index 0000000..bc2ebee --- /dev/null +++ b/src/args.ts @@ -0,0 +1,82 @@ +export type ParsedOptions = Record; +export type OutputMode = "human" | "json"; + +const BOOLEAN_FLAGS = new Set([ + "daemon", "dry-run", "force", "help", "human", "include-blocked", "json", "raw", "version", +]); + +export class CliError extends Error { + constructor( + public code: string, + message: string, + public hint: string, + public exitCode = 1, + public details?: unknown, + ) { + super(message); + } +} + +export function parseArgs(argv: string[]): { command: string; positional: string[]; options: ParsedOptions } { + const [command = "help", ...rest] = argv; + const positional: string[] = []; + const options: ParsedOptions = {}; + for (let i = 0; i < rest.length; i += 1) { + const item = rest[i]; + if (item === "-h") { options.help = true; continue; } + if (item === "-V") { options.version = true; continue; } + if (item === "-d") { options.daemon = true; continue; } + if (item === "-n") { + const next = rest[i + 1]; + if (!next || next.startsWith("-")) options.limit = true; + else { options.limit = next; i += 1; } + continue; + } + if (!item.startsWith("--")) { positional.push(item); continue; } + const eq = item.indexOf("="); + const key = eq > 2 ? item.slice(2, eq) : item.slice(2); + const inline = eq > 2 ? item.slice(eq + 1) : undefined; + const next = rest[i + 1]; + if (inline !== undefined) options[key] = inline; + else if (BOOLEAN_FLAGS.has(key)) options[key] = true; + else if (!next || next.startsWith("--")) { + throw new CliError("MISSING_ARGUMENT", `--${key} requires a value`, `Pass --${key} .`, 2); + } else { + if (key === "header") { + const current = options[key]; + options[key] = Array.isArray(current) ? [...current, next] : current ? [String(current), next] : [next]; + } else options[key] = next; + i += 1; + } + } + return { command, positional, options }; +} + +export function opt(options: ParsedOptions, key: string, fallback?: string): string | undefined { + const value = options[key]; + return typeof value === "string" ? value : fallback; +} + +export function hasFlag(options: ParsedOptions, key: string): boolean { + return options[key] === true; +} + +export function outputMode(options: ParsedOptions): OutputMode { + if (hasFlag(options, "json") && hasFlag(options, "human")) { + throw new CliError("INVALID_ARGUMENT", "--json and --human are mutually exclusive", "Pass either --json or --human, not both.", 2); + } + return hasFlag(options, "json") ? "json" : "human"; +} + +export function requireArgument(value: string | undefined, name: string, usage: string): string { + if (value === undefined || value === "") { + throw new CliError("MISSING_ARGUMENT", `${name} is required`, `Usage: ${usage}`, 2); + } + return value; +} + +export function optAll(options: ParsedOptions, key: string): string[] { + const value = options[key]; + if (Array.isArray(value)) return value; + return typeof value === "string" ? [value] : []; +} diff --git a/src/catalog-commands.ts b/src/catalog-commands.ts new file mode 100644 index 0000000..772598c --- /dev/null +++ b/src/catalog-commands.ts @@ -0,0 +1,585 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { CliError, hasFlag, opt, outputMode, parseArgs, requireArgument, type ParsedOptions } from "./args.js"; +import { catalogBuild } from "./gateway-commands.js"; +import { helpText } from "./help.js"; +import { positiveIntegerOption, readJson, readText, timeoutMs } from "./http-client.js"; +import { emit, printJson } from "./output.js"; + +const CATALOG_UPDATE_RETRIES = 3; + +type SearchHit = { + provider: any; + detail: any; + fqn: string; + title: string; + category: string; + serviceUrl: string; + description?: string; + useCase?: string; + titleZh?: string; + mainTitle?: string; + subTitle?: string; + categoryMeta?: Record; + chains: string[]; + chainKinds: string[]; + chainsMeta: Record[]; + tags: string[]; + endpoints: any[]; + score: number; + matchedFields: string[]; +}; + +const FIELD_WEIGHTS: Record = { + fqn: 12, + title: 10, + tags: 8, + chain_kinds: 8, + chains: 8, + category: 6, + category_meta: 6, + endpoints: 6, + i18n: 5, + description: 4, + use_case: 4, + service_url: 2, +}; + +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter(item => item != null).map(String) : []; +} + +function dictValues(value: unknown): string[] { + if (!value || typeof value !== "object") return []; + const out: string[] = []; + for (const child of Object.values(value as Record)) { + if (child && typeof child === "object" && !Array.isArray(child)) out.push(...dictValues(child)); + else if (Array.isArray(child)) out.push(...child.filter(item => item != null).map(String)); + else if (child != null) out.push(String(child)); + } + return out; +} + +function chainMetaValues(chainsMeta: Record[]): string[] { + return chainsMeta.flatMap(dictValues); +} + +function endpointFields(endpoints: any[]): string[] { + const values: string[] = []; + for (const endpoint of endpoints) { + values.push(String(endpoint.method ?? ""), String(endpoint.path ?? ""), String(endpoint.probe_status ?? "")); + const paid = endpoint.paid; + if (paid && typeof paid === "object") { + values.push(String(paid.network ?? ""), String(paid.currency ?? ""), String(paid.amount_raw ?? "")); + } + values.push(String(endpoint.title ?? ""), String(endpoint.description ?? ""), String(endpoint.use_case ?? endpoint.useCase ?? "")); + } + return values; +} + +function scoreFields(terms: string[], fields: Record): { score: number; matchedFields: string[] } { + let score = 0; + const matchedFields: string[] = []; + for (const [field, values] of Object.entries(fields)) { + const haystack = values.filter(Boolean).join(" ").toLowerCase(); + if (!haystack) continue; + const count = terms.reduce((sum, term) => sum + (haystack.includes(term) ? 1 : 0), 0); + if (count) { + score += (FIELD_WEIGHTS[field] ?? 1) * count; + matchedFields.push(field); + } + } + return { score, matchedFields }; +} + +async function readCatalog(source: string, options?: ParsedOptions): Promise { + const text = await readText(source, options); + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) return parsed; + if (Array.isArray(parsed.providers)) return parsed.providers; + if (Array.isArray(parsed.items)) return parsed.items; + return []; +} + +async function readCatalogObject(source: string, options?: ParsedOptions): Promise> { + const parsed = JSON.parse(await readText(source, options)); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`expected JSON object from ${source}`); + } + return parsed; +} + +function writeJson(file: string, value: unknown): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function cacheDir(): string { + return path.join(os.homedir(), ".cache", "x402-cli", "catalog"); +} + +function cachedCatalogPath(): string { + return path.join(cacheDir(), "catalog.json"); +} + +function providerFilename(fqn: string): string { + return `${sanitizeProviderName(fqn).replace(/\//g, "__")}.json`; +} + +function sanitizeProviderName(name: string): string { + if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}$/.test(name) || name.includes("..")) { + throw new Error("provider name must be a safe FQN using letters, numbers, dots, underscores, dashes, or slashes"); + } + return name; +} + +function safeOutputPath(baseDir: string, ...parts: string[]): string { + const root = path.resolve(baseDir); + const target = path.resolve(root, ...parts); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) { + throw new Error(`refusing to write outside output directory: ${target}`); + } + return target; +} + +function ensureWritable(file: string, options: ParsedOptions): void { + if (fs.existsSync(file) && !hasFlag(options, "force")) { + throw new Error(`${file} already exists; pass --force to overwrite`); + } +} + +export function defaultCatalogSource(): string { + const envSource = process.env.X402_CATALOG || process.env.X402_GATEWAY_CATALOG; + if (envSource) return envSource; + return fs.existsSync(cachedCatalogPath()) + ? cachedCatalogPath() + : "https://x402-catalog.bankofai.io/api/catalog.json"; +} + +function remoteBaseFromCatalogPayload(payload: Record): string | undefined { + const base = payload.base_url ?? payload.baseUrl; + return typeof base === "string" && /^https?:\/\//.test(base) ? `${base.replace(/\/+$/, "")}/` : undefined; +} + +async function remoteBaseFromSource(source: string, payload?: Record, options?: ParsedOptions): Promise { + const fromPayload = payload ? remoteBaseFromCatalogPayload(payload) : undefined; + if (fromPayload) { + if (/^https?:\/\//.test(source) && new URL(fromPayload).origin !== new URL(source).origin) throw new Error("catalog base_url must use the same origin as the catalog source"); + return fromPayload; + } + if (source.startsWith("http://") || source.startsWith("https://")) { + return `${source.slice(0, source.lastIndexOf("/") + 1)}`; + } + try { + return remoteBaseFromCatalogPayload(await readCatalogObject(source, options)); + } catch { + return undefined; + } +} + +function catalogDetailSource(source: string, section: "providers" | "pay", name: string): string { + name = sanitizeProviderName(name); + if (source.startsWith("http://") || source.startsWith("https://")) { + const base = new URL(source); + const pathname = base.pathname.endsWith("/catalog.json") + ? base.pathname.slice(0, -"catalog.json".length) + : base.pathname.endsWith("/") + ? base.pathname + : `${base.pathname}/`; + base.pathname = `${pathname}${section}/${providerFilename(name)}`; + base.search = ""; + base.hash = ""; + return base.toString(); + } + const stat = fs.existsSync(source) ? fs.statSync(source) : undefined; + const root = stat?.isDirectory() ? source : path.dirname(source); + const direct = path.join(root, section, `${name}.json`); + if (fs.existsSync(direct)) return direct; + return path.join(root, section, providerFilename(name)); +} + +async function readCatalogProvider(source: string, name: string, options?: ParsedOptions): Promise { + const providers = await readCatalog(source, options); + const summary = providers.find((item: any) => item.name === name || item.fqn === name); + if (!summary) throw new Error(`provider not found: ${name}`); + const fqn = summary.fqn ?? summary.name ?? name; + try { + return await readJson(catalogDetailSource(source, "providers", fqn), options); + } catch { + return summary; + } +} + +async function readCatalogPayProvider(source: string, name: string, options?: ParsedOptions): Promise { + const providers = await readCatalog(source, options); + const summary = providers.find((item: any) => item.name === name || item.fqn === name); + const fqn = summary?.fqn ?? summary?.name ?? name; + try { + return await readJson(catalogDetailSource(source, "pay", fqn), options); + } catch { + if (summary) return readCatalogProvider(source, name, options); + throw new Error(`provider not found: ${name}`); + } +} + +async function cacheProviderAssets(source: string, catalogPayload: Record, options: ParsedOptions): Promise<{ detailCount: number; payCount: number; warnings: string[] }> { + const base = await remoteBaseFromSource(source, catalogPayload, options); + const warnings: string[] = []; + if (!base) return { detailCount: 0, payCount: 0, warnings }; + let detailCount = 0; + let payCount = 0; + for (const provider of catalogPayload.providers ?? []) { + const fqn = provider?.fqn ?? provider?.name; + if (typeof fqn !== "string" || !fqn) continue; + const filename = providerFilename(fqn); + try { + const detail = await readJson(new URL(`providers/${filename}`, base).toString(), options); + writeJson(path.join(cacheDir(), "providers", filename), detail); + detailCount += 1; + } catch (error) { + warnings.push(`failed to cache provider detail ${fqn}: ${error instanceof Error ? error.message : String(error)}`); + } + try { + const pay = await readJson(new URL(`pay/${filename}`, base).toString(), options); + writeJson(path.join(cacheDir(), "pay", filename), pay); + payCount += 1; + } catch (error) { + warnings.push(`failed to cache pay JSON ${fqn}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return { detailCount, payCount, warnings }; +} + +async function catalogUpdate(source: string, options: ParsedOptions): Promise { + let payload: Record | undefined; + const warnings: string[] = []; + for (let attempt = 1; attempt <= CATALOG_UPDATE_RETRIES; attempt += 1) { + try { + payload = await readCatalogObject(source, options); + break; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (attempt === CATALOG_UPDATE_RETRIES) throw error; + warnings.push(`catalog update attempt ${attempt} failed: ${message}`); + await delay(250 * attempt); + } + } + if (!payload) throw new Error(`failed to read catalog from ${source}`); + writeJson(cachedCatalogPath(), payload); + const cached = await cacheProviderAssets(source, payload, options); + const result = { + source, + path: cachedCatalogPath(), + providerCount: payload.provider_count ?? payload.providerCount ?? (payload.providers ?? []).length, + detailCount: cached.detailCount, + payCount: cached.payCount, + warnings: [...warnings, ...cached.warnings], + }; + emit({ command: "catalog update", mode: outputMode(options), result }); +} + +function zhCopy(title: string, subtitle: string, description: string, useCase: string): Record { + return { title, subtitle, description, useCase }; +} + +function submissionCatalog(detail: any): any { + const title = String(detail.title ?? detail.fqn); + const subtitle = String(detail.subtitle ?? detail.use_case ?? title); + const description = String(detail.description ?? subtitle); + const useCase = String(detail.use_case ?? detail.useCase ?? description); + return { + version: 1, + fqn: detail.fqn, + title, + subtitle, + description, + useCase, + i18n: detail.i18n ?? { "zh-CN": zhCopy(title, subtitle, description, useCase) }, + logo: detail.logo ?? "https://x402-catalog.bankofai.io/assets/providers/default.png", + category: detail.category ?? "other", + chains: detail.chains ?? [], + isFirstParty: Boolean(detail.is_first_party ?? detail.isFirstParty), + isFeatured: Boolean(detail.is_featured ?? detail.isFeatured), + featuredTags: detail.featured_tags ?? detail.featuredTags ?? [], + serviceUrl: detail.service_url ?? detail.serviceUrl, + endpoints: (detail.endpoints ?? []).map((endpoint: any) => { + const endpointTitle = endpoint.title ?? endpoint.path; + const endpointSubtitle = endpoint.subtitle ?? endpoint.path; + const endpointDescription = endpoint.description ?? description; + const endpointUseCase = endpoint.use_case ?? endpoint.useCase ?? useCase; + return { + method: endpoint.method, + path: endpoint.path, + url: endpoint.url, + title: endpointTitle, + subtitle: endpointSubtitle, + description: endpointDescription, + useCase: endpointUseCase, + i18n: endpoint.i18n ?? { "zh-CN": zhCopy(endpointTitle, endpointSubtitle, endpointDescription, endpointUseCase) }, + metered: Boolean(endpoint.metered), + minPriceUsd: endpoint.min_price_usd ?? endpoint.minPriceUsd ?? 0, + maxPriceUsd: endpoint.max_price_usd ?? endpoint.maxPriceUsd ?? 0, + }; + }), + status: detail.status ?? { + catalog: "draft", + gateway: "unknown", + payment: "unknown", + upstream: "unknown", + }, + }; +} + +function payMarkdownFromDetail(detail: any): string { + const lines = [ + `# ${detail.title ?? detail.fqn}`, + "", + "## Service", + "", + `- FQN: \`${detail.fqn}\``, + `- Service URL: \`${detail.service_url ?? detail.serviceUrl ?? ""}\``, + `- Category: \`${detail.category ?? ""}\``, + `- Chains: \`${(detail.chains ?? []).join(", ")}\``, + "", + "## Endpoints", + "", + ]; + for (const endpoint of detail.endpoints ?? []) { + const metered = Boolean(endpoint.metered); + const price = endpoint.min_price_usd ?? endpoint.minPriceUsd ?? 0; + lines.push( + `### ${endpoint.method} ${endpoint.path}`, + "", + endpoint.description ?? "", + "", + `- URL: \`${endpoint.url ?? ""}\``, + `- Metered: \`${String(metered)}\``, + `- Price: \`$${price}\``, + "", + ); + if (metered) { + lines.push("```bash", `x402-cli pay '${endpoint.url ?? ""}'`, "```", ""); + } else { + lines.push("No payment required.", ""); + } + } + lines.push( + "## Notes", + "", + "This file is public. Do not include upstream API keys, bearer tokens, provider.yml, `.env`, passwords, or private infrastructure URLs.", + "", + ); + return lines.join("\n"); +} + +async function catalogExportGateway(gatewayUrl: string, options: ParsedOptions): Promise { + requireArgument(gatewayUrl, "gateway-url", "x402-cli catalog export-gateway --provider [options]"); + const providerFqn = requireArgument(opt(options, "provider"), "--provider", "x402-cli catalog export-gateway --provider [options]"); + sanitizeProviderName(providerFqn); + const base = gatewayUrl.replace(/\/+$/, ""); + const detail = await readJson(`${base}/__402/catalog/providers/${providerFilename(providerFqn)}`, options); + const outputRoot = opt(options, "output-dir", "providers")!; + const target = opt(options, "output-dir") + ? path.resolve(outputRoot) + : safeOutputPath("providers", providerFilename(providerFqn).replace(/\.json$/, "")); + fs.mkdirSync(target, { recursive: true }); + const catalogPath = path.join(target, "catalog.json"); + const payMdPath = path.join(target, "pay.md"); + ensureWritable(catalogPath, options); + ensureWritable(payMdPath, options); + writeJson(catalogPath, submissionCatalog(detail)); + fs.writeFileSync(payMdPath, payMarkdownFromDetail(detail)); + emit({ command: "catalog export-gateway", mode: outputMode(options), result: { provider: providerFqn, catalog: catalogPath, payMd: payMdPath } }); +} + + +async function readProviderDetailForSearch(source: string, fqn: string, options: ParsedOptions): Promise { + try { + return await readJson(catalogDetailSource(source, "providers", fqn), options); + } catch { + return {}; + } +} + +async function searchCatalog(source: string, query: string, options: ParsedOptions): Promise { + const providers = await readCatalog(source, options); + const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + if (!terms.length) return []; + const includeBlocked = hasFlag(options, "include-blocked"); + const hits: SearchHit[] = []; + for (const provider of providers) { + if (provider.block && !includeBlocked) continue; + const fqn = String(provider.fqn ?? provider.name ?? ""); + if (!fqn) continue; + const detail = await readProviderDetailForSearch(source, fqn, options); + const tags = stringList(detail.featured_tags ?? provider.featured_tags ?? detail.tags ?? provider.tags); + const endpoints = Array.isArray(detail.endpoints) ? detail.endpoints : Array.isArray(provider.endpoints) ? provider.endpoints : []; + const categoryMeta = detail.category_meta ?? provider.category_meta; + const chains = stringList(detail.chains ?? provider.chains); + const chainKinds = stringList(detail.chain_kinds ?? provider.chain_kinds); + const chainsMetaRaw = detail.chains_meta ?? provider.chains_meta ?? []; + const chainsMeta = Array.isArray(chainsMetaRaw) ? chainsMetaRaw.filter(item => item && typeof item === "object") : []; + const titleZh = String(detail.title_zh ?? provider.title_zh ?? ""); + const mainTitle = String(detail.main_title ?? provider.main_title ?? detail.mainTitle ?? provider.mainTitle ?? ""); + const subTitle = String(detail.sub_title ?? provider.sub_title ?? detail.subTitle ?? provider.subTitle ?? ""); + const fields = { + fqn: [fqn], + title: [String(detail.title ?? provider.title ?? ""), mainTitle], + i18n: [titleZh, subTitle, ...dictValues(detail.i18n ?? provider.i18n)], + category: [String(detail.category ?? provider.category ?? "")], + category_meta: dictValues(categoryMeta), + chains: [...chains, ...chainMetaValues(chainsMeta)], + chain_kinds: chainKinds, + service_url: [String(detail.service_url ?? provider.service_url ?? detail.serviceUrl ?? provider.serviceUrl ?? "")], + description: [String(detail.description ?? provider.description ?? "")], + use_case: [String(detail.use_case ?? provider.use_case ?? detail.useCase ?? provider.useCase ?? "")], + tags, + endpoints: endpointFields(endpoints), + }; + const scored = scoreFields(terms, fields); + if (scored.score === 0) continue; + hits.push({ + provider, + detail, + fqn, + title: fields.title[0], + category: fields.category[0], + serviceUrl: fields.service_url[0], + description: fields.description[0] || undefined, + useCase: fields.use_case[0] || undefined, + titleZh: titleZh || undefined, + mainTitle: mainTitle || undefined, + subTitle: subTitle || undefined, + categoryMeta: categoryMeta && typeof categoryMeta === "object" ? categoryMeta : undefined, + chains, + chainKinds, + chainsMeta, + tags, + endpoints, + score: scored.score, + matchedFields: scored.matchedFields, + }); + } + return hits + .sort((a, b) => b.score - a.score || a.fqn.localeCompare(b.fqn)) + .slice(0, positiveIntegerOption(options, "limit", 10)); +} + +function searchHitToJson(hit: SearchHit): Record { + return { + fqn: hit.fqn, + title: hit.title, + category: hit.category, + serviceUrl: hit.serviceUrl, + description: hit.description, + useCase: hit.useCase, + title_zh: hit.titleZh, + main_title: hit.mainTitle, + sub_title: hit.subTitle, + category_meta: hit.categoryMeta, + chains: hit.chains, + chain_kinds: hit.chainKinds, + chains_meta: hit.chainsMeta, + tags: hit.tags, + score: hit.score, + matchedFields: hit.matchedFields, + endpoints: hit.endpoints, + }; +} + +export async function catalogSearch(source: string, query: string, options: ParsedOptions): Promise { + positiveIntegerOption(options, "limit", 10); + const hits = await searchCatalog(source, query, options); + const results = hits.map(searchHitToJson); + if (outputMode(options) === "json") { + emit({ command: "catalog search", mode: "json", result: { query, catalog: source, count: hits.length, results } }); + return; + } + if (!hits.length) { + process.stdout.write("no matches\n"); + return; + } + for (const hit of hits) { + const tags = hit.tags.length ? hit.tags.join(",") : "-"; + process.stdout.write(`${hit.fqn.padEnd(32)} score=${String(hit.score).padEnd(3)} category=${hit.category.padEnd(12)} tags=${tags}\n`); + if (hit.title) process.stdout.write(` ${hit.title}\n`); + if (hit.description) process.stdout.write(` ${hit.description.split("\n")[0]}\n`); + if (hit.serviceUrl) process.stdout.write(` service: ${hit.serviceUrl}\n`); + for (const endpoint of hit.endpoints.slice(0, 3)) { + const method = String(endpoint.method ?? ""); + const pathText = String(endpoint.path ?? endpoint.url ?? ""); + const paid = endpoint.paid; + let suffix = ""; + if (paid && typeof paid === "object") { + suffix = ` ${paid.network ?? ""} ${paid.currency ?? ""} ${paid.amount_raw ?? ""}`.trimEnd(); + } + process.stdout.write(` ${method.padEnd(6)} ${pathText}${suffix ? ` ${suffix}` : ""}\n`); + } + process.stdout.write("\n"); + } +} + +async function catalogShow(source: string, name: string, options: ParsedOptions): Promise { + requireArgument(name, "provider", "x402-cli catalog show [--catalog ]"); + const provider = await readCatalogProvider(source, name, options); + if (outputMode(options) === "json") { + emit({ command: "catalog show", mode: "json", result: provider }); + return; + } + process.stdout.write(`${provider.fqn ?? provider.name} - ${provider.title ?? provider.main_title ?? provider.name}\n`); + if (provider.description) process.stdout.write(`${String(provider.description).split("\n")[0]}\n`); + if (provider.category) process.stdout.write(`category: ${provider.category}\n`); + if (provider.chains) process.stdout.write(`chains: ${provider.chains.join(", ")}\n`); +} + +async function catalogEndpoints(source: string, name: string, options: ParsedOptions): Promise { + requireArgument(name, "provider", "x402-cli catalog endpoints [--catalog ]"); + const provider = await readCatalogProvider(source, name, options); + const endpoints = provider.endpoints ?? []; + if (outputMode(options) === "json") { + emit({ command: "catalog endpoints", mode: "json", result: { provider: provider.fqn ?? provider.name, endpoints } }); + return; + } + for (const endpoint of endpoints) { + process.stdout.write(`${String(endpoint.method ?? "").padEnd(6)} ${endpoint.path ?? endpoint.url ?? ""}\n`); + if (endpoint.description) process.stdout.write(` ${String(endpoint.description).split("\n")[0]}\n`); + } +} + +async function catalogPayJson(source: string, name: string, options: ParsedOptions): Promise { + requireArgument(name, "provider", "x402-cli catalog pay-json [--catalog ]"); + const provider = await readCatalogPayProvider(source, name, options); + const endpoint = (provider.endpoints ?? []).find((item: any) => item.paid || item.x402_routes?.length || item.x402Routes?.length) ?? provider.endpoints?.[0]; + if (!endpoint) throw new Error(`provider has no endpoints: ${name}`); + const result = { + provider: provider.fqn ?? provider.name, + url: endpoint.url ?? endpoint.path, + method: endpoint.method, + paid: endpoint.paid, + x402_routes: endpoint.x402_routes ?? endpoint.x402Routes ?? [], + endpoint, + }; + if (hasFlag(options, "raw")) printJson(result); + else emit({ command: "catalog pay-json", mode: outputMode(options), result }); +} + + +export async function handleCatalog(args: string[]): Promise { + const { command, positional, options } = parseArgs(args); + if (hasFlag(options, "help") || ["help", "--help", "-h"].includes(command)) { + const topic = command === "help" ? positional[0] : command; + process.stdout.write(helpText(topic ? `catalog-${topic}` : "catalog")); + return; + } + const source = opt(options, "catalog", defaultCatalogSource())!; + if (command === "update") await catalogUpdate(source, options); + else if (command === "search") await catalogSearch(source, requireArgument(positional.join(" "), "query", "x402-cli catalog search [options]"), options); + else if (command === "show") await catalogShow(source, positional[0], options); + else if (command === "endpoints") await catalogEndpoints(source, positional[0], options); + else if (command === "pay-json") await catalogPayJson(source, positional[0], options); + else if (command === "export-gateway") await catalogExportGateway(positional[0], options); + else if (command === "build") catalogBuild(positional[0] ?? "providers", options); + else throw new CliError("UNKNOWN_COMMAND", `Unknown catalog command: ${command}`, "Run x402-cli catalog --help to list commands.", 2); +} diff --git a/src/cli.ts b/src/cli.ts index 14cb780..206e08a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,1013 +1,23 @@ #!/usr/bin/env node import http from "node:http"; -import { setTimeout as delay } from "node:timers/promises"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { spawn } from "node:child_process"; -import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; -import YAML from "yaml"; import { createPaymentPayload, decodeRequired, decodeResponse, decodeSignature, encodeRequired, encodeResponse, encodeSignature, headers, PaymentRequirement } from "./x402.js"; import { assertRawAmount, findTokenByAddress, getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js"; - -type ParsedOptions = Record; -type OutputMode = "human" | "json"; -type FriendlyError = { code: string; message: string; hint: string }; -const BOOLEAN_FLAGS = new Set(["daemon", "dry-run", "force", "help", "human", "include-blocked", "json", "raw", "version"]); -const require = createRequire(import.meta.url); -const DEFAULT_TIMEOUT_MS = 30_000; -const CATALOG_UPDATE_RETRIES = 3; - -class CliError extends Error { - constructor( - public code: string, - message: string, - public hint: string, - public exitCode = 1, - ) { - super(message); - } -} - -function parseArgs(argv: string[]): { command: string; positional: string[]; options: ParsedOptions } { - const [command = "help", ...rest] = argv; - const positional: string[] = []; - const options: ParsedOptions = {}; - for (let i = 0; i < rest.length; i += 1) { - const item = rest[i]; - if (item === "-h") { - options.help = true; - continue; - } - if (item === "-V") { - options.version = true; - continue; - } - if (item === "-d") { - options.daemon = true; - continue; - } - if (item === "-n") { - const next = rest[i + 1]; - if (!next || next.startsWith("-")) { - options.limit = true; - } else { - options.limit = next; - i += 1; - } - continue; - } - if (!item.startsWith("--")) { - positional.push(item); - continue; - } - const eq = item.indexOf("="); - const key = eq > 2 ? item.slice(2, eq) : item.slice(2); - const inline = eq > 2 ? item.slice(eq + 1) : undefined; - const next = rest[i + 1]; - if (inline !== undefined) { - options[key] = inline; - } else if (BOOLEAN_FLAGS.has(key)) { - options[key] = true; - } else if (!next || next.startsWith("--")) { - options[key] = true; - } else { - if (key === "header") { - const current = options[key]; - options[key] = Array.isArray(current) ? [...current, next] : current ? [String(current), next] : [next]; - } else { - options[key] = next; - } - i += 1; - } - } - return { command, positional, options }; -} - -function getVersion(): string { - try { - const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")); - return String(pkg.version ?? "0.0.0"); - } catch { - return "0.0.0"; - } -} - -function opt(options: ParsedOptions, key: string, fallback?: string): string | undefined { - const value = options[key]; - return typeof value === "string" ? value : fallback; -} - -function hasFlag(options: ParsedOptions, key: string): boolean { - return options[key] === true; -} - -function outputMode(options: ParsedOptions): OutputMode { - if (hasFlag(options, "json") && hasFlag(options, "human")) { - throw new CliError("INVALID_ARGUMENT", "--json and --human are mutually exclusive", "Pass either --json or --human, not both.", 2); - } - return hasFlag(options, "json") ? "json" : "human"; -} - -function requireArgument(value: string | undefined, name: string, usage: string): string { - if (value === undefined || value === "") { - throw new CliError("MISSING_ARGUMENT", `${name} is required`, `Usage: ${usage}`, 2); - } - return value; -} - -function optAll(options: ParsedOptions, key: string): string[] { - const value = options[key]; - if (Array.isArray(value)) return value; - return typeof value === "string" ? [value] : []; -} - -function printJson(value: unknown): void { - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); -} - -function emit(args: { - command: string; - result?: any; - error?: FriendlyError; - network?: string; - scheme?: string; - mode?: OutputMode; -}): void { - const mode = args.mode ?? "human"; - if (mode === "json") { - const envelope: Record = { - ok: !args.error, - command: args.command, - }; - if (args.network) envelope.network = args.network; - if (args.scheme) envelope.scheme = args.scheme; - if (args.error) envelope.error = args.error; - else envelope.result = args.result ?? null; - printJson(envelope); - return; - } - if (args.error) { - process.stderr.write(`ERROR ${args.command}: ${args.error.code}\n`); - process.stderr.write(` ${args.error.message}\n`); - if (args.error.hint) process.stderr.write(` hint: ${args.error.hint}\n`); - return; - } - const suffix = [args.network, args.scheme].filter(Boolean).join(" "); - process.stdout.write(`OK ${args.command}${suffix ? ` (${suffix})` : ""}\n`); - if (args.result && typeof args.result === "object" && !Array.isArray(args.result)) { - for (const [key, value] of Object.entries(args.result)) { - if (value === undefined) continue; - if (value && typeof value === "object") { - process.stdout.write(` ${key}: ${JSON.stringify(value)}\n`); - } else { - process.stdout.write(` ${key}: ${value}\n`); - } - } - } else if (args.result !== undefined) { - process.stdout.write(` ${args.result}\n`); - } -} - -function classify(error: unknown): FriendlyError { - const message = error instanceof Error ? error.message : String(error); - if (error instanceof CliError) { - return { - code: error.code, - message, - hint: error.hint, - }; - } - const lower = message.toLowerCase(); - if (lower.includes("missing private key") || lower.includes("could not find a wallet")) { - return { - code: "WALLET_NOT_CONFIGURED", - message, - hint: "Set PRIVATE_KEY, TRON_PRIVATE_KEY, EVM_PRIVATE_KEY, or configure agent-wallet with a payer wallet.", - }; - } - if (lower.includes("wallets_config") || lower.includes("wallet config")) { - return { - code: "WALLET_CONFIG_CORRUPT", - message, - hint: "Check ~/.agent-wallet/wallets_config.json or recreate the local agent-wallet configuration.", - }; - } - if (lower.includes("does not exist") && lower.includes("account [t")) { - return { - code: "TRON_ACCOUNT_NOT_ACTIVATED", - message, - hint: "Activate the TRON address by sending it a small amount of TRX before signing contract calls.", - }; - } - if (lower.includes("permit2_insufficient_balance") || lower.includes("insufficient") && lower.includes("balance")) { - return { - code: "INSUFFICIENT_TOKEN_BALANCE", - message, - hint: "Fund the payer address with the exact token and network advertised by the provider, then retry.", - }; - } - if (lower.includes("transfer_from_failed") || lower.includes("transferfrom failed")) { - return { - code: "TOKEN_TRANSFER_FAILED", - message, - hint: "Check token balance, token contract, payer address, and that the selected x402 route matches the provider requirement.", - }; - } - if (lower.includes("insufficient funds for gas") || lower.includes("insufficient gas") || lower.includes("energy")) { - return { - code: "INSUFFICIENT_GAS", - message, - hint: "Fund the payer address with the native gas token for this network.", - }; - } - if (lower.includes("deadline") || lower.includes("expired")) { - return { - code: "DEADLINE_OR_CLOCK_SKEW", - message, - hint: "Check local clock sync and retry with a fresh payment requirement.", - }; - } - if (lower.includes("permittransferfrom") || lower.includes("invalid signature") || lower.includes("permit reverted")) { - return { - code: "PERMIT_REVERTED", - message, - hint: "The token or Permit2 contract rejected the signature; retry with a fresh requirement and verify token/network support.", - }; - } - if (lower.includes("tokenregistry") && lower.includes("import")) { - return { - code: "SDK_API_DRIFT", - message, - hint: "Installed x402 SDK packages do not match this CLI; reinstall @bankofai/x402-cli and SDK dependencies.", - }; - } - if (lower.includes("429") || lower.includes("too many requests") || lower.includes("rate limit")) { - return { - code: "RATE_LIMITED", - message, - hint: "Wait briefly and retry; the upstream service or RPC is rate limiting requests.", - }; - } - if (lower.includes("402 response missing")) { - return { - code: "INVALID_X402_RESPONSE", - message, - hint: "The endpoint returned HTTP 402 without a PAYMENT-REQUIRED header.", - }; - } - if (lower.includes("no matching payment requirement")) { - return { - code: "NO_MATCHING_PAYMENT_REQUIREMENT", - message, - hint: "Relax --network, --token, or --scheme, or use values offered by the provider.", - }; - } - if (lower.includes("exceeds --max")) { - return { - code: "PAYMENT_AMOUNT_TOO_HIGH", - message, - hint: "Increase the max amount flag only if this provider price is expected.", - }; - } - if (lower.includes("failed to fetch") || lower.includes("fetch failed") || lower.includes("econnrefused") || lower.includes("timed out")) { - return { - code: "NETWORK_ERROR", - message, - hint: "Check the URL, local server, proxy, and network connectivity.", - }; - } - if (lower.includes(" is required") || lower.includes("must be") || lower.includes("invalid --") || lower.includes("mutually exclusive")) { - return { - code: lower.includes("required") ? "MISSING_ARGUMENT" : "INVALID_ARGUMENT", - message, - hint: "Run the command with --help to see valid usage and options.", - }; - } - return { - code: "IO_ERROR", - message, - hint: "Run with --json for structured output, and check the provider/gateway logs for details.", - }; -} - -function helpText(topic = "root"): string { - const sections: Record = { - root: `x402-cli ${getVersion()} - -Usage: - x402-cli [options] - -Commands: - pay Pay an x402-protected URL - serve Run a local x402 paywall endpoint - roundtrip Start serve, pay it, then exit - gateway Manage local gateway provider files - catalog Search, cache, and export provider catalog assets - -Global options: - -h, --help Show help - -V, --version Show version - --json Print machine-readable JSON envelope - --human Print human-readable output (default) -`, - pay: `Usage: - x402-cli pay [options] - -Options: - --method HTTP method (default: GET) - --header "Name: Value" Request header, repeatable - --body Request body for non-GET/HEAD methods - --network Require a specific network - --token Require a specific token - --scheme Require a specific x402 scheme - --max-amount Maximum human-readable payment amount - --max-raw-amount Maximum smallest-unit payment amount - --dry-run Read requirements but do not sign or pay - --private-key Explicit payer private key (or PRIVATE_KEY/TRON_PRIVATE_KEY/EVM_PRIVATE_KEY) - --rpc-url Explicit network RPC URL - --timeout-ms Network timeout in milliseconds (default: 30000) - --json Print JSON envelope - -Examples: - x402-cli pay https://api.example.com/paid --dry-run --json - x402-cli pay https://api.example.com/paid --max-amount 0.01 -`, - serve: `Usage: - x402-cli serve --pay-to
[options] - -Options: - --pay-to
Recipient wallet address - --amount Human-readable token amount (default: 0.0001) - --raw-amount Smallest-unit amount - --network Payment network (default: tron:nile) - --token Token symbol (default: USDT) - --asset
Explicit token address - --decimals Token decimals for unregistered --asset - --host Bind host (default: 127.0.0.1) - --port Bind port (default: 4020) - --resource-url URL advertised in payment requirements - --facilitator-url Facilitator base URL - --timeout-ms Facilitator timeout in milliseconds (default: 30000) - --daemon Run in background and print the child pid - --json Print JSON envelope - -Examples: - x402-cli serve --pay-to T... --network tron:nile --token USDT - x402-cli serve --pay-to 0x... --network eip155:97 --token USDT --amount 0.0001 -`, - roundtrip: `Usage: - x402-cli roundtrip --pay-to
[serve/pay options] -`, - gateway: `Usage: - x402-cli gateway [options] - -Commands: - search Search a gateway/catalog artifact - start Start a local x402 gateway process - check Validate provider.yml files - scaffold Write a starter provider.yml - catalog Build/check/search gateway catalog assets -`, - "gateway-catalog": `Usage: - x402-cli gateway catalog [options] - -Commands: - build Build a local catalog from provider.yml files - check Validate local provider.yml files - pay-assets List payable endpoint assets - search Search a catalog artifact -`, - catalog: `Usage: - x402-cli catalog [options] - -Commands: - update Cache hosted/local catalog assets under ~/.cache - search Search providers - show Show provider detail JSON - endpoints List provider endpoints - pay-json Print provider pay JSON - export-gateway Export catalog.json and pay.md from a gateway - build Build catalog from provider.yml files - -Options: - --catalog catalog.json path or URL - --provider Provider FQN for export-gateway - --output-dir Output directory for generated files - -n, --limit Search result limit - --timeout-ms Network timeout in milliseconds (default: 30000) - --include-blocked Include blocked providers in search - --json Print JSON envelope -`, - "catalog-search": `Usage: - x402-cli catalog search [--catalog ] [options] - -Options: - --catalog catalog.json path or URL - -n, --limit Search result limit - --timeout-ms Network timeout in milliseconds (default: 30000) - --include-blocked Include blocked providers in search - --json Print JSON envelope -`, - "catalog-show": `Usage: - x402-cli catalog show [--catalog ] [options] - -Options: - --catalog catalog.json path or URL - --timeout-ms Network timeout in milliseconds (default: 30000) - --json Print JSON envelope -`, - "catalog-pay-json": `Usage: - x402-cli catalog pay-json [--catalog ] [options] - -Options: - --catalog catalog.json path or URL - --timeout-ms Network timeout in milliseconds (default: 30000) - --raw Print raw pay payload instead of JSON envelope - --json Print JSON envelope -`, - "catalog-endpoints": `Usage: - x402-cli catalog endpoints [--catalog ] [options] - -Options: - --catalog catalog.json path or URL - --timeout-ms Network timeout in milliseconds (default: 30000) - --json Print JSON envelope -`, - "catalog-export-gateway": `Usage: - x402-cli catalog export-gateway --provider [options] - -Options: - --provider Provider FQN to export - --output-dir Output directory for generated files - --force Overwrite existing files - --json Print JSON envelope -`, - }; - return sections[topic] ?? sections.root; -} - -function readYaml(file: string): any { - return YAML.parse(fs.readFileSync(file, "utf8")); -} - -function expandEnv(value: string): string { - return value.replace(/\$\{([^}]+)\}/g, (_, name: string) => { - if (!(name in process.env)) throw new Error(`environment variable \${${name}} is not set`); - return process.env[name] ?? ""; - }); -} - -function expandDeep(value: T): T { - if (typeof value === "string") return expandEnv(value) as T; - if (Array.isArray(value)) return value.map(expandDeep) as T; - if (value && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expandDeep(item)])) as T; - } - return value; -} - -function providerFiles(root: string): string[] { - const stat = fs.statSync(root); - if (stat.isFile()) return [root]; - const out: string[] = []; - for (const entry of fs.readdirSync(root, { recursive: true })) { - const file = path.join(root, String(entry)); - if (file.endsWith("provider.yml") || file.endsWith("provider.yaml")) out.push(file); - } - return out.sort(); -} - -function loadProviderFile(file: string): any { - const provider = expandDeep(readYaml(file)); - validateProvider(provider, file); - provider.operator.network = normalizeNetwork(provider.operator.network); - provider.operator.scheme = "exact"; - return provider; -} - -function validateProvider(provider: any, file = "provider.yml"): void { - const required = [ - ["name", provider?.name], - ["forward_url", provider?.forward_url], - ["operator.network", provider?.operator?.network], - ["operator.recipient", provider?.operator?.recipient], - ]; - for (const [name, value] of required) { - if (typeof value !== "string" || !value.trim()) throw new Error(`${file}: ${name} is required`); - } - try { - const url = new URL(provider.forward_url); - if (!["http:", "https:"].includes(url.protocol)) throw new Error("unsupported protocol"); - } catch { - throw new Error(`${file}: forward_url must be a valid http(s) URL`); - } - if (!Array.isArray(provider.endpoints) || !provider.endpoints.length) { - throw new Error(`${file}: endpoints must contain at least one endpoint`); - } - const seen = new Set(); - for (const endpoint of provider.endpoints) { - if (typeof endpoint.method !== "string" || typeof endpoint.path !== "string") { - throw new Error(`${file}: each endpoint needs method and path`); - } - if (!endpoint.method.trim() || !endpoint.path.trim() || !endpoint.path.startsWith("/")) { - throw new Error(`${file}: endpoint method/path must be non-empty and path must start with /`); - } - const price = providerPrice(endpoint); - if (!Number.isFinite(price) || price < 0) throw new Error(`${file}: endpoint price_usd must be a finite number >= 0`); - const key = `${endpoint.method.toUpperCase()} ${endpoint.path}`; - if (seen.has(key)) throw new Error(`${file}: duplicate endpoint ${key}`); - seen.add(key); - } -} - -function providerPrice(endpoint: any): number { - return endpoint?.metering?.dimensions?.[0]?.tiers?.[0]?.price_usd ?? 0; -} - -function providerAssetTransferMethod(provider: any): string { - return provider.operator?.asset_transfer_method ?? provider.operator?.assetTransferMethod ?? "permit2"; -} - -function providerCatalog(provider: any): any { - return { - name: provider.name, - title: provider.title ?? provider.name, - description: provider.description ?? "", - category: provider.category ?? "other", - service_url: provider.display?.service_url, - tags: provider.display?.tags ?? [], - network: normalizeNetwork(provider.operator.network), - currency: provider.operator.currencies?.usd?.[0] ?? "USDT", - endpoints: (provider.endpoints ?? []).map((endpoint: any) => ({ - method: endpoint.method.toUpperCase(), - path: `/providers/${provider.name}${endpoint.path}`, - upstream_path: endpoint.path, - description: endpoint.description ?? "", - paid: providerPrice(endpoint) > 0 ? { - scheme: "exact", - network: normalizeNetwork(provider.operator.network), - currency: provider.operator.currencies?.usd?.[0] ?? "USDT", - price_usd: providerPrice(endpoint), - } : null, - x402_routes: providerPrice(endpoint) > 0 ? [{ - provider: provider.name, - network: normalizeNetwork(provider.operator.network), - scheme: "exact", - assetTransferMethod: providerAssetTransferMethod(provider), - url: `/providers/${provider.name}${endpoint.path}`, - }] : [], - })), - }; -} - -type SearchHit = { - provider: any; - detail: any; - fqn: string; - title: string; - category: string; - serviceUrl: string; - description?: string; - useCase?: string; - titleZh?: string; - mainTitle?: string; - subTitle?: string; - categoryMeta?: Record; - chains: string[]; - chainKinds: string[]; - chainsMeta: Record[]; - tags: string[]; - endpoints: any[]; - score: number; - matchedFields: string[]; -}; - -const FIELD_WEIGHTS: Record = { - fqn: 12, - title: 10, - tags: 8, - chain_kinds: 8, - chains: 8, - category: 6, - category_meta: 6, - endpoints: 6, - i18n: 5, - description: 4, - use_case: 4, - service_url: 2, -}; - -function stringList(value: unknown): string[] { - return Array.isArray(value) ? value.filter(item => item != null).map(String) : []; -} - -function dictValues(value: unknown): string[] { - if (!value || typeof value !== "object") return []; - const out: string[] = []; - for (const child of Object.values(value as Record)) { - if (child && typeof child === "object" && !Array.isArray(child)) out.push(...dictValues(child)); - else if (Array.isArray(child)) out.push(...child.filter(item => item != null).map(String)); - else if (child != null) out.push(String(child)); - } - return out; -} - -function chainMetaValues(chainsMeta: Record[]): string[] { - return chainsMeta.flatMap(dictValues); -} - -function endpointFields(endpoints: any[]): string[] { - const values: string[] = []; - for (const endpoint of endpoints) { - values.push(String(endpoint.method ?? ""), String(endpoint.path ?? ""), String(endpoint.probe_status ?? "")); - const paid = endpoint.paid; - if (paid && typeof paid === "object") { - values.push(String(paid.network ?? ""), String(paid.currency ?? ""), String(paid.amount_raw ?? "")); - } - values.push(String(endpoint.title ?? ""), String(endpoint.description ?? ""), String(endpoint.use_case ?? endpoint.useCase ?? "")); - } - return values; -} - -function scoreFields(terms: string[], fields: Record): { score: number; matchedFields: string[] } { - let score = 0; - const matchedFields: string[] = []; - for (const [field, values] of Object.entries(fields)) { - const haystack = values.filter(Boolean).join(" ").toLowerCase(); - if (!haystack) continue; - const count = terms.reduce((sum, term) => sum + (haystack.includes(term) ? 1 : 0), 0); - if (count) { - score += (FIELD_WEIGHTS[field] ?? 1) * count; - matchedFields.push(field); - } - } - return { score, matchedFields }; -} - -async function readCatalog(source: string, options?: ParsedOptions): Promise { - const text = await readText(source, options); - const parsed = JSON.parse(text); - if (Array.isArray(parsed)) return parsed; - if (Array.isArray(parsed.providers)) return parsed.providers; - if (Array.isArray(parsed.items)) return parsed.items; - return []; -} - -async function readCatalogObject(source: string, options?: ParsedOptions): Promise> { - const parsed = JSON.parse(await readText(source, options)); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error(`expected JSON object from ${source}`); - } - return parsed; -} - -async function readText(source: string, options?: ParsedOptions): Promise { - if (!source.startsWith("http://") && !source.startsWith("https://")) { - return fs.readFileSync(source, "utf8"); - } - const response = await fetchWithTimeout(source, {}, timeoutMs(options), `fetch ${source}`); - if (!response.ok) throw new Error(`failed to fetch ${source}: ${response.status}`); - return response.text(); -} - -async function readJson(source: string, options?: ParsedOptions): Promise { - return JSON.parse(await readText(source, options)); -} - -function writeJson(file: string, value: unknown): void { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); -} - -async function responsePayload(response: Response): Promise { - const text = await response.text(); - const contentType = response.headers.get("content-type") ?? ""; - if (contentType.toLowerCase().includes("json")) { - try { - return JSON.parse(text); - } catch { - return text; - } - } - return text; -} - -async function withSdkStdoutRedirect(enabled: boolean, fn: () => Promise): Promise { - if (!enabled) return fn(); - const originalLog = console.log; - console.log = (...args: unknown[]) => { - process.stderr.write(`${args.map(arg => typeof arg === "string" ? arg : JSON.stringify(arg, null, 2)).join(" ")}\n`); - }; - try { - return await fn(); - } finally { - console.log = originalLog; - } -} - -function cacheDir(): string { - return path.join(os.homedir(), ".cache", "x402-cli", "catalog"); -} - -function cachedCatalogPath(): string { - return path.join(cacheDir(), "catalog.json"); -} - -function providerFilename(fqn: string): string { - return `${fqn.replace(/\//g, "__")}.json`; -} - -function sanitizeProviderName(name: string): string { - if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}$/.test(name) || name.includes("..")) { - throw new Error("provider name must be a safe FQN using letters, numbers, dots, underscores, dashes, or slashes"); - } - return name; -} - -function safeOutputPath(baseDir: string, ...parts: string[]): string { - const root = path.resolve(baseDir); - const target = path.resolve(root, ...parts); - if (target !== root && !target.startsWith(`${root}${path.sep}`)) { - throw new Error(`refusing to write outside output directory: ${target}`); - } - return target; -} - -function ensureWritable(file: string, options: ParsedOptions): void { - if (fs.existsSync(file) && !hasFlag(options, "force")) { - throw new Error(`${file} already exists; pass --force to overwrite`); - } -} - -function defaultCatalogSource(): string { - const envSource = process.env.X402_CATALOG || process.env.X402_GATEWAY_CATALOG; - if (envSource) return envSource; - return fs.existsSync(cachedCatalogPath()) - ? cachedCatalogPath() - : "https://x402-catalog.bankofai.io/api/catalog.json"; -} - -function positiveIntegerOption(options: ParsedOptions, key: string, fallback: number): number { - const value = opt(options, key, String(fallback))!; - if (!/^\d+$/.test(value)) throw new CliError("INVALID_ARGUMENT", `--${key} must be a positive integer`, `Pass --${key} with a value greater than zero.`, 2); - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new CliError("INVALID_ARGUMENT", `--${key} must be a positive integer`, `Pass --${key} with a value greater than zero.`, 2); - return parsed; -} - -function timeoutMs(options?: ParsedOptions): number { - return options ? positiveIntegerOption(options, "timeout-ms", DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS; -} - -async function fetchWithTimeout(input: string | URL, init: RequestInit = {}, timeout = DEFAULT_TIMEOUT_MS, label = "request"): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeout); - try { - return await fetch(input, { ...init, signal: controller.signal }); - } catch (error) { - if ((error as any)?.name === "AbortError") throw new Error(`${label} timed out after ${timeout}ms`); - throw error; - } finally { - clearTimeout(timer); - } -} - -function remoteBaseFromCatalogPayload(payload: Record): string | undefined { - const base = payload.base_url ?? payload.baseUrl; - return typeof base === "string" && /^https?:\/\//.test(base) ? `${base.replace(/\/+$/, "")}/` : undefined; -} - -async function remoteBaseFromSource(source: string, payload?: Record, options?: ParsedOptions): Promise { - const fromPayload = payload ? remoteBaseFromCatalogPayload(payload) : undefined; - if (fromPayload) return fromPayload; - if (source.startsWith("http://") || source.startsWith("https://")) { - return `${source.slice(0, source.lastIndexOf("/") + 1)}`; - } - try { - return remoteBaseFromCatalogPayload(await readCatalogObject(source, options)); - } catch { - return undefined; - } -} - -function catalogDetailSource(source: string, section: "providers" | "pay", name: string): string { - if (source.startsWith("http://") || source.startsWith("https://")) { - const base = new URL(source); - const pathname = base.pathname.endsWith("/catalog.json") - ? base.pathname.slice(0, -"catalog.json".length) - : base.pathname.endsWith("/") - ? base.pathname - : `${base.pathname}/`; - base.pathname = `${pathname}${section}/${providerFilename(name)}`; - base.search = ""; - base.hash = ""; - return base.toString(); - } - const stat = fs.existsSync(source) ? fs.statSync(source) : undefined; - const root = stat?.isDirectory() ? source : path.dirname(source); - const direct = path.join(root, section, `${name}.json`); - if (fs.existsSync(direct)) return direct; - return path.join(root, section, providerFilename(name)); -} - -async function readCatalogProvider(source: string, name: string, options?: ParsedOptions): Promise { - const providers = await readCatalog(source, options); - const summary = providers.find((item: any) => item.name === name || item.fqn === name); - if (!summary) throw new Error(`provider not found: ${name}`); - const fqn = summary.fqn ?? summary.name ?? name; - try { - return await readJson(catalogDetailSource(source, "providers", fqn), options); - } catch { - return summary; - } -} - -async function readCatalogPayProvider(source: string, name: string, options?: ParsedOptions): Promise { - const providers = await readCatalog(source, options); - const summary = providers.find((item: any) => item.name === name || item.fqn === name); - const fqn = summary?.fqn ?? summary?.name ?? name; - try { - return await readJson(catalogDetailSource(source, "pay", fqn), options); - } catch { - if (summary) return readCatalogProvider(source, name, options); - throw new Error(`provider not found: ${name}`); - } -} - -async function cacheProviderAssets(source: string, catalogPayload: Record, options: ParsedOptions): Promise<{ detailCount: number; payCount: number; warnings: string[] }> { - const base = await remoteBaseFromSource(source, catalogPayload, options); - const warnings: string[] = []; - if (!base) return { detailCount: 0, payCount: 0, warnings }; - let detailCount = 0; - let payCount = 0; - for (const provider of catalogPayload.providers ?? []) { - const fqn = provider?.fqn ?? provider?.name; - if (typeof fqn !== "string" || !fqn) continue; - const filename = providerFilename(fqn); - try { - const detail = await readJson(new URL(`providers/${filename}`, base).toString(), options); - writeJson(path.join(cacheDir(), "providers", filename), detail); - detailCount += 1; - } catch (error) { - warnings.push(`failed to cache provider detail ${fqn}: ${error instanceof Error ? error.message : String(error)}`); - } - try { - const pay = await readJson(new URL(`pay/${filename}`, base).toString(), options); - writeJson(path.join(cacheDir(), "pay", filename), pay); - payCount += 1; - } catch (error) { - warnings.push(`failed to cache pay JSON ${fqn}: ${error instanceof Error ? error.message : String(error)}`); - } - } - return { detailCount, payCount, warnings }; -} - -async function catalogUpdate(source: string, options: ParsedOptions): Promise { - let payload: Record | undefined; - const warnings: string[] = []; - for (let attempt = 1; attempt <= CATALOG_UPDATE_RETRIES; attempt += 1) { - try { - payload = await readCatalogObject(source, options); - break; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (attempt === CATALOG_UPDATE_RETRIES) throw error; - warnings.push(`catalog update attempt ${attempt} failed: ${message}`); - await delay(250 * attempt); - } - } - if (!payload) throw new Error(`failed to read catalog from ${source}`); - writeJson(cachedCatalogPath(), payload); - const cached = await cacheProviderAssets(source, payload, options); - const result = { - source, - path: cachedCatalogPath(), - providerCount: payload.provider_count ?? payload.providerCount ?? (payload.providers ?? []).length, - detailCount: cached.detailCount, - payCount: cached.payCount, - warnings: [...warnings, ...cached.warnings], - }; - emit({ command: "catalog update", mode: outputMode(options), result }); -} - -function zhCopy(title: string, subtitle: string, description: string, useCase: string): Record { - return { title, subtitle, description, useCase }; -} - -function submissionCatalog(detail: any): any { - const title = String(detail.title ?? detail.fqn); - const subtitle = String(detail.subtitle ?? detail.use_case ?? title); - const description = String(detail.description ?? subtitle); - const useCase = String(detail.use_case ?? detail.useCase ?? description); - return { - version: 1, - fqn: detail.fqn, - title, - subtitle, - description, - useCase, - i18n: detail.i18n ?? { "zh-CN": zhCopy(title, subtitle, description, useCase) }, - logo: detail.logo ?? "https://x402-catalog.bankofai.io/assets/providers/default.png", - category: detail.category ?? "other", - chains: detail.chains ?? [], - isFirstParty: Boolean(detail.is_first_party ?? detail.isFirstParty), - isFeatured: Boolean(detail.is_featured ?? detail.isFeatured), - featuredTags: detail.featured_tags ?? detail.featuredTags ?? [], - serviceUrl: detail.service_url ?? detail.serviceUrl, - endpoints: (detail.endpoints ?? []).map((endpoint: any) => { - const endpointTitle = endpoint.title ?? endpoint.path; - const endpointSubtitle = endpoint.subtitle ?? endpoint.path; - const endpointDescription = endpoint.description ?? description; - const endpointUseCase = endpoint.use_case ?? endpoint.useCase ?? useCase; - return { - method: endpoint.method, - path: endpoint.path, - url: endpoint.url, - title: endpointTitle, - subtitle: endpointSubtitle, - description: endpointDescription, - useCase: endpointUseCase, - i18n: endpoint.i18n ?? { "zh-CN": zhCopy(endpointTitle, endpointSubtitle, endpointDescription, endpointUseCase) }, - metered: Boolean(endpoint.metered), - minPriceUsd: endpoint.min_price_usd ?? endpoint.minPriceUsd ?? 0, - maxPriceUsd: endpoint.max_price_usd ?? endpoint.maxPriceUsd ?? 0, - }; - }), - status: detail.status ?? { - catalog: "draft", - gateway: "unknown", - payment: "unknown", - upstream: "unknown", - }, - }; -} - -function payMarkdownFromDetail(detail: any): string { - const lines = [ - `# ${detail.title ?? detail.fqn}`, - "", - "## Service", - "", - `- FQN: \`${detail.fqn}\``, - `- Service URL: \`${detail.service_url ?? detail.serviceUrl ?? ""}\``, - `- Category: \`${detail.category ?? ""}\``, - `- Chains: \`${(detail.chains ?? []).join(", ")}\``, - "", - "## Endpoints", - "", - ]; - for (const endpoint of detail.endpoints ?? []) { - const metered = Boolean(endpoint.metered); - const price = endpoint.min_price_usd ?? endpoint.minPriceUsd ?? 0; - lines.push( - `### ${endpoint.method} ${endpoint.path}`, - "", - endpoint.description ?? "", - "", - `- URL: \`${endpoint.url ?? ""}\``, - `- Metered: \`${String(metered)}\``, - `- Price: \`$${price}\``, - "", - ); - if (metered) { - lines.push("```bash", `x402-cli pay '${endpoint.url ?? ""}'`, "```", ""); - } else { - lines.push("No payment required.", ""); - } - } - lines.push( - "## Notes", - "", - "This file is public. Do not include upstream API keys, bearer tokens, provider.yml, `.env`, passwords, or private infrastructure URLs.", - "", - ); - return lines.join("\n"); -} - -async function catalogExportGateway(gatewayUrl: string, options: ParsedOptions): Promise { - requireArgument(gatewayUrl, "gateway-url", "x402-cli catalog export-gateway --provider [options]"); - const providerFqn = requireArgument(opt(options, "provider"), "--provider", "x402-cli catalog export-gateway --provider [options]"); - sanitizeProviderName(providerFqn); - const base = gatewayUrl.replace(/\/+$/, ""); - const detail = await readJson(`${base}/__402/catalog/providers/${providerFilename(providerFqn)}`, options); - const outputRoot = opt(options, "output-dir", "providers")!; - const target = opt(options, "output-dir") - ? path.resolve(outputRoot) - : safeOutputPath("providers", providerFilename(providerFqn).replace(/\.json$/, "")); - fs.mkdirSync(target, { recursive: true }); - const catalogPath = path.join(target, "catalog.json"); - const payMdPath = path.join(target, "pay.md"); - ensureWritable(catalogPath, options); - ensureWritable(payMdPath, options); - writeJson(catalogPath, submissionCatalog(detail)); - fs.writeFileSync(payMdPath, payMarkdownFromDetail(detail)); - emit({ command: "catalog export-gateway", mode: outputMode(options), result: { provider: providerFqn, catalog: catalogPath, payMd: payMdPath } }); -} +import { CliError, hasFlag, opt, optAll, outputMode, parseArgs, requireArgument, type ParsedOptions } from "./args.js"; +import { classify, emit, withSdkStdoutRedirect } from "./output.js"; +import { fetchWithTimeout, readBoundedText, responsePayload, timeoutMs } from "./http-client.js"; +import { startServeDaemon } from "./daemon.js"; +import { getVersion, helpText } from "./help.js"; +import { catalogBuild, catalogPayAssets, gatewayCheck, gatewayScaffold, gatewayStart } from "./gateway-commands.js"; +import { catalogSearch, defaultCatalogSource, handleCatalog } from "./catalog-commands.js"; function buildRequirement(options: ParsedOptions): PaymentRequirement { - const network = normalizeNetwork(opt(options, "network", "tron:nile")!); + const network = normalizeNetwork(opt(options, "network", "tron:0xcd8690dc")!); + const scheme = opt(options, "scheme", "exact")!; + if (!["exact", "exact_gasfree"].includes(scheme)) throw new Error(`unsupported scheme ${scheme}`); + if (scheme === "exact_gasfree" && !network.startsWith("tron:")) { + throw new Error("exact_gasfree is supported only on TRON networks"); + } const tokenSymbol = opt(options, "token", "USDT")!; const explicitAsset = opt(options, "asset"); const registryToken = explicitAsset @@ -1019,21 +29,25 @@ function buildRequirement(options: ParsedOptions): PaymentRequirement { } if (!explicitAsset && !registryToken) throw new Error(`unknown token ${tokenSymbol} on ${network}`); const decimals = decimalsOption !== undefined ? Number(decimalsOption) : registryToken!.decimals; - if (!Number.isInteger(decimals) || decimals < 0) throw new Error("--decimals must be a non-negative integer"); + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) throw new Error("--decimals must be an integer between 0 and 255"); const rawAmount = opt(options, "rawAmount") ?? opt(options, "raw-amount"); const humanAmount = opt(options, "amount"); if (rawAmount && humanAmount) throw new CliError("INVALID_ARGUMENT", "--amount and --raw-amount are mutually exclusive", "Pass either --amount or --raw-amount, not both.", 2); const amount = rawAmount ? assertRawAmount(rawAmount, "--raw-amount") : toSmallestUnit(humanAmount ?? "0.0001", decimals); const assetAddress = explicitAsset ?? registryToken!.address; const assetTransferMethod = registryToken?.assetTransferMethod ?? "permit2"; + const maxTimeoutSeconds = Number(opt(options, "valid-for-seconds", "300")); + if (!Number.isInteger(maxTimeoutSeconds) || maxTimeoutSeconds <= 0 || maxTimeoutSeconds > 86400) { + throw new Error("--valid-for-seconds must be an integer between 1 and 86400"); + } return { - scheme: "exact", + scheme, network, amount, asset: assetAddress, payTo: opt(options, "pay-to") ?? opt(options, "payTo") ?? "", - maxTimeoutSeconds: Number(opt(options, "valid-for-seconds", "300")), - extra: assetTransferMethod ? { assetTransferMethod } : {}, + maxTimeoutSeconds, + extra: scheme === "exact" && assetTransferMethod ? { assetTransferMethod } : {}, }; } @@ -1043,9 +57,10 @@ async function facilitatorPost(baseUrl: string, path: string, body: unknown, opt headers: { "content-type": "application/json" }, body: JSON.stringify(body), }, timeoutMs(options), `facilitator ${path}`); - const text = await response.text(); - const data = text ? JSON.parse(text) : {}; - if (!response.ok) throw new Error(`facilitator ${path} failed: ${response.status} ${text}`); + const text = await readBoundedText(response, `facilitator ${path} response`); + let data: unknown = {}; + try { data = text ? JSON.parse(text) : {}; } catch { throw new Error(`facilitator ${path} returned invalid JSON`); } + if (!response.ok) throw new Error(`facilitator ${path} failed with HTTP ${response.status}`); return data; } @@ -1059,38 +74,6 @@ function requestHeaders(options: ParsedOptions): Headers { return headersOut; } -function stripFlag(argv: string[], flag: string): string[] { - return argv.filter(item => item !== flag); -} - -function serveDaemon(argv: string[], options: ParsedOptions): void { - const requirement = buildRequirement(options); - if (!requirement.payTo) throw new Error("--pay-to is required"); - const daemonArgs = stripFlag(stripFlag(argv, "--daemon"), "-d"); - const script = fileURLToPath(import.meta.url); - const child = spawn(process.execPath, [script, ...daemonArgs], { - detached: true, - stdio: "ignore", - env: process.env, - }); - child.unref(); - const host = opt(options, "host", "127.0.0.1")!; - const port = Number(opt(options, "port", "4020")); - const resourceUrl = opt(options, "resource-url", `http://${host}:${port}/pay`)!; - emit({ - command: "server", - mode: outputMode(options), - network: requirement.network, - scheme: "exact", - result: { - pid: child.pid, - pay_url: resourceUrl, - resource_url: resourceUrl, - daemon: true, - }, - }); -} - function validateAmountLimits(selected: PaymentRequirement, options: ParsedOptions): void { const maxRaw = opt(options, "max-rawAmount") ?? opt(options, "max-raw-amount"); const maxAmount = opt(options, "max-amount"); @@ -1104,13 +87,35 @@ function validateAmountLimits(selected: PaymentRequirement, options: ParsedOptio throw new Error("cannot evaluate --max-amount for an unknown asset; pass --max-raw-amount or --decimals"); } const decimals = decimalsOption !== undefined ? Number(decimalsOption) : token!.decimals; - if (!Number.isInteger(decimals) || decimals < 0) throw new Error("--decimals must be a non-negative integer"); + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) throw new Error("--decimals must be an integer between 0 and 255"); if (BigInt(selected.amount) > BigInt(toSmallestUnit(maxAmount, decimals))) { throw new Error(`payment amount exceeds --max-amount ${maxAmount}`); } } } +function gasfreeFeeLimitRaw(selected: PaymentRequirement, options: ParsedOptions): string | undefined { + const maxRaw = opt(options, "max-gasfree-fee-raw"); + const maxHuman = opt(options, "max-gasfree-fee"); + if (maxRaw && maxHuman) { + throw new CliError("INVALID_ARGUMENT", "--max-gasfree-fee and --max-gasfree-fee-raw are mutually exclusive", "Pass one GasFree fee limit.", 2); + } + if (selected.scheme !== "exact_gasfree") { + if (maxRaw || maxHuman) throw new Error("GasFree fee limits require an exact_gasfree payment requirement"); + return undefined; + } + if (maxRaw) return assertRawAmount(maxRaw, "--max-gasfree-fee-raw"); + if (!maxHuman) return undefined; + const token = findTokenByAddress(selected.network, selected.asset); + const decimalsOption = opt(options, "decimals"); + if (!token && decimalsOption === undefined) { + throw new Error("cannot evaluate --max-gasfree-fee for an unknown asset; pass --max-gasfree-fee-raw or --decimals"); + } + const decimals = decimalsOption !== undefined ? Number(decimalsOption) : token!.decimals; + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) throw new Error("--decimals must be an integer between 0 and 255"); + return toSmallestUnit(maxHuman, decimals); +} + async function serve(options: ParsedOptions): Promise { const host = opt(options, "host", "127.0.0.1")!; const port = Number(opt(options, "port", "4020")); @@ -1135,7 +140,7 @@ async function serve(options: ParsedOptions): Promise { } if (pathname === "/.well-known/x402") { response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ network: requirement.network, scheme: "exact", asset: requirement.asset, rawAmount: requirement.amount, amount: requirement.amount, payTo: requirement.payTo, pay_url: resourceUrl })); + response.end(JSON.stringify({ network: requirement.network, scheme: requirement.scheme, asset: requirement.asset, rawAmount: requirement.amount, amount: requirement.amount, payTo: requirement.payTo, pay_url: resourceUrl })); return; } if (pathname !== "/pay") { @@ -1172,7 +177,7 @@ async function serve(options: ParsedOptions): Promise { paymentPayload: payload, paymentRequirements: requirement, }, options); - if (!(settle?.success === true || settle?.settled === true || typeof settle?.transaction === "string" || typeof settle?.txHash === "string")) { + if (!(settle?.success === true && typeof settle?.transaction === "string" && settle.transaction.length > 0 && typeof settle?.network === "string" && settle.network.length > 0)) { response.writeHead(502, { "content-type": "application/json" }); response.end(JSON.stringify({ error: "settlement failed" })); return; @@ -1181,7 +186,7 @@ async function serve(options: ParsedOptions): Promise { "content-type": "application/json", [headers.response]: encodeResponse(settle), }); - response.end(JSON.stringify({ success: true, network: requirement.network, scheme: "exact", transaction: settle.transaction ?? settle.txHash ?? null })); + response.end(JSON.stringify({ success: true, network: requirement.network, scheme: requirement.scheme, transaction: settle.transaction })); } catch (error) { response.writeHead(500, { "content-type": "application/json" }); response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); @@ -1194,7 +199,7 @@ async function serve(options: ParsedOptions): Promise { emit({ command: "server", network: requirement.network, - scheme: "exact", + scheme: requirement.scheme, mode: outputMode(options), result: { pay_url: resourceUrl, token: opt(options, "token", "USDT"), rawAmount: requirement.amount, pay_to: requirement.payTo }, }); @@ -1208,10 +213,22 @@ function selectRequirement(accepts: PaymentRequirement[], options: ParsedOptions const scheme = opt(options, "scheme"); const token = opt(options, "token"); const selected = accepts.find(req => { + if (!req || !["exact", "exact_gasfree"].includes(req.scheme) || typeof req.network !== "string" || typeof req.asset !== "string") return false; + if (req.scheme === "exact_gasfree" && !req.network.startsWith("tron:")) return false; + try { + if (!findTokenByAddress(req.network, req.asset)) return false; + } catch { + return false; + } if (network && normalizeNetwork(network) !== req.network) return false; if (scheme && scheme !== req.scheme) return false; if (token) { - const tokenInfo = getToken(req.network, token); + let tokenInfo; + try { + tokenInfo = getToken(req.network, token); + } catch { + return false; + } if (tokenInfo.address.toLowerCase() !== req.asset.toLowerCase()) return false; } return true; @@ -1223,6 +240,9 @@ function selectRequirement(accepts: PaymentRequirement[], options: ParsedOptions async function pay(url: string, options: ParsedOptions): Promise { requireArgument(url, "URL", "x402-cli pay [options]"); const method = opt(options, "method", "GET")!; + if (!/^[A-Z]+$/.test(method) || !["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"].includes(method)) { + throw new CliError("INVALID_ARGUMENT", `unsupported HTTP method ${method}`, "Use an uppercase standard HTTP method.", 2); + } const baseHeaders = requestHeaders(options); const probe = await fetchWithTimeout(url, { method, @@ -1230,6 +250,15 @@ async function pay(url: string, options: ParsedOptions): Promise { body: ["GET", "HEAD"].includes(method.toUpperCase()) ? undefined : opt(options, "body"), }, timeoutMs(options), `fetch ${url}`); if (probe.status !== 402) { + const body = await responsePayload(probe); + if (!probe.ok) { + const retryAfter = probe.headers.get("retry-after"); + throw new Error( + `HTTP ${probe.status} from ${url}${retryAfter ? ` (retry after ${retryAfter}s)` : ""}: ${ + typeof body === "string" ? body.slice(0, 500) : JSON.stringify(body).slice(0, 500) + }`, + ); + } emit({ command: "client", mode: outputMode(options), @@ -1237,7 +266,7 @@ async function pay(url: string, options: ParsedOptions): Promise { url, status: probe.status, message: "Not a payment-required endpoint", - response: await responsePayload(probe), + response: body, }, }); return; @@ -1247,11 +276,12 @@ async function pay(url: string, options: ParsedOptions): Promise { const required = decodeRequired(header); const selected = selectRequirement(required.accepts ?? [], options); validateAmountLimits(selected, options); + const maxGasfreeFeeRaw = gasfreeFeeLimitRaw(selected, options); if (options["dry-run"]) { emit({ command: "client", network: selected.network, - scheme: "exact", + scheme: selected.scheme, mode: outputMode(options), result: { url, @@ -1262,17 +292,19 @@ async function pay(url: string, options: ParsedOptions): Promise { }); return; } - const payload = await withSdkStdoutRedirect(outputMode(options) === "json", () => + const creation = await withSdkStdoutRedirect(outputMode(options) === "json", () => createPaymentPayload({ selected, resource: required.resource?.url ?? url, extensions: required.extensions, rpcUrl: opt(options, "rpc-url"), privateKey: opt(options, "private-key"), + gasfreeApiUrl: opt(options, "gasfree-api-url"), + maxGasfreeFeeRaw, }), ); const retryHeaders = new Headers(baseHeaders); - retryHeaders.set(headers.signature, encodeSignature(payload)); + retryHeaders.set(headers.signature, encodeSignature(creation.payload)); const paid = await fetchWithTimeout(url, { method, headers: retryHeaders, @@ -1280,20 +312,42 @@ async function pay(url: string, options: ParsedOptions): Promise { }, timeoutMs(options), `fetch ${url}`); const body = await responsePayload(paid); const paymentResponse = paid.headers.get(headers.response); + const settlement = paymentResponse ? decodeResponse(paymentResponse) : undefined; + const settled = settlement?.success === true && typeof settlement?.transaction === "string" && settlement.transaction.length > 0 && typeof settlement?.network === "string" && settlement.network.length > 0; const result = { url, status: paid.status, - paid: paid.ok, + paid: settled, + settled, + delivered: paid.ok, response: body, - ...(paymentResponse ? { paymentResponse: decodeResponse(paymentResponse) } : {}), + ...(settlement !== undefined ? { + paymentResponse: settlement, + transaction: settlement?.transaction ?? settlement?.txHash ?? null, + } : {}), + ...(creation.gasfreeEstimate ? { gasfreeEstimate: creation.gasfreeEstimate } : {}), }; + if (paymentResponse && !settled) { + throw new CliError("INVALID_SETTLEMENT", "gateway returned an invalid or unsuccessful PAYMENT-RESPONSE", "Do not treat this request as paid; contact the gateway operator.", 1, result); + } if (!paid.ok) { - throw new Error(`HTTP ${paid.status} from ${url}: ${typeof body === "string" ? body.slice(0, 500) : JSON.stringify(body).slice(0, 500)}`); + const retryAfter = paid.headers.get("retry-after"); + throw new CliError( + paid.status === 429 ? "RATE_LIMITED" : "HTTP_ERROR", + `HTTP ${paid.status} from ${url}${retryAfter ? ` (retry after ${retryAfter}s)` : ""}: ${ + typeof body === "string" ? body.slice(0, 500) : JSON.stringify(body).slice(0, 500) + }`, + paymentResponse + ? "The gateway reports that payment was settled; retain paymentResponse for support or reconciliation." + : "Inspect the HTTP response and retry only when it is safe to do so.", + 1, + result, + ); } emit({ command: "client", network: selected.network, - scheme: "exact", + scheme: selected.scheme, mode: outputMode(options), result, }); @@ -1301,342 +355,14 @@ async function pay(url: string, options: ParsedOptions): Promise { async function roundtrip(options: ParsedOptions): Promise { const port = Number(opt(options, "port", "4020")); - serve(options); - await delay(500); + await serve(options); await pay(`http://127.0.0.1:${port}/pay`, options); process.exit(0); } -function executableInPath(name: string): string | undefined { - for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { - if (!dir) continue; - const candidate = path.join(dir, name); - try { - fs.accessSync(candidate, fs.constants.X_OK); - return candidate; - } catch { - // Try the next PATH entry. - } - } - return undefined; -} - -function resolveGatewayPackageRuntime(): string | undefined { - try { - return require.resolve("@bankofai/x402-gateway/dist/cli.js"); - } catch { - return undefined; - } -} - -function gatewayCommand(options: ParsedOptions): { command: string; argsPrefix: string[]; source: string } { - const explicit = opt(options, "gateway-bin"); - const gatewayPackageRuntime = resolveGatewayPackageRuntime(); - const candidates = [ - explicit ? { file: explicit, source: "--gateway-bin" } : undefined, - gatewayPackageRuntime ? { file: gatewayPackageRuntime, source: "@bankofai/x402-gateway dependency" } : undefined, - { file: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "gateway", "cli.js"), source: "bundled gateway runtime" }, - executableInPath("x402-gateway") ? { file: executableInPath("x402-gateway")!, source: "PATH x402-gateway" } : undefined, - { file: path.resolve(process.cwd(), "../x402-gateway/dist/cli.js"), source: "sibling ../x402-gateway" }, - ].filter(Boolean) as Array<{ file: string; source: string }>; - for (const candidate of candidates) { - try { - fs.accessSync(candidate.file, fs.constants.R_OK); - if (candidate.file.endsWith(".js")) { - return { command: process.execPath, argsPrefix: [candidate.file], source: candidate.source }; - } - return { command: candidate.file, argsPrefix: [], source: candidate.source }; - } catch { - // Try the next candidate. - } - } - throw new Error( - "x402-gateway runtime not found. Install @bankofai/x402-gateway, run from a checkout with ../x402-gateway/dist/cli.js, or pass --gateway-bin .", - ); -} - -async function gatewayStart(args: string[], options: ParsedOptions): Promise { - const gateway = gatewayCommand(options); - const child = spawn(gateway.command, [...gateway.argsPrefix, ...args], { - stdio: "inherit", - env: process.env, - }); - await new Promise((resolve, reject) => { - child.on("exit", code => code === 0 ? resolve() : reject(new Error(`gateway exited with ${code}`))); - child.on("error", reject); - }); -} - -function gatewayCheck(target: string, options: ParsedOptions): void { - const files = providerFiles(target); - const providers = files.map(loadProviderFile); - const names = new Set(); - for (const provider of providers) { - if (names.has(provider.name)) throw new Error(`duplicate provider name: ${provider.name}`); - names.add(provider.name); - } - emit({ - command: "gateway check", - mode: outputMode(options), - result: { providers: providers.map(p => p.name), count: providers.length }, - }); -} - -function gatewayScaffold(name: string, options: ParsedOptions): void { - const outputDir = opt(options, "output-dir", path.join("providers", name))!; - const forwardUrl = opt(options, "forward-url", "https://api.example.com")!; - fs.mkdirSync(outputDir, { recursive: true }); - const body = `name: ${name} -title: "${name}" -description: "x402 provider" -category: data -version: v1 - -forward_url: ${forwardUrl} - -routing: - type: proxy - -operator: - network: tron-nile - currencies: - usd: ["USDT"] - recipient: - scheme: exact - protocol: exact - asset_transfer_method: permit2 - facilitator_url: https://facilitator.bankofai.io - facilitator_api_key: - valid_for_seconds: 300 - -endpoints: - - method: GET - path: /v1/ping - metering: - dimensions: - - tiers: - - price_usd: 0.0001 - `; - fs.writeFileSync(path.join(outputDir, "provider.yml"), body); - emit({ - command: "gateway scaffold", - mode: outputMode(options), - result: { file: path.join(outputDir, "provider.yml") }, - }); -} - -function catalogBuild(target: string, options: ParsedOptions): void { - const providers = providerFiles(target).map(loadProviderFile).map(providerCatalog); - const catalog = { version: 1, generatedAt: new Date().toISOString(), providers }; - const output = opt(options, "output", opt(options, "dist-dir") ? path.join(opt(options, "dist-dir")!, "catalog.json") : undefined); - if (output) { - fs.mkdirSync(path.dirname(output), { recursive: true }); - fs.writeFileSync(output, JSON.stringify(catalog, null, 2)); - emit({ - command: "catalog build", - mode: outputMode(options), - result: { output, count: providers.length }, - }); - } else if (outputMode(options) === "json") { - emit({ command: "catalog build", mode: "json", result: catalog }); - } else { - printJson(catalog); - } -} - -function catalogPayAssets(target: string, options: ParsedOptions): void { - const rows = providerFiles(target).map(loadProviderFile).flatMap(provider => - (provider.endpoints ?? []).map((endpoint: any) => ({ - provider: provider.name, - method: endpoint.method, - path: `/providers/${provider.name}${endpoint.path}`, - network: normalizeNetwork(provider.operator.network), - currency: provider.operator.currencies?.usd?.[0] ?? "USDT", - price_usd: providerPrice(endpoint), - scheme: "exact", - assetTransferMethod: providerAssetTransferMethod(provider), - })), - ); - emit({ - command: "gateway catalog pay-assets", - mode: outputMode(options), - result: { count: rows.length, assets: rows }, - }); -} - -async function readProviderDetailForSearch(source: string, fqn: string, options: ParsedOptions): Promise { - try { - return await readJson(catalogDetailSource(source, "providers", fqn), options); - } catch { - return {}; - } -} - -async function searchCatalog(source: string, query: string, options: ParsedOptions): Promise { - const providers = await readCatalog(source, options); - const terms = query.toLowerCase().split(/\s+/).filter(Boolean); - if (!terms.length) return []; - const includeBlocked = hasFlag(options, "include-blocked"); - const hits: SearchHit[] = []; - for (const provider of providers) { - if (provider.block && !includeBlocked) continue; - const fqn = String(provider.fqn ?? provider.name ?? ""); - if (!fqn) continue; - const detail = await readProviderDetailForSearch(source, fqn, options); - const tags = stringList(detail.featured_tags ?? provider.featured_tags ?? detail.tags ?? provider.tags); - const endpoints = Array.isArray(detail.endpoints) ? detail.endpoints : Array.isArray(provider.endpoints) ? provider.endpoints : []; - const categoryMeta = detail.category_meta ?? provider.category_meta; - const chains = stringList(detail.chains ?? provider.chains); - const chainKinds = stringList(detail.chain_kinds ?? provider.chain_kinds); - const chainsMetaRaw = detail.chains_meta ?? provider.chains_meta ?? []; - const chainsMeta = Array.isArray(chainsMetaRaw) ? chainsMetaRaw.filter(item => item && typeof item === "object") : []; - const titleZh = String(detail.title_zh ?? provider.title_zh ?? ""); - const mainTitle = String(detail.main_title ?? provider.main_title ?? detail.mainTitle ?? provider.mainTitle ?? ""); - const subTitle = String(detail.sub_title ?? provider.sub_title ?? detail.subTitle ?? provider.subTitle ?? ""); - const fields = { - fqn: [fqn], - title: [String(detail.title ?? provider.title ?? ""), mainTitle], - i18n: [titleZh, subTitle, ...dictValues(detail.i18n ?? provider.i18n)], - category: [String(detail.category ?? provider.category ?? "")], - category_meta: dictValues(categoryMeta), - chains: [...chains, ...chainMetaValues(chainsMeta)], - chain_kinds: chainKinds, - service_url: [String(detail.service_url ?? provider.service_url ?? detail.serviceUrl ?? provider.serviceUrl ?? "")], - description: [String(detail.description ?? provider.description ?? "")], - use_case: [String(detail.use_case ?? provider.use_case ?? detail.useCase ?? provider.useCase ?? "")], - tags, - endpoints: endpointFields(endpoints), - }; - const scored = scoreFields(terms, fields); - if (scored.score === 0) continue; - hits.push({ - provider, - detail, - fqn, - title: fields.title[0], - category: fields.category[0], - serviceUrl: fields.service_url[0], - description: fields.description[0] || undefined, - useCase: fields.use_case[0] || undefined, - titleZh: titleZh || undefined, - mainTitle: mainTitle || undefined, - subTitle: subTitle || undefined, - categoryMeta: categoryMeta && typeof categoryMeta === "object" ? categoryMeta : undefined, - chains, - chainKinds, - chainsMeta, - tags, - endpoints, - score: scored.score, - matchedFields: scored.matchedFields, - }); - } - return hits - .sort((a, b) => b.score - a.score || a.fqn.localeCompare(b.fqn)) - .slice(0, positiveIntegerOption(options, "limit", 10)); -} - -function searchHitToJson(hit: SearchHit): Record { - return { - fqn: hit.fqn, - title: hit.title, - category: hit.category, - serviceUrl: hit.serviceUrl, - description: hit.description, - useCase: hit.useCase, - title_zh: hit.titleZh, - main_title: hit.mainTitle, - sub_title: hit.subTitle, - category_meta: hit.categoryMeta, - chains: hit.chains, - chain_kinds: hit.chainKinds, - chains_meta: hit.chainsMeta, - tags: hit.tags, - score: hit.score, - matchedFields: hit.matchedFields, - endpoints: hit.endpoints, - }; -} - -async function catalogSearch(source: string, query: string, options: ParsedOptions): Promise { - positiveIntegerOption(options, "limit", 10); - const hits = await searchCatalog(source, query, options); - const results = hits.map(searchHitToJson); - if (outputMode(options) === "json") { - emit({ command: "catalog search", mode: "json", result: { query, catalog: source, count: hits.length, results } }); - return; - } - if (!hits.length) { - process.stdout.write("no matches\n"); - return; - } - for (const hit of hits) { - const tags = hit.tags.length ? hit.tags.join(",") : "-"; - process.stdout.write(`${hit.fqn.padEnd(32)} score=${String(hit.score).padEnd(3)} category=${hit.category.padEnd(12)} tags=${tags}\n`); - if (hit.title) process.stdout.write(` ${hit.title}\n`); - if (hit.description) process.stdout.write(` ${hit.description.split("\n")[0]}\n`); - if (hit.serviceUrl) process.stdout.write(` service: ${hit.serviceUrl}\n`); - for (const endpoint of hit.endpoints.slice(0, 3)) { - const method = String(endpoint.method ?? ""); - const pathText = String(endpoint.path ?? endpoint.url ?? ""); - const paid = endpoint.paid; - let suffix = ""; - if (paid && typeof paid === "object") { - suffix = ` ${paid.network ?? ""} ${paid.currency ?? ""} ${paid.amount_raw ?? ""}`.trimEnd(); - } - process.stdout.write(` ${method.padEnd(6)} ${pathText}${suffix ? ` ${suffix}` : ""}\n`); - } - process.stdout.write("\n"); - } -} - -async function catalogShow(source: string, name: string, options: ParsedOptions): Promise { - requireArgument(name, "provider", "x402-cli catalog show [--catalog ]"); - const provider = await readCatalogProvider(source, name, options); - if (outputMode(options) === "json") { - emit({ command: "catalog show", mode: "json", result: provider }); - return; - } - process.stdout.write(`${provider.fqn ?? provider.name} - ${provider.title ?? provider.main_title ?? provider.name}\n`); - if (provider.description) process.stdout.write(`${String(provider.description).split("\n")[0]}\n`); - if (provider.category) process.stdout.write(`category: ${provider.category}\n`); - if (provider.chains) process.stdout.write(`chains: ${provider.chains.join(", ")}\n`); -} - -async function catalogEndpoints(source: string, name: string, options: ParsedOptions): Promise { - requireArgument(name, "provider", "x402-cli catalog endpoints [--catalog ]"); - const provider = await readCatalogProvider(source, name, options); - const endpoints = provider.endpoints ?? []; - if (outputMode(options) === "json") { - emit({ command: "catalog endpoints", mode: "json", result: { provider: provider.fqn ?? provider.name, endpoints } }); - return; - } - for (const endpoint of endpoints) { - process.stdout.write(`${String(endpoint.method ?? "").padEnd(6)} ${endpoint.path ?? endpoint.url ?? ""}\n`); - if (endpoint.description) process.stdout.write(` ${String(endpoint.description).split("\n")[0]}\n`); - } -} - -async function catalogPayJson(source: string, name: string, options: ParsedOptions): Promise { - requireArgument(name, "provider", "x402-cli catalog pay-json [--catalog ]"); - const provider = await readCatalogPayProvider(source, name, options); - const endpoint = (provider.endpoints ?? []).find((item: any) => item.paid || item.x402_routes?.length || item.x402Routes?.length) ?? provider.endpoints?.[0]; - if (!endpoint) throw new Error(`provider has no endpoints: ${name}`); - const result = { - provider: provider.fqn ?? provider.name, - url: endpoint.url ?? endpoint.path, - method: endpoint.method, - paid: endpoint.paid, - x402_routes: endpoint.x402_routes ?? endpoint.x402Routes ?? [], - endpoint, - }; - if (hasFlag(options, "raw")) printJson(result); - else emit({ command: "catalog pay-json", mode: outputMode(options), result }); -} - async function handleGateway(args: string[]): Promise { const { command, positional, options } = parseArgs(args); - if (hasFlag(options, "help") || command === "help") { + if (hasFlag(options, "help") || ["help", "--help", "-h"].includes(command)) { process.stdout.write(helpText(command === "catalog" || positional[0] === "catalog" ? "gateway-catalog" : "gateway")); return; } @@ -1658,24 +384,6 @@ async function handleGatewayCatalog(positional: string[], options: ParsedOptions else throw new CliError("UNKNOWN_COMMAND", `Unknown gateway catalog command: ${sub}`, "Run x402-cli gateway catalog --help to list commands.", 2); } -async function handleCatalog(args: string[]): Promise { - const { command, positional, options } = parseArgs(args); - if (hasFlag(options, "help") || command === "help") { - const topic = command === "help" ? positional[0] : command; - process.stdout.write(helpText(topic ? `catalog-${topic}` : "catalog")); - return; - } - const source = opt(options, "catalog", defaultCatalogSource())!; - if (command === "update") await catalogUpdate(source, options); - else if (command === "search") await catalogSearch(source, requireArgument(positional.join(" "), "query", "x402-cli catalog search [options]"), options); - else if (command === "show") await catalogShow(source, positional[0], options); - else if (command === "endpoints") await catalogEndpoints(source, positional[0], options); - else if (command === "pay-json") await catalogPayJson(source, positional[0], options); - else if (command === "export-gateway") await catalogExportGateway(positional[0], options); - else if (command === "build") catalogBuild(positional[0] ?? "providers", options); - else throw new CliError("UNKNOWN_COMMAND", `Unknown catalog command: ${command}`, "Run x402-cli catalog --help to list commands.", 2); -} - async function main(): Promise { const argv = process.argv.slice(2); const { command, positional, options } = parseArgs(argv); @@ -1697,7 +405,7 @@ async function main(): Promise { return; } if (command === "serve") { - if (hasFlag(options, "daemon")) serveDaemon(argv, options); + if (hasFlag(options, "daemon")) await startServeDaemon(argv, options, buildRequirement(options), fileURLToPath(import.meta.url)); else await serve(options); } else if (command === "pay") await pay(positional[0], options); diff --git a/src/daemon.ts b/src/daemon.ts new file mode 100644 index 0000000..a90a998 --- /dev/null +++ b/src/daemon.ts @@ -0,0 +1,44 @@ +import net from "node:net"; +import { spawn } from "node:child_process"; +import { setTimeout as delay } from "node:timers/promises"; +import { opt, outputMode, type ParsedOptions } from "./args.js"; +import { emit } from "./output.js"; +import type { PaymentRequirement } from "./x402.js"; + +function stripFlag(argv: string[], flag: string): string[] { + return argv.filter(item => item !== flag); +} + +async function waitForPort(host: string, port: number, timeout = 5_000): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const connected = await new Promise(resolve => { + const socket = net.createConnection({ host, port }); + socket.setTimeout(250); + socket.once("connect", () => { socket.destroy(); resolve(true); }); + socket.once("error", () => resolve(false)); + socket.once("timeout", () => { socket.destroy(); resolve(false); }); + }); + if (connected) return; + await delay(50); + } + throw new Error(`daemon did not become ready on ${host}:${port}`); +} + +export async function startServeDaemon(argv: string[], options: ParsedOptions, requirement: PaymentRequirement, script: string): Promise { + if (!requirement.payTo) throw new Error("--pay-to is required"); + const daemonArgs = stripFlag(stripFlag(argv, "--daemon"), "-d"); + const child = spawn(process.execPath, [script, ...daemonArgs], { detached: true, stdio: "ignore", env: process.env }); + child.unref(); + const host = opt(options, "host", "127.0.0.1")!; + const port = Number(opt(options, "port", "4020")); + const resourceUrl = opt(options, "resource-url", `http://${host}:${port}/pay`)!; + try { + await waitForPort(host === "0.0.0.0" ? "127.0.0.1" : host === "::" ? "::1" : host, port); + } catch (error) { + if (child.pid) try { process.kill(child.pid); } catch { /* child already exited */ } + throw error; + } + emit({ command: "server", mode: outputMode(options), network: requirement.network, scheme: requirement.scheme, + result: { pid: child.pid, pay_url: resourceUrl, resource_url: resourceUrl, daemon: true } }); +} diff --git a/src/gateway-commands.ts b/src/gateway-commands.ts new file mode 100644 index 0000000..9acccef --- /dev/null +++ b/src/gateway-commands.ts @@ -0,0 +1,150 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { opt, outputMode, type ParsedOptions } from "./args.js"; +import { emit, printJson } from "./output.js"; +import { buildGatewayCatalog, loadProviders, providerPaymentAssets } from "@bankofai/x402-gateway"; + +const require = createRequire(import.meta.url); + +function executableInPath(name: string): string | undefined { + for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { + if (!dir) continue; + const candidate = path.join(dir, name); + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch { + // Try the next PATH entry. + } + } + return undefined; +} + +function resolveGatewayPackageRuntime(): string | undefined { + try { + return path.join(path.dirname(require.resolve("@bankofai/x402-gateway/package.json")), "dist", "cli.js"); + } catch { + return undefined; + } +} + +function gatewayCommand(options: ParsedOptions): { command: string; argsPrefix: string[]; source: string } { + const explicit = opt(options, "gateway-bin"); + const gatewayPackageRuntime = resolveGatewayPackageRuntime(); + const candidates = [ + explicit ? { file: explicit, source: "--gateway-bin" } : undefined, + gatewayPackageRuntime ? { file: gatewayPackageRuntime, source: "@bankofai/x402-gateway dependency" } : undefined, + { file: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "gateway", "cli.js"), source: "bundled gateway runtime" }, + executableInPath("x402-gateway") ? { file: executableInPath("x402-gateway")!, source: "PATH x402-gateway" } : undefined, + { file: path.resolve(process.cwd(), "../x402-gateway/dist/cli.js"), source: "sibling ../x402-gateway" }, + ].filter(Boolean) as Array<{ file: string; source: string }>; + for (const candidate of candidates) { + try { + fs.accessSync(candidate.file, fs.constants.R_OK); + if (candidate.file.endsWith(".js")) { + return { command: process.execPath, argsPrefix: [candidate.file], source: candidate.source }; + } + return { command: candidate.file, argsPrefix: [], source: candidate.source }; + } catch { + // Try the next candidate. + } + } + throw new Error( + "x402-gateway runtime not found. Install @bankofai/x402-gateway, run from a checkout with ../x402-gateway/dist/cli.js, or pass --gateway-bin .", + ); +} + +export async function gatewayStart(args: string[], options: ParsedOptions): Promise { + const gateway = gatewayCommand(options); + const child = spawn(gateway.command, [...gateway.argsPrefix, ...args], { + stdio: "inherit", + env: process.env, + }); + await new Promise((resolve, reject) => { + child.on("exit", code => code === 0 ? resolve() : reject(new Error(`gateway exited with ${code}`))); + child.on("error", reject); + }); +} + +export function gatewayCheck(target: string, options: ParsedOptions): void { + const providers = loadProviders(target); + emit({ + command: "gateway check", + mode: outputMode(options), + result: { providers: [...providers.keys()], count: providers.size }, + }); +} + +export function gatewayScaffold(name: string, options: ParsedOptions): void { + const outputDir = opt(options, "output-dir", path.join("providers", name))!; + const forwardUrl = opt(options, "forward-url", "https://api.example.com")!; + fs.mkdirSync(outputDir, { recursive: true }); + const body = `name: ${name} +title: "${name}" +description: "x402 provider" +category: data +version: v1 + +forward_url: ${forwardUrl} + +routing: + type: proxy + +operator: + network: tron:0xcd8690dc + currencies: + usd: ["USDT"] + recipient: + scheme: exact + protocol: exact + asset_transfer_method: permit2 + facilitator_url: https://facilitator.bankofai.io + facilitator_api_key: + valid_for_seconds: 300 + +endpoints: + - method: GET + path: /v1/ping + metering: + dimensions: + - tiers: + - price_usd: 0.0001 + `; + fs.writeFileSync(path.join(outputDir, "provider.yml"), body); + emit({ + command: "gateway scaffold", + mode: outputMode(options), + result: { file: path.join(outputDir, "provider.yml") }, + }); +} + +export function catalogBuild(target: string, options: ParsedOptions): void { + const providers = loadProviders(target); + const catalog = buildGatewayCatalog(providers); + const output = opt(options, "output", opt(options, "dist-dir") ? path.join(opt(options, "dist-dir")!, "catalog.json") : undefined); + if (output) { + fs.mkdirSync(path.dirname(output), { recursive: true }); + fs.writeFileSync(output, JSON.stringify(catalog, null, 2)); + emit({ + command: "catalog build", + mode: outputMode(options), + result: { output, count: providers.size }, + }); + } else if (outputMode(options) === "json") { + emit({ command: "catalog build", mode: "json", result: catalog }); + } else { + printJson(catalog); + } +} + +export function catalogPayAssets(target: string, options: ParsedOptions): void { + const rows = providerPaymentAssets(loadProviders(target)); + emit({ + command: "gateway catalog pay-assets", + mode: outputMode(options), + result: { count: rows.length, assets: rows }, + }); +} diff --git a/src/help.ts b/src/help.ts new file mode 100644 index 0000000..f885da4 --- /dev/null +++ b/src/help.ts @@ -0,0 +1,171 @@ +import fs from "node:fs"; + +export function getVersion(): string { + try { + const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")); + return String(pkg.version ?? "0.0.0"); + } catch { + return "0.0.0"; + } +} + +export function helpText(topic = "root"): string { + const sections: Record = { + root: `x402-cli ${getVersion()} + +Usage: + x402-cli [options] + +Commands: + pay Pay an x402-protected URL + serve Run a local x402 paywall endpoint + roundtrip Start serve, pay it, then exit + gateway Manage local gateway provider files + catalog Search, cache, and export provider catalog assets + +Global options: + -h, --help Show help + -V, --version Show version + --json Print machine-readable JSON envelope + --human Print human-readable output (default) +`, + pay: `Usage: + x402-cli pay [options] + +Options: + --method HTTP method (default: GET) + --header "Name: Value" Request header, repeatable + --body Request body for non-GET/HEAD methods + --network Require a specific network + --token Require a specific token + --scheme Require a specific x402 scheme + --gasfree-api-url Override the TRON GasFree relayer API URL + --max-gasfree-fee Maximum GasFree relayer fee in token units + --max-gasfree-fee-raw Maximum GasFree relayer fee in smallest units + --max-amount Maximum human-readable payment amount + --max-raw-amount Maximum smallest-unit payment amount + --dry-run Read requirements but do not sign or pay + --private-key Explicit payer private key (or PRIVATE_KEY/TRON_PRIVATE_KEY/EVM_PRIVATE_KEY) + --rpc-url Explicit network RPC URL + --timeout-ms Network timeout in milliseconds (default: 30000) + --json Print JSON envelope + +Examples: + x402-cli pay https://api.example.com/paid --dry-run --json + x402-cli pay https://api.example.com/paid --max-amount 0.01 +`, + serve: `Usage: + x402-cli serve --pay-to
[options] + +Options: + --pay-to
Recipient wallet address + --amount Human-readable token amount (default: 0.0001) + --raw-amount Smallest-unit amount + --network Payment network (default: tron:0xcd8690dc) + --scheme Payment scheme: exact or exact_gasfree (default: exact) + --token Token symbol (default: USDT) + --asset
Explicit token address + --decimals Token decimals for unregistered --asset + --host Bind host (default: 127.0.0.1) + --port Bind port (default: 4020) + --resource-url URL advertised in payment requirements + --facilitator-url Facilitator base URL + --timeout-ms Facilitator timeout in milliseconds (default: 30000) + --daemon Run in background and print the child pid + --json Print JSON envelope + +Examples: + x402-cli serve --pay-to T... --network tron:0xcd8690dc --token USDT + x402-cli serve --pay-to 0x... --network eip155:97 --token USDT --amount 0.0001 +`, + roundtrip: `Usage: + x402-cli roundtrip --pay-to
[serve/pay options] +`, + gateway: `Usage: + x402-cli gateway [options] + +Commands: + search Search a gateway/catalog artifact + start Start a local x402 gateway process + check Validate provider.yml files + scaffold Write a starter provider.yml + catalog Build/check/search gateway catalog assets +`, + "gateway-catalog": `Usage: + x402-cli gateway catalog [options] + +Commands: + build Build a local catalog from provider.yml files + check Validate local provider.yml files + pay-assets List payable endpoint assets + search Search a catalog artifact +`, + catalog: `Usage: + x402-cli catalog [options] + +Commands: + update Cache hosted/local catalog assets under ~/.cache + search Search providers + show Show provider detail JSON + endpoints List provider endpoints + pay-json Print provider pay JSON + export-gateway Export catalog.json and pay.md from a gateway + build Build catalog from provider.yml files + +Options: + --catalog catalog.json path or URL + --provider Provider FQN for export-gateway + --output-dir Output directory for generated files + -n, --limit Search result limit + --timeout-ms Network timeout in milliseconds (default: 30000) + --include-blocked Include blocked providers in search + --json Print JSON envelope +`, + "catalog-search": `Usage: + x402-cli catalog search [--catalog ] [options] + +Options: + --catalog catalog.json path or URL + -n, --limit Search result limit + --timeout-ms Network timeout in milliseconds (default: 30000) + --include-blocked Include blocked providers in search + --json Print JSON envelope +`, + "catalog-show": `Usage: + x402-cli catalog show [--catalog ] [options] + +Options: + --catalog catalog.json path or URL + --timeout-ms Network timeout in milliseconds (default: 30000) + --json Print JSON envelope +`, + "catalog-pay-json": `Usage: + x402-cli catalog pay-json [--catalog ] [options] + +Options: + --catalog catalog.json path or URL + --timeout-ms Network timeout in milliseconds (default: 30000) + --raw Print raw pay payload instead of JSON envelope + --json Print JSON envelope +`, + "catalog-endpoints": `Usage: + x402-cli catalog endpoints [--catalog ] [options] + +Options: + --catalog catalog.json path or URL + --timeout-ms Network timeout in milliseconds (default: 30000) + --json Print JSON envelope +`, + "catalog-export-gateway": `Usage: + x402-cli catalog export-gateway --provider [options] + +Options: + --provider Provider FQN to export + --output-dir Output directory for generated files + --force Overwrite existing files + --json Print JSON envelope +`, + }; + return sections[topic] ?? sections.root; +} + diff --git a/src/http-client.ts b/src/http-client.ts new file mode 100644 index 0000000..3a1b38c --- /dev/null +++ b/src/http-client.ts @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import { CliError, opt, type ParsedOptions } from "./args.js"; + +const MAX_HTTP_BODY_BYTES = 10 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 30_000; + +export function positiveIntegerOption(options: ParsedOptions, key: string, fallback: number): number { + const value = opt(options, key, String(fallback))!; + if (!/^\d+$/.test(value)) throw new CliError("INVALID_ARGUMENT", `--${key} must be a positive integer`, `Pass --${key} with a value greater than zero.`, 2); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new CliError("INVALID_ARGUMENT", `--${key} must be a positive integer`, `Pass --${key} with a value greater than zero.`, 2); + return parsed; +} + +export function timeoutMs(options?: ParsedOptions): number { + return options ? positiveIntegerOption(options, "timeout-ms", DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS; +} + +export async function fetchWithTimeout(input: string | URL, init: RequestInit = {}, timeout = DEFAULT_TIMEOUT_MS, label = "request"): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") throw new Error(`${label} timed out after ${timeout}ms`); + throw error; + } finally { + clearTimeout(timer); + } +} + +export async function readBoundedText(response: Response, label: string): Promise { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > MAX_HTTP_BODY_BYTES) throw new Error(`${label} exceeds ${MAX_HTTP_BODY_BYTES} byte limit`); + if (!response.body) return ""; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_HTTP_BODY_BYTES) { + await reader.cancel(); + throw new Error(`${label} exceeds ${MAX_HTTP_BODY_BYTES} byte limit`); + } + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return new TextDecoder().decode(bytes); +} + +export async function readText(source: string, options?: ParsedOptions): Promise { + if (!source.startsWith("http://") && !source.startsWith("https://")) return fs.readFileSync(source, "utf8"); + const response = await fetchWithTimeout(source, {}, timeoutMs(options), `fetch ${source}`); + if (!response.ok) throw new Error(`failed to fetch ${source}: ${response.status}`); + return readBoundedText(response, `response from ${source}`); +} + +export async function readJson(source: string, options?: ParsedOptions): Promise { + return JSON.parse(await readText(source, options)); +} + +export async function responsePayload(response: Response): Promise { + const text = await readBoundedText(response, "HTTP response"); + if ((response.headers.get("content-type") ?? "").toLowerCase().includes("json")) { + try { return JSON.parse(text); } catch { return text; } + } + return text; +} diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 0000000..ce9722a --- /dev/null +++ b/src/output.ts @@ -0,0 +1,68 @@ +import { CliError, type OutputMode } from "./args.js"; + +export type FriendlyError = { code: string; message: string; hint: string; details?: unknown }; + +export function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +export function emit(args: { + command: string; result?: any; error?: FriendlyError; network?: string; scheme?: string; mode?: OutputMode; +}): void { + const mode = args.mode ?? "human"; + if (mode === "json") { + const envelope: Record = { ok: !args.error, command: args.command }; + if (args.network) envelope.network = args.network; + if (args.scheme) envelope.scheme = args.scheme; + if (args.error) envelope.error = args.error; + else envelope.result = args.result ?? null; + printJson(envelope); + return; + } + if (args.error) { + process.stderr.write(`ERROR ${args.command}: ${args.error.code}\n`); + process.stderr.write(` ${args.error.message}\n`); + if (args.error.hint) process.stderr.write(` hint: ${args.error.hint}\n`); + if (args.error.details !== undefined) process.stderr.write(` details: ${JSON.stringify(args.error.details)}\n`); + return; + } + const suffix = [args.network, args.scheme].filter(Boolean).join(" "); + process.stdout.write(`OK ${args.command}${suffix ? ` (${suffix})` : ""}\n`); + if (args.result && typeof args.result === "object" && !Array.isArray(args.result)) { + for (const [key, value] of Object.entries(args.result)) { + if (value === undefined) continue; + process.stdout.write(value && typeof value === "object" ? ` ${key}: ${JSON.stringify(value)}\n` : ` ${key}: ${value}\n`); + } + } else if (args.result !== undefined) process.stdout.write(` ${args.result}\n`); +} + +export function classify(error: unknown): FriendlyError { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof CliError) return { code: error.code, message, hint: error.hint, details: error.details }; + const lower = message.toLowerCase(); + if (lower.includes("missing private key") || lower.includes("could not find a wallet")) return { code: "WALLET_NOT_CONFIGURED", message, hint: "Set PRIVATE_KEY, TRON_PRIVATE_KEY, EVM_PRIVATE_KEY, or configure agent-wallet with a payer wallet." }; + if (lower.includes("wallets_config") || lower.includes("wallet config")) return { code: "WALLET_CONFIG_CORRUPT", message, hint: "Check ~/.agent-wallet/wallets_config.json or recreate the local agent-wallet configuration." }; + if (lower.includes("does not exist") && lower.includes("account [t")) return { code: "TRON_ACCOUNT_NOT_ACTIVATED", message, hint: "Activate the TRON address by sending it a small amount of TRX before signing contract calls." }; + if (lower.includes("permit2_insufficient_balance") || lower.includes("insufficient") && lower.includes("balance")) return { code: "INSUFFICIENT_TOKEN_BALANCE", message, hint: "Fund the payer address with the exact token and network advertised by the provider, then retry." }; + if (lower.includes("transfer_from_failed") || lower.includes("transferfrom failed")) return { code: "TOKEN_TRANSFER_FAILED", message, hint: "Check token balance, token contract, payer address, and that the selected x402 route matches the provider requirement." }; + if (lower.includes("insufficient funds for gas") || lower.includes("insufficient gas") || lower.includes("energy")) return { code: "INSUFFICIENT_GAS", message, hint: "Fund the payer address with the native gas token for this network." }; + if (lower.includes("deadline") || lower.includes("expired")) return { code: "DEADLINE_OR_CLOCK_SKEW", message, hint: "Check local clock sync and retry with a fresh payment requirement." }; + if (lower.includes("permittransferfrom") || lower.includes("invalid signature") || lower.includes("permit reverted")) return { code: "PERMIT_REVERTED", message, hint: "The token or Permit2 contract rejected the signature; retry with a fresh requirement and verify token/network support." }; + if (lower.includes("tokenregistry") && lower.includes("import")) return { code: "SDK_API_DRIFT", message, hint: "Installed x402 SDK packages do not match this CLI; reinstall @bankofai/x402-cli and SDK dependencies." }; + if (lower.includes("429") || lower.includes("too many requests") || lower.includes("rate limit")) return { code: "RATE_LIMITED", message, hint: "Wait briefly and retry; the upstream service or RPC is rate limiting requests." }; + if (lower.includes("402 response missing")) return { code: "INVALID_X402_RESPONSE", message, hint: "The endpoint returned HTTP 402 without a PAYMENT-REQUIRED header." }; + if (lower.includes("no matching payment requirement")) return { code: "NO_MATCHING_PAYMENT_REQUIREMENT", message, hint: "Relax --network, --token, or --scheme, or use values offered by the provider." }; + if (lower.includes("exceeds --max")) return { code: "PAYMENT_AMOUNT_TOO_HIGH", message, hint: "Increase the max amount flag only if this provider price is expected." }; + if (lower.includes("failed to fetch") || lower.includes("fetch failed") || lower.includes("econnrefused") || lower.includes("timed out")) return { code: "NETWORK_ERROR", message, hint: "Check the URL, local server, proxy, and network connectivity." }; + if (lower.includes(" is required") || lower.includes("must be") || lower.includes("invalid --") || lower.includes("mutually exclusive")) return { code: lower.includes("required") ? "MISSING_ARGUMENT" : "INVALID_ARGUMENT", message, hint: "Run the command with --help to see valid usage and options." }; + return { code: "IO_ERROR", message, hint: "Run with --json for structured output, and check the provider/gateway logs for details." }; +} + +export async function withSdkStdoutRedirect(enabled: boolean, fn: () => Promise): Promise { + if (!enabled) return fn(); + const originalLog = console.log; + console.log = (...args: unknown[]) => { + process.stderr.write(`${args.map(arg => typeof arg === "string" ? arg : JSON.stringify(arg, null, 2)).join(" ")}\n`); + }; + try { return await fn(); } finally { console.log = originalLog; } +} diff --git a/src/tokens.ts b/src/tokens.ts index 34209e5..6947de6 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -8,7 +8,7 @@ export type TokenInfo = { }; export const TOKENS: Record> = { - "tron:mainnet": { + "tron:0x2b6653dc": { USDT: { address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", decimals: 6, @@ -26,7 +26,7 @@ export const TOKENS: Record> = { assetTransferMethod: "permit2", }, }, - "tron:nile": { + "tron:0xcd8690dc": { USDT: { address: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", decimals: 6, @@ -44,7 +44,7 @@ export const TOKENS: Record> = { assetTransferMethod: "permit2", }, }, - "tron:shasta": { + "tron:0x94a9059e": { USDT: { address: "TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs", decimals: 6, @@ -84,15 +84,25 @@ export const TOKENS: Record> = { }; export function normalizeNetwork(network: string): string { - return ( - { - "tron-mainnet": "tron:mainnet", - "tron-shasta": "tron:shasta", - "tron-nile": "tron:nile", - "bsc-mainnet": "eip155:56", - "bsc-testnet": "eip155:97", - }[network] ?? network - ); + const legacyTronIds: Record = { + "tron-mainnet": "tron:0x2b6653dc", + "tron:mainnet": "tron:0x2b6653dc", + mainnet: "tron:0x2b6653dc", + "tron-shasta": "tron:0x94a9059e", + "tron:shasta": "tron:0x94a9059e", + shasta: "tron:0x94a9059e", + "tron-nile": "tron:0xcd8690dc", + "tron:nile": "tron:0xcd8690dc", + nile: "tron:0xcd8690dc", + }; + const canonical = legacyTronIds[network]; + if (canonical) { + throw new Error(`legacy TRON network identifier ${network} is not supported; use ${canonical}`); + } + return { + "bsc-mainnet": "eip155:56", + "bsc-testnet": "eip155:97", + }[network] ?? network; } export function getToken(network: string, symbol: string): TokenInfo { @@ -114,7 +124,7 @@ export function assertRawAmount(value: string, name = "raw amount"): string { } export function toSmallestUnit(amount: string, decimals: number): string { - if (!Number.isInteger(decimals) || decimals < 0) throw new Error("token decimals must be a non-negative integer"); + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) throw new Error("token decimals must be an integer between 0 and 255"); if (!/^\d+(\.\d+)?$/.test(amount)) throw new Error("amount must be a non-negative decimal string"); const [whole, fraction = ""] = amount.split("."); if (fraction.length > decimals) throw new Error(`amount has more than ${decimals} decimal places`); diff --git a/src/x402.ts b/src/x402.ts index 4968011..027d907 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -9,6 +9,7 @@ import { import { x402Client } from "@bankofai/x402-core/client"; import { ExactEvmScheme, toClientEvmSigner } from "@bankofai/x402-evm"; import { ExactTronScheme, createClientTronSigner } from "@bankofai/x402-tron"; +import { ExactGasFreeTronScheme, createGasFreeApiClients, getGasFreeApiBaseUrl } from "@bankofai/x402-tron/gasfree"; import { createPublicClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { TronWeb } from "tronweb"; @@ -58,6 +59,7 @@ export function decodeResponse(value: string): any { } export function ensurePermit2(requirement: PaymentRequirement): PaymentRequirement { + if (requirement.scheme !== "exact") return requirement; const extra = { ...(requirement.extra ?? {}) }; if (!extra.assetTransferMethod) { const token = findTokenByAddress(requirement.network, requirement.asset); @@ -65,7 +67,7 @@ export function ensurePermit2(requirement: PaymentRequirement): PaymentRequireme extra.assetTransferMethod = "permit2"; } } - return { ...requirement, scheme: "exact", extra }; + return { ...requirement, extra }; } export function paymentRequired( @@ -134,7 +136,7 @@ function evmRpcUrl(network: string, explicit?: string): string | undefined { ); } -async function createTronWallet(privateKey: `0x${string}`) { +async function createTronWallet(privateKey: `0x${string}`, maxGasfreeFeeRaw?: string) { const rawKey = privateKey.replace(/^0x/, ""); const tronWeb = new TronWeb({ fullHost: "https://api.trongrid.io" }); const address = TronWeb.address.fromPrivateKey(rawKey); @@ -142,6 +144,12 @@ async function createTronWallet(privateKey: `0x${string}`) { return { getAddress: () => address, async signTypedData(args: any) { + if (maxGasfreeFeeRaw !== undefined && args?.primaryType === "PermitTransfer") { + const maxFee = BigInt(args?.message?.maxFee ?? -1); + if (maxFee < 0n || maxFee > BigInt(maxGasfreeFeeRaw)) { + throw new Error(`final GasFree maxFee ${maxFee} exceeds --max-gasfree-fee limit ${maxGasfreeFeeRaw}`); + } + } const signature = await signTronTypedData(tronWeb, args, rawKey); return signature.startsWith("0x") ? signature : `0x${signature}`; }, @@ -166,10 +174,13 @@ export async function createPaymentPayload(args: { rpcUrl?: string; apiKey?: string; allowanceMode?: string; -}): Promise { + gasfreeApiUrl?: string; + maxGasfreeFeeRaw?: string; +}): Promise<{ payload: unknown; gasfreeEstimate?: { fee: string; total: string } }> { const selected = ensurePermit2(args.selected); const required = paymentRequired(selected, args.resource, args.extensions); if (selected.network.startsWith("eip155:")) { + if (selected.scheme !== "exact") throw new Error(`unsupported scheme ${selected.scheme} on ${selected.network}`); const privateKey = privateKeyFrom( ["EVM_PRIVATE_KEY", "AGENT_WALLET_PRIVATE_KEY", "PRIVATE_KEY"], args.privateKey, @@ -180,9 +191,10 @@ export async function createPaymentPayload(args: { const publicClient = rpcUrl ? createPublicClient({ transport: http(rpcUrl) }) : undefined; const signer = toClientEvmSigner(account, publicClient); const scheme = new ExactEvmScheme(signer, rpcUrl ? { rpcUrl } : undefined); - return new x402Client() + const payload = await new x402Client() .register(selected.network as `${string}:${string}`, scheme) .createPaymentPayload(required as never); + return { payload }; } if (selected.network.startsWith("tron:")) { const privateKey = privateKeyFrom( @@ -190,16 +202,35 @@ export async function createPaymentPayload(args: { args.privateKey, ["tron_client", "payer", "default"], ); - const wallet = await createTronWallet(privateKey); + const wallet = await createTronWallet(privateKey, selected.scheme === "exact_gasfree" ? args.maxGasfreeFeeRaw : undefined); const signer = await createClientTronSigner(wallet, { network: selected.network, rpcUrl: args.rpcUrl || process.env.TRON_RPC_URL, apiKey: args.apiKey || process.env.TRON_GRID_API_KEY, allowanceMode: args.allowanceMode || process.env.X402_TRON_ALLOWANCE_MODE || "auto", } as never); - return new x402Client() - .register(selected.network as `${string}:${string}`, new ExactTronScheme(signer)) - .createPaymentPayload(required as never); + const client = new x402Client(); + let gasfreeEstimate: { fee: string; total: string } | undefined; + if (selected.scheme === "exact_gasfree") { + const apiUrl = args.gasfreeApiUrl || process.env.X402_GASFREE_API_URL || getGasFreeApiBaseUrl(selected.network); + const scheme = new ExactGasFreeTronScheme(signer, { + apiClients: createGasFreeApiClients({ [selected.network]: apiUrl }), + }); + const total = await scheme.estimateCost(selected as never); + const amount = BigInt(selected.amount); + const fee = total - amount; + if (args.maxGasfreeFeeRaw !== undefined && fee > BigInt(args.maxGasfreeFeeRaw)) { + throw new Error(`estimated GasFree fee ${fee} exceeds --max-gasfree-fee limit ${args.maxGasfreeFeeRaw}`); + } + gasfreeEstimate = { fee: fee.toString(), total: total.toString() }; + client.register(selected.network as `${string}:${string}`, scheme); + } else if (selected.scheme === "exact") { + client.register(selected.network as `${string}:${string}`, new ExactTronScheme(signer)); + } else { + throw new Error(`unsupported scheme ${selected.scheme} on ${selected.network}`); + } + const payload = await client.createPaymentPayload(required as never); + return { payload, ...(gasfreeEstimate ? { gasfreeEstimate } : {}) }; } throw new Error(`unsupported network ${selected.network}`); } diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index c3af5a5..6cbf73c 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -6,6 +6,7 @@ import path from "node:path"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import test from "node:test"; import { signTronTypedData } from "../dist/x402.js"; +import { normalizeNetwork } from "../dist/tokens.js"; const root = path.resolve(import.meta.dirname, ".."); const cli = path.join(root, "dist", "cli.js"); @@ -63,7 +64,7 @@ function catalogFixture(dir) { const catalog = { version: 1, providers: [ - { fqn: "alpha", title: "Alpha", category: "finance", featured_tags: ["defi"], chains: ["tron:nile"] }, + { fqn: "alpha", title: "Alpha", category: "finance", featured_tags: ["defi"], chains: ["tron:0xcd8690dc"] }, { fqn: "blocked", title: "Blocked", category: "security", block: true, featured_tags: ["defi"] }, ], }; @@ -72,7 +73,7 @@ function catalogFixture(dir) { title: "Alpha Provider", category: "finance", service_url: "https://alpha.example", - chains: ["tron:nile"], + chains: ["tron:0xcd8690dc"], featured_tags: ["defi", "tvl"], endpoints: [ { @@ -80,7 +81,7 @@ function catalogFixture(dir) { path: "/protocols", url: "https://gateway.example/providers/alpha/protocols", description: "DeFi TVL endpoint", - paid: { network: "tron:nile", currency: "USDT", amount_raw: "1" }, + paid: { network: "tron:0xcd8690dc", currency: "USDT", amount_raw: "1" }, }, ], }; @@ -103,6 +104,244 @@ test("help and version work", () => { assert.match(version.stdout.trim(), /^\d+\.\d+\.\d+/); }); +test("legacy TRON aliases are rejected in favor of canonical CAIP-2 IDs", () => { + assert.throws(() => normalizeNetwork("tron:nile"), /use tron:0xcd8690dc/); + assert.throws(() => normalizeNetwork("tron-nile"), /use tron:0xcd8690dc/); + assert.throws(() => normalizeNetwork("tron:mainnet"), /use tron:0x2b6653dc/); + assert.throws(() => normalizeNetwork("tron:shasta"), /use tron:0x94a9059e/); +}); + +test("serve advertises exact_gasfree and rejects it on EVM", async () => { + const port = 47000 + Math.floor(Math.random() * 1000); + const started = run([ + "serve", + "--pay-to", "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + "--network", "tron:0xcd8690dc", + "--scheme", "exact_gasfree", + "--port", String(port), + "--daemon", + "--json", + ]); + assert.equal(started.status, 0, started.stderr); + const parsed = JSON.parse(started.stdout); + assert.equal(parsed.scheme, "exact_gasfree"); + const pid = parsed.result.pid; + try { + for (let i = 0; i < 20; i += 1) { + try { + const response = await fetch(`http://127.0.0.1:${port}/pay`); + if (response.status !== 402) throw new Error(`unexpected status ${response.status}`); + const challenge = await response.json(); + assert.equal(challenge.accepts[0].scheme, "exact_gasfree"); + assert.deepEqual(challenge.accepts[0].extra, {}); + break; + } catch (error) { + if (i === 19) throw error; + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + } finally { + try { + process.kill(pid); + } catch { + // Process may already have exited. + } + } + + const evm = run([ + "serve", + "--pay-to", "0x0000000000000000000000000000000000000001", + "--network", "eip155:97", + "--scheme", "exact_gasfree", + "--daemon", + "--json", + ]); + assert.equal(evm.status, 1); + assert.match(evm.stdout, /supported only on TRON/); +}); + +test("pay dry-run preserves an exact_gasfree requirement", async () => { + await withServer((request, response) => { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact_gasfree", + network: "tron:0xcd8690dc", + amount: "1", + asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + payTo: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + response.end(JSON.stringify(challenge)); + }, async base => { + const result = await runAsync(["pay", `${base}/pay`, "--dry-run", "--scheme", "exact_gasfree", "--json"]); + assert.equal(result.status, 0, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.scheme, "exact_gasfree"); + assert.equal(parsed.result.selected.scheme, "exact_gasfree"); + }); +}); + +test("pay skips unknown-network requirements when selecting a token", async () => { + await withServer((request, response) => { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [ + { + scheme: "exact", + network: "eip155:999999", + amount: "1", + asset: "0x0000000000000000000000000000000000000001", + payTo: "0x0000000000000000000000000000000000000002", + }, + { + scheme: "exact_gasfree", + network: "tron:0xcd8690dc", + amount: "1", + asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + payTo: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + }, + ], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + response.end(JSON.stringify(challenge)); + }, async base => { + const result = await runAsync(["pay", `${base}/pay`, "--dry-run", "--token", "USDT", "--json"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).result.selected.network, "tron:0xcd8690dc"); + }); +}); + +test("pay reports non-2xx gateway responses as failures", async () => { + await withServer((_request, response) => { + response.writeHead(429, { + "content-type": "application/json", + "retry-after": "36", + }); + response.end(JSON.stringify({ error: "facilitator rate limited" })); + }, async base => { + const result = await runAsync(["pay", `${base}/pay`, "--json"]); + assert.equal(result.status, 1); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, false); + assert.equal(parsed.error.code, "RATE_LIMITED"); + assert.match(parsed.error.message, /HTTP 429/); + assert.match(parsed.error.message, /retry after 36s/); + }); +}); + +test("pay preserves settlement details from a failed paid response", async () => { + let requests = 0; + await withServer((request, response) => { + requests += 1; + if (requests === 1) { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact", + network: "eip155:97", + amount: "1", + asset: "0x337610d27c682E347C9cD60BD4b3b107C9d34dDd", + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + return response.end(JSON.stringify(challenge)); + } + const settlement = { success: true, transaction: "settled-transaction", network: "eip155:97" }; + response.writeHead(502, { + "content-type": "application/json", + "PAYMENT-RESPONSE": Buffer.from(JSON.stringify(settlement)).toString("base64"), + }); + response.end(JSON.stringify({ error: "upstream failed after payment settlement", settled: true })); + }, async base => { + const result = await runAsync([ + "pay", `${base}/pay`, "--json", + "--private-key", `0x${"01".repeat(32)}`, + ]); + assert.equal(result.status, 1, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, false); + assert.equal(parsed.error.code, "HTTP_ERROR"); + assert.equal(parsed.error.details.status, 502); + assert.equal(parsed.error.details.paid, true); + assert.equal(parsed.error.details.settled, true); + assert.equal(parsed.error.details.delivered, false); + assert.equal(parsed.error.details.transaction, "settled-transaction"); + assert.equal(parsed.error.details.paymentResponse.transaction, "settled-transaction"); + }); +}); + +test("GasFree fee limits are enforced before signing", async () => { + await withServer((_apiRequest, apiResponse) => { + apiResponse.writeHead(200, { "content-type": "application/json" }); + apiResponse.end(JSON.stringify({ + code: 200, + data: { + accountAddress: "TD3tTestAccount", + gasFreeAddress: "TNyzTestGasFree", + active: true, + nonce: 1, + allowSubmit: true, + assets: [{ + tokenAddress: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + tokenSymbol: "USDT", + activateFee: "0", + transferFee: "500000", + decimal: 6, + frozen: 0, + }], + }, + })); + }, async gasfreeApi => { + await withServer((request, response) => { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact_gasfree", + network: "tron:0xcd8690dc", + amount: "1", + asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + payTo: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + maxTimeoutSeconds: 300, + extra: {}, + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + response.end(JSON.stringify(challenge)); + }, async gateway => { + const result = await runAsync([ + "pay", `${gateway}/pay`, "--json", + "--private-key", `0x${"01".repeat(32)}`, + "--gasfree-api-url", gasfreeApi, + "--max-gasfree-fee-raw", "499999", + ]); + assert.equal(result.status, 1, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.match(parsed.error.message, /estimated GasFree fee 500000 exceeds/); + }); + }); +}); + test("weighted catalog and gateway search support include-blocked and json output", () => { const dir = mkdtempSync(path.join(os.tmpdir(), "x402-cli-catalog-")); try { @@ -301,7 +540,7 @@ test("catalog export-gateway writes public catalog and pay docs", async () => { subtitle: "Alpha subtitle", description: "Alpha description", category: "finance", - chains: ["tron:nile"], + chains: ["tron:0xcd8690dc"], endpoints: [{ method: "GET", path: "/v1", url: "https://gateway.example/v1", metered: true, min_price_usd: 0.1 }], }; await withServer((request, response) => { @@ -331,7 +570,7 @@ test("remote catalog detail and pay files use escaped FQN filenames", async () = const detail = { fqn: "bankofai/demo", title: "Demo Detail", - endpoints: [{ method: "GET", path: "/v1", paid: { network: "tron:nile" } }], + endpoints: [{ method: "GET", path: "/v1", paid: { network: "tron:0xcd8690dc" } }], }; const payload = request.url === "/api/catalog.json" ? catalog : @@ -450,7 +689,7 @@ test("gateway check validates provider files", () => { writeFileSync(providerFile, `name: fixture-provider forward_url: https://api.example.com operator: - network: tron-nile + network: tron:0xcd8690dc recipient: TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i currencies: usd: ["USDT"] @@ -479,7 +718,7 @@ test("gateway check fails on missing provider environment variables", () => { writeFileSync(providerFile, `name: env-provider forward_url: \${MISSING_X402_TEST_FORWARD_URL} operator: - network: tron-nile + network: tron:0xcd8690dc recipient: TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i endpoints: - method: GET