From 193c83863d1468fc57c06eedaf69c98ba30c19d4 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Wed, 15 Jul 2026 10:20:48 +0800 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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",