diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e8ac552 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: cli-ci + +on: + pull_request: + push: + branches: [main] + tags: ["v*", "test-v*"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20.11" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=high + + - name: Verify packed CLI + run: npm run test:package diff --git a/README.md b/README.md index e6e7686..6521154 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,25 @@ TypeScript command-line client for BankofAI x402 payments. This version uses the npm TypeScript SDK packages only: -- `@bankofai/x402-core@1.0.0` -- `@bankofai/x402-evm@1.0.0` -- `@bankofai/x402-tron@1.0.0` +- `@bankofai/x402-core@1.0.1` +- `@bankofai/x402-evm@1.0.1` +- `@bankofai/x402-tron@1.0.1` -Stablecoin payments use `scheme=exact` with -`extra.assetTransferMethod=permit2`. +Stablecoin payments support `scheme=exact` and TRON `scheme=exact_gasfree`. +The GasFree flow lets the relayer pay network energy while deducting its fee +from the payment token, so the payer does not need TRX. ## Install +Install the CLI package: + +```bash +npm install -g @bankofai/x402-cli@beta +x402-cli --version +``` + +For repository development: + ```bash npm install npm run build @@ -20,7 +30,7 @@ npm run build Run from source during development: ```bash -npm run dev -- serve --pay-to --amount 0.0001 --network tron:nile --token USDT +npm run dev -- serve --pay-to --amount 0.0001 --network tron:0xcd8690dc --token USDT ``` Run the compiled CLI: @@ -48,10 +58,10 @@ envelope with `ok`, `command`, `result`, or structured `error` fields. Start a local x402 paywall endpoint: ```bash -node dist/cli.js serve \ +x402-cli serve \ --pay-to \ --amount 0.0001 \ - --network tron:nile \ + --network tron:0xcd8690dc \ --token USDT \ --port 4020 ``` @@ -60,8 +70,8 @@ The server exposes: - `GET /health` - `GET /.well-known/x402` -- `GET /pay` returns `402 Payment Required` -- `POST /pay` verifies and settles with the facilitator +- `/pay` returns `402 Payment Required` without a payment signature +- The signed retry uses the same HTTP method, then verifies and settles with the facilitator ### Pay @@ -69,12 +79,53 @@ Pay an x402-protected URL: ```bash TRON_PRIVATE_KEY= \ -node dist/cli.js pay http://127.0.0.1:4020/pay \ - --network tron:nile \ +x402-cli pay http://127.0.0.1:4020/pay \ + --network tron:0xcd8690dc \ --token USDT ``` +For automated or unfamiliar endpoints, set `--max-amount` or +`--max-raw-amount` before allowing the CLI to sign a payment. + +Pay a TRON GasFree endpoint (the CLI normally selects this automatically from +the server challenge): + +```bash +TRON_PRIVATE_KEY= \ +x402-cli pay https://api.example.com/pay \ + --network tron:0xcd8690dc \ + --token USDT \ + --scheme exact_gasfree +``` + +Use `--gasfree-api-url ` or `X402_GASFREE_API_URL` to override the SDK's +default relayer endpoint. + +GasFree fees are separate from the advertised payment amount. Set a fee limit +so the CLI estimates the relayer fee and rejects the payment before signing if +the estimate is too high: + +```bash +x402-cli pay https://api.example.com/pay \ + --scheme exact_gasfree \ + --max-amount 0.01 \ + --max-gasfree-fee 0.5 \ + --json +``` + +Use `--max-gasfree-fee-raw` to express the fee limit in the token's smallest +unit. Successful and failed paid responses distinguish `settled` (payment +completed) from `delivered` (HTTP business response succeeded). A settled +upstream failure has `paid=true`, `settled=true`, and `delivered=false` and +includes its transaction information. + For EVM networks use `EVM_PRIVATE_KEY` or `PRIVATE_KEY`. +Prefer environment variables over `--private-key` in shared environments, +because command-line arguments may be visible to other local processes. + +If the gateway settles a payment but the upstream request fails, JSON error +output includes `error.details.paymentResponse` for reconciliation. Do not retry +such a request blindly; inspect the transaction and provider behavior first. ### Roundtrip @@ -82,10 +133,10 @@ Start a temporary local server and immediately pay it: ```bash TRON_PRIVATE_KEY= \ -node dist/cli.js roundtrip \ +x402-cli roundtrip \ --pay-to \ --amount 0.0001 \ - --network tron:nile \ + --network tron:0xcd8690dc \ --token USDT ``` @@ -93,16 +144,16 @@ node dist/cli.js roundtrip \ Supported built-in token registry: -- `tron:mainnet` USDT, USDD -- `tron:nile` USDT, USDD -- `tron:shasta` USDT +- `tron:0x2b6653dc` USDT, USDD +- `tron:0xcd8690dc` USDT, USDD +- `tron:0x94a9059e` USDT - `eip155:56` USDT - `eip155:97` USDT, USDC -Aliases accepted: +Non-CAIP TRON aliases are rejected. Use the canonical TRON IDs above. + +EVM convenience aliases accepted: -- `tron-mainnet` -> `tron:mainnet` -- `tron-nile` -> `tron:nile` - `bsc-mainnet` -> `eip155:56` - `bsc-testnet` -> `eip155:97` @@ -114,5 +165,6 @@ Pass a facilitator URL when needed: x402-cli serve --facilitator-url https://facilitator.bankofai.io ... ``` -CLI payment challenges and payload selection always emit `scheme: "exact"` for -the SDK 1.0 Permit2 path. +`serve --scheme exact_gasfree` advertises a TRON GasFree requirement. The +configured facilitator must advertise and settle `exact_gasfree` for that +network and token. diff --git a/package-lock.json b/package-lock.json index cfd193e..bc46a2a 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.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.0", + "version": "1.0.1-beta.8", "dependencies": { - "@bankofai/x402-core": "1.0.0", - "@bankofai/x402-evm": "1.0.0", - "@bankofai/x402-gateway": "^1.0.0", - "@bankofai/x402-tron": "1.0.0", + "@bankofai/x402-core": "1.0.1", + "@bankofai/x402-evm": "1.0.1", + "@bankofai/x402-gateway": "1.0.1-beta.7", + "@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.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.0.0.tgz", - "integrity": "sha512-uVvvXCGfk/HqusxcKmjeTgBlJBMTR7QPZH2IDZlqoliv5O36YASaHSgSo/JOKDvVzPoXlY+5VmxEG6EdWA+wGA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.0.1.tgz", + "integrity": "sha512-2D4W1dTIlaHFrK+C6tazk12iERAcofHMHwG9qFuUhC4Dubhma4YJfpDsbbXQm5QXOsHDMOCTItpdp7nvZoLCeQ==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.2" } }, "node_modules/@bankofai/x402-evm": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-evm/-/x402-evm-1.0.0.tgz", - "integrity": "sha512-j0T88Y5ngItIS9FTWXtbvoITePISzhuurU640dDL9075uQxBiLzYt20rkPebM7gi9D00HTbyJXbHnCq0zajCBQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-evm/-/x402-evm-1.0.1.tgz", + "integrity": "sha512-4JSElA0vdxdDhkqHfXMT0Q3QH7II7CrHfct8ZV714N42PrxCWdGglHSsSeC2ZFBdracozBZyFxPWZKDSy6CTxg==", "license": "Apache-2.0", "dependencies": { - "@bankofai/x402-core": "~1.0.0", + "@bankofai/x402-core": "~1.0.1", "viem": "^2.48.11", "zod": "^3.24.2" } }, "node_modules/@bankofai/x402-gateway": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.0.tgz", - "integrity": "sha512-GStr4jTzeCC4P1wM/16mPPsMtT4+ECDGcLnYTqjlF6mPniF/l2gWZGQp1SznAIeVSyXDJw5b40275If76bzosw==", + "version": "1.0.1-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.0", - "@bankofai/x402-evm": "1.0.0", - "@bankofai/x402-tron": "1.0.0", + "@bankofai/x402-core": "1.0.1", + "@bankofai/x402-evm": "1.0.1", + "@bankofai/x402-tron": "1.0.1", "yaml": "^2.8.2" }, "bin": { @@ -84,12 +84,12 @@ } }, "node_modules/@bankofai/x402-tron": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.0.0.tgz", - "integrity": "sha512-ycq+I1jMFLoQdMP8AzgXEUkSjzQFPrMkRAehcfiFACzrtAciq8Ev3uB0N8+2TXIC55l9fcTxrm+EB/C75T9xVg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.0.1.tgz", + "integrity": "sha512-dsjoOSOJ97dk/o8vpOPJ/FIYAJt2N06SonN9H51WwKdqGoiK5w5fz0n5P36drny/d+Zvtxsy7EMI/g09pRxJUA==", "license": "Apache-2.0", "dependencies": { - "@bankofai/x402-core": "~1.0.0", + "@bankofai/x402-core": "~1.0.1", "tronweb": "^6.1.0" } }, @@ -1429,7 +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 e544834..2a1214b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.0", + "version": "1.0.1-beta.8", "type": "module", "files": [ "dist" @@ -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" }, @@ -20,10 +21,10 @@ "node": ">=20" }, "dependencies": { - "@bankofai/x402-core": "1.0.0", - "@bankofai/x402-evm": "1.0.0", - "@bankofai/x402-gateway": "^1.0.0", - "@bankofai/x402-tron": "1.0.0", + "@bankofai/x402-core": "1.0.1", + "@bankofai/x402-evm": "1.0.1", + "@bankofai/x402-gateway": "1.0.1-beta.7", + "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", "viem": "^2.55.0", "yaml": "^2.8.2" @@ -32,5 +33,8 @@ "@types/node": "^24.10.1", "tsx": "^4.20.6", "typescript": "^5.9.3" + }, + "overrides": { + "ws": "8.21.0" } } 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 14cb780..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"; @@ -13,8 +14,9 @@ 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 MAX_HTTP_BODY_BYTES = 10 * 1024 * 1024; const require = createRequire(import.meta.url); const DEFAULT_TIMEOUT_MS = 30_000; const CATALOG_UPDATE_RETRIES = 3; @@ -25,6 +27,7 @@ class CliError extends Error { message: string, public hint: string, public exitCode = 1, + public details?: unknown, ) { super(message); } @@ -71,7 +74,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]; @@ -152,6 +155,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 +181,7 @@ function classify(error: unknown): FriendlyError { code: error.code, message, hint: error.hint, + details: error.details, }; } const lower = message.toLowerCase(); @@ -322,6 +327,9 @@ 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-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 @@ -341,7 +349,8 @@ 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 --decimals Token decimals for unregistered --asset @@ -354,7 +363,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: @@ -483,7 +492,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 +539,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 +559,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 +567,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}`, }] : [], @@ -669,7 +682,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 { @@ -682,7 +720,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 { @@ -716,7 +754,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 { @@ -767,7 +805,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); @@ -781,7 +819,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)}`; } @@ -793,6 +834,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: "exact", + scheme, network, amount, asset: assetAddress, payTo: opt(options, "pay-to") ?? opt(options, "payTo") ?? "", - maxTimeoutSeconds: Number(opt(options, "valid-for-seconds", "300")), - extra: assetTransferMethod ? { assetTransferMethod } : {}, + maxTimeoutSeconds, + extra: scheme === "exact" && assetTransferMethod ? { assetTransferMethod } : {}, }; } @@ -1043,9 +1094,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; } @@ -1063,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"); @@ -1077,11 +1145,19 @@ 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), network: requirement.network, - scheme: "exact", + scheme: requirement.scheme, result: { pid: child.pid, pay_url: resourceUrl, @@ -1104,13 +1180,35 @@ function validateAmountLimits(selected: PaymentRequirement, options: ParsedOptio throw new Error("cannot evaluate --max-amount for an unknown asset; pass --max-raw-amount or --decimals"); } const decimals = decimalsOption !== undefined ? Number(decimalsOption) : token!.decimals; - if (!Number.isInteger(decimals) || decimals < 0) throw new Error("--decimals must be a non-negative integer"); + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) throw new Error("--decimals must be an integer between 0 and 255"); if (BigInt(selected.amount) > BigInt(toSmallestUnit(maxAmount, decimals))) { throw new Error(`payment amount exceeds --max-amount ${maxAmount}`); } } } +function gasfreeFeeLimitRaw(selected: PaymentRequirement, options: ParsedOptions): string | undefined { + const maxRaw = opt(options, "max-gasfree-fee-raw"); + const maxHuman = opt(options, "max-gasfree-fee"); + if (maxRaw && maxHuman) { + throw new CliError("INVALID_ARGUMENT", "--max-gasfree-fee and --max-gasfree-fee-raw are mutually exclusive", "Pass one GasFree fee limit.", 2); + } + if (selected.scheme !== "exact_gasfree") { + if (maxRaw || maxHuman) throw new Error("GasFree fee limits require an exact_gasfree payment requirement"); + return undefined; + } + if (maxRaw) return assertRawAmount(maxRaw, "--max-gasfree-fee-raw"); + if (!maxHuman) return undefined; + const token = findTokenByAddress(selected.network, selected.asset); + const decimalsOption = opt(options, "decimals"); + if (!token && decimalsOption === undefined) { + throw new Error("cannot evaluate --max-gasfree-fee for an unknown asset; pass --max-gasfree-fee-raw or --decimals"); + } + const decimals = decimalsOption !== undefined ? Number(decimalsOption) : token!.decimals; + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) throw new Error("--decimals must be an integer between 0 and 255"); + return toSmallestUnit(maxHuman, decimals); +} + async function serve(options: ParsedOptions): Promise { const host = opt(options, "host", "127.0.0.1")!; const port = Number(opt(options, "port", "4020")); @@ -1135,7 +1233,7 @@ async function serve(options: ParsedOptions): Promise { } if (pathname === "/.well-known/x402") { response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ network: requirement.network, scheme: "exact", asset: requirement.asset, rawAmount: requirement.amount, amount: requirement.amount, payTo: requirement.payTo, pay_url: resourceUrl })); + response.end(JSON.stringify({ network: requirement.network, scheme: requirement.scheme, asset: requirement.asset, rawAmount: requirement.amount, amount: requirement.amount, payTo: requirement.payTo, pay_url: resourceUrl })); return; } if (pathname !== "/pay") { @@ -1172,7 +1270,7 @@ async function serve(options: ParsedOptions): Promise { paymentPayload: payload, paymentRequirements: requirement, }, options); - if (!(settle?.success === true || settle?.settled === true || typeof settle?.transaction === "string" || typeof settle?.txHash === "string")) { + if (!(settle?.success === true && typeof settle?.transaction === "string" && settle.transaction.length > 0 && typeof settle?.network === "string" && settle.network.length > 0)) { response.writeHead(502, { "content-type": "application/json" }); response.end(JSON.stringify({ error: "settlement failed" })); return; @@ -1181,7 +1279,7 @@ async function serve(options: ParsedOptions): Promise { "content-type": "application/json", [headers.response]: encodeResponse(settle), }); - response.end(JSON.stringify({ success: true, network: requirement.network, scheme: "exact", transaction: settle.transaction ?? settle.txHash ?? null })); + response.end(JSON.stringify({ success: true, network: requirement.network, scheme: requirement.scheme, transaction: settle.transaction })); } catch (error) { response.writeHead(500, { "content-type": "application/json" }); response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); @@ -1194,7 +1292,7 @@ async function serve(options: ParsedOptions): Promise { emit({ command: "server", network: requirement.network, - scheme: "exact", + scheme: requirement.scheme, mode: outputMode(options), result: { pay_url: resourceUrl, token: opt(options, "token", "USDT"), rawAmount: requirement.amount, pay_to: requirement.payTo }, }); @@ -1208,10 +1306,22 @@ function selectRequirement(accepts: PaymentRequirement[], options: ParsedOptions const scheme = opt(options, "scheme"); const token = opt(options, "token"); const selected = accepts.find(req => { + if (!req || !["exact", "exact_gasfree"].includes(req.scheme) || typeof req.network !== "string" || typeof req.asset !== "string") return false; + if (req.scheme === "exact_gasfree" && !req.network.startsWith("tron:")) return false; + try { + if (!findTokenByAddress(req.network, req.asset)) return false; + } catch { + return false; + } if (network && normalizeNetwork(network) !== req.network) return false; if (scheme && scheme !== req.scheme) return false; if (token) { - const tokenInfo = getToken(req.network, token); + let tokenInfo; + try { + tokenInfo = getToken(req.network, token); + } catch { + return false; + } if (tokenInfo.address.toLowerCase() !== req.asset.toLowerCase()) return false; } return true; @@ -1223,6 +1333,9 @@ function selectRequirement(accepts: PaymentRequirement[], options: ParsedOptions async function pay(url: string, options: ParsedOptions): Promise { requireArgument(url, "URL", "x402-cli pay [options]"); const method = opt(options, "method", "GET")!; + if (!/^[A-Z]+$/.test(method) || !["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"].includes(method)) { + throw new CliError("INVALID_ARGUMENT", `unsupported HTTP method ${method}`, "Use an uppercase standard HTTP method.", 2); + } const baseHeaders = requestHeaders(options); const probe = await fetchWithTimeout(url, { method, @@ -1230,6 +1343,15 @@ async function pay(url: string, options: ParsedOptions): Promise { body: ["GET", "HEAD"].includes(method.toUpperCase()) ? undefined : opt(options, "body"), }, timeoutMs(options), `fetch ${url}`); if (probe.status !== 402) { + const body = await responsePayload(probe); + if (!probe.ok) { + const retryAfter = probe.headers.get("retry-after"); + throw new Error( + `HTTP ${probe.status} from ${url}${retryAfter ? ` (retry after ${retryAfter}s)` : ""}: ${ + typeof body === "string" ? body.slice(0, 500) : JSON.stringify(body).slice(0, 500) + }`, + ); + } emit({ command: "client", mode: outputMode(options), @@ -1237,7 +1359,7 @@ async function pay(url: string, options: ParsedOptions): Promise { url, status: probe.status, message: "Not a payment-required endpoint", - response: await responsePayload(probe), + response: body, }, }); return; @@ -1247,11 +1369,12 @@ async function pay(url: string, options: ParsedOptions): Promise { const required = decodeRequired(header); const selected = selectRequirement(required.accepts ?? [], options); validateAmountLimits(selected, options); + const maxGasfreeFeeRaw = gasfreeFeeLimitRaw(selected, options); if (options["dry-run"]) { emit({ command: "client", network: selected.network, - scheme: "exact", + scheme: selected.scheme, mode: outputMode(options), result: { url, @@ -1262,17 +1385,19 @@ async function pay(url: string, options: ParsedOptions): Promise { }); return; } - const payload = await withSdkStdoutRedirect(outputMode(options) === "json", () => + const creation = await withSdkStdoutRedirect(outputMode(options) === "json", () => createPaymentPayload({ selected, resource: required.resource?.url ?? url, extensions: required.extensions, rpcUrl: opt(options, "rpc-url"), privateKey: opt(options, "private-key"), + gasfreeApiUrl: opt(options, "gasfree-api-url"), + maxGasfreeFeeRaw, }), ); const retryHeaders = new Headers(baseHeaders); - retryHeaders.set(headers.signature, encodeSignature(payload)); + retryHeaders.set(headers.signature, encodeSignature(creation.payload)); const paid = await fetchWithTimeout(url, { method, headers: retryHeaders, @@ -1280,20 +1405,42 @@ async function pay(url: string, options: ParsedOptions): Promise { }, timeoutMs(options), `fetch ${url}`); const body = await responsePayload(paid); const paymentResponse = paid.headers.get(headers.response); + const settlement = paymentResponse ? decodeResponse(paymentResponse) : undefined; + const settled = settlement?.success === true && typeof settlement?.transaction === "string" && settlement.transaction.length > 0 && typeof settlement?.network === "string" && settlement.network.length > 0; const result = { url, status: paid.status, - paid: paid.ok, + paid: settled, + settled, + delivered: paid.ok, response: body, - ...(paymentResponse ? { paymentResponse: decodeResponse(paymentResponse) } : {}), + ...(settlement !== undefined ? { + paymentResponse: settlement, + transaction: settlement?.transaction ?? settlement?.txHash ?? null, + } : {}), + ...(creation.gasfreeEstimate ? { gasfreeEstimate: creation.gasfreeEstimate } : {}), }; + if (paymentResponse && !settled) { + throw new CliError("INVALID_SETTLEMENT", "gateway returned an invalid or unsuccessful PAYMENT-RESPONSE", "Do not treat this request as paid; contact the gateway operator.", 1, result); + } if (!paid.ok) { - throw new Error(`HTTP ${paid.status} from ${url}: ${typeof body === "string" ? body.slice(0, 500) : JSON.stringify(body).slice(0, 500)}`); + const retryAfter = paid.headers.get("retry-after"); + throw new CliError( + paid.status === 429 ? "RATE_LIMITED" : "HTTP_ERROR", + `HTTP ${paid.status} from ${url}${retryAfter ? ` (retry after ${retryAfter}s)` : ""}: ${ + typeof body === "string" ? body.slice(0, 500) : JSON.stringify(body).slice(0, 500) + }`, + paymentResponse + ? "The gateway reports that payment was settled; retain paymentResponse for support or reconciliation." + : "Inspect the HTTP response and retry only when it is safe to do so.", + 1, + result, + ); } emit({ command: "client", network: selected.network, - scheme: "exact", + scheme: selected.scheme, mode: outputMode(options), result, }); @@ -1301,8 +1448,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); } @@ -1398,7 +1544,7 @@ routing: type: proxy operator: - network: tron-nile + network: tron:0xcd8690dc currencies: usd: ["USDT"] recipient: @@ -1453,7 +1599,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), })), ); @@ -1636,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; } @@ -1660,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; @@ -1697,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); diff --git a/src/tokens.ts b/src/tokens.ts index 34209e5..6947de6 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -8,7 +8,7 @@ export type TokenInfo = { }; export const TOKENS: Record> = { - "tron:mainnet": { + "tron:0x2b6653dc": { USDT: { address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", decimals: 6, @@ -26,7 +26,7 @@ export const TOKENS: Record> = { assetTransferMethod: "permit2", }, }, - "tron:nile": { + "tron:0xcd8690dc": { USDT: { address: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", decimals: 6, @@ -44,7 +44,7 @@ export const TOKENS: Record> = { assetTransferMethod: "permit2", }, }, - "tron:shasta": { + "tron:0x94a9059e": { USDT: { address: "TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs", decimals: 6, @@ -84,15 +84,25 @@ export const TOKENS: Record> = { }; export function normalizeNetwork(network: string): string { - return ( - { - "tron-mainnet": "tron:mainnet", - "tron-shasta": "tron:shasta", - "tron-nile": "tron:nile", - "bsc-mainnet": "eip155:56", - "bsc-testnet": "eip155:97", - }[network] ?? network - ); + const legacyTronIds: Record = { + "tron-mainnet": "tron:0x2b6653dc", + "tron:mainnet": "tron:0x2b6653dc", + mainnet: "tron:0x2b6653dc", + "tron-shasta": "tron:0x94a9059e", + "tron:shasta": "tron:0x94a9059e", + shasta: "tron:0x94a9059e", + "tron-nile": "tron:0xcd8690dc", + "tron:nile": "tron:0xcd8690dc", + nile: "tron:0xcd8690dc", + }; + const canonical = legacyTronIds[network]; + if (canonical) { + throw new Error(`legacy TRON network identifier ${network} is not supported; use ${canonical}`); + } + return { + "bsc-mainnet": "eip155:56", + "bsc-testnet": "eip155:97", + }[network] ?? network; } export function getToken(network: string, symbol: string): TokenInfo { @@ -114,7 +124,7 @@ export function assertRawAmount(value: string, name = "raw amount"): string { } export function toSmallestUnit(amount: string, decimals: number): string { - if (!Number.isInteger(decimals) || decimals < 0) throw new Error("token decimals must be a non-negative integer"); + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) throw new Error("token decimals must be an integer between 0 and 255"); if (!/^\d+(\.\d+)?$/.test(amount)) throw new Error("amount must be a non-negative decimal string"); const [whole, fraction = ""] = amount.split("."); if (fraction.length > decimals) throw new Error(`amount has more than ${decimals} decimal places`); diff --git a/src/x402.ts b/src/x402.ts index 4968011..027d907 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -9,6 +9,7 @@ import { import { x402Client } from "@bankofai/x402-core/client"; import { ExactEvmScheme, toClientEvmSigner } from "@bankofai/x402-evm"; import { ExactTronScheme, createClientTronSigner } from "@bankofai/x402-tron"; +import { ExactGasFreeTronScheme, createGasFreeApiClients, getGasFreeApiBaseUrl } from "@bankofai/x402-tron/gasfree"; import { createPublicClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { TronWeb } from "tronweb"; @@ -58,6 +59,7 @@ export function decodeResponse(value: string): any { } export function ensurePermit2(requirement: PaymentRequirement): PaymentRequirement { + if (requirement.scheme !== "exact") return requirement; const extra = { ...(requirement.extra ?? {}) }; if (!extra.assetTransferMethod) { const token = findTokenByAddress(requirement.network, requirement.asset); @@ -65,7 +67,7 @@ export function ensurePermit2(requirement: PaymentRequirement): PaymentRequireme extra.assetTransferMethod = "permit2"; } } - return { ...requirement, scheme: "exact", extra }; + return { ...requirement, extra }; } export function paymentRequired( @@ -134,7 +136,7 @@ function evmRpcUrl(network: string, explicit?: string): string | undefined { ); } -async function createTronWallet(privateKey: `0x${string}`) { +async function createTronWallet(privateKey: `0x${string}`, maxGasfreeFeeRaw?: string) { const rawKey = privateKey.replace(/^0x/, ""); const tronWeb = new TronWeb({ fullHost: "https://api.trongrid.io" }); const address = TronWeb.address.fromPrivateKey(rawKey); @@ -142,6 +144,12 @@ async function createTronWallet(privateKey: `0x${string}`) { return { getAddress: () => address, async signTypedData(args: any) { + if (maxGasfreeFeeRaw !== undefined && args?.primaryType === "PermitTransfer") { + const maxFee = BigInt(args?.message?.maxFee ?? -1); + if (maxFee < 0n || maxFee > BigInt(maxGasfreeFeeRaw)) { + throw new Error(`final GasFree maxFee ${maxFee} exceeds --max-gasfree-fee limit ${maxGasfreeFeeRaw}`); + } + } const signature = await signTronTypedData(tronWeb, args, rawKey); return signature.startsWith("0x") ? signature : `0x${signature}`; }, @@ -166,10 +174,13 @@ export async function createPaymentPayload(args: { rpcUrl?: string; apiKey?: string; allowanceMode?: string; -}): Promise { + gasfreeApiUrl?: string; + maxGasfreeFeeRaw?: string; +}): Promise<{ payload: unknown; gasfreeEstimate?: { fee: string; total: string } }> { const selected = ensurePermit2(args.selected); const required = paymentRequired(selected, args.resource, args.extensions); if (selected.network.startsWith("eip155:")) { + if (selected.scheme !== "exact") throw new Error(`unsupported scheme ${selected.scheme} on ${selected.network}`); const privateKey = privateKeyFrom( ["EVM_PRIVATE_KEY", "AGENT_WALLET_PRIVATE_KEY", "PRIVATE_KEY"], args.privateKey, @@ -180,9 +191,10 @@ export async function createPaymentPayload(args: { const publicClient = rpcUrl ? createPublicClient({ transport: http(rpcUrl) }) : undefined; const signer = toClientEvmSigner(account, publicClient); const scheme = new ExactEvmScheme(signer, rpcUrl ? { rpcUrl } : undefined); - return new x402Client() + const payload = await new x402Client() .register(selected.network as `${string}:${string}`, scheme) .createPaymentPayload(required as never); + return { payload }; } if (selected.network.startsWith("tron:")) { const privateKey = privateKeyFrom( @@ -190,16 +202,35 @@ export async function createPaymentPayload(args: { args.privateKey, ["tron_client", "payer", "default"], ); - const wallet = await createTronWallet(privateKey); + const wallet = await createTronWallet(privateKey, selected.scheme === "exact_gasfree" ? args.maxGasfreeFeeRaw : undefined); const signer = await createClientTronSigner(wallet, { network: selected.network, rpcUrl: args.rpcUrl || process.env.TRON_RPC_URL, apiKey: args.apiKey || process.env.TRON_GRID_API_KEY, allowanceMode: args.allowanceMode || process.env.X402_TRON_ALLOWANCE_MODE || "auto", } as never); - return new x402Client() - .register(selected.network as `${string}:${string}`, new ExactTronScheme(signer)) - .createPaymentPayload(required as never); + const client = new x402Client(); + let gasfreeEstimate: { fee: string; total: string } | undefined; + if (selected.scheme === "exact_gasfree") { + const apiUrl = args.gasfreeApiUrl || process.env.X402_GASFREE_API_URL || getGasFreeApiBaseUrl(selected.network); + const scheme = new ExactGasFreeTronScheme(signer, { + apiClients: createGasFreeApiClients({ [selected.network]: apiUrl }), + }); + const total = await scheme.estimateCost(selected as never); + const amount = BigInt(selected.amount); + const fee = total - amount; + if (args.maxGasfreeFeeRaw !== undefined && fee > BigInt(args.maxGasfreeFeeRaw)) { + throw new Error(`estimated GasFree fee ${fee} exceeds --max-gasfree-fee limit ${args.maxGasfreeFeeRaw}`); + } + gasfreeEstimate = { fee: fee.toString(), total: total.toString() }; + client.register(selected.network as `${string}:${string}`, scheme); + } else if (selected.scheme === "exact") { + client.register(selected.network as `${string}:${string}`, new ExactTronScheme(signer)); + } else { + throw new Error(`unsupported scheme ${selected.scheme} on ${selected.network}`); + } + const payload = await client.createPaymentPayload(required as never); + return { payload, ...(gasfreeEstimate ? { gasfreeEstimate } : {}) }; } throw new Error(`unsupported network ${selected.network}`); } diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index c3af5a5..6cbf73c 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -6,6 +6,7 @@ import path from "node:path"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import test from "node:test"; import { signTronTypedData } from "../dist/x402.js"; +import { normalizeNetwork } from "../dist/tokens.js"; const root = path.resolve(import.meta.dirname, ".."); const cli = path.join(root, "dist", "cli.js"); @@ -63,7 +64,7 @@ function catalogFixture(dir) { const catalog = { version: 1, providers: [ - { fqn: "alpha", title: "Alpha", category: "finance", featured_tags: ["defi"], chains: ["tron:nile"] }, + { fqn: "alpha", title: "Alpha", category: "finance", featured_tags: ["defi"], chains: ["tron:0xcd8690dc"] }, { fqn: "blocked", title: "Blocked", category: "security", block: true, featured_tags: ["defi"] }, ], }; @@ -72,7 +73,7 @@ function catalogFixture(dir) { title: "Alpha Provider", category: "finance", service_url: "https://alpha.example", - chains: ["tron:nile"], + chains: ["tron:0xcd8690dc"], featured_tags: ["defi", "tvl"], endpoints: [ { @@ -80,7 +81,7 @@ function catalogFixture(dir) { path: "/protocols", url: "https://gateway.example/providers/alpha/protocols", description: "DeFi TVL endpoint", - paid: { network: "tron:nile", currency: "USDT", amount_raw: "1" }, + paid: { network: "tron:0xcd8690dc", currency: "USDT", amount_raw: "1" }, }, ], }; @@ -103,6 +104,244 @@ test("help and version work", () => { assert.match(version.stdout.trim(), /^\d+\.\d+\.\d+/); }); +test("legacy TRON aliases are rejected in favor of canonical CAIP-2 IDs", () => { + assert.throws(() => normalizeNetwork("tron:nile"), /use tron:0xcd8690dc/); + assert.throws(() => normalizeNetwork("tron-nile"), /use tron:0xcd8690dc/); + assert.throws(() => normalizeNetwork("tron:mainnet"), /use tron:0x2b6653dc/); + assert.throws(() => normalizeNetwork("tron:shasta"), /use tron:0x94a9059e/); +}); + +test("serve advertises exact_gasfree and rejects it on EVM", async () => { + const port = 47000 + Math.floor(Math.random() * 1000); + const started = run([ + "serve", + "--pay-to", "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + "--network", "tron:0xcd8690dc", + "--scheme", "exact_gasfree", + "--port", String(port), + "--daemon", + "--json", + ]); + assert.equal(started.status, 0, started.stderr); + const parsed = JSON.parse(started.stdout); + assert.equal(parsed.scheme, "exact_gasfree"); + const pid = parsed.result.pid; + try { + for (let i = 0; i < 20; i += 1) { + try { + const response = await fetch(`http://127.0.0.1:${port}/pay`); + if (response.status !== 402) throw new Error(`unexpected status ${response.status}`); + const challenge = await response.json(); + assert.equal(challenge.accepts[0].scheme, "exact_gasfree"); + assert.deepEqual(challenge.accepts[0].extra, {}); + break; + } catch (error) { + if (i === 19) throw error; + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + } finally { + try { + process.kill(pid); + } catch { + // Process may already have exited. + } + } + + const evm = run([ + "serve", + "--pay-to", "0x0000000000000000000000000000000000000001", + "--network", "eip155:97", + "--scheme", "exact_gasfree", + "--daemon", + "--json", + ]); + assert.equal(evm.status, 1); + assert.match(evm.stdout, /supported only on TRON/); +}); + +test("pay dry-run preserves an exact_gasfree requirement", async () => { + await withServer((request, response) => { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact_gasfree", + network: "tron:0xcd8690dc", + amount: "1", + asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + payTo: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + response.end(JSON.stringify(challenge)); + }, async base => { + const result = await runAsync(["pay", `${base}/pay`, "--dry-run", "--scheme", "exact_gasfree", "--json"]); + assert.equal(result.status, 0, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.scheme, "exact_gasfree"); + assert.equal(parsed.result.selected.scheme, "exact_gasfree"); + }); +}); + +test("pay skips unknown-network requirements when selecting a token", async () => { + await withServer((request, response) => { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [ + { + scheme: "exact", + network: "eip155:999999", + amount: "1", + asset: "0x0000000000000000000000000000000000000001", + payTo: "0x0000000000000000000000000000000000000002", + }, + { + scheme: "exact_gasfree", + network: "tron:0xcd8690dc", + amount: "1", + asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + payTo: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + }, + ], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + response.end(JSON.stringify(challenge)); + }, async base => { + const result = await runAsync(["pay", `${base}/pay`, "--dry-run", "--token", "USDT", "--json"]); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).result.selected.network, "tron:0xcd8690dc"); + }); +}); + +test("pay reports non-2xx gateway responses as failures", async () => { + await withServer((_request, response) => { + response.writeHead(429, { + "content-type": "application/json", + "retry-after": "36", + }); + response.end(JSON.stringify({ error: "facilitator rate limited" })); + }, async base => { + const result = await runAsync(["pay", `${base}/pay`, "--json"]); + assert.equal(result.status, 1); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, false); + assert.equal(parsed.error.code, "RATE_LIMITED"); + assert.match(parsed.error.message, /HTTP 429/); + assert.match(parsed.error.message, /retry after 36s/); + }); +}); + +test("pay preserves settlement details from a failed paid response", async () => { + let requests = 0; + await withServer((request, response) => { + requests += 1; + if (requests === 1) { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact", + network: "eip155:97", + amount: "1", + asset: "0x337610d27c682E347C9cD60BD4b3b107C9d34dDd", + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + return response.end(JSON.stringify(challenge)); + } + const settlement = { success: true, transaction: "settled-transaction", network: "eip155:97" }; + response.writeHead(502, { + "content-type": "application/json", + "PAYMENT-RESPONSE": Buffer.from(JSON.stringify(settlement)).toString("base64"), + }); + response.end(JSON.stringify({ error: "upstream failed after payment settlement", settled: true })); + }, async base => { + const result = await runAsync([ + "pay", `${base}/pay`, "--json", + "--private-key", `0x${"01".repeat(32)}`, + ]); + assert.equal(result.status, 1, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.ok, false); + assert.equal(parsed.error.code, "HTTP_ERROR"); + assert.equal(parsed.error.details.status, 502); + assert.equal(parsed.error.details.paid, true); + assert.equal(parsed.error.details.settled, true); + assert.equal(parsed.error.details.delivered, false); + assert.equal(parsed.error.details.transaction, "settled-transaction"); + assert.equal(parsed.error.details.paymentResponse.transaction, "settled-transaction"); + }); +}); + +test("GasFree fee limits are enforced before signing", async () => { + await withServer((_apiRequest, apiResponse) => { + apiResponse.writeHead(200, { "content-type": "application/json" }); + apiResponse.end(JSON.stringify({ + code: 200, + data: { + accountAddress: "TD3tTestAccount", + gasFreeAddress: "TNyzTestGasFree", + active: true, + nonce: 1, + allowSubmit: true, + assets: [{ + tokenAddress: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + tokenSymbol: "USDT", + activateFee: "0", + transferFee: "500000", + decimal: 6, + frozen: 0, + }], + }, + })); + }, async gasfreeApi => { + await withServer((request, response) => { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact_gasfree", + network: "tron:0xcd8690dc", + amount: "1", + asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + payTo: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + maxTimeoutSeconds: 300, + extra: {}, + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + response.end(JSON.stringify(challenge)); + }, async gateway => { + const result = await runAsync([ + "pay", `${gateway}/pay`, "--json", + "--private-key", `0x${"01".repeat(32)}`, + "--gasfree-api-url", gasfreeApi, + "--max-gasfree-fee-raw", "499999", + ]); + assert.equal(result.status, 1, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.match(parsed.error.message, /estimated GasFree fee 500000 exceeds/); + }); + }); +}); + test("weighted catalog and gateway search support include-blocked and json output", () => { const dir = mkdtempSync(path.join(os.tmpdir(), "x402-cli-catalog-")); try { @@ -301,7 +540,7 @@ test("catalog export-gateway writes public catalog and pay docs", async () => { subtitle: "Alpha subtitle", description: "Alpha description", category: "finance", - chains: ["tron:nile"], + chains: ["tron:0xcd8690dc"], endpoints: [{ method: "GET", path: "/v1", url: "https://gateway.example/v1", metered: true, min_price_usd: 0.1 }], }; await withServer((request, response) => { @@ -331,7 +570,7 @@ test("remote catalog detail and pay files use escaped FQN filenames", async () = const detail = { fqn: "bankofai/demo", title: "Demo Detail", - endpoints: [{ method: "GET", path: "/v1", paid: { network: "tron:nile" } }], + endpoints: [{ method: "GET", path: "/v1", paid: { network: "tron:0xcd8690dc" } }], }; const payload = request.url === "/api/catalog.json" ? catalog : @@ -450,7 +689,7 @@ test("gateway check validates provider files", () => { writeFileSync(providerFile, `name: fixture-provider forward_url: https://api.example.com operator: - network: tron-nile + network: tron:0xcd8690dc recipient: TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i currencies: usd: ["USDT"] @@ -479,7 +718,7 @@ test("gateway check fails on missing provider environment variables", () => { writeFileSync(providerFile, `name: env-provider forward_url: \${MISSING_X402_TEST_FORWARD_URL} operator: - network: tron-nile + network: tron:0xcd8690dc recipient: TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i endpoints: - method: GET