From 193c83863d1468fc57c06eedaf69c98ba30c19d4 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Wed, 15 Jul 2026 10:20:48 +0800 Subject: [PATCH 01/23] feat: add TRON GasFree payment support --- README.md | 30 +++++++++++++----- package-lock.json | 56 ++++++++++++++++++++++++++-------- package.json | 8 ++--- src/cli.ts | 36 ++++++++++++++-------- src/x402.ts | 24 ++++++++++++--- tests/cli.test.mjs | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 190 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index e6e7686..5013bc7 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,13 @@ 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-beta.2` +- `@bankofai/x402-evm@1.0.1-beta.2` +- `@bankofai/x402-tron@1.0.1-beta.2` -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 @@ -74,6 +75,20 @@ node dist/cli.js pay http://127.0.0.1:4020/pay \ --token USDT ``` +Pay a TRON GasFree endpoint (the CLI normally selects this automatically from +the server challenge): + +```bash +TRON_PRIVATE_KEY= \ +node dist/cli.js pay https://api.example.com/pay \ + --network tron:nile \ + --token USDT \ + --scheme exact_gasfree +``` + +Use `--gasfree-api-url ` or `X402_GASFREE_API_URL` to override the SDK's +default relayer endpoint. + For EVM networks use `EVM_PRIVATE_KEY` or `PRIVATE_KEY`. ### Roundtrip @@ -114,5 +129,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..3621f74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.0", + "version": "1.0.1-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.0", + "version": "1.0.1-beta.0", "dependencies": { - "@bankofai/x402-core": "1.0.0", - "@bankofai/x402-evm": "1.0.0", + "@bankofai/x402-core": "1.0.1-beta.2", + "@bankofai/x402-evm": "1.0.1-beta.2", "@bankofai/x402-gateway": "^1.0.0", - "@bankofai/x402-tron": "1.0.0", + "@bankofai/x402-tron": "1.0.1-beta.2", "tronweb": "6.4.0", "viem": "^2.55.0", "yaml": "^2.8.2" @@ -47,21 +47,21 @@ } }, "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-beta.2", + "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.0.1-beta.2.tgz", + "integrity": "sha512-KunU4mOoqkAoZPX64BKIEmS3YNu3/hd/hYTJPLoKku5gkO1/Etp31zXNtJM+/Qyf9gABzk2cb56V/v6DVRaerQ==", "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-beta.2", + "resolved": "https://registry.npmjs.org/@bankofai/x402-evm/-/x402-evm-1.0.1-beta.2.tgz", + "integrity": "sha512-lttACx8L+DbqsjhIpiVK5S+cQjON+GccaCAK6Qdf7eDwFUaV1gbbI3sbY5a9trBolNQ7yberPasEUkP3tn4QOQ==", "license": "Apache-2.0", "dependencies": { - "@bankofai/x402-core": "~1.0.0", + "@bankofai/x402-core": "~1.0.1-beta.2", "viem": "^2.48.11", "zod": "^3.24.2" } @@ -83,7 +83,27 @@ "node": ">=20" } }, - "node_modules/@bankofai/x402-tron": { + "node_modules/@bankofai/x402-gateway/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==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@bankofai/x402-gateway/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==", + "license": "Apache-2.0", + "dependencies": { + "@bankofai/x402-core": "~1.0.0", + "viem": "^2.48.11", + "zod": "^3.24.2" + } + }, + "node_modules/@bankofai/x402-gateway/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==", @@ -93,6 +113,16 @@ "tronweb": "^6.1.0" } }, + "node_modules/@bankofai/x402-tron": { + "version": "1.0.1-beta.2", + "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.0.1-beta.2.tgz", + "integrity": "sha512-3ophd4IoXcE1u89YPIy/gdTMQGiN9x8InefKGtK580VJkk3rfF/PrTMDsNTeg7HCT2YzElNxXoYJcud9hrSemA==", + "license": "Apache-2.0", + "dependencies": { + "@bankofai/x402-core": "~1.0.1-beta.2", + "tronweb": "^6.1.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", diff --git a/package.json b/package.json index e544834..1f272c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.0", + "version": "1.0.1-beta.0", "type": "module", "files": [ "dist" @@ -20,10 +20,10 @@ "node": ">=20" }, "dependencies": { - "@bankofai/x402-core": "1.0.0", - "@bankofai/x402-evm": "1.0.0", + "@bankofai/x402-core": "1.0.1-beta.2", + "@bankofai/x402-evm": "1.0.1-beta.2", "@bankofai/x402-gateway": "^1.0.0", - "@bankofai/x402-tron": "1.0.0", + "@bankofai/x402-tron": "1.0.1-beta.2", "tronweb": "6.4.0", "viem": "^2.55.0", "yaml": "^2.8.2" diff --git a/src/cli.ts b/src/cli.ts index 14cb780..0065428 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -322,6 +322,7 @@ Options: --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-amount Maximum human-readable payment amount --max-raw-amount Maximum smallest-unit payment amount --dry-run Read requirements but do not sign or pay @@ -342,6 +343,7 @@ Options: --amount Human-readable token amount (default: 0.0001) --raw-amount Smallest-unit amount --network Payment network (default: tron:nile) + --scheme Payment scheme: exact or exact_gasfree (default: exact) --token Token symbol (default: USDT) --asset
Explicit token address --decimals Token decimals for unregistered --asset @@ -483,7 +485,7 @@ function loadProviderFile(file: string): any { const provider = expandDeep(readYaml(file)); validateProvider(provider, file); provider.operator.network = normalizeNetwork(provider.operator.network); - provider.operator.scheme = "exact"; + provider.operator.scheme = provider.operator.scheme ?? "exact"; return provider; } @@ -530,6 +532,10 @@ function providerAssetTransferMethod(provider: any): string { return provider.operator?.asset_transfer_method ?? provider.operator?.assetTransferMethod ?? "permit2"; } +function providerScheme(provider: any): string { + return provider.operator?.scheme ?? "exact"; +} + function providerCatalog(provider: any): any { return { name: provider.name, @@ -546,7 +552,7 @@ function providerCatalog(provider: any): any { upstream_path: endpoint.path, description: endpoint.description ?? "", paid: providerPrice(endpoint) > 0 ? { - scheme: "exact", + scheme: providerScheme(provider), network: normalizeNetwork(provider.operator.network), currency: provider.operator.currencies?.usd?.[0] ?? "USDT", price_usd: providerPrice(endpoint), @@ -554,7 +560,7 @@ function providerCatalog(provider: any): any { x402_routes: providerPrice(endpoint) > 0 ? [{ provider: provider.name, network: normalizeNetwork(provider.operator.network), - scheme: "exact", + scheme: providerScheme(provider), assetTransferMethod: providerAssetTransferMethod(provider), url: `/providers/${provider.name}${endpoint.path}`, }] : [], @@ -1008,6 +1014,11 @@ async function catalogExportGateway(gatewayUrl: string, options: ParsedOptions): function buildRequirement(options: ParsedOptions): PaymentRequirement { const network = normalizeNetwork(opt(options, "network", "tron:nile")!); + 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 @@ -1027,13 +1038,13 @@ function buildRequirement(options: ParsedOptions): PaymentRequirement { const assetAddress = explicitAsset ?? registryToken!.address; const assetTransferMethod = registryToken?.assetTransferMethod ?? "permit2"; 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 } : {}, + extra: scheme === "exact" && assetTransferMethod ? { assetTransferMethod } : {}, }; } @@ -1081,7 +1092,7 @@ function serveDaemon(argv: string[], options: ParsedOptions): void { command: "server", mode: outputMode(options), network: requirement.network, - scheme: "exact", + scheme: requirement.scheme, result: { pid: child.pid, pay_url: resourceUrl, @@ -1135,7 +1146,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") { @@ -1181,7 +1192,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 ?? settle.txHash ?? null })); } catch (error) { response.writeHead(500, { "content-type": "application/json" }); response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); @@ -1194,7 +1205,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 }, }); @@ -1251,7 +1262,7 @@ async function pay(url: string, options: ParsedOptions): Promise { emit({ command: "client", network: selected.network, - scheme: "exact", + scheme: selected.scheme, mode: outputMode(options), result: { url, @@ -1269,6 +1280,7 @@ async function pay(url: string, options: ParsedOptions): Promise { extensions: required.extensions, rpcUrl: opt(options, "rpc-url"), privateKey: opt(options, "private-key"), + gasfreeApiUrl: opt(options, "gasfree-api-url"), }), ); const retryHeaders = new Headers(baseHeaders); @@ -1293,7 +1305,7 @@ async function pay(url: string, options: ParsedOptions): Promise { emit({ command: "client", network: selected.network, - scheme: "exact", + scheme: selected.scheme, mode: outputMode(options), result, }); @@ -1453,7 +1465,7 @@ function catalogPayAssets(target: string, options: ParsedOptions): void { network: normalizeNetwork(provider.operator.network), currency: provider.operator.currencies?.usd?.[0] ?? "USDT", price_usd: providerPrice(endpoint), - scheme: "exact", + scheme: providerScheme(provider), assetTransferMethod: providerAssetTransferMethod(provider), })), ); diff --git a/src/x402.ts b/src/x402.ts index 4968011..46acbd4 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 { registerExactGasFreeTronScheme } from "@bankofai/x402-tron/gasfree/client"; 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( @@ -166,10 +168,12 @@ export async function createPaymentPayload(args: { rpcUrl?: string; apiKey?: string; allowanceMode?: string; + gasfreeApiUrl?: string; }): Promise { 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, @@ -197,9 +201,21 @@ export async function createPaymentPayload(args: { 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(); + if (selected.scheme === "exact_gasfree") { + registerExactGasFreeTronScheme(client, { + signer, + networks: [selected.network as `${string}:${string}`], + ...(args.gasfreeApiUrl || process.env.X402_GASFREE_API_URL + ? { schemeOptions: { apiBaseUrls: { [selected.network]: args.gasfreeApiUrl || process.env.X402_GASFREE_API_URL! } } } + : {}), + }); + } 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}`); + } + return client.createPaymentPayload(required as never); } throw new Error(`unsupported network ${selected.network}`); } diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index c3af5a5..a65bb00 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -103,6 +103,82 @@ test("help and version work", () => { assert.match(version.stdout.trim(), /^\d+\.\d+\.\d+/); }); +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:nile", + "--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:nile", + 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("weighted catalog and gateway search support include-blocked and json output", () => { const dir = mkdtempSync(path.join(os.tmpdir(), "x402-cli-catalog-")); try { From 143f9aa17a8122c4523c9ff11232e8c38cf4bfae Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Wed, 15 Jul 2026 15:41:06 +0800 Subject: [PATCH 02/23] chore: prepare CLI 1.0.1 beta.1 --- package-lock.json | 32 ++++++++++++++++---------------- package.json | 8 ++++---- src/tokens.ts | 18 ++++++++++++------ 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3621f74..f2b1ba8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.0", + "version": "1.0.1-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.0", + "version": "1.0.1-beta.1", "dependencies": { - "@bankofai/x402-core": "1.0.1-beta.2", - "@bankofai/x402-evm": "1.0.1-beta.2", + "@bankofai/x402-core": "1.0.1-beta.4", + "@bankofai/x402-evm": "1.0.1-beta.4", "@bankofai/x402-gateway": "^1.0.0", - "@bankofai/x402-tron": "1.0.1-beta.2", + "@bankofai/x402-tron": "1.0.1-beta.4", "tronweb": "6.4.0", "viem": "^2.55.0", "yaml": "^2.8.2" @@ -47,21 +47,21 @@ } }, "node_modules/@bankofai/x402-core": { - "version": "1.0.1-beta.2", - "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.0.1-beta.2.tgz", - "integrity": "sha512-KunU4mOoqkAoZPX64BKIEmS3YNu3/hd/hYTJPLoKku5gkO1/Etp31zXNtJM+/Qyf9gABzk2cb56V/v6DVRaerQ==", + "version": "1.0.1-beta.4", + "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.0.1-beta.4.tgz", + "integrity": "sha512-uolluQsJozPpQEBqR9NAP6+l3MCerRY+xMztlMBTAEP7Cb+3KTIOpuqb2P0TCD1BifgJeL1ajG/Cw2OGYl2wNg==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.2" } }, "node_modules/@bankofai/x402-evm": { - "version": "1.0.1-beta.2", - "resolved": "https://registry.npmjs.org/@bankofai/x402-evm/-/x402-evm-1.0.1-beta.2.tgz", - "integrity": "sha512-lttACx8L+DbqsjhIpiVK5S+cQjON+GccaCAK6Qdf7eDwFUaV1gbbI3sbY5a9trBolNQ7yberPasEUkP3tn4QOQ==", + "version": "1.0.1-beta.4", + "resolved": "https://registry.npmjs.org/@bankofai/x402-evm/-/x402-evm-1.0.1-beta.4.tgz", + "integrity": "sha512-js8/ZIzgaHkyo+M2ZvthwXj2R+8lHcT55rWT7weDgbJdESoYoEIQNmdQ3kvhPEtPkd7UDg2ATfLObL+f5oYzeg==", "license": "Apache-2.0", "dependencies": { - "@bankofai/x402-core": "~1.0.1-beta.2", + "@bankofai/x402-core": "~1.0.1-beta.4", "viem": "^2.48.11", "zod": "^3.24.2" } @@ -114,12 +114,12 @@ } }, "node_modules/@bankofai/x402-tron": { - "version": "1.0.1-beta.2", - "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.0.1-beta.2.tgz", - "integrity": "sha512-3ophd4IoXcE1u89YPIy/gdTMQGiN9x8InefKGtK580VJkk3rfF/PrTMDsNTeg7HCT2YzElNxXoYJcud9hrSemA==", + "version": "1.0.1-beta.4", + "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.0.1-beta.4.tgz", + "integrity": "sha512-l+ra5PfvfBL/h2+OvhkikZLCSn/IEzYmMc0XR9bycemjnlBfti256DLLj63qTtewkSyn7bn59GGgfHkzM7e4JA==", "license": "Apache-2.0", "dependencies": { - "@bankofai/x402-core": "~1.0.1-beta.2", + "@bankofai/x402-core": "~1.0.1-beta.4", "tronweb": "^6.1.0" } }, diff --git a/package.json b/package.json index 1f272c8..2aa3456 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.0", + "version": "1.0.1-beta.1", "type": "module", "files": [ "dist" @@ -20,10 +20,10 @@ "node": ">=20" }, "dependencies": { - "@bankofai/x402-core": "1.0.1-beta.2", - "@bankofai/x402-evm": "1.0.1-beta.2", + "@bankofai/x402-core": "1.0.1-beta.4", + "@bankofai/x402-evm": "1.0.1-beta.4", "@bankofai/x402-gateway": "^1.0.0", - "@bankofai/x402-tron": "1.0.1-beta.2", + "@bankofai/x402-tron": "1.0.1-beta.4", "tronweb": "6.4.0", "viem": "^2.55.0", "yaml": "^2.8.2" diff --git a/src/tokens.ts b/src/tokens.ts index 34209e5..5e6a9b8 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, @@ -86,9 +86,15 @@ export const TOKENS: Record> = { export function normalizeNetwork(network: string): string { return ( { - "tron-mainnet": "tron:mainnet", - "tron-shasta": "tron:shasta", - "tron-nile": "tron:nile", + "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", "bsc-mainnet": "eip155:56", "bsc-testnet": "eip155:97", }[network] ?? network From fb2b08f2234e68b01e3bb5a639dce0715ad172fd Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Wed, 15 Jul 2026 15:51:25 +0800 Subject: [PATCH 03/23] fix: bundle gateway beta.1 in CLI --- package-lock.json | 44 +++++++------------------------------------- package.json | 2 +- 2 files changed, 8 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index f2b1ba8..5977321 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-gateway": "^1.0.0", + "@bankofai/x402-gateway": "1.0.1-beta.1", "@bankofai/x402-tron": "1.0.1-beta.4", "tronweb": "6.4.0", "viem": "^2.55.0", @@ -67,13 +67,13 @@ } }, "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-beta.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.1.tgz", + "integrity": "sha512-TUU/VKtAEYi8QZGtWO/ukZp2s2BL0Tt6m/6QYehgFFvA44B0Xoz1hCnQfT8SgSjdfRG9wHeSKW82KU3K01YyvQ==", "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-beta.4", + "@bankofai/x402-evm": "1.0.1-beta.4", + "@bankofai/x402-tron": "1.0.1-beta.4", "yaml": "^2.8.2" }, "bin": { @@ -83,36 +83,6 @@ "node": ">=20" } }, - "node_modules/@bankofai/x402-gateway/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==", - "license": "Apache-2.0", - "dependencies": { - "zod": "^3.24.2" - } - }, - "node_modules/@bankofai/x402-gateway/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==", - "license": "Apache-2.0", - "dependencies": { - "@bankofai/x402-core": "~1.0.0", - "viem": "^2.48.11", - "zod": "^3.24.2" - } - }, - "node_modules/@bankofai/x402-gateway/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==", - "license": "Apache-2.0", - "dependencies": { - "@bankofai/x402-core": "~1.0.0", - "tronweb": "^6.1.0" - } - }, "node_modules/@bankofai/x402-tron": { "version": "1.0.1-beta.4", "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.0.1-beta.4.tgz", diff --git a/package.json b/package.json index 2aa3456..4631d38 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-gateway": "^1.0.0", + "@bankofai/x402-gateway": "1.0.1-beta.1", "@bankofai/x402-tron": "1.0.1-beta.4", "tronweb": "6.4.0", "viem": "^2.55.0", From 309587627687bfc588da960f15ae178277fd52a4 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 16 Jul 2026 14:14:33 +0800 Subject: [PATCH 04/23] fix: fail CLI payments on HTTP errors --- package-lock.json | 12 ++++++------ package.json | 4 ++-- src/cli.ts | 18 ++++++++++++++++-- tests/cli.test.mjs | 18 ++++++++++++++++++ 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5977321..774705a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.1", + "version": "1.0.1-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.1", + "version": "1.0.1-beta.2", "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-gateway": "1.0.1-beta.1", + "@bankofai/x402-gateway": "1.0.1-beta.2", "@bankofai/x402-tron": "1.0.1-beta.4", "tronweb": "6.4.0", "viem": "^2.55.0", @@ -67,9 +67,9 @@ } }, "node_modules/@bankofai/x402-gateway": { - "version": "1.0.1-beta.1", - "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.1.tgz", - "integrity": "sha512-TUU/VKtAEYi8QZGtWO/ukZp2s2BL0Tt6m/6QYehgFFvA44B0Xoz1hCnQfT8SgSjdfRG9wHeSKW82KU3K01YyvQ==", + "version": "1.0.1-beta.2", + "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.2.tgz", + "integrity": "sha512-CV1zKBoNbDuw1TcD+3w6Fb3zZFvBv/XPgbxnd+6+g8SfCcOKp2WLKlYCajw0xdZNS+OUcMplRSs/b3C7iQFZSQ==", "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", diff --git a/package.json b/package.json index 4631d38..ef52775 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.1", + "version": "1.0.1-beta.2", "type": "module", "files": [ "dist" @@ -22,7 +22,7 @@ "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-gateway": "1.0.1-beta.1", + "@bankofai/x402-gateway": "1.0.1-beta.2", "@bankofai/x402-tron": "1.0.1-beta.4", "tronweb": "6.4.0", "viem": "^2.55.0", diff --git a/src/cli.ts b/src/cli.ts index 0065428..8b0130d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1241,6 +1241,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), @@ -1248,7 +1257,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; @@ -1300,7 +1309,12 @@ async function pay(url: string, options: ParsedOptions): Promise { ...(paymentResponse ? { paymentResponse: decodeResponse(paymentResponse) } : {}), }; 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 Error( + `HTTP ${paid.status} from ${url}${retryAfter ? ` (retry after ${retryAfter}s)` : ""}: ${ + typeof body === "string" ? body.slice(0, 500) : JSON.stringify(body).slice(0, 500) + }`, + ); } emit({ command: "client", diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index a65bb00..f0461a0 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -179,6 +179,24 @@ test("pay dry-run preserves an exact_gasfree requirement", async () => { }); }); +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("weighted catalog and gateway search support include-blocked and json output", () => { const dir = mkdtempSync(path.join(os.tmpdir(), "x402-cli-catalog-")); try { From ae91671cdb9029f3f8032eddc18f70735aae6825 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 16 Jul 2026 15:46:07 +0800 Subject: [PATCH 05/23] fix: use canonical TRON network IDs by default --- README.md | 29 +++++++++++++++-------------- package-lock.json | 12 ++++++------ package.json | 4 ++-- src/cli.ts | 8 ++++---- tests/cli.test.mjs | 26 +++++++++++++++++--------- 5 files changed, 44 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 5013bc7..ebfb908 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ TypeScript command-line client for BankofAI x402 payments. This version uses the npm TypeScript SDK packages only: -- `@bankofai/x402-core@1.0.1-beta.2` -- `@bankofai/x402-evm@1.0.1-beta.2` -- `@bankofai/x402-tron@1.0.1-beta.2` +- `@bankofai/x402-core@1.0.1-beta.4` +- `@bankofai/x402-evm@1.0.1-beta.4` +- `@bankofai/x402-tron@1.0.1-beta.4` Stablecoin payments support `scheme=exact` and TRON `scheme=exact_gasfree`. The GasFree flow lets the relayer pay network energy while deducting its fee @@ -21,7 +21,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: @@ -52,7 +52,7 @@ Start a local x402 paywall endpoint: node dist/cli.js serve \ --pay-to \ --amount 0.0001 \ - --network tron:nile \ + --network tron:0xcd8690dc \ --token USDT \ --port 4020 ``` @@ -71,7 +71,7 @@ Pay an x402-protected URL: ```bash TRON_PRIVATE_KEY= \ node dist/cli.js pay http://127.0.0.1:4020/pay \ - --network tron:nile \ + --network tron:0xcd8690dc \ --token USDT ``` @@ -81,7 +81,7 @@ the server challenge): ```bash TRON_PRIVATE_KEY= \ node dist/cli.js pay https://api.example.com/pay \ - --network tron:nile \ + --network tron:0xcd8690dc \ --token USDT \ --scheme exact_gasfree ``` @@ -100,7 +100,7 @@ TRON_PRIVATE_KEY= \ node dist/cli.js roundtrip \ --pay-to \ --amount 0.0001 \ - --network tron:nile \ + --network tron:0xcd8690dc \ --token USDT ``` @@ -108,16 +108,17 @@ 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: +Legacy aliases remain accepted for backward compatibility, but new commands and +configuration should use the canonical IDs above: -- `tron-mainnet` -> `tron:mainnet` -- `tron-nile` -> `tron:nile` +- `tron-mainnet` -> `tron:0x2b6653dc` +- `tron-nile` -> `tron:0xcd8690dc` - `bsc-mainnet` -> `eip155:56` - `bsc-testnet` -> `eip155:97` diff --git a/package-lock.json b/package-lock.json index 774705a..818a971 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.2", + "version": "1.0.1-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.2", + "version": "1.0.1-beta.3", "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-gateway": "1.0.1-beta.2", + "@bankofai/x402-gateway": "1.0.1-beta.3", "@bankofai/x402-tron": "1.0.1-beta.4", "tronweb": "6.4.0", "viem": "^2.55.0", @@ -67,9 +67,9 @@ } }, "node_modules/@bankofai/x402-gateway": { - "version": "1.0.1-beta.2", - "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.2.tgz", - "integrity": "sha512-CV1zKBoNbDuw1TcD+3w6Fb3zZFvBv/XPgbxnd+6+g8SfCcOKp2WLKlYCajw0xdZNS+OUcMplRSs/b3C7iQFZSQ==", + "version": "1.0.1-beta.3", + "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.3.tgz", + "integrity": "sha512-zwUmYJ5DKOmYcWvBGIqo9sAISAT+nyZx1Otp/7s1gcddQ76b7SifKpYpE+HkQRpGtHaDpyaGyhOrWnyAf4st0Q==", "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", diff --git a/package.json b/package.json index ef52775..8bc6112 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.2", + "version": "1.0.1-beta.3", "type": "module", "files": [ "dist" @@ -22,7 +22,7 @@ "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-gateway": "1.0.1-beta.2", + "@bankofai/x402-gateway": "1.0.1-beta.3", "@bankofai/x402-tron": "1.0.1-beta.4", "tronweb": "6.4.0", "viem": "^2.55.0", diff --git a/src/cli.ts b/src/cli.ts index 8b0130d..e7573fc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -342,7 +342,7 @@ 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) + --network Payment network (default: tron:0xcd8690dc) --scheme Payment scheme: exact or exact_gasfree (default: exact) --token Token symbol (default: USDT) --asset
Explicit token address @@ -356,7 +356,7 @@ Options: --json Print JSON envelope Examples: - x402-cli serve --pay-to T... --network tron:nile --token USDT + 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: @@ -1013,7 +1013,7 @@ async function catalogExportGateway(gatewayUrl: string, options: ParsedOptions): } 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:")) { @@ -1424,7 +1424,7 @@ routing: type: proxy operator: - network: tron-nile + network: tron:0xcd8690dc currencies: usd: ["USDT"] recipient: diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index f0461a0..4b55da8 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,12 +104,19 @@ test("help and version work", () => { assert.match(version.stdout.trim(), /^\d+\.\d+\.\d+/); }); +test("legacy TRON aliases normalize to canonical CAIP-2 IDs", () => { + assert.equal(normalizeNetwork("tron:nile"), "tron:0xcd8690dc"); + assert.equal(normalizeNetwork("tron-nile"), "tron:0xcd8690dc"); + assert.equal(normalizeNetwork("tron:mainnet"), "tron:0x2b6653dc"); + assert.equal(normalizeNetwork("tron:shasta"), "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:nile", + "--network", "tron:0xcd8690dc", "--scheme", "exact_gasfree", "--port", String(port), "--daemon", @@ -159,7 +167,7 @@ test("pay dry-run preserves an exact_gasfree requirement", async () => { resource: { url: `http://${request.headers.host}/pay` }, accepts: [{ scheme: "exact_gasfree", - network: "tron:nile", + network: "tron:0xcd8690dc", amount: "1", asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", payTo: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", @@ -395,7 +403,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) => { @@ -425,7 +433,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 : @@ -544,7 +552,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"] @@ -573,7 +581,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 From 099c5453a4c4ff9835d7e10ef49e8ffa9231022e Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 16 Jul 2026 15:57:00 +0800 Subject: [PATCH 06/23] fix: reject legacy TRON network identifiers --- README.md | 7 +++---- package-lock.json | 12 ++++++------ package.json | 4 ++-- src/tokens.ts | 34 +++++++++++++++++++--------------- tests/cli.test.mjs | 10 +++++----- 5 files changed, 35 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index ebfb908..f4d423d 100644 --- a/README.md +++ b/README.md @@ -114,11 +114,10 @@ Supported built-in token registry: - `eip155:56` USDT - `eip155:97` USDT, USDC -Legacy aliases remain accepted for backward compatibility, but new commands and -configuration should use the canonical IDs above: +Non-CAIP TRON aliases are rejected. Use the canonical TRON IDs above. + +EVM convenience aliases accepted: -- `tron-mainnet` -> `tron:0x2b6653dc` -- `tron-nile` -> `tron:0xcd8690dc` - `bsc-mainnet` -> `eip155:56` - `bsc-testnet` -> `eip155:97` diff --git a/package-lock.json b/package-lock.json index 818a971..f631eda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.3", + "version": "1.0.1-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.3", + "version": "1.0.1-beta.4", "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-gateway": "1.0.1-beta.3", + "@bankofai/x402-gateway": "1.0.1-beta.4", "@bankofai/x402-tron": "1.0.1-beta.4", "tronweb": "6.4.0", "viem": "^2.55.0", @@ -67,9 +67,9 @@ } }, "node_modules/@bankofai/x402-gateway": { - "version": "1.0.1-beta.3", - "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.3.tgz", - "integrity": "sha512-zwUmYJ5DKOmYcWvBGIqo9sAISAT+nyZx1Otp/7s1gcddQ76b7SifKpYpE+HkQRpGtHaDpyaGyhOrWnyAf4st0Q==", + "version": "1.0.1-beta.4", + "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.4.tgz", + "integrity": "sha512-UDvO2Y/rMdpdVAixNfBsidfJjizjLQrcYIqCbL5c1EZS/rfaQez6czWV+v1vPrFGMKQPlq5U6DWSfnAHnUQi2Q==", "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", diff --git a/package.json b/package.json index 8bc6112..7d6f40c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.3", + "version": "1.0.1-beta.4", "type": "module", "files": [ "dist" @@ -22,7 +22,7 @@ "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-gateway": "1.0.1-beta.3", + "@bankofai/x402-gateway": "1.0.1-beta.4", "@bankofai/x402-tron": "1.0.1-beta.4", "tronweb": "6.4.0", "viem": "^2.55.0", diff --git a/src/tokens.ts b/src/tokens.ts index 5e6a9b8..f98959a 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -84,21 +84,25 @@ export const TOKENS: Record> = { }; export function normalizeNetwork(network: string): string { - return ( - { - "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", - "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 { diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 4b55da8..9a07dc2 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -104,11 +104,11 @@ test("help and version work", () => { assert.match(version.stdout.trim(), /^\d+\.\d+\.\d+/); }); -test("legacy TRON aliases normalize to canonical CAIP-2 IDs", () => { - assert.equal(normalizeNetwork("tron:nile"), "tron:0xcd8690dc"); - assert.equal(normalizeNetwork("tron-nile"), "tron:0xcd8690dc"); - assert.equal(normalizeNetwork("tron:mainnet"), "tron:0x2b6653dc"); - assert.equal(normalizeNetwork("tron:shasta"), "tron:0x94a9059e"); +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 () => { From a7851d12a65a1da7a5f00cf2098d7c98bbb4905c Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 16 Jul 2026 16:09:12 +0800 Subject: [PATCH 07/23] fix: preserve settlement details on payment errors --- package-lock.json | 23 +---------------------- package.json | 3 +++ src/cli.ts | 13 +++++++++++-- tests/cli.test.mjs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index f631eda..d25a21a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1429,7 +1429,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 +1450,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 7d6f40c..f5b2edd 100644 --- a/package.json +++ b/package.json @@ -32,5 +32,8 @@ "@types/node": "^24.10.1", "tsx": "^4.20.6", "typescript": "^5.9.3" + }, + "overrides": { + "ws": "8.21.0" } } diff --git a/src/cli.ts b/src/cli.ts index e7573fc..8861509 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,7 +13,7 @@ import { assertRawAmount, findTokenByAddress, getToken, normalizeNetwork, toSmal type ParsedOptions = Record; type OutputMode = "human" | "json"; -type FriendlyError = { code: string; message: string; hint: string }; +type FriendlyError = { code: string; message: string; hint: string; details?: unknown }; 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; @@ -25,6 +25,7 @@ class CliError extends Error { message: string, public hint: string, public exitCode = 1, + public details?: unknown, ) { super(message); } @@ -152,6 +153,7 @@ function emit(args: { 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(" "); @@ -177,6 +179,7 @@ function classify(error: unknown): FriendlyError { code: error.code, message, hint: error.hint, + details: error.details, }; } const lower = message.toLowerCase(); @@ -1310,10 +1313,16 @@ async function pay(url: string, options: ParsedOptions): Promise { }; if (!paid.ok) { const retryAfter = paid.headers.get("retry-after"); - throw new Error( + 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({ diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 9a07dc2..6ca5175 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -205,6 +205,50 @@ test("pay reports non-2xx gateway responses as failures", async () => { }); }); +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" }; + 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.paymentResponse.transaction, "settled-transaction"); + }); +}); + test("weighted catalog and gateway search support include-blocked and json output", () => { const dir = mkdtempSync(path.join(os.tmpdir(), "x402-cli-catalog-")); try { From 5f29732d36da9e4b22322905c52684a249df04c7 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 16 Jul 2026 16:13:47 +0800 Subject: [PATCH 08/23] docs: clarify payment safety and CLI behavior --- README.md | 22 ++++++++++++++++++++-- src/cli.ts | 5 ++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f4d423d..44e7e59 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,15 @@ from the payment token, so the payer does not need TRX. ## Install +Install the CLI package: + +```bash +npm install -g @bankofai/x402-cli +x402-cli --version +``` + +For repository development: + ```bash npm install npm run build @@ -61,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 @@ -75,6 +84,9 @@ node dist/cli.js pay http://127.0.0.1:4020/pay \ --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): @@ -90,6 +102,12 @@ Use `--gasfree-api-url ` or `X402_GASFREE_API_URL` to override the SDK's default relayer endpoint. 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 diff --git a/src/cli.ts b/src/cli.ts index 8861509..430cdd8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -776,7 +776,7 @@ async function fetchWithTimeout(input: string | URL, init: RequestInit = {}, tim 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`); + if (error instanceof Error && error.name === "AbortError") throw new Error(`${label} timed out after ${timeout}ms`); throw error; } finally { clearTimeout(timer); @@ -1336,8 +1336,7 @@ 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); } From 1540f334d4ff2ed8abddeb3d8f602bcd9c6e170d Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 16 Jul 2026 16:19:07 +0800 Subject: [PATCH 09/23] fix: improve multi-network payment selection --- package-lock.json | 12 ++++++------ package.json | 4 ++-- src/cli.ts | 7 ++++++- tests/cli.test.mjs | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index d25a21a..db890d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.4", + "version": "1.0.1-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.4", + "version": "1.0.1-beta.5", "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-gateway": "1.0.1-beta.4", + "@bankofai/x402-gateway": "1.0.1-beta.5", "@bankofai/x402-tron": "1.0.1-beta.4", "tronweb": "6.4.0", "viem": "^2.55.0", @@ -67,9 +67,9 @@ } }, "node_modules/@bankofai/x402-gateway": { - "version": "1.0.1-beta.4", - "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.4.tgz", - "integrity": "sha512-UDvO2Y/rMdpdVAixNfBsidfJjizjLQrcYIqCbL5c1EZS/rfaQez6czWV+v1vPrFGMKQPlq5U6DWSfnAHnUQi2Q==", + "version": "1.0.1-beta.5", + "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.5.tgz", + "integrity": "sha512-ZNlqkjBXzhpysOLr6sspK5HWG1CpLyICESxh6QIFUowgKiEsTPSiagMxswTAG3vGN+o2WRsaBos9+fgJvEjEtw==", "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", diff --git a/package.json b/package.json index f5b2edd..727e8f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.4", + "version": "1.0.1-beta.5", "type": "module", "files": [ "dist" @@ -22,7 +22,7 @@ "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-gateway": "1.0.1-beta.4", + "@bankofai/x402-gateway": "1.0.1-beta.5", "@bankofai/x402-tron": "1.0.1-beta.4", "tronweb": "6.4.0", "viem": "^2.55.0", diff --git a/src/cli.ts b/src/cli.ts index 430cdd8..08d0ac4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1225,7 +1225,12 @@ function selectRequirement(accepts: PaymentRequirement[], options: ParsedOptions 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; diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 6ca5175..72dd637 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -187,6 +187,40 @@ test("pay dry-run preserves an exact_gasfree requirement", async () => { }); }); +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, { From 963c8a683003b1ccca11ae196b9803c7722c4fb0 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 16 Jul 2026 17:09:06 +0800 Subject: [PATCH 10/23] fix: cap GasFree fees and report settlement state --- README.md | 18 ++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/cli.ts | 42 +++++++++++++++++++++++++++++---- src/x402.ts | 29 +++++++++++++++-------- tests/cli.test.mjs | 59 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 44e7e59..5f97e9b 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,24 @@ node dist/cli.js pay https://api.example.com/pay \ 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. diff --git a/package-lock.json b/package-lock.json index db890d6..ff7f5d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.5", + "version": "1.0.1-beta.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.5", + "version": "1.0.1-beta.6", "dependencies": { "@bankofai/x402-core": "1.0.1-beta.4", "@bankofai/x402-evm": "1.0.1-beta.4", diff --git a/package.json b/package.json index 727e8f1..12fefe6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.5", + "version": "1.0.1-beta.6", "type": "module", "files": [ "dist" diff --git a/src/cli.ts b/src/cli.ts index 08d0ac4..61a4b12 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -326,6 +326,8 @@ Options: --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 @@ -1125,6 +1127,28 @@ function validateAmountLimits(selected: PaymentRequirement, options: ParsedOptio } } +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) throw new Error("--decimals must be a non-negative integer"); + 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")); @@ -1275,6 +1299,7 @@ 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", @@ -1290,7 +1315,7 @@ 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, @@ -1298,10 +1323,11 @@ async function pay(url: string, options: ParsedOptions): Promise { 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, @@ -1309,12 +1335,20 @@ 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 !== undefined; 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 (!paid.ok) { const retryAfter = paid.headers.get("retry-after"); diff --git a/src/x402.ts b/src/x402.ts index 46acbd4..d842ca6 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -9,7 +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 { registerExactGasFreeTronScheme } from "@bankofai/x402-tron/gasfree/client"; +import { ExactGasFreeTronScheme, createGasFreeApiClients, getGasFreeApiBaseUrl } from "@bankofai/x402-tron/gasfree"; import { createPublicClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { TronWeb } from "tronweb"; @@ -169,7 +169,8 @@ export async function createPaymentPayload(args: { apiKey?: string; allowanceMode?: string; gasfreeApiUrl?: string; -}): Promise { + 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:")) { @@ -184,9 +185,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( @@ -202,20 +204,27 @@ export async function createPaymentPayload(args: { allowanceMode: args.allowanceMode || process.env.X402_TRON_ALLOWANCE_MODE || "auto", } as never); const client = new x402Client(); + let gasfreeEstimate: { fee: string; total: string } | undefined; if (selected.scheme === "exact_gasfree") { - registerExactGasFreeTronScheme(client, { - signer, - networks: [selected.network as `${string}:${string}`], - ...(args.gasfreeApiUrl || process.env.X402_GASFREE_API_URL - ? { schemeOptions: { apiBaseUrls: { [selected.network]: args.gasfreeApiUrl || process.env.X402_GASFREE_API_URL! } } } - : {}), + 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}`); } - return client.createPaymentPayload(required as never); + 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 72dd637..3e889c5 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -279,10 +279,69 @@ test("pay preserves settlement details from a failed paid response", async () => 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 { From 8f648e76364aa18a73eb162e0ff9e25791e40e4e Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Fri, 17 Jul 2026 15:48:02 +0800 Subject: [PATCH 11/23] docs: install and use CLI beta explicitly --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5f97e9b..ab8d6f9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ from the payment token, so the payer does not need TRX. Install the CLI package: ```bash -npm install -g @bankofai/x402-cli +npm install -g @bankofai/x402-cli@beta x402-cli --version ``` @@ -58,7 +58,7 @@ 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:0xcd8690dc \ @@ -79,7 +79,7 @@ Pay an x402-protected URL: ```bash TRON_PRIVATE_KEY= \ -node dist/cli.js pay http://127.0.0.1:4020/pay \ +x402-cli pay http://127.0.0.1:4020/pay \ --network tron:0xcd8690dc \ --token USDT ``` @@ -92,7 +92,7 @@ the server challenge): ```bash TRON_PRIVATE_KEY= \ -node dist/cli.js pay https://api.example.com/pay \ +x402-cli pay https://api.example.com/pay \ --network tron:0xcd8690dc \ --token USDT \ --scheme exact_gasfree @@ -133,7 +133,7 @@ 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:0xcd8690dc \ From 9ed244539780a6e1791239e645866b5e331d067d Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Fri, 17 Jul 2026 20:16:01 +0800 Subject: [PATCH 12/23] chore: use x402 SDK 1.0.1 release --- README.md | 6 +++--- package-lock.json | 46 +++++++++++++++++++++++----------------------- package.json | 10 +++++----- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index ab8d6f9..6521154 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ TypeScript command-line client for BankofAI x402 payments. This version uses the npm TypeScript SDK packages only: -- `@bankofai/x402-core@1.0.1-beta.4` -- `@bankofai/x402-evm@1.0.1-beta.4` -- `@bankofai/x402-tron@1.0.1-beta.4` +- `@bankofai/x402-core@1.0.1` +- `@bankofai/x402-evm@1.0.1` +- `@bankofai/x402-tron@1.0.1` Stablecoin payments support `scheme=exact` and TRON `scheme=exact_gasfree`. The GasFree flow lets the relayer pay network energy while deducting its fee diff --git a/package-lock.json b/package-lock.json index ff7f5d4..0a7fc3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.6", + "version": "1.0.1-beta.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.6", + "version": "1.0.1-beta.7", "dependencies": { - "@bankofai/x402-core": "1.0.1-beta.4", - "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-gateway": "1.0.1-beta.5", - "@bankofai/x402-tron": "1.0.1-beta.4", + "@bankofai/x402-core": "1.0.1", + "@bankofai/x402-evm": "1.0.1", + "@bankofai/x402-gateway": "1.0.1-beta.6", + "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", "viem": "^2.55.0", "yaml": "^2.8.2" @@ -47,33 +47,33 @@ } }, "node_modules/@bankofai/x402-core": { - "version": "1.0.1-beta.4", - "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.0.1-beta.4.tgz", - "integrity": "sha512-uolluQsJozPpQEBqR9NAP6+l3MCerRY+xMztlMBTAEP7Cb+3KTIOpuqb2P0TCD1BifgJeL1ajG/Cw2OGYl2wNg==", + "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.1-beta.4", - "resolved": "https://registry.npmjs.org/@bankofai/x402-evm/-/x402-evm-1.0.1-beta.4.tgz", - "integrity": "sha512-js8/ZIzgaHkyo+M2ZvthwXj2R+8lHcT55rWT7weDgbJdESoYoEIQNmdQ3kvhPEtPkd7UDg2ATfLObL+f5oYzeg==", + "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.1-beta.4", + "@bankofai/x402-core": "~1.0.1", "viem": "^2.48.11", "zod": "^3.24.2" } }, "node_modules/@bankofai/x402-gateway": { - "version": "1.0.1-beta.5", - "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.5.tgz", - "integrity": "sha512-ZNlqkjBXzhpysOLr6sspK5HWG1CpLyICESxh6QIFUowgKiEsTPSiagMxswTAG3vGN+o2WRsaBos9+fgJvEjEtw==", + "version": "1.0.1-beta.6", + "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.6.tgz", + "integrity": "sha512-00+KsYXdDq3iKRR/sXdwNBX9qnkkURtxFr2mbjcoDLju3M3ZRBEIRpLlemasbXfusqdyMDyUxUfuccKBLMtoTw==", "dependencies": { - "@bankofai/x402-core": "1.0.1-beta.4", - "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-tron": "1.0.1-beta.4", + "@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 +84,12 @@ } }, "node_modules/@bankofai/x402-tron": { - "version": "1.0.1-beta.4", - "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.0.1-beta.4.tgz", - "integrity": "sha512-l+ra5PfvfBL/h2+OvhkikZLCSn/IEzYmMc0XR9bycemjnlBfti256DLLj63qTtewkSyn7bn59GGgfHkzM7e4JA==", + "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.1-beta.4", + "@bankofai/x402-core": "~1.0.1", "tronweb": "^6.1.0" } }, diff --git a/package.json b/package.json index 12fefe6..249a7ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.6", + "version": "1.0.1-beta.7", "type": "module", "files": [ "dist" @@ -20,10 +20,10 @@ "node": ">=20" }, "dependencies": { - "@bankofai/x402-core": "1.0.1-beta.4", - "@bankofai/x402-evm": "1.0.1-beta.4", - "@bankofai/x402-gateway": "1.0.1-beta.5", - "@bankofai/x402-tron": "1.0.1-beta.4", + "@bankofai/x402-core": "1.0.1", + "@bankofai/x402-evm": "1.0.1", + "@bankofai/x402-gateway": "1.0.1-beta.6", + "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", "viem": "^2.55.0", "yaml": "^2.8.2" From 5cbb584febda86ba0c631cbb64f2975c3c4485f7 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Fri, 17 Jul 2026 22:58:05 +0800 Subject: [PATCH 13/23] fix: harden CLI payment validation --- package-lock.json | 4 +-- package.json | 2 +- src/cli.ts | 78 +++++++++++++++++++++++++++++++++++++--------- src/tokens.ts | 2 +- src/x402.ts | 10 ++++-- tests/cli.test.mjs | 2 +- 6 files changed, 76 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0a7fc3d..fe59eed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.7", + "version": "1.0.1-beta.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.7", + "version": "1.0.1-beta.8", "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", diff --git a/package.json b/package.json index 249a7ea..c203d5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.7", + "version": "1.0.1-beta.8", "type": "module", "files": [ "dist" diff --git a/src/cli.ts b/src/cli.ts index 61a4b12..8285a08 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ type ParsedOptions = Record; type OutputMode = "human" | "json"; type FriendlyError = { code: string; message: string; hint: string; details?: unknown }; const BOOLEAN_FLAGS = new Set(["daemon", "dry-run", "force", "help", "human", "include-blocked", "json", "raw", "version"]); +const MAX_HTTP_BODY_BYTES = 10 * 1024 * 1024; const require = createRequire(import.meta.url); const DEFAULT_TIMEOUT_MS = 30_000; const CATALOG_UPDATE_RETRIES = 3; @@ -72,7 +73,7 @@ function parseArgs(argv: string[]): { command: string; positional: string[]; opt } else if (BOOLEAN_FLAGS.has(key)) { options[key] = true; } else if (!next || next.startsWith("--")) { - options[key] = true; + throw new CliError("MISSING_ARGUMENT", `--${key} requires a value`, `Pass --${key} .`, 2); } else { if (key === "header") { const current = options[key]; @@ -680,7 +681,32 @@ async function readText(source: string, options?: ParsedOptions): 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); } async function readJson(source: string, options?: ParsedOptions): Promise { @@ -693,7 +719,7 @@ function writeJson(file: string, value: unknown): void { } async function responsePayload(response: Response): Promise { - const text = await response.text(); + const text = await readBoundedText(response, "HTTP response"); const contentType = response.headers.get("content-type") ?? ""; if (contentType.toLowerCase().includes("json")) { try { @@ -727,7 +753,7 @@ function cachedCatalogPath(): string { } function providerFilename(fqn: string): string { - return `${fqn.replace(/\//g, "__")}.json`; + return `${sanitizeProviderName(fqn).replace(/\//g, "__")}.json`; } function sanitizeProviderName(name: string): string { @@ -792,7 +818,10 @@ function remoteBaseFromCatalogPayload(payload: Record): string | un async function remoteBaseFromSource(source: string, payload?: Record, options?: ParsedOptions): Promise { const fromPayload = payload ? remoteBaseFromCatalogPayload(payload) : undefined; - if (fromPayload) return fromPayload; + 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)}`; } @@ -804,6 +833,7 @@ async function remoteBaseFromSource(source: string, payload?: Record 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, network, amount, asset: assetAddress, payTo: opt(options, "pay-to") ?? opt(options, "payTo") ?? "", - maxTimeoutSeconds: Number(opt(options, "valid-for-seconds", "300")), + maxTimeoutSeconds, extra: scheme === "exact" && assetTransferMethod ? { assetTransferMethod } : {}, }; } @@ -1059,9 +1093,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; } @@ -1120,7 +1155,7 @@ 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}`); } @@ -1145,7 +1180,7 @@ function gasfreeFeeLimitRaw(selected: PaymentRequirement, options: ParsedOptions 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) 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"); return toSmallestUnit(maxHuman, decimals); } @@ -1210,7 +1245,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; @@ -1219,7 +1254,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: requirement.scheme, 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) })); @@ -1246,6 +1281,13 @@ 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) { @@ -1266,6 +1308,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, @@ -1336,7 +1381,7 @@ async function pay(url: string, options: ParsedOptions): Promise { const body = await responsePayload(paid); const paymentResponse = paid.headers.get(headers.response); const settlement = paymentResponse ? decodeResponse(paymentResponse) : undefined; - const settled = settlement !== 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, @@ -1350,6 +1395,9 @@ async function pay(url: string, options: ParsedOptions): Promise { } : {}), ...(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) { const retryAfter = paid.headers.get("retry-after"); throw new CliError( diff --git a/src/tokens.ts b/src/tokens.ts index f98959a..6947de6 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -124,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 d842ca6..027d907 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -136,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); @@ -144,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}`; }, @@ -196,7 +202,7 @@ 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, diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 3e889c5..6cbf73c 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -263,7 +263,7 @@ test("pay preserves settlement details from a failed paid response", async () => }); return response.end(JSON.stringify(challenge)); } - const settlement = { success: true, transaction: "settled-transaction" }; + 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"), From 7a1ce2b933e2c5d302754ea9046e6baacd944223 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Fri, 17 Jul 2026 23:14:16 +0800 Subject: [PATCH 14/23] test: verify daemon readiness and packed CLI --- package.json | 1 + scripts/test-package.mjs | 26 ++++++++++++++++++++++++++ src/cli.ts | 33 +++++++++++++++++++++++++++++---- 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 scripts/test-package.mjs diff --git a/package.json b/package.json index c203d5d..b31c475 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "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" }, diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs new file mode 100644 index 0000000..036b13d --- /dev/null +++ b/scripts/test-package.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, ".."); +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.match(version, /^1\.0\.1-/); + 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/cli.ts b/src/cli.ts index 8285a08..fe5f355 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import http from "node:http"; import { setTimeout as delay } from "node:timers/promises"; +import net from "node:net"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -1114,7 +1115,23 @@ function stripFlag(argv: string[], flag: string): string[] { return argv.filter(item => item !== flag); } -function serveDaemon(argv: string[], options: ParsedOptions): void { +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}`); +} + +async function serveDaemon(argv: string[], options: ParsedOptions): Promise { const requirement = buildRequirement(options); if (!requirement.payTo) throw new Error("--pay-to is required"); const daemonArgs = stripFlag(stripFlag(argv, "--daemon"), "-d"); @@ -1128,6 +1145,14 @@ function serveDaemon(argv: string[], options: ParsedOptions): void { 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), @@ -1757,7 +1782,7 @@ async function catalogPayJson(source: string, name: string, options: ParsedOptio 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; } @@ -1781,7 +1806,7 @@ async function handleGatewayCatalog(positional: string[], options: ParsedOptions async function handleCatalog(args: string[]): Promise { const { command, positional, options } = parseArgs(args); - if (hasFlag(options, "help") || command === "help") { + 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; @@ -1818,7 +1843,7 @@ async function main(): Promise { return; } if (command === "serve") { - if (hasFlag(options, "daemon")) serveDaemon(argv, options); + if (hasFlag(options, "daemon")) await serveDaemon(argv, options); else await serve(options); } else if (command === "pay") await pay(positional[0], options); From 8da9584d9dd323af44549af503e42bfcede0e343 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Sat, 18 Jul 2026 00:57:58 +0800 Subject: [PATCH 15/23] ci: test TypeScript CLI and release package --- .github/workflows/test.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/test.yml 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 From 8146b710e12ab75d9ab45a7a7b6948d51426ef32 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Sat, 18 Jul 2026 10:55:02 +0800 Subject: [PATCH 16/23] chore: bundle gateway 1.0.1-beta.7 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe59eed..bc46a2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", - "@bankofai/x402-gateway": "1.0.1-beta.6", + "@bankofai/x402-gateway": "1.0.1-beta.7", "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", "viem": "^2.55.0", @@ -67,9 +67,9 @@ } }, "node_modules/@bankofai/x402-gateway": { - "version": "1.0.1-beta.6", - "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.6.tgz", - "integrity": "sha512-00+KsYXdDq3iKRR/sXdwNBX9qnkkURtxFr2mbjcoDLju3M3ZRBEIRpLlemasbXfusqdyMDyUxUfuccKBLMtoTw==", + "version": "1.0.1-beta.7", + "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.7.tgz", + "integrity": "sha512-CgFcrYVwzVhxsRvrQVwap2Irwly/LhXy3jzbE1RX7r3FOAcaU1gRw9mGw4REXSZdgUHUtkKkf5Tst9Y3NOoZvA==", "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", diff --git a/package.json b/package.json index b31c475..2a1214b 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", - "@bankofai/x402-gateway": "1.0.1-beta.6", + "@bankofai/x402-gateway": "1.0.1-beta.7", "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", "viem": "^2.55.0", From 1617cc285842e309938612d18bd0e313e14ba457 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Mon, 20 Jul 2026 12:27:28 +0800 Subject: [PATCH 17/23] refactor: extract CLI infrastructure modules --- src/args.ts | 82 +++++++ src/cli.ts | 472 +---------------------------------------- src/http-client.ts | 72 +++++++ src/output.ts | 59 ++++++ src/provider-config.ts | 87 ++++++++ 5 files changed, 304 insertions(+), 468 deletions(-) create mode 100644 src/args.ts create mode 100644 src/http-client.ts create mode 100644 src/output.ts create mode 100644 src/provider-config.ts 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/cli.ts b/src/cli.ts index fe5f355..ad3225a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,86 +8,16 @@ 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"; +import { CliError, hasFlag, opt, optAll, outputMode, parseArgs, requireArgument, type ParsedOptions } from "./args.js"; +import { classify, emit, printJson } from "./output.js"; +import { fetchWithTimeout, positiveIntegerOption, readBoundedText, readJson, readText, responsePayload, timeoutMs } from "./http-client.js"; +import { loadProviderFile, providerAssetTransferMethod, providerCatalog, providerFiles, providerPrice, providerScheme, validateProvider } from "./provider-config.js"; -type ParsedOptions = Record; -type OutputMode = "human" | "json"; -type FriendlyError = { code: string; message: string; hint: string; details?: unknown }; -const BOOLEAN_FLAGS = new Set(["daemon", "dry-run", "force", "help", "human", "include-blocked", "json", "raw", "version"]); -const MAX_HTTP_BODY_BYTES = 10 * 1024 * 1024; 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, - public details?: unknown, - ) { - 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("--")) { - 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 }; -} - function getVersion(): string { try { const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")); @@ -97,206 +27,6 @@ function getVersion(): string { } } -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`); - 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; - 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, - 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.", - }; -} - function helpText(topic = "root"): string { const sections: Record = { root: `x402-cli ${getVersion()} @@ -457,124 +187,6 @@ Options: 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 = 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 providerScheme(provider: any): string { - return provider.operator?.scheme ?? "exact"; -} - -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: providerScheme(provider), - 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: providerScheme(provider), - assetTransferMethod: providerAssetTransferMethod(provider), - url: `/providers/${provider.name}${endpoint.path}`, - }] : [], - })), - }; -} - type SearchHit = { provider: any; detail: any; @@ -676,62 +288,11 @@ async function readCatalogObject(source: string, options?: ParsedOptions): Promi 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 readBoundedText(response, `response from ${source}`); -} - -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); -} - -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 readBoundedText(response, "HTTP response"); - 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; @@ -787,31 +348,6 @@ function defaultCatalogSource(): string { : "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 instanceof Error && error.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; 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..96e411f --- /dev/null +++ b/src/output.ts @@ -0,0 +1,59 @@ +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." }; +} diff --git a/src/provider-config.ts b/src/provider-config.ts new file mode 100644 index 0000000..856eec2 --- /dev/null +++ b/src/provider-config.ts @@ -0,0 +1,87 @@ +import fs from "node:fs"; +import path from "node:path"; +import YAML from "yaml"; +import { normalizeNetwork } from "./tokens.js"; + +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; +} + +export 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(); +} + +export function providerPrice(endpoint: any): number { + return endpoint?.metering?.dimensions?.[0]?.tiers?.[0]?.price_usd ?? 0; +} + +export 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); + } +} + +export function loadProviderFile(file: string): any { + const provider = expandDeep(readYaml(file)); + validateProvider(provider, file); + provider.operator.network = normalizeNetwork(provider.operator.network); + provider.operator.scheme = provider.operator.scheme ?? "exact"; + return provider; +} + +export function providerAssetTransferMethod(provider: any): string { + return provider.operator?.asset_transfer_method ?? provider.operator?.assetTransferMethod ?? "permit2"; +} + +export function providerScheme(provider: any): string { + return provider.operator?.scheme ?? "exact"; +} + +export 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: providerScheme(provider), 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: providerScheme(provider), assetTransferMethod: providerAssetTransferMethod(provider), url: `/providers/${provider.name}${endpoint.path}` }] : [], + })), + }; +} From b562c95f1f2ea862c1ba3a99782b5ee2610aaeeb Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Mon, 20 Jul 2026 12:28:52 +0800 Subject: [PATCH 18/23] refactor: isolate CLI daemon lifecycle --- src/cli.ts | 60 ++------------------------------------------------- src/daemon.ts | 44 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 58 deletions(-) create mode 100644 src/daemon.ts diff --git a/src/cli.ts b/src/cli.ts index ad3225a..3f80a40 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import http from "node:http"; import { setTimeout as delay } from "node:timers/promises"; -import net from "node:net"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -14,6 +13,7 @@ import { CliError, hasFlag, opt, optAll, outputMode, parseArgs, requireArgument, import { classify, emit, printJson } from "./output.js"; import { fetchWithTimeout, positiveIntegerOption, readBoundedText, readJson, readText, responsePayload, timeoutMs } from "./http-client.js"; import { loadProviderFile, providerAssetTransferMethod, providerCatalog, providerFiles, providerPrice, providerScheme, validateProvider } from "./provider-config.js"; +import { startServeDaemon } from "./daemon.js"; const require = createRequire(import.meta.url); const CATALOG_UPDATE_RETRIES = 3; @@ -647,62 +647,6 @@ function requestHeaders(options: ParsedOptions): Headers { return headersOut; } -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}`); -} - -async function serveDaemon(argv: string[], options: ParsedOptions): Promise { - 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`)!; - 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, - }, - }); -} - function validateAmountLimits(selected: PaymentRequirement, options: ParsedOptions): void { const maxRaw = opt(options, "max-rawAmount") ?? opt(options, "max-raw-amount"); const maxAmount = opt(options, "max-amount"); @@ -1379,7 +1323,7 @@ async function main(): Promise { return; } if (command === "serve") { - if (hasFlag(options, "daemon")) await 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 } }); +} From d07246a3411c47e0fb2042353b84d084f6ea13e4 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Mon, 20 Jul 2026 12:35:18 +0800 Subject: [PATCH 19/23] refactor: extract catalog gateway and help commands --- src/catalog-commands.ts | 585 +++++++++++++++++++++++++ src/cli.ts | 928 +--------------------------------------- src/gateway-commands.ts | 169 ++++++++ src/help.ts | 171 ++++++++ src/output.ts | 9 + 5 files changed, 939 insertions(+), 923 deletions(-) create mode 100644 src/catalog-commands.ts create mode 100644 src/gateway-commands.ts create mode 100644 src/help.ts 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 3f80a40..206e08a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,588 +1,15 @@ #!/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 { createPaymentPayload, decodeRequired, decodeResponse, decodeSignature, encodeRequired, encodeResponse, encodeSignature, headers, PaymentRequirement } from "./x402.js"; import { assertRawAmount, findTokenByAddress, getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js"; import { CliError, hasFlag, opt, optAll, outputMode, parseArgs, requireArgument, type ParsedOptions } from "./args.js"; -import { classify, emit, printJson } from "./output.js"; -import { fetchWithTimeout, positiveIntegerOption, readBoundedText, readJson, readText, responsePayload, timeoutMs } from "./http-client.js"; -import { loadProviderFile, providerAssetTransferMethod, providerCatalog, providerFiles, providerPrice, providerScheme, validateProvider } from "./provider-config.js"; +import { classify, emit, withSdkStdoutRedirect } from "./output.js"; +import { fetchWithTimeout, readBoundedText, responsePayload, timeoutMs } from "./http-client.js"; import { startServeDaemon } from "./daemon.js"; - -const require = createRequire(import.meta.url); -const CATALOG_UPDATE_RETRIES = 3; - -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 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; -} - -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`); -} - -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 `${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`); - } -} - -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 } }); -} +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:0xcd8690dc")!); @@ -933,333 +360,6 @@ async function roundtrip(options: ParsedOptions): Promise { 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: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") }, - }); -} - -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: providerScheme(provider), - 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") || ["help", "--help", "-h"].includes(command)) { @@ -1284,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") || ["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); -} - async function main(): Promise { const argv = process.argv.slice(2); const { command, positional, options } = parseArgs(argv); diff --git a/src/gateway-commands.ts b/src/gateway-commands.ts new file mode 100644 index 0000000..9001ebd --- /dev/null +++ b/src/gateway-commands.ts @@ -0,0 +1,169 @@ +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 { loadProviderFile, providerAssetTransferMethod, providerCatalog, providerFiles, providerPrice, providerScheme } from "./provider-config.js"; +import { normalizeNetwork } from "./tokens.js"; + +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 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 .", + ); +} + +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 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 }, + }); +} + +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 = 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); + } +} + +export 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: providerScheme(provider), + assetTransferMethod: providerAssetTransferMethod(provider), + })), + ); + 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/output.ts b/src/output.ts index 96e411f..ce9722a 100644 --- a/src/output.ts +++ b/src/output.ts @@ -57,3 +57,12 @@ export function classify(error: unknown): FriendlyError { 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; } +} From cf0c6ccd7813c420b00055936792cd9d964c17a0 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Mon, 20 Jul 2026 12:45:23 +0800 Subject: [PATCH 20/23] refactor: delegate provider operations to gateway API --- package-lock.json | 15 ++++--- package.json | 7 ++-- src/gateway-commands.ts | 35 ++++------------- src/provider-config.ts | 87 ----------------------------------------- 4 files changed, 18 insertions(+), 126 deletions(-) delete mode 100644 src/provider-config.ts diff --git a/package-lock.json b/package-lock.json index bc46a2a..84980b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,19 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.8", + "version": "1.0.1-beta.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.8", + "version": "1.0.1-beta.9", "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", - "@bankofai/x402-gateway": "1.0.1-beta.7", + "@bankofai/x402-gateway": "1.0.1-beta.10", "@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" @@ -67,9 +66,9 @@ } }, "node_modules/@bankofai/x402-gateway": { - "version": "1.0.1-beta.7", - "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.7.tgz", - "integrity": "sha512-CgFcrYVwzVhxsRvrQVwap2Irwly/LhXy3jzbE1RX7r3FOAcaU1gRw9mGw4REXSZdgUHUtkKkf5Tst9Y3NOoZvA==", + "version": "1.0.1-beta.10", + "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.10.tgz", + "integrity": "sha512-0ot7f/51b+6QBQ2cZXp2u22T4INXFCaTJHQeqERn6PHXVfhUSKbNjVBi3jIi2qfbZCH6QZm3+1jkM6HBAIyUNA==", "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", diff --git a/package.json b/package.json index 2a1214b..00e35bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.8", + "version": "1.0.1-beta.9", "type": "module", "files": [ "dist" @@ -23,11 +23,10 @@ "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", - "@bankofai/x402-gateway": "1.0.1-beta.7", + "@bankofai/x402-gateway": "1.0.1-beta.10", "@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", diff --git a/src/gateway-commands.ts b/src/gateway-commands.ts index 9001ebd..9acccef 100644 --- a/src/gateway-commands.ts +++ b/src/gateway-commands.ts @@ -5,8 +5,7 @@ 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 { loadProviderFile, providerAssetTransferMethod, providerCatalog, providerFiles, providerPrice, providerScheme } from "./provider-config.js"; -import { normalizeNetwork } from "./tokens.js"; +import { buildGatewayCatalog, loadProviders, providerPaymentAssets } from "@bankofai/x402-gateway"; const require = createRequire(import.meta.url); @@ -26,7 +25,7 @@ function executableInPath(name: string): string | undefined { function resolveGatewayPackageRuntime(): string | undefined { try { - return require.resolve("@bankofai/x402-gateway/dist/cli.js"); + return path.join(path.dirname(require.resolve("@bankofai/x402-gateway/package.json")), "dist", "cli.js"); } catch { return undefined; } @@ -71,17 +70,11 @@ export async function gatewayStart(args: string[], options: ParsedOptions): Prom } export 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); - } + const providers = loadProviders(target); emit({ command: "gateway check", mode: outputMode(options), - result: { providers: providers.map(p => p.name), count: providers.length }, + result: { providers: [...providers.keys()], count: providers.size }, }); } @@ -129,8 +122,8 @@ endpoints: } export 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 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 }); @@ -138,7 +131,7 @@ export function catalogBuild(target: string, options: ParsedOptions): void { emit({ command: "catalog build", mode: outputMode(options), - result: { output, count: providers.length }, + result: { output, count: providers.size }, }); } else if (outputMode(options) === "json") { emit({ command: "catalog build", mode: "json", result: catalog }); @@ -148,22 +141,10 @@ export function catalogBuild(target: string, options: ParsedOptions): void { } export 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: providerScheme(provider), - assetTransferMethod: providerAssetTransferMethod(provider), - })), - ); + const rows = providerPaymentAssets(loadProviders(target)); emit({ command: "gateway catalog pay-assets", mode: outputMode(options), result: { count: rows.length, assets: rows }, }); } - diff --git a/src/provider-config.ts b/src/provider-config.ts deleted file mode 100644 index 856eec2..0000000 --- a/src/provider-config.ts +++ /dev/null @@ -1,87 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import YAML from "yaml"; -import { normalizeNetwork } from "./tokens.js"; - -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; -} - -export 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(); -} - -export function providerPrice(endpoint: any): number { - return endpoint?.metering?.dimensions?.[0]?.tiers?.[0]?.price_usd ?? 0; -} - -export 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); - } -} - -export function loadProviderFile(file: string): any { - const provider = expandDeep(readYaml(file)); - validateProvider(provider, file); - provider.operator.network = normalizeNetwork(provider.operator.network); - provider.operator.scheme = provider.operator.scheme ?? "exact"; - return provider; -} - -export function providerAssetTransferMethod(provider: any): string { - return provider.operator?.asset_transfer_method ?? provider.operator?.assetTransferMethod ?? "permit2"; -} - -export function providerScheme(provider: any): string { - return provider.operator?.scheme ?? "exact"; -} - -export 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: providerScheme(provider), 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: providerScheme(provider), assetTransferMethod: providerAssetTransferMethod(provider), url: `/providers/${provider.name}${endpoint.path}` }] : [], - })), - }; -} From 2ce4237f71706f3ada18e4b0605c6a261dcb153b Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Mon, 20 Jul 2026 12:46:50 +0800 Subject: [PATCH 21/23] fix: make CLI package builds clean and reproducible --- package-lock.json | 4 ++-- package.json | 5 +++-- scripts/bundle-gateway.mjs | 8 ++++---- scripts/clean.mjs | 6 ++++++ 4 files changed, 15 insertions(+), 8 deletions(-) create mode 100644 scripts/clean.mjs diff --git a/package-lock.json b/package-lock.json index 84980b3..ac26cf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.9", + "version": "1.0.1-beta.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.9", + "version": "1.0.1-beta.10", "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", diff --git a/package.json b/package.json index 00e35bb..72ff604 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.9", + "version": "1.0.1-beta.10", "type": "module", "files": [ "dist" @@ -9,7 +9,8 @@ "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", 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 }); From c1f940d26524898cd237d749164a500c342e3306 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Mon, 20 Jul 2026 16:50:16 +0800 Subject: [PATCH 22/23] chore: prepare CLI 1.0.1 release --- README.md | 2 +- package-lock.json | 10 ++++------ package.json | 4 ++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6521154..c1cc473 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ from the payment token, so the payer does not need TRX. Install the CLI package: ```bash -npm install -g @bankofai/x402-cli@beta +npm install -g @bankofai/x402-cli@1.0.1 x402-cli --version ``` diff --git a/package-lock.json b/package-lock.json index ac26cf2..932f181 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.10", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.10", + "version": "1.0.1", "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", - "@bankofai/x402-gateway": "1.0.1-beta.10", + "@bankofai/x402-gateway": "1.0.1", "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", "viem": "^2.55.0" @@ -66,9 +66,7 @@ } }, "node_modules/@bankofai/x402-gateway": { - "version": "1.0.1-beta.10", - "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.1-beta.10.tgz", - "integrity": "sha512-0ot7f/51b+6QBQ2cZXp2u22T4INXFCaTJHQeqERn6PHXVfhUSKbNjVBi3jIi2qfbZCH6QZm3+1jkM6HBAIyUNA==", + "version": "1.0.1", "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", diff --git a/package.json b/package.json index 72ff604..3601bf7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1-beta.10", + "version": "1.0.1", "type": "module", "files": [ "dist" @@ -24,7 +24,7 @@ "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", - "@bankofai/x402-gateway": "1.0.1-beta.10", + "@bankofai/x402-gateway": "1.0.1", "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", "viem": "^2.55.0" From c42c832f84c88e89cb19bf253b051611f7006266 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Mon, 20 Jul 2026 19:46:49 +0800 Subject: [PATCH 23/23] test: validate packed CLI release version --- scripts/test-package.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 036b13d..bb59760 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -1,10 +1,13 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +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" }); @@ -16,7 +19,7 @@ try { 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.match(version, /^1\.0\.1-/); + assert.equal(version, expectedVersion); const gatewayHelp = execFileSync(cli, ["gateway", "--help"], { encoding: "utf8" }); assert.match(gatewayHelp, /gateway/iu); rmSync(tarball, { force: true });