diff --git a/.github/workflows/ts-ci.yml b/.github/workflows/ts-ci.yml index f6e747db8..5e24104a7 100644 --- a/.github/workflows/ts-ci.yml +++ b/.github/workflows/ts-ci.yml @@ -69,5 +69,5 @@ jobs: - name: Build npm bundle run: npm run build - - name: Validate npm package contents - run: npm pack --dry-run + - name: Verify independently installed npm package + run: npm run verify:package diff --git a/CLAUDE.md b/CLAUDE.md index 87baeb635..9b6f062ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,13 +48,14 @@ stdout/stderr discipline). Key points: - **Ports live in `application/ports/`** (e.g. `wallet-repository`, `tron-gateway`, `ledger-device`, - `price-provider`); outbound adapters implement them (dependency inversion). + `price-provider`); outbound adapters implement them (dependency inversion). Shared transaction input types live in + `application/contracts/`; ports must not import use cases or services (`ports-are-innermost`). - **Chain-family differences** are isolated per family — `application/use-cases//`, `adapters/outbound/chain//`, and the family plugin under `bootstrap/families/`. Both `tron` and `evm` are registered unconditionally (`bootstrap/composition.ts`) and reachable: the - builtin networks and aliases cover ETH, Sepolia, BSC and BSC testnet alongside the TRON three. - There is no family-level feature gate. EVM simply binds a narrower command set (~22 bindings: - account, block, tx, token, contract, message/typed-data signing) against TRON's ~53, which adds + builtin networks and aliases cover ETH, Sepolia, BSC, BSC testnet, Base and Base Sepolia alongside the TRON three. + There is no family-level feature gate. EVM simply binds a narrower command set (29 bindings: + account, block, chain, tx, token, contract, message/typed-data signing and eight ERC-8004 identity commands) against TRON's 78, which adds stake, permission, proposal, asset, GasFree and TronLink multisig. - **A single Zod schema per command** drives validation, yargs arity, help text, and JSON Schema. - **Secrets** (private keys, mnemonics, BIP39 passphrases) are encrypted at rest and never accepted diff --git a/ts/.dependency-cruiser.cjs b/ts/.dependency-cruiser.cjs index e2e0ac004..ed6435a70 100644 --- a/ts/.dependency-cruiser.cjs +++ b/ts/.dependency-cruiser.cjs @@ -8,6 +8,12 @@ */ module.exports = { forbidden: [ + { + name: "ports-are-innermost", + severity: "error", + from: { path: "^src/application/ports/" }, + to: { path: "^src/application/(use-cases|services)/" }, + }, { name: "no-circular", severity: "error", diff --git a/ts/README.md b/ts/README.md index 21ea70bf2..beb743d36 100644 --- a/ts/README.md +++ b/ts/README.md @@ -27,17 +27,19 @@ The agent-first implementation of wallet-cli, built for automation: every comman ## Supported chains -Seven built-in networks are supported. Networks use a canonical [CAIP-2](https://chainagnostic.org/CAIPs/caip-2) `namespace:reference` id. The namespace is not the family: `eip155` is CAIP-2's namespace for EVM chains, while the family this CLI branches on is `evm`. - -| Network id | Family | Native coin | Environment | -|---|---|---|---| -| `tron:728126428` | TRON | TRX | Mainnet — **real funds** | -| `tron:3448148188` | TRON | TRX | Testnet | -| `tron:2494104990` | TRON | TRX | Testnet | -| `eip155:1` | EVM | ETH | Ethereum mainnet — **real funds** | -| `eip155:11155111` | EVM | ETH | Sepolia testnet | -| `eip155:56` | EVM | BNB | BNB Smart Chain mainnet — **real funds** | -| `eip155:97` | EVM | BNB | BNB Smart Chain testnet | +Nine built-in networks are supported. Networks use a canonical [CAIP-2](https://chainagnostic.org/CAIPs/caip-2) `namespace:reference` id. The namespace is not the family: `eip155` is CAIP-2's namespace for EVM chains, while the family this CLI branches on is `evm`. + +| Network id | Family | Native coin | Environment | +| ----------------- | ------ | ----------- | ---------------------------------------- | +| `tron:728126428` | TRON | TRX | Mainnet — **real funds** | +| `tron:3448148188` | TRON | TRX | Testnet | +| `tron:2494104990` | TRON | TRX | Testnet | +| `eip155:1` | EVM | ETH | Ethereum mainnet — **real funds** | +| `eip155:11155111` | EVM | ETH | Sepolia testnet | +| `eip155:56` | EVM | BNB | BNB Smart Chain mainnet — **real funds** | +| `eip155:97` | EVM | BNB | BNB Smart Chain testnet | +| `eip155:8453` | EVM | ETH | Base mainnet — **real funds** | +| `eip155:84532` | EVM | ETH | Base Sepolia testnet | One seed produces a TRON address and a different EVM address. Each address is reused within its family, while balances, tokens, and transactions remain isolated per network. TRON uses the `tron-resource` fee model (bandwidth + energy); EVM networks use gas. See [networks](docs/concepts/networks.md) and [energy & bandwidth](docs/concepts/energy-bandwidth.md). @@ -111,75 +113,75 @@ Every command — including every subcommand — has its own reference page; the Create, import, and manage local wallets and accounts. -| Command | Description | -|---|---| -| [`create`](docs/commands/create.md) | Create a new HD wallet (BIP39 seed) | -| `import` | Import a wallet — [mnemonic](docs/commands/import/mnemonic.md) · [private-key](docs/commands/import/private-key.md) · [keystore](docs/commands/import/keystore.md) · [ledger](docs/commands/import/ledger.md) · [watch](docs/commands/import/watch.md)-only | -| [`list`](docs/commands/list.md) | List wallets and accounts | -| [`use`](docs/commands/use.md) · [`current`](docs/commands/current.md) | Set / show the active account (`current --qr` for a receive QR) | -| [`derive`](docs/commands/derive.md) | Derive the next HD account from a seed wallet | -| [`rename`](docs/commands/rename.md) · [`backup`](docs/commands/backup.md) · [`delete`](docs/commands/delete.md) | Rename, back up, or delete an account (backup writes secret + metadata, mode 0600; `--keystore` for Web3 keystore format, `--records` for the export audit log) | -| [`change-password`](docs/commands/change-password.md) | Change the master password (re-encrypt all software keystores) | +| Command | Description | +| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`create`](docs/commands/create.md) | Create a new HD wallet (BIP39 seed) | +| `import` | Import a wallet — [mnemonic](docs/commands/import/mnemonic.md) · [private-key](docs/commands/import/private-key.md) · [keystore](docs/commands/import/keystore.md) · [ledger](docs/commands/import/ledger.md) · [watch](docs/commands/import/watch.md)-only | +| [`list`](docs/commands/list.md) | List wallets and accounts | +| [`use`](docs/commands/use.md) · [`current`](docs/commands/current.md) | Set / show the active account (`current --qr` for a receive QR) | +| [`derive`](docs/commands/derive.md) | Derive the next HD account from a seed wallet | +| [`rename`](docs/commands/rename.md) · [`backup`](docs/commands/backup.md) · [`delete`](docs/commands/delete.md) | Rename, back up, or delete an account (backup writes secret + metadata, mode 0600; `--keystore` for Web3 keystore format, `--records` for the export audit log) | +| [`change-password`](docs/commands/change-password.md) | Change the master password (re-encrypt all software keystores) | ### Transactions Send, broadcast, inspect, and co-sign transactions. -| Command | Description | -|---|---| -| [`tx send`](docs/commands/tx/send.md) | Send native TRX or TRC20/TRC10 tokens | -| [`tx broadcast`](docs/commands/tx/broadcast.md) | Broadcast a presigned transaction | -| [`tx status`](docs/commands/tx/status.md) · [`tx info`](docs/commands/tx/info.md) | Confirmation status, or full detail + receipt | +| Command | Description | +| --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| [`tx send`](docs/commands/tx/send.md) | Send native TRX or TRC20/TRC10 tokens | +| [`tx broadcast`](docs/commands/tx/broadcast.md) | Broadcast a presigned transaction | +| [`tx status`](docs/commands/tx/status.md) · [`tx info`](docs/commands/tx/info.md) | Confirmation status, or full detail + receipt | | [`tx sign`](docs/commands/tx/sign.md) · [`tx approvals`](docs/commands/tx/approvals.md) · [`tx multisig`](docs/commands/tx/multisig.md) | Co-sign multi-sig transactions and inspect approvals | ### On-chain queries Read account, block, and chain state. -| Command | Description | -|---|---| +| Command | Description | +| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | [`account balance`](docs/commands/account/balance.md) · [`info`](docs/commands/account/info.md) · [`portfolio`](docs/commands/account/portfolio.md) | Balance, raw account data, or balances with USD estimate | -| [`account history`](docs/commands/account/history.md) | Transaction history (requires TronGrid) | -| [`account activate`](docs/commands/account/activate.md) · [`set`](docs/commands/account/set.md) | Activate an account, or set its on-chain name / ID | -| [`block`](docs/commands/block.md) | Get a block (latest if omitted) | -| [`chain params`](docs/commands/chain/params.md) · [`prices`](docs/commands/chain/prices.md) · [`node`](docs/commands/chain/node.md) | Governance params, resource prices, or node status | +| [`account history`](docs/commands/account/history.md) | Transaction history (requires TronGrid) | +| [`account activate`](docs/commands/account/activate.md) · [`set`](docs/commands/account/set.md) | Activate an account, or set its on-chain name / ID | +| [`block`](docs/commands/block.md) | Get a block (latest if omitted) | +| [`chain params`](docs/commands/chain/params.md) · [`prices`](docs/commands/chain/prices.md) · [`node`](docs/commands/chain/node.md) | Governance params, resource prices, or node status | ### Tokens, contracts, staking, signing Token and contract operations, resource staking, voting rewards, message signing, and permissions. -| Command | Description | -| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [`token`](docs/commands/token/index.md) | Token address book and queries ([balance](docs/commands/token/balance.md) · [info](docs/commands/token/info.md) · [add](docs/commands/token/add.md) · [list](docs/commands/token/list.md) · [remove](docs/commands/token/remove.md)) | -| [`contact`](docs/commands/contact/index.md) | Recipient contact book ([add](docs/commands/contact/add.md) · [list](docs/commands/contact/list.md) · [remove](docs/commands/contact/remove.md)) | +| Command | Description | +| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`token`](docs/commands/token/index.md) | Token address book and queries ([balance](docs/commands/token/balance.md) · [info](docs/commands/token/info.md) · [add](docs/commands/token/add.md) · [list](docs/commands/token/list.md) · [remove](docs/commands/token/remove.md)) | +| [`contact`](docs/commands/contact/index.md) | Recipient contact book ([add](docs/commands/contact/add.md) · [list](docs/commands/contact/list.md) · [remove](docs/commands/contact/remove.md)) | | [`contract`](docs/commands/contract/index.md) | Call, send, deploy, inspect, and govern contracts ([call](docs/commands/contract/call.md) · [send](docs/commands/contract/send.md) · [deploy](docs/commands/contract/deploy.md) · [info](docs/commands/contract/info.md) · [clear-abi](docs/commands/contract/clear-abi.md) · [set-origin-energy-limit](docs/commands/contract/set-origin-energy-limit.md) · [set-user-resource-percent](docs/commands/contract/set-user-resource-percent.md) · [create2](docs/commands/contract/create2.md)) | -| [`stake`](docs/commands/stake/index.md) | Stake / delegate resources ([freeze](docs/commands/stake/freeze.md) · [unfreeze](docs/commands/stake/unfreeze.md) · [delegate](docs/commands/stake/delegate.md) · [info](docs/commands/stake/info.md), …) | -| [`vote`](docs/commands/vote/index.md) · [`reward`](docs/commands/reward/index.md) | Vote for super representatives and claim voting rewards | -| [`message`](docs/commands/message/index.md) · [`typed-data`](docs/commands/typed-data/index.md) | Sign arbitrary messages, or EIP-712/TIP-712 structured data | -| [`permission`](docs/commands/permission/index.md) | View / update account permissions for multi-sig | -| [`gasfree`](docs/commands/gasfree/index.md) | Gas-free token transfers via the GasFree service | +| [`stake`](docs/commands/stake/index.md) | Stake / delegate resources ([freeze](docs/commands/stake/freeze.md) · [unfreeze](docs/commands/stake/unfreeze.md) · [delegate](docs/commands/stake/delegate.md) · [info](docs/commands/stake/info.md), …) | +| [`vote`](docs/commands/vote/index.md) · [`reward`](docs/commands/reward/index.md) | Vote for super representatives and claim voting rewards | +| [`message`](docs/commands/message/index.md) · [`typed-data`](docs/commands/typed-data/index.md) | Sign arbitrary messages, or EIP-712/TIP-712 structured data | +| [`permission`](docs/commands/permission/index.md) | View / update account permissions for multi-sig | +| [`gasfree`](docs/commands/gasfree/index.md) | Gas-free token transfers via the GasFree service | ### Governance, TRC10, and the on-chain exchange Chain governance, super-representative operation, and TRON's protocol-level TRC10 and Bancor exchange mechanics. -| Command | Description | -|---|---| -| [`proposal`](docs/commands/proposal/index.md) | Chain-parameter proposals ([list](docs/commands/proposal/list.md) · [show](docs/commands/proposal/show.md) · [create](docs/commands/proposal/create.md) · [approve](docs/commands/proposal/approve.md) · [delete](docs/commands/proposal/delete.md)) — `list` / `show` are open to anyone, the write commands require a registered witness | -| [`witness`](docs/commands/witness/index.md) | Register and operate a super representative ([create](docs/commands/witness/create.md) · [update](docs/commands/witness/update.md) · [set-brokerage](docs/commands/witness/set-brokerage.md)) | -| [`asset`](docs/commands/asset/index.md) | Issue and manage TRC10 tokens ([issue](docs/commands/asset/issue.md) · [update](docs/commands/asset/update.md) · [participate](docs/commands/asset/participate.md) · [unfreeze](docs/commands/asset/unfreeze.md) · [info](docs/commands/asset/info.md) · [list](docs/commands/asset/list.md)); TRC10 transfers go through [`tx send`](docs/commands/tx/send.md) | -| [`exchange`](docs/commands/exchange/index.md) | The protocol-level Bancor exchange between TRX and TRC10 ([create](docs/commands/exchange/create.md) · [inject](docs/commands/exchange/inject.md) · [withdraw](docs/commands/exchange/withdraw.md) · [trade](docs/commands/exchange/trade.md) · [show](docs/commands/exchange/show.md) · [list](docs/commands/exchange/list.md)) | +| Command | Description | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`proposal`](docs/commands/proposal/index.md) | Chain-parameter proposals ([list](docs/commands/proposal/list.md) · [show](docs/commands/proposal/show.md) · [create](docs/commands/proposal/create.md) · [approve](docs/commands/proposal/approve.md) · [delete](docs/commands/proposal/delete.md)) — `list` / `show` are open to anyone, the write commands require a registered witness | +| [`witness`](docs/commands/witness/index.md) | Register and operate a super representative ([create](docs/commands/witness/create.md) · [update](docs/commands/witness/update.md) · [set-brokerage](docs/commands/witness/set-brokerage.md)) | +| [`asset`](docs/commands/asset/index.md) | Issue and manage TRC10 tokens ([issue](docs/commands/asset/issue.md) · [update](docs/commands/asset/update.md) · [participate](docs/commands/asset/participate.md) · [unfreeze](docs/commands/asset/unfreeze.md) · [info](docs/commands/asset/info.md) · [list](docs/commands/asset/list.md)); TRC10 transfers go through [`tx send`](docs/commands/tx/send.md) | +| [`exchange`](docs/commands/exchange/index.md) | The protocol-level Bancor exchange between TRX and TRC10 ([create](docs/commands/exchange/create.md) · [inject](docs/commands/exchange/inject.md) · [withdraw](docs/commands/exchange/withdraw.md) · [trade](docs/commands/exchange/trade.md) · [show](docs/commands/exchange/show.md) · [list](docs/commands/exchange/list.md)) | ### Local tools and configuration Offline local commands and configuration. -| Command | Description | -|---|---| -| [`encoding convert`](docs/commands/encoding/convert.md) | Convert / validate addresses and encodings | +| Command | Description | +| ------------------------------------------------------- | --------------------------------------------- | +| [`encoding convert`](docs/commands/encoding/convert.md) | Convert / validate addresses and encodings | | [`address generate`](docs/commands/address/generate.md) | Generate a random keypair (local, not stored) | -| [`config`](docs/commands/config.md) | Show / get / set configuration values | -| [`networks`](docs/commands/networks.md) | List known networks | +| [`config`](docs/commands/config.md) | Show / get / set configuration values | +| [`networks`](docs/commands/networks.md) | List known networks | ## The contract, in one paragraph @@ -199,3 +201,44 @@ TRON differs a lot from EVM chains in fees, accounts, and key permissions — th A command errored or behaved unexpectedly? Common issues and how to diagnose them are in [troubleshooting.md](docs/troubleshooting.md). > Copy-pasteable examples that spend anything target a testnet — **Nile** (`--network tron:3448148188`) on TRON, **Sepolia** (`--network eip155:11155111`) on EVM. Mainnet ids (`tron:728126428`, `eip155:1`) also appear: in read-only examples such as token-book listings and config paths, and in a few illustrations of mainnet token contracts. Those last ones carry placeholder recipients (`T...` / `0x...`) and are not runnable as written. + +## ERC-8004 beta integration + +The `8004` command group uses `@bankofai/8004-sdk@1.2.0-beta.1`. + +```sh +wallet-cli 8004 show eip155:97:42 --network bsc-testnet --output json +wallet-cli 8004 register 'data:application/json;base64,eyJuYW1lIjoiRXhhbXBsZSJ9' --network nile --dry-run +wallet-cli 8004 approve 42 --revoke --network nile +wallet-cli 8004 operator-check --network nile +``` + +Agent IDs are decimal uint256 strings; scoped IDs must match the selected network. +HTTP(S), IPFS and base64 JSON data registration URIs are supported (maximum 2048 +characters on register/update). Metadata loading is bounded and failures preserve +chain fields with a warning. `show` and `operator-check` do not require a wallet. +Write commands retain the normal wallet transaction modes. `--wait` reports the +registered ID or re-reads URI/owner after successful confirmation; an unconfirmed +transaction is returned as submitted and must not be blindly retried. + +Registry configuration stays in the SDK; signing and broadcasting stay in the wallet +transaction pipeline. See [the SDK integration](docs/development/erc8004-sdk-integration.md). + +## B.AI usage and x402 providers + +```sh +wallet-cli bai usage --output json +wallet-cli bai usage-list --limit 20 --output json +wallet-cli x402 provider-list --output json +``` + +`bai usage` reads the service's `usage.summary`: current credit balance, +current-month spend, and monthly trend. It accepts no date filters and does not +aggregate usage records locally. `bai usage-list` exposes `hasMore` and +`nextCursor`; pass `--cursor` to continue listing records. B.AI account reads +require the configured API key but no wallet signature. + +x402 payments use the selected wallet account through the payer signer bridge, +including the existing device precheck and signing ceremony. Payment guards +validate the declared payer and configured GasFree fee ceiling. Base USDC, +BSC, and TRON routes are supported according to the provider's challenge. diff --git a/ts/docs/commands/networks.md b/ts/docs/commands/networks.md index ce91b4720..4c78d645a 100644 --- a/ts/docs/commands/networks.md +++ b/ts/docs/commands/networks.md @@ -36,6 +36,8 @@ wallet-cli networks | eip155:11155111 | sepolia | evm | 11155111 | evm-gas | ethereum-sepolia-rpc.publicnode.com | | eip155:56 | bsc | evm | 56 | evm-gas | bsc-dataseed.bnbchain.org | | eip155:97 | bsc-testnet | evm | 97 | evm-gas | bsc-testnet-dataseed.bnbchain.org | +| eip155:8453 | base | evm | 8453 | evm-gas | mainnet.base.org | +| eip155:84532 | base-sepolia | evm | 84532 | evm-gas | sepolia.base.org | ``` ```bash @@ -43,7 +45,7 @@ wallet-cli networks -o json ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"networks","data":[{"id":"tron:728126428","alias":"tron","family":"tron","chainId":"728126428","feeModel":"tron-resource","endpoint":"api.trongrid.io"},{"id":"tron:3448148188","alias":"nile","family":"tron","chainId":"3448148188","feeModel":"tron-resource","endpoint":"nile.trongrid.io"},{"id":"tron:2494104990","alias":"shasta","family":"tron","chainId":"2494104990","feeModel":"tron-resource","endpoint":"api.shasta.trongrid.io"},{"id":"eip155:1","alias":"ethereum","family":"evm","chainId":"1","feeModel":"evm-gas","endpoint":"ethereum-rpc.publicnode.com"},{"id":"eip155:11155111","alias":"sepolia","family":"evm","chainId":"11155111","feeModel":"evm-gas","endpoint":"ethereum-sepolia-rpc.publicnode.com"},{"id":"eip155:56","alias":"bsc","family":"evm","chainId":"56","feeModel":"evm-gas","endpoint":"bsc-dataseed.bnbchain.org"},{"id":"eip155:97","alias":"bsc-testnet","family":"evm","chainId":"97","feeModel":"evm-gas","endpoint":"bsc-testnet-dataseed.bnbchain.org"}],"meta":{"durationMs":2,"warnings":[]}} +{"schema":"wallet-cli.result.v1","success":true,"command":"networks","data":[{"id":"tron:728126428","alias":"tron","family":"tron","chainId":"728126428","feeModel":"tron-resource","endpoint":"api.trongrid.io"},{"id":"tron:3448148188","alias":"nile","family":"tron","chainId":"3448148188","feeModel":"tron-resource","endpoint":"nile.trongrid.io"},{"id":"tron:2494104990","alias":"shasta","family":"tron","chainId":"2494104990","feeModel":"tron-resource","endpoint":"api.shasta.trongrid.io"},{"id":"eip155:1","alias":"ethereum","family":"evm","chainId":"1","feeModel":"evm-gas","endpoint":"ethereum-rpc.publicnode.com"},{"id":"eip155:11155111","alias":"sepolia","family":"evm","chainId":"11155111","feeModel":"evm-gas","endpoint":"ethereum-sepolia-rpc.publicnode.com"},{"id":"eip155:56","alias":"bsc","family":"evm","chainId":"56","feeModel":"evm-gas","endpoint":"bsc-dataseed.bnbchain.org"},{"id":"eip155:97","alias":"bsc-testnet","family":"evm","chainId":"97","feeModel":"evm-gas","endpoint":"bsc-testnet-dataseed.bnbchain.org"},{"id":"eip155:8453","alias":"base","family":"evm","chainId":"8453","feeModel":"evm-gas","endpoint":"mainnet.base.org"},{"id":"eip155:84532","alias":"base-sepolia","family":"evm","chainId":"84532","feeModel":"evm-gas","endpoint":"sepolia.base.org"}],"meta":{"durationMs":2,"warnings":[]}} ``` ## Output diff --git a/ts/docs/concepts/networks.md b/ts/docs/concepts/networks.md index 424617953..8e5467de8 100644 --- a/ts/docs/concepts/networks.md +++ b/ts/docs/concepts/networks.md @@ -16,6 +16,8 @@ wallet-cli networks | eip155:11155111 | sepolia | evm | 11155111 | evm-gas | ethereum-sepolia-rpc.publicnode.com | | eip155:56 | bsc | evm | 56 | evm-gas | bsc-dataseed.bnbchain.org | | eip155:97 | bsc-testnet | evm | 97 | evm-gas | bsc-testnet-dataseed.bnbchain.org | +| eip155:8453 | base | evm | 8453 | evm-gas | mainnet.base.org | +| eip155:84532 | base-sepolia | evm | 84532 | evm-gas | sepolia.base.org | ``` | Id | Alias | What it is | Native coin value | @@ -27,6 +29,8 @@ wallet-cli networks | `eip155:11155111` | `sepolia` | Ethereum test network | none | | `eip155:56` | `bsc` | BNB Smart Chain | **Real money** | | `eip155:97` | `bsc-testnet` | BNB Smart Chain test network | none | +| `eip155:8453` | `base` | Base mainnet | **Real money** | +| `eip155:84532` | `base-sepolia` | Base test network | none | An **alias** is a short name you may type instead of the id. It resolves once, at selection, and nothing downstream ever sees it — `chain.network` in the JSON envelope always reports the canonical id. Aliases live in config and can be re-pointed, so scripts should pass canonical ids. diff --git a/ts/docs/concepts/provider-catalog.md b/ts/docs/concepts/provider-catalog.md new file mode 100644 index 000000000..e3205c043 --- /dev/null +++ b/ts/docs/concepts/provider-catalog.md @@ -0,0 +1,7 @@ +# Provider catalog queries and snapshots + +`wallet-cli x402 provider-list`, `provider-show` and `provider-endpoints` query the online catalog. They do not read the local snapshot or silently fall back to stale data. + +`wallet-cli x402 provider-update` downloads a catalog snapshot for inspection or external tooling. Its result includes the `cache` file path. Updating this snapshot does not change subsequent online queries. Offline catalog lookup is not currently implemented. + +Catalog requests honor the CLI `--timeout` value and limit response bodies to 10 MiB, including responses without a Content-Length header. Facilitator requests use the same configured per-request timeout and a 1 MiB response limit. The timeout does not stop the lifetime of a running `x402 serve` process. diff --git a/ts/docs/development/bai-recharge.md b/ts/docs/development/bai-recharge.md new file mode 100644 index 000000000..e8e77d017 --- /dev/null +++ b/ts/docs/development/bai-recharge.md @@ -0,0 +1,178 @@ +# B.AI recharge + +B.AI now authenticates recharge operations with each user's personal API key. +The CLI calls B.AI directly to resolve the credit recipient, create a preorder, +and report the payment transaction. The selected wallet signs the payment. + +## Payment flow + +1. Check the local confirmation for the API key, payer wallet and mainnet. +2. Validate the amount and trusted platform destination. Resolve `--to` when it + identifies another B.AI user. +3. Create the preorder with the personal API key. +4. Call the existing `X402Service.roundtrip()` with the platform destination, + token and exact amount. It starts a temporary endpoint on `127.0.0.1` using + an automatically allocated port, pays through `X402PaymentClient`, and closes + the endpoint in `finally`. +5. The local endpoint calls the facilitator's `/verify` and `/settle` endpoints. + Wallet account selection and signing use the existing x402 signer bridge. +6. Validate the successful settlement, network, transaction hash and payer. + Call `order.reportTxHash` with the original chain, amount and credit target. + +The personal API key is sent only to B.AI business APIs. Neither the local payment +endpoint nor the facilitator receives it. `--to` selects the account receiving +credits; the on-chain recipient is always the platform address. + +The CLI no longer calls the old recharge MCP or its merchant credit endpoint. +That removes a second credit-reporting path and a second set of recharge-server +configuration. The existing x402 SDK, facilitator and `roundtrip` remain in use. +Retiring the deployed recharge server is a separate operation. + +## Wallet binding and signed message + +Binding uses the personal API key to identify the B.AI user. Before signing, +construct the message using the recharge binding template from the updated API +specification. Arbitrary test text is rejected with `WalletInvalidSignature`, +even when the signature recovers the correct wallet address locally. + +```javascript +const message = [ + "Welcome to BAI !", + `${origin} wants you to confirm wallet binding for recharge:`, + address, + "", + `Chain ID: ${chainId}`, + `Expiration Time: ${expirationTime}`, + `Nonce: ${nonce}`, +].join("\n"); +``` + +For production, `origin` is `https://chat.bankofai.io`; the specification's +`https://chat-dev.b.ai` is the development example. Use the origin of the target +B.AI deployment. Mainnet chain IDs are `728126428` (TRON), `8453` (Base), and +`56` (BNB Chain). The live test used an ISO 8601 UTC expiration five minutes ahead +and a fresh 16-byte random nonce encoded as 32 hexadecimal characters; these are +verified client choices, not documented server limits or a server-issued challenge. + +Select the wallet explicitly when signing: + +```bash +wallet-cli message sign --account --network \ + --message "$message" --password-stdin -o json +``` + +Pass the master password through stdin. Send the returned `address`, unchanged +`message`, and `signature` to `POST /trpc/lambda/wallet.bindRechargeWallet`, inside +`{"json":{...}}`, with the personal API key as Bearer authentication. Set `chain` +to `tron`, `base`, or `bnb`; `version: 2` selects TRON V2 signing and was also +accepted on both EVM chains. Never trim, reformat, or rebuild the message after +signing. Binding signatures authorize account association; this step sends no +payment transaction. + +The backend canonicalizes EVM binding responses: `chain` becomes `eth`, and the +address is lowercase. The adapter accepts that family alias for `bnb`/`base`/`eth` +and compares EVM addresses without case sensitivity, while still rejecting another +address or unrelated chain. TRON addresses remain case-sensitive. The adapter +returns the server's canonical binding; subsequent network-specific checks still +use the original `base` or `bnb` request chain. + +On 2026-09-09, three different wallets were signed with Wallet CLI and bound using +one personal API key. Every successful binding returned the same user ID. After +each binding, all three original chain/address pairs were queried through +`wallet.isRechargeBound` using that same key: + +| After binding | TRON wallet | Base wallet | BNB Chain wallet | +| --- | --- | --- | --- | +| TRON | true | false | false | +| Base | true | true | false | +| BNB Chain | true | true | true | + +This verifies those three bindings coexist on the server; it does not establish an +unlimited wallet count or prove recharge settlement. No funds were transferred. +The local `bai-binding.json` still stores only the last confirmed API-key/chain/address +fingerprint. Switching wallet or network requires configuring the same key again +for that selection to refresh local confirmation; this does not remove server +bindings. CLI credential setup checks existing bindings, rather than creating one. +`BaiRechargeClient.bind()` accepts an already signed message; there is no automatic +binding or new binding command in this change. + +## Networks and payment requirements + +| Network | Token | B.AI payment scheme | +| --- | --- | --- | +| TRON mainnet | USDT, USDD | exact / Permit2 | +| BNB Chain mainnet | USDT | exact / Permit2 | +| Base mainnet | USDC | exact / EIP-3009 | + +B.AI uses `exact`; it does not select GasFree automatically. Generic x402 commands +continue to support TRON `exact_gasfree`. The local server owns token metadata, +including Base USDC's six decimals and EIP-712 domain version `2`. + +Platform addresses live in `adapters/outbound/config/bai-builtins.ts`. Only TRON, +BNB Chain and Base are retained. The allowlist cannot be overridden by user +configuration; changing it requires a CLI release. Minimum recharge rules remain +in `domain/bai/recharge-policy.ts`. + +Roundtrip enforces token, scheme, destination and exact amount before signing. +The explicit maximum equals the requested amount. On TRON, the SDK checks Permit2 +allowance and automatically signs, broadcasts and waits for an approval when it is +insufficient and the server has not declared approval resource sponsoring. The SDK +approves the maximum uint256 amount. A failed approval stops payment; tokens that +require resetting an existing allowance to zero may still require manual handling. +When the server declares approval resource sponsoring, the signed approval is sent +in the extension instead. EVM self-funded approval fallback is not implemented. +For EVM approval sponsoring, the x402 signer bridge maps the SDK transaction +`gas` field to wallet `gasLimit`, preserving an explicit `gasLimit` when supplied. + +## Failure and verification + +The roundtrip port validates the token and decimal precision before target resolution +and preorder creation, using the same adapter rules as server startup. Classified +payment errors retain their codes and any settlement evidence through the recharge +flow. Preorder failure stops payment. An uncertain payment is never retried automatically. +Only a successful settlement with a valid hash and matching network can be reported. +Reporting failure preserves the hash, original target and `retryPayment: false`. +`bai recharge-report --chain tron|bnb|base [--amount ]` +retries reporting without creating an order, resolving a recipient, signing or +paying. It requires the original personal API key but no local wallet. For another +recipient, supply both `--to ` and `--target-id ` +from `rechargeTarget`; omit both only for self recharge. Backend verification remains +authoritative. A persistent recovery log is not implemented; retain the JSON result. + +Settlement validation failures preserve a syntactically valid hash as +`details.candidateTxHash`, with a fixed `reason`, `paymentStatus: unknown`, +`settled: false` and `retryPayment: false`. Original chain, amount and recipient are +retained by the recharge flow. A candidate is evidence for reconciliation, not a +confirmed payment: verify it before using the report-only command. + +Tests cover the local HTTP roundtrip with the installed SDK on BSC and Base, +settlement validation, endpoint cleanup, self/recipient CLI orchestration and +reporting failure. The facilitator and B.AI backend are mocked. Real settlement, +credit attribution, repeated reporting and Ledger operation still need live +integration verification. + +## API failure diagnostics + +Both B.AI API adapters decode bounded HTTP error bodies and tRPC error envelopes. +Recognized business failures return `bai_rejected` with a fixed explanatory message +and `details.reason`, `procedure`, `httpStatus`, and `retryPayment: false`. +The recognized reasons are `WalletInvalidSignature`, `UNSUPPORTED_CHAIN`, +`TX_NOT_FOUND_OR_INVALID`, `UNSUPPORTED_TOKEN`, `PAYER_MISMATCH`, `WALLET_NOT_BOUND`, +`RECHARGE_TX_TOO_OLD`, `TX_TIMESTAMP_UNAVAILABLE`, `PRICE_UNAVAILABLE`, +`RECHARGE_AMOUNT_TOO_SMALL`, and `SELF_RECHARGE_TARGET` (the documented Chinese +self-recipient error). Signature rejection explains the required message fields +and wallet selection rather than blaming the signer. + +HTTP 401/403 retain `bai_auth_failed`; 429 retains `provider_rate_limited` without +waiting for an error body. Timeouts, oversized responses, malformed JSON and +connection failures remain distinguishable. Unknown server messages are never +copied into output; callers receive the operation, HTTP status and a safe message. +Invalid local recharge request fields return `invalid_value` before HTTP. + +A report result with `success: false` retains its existing business `code` and adds +a locally defined explanation. The recharge/recharge-report result keeps the hash, +original target, `creditStatus: unconfirmed` and `retryPayment: false`. Thrown API +errors also retain their structured error envelope inside that result. These are +credit failures after payment, not permission to repeat the payment. A failure to +retrieve a price or timestamp suggests retrying reporting only. Unknown report +codes retain the bounded code and a generic reconciliation instruction. diff --git a/ts/docs/development/beta-release.md b/ts/docs/development/beta-release.md new file mode 100644 index 000000000..8f67e6569 --- /dev/null +++ b/ts/docs/development/beta-release.md @@ -0,0 +1,67 @@ +# TypeScript CLI beta release + +Release the TypeScript npm package as `@tron-walletcli/wallet-cli@4.14.0-beta.1` with the `beta` dist-tag. Keep the stable `latest` tag unchanged. The standalone Actions workflow builds downloadable artifacts; it does not publish the npm package automatically. + +## Prepare + +Use a clean checkout of the reviewed commit and Node.js 22. The package uses an explicit public-document allowlist; internal development and API reports are excluded. Run `npm run verify:package` to check the packed files and independently installed executable. + +Run from `ts/`: + +```sh +npm version 4.14.0-beta.1 --no-git-tag-version +npm ci +npm run typecheck +npm run lint +npm run format:check +npm run depcruise +npm test -- --maxWorkers=4 +npm run build +npm run verify:package +npm pack +``` + +The CLI version comes from `package.json`. The x402 core, TRON, EVM and fetch packages are build dependencies compiled into the CLI bundle, preserving the tested SDK implementations. Root-only npm overrides are insufficient for consumer installations. + +## Validate the actual tarball + +Install the generated tarball into a fresh directory outside the repository: + +```sh +npm init -y +npm install /absolute/path/to/tron-walletcli-wallet-cli-4.14.0-beta.1.tgz +./node_modules/.bin/wallet-cli --version +./node_modules/.bin/wallet-cli --help +``` + +Require CLI version `4.14.0-beta.1`. Verify TRON `2.0.0-beta.1` and core `1.1.1-beta.1` in the build checkout with `npm ls @bankofai/x402-core @bankofai/x402-tron`; the installed CLI must not import external x402 packages. Verify the installed entry point with mocked provider challenges on Base/BSC/TRON/Nile, BAI summary and rejection of Nile recharge, and 8004 reads and dry-run/build-only transactions. + +From the build checkout, run the artifact checks against that installed executable: + +```sh +WALLET_CLI_TEST_ENTRY=/absolute/path/to/node_modules/@tron-walletcli/wallet-cli/dist/index.js \ + npx vitest run test/x402-provider-payment.test.ts test/bai-nile-compatibility.test.ts test/erc8004.test.ts test/beta-artifact-signing.test.ts --maxWorkers=4 +``` + +These checks include real signing with a temporary encrypted wallet and simulated HTTP responses, without broadcasting a transaction. + +Before claiming production readiness, separately verify real chain receipts, Permit2 allowance requirements, BAI self/recipient credit attribution and duplicate transaction reporting with the backend. Offline settlement tests simulate broadcast and receipts; they do not establish actual chain execution. Physical Ledger verification remains a separate check. + +## Publish the verified artifact + +Use an npm account with write permission for this package. Publish the exact tested tarball: + +```sh +npm whoami +npm publish /absolute/path/to/tron-walletcli-wallet-cli-4.14.0-beta.1.tgz --tag beta --access public +npm view @tron-walletcli/wallet-cli dist-tags --json +``` + +Record the source commit, tarball SHA-256 and validation results. After publishing, confirm installation from the registry: + +```sh +npm install -g @tron-walletcli/wallet-cli@4.14.0-beta.1 +wallet-cli --version +``` + +A subsequent beta must use a new version, such as `4.14.0-beta.2`. Publish standalone archives separately after the platform workflow succeeds, and mark their GitHub release as a prerelease. diff --git a/ts/docs/development/erc8004-sdk-integration.md b/ts/docs/development/erc8004-sdk-integration.md new file mode 100644 index 000000000..884965dd9 --- /dev/null +++ b/ts/docs/development/erc8004-sdk-integration.md @@ -0,0 +1,32 @@ +# ERC-8004 SDK integration + +The SDK supplies registry configuration and ABIs. Wallet account selection, signing, +device interaction and transaction broadcasting belong to wallet-cli. No SDK +`ExternalSigner` adapter is required or planned for this integration. + +## What is already connected + +The CLI's eight Identity commands use the SDK's network configuration and ABI +through `adapters/outbound/erc8004/sdk-registry.ts`. RPC reads and receipt reads +continue through the wallet's configured gateway, preserving the API-key header, +timeout and TRON pacing. No indexer or subgraph is required for `show`. + +Transactions continue through existing EVM/TRON ContractService and TxPipeline. +That path already supports the wallet's existing software/device signers. This +change does not disable it while waiting for a new adapter. It retains dry-run, +build-only, sign-only, permission-id, expiration and `--wait` behavior. The SDK +registry adapter never calls SDK submit methods and never broadcasts. + +`show` returns authoritative chain fields and optional registration metadata. +Metadata failures produce warnings. Registration gets a minted agentId only +from a confirmed receipt. Update/transfer preserve submitted results and add +observed current URI/owner after confirmation. Failed post-confirmation reads +never cause a second transaction. Per-Agent approvals are ERC721 approvals: +`operator` and decimal `agentId`, not a fungible `allowance`. + +## Signing boundary + +The eight identity commands continue through ContractService and TxPipeline. +They do not call SDK submission methods and do not supply private keys to the SDK. +The SDK's custom external-signer extension was removed in `1.2.0-beta.1`; +this does not remove wallet-cli's independent x402 signer bridge. diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index 25e220535..52b313645 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -244,6 +244,9 @@ Common codes at exit **1** (execution — runtime failure): | `auth_failed` | Wrong master password (decryption failed) | | `signing_rejected` / `transaction_rejected` | Signing or broadcast rejected (device or chain) | | `watch_only_no_signer` | The account is watch-only and cannot sign | +| `payer_mismatch` | An x402 payment payload names a payer other than the selected account. The payment is refused before any signature is requested | +| `fee_cap_exceeded` | An x402 GasFree authorization's `maxFee` exceeds the ceiling the caller set | +| `signed_payload_mismatch` | The signature returned is for a different struct than the one that was requested | | `invalid_mnemonic` / `invalid_private_key` | Storage validation rejected a malformed mnemonic or private key; interactive import normally catches it at the prompt and asks again | | `token_metadata_unavailable` | Required token metadata could not be read from the selected network. This one crosses exit codes: most sites raise it at exit `1`, but `tx send` on TRON raises it at exit **2** when a contract answers no `decimals()` and the address book has no entry either — there, the call itself has to change | | `wrong_device_seed` | Connected Ledger does not match the registered account | @@ -321,6 +324,10 @@ This is a wallet; a wrong success check loses money. The rules: **Ids the chain assigns arrive only with confirmation.** A new proposal's `proposalId`, a TRC10's `assetId`, an exchange pair's `exchangeId` do not exist at submission — they are absent from the submitted receipt and appear once `--wait` (or a later query) sees the transaction on chain. Scripts that create one of these must wait for it. + **ERC-8004 write receipts group their business results under `data.identity`.** Shared fields such as `kind`, `stage`, `txId`, `tx` and `fee` remain at `data` level. The optional identity fields are `agentId`, `operator`, `uri`, `oldURI`, `requestedURI`, `newURI`, `oldOwner`, `requestedOwner` and `newOwner`. Agent IDs remain decimal strings. A registration includes `identity.uri` immediately; `identity.agentId` is added only when the confirmed registration event can be read. Update/transfer receipts include the requested state, and add the observed `newURI`/`newOwner` after confirmation. ERC721 approval receipts use `identity.operator` and `identity.agentId`, without fungible allowance fields. + + This replaces the earlier flat identity fields on write receipts: scripts must read `data.identity.agentId`, for example, instead of `data.agentId`. The read-only `8004 show` and `8004 operator-check` results retain their existing shapes. Unrelated transaction receipts omit `identity`. + 2. To block until the outcome is known, pass `--wait` (polls until confirmed/failed, capped by `--wait-timeout`, default 60000 ms; on cap it returns the submitted receipt). **A `--wait` receipt reports the transaction outcome in `data.stage`, never in `success`.** A transaction that was accepted, mined, and then reverted is a *successful command* carrying a *failed transaction*: the envelope stays `success: true` and the exit code stays `0`, while `data.stage` is `"failed"`. Exit codes say whether the CLI could carry out the request, not whether the chain accepted the result — so after any `--wait`, branch on `data.stage` (`confirmed` / `failed` / `submitted`) before recording the operation as done. @@ -402,3 +409,68 @@ Not covered: text-mode output, `error.message` wording, field ordering, `meta.du - [Scripting guide](guide/scripting.md) — a gentler introduction - [Command reference](commands/index.md) — per-command `data` payloads - [Troubleshooting](troubleshooting.md) — human-facing remedies, keyed by the error codes above + + +### x402 payment failures + +Known SDK errors retain stable codes: `gasfree_insufficient_balance`, +`gasfree_not_activated`, `permit2_allowance_required` and `approval_reset_required`. +GasFree balance includes the payment amount and maximum service fee. A GasFree +shortfall does not fall back to payment from the ordinary TRON wallet. Unknown SDK +and facilitator messages are redacted; recognized facilitator failures retain +`details.phase` (`verify` or `settle`). + +For payment errors that provide `details.paymentStatus`: + +| Value | Meaning | +| --- | --- | +| `not_sent` | The known GasFree preflight failure occurred before payment was sent | +| `unknown` | The CLI cannot establish the payment outcome; reconcile before another payment | +| `settled` | A successful settlement receipt was received, but resource processing failed | + +If JSON parsing, response buffering or writing `--out` fails after a valid +settlement header was received, the command still exits with an error. Its details +retain `txHash`, `paymentResponse` (success, network, transaction), `settled: true` +and the selected payer when available. `retryPayment: false` means do not repeat +the payment to recover the resource. This is evidence from the settlement response, +not an independent chain-finality check. The error's generic retry metadata does +not override this payment-specific guidance. + +B.AI validates the local payment token and decimal precision before creating an +order. Classified payment errors and settlement evidence survive B.AI orchestration. +EVM self-funded approval remains an explicit agent decision; no automatic fallback +is performed. + + +### B.AI recharge recovery + +When B.AI rejects settlement evidence (for example, `payer_mismatch` or +`network_mismatch`), the error retains a syntactically valid `candidateTxHash`, +`candidateNetwork` when valid, `expectedNetwork`, and a fixed `reason` in details. +It remains `paymentStatus: unknown`, `settled: false`, `retryPayment: false`. +Malformed remote values are omitted. The recharge flow also retains the original +`chain`, `amount` and `rechargeTarget` in classified payment errors. + +Use `wallet-cli bai recharge-report --chain base --amount 1` to report a +verified existing transaction after a report failure. Chain accepts `tron`, `bnb` +or `base` and must match the original recharge. Use the original personal API key. +No local wallet, signature, new order or payment is required. + +For recipient recharge, additionally pass `--to ` and +`--target-id ` together. The +command does not resolve a new recipient. Omit both only for self recharge. +Reporting failures retain recovery data and `creditStatus: unconfirmed`; successful +backend confirmation returns `creditStatus: credited`. Neither path repeats payment. +A candidate hash from failed settlement validation must be reconciled before reporting. + +### B.AI business failure details + +`bai_rejected` (exit 1) identifies a recognized B.AI rejection. Inspect +`error.details.reason` for the documented business identifier and `procedure` / +`httpStatus` for the failed API operation. Messages are fixed local explanations; +raw server prose and credentials are not returned. `retryPayment: false` means +that retry guidance applies to the API operation, not to sending funds again. +Report-only failures retain the transaction hash and `creditStatus: unconfirmed`; +the result contains a business `code` and explanatory `warning`, or an `error` +envelope for a thrown classified API failure. See `development/bai-recharge.md` +for supported reasons and recovery behavior. diff --git a/ts/package-lock.json b/ts/package-lock.json index b10b1c0f3..8bdd0796e 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -9,6 +9,7 @@ "version": "4.13.0", "license": "LGPL-3.0-or-later", "dependencies": { + "@bankofai/8004-sdk": "1.2.0-beta.1", "@ledgerhq/hw-app-eth": "^7.8.15", "@ledgerhq/hw-app-trx": "^6.36.3", "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", @@ -33,6 +34,10 @@ "wallet-cli": "dist/index.js" }, "devDependencies": { + "@bankofai/x402-core": "1.1.1-beta.1", + "@bankofai/x402-evm": "^1.1.0", + "@bankofai/x402-fetch": "^1.1.0", + "@bankofai/x402-tron": "2.0.0-beta.1", "@eslint/js": "^10.0.1", "@types/node": "^25.9.3", "@types/qrcode": "^1.5.6", @@ -71,6 +76,83 @@ "node": ">=6.9.0" } }, + "node_modules/@bankofai/8004-sdk": { + "version": "1.2.0-beta.1", + "resolved": "https://registry.npmjs.org/@bankofai/8004-sdk/-/8004-sdk-1.2.0-beta.1.tgz", + "integrity": "sha512-Lnbq+1ruQWZO/bwWu0vzBPRgmlHNcZWqSZv8pwkMvAx0x14YevNVfirouM/eFi4UCcT5cZjyxulycthk4Z1RHg==", + "license": "MIT", + "dependencies": { + "graphql-request": "^7.2.0", + "tronweb": "^6.0.4", + "viem": "^2.37.5" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@bankofai/x402-core": { + "version": "1.1.1-beta.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.1.1-beta.1.tgz", + "integrity": "sha512-xT9W1A6Sh710i09x46+KeHaQ/xtBxmWNUzYB8n00uLZ9MbfnNCBodTagPHr4g+wC1q9wiLXwcouu4zmiojWCIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@bankofai/x402-core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@bankofai/x402-evm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@bankofai/x402-evm/-/x402-evm-1.1.0.tgz", + "integrity": "sha512-HaURYqlBf97/gTAt+VFOI9xLabrRYe+KnTJ5j3jzP23onwFcxKr6kA48cO4BTLQMWZ9Tga8c3+GU5ES6ffqskg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bankofai/x402-core": "~1.1.0", + "viem": "^2.48.11", + "zod": "^3.24.2" + } + }, + "node_modules/@bankofai/x402-evm/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@bankofai/x402-fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@bankofai/x402-fetch/-/x402-fetch-1.1.0.tgz", + "integrity": "sha512-8XB04B8CEvgRvtBP7zx9yXKtrSVlme9d8iTq23LRA+PFdvKc3d24lFUVIYmZiWfFSZ5rG17qdqIdaC5o3T1+ZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bankofai/x402-core": "~1.1.0" + } + }, + "node_modules/@bankofai/x402-tron": { + "version": "2.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-2.0.0-beta.1.tgz", + "integrity": "sha512-hYcG/gMDA7LcFzv78rfj8qUmYEp4EbaMiE5n8MqPJKGzcX4XzmzOiiX/KqvW12YNrYQ8W/mfGx76EAplWhR65g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bankofai/x402-core": "~1.1.1-beta.1", + "tronweb": "^6.1.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1067,6 +1149,15 @@ "@ethersproject/strings": "^5.8.0" } }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1756,9 +1847,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1776,9 +1864,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1796,9 +1881,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1816,9 +1898,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1836,9 +1915,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1856,9 +1932,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2037,9 +2110,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2054,9 +2124,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2071,9 +2138,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2088,9 +2152,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2105,9 +2166,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2122,9 +2180,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2139,9 +2194,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2156,9 +2208,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2173,9 +2222,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2190,9 +2236,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2207,9 +2250,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2224,9 +2264,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2241,9 +2278,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2807,6 +2841,27 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -4350,6 +4405,28 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-request": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-7.4.0.tgz", + "integrity": "sha512-xfr+zFb/QYbs4l4ty0dltqiXIp07U6sl+tOKAb0t50/EnQek6CVVBLjETXi+FghElytvgaAWtIOt3EV7zLzIAQ==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0" + }, + "peerDependencies": { + "graphql": "14 - 16" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4584,6 +4661,21 @@ "dev": true, "license": "ISC" }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -4832,9 +4924,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4856,9 +4945,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4880,9 +4966,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4904,9 +4987,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5137,9 +5217,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -5266,6 +5346,117 @@ "node": ">= 0.8.0" } }, + "node_modules/ox": { + "version": "0.14.44", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.44.tgz", + "integrity": "sha512-O54qXXHEk4ySamMSyBpI0AeBegS5frxU6+LA6Jb9Sf6kMOxcTNaxYmwZfpOu22DIQsdKfgQxHq8EsAGaoBQScA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/ox/node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/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/ox/node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -6504,7 +6695,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6577,6 +6768,99 @@ "node": ">= 0.10" } }, + "node_modules/viem": { + "version": "2.56.3", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.56.3.tgz", + "integrity": "sha512-vUObq3GO7D3lz9gPNEE/xd5jIUnMbeVDWewh252o54pDEDlW4pDe6FEd/qTGL5RyK52cGHMM7kQ3wjxhilEvkQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.44", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/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/viem/node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", diff --git a/ts/package.json b/ts/package.json index d7a4b63a9..7df22651c 100644 --- a/ts/package.json +++ b/ts/package.json @@ -8,7 +8,12 @@ }, "files": [ "dist", - "docs", + "docs/commands", + "docs/concepts", + "docs/guide", + "docs/machine-interface.md", + "docs/troubleshooting.md", + "docs/development/erc8004-sdk-integration.md", "README.md", "LICENSE" ], @@ -50,10 +55,12 @@ "lint:fix": "eslint . --fix", "test": "vitest run", "test:watch": "vitest", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "verify:package": "node scripts/verify-package.mjs" }, "license": "LGPL-3.0-or-later", "dependencies": { + "@bankofai/8004-sdk": "1.2.0-beta.1", "@ledgerhq/hw-app-eth": "^7.8.15", "@ledgerhq/hw-app-trx": "^6.36.3", "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", @@ -77,9 +84,15 @@ "overrides": { "axios": "^1.18.1", "ws": "8.21.1", - "esbuild": "^0.28.1" + "esbuild": "^0.28.1", + "@bankofai/x402-core": "$@bankofai/x402-core", + "nanoid": "^3.3.18" }, "devDependencies": { + "@bankofai/x402-core": "1.1.1-beta.1", + "@bankofai/x402-evm": "^1.1.0", + "@bankofai/x402-fetch": "^1.1.0", + "@bankofai/x402-tron": "2.0.0-beta.1", "@eslint/js": "^10.0.1", "@types/node": "^25.9.3", "@types/qrcode": "^1.5.6", diff --git a/ts/scripts/verify-package.mjs b/ts/scripts/verify-package.mjs new file mode 100644 index 000000000..83659e9b8 --- /dev/null +++ b/ts/scripts/verify-package.mjs @@ -0,0 +1,50 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import assert from "node:assert/strict"; + +const root = resolve(import.meta.dirname, ".."); +const temp = mkdtempSync(join(tmpdir(), "wallet-cli-package-")); +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; +const call = (cmd, args, cwd = root, env = process.env) => + execFileSync(cmd, args, { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }); +try { + const [packed] = JSON.parse(call(npm, ["pack", "--json", "--pack-destination", temp])); + const forbidden = packed.files.filter( + ({ path }) => + path.startsWith("docs/development/") && + path !== "docs/development/erc8004-sdk-integration.md", + ); + assert.equal(forbidden.length, 0, "internal development reports must not be packaged"); + assert( + packed.files.some(({ path }) => path === "dist/index.js"), + "missing CLI entry", + ); + call(npm, ["init", "-y"], temp); + call(npm, ["install", join(temp, packed.filename), "--no-audit", "--no-fund"], temp); + const entry = join(temp, "node_modules/@tron-walletcli/wallet-cli/dist/index.js"); + const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; + assert.equal(call(process.execPath, [entry, "--version"], temp).trim(), version); + const env = { ...process.env, WALLET_CLI_TEST_ENTRY: entry }; + const output = call( + process.execPath, + [ + join(root, "node_modules/vitest/vitest.mjs"), + "run", + "test/beta-command-surface.test.ts", + "test/beta-server-roundtrip.test.ts", + "test/erc8004.test.ts", + "test/x402-provider-payment.test.ts", + "test/bai-nile-compatibility.test.ts", + "test/bai-recharge-report.test.ts", + "test/beta-artifact-signing.test.ts", + "--maxWorkers=4", + ], + root, + env, + ); + process.stdout.write(output); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/ts/src/adapters/inbound/cli/commands/bai.test.ts b/ts/src/adapters/inbound/cli/commands/bai.test.ts new file mode 100644 index 000000000..212b6a598 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/bai.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { CommandRegistry } from "../registry/index.js"; +import { registerBaiCommands } from "./bai.js"; +import type { BaiService } from "../../../../application/use-cases/bai-service.js"; + +function service(): BaiService { + return { + status: vi.fn(async () => ({ credits: "10", thisMonth: {}, trend: [] })), + usage: vi.fn(async () => ({})), + usageList: vi.fn(async () => ({ records: [], pagination: {} })), + rechargeList: vi.fn(async () => ({ orders: [], pagination: {} })), + recharge: vi.fn(async () => ({})), + } as unknown as BaiService; +} + +describe("B.AI command surface", () => { + it("registers recharge plus four API-backed read commands under bai", () => { + const registry = new CommandRegistry(); + registerBaiCommands(registry, service()); + expect( + ["status", "usage", "usage-list", "recharge-list"].map((verb) => + registry.resolveNeutral(["bai", verb])?.path.join("."), + ), + ).toEqual(["bai.status", "bai.usage", "bai.usage-list", "bai.recharge-list"]); + expect(registry.resolveNeutral(["bai", "recharge"])?.network).toBe("optional"); + }); + + it("exposes summary without dates and keeps bounded list pagination", () => { + const registry = new CommandRegistry(); + registerBaiCommands(registry, service()); + expect(Object.keys(registry.resolveNeutral(["bai", "usage"])!.fields.shape)).toEqual([]); + expect( + registry.resolveNeutral(["bai", "usage-list"])!.input.safeParse({ + limit: 20, + offset: 0, + sort: "desc", + }).success, + ).toBe(true); + expect( + registry.resolveNeutral(["bai", "usage-list"])!.input.safeParse({ limit: 1001 }).success, + ).toBe(false); + }); + + it("uses one recharge command for self recharge and recipient recharge", async () => { + const recharge = vi.fn(async (_ctx, _network, input) => input); + const registry = new CommandRegistry(); + registerBaiCommands(registry, { ...service(), recharge } as unknown as BaiService); + const command = registry.resolveNeutral(["bai", "recharge"])!; + + const selfInput = command.input.parse({ amount: "10", token: "USDT" }); + await expect( + command.run( + { config: { baiApiKey: "test-key" } } as never, + { id: "tron:728126428" } as never, + selfInput, + ), + ).resolves.toMatchObject({ amount: "10", token: "USDT" }); + + const recipientInput = command.input.parse({ + amount: "10", + token: "USDT", + to: "recipient@example.com", + }); + await expect( + command.run( + { config: { baiApiKey: "test-key" } } as never, + { id: "tron:728126428" } as never, + recipientInput, + ), + ).resolves.toMatchObject({ + amount: "10", + token: "USDT", + to: "recipient@example.com", + }); + }); +}); + +it("exposes report-only recovery without wallet authentication or chain broadcast", () => { + const registry = new CommandRegistry(); + registerBaiCommands(registry, service()); + const command = registry.resolveNeutral(["bai", "recharge-report"])!; + expect(command).toMatchObject({ + network: "none", + wallet: "none", + auth: "none", + broadcasts: false, + }); + const input = { chain: "base", txHash: "0x" + "a".repeat(64) }; + expect(command.input.safeParse(input).success).toBe(true); + expect(command.input.safeParse({ ...input, to: "recipient" }).success).toBe(false); + expect(command.input.safeParse({ ...input, chain: "tron" }).success).toBe(false); + expect( + command.input.safeParse({ ...input, to: "recipient", targetId: "original-id" }).success, + ).toBe(true); +}); diff --git a/ts/src/adapters/inbound/cli/commands/bai.ts b/ts/src/adapters/inbound/cli/commands/bai.ts new file mode 100644 index 000000000..466fe4cbf --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/bai.ts @@ -0,0 +1,192 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../contracts/index.js"; +import type { CommandRegistry } from "../registry/index.js"; +import type { BaiService } from "../../../../application/use-cases/bai-service.js"; + +const requires = ["config baiApiKey"]; + +const listFields = z.object({ + limit: z.coerce + .number() + .int() + .positive() + .max(1000) + .default(20) + .describe("maximum rows to return"), + offset: z.coerce.number().int().min(0).default(0).describe("zero-based pagination offset"), + sort: z.enum(["asc", "desc"]).default("desc").describe("creation-time sort direction"), +}); + +export function registerBaiCommands(registry: CommandRegistry, service: BaiService): void { + const rechargeFields = z.object({ + amount: z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/, "must be a decimal amount"), + token: z + .string() + .trim() + .min(1) + .optional() + .describe("recharge token symbol; defaults to USDC on Base and USDT otherwise"), + to: z + .string() + .trim() + .min(1) + .max(320) + .optional() + .describe("B.AI recipient email or EVM, TRON, or Solana address; omit to recharge yourself"), + }); + registry.add({ + path: ["bai", "recharge"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "bai.recharge", + requires, + positionals: [{ field: "amount" }], + summary: "Recharge your own or another B.AI account", + description: + "Recharge B.AI using the selected network and token. Omit --to to recharge the API-key account, or set --to to the recipient's email or wallet address. Both modes use the same recharge flow. Recharge uses local x402 exact on mainnet: TRON USDT/USDD, BSC USDT, or Base USDC. USDT/USDC minimum: 1. Token and amount precision are checked before an order is created.", + fields: rechargeFields, + input: rechargeFields, + examples: [ + { cmd: "wallet-cli bai recharge 10 --token USDT --network tron --password-stdin" }, + { + cmd: "wallet-cli bai recharge 10 --token USDT --network tron --to recipient@example.com --password-stdin", + note: "recharge another B.AI account", + }, + { cmd: "wallet-cli bai recharge 10 --token USDT --network bsc --password-stdin" }, + { cmd: "wallet-cli bai recharge 1 --token USDC --network base --password-stdin" }, + ], + run: async (ctx, network, input) => { + if (!network) throw new Error("B.AI recharge requires a resolved network"); + return service.recharge(ctx, network, { + amount: input.amount, + token: input.token ?? (network.id === "eip155:8453" ? "USDC" : "USDT"), + to: input.to, + apiKey: ctx.config.baiApiKey, + }); + }, + } satisfies CommandDefinition); + + const reportFields = z.object({ + txHash: z.string().max(66).describe("existing transaction hash from the original recharge"), + chain: z.enum(["tron", "bnb", "base"]).describe("original recharge chain; BSC is bnb"), + amount: z + .string() + .regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/) + .optional() + .describe("original recharge amount, when available"), + to: z + .string() + .trim() + .min(1) + .max(320) + .optional() + .describe("original recipient identifier; pair with --target-id"), + targetId: z + .string() + .trim() + .min(1) + .max(320) + .optional() + .describe("original rechargeTarget.confirmedTarget.targetId; do not resolve a new target"), + }); + registry.add({ + path: ["bai", "recharge-report"], + network: "none", + wallet: "none", + auth: "none", + broadcasts: false, + requires, + positionals: [{ field: "txHash" }], + summary: "Report an existing recharge transaction without paying again", + description: + "Recover a failed B.AI report using the original API key, chain, hash, amount and recipient. For recipient recharge, supply both original --to and --target-id; omit both only for self recharge. Reconcile any unconfirmed candidate hash before reporting. This command does not create an order, sign, pay, or resolve a new recipient. B.AI verifies whether the reported transaction can be credited.", + fields: reportFields, + input: reportFields.superRefine((value, ctx) => { + if ( + !(value.chain === "tron" ? /^[0-9a-fA-F]{64}$/ : /^0x[0-9a-fA-F]{64}$/).test(value.txHash) + ) + ctx.addIssue({ + code: "custom", + path: ["txHash"], + message: "invalid transaction hash for chain", + }); + if (Boolean(value.to) !== Boolean(value.targetId)) + ctx.addIssue({ + code: "custom", + path: ["targetId"], + message: "--to and --target-id must be supplied together", + }); + }), + examples: [ + { cmd: "wallet-cli bai recharge-report 0x" + "a".repeat(64) + " --chain base --amount 1" }, + ], + run: async (_ctx, _network, input) => service.rechargeReport(input), + } satisfies CommandDefinition); + + const empty = z.object({}); + registry.add({ + path: ["bai", "status"], + network: "none", + wallet: "none", + auth: "none", + requires, + summary: "Show B.AI credit balance and monthly usage", + description: + "Show the authenticated B.AI account's credit balance, current-month spend, and monthly trend.", + fields: empty, + input: empty, + examples: [{ cmd: "wallet-cli bai status" }], + run: async () => service.status(), + } satisfies CommandDefinition); + + registry.add({ + path: ["bai", "usage"], + network: "none", + wallet: "none", + auth: "none", + requires, + summary: "Show B.AI usage summary", + description: + "Show the API-provided credit balance, current-month spend, and monthly usage trend.", + fields: empty, + input: empty, + examples: [{ cmd: "wallet-cli bai usage" }], + run: async () => service.usage(), + } satisfies CommandDefinition); + + const usageListFields = listFields.extend({ + cursor: z + .string() + .min(1) + .max(8192) + .optional() + .describe("nextCursor returned by the preceding usage-list page"), + }); + registry.add({ + path: ["bai", "usage-list"], + network: "none", + wallet: "none", + auth: "none", + requires, + summary: "List individual B.AI usage records", + fields: usageListFields, + input: usageListFields, + examples: [{ cmd: "wallet-cli bai usage-list --limit 20" }], + run: async (_context, _network, input) => service.usageList(input), + } satisfies CommandDefinition); + + registry.add({ + path: ["bai", "recharge-list"], + network: "none", + wallet: "none", + auth: "none", + requires, + summary: "List B.AI recharge orders", + fields: listFields, + input: listFields, + examples: [{ cmd: "wallet-cli bai recharge-list --limit 20" }], + run: async (_context, _network, input) => service.rechargeList(input), + } satisfies CommandDefinition); +} diff --git a/ts/src/adapters/inbound/cli/commands/config.bai.test.ts b/ts/src/adapters/inbound/cli/commands/config.bai.test.ts new file mode 100644 index 000000000..a9963fb0b --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/config.bai.test.ts @@ -0,0 +1,38 @@ +import { expect, it, vi } from "vitest"; +import { registerConfigCommands } from "./config.js"; +import { CommandRegistry } from "../registry/index.js"; +import type { ConfigService } from "../../../../application/use-cases/config-service.js"; +function fixture(check = vi.fn(async (_key: string) => {})) { + const execute = vi.fn(() => ({ value: "********" })); + const registry = new CommandRegistry(); + registerConfigCommands(registry, { execute } as unknown as ConfigService, { execute: check }); + const ctx = { + secrets: { has: () => true, require: () => "new-secret" }, + config: {}, + networkRegistry: {}, + }; + return { command: registry.resolveNeutral(["config"])!, ctx, execute, check }; +} +it("confirms the candidate key before saving it", async () => { + const { command, ctx, execute, check } = fixture(); + await command.run(ctx as never, undefined, { key: "baiApiKey" }); + expect(check).toHaveBeenCalledWith("new-secret"); + expect(check.mock.invocationCallOrder[0]).toBeLessThan(execute.mock.invocationCallOrder[0]!); +}); +it("does not overwrite the saved key when confirmation fails", async () => { + const { command, ctx, execute } = fixture( + vi.fn(async () => { + throw new Error("not bound"); + }), + ); + await expect(command.run(ctx as never, undefined, { key: "baiApiKey" })).rejects.toThrow( + "not bound", + ); + expect(execute).not.toHaveBeenCalled(); +}); +it("does not check binding for config reads", async () => { + const { command, ctx, check } = fixture(); + ctx.secrets.has = () => false; + await command.run(ctx as never, undefined, { key: "baiApiKey" }); + expect(check).not.toHaveBeenCalled(); +}); diff --git a/ts/src/adapters/inbound/cli/commands/config.ts b/ts/src/adapters/inbound/cli/commands/config.ts index 123c8dc51..8738c3c03 100644 --- a/ts/src/adapters/inbound/cli/commands/config.ts +++ b/ts/src/adapters/inbound/cli/commands/config.ts @@ -1,3 +1,4 @@ +import type { BaiCredentialSetup } from "../../../../application/use-cases/bai-credential-setup.js"; import { z } from "zod"; import type { CommandDefinition } from "../contracts/index.js"; import { @@ -7,8 +8,13 @@ import { } from "../../../../application/use-cases/config-service.js"; import { CommandRegistry } from "../registry/index.js"; import { TextFormatters } from "../render/index.js"; +import { UsageError } from "../../../../domain/errors/index.js"; -export function registerConfigCommands(registry: CommandRegistry, service: ConfigService): void { +export function registerConfigCommands( + registry: CommandRegistry, + service: ConfigService, + baiSetup: Pick, +): void { const fields = z.object({ // Not an enum: `networks.[.]` is a nested path, and the id segment is // open-ended (any canonical id or alias). The service validates the key and names the @@ -28,7 +34,10 @@ export function registerConfigCommands(registry: CommandRegistry, service: Confi network: "none", wallet: "none", auth: "none", + stdin: "apiKey", summary: "Show / get / set configuration values", + description: + "Read or update configuration. Setting baiApiKey verifies the selected account and mainnet with B.AI once before saving; select them with --account and --network. The wallet must already be bound.", positionals: [{ field: "key" }, { field: "value" }], fields, input: fields, @@ -38,8 +47,30 @@ export function registerConfigCommands(registry: CommandRegistry, service: Confi { cmd: "wallet-cli config defaultNetwork tron:3448148188" }, { cmd: "wallet-cli config networks.tron:728126428" }, { cmd: "wallet-cli config networks.tron:728126428.apiKeyHeader TRON-PRO-API-KEY" }, + { + cmd: "printf '%s\\n' \"$BAI_KEY\" | wallet-cli config baiApiKey --api-key-stdin --network tron", + }, ], formatText: TextFormatters.config, - run: async (ctx, _network, input) => service.execute(input, ctx.config, ctx.networkRegistry), + run: async (ctx, _network, input) => { + const hasApiKeyInput = ctx.secrets.has("apiKey"); + if (input.key === "baiApiKey" && input.value !== undefined) { + throw new UsageError( + "invalid_option", + "B.AI API key must not be passed on the command line; use --api-key-stdin", + ); + } + if (hasApiKeyInput && input.key !== "baiApiKey") { + throw new UsageError( + "invalid_option", + "--api-key-stdin is accepted only with config baiApiKey", + ); + } + const effectiveInput = hasApiKeyInput + ? { key: "baiApiKey", value: ctx.secrets.require("apiKey") } + : input; + if (hasApiKeyInput) await baiSetup.execute(effectiveInput.value!); + return service.execute(effectiveInput, ctx.config, ctx.networkRegistry); + }, } satisfies CommandDefinition); } diff --git a/ts/src/adapters/inbound/cli/commands/erc8004.test.ts b/ts/src/adapters/inbound/cli/commands/erc8004.test.ts new file mode 100644 index 000000000..220fe2a42 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/erc8004.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { CommandRegistry } from "../registry/index.js"; +import { + registerEvmChainCommands, + type EvmChainCommandDependencies, +} from "../../../../bootstrap/families/evm.js"; +import { + registerTronChainCommands, + type TronChainCommandDependencies, +} from "../../../../bootstrap/families/tron.js"; +function registerAgentCommands(registry: CommandRegistry, agents: AgentService) { + registerEvmChainCommands(registry, { agents } as EvmChainCommandDependencies); + registerTronChainCommands(registry, { agents } as TronChainCommandDependencies); +} +import type { AgentService } from "../../../../application/use-cases/agent-service.js"; + +describe("ERC-8004 command surface", () => { + it("registers exactly eight commands for EVM and TRON", () => { + const registry = new CommandRegistry(); + registerAgentCommands(registry, {} as AgentService); + const verbs = [ + "show", + "register", + "update", + "transfer", + "approve", + "operator-add", + "operator-remove", + "operator-check", + ]; + for (const verb of verbs) { + const command = registry.resolveChain(["8004", verb]); + expect(command?.spec.path).toEqual(["8004", verb]); + expect(Object.keys(command?.families ?? {}).sort()).toEqual(["evm", "tron"]); + } + expect( + registry.all().filter((command) => "spec" in command && command.spec.path[0] === "8004"), + ).toHaveLength(8); + }); + + it("register takes only the externally-built URI plus transaction controls", () => { + const registry = new CommandRegistry(); + registerAgentCommands(registry, {} as AgentService); + const spec = registry.resolveChain(["8004", "register"])!.spec; + expect(spec.positionals).toEqual([{ field: "uri" }]); + expect(Object.keys(spec.baseFields.shape)).toEqual(["uri", "dryRun", "signOnly", "buildOnly"]); + }); +}); + +it("accepts a data JSON registration URI and rejects local file schemes", () => { + const registry = new CommandRegistry(); + registerAgentCommands(registry, {} as AgentService); + const schema = registry.resolveChain(["8004", "register"])!.spec.baseFields; + expect(schema.safeParse({ uri: "data:application/json;base64,eyJuYW1lIjoiQSJ9" }).success).toBe( + true, + ); + expect(schema.safeParse({ uri: "file:///etc/passwd" }).success).toBe(false); + expect(schema.safeParse({ uri: "https://user:password@example.com/agent.json" }).success).toBe( + false, + ); +}); diff --git a/ts/src/adapters/inbound/cli/commands/erc8004.ts b/ts/src/adapters/inbound/cli/commands/erc8004.ts new file mode 100644 index 000000000..2722197e6 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/erc8004.ts @@ -0,0 +1,294 @@ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { AgentService } from "../../../../application/use-cases/agent-service.js"; +import { Schemas, addressFieldsFor } from "../schemas/index.js"; +import { governanceTxRefine, tronTxModeFields, txModeFields } from "./shared.js"; +import { TextFormatters, renderGenericText } from "../render/index.js"; + +const agentId = z + .string() + .trim() + .min(1) + .max(128) + .describe("agent token id, optionally chain-scoped"); +const uri = z + .string() + .trim() + .min(1) + .max(2048) + .refine((value) => { + if (/^data:application\/json;base64,[A-Za-z0-9+/]+={0,2}$/.test(value)) return true; + try { + const url = new URL(value); + return ( + ["https:", "http:", "ipfs:"].includes(url.protocol) && + !!url.hostname && + !url.username && + !url.password + ); + } catch { + return false; + } + }, "must be an HTTP(S), IPFS, or base64 JSON data URI without credentials") + .describe("URI of an agent registration document built and hosted outside wallet-cli"); +const address = Schemas.address(); + +const tronWriteFields = z.object({ + feeLimit: Schemas.positiveIntString() + .default("100000000") + .describe("maximum energy fee to burn, in SUN"), + ...tronTxModeFields, +}); + +function writeSpec( + verb: string, + fields: z.ZodObject, + positionals: { field: string }[], + summary: string, +): ChainSpec { + return { + path: ["8004", verb], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "erc8004.identity.write", + positionals, + summary, + baseFields: fields.extend(txModeFields), + baseRefine: governanceTxRefine, + examples: [], + formatText: TextFormatters.txReceipt, + }; +} + +export const showSpec: ChainSpec = { + path: ["8004", "show"], + network: "optional", + wallet: "none", + auth: "none", + capability: "erc8004.identity.read", + formatText: (data, ctx) => renderGenericText(ctx.command, ctx.net, data), + positionals: [{ field: "id" }], + summary: "Load one ERC-8004 Agent directly from the Identity Registry", + baseFields: z.object({ id: agentId }), + examples: [ + { cmd: "wallet-cli 8004 show 123 --network nile" }, + { cmd: "wallet-cli 8004 show eip155:97:123 --network bsc-testnet" }, + ], +}; + +export const registerSpec = writeSpec( + "register", + z.object({ uri }), + [{ field: "uri" }], + "Register an externally hosted Agent URI", +); +registerSpec.examples = [ + { cmd: "wallet-cli 8004 register ipfs://bafy... --network nile --password-stdin" }, +]; + +export const updateSpec = writeSpec( + "update", + z.object({ id: agentId, uri }), + [{ field: "id" }, { field: "uri" }], + "Update an Agent registration URI", +); + +export const transferSpec = writeSpec( + "transfer", + z.object({ id: agentId, newOwner: address.describe("new owner address") }), + [{ field: "id" }, { field: "newOwner" }], + "Transfer Agent ownership", +); + +const approveFields = z.object({ + id: agentId, + operator: address.optional().describe("address approved for this Agent"), + revoke: z.boolean().default(false).describe("clear the current per-Agent approval"), +}); +export const approveSpec = writeSpec( + "approve", + approveFields, + [{ field: "id" }, { field: "operator" }], + "Approve or revoke an operator for one Agent", +); +approveSpec.baseRefine = (value, context) => { + governanceTxRefine(value, context); + if (!value.revoke && !value.operator) { + context.addIssue({ + code: "custom", + path: ["operator"], + message: "is required unless --revoke is used", + }); + } + if (value.revoke && value.operator) { + context.addIssue({ + code: "custom", + path: ["operator"], + message: "must be omitted with --revoke", + }); + } +}; + +export const operatorAddSpec = writeSpec( + "operator-add", + z.object({ operator: address.describe("operator address") }), + [{ field: "operator" }], + "Give an operator access to all Agents owned by this account", +); + +export const operatorRemoveSpec = writeSpec( + "operator-remove", + z.object({ operator: address.describe("operator address") }), + [{ field: "operator" }], + "Remove an owner-wide Agent operator", +); + +export const operatorCheckSpec: ChainSpec = { + path: ["8004", "operator-check"], + network: "optional", + wallet: "none", + auth: "none", + capability: "erc8004.identity.read", + formatText: (data, ctx) => renderGenericText(ctx.command, ctx.net, data), + positionals: [{ field: "owner" }, { field: "operator" }], + summary: "Check an owner-wide Agent operator approval", + baseFields: z.object({ + owner: address.describe("Agent owner address"), + operator: address.describe("operator address"), + }), + examples: [ + { cmd: "wallet-cli 8004 operator-check T... T... --network nile" }, + { cmd: "wallet-cli 8004 operator-check 0x... 0x... --network bsc-testnet" }, + ], +}; + +function binding( + family: "evm" | "tron", + run: FamilyBinding["run"], + addressFields: string[] = [], + write = false, +): FamilyBinding { + return { + run, + ...(write && family === "tron" ? { fields: tronWriteFields } : {}), + ...(addressFields.length ? { refine: addressFieldsFor(family, ...addressFields) } : {}), + }; +} +export function showEvmBinding(service: AgentService): FamilyBinding { + return binding( + "evm", + async (ctx, net, input) => { + const result = await service.show(net, input.id); + for (const warning of result.warnings ?? []) ctx.warn(warning); + return result; + }, + [], + false, + ); +} +export function showTronBinding(service: AgentService): FamilyBinding { + return binding( + "tron", + async (ctx, net, input) => { + const result = await service.show(net, input.id); + for (const warning of result.warnings ?? []) ctx.warn(warning); + return result; + }, + [], + false, + ); +} +export function registerEvmBinding(service: AgentService): FamilyBinding { + return binding("evm", async (ctx, net, input) => service.register(ctx, net, input), [], true); +} +export function registerTronBinding(service: AgentService): FamilyBinding { + return binding("tron", async (ctx, net, input) => service.register(ctx, net, input), [], true); +} +export function updateEvmBinding(service: AgentService): FamilyBinding { + return binding("evm", async (ctx, net, input) => service.update(ctx, net, input), [], true); +} +export function updateTronBinding(service: AgentService): FamilyBinding { + return binding("tron", async (ctx, net, input) => service.update(ctx, net, input), [], true); +} +export function transferEvmBinding(service: AgentService): FamilyBinding { + return binding( + "evm", + async (ctx, net, input) => service.transfer(ctx, net, input), + ["newOwner"], + true, + ); +} +export function transferTronBinding(service: AgentService): FamilyBinding { + return binding( + "tron", + async (ctx, net, input) => service.transfer(ctx, net, input), + ["newOwner"], + true, + ); +} +export function approveEvmBinding(service: AgentService): FamilyBinding { + return binding( + "evm", + async (ctx, net, input) => service.approve(ctx, net, input), + ["operator"], + true, + ); +} +export function approveTronBinding(service: AgentService): FamilyBinding { + return binding( + "tron", + async (ctx, net, input) => service.approve(ctx, net, input), + ["operator"], + true, + ); +} +export function operatorAddEvmBinding(service: AgentService): FamilyBinding { + return binding( + "evm", + async (ctx, net, input) => service.operatorAdd(ctx, net, input), + ["operator"], + true, + ); +} +export function operatorAddTronBinding(service: AgentService): FamilyBinding { + return binding( + "tron", + async (ctx, net, input) => service.operatorAdd(ctx, net, input), + ["operator"], + true, + ); +} +export function operatorRemoveEvmBinding(service: AgentService): FamilyBinding { + return binding( + "evm", + async (ctx, net, input) => service.operatorRemove(ctx, net, input), + ["operator"], + true, + ); +} +export function operatorRemoveTronBinding(service: AgentService): FamilyBinding { + return binding( + "tron", + async (ctx, net, input) => service.operatorRemove(ctx, net, input), + ["operator"], + true, + ); +} +export function operatorCheckEvmBinding(service: AgentService): FamilyBinding { + return binding( + "evm", + async (_ctx, net, input) => service.operatorCheck(net, input.owner, input.operator), + ["owner", "operator"], + false, + ); +} +export function operatorCheckTronBinding(service: AgentService): FamilyBinding { + return binding( + "tron", + async (_ctx, net, input) => service.operatorCheck(net, input.owner, input.operator), + ["owner", "operator"], + false, + ); +} diff --git a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts index 19bdcffa1..04cdf1c69 100644 --- a/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts +++ b/ts/src/adapters/inbound/cli/commands/text-formatters.test.ts @@ -30,7 +30,7 @@ describe("text formatters", () => { it("every registered command has a command-owned text formatter", () => { const registry = new CommandRegistry(); registerWalletCommands(registry, {} as Parameters[1]); - registerConfigCommands(registry, {} as ConfigService); + registerConfigCommands(registry, {} as ConfigService, { execute: async () => {} }); registerNetworkCommands(registry); registerContactCommands(registry, {} as never); registerAddressCommands(registry, {} as never); @@ -54,7 +54,7 @@ describe("text formatters", () => { it("every registered command field carries a help description", () => { const registry = new CommandRegistry(); registerWalletCommands(registry, {} as Parameters[1]); - registerConfigCommands(registry, {} as ConfigService); + registerConfigCommands(registry, {} as ConfigService, { execute: async () => {} }); registerNetworkCommands(registry); registerContactCommands(registry, {} as never); registerAddressCommands(registry, {} as never); diff --git a/ts/src/adapters/inbound/cli/commands/x402.test.ts b/ts/src/adapters/inbound/cli/commands/x402.test.ts new file mode 100644 index 000000000..b47ca6800 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/x402.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; +import { CommandRegistry } from "../registry/index.js"; +import { registerX402Commands } from "./x402.js"; +import type { X402Service } from "../../../../application/use-cases/x402-service.js"; + +function service(): X402Service { + return { + pay: vi.fn(async () => ({})), + providerList: vi.fn(async () => ({})), + providerShow: vi.fn(async () => ({})), + providerEndpoints: vi.fn(async () => ({})), + providerUpdate: vi.fn(async () => ({})), + serve: vi.fn(async () => ({})), + roundtrip: vi.fn(async () => ({})), + } as unknown as X402Service; +} + +describe("x402 command surface", () => { + it("registers pay for EVM and TRON and four provider commands", () => { + const registry = new CommandRegistry(); + registerX402Commands(registry, service()); + + const pay = registry.resolveNeutral(["x402", "pay"]); + expect(pay?.network).toBe("optional"); + for (const verb of [ + "serve", + "roundtrip", + "provider-list", + "provider-show", + "provider-endpoints", + "provider-update", + ]) { + expect(registry.resolveNeutral(["x402", verb])?.path).toEqual(["x402", verb]); + } + }); + + it("does not register search or gateway commands", () => { + const registry = new CommandRegistry(); + registerX402Commands(registry, service()); + expect(registry.resolveNeutral(["x402", "search"])).toBeNull(); + expect(registry.resolveNeutral(["x402", "gateway"])).toBeNull(); + }); + + it("exposes dry-run and mutually exclusive payment limits", () => { + const registry = new CommandRegistry(); + registerX402Commands(registry, service()); + const pay = registry.resolveNeutral(["x402", "pay"])!; + expect(Object.keys(pay.fields.shape)).toContain("dryRun"); + expect( + pay.input.safeParse({ + url: "https://example.test", + method: "GET", + header: [], + maxAmount: "1", + maxRawAmount: "1", + }).success, + ).toBe(false); + }); + + it("rejects using inline and file request bodies together", () => { + const registry = new CommandRegistry(); + registerX402Commands(registry, service()); + const pay = registry.resolveNeutral(["x402", "pay"])!; + expect( + pay.input.safeParse({ + url: "https://example.test", + method: "POST", + header: [], + body: "{}", + bodyFile: "request.json", + }).success, + ).toBe(false); + }); + + it("rejects conflicting GasFree fee ceilings", () => { + const registry = new CommandRegistry(); + registerX402Commands(registry, service()); + const pay = registry.resolveNeutral(["x402", "pay"])!; + expect( + pay.input.safeParse({ + url: "https://example.test", + method: "GET", + header: [], + maxGasfreeFee: "1", + maxGasfreeFeeRaw: "1", + }).success, + ).toBe(false); + }); +}); diff --git a/ts/src/adapters/inbound/cli/commands/x402.ts b/ts/src/adapters/inbound/cli/commands/x402.ts new file mode 100644 index 000000000..1e3e2b1b1 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/x402.ts @@ -0,0 +1,280 @@ +import { z } from "zod"; +import type { CommandDefinition } from "../contracts/index.js"; +import type { CommandRegistry } from "../registry/index.js"; +import type { X402Service } from "../../../../application/use-cases/x402-service.js"; +import { readFile } from "node:fs/promises"; +import { UsageError } from "../../../../domain/errors/index.js"; + +const url = z + .string() + .url() + .refine((value) => /^https?:\/\//.test(value), "must use http:// or https://"); +const fqn = z.string().trim().min(1).max(128).describe("provider fully-qualified name"); + +const payFields = z.object({ + url: url.describe("x402-protected endpoint URL"), + method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).default("GET"), + header: z.array(z.string()).default([]).describe('repeatable HTTP header in "Name: value" form'), + body: z.string().max(1_048_576).optional().describe("HTTP request body"), + bodyFile: z.string().trim().min(1).optional().describe("request body file; - reads stdin"), + out: z.string().trim().min(1).optional().describe("write response bytes to a new file"), + token: z.string().trim().min(1).optional().describe("only accept this token symbol"), + asset: z.string().trim().min(1).optional().describe("only accept this asset address"), + decimals: z.coerce + .number() + .int() + .min(0) + .max(255) + .optional() + .describe("decimals for an explicit asset"), + scheme: z.enum(["exact", "exact_gasfree"]).optional(), + maxAmount: z + .string() + .regex(/^\d+(?:\.\d+)?$/) + .optional() + .describe("maximum payment in whole tokens; strongly recommended"), + maxRawAmount: z.string().regex(/^\d+$/).optional().describe("maximum payment in smallest units"), + dryRun: z.boolean().default(false).describe("inspect the payment challenge without signing"), + maxGasfreeFee: z + .string() + .regex(/^\d+(?:\.\d+)?$/) + .optional() + .describe("maximum GasFree relay fee in whole tokens"), + maxGasfreeFeeRaw: z + .string() + .regex(/^\d+$/) + .optional() + .describe("maximum GasFree relay fee in smallest units"), +}); +const payInput = payFields.superRefine((value, context) => { + if (value.maxAmount && value.maxRawAmount) { + context.addIssue({ + code: "custom", + path: ["maxAmount"], + message: "cannot be combined with --max-raw-amount", + }); + } + if (value.body && value.bodyFile) { + context.addIssue({ + code: "custom", + path: ["body"], + message: "cannot be combined with --body-file", + }); + } + if (value.maxGasfreeFee && value.maxGasfreeFeeRaw) { + context.addIssue({ + code: "custom", + path: ["maxGasfreeFee"], + message: "cannot be combined with --max-gasfree-fee-raw", + }); + } + if (value.decimals !== undefined && !value.asset) { + context.addIssue({ code: "custom", path: ["decimals"], message: "requires --asset" }); + } +}); + +const payCommand: CommandDefinition = { + path: ["x402", "pay"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "x402.pay", + positionals: [{ field: "url" }], + summary: "Request an endpoint and pay an x402 challenge", + description: + "Call an HTTP endpoint and authorize a supported x402 payment with the selected wallet account.", + fields: payFields, + input: payInput, + exclusive: [ + { label: "payment limit", flags: ["max-amount", "max-raw-amount"], select: "at-most-one" }, + { + label: "GasFree fee limit", + flags: ["max-gasfree-fee", "max-gasfree-fee-raw"], + select: "at-most-one", + }, + ], + examples: [ + { cmd: "wallet-cli x402 pay https://service.example/resource --network bsc --password-stdin" }, + { + cmd: "wallet-cli x402 pay https://service.example/task --method POST --body '{}' --network tron", + }, + ], + run: async () => ({}), +}; + +const listFields = z.object({ + limit: z.coerce.number().int().positive().max(1000).default(20), + offset: z.coerce.number().int().min(0).default(0), + type: z.string().trim().min(1).optional(), + category: z.string().trim().min(1).optional(), + capability: z.string().trim().min(1).optional(), + network: z.string().trim().min(1).optional().describe("CAIP-2 network id"), + includeBlocked: z.boolean().default(false).describe("include providers marked as blocked"), +}); + +const serveFields = z.object({ + payTo: z.string().trim().min(1).describe("recipient address on the selected network"), + amount: z + .string() + .regex(/^\d+(?:\.\d+)?$/) + .default("0.0001") + .describe("human-readable token amount"), + token: z.string().trim().min(1).default("USDT").describe("payment token symbol"), + scheme: z.enum(["exact", "exact_gasfree"]).default("exact"), + host: z.enum(["127.0.0.1", "::1"]).default("127.0.0.1").describe("loopback bind address"), + port: z.coerce.number().int().min(1).max(65535).default(4020), + facilitatorUrl: z + .string() + .url() + .refine((value) => value.startsWith("https://"), "must use HTTPS") + .default("https://facilitator.bankofai.io"), +}); + +export function registerX402Commands(registry: CommandRegistry, service: X402Service): void { + registry.add({ + ...payCommand, + run: async (ctx, network, input) => { + if (!network) throw new Error("x402 pay requires a resolved network"); + const body = await requestBody(ctx, input.body, input.bodyFile); + return service.pay(ctx, network, { + url: input.url, + method: input.method, + headers: input.header, + ...(body === undefined ? {} : { body }), + ...(input.token === undefined ? {} : { token: input.token }), + ...(input.asset === undefined ? {} : { asset: input.asset }), + ...(input.decimals === undefined ? {} : { decimals: input.decimals }), + ...(input.scheme === undefined ? {} : { scheme: input.scheme }), + ...(input.maxAmount === undefined ? {} : { maxAmount: input.maxAmount }), + ...(input.maxRawAmount === undefined ? {} : { maxRawAmount: input.maxRawAmount }), + dryRun: input.dryRun, + ...(input.out === undefined ? {} : { out: input.out }), + ...(input.maxGasfreeFee === undefined ? {} : { maxGasfreeFee: input.maxGasfreeFee }), + ...(input.maxGasfreeFeeRaw === undefined + ? {} + : { maxGasfreeFeeRaw: input.maxGasfreeFeeRaw }), + }); + }, + }); + + registry.add({ + path: ["x402", "serve"], + network: "optional", + wallet: "none", + auth: "none", + broadcasts: false, + capability: "x402.serve", + summary: "Run a local x402-protected endpoint", + fields: serveFields, + input: serveFields, + examples: [ + { cmd: "wallet-cli x402 serve --pay-to T... --amount 1 --token USDT --network tron" }, + ], + run: async (_ctx, network, input) => { + if (!network) throw new Error("x402 serve requires a resolved network"); + return service.serve(network, input); + }, + } satisfies CommandDefinition); + + registry.add({ + path: ["x402", "roundtrip"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "x402.pay", + summary: "Start a local paywall, pay it, and exit", + fields: serveFields, + input: serveFields, + examples: [{ cmd: "wallet-cli x402 roundtrip --pay-to T... --network tron --password-stdin" }], + run: async (ctx, network, input) => { + if (!network) throw new Error("x402 roundtrip requires a resolved network"); + return service.roundtrip(ctx, network, input); + }, + } satisfies CommandDefinition); + + registry.add({ + path: ["x402", "provider-list"], + network: "none", + wallet: "none", + auth: "none", + summary: "List x402 catalog providers", + fields: listFields, + input: listFields, + examples: [ + { cmd: "wallet-cli x402 provider-list" }, + { cmd: "wallet-cli x402 provider-list --network tron:728126428 --capability recharge" }, + ], + run: async (_ctx, _network, input) => service.providerList(input), + } satisfies CommandDefinition); + + for (const [verb, summary, run] of [ + ["provider-show", "Show one x402 provider", (name: string) => service.providerShow(name)], + [ + "provider-endpoints", + "List one x402 provider's endpoints", + (name: string) => service.providerEndpoints(name), + ], + ] as const) { + const fields = z.object({ provider: fqn }); + registry.add({ + path: ["x402", verb], + network: "none", + wallet: "none", + auth: "none", + positionals: [{ field: "provider" }], + summary, + fields, + input: fields, + examples: [{ cmd: `wallet-cli x402 ${verb} bai/recharge` }], + run: async (_ctx, _network, input) => run(input.provider), + } satisfies CommandDefinition); + } + + const empty = z.object({}); + registry.add({ + path: ["x402", "provider-update"], + network: "none", + wallet: "none", + auth: "none", + summary: "Refresh the local x402 provider catalog cache", + fields: empty, + input: empty, + examples: [{ cmd: "wallet-cli x402 provider-update" }], + run: async () => service.providerUpdate(), + } satisfies CommandDefinition); +} + +async function requestBody( + ctx: Parameters[0], + inline?: string, + path?: string, +): Promise { + if (!path) return inline; + if (path === "-") { + if (ctx.secrets.has("password")) { + throw new UsageError( + "invalid_option", + "--body-file - cannot share stdin with --password-stdin", + ); + } + return checkedBody(ctx.streams.readStdinOnce(), "stdin"); + } + try { + return checkedBody(await readFile(path, "utf8"), path); + } catch (error) { + if (error instanceof UsageError) throw error; + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new UsageError("file_not_found", `request body file not found: ${path}`); + } + throw new UsageError("invalid_value", `cannot read request body file: ${path}`); + } +} + +function checkedBody(body: string, source: string): string { + if (Buffer.byteLength(body) > 1_048_576) { + throw new UsageError("invalid_value", `request body from ${source} exceeds 1 MiB`); + } + return body; +} diff --git a/ts/src/adapters/inbound/cli/contracts/command.ts b/ts/src/adapters/inbound/cli/contracts/command.ts index b7757542e..32bcbdd1f 100644 --- a/ts/src/adapters/inbound/cli/contracts/command.ts +++ b/ts/src/adapters/inbound/cli/contracts/command.ts @@ -37,7 +37,7 @@ export type AuthRequirement = "none" | "conditional" | "required"; /** secret/payload channel a command reads from stdin; documents the matching --*-stdin flag. * (Wallet-secret entry — mnemonic/private-key/master-password — is TTY-only, so those never * appear here; see `secretsTtyOnly`.) */ -export type StdinChannel = "tx" | "message"; +export type StdinChannel = "tx" | "message" | "apiKey"; export interface TextRenderContext { command: string; diff --git a/ts/src/adapters/inbound/cli/contracts/runtime.ts b/ts/src/adapters/inbound/cli/contracts/runtime.ts index 659c23e58..619798e8e 100644 --- a/ts/src/adapters/inbound/cli/contracts/runtime.ts +++ b/ts/src/adapters/inbound/cli/contracts/runtime.ts @@ -16,7 +16,7 @@ export interface StreamManager { warnings(): WarningItem[]; } -export type SecretKind = "password" | "privateKey" | "mnemonic" | "tx" | "message"; +export type SecretKind = "password" | "privateKey" | "mnemonic" | "tx" | "message" | "apiKey"; export interface SecretResolver { masterPassword(): string; /** whether a master-password source exists, WITHOUT consuming stdin. */ diff --git a/ts/src/adapters/inbound/cli/globals/index.ts b/ts/src/adapters/inbound/cli/globals/index.ts index 94c5e62d0..f31ca62e9 100644 --- a/ts/src/adapters/inbound/cli/globals/index.ts +++ b/ts/src/adapters/inbound/cli/globals/index.ts @@ -119,6 +119,13 @@ export const GLOBAL_FLAG_SPECS: readonly GlobalFlagSpec[] = [ commandScoped: true, description: "read the message bytes/text from stdin (fd 0)", }, + { + name: "api-key-stdin", + kind: "secret-stdin", + secretKey: "apiKey", + commandScoped: true, + description: "read the B.AI API key from stdin (fd 0); accepted only by config baiApiKey", + }, ]; /** kebab → camel default; the `field` override wins when the runtime Globals key differs from the flag. */ diff --git a/ts/src/adapters/inbound/cli/help/index.ts b/ts/src/adapters/inbound/cli/help/index.ts index 96e1609d4..4a3b9c549 100644 --- a/ts/src/adapters/inbound/cli/help/index.ts +++ b/ts/src/adapters/inbound/cli/help/index.ts @@ -171,6 +171,9 @@ export class HelpService { ["message", "Sign arbitrary messages", ""], ["typed-data", "Sign EIP-712 / TIP-712 structured data", ""], ["block", "Get a block (latest if omitted)", ""], + ["x402", "Pay and inspect x402 service providers", ""], + ["bai", "Query and recharge a B.AI account", ""], + ["8004", "Read and manage ERC-8004 Agent identities", ""], ] as const; const commands = [ ["use", "Set the active account", ""], @@ -690,6 +693,9 @@ const GROUP_DESCRIPTIONS: Record = { encoding: "Convert and validate addresses and encodings across formats.", address: "Generate a random secp256k1 keypair locally without storing it in the wallet.", contact: "Manage the recipient address book.", + bai: "Query B.AI account credits, usage records, and recharge orders.", + "8004": "Read and manage ERC-8004 Agent identities on supported networks.", + x402: "Pay x402 endpoints and inspect the provider catalog.", }; /** "--output, -o " style header for text help. */ diff --git a/ts/src/adapters/inbound/cli/help/root-help-coverage.test.ts b/ts/src/adapters/inbound/cli/help/root-help-coverage.test.ts index da2d5e781..bdd2e5988 100644 --- a/ts/src/adapters/inbound/cli/help/root-help-coverage.test.ts +++ b/ts/src/adapters/inbound/cli/help/root-help-coverage.test.ts @@ -60,7 +60,7 @@ describe("wallet-cli --help lists every registered top-level command", () => { const listed = new Set( text .split("\n") - .map((line) => /^ {2}([a-z][a-z0-9-]*)\s{2,}\S/.exec(line)?.[1]) + .map((line) => /^ {2}([a-z0-9][a-z0-9-]*)\s{2,}\S/.exec(line)?.[1]) .filter((name): name is string => name !== undefined), ); expect(heads.filter((head) => !listed.has(head))).toEqual([]); diff --git a/ts/src/adapters/inbound/cli/render/erc8004.test.ts b/ts/src/adapters/inbound/cli/render/erc8004.test.ts new file mode 100644 index 000000000..d25e52810 --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/erc8004.test.ts @@ -0,0 +1,42 @@ +import { expect, it } from "vitest"; +import { TxFormatters } from "./tx.js"; +const ctx = { net: { id: "eip155:56", family: "evm", nativeSymbol: "BNB" } } as never; + +it("renders Agent approval as an operator and ID without an allowance", () => { + const rendered = TxFormatters.txReceipt( + { + kind: "contract-send", + stage: "submitted", + txId: "0xabc", + identity: { + agentId: "9007199254740993", + operator: "0x2222222222222222222222222222222222222222", + }, + }, + ctx, + ); + expect(rendered).toContain("9007199254740993"); + expect(rendered).toContain("Operator"); + expect(rendered).not.toContain("Allowance"); +}); +it("shows URI and owner results only when supplied by the confirmed service result", () => { + const rendered = TxFormatters.txReceipt( + { + kind: "contract-send", + stage: "confirmed", + txId: "0xabc", + identity: { + oldURI: "ipfs://old", + newURI: "ipfs://actual", + requestedURI: "ipfs://requested", + oldOwner: "0x1111", + newOwner: "0x2222", + }, + }, + ctx, + ); + expect(rendered).toContain("ipfs://actual"); + expect(rendered).toContain("ipfs://requested"); + expect(rendered).toContain("0x1111"); + expect(rendered).toContain("0x2222"); +}); diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index e52302e3c..cd918f662 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -395,6 +395,19 @@ function receiptRows(r: TxReceiptView): Pair[] { // approve(address,uint256): the two facts the caller cannot verify from what they typed — the // uint256 on the command line is scaled by the token's decimals, and its maximum is 78 digits. // Present in the dry run too, which is where an approval most wants checking. + if (r.identity) { + if (r.identity.agentId !== undefined) rows.push(["Agent ID", r.identity.agentId]); + if (r.identity.operator !== undefined) rows.push(["Operator", r.identity.operator]); + if (r.identity.uri !== undefined) rows.push(["URI", r.identity.uri]); + if (r.identity.oldURI !== undefined) rows.push(["Previous URI", r.identity.oldURI]); + if (r.identity.requestedURI !== undefined) + rows.push(["Requested URI", r.identity.requestedURI]); + if (r.identity.newURI !== undefined) rows.push(["Current URI", r.identity.newURI]); + if (r.identity.oldOwner !== undefined) rows.push(["Previous owner", r.identity.oldOwner]); + if (r.identity.requestedOwner !== undefined) + rows.push(["Requested owner", r.identity.requestedOwner]); + if (r.identity.newOwner !== undefined) rows.push(["Current owner", r.identity.newOwner]); + } if (r.spender !== undefined) rows.push(["Spender", String(r.spender)]); if (r.allowance !== undefined) rows.push(["Allowance", allowanceLabel(r)]); return rows; diff --git a/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts index 90fb71ece..91287b4d4 100644 --- a/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts +++ b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts @@ -91,9 +91,19 @@ describe("every registered positional command rejects its -- spelling", ( .map((c) => c.path.join(" ")) .sort(); expect(found).toEqual([ + "8004 approve", + "8004 operator-add", + "8004 operator-check", + "8004 operator-remove", + "8004 register", + "8004 show", + "8004 transfer", + "8004 update", "asset info", "asset participate", "backup", + "bai recharge", + "bai recharge-report", "block", "config", "contact add", @@ -115,6 +125,9 @@ describe("every registered positional command rejects its -- spelling", ( "rename", "use", "witness set-brokerage", + "x402 pay", + "x402 provider-endpoints", + "x402 provider-show", ]); }); diff --git a/ts/src/adapters/outbound/bai/api-error.test.ts b/ts/src/adapters/outbound/bai/api-error.test.ts new file mode 100644 index 000000000..71a76f703 --- /dev/null +++ b/ts/src/adapters/outbound/bai/api-error.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from "vitest"; +import { BaiClient } from "./client.js"; +import { BaiRechargeClient } from "./recharge-client.js"; +import { baiApiError } from "./api-error.js"; + +const codes = [ + "WalletInvalidSignature", + "UNSUPPORTED_CHAIN", + "TX_NOT_FOUND_OR_INVALID", + "UNSUPPORTED_TOKEN", + "PAYER_MISMATCH", + "WALLET_NOT_BOUND", + "RECHARGE_TX_TOO_OLD", + "TX_TIMESTAMP_UNAVAILABLE", + "PRICE_UNAVAILABLE", + "RECHARGE_AMOUNT_TOO_SMALL", +]; +describe("B.AI failure diagnostics", () => { + it.each(codes)("classifies %s in HTTP and tRPC failures", (reason) => { + for (const status of [200, 400]) { + const error = baiApiError( + { error: { json: { message: reason, data: { code: "BAD_REQUEST" } } } }, + "wallet.bindRechargeWallet", + status, + ); + expect(error).toMatchObject({ + code: "bai_rejected", + details: { + reason, + httpStatus: status, + procedure: "wallet.bindRechargeWallet", + retryPayment: false, + }, + }); + expect(error!.message.length).toBeGreaterThan(30); + } + }); + it("maps the documented self-recipient rejection", () => { + expect( + baiApiError( + { error: { json: { message: "请勿输入当前账号的邮箱或地址" } } }, + "order.resolveRechargeTarget", + 400, + ), + ).toMatchObject({ details: { reason: "SELF_RECHARGE_TARGET" } }); + }); + it.each([200, 400, 500])("redacts unknown server text at HTTP %s", (status) => { + const error = baiApiError( + { error: { json: { message: "secret-key-user-data", data: { code: "unknown-secret" } } } }, + "usage.summary", + status, + ); + expect(JSON.stringify(error?.toEnvelope())).not.toContain("secret"); + expect(error?.code).toBe("provider_error"); + }); + it.each(["query", "recharge"])( + "maps bounded non-2xx JSON through the %s client", + async (kind) => { + const fetcher = vi.fn( + async () => + new Response(JSON.stringify({ error: { json: { message: "WalletInvalidSignature" } } }), { + status: 400, + }), + ); + const call = + kind === "query" + ? new BaiClient({ baiApiKey: "secret" }, 1000, fetcher).status() + : new BaiRechargeClient({ baiApiKey: "secret" }, 1000, fetcher).bind({ + chain: "tron", + address: "payer", + message: "m", + signature: "s", + }); + await expect(call).rejects.toMatchObject({ + code: "bai_rejected", + details: { reason: "WalletInvalidSignature" }, + }); + expect(fetcher).toHaveBeenCalledTimes(1); + }, + ); + it("preserves a report rejection explanation without server prose", async () => { + const api = new BaiRechargeClient( + { baiApiKey: "secret" }, + 1000, + async () => + new Response( + JSON.stringify({ success: false, code: "PRICE_UNAVAILABLE", message: "secret" }), + ), + ); + await expect(api.reportTxHash({ chain: "tron", txHash: "hash" })).resolves.toMatchObject({ + success: false, + code: "PRICE_UNAVAILABLE", + message: expect.stringContaining("retry reporting later"), + }); + }); + it("rejects invalid binding input before making a request", async () => { + const fetcher = vi.fn(); + await expect( + new BaiRechargeClient({ baiApiKey: "secret" }, 1000, fetcher).bind({ + chain: "tron", + address: "payer", + message: " ", + signature: "s", + }), + ).rejects.toMatchObject({ code: "invalid_value", details: { fields: ["message"] } }); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); + +it("bounds non-2xx error bodies instead of reading them without a limit", async () => { + const cancel = vi.fn(); + const api = new BaiClient( + { baiApiKey: "secret" }, + 1000, + async () => + new Response( + new ReadableStream({ + pull(c) { + c.enqueue(new Uint8Array(1024 * 1024)); + }, + cancel, + }), + { status: 400 }, + ), + ); + await expect(api.status()).rejects.toMatchObject({ code: "response_too_large" }); + expect(cancel).toHaveBeenCalledOnce(); +}); +it("does not wait on an authentication error body", async () => { + const cancel = vi.fn(); + const api = new BaiClient( + { baiApiKey: "secret" }, + 1000, + async () => new Response(new ReadableStream({ pull() {}, cancel }), { status: 401 }), + ); + await expect(api.status()).rejects.toMatchObject({ code: "bai_auth_failed" }); + expect(cancel).toHaveBeenCalledOnce(); +}); diff --git a/ts/src/adapters/outbound/bai/api-error.ts b/ts/src/adapters/outbound/bai/api-error.ts new file mode 100644 index 000000000..d64954283 --- /dev/null +++ b/ts/src/adapters/outbound/bai/api-error.ts @@ -0,0 +1,89 @@ +import { TransportError } from "../../../domain/errors/index.js"; + +/** Only documented or observed identifiers are exposed; never forward server prose. */ +const BUSINESS_ERRORS: Record = { + WalletInvalidSignature: + "B.AI rejected the binding signature. Rebuild the BAI binding message with the correct origin, address, chain ID, expiration and nonce, then sign it with the selected wallet", + UNSUPPORTED_CHAIN: "B.AI does not support this recharge chain; select a supported mainnet", + TX_NOT_FOUND_OR_INVALID: + "B.AI could not verify the transaction. Check confirmation, chain, recipient and sender; do not pay again", + UNSUPPORTED_TOKEN: "B.AI does not support this recharge token on the selected chain", + PAYER_MISMATCH: + "The transaction sender does not match the wallet bound to this B.AI user; verify the original payer and API key", + WALLET_NOT_BOUND: + "The wallet is not bound to this B.AI user; complete wallet binding and confirm the selected account and network", + RECHARGE_TX_TOO_OLD: + "The transaction is outside B.AI's recharge reporting window; retain the hash and contact B.AI support instead of paying again", + TX_TIMESTAMP_UNAVAILABLE: + "B.AI could not obtain the transaction timestamp; check the transaction and retry reporting later, not payment", + PRICE_UNAVAILABLE: + "B.AI could not obtain the token price; retry reporting later without paying again", + RECHARGE_AMOUNT_TOO_SMALL: + "The recharge amount is below B.AI's minimum; verify the token and amount and retain any existing transaction hash", + SELF_RECHARGE_TARGET: + "The recipient is the current B.AI user; omit the recipient override for self recharge", +}; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} +export function baiBusinessMessage(code: string): string | undefined { + return Object.hasOwn(BUSINESS_ERRORS, code) ? BUSINESS_ERRORS[code] : undefined; +} + +export function baiApiError( + value: unknown, + procedure: string, + status: number, +): TransportError | undefined { + const root = record(Array.isArray(value) ? value[0] : value); + const error = record(root?.error); + const detail = record(error?.json) ?? error; + const result = record(record(root?.result)?.data); + const payload = record(result?.json) ?? root; + const failed = root?.error !== undefined || payload?.success === false || status >= 400; + if (!failed) return undefined; + const context = { procedure, httpStatus: status, retryPayment: false }; + if (status === 401 || status === 403) + return new TransportError( + "bai_auth_failed", + "B.AI rejected this API key or its permissions; check the personal API key for this user", + context, + ); + if (status === 429) + return new TransportError( + "provider_rate_limited", + "B.AI rate limit exceeded; wait before retrying this API operation", + context, + ); + const candidates = [detail?.message, detail?.code, record(detail?.data)?.code, payload?.code]; + for (const candidate of candidates) { + const reason = + candidate === "请勿输入当前账号的邮箱或地址" ? "SELF_RECHARGE_TARGET" : candidate; + if (typeof reason !== "string") continue; + const message = baiBusinessMessage(reason); + if (message) return new TransportError("bai_rejected", message, { ...context, reason }); + } + const rpcCode = record(detail?.data)?.code; + if (rpcCode === "UNAUTHORIZED" || rpcCode === "FORBIDDEN") + return new TransportError( + "bai_auth_failed", + "B.AI rejected this API key or its permissions; check the personal API key for this user", + context, + ); + if (rpcCode === "TOO_MANY_REQUESTS") + return new TransportError( + "provider_rate_limited", + "B.AI rate limit exceeded; wait before retrying this API operation", + context, + ); + return new TransportError( + "provider_error", + status >= 500 + ? "B.AI service failed; check service availability and reconcile any pending mutation before retrying" + : "B.AI rejected the API request with an unrecognized error; check the operation parameters and contact B.AI support if it persists", + context, + ); +} diff --git a/ts/src/adapters/outbound/bai/binding-store.test.ts b/ts/src/adapters/outbound/bai/binding-store.test.ts new file mode 100644 index 000000000..b924d7e42 --- /dev/null +++ b/ts/src/adapters/outbound/bai/binding-store.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FileBaiBindingStore } from "./binding-store.js"; +import { AtomicFileStore } from "../persistence/fs/index.js"; +it("persists confirmation without storing the API key and scopes it to key, chain and wallet", () => { + const root = mkdtempSync(join(tmpdir(), "bai-binding-")); + try { + const store = new FileBaiBindingStore(root, new AtomicFileStore()); + expect(store.isConfirmed("secret", "bnb", "payer")).toBe(false); + store.confirm("secret", "bnb", "payer"); + const reopened = new FileBaiBindingStore(root, new AtomicFileStore()); + expect(reopened.isConfirmed("secret", "bnb", "payer")).toBe(true); + expect(reopened.isConfirmed("different", "bnb", "payer")).toBe(false); + expect(reopened.isConfirmed("secret", "tron", "payer")).toBe(false); + expect(reopened.isConfirmed("secret", "bnb", "other")).toBe(false); + const path = join(root, "bai-binding.json"); + expect(readFileSync(path, "utf8")).not.toContain("secret"); + if (process.platform !== "win32") expect(statSync(path).mode & 0o777).toBe(0o600); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/ts/src/adapters/outbound/bai/binding-store.ts b/ts/src/adapters/outbound/bai/binding-store.ts new file mode 100644 index 000000000..5a246463e --- /dev/null +++ b/ts/src/adapters/outbound/bai/binding-store.ts @@ -0,0 +1,34 @@ +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { z } from "zod"; +import type { BaiBindingStore } from "../../../application/ports/bai-binding-store.js"; +import { AtomicFileStore } from "../persistence/fs/index.js"; + +const record = z.object({ version: z.literal(1), fingerprint: z.string().regex(/^[a-f0-9]{64}$/) }); +/** Stores only the last confirmed combination; never a second copy of the credential. */ +export class FileBaiBindingStore implements BaiBindingStore { + private readonly path: string; + constructor( + root: string, + private readonly store: AtomicFileStore, + ) { + this.path = join(root, "bai-binding.json"); + } + isConfirmed(apiKey: string, chain: string, address: string): boolean { + const value = record.safeParse(this.store.readJson(this.path)); + return value.success && value.data.fingerprint === this.fingerprint(apiKey, chain, address); + } + confirm(apiKey: string, chain: string, address: string): void { + this.store.withLock(this.path, () => + this.store.writeJson(this.path, { + version: 1, + fingerprint: this.fingerprint(apiKey, chain, address), + }), + ); + } + private fingerprint(apiKey: string, chain: string, address: string): string { + return createHash("sha256") + .update(JSON.stringify(["bai-binding-v1", apiKey, chain, address])) + .digest("hex"); + } +} diff --git a/ts/src/adapters/outbound/bai/client.test.ts b/ts/src/adapters/outbound/bai/client.test.ts new file mode 100644 index 000000000..00960238b --- /dev/null +++ b/ts/src/adapters/outbound/bai/client.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; +import { BaiClient } from "./client.js"; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("BaiClient", () => { + it("uses the live B.AI origin and prevents credential-bearing redirects", async () => { + const fetcher = vi.fn(async () => + response([ + { result: { data: { json: { points_balance: 1, monthly_spent: 0, monthly_chart: [] } } } }, + ]), + ); + await new BaiClient({ baiApiKey: "test-key" }, 1000, fetcher).status(); + const [url, init] = fetcher.mock.calls[0]! as unknown as [string, RequestInit]; + expect(new URL(url).origin).toBe("https://chat.bankofai.io"); + expect(init.redirect).toBe("error"); + }); + + it("maps the shared creation sort field to the order API's accepted spelling", async () => { + const fetcher = vi.fn(async () => + response([{ result: { data: { json: { data: [], page: 1, pageSize: 5 } } } }]), + ); + await new BaiClient({ baiApiKey: "test-key" }, 1000, fetcher).rechargeList({ + page: 1, + pageSize: 5, + sortBy: "created_at", + sortOrder: "desc", + }); + const [url] = fetcher.mock.calls[0]! as unknown as [string]; + expect(JSON.parse(new URL(url).searchParams.get("input")!)).toEqual({ + 0: { json: { page: 1, pageSize: 5, sortBy: "createdAt", order: "desc" } }, + }); + }); + it("queries points with a bearer key and unwraps a tRPC batch envelope", async () => { + const fetcher = vi.fn(async () => + response([ + { + result: { + data: { + json: { + points_balance: 123.5, + monthly_spent: 20, + monthly_chart: [{ month: "2026-09", points: 20 }], + }, + }, + }, + }, + ]), + ); + const client = new BaiClient( + { baiApiKey: "secret" }, + 1000, + fetcher as typeof fetch, + "https://bai.example", + ); + + await expect(client.status()).resolves.toEqual({ + pointsBalance: "123.5", + monthlySpent: "20", + monthlyChart: [{ month: "2026-09", points: "20" }], + }); + const [url, init] = fetcher.mock.calls[0]! as unknown as [string | URL | Request, RequestInit]; + expect(String(url)).toContain("/trpc/lambda/usage.summary"); + expect((init as RequestInit).headers).toMatchObject({ Authorization: "Bearer secret" }); + }); + + it("normalizes usage records and recharge orders", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + response([ + { + result: { + data: { + json: { data: [{ id: "u1", total_tokens: 42 }], page: 2, pageSize: 10, total: 11 }, + }, + }, + }, + ]), + ) + .mockResolvedValueOnce( + response([ + { + result: { + data: { json: { orders: [{ id: "o1", status: "paid" }], page: 1, pageSize: 20 } }, + }, + }, + ]), + ); + const client = new BaiClient( + { baiApiKey: "secret" }, + 1000, + fetcher as typeof fetch, + "https://bai.example", + ); + + await expect( + client.usageList({ page: 2, pageSize: 10, sortBy: "createdAt", sortOrder: "desc" }), + ).resolves.toMatchObject({ items: [{ id: "u1" }], page: 2, pageSize: 10, total: 11 }); + await expect( + client.rechargeList({ page: 1, pageSize: 20, sortBy: "createdAt", sortOrder: "desc" }), + ).resolves.toMatchObject({ items: [{ id: "o1", status: "paid" }], page: 1, pageSize: 20 }); + }); + + it("classifies missing credentials and rejected credentials without exposing the key", async () => { + const missing = new BaiClient({}, 1000, vi.fn(), "https://bai.example"); + await expect(missing.status()).rejects.toMatchObject({ code: "bai_credentials_missing" }); + + const rejected = new BaiClient( + { baiApiKey: "top-secret" }, + 1000, + vi.fn(async () => response({ error: "top-secret" }, 401)) as typeof fetch, + "https://bai.example", + ); + await expect(rejected.status()).rejects.toMatchObject({ + code: "bai_auth_failed", + message: expect.not.stringContaining("top-secret"), + }); + }); +}); diff --git a/ts/src/adapters/outbound/bai/client.ts b/ts/src/adapters/outbound/bai/client.ts new file mode 100644 index 000000000..74fa8e8ab --- /dev/null +++ b/ts/src/adapters/outbound/bai/client.ts @@ -0,0 +1,199 @@ +import { baiApiError } from "./api-error.js"; +import { boundedResponse, MAX_HTTP_RESPONSE_BYTES } from "../http/http-response.js"; +import { z } from "zod"; +import type { + BaiApi, + BaiPageInput, + BaiPageView, + BaiStatusView, +} from "../../../application/ports/bai-api.js"; +import type { Config } from "../../../domain/types/index.js"; +import { TransportError, UsageError } from "../../../domain/errors/index.js"; + +const BatchItemSchema = z.looseObject({ + result: z.looseObject({ data: z.unknown().optional() }).optional(), + error: z.unknown().optional(), +}); + +const ObjectSchema = z.record(z.string(), z.unknown()); + +export const DEFAULT_BAI_BASE_URL = "https://chat.bankofai.io"; + +export class BaiClient implements BaiApi { + constructor( + private readonly config: Pick, + private readonly timeoutMs: number, + private readonly fetcher: typeof fetch = globalThis.fetch, + private readonly baseUrl = DEFAULT_BAI_BASE_URL, + ) {} + + async status(): Promise { + const value = responseObject(await this.call("usage.summary", null)); + return { + pointsBalance: scalar(value.points_balance, "points_balance"), + monthlySpent: scalar(value.monthly_spent, "monthly_spent"), + monthlyChart: arrayOfObjects(value.monthly_chart).map((row) => ({ + month: scalar(row.month, "monthly_chart.month"), + points: scalar(row.points, "monthly_chart.points"), + })), + }; + } + + async usageList(input: BaiPageInput): Promise { + const raw = await this.call("usage.records", { ...input, mode: "all" }); + const parsed = z.looseObject({ data: z.array(ObjectSchema) }).safeParse(raw); + if (!parsed.success) + throw new TransportError("provider_error", "B.AI returned invalid usage records"); + return page(parsed.data); + } + + async rechargeList(input: BaiPageInput): Promise { + return page( + await this.call("order.listOrders", { + page: input.page, + pageSize: input.pageSize, + sortBy: input.sortBy === "created_at" ? "createdAt" : input.sortBy, + order: input.sortOrder, + }), + ); + } + + private async call(procedure: string, input: unknown): Promise { + const key = this.config.baiApiKey; + if (!key) { + throw new UsageError( + "bai_credentials_missing", + "B.AI API key is not configured; set config baiApiKey via the secure input channel", + ); + } + const encoded = encodeURIComponent( + JSON.stringify( + input === null + ? { 0: { json: null, meta: { values: ["undefined"], v: 1 } } } + : { 0: { json: input } }, + ), + ); + const query = new URLSearchParams(); + if (procedure === "usage.records" && input && typeof input === "object") { + for (const [name, value] of Object.entries(input)) { + if (value !== undefined) query.set(name, String(value)); + } + } + const suffix = + procedure === "usage.records" + ? `?${query}` + : procedure === "usage.summary" + ? "" + : `?batch=1&input=${encoded}`; + const url = `${this.baseUrl}/trpc/lambda/${procedure}${suffix}`; + const signal = AbortSignal.timeout(this.timeoutMs); + let response: Response; + try { + response = await this.fetcher(url, { + method: "GET", + headers: { Accept: "application/json", Authorization: `Bearer ${key}` }, + redirect: "error", + signal, + }); + if ([401, 403, 429].includes(response.status)) { + await response.body?.cancel(); + throw baiApiError(undefined, procedure, response.status)!; + } + response = await boundedResponse(response, MAX_HTTP_RESPONSE_BYTES, signal); + } catch (error) { + if (error instanceof TransportError) throw error; + if ( + signal.aborted || + (error instanceof Error && /TimeoutError|AbortError/.test(error.name)) + ) { + throw new TransportError("timeout", "B.AI API request timed out"); + } + throw new TransportError( + "provider_error", + "B.AI API connection failed; check connectivity and the service endpoint", + { procedure, reason: "connection_failed" }, + ); + } + let decoded: unknown; + try { + decoded = JSON.parse(await response.text()); + } catch { + const statusError = baiApiError(undefined, procedure, response.status); + if (statusError) throw statusError; + throw new TransportError("provider_error", "B.AI API returned malformed JSON", { + procedure, + reason: "malformed_json", + }); + } + const apiError = baiApiError(decoded, procedure, response.status); + if (apiError) throw apiError; + const first = Array.isArray(decoded) ? decoded[0] : decoded; + if ( + first && + typeof first === "object" && + !Array.isArray(first) && + !("error" in first) && + !("result" in first) + ) { + if (procedure === "usage.records" && "data" in first && Array.isArray(first.data)) + return first; + if (procedure === "usage.summary" && "points_balance" in first) return first; + } + const item = BatchItemSchema.safeParse(first); + if (!item.success || item.data.error !== undefined || item.data.result?.data === undefined) { + throw new TransportError("provider_error", "B.AI API returned an invalid tRPC response"); + } + const data = item.data.result.data; + if (data && typeof data === "object" && !Array.isArray(data) && "json" in data) { + return (data as { json: unknown }).json; + } + return data; + } +} + +function scalar(value: unknown, field: string): string { + if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") { + return String(value); + } + throw new TransportError("provider_error", `B.AI API response is missing ${field}`); +} + +function arrayOfObjects(value: unknown): Record[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + const parsed = ObjectSchema.safeParse(entry); + return parsed.success ? [parsed.data] : []; + }); +} + +function page(raw: unknown): BaiPageView { + const value = responseObject(raw); + const items = arrayOfObjects(Array.isArray(value.data) ? value.data : value.orders); + const pageNumber = finiteInteger(value.page, 1) ?? 1; + const pageSize = finiteInteger(value.pageSize, items.length) ?? items.length; + const total = finiteInteger(value.total, undefined); + return { + items, + page: pageNumber, + pageSize, + ...(total === undefined ? {} : { total }), + ...(typeof value.has_more === "boolean" ? { hasMore: value.has_more } : {}), + ...(typeof value.next_cursor === "string" || value.next_cursor === null + ? { nextCursor: value.next_cursor } + : {}), + }; +} + +function finiteInteger(value: unknown, fallback: number | undefined): number | undefined { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback; +} + +function responseObject(value: unknown): Record { + const result = ObjectSchema.safeParse(value); + if (!result.success) + throw new TransportError("provider_error", "B.AI returned an invalid response object", { + reason: "invalid_response", + }); + return result.data; +} diff --git a/ts/src/adapters/outbound/bai/http-boundary.test.ts b/ts/src/adapters/outbound/bai/http-boundary.test.ts new file mode 100644 index 000000000..01a028d41 --- /dev/null +++ b/ts/src/adapters/outbound/bai/http-boundary.test.ts @@ -0,0 +1,52 @@ +import { expect, it, vi } from "vitest"; +import { BaiClient } from "./client.js"; +import { BaiRechargeClient } from "./recharge-client.js"; + +for (const kind of ["query", "recharge"] as const) { + const call = (fetcher: typeof fetch, timeout = 1000) => + kind === "query" + ? new BaiClient({ baiApiKey: "test-key" }, timeout, fetcher).status() + : new BaiRechargeClient({ baiApiKey: "test-key" }, timeout, fetcher).createOrder({ + channel: "crypto", + walletAddress: "payer", + chain: "tron", + tokenName: "USDT", + amount: 1, + deviceType: "web", + }); + it(`${kind} cancels oversized chunked responses before reading to the end`, async () => { + let chunks = 0; + const cancel = vi.fn(); + const fetcher = vi.fn( + async () => + new Response( + new ReadableStream( + { + pull(controller) { + chunks++; + controller.enqueue(new Uint8Array(1024 * 1024)); + }, + cancel, + }, + { highWaterMark: 0 }, + ), + ), + ); + await expect(call(fetcher)).rejects.toMatchObject({ code: "response_too_large" }); + expect(chunks).toBe(11); + expect(cancel).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledOnce(); + }); + it(`${kind} maps stalled response bodies to timeout without retrying`, async () => { + const cancel = vi.fn(); + const fetcher = vi.fn(async () => new Response(new ReadableStream({ pull() {}, cancel }))); + await expect(call(fetcher, 10)).rejects.toMatchObject({ code: "timeout" }); + expect(cancel).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledOnce(); + }); + it(`${kind} keeps malformed JSON distinct from timeout`, async () => { + await expect(call(async () => new Response("{"))).rejects.toMatchObject({ + code: "provider_error", + }); + }); +} diff --git a/ts/src/adapters/outbound/bai/recharge-client.test.ts b/ts/src/adapters/outbound/bai/recharge-client.test.ts new file mode 100644 index 000000000..71cf2c64e --- /dev/null +++ b/ts/src/adapters/outbound/bai/recharge-client.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from "vitest"; +import { BaiRechargeClient } from "./recharge-client.js"; +const target = { + input: { type: "personal" as const, identifier: "recipient@example.com" }, + confirmedTarget: { type: "personal" as const, targetId: "recipient-id" }, +}; +function fixture(payload: unknown) { + const fetcher = vi.fn( + async (_url: string, _init: RequestInit) => new Response(JSON.stringify(payload)), + ); + return { + fetcher, + client: new BaiRechargeClient( + { baiApiKey: "secret" }, + 1000, + fetcher as typeof fetch, + "https://bai.example", + ), + }; +} +describe("B.AI recharge API", () => { + it("checks binding without treating the boolean as a recipient ID", async () => { + const { client, fetcher } = fixture({ result: { data: { json: true } } }); + await expect(client.isBound({ address: "payer", chain: "bnb" })).resolves.toBe(true); + const [url, init] = fetcher.mock.calls[0]!; + expect(new URL(url).pathname).toBe("/trpc/lambda/wallet.isRechargeBound"); + expect(JSON.parse(new URL(url).searchParams.get("input")!)).toEqual({ + json: { address: "payer", chain: "bnb" }, + }); + expect(init).toMatchObject({ method: "GET", redirect: "error" }); + }); + it("binds the payer with a supplied signed message", async () => { + const { client, fetcher } = fixture({ + success: true, + binding: { + userId: "payer-id", + address: "0x1234567890abcdef1234567890abcdef12345678", + chain: "bnb", + }, + }); + const input = { + address: "0x1234567890abcdef1234567890abcdef12345678", + chain: "bnb", + message: "documented message", + signature: "signature", + version: 2, + }; + await expect(client.bind(input)).resolves.toEqual({ + userId: "payer-id", + address: "0x1234567890abcdef1234567890abcdef12345678", + chain: "bnb", + }); + const [url, init] = fetcher.mock.calls[0]!; + expect(url).toContain("/wallet.bindRechargeWallet"); + expect(init.headers).toMatchObject({ Authorization: "Bearer secret" }); + expect(JSON.parse(init.body as string)).toEqual({ json: input }); + }); + it.each(["bnb", "base"])( + "accepts canonical eth binding for %s and preserves signed bytes", + async (chain) => { + const address = "0xABCDEF1234567890abcdef1234567890abcdef12"; + const returned = { userId: "payer-id", address: address.toLowerCase(), chain: "eth" }; + const { client, fetcher } = fixture({ + result: { data: { json: { success: true, binding: returned } } }, + }); + const message = + " Welcome to BAI !\nhttps://chat.bankofai.io wants you to confirm wallet binding for recharge:\n" + + address + + "\n"; + await expect( + client.bind({ address, chain, message, signature: "signature" }), + ).resolves.toEqual(returned); + expect(JSON.parse(fetcher.mock.calls[0]![1].body as string).json.message).toBe(message); + }, + ); + it.each([ + [ + "base", + "bnb", + "0xABCDEF1234567890abcdef1234567890abcdef12", + "0xabcdef1234567890abcdef1234567890abcdef12", + ], + [ + "base", + "eth", + "0xABCDEF1234567890abcdef1234567890abcdef12", + "0xabcdef1234567890abcdef1234567890abcdef13", + ], + ["tron", "eth", "TPayer", "TPayer"], + ["tron", "tron", "TPayer", "Tpayer"], + ])("rejects mismatched binding %s/%s", async (chain, returnedChain, address, returnedAddress) => { + const { client } = fixture({ + success: true, + binding: { userId: "id", chain: returnedChain, address: returnedAddress }, + }); + await expect( + client.bind({ chain, address, message: "message", signature: "signature" }), + ).rejects.toMatchObject({ code: "provider_error" }); + }); + it("accepts an exact TRON binding", async () => { + const returned = { userId: "id", chain: "tron", address: "TPayer" }; + await expect( + fixture({ success: true, binding: returned }).client.bind({ + chain: "tron", + address: "TPayer", + message: "message", + signature: "signature", + version: 2, + }), + ).resolves.toEqual(returned); + }); + it("creates a preorder carrying the exact payer and confirmed recipient", async () => { + const { client, fetcher } = fixture({ result: { data: { json: { orderId: 123 } } } }); + const input = { + channel: "crypto" as const, + chain: "bnb", + tokenName: "USDT", + amount: 10, + walletAddress: "payer", + deviceType: "web" as const, + rechargeTarget: target, + }; + await expect(client.createOrder(input)).resolves.toEqual({ orderId: 123 }); + expect(JSON.parse(fetcher.mock.calls[0]![1].body as string)).toEqual({ json: input }); + }); + it("reports the paid transaction and preserves credited order data", async () => { + const order = { id: 12345, status: "success", points: 100000 }; + const { client, fetcher } = fixture({ success: true, order }); + const input = { chain: "bnb", txHash: "hash", rechargeTarget: target }; + await expect(client.reportTxHash(input)).resolves.toEqual({ success: true, order }); + expect(JSON.parse(fetcher.mock.calls[0]![1].body as string)).toEqual({ json: input }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + it.each(["PAYER_MISMATCH", "TX_NOT_FOUND_OR_INVALID", "WALLET_NOT_BOUND"])( + "preserves %s without exposing server details or retrying", + async (code) => { + const { client, fetcher } = fixture({ success: false, code, message: "secret" }); + await expect( + client.reportTxHash({ chain: "bnb", txHash: "hash", rechargeTarget: target }), + ).resolves.toEqual({ success: false, code, message: expect.not.stringContaining("secret") }); + expect(fetcher).toHaveBeenCalledTimes(1); + }, + ); + it("rejects a mismatched payer binding", async () => { + const { client } = fixture({ + success: true, + binding: { userId: "id", address: "different", chain: "bnb" }, + }); + await expect( + client.bind({ address: "payer", chain: "bnb", message: "m", signature: "s" }), + ).rejects.toMatchObject({ code: "provider_error" }); + }); + it.each([{}, { success: true }, { result: { data: { json: "true" } } }])( + "rejects malformed binding status", + async (payload) => { + await expect( + fixture(payload).client.isBound({ address: "payer", chain: "bnb" }), + ).rejects.toMatchObject({ code: "provider_error" }); + }, + ); + it("rejects missing credentials before HTTP", async () => { + const fetcher = vi.fn(); + await expect( + new BaiRechargeClient({}, 1000, fetcher).isBound({ address: "payer", chain: "bnb" }), + ).rejects.toMatchObject({ code: "bai_credentials_missing" }); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); + +it.each([ + [401, "bai_auth_failed"], + [403, "bai_auth_failed"], + [429, "provider_rate_limited"], + [500, "provider_error"], +])("classifies HTTP %s without exposing response details", async (status, code) => { + const fetcher = vi.fn(async () => new Response("secret", { status: Number(status) })); + await expect( + new BaiRechargeClient({ baiApiKey: "secret" }, 1000, fetcher).isBound({ + address: "payer", + chain: "bnb", + }), + ).rejects.toMatchObject({ code, message: expect.not.stringContaining("secret") }); + expect(fetcher).toHaveBeenCalledTimes(1); +}); +it("does not follow credential-bearing mutation redirects", async () => { + const fetcher = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.redirect).toBe("error"); + throw new TypeError("redirect to secret"); + }); + await expect( + new BaiRechargeClient({ baiApiKey: "secret" }, 1000, fetcher).bind({ + address: "payer", + chain: "bnb", + message: "m", + signature: "s", + }), + ).rejects.toMatchObject({ + code: "provider_error", + message: expect.not.stringContaining("secret"), + }); + expect(fetcher).toHaveBeenCalledTimes(1); +}); +it("refuses malformed or missing credited order results", async () => { + await expect( + fixture({ success: true }).client.reportTxHash({ + chain: "bnb", + txHash: "hash", + rechargeTarget: target, + }), + ).rejects.toMatchObject({ code: "provider_error" }); +}); + +it("resolves a recipient through the documented endpoint before constructing a target", async () => { + const { client, fetcher } = fixture({ + result: { + data: { + json: { type: "personal", targetId: "recipient-id", displayLabel: "recipient@example.com" }, + }, + }, + }); + await expect(client.resolveTarget("recipient@example.com")).resolves.toEqual({ + type: "personal", + targetId: "recipient-id", + displayLabel: "recipient@example.com", + }); + expect(fetcher.mock.calls[0]![0]).toContain("/order.resolveRechargeTarget"); + expect(JSON.parse(fetcher.mock.calls[0]![1].body as string)).toEqual({ + json: { type: "personal", identifier: "recipient@example.com" }, + }); +}); +it("rejects an unresolved or wrong-type recipient", async () => { + for (const payload of [ + { type: "personal" }, + { type: "organization", targetId: "id", displayLabel: "label" }, + ]) { + await expect( + fixture({ result: { data: { json: payload } } }).client.resolveTarget("recipient"), + ).rejects.toMatchObject({ code: "provider_error" }); + } +}); +it("rejects an empty preorder response before the caller can pay", async () => { + await expect( + fixture({}).client.createOrder({ + channel: "crypto", + chain: "bnb", + tokenName: "USDT", + amount: 10, + walletAddress: "payer", + deviceType: "web", + rechargeTarget: target, + }), + ).rejects.toMatchObject({ code: "provider_error" }); +}); diff --git a/ts/src/adapters/outbound/bai/recharge-client.ts b/ts/src/adapters/outbound/bai/recharge-client.ts new file mode 100644 index 000000000..393f0f480 --- /dev/null +++ b/ts/src/adapters/outbound/bai/recharge-client.ts @@ -0,0 +1,232 @@ +import { baiApiError, baiBusinessMessage } from "./api-error.js"; +import { boundedResponse, MAX_HTTP_RESPONSE_BYTES } from "../http/http-response.js"; +import { z } from "zod"; +import type { Config } from "../../../domain/types/index.js"; +import { TransportError, UsageError } from "../../../domain/errors/index.js"; +import type { + BaiRechargeApi, + BaiBindWalletInput, + BaiWalletBindingInput, + BaiCreateOrderInput, + BaiReportTransactionInput, + BaiReportResult, +} from "../../../application/ports/bai-recharge.js"; +import { DEFAULT_BAI_BASE_URL } from "./client.js"; + +const text = z.string().trim().min(1); +const wallet = z.object({ address: text, chain: text }); +const target = z.object({ + input: z.object({ type: z.literal("personal"), identifier: text }), + confirmedTarget: z.object({ type: z.literal("personal"), targetId: text }), +}); +const amount = z.number().positive().finite().max(Number.MAX_SAFE_INTEGER); +const bindInput = wallet.extend({ + // Preserve the exact bytes signed by the wallet, including surrounding whitespace. + message: z.string().refine((value) => value.trim().length > 0), + signature: text, + version: z.number().int().positive().optional(), +}); +const orderInput = z.object({ + channel: z.literal("crypto"), + chain: text, + tokenName: text, + amount, + walletAddress: text, + deviceType: z.literal("web"), + rechargeTarget: target.optional(), +}); +const reportInput = z.object({ + chain: text, + txHash: text, + rechargeTarget: target.optional(), + amount: amount.optional(), +}); +const object = z.record(z.string(), z.unknown()); +const binding = z.object({ success: z.literal(true), binding: wallet.extend({ userId: text }) }); +const report = z.discriminatedUnion("success", [ + z.object({ success: z.literal(true), order: object }), + z.object({ success: z.literal(false), code: z.string().regex(/^[A-Z][A-Z0-9_]{0,80}$/) }), +]); + +/** Documented non-batch tRPC recharge endpoints. Never signs, transfers, or retries mutations. */ +export class BaiRechargeClient implements BaiRechargeApi { + constructor( + private readonly config: Pick, + private readonly timeoutMs: number, + private readonly fetcher: typeof fetch = globalThis.fetch, + private readonly baseUrl = DEFAULT_BAI_BASE_URL, + ) {} + + async resolveTarget(identifier: string) { + return this.decode( + z.object({ type: z.literal("personal"), targetId: text, displayLabel: text }), + await this.call("order.resolveRechargeTarget", { + type: "personal", + identifier: this.input(text, identifier), + }), + ); + } + async isBound(input: BaiWalletBindingInput): Promise { + return this.decode( + z.boolean(), + await this.call("wallet.isRechargeBound", this.input(wallet, input), "GET"), + ); + } + async bind(input: BaiBindWalletInput) { + const checked = this.input(bindInput, input); + const result = this.decode( + binding, + await this.call("wallet.bindRechargeWallet", checked), + ).binding; + const evm = ["bnb", "base", "eth"].includes(checked.chain); + const matchingAddress = evm + ? /^0x[0-9a-fA-F]{40}$/.test(checked.address) && + result.address.toLowerCase() === checked.address.toLowerCase() + : result.address === checked.address; + const matchingChain = result.chain === checked.chain || (evm && result.chain === "eth"); + if (!matchingAddress) + throw new TransportError( + "provider_error", + "B.AI returned a binding for a different or invalid wallet address", + { + procedure: "wallet.bindRechargeWallet", + reason: "binding_address_mismatch", + retryPayment: false, + }, + ); + if (!matchingChain) + throw new TransportError("provider_error", "B.AI returned a binding for a different chain", { + procedure: "wallet.bindRechargeWallet", + reason: "binding_chain_mismatch", + retryPayment: false, + }); + return result; + } + async createOrder(input: BaiCreateOrderInput): Promise> { + const result = this.decode( + object, + await this.call("order.createOrder", this.input(orderInput, input)), + ); + if (result.success === false || Object.keys(result).length === 0) throw this.invalid(); + return result; + } + async reportTxHash(input: BaiReportTransactionInput): Promise { + const result = this.decode( + report, + await this.call("order.reportTxHash", this.input(reportInput, input)), + ); + return result.success + ? result + : { + ...result, + message: + baiBusinessMessage(result.code) ?? + "B.AI has not confirmed credit; retain the hash and reconcile before retrying reporting. Do not pay again", + }; + } + private input(schema: z.ZodType, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) + throw new UsageError("invalid_value", "Invalid B.AI request parameters", { + fields: [...new Set(result.error.issues.map((issue) => issue.path.join(".")))], + }); + return result.data; + } + private invalid() { + return new TransportError("provider_error", "B.AI recharge API returned an invalid response"); + } + private decode(schema: z.ZodType, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) throw this.invalid(); + return result.data; + } + private async call( + procedure: string, + input: unknown, + method: "GET" | "POST" = "POST", + ): Promise { + const key = this.config.baiApiKey; + if (!key) + throw new UsageError( + "bai_credentials_missing", + "B.AI credential is required for recharge account operations", + ); + const url = new URL(`/trpc/lambda/${procedure}`, this.baseUrl); + if (url.protocol !== "https:" || url.username || url.password) + throw new UsageError( + "invalid_value", + "B.AI recharge API requires an HTTPS origin without URL credentials", + ); + const payload = JSON.stringify({ json: input }); + if (method === "GET") url.searchParams.set("input", payload); + const signal = AbortSignal.timeout(this.timeoutMs); + let response: Response; + let decoded: unknown; + try { + response = await this.fetcher(url.toString(), { + method, + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + redirect: "error", + signal, + ...(method === "POST" ? { body: payload } : {}), + }); + if ([401, 403, 429].includes(response.status)) { + await response.body?.cancel(); + throw baiApiError(undefined, procedure, response.status)!; + } + response = await boundedResponse(response, MAX_HTTP_RESPONSE_BYTES, signal); + try { + decoded = await response.json(); + } catch { + const statusError = baiApiError(undefined, procedure, response.status); + if (statusError) throw statusError; + throw new TransportError("provider_error", "B.AI returned malformed JSON", { + procedure, + reason: "malformed_json", + retryPayment: false, + }); + } + const apiError = baiApiError(decoded, procedure, response.status); + // A report rejection is a credit result, not a reason to pay again. + const envelope = object.safeParse(decoded); + const data = envelope.success ? envelope.data : undefined; + const wrapped = data?.result as { data?: { json?: unknown } } | undefined; + const reported = report.safeParse(wrapped?.data?.json ?? data); + if ( + apiError && + !( + response.ok && + procedure === "order.reportTxHash" && + reported.success && + !reported.data.success + ) + ) + throw apiError; + } catch (error) { + if (error instanceof TransportError) throw error; + if (error instanceof Error && /TimeoutError|AbortError/.test(error.name)) + throw new TransportError( + "timeout", + "B.AI recharge API timed out; mutation outcome may be unknown", + ); + throw new TransportError( + "provider_error", + "B.AI recharge API connection failed; check connectivity and reconcile any pending mutation before retrying", + { procedure, reason: "connection_failed", retryPayment: false }, + ); + } + const envelope = this.decode(object, decoded); + if (envelope.error !== undefined) throw this.invalid(); + if (envelope.result !== undefined) { + const result = this.decode(object, envelope.result); + const data = this.decode(object, result.data); + if (!("json" in data)) throw this.invalid(); + return data.json; + } + return envelope; + } +} diff --git a/ts/src/adapters/outbound/bai/usage.test.ts b/ts/src/adapters/outbound/bai/usage.test.ts new file mode 100644 index 000000000..410d81526 --- /dev/null +++ b/ts/src/adapters/outbound/bai/usage.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { BaiClient } from "./client.js"; +const response = (data: unknown) => new Response(JSON.stringify(data)); + +describe("documented BAI usage API", () => { + it("preserves cursor metadata from the documented direct response", async () => { + const fetcher = vi.fn(async () => + response({ data: [], has_more: true, next_cursor: "next", page: 1, pageSize: 20 }), + ); + await expect( + new BaiClient({ baiApiKey: "test" }, 1000, fetcher).usageList({ + page: 1, + pageSize: 20, + sortBy: "created_at", + sortOrder: "desc", + }), + ).resolves.toMatchObject({ hasMore: true, nextCursor: "next" }); + }); + it("accepts the documented direct summary", async () => { + const fetcher = vi.fn(async () => + response({ points_balance: 10, monthly_spent: 2, monthly_chart: [] }), + ); + await expect( + new BaiClient({ baiApiKey: "test" }, 1000, fetcher).status(), + ).resolves.toMatchObject({ pointsBalance: "10", monthlySpent: "2" }); + }); +}); diff --git a/ts/src/adapters/outbound/config/bai-builtins.ts b/ts/src/adapters/outbound/config/bai-builtins.ts new file mode 100644 index 000000000..ffa7f7ad4 --- /dev/null +++ b/ts/src/adapters/outbound/config/bai-builtins.ts @@ -0,0 +1,8 @@ +/** Trusted recharge destinations. Changing this allowlist requires a CLI release. + * These addresses cannot be overridden by user configuration. + */ +export const BAI_RECHARGE_ADDRESSES: Readonly> = Object.freeze({ + tron: "TSNEPtuCagKEgF2EU4pAKWLzXLz1bekfTE", + bnb: "0x060f7fd9c9622bdcf9f2887c8171d6e6b4b4ba17", + base: "0x10bf3d09bd80a00ddbbfe934c7dcc477b42ffdb0", +}); diff --git a/ts/src/adapters/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index 0f1cfc8fa..524b96a4f 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -15,6 +15,8 @@ export const TRAIT_SUMMARIES: Record = {}; /** human-readable labels for command-backed capability keys (the keys commands declare via * `capability`). Sibling of TRAIT_SUMMARIES; the runner resolves both the same way. */ export const CAP_SUMMARIES: Record = { + "erc8004.identity.read": "Read ERC-8004 identities and operator approvals", + "erc8004.identity.write": "Register and manage ERC-8004 identities", "account.balance.native": "native balance", "account.balance.token": "token balance", "account.portfolio": "holdings with USD valuation", @@ -133,6 +135,25 @@ export const BUILTIN_NETWORKS: Record = { feeModel: "evm-gas", capabilities: [], }, + "eip155:8453": { + id: "eip155:8453", + nativeSymbol: "ETH", + family: "evm", + chainId: "8453", + httpEndpoint: "https://mainnet.base.org", + feeModel: "evm-gas", + capabilities: [], + }, + "eip155:84532": { + id: "eip155:84532", + nativeSymbol: "ETH", + family: "evm", + chainId: "84532", + httpEndpoint: "https://sepolia.base.org", + feeModel: "evm-gas", + capabilities: [], + testnet: true, + }, }; /** The short name a person types, plus — for TRON only — the id this CLI carried before its @@ -158,6 +179,8 @@ export const BUILTIN_ALIASES: Record = { sepolia: "eip155:11155111", bsc: "eip155:56", "bsc-testnet": "eip155:97", + base: "eip155:8453", + "base-sepolia": "eip155:84532", }; export const DEFAULT_CONFIG = { diff --git a/ts/src/adapters/outbound/config/config.test.ts b/ts/src/adapters/outbound/config/config.test.ts index f5ad34a23..0370362f9 100644 --- a/ts/src/adapters/outbound/config/config.test.ts +++ b/ts/src/adapters/outbound/config/config.test.ts @@ -24,12 +24,10 @@ describe("ConfigLoader defaultNetwork", () => { expect(registry.resolveDefault().id).toBe("tron:3448148188"); }); - // Base is an L2, so it is in neither table (see the evm-gas fee-model note below); a name that - // reaches neither is rejected outright. There is no family-level gate — every builtin EVM - // network and alias resolves, as the alias-book cases assert. + // Unknown names are rejected; every builtin network and alias resolves. it("rejects a name that is in neither the builtin table nor the alias book", () => { const registry = new NetworkRegistry(ConfigLoader.load(envWithConfig(""))); - expect(() => registry.resolve("base")).toThrow(/unknown network/); + expect(() => registry.resolve("not-a-network")).toThrow(/unknown network/); }); }); @@ -100,6 +98,23 @@ describe("ConfigLoader GasFree credentials", () => { ); }); +describe("ConfigLoader B.AI configuration", () => { + const configured = ["baiApiKey: bai_test_secret", ""].join("\n"); + + it("loads the B.AI key only from a private config file", () => { + expect(ConfigLoader.load(envWithConfig(configured, 0o600))).toMatchObject({ + baiApiKey: "bai_test_secret", + }); + }); + + it.runIf(process.platform !== "win32")( + "rejects a B.AI key in a group/world-readable file", + () => { + expect(() => ConfigLoader.load(envWithConfig(configured, 0o644))).toThrow(/mode 0600/); + }, + ); +}); + // A broken config.yaml is the user's typo, not an internal fault — but the underlying errors quote // file content (YAML parse) or OS detail, and a credential can sit on the very line that failed. describe("ConfigLoader unreadable/malformed config", () => { @@ -142,24 +157,44 @@ describe("ConfigLoader unreadable/malformed config", () => { describe("builtin EVM networks", () => { const registry = () => new NetworkRegistry(ConfigLoader.load(envWithConfig(""))); - // One L1 pair per chain. L2s are deliberately excluded — the evm-gas fee model computes - // gasLimit x gasPrice and would systematically under-report cost on rollups. + // Base is included for x402 USDC authorization. The existing evm-gas model describes + // execution gas only; it does not include rollup L1 data fees for ordinary transactions. it.each([ ["eip155:1", "1"], ["eip155:11155111", "11155111"], ["eip155:56", "56"], ["eip155:97", "97"], + ["eip155:8453", "8453"], + ["eip155:84532", "84532"], ])("resolves %s as an evm-gas network", (id, chainId) => { const net = registry().resolve(id); expect(net).toMatchObject({ id, family: "evm", chainId, feeModel: "evm-gas" }); }); it("ships every EVM network with a usable endpoint", () => { - for (const id of ["eip155:1", "eip155:11155111", "eip155:56", "eip155:97"]) { + for (const id of [ + "eip155:1", + "eip155:11155111", + "eip155:56", + "eip155:97", + "eip155:8453", + "eip155:84532", + ]) { expect(registry().resolve(id).httpEndpoint).toMatch(/^https:\/\//); } }); + it("pairs Base mainnet with its builtin testnet and alias", () => { + const testnet = registry().resolve("base-sepolia"); + expect(testnet).toMatchObject({ + id: "eip155:84532", + chainId: "84532", + testnet: true, + httpEndpoint: "https://sepolia.base.org", + }); + expect(registry().resolve("base").testnet).not.toBe(true); + }); + it("keeps the TRON networks unchanged", () => { expect(registry().resolve("tron:3448148188")).toMatchObject({ family: "tron", diff --git a/ts/src/adapters/outbound/config/index.ts b/ts/src/adapters/outbound/config/index.ts index 8185d698a..157b3fe97 100644 --- a/ts/src/adapters/outbound/config/index.ts +++ b/ts/src/adapters/outbound/config/index.ts @@ -42,6 +42,7 @@ export class ConfigLoader { let tronlinkChannel: string | undefined; let gasfreeApiKey: string | undefined; let gasfreeApiSecret: string | undefined; + let baiApiKey: string | undefined; const path = ConfigLoader.configPath(env); if (existsSync(path)) { @@ -49,6 +50,7 @@ export class ConfigLoader { if ( (typeof raw.tronlinkSecretKey === "string" && raw.tronlinkSecretKey !== "") || (typeof raw.gasfreeApiSecret === "string" && raw.gasfreeApiSecret !== "") || + (typeof raw.baiApiKey === "string" && raw.baiApiKey !== "") || // A network's RPC apiKey is a credential too, and it sits NESTED under `networks`; a gate // that only inspected top-level keys would hand out a 644 file holding one. holdsNetworkApiKey(raw.networks) @@ -76,6 +78,7 @@ export class ConfigLoader { if (validCredential(raw.tronlinkChannel)) tronlinkChannel = raw.tronlinkChannel; if (validCredential(raw.gasfreeApiKey)) gasfreeApiKey = raw.gasfreeApiKey; if (validCredential(raw.gasfreeApiSecret)) gasfreeApiSecret = raw.gasfreeApiSecret; + if (validCredential(raw.baiApiKey)) baiApiKey = raw.baiApiKey; // aliases first: a network key may be written as an alias, and normalising it needs the // book the same file may have just extended. if (raw.aliases && typeof raw.aliases === "object" && !Array.isArray(raw.aliases)) { @@ -133,6 +136,7 @@ export class ConfigLoader { tronlinkChannel, gasfreeApiKey, gasfreeApiSecret, + baiApiKey, }; } } diff --git a/ts/src/adapters/outbound/config/x402-builtins.ts b/ts/src/adapters/outbound/config/x402-builtins.ts new file mode 100644 index 000000000..f5a0a1147 --- /dev/null +++ b/ts/src/adapters/outbound/config/x402-builtins.ts @@ -0,0 +1 @@ +export const DEFAULT_X402_FACILITATOR_URL = "https://facilitator.bankofai.io"; diff --git a/ts/src/adapters/outbound/erc8004/registration-loader.test.ts b/ts/src/adapters/outbound/erc8004/registration-loader.test.ts new file mode 100644 index 000000000..6d3a6623e --- /dev/null +++ b/ts/src/adapters/outbound/erc8004/registration-loader.test.ts @@ -0,0 +1,368 @@ +import { lookup } from "node:dns/promises"; +import { EventEmitter } from "node:events"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { Readable } from "node:stream"; +import type { ClientRequest, IncomingMessage, RequestOptions } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RegistrationLoader } from "./registration-loader.js"; + +vi.mock("node:dns/promises", () => ({ lookup: vi.fn() })); +vi.mock("node:http", () => ({ request: vi.fn() })); +vi.mock("node:https", () => ({ request: vi.fn() })); + +const dnsLookup = vi.mocked(lookup); +const httpRequestMock = vi.mocked(httpRequest); +const httpsRequestMock = vi.mocked(httpsRequest); +const fetchMock = vi.fn(); + +function allowPublicDns(): void { + dnsLookup.mockImplementation(async () => [{ address: "93.184.216.34", family: 4 }] as never); +} + +function incomingResponse( + body: string, + statusCode = 200, + headers: IncomingMessage["headers"] = {}, +): IncomingMessage { + const response = Readable.from([Buffer.from(body)]) as IncomingMessage; + response.statusCode = statusCode; + response.headers = headers; + return response; +} + +function clientRequest(): ClientRequest { + const request = new EventEmitter() as ClientRequest; + request.end = vi.fn(); + return request; +} + +function replyHttps( + body: string, + statusCode = 200, + headers: IncomingMessage["headers"] = {}, +): void { + httpsRequestMock.mockImplementationOnce((( + _url: URL, + _options: RequestOptions, + callback: (response: IncomingMessage) => void, + ) => { + callback(incomingResponse(body, statusCode, headers)); + return clientRequest(); + }) as typeof httpsRequest); +} + +function replyHttp(body: string, statusCode = 200): void { + httpRequestMock.mockImplementationOnce((( + _url: URL, + _options: RequestOptions, + callback: (response: IncomingMessage) => void, + ) => { + callback(incomingResponse(body, statusCode)); + return clientRequest(); + }) as typeof httpRequest); +} + +describe("RegistrationLoader", () => { + beforeEach(() => { + dnsLookup.mockReset(); + httpRequestMock.mockReset(); + httpsRequestMock.mockReset(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + it("decodes a strict base64 JSON data URI", async () => { + const encoded = Buffer.from(JSON.stringify({ name: "Ada", active: true })).toString("base64"); + + await expect( + new RegistrationLoader(1_000).load(`data:application/json;base64,${encoded}`), + ).resolves.toEqual({ metadata: { name: "Ada", active: true } }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(httpRequestMock).not.toHaveBeenCalled(); + expect(httpsRequestMock).not.toHaveBeenCalled(); + expect(dnsLookup).not.toHaveBeenCalled(); + }); + + it.each([ + ["non-base64 data", "data:application/json,%7B%7D"], + ["invalid base64", "data:application/json;base64,e30"], + ["non-JSON bytes", "data:application/json;base64,////"], + ])("rejects %s without transport access", async (_label, uri) => { + const result = await new RegistrationLoader(1_000).load(uri); + + expect(result.metadata).toBeUndefined(); + expect(result.warning).toMatch(/^Registration metadata /); + expect(fetchMock).not.toHaveBeenCalled(); + expect(httpRequestMock).not.toHaveBeenCalled(); + expect(httpsRequestMock).not.toHaveBeenCalled(); + expect(dnsLookup).not.toHaveBeenCalled(); + }); + + it.each([ + ["an array", ["not", "an", "object"]], + ["null", null], + ["a scalar", "metadata"], + ])("requires data metadata to be a JSON object: %s", async (_label, value) => { + const encoded = Buffer.from(JSON.stringify(value)).toString("base64"); + + await expect( + new RegistrationLoader(1_000).load(`data:application/json;base64,${encoded}`), + ).resolves.toEqual({ warning: "Registration metadata is not a JSON object" }); + }); + + it("loads an HTTP JSON object after resolving the host to a public address", async () => { + allowPublicDns(); + replyHttps(JSON.stringify({ name: "remote" }), 200, { "content-type": "application/json" }); + + await expect( + new RegistrationLoader(1_000).load("https://metadata.example/agent.json"), + ).resolves.toEqual({ metadata: { name: "remote" } }); + expect(String(httpsRequestMock.mock.calls[0]![0])).toBe("https://metadata.example/agent.json"); + expect(httpsRequestMock.mock.calls[0]![1]).toEqual( + expect.objectContaining({ + method: "GET", + lookup: expect.any(Function), + signal: expect.any(AbortSignal), + }), + ); + }); + + it("pins the connection to the vetted DNS address", async () => { + dnsLookup + .mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }] as never) + .mockResolvedValueOnce([{ address: "127.0.0.1", family: 4 }] as never); + let requestOptions: RequestOptions | undefined; + httpsRequestMock.mockImplementation((( + _url: URL, + options: RequestOptions, + callback: (response: IncomingMessage) => void, + ) => { + requestOptions = options; + const response = Readable.from(['{"name":"pinned"}']) as IncomingMessage; + response.statusCode = 200; + response.headers = {}; + callback(response); + const request = new EventEmitter() as ClientRequest; + request.end = vi.fn(); + return request; + }) as typeof httpsRequest); + fetchMock.mockRejectedValue(new Error("unsafe hostname resolution was used")); + + await expect( + new RegistrationLoader(1_000).load("https://metadata.example/agent.json"), + ).resolves.toEqual({ metadata: { name: "pinned" } }); + + expect(dnsLookup).toHaveBeenCalledTimes(1); + const pinned = await new Promise<{ address: string; family: number }>((resolve, reject) => { + requestOptions?.lookup?.("metadata.example", {}, (error, address, family) => { + if (error) reject(error); + else resolve({ address: String(address), family: Number(family) }); + }); + }); + expect(pinned).toEqual({ address: "93.184.216.34", family: 4 }); + }); + + it("uses the HTTP transport for an allowed http URI", async () => { + allowPublicDns(); + replyHttp('{"name":"http"}'); + + await expect( + new RegistrationLoader(1_000).load("http://metadata.example/agent.json"), + ).resolves.toEqual({ metadata: { name: "http" } }); + expect(httpRequestMock).toHaveBeenCalledTimes(1); + expect(httpsRequestMock).not.toHaveBeenCalled(); + }); + + it.each([ + "http://localhost/admin", + "http://127.0.0.1/admin", + "http://[::1]/admin", + "http://[::ffff:127.0.0.1]/admin", + "http://[fd00::1]/admin", + "http://[fe80::1]/admin", + "http://169.254.169.254/latest/meta-data/", + "http://10.0.0.4/private", + ])("blocks a local or private literal before fetch: %s", async (uri) => { + await expect(new RegistrationLoader(1_000).load(uri)).resolves.toEqual({ + warning: "Registration metadata URI targets a restricted network address", + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(httpRequestMock).not.toHaveBeenCalled(); + expect(httpsRequestMock).not.toHaveBeenCalled(); + }); + + it("blocks a host when DNS returns any private address", async () => { + dnsLookup.mockResolvedValue([ + { address: "93.184.216.34", family: 4 }, + { address: "192.168.1.20", family: 4 }, + ] as never); + + await expect( + new RegistrationLoader(1_000).load("https://mixed.example/agent"), + ).resolves.toEqual({ + warning: "Registration metadata URI targets a restricted network address", + }); + expect(httpsRequestMock).not.toHaveBeenCalled(); + }); + + it.each([ + "file:///etc/passwd", + "ftp://public.example/agent.json", + "https://alice:secret@metadata.example/agent.json?token=query-secret", + ])("rejects an unsafe URI without echoing it: %s", async (uri) => { + const result = await new RegistrationLoader(1_000).load(uri); + + expect(result).toEqual({ warning: "Registration metadata URI is invalid or unsupported" }); + expect(result.warning).not.toContain("secret"); + expect(result.warning).not.toContain("query-secret"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(httpRequestMock).not.toHaveBeenCalled(); + expect(httpsRequestMock).not.toHaveBeenCalled(); + }); + + it("maps an IPFS URI through the fixed HTTPS gateway", async () => { + allowPublicDns(); + replyHttps('{"name":"ipfs"}'); + + await expect( + new RegistrationLoader(1_000).load("ipfs://QmAgentCID/metadata/agent%201.json"), + ).resolves.toEqual({ metadata: { name: "ipfs" } }); + expect(String(httpsRequestMock.mock.calls[0]![0])).toBe( + "https://ipfs.io/ipfs/QmAgentCID/metadata/agent%201.json", + ); + }); + + it("validates every redirect target and does not expose its URL", async () => { + dnsLookup + .mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }] as never) + .mockResolvedValueOnce([{ address: "10.2.3.4", family: 4 }] as never); + replyHttps("", 302, { location: "http://internal.example/admin?token=redirect-secret" }); + + const result = await new RegistrationLoader(1_000).load("https://public.example/agent"); + + expect(result).toEqual({ + warning: "Registration metadata URI targets a restricted network address", + }); + expect(result.warning).not.toContain("redirect-secret"); + expect(httpsRequestMock).toHaveBeenCalledTimes(1); + }); + + it("bounds redirect chains", async () => { + allowPublicDns(); + httpsRequestMock.mockImplementation((( + input: URL, + _options: RequestOptions, + callback: (response: IncomingMessage) => void, + ) => { + const step = Number(input.searchParams.get("step") ?? "0"); + callback( + incomingResponse("", 302, { + location: `https://metadata.example/agent?step=${step + 1}`, + }), + ); + return clientRequest(); + }) as typeof httpsRequest); + + await expect( + new RegistrationLoader(1_000).load("https://metadata.example/agent?step=0"), + ).resolves.toEqual({ warning: "Registration metadata redirect limit exceeded" }); + expect(httpsRequestMock).toHaveBeenCalledTimes(4); + }); + + it("rejects a declared response larger than 1 MiB without reading it", async () => { + allowPublicDns(); + replyHttps("ignored", 200, { "content-length": String(1024 * 1024 + 1) }); + + await expect( + new RegistrationLoader(1_000).load("https://metadata.example/large"), + ).resolves.toEqual({ warning: "Registration metadata response exceeds the 1 MiB limit" }); + }); + + it("stops reading an undeclared response once it exceeds 1 MiB", async () => { + allowPublicDns(); + replyHttps("x".repeat(1024 * 1024 + 1)); + + await expect( + new RegistrationLoader(1_000).load("https://metadata.example/streamed"), + ).resolves.toEqual({ warning: "Registration metadata response exceeds the 1 MiB limit" }); + }); + + it("returns a sanitized warning for malformed remote JSON", async () => { + allowPublicDns(); + replyHttps("{not-json"); + + await expect( + new RegistrationLoader(1_000).load("https://metadata.example/agent?token=query-secret"), + ).resolves.toEqual({ warning: "Registration metadata contains malformed JSON" }); + }); + + it("returns a sanitized HTTP status warning", async () => { + allowPublicDns(); + replyHttps("provider secret", 503); + + await expect( + new RegistrationLoader(1_000).load("https://metadata.example/agent?token=query-secret"), + ).resolves.toEqual({ warning: "Registration metadata request returned HTTP 503" }); + }); + + it("aborts at the configured deadline without exposing the network error", async () => { + allowPublicDns(); + httpsRequestMock.mockImplementation((( + _url: URL, + _options: RequestOptions, + _callback: (response: IncomingMessage) => void, + ) => clientRequest()) as typeof httpsRequest); + + await expect( + new RegistrationLoader(5).load("https://metadata.example/agent?token=query-secret"), + ).resolves.toEqual({ warning: "Registration metadata request timed out" }); + }); + + it("destroys an active response stream at the deadline", async () => { + allowPublicDns(); + const response = new Readable({ read() {} }) as IncomingMessage; + response.statusCode = 200; + response.headers = {}; + httpsRequestMock.mockImplementation((( + _url: URL, + _options: RequestOptions, + callback: (value: IncomingMessage) => void, + ) => { + callback(response); + return clientRequest(); + }) as typeof httpsRequest); + + await expect( + new RegistrationLoader(5).load("https://metadata.example/never-ending"), + ).resolves.toEqual({ warning: "Registration metadata request timed out" }); + expect(response.destroyed).toBe(true); + }); + + it("does not start transport after a pending DNS lookup exceeds the deadline", async () => { + let releaseDns: ((addresses: Array<{ address: string; family: number }>) => void) | undefined; + dnsLookup.mockImplementation( + () => + new Promise((resolve) => { + releaseDns = resolve as typeof releaseDns; + }) as never, + ); + + await expect( + new RegistrationLoader(5).load("https://metadata.example/slow-dns"), + ).resolves.toEqual({ warning: "Registration metadata request timed out" }); + releaseDns?.([{ address: "93.184.216.34", family: 4 }]); + await new Promise((resolve) => setImmediate(resolve)); + expect(httpsRequestMock).not.toHaveBeenCalled(); + }); + + it("does not expose raw DNS or transport failures", async () => { + dnsLookup.mockRejectedValue( + new Error("getaddrinfo ENOTFOUND metadata.example?token=query-secret"), + ); + + await expect( + new RegistrationLoader(1_000).load("https://metadata.example/agent?token=query-secret"), + ).resolves.toEqual({ warning: "Registration metadata request failed" }); + }); +}); diff --git a/ts/src/adapters/outbound/erc8004/registration-loader.ts b/ts/src/adapters/outbound/erc8004/registration-loader.ts new file mode 100644 index 000000000..b5594d5da --- /dev/null +++ b/ts/src/adapters/outbound/erc8004/registration-loader.ts @@ -0,0 +1,391 @@ +import { lookup } from "node:dns/promises"; +import type { LookupAddress } from "node:dns"; +import { request as httpRequest, type IncomingMessage } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { BlockList, isIP, type LookupFunction } from "node:net"; + +const MAX_RESPONSE_BYTES = 1024 * 1024; +const MAX_BASE64_BYTES = Math.ceil(MAX_RESPONSE_BYTES / 3) * 4; +const MAX_REDIRECTS = 3; +const IPFS_GATEWAY = "https://ipfs.io/ipfs/"; + +export interface RegistrationLoadResult { + metadata?: Record; + warning?: string; +} + +type FailureKind = + | "invalid_uri" + | "restricted_address" + | "redirect_limit" + | "too_large" + | "malformed_json" + | "not_object" + | "timeout" + | "http_status" + | "request_failed"; + +class LoaderFailure extends Error { + constructor( + readonly kind: FailureKind, + readonly status?: number, + ) { + super(kind); + this.name = "LoaderFailure"; + } +} + +const restrictedAddresses = new BlockList(); + +for (const [network, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.88.99.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +] as const) { + restrictedAddresses.addSubnet(network, prefix, "ipv4"); +} + +for (const [network, prefix] of [ + ["::", 128], + ["::1", 128], + ["64:ff9b:1::", 48], + ["100::", 64], + ["2001:db8::", 32], + ["fc00::", 7], + ["fe80::", 10], + ["ff00::", 8], +] as const) { + restrictedAddresses.addSubnet(network, prefix, "ipv6"); +} + +/** Loads untrusted ERC-8004 registration metadata without exposing transport details. */ +export class RegistrationLoader { + readonly #timeoutMs: number; + + constructor(timeoutMs: number) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new RangeError("Registration metadata timeout must be positive"); + } + this.#timeoutMs = timeoutMs; + } + + async load(uri: string): Promise { + try { + if (/^data:/i.test(uri)) return decodeDataUri(uri); + const target = /^ipfs:/i.test(uri) ? ipfsGatewayUrl(uri) : uri; + return await this.#loadRemote(target); + } catch (error) { + return { warning: warningFor(error) }; + } + } + + async #loadRemote(initialTarget: string): Promise { + const controller = new AbortController(); + let timer: ReturnType | undefined; + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new LoaderFailure("timeout")); + }, this.#timeoutMs); + }); + + try { + return await Promise.race([fetchMetadata(initialTarget, controller.signal), deadline]); + } catch (error) { + if (controller.signal.aborted) throw new LoaderFailure("timeout"); + throw error; + } finally { + if (timer !== undefined) clearTimeout(timer); + controller.abort(); + } + } +} + +async function fetchMetadata( + initialTarget: string, + signal: AbortSignal, +): Promise { + let target = initialTarget; + let redirectCount = 0; + + while (true) { + const validated = await validatedRemoteUrl(target); + if (signal.aborted) throw new LoaderFailure("timeout"); + let response: IncomingMessage; + try { + response = await requestOnce(validated, signal); + } catch { + throw new LoaderFailure("request_failed"); + } + + const destroyOnAbort = () => response.destroy(); + signal.addEventListener("abort", destroyOnAbort, { once: true }); + try { + const status = response.statusCode ?? 0; + if (isRedirect(status)) { + const location = headerValue(response, "location"); + response.destroy(); + if (redirectCount >= MAX_REDIRECTS) throw new LoaderFailure("redirect_limit"); + if (!location) throw new LoaderFailure("invalid_uri"); + try { + target = new URL(location, validated.url).toString(); + } catch { + throw new LoaderFailure("invalid_uri"); + } + redirectCount += 1; + continue; + } + + if (status < 200 || status >= 300) { + response.destroy(); + throw new LoaderFailure("http_status", status); + } + + return parseMetadata(await readBoundedText(response)); + } finally { + signal.removeEventListener("abort", destroyOnAbort); + } + } +} + +interface ValidatedTarget { + url: URL; + addresses: LookupAddress[]; +} + +function requestOnce(target: ValidatedTarget, signal: AbortSignal): Promise { + const requestFn = target.url.protocol === "https:" ? httpsRequest : httpRequest; + const pinnedLookup: LookupFunction = (_hostname, options, callback) => { + const requestedFamily = Number(options.family ?? 0); + const candidates = target.addresses.filter( + (address) => requestedFamily === 0 || requestedFamily === address.family, + ); + if (candidates.length === 0) { + const error = Object.assign(new Error("Pinned address family unavailable"), { + code: "ENOTFOUND", + }); + callback(error, ""); + return; + } + if (options.all) callback(null, candidates); + else callback(null, candidates[0]!.address, candidates[0]!.family); + }; + + return new Promise((resolve, reject) => { + const request = requestFn( + target.url, + { + method: "GET", + headers: { accept: "application/json" }, + lookup: pinnedLookup, + signal, + }, + resolve, + ); + request.once("error", reject); + request.end(); + }); +} + +function isRedirect(status: number): boolean { + return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; +} + +async function validatedRemoteUrl(target: string): Promise { + let url: URL; + try { + url = new URL(target); + } catch { + throw new LoaderFailure("invalid_uri"); + } + + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username !== "" || + url.password !== "" + ) { + throw new LoaderFailure("invalid_uri"); + } + + const hostname = unbracket(url.hostname).toLowerCase().replace(/\.$/, ""); + if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local")) { + throw new LoaderFailure("restricted_address"); + } + + const family = isIP(hostname); + if (family !== 0) { + assertPublicAddress(hostname, family); + return { url, addresses: [{ address: hostname, family }] }; + } + + let addresses: LookupAddress[]; + try { + addresses = await lookup(hostname, { all: true, verbatim: true }); + } catch { + throw new LoaderFailure("request_failed"); + } + if (addresses.length === 0) throw new LoaderFailure("request_failed"); + for (const address of addresses) assertPublicAddress(address.address, address.family); + return { url, addresses }; +} + +function unbracket(hostname: string): string { + return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; +} + +function assertPublicAddress(address: string, family: number): void { + if ( + (family === 4 && restrictedAddresses.check(address, "ipv4")) || + (family === 6 && restrictedAddresses.check(address, "ipv6")) || + (family !== 4 && family !== 6) + ) { + throw new LoaderFailure("restricted_address"); + } +} + +function ipfsGatewayUrl(uri: string): string { + const match = /^ipfs:\/\/([^/?#]+)(\/[^?#]*)?(?:\?([^#]*))?(?:#.*)?$/i.exec(uri); + if (!match) throw new LoaderFailure("invalid_uri"); + + const cid = match[1]!; + if ( + cid.length > 128 || + !/^[A-Za-z0-9][A-Za-z0-9._~-]*$/.test(cid) || + cid === "." || + cid === ".." + ) { + throw new LoaderFailure("invalid_uri"); + } + + const path = normalizeIpfsPath(match[2] ?? ""); + const query = match[3] === undefined ? "" : `?${match[3]}`; + return `${IPFS_GATEWAY}${encodeURIComponent(cid)}${path}${query}`; +} + +function normalizeIpfsPath(path: string): string { + if (path === "") return ""; + try { + return `/${path + .slice(1) + .split("/") + .map((segment) => { + const decoded = decodeURIComponent(segment); + if (decoded === "." || decoded === "..") throw new LoaderFailure("invalid_uri"); + return encodeURIComponent(decoded); + }) + .join("/")}`; + } catch (error) { + if (error instanceof LoaderFailure) throw error; + throw new LoaderFailure("invalid_uri"); + } +} + +function decodeDataUri(uri: string): RegistrationLoadResult { + const match = /^data:application\/json;base64,([A-Za-z0-9+/]*={0,2})$/i.exec(uri); + if (!match) throw new LoaderFailure("invalid_uri"); + const encoded = match[1]!; + if (encoded.length === 0 || encoded.length % 4 !== 0) { + throw new LoaderFailure("invalid_uri"); + } + if (encoded.length > MAX_BASE64_BYTES) throw new LoaderFailure("too_large"); + + const bytes = Buffer.from(encoded, "base64"); + if (bytes.toString("base64") !== encoded) throw new LoaderFailure("invalid_uri"); + if (bytes.byteLength > MAX_RESPONSE_BYTES) throw new LoaderFailure("too_large"); + return parseMetadata(decodeUtf8(bytes)); +} + +async function readBoundedText(response: IncomingMessage): Promise { + const contentLength = headerValue(response, "content-length"); + if (contentLength && /^\d+$/.test(contentLength) && BigInt(contentLength) > MAX_RESPONSE_BYTES) { + response.destroy(); + throw new LoaderFailure("too_large"); + } + + const chunks: Uint8Array[] = []; + let total = 0; + try { + for await (const rawChunk of response) { + const value = typeof rawChunk === "string" ? Buffer.from(rawChunk) : new Uint8Array(rawChunk); + total += value.byteLength; + if (total > MAX_RESPONSE_BYTES) { + response.destroy(); + throw new LoaderFailure("too_large"); + } + chunks.push(value); + } + } catch (error) { + if (error instanceof LoaderFailure) throw error; + throw new LoaderFailure("request_failed"); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return decodeUtf8(bytes); +} + +function headerValue(response: IncomingMessage, name: string): string | undefined { + const value = response.headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +function decodeUtf8(bytes: Uint8Array): string { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new LoaderFailure("malformed_json"); + } +} + +function parseMetadata(text: string): RegistrationLoadResult { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new LoaderFailure("malformed_json"); + } + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new LoaderFailure("not_object"); + } + return { metadata: value as Record }; +} + +function warningFor(error: unknown): string { + if (!(error instanceof LoaderFailure)) return "Registration metadata request failed"; + switch (error.kind) { + case "invalid_uri": + return "Registration metadata URI is invalid or unsupported"; + case "restricted_address": + return "Registration metadata URI targets a restricted network address"; + case "redirect_limit": + return "Registration metadata redirect limit exceeded"; + case "too_large": + return "Registration metadata response exceeds the 1 MiB limit"; + case "malformed_json": + return "Registration metadata contains malformed JSON"; + case "not_object": + return "Registration metadata is not a JSON object"; + case "timeout": + return "Registration metadata request timed out"; + case "http_status": + return `Registration metadata request returned HTTP ${error.status ?? "error"}`; + case "request_failed": + return "Registration metadata request failed"; + } +} diff --git a/ts/src/adapters/outbound/erc8004/sdk-registry.test.ts b/ts/src/adapters/outbound/erc8004/sdk-registry.test.ts new file mode 100644 index 000000000..cd6264b63 --- /dev/null +++ b/ts/src/adapters/outbound/erc8004/sdk-registry.test.ts @@ -0,0 +1,220 @@ +import { AbiCoder, Interface, id, toBeHex, zeroPadValue } from "ethers"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentContractPorts } from "../../../application/ports/agent-registry.js"; +import type { + ChainGatewayProvider, + EvmGateway, +} from "../../../application/ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../../application/ports/chain/tron-gateway.js"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import { SdkAgentRegistry } from "./sdk-registry.js"; + +const sdkConstructions = vi.hoisted(() => [] as string[]); + +vi.mock("@bankofai/8004-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + SDK: class extends actual.SDK { + constructor(config: ConstructorParameters[0]) { + sdkConstructions.push(config.network); + super(config); + } + }, + }; +}); + +const evmNetwork = (idValue: string, chainId = idValue.split(":")[1]!): NetworkDescriptor => + ({ + id: idValue, + chainId, + family: "evm", + nativeSymbol: "ETH", + capabilities: [], + }) as NetworkDescriptor; + +const tronNetwork = (idValue: string, chainId = idValue.split(":")[1]!): NetworkDescriptor => + ({ + id: idValue, + chainId, + family: "tron", + nativeSymbol: "TRX", + capabilities: [], + }) as NetworkDescriptor; + +function harness( + options: { + evmResult?: string; + tronResult?: string; + evmReceipt?: Record | null; + tronReceipt?: Record; + } = {}, +) { + const calls: Array<{ family: string; contract: string; method: string; params: unknown[] }> = []; + const contracts = { + evm: { + call: async ( + _network: NetworkDescriptor, + contract: string, + method: string, + params: unknown[], + ) => { + calls.push({ family: "evm", contract, method, params }); + return { result: options.evmResult ?? "0x" }; + }, + }, + tron: { + call: async ( + _network: NetworkDescriptor, + contract: string, + method: string, + params: unknown[], + ) => { + calls.push({ family: "tron", contract, method, params }); + return { result: [(options.tronResult ?? "0x").replace(/^0x/, "")] }; + }, + }, + } as unknown as AgentContractPorts; + const evmGateway = { + getTransactionReceipt: async () => options.evmReceipt ?? null, + } as unknown as EvmGateway; + const tronGateway = { + getTransactionInfoById: async () => options.tronReceipt ?? {}, + } as unknown as TronGateway; + const gateways = { + get: (_network: NetworkDescriptor, family: "evm" | "tron") => + family === "evm" ? evmGateway : tronGateway, + } as unknown as ChainGatewayProvider; + return { registry: new SdkAgentRegistry(contracts, gateways), calls }; +} + +describe("SdkAgentRegistry", () => { + beforeEach(() => sdkConstructions.splice(0)); + + it("does not create SDK chain clients until an ERC-8004 network is used", () => { + const { registry } = harness(); + + expect(sdkConstructions).toEqual([]); + registry.registry(evmNetwork("eip155:56")); + registry.registry(evmNetwork("eip155:56")); + expect(sdkConstructions).toEqual(["eip155:56"]); + }); + + it.each([ + [evmNetwork("eip155:56"), "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432"], + [evmNetwork("eip155:97"), "0x8004A818BFB912233c491871b3d84c89A494BD9e"], + [evmNetwork("eip155:8453"), "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432"], + [evmNetwork("eip155:84532"), "0x8004A818BFB912233c491871b3d84c89A494BD9e"], + [tronNetwork("tron:728126428"), "TFLvivMdKsk6v2GrwyD2apEr9dU1w7p7Fy"], + [tronNetwork("tron:3448148188"), "TDDk4vc69nzBCbsY4kfu7gw2jmvbinirj5"], + [tronNetwork("tron:2494104990"), "TH775ZzfJ5V25EZkFuX6SkbAP53ykXTcma"], + ])("uses the published registry for $id", (network, expected) => { + expect(harness().registry.registry(network)).toBe(expected); + }); + + it.each([ + [{ ...evmNetwork("eip155:56"), family: "tron" }], + [evmNetwork("eip155:56", "97")], + [{ ...tronNetwork("tron:728126428"), family: "evm" }], + [tronNetwork("tron:728126428", "3448148188")], + ] as Array<[NetworkDescriptor]>)( + "rejects a descriptor whose id, family, and chain id disagree", + (network) => { + expect(() => harness().registry.registry(network)).toThrow(/does not match/); + }, + ); + + it("decodes an exact uint256 while retaining the existing EVM call transport", async () => { + const large = 2n ** 200n + 123n; + const result = AbiCoder.defaultAbiCoder().encode(["uint256"], [large]); + const { registry, calls } = harness({ evmResult: result }); + const network = evmNetwork("eip155:56"); + const params = [{ type: "address", value: "0x1111111111111111111111111111111111111111" }]; + + expect(await registry.read(network, "balanceOf(address)", params)).toBe(large); + expect(calls).toEqual([ + { + family: "evm", + contract: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + method: "balanceOf(address)", + params, + }, + ]); + }); + + it("normalizes a TRON address result and preserves the zero approval address", async () => { + const result = AbiCoder.defaultAbiCoder().encode( + ["address"], + ["0x0000000000000000000000000000000000000000"], + ); + const { registry } = harness({ tronResult: result }); + + expect( + await registry.read(tronNetwork("tron:728126428"), "getApproved(uint256)", [ + { type: "uint256", value: "9" }, + ]), + ).toBe("T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"); + }); + + it("extracts a large EVM Registered id from the gateway's nested raw receipt", async () => { + const agentId = 2n ** 200n + 987654321n; + const event = new Interface([ + "event Registered(uint256 indexed agentId, string agentURI, address indexed owner)", + ]).encodeEventLog("Registered", [ + agentId, + "ipfs://agent", + "0x1111111111111111111111111111111111111111", + ]); + const registryAddress = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432"; + const { registry } = harness({ + evmReceipt: { success: true, raw: { logs: [{ address: registryAddress, ...event }] } }, + }); + + expect(await registry.registeredAgentId(evmNetwork("eip155:56"), "0xtx")).toBe( + agentId.toString(), + ); + }); + + it("ignores a matching EVM event emitted by another contract", async () => { + const event = new Interface([ + "event Registered(uint256 indexed agentId, string agentURI, address indexed owner)", + ]).encodeEventLog("Registered", [ + 42n, + "ipfs://agent", + "0x1111111111111111111111111111111111111111", + ]); + const { registry } = harness({ + evmReceipt: { + logs: [{ address: "0x2222222222222222222222222222222222222222", ...event }], + }, + }); + + expect(await registry.registeredAgentId(evmNetwork("eip155:56"), "0xtx")).toBeUndefined(); + }); + + it("extracts a TRON Registered id after filtering the emitting contract", async () => { + const agentId = 2n ** 200n + 456n; + const log = { + // java-tron receipts use a 20-byte hex contract address without 0x or the 41 prefix. + address: "3af43112221546eb8303e6c311f7ae54d5496822", + topics: [ + id("Registered(uint256,string,address)"), + zeroPadValue(toBeHex(agentId), 32), + zeroPadValue("0x1111111111111111111111111111111111111111", 32), + ], + data: AbiCoder.defaultAbiCoder().encode(["string"], ["ipfs://agent"]), + }; + const { registry } = harness({ tronReceipt: { blockNumber: 100, log: [log] } }); + + expect(await registry.registeredAgentId(tronNetwork("tron:728126428"), "tx-id")).toBe( + agentId.toString(), + ); + }); + + it.each([[evmNetwork("eip155:56")], [tronNetwork("tron:728126428")]])( + "returns undefined when the gateway has no registration receipt on $id", + async (network) => { + expect(await harness().registry.registeredAgentId(network, "missing")).toBeUndefined(); + }, + ); +}); diff --git a/ts/src/adapters/outbound/erc8004/sdk-registry.ts b/ts/src/adapters/outbound/erc8004/sdk-registry.ts new file mode 100644 index 000000000..d1ca7d533 --- /dev/null +++ b/ts/src/adapters/outbound/erc8004/sdk-registry.ts @@ -0,0 +1,152 @@ +import { SDK } from "@bankofai/8004-sdk"; +import { Interface, type InterfaceAbi } from "ethers"; +import type { AgentContractPorts } from "../../../application/ports/agent-registry.js"; +import type { AgentRegistryReader } from "../../../application/ports/agent-sdk.js"; +import type { ChainGatewayProvider } from "../../../application/ports/chain/gateway-provider.js"; +import { UsageError } from "../../../domain/errors/index.js"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; + +interface SupportedNetwork { + family: "evm" | "tron"; + chainId: string; + sdkNetwork: string; +} + +interface SdkNetwork { + sdk: SDK; + identity: Interface; +} + +const UNUSED_RPC_URL = "https://unused.invalid"; + +const SUPPORTED_NETWORKS: Readonly> = { + "eip155:56": { family: "evm", chainId: "56", sdkNetwork: "eip155:56" }, + "eip155:97": { family: "evm", chainId: "97", sdkNetwork: "eip155:97" }, + "eip155:8453": { family: "evm", chainId: "8453", sdkNetwork: "eip155:8453" }, + "eip155:84532": { family: "evm", chainId: "84532", sdkNetwork: "eip155:84532" }, + "tron:728126428": { + family: "tron", + chainId: "728126428", + sdkNetwork: "tron:mainnet", + }, + "tron:3448148188": { + family: "tron", + chainId: "3448148188", + sdkNetwork: "tron:nile", + }, + "tron:2494104990": { + family: "tron", + chainId: "2494104990", + sdkNetwork: "tron:shasta", + }, +}; + +export class SdkAgentRegistry implements AgentRegistryReader { + readonly #networks = new Map(); + + constructor( + private readonly contracts: AgentContractPorts, + private readonly gateways: ChainGatewayProvider, + ) {} + + registry(network: NetworkDescriptor): string { + return this.#for(network).sdk.identityRegistry; + } + + async read( + network: NetworkDescriptor, + method: string, + params: Array<{ type: string; value: unknown }>, + ): Promise { + const configured = this.#for(network); + const response = await this.contracts[network.family].call( + network, + configured.sdk.identityRegistry, + method, + params, + ); + const raw = network.family === "tron" ? response.result[0] : response.result; + const data = String(raw ?? ""); + const decoded = configured.identity.decodeFunctionResult( + method, + data.startsWith("0x") ? data : `0x${data}`, + ); + const value = decoded[0]; + const output = configured.identity.getFunction(method)?.outputs[0]; + return output?.baseType === "address" + ? configured.sdk.chain.toChainAddress(String(value)) + : value; + } + + async registeredAgentId(network: NetworkDescriptor, txId: string): Promise { + const configured = this.#for(network); + if (network.family === "evm") { + const receipt = await this.gateways.get(network, "evm").getTransactionReceipt(txId); + if (!receipt) return undefined; + const source = record(receipt.raw) ?? receipt; + const logs = matchingLogs(source.logs, configured.sdk); + return configured.sdk.chain.parseRegisteredAgentId({ logs }); + } + + const receipt = await this.gateways.get(network, "tron").getTransactionInfoById(txId); + const logs = matchingLogs(receipt.log, configured.sdk); + return configured.sdk.chain.parseRegisteredAgentId({ log: logs }); + } + + #for(network: NetworkDescriptor): SdkNetwork { + const supported = SUPPORTED_NETWORKS[network.id]; + if (!supported) { + throw new UsageError( + "unsupported_network_capability", + `ERC-8004 Identity Registry is not deployed on ${network.id}`, + ); + } + if (network.family !== supported.family || network.chainId !== supported.chainId) { + throw new UsageError( + "family_mismatch", + `network descriptor ${network.id} does not match family ${network.family} and chain id ${network.chainId}`, + ); + } + const existing = this.#networks.get(network.id); + if (existing) return existing; + const sdk = new SDK({ + network: supported.sdkNetwork, + chainId: Number(supported.chainId), + rpcUrl: UNUSED_RPC_URL, + }); + const configured = { + sdk, + identity: new Interface(sdk.identityRegistryAbi as unknown as InterfaceAbi), + }; + this.#networks.set(network.id, configured); + return configured; + } +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" + ? (value as Record) + : undefined; +} + +function matchingLogs(value: unknown, sdk: SDK): Record[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is Record => { + const log = record(entry); + if (!log || typeof log.address !== "string") return false; + try { + return normalizedAddress(log.address, sdk) === normalizedAddress(sdk.identityRegistry, sdk); + } catch { + return false; + } + }); +} + +function normalizedAddress(address: string, sdk: SDK): string { + const raw = address.trim(); + if (sdk.chainType === "tron") { + if (/^[0-9a-fA-F]{40}$/.test(raw)) return `0x${raw.toLowerCase()}`; + if (/^41[0-9a-fA-F]{40}$/.test(raw)) return `0x${raw.slice(2).toLowerCase()}`; + } + return sdk.chain.toEvmAddress(raw).toLowerCase(); +} diff --git a/ts/src/adapters/outbound/http/http-response.test.ts b/ts/src/adapters/outbound/http/http-response.test.ts new file mode 100644 index 000000000..7bf1c8f0b --- /dev/null +++ b/ts/src/adapters/outbound/http/http-response.test.ts @@ -0,0 +1,59 @@ +import { expect, it, vi } from "vitest"; +import { boundedResponse, fetchBounded } from "./http-response.js"; + +it("cancels a chunked response before buffering the whole body", async () => { + let pulled = 0; + const cancel = vi.fn(); + const response = new Response( + new ReadableStream( + { + pull(controller) { + pulled++; + controller.enqueue(new Uint8Array(1024)); + }, + cancel, + }, + { highWaterMark: 0 }, + ), + ); + await expect(boundedResponse(response, 2048)).rejects.toMatchObject({ + code: "response_too_large", + }); + expect(pulled).toBe(3); + expect(cancel).toHaveBeenCalledOnce(); +}); +it("times out while a body is stalled, even after response headers arrive", async () => { + const cancel = vi.fn(); + const controller = new AbortController(); + const response = new Response(new ReadableStream({ pull() {}, cancel })); + const result = boundedResponse(response, 1024, controller.signal); + controller.abort(); + await expect(result).rejects.toMatchObject({ code: "timeout" }); + expect(cancel).toHaveBeenCalledOnce(); +}); +it("rejects an oversized Content-Length without reading the body", async () => { + const cancel = vi.fn(); + const response = new Response(new ReadableStream({ cancel }, { highWaterMark: 0 }), { + headers: { "content-length": "2049" }, + }); + await expect(boundedResponse(response, 2048)).rejects.toMatchObject({ + code: "response_too_large", + }); + expect(cancel).toHaveBeenCalledOnce(); +}); +it("keeps the request's cancellation when applying the configured timeout", async () => { + const controller = new AbortController(); + controller.abort(); + const fetcher = vi.fn(async (_request, init) => { + expect(init.signal.aborted).toBe(true); + throw init.signal.reason; + }); + await expect( + fetchBounded( + fetcher as typeof fetch, + new Request("https://example.test", { signal: controller.signal }), + undefined, + 1000, + ), + ).rejects.toMatchObject({ code: "timeout" }); +}); diff --git a/ts/src/adapters/outbound/http/http-response.ts b/ts/src/adapters/outbound/http/http-response.ts new file mode 100644 index 000000000..d1970bc62 --- /dev/null +++ b/ts/src/adapters/outbound/http/http-response.ts @@ -0,0 +1,81 @@ +import { TransportError } from "../../../domain/errors/index.js"; + +export const MAX_HTTP_RESPONSE_BYTES = 10 * 1024 * 1024; + +/** Enforce limits while reading, before an SDK or JSON parser can buffer the body. */ +export async function boundedResponse( + response: Response, + maxBytes: number, + signal?: AbortSignal, +): Promise { + const tooLarge = () => + new TransportError("response_too_large", "HTTP response exceeds its byte limit"); + if (Number(response.headers.get("content-length")) > maxBytes) { + await response.body?.cancel(); + throw tooLarge(); + } + if (!response.body) return response; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let abort: (() => void) | undefined; + const interrupted = new Promise((_resolve, reject) => { + abort = () => { + reject(new TransportError("timeout", "HTTP request was aborted or timed out")); + void reader.cancel().catch(() => {}); + }; + signal?.addEventListener("abort", abort, { once: true }); + }); + try { + if (signal?.aborted) { + void reader.cancel().catch(() => {}); + throw new TransportError("timeout", "HTTP request was aborted or timed out"); + } + while (true) { + const next = await Promise.race([reader.read(), interrupted]); + if (next.done) break; + total += next.value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + throw tooLarge(); + } + chunks.push(next.value); + } + } finally { + if (abort) signal?.removeEventListener("abort", abort); + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + const result = new Response(bytes, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + Object.defineProperty(result, "url", { value: response.url }); + return result; +} + +export async function fetchBounded( + fetcher: typeof fetch, + request: Parameters[0], + init: RequestInit | undefined, + timeoutMs: number, + maxBytes = MAX_HTTP_RESPONSE_BYTES, +): Promise { + const signals = [AbortSignal.timeout(timeoutMs)]; + if (request instanceof Request) signals.push(request.signal); + if (init?.signal) signals.push(init.signal); + const signal = AbortSignal.any(signals); + try { + return await boundedResponse(await fetcher(request, { ...init, signal }), maxBytes, signal); + } catch (error) { + if (signal.aborted) + throw new TransportError("timeout", "HTTP request timed out or was aborted"); + throw error; + } +} diff --git a/ts/src/adapters/outbound/x402/allowance.test.ts b/ts/src/adapters/outbound/x402/allowance.test.ts new file mode 100644 index 000000000..2913b0060 --- /dev/null +++ b/ts/src/adapters/outbound/x402/allowance.test.ts @@ -0,0 +1,167 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { Wallet, Interface } from "ethers"; +import { TronWeb, providers, utils } from "tronweb"; +import { X402PaymentClient } from "./payment-client.js"; +import { tronSignStrategy } from "../chain/tron/signing-strategy.js"; +import type { TypedDataPayload } from "../../../domain/types/index.js"; + +afterEach(() => vi.restoreAllMocks()); + +it.each(["sufficient", "auto", "sponsored", "reverted"])( + "handles TRON allowance through the installed SDK: %s", + async (mode) => { + const key = Wallet.createRandom().privateKey; + const address = TronWeb.address.fromPrivateKey(key.slice(2)) as string; + const calls: string[] = []; + const sign = vi.fn(async (tx: unknown) => { + calls.push("approve-sign"); + return tronSignStrategy.sign(key, tx); + }); + const signTypedData = vi.fn(async (payload: TypedDataPayload) => { + calls.push("payment-sign"); + return tronSignStrategy.signTypedData(key, payload); + }); + const rpc = vi + .spyOn(providers.HttpProvider.prototype, "request") + .mockImplementation(async (path, data) => { + const input = data as Record; + if (path === "wallet/triggerconstantcontract") { + calls.push("allowance"); + return { + result: { result: true }, + constant_result: [mode === "sufficient" ? "f".repeat(64) : "0".repeat(64)], + }; + } + if (path === "wallet/triggersmartcontract") { + const decoded = new Interface(["function approve(address,uint256)"]).decodeFunctionData( + "approve", + `0x095ea7b3${input.parameter}`, + ); + expect(decoded[1]).toBe((1n << 256n) - 1n); + const shell = { + visible: false, + raw_data: { + contract: [ + { + type: "TriggerSmartContract", + parameter: { + type_url: "type.googleapis.com/protocol.TriggerSmartContract", + value: { + owner_address: input.owner_address, + contract_address: input.contract_address, + data: `095ea7b3${input.parameter}`, + call_value: 0, + }, + }, + }, + ], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: Date.now(), + expiration: Date.now() + 60000, + fee_limit: 100000000, + }, + }; + const pb = utils.transaction.txJsonToPb(shell as never); + return { + result: { result: true }, + transaction: { + ...shell, + txID: utils.transaction.txPbToTxID(pb).replace(/^0x/, ""), + raw_data_hex: utils.transaction.txPbToRawDataHex(pb).toLowerCase(), + }, + }; + } + if (path === "wallet/broadcasttransaction") { + calls.push("approve-broadcast"); + expect(input.signature).toHaveLength(1); + return { result: true, txid: input.txID }; + } + if (path === "wallet/gettransactioninfobyid") { + calls.push("approve-receipt"); + return { + blockNumber: 1, + receipt: { result: mode === "reverted" ? "REVERT" : "SUCCESS" }, + }; + } + throw new Error(`Unexpected RPC ${path}`); + }); + const extension = "trc20ApprovalResourceSponsoring"; + const challenge = { + x402Version: 2, + resource: { url: "https://payment.example" }, + accepts: [ + { + network: "tron:0xcd8690dc", + scheme: "exact", + amount: "1000000", + asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + payTo: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }, + ], + ...(mode === "sponsored" ? { extensions: { [extension]: { info: { version: "1" } } } } : {}), + }; + let payload: any; + const fetcher = vi.fn(async (request, init) => { + const req = new Request(request, init); + const signature = req.headers.get("payment-signature"); + if (!signature) + return Response.json(challenge, { + status: 402, + headers: { + "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }, + }); + calls.push("payment-send"); + payload = JSON.parse(Buffer.from(signature, "base64").toString()); + return new Response("ok"); + }); + const client = new X402PaymentClient( + { + assertCanSign() {}, + resolve: () => ({ + kind: "software", + address, + sign, + signTypedData, + }), + } as never, + fetcher, + ); + const payment = client.pay( + { activeAccount: "payer", timeoutMs: 1000, emit() {} } as never, + { + id: "tron:3448148188", + chainId: "3448148188", + family: "tron", + httpEndpoint: "http://127.0.0.1:1", + } as never, + { url: "https://payment.example", method: "GET", headers: [], token: "USDT", maxAmount: "1" }, + ); + if (mode === "reverted") { + await expect(payment).rejects.toMatchObject({ code: "provider_error" }); + expect(signTypedData).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledOnce(); + } else { + await expect(payment).resolves.toMatchObject({ delivered: true }); + if (mode === "sufficient") expect(sign).not.toHaveBeenCalled(); + else expect(sign).toHaveBeenCalledOnce(); + if (mode === "auto") + expect(calls).toEqual([ + "allowance", + "approve-sign", + "approve-broadcast", + "approve-receipt", + "payment-sign", + "payment-send", + ]); + if (mode === "sponsored") { + expect(calls).not.toContain("approve-broadcast"); + expect(payload.extensions[extension].info.signedTransaction).toBeTruthy(); + } + } + expect(rpc).toHaveBeenCalled(); + }, +); diff --git a/ts/src/adapters/outbound/x402/evm-approval-extension.test.ts b/ts/src/adapters/outbound/x402/evm-approval-extension.test.ts new file mode 100644 index 000000000..d40dd38b8 --- /dev/null +++ b/ts/src/adapters/outbound/x402/evm-approval-extension.test.ts @@ -0,0 +1,113 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { Interface, Transaction, Wallet } from "ethers"; +import { X402PaymentClient } from "./payment-client.js"; +import { evmSignStrategy } from "../chain/evm/signing-strategy.js"; +import type { TypedDataPayload } from "../../../domain/types/index.js"; + +afterEach(() => vi.unstubAllGlobals()); + +it.each(["56", "8453"])( + "signs an ERC20 approval extension with valid gas on EVM chain %s", + async (chainId) => { + const wallet = Wallet.createRandom(); + const asset = + chainId === "8453" + ? "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + : "0x55d398326f99059fF775485246999027B3197955"; + const rpc = vi.fn(async (input, init) => { + const request = new Request(input, init); + expect(request.url).toBe("https://rpc.example/"); + const body = (await request.json()) as { method: string; id: number }; + const result = + body.method === "eth_call" + ? "0x" + "0".repeat(64) + : body.method === "eth_getTransactionCount" + ? "0x3" + : null; + return Response.json({ jsonrpc: "2.0", id: body.id, result }); + }); + vi.stubGlobal("fetch", rpc); + const sign = vi.fn((tx: unknown) => evmSignStrategy.sign(wallet.privateKey, tx)); + const signTypedData = vi.fn((payload: TypedDataPayload) => + evmSignStrategy.signTypedData(wallet.privateKey, payload), + ); + const extension = "erc20ApprovalGasSponsoring"; + const requirement = { + scheme: "exact", + network: `eip155:${chainId}`, + amount: "1000000", + asset, + payTo: "0x2222222222222222222222222222222222222222", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }; + const challenge = { + x402Version: 2, + resource: { url: "https://payment.example" }, + accepts: [requirement], + extensions: { [extension]: { info: { version: "1" } } }, + }; + let approval: { signedTransaction: string; spender: string; amount: string } | undefined; + const fetcher = vi.fn(async (input, init) => { + const request = new Request(input, init); + const header = request.headers.get("payment-signature"); + if (!header) + return new Response(null, { + status: 402, + headers: { + "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }, + }); + const payload = JSON.parse(Buffer.from(header, "base64").toString()); + approval = payload.extensions[extension].info; + return new Response("ok"); + }); + const client = new X402PaymentClient( + { + assertCanSign() {}, + resolve: () => ({ + address: wallet.address, + kind: "software", + sign, + signTypedData, + }), + } as never, + fetcher, + ); + await expect( + client.pay( + { activeAccount: "payer", timeoutMs: 2000, emit() {} } as never, + { + family: "evm", + chainId, + id: `eip155:${chainId}`, + httpEndpoint: "https://rpc.example", + } as never, + { + url: "https://payment.example", + method: "GET", + headers: [], + asset, + maxRawAmount: "1000000", + }, + ), + ).resolves.toMatchObject({ delivered: true }); + expect(sign).toHaveBeenCalledOnce(); + const signedInput = sign.mock.calls[0]![0] as Record; + expect(signedInput).not.toHaveProperty("gas"); + const tx = Transaction.from(approval!.signedTransaction); + expect(tx.gasLimit).toBeGreaterThan(0n); + expect(tx.gasLimit).toBe(signedInput.gasLimit); + expect(tx.from).toBe(wallet.address); + expect(tx.chainId).toBe(BigInt(chainId)); + expect(tx.nonce).toBe(3); + expect(tx.to).toBe(asset); + const decoded = new Interface(["function approve(address,uint256)"]).decodeFunctionData( + "approve", + tx.data, + ); + expect(decoded[0]).toBe(approval!.spender); + expect(decoded[1].toString()).toBe(approval!.amount); + expect(fetcher).toHaveBeenCalledTimes(2); + }, +); diff --git a/ts/src/adapters/outbound/x402/gasfree-errors.test.ts b/ts/src/adapters/outbound/x402/gasfree-errors.test.ts new file mode 100644 index 000000000..ddbe2a653 --- /dev/null +++ b/ts/src/adapters/outbound/x402/gasfree-errors.test.ts @@ -0,0 +1,69 @@ +import { expect, it, vi } from "vitest"; +import { x402Client, wrapFetchWithPayment } from "@bankofai/x402-fetch"; +import { ExactGasFreeTronScheme } from "@bankofai/x402-tron/gasfree/client"; +import { X402PaymentClient } from "./payment-client.js"; + +it("reports the installed SDK's GasFree shortfall before signing, without a wallet fallback", async () => { + const payer = "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ"; + const gasfreeAddress = "TSNEPtuCagKEgF2EU4pAKWLzXLz1bekfTE"; + const asset = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + const signTypedData = vi.fn(); + const readContract = vi.fn(async () => 1_000_000n); + const scheme = new ExactGasFreeTronScheme( + { address: payer, readContract, signTypedData } as never, + { + apiClients: { + "tron:0x2b6653dc": { + getAddressInfo: async () => ({ + gasFreeAddress: gasfreeAddress, + active: true, + assets: [{ tokenAddress: asset, transferFee: "1000000" }], + }), + getProviders: async () => [{ address: payer }], + }, + } as never, + }, + ); + const sdk = new x402Client(); + sdk.setSpendControls(false); + sdk.register("tron:0x2b6653dc", scheme); + const challenge = { + x402Version: 2, + resource: { url: "https://example.test/pay" }, + accepts: [ + { + scheme: "exact_gasfree", + network: "tron:0x2b6653dc", + amount: "1000000", + asset, + payTo: payer, + maxTimeoutSeconds: 300, + }, + ], + }; + const fetcher = vi.fn( + async () => + new Response(null, { + status: 402, + headers: { "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64") }, + }), + ); + const client = new X402PaymentClient( + { assertCanSign: vi.fn(), resolve: () => ({ address: payer }) } as never, + globalThis.fetch, + async () => wrapFetchWithPayment(fetcher, sdk), + ); + await expect( + client.pay( + { activeAccount: "payer" } as never, + { id: "tron:728126428", family: "tron", chainId: "728126428" } as never, + { url: "https://example.test/pay", method: "GET", headers: [] }, + ), + ).rejects.toMatchObject({ + code: "gasfree_insufficient_balance", + details: { paymentStatus: "not_sent", retryPayment: false }, + }); + expect(readContract).toHaveBeenCalledWith(expect.objectContaining({ args: [gasfreeAddress] })); + expect(signTypedData).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledOnce(); +}); diff --git a/ts/src/adapters/outbound/x402/nile-compatibility.test.ts b/ts/src/adapters/outbound/x402/nile-compatibility.test.ts new file mode 100644 index 000000000..628d58b11 --- /dev/null +++ b/ts/src/adapters/outbound/x402/nile-compatibility.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { Wallet } from "ethers"; +import { TronWeb, providers, utils as tronUtils } from "tronweb"; +import { X402PaymentClient } from "./payment-client.js"; +import { tronSignStrategy } from "../chain/tron/signing-strategy.js"; +import type { TypedDataPayload } from "../../../domain/types/index.js"; + +beforeEach(() => { + vi.spyOn(providers.HttpProvider.prototype, "request").mockImplementation(async (path) => { + if (path === "wallet/triggerconstantcontract") + return { result: { result: true }, constant_result: ["f".repeat(64)] }; + throw new Error(`Unexpected payer RPC ${path}`); + }); +}); +afterEach(() => vi.restoreAllMocks()); + +it("signs a Nile Permit2 payment through the existing TRON signing strategy and x402 SDK", async () => { + const key = Wallet.createRandom().privateKey; + const address = TronWeb.address.fromPrivateKey(key.slice(2)) as string; + const signTypedData = vi.fn((payload: TypedDataPayload) => + tronSignStrategy.signTypedData(key, payload), + ); + const signers = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => ({ kind: "software", address, signTypedData })), + }; + const challenge = { + x402Version: 2, + resource: { url: "https://example.test/nile" }, + accepts: [ + { + scheme: "exact", + network: "tron:0xcd8690dc", + amount: "1000000", + asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + payTo: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }, + ], + }; + const fetcher = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 402, + headers: { "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64") }, + }), + ) + .mockResolvedValueOnce(new Response("ok")); + const client = new X402PaymentClient(signers as never, fetcher); + await expect( + client.pay( + { activeAccount: "nile-test", timeoutMs: 1000, emit: vi.fn() } as never, + { + id: "tron:3448148188", + family: "tron", + chainId: "3448148188", + httpEndpoint: "http://127.0.0.1:1", + } as never, + { + url: "https://example.test/nile", + method: "GET", + headers: [], + token: "USDT", + maxAmount: "1", + }, + ), + ).resolves.toMatchObject({ delivered: true, payer: { address } }); + expect(signers.resolve).toHaveBeenCalledWith("nile-test", "tron"); + expect(signTypedData).toHaveBeenCalledOnce(); + const payload = signTypedData.mock.calls[0]![0]; + const signed = await signTypedData.mock.results[0]!.value; + expect(payload.domain.chainId).toBe(3448148188); + expect( + tronUtils.typedData + .verifyTypedData(payload.domain, payload.types, payload.message, signed.signature) + .toLowerCase(), + ).toBe(`0x${TronWeb.address.toHex(address).slice(2)}`.toLowerCase()); + expect(fetcher).toHaveBeenCalledTimes(2); + expect((fetcher.mock.calls[1]![0] as Request).headers.has("payment-signature")).toBe(true); +}); diff --git a/ts/src/adapters/outbound/x402/nile-settlement.test.ts b/ts/src/adapters/outbound/x402/nile-settlement.test.ts new file mode 100644 index 000000000..4358f335e --- /dev/null +++ b/ts/src/adapters/outbound/x402/nile-settlement.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { Interface, Wallet } from "ethers"; +import { TronWeb, providers, utils as tronUtils } from "tronweb"; +import { ExactTronScheme } from "@bankofai/x402-tron/exact/facilitator"; +import type { FacilitatorTronSigner } from "@bankofai/x402-tron"; +import type { PaymentPayload, PaymentRequirements } from "@bankofai/x402-core/types"; +import { X402PaymentClient } from "./payment-client.js"; +import { tronSignStrategy } from "../chain/tron/signing-strategy.js"; +import type { TypedDataPayload } from "../../../domain/types/index.js"; + +beforeEach(() => { + vi.spyOn(providers.HttpProvider.prototype, "request").mockImplementation(async (path) => { + if (path === "wallet/triggerconstantcontract") + return { result: { result: true }, constant_result: ["f".repeat(64)] }; + throw new Error(`Unexpected payer RPC ${path}`); + }); +}); +afterEach(() => vi.restoreAllMocks()); + +it.each([true, false])( + "Nile SDK settlement with sufficient Permit2 allowance=%s", + async (approved) => { + const payerKey = Wallet.createRandom().privateKey; + const payer = TronWeb.address.fromPrivateKey(payerKey.slice(2)) as string; + const facilitatorKey = Wallet.createRandom().privateKey; + const facilitator = TronWeb.address.fromPrivateKey(facilitatorKey.slice(2)) as string; + const signTypedData = vi.fn((payload: TypedDataPayload) => + tronSignStrategy.signTypedData(payerKey, payload), + ); + const signers = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => ({ kind: "software", address: payer, signTypedData })), + }; + const requirement: PaymentRequirements = { + scheme: "exact", + network: "tron:0xcd8690dc", + amount: "1000000", + asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + payTo: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }; + const transactions = new Map(); + // This boundary simulates node acceptance. No network or chain VM is used. + const broadcast = vi.fn(async (signed: { txID: string; signature: string[] }) => { + expect(signed.signature[0]).toMatch(/^[0-9a-f]{130}$/i); + expect( + tronUtils.transaction + .txPbToTxID(tronUtils.transaction.txJsonToPb(signed as never)) + .replace(/^0x/, ""), + ).toBe(signed.txID); + expect(tronUtils.crypto.ecRecover(signed.txID, signed.signature[0]!).toLowerCase()).toBe( + TronWeb.address.toHex(facilitator).toLowerCase(), + ); + transactions.set(signed.txID, signed); + return signed.txID; + }); + const readContract = vi.fn(async ({ functionName }) => { + if (functionName === "allowance") return approved ? 1000000n : 0n; + if (functionName === "balanceOf") return 10000000n; + throw new Error(`Unexpected read ${functionName}`); + }); + const writeContract = vi.fn(async (call) => { + expect(call.functionName).toBe("settle"); + const encoded = new Interface(call.abi as never).encodeFunctionData( + call.functionName, + call.args, + ); + const shell = { + visible: false, + raw_data: { + contract: [ + { + type: "TriggerSmartContract", + parameter: { + type_url: "type.googleapis.com/protocol.TriggerSmartContract", + value: { + owner_address: TronWeb.address.toHex(facilitator), + contract_address: TronWeb.address.toHex(call.address), + data: encoded.slice(2), + call_value: 0, + }, + }, + }, + ], + ref_block_bytes: "1234", + ref_block_hash: "0011223344556677", + timestamp: Date.now(), + expiration: Date.now() + 60000, + fee_limit: 100000000, + }, + }; + const pb = tronUtils.transaction.txJsonToPb(shell as never); + const unsigned = { + ...shell, + txID: tronUtils.transaction.txPbToTxID(pb).replace(/^0x/, ""), + raw_data_hex: tronUtils.transaction.txPbToRawDataHex(pb).toLowerCase(), + }; + const signed = await tronSignStrategy.sign(facilitatorKey, unsigned); + return broadcast(signed as { txID: string; signature: string[] }); + }); + const waitForTransactionReceipt = vi.fn( + async ({ hash }) => { + expect(transactions.has(hash)).toBe(true); + return { status: "success" }; + }, + ); + const scheme = new ExactTronScheme({ + getAddresses: () => [facilitator], + readContract, + writeContract, + waitForTransactionReceipt, + verifyTypedData: async ({ domain, types, message, signature, address }) => + tronUtils.typedData + .verifyTypedData(domain, types as never, message, signature) + .toLowerCase() === address.toLowerCase(), + }); + let settlement: Awaited> | undefined; + const fetcher = vi.fn(async (request, init) => { + const req = new Request(request, init); + const header = req.headers.get("payment-signature"); + if (!header) + return new Response(null, { + status: 402, + headers: { + "payment-required": Buffer.from( + JSON.stringify({ + x402Version: 2, + resource: { url: req.url }, + accepts: [requirement], + }), + ).toString("base64"), + }, + }); + const payload = JSON.parse(Buffer.from(header, "base64").toString()) as PaymentPayload; + settlement = await scheme.settle(payload, requirement); + if (!settlement.success) throw new Error(settlement.errorReason); + return new Response(JSON.stringify({ transaction_hash: settlement.transaction }), { + headers: { + "content-type": "application/json", + "payment-response": Buffer.from(JSON.stringify(settlement)).toString("base64"), + }, + }); + }); + const client = new X402PaymentClient(signers as never, fetcher); + const payment = client.pay( + { activeAccount: "payer", timeoutMs: 1000, emit: vi.fn() } as never, + { + id: "tron:3448148188", + family: "tron", + chainId: "3448148188", + httpEndpoint: "http://127.0.0.1:1", + } as never, + { + url: "https://example.test/nile", + method: "GET", + headers: [], + token: "USDT", + maxAmount: "1", + }, + ); + if (approved) { + await expect(payment).resolves.toMatchObject({ + settled: true, + delivered: true, + paymentResponse: { success: true, network: "tron:0xcd8690dc" }, + }); + expect(settlement?.transaction).toMatch(/^[0-9a-f]{64}$/); + expect(broadcast).toHaveBeenCalledOnce(); + expect(waitForTransactionReceipt).toHaveBeenCalledWith({ hash: settlement?.transaction }); + } else { + await expect(payment).rejects.toMatchObject({ code: "permit2_allowance_required" }); + expect(settlement).toMatchObject({ success: false }); + expect(broadcast).not.toHaveBeenCalled(); + expect(writeContract).not.toHaveBeenCalled(); + } + expect(signTypedData).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledTimes(2); + }, +); diff --git a/ts/src/adapters/outbound/x402/payment-client.test.ts b/ts/src/adapters/outbound/x402/payment-client.test.ts new file mode 100644 index 000000000..ccbb84cbe --- /dev/null +++ b/ts/src/adapters/outbound/x402/payment-client.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it, vi } from "vitest"; +import { X402PaymentClient } from "./payment-client.js"; +import type { SignerResolver } from "../../../application/services/signer/index.js"; +import type { NetworkDescriptor, Signer } from "../../../domain/types/index.js"; +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const signer = { + kind: "software", + address: "0x1111111111111111111111111111111111111111", +} as Signer; +const resolver = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => signer), +} as unknown as SignerResolver; +const net = { + id: "eip155:56", + family: "evm", + chainId: "56", + httpEndpoint: "https://rpc.example", +} as NetworkDescriptor; +const scope = { + activeAccount: "acc_1", + timeoutMs: 1000, + emit: vi.fn(), +} as never; + +describe("X402PaymentClient", () => { + it("returns an unprotected response without resolving a wallet signer", async () => { + const localResolver = { + assertCanSign: vi.fn(), + resolve: vi.fn(), + } as unknown as SignerResolver; + const fetcher = vi.fn( + async () => + new Response(JSON.stringify({ free: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const client = new X402PaymentClient(localResolver, fetcher as typeof fetch); + + await expect( + client.pay(scope, net, { + url: "https://api.example/free", + method: "GET", + headers: [], + }), + ).resolves.toMatchObject({ delivered: true, settled: false, response: { free: true } }); + expect(localResolver.resolve).not.toHaveBeenCalled(); + }); + + it("inspects a 402 challenge in dry-run mode without resolving a signer", async () => { + const localResolver = { + assertCanSign: vi.fn(), + resolve: vi.fn(), + } as unknown as SignerResolver; + const challenge = { + x402Version: 2, + resource: { url: "https://api.example/paid" }, + accepts: [ + { + scheme: "exact", + network: "eip155:56", + amount: "1000000000000000000", + asset: "0x55d398326f99059fF775485246999027B3197955", + payTo: "0x1111111111111111111111111111111111111111", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }, + ], + }; + const fetcher = vi.fn( + async () => + new Response(JSON.stringify(challenge), { + status: 402, + headers: { "content-type": "application/json" }, + }), + ); + const client = new X402PaymentClient(localResolver, fetcher as typeof fetch); + + await expect( + client.pay(scope, net, { + url: "https://api.example/paid", + method: "GET", + headers: [], + dryRun: true, + maxAmount: "1", + }), + ).resolves.toMatchObject({ + dryRun: true, + paymentRequired: true, + selected: challenge.accepts[0], + }); + expect(localResolver.resolve).not.toHaveBeenCalled(); + }); + + it("uses the selected wallet signer and returns the paid resource body", async () => { + const paidFetch = vi.fn( + async () => + new Response(JSON.stringify({ value: 7 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const factory = vi.fn(async () => paidFetch as typeof fetch); + const client = new X402PaymentClient(resolver, globalThis.fetch, factory); + + await expect( + client.pay(scope, net, { + url: "https://api.example/paid", + method: "POST", + headers: ["X-Test: yes"], + body: "{}", + }), + ).resolves.toMatchObject({ + url: "https://api.example/paid", + status: 200, + delivered: true, + response: { value: 7 }, + payer: { address: signer.address }, + }); + expect(resolver.assertCanSign).toHaveBeenCalledWith("acc_1", "evm"); + expect(factory).toHaveBeenCalledWith(net, signer, scope); + }); + + it("rejects malformed headers before making a request", async () => { + const factory = vi.fn(); + const client = new X402PaymentClient(resolver, globalThis.fetch, factory); + await expect( + client.pay(scope, net, { + url: "https://api.example/paid", + method: "GET", + headers: ["not-a-header"], + }), + ).rejects.toMatchObject({ code: "invalid_value" }); + expect(factory).not.toHaveBeenCalled(); + }); + + it("writes response bytes to a new output file without putting the body in the result", async () => { + const directory = await mkdtemp(join(tmpdir(), "wallet-cli-x402-out-")); + const output = join(directory, "response.bin"); + const paidFetch = vi.fn( + async () => + new Response(Uint8Array.from([0, 1, 2, 255]), { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }), + ); + const client = new X402PaymentClient( + resolver, + globalThis.fetch, + vi.fn(async () => paidFetch as typeof fetch), + ); + + const result = await client.pay(scope, net, { + url: "https://api.example/file", + method: "GET", + headers: [], + out: output, + }); + expect([...(await readFile(output))]).toEqual([0, 1, 2, 255]); + expect(result).toMatchObject({ output: { path: output, bytes: 4 } }); + expect(result).not.toHaveProperty("response"); + await expect( + client.pay(scope, net, { + url: "https://api.example/file", + method: "GET", + headers: [], + out: output, + }), + ).rejects.toMatchObject({ code: "output_exists" }); + }); +}); + +it.each([ + [ + { success: false, errorReason: "transaction_failed", transaction: "", network: "eip155:56" }, + false, + ], + [{ success: true, transaction: "0x" + "a".repeat(64), network: "eip155:56" }, true], + [{ success: true, transaction: "", network: "eip155:56" }, false], + [{ success: true, transaction: "0x" + "a".repeat(64), network: "eip155:8453" }, false], + [{ transaction: "0x" + "a".repeat(64), network: "eip155:56" }, false], +])("only marks a successful matching settlement as settled (%j)", async (header, settled) => { + const client = new X402PaymentClient( + resolver, + async () => + new Response("{}", { + status: 502, + headers: { + "content-type": "application/json", + "payment-response": Buffer.from(JSON.stringify(header)).toString("base64"), + }, + }), + ); + await expect( + client.pay(scope, net, { url: "https://example.test", method: "GET", headers: [] }), + ).resolves.toMatchObject({ settled, delivered: false }); +}); +it("bounds oversized 402 bodies before the SDK or signer handles them", async () => { + let pulled = 0; + const cancel = vi.fn(); + const local = { assertCanSign: vi.fn(), resolve: vi.fn() } as unknown as SignerResolver; + const client = new X402PaymentClient( + local, + async () => + new Response( + new ReadableStream( + { + pull(c) { + pulled++; + c.enqueue(new Uint8Array(1024 * 1024)); + }, + cancel, + }, + { highWaterMark: 0 }, + ), + { status: 402 }, + ), + ); + await expect( + client.pay(scope, net, { url: "https://example.test", method: "GET", headers: [] }), + ).rejects.toMatchObject({ code: "response_too_large" }); + expect(pulled).toBe(11); + expect(cancel).toHaveBeenCalledOnce(); + expect(local.resolve).not.toHaveBeenCalled(); +}); + +it.each(["json", "exists", "io"])( + "retains settlement when response processing fails: %s", + async (mode) => { + const directory = await mkdtemp(join(tmpdir(), "wallet-cli-settled-")); + const out = join(directory, mode === "io" ? "missing/out" : "out"); + const transaction = "0x" + "a".repeat(64); + const paidFetch = vi.fn( + async () => + new Response(mode === "json" ? "{" : "ok", { + headers: { + "content-type": mode === "json" ? "application/json" : "text/plain", + "payment-response": Buffer.from( + JSON.stringify({ + success: true, + network: net.id, + transaction, + secret: "do-not-copy", + }), + ).toString("base64"), + }, + }), + ); + const client = new X402PaymentClient(resolver, globalThis.fetch, async () => paidFetch); + try { + if (mode === "exists") await writeFile(out, "original"); + const error = await client + .pay(scope, net, { + url: "https://api.example/paid", + method: "GET", + headers: [], + ...(mode === "json" ? {} : { out }), + }) + .catch((error) => error); + expect(error).toMatchObject({ + code: + mode === "json" + ? "invalid_x402_response" + : mode === "exists" + ? "output_exists" + : "io_error", + details: { + paymentStatus: "settled", + retryPayment: false, + txHash: transaction, + settled: true, + paymentResponse: { success: true, network: net.id, transaction }, + payer: { address: signer.address }, + }, + }); + expect(JSON.stringify(error)).not.toContain("do-not-copy"); + expect(paidFetch).toHaveBeenCalledOnce(); + if (mode === "exists") expect(await readFile(out, "utf8")).toBe("original"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, +); + +it.each([ + [ + "Failed to create payment payload: Insufficient balance in GasFree wallet SECRET.", + "gasfree_insufficient_balance", + "not_sent", + ], + [ + "Failed to create payment payload: GasFree account for SECRET is not activated.", + "gasfree_not_activated", + "not_sent", + ], + [ + "Failed to create payment payload: approval_reset_required", + "approval_reset_required", + "unknown", + ], + [ + "Failed to create payment payload: permit2_allowance_required: SECRET", + "permit2_allowance_required", + "unknown", + ], + ["Failed to create payment payload: SECRET", "provider_error", "unknown"], +])("classifies SDK failures without echoing secrets: %s", async (message, code, paymentStatus) => { + const paidFetch = vi.fn(async () => { + throw new Error(message); + }); + const client = new X402PaymentClient(resolver, globalThis.fetch, async () => paidFetch); + const error = await client + .pay(scope, net, { url: "https://api.example/paid", method: "GET", headers: [] }) + .catch((error) => error); + expect(error).toMatchObject({ code, details: { paymentStatus, retryPayment: false } }); + expect(JSON.stringify(error.toEnvelope())).not.toContain("SECRET"); + expect(paidFetch).toHaveBeenCalledOnce(); +}); + +it("retains settlement when reading the paid body exceeds its limit", async () => { + const transaction = "0x" + "b".repeat(64); + const response = new Response("x", { + headers: { + "content-length": String(11 * 1024 * 1024), + "payment-response": Buffer.from( + JSON.stringify({ success: true, network: net.id, transaction }), + ).toString("base64"), + }, + }); + const client = new X402PaymentClient( + resolver, + globalThis.fetch, + async () => async () => response, + ); + await expect( + client.pay(scope, net, { url: "https://api.example/paid", method: "GET", headers: [] }), + ).rejects.toMatchObject({ + code: "response_too_large", + details: { txHash: transaction, paymentStatus: "settled", retryPayment: false }, + }); +}); + +it.each(["verify", "settle"])( + "exposes a sanitized facilitator failure in phase %s", + async (phase) => { + const client = new X402PaymentClient( + resolver, + globalThis.fetch, + async () => async () => + Response.json( + { phase, code: "permit2_allowance_required", error: "SECRET" }, + { status: 502 }, + ), + ); + const error = await client + .pay(scope, net, { url: "https://api.example/paid", method: "GET", headers: [] }) + .catch((error) => error); + expect(error).toMatchObject({ + code: "permit2_allowance_required", + details: { phase, paymentStatus: "unknown", retryPayment: false }, + }); + expect(JSON.stringify(error.toEnvelope())).not.toContain("SECRET"); + }, +); diff --git a/ts/src/adapters/outbound/x402/payment-client.ts b/ts/src/adapters/outbound/x402/payment-client.ts new file mode 100644 index 000000000..881315dbb --- /dev/null +++ b/ts/src/adapters/outbound/x402/payment-client.ts @@ -0,0 +1,506 @@ +import { sdkPaymentError, providerPaymentError } from "./payment-error.js"; +import { successfulSettlement } from "./settlement.js"; +import { boundedResponse, fetchBounded, MAX_HTTP_RESPONSE_BYTES } from "../http/http-response.js"; +import { + x402Client, + wrapFetchWithPayment, + decodePaymentResponseHeader, +} from "@bankofai/x402-fetch"; +import { registerExactEvmScheme } from "@bankofai/x402-evm/exact/client"; +import { createClientTronSigner, type ClientTronSigner } from "@bankofai/x402-tron"; +import { registerExactTronScheme } from "@bankofai/x402-tron/exact/client"; +import { registerExactGasFreeTronScheme } from "@bankofai/x402-tron/gasfree/client"; +import type { ClientEvmSigner } from "@bankofai/x402-evm"; +import type { Network } from "@bankofai/x402-core/types"; +import { decodePaymentRequiredHeader } from "@bankofai/x402-core/http"; +import { writeFile } from "node:fs/promises"; +import type { X402PayInput, X402PaymentPort } from "../../../application/ports/x402-payment.js"; +import type { SignerResolver } from "../../../application/services/signer/index.js"; +import type { TransactionScope } from "../../../application/contracts/execution-scope.js"; +import type { NetworkDescriptor, Signer } from "../../../domain/types/index.js"; +import { ExecutionError, TransportError, UsageError } from "../../../domain/errors/index.js"; +import { normalizeTypedData } from "../../../domain/typed-data/index.js"; +import { toX402Wallet } from "./signer-bridge.js"; +import { createPayerSigner } from "../../../application/services/x402/payer-signer.js"; +import type { PayerSigner } from "../../../application/contracts/x402-payer.js"; +import { toX402Network } from "../../../domain/x402/network-id.js"; + +type PaidFetchFactory = ( + network: NetworkDescriptor, + signer: Signer, + scope: TransactionScope, +) => Promise; + +const MAX_RESPONSE_BYTES = MAX_HTTP_RESPONSE_BYTES; + +export class X402PaymentClient implements X402PaymentPort { + constructor( + private readonly signers: SignerResolver, + private readonly fetcher: typeof fetch = globalThis.fetch, + private readonly paidFetchFactory?: PaidFetchFactory, + ) {} + + async pay(scope: TransactionScope, network: NetworkDescriptor, input: X402PayInput) { + const headers = parseHeaders(input.headers); + const requestInit: RequestInit = { + method: input.method, + headers, + redirect: "error", + ...(input.body === undefined ? {} : { body: input.body }), + }; + try { + if (this.paidFetchFactory && !input.expectedPayTo && input.exactAmount === undefined) { + const signer = this.resolveSigner(scope, network); + const paidFetch = await this.paidFetchFactory(network, signer, scope); + const response = await paidFetch(input.url, requestInit); + let bounded: Response; + try { + bounded = await boundedResponse(response, MAX_RESPONSE_BYTES); + } catch (error) { + throw settlementError(error, response, toX402Network(network), signer); + } + return await this.readResponse( + input.url, + bounded, + signer, + input.out, + toX402Network(network), + ); + } + + const boundedFetch = this.boundedFetch(scope, network); + const initial = await boundedFetch(input.url, requestInit); + if (initial.status !== 402) + return await this.readResponse( + input.url, + initial, + undefined, + input.out, + toX402Network(network), + ); + if (input.dryRun) return inspectChallenge(input.url, initial, network, input); + + if (input.expectedPayTo || input.exactAmount !== undefined) { + const challenge = await decodeChallenge(initial.clone()); + selectMatching(challenge.accepts, network, input); + } + + const signer = createPayerSigner(this.signers, scope, network.family); + const paidFetch = await this.createPaidFetch(network, signer, scope, input, initial); + return await this.readResponse( + input.url, + await paidFetch(input.url, requestInit), + signer, + input.out, + toX402Network(network), + ); + } catch (error) { + throw sdkPaymentError(error); + } + } + + private async readResponse( + url: string, + response: Response, + signer?: Pick, + out?: string, + expectedNetwork?: string, + ) { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { + throw new TransportError("response_too_large", "x402 response exceeds the 10 MB limit"); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_RESPONSE_BYTES) { + throw new TransportError("response_too_large", "x402 response exceeds the 10 MB limit"); + } + const paymentHeader = + response.headers.get("payment-response") ?? response.headers.get("x-payment-response"); + let paymentResponse: unknown; + if (paymentHeader) { + try { + paymentResponse = decodePaymentResponseHeader(paymentHeader); + } catch { + throw new TransportError( + "invalid_settlement", + "paid endpoint returned an invalid payment settlement header", + ); + } + } + const base = { + url, + status: response.status, + delivered: response.ok, + settled: successfulSettlement(paymentResponse, expectedNetwork), + ...(signer ? { payer: { address: signer.address } } : {}), + ...(paymentResponse === undefined ? {} : { paymentResponse }), + }; + try { + if (out) { + await writeOutput(out, bytes); + return { ...base, output: { path: out, bytes: bytes.byteLength } }; + } + const text = new TextDecoder().decode(bytes); + const contentType = response.headers.get("content-type") ?? ""; + let body: unknown = text; + if (/json/i.test(contentType) && text !== "") { + try { + body = JSON.parse(text); + } catch { + throw new TransportError( + "invalid_x402_response", + "paid endpoint returned malformed JSON", + ); + } + } + if (!response.ok && signer && body && typeof body === "object" && !base.settled) { + const failure = body as Record; + if (failure.phase === "verify" || failure.phase === "settle") { + throw providerPaymentError(failure.reason ?? failure.code, failure.phase); + } + } + return { ...base, response: body }; + } catch (error) { + throw settlementError(error, response, expectedNetwork, signer); + } + } + + private async createPaidFetch( + network: NetworkDescriptor, + signer: PayerSigner, + scope: TransactionScope, + input: X402PayInput, + initial: Response, + ): Promise { + let maxGasfreeFeeRaw = input.maxGasfreeFeeRaw; + if (maxGasfreeFeeRaw === undefined && input.maxGasfreeFee !== undefined) { + const challenge = await decodeChallenge(initial.clone()); + const selected = selectMatching(challenge.accepts, network, input)[0]!; + maxGasfreeFeeRaw = decimalToRaw( + input.maxGasfreeFee, + input.decimals ?? tokenDecimals(network.id, selected.asset), + ); + } + const wallet = toX402Wallet(signer, { family: network.family, maxGasfreeFeeRaw }); + const bridge = { + ...wallet, + signTypedData: (payload: unknown) => wallet.signTypedData(normalizeTypedData(payload)), + async signTransaction(tx: unknown): Promise> { + const signed = await wallet.signTransaction(tx); + if (typeof signed === "string") return signed; + if (typeof signed === "object" && signed !== null && !Array.isArray(signed)) { + return signed as Record; + } + throw new ExecutionError( + "signed_payload_mismatch", + "signer returned no signed transaction", + ); + }, + }; + const client = new x402Client(); + // The SDK's conservative default spend control remains active unless the caller supplied + // an explicit wallet-cli ceiling. In that case our exact raw/human limit below is authoritative. + if (input.maxAmount !== undefined || input.maxRawAmount !== undefined) { + client.setSpendControls(false); + } + client.registerPolicy((_version, requirements) => selectMatching(requirements, network, input)); + const x402Network = toX402Network(network); + if (network.family === "evm") { + registerExactEvmScheme(client, { + signer: bridge as ClientEvmSigner, + networks: [x402Network as Network], + schemeOptions: network.httpEndpoint ? { rpcUrl: network.httpEndpoint } : undefined, + }); + } else { + const tronSigner = await createClientTronSigner(bridge, { + network: x402Network, + ...(network.httpEndpoint ? { rpcUrl: network.httpEndpoint } : {}), + ...(network.apiKey ? { apiKey: network.apiKey } : {}), + allowanceMode: "auto", + }); + registerExactTronScheme(client, { + signer: tronSigner as ClientTronSigner, + networks: [x402Network as Network], + }); + registerExactGasFreeTronScheme(client, { + signer: tronSigner as ClientTronSigner, + networks: [x402Network as Network], + }); + } + // Preserve typed wallet/SDK errors before x402-fetch wraps them in a plain Error. + let creationError: unknown; + client.onPaymentCreationFailure(async ({ error }) => { + creationError = sdkPaymentError(error); + }); + const boundedFetch = this.boundedFetch(scope, network, signer); + let first: Response | undefined = initial; + const fetchWithInitial: typeof fetch = (request, init) => { + if (first) { + const response = first; + first = undefined; + return Promise.resolve(response); + } + return boundedFetch(request, init); + }; + const paidFetch = wrapFetchWithPayment(fetchWithInitial, client); + return async (request, init) => { + try { + return await paidFetch(request, init); + } catch (error) { + throw creationError ?? error; + } + }; + } + + private boundedFetch( + scope: TransactionScope, + network: NetworkDescriptor, + signer?: Pick, + ): typeof fetch { + return async (request, init) => { + let response: Response | undefined; + const fetcher: typeof fetch = async (url, options) => { + response = await this.fetcher(url, options); + return response; + }; + try { + return await fetchBounded(fetcher, request, init, scope.timeoutMs); + } catch (error) { + throw settlementError(error, response, toX402Network(network), signer); + } + }; + } + + private resolveSigner(scope: TransactionScope, network: NetworkDescriptor): Signer { + this.signers.assertCanSign(scope.activeAccount, network.family); + return this.signers.resolve(scope.activeAccount, network.family); + } +} + +async function writeOutput(path: string, bytes: Uint8Array): Promise { + try { + await writeFile(path, bytes, { flag: "wx", mode: 0o600 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new UsageError("output_exists", `output already exists: ${path}`); + } + throw new ExecutionError("io_error", `could not write x402 response to ${path}`); + } +} + +const TOKEN_METADATA: Record> = { + "tron:728126428": { + TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: { symbol: "USDT", decimals: 6 }, + TXDk8mbtRbXeYuMNS83CfKPaYYT8XWv9Hz: { symbol: "USDD", decimals: 18 }, + }, + "tron:3448148188": { + TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf: { symbol: "USDT", decimals: 6 }, + TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK: { symbol: "USDD", decimals: 18 }, + }, + "tron:2494104990": { + TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs: { symbol: "USDT", decimals: 6 }, + }, + "eip155:56": { + "0x55d398326f99059ff775485246999027b3197955": { symbol: "USDT", decimals: 18 }, + }, + "eip155:8453": { + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": { symbol: "USDC", decimals: 6 }, + }, + "eip155:97": { + "0x337610d27c682e347c9cd60bd4b3b107c9d34ddd": { symbol: "USDT", decimals: 18 }, + "0x64544969ed7ebf5f083679233325356ebe738930": { symbol: "USDC", decimals: 18 }, + }, +}; + +function metadata(network: string, asset: string) { + const tokens = TOKEN_METADATA[network] ?? {}; + return tokens[asset] ?? tokens[asset.toLowerCase()]; +} + +function tokenSymbol(network: string, asset: string): string | undefined { + return metadata(network, asset)?.symbol; +} + +function tokenDecimals(network: string, asset: string): number { + const decimals = metadata(network, asset)?.decimals; + if (decimals === undefined) { + throw new UsageError( + "invalid_option", + "--max-amount requires a known token or explicit --decimals", + ); + } + return decimals; +} + +function decimalToRaw(value: string, decimals: number): string { + const [whole, fraction = ""] = value.split("."); + if (fraction.length > decimals) + throw new UsageError("invalid_value", `amount supports at most ${decimals} decimal places`); + return ( + BigInt(whole!) * 10n ** BigInt(decimals) + + BigInt(fraction.padEnd(decimals, "0") || "0") + ).toString(); +} + +interface OfferedRequirement { + scheme: string; + network: string; + amount: string; + asset: string; + [key: string]: unknown; +} + +function selectMatching( + requirements: T[], + network: NetworkDescriptor, + input: X402PayInput, +): T[] { + const expectedNetwork = + network.family === "tron" ? `tron:0x${BigInt(network.chainId).toString(16)}` : network.id; + const matching = requirements.filter((requirement) => { + if (requirement.network !== expectedNetwork) return false; + if (requirement.scheme !== "exact" && requirement.scheme !== "exact_gasfree") return false; + if (requirement.scheme === "exact_gasfree" && network.family !== "tron") return false; + if (input.scheme && requirement.scheme !== input.scheme) return false; + if (input.asset && requirement.asset.toLowerCase() !== input.asset.toLowerCase()) return false; + if (input.token && tokenSymbol(network.id, requirement.asset) !== input.token.toUpperCase()) + return false; + if (input.expectedPayTo) { + if (typeof requirement.payTo !== "string") return false; + const normalize = (address: string) => + network.family === "evm" ? address.toLowerCase() : address; + if (normalize(requirement.payTo) !== normalize(input.expectedPayTo)) return false; + } + if (input.exactAmount !== undefined) { + const expected = decimalToRaw( + input.exactAmount, + input.decimals ?? tokenDecimals(network.id, requirement.asset), + ); + if (!/^\d+$/.test(requirement.amount) || BigInt(requirement.amount) !== BigInt(expected)) + return false; + } + return true; + }); + if (matching.length === 0) { + throw new TransportError( + "no_matching_requirement", + "the endpoint offered no matching x402 payment requirement", + ); + } + const selected = matching[0]!; + const limit = + input.maxRawAmount ?? + (input.maxAmount === undefined + ? undefined + : decimalToRaw(input.maxAmount, input.decimals ?? tokenDecimals(network.id, selected.asset))); + if (limit !== undefined && BigInt(selected.amount) > BigInt(limit)) { + throw new TransportError( + "amount_exceeds_limit", + "the x402 payment requirement exceeds the configured limit", + ); + } + // The SDK may reorder offers after policies run. Only expose the validated choice. + return [selected]; +} + +async function inspectChallenge( + url: string, + response: Response, + network: NetworkDescriptor, + input: X402PayInput, +) { + const decoded = await decodeChallenge(response.clone()); + const selected = selectMatching(decoded.accepts, network, input)[0]!; + return { + url, + status: 402, + delivered: false, + settled: false, + dryRun: true, + paymentRequired: true, + selected: { ...selected, network: network.id }, + }; +} + +async function decodeChallenge(response: Response): Promise<{ accepts: OfferedRequirement[] }> { + let challenge: unknown; + const header = response.headers.get("payment-required"); + try { + challenge = header ? decodePaymentRequiredHeader(header) : await response.json(); + } catch { + throw new TransportError( + "invalid_x402_response", + "could not decode the x402 payment challenge", + ); + } + if (!challenge || typeof challenge !== "object" || Array.isArray(challenge)) { + throw new TransportError("invalid_x402_response", "x402 payment challenge must be an object"); + } + const accepts = (challenge as { accepts?: unknown }).accepts; + if (!Array.isArray(accepts)) { + throw new TransportError("invalid_x402_response", "x402 payment challenge has no accepts list"); + } + return { accepts: accepts.filter(isOfferedRequirement) }; +} + +function isOfferedRequirement(value: unknown): value is OfferedRequirement { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const item = value as Record; + return ["scheme", "network", "amount", "asset"].every((field) => typeof item[field] === "string"); +} + +function parseHeaders(values: string[]): Headers { + const headers = new Headers(); + for (const value of values) { + const colon = value.indexOf(":"); + if (colon <= 0) { + throw new UsageError("invalid_value", '--header must use "Name: value"'); + } + const name = value.slice(0, colon).trim(); + const fieldValue = value.slice(colon + 1).trim(); + if (/^(payment-signature|x-payment)$/i.test(name)) { + throw new UsageError("invalid_option", `${name} is managed by the x402 client`); + } + try { + headers.append(name, fieldValue); + } catch { + throw new UsageError("invalid_value", `invalid HTTP header name or value: ${name}`); + } + } + return headers; +} + +/** A response/body failure must not discard settlement already received in headers. */ +function settlementError( + error: unknown, + response?: Response, + expectedNetwork?: string, + signer?: Pick, +) { + const classified = sdkPaymentError(error); + const header = + response?.headers.get("payment-response") ?? response?.headers.get("x-payment-response"); + let receipt: unknown; + try { + if (header) receipt = decodePaymentResponseHeader(header); + } catch { + return classified; + } + if (!successfulSettlement(receipt, expectedNetwork)) return classified; + const settlement = receipt as Record; + const ErrorType = classified.kind === "usage" ? UsageError : TransportError; + return new ErrorType(classified.code, classified.message, { + ...classified.details, + phase: "response", + paymentStatus: "settled", + retryPayment: false, + settled: true, + txHash: settlement.transaction, + paymentResponse: { + success: true, + network: settlement.network, + transaction: settlement.transaction, + }, + ...(signer ? { payer: { address: signer.address } } : {}), + }); +} diff --git a/ts/src/adapters/outbound/x402/payment-destination.test.ts b/ts/src/adapters/outbound/x402/payment-destination.test.ts new file mode 100644 index 000000000..ac648289e --- /dev/null +++ b/ts/src/adapters/outbound/x402/payment-destination.test.ts @@ -0,0 +1,86 @@ +import { expect, it, vi } from "vitest"; +import { X402PaymentClient } from "./payment-client.js"; + +const address = "0x060f7fd9c9622bdcf9f2887c8171d6e6b4b4ba17"; +const network = { id: "eip155:56", family: "evm", chainId: "56" }; +function fixture( + payTo: string, + amount = "1000000000000000000", + selectedNetwork = network, + token = "USDT", + asset = "0x55d398326f99059fF775485246999027B3197955", + expectedPayTo = address, +) { + const resolve = vi.fn(() => { + throw new Error("must not resolve signer"); + }); + const fetcher = vi.fn( + async () => + new Response( + JSON.stringify({ + x402Version: 2, + resource: { url: "https://example.com" }, + accepts: [ + { + scheme: "exact", + network: selectedNetwork.family === "tron" ? "tron:0x2b6653dc" : selectedNetwork.id, + asset, + payTo, + amount, + maxTimeoutSeconds: 300, + }, + ], + }), + { status: 402, headers: { "content-type": "application/json" } }, + ), + ); + const client = new X402PaymentClient({ resolve, assertCanSign: vi.fn() } as never, fetcher); + const pay = (dryRun = false) => + client.pay({ timeoutMs: 1000 } as never, selectedNetwork as never, { + url: "https://example.com", + method: "POST", + headers: [], + expectedPayTo, + exactAmount: "1", + maxAmount: "1", + token, + dryRun, + }); + return { pay, resolve, fetcher }; +} +it.each([ + ["0x1111111111111111111111111111111111111111", "1000000000000000000"], + [address, "999999999999999999"], + [address, "1000000000000000001"], +])("rejects an unexpected destination or amount before resolving a signer", async (to, amount) => { + const { pay, resolve, fetcher } = fixture(to, amount); + await expect(pay()).rejects.toMatchObject({ code: "no_matching_requirement" }); + expect(resolve).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledTimes(1); +}); +it.each([ + [ + { id: "eip155:8453", family: "evm", chainId: "8453" }, + "USDC", + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "0x10bf3d09bd80a00ddbbfe934c7dcc477b42ffdb0", + ], + [ + { id: "tron:728126428", family: "tron", chainId: "728126428" }, + "USDT", + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "TSNEPtuCagKEgF2EU4pAKWLzXLz1bekfTE", + ], +])("checks the token precision and destination on %s", async (net, token, asset, to) => { + await expect(fixture(to, "1000000", net, token, asset, to).pay(true)).resolves.toMatchObject({ + paymentRequired: true, + }); + const underpaid = fixture(to, "999999", net, token, asset, to); + await expect(underpaid.pay()).rejects.toMatchObject({ code: "no_matching_requirement" }); + expect(underpaid.resolve).not.toHaveBeenCalled(); +}); +it("accepts an EVM address with different casing at the exact amount", async () => { + await expect(fixture(address.toUpperCase()).pay(true)).resolves.toMatchObject({ + paymentRequired: true, + }); +}); diff --git a/ts/src/adapters/outbound/x402/payment-error.ts b/ts/src/adapters/outbound/x402/payment-error.ts new file mode 100644 index 000000000..9b71a35e3 --- /dev/null +++ b/ts/src/adapters/outbound/x402/payment-error.ts @@ -0,0 +1,71 @@ +import { CliError, TransportError } from "../../../domain/errors/index.js"; + +// Only emit our own messages. SDK/provider messages can contain credentials and URLs. +const reasons: Record TransportError> = { + gasfree_insufficient_balance: () => + new TransportError( + "gasfree_insufficient_balance", + "GasFree wallet balance cannot cover the payment and maximum fee", + ), + gasfree_not_activated: () => + new TransportError("gasfree_not_activated", "GasFree account is not activated"), + permit2_allowance_required: () => + new TransportError( + "permit2_allowance_required", + "Token allowance for Permit2 is insufficient; review the allowance before approving or retrying payment", + ), + approval_reset_required: () => + new TransportError( + "approval_reset_required", + "This token requires its existing allowance to be reset to zero before approval", + ), + insufficient_funds: () => + new TransportError("insufficient_balance", "The payment account has insufficient funds"), +}; + +export function providerPaymentError(reason: unknown, phase: "verify" | "settle") { + const key = reason === "insufficient_balance" ? "insufficient_funds" : reason; + const known = + typeof key === "string" && Object.hasOwn(reasons, key) ? reasons[key]!() : undefined; + return new TransportError( + known?.code ?? "provider_error", + known?.message ?? `x402 payment ${phase === "verify" ? "verification" : "settlement"} failed`, + { phase, paymentStatus: "unknown", retryPayment: false }, + ); +} + +export function sdkPaymentError(error: unknown): CliError { + if (error instanceof CliError) return error; + const message = error instanceof Error ? error.message : ""; + // x402-fetch wraps errors while retaining the SDK message. + const cause = message.replace(/^Failed to create payment payload: /, ""); + let reason: string | undefined; + if (/^Insufficient balance in GasFree wallet /.test(cause)) { + reason = "gasfree_insufficient_balance"; + } else if (/^GasFree account for .* is not activated\.$/.test(cause)) { + reason = "gasfree_not_activated"; + } else { + reason = Object.keys(reasons).find((key) => cause === key || cause.startsWith(`${key}:`)); + } + if (reason) { + const known = reasons[reason]!(); + return new TransportError(known.code, known.message, { + paymentStatus: reason.startsWith("gasfree_") ? "not_sent" : "unknown", + retryPayment: false, + }); + } + if (error instanceof Error && /timeout|aborted/i.test(`${error.name} ${message}`)) { + return new TransportError("timeout", "x402 request timed out; reconcile before paying again", { + paymentStatus: "unknown", + retryPayment: false, + }); + } + return new TransportError( + "provider_error", + "x402 request or payment failed; reconcile before paying again", + { + paymentStatus: "unknown", + retryPayment: false, + }, + ); +} diff --git a/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts b/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts new file mode 100644 index 000000000..f2d0fc71f --- /dev/null +++ b/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from "vitest"; +import { Wallet, verifyTypedData } from "ethers"; +import { X402PaymentClient } from "./payment-client.js"; +import type { SignerResolver } from "../../../application/services/signer/index.js"; +import type { TransactionScope } from "../../../application/contracts/execution-scope.js"; +import type { NetworkDescriptor, TypedDataPayload } from "../../../domain/types/index.js"; + +const network = { id: "eip155:8453", family: "evm", chainId: "8453" } as NetworkDescriptor; +const scope = { + activeAccount: "test", + timeoutMs: 1000, + emit: vi.fn(), +} as unknown as TransactionScope; +const input = { url: "https://example.test/paid", method: "GET", headers: [] }; + +function fixture(wrongType = false, multiple = false) { + const wallet = Wallet.createRandom(); + const signTypedData = vi.fn(async (payload: TypedDataPayload) => ({ + signature: await wallet.signTypedData(payload.domain, payload.types, payload.message), + digest: "unused", + primaryType: wrongType ? "WrongType" : payload.primaryType, + })); + const resolver = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => ({ address: wallet.address, kind: "software", signTypedData })), + } as unknown as SignerResolver; + const fetcher = vi.fn(); + const challenge = { + x402Version: 2, + resource: { url: input.url }, + accepts: [ + { + scheme: "exact", + network: network.id, + amount: "1", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + payTo: "0x1111111111111111111111111111111111111111", + maxTimeoutSeconds: 300, + extra: { + name: "USD Coin", + version: "2", + paymentFlow: multiple ? "upfront" : "authorization", + }, + }, + ], + }; + if (multiple) + challenge.accepts.push({ + ...challenge.accepts[0]!, + amount: "100", + extra: { name: "USD Coin", version: "2", paymentFlow: "authorization" }, + }); + fetcher.mockResolvedValueOnce( + new Response(null, { + status: 402, + headers: { "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64") }, + }), + ); + fetcher.mockResolvedValueOnce(new Response("ok")); + return { wallet, signTypedData, fetcher, client: new X402PaymentClient(resolver, fetcher) }; +} + +describe("x402 SDK payer integration", () => { + it("uses the selected wallet to sign a Base USDC authorization and retries once", async () => { + const { wallet, signTypedData, fetcher, client } = fixture(); + await expect(client.pay(scope, network, input)).resolves.toMatchObject({ delivered: true }); + expect(signTypedData).toHaveBeenCalledOnce(); + const payload = signTypedData.mock.calls[0]![0]; + const signed = await signTypedData.mock.results[0]!.value; + expect(verifyTypedData(payload.domain, payload.types, payload.message, signed.signature)).toBe( + wallet.address, + ); + expect(payload.primaryType).toBe("TransferWithAuthorization"); + expect(fetcher).toHaveBeenCalledTimes(2); + expect((fetcher.mock.calls[1]![0] as Request).headers.has("payment-signature")).toBe(true); + }); + + it("does not send a payment when the signer returns a different primary type", async () => { + const { fetcher, client } = fixture(true); + await expect(client.pay(scope, network, input)).rejects.toMatchObject({ + code: "signed_payload_mismatch", + }); + expect(fetcher).toHaveBeenCalledOnce(); + }); +}); + +it("keeps the SDK on the requirement whose spend limit was checked", async () => { + const { client, signTypedData } = fixture(false, true); + await client.pay(scope, network, { ...input, maxRawAmount: "10" }); + expect(signTypedData.mock.calls[0]![0].message.value).toBe(1n); +}); diff --git a/ts/src/adapters/outbound/x402/provider-catalog.test.ts b/ts/src/adapters/outbound/x402/provider-catalog.test.ts new file mode 100644 index 000000000..16466177a --- /dev/null +++ b/ts/src/adapters/outbound/x402/provider-catalog.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { X402ProviderCatalog } from "./provider-catalog.js"; + +describe("X402ProviderCatalog", () => { + it("accepts the Base alias when filtering the online catalog's canonical chain ids", async () => { + const catalog = new X402ProviderCatalog( + vi.fn( + async () => + new Response( + JSON.stringify({ providers: [{ fqn: "demo/base", chains: ["eip155:8453"] }] }), + ), + ), + ); + await expect(catalog.list({ limit: 20, offset: 0, network: "base" })).resolves.toMatchObject({ + count: 1, + filters: { network: "eip155:8453" }, + }); + }); + it("lists, filters and normalizes TRON network ids", async () => { + const fetcher = vi.fn( + async () => + new Response( + JSON.stringify({ + providers: [ + { + fqn: "bai/recharge", + type: "service", + chains: ["tron:0x2b6653dc"], + featured_tags: ["recharge"], + }, + { fqn: "demo/other", type: "service", chains: ["eip155:56"], featured_tags: [] }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + const catalog = new X402ProviderCatalog(fetcher as typeof fetch); + const result = await catalog.list({ limit: 20, offset: 0, network: "tron:728126428" }); + expect(result.results).toHaveLength(1); + expect(result.results[0]).toMatchObject({ + fqn: "bai/recharge", + chains: ["tron:728126428"], + }); + }); + + it("rejects unsafe provider names before constructing a detail URL", async () => { + const catalog = new X402ProviderCatalog(vi.fn() as never); + await expect(catalog.show("../secret")).rejects.toMatchObject({ code: "invalid_value" }); + }); +}); + +it("applies the configured timeout to provider requests", async () => { + const fetcher = vi.fn( + (_request, init) => + new Promise((_resolve, reject) => + init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true }), + ), + ); + const catalog = new X402ProviderCatalog(fetcher as typeof fetch, undefined, 10); + await expect(catalog.list({ limit: 1, offset: 0 })).rejects.toMatchObject({ code: "timeout" }); +}); diff --git a/ts/src/adapters/outbound/x402/provider-catalog.ts b/ts/src/adapters/outbound/x402/provider-catalog.ts new file mode 100644 index 000000000..67da7f04b --- /dev/null +++ b/ts/src/adapters/outbound/x402/provider-catalog.ts @@ -0,0 +1,214 @@ +import { fetchBounded } from "../http/http-response.js"; +import { mkdir, rename, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { + ProviderCatalogPort, + ProviderListInput, +} from "../../../application/ports/provider-catalog.js"; +import { CliError, TransportError, UsageError } from "../../../domain/errors/index.js"; + +const CATALOG_URL = "https://x402-catalog.bankofai.io/api/catalog.json"; + +export class X402ProviderCatalog implements ProviderCatalogPort { + constructor( + private readonly fetcher: typeof fetch = globalThis.fetch, + private readonly cacheFile = join(homedir(), ".cache", "wallet-cli", "x402", "catalog.json"), + private readonly timeoutMs = 60000, + ) {} + + async list(input: ProviderListInput) { + const payload = await this.catalog(); + let providers = arrayOfObjects(payload.providers).map(normalizeObject); + validateFilter(providers, "type", input.type, "type is not exposed by the catalog yet"); + validateFilter(providers, "category", input.category); + validateArrayFilter(providers, "featuredTags", input.capability); + const wantedNetwork = input.network ? normalizeNetworkAlias(input.network) : undefined; + validateArrayFilter(providers, "chains", wantedNetwork); + providers = providers.filter( + (provider) => + matches(provider, "type", input.type) && + matches(provider, "category", input.category) && + includes(provider, "featuredTags", input.capability) && + includes(provider, "chains", wantedNetwork) && + (input.includeBlocked === true || provider.blocked !== true), + ); + const total = providers.length; + return { + catalog: CATALOG_URL, + generatedAt: payload.generated_at, + count: Math.min(input.limit, Math.max(0, total - input.offset)), + filters: Object.fromEntries( + Object.entries({ + type: input.type, + category: input.category, + capability: input.capability, + network: wantedNetwork, + }).filter((entry) => entry[1] !== undefined), + ), + results: providers.slice(input.offset, input.offset + input.limit), + pagination: { offset: input.offset, limit: input.limit, total }, + }; + } + + async show(fqn: string) { + safeFqn(fqn); + return normalizeObject(await this.readJson(detailUrl("providers", fqn))); + } + + async endpoints(fqn: string) { + const provider = await this.show(fqn); + return { fqn, endpoints: Array.isArray(provider.endpoints) ? provider.endpoints : [] }; + } + + async update() { + const payload = await this.readJson(CATALOG_URL); + const body = `${JSON.stringify(payload, null, 2)}\n`; + const temporary = `${this.cacheFile}.${process.pid}.tmp`; + await mkdir(dirname(this.cacheFile), { recursive: true, mode: 0o700 }); + await writeFile(temporary, body, { encoding: "utf8", mode: 0o600 }); + await rename(temporary, this.cacheFile); + return { + updated: true, + cache: this.cacheFile, + providers: arrayOfObjects(payload.providers).length, + }; + } + + private catalog() { + return this.readJson(CATALOG_URL); + } + + private async readJson(url: string): Promise> { + let response: Response; + try { + response = await fetchBounded( + this.fetcher, + url, + { headers: { accept: "application/json" }, redirect: "error" }, + this.timeoutMs, + ); + } catch (error) { + if (error instanceof CliError) throw error; + throw new TransportError("provider_error", "x402 catalog request failed"); + } + if (!response.ok) { + throw new TransportError("provider_error", `x402 catalog returned HTTP ${response.status}`); + } + try { + const value: unknown = await response.json(); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(); + return value as Record; + } catch { + throw new TransportError("invalid_x402_response", "x402 catalog returned invalid JSON"); + } + } +} + +function safeFqn(fqn: string): void { + if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}$/.test(fqn) || fqn.includes("..")) { + throw new UsageError("invalid_value", "provider must be a safe FQN"); + } +} + +function detailUrl(section: "providers" | "pay", fqn: string): string { + const filename = `${fqn.replace(/\//g, "__")}.json`; + return new URL(`${section}/${filename}`, CATALOG_URL).toString(); +} + +function arrayOfObjects(value: unknown): Record[] { + return Array.isArray(value) + ? value.filter( + (item): item is Record => + Boolean(item) && typeof item === "object" && !Array.isArray(item), + ) + : []; +} + +function normalizeObject(value: Record): Record { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [camel(key), normalize(item)]), + ); +} + +function normalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalize); + if (value && typeof value === "object") return normalizeObject(value as Record); + if (typeof value === "string") return normalizeNetwork(value); + return value; +} + +function camel(value: string): string { + return value.replace(/_([a-z])/g, (_all, letter: string) => letter.toUpperCase()); +} + +function normalizeNetwork(value: string): string { + const match = /^tron:0x([0-9a-f]+)$/i.exec(value); + return match ? `tron:${Number.parseInt(match[1]!, 16)}` : value; +} + +function matches(provider: Record, field: string, expected?: string): boolean { + return ( + expected === undefined || String(provider[field] ?? "").toLowerCase() === expected.toLowerCase() + ); +} + +function includes(provider: Record, field: string, expected?: string): boolean { + if (expected === undefined) return true; + const values = provider[field]; + return ( + Array.isArray(values) && + values.some((value) => String(value).toLowerCase() === expected.toLowerCase()) + ); +} + +function validateFilter( + providers: Record[], + field: string, + expected?: string, + unavailable?: string, +): void { + if (expected === undefined) return; + const matches = [ + ...new Set( + providers + .map((provider) => provider[field]) + .filter((value): value is string => typeof value === "string"), + ), + ].sort(); + if (!matches.some((value) => value.toLowerCase() === expected.toLowerCase())) { + throw new UsageError("invalid_value", unavailable ?? `unknown provider ${field}: ${expected}`, { + matches, + }); + } +} + +function validateArrayFilter( + providers: Record[], + field: string, + expected?: string, +): void { + if (expected === undefined) return; + const matches = [ + ...new Set( + providers.flatMap((provider) => + Array.isArray(provider[field]) ? provider[field].map(String) : [], + ), + ), + ].sort(); + if (!matches.some((value) => value.toLowerCase() === expected.toLowerCase())) { + throw new UsageError("invalid_value", `unknown provider ${field}: ${expected}`, { matches }); + } +} + +function normalizeNetworkAlias(value: string): string { + const aliases: Record = { + tron: "tron:728126428", + nile: "tron:3448148188", + shasta: "tron:2494104990", + bsc: "eip155:56", + "bsc-testnet": "eip155:97", + base: "eip155:8453", + }; + return aliases[value.toLowerCase()] ?? normalizeNetwork(value); +} diff --git a/ts/src/adapters/outbound/x402/roundtrip.test.ts b/ts/src/adapters/outbound/x402/roundtrip.test.ts new file mode 100644 index 000000000..980bb8773 --- /dev/null +++ b/ts/src/adapters/outbound/x402/roundtrip.test.ts @@ -0,0 +1,71 @@ +import { expect, it, vi } from "vitest"; +import { X402Service } from "../../../application/use-cases/x402-service.js"; +import type { SignerResolver } from "../../../application/services/signer/index.js"; +import type { ProviderCatalogPort } from "../../../application/ports/provider-catalog.js"; +import { X402PaymentClient } from "./payment-client.js"; +import { X402HttpServer } from "./server.js"; +import { baiPaymentResult } from "../../../application/services/bai-payment-result.js"; + +it.each([ + ["eip155:56", "56", "USDT", "10000000000000000000"], + ["eip155:8453", "8453", "USDC", "10000000"], +])( + "settles %s through the local roundtrip with the installed SDK", + async (id, chainId, token, raw) => { + const payer = "0x1111111111111111111111111111111111111111"; + const payTo = "0x2222222222222222222222222222222222222222"; + const transaction = "0x" + "a".repeat(64); + const signTypedData = vi.fn(async (payload) => ({ + primaryType: payload.primaryType, + signature: "0x" + "a".repeat(130), + })); + const resolver = { + assertCanSign: vi.fn(), + resolve: () => ({ address: payer, kind: "software", signTypedData }), + } as unknown as SignerResolver; + const facilitator = vi.fn(async (url, init) => { + expect(String(url)).toMatch(/^https:\/\/facilitator.example\/(verify|settle)$/); + expect(init.redirect).toBe("error"); + expect(init.headers).not.toHaveProperty("Authorization"); + const body = JSON.parse(init.body); + expect(body.paymentRequirements).toMatchObject({ + network: id, + scheme: "exact", + amount: raw, + payTo, + }); + expect(body.paymentPayload).toMatchObject({ + x402Version: 2, + accepted: body.paymentRequirements, + }); + return Response.json( + String(url).endsWith("/verify") + ? { isValid: true, payer } + : { success: true, transaction, network: id, payer }, + ); + }); + const service = new X402Service( + new X402PaymentClient(resolver), + {} as ProviderCatalogPort, + new X402HttpServer(facilitator as typeof fetch), + ); + const result = await service.roundtrip( + { activeAccount: "payer", timeoutMs: 2000, emit: vi.fn() } as never, + { id, chainId, family: "evm" } as never, + { + host: "127.0.0.1", + port: 0, + payTo, + amount: "10", + token, + scheme: "exact", + facilitatorUrl: "https://facilitator.example", + }, + ); + expect(new URL(String(result.serve.payUrl)).port).not.toBe("0"); + expect(baiPaymentResult(result.pay, id)).toEqual({ txHash: transaction, payer }); + expect(signTypedData).toHaveBeenCalledOnce(); + expect(facilitator).toHaveBeenCalledTimes(2); + await expect(fetch(String(result.serve.payUrl))).rejects.toThrow(); + }, +); diff --git a/ts/src/adapters/outbound/x402/server.test.ts b/ts/src/adapters/outbound/x402/server.test.ts new file mode 100644 index 000000000..c8976eed1 --- /dev/null +++ b/ts/src/adapters/outbound/x402/server.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { toSmallestUnit } from "./server.js"; + +describe("x402 server amount conversion", () => { + it("converts without floating point loss", () => { + expect(toSmallestUnit("1.000001", 6)).toBe("1000001"); + expect(() => toSmallestUnit("0.0000001", 6)).toThrow(/at most 6/); + }); +}); + +import { X402HttpServer } from "./server.js"; +import { createServer } from "node:net"; +import { request } from "node:http"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; + +async function withServer(settlement: object, run: (port: number) => Promise) { + const socket = createServer(); + await new Promise((resolve) => socket.listen(0, "127.0.0.1", resolve)); + const port = (socket.address() as { port: number }).port; + await new Promise((resolve) => socket.close(() => resolve())); + const server = new X402HttpServer(async (url) => + Response.json(String(url).endsWith("/verify") ? { isValid: true } : settlement), + ); + const handle = await server.start( + { id: "tron:3448148188", family: "tron", chainId: "3448148188" } as NetworkDescriptor, + { + host: "127.0.0.1", + port, + payTo: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + amount: "0.01", + token: "USDT", + scheme: "exact", + facilitatorUrl: "https://fake.invalid", + }, + ); + try { + await run(port); + } finally { + await handle.close(); + } +} + +it("rejects malformed request URLs and keeps serving health requests", async () => { + await withServer({}, async (port) => { + const status = await new Promise((resolve, reject) => { + const req = request({ hostname: "127.0.0.1", port, path: "//[" }, (response) => { + response.resume(); + response.on("end", () => resolve(response.statusCode)); + }); + req.on("error", reject); + req.end(); + }); + expect(status).toBe(400); + expect((await fetch(`http://127.0.0.1:${port}/health`)).status).toBe(200); + }); +}); + +it.each([ + [{ success: true, transaction: "", network: "tron:0xcd8690dc" }, 502], + [{ success: true, transaction: "a".repeat(64), network: "eip155:1" }, 502], + [{ success: true, transaction: "garbage", network: "tron:0xcd8690dc" }, 502], + [{ success: false, transaction: "a".repeat(64), network: "tron:0xcd8690dc" }, 502], + [{ success: true, transaction: "a".repeat(64), network: "tron:0xcd8690dc" }, 200], +])("validates facilitator settlement %j", async (settlement, status) => { + await withServer(settlement, async (port) => { + const response = await fetch(`http://127.0.0.1:${port}/pay`, { + headers: { + "payment-signature": Buffer.from(JSON.stringify({ x402Version: 2, payload: {} })).toString( + "base64", + ), + }, + }); + expect(response.status).toBe(status); + expect(response.headers.has("payment-response")).toBe(status === 200); + await response.arrayBuffer(); + }); +}); + +it.each([ + ["permit2_allowance_required", "permit2_allowance_required"], + ["insufficient_funds", "insufficient_balance"], + ["SECRET", "provider_error"], +])("keeps known settlement reasons and redacts unknown text: %s", async (reason, code) => { + await withServer( + { success: false, errorReason: reason, errorMessage: "SECRET" }, + async (port) => { + const response = await fetch(`http://127.0.0.1:${port}/pay`, { + headers: { + "payment-signature": Buffer.from( + JSON.stringify({ x402Version: 2, payload: {} }), + ).toString("base64"), + }, + }); + const body = await response.json(); + expect(body).toMatchObject({ code, phase: "settle" }); + expect(JSON.stringify(body)).not.toContain("SECRET"); + }, + ); +}); diff --git a/ts/src/adapters/outbound/x402/server.ts b/ts/src/adapters/outbound/x402/server.ts new file mode 100644 index 000000000..514be5d21 --- /dev/null +++ b/ts/src/adapters/outbound/x402/server.ts @@ -0,0 +1,286 @@ +import { providerPaymentError } from "./payment-error.js"; +import { successfulSettlement } from "./settlement.js"; +import { fetchBounded } from "../http/http-response.js"; +import { createServer, type Server } from "node:http"; +import { + decodePaymentSignatureHeader, + encodePaymentRequiredHeader, + encodePaymentResponseHeader, +} from "@bankofai/x402-core/http"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import type { + X402ServeInput, + X402ServerHandle, + X402ServerPort, +} from "../../../application/ports/x402-server.js"; +import { TransportError, UsageError } from "../../../domain/errors/index.js"; + +interface Token { + address: string; + decimals: number; + name: string; + version: string; + permit2?: boolean; +} +const TOKENS: Record> = { + "tron:728126428": { + USDT: { + address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + decimals: 6, + name: "Tether USD", + version: "1", + permit2: true, + }, + USDD: { + address: "TXDk8mbtRbXeYuMNS83CfKPaYYT8XWv9Hz", + decimals: 18, + name: "Decentralized USD", + version: "1", + permit2: true, + }, + }, + "tron:3448148188": { + USDT: { + address: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + decimals: 6, + name: "Tether USD", + version: "1", + permit2: true, + }, + USDD: { + address: "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK", + decimals: 18, + name: "Decentralized USD", + version: "1", + permit2: true, + }, + }, + "tron:2494104990": { + USDT: { + address: "TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs", + decimals: 6, + name: "Tether USD", + version: "1", + }, + }, + "eip155:56": { + USDT: { + address: "0x55d398326f99059fF775485246999027B3197955", + decimals: 18, + name: "Tether USD", + version: "1", + permit2: true, + }, + }, + "eip155:8453": { + USDC: { + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + decimals: 6, + name: "USD Coin", + version: "2", + }, + }, + "eip155:97": { + USDT: { + address: "0x337610d27c682E347C9cD60BD4b3b107C9d34dDd", + decimals: 18, + name: "Tether USD", + version: "1", + permit2: true, + }, + USDC: { + address: "0x64544969ed7EBf5f083679233325356EbE738930", + decimals: 18, + name: "USD Coin", + version: "1", + permit2: true, + }, + }, +}; + +export class X402HttpServer implements X402ServerPort { + constructor( + private readonly fetcher: typeof fetch = globalThis.fetch, + private readonly timeoutMs = 60000, + ) {} + + validate(network: NetworkDescriptor, input: X402ServeInput): void { + this.requirement(network, input); + } + + private requirement(network: NetworkDescriptor, input: X402ServeInput) { + if (input.scheme === "exact_gasfree" && network.family !== "tron") { + throw new UsageError("invalid_value", "exact_gasfree is supported only on TRON"); + } + const token = TOKENS[network.id]?.[input.token.toUpperCase()]; + if (!token) + throw new UsageError("invalid_value", `${input.token} is not registered on ${network.id}`); + validatePayTo(network, input.payTo); + const rawAmount = toSmallestUnit(input.amount, token.decimals); + return { token, rawAmount }; + } + + async start(network: NetworkDescriptor, input: X402ServeInput): Promise { + const { token, rawAmount } = this.requirement(network, input); + const x402Network = + network.family === "tron" ? `tron:0x${BigInt(network.chainId).toString(16)}` : network.id; + const host = input.host.includes(":") ? `[${input.host}]` : input.host; + let resourceUrl = `http://${host}:${input.port}/pay`; + const requirement = { + scheme: input.scheme, + network: x402Network, + amount: rawAmount, + asset: token.address, + payTo: input.payTo, + maxTimeoutSeconds: 300, + extra: + input.scheme === "exact_gasfree" + ? { name: token.name, version: token.version } + : token.permit2 + ? { assetTransferMethod: "permit2" } + : { name: token.name, version: token.version }, + }; + const challenge = { + x402Version: 2, + error: "Payment required", + resource: { url: resourceUrl }, + accepts: [requirement], + }; + const server = createServer(async (request, response) => { + let pathname: string; + try { + pathname = new URL(request.url ?? "/", resourceUrl).pathname; + } catch { + return json(response, 400, { error: "invalid request URL" }); + } + if (pathname === "/health") return json(response, 200, { ok: true }); + if (pathname !== "/pay") return json(response, 404, { error: "not found" }); + const signature = request.headers["payment-signature"]; + if (!signature || Array.isArray(signature)) { + response.setHeader("payment-required", encodePaymentRequiredHeader(challenge as never)); + return json(response, 402, challenge); + } + let phase: "verify" | "settle" = "verify"; + try { + const paymentPayload = decodePaymentSignatureHeader(signature); + const verify = await this.facilitator(input.facilitatorUrl, "/verify", { + paymentPayload, + paymentRequirements: requirement, + }); + if (!(verify.valid === true || verify.isValid === true)) + return paymentFailure(response, 400, verify.invalidReason ?? verify.errorReason, phase); + phase = "settle"; + const settle = await this.facilitator(input.facilitatorUrl, "/settle", { + paymentPayload, + paymentRequirements: requirement, + }); + if (!successfulSettlement(settle, x402Network)) + return paymentFailure(response, 502, settle.errorReason, phase); + response.setHeader("payment-response", encodePaymentResponseHeader(settle as never)); + return json(response, 200, { + success: true, + network: x402Network, + scheme: input.scheme, + transaction: settle.transaction, + }); + } catch { + return paymentFailure(response, 502, undefined, phase); + } + }); + await listen(server, input.host, input.port); + const address = server.address(); + if (address && typeof address === "object") { + resourceUrl = `http://${host}:${address.port}/pay`; + challenge.resource.url = resourceUrl; + } + return { + details: { + payUrl: resourceUrl, + network: network.id, + scheme: input.scheme, + token: input.token.toUpperCase(), + amount: input.amount, + rawAmount, + payTo: input.payTo, + }, + close: () => close(server), + }; + } + + private async facilitator( + base: string, + path: string, + body: unknown, + ): Promise> { + const response = await fetchBounded( + this.fetcher, + new URL(path, `${base.replace(/\/+$/, "")}/`), + { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify(body), + redirect: "error", + }, + this.timeoutMs, + 1024 * 1024, + ); + if (!response.ok) + throw new TransportError("provider_error", `facilitator returned HTTP ${response.status}`); + return (await response.json()) as Record; + } +} + +function validatePayTo(network: NetworkDescriptor, value: string): void { + const valid = + network.family === "evm" + ? /^0x[0-9a-fA-F]{40}$/.test(value) + : /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(value); + if (!valid) + throw new UsageError( + "invalid_address", + `invalid ${network.family.toUpperCase()} --pay-to address`, + ); +} + +export function toSmallestUnit(value: string, decimals: number): string { + if (!/^\d+(?:\.\d+)?$/.test(value)) + throw new UsageError("invalid_value", "amount must be a decimal string"); + const [whole, fraction = ""] = value.split("."); + if (fraction.length > decimals) + throw new UsageError("invalid_value", `amount supports at most ${decimals} decimal places`); + return ( + BigInt(whole!) * 10n ** BigInt(decimals) + + BigInt(fraction.padEnd(decimals, "0") || "0") + ).toString(); +} + +function json(response: import("node:http").ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); +} + +function listen(server: Server, host: string, port: number): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); +} + +function paymentFailure( + response: import("node:http").ServerResponse, + status: number, + reason: unknown, + phase: "verify" | "settle", +): void { + const error = providerPaymentError(reason, phase); + json(response, status, { code: error.code, error: error.message, phase }); +} diff --git a/ts/src/adapters/outbound/x402/settlement.ts b/ts/src/adapters/outbound/x402/settlement.ts new file mode 100644 index 000000000..2dcf5fba5 --- /dev/null +++ b/ts/src/adapters/outbound/x402/settlement.ts @@ -0,0 +1,10 @@ +export function successfulSettlement(value: unknown, expectedNetwork?: string): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const settlement = value as Record; + if (settlement.success !== true || typeof settlement.network !== "string") return false; + if (expectedNetwork && settlement.network !== expectedNetwork) return false; + if (typeof settlement.transaction !== "string") return false; + return settlement.network.startsWith("tron:") + ? /^[0-9a-fA-F]{64}$/.test(settlement.transaction) + : /^0x[0-9a-fA-F]{64}$/.test(settlement.transaction); +} diff --git a/ts/src/adapters/outbound/x402/signer-bridge.test.ts b/ts/src/adapters/outbound/x402/signer-bridge.test.ts new file mode 100644 index 000000000..5826e76d4 --- /dev/null +++ b/ts/src/adapters/outbound/x402/signer-bridge.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi } from "vitest"; +import { toX402Wallet } from "./signer-bridge.js"; +import type { PayerSigner } from "../../../application/contracts/x402-payer.js"; +import type { TypedDataPayload } from "../../../domain/types/index.js"; + +const EVM_ADDRESS = "0xaB5801a7D398351b8bE11C439e05C5B3259aeC9B"; +const TRON_ADDRESS = "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ"; +// The same TRON address in the 41-prefixed hex form a counterparty may send instead. +const TRON_HEX = "4119e7e376e7c213b7e7e7e46cc70a5dd086daff2a"; + +it.each([ + [{ gas: 65000n }, 65000n], + [{ gasLimit: 70000n }, 70000n], + [{ gas: 65000n, gasLimit: 70000n }, 70000n], +])( + "normalizes EVM gas without mutating the SDK transaction (case %#)", + async (fields, expected) => { + const tx = Object.freeze({ to: EVM_ADDRESS, ...fields }); + const signTransaction = vi.fn(async () => ({ raw: "0xraw" })); + const payer = { ...payerOf(EVM_ADDRESS), signTransaction }; + await toX402Wallet(payer, { family: "evm" }).signTransaction(tx); + expect(signTransaction).toHaveBeenCalledWith({ to: EVM_ADDRESS, gasLimit: expected }); + expect(tx).toEqual({ to: EVM_ADDRESS, ...fields }); + }, +); + +it.each([null, "0x1234", []])( + "rejects invalid EVM transaction input before signing %j", + async (tx) => { + const payer = payerOf(EVM_ADDRESS); + await expect(toX402Wallet(payer, { family: "evm" }).signTransaction(tx)).rejects.toThrow( + "must be an object", + ); + expect(payer.signTransaction).not.toHaveBeenCalled(); + }, +); + +const payerOf = (address: string, signature = "sig", primaryType = "Transfer"): PayerSigner => ({ + address, + signTypedData: vi.fn(async () => ({ signature, digest: "0xdig", primaryType })), + signTransaction: vi.fn(async (tx: unknown) => tx), +}); + +const evmPayload = (from: string): TypedDataPayload => ({ + domain: { name: "x402" }, + types: { Transfer: [{ name: "from", type: "address" }] }, + primaryType: "Transfer", + message: { from }, +}); + +const permitPayload = (user: string, maxFee: string): TypedDataPayload => ({ + domain: { name: "GasFreeController" }, + types: { + PermitTransfer: [ + { name: "user", type: "address" }, + { name: "maxFee", type: "uint256" }, + ], + }, + primaryType: "PermitTransfer", + message: { user, maxFee }, +}); + +// Same structs as permitPayload/evmPayload but with `primaryType` OMITTED — the shape a well-formed +// single-root payload is allowed to arrive in (see domain/typed-data). The bridge must still resolve +// the root and run every guard against it, not skip the guards because the field is absent. +const permitPayloadNoPrimaryType = (user: string, maxFee: string): TypedDataPayload => ({ + domain: { name: "GasFreeController" }, + types: { + PermitTransfer: [ + { name: "user", type: "address" }, + { name: "maxFee", type: "uint256" }, + ], + }, + message: { user, maxFee }, +}); + +const evmPayloadNoPrimaryType = (from: string): TypedDataPayload => ({ + domain: { name: "x402" }, + types: { Transfer: [{ name: "from", type: "address" }] }, + message: { from }, +}); + +describe("toX402Wallet", () => { + it("reports the payer's address", () => { + expect(toX402Wallet(payerOf(EVM_ADDRESS), { family: "evm" }).getAddress()).toBe(EVM_ADDRESS); + }); + + // Finding 2: TRON's scheme (createClientTronSigner) calls getAddress(); EVM's (toClientEvmSigner) + // reads a viem-account-shaped `address` property. Both spellings must carry the same value. + it("exposes the payer's address under both spellings the two schemes read, for evm", () => { + const wallet = toX402Wallet(payerOf(EVM_ADDRESS), { family: "evm" }); + expect(wallet.address).toBe(wallet.getAddress()); + expect(wallet.address).toBe(EVM_ADDRESS); + }); + + it("exposes the payer's address under both spellings the two schemes read, for tron", () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS), { family: "tron" }); + expect(wallet.address).toBe(wallet.getAddress()); + expect(wallet.address).toBe(TRON_ADDRESS); + }); + + it("returns a 0x-prefixed signature even when the signer omits the prefix", async () => { + const wallet = toX402Wallet(payerOf(EVM_ADDRESS, "abcd"), { family: "evm" }); + expect(await wallet.signTypedData(evmPayload(EVM_ADDRESS))).toBe("0xabcd"); + }); + + it("keeps a signature that is already prefixed", async () => { + const wallet = toX402Wallet(payerOf(EVM_ADDRESS, "0xabcd"), { family: "evm" }); + expect(await wallet.signTypedData(evmPayload(EVM_ADDRESS))).toBe("0xabcd"); + }); + + it("accepts an EVM payer that differs only in case", async () => { + const wallet = toX402Wallet(payerOf(EVM_ADDRESS), { family: "evm" }); + await expect( + wallet.signTypedData(evmPayload(EVM_ADDRESS.toLowerCase())), + ).resolves.toBeDefined(); + }); + + it("refuses to sign for a different EVM payer", async () => { + const payer = payerOf(EVM_ADDRESS); + const wallet = toX402Wallet(payer, { family: "evm" }); + await expect( + wallet.signTypedData(evmPayload("0x2222222222222222222222222222222222222222")), + ).rejects.toMatchObject({ code: "payer_mismatch" }); + expect(payer.signTypedData).not.toHaveBeenCalled(); + }); + + it("accepts a TRON payer given in hex form", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), { family: "tron" }); + await expect(wallet.signTypedData(permitPayload(TRON_HEX, "100"))).resolves.toBeDefined(); + }); + + it("refuses to sign for a different TRON payer", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), { family: "tron" }); + await expect( + wallet.signTypedData(permitPayload("TBvJUBXorwBPzqvV38vjDgegj5Eh6g2Tsq", "100")), + ).rejects.toMatchObject({ code: "payer_mismatch" }); + }); + + it("signs a PermitTransfer whose fee is within the ceiling", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), { + family: "tron", + maxGasfreeFeeRaw: "100", + }); + await expect(wallet.signTypedData(permitPayload(TRON_ADDRESS, "100"))).resolves.toBeDefined(); + }); + + it("refuses a PermitTransfer whose fee exceeds the ceiling", async () => { + const payer = payerOf(TRON_ADDRESS, "sig", "PermitTransfer"); + const wallet = toX402Wallet(payer, { family: "tron", maxGasfreeFeeRaw: "100" }); + await expect(wallet.signTypedData(permitPayload(TRON_ADDRESS, "101"))).rejects.toMatchObject({ + code: "fee_cap_exceeded", + }); + expect(payer.signTypedData).not.toHaveBeenCalled(); + }); + + it("refuses a PermitTransfer whose fee is not a whole number", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), { + family: "tron", + maxGasfreeFeeRaw: "100", + }); + await expect(wallet.signTypedData(permitPayload(TRON_ADDRESS, "ten"))).rejects.toMatchObject({ + code: "fee_cap_exceeded", + }); + }); + + // Finding 4: BigInt(maxGasfreeFeeRaw) used to sit outside the try block, so an unparseable + // ceiling threw a bare, uncoded SyntaxError instead of a ChainError. + it("refuses a PermitTransfer when the policy's own fee ceiling will not parse", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), { + family: "tron", + maxGasfreeFeeRaw: "1e6", + }); + await expect(wallet.signTypedData(permitPayload(TRON_ADDRESS, "100"))).rejects.toMatchObject({ + code: "fee_cap_exceeded", + }); + }); + + it("ignores the fee ceiling for a struct that is not a PermitTransfer", async () => { + const wallet = toX402Wallet(payerOf(EVM_ADDRESS), { family: "evm", maxGasfreeFeeRaw: "0" }); + await expect(wallet.signTypedData(evmPayload(EVM_ADDRESS))).resolves.toBeDefined(); + }); + + it("refuses a signature produced for a different struct", async () => { + const wallet = toX402Wallet(payerOf(EVM_ADDRESS, "sig", "SomethingElse"), { family: "evm" }); + await expect(wallet.signTypedData(evmPayload(EVM_ADDRESS))).rejects.toMatchObject({ + code: "signed_payload_mismatch", + }); + }); + + // Finding 1: an absent `primaryType` must not disable the guards. `declaredPayer`, + // `assertFeeWithinCap` and `assertSignedTheRequest` all branched on `payload.primaryType` + // directly, so a payload that simply omitted the field slipped past every one of them. + it("still rejects a payer mismatch and an over-cap fee when primaryType is omitted", async () => { + const payer = payerOf(TRON_ADDRESS, "sig", "PermitTransfer"); + const wallet = toX402Wallet(payer, { family: "tron", maxGasfreeFeeRaw: "100" }); + await expect( + wallet.signTypedData( + permitPayloadNoPrimaryType("TBvJUBXorwBPzqvV38vjDgegj5Eh6g2Tsq", "999999999"), + ), + ).rejects.toMatchObject({ code: "payer_mismatch" }); + expect(payer.signTypedData).not.toHaveBeenCalled(); + }); + + it("resolves the root and signs a PermitTransfer with omitted primaryType when payer and fee are fine", async () => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", "PermitTransfer"), { + family: "tron", + maxGasfreeFeeRaw: "100", + }); + await expect( + wallet.signTypedData(permitPayloadNoPrimaryType(TRON_ADDRESS, "100")), + ).resolves.toBeDefined(); + }); + + it("rejects an EVM payer mismatch when primaryType is omitted", async () => { + const payer = payerOf(EVM_ADDRESS); + const wallet = toX402Wallet(payer, { family: "evm" }); + await expect( + wallet.signTypedData(evmPayloadNoPrimaryType("0x2222222222222222222222222222222222222222")), + ).rejects.toMatchObject({ code: "payer_mismatch" }); + expect(payer.signTypedData).not.toHaveBeenCalled(); + }); + + it("passes a TRON transaction through untouched", async () => { + const payer = payerOf(TRON_ADDRESS); + const tx = { raw_data: {}, txID: "abc" }; + expect(await toX402Wallet(payer, { family: "tron" }).signTransaction(tx)).toEqual(tx); + expect(payer.signTransaction).toHaveBeenCalledWith(tx); + }); + + it("unwraps an EVM signature to the raw serialisation x402 broadcasts", async () => { + const payer: PayerSigner = { + address: EVM_ADDRESS, + signTypedData: vi.fn(), + signTransaction: vi.fn(async () => ({ raw: "0xraw", hash: "0xhash" })), + }; + expect(await toX402Wallet(payer, { family: "evm" }).signTransaction({})).toBe("0xraw"); + }); + + it("refuses an EVM signature that carries no raw transaction", async () => { + const payer: PayerSigner = { + address: EVM_ADDRESS, + signTypedData: vi.fn(), + signTransaction: vi.fn(async () => ({ hash: "0xhash" })), + }; + await expect(toX402Wallet(payer, { family: "evm" }).signTransaction({})).rejects.toMatchObject({ + code: "signed_payload_mismatch", + }); + }); +}); + +it.each(["Transfer", "PermitTransfer"])( + "accepts the TRON SDK's 20-byte payer for %s", + async (primaryType) => { + const wallet = toX402Wallet(payerOf(TRON_ADDRESS, "sig", primaryType), { + family: "tron", + maxGasfreeFeeRaw: "10", + }); + const address = `0x${TRON_HEX.slice(2)}`; + await expect( + wallet.signTypedData( + primaryType === "Transfer" ? evmPayload(address) : permitPayload(address, "1"), + ), + ).resolves.toBe("0xsig"); + }, +); diff --git a/ts/src/adapters/outbound/x402/signer-bridge.ts b/ts/src/adapters/outbound/x402/signer-bridge.ts new file mode 100644 index 000000000..0ad876b9c --- /dev/null +++ b/ts/src/adapters/outbound/x402/signer-bridge.ts @@ -0,0 +1,171 @@ +/** + * signer-bridge — a wallet-cli PayerSigner in the shape the x402 schemes call. + * + * The shape is described STRUCTURALLY rather than imported: this file must compile before any + * x402 package is a dependency, and the SDK only ever duck-types the wallet it is handed. The two + * schemes duck-type it differently: TRON's (`createClientTronSigner`) calls `getAddress()`, EVM's + * (`toClientEvmSigner`) reads a viem-account-shaped `address` property. `X402Wallet` carries both + * spellings of the same value so this bridge need not be reopened once a scheme is actually wired. + * + * This is also the only place every typed-data payload passes through, which is why all three + * guards live here rather than at the call sites. Two of them refuse BEFORE the signature is + * requested, so a rejected payment never reaches a device prompt. + */ +import type { PayerPolicy, PayerSigner } from "../../../application/contracts/x402-payer.js"; +import type { TypedDataPayload, TypedDataSignature } from "../../../domain/types/index.js"; +import type { ChainFamily } from "../../../domain/family/chain-family.js"; +import { ChainError } from "../../../domain/errors/index.js"; +import { tronHexToBase58 } from "../../../domain/address/index.js"; +import { resolvePrimaryType } from "../../../domain/typed-data/index.js"; + +/** + * The wallet an x402 scheme calls. Structural on purpose — see the module comment. TRON's scheme + * reads `getAddress()`; EVM's reads `address`. Both are the same payer address. + */ +export interface X402Wallet { + readonly address: string; + getAddress(): string; + signTypedData(payload: TypedDataPayload): Promise; + signTransaction(tx: unknown): Promise; +} + +/** TIP-712 GasFree authorization; the only struct whose fee this bridge caps. */ +const PERMIT_TRANSFER = "PermitTransfer"; + +/** + * Which field names the payer. + * + * `from` is the payer in the EVM exact/permit2 structs; the GasFree `PermitTransfer` calls the + * same party `user`. A struct that names neither (a nonce read, say) has no payer to check. + */ +function declaredPayer(payload: TypedDataPayload, primaryType: string): unknown { + return primaryType === PERMIT_TRANSFER ? payload.message.user : payload.message.from; +} + +/** The TRON SDK uses 20-byte 0x addresses inside typed data, without the chain prefix. */ +function canonicalTronPayer(address: string): string { + const full = /^0x[0-9a-f]{40}$/i.test(address) ? `41${address.slice(2)}` : address; + return tronHexToBase58(full); +} + +/** Compare each chain's equivalent address representations. */ +function samePayer(family: ChainFamily, a: string, b: string): boolean { + return family === "tron" + ? canonicalTronPayer(a) === canonicalTronPayer(b) + : a.toLowerCase() === b.toLowerCase(); +} + +function assertPayerMatches( + payload: TypedDataPayload, + primaryType: string, + address: string, + family: ChainFamily, +): void { + const declared = declaredPayer(payload, primaryType); + if (declared === undefined) return; + if (typeof declared !== "string" || !samePayer(family, declared, address)) { + throw new ChainError( + "payer_mismatch", + `this payment names a different payer than the selected account ${address}`, + { account: address, payload: String(declared) }, + ); + } +} + +/** + * A GasFree authorization signs a maxFee the service is then entitled to take, so a caller that + * set a ceiling must have it enforced against the FINAL payload, after the SDK has filled the + * value in. Both the payload's fee and the policy's own ceiling are parsed inside this guarded + * path: a ceiling that will not parse must never be treated as "no ceiling". + */ +function assertFeeWithinCap( + payload: TypedDataPayload, + primaryType: string, + maxGasfreeFeeRaw?: string, +): void { + if (maxGasfreeFeeRaw === undefined || primaryType !== PERMIT_TRANSFER) return; + const declared = payload.message.maxFee; + let fee: bigint; + let cap: bigint; + try { + fee = BigInt(declared as string | number | bigint); + cap = BigInt(maxGasfreeFeeRaw); + } catch { + throw new ChainError( + "fee_cap_exceeded", + `GasFree maxFee ${String(declared)} or cap ${maxGasfreeFeeRaw} is not a whole number`, + ); + } + if (fee < 0n || fee > cap) { + throw new ChainError( + "fee_cap_exceeded", + `GasFree maxFee ${fee} exceeds the ${maxGasfreeFeeRaw} ceiling`, + { fee: fee.toString(), cap: maxGasfreeFeeRaw }, + ); + } +} + +/** A signature is only evidence about the struct it was produced for. */ +function assertSignedTheRequest(signed: TypedDataSignature, primaryType: string): void { + if (signed.primaryType !== primaryType) { + throw new ChainError( + "signed_payload_mismatch", + `signed ${signed.primaryType} but ${primaryType} was requested`, + ); + } +} + +const prefixedHex = (value: string): string => (value.startsWith("0x") ? value : `0x${value}`); + +/** + * `evmSignStrategy.sign` returns `{ raw, hash }` — the serialisation plus the locally derived id. + * x402 wants only the serialisation it will broadcast. TRON's strategy returns the signed + * transaction object the SDK already expects, so it passes through as it is. + */ +function evmRawTransaction(signed: unknown): string { + const raw = (signed as { raw?: unknown } | null)?.raw; + if (typeof raw !== "string") { + throw new ChainError("signed_payload_mismatch", "the EVM signature carried no raw transaction"); + } + return raw; +} + +export function toX402Wallet(payer: PayerSigner, policy: PayerPolicy): X402Wallet { + return { + address: payer.address, + getAddress: () => payer.address, + async signTypedData(payload) { + // Resolve the effective root ONCE and feed every guard from it, rather than branching each + // guard on `payload.primaryType` directly — a payload that legitimately omits the field (see + // domain/typed-data) must still be checked, not silently waved through. + const primaryType = resolvePrimaryType(payload); + if (primaryType === undefined) { + throw new ChainError( + "signed_payload_mismatch", + "typed data has no primaryType and its root type cannot be resolved unambiguously", + ); + } + assertPayerMatches(payload, primaryType, payer.address, policy.family); + assertFeeWithinCap(payload, primaryType, policy.maxGasfreeFeeRaw); + const signed = await payer.signTypedData(payload); + assertSignedTheRequest(signed, primaryType); + return prefixedHex(signed.signature); + }, + async signTransaction(tx) { + const signed = await payer.signTransaction( + policy.family === "evm" ? evmTransactionInput(tx) : tx, + ); + return policy.family === "evm" ? evmRawTransaction(signed) : signed; + }, + }; +} + +/** x402 uses viem's `gas`; wallet signers use ethers' `gasLimit`. */ +function evmTransactionInput(tx: unknown): Record { + if (!tx || typeof tx !== "object" || Array.isArray(tx)) { + throw new ChainError("signed_payload_mismatch", "EVM transaction must be an object"); + } + const { gas, ...transaction } = tx as Record; + if (gas !== undefined && transaction.gasLimit == null) transaction.gasLimit = gas; + return transaction; +} diff --git a/ts/src/application/contracts/index.ts b/ts/src/application/contracts/index.ts index 2dce1c944..a7300b762 100644 --- a/ts/src/application/contracts/index.ts +++ b/ts/src/application/contracts/index.ts @@ -1,3 +1,4 @@ export * from "./execution-policy.js"; export * from "./execution-scope.js"; export * from "./progress.js"; +export * from "./x402-payer.js"; diff --git a/ts/src/application/contracts/transaction-input.ts b/ts/src/application/contracts/transaction-input.ts new file mode 100644 index 000000000..50bf642ce --- /dev/null +++ b/ts/src/application/contracts/transaction-input.ts @@ -0,0 +1,34 @@ +import type { DeployConstructorArgs } from "../ports/chain/gateway-provider.js"; + +export type ApprovalKind = "erc721"; + +export interface TransactionModeInput { + dryRun?: boolean; + signOnly?: boolean; + buildOnly?: boolean; + permissionId?: number; + expiration?: number; +} + +export interface EvmContractWriteInput extends TransactionModeInput { + contract?: string; + method?: string; + /** disambiguates standards that share a write signature. */ + approvalKind?: ApprovalKind; + /** `{type,value}` entries for a call; raw positional values for a deployment. */ + params?: unknown[]; + /** native coin sent along with the call, in whole coins (as `tx send --amount` is). */ + callValue?: string; + bytecode?: string; + /** how the constructor's arguments are typed and what they are; see DeployConstructorArgs. */ + constructorArgs?: DeployConstructorArgs; + gasLimit?: string; + maxFee?: string; + priorityFee?: string; + nonce?: number; +} + +export interface GovernanceTransactionInput extends TransactionModeInput { + expiration?: number; + permissionId?: number; +} diff --git a/ts/src/application/contracts/x402-payer.ts b/ts/src/application/contracts/x402-payer.ts new file mode 100644 index 000000000..596ae7155 --- /dev/null +++ b/ts/src/application/contracts/x402-payer.ts @@ -0,0 +1,25 @@ +/** + * PayerSigner — the signing capability an x402 payment flow consumes. + * + * Deliberately NOT the domain `Signer`. An x402 scheme calls the wallet from deep inside a + * payment flow, where nothing can run a device's precheck / prompt / abort ceremony. So the + * ceremony is applied at construction (`createPayerSigner`) and what crosses this port is two + * closures that have already been through it. The outbound adapter therefore never learns that + * Ledger accounts exist. + */ +import type { ChainFamily } from "../../domain/family/chain-family.js"; +import type { TypedDataPayload, TypedDataSignature } from "../../domain/types/index.js"; + +export interface PayerSigner { + /** family-native spelling: base58 `T...` for tron, `0x...` for evm. */ + readonly address: string; + signTypedData(payload: TypedDataPayload): Promise; + signTransaction(tx: unknown): Promise; +} + +/** Per-payment limits the bridge enforces on every payload it passes on. */ +export interface PayerPolicy { + readonly family: ChainFamily; + /** GasFree `PermitTransfer.maxFee` ceiling in base units; absent means no ceiling. */ + readonly maxGasfreeFeeRaw?: string; +} diff --git a/ts/src/application/ports/agent-registry.ts b/ts/src/application/ports/agent-registry.ts new file mode 100644 index 000000000..5f26fcd81 --- /dev/null +++ b/ts/src/application/ports/agent-registry.ts @@ -0,0 +1,48 @@ +import type { NetworkDescriptor } from "../../domain/types/index.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; +import type { + EvmContractWriteInput, + ApprovalKind, + GovernanceTransactionInput, +} from "../contracts/transaction-input.js"; +import type { TronContractParameter } from "./chain/tron-gateway.js"; + +export interface EvmContractPort { + call( + network: NetworkDescriptor, + contract: string, + method: string, + params: Array<{ type: string; value: unknown }>, + ): Promise<{ result: string }>; + send( + scope: TransactionScope, + network: NetworkDescriptor, + input: EvmContractWriteInput, + ): Promise>; +} + +export interface TronContractPort { + call( + network: NetworkDescriptor, + contract: string, + method: string, + params: TronContractParameter[], + ): Promise<{ result: string[] }>; + send( + scope: TransactionScope, + network: NetworkDescriptor, + input: GovernanceTransactionInput & { + contract: string; + method: string; + approvalKind?: ApprovalKind; + parameters: TronContractParameter[]; + callValueSun: string; + feeLimit: string; + }, + ): Promise>; +} + +export interface AgentContractPorts { + evm: EvmContractPort; + tron: TronContractPort; +} diff --git a/ts/src/application/ports/agent-sdk.ts b/ts/src/application/ports/agent-sdk.ts new file mode 100644 index 000000000..ebd1c3ec4 --- /dev/null +++ b/ts/src/application/ports/agent-sdk.ts @@ -0,0 +1,15 @@ +import type { NetworkDescriptor } from "../../domain/types/index.js"; + +export interface AgentRegistryReader { + registry(network: NetworkDescriptor): string; + read( + network: NetworkDescriptor, + method: string, + params: Array<{ type: string; value: unknown }>, + ): Promise; + registeredAgentId(network: NetworkDescriptor, txId: string): Promise; +} + +export interface AgentRegistrationLoader { + load(uri: string): Promise<{ metadata?: Record; warning?: string }>; +} diff --git a/ts/src/application/ports/bai-api.ts b/ts/src/application/ports/bai-api.ts new file mode 100644 index 000000000..089764a29 --- /dev/null +++ b/ts/src/application/ports/bai-api.ts @@ -0,0 +1,28 @@ +export interface BaiStatusView { + pointsBalance: string; + monthlySpent: string; + monthlyChart: Array<{ month: string; points: string }>; +} + +export interface BaiPageInput { + cursor?: string; + page: number; + pageSize: number; + sortBy: string; + sortOrder: "asc" | "desc"; +} + +export interface BaiPageView { + items: Record[]; + page: number; + pageSize: number; + total?: number; + hasMore?: boolean; + nextCursor?: string | null; +} + +export interface BaiApi { + status(): Promise; + usageList(input: BaiPageInput): Promise; + rechargeList(input: BaiPageInput): Promise; +} diff --git a/ts/src/application/ports/bai-binding-store.ts b/ts/src/application/ports/bai-binding-store.ts new file mode 100644 index 000000000..312c19810 --- /dev/null +++ b/ts/src/application/ports/bai-binding-store.ts @@ -0,0 +1,5 @@ +/** Local setup confirmation, scoped to a credential, backend chain and payer address. */ +export interface BaiBindingStore { + isConfirmed(apiKey: string, chain: string, address: string): boolean; + confirm(apiKey: string, chain: string, address: string): void; +} diff --git a/ts/src/application/ports/bai-recharge.ts b/ts/src/application/ports/bai-recharge.ts new file mode 100644 index 000000000..c89044ce5 --- /dev/null +++ b/ts/src/application/ports/bai-recharge.ts @@ -0,0 +1,61 @@ +/** Trusted deployment settings injected by the composition root, not user configuration. */ +export interface BaiRechargeConfig { + readonly facilitatorUrl: string; + readonly payTo: Readonly>>; +} + +/** Confirmed credit recipient; distinct from the authenticated payer's wallet binding. */ +export interface BaiRechargeTarget { + input: { type: "personal"; identifier: string }; + confirmedTarget: { type: "personal"; targetId: string }; +} +export interface BaiWalletBindingInput { + address: string; + chain: string; +} +export interface BaiBindWalletInput extends BaiWalletBindingInput { + message: string; + signature: string; + version?: number; +} +export interface BaiCreateOrderInput { + channel: "crypto"; + chain: string; + tokenName: string; + amount: number; + walletAddress: string; + deviceType: "web"; + rechargeTarget?: BaiRechargeTarget; +} +export interface BaiReportTransactionInput { + chain: string; + txHash: string; + rechargeTarget?: BaiRechargeTarget; + /** Listed in the parameter table but omitted in the documentation's request example. */ + amount?: number; +} +export type BaiReportResult = + | { success: true; order: Record } + | { success: false; code: string; message?: string }; +export interface BaiRechargeApi { + resolveTarget( + identifier: string, + ): Promise<{ type: "personal"; targetId: string; displayLabel: string }>; + isBound(input: BaiWalletBindingInput): Promise; + bind(input: BaiBindWalletInput): Promise<{ userId: string; address: string; chain: string }>; + /** Response contract is not documented yet. Do not infer a payment destination. */ + createOrder(input: BaiCreateOrderInput): Promise>; + reportTxHash(input: BaiReportTransactionInput): Promise; +} + +/** A payment implementation verified against B.AI's preorder destination and payer rules. */ +export interface BaiRechargePayment { + pay( + order: Record, + input: BaiCreateOrderInput, + ): Promise<{ + txHash: string; + chain: string; + payer: string; + }>; +} diff --git a/ts/src/application/ports/provider-catalog.ts b/ts/src/application/ports/provider-catalog.ts new file mode 100644 index 000000000..c6ded83cb --- /dev/null +++ b/ts/src/application/ports/provider-catalog.ts @@ -0,0 +1,20 @@ +export interface ProviderListInput { + limit: number; + offset: number; + type?: string; + category?: string; + capability?: string; + network?: string; + includeBlocked?: boolean; +} + +export interface ProviderCatalogPort { + list(input: ProviderListInput): Promise<{ + results: Record[]; + pagination: Record; + [key: string]: unknown; + }>; + show(fqn: string): Promise>; + endpoints(fqn: string): Promise>; + update(): Promise>; +} diff --git a/ts/src/application/ports/x402-payment.ts b/ts/src/application/ports/x402-payment.ts new file mode 100644 index 000000000..5ae8b853d --- /dev/null +++ b/ts/src/application/ports/x402-payment.ts @@ -0,0 +1,30 @@ +import type { NetworkDescriptor } from "../../domain/types/index.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; + +export interface X402PayInput { + url: string; + method: string; + headers: string[]; + body?: string; + token?: string; + asset?: string; + decimals?: number; + scheme?: "exact" | "exact_gasfree"; + maxAmount?: string; + maxRawAmount?: string; + /** Internal payment constraints, enforced against every offered x402 requirement. */ + expectedPayTo?: string; + exactAmount?: string; + dryRun?: boolean; + out?: string; + maxGasfreeFee?: string; + maxGasfreeFeeRaw?: string; +} + +export interface X402PaymentPort { + pay( + scope: TransactionScope, + network: NetworkDescriptor, + input: X402PayInput, + ): Promise>; +} diff --git a/ts/src/application/ports/x402-server.ts b/ts/src/application/ports/x402-server.ts new file mode 100644 index 000000000..b07ba7236 --- /dev/null +++ b/ts/src/application/ports/x402-server.ts @@ -0,0 +1,31 @@ +import type { NetworkDescriptor } from "../../domain/types/index.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; + +export interface X402RoundtripPort { + validate(network: NetworkDescriptor, input: X402ServeInput): void; + roundtrip( + scope: TransactionScope, + network: NetworkDescriptor, + input: X402ServeInput, + ): Promise<{ serve: Record; pay: Record }>; +} + +export interface X402ServeInput { + payTo: string; + amount: string; + token: string; + scheme: "exact" | "exact_gasfree"; + host: string; + port: number; + facilitatorUrl: string; +} + +export interface X402ServerHandle { + details: Record; + close(): Promise; +} + +export interface X402ServerPort { + validate(network: NetworkDescriptor, input: X402ServeInput): void; + start(network: NetworkDescriptor, input: X402ServeInput): Promise; +} diff --git a/ts/src/application/services/approve-receipt.test.ts b/ts/src/application/services/approve-receipt.test.ts index 001c18afe..002fe803f 100644 --- a/ts/src/application/services/approve-receipt.test.ts +++ b/ts/src/application/services/approve-receipt.test.ts @@ -66,6 +66,21 @@ describe("approveRows", () => { expect(rows.spender).toBe("TBhCfAytweLuLLL2gr8xxxxxxxxxxxxxxx"); }); + it("reports an ERC-721 operator and agent ID without reading fungible-token metadata", async () => { + const metadata = vi.fn(async () => ({ decimals: 6, symbol: "USDC" })); + + const rows = await approveRows({ + ...base, + approvalKind: "erc721", + method: "approve(address,uint256)", + params: params("42"), + metadata, + }); + + expect(rows).toEqual({ identity: { operator: SPENDER, agentId: "42" } }); + expect(metadata).not.toHaveBeenCalled(); + }); + // Spacing is a typing habit, not a different method. it("matches the signature regardless of spacing", async () => { await expect( diff --git a/ts/src/application/services/approve-receipt.ts b/ts/src/application/services/approve-receipt.ts index 0c11fd388..5126dad0b 100644 --- a/ts/src/application/services/approve-receipt.ts +++ b/ts/src/application/services/approve-receipt.ts @@ -1,3 +1,5 @@ +import type { ApprovalKind } from "../contracts/transaction-input.js"; +export type { ApprovalKind } from "../contracts/transaction-input.js"; /** * `approve(address,uint256)` in the terms a person can check. * @@ -26,6 +28,8 @@ function normalizeSignature(signature?: string): string { export interface ApproveContext { method?: string; params?: Array<{ value?: unknown }>; + /** disambiguates standards that share approve(address,uint256). */ + approvalKind?: ApprovalKind; /** the token's decimals and symbol; may fail — labelling is not worth failing the call over. */ metadata: () => Promise<{ decimals?: number; symbol?: string }>; /** the spender address in the family's own display form (TRON hex → base58, EVM as-is). */ @@ -35,7 +39,7 @@ export interface ApproveContext { } /** - * The `spender` / `allowance` fields for an approve call, or nothing at all for any other method. + * Human-readable fields for an approve call, or nothing at all for any other method. * * `unlimited` short-circuits before the metadata read: the 78-digit form tells the reader only * that the number is long, and no decimals can make it readable. @@ -52,6 +56,9 @@ export async function approveRows(ctx: ApproveContext): Promise v))(spenderRaw); + if (ctx.approvalKind === "erc721") { + return { identity: { operator: spender, agentId: amount.toString(10) } }; + } if (amount === MAX_UINT256) return { spender, allowance: "unlimited" }; const meta = await ctx.metadata().catch(() => ({}) as { decimals?: number; symbol?: string }); diff --git a/ts/src/application/services/bai-payment-result.test.ts b/ts/src/application/services/bai-payment-result.test.ts new file mode 100644 index 000000000..70e179397 --- /dev/null +++ b/ts/src/application/services/bai-payment-result.test.ts @@ -0,0 +1,102 @@ +import { expect, it } from "vitest"; +import { baiPaymentResult } from "./bai-payment-result.js"; + +const payer = "0x1111111111111111111111111111111111111111"; +const transaction = "0x" + "a".repeat(64); +const payment = () => ({ + settled: true, + payer: { address: payer }, + paymentResponse: { success: true, transaction, network: "eip155:56", payer }, +}); + +it("extracts a confirmed x402 settlement without an MCP response body", () => { + expect(baiPaymentResult(payment(), "eip155:56")).toEqual({ txHash: transaction, payer }); +}); +it.each([ + { success: false }, + { transaction: "" }, + { transaction: "garbage" }, + { network: "eip155:8453" }, + { payer: "0x2222222222222222222222222222222222222222" }, +])("rejects inconsistent settlement evidence %j", (override) => { + const value = payment(); + Object.assign(value.paymentResponse, override); + expect(() => baiPaymentResult(value, "eip155:56")).toThrow(/unconfirmed/); +}); +it("does not accept HTTP delivery or a legacy MCP hash as proof of settlement", () => { + expect(() => + baiPaymentResult( + { + delivered: true, + payer: { address: payer }, + response: { result: { transaction_hash: transaction, network: "eip155:56" } }, + }, + "eip155:56", + ), + ).toThrow(); + expect(() => baiPaymentResult({ ...payment(), settled: false }, "eip155:56")).toThrow(); +}); +it("accepts TRON settlement network notation", () => { + expect( + baiPaymentResult( + { + settled: true, + payer: { address: "tron-payer" }, + paymentResponse: { success: true, network: "tron:0x2b6653dc", transaction: "a".repeat(64) }, + }, + "tron:728126428", + ), + ).toEqual({ txHash: "a".repeat(64), payer: "tron-payer" }); +}); + +it.each([ + [{ payer: "0x2222222222222222222222222222222222222222" }, "payer_mismatch"], + [{ network: "eip155:8453" }, "network_mismatch"], + [{ success: false }, "settlement_unconfirmed"], +])("retains a candidate hash without claiming confirmation: %j", (override, reason) => { + const value = payment(); + Object.assign(value.paymentResponse, override); + let caught; + try { + baiPaymentResult(value, "eip155:56"); + } catch (error) { + caught = error; + } + expect(caught).toMatchObject({ + code: "invalid_x402_response", + details: { + candidateTxHash: transaction, + reason, + settled: false, + paymentStatus: "unknown", + retryPayment: false, + }, + }); + expect(caught).not.toHaveProperty("details.txHash"); +}); + +it("does not expose malformed evidence or discard a hash when payer metadata is missing", () => { + for (const value of [ + { ...payment(), payer: undefined }, + { + ...payment(), + paymentResponse: { + ...payment().paymentResponse, + transaction: "SECRET", + network: "SECRET", + payer: "SECRET", + }, + }, + ]) { + try { + baiPaymentResult(value, "eip155:56"); + throw new Error("unexpected success"); + } catch (error) { + expect(error).toMatchObject({ code: "invalid_x402_response" }); + expect(JSON.stringify(error)).not.toContain("SECRET"); + if (value.payer === undefined) + expect(error).toHaveProperty("details.candidateTxHash", transaction); + else expect(error).not.toHaveProperty("details.candidateTxHash"); + } + } +}); diff --git a/ts/src/application/services/bai-payment-result.ts b/ts/src/application/services/bai-payment-result.ts new file mode 100644 index 000000000..18efca634 --- /dev/null +++ b/ts/src/application/services/bai-payment-result.ts @@ -0,0 +1,62 @@ +import { TransportError } from "../../domain/errors/index.js"; + +/** Only a successful x402 settlement can be reported to B.AI as a payment. */ +export function baiPaymentResult(payment: Record, network: string) { + const settlement = record(payment.paymentResponse); + const payer = record(payment.payer)?.address; + const txHash = settlement?.transaction; + const expectedNetwork = network === "tron:728126428" ? "tron:0x2b6653dc" : network; + const validHash = network.startsWith("tron:") ? /^[0-9a-fA-F]{64}$/ : /^0x[0-9a-fA-F]{64}$/; + const invalid = (reason: string) => + invalidSettlement(reason, txHash, settlement?.network, expectedNetwork); + if (!settlement) throw invalid("missing_settlement"); + if (payment.settled !== true || settlement.success !== true) + throw invalid("settlement_unconfirmed"); + if (settlement.network !== expectedNetwork) throw invalid("network_mismatch"); + if (typeof txHash !== "string" || !validHash.test(txHash)) + throw invalid("invalid_transaction_hash"); + if (typeof payer !== "string" || !payer) throw invalid("missing_payer"); + if ( + settlement.payer !== undefined && + (typeof settlement.payer !== "string" || + (network.startsWith("eip155:") + ? settlement.payer.toLowerCase() !== payer.toLowerCase() + : settlement.payer !== payer)) + ) { + throw invalid("payer_mismatch"); + } + return { txHash, payer }; +} + +function invalidSettlement( + reason: string, + txHash: unknown, + candidateNetwork: unknown, + expectedNetwork: string, +) { + // Retain only bounded, syntactically valid evidence; it is NOT a confirmed payment. + const candidateTxHash = + typeof txHash === "string" && /^(?:0x)?[0-9a-fA-F]{64}$/.test(txHash) ? txHash : undefined; + return new TransportError( + "invalid_x402_response", + "Recharge settlement is unconfirmed; reconcile before paying again", + { + reason, + paymentStatus: "unknown", + settled: false, + retryPayment: false, + expectedNetwork, + ...(candidateTxHash ? { candidateTxHash } : {}), + ...(typeof candidateNetwork === "string" && + /^(?:eip155:\d{1,20}|tron:(?:0x[0-9a-fA-F]{1,16}|\d{1,20}))$/.test(candidateNetwork) + ? { candidateNetwork } + : {}), + }, + ); +} + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} diff --git a/ts/src/application/services/transaction-mode.ts b/ts/src/application/services/transaction-mode.ts index 188a0cfea..f04acc620 100644 --- a/ts/src/application/services/transaction-mode.ts +++ b/ts/src/application/services/transaction-mode.ts @@ -1,14 +1,8 @@ +import type { TransactionModeInput } from "../contracts/transaction-input.js"; +export type { TransactionModeInput } from "../contracts/transaction-input.js"; import type { TxOutcome } from "../../domain/types/index.js"; import { UsageError } from "../../domain/errors/index.js"; -export interface TransactionModeInput { - dryRun?: boolean; - signOnly?: boolean; - buildOnly?: boolean; - permissionId?: number; - expiration?: number; -} - export type TransactionExecutionMode = "dry-run" | "build-only" | "sign-only" | "broadcast"; export interface ResolvedTransactionMode { diff --git a/ts/src/application/services/x402/payer-signer.test.ts b/ts/src/application/services/x402/payer-signer.test.ts new file mode 100644 index 000000000..8206d61cc --- /dev/null +++ b/ts/src/application/services/x402/payer-signer.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi } from "vitest"; +import { createPayerSigner } from "./payer-signer.js"; +import type { SignerResolver } from "../signer/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { Signer, TypedDataPayload } from "../../../domain/types/index.js"; +import { WalletError } from "../../../domain/errors/index.js"; + +const PAYLOAD: TypedDataPayload = { + domain: { name: "x402" }, + types: { Transfer: [{ name: "from", type: "address" }] }, + primaryType: "Transfer", + message: { from: "0xabc" }, +}; + +const scope = (): TransactionScope & { emitted: unknown[] } => ({ + activeAccount: "wlt_k", + timeoutMs: 50, + wait: false, + waitTimeoutMs: 50, + emitted: [] as unknown[], + emit(e: unknown) { + (this.emitted as unknown[]).push(e); + }, + warn() {}, + resolveAddress: () => "0xdead", +}); + +const resolverOf = (signer: Signer, assertCanSign = vi.fn()) => { + const resolve = vi.fn(() => signer); + return { assertCanSign, resolve } as unknown as SignerResolver; +}; + +describe("createPayerSigner", () => { + it("refuses a watch-only account before resolving a signer", () => { + const assertCanSign = vi.fn(() => { + throw new WalletError("watch_only_no_signer", "watch-only account cannot sign"); + }); + const resolve = vi.fn(); + const signers = { assertCanSign, resolve } as unknown as SignerResolver; + expect(() => createPayerSigner(signers, scope(), "evm")).toThrow( + expect.objectContaining({ code: "watch_only_no_signer" }), + ); + expect(resolve).not.toHaveBeenCalled(); + }); + + it("exposes the resolved signer's address", () => { + const signer = { kind: "software", address: "0xdead" } as unknown as Signer; + expect(createPayerSigner(resolverOf(signer), scope(), "evm").address).toBe("0xdead"); + }); + + // Finding 8: `resolverOf` used to ignore its arguments entirely, so all six tests stayed green + // even if `payer-signer.ts` transposed the two arguments to `assertCanSign`/`resolve`. + it("calls assertCanSign and resolve with the active account and family, in that order", () => { + const signer = { kind: "software", address: "0xdead" } as unknown as Signer; + const assertCanSign = vi.fn(); + const signers = resolverOf(signer, assertCanSign); + createPayerSigner(signers, scope(), "evm"); + expect(assertCanSign).toHaveBeenCalledWith("wlt_k", "evm"); + expect(signers.resolve).toHaveBeenCalledWith("wlt_k", "evm"); + const resolveOrder = (signers.resolve as ReturnType).mock.invocationCallOrder[0]; + expect(assertCanSign.mock.invocationCallOrder[0]).toBeLessThan(resolveOrder as number); + }); + + it("passes a software signature straight through with no device event", async () => { + const signTypedData = vi.fn(async () => ({ + signature: "0xsig", + digest: "0xdig", + primaryType: "Transfer", + })); + const signer = { kind: "software", address: "0xdead", signTypedData } as unknown as Signer; + const s = scope(); + const out = await createPayerSigner(resolverOf(signer), s, "evm").signTypedData(PAYLOAD); + expect(out.signature).toBe("0xsig"); + expect(signTypedData).toHaveBeenCalledWith(PAYLOAD, {}); + expect(s.emitted).toEqual([]); + }); + + it("runs the device ceremony for a device signer", async () => { + const precheck = vi.fn(async () => {}); + const signer = { + kind: "device", + address: "0xdead", + precheck, + signTypedData: async () => ({ signature: "0xsig", digest: "0xdig", primaryType: "Transfer" }), + } as unknown as Signer; + const s = scope(); + await createPayerSigner(resolverOf(signer), s, "evm").signTypedData(PAYLOAD); + expect(precheck).toHaveBeenCalledOnce(); + expect(s.emitted).toEqual([{ type: "awaiting_device", reason: "sign" }]); + }); + + // The TRON allowanceMode "auto" path may sign an approve transaction before the payment + // itself; each signature must get its own precheck and its own prompt. + it("runs one ceremony per signature", async () => { + const precheck = vi.fn(async () => {}); + const signer = { + kind: "device", + address: "0xdead", + precheck, + sign: async () => ({ raw: "0xraw", hash: "0xhash" }), + signTypedData: async () => ({ signature: "0xsig", digest: "0xdig", primaryType: "Transfer" }), + } as unknown as Signer; + const s = scope(); + const payer = createPayerSigner(resolverOf(signer), s, "evm"); + await payer.signTransaction({ to: "0xdead" }); + await payer.signTypedData(PAYLOAD); + expect(precheck).toHaveBeenCalledTimes(2); + expect(s.emitted).toHaveLength(2); + }); + + it("returns what the signer returned for a transaction", async () => { + const sign = vi.fn(async () => ({ raw: "0xraw", hash: "0xhash" })); + const signer = { kind: "software", address: "0xdead", sign } as unknown as Signer; + const tx = { to: "0xdead" }; + const out = await createPayerSigner(resolverOf(signer), scope(), "evm").signTransaction(tx); + expect(sign).toHaveBeenCalledWith(tx, {}); + expect(out).toEqual({ raw: "0xraw", hash: "0xhash" }); + }); +}); diff --git a/ts/src/application/services/x402/payer-signer.ts b/ts/src/application/services/x402/payer-signer.ts new file mode 100644 index 000000000..00fb1f2ce --- /dev/null +++ b/ts/src/application/services/x402/payer-signer.ts @@ -0,0 +1,28 @@ +/** + * createPayerSigner — the active account, as an x402 payer. + * + * `assertCanSign` runs FIRST so a watch-only account fails before any keystore decrypt or network + * call, the same ordering every write command uses. The keystore itself is still untouched at this + * point: `SoftwareSigner` decrypts lazily on its first signature, so a dry-run path that builds a + * payer and never signs never prompts for the master password. + */ +import type { ChainFamily } from "../../../domain/family/chain-family.js"; +import type { PayerSigner } from "../../contracts/x402-payer.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { SignerResolver } from "../signer/index.js"; +import { obtainSignature } from "../signing/obtain-signature.js"; + +export function createPayerSigner( + signers: SignerResolver, + scope: TransactionScope, + family: ChainFamily, +): PayerSigner { + signers.assertCanSign(scope.activeAccount, family); + const signer = signers.resolve(scope.activeAccount, family); + return { + address: signer.address, + signTypedData: (payload) => + obtainSignature(signer, scope, (opts) => signer.signTypedData(payload, opts)), + signTransaction: (tx) => obtainSignature(signer, scope, (opts) => signer.sign(tx, opts)), + }; +} diff --git a/ts/src/application/use-cases/agent-service.test.ts b/ts/src/application/use-cases/agent-service.test.ts new file mode 100644 index 000000000..ff95beb76 --- /dev/null +++ b/ts/src/application/use-cases/agent-service.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from "vitest"; +import { AgentService } from "./agent-service.js"; +import type { AgentContractPorts } from "../ports/agent-registry.js"; +import type { AgentRegistryReader, AgentRegistrationLoader } from "../ports/agent-sdk.js"; +import type { NetworkDescriptor } from "../../domain/types/index.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; + +const owner = "0x1111111111111111111111111111111111111111"; +const operator = "0x2222222222222222222222222222222222222222"; +const registryAddress = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432"; +const evmNet = { id: "eip155:56", family: "evm", chainId: "56" } as NetworkDescriptor; +function fixture(stage = "submitted") { + const send = vi.fn( + async (_scope: TransactionScope, _net: NetworkDescriptor, _input: unknown) => ({ + stage, + txId: "0xabc", + kind: "contract-send", + }), + ); + const contracts = { + evm: { call: vi.fn(), send }, + tron: { call: vi.fn(), send }, + } as AgentContractPorts; + const reader = { + registry: vi.fn(() => registryAddress), + read: vi.fn(async (_net: NetworkDescriptor, method: string): Promise => { + if (method === "ownerOf(uint256)") return owner; + if (method === "tokenURI(uint256)") return "ipfs://old"; + if (method === "getApproved(uint256)") return operator; + return true; + }), + registeredAgentId: vi.fn(async () => "9007199254740993"), + } satisfies AgentRegistryReader; + const metadata = { + load: vi.fn(async () => ({ metadata: { name: "Example" } })), + } satisfies AgentRegistrationLoader; + const scope = { + wait: stage !== "submitted", + warn: vi.fn(), + resolveAddress: () => owner, + } as unknown as TransactionScope; + return { service: new AgentService(contracts, reader, metadata), send, reader, metadata, scope }; +} + +describe("AgentService beta integration", () => { + it("shows authoritative on-chain fields and independently loaded metadata", async () => { + const { service } = fixture(); + expect(await service.show(evmNet, "56:42")).toEqual({ + agentId: "42", + owner, + uri: "ipfs://old", + approved: operator, + registry: registryAddress, + metadata: { name: "Example" }, + }); + }); + it("keeps chain data when metadata cannot be loaded", async () => { + const f = fixture(); + f.metadata.load.mockResolvedValue({ warning: "Metadata unavailable" } as never); + expect(await f.service.show(evmNet, "42")).toMatchObject({ + owner, + uri: "ipfs://old", + warnings: ["Metadata unavailable"], + }); + }); + it("rejects cross-network IDs before any RPC", async () => { + const f = fixture(); + await expect(f.service.show(evmNet, "97:42")).rejects.toMatchObject({ code: "invalid_value" }); + expect(f.reader.read).not.toHaveBeenCalled(); + }); + it("returns submitted register without fetching a receipt", async () => { + const f = fixture(); + expect(await f.service.register(f.scope, evmNet, { uri: "ipfs://new" })).toMatchObject({ + stage: "submitted", + txId: "0xabc", + identity: { uri: "ipfs://new" }, + }); + expect(f.reader.registeredAgentId).not.toHaveBeenCalled(); + expect(f.send).toHaveBeenCalledOnce(); + }); + it("decodes the minted ID only after confirmed registration", async () => { + const f = fixture("confirmed"); + expect(await f.service.register(f.scope, evmNet, { uri: "ipfs://new" })).toMatchObject({ + stage: "confirmed", + identity: { agentId: "9007199254740993", uri: "ipfs://new" }, + }); + }); + it("retains confirmed tx evidence if the registration event is unavailable", async () => { + const f = fixture("confirmed"); + f.reader.registeredAgentId.mockRejectedValue(new Error("rpc secret")); + expect(await f.service.register(f.scope, evmNet, { uri: "ipfs://new" })).toMatchObject({ + stage: "confirmed", + txId: "0xabc", + }); + expect(f.scope.warn).toHaveBeenCalled(); + expect(f.send).toHaveBeenCalledOnce(); + }); + it("does not enrich reverted registrations", async () => { + const f = fixture("failed"); + expect(await f.service.register(f.scope, evmNet, { uri: "ipfs://new" })).toMatchObject({ + stage: "failed", + }); + expect(f.reader.registeredAgentId).not.toHaveBeenCalled(); + }); + it("updates URI and reads actual final state only after confirmation", async () => { + const f = fixture("confirmed"); + f.reader.read.mockResolvedValueOnce("ipfs://old").mockResolvedValueOnce("ipfs://actual"); + expect(await f.service.update(f.scope, evmNet, { id: "42", uri: "ipfs://new" })).toMatchObject({ + identity: { + oldURI: "ipfs://old", + newURI: "ipfs://actual", + requestedURI: "ipfs://new", + agentId: "42", + }, + }); + expect(f.send).toHaveBeenCalledOnce(); + }); + it("does not fetch metadata before or during a write", async () => { + const f = fixture(); + await f.service.update(f.scope, evmNet, { id: "42", uri: "ipfs://new" }); + expect(f.metadata.load).not.toHaveBeenCalled(); + expect(f.reader.read).toHaveBeenCalledTimes(1); + }); + it("transfers from the actual owner and reports confirmed owner", async () => { + const f = fixture("confirmed"); + f.reader.read.mockResolvedValueOnce(owner).mockResolvedValueOnce(operator); + expect( + await f.service.transfer(f.scope, evmNet, { id: "42", newOwner: operator }), + ).toMatchObject({ identity: { oldOwner: owner, newOwner: operator, agentId: "42" } }); + expect(f.send).toHaveBeenCalledWith( + f.scope, + evmNet, + expect.objectContaining({ + method: "transferFrom(address,address,uint256)", + params: [ + { type: "address", value: owner }, + { type: "address", value: operator }, + { type: "uint256", value: "42" }, + ], + }), + ); + }); + it("marks approve as ERC721 and keeps transaction control flags", async () => { + const f = fixture(); + await f.service.approve(f.scope, evmNet, { id: "42", operator, signOnly: true }); + expect(f.send).toHaveBeenCalledWith( + f.scope, + evmNet, + expect.objectContaining({ approvalKind: "erc721", signOnly: true }), + ); + }); + it("revokes a TRON agent with the TRON zero address", async () => { + const f = fixture(); + const net = { + family: "tron", + id: "tron:3448148188", + chainId: "3448148188", + } as NetworkDescriptor; + await f.service.approve(f.scope, net, { + id: "42", + revoke: true, + buildOnly: true, + permissionId: 2, + expiration: 60000, + }); + expect(f.send).toHaveBeenCalledWith( + f.scope, + net, + expect.objectContaining({ + buildOnly: true, + permissionId: 2, + expiration: 60000, + approvalKind: "erc721", + parameters: [ + { type: "address", value: "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb" }, + { type: "uint256", value: "42" }, + ], + }), + ); + }); + it("sets owner-wide operators without loading an Agent", async () => { + const f = fixture(); + await f.service.operatorAdd(f.scope, evmNet, { operator }); + await f.service.operatorRemove(f.scope, evmNet, { operator }); + expect(f.send.mock.calls.map((call) => (call[2] as Record).params)).toEqual([ + [ + { type: "address", value: operator }, + { type: "bool", value: true }, + ], + [ + { type: "address", value: operator }, + { type: "bool", value: false }, + ], + ]); + expect(f.reader.read).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/agent-service.ts b/ts/src/application/use-cases/agent-service.ts new file mode 100644 index 000000000..524ddea01 --- /dev/null +++ b/ts/src/application/use-cases/agent-service.ts @@ -0,0 +1,237 @@ +import type { NetworkDescriptor } from "../../domain/types/index.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; +import type { AgentContractPorts } from "../ports/agent-registry.js"; +import type { AgentRegistryReader, AgentRegistrationLoader } from "../ports/agent-sdk.js"; +import { resolveAgentId } from "../../domain/erc8004/index.js"; +import { tronHexToBase58 } from "../../domain/address/index.js"; + +interface TransactionOptions { + dryRun?: boolean; + signOnly?: boolean; + buildOnly?: boolean; + permissionId?: number; + expiration?: number; + feeLimit?: string; +} + +export class AgentService { + constructor( + private readonly contracts: AgentContractPorts, + private readonly registry: AgentRegistryReader, + private readonly registration: AgentRegistrationLoader, + ) {} + + async show(network: NetworkDescriptor, id: string) { + const agentId = resolveAgentId(id, network).toString(); + const registry = this.registry.registry(network); + const [owner, uri, approved] = await Promise.all([ + this.read(network, registry, "ownerOf(uint256)", [{ type: "uint256", value: agentId }]), + this.read(network, registry, "tokenURI(uint256)", [{ type: "uint256", value: agentId }]), + this.read(network, registry, "getApproved(uint256)", [{ type: "uint256", value: agentId }]), + ]); + const loaded = await this.registration.load(String(uri)); + return { + agentId, + owner: this.address(network, String(owner)), + uri: String(uri), + approved: this.address(network, String(approved)), + registry, + ...(loaded.metadata ? { metadata: loaded.metadata } : {}), + ...(loaded.warning ? { warnings: [loaded.warning] } : {}), + }; + } + + async register( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionOptions & { uri: string }, + ) { + const result: Record & { identity: { uri: string } } = { + ...(await this.write(scope, network, input, "register(string)", [ + { type: "string", value: input.uri }, + ])), + identity: { uri: input.uri }, + }; + if (result.stage !== "confirmed") return result; + const txId = String(result.txId ?? result.hash ?? ""); + try { + const agentId = txId ? await this.registry.registeredAgentId(network, txId) : undefined; + if (agentId !== undefined) return { ...result, identity: { ...result.identity, agentId } }; + } catch { + /* Keep the confirmed transaction even when receipt enrichment fails. */ + } + scope.warn( + "Registration confirmed, but the minted Agent ID could not be read; do not resubmit the transaction.", + ); + return result; + } + + async update( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionOptions & { id: string; uri: string }, + ) { + const id = resolveAgentId(input.id, network).toString(); + const oldURI = String( + await this.read(network, this.registry.registry(network), "tokenURI(uint256)", [ + { type: "uint256", value: id }, + ]), + ); + const result = await this.write(scope, network, input, "setAgentURI(uint256,string)", [ + { type: "uint256", value: id }, + { type: "string", value: input.uri }, + ]); + const view = { ...result, identity: { agentId: id, oldURI, requestedURI: input.uri } }; + if (result.stage !== "confirmed") return view; + try { + const newURI = String( + await this.read(network, this.registry.registry(network), "tokenURI(uint256)", [ + { type: "uint256", value: id }, + ]), + ); + return { ...view, identity: { ...view.identity, newURI } }; + } catch { + scope.warn("URI update confirmed, but the current Agent URI could not be read."); + return view; + } + } + + async transfer( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionOptions & { id: string; newOwner: string }, + ) { + const id = resolveAgentId(input.id, network).toString(); + const registry = this.registry.registry(network); + const owner = String( + await this.read(network, registry, "ownerOf(uint256)", [{ type: "uint256", value: id }]), + ); + const result = await this.write( + scope, + network, + input, + "transferFrom(address,address,uint256)", + [ + { type: "address", value: this.address(network, owner) }, + { type: "address", value: input.newOwner }, + { type: "uint256", value: id }, + ], + ); + const view = { + ...result, + identity: { + agentId: id, + oldOwner: this.address(network, owner), + requestedOwner: input.newOwner, + }, + }; + if (result.stage !== "confirmed") return view; + try { + const newOwner = String( + await this.read(network, registry, "ownerOf(uint256)", [{ type: "uint256", value: id }]), + ); + return { ...view, identity: { ...view.identity, newOwner: this.address(network, newOwner) } }; + } catch { + scope.warn("Transfer confirmed, but the current Agent owner could not be read."); + return view; + } + } + + approve( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionOptions & { id: string; operator?: string; revoke?: boolean }, + ) { + const operator = input.revoke ? zeroAddress(network) : input.operator!; + return this.write(scope, network, input, "approve(address,uint256)", [ + { type: "address", value: operator }, + { type: "uint256", value: resolveAgentId(input.id, network).toString() }, + ]); + } + + operatorAdd( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionOptions & { operator: string }, + ) { + return this.setOperator(scope, network, input, true); + } + + operatorRemove( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionOptions & { operator: string }, + ) { + return this.setOperator(scope, network, input, false); + } + + async operatorCheck(network: NetworkDescriptor, owner: string, operator: string) { + const registry = this.registry.registry(network); + const approved = await this.read(network, registry, "isApprovedForAll(address,address)", [ + { type: "address", value: owner }, + { type: "address", value: operator }, + ]); + return { owner, operator, approved: Boolean(approved), registry }; + } + + private setOperator( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionOptions & { operator: string }, + approved: boolean, + ) { + return this.write(scope, network, input, "setApprovalForAll(address,bool)", [ + { type: "address", value: input.operator }, + { type: "bool", value: approved }, + ]); + } + + private async read( + network: NetworkDescriptor, + _registry: string, + method: string, + params: Array<{ type: string; value: unknown }>, + ): Promise { + // Registry selection and ABI decoding are supplied by the published SDK adapter. + return this.registry.read(network, method, params); + } + + private write( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionOptions, + method: string, + params: Array<{ type: string; value: unknown }>, + ) { + const contract = this.registry.registry(network); + if (network.family === "evm") { + return this.contracts.evm.send(scope, network, { + ...input, + ...(method === "approve(address,uint256)" ? { approvalKind: "erc721" as const } : {}), + contract, + method, + params, + }); + } + return this.contracts.tron.send(scope, network, { + ...input, + ...(method === "approve(address,uint256)" ? { approvalKind: "erc721" as const } : {}), + contract, + method, + parameters: params, + callValueSun: "0", + feeLimit: input.feeLimit ?? "100000000", + }); + } + + private address(network: NetworkDescriptor, value: string): string { + if (network.family !== "tron" || !/^0x[0-9a-fA-F]{40}$/.test(value)) return value; + return tronHexToBase58(`41${value.slice(2)}`); + } +} + +function zeroAddress(network: NetworkDescriptor): string { + return network.family === "tron" + ? "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb" + : "0x0000000000000000000000000000000000000000"; +} diff --git a/ts/src/application/use-cases/bai-credential-setup.test.ts b/ts/src/application/use-cases/bai-credential-setup.test.ts new file mode 100644 index 000000000..a56acc461 --- /dev/null +++ b/ts/src/application/use-cases/bai-credential-setup.test.ts @@ -0,0 +1,141 @@ +import { expect, it, vi } from "vitest"; +import { BaiCredentialSetup } from "./bai-credential-setup.js"; + +it("checks the selected key and payer once, then reuses local confirmation", async () => { + const verified = new Set(); + const store = { + isConfirmed: (key: string, chain: string, address: string) => + verified.has(JSON.stringify([key, chain, address])), + confirm: (key: string, chain: string, address: string) => { + verified.add(JSON.stringify([key, chain, address])); + }, + }; + const check = vi.fn(async () => true); + const setup = new BaiCredentialSetup( + store, + check, + { + resolve: () => { + throw new Error("unused"); + }, + }, + { + activeAccount: () => null, + resolveAccount: () => { + throw new Error("unused"); + }, + }, + ); + await setup.confirm("key", "bnb", "payer"); + await setup.confirm("key", "bnb", "payer"); + expect(check).toHaveBeenCalledTimes(1); + expect(check).toHaveBeenCalledWith("key", { chain: "bnb", address: "payer" }); + await setup.confirm("different-key", "bnb", "payer"); + await setup.confirm("key", "bnb", "different-payer"); + await setup.confirm("key", "tron", "payer"); + expect(check).toHaveBeenCalledTimes(4); +}); +it.each([false, new Error("unavailable")])( + "does not record failed confirmation", + async (result) => { + const store = { isConfirmed: () => false, confirm: vi.fn() }; + const check = vi.fn(async () => { + if (result instanceof Error) throw result; + return result; + }); + await expect( + new BaiCredentialSetup( + store, + check, + { + resolve: () => { + throw new Error("unused"); + }, + }, + { + activeAccount: () => null, + resolveAccount: () => { + throw new Error("unused"); + }, + }, + ).confirm("key", "bnb", "payer"), + ).rejects.toThrow(); + expect(store.confirm).not.toHaveBeenCalled(); + }, +); + +import { baiChain, requireBaiChain } from "./bai-credential-setup.js"; +import type { NetworkDescriptor, Wallet } from "../../domain/types/index.js"; + +function selectionFixture(selection: { network?: string; account?: string } = {}) { + const network = { id: "eip155:8453", family: "evm", chainId: "8453" } as NetworkDescriptor; + const networks = { resolve: vi.fn(() => network) }; + const wallet: Wallet = { + id: "wlt_test", + source: { type: "watch", family: "evm", address: "0x1111111111111111111111111111111111111111" }, + }; + const accounts = { + activeAccount: vi.fn((): string | null => "active"), + resolveAccount: vi.fn(() => ({ wallet, index: 0 })), + }; + const store = { isConfirmed: () => false, confirm: vi.fn() }; + const check = vi.fn(async () => true); + return { + setup: new BaiCredentialSetup(store, check, networks, accounts, selection), + networks, + accounts, + store, + check, + network, + wallet, + }; +} +it("resolves default network and active account inside the setup use case", async () => { + const f = selectionFixture(); + await f.setup.execute("key"); + expect(f.networks.resolve).toHaveBeenCalledWith(undefined); + expect(f.accounts.resolveAccount).toHaveBeenCalledWith("active", "evm"); + expect(f.check).toHaveBeenCalledWith("key", { + chain: "base", + address: f.wallet.source.type === "watch" ? f.wallet.source.address : "", + }); +}); +it("honors explicit network and account without consulting the active account", async () => { + const f = selectionFixture({ network: "base", account: "selected" }); + await f.setup.execute("key"); + expect(f.networks.resolve).toHaveBeenCalledWith("base"); + expect(f.accounts.resolveAccount).toHaveBeenCalledWith("selected", "evm"); + expect(f.accounts.activeAccount).not.toHaveBeenCalled(); +}); +it("rejects missing accounts before contacting BAI", async () => { + const f = selectionFixture(); + f.accounts.activeAccount.mockReturnValue(null); + await expect(f.setup.execute("key")).rejects.toMatchObject({ code: "invalid_value" }); + expect(f.check).not.toHaveBeenCalled(); +}); +it("rejects a missing family address before contacting BAI", async () => { + const f = selectionFixture(); + f.wallet.source = { type: "watch", family: "tron", address: "Ttest" }; + await expect(f.setup.execute("key")).rejects.toMatchObject({ code: "family_mismatch" }); + expect(f.check).not.toHaveBeenCalled(); +}); +it("uses one supported-network mapping and rejects testnet before accessing a wallet", async () => { + const f = selectionFixture(); + Object.assign(f.network, { chainId: "84532", id: "eip155:84532" }); + expect(baiChain(f.network)).toBeUndefined(); + expect(() => requireBaiChain(f.network)).toThrow(); + await expect(f.setup.execute("key")).rejects.toMatchObject({ + code: "unsupported_network_capability", + }); + expect(f.accounts.resolveAccount).not.toHaveBeenCalled(); +}); +it.each([ + ["evm", "56", "bnb"], + ["evm", "8453", "base"], + ["tron", "728126428", "tron"], + ["evm", "84532", undefined], + ["tron", "3448148188", undefined], + ["evm", "1", undefined], +])("maps BAI support for %s:%s", (family, chainId, expected) => { + expect(baiChain({ family, chainId } as NetworkDescriptor)).toBe(expected); +}); diff --git a/ts/src/application/use-cases/bai-credential-setup.ts b/ts/src/application/use-cases/bai-credential-setup.ts new file mode 100644 index 000000000..831ab52db --- /dev/null +++ b/ts/src/application/use-cases/bai-credential-setup.ts @@ -0,0 +1,61 @@ +import type { NetworkRegistry } from "../ports/network-registry.js"; +import type { AccountStore } from "../ports/account-store.js"; +import { walletAddress } from "../../domain/wallet/index.js"; +import type { BaiBindingStore } from "../ports/bai-binding-store.js"; +import type { BaiWalletBindingInput } from "../ports/bai-recharge.js"; +import { UsageError } from "../../domain/errors/index.js"; +import type { NetworkDescriptor } from "../../domain/types/index.js"; + +export function baiChain(network: NetworkDescriptor): string | undefined { + if (network.family === "evm" && network.chainId === "56") return "bnb"; + if (network.family === "evm" && network.chainId === "8453") return "base"; + if (network.family === "tron" && network.chainId === "728126428") return "tron"; +} + +export function requireBaiChain(network: NetworkDescriptor): string { + const chain = baiChain(network); + if (chain) return chain; + throw new UsageError( + "unsupported_network_capability", + "B.AI recharge supports TRON, BSC and Base mainnet", + ); +} + +/** Setup only: recharge checks the persisted confirmation without making another API call. */ +export class BaiCredentialSetup { + constructor( + private readonly store: BaiBindingStore, + private readonly check: (apiKey: string, input: BaiWalletBindingInput) => Promise, + private readonly networks: Pick, + private readonly accounts: Pick, + private readonly selection: { network?: string; account?: string } = {}, + ) {} + + async execute(apiKey: string): Promise { + const network = this.networks.resolve(this.selection.network); + const chain = requireBaiChain(network); + const account = this.selection.account ?? this.accounts.activeAccount(); + if (!account) + throw new UsageError( + "invalid_value", + "Select a payer wallet before configuring the B.AI API key", + ); + const selected = this.accounts.resolveAccount(account, network.family); + const address = walletAddress(selected.wallet, network.family, selected.index); + if (!address) + throw new UsageError("family_mismatch", "Selected wallet has no address for this network"); + await this.confirm(apiKey, chain, address); + } + async confirm(apiKey: string, chain: string, address: string): Promise { + if (!apiKey.trim() || !chain || !address) + throw new UsageError("invalid_value", "B.AI setup requires a credential and payer wallet"); + if (this.store.isConfirmed(apiKey, chain, address)) return; + if (!(await this.check(apiKey, { chain, address }))) { + throw new UsageError( + "invalid_value", + "The selected wallet is not bound to this B.AI account; complete binding before configuring the API key", + ); + } + this.store.confirm(apiKey, chain, address); + } +} diff --git a/ts/src/application/use-cases/bai-recharge-flow.test.ts b/ts/src/application/use-cases/bai-recharge-flow.test.ts new file mode 100644 index 000000000..890b42b72 --- /dev/null +++ b/ts/src/application/use-cases/bai-recharge-flow.test.ts @@ -0,0 +1,175 @@ +import { TransportError } from "../../domain/errors/index.js"; +import { expect, it, vi } from "vitest"; +import { BaiRechargeFlow } from "./bai-recharge-flow.js"; +const input = { + channel: "crypto" as const, + chain: "bnb", + tokenName: "USDT", + amount: 10, + walletAddress: "payer", + deviceType: "web" as const, + rechargeTarget: { + input: { type: "personal" as const, identifier: "recipient" }, + confirmedTarget: { type: "personal" as const, targetId: "recipient-id" }, + }, +}; +function fixture() { + const sequence: string[] = []; + const api = { + isBound: vi.fn(async () => { + sequence.push("check"); + return true; + }), + bind: vi.fn(async () => ({ userId: "payer-id", address: "payer", chain: "bnb" })), + createOrder: vi.fn(async () => { + sequence.push("order"); + return { id: 1 }; + }), + reportTxHash: vi.fn(async () => { + sequence.push("report"); + return { success: true as const, order: { id: 1, points: 100000 } }; + }), + }; + const pay = vi.fn(async () => { + sequence.push("pay"); + return { txHash: "hash", chain: "bnb", payer: "payer" }; + }); + return { api, pay, sequence, flow: new BaiRechargeFlow(api, { pay }) }; +} +it("creates the target preorder, pays once and reports without repeating setup binding checks", async () => { + const { flow, sequence, api, pay } = fixture(); + await expect(flow.execute(input)).resolves.toMatchObject({ + txHash: "hash", + creditStatus: "credited", + order: { points: 100000 }, + rechargeTarget: input.rechargeTarget, + }); + expect(sequence).toEqual(["order", "pay", "report"]); + expect(api.reportTxHash).toHaveBeenCalledWith({ + chain: "bnb", + txHash: "hash", + amount: 10, + rechargeTarget: input.rechargeTarget, + }); + expect(pay).toHaveBeenCalledTimes(1); +}); +it("does not contact binding endpoints during an already configured recharge", async () => { + const { flow, api } = fixture(); + api.isBound.mockRejectedValue(new Error("must not query")); + await expect(flow.execute(input)).resolves.toMatchObject({ creditStatus: "credited" }); + expect(api.isBound).not.toHaveBeenCalled(); + expect(api.bind).not.toHaveBeenCalled(); +}); +it("does not pay if preorder creation fails", async () => { + const { flow, api, pay } = fixture(); + api.createOrder.mockRejectedValue(new Error("unavailable")); + await expect(flow.execute(input)).rejects.toThrow(); + expect(pay).not.toHaveBeenCalled(); +}); +it("retains the transaction if reporting fails and resumes only reporting", async () => { + const { flow, api, pay } = fixture(); + api.reportTxHash.mockRejectedValueOnce(new Error("secret")); + const result = await flow.execute(input); + expect(result).toMatchObject({ + txHash: "hash", + creditStatus: "unconfirmed", + retryPayment: false, + }); + expect(JSON.stringify(result)).not.toContain("secret"); + await expect( + flow.report({ + chain: input.chain, + txHash: result.txHash, + amount: input.amount, + rechargeTarget: input.rechargeTarget, + }), + ).resolves.toMatchObject({ creditStatus: "credited" }); + expect(pay).toHaveBeenCalledTimes(1); + expect(api.createOrder).toHaveBeenCalledTimes(1); +}); +it("does not report a transaction from a mismatched payer", async () => { + const { flow, api, pay } = fixture(); + pay.mockResolvedValue({ txHash: "hash", chain: "bnb", payer: "other" }); + await expect(flow.execute(input)).resolves.toMatchObject({ + txHash: "hash", + creditStatus: "unconfirmed", + retryPayment: false, + }); + expect(api.reportTxHash).not.toHaveBeenCalled(); +}); + +it("never substitutes the payer ID for a missing recipient ID", async () => { + const { flow, api, pay } = fixture(); + const request = structuredClone(input); + request.rechargeTarget.confirmedTarget.targetId = ""; + await expect(flow.execute(request)).rejects.toMatchObject({ code: "invalid_value" }); + expect(api.isBound).not.toHaveBeenCalled(); + expect(pay).not.toHaveBeenCalled(); +}); +it("does not automatically retry an uncertain payment", async () => { + const { flow, api, pay } = fixture(); + pay.mockRejectedValue(new Error("secret")); + await expect(flow.execute(input)).rejects.toMatchObject({ + message: expect.stringContaining("reconcile"), + }); + expect(pay).toHaveBeenCalledTimes(1); + expect(api.reportTxHash).not.toHaveBeenCalled(); +}); +it("keeps the confirmed recipient stable if the payment adapter mutates its input", async () => { + const { api } = fixture(); + const flow = new BaiRechargeFlow(api, { + pay: async (_order, request) => { + request.rechargeTarget!.confirmedTarget.targetId = "payer-id"; + return { txHash: "hash", chain: "bnb", payer: "payer" }; + }, + }); + await flow.execute(input); + expect(api.reportTxHash).toHaveBeenCalledWith( + expect.objectContaining({ rechargeTarget: input.rechargeTarget }), + ); +}); + +it("preserves classified errors and settlement evidence without reporting or paying again", async () => { + const { flow, api, pay } = fixture(); + const failure = new TransportError("invalid_x402_response", "Response processing failed", { + paymentStatus: "settled", + txHash: "confirmed-hash", + retryPayment: false, + }); + pay.mockRejectedValue(failure); + await expect(flow.execute(input)).rejects.toMatchObject({ + code: failure.code, + details: { + ...failure.details, + chain: input.chain, + amount: input.amount, + rechargeTarget: input.rechargeTarget, + }, + }); + expect(pay).toHaveBeenCalledOnce(); + expect(api.reportTxHash).not.toHaveBeenCalled(); +}); + +it("retains a classified reporting failure and the paid transaction for recovery", async () => { + const { flow, api, pay } = fixture(); + api.reportTxHash.mockRejectedValue( + new TransportError("bai_rejected", "B.AI could not verify the transaction", { + reason: "TX_NOT_FOUND_OR_INVALID", + procedure: "order.reportTxHash", + httpStatus: 400, + retryPayment: false, + }), + ); + await expect(flow.execute(input)).resolves.toMatchObject({ + creditStatus: "unconfirmed", + retryPayment: false, + txHash: "hash", + error: { + code: "bai_rejected", + message: "B.AI could not verify the transaction", + details: { reason: "TX_NOT_FOUND_OR_INVALID" }, + }, + }); + expect(pay).toHaveBeenCalledOnce(); + expect(api.reportTxHash).toHaveBeenCalledOnce(); +}); diff --git a/ts/src/application/use-cases/bai-recharge-flow.ts b/ts/src/application/use-cases/bai-recharge-flow.ts new file mode 100644 index 000000000..44ae8cec1 --- /dev/null +++ b/ts/src/application/use-cases/bai-recharge-flow.ts @@ -0,0 +1,105 @@ +import type { + BaiCreateOrderInput, + BaiRechargeApi, + BaiRechargePayment, + BaiReportTransactionInput, +} from "../ports/bai-recharge.js"; +import { CliError, UsageError, TransportError } from "../../domain/errors/index.js"; + +/** Internal orchestration after target resolution and wallet binding. No implicit payment retries. */ +export class BaiRechargeFlow { + constructor( + private readonly api: Pick, + private readonly payment: BaiRechargePayment, + ) {} + + async execute(input: BaiCreateOrderInput) { + // Keep the credit target stable even if an adapter mutates its input while awaiting I/O. + const request = structuredClone(input); + if ( + request.rechargeTarget && + (!request.rechargeTarget.input.identifier.trim() || + !request.rechargeTarget.confirmedTarget.targetId.trim()) + ) { + throw new UsageError("invalid_value", "B.AI recharge requires a resolved recipient"); + } + const order = await this.api.createOrder(structuredClone(request)); + let paid: Awaited>; + try { + paid = await this.payment.pay(order, structuredClone(request)); + } catch (error) { + // Preserve classified payment failures and any settlement evidence. + if (error instanceof CliError) { + const ErrorType = error.kind === "usage" ? UsageError : TransportError; + throw new ErrorType(error.code, error.message, { + paymentStatus: "unknown", + ...error.details, + retryPayment: false, + chain: request.chain, + amount: request.amount, + ...(request.rechargeTarget ? { rechargeTarget: request.rechargeTarget } : {}), + }); + } + throw new TransportError( + "provider_error", + "Recharge payment outcome is unknown; reconcile the transaction before paying again", + { paymentStatus: "unknown", retryPayment: false }, + ); + } + if (!paid.txHash?.trim()) { + throw new TransportError( + "provider_error", + "Recharge payment returned no transaction hash; reconcile before paying again", + ); + } + const reportInput = { + chain: request.chain, + txHash: paid.txHash, + amount: request.amount, + rechargeTarget: request.rechargeTarget, + }; + if (paid.chain !== request.chain || paid.payer !== request.walletAddress) { + return { + ...reportInput, + creditStatus: "unconfirmed" as const, + retryPayment: false as const, + warning: "Payment identity does not match the preorder; transaction was not reported", + }; + } + return this.report(reportInput); + } + + /** Recovery entry point: only reports an existing hash; never creates an order or pays. */ + async report(input: BaiReportTransactionInput) { + return reportBaiTransaction(this.api, input); + } +} + +/** Report-only recovery shared by the recharge flow and CLI. Never invokes payment. */ +export async function reportBaiTransaction( + api: Pick, + input: BaiReportTransactionInput, +) { + const request = structuredClone(input); + const base = { ...request, retryPayment: false as const }; + try { + const result = await api.reportTxHash(structuredClone(request)); + if (!result.success) + return { + ...base, + creditStatus: "unconfirmed" as const, + code: result.code, + ...(result.message ? { warning: result.message } : {}), + }; + return { ...base, creditStatus: "credited" as const, order: result.order }; + } catch (error) { + if (error instanceof UsageError) throw error; + return { + ...base, + creditStatus: "unconfirmed" as const, + ...(error instanceof CliError ? { code: error.code, error: error.toEnvelope() } : {}), + warning: + "Recharge reporting failed; retain the transaction hash and reconcile before retrying reporting. Do not pay again", + }; + } +} diff --git a/ts/src/application/use-cases/bai-recharge-integration.test.ts b/ts/src/application/use-cases/bai-recharge-integration.test.ts new file mode 100644 index 000000000..f4a6660b0 --- /dev/null +++ b/ts/src/application/use-cases/bai-recharge-integration.test.ts @@ -0,0 +1,180 @@ +import { X402HttpServer } from "../../adapters/outbound/x402/server.js"; +import { expect, it, vi } from "vitest"; +import { BaiService } from "./bai-service.js"; +import type { BaiApi } from "../ports/bai-api.js"; +import type { BaiBindingStore } from "../ports/bai-binding-store.js"; +const payer = "0x1111111111111111111111111111111111111111"; +const txHash = "0x" + "a".repeat(64); +const network = { id: "eip155:56", chainId: "56", family: "evm" } as const; +function fixture( + payTo: Record = { bnb: "0x060f7fd9c9622bdcf9f2887c8171d6e6b4b4ba17" }, +) { + const calls: string[] = []; + const api = { + resolveTarget: vi.fn(async () => { + calls.push("resolve"); + return { type: "personal" as const, targetId: "recipient-id", displayLabel: "Recipient" }; + }), + isBound: vi.fn(), + bind: vi.fn(), + createOrder: vi.fn(async () => { + calls.push("order"); + return { id: 1 }; + }), + reportTxHash: vi.fn(async () => { + calls.push("report"); + return { success: true as const, order: { id: 1, points: 100000 } }; + }), + }; + const payments = { + validate: vi.fn(), + roundtrip: vi.fn(async () => { + calls.push("pay"); + return { + serve: {}, + pay: { + settled: true, + payer: { address: payer }, + paymentResponse: { success: true, transaction: txHash, network: "eip155:56" }, + }, + }; + }), + }; + const service = new BaiService( + {} as BaiApi, + () => new Date(), + payments, + { isConfirmed: () => true } as unknown as BaiBindingStore, + api, + { facilitatorUrl: "https://facilitator.example", payTo }, + ); + const run = (to?: string, amount = "10", token = "USDT") => + service.recharge({ resolveAddress: () => payer } as never, network as never, { + amount, + token, + apiKey: "secret", + to, + }); + return { api, payments, calls, run }; +} +it("resolves another recipient, then reuses exactly that target for preorder and report", async () => { + const { api, calls, run } = fixture(); + const result = await run("recipient@example.com"); + expect(calls).toEqual(["resolve", "order", "pay", "report"]); + const target = { + input: { type: "personal", identifier: "recipient@example.com" }, + confirmedTarget: { type: "personal", targetId: "recipient-id" }, + }; + expect(api.createOrder).toHaveBeenCalledWith( + expect.objectContaining({ + walletAddress: payer, + chain: "bnb", + tokenName: "USDT", + amount: 10, + rechargeTarget: target, + }), + ); + expect(api.reportTxHash).toHaveBeenCalledWith({ + chain: "bnb", + amount: 10, + txHash, + rechargeTarget: target, + }); + expect(result).toMatchObject({ creditStatus: "credited", txHash }); + expect(api.isBound).not.toHaveBeenCalled(); +}); +it.each([ + ["TRX", "14.999999", "15"], + ["USDT", "0.999999", "1"], + ["usdc", "0.999999", "1"], + ["ETH", "0.000099999999999999", "0.0001"], + ["SOL", "0.009999999", "0.01"], +])( + "rejects %s below its minimum before resolving or creating an order", + async (token, amount, minimum) => { + const { run, calls } = fixture(); + await expect(run("recipient", amount, token)).rejects.toThrow(`minimum recharge is ${minimum}`); + expect(calls).toEqual([]); + }, +); +it.each([ + ["TRX", "15"], + ["USDT", "1"], + ["USDC", "1"], + ["ETH", "0.0001"], + ["SOL", "0.01"], + ["OTHER", "0.000001"], +])( + "accepts the %s boundary or an unlisted token without an extra minimum", + async (token, amount) => { + const { run, payments } = fixture(); + await run(undefined, amount, token); + expect(payments.roundtrip).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + facilitatorUrl: "https://facilitator.example", + host: "127.0.0.1", + port: 0, + scheme: "exact", + payTo: "0x060f7fd9c9622bdcf9f2887c8171d6e6b4b4ba17", + amount, + }), + ); + }, +); +it.each([undefined, payer, payer.toUpperCase().replace("0X", "0x")])( + "skips other-recipient resolution for self %s", + async (to) => { + const { api, calls, run } = fixture(); + await run(to); + expect(calls).toEqual(["order", "pay", "report"]); + expect(api.resolveTarget).not.toHaveBeenCalled(); + expect(api.createOrder).toHaveBeenCalledWith( + expect.not.objectContaining({ rechargeTarget: expect.anything() }), + ); + }, +); +it("does not create an order or pay when target validation fails", async () => { + const { api, payments, run } = fixture(); + api.resolveTarget.mockRejectedValue(new Error("invalid target")); + await expect(run("recipient")).rejects.toThrow("invalid target"); + expect(api.createOrder).not.toHaveBeenCalled(); + expect(payments.roundtrip).not.toHaveBeenCalled(); +}); +it("retains paid hash and target when reporting fails", async () => { + const { api, payments, run } = fixture(); + api.reportTxHash.mockRejectedValue(new Error("secret")); + await expect(run("recipient")).resolves.toMatchObject({ + creditStatus: "unconfirmed", + txHash, + retryPayment: false, + }); + expect(payments.roundtrip).toHaveBeenCalledTimes(1); +}); + +it("rejects missing trusted destinations before resolving, ordering or paying", async () => { + const { run, calls } = fixture({}); + await expect(run("recipient@example.com")).rejects.toMatchObject({ + code: "unsupported_network_capability", + }); + expect(calls).toEqual([]); +}); + +it.each([ + ["USDC", "1"], + ["USDT", "1.0000000000000000001"], +])( + "validates BSC token and precision before target resolution and preorder: %s %s", + async (token, amount) => { + const { payments, api, run } = fixture(); + const server = new X402HttpServer(); + payments.validate.mockImplementation((...args: unknown[]) => + server.validate(args[0] as never, args[1] as never), + ); + await expect(run("recipient", amount, token)).rejects.toMatchObject({ code: "invalid_value" }); + expect(api.resolveTarget).not.toHaveBeenCalled(); + expect(api.createOrder).not.toHaveBeenCalled(); + expect(payments.roundtrip).not.toHaveBeenCalled(); + }, +); diff --git a/ts/src/application/use-cases/bai-recharge-report.test.ts b/ts/src/application/use-cases/bai-recharge-report.test.ts new file mode 100644 index 000000000..46c32244a --- /dev/null +++ b/ts/src/application/use-cases/bai-recharge-report.test.ts @@ -0,0 +1,68 @@ +import { expect, it, vi } from "vitest"; +import { BaiService } from "./bai-service.js"; +import { TransportError } from "../../domain/errors/index.js"; +function fixture() { + const api = { + reportTxHash: vi.fn(async () => ({ success: true as const, order: { id: 1 } })), + createOrder: vi.fn(), + resolveTarget: vi.fn(), + }; + const payments = { validate: vi.fn(), roundtrip: vi.fn() }; + const service = new BaiService({} as never, () => new Date(), payments, undefined, api as never); + return { api, payments, service }; +} +const request = { + chain: "base" as const, + txHash: "0x" + "a".repeat(64), + amount: "1", + to: "recipient@example.com", + targetId: "original-id", +}; +it("reports the original recipient without a wallet, preorder, target resolution or payment", async () => { + const { api, payments, service } = fixture(); + await expect(service.rechargeReport(request)).resolves.toMatchObject({ + creditStatus: "credited", + retryPayment: false, + }); + expect(api.reportTxHash).toHaveBeenCalledExactlyOnceWith({ + chain: "base", + txHash: request.txHash, + amount: 1, + rechargeTarget: { + input: { type: "personal", identifier: request.to }, + confirmedTarget: { type: "personal", targetId: request.targetId }, + }, + }); + expect(api.createOrder).not.toHaveBeenCalled(); + expect(api.resolveTarget).not.toHaveBeenCalled(); + expect(payments.roundtrip).not.toHaveBeenCalled(); + expect(payments.validate).not.toHaveBeenCalled(); +}); +it.each([ + { txHash: "bad" }, + { chain: "tron" }, + { amount: "0" }, + { amount: "9007199254740992" }, + { targetId: undefined }, + { to: undefined }, +])("rejects invalid recovery before an API mutation: %j", async (override) => { + const { service, api } = fixture(); + await expect(service.rechargeReport({ ...request, ...override } as never)).rejects.toMatchObject({ + code: "invalid_value", + }); + expect(api.reportTxHash).not.toHaveBeenCalled(); +}); +it("retains the original recovery data and error code when reporting fails", async () => { + const { service, api } = fixture(); + api.reportTxHash.mockRejectedValue(new TransportError("bai_auth_failed", "rejected")); + await expect(service.rechargeReport(request)).resolves.toMatchObject({ + txHash: request.txHash, + chain: "base", + amount: 1, + code: "bai_auth_failed", + creditStatus: "unconfirmed", + retryPayment: false, + rechargeTarget: { confirmedTarget: { targetId: request.targetId } }, + }); + expect(api.reportTxHash).toHaveBeenCalledOnce(); +}); diff --git a/ts/src/application/use-cases/bai-service.test.ts b/ts/src/application/use-cases/bai-service.test.ts new file mode 100644 index 000000000..b771905b3 --- /dev/null +++ b/ts/src/application/use-cases/bai-service.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { BaiService } from "./bai-service.js"; +import type { BaiApi } from "../ports/bai-api.js"; +import type { BaiBindingStore } from "../ports/bai-binding-store.js"; + +function api(): BaiApi { + return { + status: vi.fn(async () => ({ + pointsBalance: "1200000", + monthlySpent: "300000", + monthlyChart: [{ month: "2026-09", points: "300000" }], + })), + usageList: vi.fn(async () => ({ page: 1, pageSize: 20, total: 1, items: [] })), + rechargeList: vi.fn(async () => ({ page: 1, pageSize: 20, total: 1, items: [] })), + }; +} + +describe("BaiService", () => { + it("maps the account summary to stable credit names", async () => { + await expect(new BaiService(api()).status()).resolves.toEqual({ + credits: "1200000", + thisMonth: { month: "2026-09", credits: "300000" }, + trend: [{ month: "2026-09", credits: "300000" }], + }); + }); + + it("reads usage summary once without fetching records", async () => { + const remote = api(); + await expect(new BaiService(remote).usage()).resolves.toEqual({ + credits: "1200000", + thisMonth: { month: "2026-09", credits: "300000" }, + trend: [{ month: "2026-09", credits: "300000" }], + }); + expect(remote.status).toHaveBeenCalledOnce(); + expect(remote.usageList).not.toHaveBeenCalled(); + }); +}); + +it("stops an unconfirmed local recharge before requesting or signing payment", async () => { + const pay = vi.fn(); + const isConfirmed = vi.fn(() => false); + const service = new BaiService(api(), () => new Date(), { validate: vi.fn(), roundtrip: pay }, { + isConfirmed, + } as unknown as BaiBindingStore); + await expect( + service.recharge( + { resolveAddress: () => "payer" } as never, + { id: "eip155:56", family: "evm", chainId: "56" } as never, + { amount: "10", token: "USDT", apiKey: "secret" }, + ), + ).rejects.toMatchObject({ code: "invalid_value" }); + expect(isConfirmed).toHaveBeenCalledWith("secret", "bnb", "payer"); + expect(pay).not.toHaveBeenCalled(); +}); +it("does not proceed when local confirmation cannot be read", async () => { + const pay = vi.fn(); + const isConfirmed = vi.fn(() => { + throw new Error("API unavailable"); + }); + const service = new BaiService(api(), () => new Date(), { validate: vi.fn(), roundtrip: pay }, { + isConfirmed, + } as unknown as BaiBindingStore); + await expect( + service.recharge( + { resolveAddress: () => "payer" } as never, + { id: "tron:728126428", family: "tron", chainId: "728126428" } as never, + { amount: "10", token: "USDT", apiKey: "secret" }, + ), + ).rejects.toThrow("API unavailable"); + expect(pay).not.toHaveBeenCalled(); +}); + +it("passes the usage cursor through and exposes continuation metadata", async () => { + const remote = api(); + vi.mocked(remote.usageList).mockResolvedValue({ + items: [], + page: 2, + pageSize: 20, + hasMore: true, + nextCursor: "next", + }); + await expect( + new BaiService(remote).usageList({ limit: 20, offset: 20, sort: "desc", cursor: "previous" }), + ).resolves.toMatchObject({ pagination: { hasMore: true, nextCursor: "next" } }); + expect(remote.usageList).toHaveBeenCalledWith({ + page: 2, + pageSize: 20, + sortBy: "created_at", + sortOrder: "desc", + cursor: "previous", + }); +}); diff --git a/ts/src/application/use-cases/bai-service.ts b/ts/src/application/use-cases/bai-service.ts new file mode 100644 index 000000000..16d5f57ec --- /dev/null +++ b/ts/src/application/use-cases/bai-service.ts @@ -0,0 +1,254 @@ +import type { + BaiRechargeApi, + BaiRechargeConfig, + BaiRechargeTarget, +} from "../ports/bai-recharge.js"; +import { BaiRechargeFlow, reportBaiTransaction } from "./bai-recharge-flow.js"; +import { requireBaiChain } from "./bai-credential-setup.js"; +import type { BaiBindingStore } from "../ports/bai-binding-store.js"; +import type { BaiApi, BaiPageInput } from "../ports/bai-api.js"; +import { UsageError } from "../../domain/errors/index.js"; +import { assertBaiRechargeMinimum } from "../../domain/bai/recharge-policy.js"; +import type { X402RoundtripPort, X402ServeInput } from "../ports/x402-server.js"; +import { baiPaymentResult } from "../services/bai-payment-result.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; +import type { NetworkDescriptor } from "../../domain/types/index.js"; + +export interface BaiListCommandInput { + cursor?: string; + limit: number; + offset: number; + sort: "asc" | "desc"; +} + +export class BaiService { + constructor( + private readonly api: BaiApi, + private readonly now: () => Date = () => new Date(), + private readonly payments?: X402RoundtripPort, + private readonly bindings?: BaiBindingStore, + private readonly rechargeApi?: BaiRechargeApi, + private readonly rechargeConfig?: BaiRechargeConfig, + ) {} + + async recharge( + scope: TransactionScope, + network: NetworkDescriptor, + input: { amount: string; token: string; to?: string; apiKey?: string }, + ) { + if (!input.apiKey) { + throw new UsageError( + "bai_credentials_missing", + "configure baiApiKey before using B.AI recharge", + ); + } + if (!this.bindings) + throw new UsageError("invalid_option", "B.AI recharge binding verification is unavailable"); + const chain = requireBaiChain(network); + const payer = scope.resolveAddress(network.family); + if (!this.bindings.isConfirmed(input.apiKey, chain, payer)) { + throw new UsageError( + "invalid_value", + "Confirm this API key and payer wallet first by configuring baiApiKey with --api-key-stdin for the selected account/network. No payment was sent", + ); + } + if (!this.payments || !this.rechargeApi || !this.rechargeConfig) { + throw new UsageError("invalid_option", "B.AI recharge is not available in this runtime"); + } + const expectedPayTo = this.rechargeConfig.payTo[chain]; + if (!expectedPayTo?.trim()) { + throw new UsageError( + "unsupported_network_capability", + "No trusted B.AI recharge destination for this network", + ); + } + const amount = baiNumericAmount(input.amount); + assertBaiRechargeMinimum(input.token, input.amount); + const paymentInput: X402ServeInput = { + payTo: expectedPayTo, + amount: input.amount, + token: input.token, + scheme: "exact", + host: "127.0.0.1", + port: 0, + facilitatorUrl: this.rechargeConfig.facilitatorUrl, + }; + this.payments.validate(network, paymentInput); + const identifier = input.to?.trim(); + const self = + !identifier || + (network.family === "evm" + ? identifier.toLowerCase() === payer.toLowerCase() + : identifier === payer); + let rechargeTarget: BaiRechargeTarget | undefined; + if (!self) { + const resolved = await this.rechargeApi.resolveTarget(identifier!); + rechargeTarget = { + input: { type: "personal", identifier: identifier! }, + confirmedTarget: { type: "personal", targetId: resolved.targetId }, + }; + } + const flow = new BaiRechargeFlow(this.rechargeApi, { + pay: async () => { + const result = await this.payments!.roundtrip(scope, network, paymentInput); + return { ...baiPaymentResult(result.pay, network.id), chain }; + }, + }); + const result = await flow.execute({ + channel: "crypto", + chain, + tokenName: input.token, + amount, + walletAddress: payer, + deviceType: "web", + ...(rechargeTarget ? { rechargeTarget } : {}), + }); + return { ...result, network: network.id, token: input.token, amount: input.amount, payer }; + } + + async rechargeReport(input: { + chain: "tron" | "bnb" | "base"; + txHash: string; + amount?: string; + to?: string; + targetId?: string; + }) { + if (!this.rechargeApi) + throw new UsageError("invalid_option", "B.AI recharge reporting is unavailable"); + if (!["tron", "bnb", "base"].includes(input.chain)) + throw new UsageError( + "invalid_value", + "Recharge report requires the original tron, bnb or base chain", + ); + const hashPattern = input.chain === "tron" ? /^[0-9a-fA-F]{64}$/ : /^0x[0-9a-fA-F]{64}$/; + if (!hashPattern.test(input.txHash)) + throw new UsageError("invalid_value", "Invalid transaction hash for the recharge chain"); + const to = input.to?.trim(); + const targetId = input.targetId?.trim(); + if ( + (input.to !== undefined || input.targetId !== undefined) && + (!to || !targetId || to.length > 320 || targetId.length > 320) + ) { + throw new UsageError( + "invalid_value", + "Recipient recovery requires both the original --to and --target-id", + ); + } + const amount = input.amount === undefined ? undefined : baiNumericAmount(input.amount); + return reportBaiTransaction(this.rechargeApi, { + chain: input.chain, + txHash: input.txHash, + ...(amount === undefined ? {} : { amount }), + ...(to && targetId + ? { + rechargeTarget: { + input: { type: "personal", identifier: to }, + confirmedTarget: { type: "personal", targetId }, + }, + } + : {}), + }); + } + + async status() { + const summary = await this.api.status(); + const current = summary.monthlyChart.at(-1); + return { + credits: summary.pointsBalance, + thisMonth: { + month: current?.month ?? utcMonth(this.now()), + credits: summary.monthlySpent, + }, + trend: summary.monthlyChart.map((item) => ({ month: item.month, credits: item.points })), + }; + } + + async usage() { + return this.status(); + } + + async usageList(input: BaiListCommandInput) { + const result = await this.api.usageList({ + ...pageInput(input), + ...(input.cursor ? { cursor: input.cursor } : {}), + }); + return { + records: result.items.map((row) => ({ + id: optionalString(row.id), + createdAt: optionalString(row.created_at ?? row.createdAt), + model: optionalString(row.model), + inputTokens: optionalScalar(row.input_tokens ?? row.inputTokens), + outputTokens: optionalScalar(row.output_tokens ?? row.outputTokens), + totalTokens: optionalScalar(row.total_tokens ?? row.totalTokens), + credits: optionalScalar(row.cost_points ?? row.credits), + latencyMs: secondsToMilliseconds(row.duration_sec ?? row.durationSec), + source: optionalString(row.source_type ?? row.source), + })), + pagination: { + offset: input.offset, + limit: input.limit, + total: result.total, + ...(result.hasMore === undefined ? {} : { hasMore: result.hasMore }), + ...(result.nextCursor === undefined ? {} : { nextCursor: result.nextCursor }), + }, + }; + } + + async rechargeList(input: BaiListCommandInput) { + const result = await this.api.rechargeList(pageInput(input)); + return { + orders: result.items, + pagination: { offset: input.offset, limit: input.limit, total: result.total }, + }; + } +} + +function pageInput(input: BaiListCommandInput): BaiPageInput { + if (input.offset % input.limit !== 0) { + throw new UsageError( + "invalid_value", + `--offset must be a multiple of --limit for the B.AI page API`, + ); + } + return { + page: input.offset / input.limit + 1, + pageSize: input.limit, + sortBy: "created_at", + sortOrder: input.sort, + }; +} + +function utcMonth(date: Date): string { + return date.toISOString().slice(0, 7); +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function optionalScalar(value: unknown): string | undefined { + return typeof value === "string" || typeof value === "number" ? String(value) : undefined; +} + +function secondsToMilliseconds(value: unknown): number | undefined { + const seconds = typeof value === "number" ? value : Number(value); + return Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1000) : undefined; +} + +function baiNumericAmount(value: string): number { + const normalizedAmount = value.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ""); + const amount = Number(normalizedAmount); + if ( + !/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value) || + !Number.isFinite(amount) || + amount > Number.MAX_SAFE_INTEGER || + amount <= 0 || + String(amount) !== normalizedAmount + ) { + throw new UsageError( + "invalid_value", + "Recharge amount must be positive and exactly representable by the B.AI numeric API", + ); + } + return amount; +} diff --git a/ts/src/application/use-cases/config-service.test.ts b/ts/src/application/use-cases/config-service.test.ts index 50a2f84cd..a1f4c086a 100644 --- a/ts/src/application/use-cases/config-service.test.ts +++ b/ts/src/application/use-cases/config-service.test.ts @@ -118,6 +118,22 @@ describe("ConfigService GasFree credentials", () => { }); }); +describe("ConfigService B.AI configuration", () => { + it("masks the API key in write and read receipts", () => { + const { svc } = service(); + expect( + svc.execute({ key: "baiApiKey", value: "bai_test_secret" }, effective, networks), + ).toMatchObject({ key: "baiApiKey", value: "********", input: "********" }); + + const configured = { ...effective, baiApiKey: "bai_test_secret" }; + expect(svc.execute({}, configured, networks)).toMatchObject({ baiApiKey: "********" }); + expect(svc.execute({ key: "baiApiKey" }, configured, networks)).toEqual({ + key: "baiApiKey", + value: "********", + }); + }); +}); + const twoNetworks = { timeoutMs: 60_000, waitTimeoutMs: 60_000, diff --git a/ts/src/application/use-cases/config-service.ts b/ts/src/application/use-cases/config-service.ts index 4b29785b8..a953536cb 100644 --- a/ts/src/application/use-cases/config-service.ts +++ b/ts/src/application/use-cases/config-service.ts @@ -9,6 +9,7 @@ export const TRONLINK_CONFIG_KEYS = [ "tronlinkChannel", ] as const; export const GASFREE_CONFIG_KEYS = ["gasfreeApiKey", "gasfreeApiSecret"] as const; +export const BAI_CONFIG_KEYS = ["baiApiKey"] as const; export const CONFIG_KEYS = [ "defaultNetwork", "defaultOutput", @@ -18,6 +19,7 @@ export const CONFIG_KEYS = [ "aliases", ...TRONLINK_CONFIG_KEYS, ...GASFREE_CONFIG_KEYS, + ...BAI_CONFIG_KEYS, ] as const; export const WRITABLE_CONFIG_KEYS = [ "defaultNetwork", @@ -26,6 +28,7 @@ export const WRITABLE_CONFIG_KEYS = [ "waitTimeoutMs", ...TRONLINK_CONFIG_KEYS, ...GASFREE_CONFIG_KEYS, + ...BAI_CONFIG_KEYS, ] as const; export type ConfigKey = (typeof CONFIG_KEYS)[number]; export type WritableConfigKey = (typeof WRITABLE_CONFIG_KEYS)[number]; @@ -92,6 +95,7 @@ export class ConfigService { tronlinkChannel: effective.tronlinkChannel, gasfreeApiKey: effective.gasfreeApiKey, gasfreeApiSecret: maskSecret(effective.gasfreeApiSecret), + baiApiKey: maskSecret(effective.baiApiKey), }; if (input.key === undefined) return view; @@ -112,7 +116,7 @@ export class ConfigService { const key = input.key as WritableConfigKey; const value = this.normalize(key, input.value, networks); - if (key === "tronlinkSecretKey" || key === "gasfreeApiSecret") { + if (key === "tronlinkSecretKey" || key === "gasfreeApiSecret" || key === "baiApiKey") { return this.documents.update((current) => ({ document: { ...current, [key]: value }, result: { key, value: maskSecret(String(value)), input: "********" }, @@ -207,6 +211,15 @@ export class ConfigService { } return raw; } + if (key === "baiApiKey") { + if (raw.length === 0 || raw.length > 256 || /[\u0000-\u001f\u007f]/.test(raw)) { + throw new UsageError( + "invalid_value", + `${key} must be 1 to 256 characters without control characters`, + ); + } + return raw; + } return networks.resolve(raw).id; } } diff --git a/ts/src/application/use-cases/evm/contract-service.test.ts b/ts/src/application/use-cases/evm/contract-service.test.ts index 3d8a93373..1be2ff812 100644 --- a/ts/src/application/use-cases/evm/contract-service.test.ts +++ b/ts/src/application/use-cases/evm/contract-service.test.ts @@ -244,7 +244,7 @@ describe("EvmContractService.send — approve", () => { return { service, gateway }; } - const send = (service: EvmContractService, rawAllowance: string) => + const send = (service: EvmContractService, rawAllowance: string, approvalKind?: "erc721") => service.send(scope(), SEPOLIA, { contract: CONTRACT, method: "approve(address,uint256)", @@ -253,6 +253,7 @@ describe("EvmContractService.send — approve", () => { { type: "uint256", value: rawAllowance }, ], dryRun: true, + approvalKind, } as never) as Promise>; it("reports the spender and the allowance in the token's own units", async () => { @@ -283,6 +284,15 @@ describe("EvmContractService.send — approve", () => { await expect(send(service, "1000000")).resolves.toMatchObject({ allowance: "1000000" }); }); + it("reports ERC-721 approval semantics without querying ERC-20 metadata", async () => { + const { service, gateway } = approveHarness(6); + + await expect(send(service, "42", "erc721")).resolves.toMatchObject({ + identity: { operator: SPENDER, agentId: "42" }, + }); + expect(gateway.getErc20Metadata).not.toHaveBeenCalled(); + }); + it("adds nothing for any other method", async () => { const { service } = approveHarness(6); const out = (await service.send(scope(), SEPOLIA, { diff --git a/ts/src/application/use-cases/evm/contract-service.ts b/ts/src/application/use-cases/evm/contract-service.ts index e299066e0..b74c1811b 100644 --- a/ts/src/application/use-cases/evm/contract-service.ts +++ b/ts/src/application/use-cases/evm/contract-service.ts @@ -1,3 +1,5 @@ +import type { EvmContractWriteInput } from "../../contracts/transaction-input.js"; +export type { EvmContractWriteInput } from "../../contracts/transaction-input.js"; import type { NetworkDescriptor } from "../../../domain/types/index.js"; import { FAMILIES } from "../../../domain/family/index.js"; import { fromBaseUnits, toBaseUnits } from "../../../domain/amounts/index.js"; @@ -5,35 +7,14 @@ import { evmConfirmation } from "../../services/evm-confirmation.js"; import { approveRows } from "../../services/approve-receipt.js"; import { buildEvmUnsignedTx } from "./tx-build.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; -import type { - ChainGatewayProvider, - DeployConstructorArgs, - EvmGateway, -} from "../../ports/chain/gateway-provider.js"; +import type { ChainGatewayProvider, EvmGateway } from "../../ports/chain/gateway-provider.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; import { outcomeData, transactionMode, transactionRequiresSigner, - type TransactionModeInput, } from "../../services/transaction-mode.js"; -export interface EvmContractWriteInput extends TransactionModeInput { - contract?: string; - method?: string; - /** `{type,value}` entries for a call; raw positional values for a deployment. */ - params?: unknown[]; - /** native coin sent along with the call, in whole coins (as `tx send --amount` is). */ - callValue?: string; - bytecode?: string; - /** how the constructor's arguments are typed and what they are; see DeployConstructorArgs. */ - constructorArgs?: DeployConstructorArgs; - gasLimit?: string; - maxFee?: string; - priorityFee?: string; - nonce?: number; -} - /** * Contract reads and writes. * @@ -108,6 +89,7 @@ export class EvmContractService { return approveRows({ method: input.method, params: (input.params ?? []) as Array<{ value?: unknown }>, + approvalKind: input.approvalKind, metadata: () => gateway.getErc20Metadata(input.contract!), fromBaseUnits, }); diff --git a/ts/src/application/use-cases/tron/contract-service.approve.test.ts b/ts/src/application/use-cases/tron/contract-service.approve.test.ts new file mode 100644 index 000000000..a424df49f --- /dev/null +++ b/ts/src/application/use-cases/tron/contract-service.approve.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import type { TransactionScope } from "../../contracts/execution-scope.js"; +import type { ChainGatewayProvider } from "../../ports/chain/gateway-provider.js"; +import type { TronGateway } from "../../ports/chain/tron-gateway.js"; +import type { TxPipeline, TxPipelineParams } from "../../services/pipeline/index.js"; +import { TronContractService } from "./contract-service.js"; + +const NETWORK = { + id: "tron:3448148188", + family: "tron", + nativeSymbol: "TRX", + chainId: "nile", + capabilities: [], +} as NetworkDescriptor; +const OWNER = "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7"; +const OPERATOR = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; +const CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const scope = { + activeAccount: "wlt_test.0", + resolveAddress: () => OWNER, + timeoutMs: 60_000, + wait: false, + waitTimeoutMs: 60_000, + emit: vi.fn(), + warn: vi.fn(), +} as TransactionScope; + +describe("TronContractService.send — approve", () => { + it("reports ERC-721 approval semantics without querying TRC-20 metadata", async () => { + const getTokenInfo = vi.fn(async () => ({ decimals: 6, symbol: "USDT" })); + const gateway = { + getTokenInfo, + triggerSmartContract: vi.fn(async () => ({ txID: "plan" })), + estimateResources: vi.fn(async () => ({ feeModel: "tron-resource", energy: 1 })), + } as unknown as TronGateway; + const pipeline = { + run: vi.fn(async (params: TxPipelineParams) => ({ + stage: "plan" as const, + tx: await params.build(OWNER), + fee: {}, + })), + } as unknown as TxPipeline; + const service = new TronContractService( + { get: () => gateway } as unknown as ChainGatewayProvider, + pipeline, + ); + + await expect( + service.send(scope, NETWORK, { + contract: CONTRACT, + method: "approve(address,uint256)", + parameters: [ + { type: "address", value: OPERATOR }, + { type: "uint256", value: "42" }, + ], + callValueSun: "0", + feeLimit: "100000000", + dryRun: true, + approvalKind: "erc721", + }), + ).resolves.toMatchObject({ identity: { operator: OPERATOR, agentId: "42" } }); + expect(getTokenInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/ts/src/application/use-cases/tron/contract-service.ts b/ts/src/application/use-cases/tron/contract-service.ts index 62d1c5890..660d914c4 100644 --- a/ts/src/application/use-cases/tron/contract-service.ts +++ b/ts/src/application/use-cases/tron/contract-service.ts @@ -19,7 +19,7 @@ import { import { tronConfirmation } from "../../services/tron-confirmation.js"; import { tronHexToBase58 } from "../../../domain/address/index.js"; import { fromBaseUnits } from "../../../domain/amounts/index.js"; -import { approveRows } from "../../services/approve-receipt.js"; +import { approveRows, type ApprovalKind } from "../../services/approve-receipt.js"; import { tronTransactionHooks } from "./multisig-authorization.js"; export class TronContractService { @@ -49,6 +49,8 @@ export class TronContractService { input: GovernanceTransactionInput & { contract: string; method: string; + /** disambiguates standards that share a write signature. */ + approvalKind?: ApprovalKind; parameters: TronContractParameter[]; callValueSun: string; feeLimit: string; @@ -62,6 +64,7 @@ export class TronContractService { const approval = await approveRows({ method: input.method, params: input.parameters, + approvalKind: input.approvalKind, metadata: () => gateway.getTokenInfo(input.contract).then((info) => ({ decimals: info.decimals ?? info.precision, diff --git a/ts/src/application/use-cases/tron/governance-transaction.ts b/ts/src/application/use-cases/tron/governance-transaction.ts index f982265ae..115b107b4 100644 --- a/ts/src/application/use-cases/tron/governance-transaction.ts +++ b/ts/src/application/use-cases/tron/governance-transaction.ts @@ -1,12 +1,9 @@ +import type { GovernanceTransactionInput } from "../../contracts/transaction-input.js"; +export type { GovernanceTransactionInput } from "../../contracts/transaction-input.js"; import { UsageError } from "../../../domain/errors/index.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { TxPipeline } from "../../services/pipeline/index.js"; -import { transactionMode, type TransactionModeInput } from "../../services/transaction-mode.js"; - -export interface GovernanceTransactionInput extends TransactionModeInput { - expiration?: number; - permissionId?: number; -} +import { transactionMode } from "../../services/transaction-mode.js"; export function governanceTransactionMode( pipeline: TxPipeline, diff --git a/ts/src/application/use-cases/x402-service.test.ts b/ts/src/application/use-cases/x402-service.test.ts new file mode 100644 index 000000000..dd49f8a93 --- /dev/null +++ b/ts/src/application/use-cases/x402-service.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { X402Service } from "./x402-service.js"; +import type { X402PaymentPort } from "../ports/x402-payment.js"; +import type { ProviderCatalogPort } from "../ports/provider-catalog.js"; + +describe("X402Service", () => { + it("delegates payments and catalog operations through ports", async () => { + const payment = { pay: vi.fn(async () => ({ delivered: true })) } as X402PaymentPort; + const catalog = { + list: vi.fn(async () => ({ results: [], pagination: {} })), + show: vi.fn(async () => ({ fqn: "a/b" })), + endpoints: vi.fn(async () => ({ endpoints: [] })), + update: vi.fn(async () => ({ updated: true })), + } as unknown as ProviderCatalogPort; + const service = new X402Service(payment, catalog); + const scope = {} as never; + const network = { id: "eip155:56" } as never; + + await expect( + service.pay(scope, network, { url: "https://example.test", method: "GET", headers: [] }), + ).resolves.toEqual({ delivered: true }); + await service.providerList({ limit: 20, offset: 0 }); + await service.providerShow("a/b"); + await service.providerEndpoints("a/b"); + await service.providerUpdate(); + expect(catalog.list).toHaveBeenCalledWith({ limit: 20, offset: 0 }); + }); +}); + +it.each([false, true])( + "closes the roundtrip server after payment (failure=%s)", + async (failure) => { + const close = vi.fn(async () => {}); + const pay = vi.fn(async () => { + if (failure) throw new Error("settlement failed"); + return { settled: true }; + }); + const service = new X402Service({ pay }, {} as ProviderCatalogPort, { + validate: vi.fn(), + start: async () => ({ details: { payUrl: "http://127.0.0.1:45678/pay" }, close }), + }); + const result = service.roundtrip({} as never, {} as never, { + payTo: "trusted", + amount: "10", + token: "USDT", + scheme: "exact", + host: "127.0.0.1", + port: 0, + facilitatorUrl: "https://facilitator.example", + }); + if (failure) await expect(result).rejects.toThrow("settlement failed"); + else await expect(result).resolves.toMatchObject({ pay: { settled: true } }); + expect(pay).toHaveBeenCalledWith( + {}, + {}, + expect.objectContaining({ + url: "http://127.0.0.1:45678/pay", + token: "USDT", + scheme: "exact", + expectedPayTo: "trusted", + exactAmount: "10", + maxAmount: "10", + }), + ); + expect(close).toHaveBeenCalledOnce(); + }, +); diff --git a/ts/src/application/use-cases/x402-service.ts b/ts/src/application/use-cases/x402-service.ts new file mode 100644 index 000000000..c70662437 --- /dev/null +++ b/ts/src/application/use-cases/x402-service.ts @@ -0,0 +1,64 @@ +import type { NetworkDescriptor } from "../../domain/types/index.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; +import type { ProviderCatalogPort, ProviderListInput } from "../ports/provider-catalog.js"; +import type { X402PayInput, X402PaymentPort } from "../ports/x402-payment.js"; +import type { X402ServeInput, X402ServerPort } from "../ports/x402-server.js"; + +export class X402Service { + constructor( + private readonly payments: X402PaymentPort, + private readonly catalog: ProviderCatalogPort, + private readonly server?: X402ServerPort, + ) {} + + pay(scope: TransactionScope, network: NetworkDescriptor, input: X402PayInput) { + return this.payments.pay(scope, network, input); + } + + providerList(input: ProviderListInput) { + return this.catalog.list(input); + } + + providerShow(fqn: string) { + return this.catalog.show(fqn); + } + + providerEndpoints(fqn: string) { + return this.catalog.endpoints(fqn); + } + + providerUpdate() { + return this.catalog.update(); + } + + validate(network: NetworkDescriptor, input: X402ServeInput): void { + if (!this.server) throw new Error("x402 server is not available in this runtime"); + this.server.validate(network, input); + } + + async serve(network: NetworkDescriptor, input: X402ServeInput) { + if (!this.server) throw new Error("x402 server is not available in this runtime"); + const handle = await this.server.start(network, input); + return handle.details; + } + + async roundtrip(scope: TransactionScope, network: NetworkDescriptor, input: X402ServeInput) { + if (!this.server) throw new Error("x402 server is not available in this runtime"); + const handle = await this.server.start(network, input); + try { + const pay = await this.payments.pay(scope, network, { + url: String(handle.details.payUrl), + method: "GET", + headers: [], + token: input.token, + scheme: input.scheme, + expectedPayTo: input.payTo, + exactAmount: input.amount, + maxAmount: input.amount, + }); + return { serve: handle.details, pay }; + } finally { + await handle.close(); + } + } +} diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index f30efea1b..f8588dedb 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -1,3 +1,9 @@ +import { DEFAULT_X402_FACILITATOR_URL } from "../adapters/outbound/config/x402-builtins.js"; +import { setLogger, noopLogger } from "@bankofai/x402-core"; +import { FileBaiBindingStore } from "../adapters/outbound/bai/binding-store.js"; +import { BaiCredentialSetup, baiChain } from "../application/use-cases/bai-credential-setup.js"; +import { BaiRechargeClient } from "../adapters/outbound/bai/recharge-client.js"; +import { BAI_RECHARGE_ADDRESSES } from "../adapters/outbound/config/bai-builtins.js"; import { isTronNetwork } from "../domain/types/network.js"; import type { OutputMode } from "../domain/types/index.js"; import type { Globals, SessionRef } from "../adapters/inbound/cli/contracts/index.js"; @@ -43,6 +49,19 @@ import { SecureKeypairWriter } from "../adapters/outbound/persistence/keypair-wr import { registerEncodingCommands } from "../adapters/inbound/cli/commands/encoding.js"; import { registerAddressCommands } from "../adapters/inbound/cli/commands/address.js"; import { TerminalQrEncoder } from "../adapters/outbound/qr/index.js"; +import { BaiClient } from "../adapters/outbound/bai/client.js"; +import { BaiService } from "../application/use-cases/bai-service.js"; +import { registerBaiCommands } from "../adapters/inbound/cli/commands/bai.js"; +import { EvmContractService } from "../application/use-cases/evm/contract-service.js"; +import { TronContractService } from "../application/use-cases/tron/contract-service.js"; +import { SdkAgentRegistry } from "../adapters/outbound/erc8004/sdk-registry.js"; +import { RegistrationLoader } from "../adapters/outbound/erc8004/registration-loader.js"; +import { AgentService } from "../application/use-cases/agent-service.js"; +import { X402PaymentClient } from "../adapters/outbound/x402/payment-client.js"; +import { X402ProviderCatalog } from "../adapters/outbound/x402/provider-catalog.js"; +import { X402Service } from "../application/use-cases/x402-service.js"; +import { registerX402Commands } from "../adapters/inbound/cli/commands/x402.js"; +import { X402HttpServer } from "../adapters/outbound/x402/server.js"; export interface BootstrapOptions { readonly globals: Globals; @@ -52,6 +71,8 @@ export interface BootstrapOptions { /** Fully wired process-scoped dependencies. No command side effect runs during construction. */ export function composeCliRuntime(options: BootstrapOptions) { + // SDK console logs must not corrupt the CLI result envelope or expose request URLs. + setLogger(noopLogger); const config = ConfigLoader.load(); // effective per-invocation RPC/device timeout: --timeout wins over the config default. const timeoutMs = options.globals.timeoutMs ?? config.timeoutMs; @@ -105,14 +126,50 @@ export function composeCliRuntime(options: BootstrapOptions) { ledger, qr: new TerminalQrEncoder(), }); - registerConfigCommands(registry, configService); + const baiBindings = new FileBaiBindingStore(root, store); + const baiSetup = new BaiCredentialSetup( + baiBindings, + (apiKey, input) => new BaiRechargeClient({ baiApiKey: apiKey }, timeoutMs).isBound(input), + networkRegistry, + keystore, + { network: options.globals.network, account: options.globals.account }, + ); + registerConfigCommands(registry, configService, baiSetup); registerNetworkCommands(registry); registerContactCommands(registry, new ContactService(contactBook)); registerEncodingCommands(registry, new EncodingService()); registerAddressCommands(registry, new AddressService(new SecureKeypairWriter(root))); + const x402Payments = new X402PaymentClient(signerResolver); + const x402Service = new X402Service( + x402Payments, + new X402ProviderCatalog(undefined, undefined, timeoutMs), + new X402HttpServer(undefined, timeoutMs), + ); + registerBaiCommands( + registry, + new BaiService( + new BaiClient(config, timeoutMs), + () => new Date(), + x402Service, + baiBindings, + new BaiRechargeClient(config, timeoutMs), + { facilitatorUrl: DEFAULT_X402_FACILITATOR_URL, payTo: BAI_RECHARGE_ADDRESSES }, + ), + ); + const agentContracts = { + evm: new EvmContractService(gatewayProvider, txPipeline), + tron: new TronContractService(gatewayProvider, txPipeline), + }; + const agents = new AgentService( + agentContracts, + new SdkAgentRegistry(agentContracts, gatewayProvider), + new RegistrationLoader(timeoutMs), + ); + registerX402Commands(registry, x402Service); const accountBalances = new AccountBalanceService(gatewayProvider); const tokenBookService = new TokenBookService(tokenBook); registerTronChainCommands(registry, { + agents, gateways: gatewayProvider, tokens: tokenBook, prices: priceProvider, @@ -127,6 +184,7 @@ export function composeCliRuntime(options: BootstrapOptions) { tokenBook: tokenBookService, }); registerEvmChainCommands(registry, { + agents, signers: signerResolver, gateways: gatewayProvider, balances: accountBalances, @@ -153,6 +211,22 @@ export function composeCliRuntime(options: BootstrapOptions) { key, summary: CAP_SUMMARIES[key] ?? key, })); + // Neutral commands are absent from capabilityKeysByFamily; the payment adapter + // supports both wallet network families and validates the offered scheme itself. + commandCapabilities.push({ + key: "x402.pay", + summary: "Inspect or pay an x402 endpoint using the selected wallet network", + }); + commandCapabilities.push({ + key: "x402.serve", + summary: "Serve a local x402 endpoint; the server validates network token support", + }); + if (baiChain(network)) { + commandCapabilities.push({ + key: "bai.recharge", + summary: "Recharge B.AI from a configured payer wallet", + }); + } const traits = network.capabilities.map((key) => ({ key, summary: TRAIT_SUMMARIES[key] ?? key, diff --git a/ts/src/bootstrap/families/evm.test.ts b/ts/src/bootstrap/families/evm.test.ts index 3906e5fb1..c8996f3df 100644 --- a/ts/src/bootstrap/families/evm.test.ts +++ b/ts/src/bootstrap/families/evm.test.ts @@ -1,3 +1,4 @@ +import type { AgentService } from "../../application/use-cases/agent-service.js"; /** * The EVM family's command registrations. * @@ -26,6 +27,7 @@ import type { RecipientResolver } from "../../application/services/recipient-res function registry(): CommandRegistry { const reg = new CommandRegistry(); registerEvmChainCommands(reg, { + agents: {} as AgentService, signers: {} as SignerResolver, gateways: {} as ChainGatewayProvider, balances: {} as AccountBalanceService, @@ -39,6 +41,12 @@ function registry(): CommandRegistry { } describe("registerEvmChainCommands", () => { + it("owns all 29 EVM bindings including identity commands", () => { + const commands = registry().all(); + expect( + commands.filter((command) => "families" in command && command.families.evm), + ).toHaveLength(29); + }); it("binds message sign and typed-data sign to the evm family", () => { const reg = registry(); expect(reg.resolveChain(["message", "sign"])?.families.evm).toBeDefined(); diff --git a/ts/src/bootstrap/families/evm.ts b/ts/src/bootstrap/families/evm.ts index 0bbf91f70..3735fc496 100644 --- a/ts/src/bootstrap/families/evm.ts +++ b/ts/src/bootstrap/families/evm.ts @@ -1,3 +1,22 @@ +import type { AgentService } from "../../application/use-cases/agent-service.js"; +import { + showSpec, + showEvmBinding, + registerSpec, + registerEvmBinding, + updateSpec, + updateEvmBinding, + transferSpec, + transferEvmBinding, + approveSpec, + approveEvmBinding, + operatorAddSpec, + operatorAddEvmBinding, + operatorRemoveSpec, + operatorRemoveEvmBinding, + operatorCheckSpec, + operatorCheckEvmBinding, +} from "../../adapters/inbound/cli/commands/erc8004.js"; /** * The EVM family plugin — the composition root's entry for `evm`. * @@ -5,7 +24,7 @@ * `registerEvmChainCommands` binds the commands EVM can serve. Paths with no binding here still * refuse cleanly at dispatch (`family_mismatch`). * - * Twenty-one commands are bound: the two signing commands (which need nothing from the chain — + * Twenty-nine commands are bound: eight ERC-8004 identity commands, the two signing commands (which need nothing from the chain — * the family difference lives entirely inside `evmSignStrategy` — and so reuse the very binding * objects the TRON family registers), plus the account, block, chain, tx, token and contract * commands that sit on the JSON-RPC gateway. @@ -92,6 +111,7 @@ export const evmFamily: FamilyPlugin<"evm"> = { }; export interface EvmChainCommandDependencies { + agents: AgentService; signers: SignerResolver; gateways: ChainGatewayProvider; /** the family-neutral native-balance service, shared with every other family. */ @@ -108,6 +128,15 @@ export function registerEvmChainCommands( reg: CommandRegistry, deps: EvmChainCommandDependencies, ): void { + reg.addChain(showSpec, "evm", showEvmBinding(deps.agents)); + reg.addChain(registerSpec, "evm", registerEvmBinding(deps.agents)); + reg.addChain(updateSpec, "evm", updateEvmBinding(deps.agents)); + reg.addChain(transferSpec, "evm", transferEvmBinding(deps.agents)); + reg.addChain(approveSpec, "evm", approveEvmBinding(deps.agents)); + reg.addChain(operatorAddSpec, "evm", operatorAddEvmBinding(deps.agents)); + reg.addChain(operatorRemoveSpec, "evm", operatorRemoveEvmBinding(deps.agents)); + reg.addChain(operatorCheckSpec, "evm", operatorCheckEvmBinding(deps.agents)); + reg.addChain(messageSignSpec, "evm", messageSignBinding(new MessageService(deps.signers))); reg.addChain(typedDataSignSpec, "evm", typedDataSignBinding(new TypedDataService(deps.signers))); diff --git a/ts/src/bootstrap/families/tron.ts b/ts/src/bootstrap/families/tron.ts index 72d3d658f..7964a15b7 100644 --- a/ts/src/bootstrap/families/tron.ts +++ b/ts/src/bootstrap/families/tron.ts @@ -1,3 +1,22 @@ +import type { AgentService } from "../../application/use-cases/agent-service.js"; +import { + showSpec, + showTronBinding, + registerSpec, + registerTronBinding, + updateSpec, + updateTronBinding, + transferSpec, + transferTronBinding, + approveSpec, + approveTronBinding, + operatorAddSpec, + operatorAddTronBinding, + operatorRemoveSpec, + operatorRemoveTronBinding, + operatorCheckSpec, + operatorCheckTronBinding, +} from "../../adapters/inbound/cli/commands/erc8004.js"; import { FAMILIES } from "../../domain/family/index.js"; import { tronSignStrategy } from "../../adapters/outbound/chain/tron/signing-strategy.js"; import { TronRpcClient } from "../../adapters/outbound/chain/tron/tron.js"; @@ -168,6 +187,7 @@ export const tronFamily: FamilyPlugin<"tron"> = { }; export interface TronChainCommandDependencies { + agents: AgentService; gateways: ChainGatewayProvider; tokens: TokenRepository; prices: PriceProvider; @@ -186,6 +206,15 @@ export function registerTronChainCommands( reg: CommandRegistry, deps: TronChainCommandDependencies, ): void { + reg.addChain(showSpec, "tron", showTronBinding(deps.agents)); + reg.addChain(registerSpec, "tron", registerTronBinding(deps.agents)); + reg.addChain(updateSpec, "tron", updateTronBinding(deps.agents)); + reg.addChain(transferSpec, "tron", transferTronBinding(deps.agents)); + reg.addChain(approveSpec, "tron", approveTronBinding(deps.agents)); + reg.addChain(operatorAddSpec, "tron", operatorAddTronBinding(deps.agents)); + reg.addChain(operatorRemoveSpec, "tron", operatorRemoveTronBinding(deps.agents)); + reg.addChain(operatorCheckSpec, "tron", operatorCheckTronBinding(deps.agents)); + const account = new TronAccountService( deps.gateways, new TronGridHistoryReader(deps.timeoutMs), diff --git a/ts/src/bootstrap/runner.test.ts b/ts/src/bootstrap/runner.test.ts index 25c05acf6..2a4b3f4d9 100644 --- a/ts/src/bootstrap/runner.test.ts +++ b/ts/src/bootstrap/runner.test.ts @@ -107,6 +107,12 @@ describe("parseGlobals", () => { expect(secretPaths.password).toBeUndefined(); }); + it("maps --api-key-stdin to the B.AI configuration secret channel", () => { + const { secretPaths, stdinFlags } = parseGlobals(["config", "baiApiKey", "--api-key-stdin"]); + expect(secretPaths.apiKey).toBe("-"); + expect(stdinFlags).toEqual(["--api-key-stdin"]); + }); + // BUG-V413-019: stdin (fd 0) can serve only one secret per run. `stdinFlags` names every // distinct `--*-stdin` flag seen so the caller (runner.ts) can reject a combination BEFORE // any secret is read, rather than discovering it later as secret_source_error. diff --git a/ts/src/bootstrap/runner.ts b/ts/src/bootstrap/runner.ts index 09748b34d..73eb375cf 100644 --- a/ts/src/bootstrap/runner.ts +++ b/ts/src/bootstrap/runner.ts @@ -1,3 +1,4 @@ +import packageMetadata from "../../package.json" with { type: "json" }; import { runMigrationGate, type PendingUpgrade } from "./migration-gate.js"; import { migrationSteps } from "./migration-steps.js"; import { MigrationRunner } from "../adapters/outbound/persistence/migration.js"; @@ -13,7 +14,7 @@ import { hasCommand, parseGlobals } from "./argv.js"; import { composeCliRuntime } from "./composition.js"; import { basename } from "node:path"; -export const VERSION = "4.13.0"; +export const VERSION = packageMetadata.version; /** * Report a failure raised while the composition root was still being built — an unreadable, diff --git a/ts/src/domain/bai/recharge-policy.ts b/ts/src/domain/bai/recharge-policy.ts new file mode 100644 index 000000000..b35afee7a --- /dev/null +++ b/ts/src/domain/bai/recharge-policy.ts @@ -0,0 +1,27 @@ +import { UsageError } from "../errors/index.js"; + +const minimums: Readonly> = Object.freeze({ + TRX: "15", + USDT: "1", + USDC: "1", + ETH: "0.0001", + SOL: "0.01", +}); + +/** Compare decimal quantities exactly; equality is accepted and unlisted tokens have no floor. */ +export function assertBaiRechargeMinimum(token: string, amount: string): void { + const minimum = minimums[token.toUpperCase()]; + if (!minimum) return; + const [whole, fraction = ""] = amount.split("."); + const [minimumWhole, minimumFraction = ""] = minimum.split("."); + const scale = Math.max(fraction.length, minimumFraction.length); + if ( + BigInt(whole + fraction.padEnd(scale, "0")) < + BigInt(minimumWhole + minimumFraction.padEnd(scale, "0")) + ) { + throw new UsageError( + "invalid_value", + `${token.toUpperCase()} minimum recharge is ${minimum}; no preorder or payment was sent`, + ); + } +} diff --git a/ts/src/domain/erc8004/erc8004.test.ts b/ts/src/domain/erc8004/erc8004.test.ts new file mode 100644 index 000000000..a662e88de --- /dev/null +++ b/ts/src/domain/erc8004/erc8004.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { parseAgentId, resolveAgentId } from "./index.js"; +import type { NetworkDescriptor } from "../types/index.js"; + +const net = (id: string): NetworkDescriptor => ({ id }) as NetworkDescriptor; + +describe("ERC-8004 deployment selection", () => { + it("parses arbitrarily large decimal agent ids without Number coercion", () => { + expect(parseAgentId("9007199254740993")).toBe(9007199254740993n); + expect(() => parseAgentId("1.5")).toThrowError(/unsigned decimal/); + }); + + it("accepts scoped ids only when they match the selected network", () => { + expect(resolveAgentId("97:123", net("eip155:97"))).toBe(123n); + expect(resolveAgentId("tron:3448148188:123", net("tron:3448148188"))).toBe(123n); + expect(() => resolveAgentId("eip155:56:123", net("eip155:97"))).toThrowError( + expect.objectContaining({ code: "invalid_value" }), + ); + }); +}); + +it("rejects IDs that cannot be encoded as uint256", () => { + expect(() => parseAgentId((1n << 256n).toString())).toThrowError( + expect.objectContaining({ code: "invalid_value" }), + ); + expect(parseAgentId(((1n << 256n) - 1n).toString())).toBe((1n << 256n) - 1n); +}); diff --git a/ts/src/domain/erc8004/index.ts b/ts/src/domain/erc8004/index.ts new file mode 100644 index 000000000..8ebb19707 --- /dev/null +++ b/ts/src/domain/erc8004/index.ts @@ -0,0 +1,37 @@ +import type { NetworkDescriptor } from "../types/index.js"; +import { UsageError } from "../errors/index.js"; + +export function parseAgentId(value: string): bigint { + if (!/^\d+$/.test(value)) { + throw new UsageError("invalid_value", "agent id must be an unsigned decimal integer"); + } + const id = BigInt(value); + if (id >= 1n << 256n) throw new UsageError("invalid_value", "agent id must fit uint256"); + return id; +} + +export function resolveAgentId(value: string, network: NetworkDescriptor): bigint { + const parts = value.split(":"); + if (parts.length === 1) return parseAgentId(parts[0]!); + let namespace: string; + let chainId: string; + let tokenId: string; + if (parts.length === 2) { + [chainId, tokenId] = parts as [string, string]; + namespace = network.id.split(":")[0]!; + } else if (parts.length === 3) { + [namespace, chainId, tokenId] = parts as [string, string, string]; + if (namespace !== "tron" && namespace !== "eip155") { + throw new UsageError("invalid_value", `unknown Agent ID namespace: ${namespace}`); + } + } else { + throw new UsageError("invalid_value", "agent id has too many components"); + } + if (`${namespace}:${chainId}` !== network.id) { + throw new UsageError( + "invalid_value", + `agent id belongs to ${namespace}:${chainId}, but selected network is ${network.id}`, + ); + } + return parseAgentId(tokenId); +} diff --git a/ts/src/domain/errors/codes.ts b/ts/src/domain/errors/codes.ts index 7fd474dea..dbe603baf 100644 --- a/ts/src/domain/errors/codes.ts +++ b/ts/src/domain/errors/codes.ts @@ -95,6 +95,9 @@ export const ERROR_CODES = { tx_integrity: { exit: 1, retry: "never", meaning: "the transaction re-encoded differently than it arrived — it was altered in flight" }, chain_id_mismatch: { exit: 1, retry: "never", meaning: "the transaction was built for a different chain than the one selected" }, signing_rejected: { exit: 1, retry: "never", meaning: "the signature was declined on the device" }, + payer_mismatch: { exit: 1, retry: "never", meaning: "the payload names a payer other than the signing account" }, + fee_cap_exceeded: { exit: 1, retry: "never", meaning: "the payload's fee exceeds the ceiling the caller set" }, + signed_payload_mismatch: { exit: 1, retry: "never", meaning: "the signature is not for the struct that was requested" }, dry_run_violation: { exit: 1, retry: "never", meaning: "a --dry-run path attempted to broadcast; the attempt was barred" }, invalid_permission: { exit: 2, retry: "never", meaning: "no such permission group on the account, or it cannot be used here" }, not_authorized: { exit: 1, retry: "never", meaning: "the account is not permitted to perform this operation" }, @@ -122,6 +125,15 @@ export const ERROR_CODES = { invalid_node_response: { exit: 1, retry: "same", meaning: "the node's answer was not in the shape the API defines" }, provider_error: { exit: 1, retry: "same", meaning: "an external service failed" }, provider_rate_limited: { exit: 1, retry: "later", meaning: "an external service is rate-limiting this client" }, + gasfree_insufficient_balance: { exit: 1, retry: "changed", meaning: "the GasFree token balance cannot cover payment and maximum fee" }, + gasfree_not_activated: { exit: 1, retry: "changed", meaning: "the GasFree account is not activated" }, + permit2_allowance_required: { exit: 1, retry: "changed", meaning: "the token allowance for Permit2 is insufficient" }, + approval_reset_required: { exit: 1, retry: "changed", meaning: "the token requires zero allowance before a new approval" }, + invalid_x402_response: { exit: 1, retry: "same", meaning: "an x402 response could not be decoded" }, + invalid_settlement: { exit: 1, retry: "never", meaning: "the paid response carried an invalid settlement receipt" }, + no_matching_requirement: { exit: 1, retry: "never", meaning: "no offered x402 payment route matched the requested filters" }, + amount_exceeds_limit: { exit: 1, retry: "never", meaning: "the requested x402 payment exceeds its configured limit" }, + response_too_large: { exit: 1, retry: "changed", meaning: "the remote response exceeded the CLI safety limit" }, timeout: { exit: 1, retry: "same", meaning: "the node, service or device did not answer in time" }, aborted: { exit: "either", retry: "never", meaning: "the operation was stopped before it finished" }, cancelled: { exit: 1, retry: "never", meaning: "the operation was cancelled before it reached the device" }, @@ -131,6 +143,9 @@ export const ERROR_CODES = { gasfree_credentials_missing: { exit: 2, retry: "never", meaning: "no GasFree credentials are configured" }, gasfree_integrity: { exit: 1, retry: "never", meaning: "the GasFree service's answer failed its integrity check" }, gasfree_rejected: { exit: 1, retry: "never", meaning: "the GasFree service refused the transfer" }, + bai_rejected: { exit: 1, retry: "changed", meaning: "the B.AI service rejected an operation with a recognized business reason" }, + bai_auth_failed: { exit: 1, retry: "never", meaning: "the B.AI service rejected the configured API key" }, + bai_credentials_missing: { exit: 2, retry: "never", meaning: "no B.AI API key is configured" }, tronlink_credentials_missing: { exit: 2, retry: "never", meaning: "no TronLink multi-sig service credentials are configured" }, // ── hardware wallet ─────────────────────────────────────────────────────── diff --git a/ts/src/domain/typed-data/index.test.ts b/ts/src/domain/typed-data/index.test.ts index c2550d687..d6cecab68 100644 --- a/ts/src/domain/typed-data/index.test.ts +++ b/ts/src/domain/typed-data/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { normalizeTypedData } from "./index.js"; +import { normalizeTypedData, resolvePrimaryType } from "./index.js"; import { CliError } from "../errors/index.js"; const DOMAIN = { name: "SunPerp", version: "1", chainId: 728126428 }; @@ -260,3 +260,37 @@ describe("normalizeTypedData narrows types to the primaryType's closure", () => expect(Object.keys(p.types).sort()).toEqual(["Mail", "Person", "Unrelated"]); }); }); + +describe("resolvePrimaryType", () => { + const Person = [ + { name: "name", type: "string" }, + { name: "wallet", type: "address" }, + ]; + const Mail = [ + { name: "from", type: "Person" }, + { name: "to", type: "Person" }, + { name: "contents", type: "string" }, + ]; + + it("returns the declared primaryType without consulting types", () => { + expect(resolvePrimaryType({ types: { Mail, Person }, primaryType: "Mail" })).toBe("Mail"); + }); + + it("infers the single root when primaryType is omitted", () => { + expect(resolvePrimaryType({ types: { Mail, Person } })).toBe("Mail"); + }); + + it("returns undefined when more than one root exists and primaryType is omitted", () => { + expect( + resolvePrimaryType({ types: { Mail, Person, Receipt: [{ name: "id", type: "uint256" }] } }), + ).toBeUndefined(); + }); + + it("returns undefined when no root exists (a cycle with nothing outside it)", () => { + const types = { + A: [{ name: "b", type: "B" }], + B: [{ name: "a", type: "A" }], + }; + expect(resolvePrimaryType({ types })).toBeUndefined(); + }); +}); diff --git a/ts/src/domain/typed-data/index.ts b/ts/src/domain/typed-data/index.ts index 28906e3df..f54ce1ff9 100644 --- a/ts/src/domain/typed-data/index.ts +++ b/ts/src/domain/typed-data/index.ts @@ -65,6 +65,30 @@ function typeClosure( return Object.fromEntries(Object.entries(types).filter(([name]) => seen.has(name))); } +/** The struct names in `types` that no other struct references — the candidate signing roots. */ +function rootTypes(types: Record): string[] { + return Object.keys(types).filter((name) => !isReferencedType(types, name)); +} + +/** + * Resolve which struct a payload is signing: the declared `primaryType`, or — when the caller + * omitted it — the struct `types` reaches from nowhere else, PROVIDED there is exactly one such + * struct. Returns `undefined` when that root cannot be determined unambiguously (zero roots, e.g. + * a cycle with nothing outside it, or more than one candidate), the same condition under which an + * encoder handed the bare map would refuse to infer a root. + * + * Callers that must never silently treat "root unknown" as "no guard needed" — see + * `adapters/outbound/x402/signer-bridge.ts` — should refuse rather than proceed when this returns + * `undefined`. + */ +export function resolvePrimaryType( + payload: Pick, +): string | undefined { + if (payload.primaryType !== undefined) return payload.primaryType; + const roots = rootTypes(payload.types); + return roots.length === 1 ? roots[0] : undefined; +} + /** * Validate and canonicalize a caller-supplied typed-data payload. * - `EIP712Domain` is dropped from `types`: it describes `domain`, it is not a struct to hash, diff --git a/ts/src/domain/types/network.ts b/ts/src/domain/types/network.ts index 6804e5eed..d8a082767 100644 --- a/ts/src/domain/types/network.ts +++ b/ts/src/domain/types/network.ts @@ -115,6 +115,8 @@ export interface Config { /** GasFree Open Platform credentials. The secret is never rendered in clear text. */ gasfreeApiKey?: string; gasfreeApiSecret?: string; + /** B.AI account API. The bearer key is never rendered in clear text. */ + baiApiKey?: string; } export interface GasFreeNetworkConfig { diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index 95594cc4f..f46a0905d 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -144,11 +144,24 @@ export type TxReceiptKind = /** * Canonical tx receipt the signing commands return (dry-run / sign-only / broadcast stages). - * Flat (JSON stays additive); the text formatter narrows on `kind` (+ `ctx.net.family` for the + * Shared transaction fields remain flat; feature-specific results are nested. + * The text formatter narrows on `kind` (+ `ctx.net.family` for the * per-family fee/amount hooks) and reads fixed keys instead of probing aliases. Commands populate * the subset relevant to their action. */ export interface TxReceiptView { + /** ERC-8004 write results; absent from unrelated transaction receipts. */ + identity?: { + agentId?: string; + operator?: string; + uri?: string; + oldURI?: string; + requestedURI?: string; + newURI?: string; + oldOwner?: string; + requestedOwner?: string; + newOwner?: string; + }; kind: TxReceiptKind; mode?: "dry-run" | "build-only" | "sign-only"; stage?: BroadcastStage; diff --git a/ts/src/domain/x402/network-id.test.ts b/ts/src/domain/x402/network-id.test.ts new file mode 100644 index 000000000..cc46298f2 --- /dev/null +++ b/ts/src/domain/x402/network-id.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { fromX402Network, toX402Network } from "./network-id.js"; + +// The full builtin set, as a literal table. A domain test may not import BUILTIN_NETWORKS +// (that lives in an outbound adapter), and a hand-written table is also the clearer contract: +// these exact pairs are what the migration relies on. +const PAIRS: Array<[{ family: "tron" | "evm"; chainId: string }, string]> = [ + [{ family: "tron", chainId: "728126428" }, "tron:0x2b6653dc"], + [{ family: "tron", chainId: "3448148188" }, "tron:0xcd8690dc"], + [{ family: "tron", chainId: "2494104990" }, "tron:0x94a9059e"], + [{ family: "evm", chainId: "1" }, "eip155:1"], + [{ family: "evm", chainId: "11155111" }, "eip155:11155111"], + [{ family: "evm", chainId: "56" }, "eip155:56"], + [{ family: "evm", chainId: "97" }, "eip155:97"], +]; + +describe("toX402Network", () => { + it.each(PAIRS)("renders %j as its x402 id", (network, id) => { + expect(toX402Network(network)).toBe(id); + }); +}); + +describe("fromX402Network", () => { + it.each(PAIRS)("parses the x402 id back into %j", (network, id) => { + expect(fromX402Network(id)).toEqual(network); + }); + + it("accepts an id whose case differs", () => { + expect(fromX402Network("TRON:0x2B6653DC")).toEqual({ family: "tron", chainId: "728126428" }); + }); + + it("rejects an unknown namespace", () => { + expect(() => fromX402Network("solana:mainnet")).toThrow( + expect.objectContaining({ code: "unsupported_network" }), + ); + }); + + it("rejects a malformed reference", () => { + expect(() => fromX402Network("eip155:mainnet")).toThrow( + expect.objectContaining({ code: "unsupported_network" }), + ); + }); +}); diff --git a/ts/src/domain/x402/network-id.ts b/ts/src/domain/x402/network-id.ts new file mode 100644 index 000000000..ae2b7ac6d --- /dev/null +++ b/ts/src/domain/x402/network-id.ts @@ -0,0 +1,41 @@ +/** + * x402 network identifiers. + * + * x402 addresses a chain by CAIP-2. For `eip155` that is the EIP-155 chain id in decimal — the + * same string `NetworkDescriptor.chainId` already carries. For TRON, CAIP-2 uses the chain id in + * HEXADECIMAL (`tron:0x2b6653dc`) while wallet-cli's canonical id uses decimal + * (`tron:728126428`); the two name the same number in different bases. + * + * Pure: no registry lookup, no I/O. Whether a parsed id corresponds to a network this wallet is + * configured for is the NetworkRegistry's question, not this module's. + */ +import type { ChainFamily } from "../family/chain-family.js"; +import { UsageError } from "../errors/index.js"; + +export interface X402NetworkIdentity { + family: ChainFamily; + /** decimal, matching NetworkDescriptor.chainId. */ + chainId: string; +} + +/** `tron:` or `eip155:`; a hex reference is also accepted for eip155 so a + * round-trip never depends on which base a counterparty chose. */ +const X402_ID = /^(tron|eip155):(0x[0-9a-f]+|[0-9]+)$/; + +export function toX402Network(network: X402NetworkIdentity): string { + const value = BigInt(network.chainId); + return network.family === "tron" + ? `tron:0x${value.toString(16)}` + : `eip155:${value.toString(10)}`; +} + +export function fromX402Network(id: string): X402NetworkIdentity { + const match = X402_ID.exec(id.trim().toLowerCase()); + if (!match) { + throw new UsageError("unsupported_network", `not an x402 network id: ${id}`); + } + return { + family: match[1] === "tron" ? "tron" : "evm", + chainId: BigInt(match[2]!).toString(10), + }; +} diff --git a/ts/test/bai-nile-compatibility.test.ts b/ts/test/bai-nile-compatibility.test.ts new file mode 100644 index 000000000..abfd62920 --- /dev/null +++ b/ts/test/bai-nile-compatibility.test.ts @@ -0,0 +1,74 @@ +import { expect, it } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; +import { DETACHED } from "./detached.js"; +import { Keystore } from "../src/adapters/outbound/keystore/index.js"; +import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js"; + +it("keeps BAI summary available but rejects recharge on Nile before any account API or payment", () => { + const home = mkdtempSync(join(tmpdir(), "wallet-bai-nile-")); + try { + new Keystore(home, new AtomicFileStore(), () => { + throw new Error("must not sign"); + }).registerWatch({ + family: "tron", + address: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + label: "nile-payer", + }); + writeFileSync(join(home, "config.yaml"), "baiApiKey: test-key\n", { mode: 0o600 }); + const log = join(home, "calls.jsonl"); + writeFileSync(log, ""); + const preload = join(home, "fetch.mjs"); + writeFileSync( + preload, + `import {appendFileSync} from 'node:fs'; + globalThis.fetch = async (url, init) => { + const path = new URL(url).pathname; + appendFileSync(${JSON.stringify(log)}, path+'\\n'); + if (path !== '/trpc/lambda/usage.summary') throw new Error('unexpected call'); + return new Response(JSON.stringify({points_balance:100,monthly_spent:5,monthly_chart:[{month:'2026-09',points:5}]})); + };`, + ); + const run = (args: string[]) => + spawnSync( + process.execPath, + [ + ...(process.env.WALLET_CLI_TEST_ENTRY ? [] : ["--import", "tsx"]), + "--import", + pathToFileURL(preload).href, + process.env.WALLET_CLI_TEST_ENTRY ?? "src/index.ts", + "bai", + ...args, + "--network", + "nile", + "--account", + "nile-payer", + "--output", + "json", + ], + { + ...DETACHED, + env: { ...process.env, WALLET_CLI_HOME: home }, + encoding: "utf8", + timeout: 20000, + }, + ); + const recharge = run(["recharge", "1"]); + expect(JSON.parse(recharge.stdout).error.code, recharge.stderr).toBe( + "unsupported_network_capability", + ); + expect(readFileSync(log, "utf8")).toBe(""); + const usage = run(["usage"]); + expect(usage.status, usage.stderr || usage.stdout).toBe(0); + expect(JSON.parse(usage.stdout).data).toMatchObject({ + credits: "100", + thisMonth: { credits: "5" }, + }); + expect(readFileSync(log, "utf8").trim()).toBe("/trpc/lambda/usage.summary"); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/ts/test/bai-recharge-report.test.ts b/ts/test/bai-recharge-report.test.ts new file mode 100644 index 000000000..0602b3443 --- /dev/null +++ b/ts/test/bai-recharge-report.test.ts @@ -0,0 +1,76 @@ +import { expect, it } from "vitest"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; +import { DETACHED } from "./detached.js"; + +it.each([false, true])( + "reports an existing recharge through CLI without a wallet (recipient=%s)", + (recipient) => { + const home = mkdtempSync(join(tmpdir(), "bai-report-cli-")); + const hash = "0x" + "a".repeat(64); + try { + writeFileSync(join(home, "config.yaml"), "baiApiKey: test-key\n", { mode: 0o600 }); + const log = join(home, "requests.jsonl"); + const preload = join(home, "backend.mjs"); + writeFileSync( + preload, + `import {appendFileSync} from 'node:fs'; + globalThis.fetch = async (url, init) => { + if (new URL(url).pathname !== '/trpc/lambda/order.reportTxHash') throw new Error('unexpected API or payment'); + if (init.headers.Authorization !== 'Bearer test-key') throw new Error('missing credential'); + appendFileSync(${JSON.stringify(log)}, JSON.stringify(JSON.parse(init.body).json)+'\\n'); + return Response.json({result:{data:{json:{success:true,order:{id:1}}}}}); + };`, + ); + const entry = process.env.WALLET_CLI_TEST_ENTRY; + const result = spawnSync( + process.execPath, + [ + ...(entry ? [] : ["--import", "tsx"]), + "--import", + pathToFileURL(preload).href, + entry ?? "src/index.ts", + "bai", + "recharge-report", + hash, + "--chain", + "base", + "--amount", + "1", + "--output", + "json", + ...(recipient ? ["--to", "recipient@example.com", "--target-id", "original-id"] : []), + ], + { + ...DETACHED, + env: { ...process.env, WALLET_CLI_HOME: home }, + encoding: "utf8", + timeout: 20000, + }, + ); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(JSON.parse(result.stdout).data).toMatchObject({ + txHash: hash, + creditStatus: "credited", + retryPayment: false, + }); + const calls = readFileSync(log, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ chain: "base", txHash: hash, amount: 1 }); + if (recipient) + expect(calls[0].rechargeTarget).toEqual({ + input: { type: "personal", identifier: "recipient@example.com" }, + confirmedTarget: { type: "personal", targetId: "original-id" }, + }); + else expect(calls[0]).not.toHaveProperty("rechargeTarget"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, +); diff --git a/ts/test/bai-recharge.test.ts b/ts/test/bai-recharge.test.ts new file mode 100644 index 000000000..cbef77aec --- /dev/null +++ b/ts/test/bai-recharge.test.ts @@ -0,0 +1,113 @@ +import { expect, it } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; +import { Keystore } from "../src/adapters/outbound/keystore/index.js"; +import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js"; +import { FileBaiBindingStore } from "../src/adapters/outbound/bai/binding-store.js"; +import { DETACHED } from "./detached.js"; + +it.each([ + ["bsc", "bnb", "eip155:56", "USDT"], + ["base", "base", "eip155:8453", "USDC"], +])( + "runs %s self and recipient recharge through the real CLI with mocked backend and payment", + (alias, chain, networkId, token) => { + const home = mkdtempSync(join(tmpdir(), "bai-recharge-cli-")); + const payer = "0x1111111111111111111111111111111111111111"; + const hash = "0x" + "a".repeat(64); + try { + const store = new AtomicFileStore(); + new Keystore(home, store, () => { + throw new Error("no signing in this test"); + }).registerWatch({ family: "evm", address: payer, label: "payer" }); + new FileBaiBindingStore(home, store).confirm("test-key", chain, payer); + writeFileSync(join(home, "config.yaml"), "baiApiKey: test-key\n", { mode: 0o600 }); + const log = join(home, "calls.jsonl"); + const loader = join(home, "backend.mjs"); + const paymentModule = pathToFileURL( + join(process.cwd(), "src/adapters/outbound/x402/payment-client.ts"), + ).href; + writeFileSync( + loader, + `import {appendFileSync} from 'node:fs'; + import {X402PaymentClient} from ${JSON.stringify(paymentModule)}; + const log = (value) => appendFileSync(${JSON.stringify(log)}, JSON.stringify(value)+'\\n'); + globalThis.fetch = async (url, init) => { + if (init.headers.Authorization !== 'Bearer test-key') throw new Error('missing credential'); + const method = new URL(url).pathname.split('/').at(-1); + const input = JSON.parse(init.body).json; + log({method, input}); + const results = { + 'order.resolveRechargeTarget': {type:'personal', targetId:'recipient-id', displayLabel:'Recipient'}, + 'order.createOrder': {id:1}, + 'order.reportTxHash': {success:true, order:{id:1, points:100000}} + }; + if (!results[method]) throw new Error('unexpected API'); + return new Response(JSON.stringify({result:{data:{json:results[method]}}})); + }; + X402PaymentClient.prototype.pay = async () => { + log({method:'pay'}); + return {settled:true, payer:{address:${JSON.stringify(payer)}}, paymentResponse:{success:true, transaction:${JSON.stringify(hash)}, network:${JSON.stringify(networkId)}}}; + };`, + ); + for (const to of [undefined, "recipient@example.com"]) { + writeFileSync(log, ""); + const result = spawnSync( + process.execPath, + [ + "--import", + "tsx", + "--import", + pathToFileURL(loader).href, + join(process.cwd(), "src/index.ts"), + "bai", + "recharge", + "10", + "--network", + alias, + "--account", + "payer", + "--output", + "json", + ...(to ? ["--to", to] : []), + ], + { + ...DETACHED, + env: { ...process.env, WALLET_CLI_HOME: home }, + encoding: "utf8", + timeout: 20000, + }, + ); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(JSON.parse(result.stdout).data).toMatchObject({ + txHash: hash, + creditStatus: "credited", + retryPayment: false, + }); + const calls = readFileSync(log, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(calls.map((call) => call.method)).toEqual([ + ...(to ? ["order.resolveRechargeTarget"] : []), + "order.createOrder", + "pay", + "order.reportTxHash", + ]); + const preorder = calls.find((call) => call.method === "order.createOrder").input; + const report = calls.find((call) => call.method === "order.reportTxHash").input; + expect(preorder.chain).toBe(chain); + expect(preorder.tokenName).toBe(token); + expect(report.rechargeTarget).toEqual(preorder.rechargeTarget); + if (to) expect(report.rechargeTarget.confirmedTarget.targetId).toBe("recipient-id"); + else expect(report).not.toHaveProperty("rechargeTarget"); + } + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, + 60000, +); diff --git a/ts/test/bai-setup.test.ts b/ts/test/bai-setup.test.ts new file mode 100644 index 000000000..8203cb48d --- /dev/null +++ b/ts/test/bai-setup.test.ts @@ -0,0 +1,91 @@ +import { expect, it } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { Keystore } from "../src/adapters/outbound/keystore/index.js"; +import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js"; +import { DETACHED } from "./detached.js"; + +it("configures a key once, reuses setup across processes and rejects an unconfirmed wallet", () => { + const home = mkdtempSync(join(tmpdir(), "bai-setup-cli-")); + try { + const keystore = new Keystore(home, new AtomicFileStore(), () => { + throw new Error("no decrypt"); + }); + keystore.registerWatch({ + family: "evm", + address: "0x1111111111111111111111111111111111111111", + label: "payer", + }); + keystore.registerWatch({ + family: "evm", + address: "0x2222222222222222222222222222222222222222", + label: "other", + }); + const loader = join(home, "test-http.mjs"); + writeFileSync( + loader, + `import {appendFileSync} from 'node:fs'; + globalThis.fetch = async (url, init) => { + if (init.headers.Authorization !== 'Bearer test-key') return new Response('{}', {status: 401}); + if (String(url).includes('/wallet.isRechargeBound?')) { + appendFileSync(process.env.WALLET_CLI_HOME + '/calls.txt', 'binding\\n'); + return new Response(JSON.stringify({result:{data:{json:true}}})); + } + throw new Error('Recipient API intentionally unavailable in setup test'); + };`, + ); + const run = (args: string[], input?: string, account = "payer") => + spawnSync( + process.execPath, + [ + "--import", + pathToFileURL(loader).href, + "--import", + "tsx", + join(process.cwd(), "src/index.ts"), + ...args, + "--network", + "bsc", + "--account", + account, + "--output", + "json", + ], + { + ...DETACHED, + env: { ...process.env, WALLET_CLI_HOME: home }, + input, + encoding: "utf8", + timeout: 20000, + }, + ); + const first = run(["config", "baiApiKey", "--api-key-stdin"], "test-key\n"); + expect(first.status, first.stderr || first.stdout).toBe(0); + expect(first.stdout).not.toContain("test-key"); + const again = run(["config", "baiApiKey", "--api-key-stdin"], "test-key\n"); + expect(again.status, again.stderr || again.stdout).toBe(0); + expect(readFileSync(join(home, "calls.txt"), "utf8")).toBe("binding\n"); + // This test's target API stub rejects requests, before payment and after local setup. + const recharge = run(["bai", "recharge", "10", "--to", "recipient@example.com"]); + expect(recharge.status).not.toBe(0); + expect(recharge.stdout + recharge.stderr).toContain("provider_error"); + expect(readFileSync(join(home, "calls.txt"), "utf8")).toBe("binding\n"); + const other = run( + ["bai", "recharge", "10", "--to", "recipient@example.com"], + undefined, + "other", + ); + expect(other.status).not.toBe(0); + expect(other.stdout + other.stderr).toContain("Confirm this API key and payer wallet first"); + expect(readFileSync(join(home, "calls.txt"), "utf8")).toBe("binding\n"); + const badKey = run(["config", "baiApiKey", "--api-key-stdin"], "rejected-key\n"); + expect(badKey.status).not.toBe(0); + expect(readFileSync(join(home, "config.yaml"), "utf8")).toContain("test-key"); + expect(readFileSync(join(home, "config.yaml"), "utf8")).not.toContain("rejected-key"); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}, 60000); diff --git a/ts/test/beta-artifact-signing.test.ts b/ts/test/beta-artifact-signing.test.ts new file mode 100644 index 000000000..d992f1327 --- /dev/null +++ b/ts/test/beta-artifact-signing.test.ts @@ -0,0 +1,89 @@ +import { tronAllowancePreload } from "./tron-allowance-preload.js"; +import { it, expect } from "vitest"; +import { Wallet } from "ethers"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { Keystore } from "../src/adapters/outbound/keystore/index.js"; +import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js"; + +it.skipIf(!process.env.WALLET_CLI_TEST_ENTRY)( + "installed beta signs a Nile x402 payment using an encrypted throwaway wallet", + () => { + const home = mkdtempSync(join(tmpdir(), "beta-signing-")); + try { + new Keystore(home, new AtomicFileStore(), () => "test-password").import({ + secret: Wallet.createRandom().privateKey.slice(2), + type: "privateKey", + label: "payer", + }); + const challenge = { + x402Version: 2, + resource: { url: "https://example.test/nile" }, + accepts: [ + { + scheme: "exact", + network: "tron:0xcd8690dc", + amount: "1000000", + asset: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", + payTo: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + maxTimeoutSeconds: 300, + extra: { assetTransferMethod: "permit2" }, + }, + ], + }; + const preload = join(home, "fetch.mjs"); + const log = join(home, "signature.json"); + writeFileSync( + preload, + `${tronAllowancePreload} +import {writeFileSync} from 'node:fs'; + globalThis.fetch=async(input,init)=>{ + const request=input instanceof Request?input:new Request(input,init); + if(request.url!=='https://example.test/nile') throw new Error('unexpected network request'); + const header=request.headers.get('payment-signature'); + if(!header) return new Response(null,{status:402,headers:{'payment-required':${JSON.stringify(Buffer.from(JSON.stringify(challenge)).toString("base64"))}}}); + writeFileSync(${JSON.stringify(log)},Buffer.from(header,'base64').toString()); + return new Response('ok'); + };`, + ); + const result = spawnSync( + process.execPath, + [ + "--import", + pathToFileURL(preload).href, + process.env.WALLET_CLI_TEST_ENTRY!, + "x402", + "pay", + "https://example.test/nile", + "--network", + "nile", + "--account", + "payer", + "--token", + "USDT", + "--max-amount", + "1", + "--output", + "json", + "--password-stdin", + ], + { + env: { ...process.env, WALLET_CLI_HOME: home }, + input: "test-password\n", + encoding: "utf8", + timeout: 20000, + }, + ); + expect(result.status, result.stdout + result.stderr).toBe(0); + const signed = JSON.parse(readFileSync(log, "utf8")); + expect(signed.accepted.network).toBe("tron:0xcd8690dc"); + expect(signed.payload.signature).toMatch(/^(0x)?[a-fA-F0-9]{130}$/); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, + 30000, +); diff --git a/ts/test/beta-command-surface.test.ts b/ts/test/beta-command-surface.test.ts new file mode 100644 index 000000000..84a56a918 --- /dev/null +++ b/ts/test/beta-command-surface.test.ts @@ -0,0 +1,137 @@ +import { it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const entry = process.env.WALLET_CLI_TEST_ENTRY; +const commands = [ + ...[ + "pay", + "serve", + "roundtrip", + "provider-list", + "provider-show", + "provider-endpoints", + "provider-update", + ].map((v) => ["x402", v]), + ...["recharge", "recharge-report", "status", "usage", "usage-list", "recharge-list"].map((v) => [ + "bai", + v, + ]), + ...[ + "show", + "register", + "update", + "transfer", + "approve", + "operator-add", + "operator-remove", + "operator-check", + ].map((v) => ["8004", v]), +]; +function run(args: string[], body = "globalThis.fetch=()=>{throw new Error('network forbidden')}") { + const home = mkdtempSync(join(tmpdir(), "beta-surface-")); + try { + const preload = join(home, "mock.mjs"); + writeFileSync(preload, body); + writeFileSync(join(home, "config.yaml"), "baiApiKey: regression-placeholder\n", { + mode: 0o600, + }); + return spawnSync(process.execPath, ["--import", pathToFileURL(preload).href, entry!, ...args], { + env: { ...process.env, HOME: home, WALLET_CLI_HOME: home }, + encoding: "utf8", + timeout: 15000, + }); + } finally { + rmSync(home, { recursive: true, force: true }); + } +} +it.skipIf(!entry)("root help presents new groups consistently with existing commands", () => { + const root = run(["--help"]); + expect(root.status, root.stderr).toBe(0); + const short = run(["-h"]); + expect(short.status, short.stderr).toBe(0); + expect(short.stdout).toBe(root.stdout); + const management = root.stdout.split("Management Commands:")[1]!.split("\nCommands:")[0]!; + for (const group of ["account", "tx", "contract", "x402", "bai", "8004"]) { + expect(management).toMatch(new RegExp(`^ ${group}\\s+\\S`, "m")); + } + expect(root.stdout).toMatch(/^ config\s+Show \/ get \/ set configuration values$/m); + for (const group of ["x402", "bai", "8004"]) { + const help = run([group, "--help"]); + expect(help.status, help.stderr).toBe(0); + expect(help.stdout).toContain(`Usage: wallet-cli ${group} COMMAND`); + for (const [, verb] of commands.filter(([name]) => name === group)) { + expect(help.stdout).toMatch(new RegExp(`^ ${verb}\\s+`, "m")); + expect(root.stdout).not.toMatch(new RegExp(`^ ${group} ${verb}\\s`, "m")); + } + } + const config = run(["config", "--help"]); + expect(config.status, config.stderr).toBe(0); + expect(config.stdout).toContain("baiApiKey"); + expect(config.stdout).toContain("--api-key-stdin"); +}); +it.skipIf(!entry).each(commands)("%s %s help and unknown-flag rejection", (group, verb) => { + const help = run([group!, verb!, "--help"]); + expect(help.status, help.stderr).toBe(0); + expect(help.stdout).toContain("Usage:"); + const bad = run([group!, verb!, "--regression-unknown-flag", "--output", "json"]); + expect(bad.status).not.toBe(0); + expect(JSON.parse(bad.stdout).success).toBe(false); +}); +const catalog = { + providers: [ + { + fqn: "bai/recharge", + name: "B.AI", + category: "ai", + chains: ["base"], + featuredTags: ["recharge"], + }, + ], +}; +const provider = { + fqn: "bai/recharge", + endpoints: [{ path: "/m/credit/recharge", method: "POST" }], +}; +it.skipIf(!entry).each(["provider-list", "provider-show", "provider-endpoints", "provider-update"])( + "x402 %s against catalog fixture", + (verb) => { + const mock = `globalThis.fetch=async(url)=>new Response(JSON.stringify(String(url).endsWith('catalog.json')?${JSON.stringify(catalog)}:${JSON.stringify(provider)}));`; + const r = run( + [ + "x402", + verb, + ...(["provider-show", "provider-endpoints"].includes(verb) ? ["bai/recharge"] : []), + "--output", + "json", + ], + mock, + ); + expect(r.status, r.stderr + r.stdout).toBe(0); + const data = JSON.parse(r.stdout).data; + if (verb === "provider-list") expect(data.count).toBe(1); + if (verb === "provider-show") expect(data.fqn).toBe("bai/recharge"); + if (verb === "provider-endpoints") expect(data.endpoints).toHaveLength(1); + if (verb === "provider-update") expect(data.updated).toBe(true); + }, +); +it.skipIf(!entry).each(["status", "usage", "usage-list", "recharge-list"])( + "bai %s against authenticated fixture", + (verb) => { + const mock = `globalThis.fetch=async(url,init)=>{ + if(init.headers.Authorization!=='Bearer regression-placeholder')throw new Error('missing auth'); + const path=new URL(url).pathname; + if(path.endsWith('usage.summary'))return new Response(JSON.stringify({points_balance:100,monthly_spent:5,monthly_chart:[]})); + if(path.endsWith('usage.records'))return new Response(JSON.stringify({data:[{id:'r1'}],page:1,pageSize:20,hasMore:false})); + if(path.endsWith('order.listOrders'))return new Response(JSON.stringify({result:{data:{json:{orders:[{id:'o1'}],page:1,pageSize:20}}}})); + throw new Error('unexpected API');};`; + const r = run(["bai", verb, "--output", "json"], mock); + expect(r.status, r.stderr + r.stdout).toBe(0); + const data = JSON.parse(r.stdout).data; + if (verb === "status" || verb === "usage") expect(data.credits).toBe("100"); + else expect(JSON.stringify(data)).toContain(verb === "usage-list" ? "r1" : "o1"); + }, +); diff --git a/ts/test/beta-server-roundtrip.test.ts b/ts/test/beta-server-roundtrip.test.ts new file mode 100644 index 000000000..6a318ed91 --- /dev/null +++ b/ts/test/beta-server-roundtrip.test.ts @@ -0,0 +1,130 @@ +import { tronAllowancePreload } from "./tron-allowance-preload.js"; +import { it, expect } from "vitest"; +import { Wallet } from "ethers"; +import { createServer } from "node:net"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { Keystore } from "../src/adapters/outbound/keystore/index.js"; +import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js"; +const entry = process.env.WALLET_CLI_TEST_ENTRY; +async function port() { + const s = createServer(); + await new Promise((r) => s.listen(0, "127.0.0.1", r)); + const p = (s.address() as { port: number }).port; + await new Promise((r) => s.close(() => r())); + return p; +} +it.skipIf(!entry)( + "installed x402 serve exposes health and a Nile 402 challenge", + async () => { + const home = mkdtempSync(join(tmpdir(), "beta-serve-")); + const p = await port(); + const child = spawn( + process.execPath, + [ + entry!, + "x402", + "serve", + "--network", + "nile", + "--pay-to", + "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + "--port", + String(p), + "--output", + "json", + ], + { env: { ...process.env, WALLET_CLI_HOME: home }, stdio: ["ignore", "pipe", "pipe"] }, + ); + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("server start timeout")), 10000); + child.stdout.once("data", () => { + clearTimeout(timer); + resolve(); + }); + child.once("exit", (code) => { + clearTimeout(timer); + reject(new Error("early exit " + code)); + }); + }); + expect((await fetch(`http://127.0.0.1:${p}/health`)).status).toBe(200); + const r = await fetch(`http://127.0.0.1:${p}/pay`); + expect(r.status).toBe(402); + expect(r.headers.has("payment-required")).toBe(true); + expect(((await r.json()) as { accepts: { network: string }[] }).accepts[0]!.network).toBe( + "tron:0xcd8690dc", + ); + } finally { + child.kill("SIGTERM"); + await new Promise((r) => child.once("close", () => r())); + rmSync(home, { recursive: true, force: true }); + } + }, + 20000, +); +it.skipIf(!entry)( + "installed x402 roundtrip signs and uses mocked facilitator settlement", + async () => { + const home = mkdtempSync(join(tmpdir(), "beta-roundtrip-")); + const p = await port(); + try { + new Keystore(home, new AtomicFileStore(), () => "test-password").import({ + secret: Wallet.createRandom().privateKey.slice(2), + type: "privateKey", + label: "payer", + }); + const log = join(home, "calls.jsonl"); + const preload = join(home, "fetch.mjs"); + writeFileSync( + preload, + `${tronAllowancePreload} +import {appendFileSync} from 'node:fs';const realFetch=globalThis.fetch;globalThis.fetch=async(input,init)=>{ + const url=new URL(input instanceof Request?input.url:input); + if(url.hostname==='127.0.0.1')return realFetch(input,init); + if(url.origin!=='https://facilitator.bankofai.io')throw new Error('unexpected network'); + const data=JSON.parse(init.body);if(!data.paymentPayload.payload.signature)throw new Error('missing signature'); + appendFileSync(${JSON.stringify(log)},url.pathname+'\\n'); + if(url.pathname==='/verify')return new Response(JSON.stringify({isValid:true})); + if(url.pathname==='/settle')return new Response(JSON.stringify({success:true,transaction:'a'.repeat(64),network:'tron:0xcd8690dc'})); + throw new Error('unexpected facilitator call');};`, + ); + const r = spawnSync( + process.execPath, + [ + "--import", + pathToFileURL(preload).href, + entry!, + "x402", + "roundtrip", + "--network", + "nile", + "--pay-to", + "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + "--port", + String(p), + "--account", + "payer", + "--password-stdin", + "--output", + "json", + ], + { + env: { ...process.env, WALLET_CLI_HOME: home }, + input: "test-password\n", + encoding: "utf8", + timeout: 20000, + }, + ); + expect(r.status, r.stderr + r.stdout).toBe(0); + expect(JSON.parse(r.stdout).data.pay.settled).toBe(true); + expect(readFileSync(log, "utf8")).toBe("/verify\n/settle\n"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, + 30000, +); diff --git a/ts/test/erc8004.test.ts b/ts/test/erc8004.test.ts new file mode 100644 index 000000000..52d16438c --- /dev/null +++ b/ts/test/erc8004.test.ts @@ -0,0 +1,275 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createServer, type Server } from "node:http"; +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Interface } from "ethers"; +import { Keystore } from "../src/adapters/outbound/keystore/index.js"; +import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js"; +import { DETACHED } from "./detached.js"; + +const abi = new Interface([ + "function register(string)", + "function setAgentURI(uint256,string)", + "function transferFrom(address,address,uint256)", + "function setApprovalForAll(address,bool)", + "function approve(address,uint256)", + "function ownerOf(uint256) view returns(address)", + "function tokenURI(uint256) view returns(string)", + "function getApproved(uint256) view returns(address)", + "function isApprovedForAll(address,address) view returns(bool)", +]); +const owner = "0x1111111111111111111111111111111111111111"; +const homes: string[] = []; +const servers: Server[] = []; +afterEach(async () => { + for (const server of servers.splice(0)) + await new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve()))); + for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }); +}); +async function fixture(family: "evm" | "tron") { + const calls: Array<{ path: string; header: string | string[] | undefined; method: string }> = []; + const server = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + const rpc = JSON.parse(body || "{}"); + if (family === "tron" && !rpc.function_selector) { + calls.push({ path: req.url!, header: req.headers["x-test-api-key"], method: req.url! }); + const data = + req.url === "/wallet/getblock" + ? { + blockID: "00".repeat(32), + block_header: { raw_data: { number: 12345, timestamp: Date.now() } }, + } + : {}; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(data)); + return; + } + if ( + family === "tron" && + [ + "register(string)", + "setAgentURI(uint256,string)", + "transferFrom(address,address,uint256)", + "setApprovalForAll(address,bool)", + "approve(address,uint256)", + ].includes(rpc.function_selector) + ) { + calls.push({ path: req.url!, header: req.headers["x-test-api-key"], method: "register" }); + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ result: { result: true }, energy_used: 20000, constant_result: [] }), + ); + return; + } + if (family === "evm" && rpc.method !== "eth_call") { + calls.push({ path: req.url!, header: req.headers["x-test-api-key"], method: rpc.method }); + const results: Record = { + eth_getTransactionCount: "0x7", + eth_gasPrice: "0x3b9aca00", + eth_maxPriorityFeePerGas: "0x1", + eth_estimateGas: "0x186a0", + eth_getBlockByNumber: { number: "0x1", transactions: [], gasLimit: "0x1c9c380" }, + }; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result: results[rpc.method] ?? null })); + return; + } + const selector = + family === "evm" ? String(rpc.params[0].data).slice(0, 10) : rpc.function_selector; + const fn = abi.getFunction(selector)!; + calls.push({ path: req.url!, header: req.headers["x-test-api-key"], method: fn.name }); + const value = + fn.name === "tokenURI" + ? "data:application/json;base64,eyJuYW1lIjoiRXhhbXBsZSJ9" + : fn.name === "isApprovedForAll" + ? true + : owner; + const encoded = abi.encodeFunctionResult(fn, [value]); + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify( + family === "evm" + ? { jsonrpc: "2.0", id: rpc.id, result: encoded } + : { result: { result: true }, constant_result: [encoded.slice(2)] }, + ), + ); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing test listener"); + const home = mkdtempSync(join(tmpdir(), "wallet-8004-test-")); + homes.push(home); + const network = family === "evm" ? "eip155:97" : "tron:3448148188"; + writeFileSync( + join(home, "config.yaml"), + JSON.stringify({ + networks: { + [network]: { + httpEndpoint: `http://127.0.0.1:${address.port}`, + apiKeyHeader: "X-Test-Api-Key", + apiKey: "test-only-key", + }, + }, + }), + { mode: 0o600 }, + ); + const run = (args: string[]) => + new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { + const child = spawn( + process.execPath, + [ + ...(process.env.WALLET_CLI_TEST_ENTRY + ? [process.env.WALLET_CLI_TEST_ENTRY] + : ["--import", "tsx", join(process.cwd(), "src/index.ts")]), + "8004", + ...args, + "--network", + network, + "--output", + "json", + ], + { + ...DETACHED, + env: { ...process.env, WALLET_CLI_HOME: home }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = "", + stderr = ""; + const timer = setTimeout(() => child.kill("SIGKILL"), 25000); + child.stdout.on("data", (b) => (stdout += b)); + child.stderr.on("data", (b) => (stderr += b)); + child.on("error", (e) => { + clearTimeout(timer); + reject(e); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ code, stdout, stderr }); + }); + }); + const watch = () => + new Keystore(home, new AtomicFileStore(), () => { + throw new Error("watch needs no key"); + }).registerWatch({ + family, + address: family === "tron" ? "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ" : owner, + label: "observer", + }); + return { run, calls, network, watch }; +} + +describe("8004 CLI with published SDK and wallet RPC transport", () => { + for (const family of ["evm", "tron"] as const) { + it(`${family} show preserves scoped IDs, configured RPC credentials and data metadata`, async () => { + const f = await fixture(family); + const r = await f.run(["show", `${f.network}:9007199254740993`]); + expect(r.code, r.stderr || r.stdout).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.data).toMatchObject({ + agentId: "9007199254740993", + metadata: { name: "Example" }, + }); + expect(f.calls.map((c) => c.method).sort()).toEqual(["getApproved", "ownerOf", "tokenURI"]); + expect(f.calls.every((c) => c.header === "test-only-key")).toBe(true); + }); + } + it("operator-check requires neither a wallet nor an Agent ID", async () => { + const f = await fixture("evm"); + const r = await f.run(["operator-check", owner, owner]); + expect(r.code, r.stderr || r.stdout).toBe(0); + expect(JSON.parse(r.stdout).data).toMatchObject({ approved: true, owner, operator: owner }); + }); + it("rejects an Agent ID from another network without making RPC calls", async () => { + const f = await fixture("evm"); + const r = await f.run(["show", "eip155:56:42"]); + expect(r.code).not.toBe(0); + expect(f.calls).toHaveLength(0); + }); +}); + +for (const mode of ["dry-run", "build-only"] as const) { + it(`EVM register ${mode} constructs a transaction with a watch account and no broadcast`, async () => { + const f = await fixture("evm"); + f.watch(); + const r = await f.run(["register", "ipfs://example", "--account", "observer", `--${mode}`]); + expect(r.code, r.stderr || r.stdout).toBe(0); + const result = JSON.parse(r.stdout).data; + expect(result.mode).toBe(mode); + expect(result.identity).toEqual({ uri: "ipfs://example" }); + expect(result).not.toHaveProperty("uri"); + expect(abi.parseTransaction({ data: result.tx.data })?.args[0]).toBe("ipfs://example"); + expect(result.tx.chainId).toBe(97); + expect(f.calls.some((c) => c.method === "eth_sendRawTransaction")).toBe(false); + }); +} +it("EVM approve dry-run renders the Agent ID without any fungible-token read", async () => { + const f = await fixture("evm"); + f.watch(); + const r = await f.run([ + "approve", + "9007199254740993", + owner, + "--account", + "observer", + "--dry-run", + ]); + expect(r.code, r.stderr || r.stdout).toBe(0); + expect(JSON.parse(r.stdout).data).toMatchObject({ + mode: "dry-run", + identity: { agentId: "9007199254740993", operator: owner }, + }); + expect(JSON.parse(r.stdout).data).not.toHaveProperty("allowance"); + expect(JSON.parse(r.stdout).data).not.toHaveProperty("agentId"); + expect(JSON.parse(r.stdout).data).not.toHaveProperty("operator"); + expect( + f.calls.some((c) => ["decimals", "symbol", "eth_sendRawTransaction"].includes(c.method)), + ).toBe(false); +}); + +for (const mode of ["dry-run", "build-only"] as const) { + it(`Nile register ${mode} uses the existing TRON transaction pipeline without broadcasting`, async () => { + const f = await fixture("tron"); + f.watch(); + const r = await f.run(["register", "ipfs://example", "--account", "observer", `--${mode}`]); + expect(r.code, r.stderr || r.stdout).toBe(0); + const data = JSON.parse(r.stdout).data; + expect(data.mode).toBe(mode); + const value = data.tx.raw_data.contract[0].parameter.value; + expect(abi.parseTransaction({ data: `0x${value.data}` })?.args[0]).toBe("ipfs://example"); + expect(f.calls.some((c) => c.path.includes("broadcast"))).toBe(false); + expect(f.calls.every((c) => c.header === "test-only-key")).toBe(true); + }); +} + +for (const family of ["evm", "tron"] as const) { + const recipient = family === "evm" ? owner : "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ"; + for (const [verb, args, method] of [ + ["update", ["42", "ipfs://updated"], "setAgentURI"], + ["transfer", ["42", recipient], "transferFrom"], + ["approve", ["42", recipient], "approve"], + ["operator-add", [recipient], "setApprovalForAll"], + ["operator-remove", [recipient], "setApprovalForAll"], + ] as const) { + it(`${family} ${verb} builds the expected identity transaction without broadcasting`, async () => { + const f = await fixture(family); + f.watch(); + const r = await f.run([verb, ...args, "--account", "observer", "--build-only"]); + expect(r.code, r.stderr || r.stdout).toBe(0); + const data = JSON.parse(r.stdout).data; + const calldata = + family === "evm" ? data.tx.data : `0x${data.tx.raw_data.contract[0].parameter.value.data}`; + const parsed = abi.parseTransaction({ data: calldata }); + expect(parsed?.name).toBe(method); + if (verb === "operator-add" || verb === "operator-remove") + expect(parsed?.args[1]).toBe(verb === "operator-add"); + expect( + f.calls.some((c) => c.method === "eth_sendRawTransaction" || c.path.includes("broadcast")), + ).toBe(false); + }); + } +} diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index e5fa066c5..104e64510 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -1,3 +1,4 @@ +import packageMetadata from "../package.json" with { type: "json" }; import { describe, it, expect, beforeEach } from "vitest"; import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process"; import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; @@ -71,7 +72,7 @@ describe("golden CLI — meta & introspection", () => { it("--version prints the version, exit 0", () => { const r = run(["--version"]); expect(r.status).toBe(0); - expect(r.stdout.trim()).toBe("4.13.0"); + expect(r.stdout.trim()).toBe(packageMetadata.version); }); it("root --help shows the TRON first-release command surface", () => { @@ -114,8 +115,7 @@ describe("golden CLI — meta & introspection", () => { expect(r.json.success).toBe(true); expect(r.json.chain).toBeUndefined(); const ids = r.json.data.map((n: { id: string }) => n.id); - // Both families ship: 3 TRON + 4 EVM, each mainnet paired with a testnet. EVM was - // hidden while it was incomplete, and is deliberately exposed now. + // Both families ship: 3 TRON + 6 EVM; every EVM mainnet has a builtin testnet partner. expect(ids).toEqual( expect.arrayContaining([ "tron:728126428", @@ -125,9 +125,11 @@ describe("golden CLI — meta & introspection", () => { "eip155:11155111", "eip155:56", "eip155:97", + "eip155:8453", + "eip155:84532", ]), ); - expect(ids).toHaveLength(7); + expect(ids).toHaveLength(9); // machine surfaces carry canonical ids only, never aliases — and a canonical id is // CAIP-2, so its namespace is `eip155` for the EVM family rather than the family's own name expect(ids.every((id: string) => /^(tron|eip155):/.test(id))).toBe(true); diff --git a/ts/test/tron-allowance-preload.ts b/ts/test/tron-allowance-preload.ts new file mode 100644 index 000000000..43f76d445 --- /dev/null +++ b/ts/test/tron-allowance-preload.ts @@ -0,0 +1,18 @@ +/** Child-process RPC fixture: the test payer already has enough Permit2 allowance. */ +export const tronAllowancePreload = ` +import {createServer as createAllowanceRpc} from 'node:http'; +import {writeFileSync as writeAllowanceConfig} from 'node:fs'; +import {join as allowancePath} from 'node:path'; +const allowanceRpc = createAllowanceRpc((request, response) => { + request.resume(); + response.setHeader('content-type', 'application/json'); + if(request.url !== '/wallet/triggerconstantcontract') { + response.writeHead(500); response.end('{}'); return; + } + response.end(JSON.stringify({result:{result:true}, constant_result:['f'.repeat(64)]})); +}); +await new Promise(resolve => allowanceRpc.listen(0, '127.0.0.1', resolve)); +allowanceRpc.unref(); +writeAllowanceConfig(allowancePath(process.env.WALLET_CLI_HOME, 'config.yaml'), + 'networks:\\n "tron:3448148188":\\n httpEndpoint: "http://127.0.0.1:' + allowanceRpc.address().port + '"\\n'); +`; diff --git a/ts/test/x402-provider-payment.test.ts b/ts/test/x402-provider-payment.test.ts new file mode 100644 index 000000000..8335990dd --- /dev/null +++ b/ts/test/x402-provider-payment.test.ts @@ -0,0 +1,91 @@ +import { expect, it } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; +import { DETACHED } from "./detached.js"; +import { Keystore } from "../src/adapters/outbound/keystore/index.js"; +import { AtomicFileStore } from "../src/adapters/outbound/persistence/fs/index.js"; + +it.each([ + ["base", "eip155:8453", "USDC", "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"], + ["bsc", "eip155:56", "USDT", "0x55d398326f99059fF775485246999027B3197955"], + ["tron", "tron:0x2b6653dc", "USDT", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"], + ["nile", "tron:0xcd8690dc", "USDT", "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"], +])( + "inspects an online-provider-shaped challenge on %s with a watch-only wallet", + (alias, network, token, asset) => { + const home = mkdtempSync(join(tmpdir(), "x402-provider-cli-")); + try { + new Keystore(home, new AtomicFileStore(), () => { + throw new Error("dry-run must not request a password"); + }).registerWatch({ + family: alias === "tron" || alias === "nile" ? "tron" : "evm", + address: + alias === "tron" || alias === "nile" + ? "TSNEPtuCagKEgF2EU4pAKWLzXLz1bekfTE" + : "0x1111111111111111111111111111111111111111", + label: "inspection-only", + }); + const preload = join(home, "fetch.mjs"); + const challenge = { + x402Version: 2, + resource: { url: "https://example.com/data" }, + accepts: [ + { + scheme: "exact", + network, + asset, + amount: "1", + payTo: + alias === "tron" || alias === "nile" + ? "TSNEPtuCagKEgF2EU4pAKWLzXLz1bekfTE" + : "0x1111111111111111111111111111111111111111", + maxTimeoutSeconds: 300, + }, + ], + }; + writeFileSync( + preload, + `globalThis.fetch=async()=>new Response(${JSON.stringify(JSON.stringify(challenge))},{status:402,headers:{"content-type":"application/json"}});`, + ); + const result = spawnSync( + process.execPath, + [ + ...(process.env.WALLET_CLI_TEST_ENTRY ? [] : ["--import", "tsx"]), + "--import", + pathToFileURL(preload).href, + process.env.WALLET_CLI_TEST_ENTRY ?? "src/index.ts", + "x402", + "pay", + "https://example.com/data", + "--network", + alias, + "--account", + "inspection-only", + "--token", + token, + "--dry-run", + "--output", + "json", + ], + { + ...DETACHED, + env: { ...process.env, WALLET_CLI_HOME: home }, + encoding: "utf8", + timeout: 20000, + }, + ); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(JSON.parse(result.stdout).data).toMatchObject({ + dryRun: true, + paymentRequired: true, + settled: false, + }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, + 30000, +); diff --git a/ts/tsup.config.ts b/ts/tsup.config.ts index 8dbc768fa..aadc4c343 100644 --- a/ts/tsup.config.ts +++ b/ts/tsup.config.ts @@ -10,7 +10,9 @@ export default defineConfig({ // imports (`./utils` instead of `./utils.js`) that Node's native ESM loader // cannot resolve — they're meant to be bundled. So bundle the whole family // through esbuild, which rewrites those specifiers. - noExternal: [/@ledgerhq\//], + // Bundle the tested x402 dependency graph; npm consumers do not inherit overrides. + // Include schema and crypto libraries so each SDK resolves its compatible version. + noExternal: [/@ledgerhq\//, /^@bankofai\/x402-/, /^zod(?:\/|$)/, /^@noble\//, /^@scure\//], // Kept external, resolved from node_modules at runtime: // - node-hid: native .node addon esbuild cannot bundle. // - axios: a CJS dep dragged in by @ledgerhq's Speculos transport. Bundling it