From 22d0693ab2c741c2da82daf1352e096287682876 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Tue, 28 Jul 2026 10:40:31 +0800 Subject: [PATCH 1/5] feat: add Base USDC and Agent Wallet payments --- README.md | 31 ++- package-lock.json | 506 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 6 +- src/cli.ts | 50 +++-- src/help.ts | 6 +- src/http-client.ts | 2 +- src/output.ts | 17 +- src/tokens.ts | 20 ++ src/x402.ts | 174 +++++++++++----- tests/cli.test.mjs | 169 ++++++++++++++- 10 files changed, 899 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index c1cc473..a4c04ac 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ the npm TypeScript SDK packages only: - `@bankofai/x402-core@1.0.1` - `@bankofai/x402-evm@1.0.1` +- `@bankofai/x402-fetch@1.0.1` - `@bankofai/x402-tron@1.0.1` Stablecoin payments support `scheme=exact` and TRON `scheme=exact_gasfree`. @@ -16,7 +17,7 @@ from the payment token, so the payer does not need TRX. Install the CLI package: ```bash -npm install -g @bankofai/x402-cli@1.0.1 +npm install -g @bankofai/x402-cli@1.0.2 x402-cli --version ``` @@ -119,10 +120,32 @@ 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`. +By default, `x402-cli pay` resolves the active wallet from +`@bankofai/agent-wallet` for the selected payment network and delegates signing +to the wallet. The CLI does not read private keys from `wallets_config.json`. +Use `AGENT_WALLET_DIR` to select a non-default Agent Wallet directory, or +`AGENT_WALLET_ID` to explicitly select a configured wallet. + +For development and CI only, `--private-key`, `EVM_PRIVATE_KEY`, +`TRON_PRIVATE_KEY`, or `PRIVATE_KEY` can explicitly override Agent Wallet. Prefer environment variables over `--private-key` in shared environments, because command-line arguments may be visible to other local processes. +Pay a Base Sepolia USDC endpoint: + +```bash +x402-cli pay https://api.example.com/pay \ + --network base-sepolia \ + --token USDC \ + --max-amount 0.01 \ + --rpc-url +``` + +Base uses the x402 `exact` EVM flow with USDC EIP-3009 authorization. The +built-in public RPC fallback is intended for development; production callers +should supply `--rpc-url`, `EVM_RPC_URL_8453`/`EVM_RPC_URL_84532`, or +`EVM_RPC_URL`. + 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. @@ -149,6 +172,8 @@ Supported built-in token registry: - `tron:0x94a9059e` USDT - `eip155:56` USDT - `eip155:97` USDT, USDC +- `eip155:8453` USDC +- `eip155:84532` USDC Non-CAIP TRON aliases are rejected. Use the canonical TRON IDs above. @@ -156,6 +181,8 @@ EVM convenience aliases accepted: - `bsc-mainnet` -> `eip155:56` - `bsc-testnet` -> `eip155:97` +- `base-mainnet` -> `eip155:8453` +- `base-sepolia` -> `eip155:84532` ## Facilitator diff --git a/package-lock.json b/package-lock.json index 932f181..d2d3484 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1", + "version": "1.0.2-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1", + "version": "1.0.2-beta.1", "dependencies": { + "@bankofai/agent-wallet": "2.4.0", "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", - "@bankofai/x402-gateway": "1.0.1", + "@bankofai/x402-fetch": "1.0.1", + "@bankofai/x402-gateway": "1.0.2-beta.0", "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", "viem": "^2.55.0" @@ -45,6 +47,52 @@ "node": ">=6.9.0" } }, + "node_modules/@bankofai/agent-wallet": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@bankofai/agent-wallet/-/agent-wallet-2.4.0.tgz", + "integrity": "sha512-6btHL2TM88pZXYVf2Oav1WebVhU9mu+FZYur1meqGvnMrwSE6ibi9feUWXQtl2cOFH+2r+lH6kp7SavVuBlFXQ==", + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^8.3.0", + "@noble/curves": "^1.9.1", + "bs58check": "^4.0.0", + "viem": "^2.0.0", + "zod": "^3.22.0" + }, + "bin": { + "agent-wallet": "dist/delivery/bin.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@bankofai/agent-wallet/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@bankofai/agent-wallet/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@bankofai/x402-core": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.0.1.tgz", @@ -65,8 +113,19 @@ "zod": "^3.24.2" } }, - "node_modules/@bankofai/x402-gateway": { + "node_modules/@bankofai/x402-fetch": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-fetch/-/x402-fetch-1.0.1.tgz", + "integrity": "sha512-AS5gZjh4jB1PlLauKOBvM8jHjGNNqcPRIU/DnZDtcgNa72H2n7kjKUZD4H8xFr+yGmal5S/TxMzDXX43nmyf3A==", + "license": "Apache-2.0", + "dependencies": { + "@bankofai/x402-core": "~1.0.1" + } + }, + "node_modules/@bankofai/x402-gateway": { + "version": "1.0.2-beta.0", + "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.2-beta.0.tgz", + "integrity": "sha512-TxFkqL4A2e/kFSo3jXQ/lZ9dq7El5PeqcXVeZmv4fCQagD3oNJM/JBAbyZ3oC8cVwMg8b4/7wX4AZwBi5osSeA==", "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", @@ -532,6 +591,334 @@ "node": ">=18" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -608,7 +995,7 @@ "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -671,6 +1058,12 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, "node_modules/bignumber.js": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", @@ -680,6 +1073,25 @@ "node": "*" } }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/bs58check": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz", + "integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^6.0.0" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -693,6 +1105,21 @@ "node": ">= 0.4" } }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -917,6 +1344,30 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -1084,6 +1535,22 @@ "node": ">= 6" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/isows": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", @@ -1135,6 +1602,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/ox": { "version": "0.14.30", "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.30.tgz", @@ -1249,6 +1725,12 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", @@ -1261,6 +1743,18 @@ "node": ">=10" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tronweb": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tronweb/-/tronweb-6.4.0.tgz", @@ -1321,7 +1815,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/validator": { diff --git a/package.json b/package.json index 3601bf7..396fe87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1", + "version": "1.0.2-beta.1", "type": "module", "files": [ "dist" @@ -22,9 +22,11 @@ "node": ">=20" }, "dependencies": { + "@bankofai/agent-wallet": "2.4.0", "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", - "@bankofai/x402-gateway": "1.0.1", + "@bankofai/x402-fetch": "1.0.1", + "@bankofai/x402-gateway": "1.0.2-beta.0", "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", "viem": "^2.55.0" diff --git a/src/cli.ts b/src/cli.ts index 206e08a..ef5be70 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,8 @@ #!/usr/bin/env node import http from "node:http"; import { fileURLToPath } from "node:url"; -import { createPaymentPayload, decodeRequired, decodeResponse, decodeSignature, encodeRequired, encodeResponse, encodeSignature, headers, PaymentRequirement } from "./x402.js"; +import { wrapFetchWithPayment } from "@bankofai/x402-fetch"; +import { createPaymentClient, decodeRequired, decodeResponse, decodeSignature, encodeRequired, encodeResponse, headers, PaymentRequirement } from "./x402.js"; import { assertRawAmount, findTokenByAddress, getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js"; import { CliError, hasFlag, opt, optAll, outputMode, parseArgs, requireArgument, type ParsedOptions } from "./args.js"; import { classify, emit, withSdkStdoutRedirect } from "./output.js"; @@ -28,6 +29,10 @@ function buildRequirement(options: ParsedOptions): PaymentRequirement { throw new Error("When --asset is set without a registry match, --decimals must be provided"); } if (!explicitAsset && !registryToken) throw new Error(`unknown token ${tokenSymbol} on ${network}`); + const isBase = network === "eip155:8453" || network === "eip155:84532"; + if (isBase && !registryToken) { + throw new Error("Base support is currently limited to the official USDC contract"); + } const decimals = decimalsOption !== undefined ? Number(decimalsOption) : registryToken!.decimals; if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) throw new Error("--decimals must be an integer between 0 and 255"); const rawAmount = opt(options, "rawAmount") ?? opt(options, "raw-amount"); @@ -35,7 +40,16 @@ function buildRequirement(options: ParsedOptions): PaymentRequirement { 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 assetTransferMethod = + registryToken?.assetTransferMethod ?? (isBase ? undefined : "permit2"); + const extra = + scheme !== "exact" + ? {} + : assetTransferMethod + ? { assetTransferMethod } + : registryToken?.version + ? { name: registryToken.name, version: registryToken.version } + : {}; 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"); @@ -47,12 +61,14 @@ function buildRequirement(options: ParsedOptions): PaymentRequirement { asset: assetAddress, payTo: opt(options, "pay-to") ?? opt(options, "payTo") ?? "", maxTimeoutSeconds, - extra: scheme === "exact" && assetTransferMethod ? { assetTransferMethod } : {}, + extra, }; } async function facilitatorPost(baseUrl: string, path: string, body: unknown, options: ParsedOptions): Promise { - const response = await fetchWithTimeout(new URL(path, baseUrl), { + const base = new URL(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`); + const endpoint = new URL(path.replace(/^\/+/, ""), base); + const response = await fetchWithTimeout(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), @@ -293,7 +309,7 @@ async function pay(url: string, options: ParsedOptions): Promise { return; } const creation = await withSdkStdoutRedirect(outputMode(options) === "json", () => - createPaymentPayload({ + createPaymentClient({ selected, resource: required.resource?.url ?? url, extensions: required.extensions, @@ -303,13 +319,23 @@ async function pay(url: string, options: ParsedOptions): Promise { maxGasfreeFeeRaw, }), ); - const retryHeaders = new Headers(baseHeaders); - retryHeaders.set(headers.signature, encodeSignature(creation.payload)); - const paid = await fetchWithTimeout(url, { - method, - headers: retryHeaders, - body: ["GET", "HEAD"].includes(method.toUpperCase()) ? undefined : opt(options, "body"), - }, timeoutMs(options), `fetch ${url}`); + let cachedProbe: Response | undefined = probe; + const transport: typeof globalThis.fetch = async (input, init) => { + if (cachedProbe) { + const response = cachedProbe; + cachedProbe = undefined; + return response; + } + return fetchWithTimeout(input, init, timeoutMs(options), `fetch ${url}`); + }; + const fetchWithPayment = wrapFetchWithPayment(transport, creation.client); + const paid = await withSdkStdoutRedirect(outputMode(options) === "json", () => + fetchWithPayment(url, { + method, + headers: baseHeaders, + body: ["GET", "HEAD"].includes(method.toUpperCase()) ? undefined : opt(options, "body"), + }), + ); const body = await responsePayload(paid); const paymentResponse = paid.headers.get(headers.response); const settlement = paymentResponse ? decodeResponse(paymentResponse) : undefined; diff --git a/src/help.ts b/src/help.ts index f885da4..f0afec9 100644 --- a/src/help.ts +++ b/src/help.ts @@ -45,7 +45,7 @@ Options: --max-amount Maximum human-readable payment amount --max-raw-amount Maximum smallest-unit payment amount --dry-run Read requirements but do not sign or pay - --private-key Explicit payer private key (or PRIVATE_KEY/TRON_PRIVATE_KEY/EVM_PRIVATE_KEY) + --private-key Override Agent Wallet for development/CI --rpc-url Explicit network RPC URL --timeout-ms Network timeout in milliseconds (default: 30000) --json Print JSON envelope @@ -53,6 +53,9 @@ Options: Examples: x402-cli pay https://api.example.com/paid --dry-run --json x402-cli pay https://api.example.com/paid --max-amount 0.01 + x402-cli pay https://api.example.com/paid --network base-sepolia --token USDC + +By default, pay uses the active @bankofai/agent-wallet wallet for the selected network. `, serve: `Usage: x402-cli serve --pay-to
[options] @@ -168,4 +171,3 @@ Options: }; return sections[topic] ?? sections.root; } - diff --git a/src/http-client.ts b/src/http-client.ts index 3a1b38c..ef86f25 100644 --- a/src/http-client.ts +++ b/src/http-client.ts @@ -16,7 +16,7 @@ export function timeoutMs(options?: ParsedOptions): number { return options ? positiveIntegerOption(options, "timeout-ms", DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS; } -export async function fetchWithTimeout(input: string | URL, init: RequestInit = {}, timeout = DEFAULT_TIMEOUT_MS, label = "request"): Promise { +export async function fetchWithTimeout(input: RequestInfo | URL, init: RequestInit = {}, timeout = DEFAULT_TIMEOUT_MS, label = "request"): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { diff --git a/src/output.ts b/src/output.ts index ce9722a..dca2013 100644 --- a/src/output.ts +++ b/src/output.ts @@ -40,7 +40,7 @@ export function classify(error: unknown): FriendlyError { const message = error instanceof Error ? error.message : String(error); if (error instanceof CliError) return { code: error.code, message, hint: error.hint, details: error.details }; const lower = message.toLowerCase(); - if (lower.includes("missing private key") || lower.includes("could not find a wallet")) return { code: "WALLET_NOT_CONFIGURED", message, hint: "Set PRIVATE_KEY, TRON_PRIVATE_KEY, EVM_PRIVATE_KEY, or configure agent-wallet with a payer wallet." }; + if (lower.includes("missing private key") || lower.includes("could not find a wallet") || lower.includes("wallet not found")) return { code: "WALLET_NOT_CONFIGURED", message, hint: "Configure an active Agent Wallet for this network. For development/CI, use --private-key or the chain-specific private-key environment variable." }; if (lower.includes("wallets_config") || lower.includes("wallet config")) return { code: "WALLET_CONFIG_CORRUPT", message, hint: "Check ~/.agent-wallet/wallets_config.json or recreate the local agent-wallet configuration." }; if (lower.includes("does not exist") && lower.includes("account [t")) return { code: "TRON_ACCOUNT_NOT_ACTIVATED", message, hint: "Activate the TRON address by sending it a small amount of TRX before signing contract calls." }; if (lower.includes("permit2_insufficient_balance") || lower.includes("insufficient") && lower.includes("balance")) return { code: "INSUFFICIENT_TOKEN_BALANCE", message, hint: "Fund the payer address with the exact token and network advertised by the provider, then retry." }; @@ -61,8 +61,19 @@ export function classify(error: unknown): FriendlyError { export async function withSdkStdoutRedirect(enabled: boolean, fn: () => Promise): Promise { if (!enabled) return fn(); const originalLog = console.log; - console.log = (...args: unknown[]) => { + const originalInfo = console.info; + const originalDebug = console.debug; + const redirect = (...args: unknown[]) => { process.stderr.write(`${args.map(arg => typeof arg === "string" ? arg : JSON.stringify(arg, null, 2)).join(" ")}\n`); }; - try { return await fn(); } finally { console.log = originalLog; } + console.log = redirect; + console.info = redirect; + console.debug = redirect; + try { + return await fn(); + } finally { + console.log = originalLog; + console.info = originalInfo; + console.debug = originalDebug; + } } diff --git a/src/tokens.ts b/src/tokens.ts index 6947de6..f6872ec 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -81,6 +81,24 @@ export const TOKENS: Record> = { assetTransferMethod: "permit2", }, }, + "eip155:8453": { + USDC: { + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + decimals: 6, + name: "USD Coin", + symbol: "USDC", + version: "2", + }, + }, + "eip155:84532": { + USDC: { + address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + decimals: 6, + name: "USDC", + symbol: "USDC", + version: "2", + }, + }, }; export function normalizeNetwork(network: string): string { @@ -102,6 +120,8 @@ export function normalizeNetwork(network: string): string { return { "bsc-mainnet": "eip155:56", "bsc-testnet": "eip155:97", + "base-mainnet": "eip155:8453", + "base-sepolia": "eip155:84532", }[network] ?? network; } diff --git a/src/x402.ts b/src/x402.ts index 027d907..8db7e27 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -7,6 +7,7 @@ import { encodePaymentSignatureHeader, } from "@bankofai/x402-core/http"; import { x402Client } from "@bankofai/x402-core/client"; +import { resolveWallet, type Eip712Capable, type Wallet } from "@bankofai/agent-wallet"; import { ExactEvmScheme, toClientEvmSigner } from "@bankofai/x402-evm"; import { ExactTronScheme, createClientTronSigner } from "@bankofai/x402-tron"; import { ExactGasFreeTronScheme, createGasFreeApiClients, getGasFreeApiBaseUrl } from "@bankofai/x402-tron/gasfree"; @@ -14,9 +15,6 @@ import { createPublicClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { TronWeb } from "tronweb"; import { findTokenByAddress } from "./tokens.js"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; export type PaymentRequirement = { scheme: string; @@ -89,39 +87,30 @@ function normalizePrivateKey(value: string | undefined): `0x${string}` | undefin return (trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`) as `0x${string}`; } -function privateKeyFromAgentWallet(walletIds: string[]): `0x${string}` | undefined { - const configPath = process.env.AGENT_WALLET_CONFIG || - path.join(process.env.AGENT_WALLET_DIR || path.join(os.homedir(), ".agent-wallet"), "wallets_config.json"); - if (!fs.existsSync(configPath)) return undefined; - try { - const config = JSON.parse(fs.readFileSync(configPath, "utf8")); - const wallets = config.wallets ?? {}; - const ids = [ - process.env.AGENT_WALLET_ID, - config.activeWalletId, - ...walletIds, - ...Object.keys(wallets), - ].filter(Boolean); - for (const id of ids) { - const wallet = wallets[String(id)]; - const key = wallet?.params?.private_key ?? wallet?.material?.private_key ?? wallet?.private_key; - const normalized = normalizePrivateKey(key); - if (normalized) return normalized; - } - } catch { - return undefined; +function explicitPrivateKey(names: string[], explicit: string | undefined): `0x${string}` | undefined { + for (const value of [explicit, ...names.map(name => process.env[name])]) { + const normalized = normalizePrivateKey(value); + if (normalized) return normalized; } return undefined; } -function privateKeyFrom(names: string[], explicit: string | undefined, walletIds: string[]): `0x${string}` { - for (const value of [explicit, ...names.map(name => process.env[name])]) { - const normalized = normalizePrivateKey(value); - if (normalized) return normalized; +type SigningWallet = Wallet & Eip712Capable; + +async function activeAgentWallet(network: string): Promise { + const wallet = await resolveWallet({ + network, + ...(process.env.AGENT_WALLET_DIR ? { dir: process.env.AGENT_WALLET_DIR } : {}), + ...(process.env.AGENT_WALLET_ID ? { walletId: process.env.AGENT_WALLET_ID } : {}), + }); + if (!("signTypedData" in wallet) || typeof wallet.signTypedData !== "function") { + throw new Error(`active agent-wallet for ${network} does not support typed-data signing`); } - const walletKey = privateKeyFromAgentWallet(walletIds); - if (walletKey) return walletKey; - throw new Error(`missing private key; set one of ${names.join(", ")}`); + return wallet as SigningWallet; +} + +function prefixedHex(value: string): `0x${string}` { + return (value.startsWith("0x") ? value : `0x${value}`) as `0x${string}`; } function evmRpcUrl(network: string, explicit?: string): string | undefined { @@ -131,6 +120,8 @@ function evmRpcUrl(network: string, explicit?: string): string | undefined { process.env[`EVM_RPC_URL_${chainId}`] || process.env.RPC_URL || process.env.EVM_RPC_URL || + (chainId === "8453" ? "https://mainnet.base.org" : undefined) || + (chainId === "84532" ? "https://sepolia.base.org" : undefined) || (chainId === "56" ? "https://bsc-dataseed.binance.org" : undefined) || (chainId === "97" ? "https://data-seed-prebsc-1-s1.binance.org:8545" : undefined) ); @@ -166,7 +157,7 @@ export async function signTronTypedData(tronWeb: Pick, args: any return signer(args.domain, args.types, args.message, rawPrivateKey); } -export async function createPaymentPayload(args: { +export type CreatePaymentClientArgs = { selected: PaymentRequirement; resource: string; extensions?: Record; @@ -176,33 +167,39 @@ export async function createPaymentPayload(args: { allowanceMode?: string; gasfreeApiUrl?: string; maxGasfreeFeeRaw?: string; -}): Promise<{ payload: unknown; gasfreeEstimate?: { fee: string; total: string } }> { +}; + +export async function createPaymentClient( + args: CreatePaymentClientArgs, +): Promise<{ client: x402Client; 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, - ["evm_client", "payer", "default"], - ); - const account = privateKeyToAccount(privateKey); + const privateKey = explicitPrivateKey(["EVM_PRIVATE_KEY", "PRIVATE_KEY"], args.privateKey); const rpcUrl = evmRpcUrl(selected.network, args.rpcUrl); const publicClient = rpcUrl ? createPublicClient({ transport: http(rpcUrl) }) : undefined; - const signer = toClientEvmSigner(account, publicClient); + const signer = privateKey + ? toClientEvmSigner(privateKeyToAccount(privateKey), publicClient) + : toClientEvmSigner(await createAgentWalletEvmSigner(selected.network), publicClient); const scheme = new ExactEvmScheme(signer, rpcUrl ? { rpcUrl } : undefined); - const payload = await new x402Client() - .register(selected.network as `${string}:${string}`, scheme) - .createPaymentPayload(required as never); - return { payload }; + const client = new x402Client().register( + selected.network as `${string}:${string}`, + scheme, + ); + registerSelectedRequirementPolicy(client, selected); + return { client }; } if (selected.network.startsWith("tron:")) { - const privateKey = privateKeyFrom( - ["TRON_PRIVATE_KEY", "AGENT_WALLET_PRIVATE_KEY", "PRIVATE_KEY"], - args.privateKey, - ["tron_client", "payer", "default"], - ); - const wallet = await createTronWallet(privateKey, selected.scheme === "exact_gasfree" ? args.maxGasfreeFeeRaw : undefined); + const privateKey = explicitPrivateKey(["TRON_PRIVATE_KEY", "PRIVATE_KEY"], args.privateKey); + const wallet = privateKey + ? await createTronWallet( + privateKey, + selected.scheme === "exact_gasfree" ? args.maxGasfreeFeeRaw : undefined, + ) + : withGasfreeFeeGuard( + await activeAgentWallet(selected.network), + selected.scheme === "exact_gasfree" ? args.maxGasfreeFeeRaw : undefined, + ); const signer = await createClientTronSigner(wallet, { network: selected.network, rpcUrl: args.rpcUrl || process.env.TRON_RPC_URL, @@ -229,8 +226,79 @@ export async function createPaymentPayload(args: { } else { throw new Error(`unsupported scheme ${selected.scheme} on ${selected.network}`); } - const payload = await client.createPaymentPayload(required as never); - return { payload, ...(gasfreeEstimate ? { gasfreeEstimate } : {}) }; + registerSelectedRequirementPolicy(client, selected); + return { client, ...(gasfreeEstimate ? { gasfreeEstimate } : {}) }; } throw new Error(`unsupported network ${selected.network}`); } + +async function createAgentWalletEvmSigner(network: string) { + const wallet = await activeAgentWallet(network); + const address = await wallet.getAddress(); + if (!/^0x[0-9a-fA-F]{40}$/.test(address)) { + throw new Error(`active agent-wallet address is not valid for ${network}: ${address}`); + } + return { + address: address as `0x${string}`, + async signTypedData(data: { + domain: Record; + types: Record; + primaryType: string; + message: Record; + }) { + return prefixedHex(await wallet.signTypedData(data)); + }, + async signTransaction(transaction: Record) { + return prefixedHex(await wallet.signTransaction(transaction)); + }, + }; +} + +function withGasfreeFeeGuard(wallet: SigningWallet, maxGasfreeFeeRaw?: string) { + return { + getAddress: () => wallet.getAddress(), + async signTypedData(args: { + domain: Record; + types: Record; + primaryType: string; + message: Record; + }) { + if (maxGasfreeFeeRaw !== undefined && args.primaryType === "PermitTransfer") { + const maxFee = BigInt(args.message.maxFee as string | number | bigint ?? -1); + if (maxFee < 0n || maxFee > BigInt(maxGasfreeFeeRaw)) { + throw new Error(`final GasFree maxFee ${maxFee} exceeds --max-gasfree-fee limit ${maxGasfreeFeeRaw}`); + } + } + return prefixedHex(await wallet.signTypedData(args)); + }, + signTransaction: (transaction: Record) => wallet.signTransaction(transaction), + }; +} + +function registerSelectedRequirementPolicy(client: x402Client, selected: PaymentRequirement): void { + client.registerPolicy((_version, requirements) => + requirements.filter(requirement => + requirement.scheme === selected.scheme && + requirement.network === selected.network && + requirement.asset.toLowerCase() === selected.asset.toLowerCase() && + requirement.amount === selected.amount && + requirement.payTo.toLowerCase() === selected.payTo.toLowerCase() + ), + ); +} + +export async function createPaymentPayload( + args: CreatePaymentClientArgs, +): Promise<{ payload: unknown; gasfreeEstimate?: { fee: string; total: string } }> { + const creation = await createPaymentClient(args); + const required = paymentRequired( + ensurePermit2(args.selected), + args.resource, + args.extensions, + ); + const payload = await creation.client.createPaymentPayload(required as never); + return { + payload, + ...(creation.gasfreeEstimate ? { gasfreeEstimate: creation.gasfreeEstimate } : {}), + }; +} diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 6cbf73c..79b78e5 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -6,7 +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"; +import { getToken, normalizeNetwork } from "../dist/tokens.js"; const root = path.resolve(import.meta.dirname, ".."); const cli = path.join(root, "dist", "cli.js"); @@ -111,6 +111,172 @@ test("legacy TRON aliases are rejected in favor of canonical CAIP-2 IDs", () => assert.throws(() => normalizeNetwork("tron:shasta"), /use tron:0x94a9059e/); }); +test("Base aliases and USDC registry use canonical network data", () => { + assert.equal(normalizeNetwork("base-mainnet"), "eip155:8453"); + assert.equal(normalizeNetwork("base-sepolia"), "eip155:84532"); + assert.deepEqual(getToken("eip155:8453", "USDC"), { + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + decimals: 6, + name: "USD Coin", + symbol: "USDC", + version: "2", + }); + assert.deepEqual(getToken("base-sepolia", "usdc"), { + address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + decimals: 6, + name: "USDC", + symbol: "USDC", + version: "2", + }); +}); + +test("pay dry-run selects Base Sepolia USDC", async () => { + await withServer((request, response) => { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact", + network: "eip155:84532", + amount: "1000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + }], + }; + 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", "--network", "base-sepolia", "--token", "USDC", "--json", + ]); + assert.equal(result.status, 0, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.network, "eip155:84532"); + assert.equal(parsed.result.selected.amount, "1000"); + }); +}); + +test("pay uses the active Agent Wallet by default", async () => { + const walletDir = mkdtempSync(path.join(os.tmpdir(), "x402-cli-agent-wallet-")); + const privateKey = `0x${"01".repeat(32)}`; + writeJson(path.join(walletDir, "wallets_config.json"), { + active_wallet: "base-payer", + wallets: { + "base-payer": { + type: "raw_secret", + params: { source: "private_key", private_key: privateKey }, + }, + }, + }); + + let requests = 0; + let paymentSignature; + try { + 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:84532", + amount: "1", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + extra: { name: "USDC", version: "2" }, + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + return response.end(JSON.stringify(challenge)); + } + + paymentSignature = request.headers["payment-signature"]; + response.writeHead(200, { + "content-type": "application/json", + "PAYMENT-RESPONSE": Buffer.from(JSON.stringify({ + success: true, + transaction: "agent-wallet-test", + network: "eip155:84532", + })).toString("base64"), + }); + response.end(JSON.stringify({ ok: true })); + }, async base => { + const result = await runAsync( + ["pay", `${base}/pay`, "--network", "base-sepolia", "--token", "USDC", "--json"], + { + env: { + AGENT_WALLET_DIR: walletDir, + AGENT_WALLET_ID: undefined, + AGENT_WALLET_PRIVATE_KEY: undefined, + EVM_PRIVATE_KEY: undefined, + PRIVATE_KEY: undefined, + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.result.paid, true); + assert.equal(parsed.result.transaction, "agent-wallet-test"); + }); + + assert.equal(requests, 2); + assert.equal(typeof paymentSignature, "string"); + const payload = JSON.parse(Buffer.from(paymentSignature, "base64").toString("utf8")); + assert.match(payload.payload.signature, /^0x[0-9a-f]{130}$/i); + } finally { + rmSync(walletDir, { recursive: true, force: true }); + } +}); + +test("serve advertises Base USDC exact with EIP-712 domain metadata", async () => { + const port = 48000 + Math.floor(Math.random() * 1000); + const started = run([ + "serve", + "--pay-to", "0x0000000000000000000000000000000000000001", + "--amount", "0.001", + "--network", "base-sepolia", + "--token", "USDC", + "--port", String(port), + "--daemon", + "--json", + ]); + assert.equal(started.status, 0, started.stderr); + const pid = JSON.parse(started.stdout).result.pid; + try { + for (let i = 0; i < 20; i += 1) { + try { + const response = await fetch(`http://127.0.0.1:${port}/pay`); + assert.equal(response.status, 402); + const required = JSON.parse( + Buffer.from(response.headers.get("payment-required"), "base64").toString("utf8"), + ); + assert.equal(required.accepts[0].network, "eip155:84532"); + assert.equal(required.accepts[0].asset, "0x036CbD53842c5426634e7929541eC2318f3dCF7e"); + assert.deepEqual(required.accepts[0].extra, { name: "USDC", version: "2" }); + break; + } catch (error) { + if (i === 19) throw error; + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + } finally { + try { + process.kill(pid); + } catch { + // Daemon may already have exited. + } + } +}); + test("serve advertises exact_gasfree and rejects it on EVM", async () => { const port = 47000 + Math.floor(Math.random() * 1000); const started = run([ @@ -284,6 +450,7 @@ test("pay preserves settlement details from a failed paid response", async () => assert.equal(parsed.error.details.delivered, false); assert.equal(parsed.error.details.transaction, "settled-transaction"); assert.equal(parsed.error.details.paymentResponse.transaction, "settled-transaction"); + assert.equal(requests, 2); }); }); From 0ae26812bec393464f2936ca1ceabaab2b451eec Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Wed, 29 Jul 2026 10:53:25 +0800 Subject: [PATCH 2/5] fix: harden CLI payment and command contracts --- README.md | 37 +++- src/args.ts | 7 +- src/catalog-commands.ts | 7 +- src/cli.ts | 300 ++++++++++++++++++++++++++------ src/daemon.ts | 7 +- src/help.ts | 35 +++- src/output.ts | 61 ++++++- src/tokens.ts | 29 +++- src/x402.ts | 39 ++++- tests/cli.test.mjs | 370 +++++++++++++++++++++++++++++++++++++++- 10 files changed, 808 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index a4c04ac..83e8234 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,9 @@ x402-cli pay http://127.0.0.1:4020/pay \ For automated or unfamiliar endpoints, set `--max-amount` or `--max-raw-amount` before allowing the CLI to sign a payment. +Registered token decimals are authoritative and cannot be overridden with +`--decimals`. For an explicit unregistered non-Base asset, pass both `--asset` +and `--decimals`. Pay a TRON GasFree endpoint (the CLI normally selects this automatically from the server challenge): @@ -131,11 +134,11 @@ For development and CI only, `--private-key`, `EVM_PRIVATE_KEY`, Prefer environment variables over `--private-key` in shared environments, because command-line arguments may be visible to other local processes. -Pay a Base Sepolia USDC endpoint: +Pay a Base Mainnet USDC endpoint: ```bash x402-cli pay https://api.example.com/pay \ - --network base-sepolia \ + --network base-mainnet \ --token USDC \ --max-amount 0.01 \ --rpc-url @@ -146,6 +149,10 @@ built-in public RPC fallback is intended for development; production callers should supply `--rpc-url`, `EVM_RPC_URL_8453`/`EVM_RPC_URL_84532`, or `EVM_RPC_URL`. +The probe and signed retry do not automatically follow HTTP redirects. If an +endpoint redirects, inspect the destination and invoke the final trusted URL +explicitly so `PAYMENT-SIGNATURE` is never forwarded to another origin. + 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. @@ -163,6 +170,32 @@ x402-cli roundtrip \ --token USDT ``` +With `--json`, roundtrip emits one JSON document containing separate `serve` +and `pay` results. + +### Gateway and Catalog + +Inspect and validate local Gateway providers: + +```bash +x402-cli gateway check ./providers --json +x402-cli gateway catalog build ./providers --json +x402-cli gateway catalog search "token price" --catalog ./dist/catalog.json --json +``` + +Search and cache a hosted or local Catalog: + +```bash +x402-cli catalog search "Base USDC" --json +x402-cli catalog update --catalog https://catalog.example/api/catalog.json --json +x402-cli catalog show defillama --json +x402-cli catalog endpoints defillama --json +x402-cli catalog pay-json defillama --json +``` + +Use `x402-cli gateway --help`, `x402-cli gateway catalog --help`, and +`x402-cli catalog --help` for command-specific options. + ## Networks Supported built-in token registry: diff --git a/src/args.ts b/src/args.ts index bc2ebee..2fbd140 100644 --- a/src/args.ts +++ b/src/args.ts @@ -37,7 +37,12 @@ export function parseArgs(argv: string[]): { command: string; positional: string const key = eq > 2 ? item.slice(2, eq) : item.slice(2); const inline = eq > 2 ? item.slice(eq + 1) : undefined; const next = rest[i + 1]; - if (inline !== undefined) options[key] = inline; + if (inline !== undefined) { + if (key === "header") { + const current = options[key]; + options[key] = Array.isArray(current) ? [...current, inline] : current ? [String(current), inline] : [inline]; + } else options[key] = inline; + } else if (BOOLEAN_FLAGS.has(key)) options[key] = true; else if (!next || next.startsWith("--")) { throw new CliError("MISSING_ARGUMENT", `--${key} requires a value`, `Pass --${key} .`, 2); diff --git a/src/catalog-commands.ts b/src/catalog-commands.ts index 772598c..4e0fbc6 100644 --- a/src/catalog-commands.ts +++ b/src/catalog-commands.ts @@ -569,7 +569,12 @@ async function catalogPayJson(source: string, name: string, options: ParsedOptio export async function handleCatalog(args: string[]): Promise { const { command, positional, options } = parseArgs(args); if (hasFlag(options, "help") || ["help", "--help", "-h"].includes(command)) { - const topic = command === "help" ? positional[0] : command; + const topic = + command === "help" + ? positional[0] + : command === "--help" || command === "-h" + ? undefined + : command; process.stdout.write(helpText(topic ? `catalog-${topic}` : "catalog")); return; } diff --git a/src/cli.ts b/src/cli.ts index ef5be70..a4e0442 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,42 +3,105 @@ import http from "node:http"; import { fileURLToPath } from "node:url"; import { wrapFetchWithPayment } from "@bankofai/x402-fetch"; import { createPaymentClient, decodeRequired, decodeResponse, decodeSignature, encodeRequired, encodeResponse, headers, PaymentRequirement } from "./x402.js"; -import { assertRawAmount, findTokenByAddress, getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js"; +import { addressesEqual, assertRawAmount, findTokenByAddress, getToken, normalizeAddress, normalizeNetwork, toSmallestUnit, type TokenInfo } from "./tokens.js"; import { CliError, hasFlag, opt, optAll, outputMode, parseArgs, requireArgument, type ParsedOptions } from "./args.js"; -import { classify, emit, withSdkStdoutRedirect } from "./output.js"; -import { fetchWithTimeout, readBoundedText, responsePayload, timeoutMs } from "./http-client.js"; +import { beginEmitCapture, classify, emit, setInvocationCommand, withSdkStdoutRedirect } from "./output.js"; +import { fetchWithTimeout, positiveIntegerOption, readBoundedText, responsePayload, timeoutMs } from "./http-client.js"; import { startServeDaemon } from "./daemon.js"; import { getVersion, helpText } from "./help.js"; import { catalogBuild, catalogPayAssets, gatewayCheck, gatewayScaffold, gatewayStart } from "./gateway-commands.js"; import { catalogSearch, defaultCatalogSource, handleCatalog } from "./catalog-commands.js"; +function invalidArgument(message: string, hint = "Run the command with --help to see valid options."): CliError { + return new CliError("INVALID_ARGUMENT", message, hint, 2); +} + +function normalizeNetworkOption(value: string): string { + try { + return normalizeNetwork(value); + } catch (error) { + throw invalidArgument(error instanceof Error ? error.message : String(error)); + } +} + +function resolveDecimals(token: TokenInfo | undefined, decimalsOption: string | undefined): number { + let supplied: number | undefined; + if (decimalsOption !== undefined) { + supplied = Number(decimalsOption); + if (!Number.isInteger(supplied) || supplied < 0 || supplied > 255) { + throw invalidArgument("--decimals must be an integer between 0 and 255"); + } + } + if (token) { + if (supplied !== undefined && supplied !== token.decimals) { + throw invalidArgument( + `--decimals ${supplied} does not match registered ${token.symbol} decimals ${token.decimals}`, + "Remove --decimals or pass the registered token decimals.", + ); + } + return token.decimals; + } + if (supplied === undefined) { + throw invalidArgument( + "an unregistered asset requires --decimals", + "Pass --decimals for the explicit --asset, or use a registered token.", + ); + } + return supplied; +} + +function rawAmountOption(value: string, name: string): string { + try { + return assertRawAmount(value, name); + } catch (error) { + throw invalidArgument(error instanceof Error ? error.message : String(error)); + } +} + +function humanAmountOption(value: string, decimals: number, name: string): string { + try { + return toSmallestUnit(value, decimals); + } catch (error) { + throw invalidArgument(`${name}: ${error instanceof Error ? error.message : String(error)}`); + } +} + function buildRequirement(options: ParsedOptions): PaymentRequirement { - const network = normalizeNetwork(opt(options, "network", "tron:0xcd8690dc")!); + const network = normalizeNetworkOption(opt(options, "network", "tron:0xcd8690dc")!); const scheme = opt(options, "scheme", "exact")!; - if (!["exact", "exact_gasfree"].includes(scheme)) throw new Error(`unsupported scheme ${scheme}`); + if (!["exact", "exact_gasfree"].includes(scheme)) throw invalidArgument(`unsupported scheme ${scheme}`); if (scheme === "exact_gasfree" && !network.startsWith("tron:")) { - throw new Error("exact_gasfree is supported only on TRON networks"); + throw invalidArgument("exact_gasfree is supported only on TRON networks"); } const tokenSymbol = opt(options, "token", "USDT")!; const explicitAsset = opt(options, "asset"); - const registryToken = explicitAsset - ? findTokenByAddress(network, explicitAsset) - : getToken(network, tokenSymbol); + if (explicitAsset && !normalizeAddress(network, explicitAsset)) { + throw invalidArgument(`invalid --asset address for ${network}`); + } + let registryToken: TokenInfo | undefined; + try { + registryToken = explicitAsset + ? findTokenByAddress(network, explicitAsset) + : getToken(network, tokenSymbol); + } catch (error) { + throw invalidArgument(error instanceof Error ? error.message : String(error)); + } const decimalsOption = opt(options, "decimals"); if (explicitAsset && !registryToken && decimalsOption === undefined) { - throw new Error("When --asset is set without a registry match, --decimals must be provided"); + throw invalidArgument("When --asset is set without a registry match, --decimals must be provided"); } - if (!explicitAsset && !registryToken) throw new Error(`unknown token ${tokenSymbol} on ${network}`); + if (!explicitAsset && !registryToken) throw invalidArgument(`unknown token ${tokenSymbol} on ${network}`); const isBase = network === "eip155:8453" || network === "eip155:84532"; if (isBase && !registryToken) { - throw new Error("Base support is currently limited to the official USDC contract"); + throw invalidArgument("Base support is currently limited to the official USDC contract"); } - const decimals = decimalsOption !== undefined ? Number(decimalsOption) : registryToken!.decimals; - if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) throw new Error("--decimals must be an integer between 0 and 255"); - const rawAmount = opt(options, "rawAmount") ?? opt(options, "raw-amount"); + const decimals = resolveDecimals(registryToken, decimalsOption); + const 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 amount = rawAmount + ? rawAmountOption(rawAmount, "--raw-amount") + : humanAmountOption(humanAmount ?? "0.0001", decimals, "--amount"); const assetAddress = explicitAsset ?? registryToken!.address; const assetTransferMethod = registryToken?.assetTransferMethod ?? (isBase ? undefined : "permit2"); @@ -52,14 +115,18 @@ function buildRequirement(options: ParsedOptions): PaymentRequirement { : {}; 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"); + throw invalidArgument("--valid-for-seconds must be an integer between 1 and 86400"); + } + const payTo = opt(options, "pay-to") ?? ""; + if (payTo && !normalizeAddress(network, payTo)) { + throw invalidArgument(`invalid --pay-to address for ${network}`); } return { scheme, network, amount, asset: assetAddress, - payTo: opt(options, "pay-to") ?? opt(options, "payTo") ?? "", + payTo, maxTimeoutSeconds, extra, }; @@ -84,28 +151,33 @@ function requestHeaders(options: ParsedOptions): Headers { const headersOut = new Headers(); for (const header of optAll(options, "header")) { const idx = header.indexOf(":"); - if (idx <= 0) throw new Error(`invalid --header '${header}', expected 'Name: Value'`); + if (idx <= 0) throw invalidArgument(`invalid --header '${header}', expected 'Name: Value'`); headersOut.set(header.slice(0, idx).trim(), header.slice(idx + 1).trim()); } return headersOut; } function validateAmountLimits(selected: PaymentRequirement, options: ParsedOptions): void { - const maxRaw = opt(options, "max-rawAmount") ?? opt(options, "max-raw-amount"); + const maxRaw = opt(options, "max-raw-amount"); const maxAmount = opt(options, "max-amount"); - if (maxRaw && BigInt(selected.amount) > BigInt(assertRawAmount(maxRaw, "--max-raw-amount"))) { - throw new Error(`payment raw amount ${selected.amount} exceeds --max-raw-amount ${maxRaw}`); + if (maxRaw && BigInt(selected.amount) > BigInt(rawAmountOption(maxRaw, "--max-raw-amount"))) { + throw new CliError( + "PAYMENT_AMOUNT_TOO_HIGH", + `payment raw amount ${selected.amount} exceeds --max-raw-amount ${maxRaw}`, + "Increase the max raw amount only if this provider price is expected.", + 1, + ); } if (maxAmount) { const token = findTokenByAddress(selected.network, selected.asset); - const decimalsOption = opt(options, "decimals"); - if (!token && decimalsOption === undefined) { - 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 || 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}`); + const decimals = resolveDecimals(token, opt(options, "decimals")); + if (BigInt(selected.amount) > BigInt(humanAmountOption(maxAmount, decimals, "--max-amount"))) { + throw new CliError( + "PAYMENT_AMOUNT_TOO_HIGH", + `payment amount exceeds --max-amount ${maxAmount}`, + "Increase the max amount only if this provider price is expected.", + 1, + ); } } } @@ -117,28 +189,94 @@ function gasfreeFeeLimitRaw(selected: PaymentRequirement, options: ParsedOptions 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"); + if (maxRaw || maxHuman) throw invalidArgument("GasFree fee limits require an exact_gasfree payment requirement"); return undefined; } - if (maxRaw) return assertRawAmount(maxRaw, "--max-gasfree-fee-raw"); + if (maxRaw) return rawAmountOption(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 = resolveDecimals(token, opt(options, "decimals")); + return humanAmountOption(maxHuman, decimals, "--max-gasfree-fee"); +} + +function invalidRequirement(message: string): CliError { + return new CliError( + "INVALID_PAYMENT_REQUIREMENT", + message, + "The server returned an x402 requirement that this CLI cannot safely sign.", + 1, + ); +} + +function validateSelectedRequirement(selected: PaymentRequirement, resource: string): void { + if (!selected || !["exact", "exact_gasfree"].includes(selected.scheme)) { + throw invalidRequirement("unsupported or missing payment scheme"); + } + if (typeof selected.network !== "string" || (!selected.network.startsWith("eip155:") && !selected.network.startsWith("tron:"))) { + throw invalidRequirement("unsupported or missing payment network"); + } + try { + assertRawAmount(selected.amount, "payment amount"); + } catch (error) { + throw invalidRequirement(error instanceof Error ? error.message : String(error)); + } + if (!normalizeAddress(selected.network, selected.asset)) { + throw invalidRequirement(`invalid asset address for ${selected.network}`); + } + if (!normalizeAddress(selected.network, selected.payTo)) { + throw invalidRequirement(`invalid payTo address for ${selected.network}`); + } + if (!Number.isInteger(selected.maxTimeoutSeconds) || selected.maxTimeoutSeconds! <= 0 || selected.maxTimeoutSeconds! > 86400) { + throw invalidRequirement("maxTimeoutSeconds must be an integer between 1 and 86400"); + } + try { + const parsed = new URL(resource); + if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("unsupported protocol"); + } catch { + throw invalidRequirement("resource URL must be an absolute HTTP(S) URL"); + } + if (selected.scheme === "exact_gasfree") { + if (!selected.network.startsWith("tron:")) { + throw invalidRequirement("exact_gasfree is supported only on TRON networks"); + } + return; + } + const extra = selected.extra; + if (!extra || typeof extra !== "object") { + throw invalidRequirement("exact payment requirement is missing scheme metadata"); + } + const transferMethod = extra.assetTransferMethod; + if (transferMethod === "permit2") return; + if (transferMethod !== undefined) { + throw invalidRequirement(`unsupported assetTransferMethod ${String(transferMethod)}`); + } + if (selected.network.startsWith("eip155:")) { + const token = findTokenByAddress(selected.network, selected.asset); + if (typeof extra.name !== "string" || !extra.name || typeof extra.version !== "string" || !extra.version) { + throw invalidRequirement("EIP-3009 requirement is missing extra.name or extra.version"); + } + if (token && (extra.name !== token.name || extra.version !== token.version)) { + throw invalidRequirement("EIP-3009 domain metadata does not match the registered token"); + } + return; } - 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); + throw invalidRequirement("TRON exact requirement must declare Permit2"); } async function serve(options: ParsedOptions): Promise { const host = opt(options, "host", "127.0.0.1")!; - const port = Number(opt(options, "port", "4020")); + const port = positiveIntegerOption(options, "port", 4020); const facilitatorUrl = opt(options, "facilitator-url", "https://facilitator.bankofai.io")!; const requirement = buildRequirement(options); - if (!requirement.payTo) throw new Error("--pay-to is required"); + if (!requirement.payTo) throw new CliError("MISSING_ARGUMENT", "--pay-to is required", "Pass --pay-to .", 2); const resourceUrl = opt(options, "resource-url", `http://${host}:${port}/pay`)!; + try { + const parsed = new URL(resourceUrl); + if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("unsupported protocol"); + } catch { + throw invalidArgument("--resource-url must be an absolute HTTP(S) URL"); + } + validateSelectedRequirement(requirement, resourceUrl); const challenge = { x402Version: 2, error: "Payment required", @@ -226,17 +364,25 @@ async function serve(options: ParsedOptions): Promise { function selectRequirement(accepts: PaymentRequirement[], options: ParsedOptions): PaymentRequirement { const network = opt(options, "network"); + const requiredNetwork = network ? normalizeNetworkOption(network) : undefined; const scheme = opt(options, "scheme"); const token = opt(options, "token"); + const explicitAsset = opt(options, "asset"); + const decimals = opt(options, "decimals"); 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; + let registryToken; try { - if (!findTokenByAddress(req.network, req.asset)) return false; + registryToken = findTokenByAddress(req.network, req.asset); } catch { return false; } - if (network && normalizeNetwork(network) !== req.network) return false; + if (!registryToken) { + if (!explicitAsset || decimals === undefined || !addressesEqual(req.network, explicitAsset, req.asset)) return false; + if (req.network === "eip155:8453" || req.network === "eip155:84532") return false; + } + if (requiredNetwork && requiredNetwork !== req.network) return false; if (scheme && scheme !== req.scheme) return false; if (token) { let tokenInfo; @@ -245,7 +391,7 @@ function selectRequirement(accepts: PaymentRequirement[], options: ParsedOptions } catch { return false; } - if (tokenInfo.address.toLowerCase() !== req.asset.toLowerCase()) return false; + if (!addressesEqual(req.network, tokenInfo.address, req.asset)) return false; } return true; }); @@ -259,11 +405,24 @@ async function pay(url: string, options: ParsedOptions): Promise { 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 scheme = opt(options, "scheme"); + if (scheme && !["exact", "exact_gasfree"].includes(scheme)) { + throw invalidArgument(`unsupported scheme ${scheme}`); + } + const network = opt(options, "network"); + const normalizedNetwork = network ? normalizeNetworkOption(network) : undefined; + const decimals = opt(options, "decimals"); + if (decimals !== undefined) resolveDecimals(undefined, decimals); + const asset = opt(options, "asset"); + if (asset && normalizedNetwork && !normalizeAddress(normalizedNetwork, asset)) { + throw invalidArgument(`invalid --asset address for ${normalizedNetwork}`); + } const baseHeaders = requestHeaders(options); const probe = await fetchWithTimeout(url, { method, headers: baseHeaders, body: ["GET", "HEAD"].includes(method.toUpperCase()) ? undefined : opt(options, "body"), + redirect: "manual", }, timeoutMs(options), `fetch ${url}`); if (probe.status !== 402) { const body = await responsePayload(probe); @@ -291,6 +450,9 @@ async function pay(url: string, options: ParsedOptions): Promise { if (!header) throw new Error("402 response missing PAYMENT-REQUIRED header"); const required = decodeRequired(header); const selected = selectRequirement(required.accepts ?? [], options); + const resource = required.resource?.url ?? url; + validateSelectedRequirement(selected, resource); + resolveDecimals(findTokenByAddress(selected.network, selected.asset), opt(options, "decimals")); validateAmountLimits(selected, options); const maxGasfreeFeeRaw = gasfreeFeeLimitRaw(selected, options); if (options["dry-run"]) { @@ -301,7 +463,7 @@ async function pay(url: string, options: ParsedOptions): Promise { mode: outputMode(options), result: { url, - resource: required.resource?.url ?? url, + resource, selected, message: "Dry run - no payment submitted", }, @@ -311,7 +473,7 @@ async function pay(url: string, options: ParsedOptions): Promise { const creation = await withSdkStdoutRedirect(outputMode(options) === "json", () => createPaymentClient({ selected, - resource: required.resource?.url ?? url, + resource, extensions: required.extensions, rpcUrl: opt(options, "rpc-url"), privateKey: opt(options, "private-key"), @@ -326,7 +488,7 @@ async function pay(url: string, options: ParsedOptions): Promise { cachedProbe = undefined; return response; } - return fetchWithTimeout(input, init, timeoutMs(options), `fetch ${url}`); + return fetchWithTimeout(input, { ...init, redirect: "manual" }, timeoutMs(options), `fetch ${url}`); }; const fetchWithPayment = wrapFetchWithPayment(transport, creation.client); const paid = await withSdkStdoutRedirect(outputMode(options) === "json", () => @@ -380,9 +542,30 @@ async function pay(url: string, options: ParsedOptions): Promise { } async function roundtrip(options: ParsedOptions): Promise { - const port = Number(opt(options, "port", "4020")); - await serve(options); - await pay(`http://127.0.0.1:${port}/pay`, options); + const port = positiveIntegerOption(options, "port", 4020); + if (outputMode(options) !== "json") { + await serve(options); + await pay(`http://127.0.0.1:${port}/pay`, options); + process.exit(0); + } + const capture = beginEmitCapture(); + let events; + try { + await serve(options); + await pay(`http://127.0.0.1:${port}/pay`, options); + events = capture.finish(); + } catch (error) { + capture.finish(); + throw error; + } + emit({ + command: "roundtrip", + mode: "json", + result: { + serve: events.find(event => event.component === "server")?.result ?? null, + pay: events.find(event => event.component === "client")?.result ?? null, + }, + }); process.exit(0); } @@ -406,12 +589,13 @@ async function handleGatewayCatalog(positional: string[], options: ParsedOptions if (sub === "build") catalogBuild(target, options); else if (sub === "check") gatewayCheck(target, options); else if (sub === "pay-assets") catalogPayAssets(target, options); - else if (sub === "search") await catalogSearch(opt(options, "catalog", defaultCatalogSource())!, requireArgument(positional.slice(2).join(" ") || opt(options, "query"), "query", "x402-cli gateway catalog search [options]"), options); + else if (sub === "search") await catalogSearch(opt(options, "catalog", defaultCatalogSource())!, requireArgument(positional.slice(1).join(" ") || opt(options, "query"), "query", "x402-cli gateway catalog search [options]"), options); else throw new CliError("UNKNOWN_COMMAND", `Unknown gateway catalog command: ${sub}`, "Run x402-cli gateway catalog --help to list commands.", 2); } async function main(): Promise { const argv = process.argv.slice(2); + setInvocationCommand(invocationCommandName(argv)); const { command, positional, options } = parseArgs(argv); if (hasFlag(options, "help") && command === "gateway") { await handleGateway(argv.slice(1)); @@ -443,17 +627,21 @@ async function main(): Promise { } } -function errorCommandName(argv: string[]): string { - const [first, second] = argv; - if ((first === "catalog" || first === "gateway") && second && !second.startsWith("-")) return `${first} ${second}`; +function invocationCommandName(argv: string[]): string { + const positional = argv.filter(item => !item.startsWith("-")); + const [first, second, third] = positional; + if (first === "gateway" && second === "catalog" && third) return `${first} ${second} ${third}`; + if ((first === "catalog" || first === "gateway") && second) return `${first} ${second}`; return first ?? "x402-cli"; } main().catch(error => { + const friendly = classify(error); emit({ - command: errorCommandName(process.argv.slice(2)), + command: invocationCommandName(process.argv.slice(2)), mode: process.argv.includes("--json") ? "json" : "human", - error: classify(error), + error: friendly, }); - process.exit(error instanceof CliError ? error.exitCode : 1); + const usageCodes = new Set(["INVALID_ARGUMENT", "MISSING_ARGUMENT", "UNKNOWN_COMMAND"]); + process.exit(error instanceof CliError ? error.exitCode : usageCodes.has(friendly.code) ? 2 : 1); }); diff --git a/src/daemon.ts b/src/daemon.ts index a90a998..f6fc60b 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1,7 +1,8 @@ import net from "node:net"; import { spawn } from "node:child_process"; import { setTimeout as delay } from "node:timers/promises"; -import { opt, outputMode, type ParsedOptions } from "./args.js"; +import { CliError, opt, outputMode, type ParsedOptions } from "./args.js"; +import { positiveIntegerOption } from "./http-client.js"; import { emit } from "./output.js"; import type { PaymentRequirement } from "./x402.js"; @@ -26,12 +27,12 @@ async function waitForPort(host: string, port: number, timeout = 5_000): Promise } export async function startServeDaemon(argv: string[], options: ParsedOptions, requirement: PaymentRequirement, script: string): Promise { - if (!requirement.payTo) throw new Error("--pay-to is required"); + if (!requirement.payTo) throw new CliError("MISSING_ARGUMENT", "--pay-to is required", "Pass --pay-to .", 2); const daemonArgs = stripFlag(stripFlag(argv, "--daemon"), "-d"); const child = spawn(process.execPath, [script, ...daemonArgs], { detached: true, stdio: "ignore", env: process.env }); child.unref(); const host = opt(options, "host", "127.0.0.1")!; - const port = Number(opt(options, "port", "4020")); + const port = positiveIntegerOption(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); diff --git a/src/help.ts b/src/help.ts index f0afec9..30266c1 100644 --- a/src/help.ts +++ b/src/help.ts @@ -38,6 +38,8 @@ Options: --body Request body for non-GET/HEAD methods --network Require a specific network --token Require a specific token + --asset
Require a specific asset address + --decimals Decimals for an unregistered explicit asset --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 @@ -53,9 +55,10 @@ Options: Examples: x402-cli pay https://api.example.com/paid --dry-run --json x402-cli pay https://api.example.com/paid --max-amount 0.01 - x402-cli pay https://api.example.com/paid --network base-sepolia --token USDC + x402-cli pay https://api.example.com/paid --network base-mainnet --token USDC By default, pay uses the active @bankofai/agent-wallet wallet for the selected network. +Paid requests do not follow HTTP redirects; retry against the final trusted URL explicitly. `, serve: `Usage: x402-cli serve --pay-to
[options] @@ -73,6 +76,7 @@ Options: --port Bind port (default: 4020) --resource-url URL advertised in payment requirements --facilitator-url Facilitator base URL + --valid-for-seconds Payment requirement validity (default: 300) --timeout-ms Facilitator timeout in milliseconds (default: 30000) --daemon Run in background and print the child pid --json Print JSON envelope @@ -93,6 +97,14 @@ Commands: check Validate provider.yml files scaffold Write a starter provider.yml catalog Build/check/search gateway catalog assets + +Options: + --catalog Catalog path or URL for search + --providers Provider directory for start/check + --host Gateway bind host + --port Gateway bind port + --gateway-bin Explicit x402-gateway executable + --json Print JSON envelope `, "gateway-catalog": `Usage: x402-cli gateway catalog [options] @@ -102,6 +114,11 @@ Commands: check Validate local provider.yml files pay-assets List payable endpoint assets search Search a catalog artifact + +Options: + --catalog Catalog path or URL for search + --providers Provider directory + --json Print JSON envelope `, catalog: `Usage: x402-cli catalog [options] @@ -133,6 +150,22 @@ Options: --timeout-ms Network timeout in milliseconds (default: 30000) --include-blocked Include blocked providers in search --json Print JSON envelope +`, + "catalog-update": `Usage: + x402-cli catalog update [--catalog ] [options] + +Options: + --catalog Catalog path or URL + --timeout-ms Network timeout in milliseconds (default: 30000) + --json Print JSON envelope +`, + "catalog-build": `Usage: + x402-cli catalog build [options] + +Options: + --output Write catalog JSON to a file + --dist-dir Write catalog.json under a directory + --json Print JSON envelope `, "catalog-show": `Usage: x402-cli catalog show [--catalog ] [options] diff --git a/src/output.ts b/src/output.ts index dca2013..8ac5da4 100644 --- a/src/output.ts +++ b/src/output.ts @@ -1,6 +1,45 @@ +import { + DecryptionError, + NetworkError as WalletNetworkError, + PrivyAuthError, + PrivyConfigError, + PrivyRateLimitError, + SigningError, + UnsupportedOperationError, + WalletError, + WalletNotFoundError, +} from "@bankofai/agent-wallet"; import { CliError, type OutputMode } from "./args.js"; export type FriendlyError = { code: string; message: string; hint: string; details?: unknown }; +export type OutputEnvelope = { + ok: boolean; + command: string; + component?: string; + network?: string; + scheme?: string; + result?: unknown; + error?: FriendlyError; +}; + +let invocationCommand: string | undefined; +let emitCapture: OutputEnvelope[] | undefined; + +export function setInvocationCommand(command: string): void { + invocationCommand = command; +} + +export function beginEmitCapture(): { finish: () => OutputEnvelope[] } { + if (emitCapture) throw new Error("output capture is already active"); + emitCapture = []; + return { + finish() { + const captured = emitCapture ?? []; + emitCapture = undefined; + return captured; + }, + }; +} export function printJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); @@ -10,24 +49,30 @@ export function emit(args: { command: string; result?: any; error?: FriendlyError; network?: string; scheme?: string; mode?: OutputMode; }): void { const mode = args.mode ?? "human"; + const command = invocationCommand ?? args.command; if (mode === "json") { - const envelope: Record = { ok: !args.error, command: args.command }; + const envelope: OutputEnvelope = { ok: !args.error, command }; + if (args.command !== command) envelope.component = args.command; if (args.network) envelope.network = args.network; if (args.scheme) envelope.scheme = args.scheme; if (args.error) envelope.error = args.error; else envelope.result = args.result ?? null; + if (emitCapture) { + emitCapture.push(envelope); + return; + } printJson(envelope); return; } if (args.error) { - process.stderr.write(`ERROR ${args.command}: ${args.error.code}\n`); + process.stderr.write(`ERROR ${command}: ${args.error.code}\n`); process.stderr.write(` ${args.error.message}\n`); if (args.error.hint) process.stderr.write(` hint: ${args.error.hint}\n`); if (args.error.details !== undefined) process.stderr.write(` details: ${JSON.stringify(args.error.details)}\n`); return; } const suffix = [args.network, args.scheme].filter(Boolean).join(" "); - process.stdout.write(`OK ${args.command}${suffix ? ` (${suffix})` : ""}\n`); + process.stdout.write(`OK ${command}${suffix ? ` (${suffix})` : ""}\n`); if (args.result && typeof args.result === "object" && !Array.isArray(args.result)) { for (const [key, value] of Object.entries(args.result)) { if (value === undefined) continue; @@ -39,7 +84,17 @@ export function emit(args: { export function classify(error: unknown): FriendlyError { const message = error instanceof Error ? error.message : String(error); if (error instanceof CliError) return { code: error.code, message, hint: error.hint, details: error.details }; + if (error instanceof WalletNotFoundError) return { code: "WALLET_NOT_CONFIGURED", message, hint: "Configure an active Agent Wallet for this network." }; + if (error instanceof DecryptionError) return { code: "WALLET_DECRYPTION_FAILED", message, hint: "Unlock the Agent Wallet with the correct password and retry." }; + if (error instanceof SigningError) return { code: "WALLET_SIGNING_FAILED", message, hint: "Check that the active wallet supports this network and typed-data request." }; + if (error instanceof UnsupportedOperationError) return { code: "WALLET_UNSUPPORTED_OPERATION", message, hint: "Use a wallet backend that supports typed-data signing for this network." }; + if (error instanceof PrivyAuthError) return { code: "WALLET_AUTH_FAILED", message, hint: "Check the remote wallet authentication configuration." }; + if (error instanceof PrivyConfigError) return { code: "WALLET_CONFIG_CORRUPT", message, hint: "Check the remote Agent Wallet configuration." }; + if (error instanceof PrivyRateLimitError) return { code: "RATE_LIMITED", message, hint: "Wait briefly before retrying the remote wallet request." }; + if (error instanceof WalletNetworkError) return { code: "WALLET_NETWORK_ERROR", message, hint: "Check connectivity to the configured Agent Wallet backend." }; + if (error instanceof WalletError) return { code: "WALLET_ERROR", message, hint: "Inspect the active Agent Wallet configuration and backend status." }; const lower = message.toLowerCase(); + if (lower.includes("password required")) return { code: "WALLET_PASSWORD_REQUIRED", message, hint: "Provide the Agent Wallet password using its supported secure configuration." }; if (lower.includes("missing private key") || lower.includes("could not find a wallet") || lower.includes("wallet not found")) return { code: "WALLET_NOT_CONFIGURED", message, hint: "Configure an active Agent Wallet for this network. For development/CI, use --private-key or the chain-specific private-key environment variable." }; if (lower.includes("wallets_config") || lower.includes("wallet config")) return { code: "WALLET_CONFIG_CORRUPT", message, hint: "Check ~/.agent-wallet/wallets_config.json or recreate the local agent-wallet configuration." }; if (lower.includes("does not exist") && lower.includes("account [t")) return { code: "TRON_ACCOUNT_NOT_ACTIVATED", message, hint: "Activate the TRON address by sending it a small amount of TRX before signing contract calls." }; diff --git a/src/tokens.ts b/src/tokens.ts index f6872ec..a40f690 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -1,3 +1,5 @@ +import { TronWeb } from "tronweb"; + export type TokenInfo = { address: string; decimals: number; @@ -131,10 +133,33 @@ export function getToken(network: string, symbol: string): TokenInfo { return token; } +export function normalizeAddress(network: string, address: string): string | undefined { + const canonicalNetwork = normalizeNetwork(network); + if (canonicalNetwork.startsWith("eip155:")) { + return /^0x[0-9a-fA-F]{40}$/.test(address) ? address.toLowerCase() : undefined; + } + if (canonicalNetwork.startsWith("tron:")) { + if (!TronWeb.isAddress(address)) return undefined; + try { + return TronWeb.address.toHex(address).toLowerCase(); + } catch { + return undefined; + } + } + return undefined; +} + +export function addressesEqual(network: string, left: string, right: string): boolean { + const normalizedLeft = normalizeAddress(network, left); + const normalizedRight = normalizeAddress(network, right); + return normalizedLeft !== undefined && normalizedLeft === normalizedRight; +} + export function findTokenByAddress(network: string, address: string): TokenInfo | undefined { - const lower = address.toLowerCase(); + const normalized = normalizeAddress(network, address); + if (!normalized) return undefined; return Object.values(TOKENS[normalizeNetwork(network)] ?? {}).find( - token => token.address.toLowerCase() === lower, + token => normalizeAddress(network, token.address) === normalized, ); } diff --git a/src/x402.ts b/src/x402.ts index 8db7e27..55ebdb6 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -14,7 +14,8 @@ import { ExactGasFreeTronScheme, createGasFreeApiClients, getGasFreeApiBaseUrl } import { createPublicClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { TronWeb } from "tronweb"; -import { findTokenByAddress } from "./tokens.js"; +import { CliError } from "./args.js"; +import { addressesEqual, findTokenByAddress } from "./tokens.js"; export type PaymentRequirement = { scheme: string; @@ -98,11 +99,33 @@ function explicitPrivateKey(names: string[], explicit: string | undefined): `0x$ type SigningWallet = Wallet & Eip712Capable; async function activeAgentWallet(network: string): Promise { - const wallet = await resolveWallet({ - network, - ...(process.env.AGENT_WALLET_DIR ? { dir: process.env.AGENT_WALLET_DIR } : {}), - ...(process.env.AGENT_WALLET_ID ? { walletId: process.env.AGENT_WALLET_ID } : {}), - }); + let wallet: Wallet; + try { + wallet = await resolveWallet({ + network, + ...(process.env.AGENT_WALLET_DIR ? { dir: process.env.AGENT_WALLET_DIR } : {}), + ...(process.env.AGENT_WALLET_ID ? { walletId: process.env.AGENT_WALLET_ID } : {}), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof SyntaxError || /wallets_config|wallet config/i.test(message)) { + throw new CliError( + "WALLET_CONFIG_CORRUPT", + message, + "Check the Agent Wallet configuration or recreate the wallet.", + 1, + ); + } + if (/password required/i.test(message)) { + throw new CliError( + "WALLET_PASSWORD_REQUIRED", + message, + "Provide the Agent Wallet password using its supported secure configuration.", + 1, + ); + } + throw error; + } if (!("signTypedData" in wallet) || typeof wallet.signTypedData !== "function") { throw new Error(`active agent-wallet for ${network} does not support typed-data signing`); } @@ -280,9 +303,9 @@ function registerSelectedRequirementPolicy(client: x402Client, selected: Payment requirements.filter(requirement => requirement.scheme === selected.scheme && requirement.network === selected.network && - requirement.asset.toLowerCase() === selected.asset.toLowerCase() && + addressesEqual(requirement.network, requirement.asset, selected.asset) && requirement.amount === selected.amount && - requirement.payTo.toLowerCase() === selected.payTo.toLowerCase() + addressesEqual(requirement.network, requirement.payTo, selected.payTo) ), ); } diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 79b78e5..c69b628 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -5,8 +5,10 @@ import os from "node:os"; import path from "node:path"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import test from "node:test"; +import { DecryptionError, SigningError } from "@bankofai/agent-wallet"; +import { classify } from "../dist/output.js"; import { signTronTypedData } from "../dist/x402.js"; -import { getToken, normalizeNetwork } from "../dist/tokens.js"; +import { addressesEqual, findTokenByAddress, getToken, normalizeNetwork } from "../dist/tokens.js"; const root = path.resolve(import.meta.dirname, ".."); const cli = path.join(root, "dist", "cli.js"); @@ -142,6 +144,7 @@ test("pay dry-run selects Base Sepolia USDC", async () => { asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x0000000000000000000000000000000000000001", maxTimeoutSeconds: 300, + extra: { name: "USDC", version: "2" }, }], }; response.writeHead(402, { @@ -322,7 +325,7 @@ test("serve advertises exact_gasfree and rejects it on EVM", async () => { "--daemon", "--json", ]); - assert.equal(evm.status, 1); + assert.equal(evm.status, 2); assert.match(evm.stdout, /supported only on TRON/); }); @@ -337,6 +340,7 @@ test("pay dry-run preserves an exact_gasfree requirement", async () => { amount: "1", asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", payTo: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + maxTimeoutSeconds: 300, }], }; response.writeHead(402, { @@ -372,6 +376,7 @@ test("pay skips unknown-network requirements when selecting a token", async () = amount: "1", asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", payTo: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + maxTimeoutSeconds: 300, }, ], }; @@ -802,11 +807,11 @@ test("amount inputs are strict", () => { "--daemon", "--json", ]; - assert.equal(run([...base, "--amount", "1.2345678"]).status, 1); - assert.equal(run([...base, "--amount", "1.2.3"]).status, 1); - assert.equal(run([...base, "--amount", "-1"]).status, 1); - assert.equal(run([...base, "--amount", "1", "--rawAmount", "1"]).status, 2); - assert.equal(run([...base, "--rawAmount", "abc"]).status, 1); + assert.equal(run([...base, "--amount", "1.2345678"]).status, 2); + assert.equal(run([...base, "--amount", "1.2.3"]).status, 2); + assert.equal(run([...base, "--amount", "-1"]).status, 2); + assert.equal(run([...base, "--amount", "1", "--raw-amount", "1"]).status, 2); + assert.equal(run([...base, "--raw-amount", "abc"]).status, 2); }); test("serve rejects malformed payment signature and exact pay route only", async () => { @@ -932,3 +937,354 @@ test("serve daemon supports arbitrary asset decimals", async () => { } } }); + +test("registered token decimals cannot be overridden in serve or payment caps", async () => { + const serveResult = run([ + "serve", + "--pay-to", "0x0000000000000000000000000000000000000001", + "--network", "eip155:8453", + "--token", "USDC", + "--decimals", "18", + "--daemon", + "--json", + ]); + assert.equal(serveResult.status, 2); + assert.equal(JSON.parse(serveResult.stdout).error.code, "INVALID_ARGUMENT"); + + await withServer((request, response) => { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact", + network: "eip155:8453", + amount: "1000000000000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + extra: { name: "USD Coin", version: "2" }, + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + response.end(JSON.stringify(challenge)); + }, async base => { + const mismatch = await runAsync([ + "pay", `${base}/pay`, "--dry-run", "--max-amount", "0.01", + "--decimals", "18", "--json", + ]); + assert.equal(mismatch.status, 2); + assert.equal(JSON.parse(mismatch.stdout).error.code, "INVALID_ARGUMENT"); + + const protectedCap = await runAsync([ + "pay", `${base}/pay`, "--dry-run", "--max-amount", "0.01", + "--decimals", "6", "--json", + ]); + assert.equal(protectedCap.status, 1); + assert.equal(JSON.parse(protectedCap.stdout).error.code, "PAYMENT_AMOUNT_TOO_HIGH"); + }); +}); + +test("dry-run rejects un-signable requirements and accepts explicit non-Base assets", async () => { + async function runRequirement(requirement, extraArgs = []) { + return withServer((request, response) => { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [requirement], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + response.end(JSON.stringify(challenge)); + }, base => runAsync(["pay", `${base}/pay`, "--dry-run", "--json", ...extraArgs])); + } + + const base = { + scheme: "exact", + network: "eip155:8453", + amount: "1", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + }; + const missingMetadata = await runRequirement(base); + assert.equal(missingMetadata.status, 1); + assert.equal(JSON.parse(missingMetadata.stdout).error.code, "INVALID_PAYMENT_REQUIREMENT"); + + const invalidPayTo = await runRequirement({ + ...base, + payTo: "not-an-address", + extra: { name: "USD Coin", version: "2" }, + }); + assert.equal(invalidPayTo.status, 1); + assert.match(JSON.parse(invalidPayTo.stdout).error.message, /invalid payTo/); + + const invalidAmount = await runRequirement({ + ...base, + amount: "-1", + extra: { name: "USD Coin", version: "2" }, + }); + assert.equal(invalidAmount.status, 1); + assert.match(JSON.parse(invalidAmount.stdout).error.message, /non-negative integer/); + + const customAsset = "0x0000000000000000000000000000000000000002"; + const supportedCustom = await runRequirement({ + scheme: "exact", + network: "eip155:97", + amount: "125000000", + asset: customAsset, + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }, ["--asset", customAsset, "--decimals", "8"]); + assert.equal(supportedCustom.status, 0, supportedCustom.stderr); + + let customRequests = 0; + let customSignature; + await withServer((request, response) => { + customRequests += 1; + if (customRequests === 1) { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact", + network: "eip155:97", + amount: "125000000", + asset: customAsset, + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + response.end(JSON.stringify(challenge)); + return; + } + customSignature = request.headers["payment-signature"]; + response.writeHead(200, { + "content-type": "application/json", + "PAYMENT-RESPONSE": Buffer.from(JSON.stringify({ + success: true, + transaction: "custom-asset-test", + network: "eip155:97", + })).toString("base64"), + }); + response.end(JSON.stringify({ ok: true })); + }, async gateway => { + const result = await runAsync([ + "pay", `${gateway}/pay`, + "--asset", customAsset, + "--decimals", "8", + "--private-key", `0x${"01".repeat(32)}`, + "--json", + ]); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).result.transaction, "custom-asset-test"); + }); + assert.equal(typeof customSignature, "string"); +}); + +test("paid request does not forward PAYMENT-SIGNATURE across redirects", async () => { + let redirectedRequests = 0; + await withServer((_request, response) => { + redirectedRequests += 1; + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ shouldNotBeReached: true })); + }, async redirectedBase => { + let originRequests = 0; + await withServer((request, response) => { + originRequests += 1; + if (originRequests === 1) { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact", + network: "eip155:84532", + amount: "1", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + extra: { name: "USDC", version: "2" }, + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + response.end(JSON.stringify(challenge)); + return; + } + assert.equal(typeof request.headers["payment-signature"], "string"); + response.writeHead(307, { location: `${redirectedBase}/capture` }); + response.end(); + }, async originBase => { + const result = await runAsync([ + "pay", `${originBase}/pay`, "--json", + "--private-key", `0x${"01".repeat(32)}`, + ]); + assert.equal(result.status, 1); + assert.equal(JSON.parse(result.stdout).error.code, "HTTP_ERROR"); + }); + }); + assert.equal(redirectedRequests, 0); +}); + +test("TRON token matching validates Base58Check instead of lowercasing", () => { + const canonical = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + const invalidLowercase = canonical.toLowerCase(); + assert.equal(findTokenByAddress("tron:0x2b6653dc", canonical)?.symbol, "USDT"); + assert.equal(findTokenByAddress("tron:0x2b6653dc", invalidLowercase), undefined); + assert.equal(addressesEqual("tron:0x2b6653dc", canonical, invalidLowercase), false); + assert.equal( + addressesEqual( + "eip155:8453", + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + ), + true, + ); +}); + +test("Agent Wallet configuration failures have stable error codes", async () => { + assert.equal(classify(new DecryptionError("wrong password")).code, "WALLET_DECRYPTION_FAILED"); + assert.equal(classify(new SigningError("typed data rejected")).code, "WALLET_SIGNING_FAILED"); + await withServer((request, response) => { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact", + network: "eip155:84532", + amount: "1", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + extra: { name: "USDC", version: "2" }, + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + response.end(JSON.stringify(challenge)); + }, async base => { + const malformedDir = mkdtempSync(path.join(os.tmpdir(), "x402-cli-wallet-malformed-")); + const lockedDir = mkdtempSync(path.join(os.tmpdir(), "x402-cli-wallet-locked-")); + try { + writeFileSync(path.join(malformedDir, "wallets_config.json"), "{"); + const malformed = await runAsync(["pay", `${base}/pay`, "--json"], { + env: { + AGENT_WALLET_DIR: malformedDir, + AGENT_WALLET_PASSWORD: undefined, + AGENT_WALLET_PRIVATE_KEY: undefined, + EVM_PRIVATE_KEY: undefined, + PRIVATE_KEY: undefined, + }, + }); + assert.equal(malformed.status, 1); + assert.equal(JSON.parse(malformed.stdout).error.code, "WALLET_CONFIG_CORRUPT"); + + writeJson(path.join(lockedDir, "wallets_config.json"), { + active_wallet: "locked", + wallets: { + locked: { + type: "local_secure", + params: { secret_ref: "locked" }, + }, + }, + }); + const locked = await runAsync(["pay", `${base}/pay`, "--json"], { + env: { + AGENT_WALLET_DIR: lockedDir, + AGENT_WALLET_PASSWORD: undefined, + AGENT_WALLET_PRIVATE_KEY: undefined, + EVM_PRIVATE_KEY: undefined, + PRIVATE_KEY: undefined, + }, + }); + assert.equal(locked.status, 1); + assert.equal(JSON.parse(locked.stdout).error.code, "WALLET_PASSWORD_REQUIRED"); + } finally { + rmSync(malformedDir, { recursive: true, force: true }); + rmSync(lockedDir, { recursive: true, force: true }); + } + }); +}); + +test("invalid CLI options consistently use INVALID_ARGUMENT and exit 2", () => { + const cases = [ + ["serve", "--scheme", "foo", "--json"], + ["serve", "--decimals", "abc", "--json"], + ["serve", "--port", "abc", "--json"], + ["pay", "https://example.invalid", "--method", "get", "--json"], + ]; + for (const args of cases) { + const result = run(args); + assert.equal(result.status, 2, `${args.join(" ")}\n${result.stdout}\n${result.stderr}`); + assert.equal(JSON.parse(result.stdout).error.code, "INVALID_ARGUMENT"); + } +}); + +test("nested search, repeatable inline headers, help, and JSON identity are stable", async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "x402-cli-review-contract-")); + try { + catalogFixture(dir); + const source = path.join(dir, "catalog.json"); + const nested = run(["gateway", "catalog", "search", "defi", "--catalog", source, "--json"]); + assert.equal(nested.status, 0, nested.stderr); + const nestedJson = JSON.parse(nested.stdout); + assert.equal(nestedJson.command, "gateway catalog search"); + assert.equal(nestedJson.result.query, "defi"); + + for (const [args, expected] of [ + [["catalog", "--help"], /catalog { + assert.equal(request.headers.a, "1"); + assert.equal(request.headers.b, "2"); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + }, async base => { + const success = await runAsync([ + "pay", `${base}/plain`, "--header=A: 1", "--header=B: 2", "--json", + ]); + assert.equal(success.status, 0, success.stderr); + assert.equal(JSON.parse(success.stdout).command, "pay"); + }); + + const failure = run(["pay", "--json"]); + assert.equal(JSON.parse(failure.stdout).command, "pay"); + + const port = 50000 + Math.floor(Math.random() * 1000); + const roundtrip = await runAsync([ + "roundtrip", + "--pay-to", "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + "--port", String(port), + "--dry-run", + "--json", + ]); + assert.equal(roundtrip.status, 0, roundtrip.stderr); + const roundtripJson = JSON.parse(roundtrip.stdout); + assert.equal(roundtripJson.command, "roundtrip"); + assert.ok(roundtripJson.result.serve); + assert.ok(roundtripJson.result.pay); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From ba624773604845c9cf1b0139f541443acfed0f48 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 30 Jul 2026 14:08:58 +0800 Subject: [PATCH 3/5] fix: prevent implicit Agent Wallet fallback --- README.md | 4 ++- src/x402.ts | 26 ++++++++++++++++--- tests/cli.test.mjs | 62 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 83e8234..f6e6fdd 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,9 @@ includes its transaction information. By default, `x402-cli pay` resolves the active wallet from `@bankofai/agent-wallet` for the selected payment network and delegates signing -to the wallet. The CLI does not read private keys from `wallets_config.json`. +to the wallet. If configured wallets exist but none is active, the CLI stops +before signing instead of silently selecting the first available wallet. The +CLI does not read private keys from `wallets_config.json`. Use `AGENT_WALLET_DIR` to select a non-default Agent Wallet directory, or `AGENT_WALLET_ID` to explicitly select a configured wallet. diff --git a/src/x402.ts b/src/x402.ts index 55ebdb6..5cab880 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -7,7 +7,13 @@ import { encodePaymentSignatureHeader, } from "@bankofai/x402-core/http"; import { x402Client } from "@bankofai/x402-core/client"; -import { resolveWallet, type Eip712Capable, type Wallet } from "@bankofai/agent-wallet"; +import { + ConfigWalletProvider, + resolveWallet, + resolveWalletProvider, + type Eip712Capable, + type Wallet, +} from "@bankofai/agent-wallet"; import { ExactEvmScheme, toClientEvmSigner } from "@bankofai/x402-evm"; import { ExactTronScheme, createClientTronSigner } from "@bankofai/x402-tron"; import { ExactGasFreeTronScheme, createGasFreeApiClients, getGasFreeApiBaseUrl } from "@bankofai/x402-tron/gasfree"; @@ -101,10 +107,24 @@ type SigningWallet = Wallet & Eip712Capable; async function activeAgentWallet(network: string): Promise { let wallet: Wallet; try { + const dir = process.env.AGENT_WALLET_DIR?.trim() || undefined; + const walletId = process.env.AGENT_WALLET_ID?.trim() || undefined; + const provider = resolveWalletProvider({ + network, + ...(dir ? { dir } : {}), + }); + if (provider instanceof ConfigWalletProvider && !walletId && !provider.getActiveId()) { + throw new CliError( + "WALLET_NOT_CONFIGURED", + "Agent Wallet has configured wallets but no active wallet", + "Set an active Agent Wallet or explicitly select one with AGENT_WALLET_ID.", + 1, + ); + } wallet = await resolveWallet({ network, - ...(process.env.AGENT_WALLET_DIR ? { dir: process.env.AGENT_WALLET_DIR } : {}), - ...(process.env.AGENT_WALLET_ID ? { walletId: process.env.AGENT_WALLET_ID } : {}), + ...(dir ? { dir } : {}), + ...(walletId ? { walletId } : {}), }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index c69b628..c499589 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -240,6 +240,68 @@ test("pay uses the active Agent Wallet by default", async () => { } }); +test("pay refuses to silently select the first configured Agent Wallet", async () => { + const walletDir = mkdtempSync(path.join(os.tmpdir(), "x402-cli-agent-wallet-no-active-")); + writeJson(path.join(walletDir, "wallets_config.json"), { + active_wallet: null, + wallets: { + first: { + type: "raw_secret", + params: { source: "private_key", private_key: `0x${"01".repeat(32)}` }, + }, + intended: { + type: "raw_secret", + params: { source: "private_key", private_key: `0x${"02".repeat(32)}` }, + }, + }, + }); + + let requests = 0; + try { + await withServer((request, response) => { + requests += 1; + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact", + network: "eip155:84532", + amount: "1", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + extra: { name: "USDC", version: "2" }, + }], + }; + 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`, "--network", "base-sepolia", "--token", "USDC", "--json"], + { + env: { + AGENT_WALLET_DIR: walletDir, + AGENT_WALLET_ID: undefined, + AGENT_WALLET_PRIVATE_KEY: undefined, + EVM_PRIVATE_KEY: undefined, + PRIVATE_KEY: undefined, + }, + }, + ); + assert.equal(result.status, 1); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.error.code, "WALLET_NOT_CONFIGURED"); + assert.match(parsed.error.message, /no active wallet/i); + }); + assert.equal(requests, 1); + } finally { + rmSync(walletDir, { recursive: true, force: true }); + } +}); + test("serve advertises Base USDC exact with EIP-712 domain metadata", async () => { const port = 48000 + Math.floor(Math.random() * 1000); const started = run([ From a1836b7582a98732cc4bf979d7d5e3e7278f38da Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 30 Jul 2026 14:20:42 +0800 Subject: [PATCH 4/5] fix: bind payments to the active wallet --- README.md | 5 +- package-lock.json | 4 +- package.json | 2 +- src/cli.ts | 5 + src/help.ts | 1 + src/x402.ts | 218 ++++++++++++++++++++++------ tests/cli.test.mjs | 350 ++++++++++++++++++++++++++++++--------------- 7 files changed, 423 insertions(+), 162 deletions(-) diff --git a/README.md b/README.md index f6e6fdd..e859812 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,10 @@ to the wallet. If configured wallets exist but none is active, the CLI stops before signing instead of silently selecting the first available wallet. The CLI does not read private keys from `wallets_config.json`. Use `AGENT_WALLET_DIR` to select a non-default Agent Wallet directory, or -`AGENT_WALLET_ID` to explicitly select a configured wallet. +`--wallet-id`/`AGENT_WALLET_ID` to explicitly select a configured wallet. +For EVM payments, the CLI checks the selected payer's token balance before +signing and returns the resolved wallet ID, address, and raw balance in its +result. The EIP-712 payer must match that same address. For development and CI only, `--private-key`, `EVM_PRIVATE_KEY`, `TRON_PRIVATE_KEY`, or `PRIVATE_KEY` can explicitly override Agent Wallet. diff --git a/package-lock.json b/package-lock.json index d2d3484..238d17f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.2-beta.1", + "version": "1.0.2-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.2-beta.1", + "version": "1.0.2-beta.2", "dependencies": { "@bankofai/agent-wallet": "2.4.0", "@bankofai/x402-core": "1.0.1", diff --git a/package.json b/package.json index 396fe87..f0886bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.2-beta.1", + "version": "1.0.2-beta.2", "type": "module", "files": [ "dist" diff --git a/src/cli.ts b/src/cli.ts index a4e0442..2c66c17 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -477,6 +477,7 @@ async function pay(url: string, options: ParsedOptions): Promise { extensions: required.extensions, rpcUrl: opt(options, "rpc-url"), privateKey: opt(options, "private-key"), + walletId: opt(options, "wallet-id"), gasfreeApiUrl: opt(options, "gasfree-api-url"), maxGasfreeFeeRaw, }), @@ -508,6 +509,10 @@ async function pay(url: string, options: ParsedOptions): Promise { paid: settled, settled, delivered: paid.ok, + payer: { + ...creation.payer, + ...(creation.balanceRaw !== undefined ? { balanceRaw: creation.balanceRaw } : {}), + }, response: body, ...(settlement !== undefined ? { paymentResponse: settlement, diff --git a/src/help.ts b/src/help.ts index 30266c1..1c1a649 100644 --- a/src/help.ts +++ b/src/help.ts @@ -47,6 +47,7 @@ Options: --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 + --wallet-id Explicit configured Agent Wallet ID --private-key Override Agent Wallet for development/CI --rpc-url Explicit network RPC URL --timeout-ms Network timeout in milliseconds (default: 30000) diff --git a/src/x402.ts b/src/x402.ts index 5cab880..3d977d6 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -9,7 +9,6 @@ import { import { x402Client } from "@bankofai/x402-core/client"; import { ConfigWalletProvider, - resolveWallet, resolveWalletProvider, type Eip712Capable, type Wallet, @@ -104,28 +103,48 @@ function explicitPrivateKey(names: string[], explicit: string | undefined): `0x$ type SigningWallet = Wallet & Eip712Capable; -async function activeAgentWallet(network: string): Promise { +export type PayerContext = { + walletId: string | null; + address: string; +}; + +type SelectedAgentWallet = { + wallet: SigningWallet; + payer: PayerContext; +}; + +async function activeAgentWallet(network: string, requestedWalletId?: string): Promise { let wallet: Wallet; + let walletId: string | null = null; try { const dir = process.env.AGENT_WALLET_DIR?.trim() || undefined; - const walletId = process.env.AGENT_WALLET_ID?.trim() || undefined; + const explicitWalletId = requestedWalletId?.trim() || process.env.AGENT_WALLET_ID?.trim() || undefined; const provider = resolveWalletProvider({ network, ...(dir ? { dir } : {}), }); - if (provider instanceof ConfigWalletProvider && !walletId && !provider.getActiveId()) { - throw new CliError( - "WALLET_NOT_CONFIGURED", - "Agent Wallet has configured wallets but no active wallet", - "Set an active Agent Wallet or explicitly select one with AGENT_WALLET_ID.", - 1, - ); + if (provider instanceof ConfigWalletProvider) { + walletId = explicitWalletId ?? provider.getActiveId(); + if (!walletId) { + throw new CliError( + "WALLET_NOT_CONFIGURED", + "Agent Wallet has configured wallets but no active wallet", + "Set an active Agent Wallet or explicitly select one with --wallet-id or AGENT_WALLET_ID.", + 1, + ); + } + wallet = await provider.getWallet(walletId, network); + } else { + if (explicitWalletId) { + throw new CliError( + "WALLET_NOT_CONFIGURED", + `Agent Wallet '${explicitWalletId}' was requested but no configured wallet directory is available`, + "Check AGENT_WALLET_DIR, or remove --wallet-id/AGENT_WALLET_ID when using an environment-backed wallet.", + 1, + ); + } + wallet = await provider.getActiveWallet(network); } - wallet = await resolveWallet({ - network, - ...(dir ? { dir } : {}), - ...(walletId ? { walletId } : {}), - }); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (error instanceof SyntaxError || /wallets_config|wallet config/i.test(message)) { @@ -149,7 +168,11 @@ async function activeAgentWallet(network: string): Promise { if (!("signTypedData" in wallet) || typeof wallet.signTypedData !== "function") { throw new Error(`active agent-wallet for ${network} does not support typed-data signing`); } - return wallet as SigningWallet; + const address = await wallet.getAddress(); + return { + wallet: wallet as SigningWallet, + payer: { walletId, address }, + }; } function prefixedHex(value: string): `0x${string}` { @@ -210,39 +233,111 @@ export type CreatePaymentClientArgs = { allowanceMode?: string; gasfreeApiUrl?: string; maxGasfreeFeeRaw?: string; + walletId?: string; }; +const erc20BalanceAbi = [{ + type: "function", + name: "balanceOf", + stateMutability: "view", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }], +}] as const; + +async function requireEvmTokenBalance( + publicClient: ReturnType | undefined, + selected: PaymentRequirement, + payer: PayerContext, +): Promise { + if (!publicClient) return undefined; + let balance: bigint; + try { + balance = await publicClient.readContract({ + address: selected.asset as `0x${string}`, + abi: erc20BalanceAbi, + functionName: "balanceOf", + args: [payer.address as `0x${string}`], + }); + } catch (error) { + throw new CliError( + "TOKEN_BALANCE_CHECK_FAILED", + `failed to read token balance for payer ${payer.address}: ${error instanceof Error ? error.message : String(error)}`, + "Check --rpc-url and confirm it serves the selected payment network.", + 1, + { payer, network: selected.network, asset: selected.asset, requiredRaw: selected.amount }, + ); + } + const required = BigInt(selected.amount); + if (balance < required) { + throw new CliError( + "INSUFFICIENT_TOKEN_BALANCE", + `payer ${payer.address} has token balance ${balance} but payment requires ${required}`, + "Fund this exact payer address with the advertised token on the selected network, or select another wallet.", + 1, + { + payer, + network: selected.network, + asset: selected.asset, + balanceRaw: balance.toString(), + requiredRaw: required.toString(), + }, + ); + } + return balance.toString(); +} + export async function createPaymentClient( args: CreatePaymentClientArgs, -): Promise<{ client: x402Client; gasfreeEstimate?: { fee: string; total: string } }> { +): Promise<{ + client: x402Client; + payer: PayerContext; + balanceRaw?: string; + gasfreeEstimate?: { fee: string; total: string }; +}> { const selected = ensurePermit2(args.selected); if (selected.network.startsWith("eip155:")) { if (selected.scheme !== "exact") throw new Error(`unsupported scheme ${selected.scheme} on ${selected.network}`); const privateKey = explicitPrivateKey(["EVM_PRIVATE_KEY", "PRIVATE_KEY"], args.privateKey); const rpcUrl = evmRpcUrl(selected.network, args.rpcUrl); const publicClient = rpcUrl ? createPublicClient({ transport: http(rpcUrl) }) : undefined; - const signer = privateKey - ? toClientEvmSigner(privateKeyToAccount(privateKey), publicClient) - : toClientEvmSigner(await createAgentWalletEvmSigner(selected.network), publicClient); + let signer; + let payer: PayerContext; + if (privateKey) { + const account = privateKeyToAccount(privateKey); + signer = toClientEvmSigner(account, publicClient); + payer = { walletId: null, address: account.address }; + } else { + const agentWallet = await createAgentWalletEvmSigner(selected.network, args.walletId); + signer = toClientEvmSigner(agentWallet.signer, publicClient); + payer = agentWallet.payer; + } + const balanceRaw = await requireEvmTokenBalance(publicClient, selected, payer); const scheme = new ExactEvmScheme(signer, rpcUrl ? { rpcUrl } : undefined); const client = new x402Client().register( selected.network as `${string}:${string}`, scheme, ); registerSelectedRequirementPolicy(client, selected); - return { client }; + return { client, payer, ...(balanceRaw !== undefined ? { balanceRaw } : {}) }; } if (selected.network.startsWith("tron:")) { const privateKey = explicitPrivateKey(["TRON_PRIVATE_KEY", "PRIVATE_KEY"], args.privateKey); - const wallet = privateKey - ? await createTronWallet( + let wallet; + let payer: PayerContext; + if (privateKey) { + wallet = await createTronWallet( privateKey, selected.scheme === "exact_gasfree" ? args.maxGasfreeFeeRaw : undefined, - ) - : withGasfreeFeeGuard( - await activeAgentWallet(selected.network), + ); + payer = { walletId: null, address: await wallet.getAddress() }; + } else { + const agentWallet = await activeAgentWallet(selected.network, args.walletId); + wallet = withGasfreeFeeGuard( + agentWallet.wallet, selected.scheme === "exact_gasfree" ? args.maxGasfreeFeeRaw : undefined, ); + payer = agentWallet.payer; + } const signer = await createClientTronSigner(wallet, { network: selected.network, rpcUrl: args.rpcUrl || process.env.TRON_RPC_URL, @@ -270,29 +365,61 @@ export async function createPaymentClient( throw new Error(`unsupported scheme ${selected.scheme} on ${selected.network}`); } registerSelectedRequirementPolicy(client, selected); - return { client, ...(gasfreeEstimate ? { gasfreeEstimate } : {}) }; + return { client, payer, ...(gasfreeEstimate ? { gasfreeEstimate } : {}) }; } throw new Error(`unsupported network ${selected.network}`); } -async function createAgentWalletEvmSigner(network: string) { - const wallet = await activeAgentWallet(network); - const address = await wallet.getAddress(); - if (!/^0x[0-9a-fA-F]{40}$/.test(address)) { - throw new Error(`active agent-wallet address is not valid for ${network}: ${address}`); - } - return { - address: address as `0x${string}`, - async signTypedData(data: { +async function createAgentWalletEvmSigner( + network: string, + requestedWalletId?: string, +): Promise<{ + signer: { + address: `0x${string}`; + signTypedData(data: { domain: Record; types: Record; primaryType: string; message: Record; - }) { - return prefixedHex(await wallet.signTypedData(data)); - }, - async signTransaction(transaction: Record) { - return prefixedHex(await wallet.signTransaction(transaction)); + }): Promise<`0x${string}`>; + signTransaction(transaction: Record): Promise<`0x${string}`>; + }; + payer: PayerContext; +}> { + const selected = await activeAgentWallet(network, requestedWalletId); + const { wallet, payer } = selected; + const address = payer.address; + if (!/^0x[0-9a-fA-F]{40}$/.test(address)) { + throw new Error(`active agent-wallet address is not valid for ${network}: ${address}`); + } + return { + payer, + signer: { + address: address as `0x${string}`, + async signTypedData(data: { + domain: Record; + types: Record; + primaryType: string; + message: Record; + }) { + const messageFrom = data.message.from; + if ( + typeof messageFrom === "string" && + !addressesEqual(network, address, messageFrom) + ) { + throw new CliError( + "WALLET_ADDRESS_MISMATCH", + `selected wallet address ${address} does not match typed-data payer ${messageFrom}`, + "Do not sign this payment; reselect the intended wallet and request a fresh payment requirement.", + 1, + { payer, payloadFrom: messageFrom, network }, + ); + } + return prefixedHex(await wallet.signTypedData(data)); + }, + async signTransaction(transaction: Record) { + return prefixedHex(await wallet.signTransaction(transaction)); + }, }, }; } @@ -332,7 +459,12 @@ function registerSelectedRequirementPolicy(client: x402Client, selected: Payment export async function createPaymentPayload( args: CreatePaymentClientArgs, -): Promise<{ payload: unknown; gasfreeEstimate?: { fee: string; total: string } }> { +): Promise<{ + payload: unknown; + payer: PayerContext; + balanceRaw?: string; + gasfreeEstimate?: { fee: string; total: string }; +}> { const creation = await createPaymentClient(args); const required = paymentRequired( ensurePermit2(args.selected), @@ -342,6 +474,8 @@ export async function createPaymentPayload( const payload = await creation.client.createPaymentPayload(required as never); return { payload, + payer: creation.payer, + ...(creation.balanceRaw !== undefined ? { balanceRaw: creation.balanceRaw } : {}), ...(creation.gasfreeEstimate ? { gasfreeEstimate: creation.gasfreeEstimate } : {}), }; } diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index c499589..f72e48f 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 { DecryptionError, SigningError } from "@bankofai/agent-wallet"; +import { privateKeyToAccount } from "viem/accounts"; import { classify } from "../dist/output.js"; import { signTronTypedData } from "../dist/x402.js"; import { addressesEqual, findTokenByAddress, getToken, normalizeNetwork } from "../dist/tokens.js"; @@ -62,6 +63,24 @@ async function withServer(handler, fn) { } } +async function withEvmRpc(balanceRaw, fn) { + return withServer((request, response) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", chunk => { body += chunk; }); + request.on("end", () => { + const rpc = JSON.parse(body); + let result; + if (rpc.method === "eth_chainId") result = "0x14a34"; + else if (rpc.method === "eth_call") result = `0x${BigInt(balanceRaw).toString(16).padStart(64, "0")}`; + else if (rpc.method === "eth_blockNumber") result = "0x1"; + else result = "0x0"; + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result })); + }); + }, fn); +} + function catalogFixture(dir) { const catalog = { version: 1, @@ -166,9 +185,14 @@ test("pay dry-run selects Base Sepolia USDC", async () => { test("pay uses the active Agent Wallet by default", async () => { const walletDir = mkdtempSync(path.join(os.tmpdir(), "x402-cli-agent-wallet-")); const privateKey = `0x${"01".repeat(32)}`; + const expectedAddress = privateKeyToAccount(privateKey).address; writeJson(path.join(walletDir, "wallets_config.json"), { active_wallet: "base-payer", wallets: { + first: { + type: "raw_secret", + params: { source: "private_key", private_key: `0x${"02".repeat(32)}` }, + }, "base-payer": { type: "raw_secret", params: { source: "private_key", private_key: privateKey }, @@ -179,9 +203,100 @@ test("pay uses the active Agent Wallet by default", async () => { let requests = 0; let paymentSignature; try { - await withServer((request, response) => { - requests += 1; - if (requests === 1) { + await withEvmRpc(100n, async rpcUrl => { + 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:84532", + amount: "1", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + extra: { name: "USDC", version: "2" }, + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + return response.end(JSON.stringify(challenge)); + } + + paymentSignature = request.headers["payment-signature"]; + response.writeHead(200, { + "content-type": "application/json", + "PAYMENT-RESPONSE": Buffer.from(JSON.stringify({ + success: true, + transaction: "agent-wallet-test", + network: "eip155:84532", + })).toString("base64"), + }); + response.end(JSON.stringify({ ok: true })); + }, async base => { + const result = await runAsync( + [ + "pay", `${base}/pay`, + "--network", "base-sepolia", + "--token", "USDC", + "--rpc-url", rpcUrl, + "--json", + ], + { + env: { + AGENT_WALLET_DIR: walletDir, + AGENT_WALLET_ID: undefined, + AGENT_WALLET_PRIVATE_KEY: undefined, + EVM_PRIVATE_KEY: undefined, + PRIVATE_KEY: undefined, + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.result.paid, true); + assert.equal(parsed.result.transaction, "agent-wallet-test"); + assert.deepEqual(parsed.result.payer, { + walletId: "base-payer", + address: expectedAddress, + balanceRaw: "100", + }); + }); + }); + + assert.equal(requests, 2); + assert.equal(typeof paymentSignature, "string"); + const payload = JSON.parse(Buffer.from(paymentSignature, "base64").toString("utf8")); + assert.match(payload.payload.signature, /^0x[0-9a-f]{130}$/i); + assert.equal(payload.payload.authorization.from, expectedAddress); + } finally { + rmSync(walletDir, { recursive: true, force: true }); + } +}); + +test("pay checks the active EVM wallet balance before signing", async () => { + const walletDir = mkdtempSync(path.join(os.tmpdir(), "x402-cli-agent-wallet-balance-")); + const privateKey = `0x${"03".repeat(32)}`; + const expectedAddress = privateKeyToAccount(privateKey).address; + writeJson(path.join(walletDir, "wallets_config.json"), { + active_wallet: "empty-payer", + wallets: { + "empty-payer": { + type: "raw_secret", + params: { source: "private_key", private_key: privateKey }, + }, + }, + }); + + let requests = 0; + try { + await withEvmRpc(0n, async rpcUrl => { + await withServer((request, response) => { + requests += 1; const challenge = { x402Version: 2, resource: { url: `http://${request.headers.host}/pay` }, @@ -199,42 +314,39 @@ test("pay uses the active Agent Wallet by default", async () => { "content-type": "application/json", "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), }); - return response.end(JSON.stringify(challenge)); - } - - paymentSignature = request.headers["payment-signature"]; - response.writeHead(200, { - "content-type": "application/json", - "PAYMENT-RESPONSE": Buffer.from(JSON.stringify({ - success: true, - transaction: "agent-wallet-test", + response.end(JSON.stringify(challenge)); + }, async base => { + const result = await runAsync( + [ + "pay", `${base}/pay`, + "--network", "base-sepolia", + "--token", "USDC", + "--rpc-url", rpcUrl, + "--json", + ], + { + env: { + AGENT_WALLET_DIR: walletDir, + AGENT_WALLET_ID: undefined, + AGENT_WALLET_PRIVATE_KEY: undefined, + EVM_PRIVATE_KEY: undefined, + PRIVATE_KEY: undefined, + }, + }, + ); + assert.equal(result.status, 1); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.error.code, "INSUFFICIENT_TOKEN_BALANCE"); + assert.deepEqual(parsed.error.details, { + payer: { walletId: "empty-payer", address: expectedAddress }, network: "eip155:84532", - })).toString("base64"), + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + balanceRaw: "0", + requiredRaw: "1", + }); }); - response.end(JSON.stringify({ ok: true })); - }, async base => { - const result = await runAsync( - ["pay", `${base}/pay`, "--network", "base-sepolia", "--token", "USDC", "--json"], - { - env: { - AGENT_WALLET_DIR: walletDir, - AGENT_WALLET_ID: undefined, - AGENT_WALLET_PRIVATE_KEY: undefined, - EVM_PRIVATE_KEY: undefined, - PRIVATE_KEY: undefined, - }, - }, - ); - assert.equal(result.status, 0, result.stderr); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.result.paid, true); - assert.equal(parsed.result.transaction, "agent-wallet-test"); }); - - assert.equal(requests, 2); - assert.equal(typeof paymentSignature, "string"); - const payload = JSON.parse(Buffer.from(paymentSignature, "base64").toString("utf8")); - assert.match(payload.payload.signature, /^0x[0-9a-f]{130}$/i); + assert.equal(requests, 1); } finally { rmSync(walletDir, { recursive: true, force: true }); } @@ -474,50 +586,53 @@ 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, { + await withEvmRpc(100n, async rpcUrl => { + 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-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + "PAYMENT-RESPONSE": Buffer.from(JSON.stringify(settlement)).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)}`, + "--rpc-url", rpcUrl, + ]); + 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"); + assert.equal(requests, 2); }); - 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"); - assert.equal(requests, 2); }); }); @@ -1156,45 +1271,48 @@ test("dry-run rejects un-signable requirements and accepts explicit non-Base ass test("paid request does not forward PAYMENT-SIGNATURE across redirects", async () => { let redirectedRequests = 0; - await withServer((_request, response) => { - redirectedRequests += 1; - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ shouldNotBeReached: true })); - }, async redirectedBase => { - let originRequests = 0; - await withServer((request, response) => { - originRequests += 1; - if (originRequests === 1) { - const challenge = { - x402Version: 2, - resource: { url: `http://${request.headers.host}/pay` }, - accepts: [{ - scheme: "exact", - network: "eip155:84532", - amount: "1", - asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x0000000000000000000000000000000000000001", - maxTimeoutSeconds: 300, - extra: { name: "USDC", version: "2" }, - }], - }; - response.writeHead(402, { - "content-type": "application/json", - "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), - }); - response.end(JSON.stringify(challenge)); - return; - } - assert.equal(typeof request.headers["payment-signature"], "string"); - response.writeHead(307, { location: `${redirectedBase}/capture` }); - response.end(); - }, async originBase => { - const result = await runAsync([ - "pay", `${originBase}/pay`, "--json", - "--private-key", `0x${"01".repeat(32)}`, - ]); - assert.equal(result.status, 1); - assert.equal(JSON.parse(result.stdout).error.code, "HTTP_ERROR"); + await withEvmRpc(100n, async rpcUrl => { + await withServer((_request, response) => { + redirectedRequests += 1; + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ shouldNotBeReached: true })); + }, async redirectedBase => { + let originRequests = 0; + await withServer((request, response) => { + originRequests += 1; + if (originRequests === 1) { + const challenge = { + x402Version: 2, + resource: { url: `http://${request.headers.host}/pay` }, + accepts: [{ + scheme: "exact", + network: "eip155:84532", + amount: "1", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x0000000000000000000000000000000000000001", + maxTimeoutSeconds: 300, + extra: { name: "USDC", version: "2" }, + }], + }; + response.writeHead(402, { + "content-type": "application/json", + "PAYMENT-REQUIRED": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + response.end(JSON.stringify(challenge)); + return; + } + assert.equal(typeof request.headers["payment-signature"], "string"); + response.writeHead(307, { location: `${redirectedBase}/capture` }); + response.end(); + }, async originBase => { + const result = await runAsync([ + "pay", `${originBase}/pay`, "--json", + "--private-key", `0x${"01".repeat(32)}`, + "--rpc-url", rpcUrl, + ]); + assert.equal(result.status, 1); + assert.equal(JSON.parse(result.stdout).error.code, "HTTP_ERROR"); + }); }); }); assert.equal(redirectedRequests, 0); From d1d7005c7fea1bc013d3ccc8bb26a4e61f8a6aa2 Mon Sep 17 00:00:00 2001 From: "bobo.liu" Date: Thu, 30 Jul 2026 14:31:13 +0800 Subject: [PATCH 5/5] chore: release CLI 1.0.2 --- package-lock.json | 12 ++++++------ package.json | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 238d17f..1c49a2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.2-beta.2", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.2-beta.2", + "version": "1.0.2", "dependencies": { "@bankofai/agent-wallet": "2.4.0", "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", "@bankofai/x402-fetch": "1.0.1", - "@bankofai/x402-gateway": "1.0.2-beta.0", + "@bankofai/x402-gateway": "1.0.2", "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", "viem": "^2.55.0" @@ -123,9 +123,9 @@ } }, "node_modules/@bankofai/x402-gateway": { - "version": "1.0.2-beta.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.2-beta.0.tgz", - "integrity": "sha512-TxFkqL4A2e/kFSo3jXQ/lZ9dq7El5PeqcXVeZmv4fCQagD3oNJM/JBAbyZ3oC8cVwMg8b4/7wX4AZwBi5osSeA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bankofai/x402-gateway/-/x402-gateway-1.0.2.tgz", + "integrity": "sha512-ctUqUkWYX3wyacx4HMVYe7q4jDnR7UhplfX2EMFII9vcoLArrp78QU2rLEgzgUNTbGYAYdkmvKiAREfjNj6MTg==", "dependencies": { "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", diff --git a/package.json b/package.json index f0886bc..a6737ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.2-beta.2", + "version": "1.0.2", "type": "module", "files": [ "dist" @@ -26,7 +26,7 @@ "@bankofai/x402-core": "1.0.1", "@bankofai/x402-evm": "1.0.1", "@bankofai/x402-fetch": "1.0.1", - "@bankofai/x402-gateway": "1.0.2-beta.0", + "@bankofai/x402-gateway": "1.0.2", "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", "viem": "^2.55.0"