diff --git a/README.md b/README.md index c1cc473..e859812 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 ``` @@ -86,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): @@ -119,10 +123,41 @@ 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. 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 +`--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. Prefer environment variables over `--private-key` in shared environments, because command-line arguments may be visible to other local processes. +Pay a Base Mainnet USDC endpoint: + +```bash +x402-cli pay https://api.example.com/pay \ + --network base-mainnet \ + --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`. + +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. @@ -140,6 +175,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: @@ -149,6 +210,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 +219,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..1c49a2f 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", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bankofai/x402-cli", - "version": "1.0.1", + "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-gateway": "1.0.1", + "@bankofai/x402-fetch": "1.0.1", + "@bankofai/x402-gateway": "1.0.2", "@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", + "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", @@ -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..a6737ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bankofai/x402-cli", - "version": "1.0.1", + "version": "1.0.2", "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", "@bankofai/x402-tron": "1.0.1", "tronweb": "6.4.0", "viem": "^2.55.0" 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 206e08a..2c66c17 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,58 +1,141 @@ #!/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 { assertRawAmount, findTokenByAddress, getToken, normalizeNetwork, toSmallestUnit } from "./tokens.js"; +import { wrapFetchWithPayment } from "@bankofai/x402-fetch"; +import { createPaymentClient, decodeRequired, decodeResponse, decodeSignature, encodeRequired, encodeResponse, headers, PaymentRequirement } from "./x402.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 invalidArgument(`unknown token ${tokenSymbol} on ${network}`); + const isBase = network === "eip155:8453" || network === "eip155:84532"; + if (isBase && !registryToken) { + throw invalidArgument("Base support is currently limited to the official USDC contract"); } - if (!explicitAsset && !registryToken) throw new Error(`unknown token ${tokenSymbol} on ${network}`); - const decimals = decimalsOption !== undefined ? Number(decimalsOption) : registryToken!.decimals; - if (!Number.isInteger(decimals) || decimals < 0 || 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 ?? "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"); + 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: 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), @@ -68,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, + ); } } } @@ -101,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"); } - 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); + 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; + } + 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", @@ -210,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; @@ -229,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; }); @@ -243,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); @@ -275,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"]) { @@ -285,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", }, @@ -293,23 +471,34 @@ async function pay(url: string, options: ParsedOptions): Promise { return; } const creation = await withSdkStdoutRedirect(outputMode(options) === "json", () => - createPaymentPayload({ + createPaymentClient({ selected, - resource: required.resource?.url ?? url, + resource, 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, }), ); - 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, redirect: "manual" }, 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; @@ -320,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, @@ -354,9 +547,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); } @@ -380,12 +594,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)); @@ -417,17 +632,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 f885da4..1c1a649 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 @@ -45,7 +47,8 @@ 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) + --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) --json Print JSON envelope @@ -53,6 +56,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-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] @@ -70,6 +77,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 @@ -90,6 +98,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] @@ -99,6 +115,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] @@ -130,6 +151,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] @@ -168,4 +205,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..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,8 +84,18 @@ 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("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("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." }; 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 +116,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..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; @@ -81,6 +83,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 +122,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; } @@ -111,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 027d907..3d977d6 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -7,16 +7,20 @@ import { encodePaymentSignatureHeader, } from "@bankofai/x402-core/http"; import { x402Client } from "@bankofai/x402-core/client"; +import { + ConfigWalletProvider, + 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"; 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"; +import { CliError } from "./args.js"; +import { addressesEqual, findTokenByAddress } from "./tokens.js"; export type PaymentRequirement = { scheme: string; @@ -89,39 +93,90 @@ 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; + +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 explicitWalletId = requestedWalletId?.trim() || process.env.AGENT_WALLET_ID?.trim() || undefined; + const provider = resolveWalletProvider({ + network, + ...(dir ? { dir } : {}), + }); + 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); + } + } 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`); } - const walletKey = privateKeyFromAgentWallet(walletIds); - if (walletKey) return walletKey; - throw new Error(`missing private key; set one of ${names.join(", ")}`); + const address = await wallet.getAddress(); + return { + wallet: wallet as SigningWallet, + payer: { walletId, address }, + }; +} + +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 +186,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 +223,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 +233,111 @@ export async function createPaymentPayload(args: { allowanceMode?: string; gasfreeApiUrl?: string; maxGasfreeFeeRaw?: string; -}): Promise<{ payload: unknown; gasfreeEstimate?: { fee: string; total: 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; + payer: PayerContext; + balanceRaw?: string; + 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); + 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 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, payer, ...(balanceRaw !== undefined ? { balanceRaw } : {}) }; } 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); + let wallet; + let payer: PayerContext; + if (privateKey) { + wallet = await createTronWallet( + privateKey, + selected.scheme === "exact_gasfree" ? args.maxGasfreeFeeRaw : undefined, + ); + 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, @@ -229,8 +364,118 @@ 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, payer, ...(gasfreeEstimate ? { gasfreeEstimate } : {}) }; } throw new Error(`unsupported network ${selected.network}`); } + +async function createAgentWalletEvmSigner( + network: string, + requestedWalletId?: string, +): Promise<{ + signer: { + address: `0x${string}`; + signTypedData(data: { + domain: Record; + types: Record; + primaryType: string; + message: Record; + }): 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)); + }, + }, + }; +} + +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 && + addressesEqual(requirement.network, requirement.asset, selected.asset) && + requirement.amount === selected.amount && + addressesEqual(requirement.network, requirement.payTo, selected.payTo) + ), + ); +} + +export async function createPaymentPayload( + args: CreatePaymentClientArgs, +): Promise<{ + payload: unknown; + payer: PayerContext; + balanceRaw?: string; + 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, + 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 6cbf73c..f72e48f 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -5,8 +5,11 @@ 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 { privateKeyToAccount } from "viem/accounts"; +import { classify } from "../dist/output.js"; import { signTronTypedData } from "../dist/x402.js"; -import { 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"); @@ -60,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, @@ -111,6 +132,328 @@ 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, + 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`, "--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)}`; + 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 }, + }, + }, + }); + + let requests = 0; + let paymentSignature; + try { + 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` }, + 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", + "--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", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + balanceRaw: "0", + requiredRaw: "1", + }); + }); + }); + assert.equal(requests, 1); + } finally { + rmSync(walletDir, { recursive: true, force: true }); + } +}); + +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([ + "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([ @@ -156,7 +499,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/); }); @@ -171,6 +514,7 @@ test("pay dry-run preserves an exact_gasfree requirement", async () => { amount: "1", asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", payTo: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + maxTimeoutSeconds: 300, }], }; response.writeHead(402, { @@ -206,6 +550,7 @@ test("pay skips unknown-network requirements when selecting a token", async () = amount: "1", asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", payTo: "TTX1Us19zqsLXhY39PPR7KRUoMa93s3J3i", + maxTimeoutSeconds: 300, }, ], }; @@ -241,49 +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"); }); }); @@ -635,11 +984,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 () => { @@ -765,3 +1114,357 @@ 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 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); +}); + +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 }); + } +});