From bc9425d34fe03a79912b06dc28520e0383c2b83b Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Fri, 4 Sep 2026 17:15:55 +0800 Subject: [PATCH 01/10] feat(ts): add x402 bai and erc8004 command modules --- ts/package-lock.json | 372 ++++++++++++++---- ts/package.json | 4 + .../adapters/inbound/cli/commands/bai.test.ts | 46 +++ ts/src/adapters/inbound/cli/commands/bai.ts | 124 ++++++ .../adapters/inbound/cli/commands/config.ts | 23 +- .../inbound/cli/commands/erc8004.test.ts | 35 ++ .../adapters/inbound/cli/commands/erc8004.ts | 219 +++++++++++ .../inbound/cli/commands/x402.test.ts | 43 ++ ts/src/adapters/inbound/cli/commands/x402.ts | 206 ++++++++++ .../adapters/inbound/cli/contracts/command.ts | 2 +- .../adapters/inbound/cli/contracts/runtime.ts | 2 +- ts/src/adapters/inbound/cli/globals/index.ts | 7 + ts/src/adapters/inbound/cli/help/index.ts | 6 + .../cli/help/root-help-coverage.test.ts | 2 +- .../cli/shell/positional-contract.test.ts | 12 + ts/src/adapters/outbound/bai/client.test.ts | 138 +++++++ ts/src/adapters/outbound/bai/client.ts | 167 ++++++++ .../adapters/outbound/config/config.test.ts | 17 + ts/src/adapters/outbound/config/index.ts | 4 + .../outbound/x402/payment-client.test.ts | 68 ++++ .../adapters/outbound/x402/payment-client.ts | 248 ++++++++++++ .../outbound/x402/provider-catalog.test.ts | 36 ++ .../outbound/x402/provider-catalog.ts | 205 ++++++++++ ts/src/adapters/outbound/x402/server.test.ts | 9 + ts/src/adapters/outbound/x402/server.ts | 233 +++++++++++ .../outbound/x402/wallet-signer.test.ts | 43 ++ .../adapters/outbound/x402/wallet-signer.ts | 33 ++ ts/src/application/ports/agent-registry.ts | 44 +++ ts/src/application/ports/bai-api.ts | 41 ++ ts/src/application/ports/provider-catalog.ts | 20 + ts/src/application/ports/x402-payment.ts | 23 ++ ts/src/application/ports/x402-server.ts | 20 + .../use-cases/agent-service.test.ts | 79 ++++ ts/src/application/use-cases/agent-service.ts | 184 +++++++++ .../application/use-cases/bai-service.test.ts | 76 ++++ ts/src/application/use-cases/bai-service.ts | 175 ++++++++ .../use-cases/config-service.test.ts | 16 + .../application/use-cases/config-service.ts | 15 +- .../use-cases/x402-service.test.ts | 28 ++ ts/src/application/use-cases/x402-service.ts | 54 +++ ts/src/bootstrap/composition.ts | 28 ++ ts/src/bootstrap/runner.test.ts | 6 + ts/src/domain/erc8004/erc8004.test.ts | 33 ++ ts/src/domain/erc8004/index.ts | 54 +++ ts/src/domain/errors/codes.ts | 7 + ts/src/domain/types/network.ts | 2 + 46 files changed, 3134 insertions(+), 75 deletions(-) create mode 100644 ts/src/adapters/inbound/cli/commands/bai.test.ts create mode 100644 ts/src/adapters/inbound/cli/commands/bai.ts create mode 100644 ts/src/adapters/inbound/cli/commands/erc8004.test.ts create mode 100644 ts/src/adapters/inbound/cli/commands/erc8004.ts create mode 100644 ts/src/adapters/inbound/cli/commands/x402.test.ts create mode 100644 ts/src/adapters/inbound/cli/commands/x402.ts create mode 100644 ts/src/adapters/outbound/bai/client.test.ts create mode 100644 ts/src/adapters/outbound/bai/client.ts create mode 100644 ts/src/adapters/outbound/x402/payment-client.test.ts create mode 100644 ts/src/adapters/outbound/x402/payment-client.ts create mode 100644 ts/src/adapters/outbound/x402/provider-catalog.test.ts create mode 100644 ts/src/adapters/outbound/x402/provider-catalog.ts create mode 100644 ts/src/adapters/outbound/x402/server.test.ts create mode 100644 ts/src/adapters/outbound/x402/server.ts create mode 100644 ts/src/adapters/outbound/x402/wallet-signer.test.ts create mode 100644 ts/src/adapters/outbound/x402/wallet-signer.ts create mode 100644 ts/src/application/ports/agent-registry.ts create mode 100644 ts/src/application/ports/bai-api.ts create mode 100644 ts/src/application/ports/provider-catalog.ts create mode 100644 ts/src/application/ports/x402-payment.ts create mode 100644 ts/src/application/ports/x402-server.ts create mode 100644 ts/src/application/use-cases/agent-service.test.ts create mode 100644 ts/src/application/use-cases/agent-service.ts create mode 100644 ts/src/application/use-cases/bai-service.test.ts create mode 100644 ts/src/application/use-cases/bai-service.ts create mode 100644 ts/src/application/use-cases/x402-service.test.ts create mode 100644 ts/src/application/use-cases/x402-service.ts create mode 100644 ts/src/domain/erc8004/erc8004.test.ts create mode 100644 ts/src/domain/erc8004/index.ts diff --git a/ts/package-lock.json b/ts/package-lock.json index b10b1c0f3..8835ad27c 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -9,6 +9,10 @@ "version": "4.13.0", "license": "LGPL-3.0-or-later", "dependencies": { + "@bankofai/x402-core": "^1.1.0", + "@bankofai/x402-evm": "^1.1.0", + "@bankofai/x402-fetch": "^1.1.0", + "@bankofai/x402-tron": "^1.2.0", "@ledgerhq/hw-app-eth": "^7.8.15", "@ledgerhq/hw-app-trx": "^6.36.3", "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", @@ -71,6 +75,63 @@ "node": ">=6.9.0" } }, + "node_modules/@bankofai/x402-core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.1.0.tgz", + "integrity": "sha512-5cUUo2/4IdoRX78gZRRc4P5JmlVKwkEYfORZSY+eOkGjnguIwnFSfvshTIFi5Ngoy/JlJlf89f6szn+CyCHDVg==", + "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==", + "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==", + "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==", + "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==", + "license": "Apache-2.0", + "dependencies": { + "@bankofai/x402-core": "~1.1.0" + } + }, + "node_modules/@bankofai/x402-tron": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.2.0.tgz", + "integrity": "sha512-uUedJ+Qsh76/5VUTs1bLJ9XQZi1DLBskwd/qnXSlxB34BFsdiJ1hH93jH5zqCK9VrYdWfYxCvkOYo5+aGY8CxQ==", + "license": "Apache-2.0", + "dependencies": { + "@bankofai/x402-core": "~1.1.0", + "tronweb": "^6.1.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1756,9 +1817,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1776,9 +1834,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1796,9 +1851,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1816,9 +1868,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1836,9 +1885,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1856,9 +1902,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2037,9 +2080,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2054,9 +2094,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2071,9 +2108,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2088,9 +2122,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2105,9 +2136,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2122,9 +2150,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2139,9 +2164,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2156,9 +2178,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2173,9 +2192,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2190,9 +2206,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2207,9 +2220,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2224,9 +2234,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2241,9 +2248,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2807,6 +2811,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", @@ -4584,6 +4609,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 +4872,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4856,9 +4893,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4880,9 +4914,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4904,9 +4935,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5266,6 +5294,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 +6643,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 +6716,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..18dbbc838 100644 --- a/ts/package.json +++ b/ts/package.json @@ -54,6 +54,10 @@ }, "license": "LGPL-3.0-or-later", "dependencies": { + "@bankofai/x402-core": "^1.1.0", + "@bankofai/x402-evm": "^1.1.0", + "@bankofai/x402-fetch": "^1.1.0", + "@bankofai/x402-tron": "^1.2.0", "@ledgerhq/hw-app-eth": "^7.8.15", "@ledgerhq/hw-app-trx": "^6.36.3", "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", 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..3c774e7a5 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/bai.test.ts @@ -0,0 +1,46 @@ +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 usage dates and bounded list pagination", () => { + const registry = new CommandRegistry(); + registerBaiCommands(registry, service()); + expect(Object.keys(registry.resolveNeutral(["bai", "usage"])!.fields.shape)).toEqual([ + "from", + "to", + ]); + 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); + }); +}); 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..38f822d7e --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/bai.ts @@ -0,0 +1,124 @@ +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 date = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "must use YYYY-MM-DD") + .optional(); + +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) + .default("USDT") + .describe("recharge token symbol; currently USDT"), + }); + registry.add({ + path: ["bai", "recharge"], + network: "optional", + wallet: "optional", + auth: "conditional", + broadcasts: true, + capability: "bai.recharge", + requires, + positionals: [{ field: "amount" }], + summary: "Recharge B.AI through an x402 payment", + description: + "Recharge B.AI using the selected network and token. Mainnet TRON supports exact and exact_gasfree; BSC supports exact.", + 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 bsc --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, + apiKey: ctx.config.baiApiKey, + }); + }, + } 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); + + const usageFields = z.object({ + from: date.describe("first UTC day to include; defaults to 29 days before --to"), + to: date.describe("last UTC day to include; defaults to today"), + }); + registry.add({ + path: ["bai", "usage"], + network: "none", + wallet: "none", + auth: "none", + requires, + summary: "Summarize B.AI usage over a UTC date range", + fields: usageFields, + input: usageFields, + examples: [ + { cmd: "wallet-cli bai usage" }, + { cmd: "wallet-cli bai usage --from 2026-08-01 --to 2026-08-31" }, + ], + run: async (_context, _network, input) => service.usage(input), + } satisfies CommandDefinition); + + registry.add({ + path: ["bai", "usage-list"], + network: "none", + wallet: "none", + auth: "none", + requires, + summary: "List individual B.AI usage records", + fields: listFields, + input: listFields, + 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.ts b/ts/src/adapters/inbound/cli/commands/config.ts index 123c8dc51..c7db5b51d 100644 --- a/ts/src/adapters/inbound/cli/commands/config.ts +++ b/ts/src/adapters/inbound/cli/commands/config.ts @@ -7,6 +7,7 @@ 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 { const fields = z.object({ @@ -28,6 +29,7 @@ export function registerConfigCommands(registry: CommandRegistry, service: Confi network: "none", wallet: "none", auth: "none", + stdin: "apiKey", summary: "Show / get / set configuration values", positionals: [{ field: "key" }, { field: "value" }], fields, @@ -38,8 +40,27 @@ 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" }, ], 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; + 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..a78c579bb --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/erc8004.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { CommandRegistry } from "../registry/index.js"; +import { registerAgentCommands } from "./erc8004.js"; +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()).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"]); + }); +}); 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..862b1190b --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/erc8004.ts @@ -0,0 +1,219 @@ +import { z } from "zod"; +import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; +import type { CommandRegistry } from "../registry/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 } 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) + .regex(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, "must be an absolute URI such as https:// or ipfs://") + .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, + }; +} + +const showSpec: ChainSpec = { + path: ["8004", "show"], + network: "optional", + wallet: "none", + auth: "none", + capability: "erc8004.identity.read", + 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" }, + ], +}; + +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" }, +]; + +const updateSpec = writeSpec( + "update", + z.object({ id: agentId, uri }), + [{ field: "id" }, { field: "uri" }], + "Update an Agent registration URI", +); + +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"), +}); +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", + }); + } +}; + +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", +); + +const operatorRemoveSpec = writeSpec( + "operator-remove", + z.object({ operator: address.describe("operator address") }), + [{ field: "operator" }], + "Remove an owner-wide Agent operator", +); + +const operatorCheckSpec: ChainSpec = { + path: ["8004", "operator-check"], + network: "optional", + wallet: "none", + auth: "none", + capability: "erc8004.identity.read", + 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 addBoth( + registry: CommandRegistry, + spec: ChainSpec, + run: FamilyBinding["run"], + addressFields: string[] = [], + write = false, +): void { + for (const family of ["evm", "tron"] as const) { + registry.addChain(spec, family, { + run, + ...(write && family === "tron" ? { fields: tronWriteFields } : {}), + ...(addressFields.length ? { refine: addressFieldsFor(family, ...addressFields) } : {}), + }); + } +} + +export function registerAgentCommands(registry: CommandRegistry, service: AgentService): void { + addBoth(registry, showSpec, async (_ctx, net, input) => service.show(net, input.id)); + addBoth( + registry, + registerSpec, + async (ctx, net, input) => service.register(ctx, net, input), + [], + true, + ); + addBoth( + registry, + updateSpec, + async (ctx, net, input) => service.update(ctx, net, input), + [], + true, + ); + addBoth( + registry, + transferSpec, + async (ctx, net, input) => service.transfer(ctx, net, input), + ["newOwner"], + true, + ); + addBoth( + registry, + approveSpec, + async (ctx, net, input) => service.approve(ctx, net, input), + ["operator"], + true, + ); + addBoth( + registry, + operatorAddSpec, + async (ctx, net, input) => service.operatorAdd(ctx, net, input), + ["operator"], + true, + ); + addBoth( + registry, + operatorRemoveSpec, + async (ctx, net, input) => service.operatorRemove(ctx, net, input), + ["operator"], + true, + ); + addBoth( + registry, + operatorCheckSpec, + async (_ctx, net, input) => service.operatorCheck(net, input.owner, input.operator), + ["owner", "operator"], + ); +} 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..21545b402 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/x402.test.ts @@ -0,0 +1,43 @@ +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(); + }); +}); 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..f173499f8 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/x402.ts @@ -0,0 +1,206 @@ +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"; + +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"), + 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"), +}); +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.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" }, + ], + 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"); + return service.pay(ctx, network, { + url: input.url, + method: input.method, + headers: input.header, + ...(input.body === undefined ? {} : { body: input.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 }), + }); + }, + }); + + 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); +} 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/shell/positional-contract.test.ts b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts index 90fb71ece..2175e29df 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,18 @@ 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", "block", "config", "contact add", @@ -115,6 +124,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/client.test.ts b/ts/src/adapters/outbound/bai/client.test.ts new file mode 100644 index 000000000..20cc7f68d --- /dev/null +++ b/ts/src/adapters/outbound/bai/client.test.ts @@ -0,0 +1,138 @@ +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("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?batch=1&input="); + expect((init as RequestInit).headers).toMatchObject({ Authorization: "Bearer secret" }); + }); + + it("normalizes usage stats and preserves exact quantities as strings", async () => { + const fetcher = vi.fn(async () => + response([ + { + result: { + data: { + json: { + totalMessages: 5, + totalSessions: 2, + totalTokens: "9007199254740993", + totalCost: 8.25, + byModel: [{ model: "m", count: 5, tokens: 99, cost: 8.25 }], + byDate: [{ date: "2026-09-01", count: 5 }], + }, + }, + }, + }, + ]), + ); + const client = new BaiClient( + { baiApiKey: "secret" }, + 1000, + fetcher as typeof fetch, + "https://bai.example", + ); + + await expect(client.usage({ range: ["2026-08-06", "2026-09-04"] })).resolves.toMatchObject({ + totalMessages: "5", + totalSessions: "2", + totalTokens: "9007199254740993", + totalCost: "8.25", + byModel: [{ model: "m", count: "5", tokens: "99", cost: "8.25" }], + }); + const call = fetcher.mock.calls[0]! as unknown as [string | URL | Request, RequestInit]; + const input = new URL(String(call[0])).searchParams.get("input")!; + expect(JSON.parse(input)).toEqual({ + 0: { json: { range: ["2026-08-06", "2026-09-04"] } }, + }); + }); + + 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..aae2ba54a --- /dev/null +++ b/ts/src/adapters/outbound/bai/client.ts @@ -0,0 +1,167 @@ +import { z } from "zod"; +import type { + BaiApi, + BaiPageInput, + BaiPageView, + BaiStatusView, + BaiUsageInput, + BaiUsageStatsView, +} 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.ainft.com"; + +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 = ObjectSchema.parse(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 usage(input: BaiUsageInput): Promise { + const value = ObjectSchema.parse(await this.call("usage.getUsageStats", input)); + return { + totalMessages: scalar(value.totalMessages, "totalMessages"), + totalSessions: scalar(value.totalSessions, "totalSessions"), + totalTokens: scalar(value.totalTokens, "totalTokens"), + totalCost: scalar(value.totalCost, "totalCost"), + byModel: arrayOfObjects(value.byModel).map((row) => ({ + model: scalar(row.model, "byModel.model"), + count: scalar(row.count, "byModel.count"), + tokens: scalar(row.tokens, "byModel.tokens"), + cost: scalar(row.cost, "byModel.cost"), + })), + byDate: arrayOfObjects(value.byDate).map((row) => ({ + date: scalar(row.date, "byDate.date"), + count: scalar(row.count, "byDate.count"), + })), + }; + } + + async usageList(input: BaiPageInput): Promise { + return page(await this.call("usage.records", input)); + } + + async rechargeList(input: BaiPageInput): Promise { + return page( + await this.call("order.listOrders", { + page: input.page, + pageSize: input.pageSize, + sortBy: 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 url = `${this.baseUrl}/trpc/lambda/${procedure}?batch=1&input=${encoded}`; + let response: Response; + try { + response = await this.fetcher(url, { + method: "GET", + headers: { Accept: "application/json", Authorization: `Bearer ${key}` }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch (error) { + if (error instanceof Error && error.name === "TimeoutError") { + throw new TransportError("timeout", "B.AI API request timed out"); + } + throw new TransportError("provider_error", "B.AI API request failed"); + } + if (response.status === 401 || response.status === 403) { + throw new TransportError("bai_auth_failed", "B.AI API rejected the configured API key"); + } + if (response.status === 429) { + throw new TransportError("provider_rate_limited", "B.AI API rate limit exceeded"); + } + if (!response.ok) { + throw new TransportError("provider_error", `B.AI API returned HTTP ${response.status}`); + } + + let decoded: unknown; + try { + decoded = JSON.parse(await response.text()); + } catch { + throw new TransportError("provider_error", "B.AI API returned malformed JSON"); + } + const first = Array.isArray(decoded) ? decoded[0] : decoded; + 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 = ObjectSchema.parse(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 }), + }; +} + +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; +} diff --git a/ts/src/adapters/outbound/config/config.test.ts b/ts/src/adapters/outbound/config/config.test.ts index f5ad34a23..1d029cda0 100644 --- a/ts/src/adapters/outbound/config/config.test.ts +++ b/ts/src/adapters/outbound/config/config.test.ts @@ -100,6 +100,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", () => { 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/x402/payment-client.test.ts b/ts/src/adapters/outbound/x402/payment-client.test.ts new file mode 100644 index 000000000..1a5f79bd4 --- /dev/null +++ b/ts/src/adapters/outbound/x402/payment-client.test.ts @@ -0,0 +1,68 @@ +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"; + +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("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(); + }); +}); 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..33a14982f --- /dev/null +++ b/ts/src/adapters/outbound/x402/payment-client.ts @@ -0,0 +1,248 @@ +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 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 { CliError, TransportError, UsageError } from "../../../domain/errors/index.js"; +import { WalletX402Signer } from "./wallet-signer.js"; + +type PaidFetchFactory = ( + network: NetworkDescriptor, + signer: Signer, + scope: TransactionScope, +) => Promise; + +const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; + +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); + this.signers.assertCanSign(scope.activeAccount, network.family); + const signer = this.signers.resolve(scope.activeAccount, network.family); + const paidFetch = this.paidFetchFactory + ? await this.paidFetchFactory(network, signer, scope) + : await this.createPaidFetch(network, signer, scope, input); + + let response: Response; + try { + response = await paidFetch(input.url, { + method: input.method, + headers, + redirect: "error", + ...(input.body === undefined ? {} : { body: input.body }), + }); + } catch (error) { + if (error instanceof CliError) throw error; + if (error instanceof Error && /timeout|aborted/i.test(`${error.name} ${error.message}`)) { + throw new TransportError("timeout", "x402 request timed out"); + } + throw new TransportError("provider_error", "x402 request or payment failed"); + } + + 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 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"); + } + } + 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", + ); + } + } + return { + url: input.url, + status: response.status, + delivered: response.ok, + settled: paymentResponse !== undefined, + payer: { address: signer.address }, + ...(paymentResponse === undefined ? {} : { paymentResponse }), + response: body, + }; + } + + private async createPaidFetch( + network: NetworkDescriptor, + signer: Signer, + scope: TransactionScope, + input: X402PayInput, + ): Promise { + const bridge = new WalletX402Signer(signer, scope); + 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) => { + const matching = requirements.filter((requirement) => { + 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; + 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", + ); + } + return matching; + }); + const x402Network = + network.family === "tron" ? `tron:0x${BigInt(network.chainId).toString(16)}` : network.id; + 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: "skip", + }); + registerExactTronScheme(client, { + signer: tronSigner as ClientTronSigner, + networks: [x402Network as Network], + }); + registerExactGasFreeTronScheme(client, { + signer: tronSigner as ClientTronSigner, + networks: [x402Network as Network], + }); + } + const boundedFetch: typeof fetch = (request, init) => + this.fetcher(request, { + ...init, + signal: init?.signal ?? AbortSignal.timeout(scope.timeoutMs), + }); + return wrapFetchWithPayment(boundedFetch, client); + } +} + +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: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(); +} + +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; +} 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..1e022f680 --- /dev/null +++ b/ts/src/adapters/outbound/x402/provider-catalog.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import { X402ProviderCatalog } from "./provider-catalog.js"; + +describe("X402ProviderCatalog", () => { + 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" }); + }); +}); 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..3036da21a --- /dev/null +++ b/ts/src/adapters/outbound/x402/provider-catalog.ts @@ -0,0 +1,205 @@ +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 { 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"), + ) {} + + 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 this.fetcher(url, { headers: { accept: "application/json" } }); + } catch { + 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", + }; + return aliases[value.toLowerCase()] ?? normalizeNetwork(value); +} 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..4945dc8ae --- /dev/null +++ b/ts/src/adapters/outbound/x402/server.test.ts @@ -0,0 +1,9 @@ +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/); + }); +}); diff --git a/ts/src/adapters/outbound/x402/server.ts b/ts/src/adapters/outbound/x402/server.ts new file mode 100644 index 000000000..1dce141c7 --- /dev/null +++ b/ts/src/adapters/outbound/x402/server.ts @@ -0,0 +1,233 @@ +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: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) {} + + async start(network: NetworkDescriptor, input: X402ServeInput): Promise { + 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); + const x402Network = + network.family === "tron" ? `tron:0x${BigInt(network.chainId).toString(16)}` : network.id; + const resourceUrl = `http://${input.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) => { + const pathname = new URL(request.url ?? "/", resourceUrl).pathname; + 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); + } + try { + const paymentPayload = decodePaymentSignatureHeader(signature); + const verify = await this.facilitator(input.facilitatorUrl, "/verify", { + paymentPayload, + paymentRequirements: requirement, + }); + if (!(verify.valid === true || verify.isValid === true)) + return json(response, 400, { error: "payment verification failed" }); + const settle = await this.facilitator(input.facilitatorUrl, "/settle", { + paymentPayload, + paymentRequirements: requirement, + }); + if (settle.success !== true || typeof settle.transaction !== "string") + return json(response, 502, { error: "settlement failed" }); + response.setHeader("payment-response", encodePaymentResponseHeader(settle as never)); + return json(response, 200, { + success: true, + network: x402Network, + scheme: input.scheme, + transaction: settle.transaction, + }); + } catch { + return json(response, 502, { error: "facilitator request failed" }); + } + }); + await listen(server, input.host, input.port); + 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 this.fetcher(new URL(path, `${base.replace(/\/+$/, "")}/`), { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify(body), + }); + 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())), + ); +} diff --git a/ts/src/adapters/outbound/x402/wallet-signer.test.ts b/ts/src/adapters/outbound/x402/wallet-signer.test.ts new file mode 100644 index 000000000..854358ae9 --- /dev/null +++ b/ts/src/adapters/outbound/x402/wallet-signer.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import { WalletX402Signer } from "./wallet-signer.js"; +import type { Signer } from "../../../domain/types/index.js"; + +const payload = { + domain: { name: "Token", chainId: 56 }, + types: { Pay: [{ name: "value", type: "uint256" }] }, + primaryType: "Pay", + message: { value: "1" }, +}; + +describe("WalletX402Signer", () => { + it("returns only the wallet signature to the x402 SDK", async () => { + const signer = { + kind: "software", + address: "0x1111111111111111111111111111111111111111", + signTypedData: vi.fn(async () => ({ + signature: "0xsig", + digest: "0xhash", + primaryType: "Pay", + })), + } as unknown as Signer; + const scope = { timeoutMs: 1000, emit: vi.fn() }; + const bridge = new WalletX402Signer(signer, scope); + + await expect(bridge.signTypedData(payload)).resolves.toBe("0xsig"); + expect(bridge.address).toBe(signer.address); + expect(await bridge.getAddress()).toBe(signer.address); + }); + + it("preserves Ledger precheck and awaiting-device behavior", async () => { + const signer = { + kind: "device", + address: "TAddress", + precheck: vi.fn(async () => undefined), + signTypedData: vi.fn(async () => ({ signature: "abcd", digest: "hash", primaryType: "Pay" })), + } as unknown as Signer; + const scope = { timeoutMs: 1000, emit: vi.fn() }; + await new WalletX402Signer(signer, scope).signTypedData(payload); + expect(signer.precheck).toHaveBeenCalledOnce(); + expect(scope.emit).toHaveBeenCalledWith({ type: "awaiting_device", reason: "sign" }); + }); +}); diff --git a/ts/src/adapters/outbound/x402/wallet-signer.ts b/ts/src/adapters/outbound/x402/wallet-signer.ts new file mode 100644 index 000000000..f6c62158e --- /dev/null +++ b/ts/src/adapters/outbound/x402/wallet-signer.ts @@ -0,0 +1,33 @@ +import type { Signer } from "../../../domain/types/index.js"; +import { normalizeTypedData } from "../../../domain/typed-data/index.js"; +import type { SigningScope } from "../../../application/services/signing/obtain-signature.js"; +import { obtainSignature } from "../../../application/services/signing/obtain-signature.js"; + +/** External-wallet bridge shared by the EVM and TRON x402 packages. */ +export class WalletX402Signer { + readonly address: string; + + constructor( + private readonly signer: Signer, + private readonly scope: SigningScope, + ) { + this.address = signer.address; + } + + getAddress(): string { + return this.address; + } + + async signTypedData(args: { + domain: Record; + types: Record; + primaryType: string; + message: Record; + }): Promise<`0x${string}`> { + const payload = normalizeTypedData(args); + const result = await obtainSignature(this.signer, this.scope, (options) => + this.signer.signTypedData(payload, options), + ); + return `0x${result.signature.replace(/^0x/, "")}`; + } +} diff --git a/ts/src/application/ports/agent-registry.ts b/ts/src/application/ports/agent-registry.ts new file mode 100644 index 000000000..98dd4bc69 --- /dev/null +++ b/ts/src/application/ports/agent-registry.ts @@ -0,0 +1,44 @@ +import type { NetworkDescriptor } from "../../domain/types/index.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; +import type { EvmContractWriteInput } from "../use-cases/evm/contract-service.js"; +import type { GovernanceTransactionInput } from "../use-cases/tron/governance-transaction.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; + parameters: TronContractParameter[]; + callValueSun: string; + feeLimit: string; + }, + ): Promise>; +} + +export interface AgentContractPorts { + evm: EvmContractPort; + tron: TronContractPort; +} diff --git a/ts/src/application/ports/bai-api.ts b/ts/src/application/ports/bai-api.ts new file mode 100644 index 000000000..09e20a971 --- /dev/null +++ b/ts/src/application/ports/bai-api.ts @@ -0,0 +1,41 @@ +export interface BaiStatusView { + pointsBalance: string; + monthlySpent: string; + monthlyChart: Array<{ month: string; points: string }>; +} + +export interface BaiUsageInput { + range?: [string, string]; + startDate?: string; + endDate?: string; +} + +export interface BaiUsageStatsView { + totalMessages: string; + totalSessions: string; + totalTokens: string; + totalCost: string; + byModel: Array<{ model: string; count: string; tokens: string; cost: string }>; + byDate: Array<{ date: string; count: string }>; +} + +export interface BaiPageInput { + page: number; + pageSize: number; + sortBy: string; + sortOrder: "asc" | "desc"; +} + +export interface BaiPageView { + items: Record[]; + page: number; + pageSize: number; + total?: number; +} + +export interface BaiApi { + status(): Promise; + usage(input: BaiUsageInput): Promise; + usageList(input: BaiPageInput): Promise; + rechargeList(input: BaiPageInput): Promise; +} 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..937fa2cc3 --- /dev/null +++ b/ts/src/application/ports/x402-payment.ts @@ -0,0 +1,23 @@ +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; +} + +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..1e0deb0bd --- /dev/null +++ b/ts/src/application/ports/x402-server.ts @@ -0,0 +1,20 @@ +import type { NetworkDescriptor } from "../../domain/types/index.js"; + +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 { + start(network: NetworkDescriptor, input: X402ServeInput): Promise; +} 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..8b053fe85 --- /dev/null +++ b/ts/src/application/use-cases/agent-service.test.ts @@ -0,0 +1,79 @@ +import { Interface } from "ethers"; +import { describe, expect, it, vi } from "vitest"; +import { AgentService } from "./agent-service.js"; +import type { AgentContractPorts } from "../ports/agent-registry.js"; +import type { NetworkDescriptor } from "../../domain/types/index.js"; + +const ABI = new Interface([ + "function ownerOf(uint256 agentId) view returns (address)", + "function tokenURI(uint256 agentId) view returns (string)", + "function getApproved(uint256 agentId) view returns (address)", + "function isApprovedForAll(address owner,address operator) view returns (bool)", +]); + +const evmNet = { id: "eip155:56", family: "evm" } as NetworkDescriptor; + +describe("AgentService", () => { + it("loads one agent directly from the registry", async () => { + const call = vi + .fn() + .mockResolvedValueOnce({ + result: ABI.encodeFunctionResult("ownerOf", ["0x1111111111111111111111111111111111111111"]), + }) + .mockResolvedValueOnce({ result: ABI.encodeFunctionResult("tokenURI", ["ipfs://agent"]) }) + .mockResolvedValueOnce({ + result: ABI.encodeFunctionResult("getApproved", [ + "0x2222222222222222222222222222222222222222", + ]), + }); + const ports = { + evm: { call, send: vi.fn() }, + tron: { call: vi.fn(), send: vi.fn() }, + } as unknown as AgentContractPorts; + + await expect(new AgentService(ports).show(evmNet, "42")).resolves.toEqual({ + agentId: "42", + owner: "0x1111111111111111111111111111111111111111", + uri: "ipfs://agent", + approved: "0x2222222222222222222222222222222222222222", + registry: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + }); + }); + + it("builds register through the existing contract transaction port", async () => { + const send = vi.fn(async () => ({ stage: "submitted", txId: "0xabc" })); + const ports = { + evm: { call: vi.fn(), send }, + tron: { call: vi.fn(), send: vi.fn() }, + } as unknown as AgentContractPorts; + const scope = {} as never; + + await new AgentService(ports).register(scope, evmNet, { uri: "ipfs://agent" }); + expect(send).toHaveBeenCalledWith( + scope, + evmNet, + expect.objectContaining({ + method: "register(string)", + params: [{ type: "string", value: "ipfs://agent" }], + }), + ); + }); + + it("checks an owner-wide operator approval on chain", async () => { + const call = vi.fn(async () => ({ + result: ABI.encodeFunctionResult("isApprovedForAll", [true]), + })); + const ports = { + evm: { call, send: vi.fn() }, + tron: { call: vi.fn(), send: vi.fn() }, + } as unknown as AgentContractPorts; + + await expect( + new AgentService(ports).operatorCheck( + evmNet, + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222", + ), + ).resolves.toMatchObject({ approved: true }); + }); +}); 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..2e93c6479 --- /dev/null +++ b/ts/src/application/use-cases/agent-service.ts @@ -0,0 +1,184 @@ +import { Interface } from "ethers"; +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 { identityRegistryFor, resolveAgentId } from "../../domain/erc8004/index.js"; +import { tronHexToBase58 } from "../../domain/address/index.js"; + +const IDENTITY = new Interface([ + "function ownerOf(uint256 agentId) view returns (address)", + "function tokenURI(uint256 agentId) view returns (string)", + "function getApproved(uint256 agentId) view returns (address)", + "function isApprovedForAll(address owner,address operator) view returns (bool)", +]); + +interface TransactionOptions { + dryRun?: boolean; + signOnly?: boolean; + buildOnly?: boolean; + permissionId?: number; + expiration?: number; + feeLimit?: string; +} + +export class AgentService { + constructor(private readonly contracts: AgentContractPorts) {} + + async show(network: NetworkDescriptor, id: string) { + const agentId = resolveAgentId(id, network).toString(); + const registry = identityRegistryFor(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 }]), + ]); + return { + agentId, + owner: this.address(network, String(owner)), + uri: String(uri), + approved: this.address(network, String(approved)), + registry, + }; + } + + register( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionOptions & { uri: string }, + ) { + return this.write(scope, network, input, "register(string)", [ + { type: "string", value: input.uri }, + ]); + } + + update( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionOptions & { id: string; uri: string }, + ) { + const id = resolveAgentId(input.id, network).toString(); + return this.write(scope, network, input, "setAgentURI(uint256,string)", [ + { type: "uint256", value: id }, + { type: "string", value: input.uri }, + ]); + } + + async transfer( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionOptions & { id: string; newOwner: string }, + ) { + const id = resolveAgentId(input.id, network).toString(); + const registry = identityRegistryFor(network); + const owner = String( + await this.read(network, registry, "ownerOf(uint256)", [{ type: "uint256", value: id }]), + ); + return 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 }, + ]); + } + + 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 = identityRegistryFor(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 { + const response = await this.contracts[network.family].call(network, registry, method, params); + const raw = network.family === "tron" ? response.result[0] : response.result; + const data = String(raw ?? ""); + const [decoded] = IDENTITY.decodeFunctionResult( + method, + data.startsWith("0x") ? data : `0x${data}`, + ); + return decoded; + } + + private write( + scope: TransactionScope, + network: NetworkDescriptor, + input: TransactionOptions, + method: string, + params: Array<{ type: string; value: unknown }>, + ) { + const contract = identityRegistryFor(network); + if (network.family === "evm") { + return this.contracts.evm.send(scope, network, { + ...input, + contract, + method, + params, + }); + } + return this.contracts.tron.send(scope, network, { + ...input, + 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-service.test.ts b/ts/src/application/use-cases/bai-service.test.ts new file mode 100644 index 000000000..43d586b1e --- /dev/null +++ b/ts/src/application/use-cases/bai-service.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { BaiService } from "./bai-service.js"; +import type { BaiApi } from "../ports/bai-api.js"; +import type { X402PaymentPort } from "../ports/x402-payment.js"; + +function api(): BaiApi { + return { + status: vi.fn(async () => ({ + pointsBalance: "1200000", + monthlySpent: "300000", + monthlyChart: [{ month: "2026-09", points: "300000" }], + })), + usage: vi.fn(async () => ({ + totalMessages: "4", + totalSessions: "2", + totalTokens: "100", + totalCost: "25", + byModel: [{ model: "m", count: "4", tokens: "100", cost: "25" }], + byDate: [{ date: "2026-09-01", count: "4" }], + })), + 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("defaults usage to the latest 30 UTC calendar days", async () => { + const remote = api(); + const service = new BaiService(remote, () => new Date("2026-09-04T18:00:00Z")); + await service.usage({}); + expect(remote.usage).toHaveBeenCalledWith({ + startDate: "2026-08-06", + endDate: "2026-09-04", + range: ["2026-08-06", "2026-09-04"], + }); + }); + + it("rejects an inverted date range before calling B.AI", async () => { + const remote = api(); + await expect( + new BaiService(remote).usage({ from: "2026-09-02", to: "2026-09-01" }), + ).rejects.toMatchObject({ code: "invalid_value" }); + expect(remote.usage).not.toHaveBeenCalled(); + }); + + it("sends recharge through x402 without exposing the API key in the result", async () => { + const payments = { + pay: vi.fn(async () => ({ delivered: true, settled: true })), + } as unknown as X402PaymentPort; + const service = new BaiService(api(), () => new Date(), payments); + const result = await service.recharge({} as never, { id: "tron:728126428" } as never, { + amount: "10", + token: "USDT", + apiKey: "secret-key", + }); + expect(result).toMatchObject({ network: "tron:728126428", token: "USDT", amount: "10" }); + expect(JSON.stringify(result)).not.toContain("secret-key"); + expect(payments.pay).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + url: "https://recharge.bankofai.io/mcp", + method: "POST", + headers: expect.arrayContaining(["Authorization: Bearer secret-key"]), + }), + ); + }); +}); 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..c4416a70b --- /dev/null +++ b/ts/src/application/use-cases/bai-service.ts @@ -0,0 +1,175 @@ +import type { BaiApi, BaiPageInput } from "../ports/bai-api.js"; +import { UsageError } from "../../domain/errors/index.js"; +import type { X402PaymentPort } from "../ports/x402-payment.js"; +import type { TransactionScope } from "../contracts/execution-scope.js"; +import type { NetworkDescriptor } from "../../domain/types/index.js"; + +export interface BaiUsageCommandInput { + from?: string; + to?: string; +} + +export interface BaiListCommandInput { + limit: number; + offset: number; + sort: "asc" | "desc"; +} + +export class BaiService { + constructor( + private readonly api: BaiApi, + private readonly now: () => Date = () => new Date(), + private readonly payments?: X402PaymentPort, + ) {} + + async recharge( + scope: TransactionScope, + network: NetworkDescriptor, + input: { amount: string; token: string; apiKey?: string }, + ) { + if (!input.apiKey) { + throw new UsageError( + "bai_credentials_missing", + "configure baiApiKey before using B.AI recharge", + ); + } + if (!this.payments) { + throw new UsageError("invalid_option", "B.AI recharge is not available in this runtime"); + } + const body = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "recharge", arguments: { amount: input.amount, token: input.token } }, + }); + const result = await this.payments.pay(scope, network, { + url: "https://recharge.bankofai.io/mcp", + method: "POST", + headers: [ + "Content-Type: application/json", + "Accept: application/json, text/event-stream", + `Authorization: Bearer ${input.apiKey}`, + ], + body, + token: input.token, + maxAmount: input.amount, + }); + return { network: network.id, token: input.token, amount: input.amount, ...result }; + } + + 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(input: BaiUsageCommandInput) { + const to = input.to ?? utcDate(this.now()); + const from = input.from ?? utcDate(addUtcDays(parseDate(to, "to"), -29)); + const fromDate = parseDate(from, "from"); + const toDate = parseDate(to, "to"); + if (fromDate.getTime() > toDate.getTime()) { + throw new UsageError("invalid_value", "--from must not be later than --to"); + } + const result = await this.api.usage({ startDate: from, endDate: to, range: [from, to] }); + return { + from, + to, + messages: result.totalMessages, + sessions: result.totalSessions, + tokens: result.totalTokens, + credits: result.totalCost, + byModel: result.byModel.map((row) => ({ + model: row.model, + messages: row.count, + tokens: row.tokens, + credits: row.cost, + })), + byDate: result.byDate.map((row) => ({ date: row.date, messages: row.count })), + }; + } + + async usageList(input: BaiListCommandInput) { + const result = await this.api.usageList(pageInput(input)); + 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 }, + }; + } + + 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 parseDate(value: string, field: string): Date { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new UsageError("invalid_value", `--${field} must use YYYY-MM-DD`); + } + const date = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(date.getTime()) || utcDate(date) !== value) { + throw new UsageError("invalid_value", `--${field} must be a real UTC calendar date`); + } + return date; +} + +function addUtcDays(date: Date, days: number): Date { + return new Date(date.getTime() + days * 86_400_000); +} + +function utcDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +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; +} 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/x402-service.test.ts b/ts/src/application/use-cases/x402-service.test.ts new file mode 100644 index 000000000..1a70efb2c --- /dev/null +++ b/ts/src/application/use-cases/x402-service.test.ts @@ -0,0 +1,28 @@ +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 }); + }); +}); 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..faa9e7322 --- /dev/null +++ b/ts/src/application/use-cases/x402-service.ts @@ -0,0 +1,54 @@ +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(); + } + + 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: [], + }); + 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..2cd740ba9 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -43,6 +43,18 @@ 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 { AgentService } from "../application/use-cases/agent-service.js"; +import { registerAgentCommands } from "../adapters/inbound/cli/commands/erc8004.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; @@ -110,6 +122,22 @@ export function composeCliRuntime(options: BootstrapOptions) { registerContactCommands(registry, new ContactService(contactBook)); registerEncodingCommands(registry, new EncodingService()); registerAddressCommands(registry, new AddressService(new SecureKeypairWriter(root))); + const x402Payments = new X402PaymentClient(signerResolver); + registerBaiCommands( + registry, + new BaiService(new BaiClient(config, timeoutMs), () => new Date(), x402Payments), + ); + registerAgentCommands( + registry, + new AgentService({ + evm: new EvmContractService(gatewayProvider, txPipeline), + tron: new TronContractService(gatewayProvider, txPipeline), + }), + ); + registerX402Commands( + registry, + new X402Service(x402Payments, new X402ProviderCatalog(), new X402HttpServer()), + ); const accountBalances = new AccountBalanceService(gatewayProvider); const tokenBookService = new TokenBookService(tokenBook); registerTronChainCommands(registry, { 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/domain/erc8004/erc8004.test.ts b/ts/src/domain/erc8004/erc8004.test.ts new file mode 100644 index 000000000..dbbbb049a --- /dev/null +++ b/ts/src/domain/erc8004/erc8004.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { identityRegistryFor, 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("selects the exact registry for each supported BSC/TRON network", () => { + expect(identityRegistryFor(net("eip155:56"))).toBe( + "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + ); + expect(identityRegistryFor(net("tron:2494104990"))).toBe("TH775ZzfJ5V25EZkFuX6SkbAP53ykXTcma"); + }); + + it("never falls back when the selected network has no registry", () => { + expect(() => identityRegistryFor(net("eip155:1"))).toThrowError( + expect.objectContaining({ code: "unsupported_network_capability" }), + ); + }); + + 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" }), + ); + }); +}); diff --git a/ts/src/domain/erc8004/index.ts b/ts/src/domain/erc8004/index.ts new file mode 100644 index 000000000..18233a47f --- /dev/null +++ b/ts/src/domain/erc8004/index.ts @@ -0,0 +1,54 @@ +import type { NetworkDescriptor } from "../types/index.js"; +import { UsageError } from "../errors/index.js"; + +export const ERC8004_IDENTITY_REGISTRIES: Readonly> = { + "eip155:56": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + "eip155:97": "0x8004A818BFB912233c491871b3d84c89A494BD9e", + "tron:728126428": "TFLvivMdKsk6v2GrwyD2apEr9dU1w7p7Fy", + "tron:3448148188": "TDDk4vc69nzBCbsY4kfu7gw2jmvbinirj5", + "tron:2494104990": "TH775ZzfJ5V25EZkFuX6SkbAP53ykXTcma", +}; + +export function identityRegistryFor(network: NetworkDescriptor): string { + const address = ERC8004_IDENTITY_REGISTRIES[network.id]; + if (!address) { + throw new UsageError( + "unsupported_network_capability", + `ERC-8004 Identity Registry is not deployed on ${network.id}`, + ); + } + return address; +} + +export function parseAgentId(value: string): bigint { + if (!/^\d+$/.test(value)) { + throw new UsageError("invalid_value", "agent id must be an unsigned decimal integer"); + } + return BigInt(value); +} + +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..185d8d438 100644 --- a/ts/src/domain/errors/codes.ts +++ b/ts/src/domain/errors/codes.ts @@ -122,6 +122,11 @@ 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" }, + 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 +136,8 @@ 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_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/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 { From 45a0af599c758b5fe6da6a3b2f949194739fd858 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 7 Sep 2026 16:47:18 +0800 Subject: [PATCH 02/10] feat(x402): add the signer groundwork for an x402 command group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x402-cli signs its payments through @bankofai/agent-wallet. Migrating its commands into wallet-cli means re-pointing that signer at a wallet-cli account; this is the seam that makes the rest of that migration mechanical. No x402 SDK is a dependency yet, and no command is wired. - domain/x402/network-id — convert between wallet-cli network ids and the CAIP-2 ids x402 uses. TRON differs only in base (tron:728126428 is tron:0x2b6653dc); eip155 identifiers are identical on both sides. - application/contracts/x402-payer — PayerSigner and PayerPolicy. The contract carries two closures rather than the domain Signer, because an x402 scheme calls the wallet from deep inside a payment flow, where nothing can run a Ledger's precheck / prompt / abort ceremony. - application/services/x402/payer-signer — resolve the active account, refusing a watch-only one before any keystore decrypt, and apply that ceremony once per signature. The TRON allowance path signs an approve transaction before the payment, so each signature needs its own round. - adapters/outbound/x402/signer-bridge — reshape the payer for the x402 SDK, declared structurally so no x402 package is imported. It is the one place every typed-data payload passes through, so it carries all three guards: the payload's payer must be the selected account, a GasFree maxFee must stay within the caller's ceiling, and the returned signature must be for the struct that was requested. The first two refuse before any signature is requested, so a rejected payment never reaches a device prompt. - domain/typed-data — resolvePrimaryType, so those guards work from the payload's real signing root instead of an optional field. Without it, a payload that omitted primaryType skipped all three checks. - three error codes: payer_mismatch, fee_cap_exceeded and signed_payload_mismatch, with their rows in machine-interface.md. --- ts/docs/machine-interface.md | 3 + .../outbound/x402/signer-bridge.test.ts | 221 ++++++++++++++++++ .../adapters/outbound/x402/signer-bridge.ts | 149 ++++++++++++ ts/src/application/contracts/index.ts | 1 + ts/src/application/contracts/x402-payer.ts | 25 ++ .../services/x402/payer-signer.test.ts | 119 ++++++++++ .../application/services/x402/payer-signer.ts | 28 +++ ts/src/domain/errors/codes.ts | 3 + ts/src/domain/typed-data/index.test.ts | 36 ++- ts/src/domain/typed-data/index.ts | 24 ++ ts/src/domain/x402/network-id.test.ts | 43 ++++ ts/src/domain/x402/network-id.ts | 41 ++++ 12 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 ts/src/adapters/outbound/x402/signer-bridge.test.ts create mode 100644 ts/src/adapters/outbound/x402/signer-bridge.ts create mode 100644 ts/src/application/contracts/x402-payer.ts create mode 100644 ts/src/application/services/x402/payer-signer.test.ts create mode 100644 ts/src/application/services/x402/payer-signer.ts create mode 100644 ts/src/domain/x402/network-id.test.ts create mode 100644 ts/src/domain/x402/network-id.ts diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index 25e220535..c32bcc6d7 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 | 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..8233264c6 --- /dev/null +++ b/ts/src/adapters/outbound/x402/signer-bridge.test.ts @@ -0,0 +1,221 @@ +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"; + +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", + }); + }); +}); 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..7aa2a3271 --- /dev/null +++ b/ts/src/adapters/outbound/x402/signer-bridge.ts @@ -0,0 +1,149 @@ +/** + * 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; +} + +/** TRON addresses travel as base58 or as 41-prefixed hex; EVM addresses are case-insensitive. */ +function samePayer(family: ChainFamily, a: string, b: string): boolean { + return family === "tron" + ? tronHexToBase58(a) === tronHexToBase58(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(tx); + return policy.family === "evm" ? evmRawTransaction(signed) : signed; + }, + }; +} 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/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/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/domain/errors/codes.ts b/ts/src/domain/errors/codes.ts index 185d8d438..6ece890d3 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" }, 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/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), + }; +} From 7fd4cae5cf46dc80b1bc773b25f915a5c419ee69 Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Mon, 7 Sep 2026 19:43:42 +0800 Subject: [PATCH 03/10] feat(ts): integrate wallet signer, BAI and ERC-8004 workflows --- ts/README.md | 139 ++++--- ts/docs/development/8004-signer-handoff.md | 85 ++++ ts/package-lock.json | 46 +++ ts/package.json | 1 + .../adapters/inbound/cli/commands/bai.test.ts | 40 +- ts/src/adapters/inbound/cli/commands/bai.ts | 59 +-- .../inbound/cli/commands/config.bai.test.ts | 38 ++ .../adapters/inbound/cli/commands/config.ts | 13 +- .../inbound/cli/commands/erc8004.test.ts | 13 + .../adapters/inbound/cli/commands/erc8004.ts | 21 +- .../inbound/cli/commands/x402.test.ts | 46 +++ ts/src/adapters/inbound/cli/commands/x402.ts | 76 +++- .../inbound/cli/render/erc8004.test.ts | 38 ++ ts/src/adapters/inbound/cli/render/tx.ts | 9 + .../outbound/bai/binding-store.test.ts | 24 ++ ts/src/adapters/outbound/bai/binding-store.ts | 34 ++ ts/src/adapters/outbound/bai/client.test.ts | 69 ++-- ts/src/adapters/outbound/bai/client.ts | 62 +-- .../outbound/bai/recharge-client.test.ts | 195 +++++++++ .../adapters/outbound/bai/recharge-client.ts | 165 ++++++++ ts/src/adapters/outbound/bai/usage.test.ts | 27 ++ ts/src/adapters/outbound/config/builtins.ts | 10 + .../adapters/outbound/config/config.test.ts | 13 +- .../erc8004/registration-loader.test.ts | 368 +++++++++++++++++ .../outbound/erc8004/registration-loader.ts | 391 ++++++++++++++++++ .../outbound/erc8004/sdk-registry.test.ts | 220 ++++++++++ .../adapters/outbound/erc8004/sdk-registry.ts | 152 +++++++ .../outbound/x402/payment-client.test.ts | 107 +++++ .../adapters/outbound/x402/payment-client.ts | 311 +++++++++++--- .../outbound/x402/payment-destination.test.ts | 86 ++++ .../x402/payment-signer-integration.test.ts | 91 ++++ .../outbound/x402/provider-catalog.test.ts | 14 + .../outbound/x402/provider-catalog.ts | 1 + .../outbound/x402/signer-bridge.test.ts | 20 +- .../adapters/outbound/x402/signer-bridge.ts | 16 +- .../outbound/x402/wallet-signer.test.ts | 43 -- .../adapters/outbound/x402/wallet-signer.ts | 33 -- ts/src/application/ports/agent-registry.ts | 2 + ts/src/application/ports/agent-sdk.ts | 15 + ts/src/application/ports/bai-api.ts | 19 +- ts/src/application/ports/bai-binding-store.ts | 5 + ts/src/application/ports/bai-recharge.ts | 54 +++ ts/src/application/ports/x402-payment.ts | 7 + .../services/approve-receipt.test.ts | 15 + .../application/services/approve-receipt.ts | 9 +- .../services/bai-payment-result.test.ts | 41 ++ .../services/bai-payment-result.ts | 51 +++ .../use-cases/agent-service.test.ts | 242 ++++++++--- ts/src/application/use-cases/agent-service.ts | 119 ++++-- .../use-cases/bai-credential-setup.test.ts | 37 ++ .../use-cases/bai-credential-setup.ts | 34 ++ .../use-cases/bai-recharge-flow.test.ts | 129 ++++++ .../use-cases/bai-recharge-flow.ts | 77 ++++ .../bai-recharge-integration.test.ts | 145 +++++++ .../application/use-cases/bai-service.test.ts | 102 +++-- ts/src/application/use-cases/bai-service.ts | 165 ++++---- .../use-cases/evm/contract-service.test.ts | 13 +- .../use-cases/evm/contract-service.ts | 5 +- .../tron/contract-service.approve.test.ts | 65 +++ .../use-cases/tron/contract-service.ts | 5 +- ts/src/bootstrap/composition.ts | 63 ++- ts/src/domain/bai/recharge-policy.ts | 39 ++ ts/src/domain/erc8004/erc8004.test.ts | 22 +- ts/src/domain/erc8004/index.ts | 23 +- ts/src/domain/types/tx.ts | 10 + ts/test/bai-recharge.test.ts | 113 +++++ ts/test/bai-setup.test.ts | 91 ++++ ts/test/erc8004.test.ts | 192 +++++++++ ts/test/golden.test.ts | 6 +- ts/test/x402-provider-payment.test.ts | 91 ++++ 70 files changed, 4506 insertions(+), 576 deletions(-) create mode 100644 ts/docs/development/8004-signer-handoff.md create mode 100644 ts/src/adapters/inbound/cli/commands/config.bai.test.ts create mode 100644 ts/src/adapters/inbound/cli/render/erc8004.test.ts create mode 100644 ts/src/adapters/outbound/bai/binding-store.test.ts create mode 100644 ts/src/adapters/outbound/bai/binding-store.ts create mode 100644 ts/src/adapters/outbound/bai/recharge-client.test.ts create mode 100644 ts/src/adapters/outbound/bai/recharge-client.ts create mode 100644 ts/src/adapters/outbound/bai/usage.test.ts create mode 100644 ts/src/adapters/outbound/erc8004/registration-loader.test.ts create mode 100644 ts/src/adapters/outbound/erc8004/registration-loader.ts create mode 100644 ts/src/adapters/outbound/erc8004/sdk-registry.test.ts create mode 100644 ts/src/adapters/outbound/erc8004/sdk-registry.ts create mode 100644 ts/src/adapters/outbound/x402/payment-destination.test.ts create mode 100644 ts/src/adapters/outbound/x402/payment-signer-integration.test.ts delete mode 100644 ts/src/adapters/outbound/x402/wallet-signer.test.ts delete mode 100644 ts/src/adapters/outbound/x402/wallet-signer.ts create mode 100644 ts/src/application/ports/agent-sdk.ts create mode 100644 ts/src/application/ports/bai-binding-store.ts create mode 100644 ts/src/application/ports/bai-recharge.ts create mode 100644 ts/src/application/services/bai-payment-result.test.ts create mode 100644 ts/src/application/services/bai-payment-result.ts create mode 100644 ts/src/application/use-cases/bai-credential-setup.test.ts create mode 100644 ts/src/application/use-cases/bai-credential-setup.ts create mode 100644 ts/src/application/use-cases/bai-recharge-flow.test.ts create mode 100644 ts/src/application/use-cases/bai-recharge-flow.ts create mode 100644 ts/src/application/use-cases/bai-recharge-integration.test.ts create mode 100644 ts/src/application/use-cases/tron/contract-service.approve.test.ts create mode 100644 ts/src/domain/bai/recharge-policy.ts create mode 100644 ts/test/bai-recharge.test.ts create mode 100644 ts/test/bai-setup.test.ts create mode 100644 ts/test/erc8004.test.ts create mode 100644 ts/test/x402-provider-payment.test.ts diff --git a/ts/README.md b/ts/README.md index 21ea70bf2..41433cda5 100644 --- a/ts/README.md +++ b/ts/README.md @@ -29,15 +29,15 @@ The agent-first implementation of wallet-cli, built for automation: every comman 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 | +| 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 | 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 +111,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 +199,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.0`. + +```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. + +Development details and the reserved external signer bridge are documented in +[the signer handoff](docs/development/8004-signer-handoff.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/development/8004-signer-handoff.md b/ts/docs/development/8004-signer-handoff.md new file mode 100644 index 000000000..8ffb04393 --- /dev/null +++ b/ts/docs/development/8004-signer-handoff.md @@ -0,0 +1,85 @@ +# ERC-8004 signer handoff + +## Development baseline + +Use the exact npm dependency `@bankofai/8004-sdk@1.2.0-beta.0` (PR #8 source +`5af14d90bea7c9c21aa52c51f32382b28a1fc02c`). Do not replace it with a local SDK +checkout or a floating beta tag. Production signer bridge implementation is the +only implementation explicitly reserved for the next developer. + +## 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`. + +## Remaining signer adapter + +Suggested location: `src/adapters/outbound/erc8004/wallet-signer.ts` and its tests. +Implement the SDK's exported `ExternalSigner` against the existing wallet `Signer`. +The factory should accept the selected `NetworkDescriptor`, wallet `Signer`, and +`SigningScope`; ensure the signer belongs to that selected family/account before +constructing the bridge. Do not export or decrypt a key in the bridge. + +| SDK boundary | Wallet boundary | Required behavior | +| ---------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `address` | `signer.address` | Preserve chain address representation; reject incompatible family/address | +| `signTransaction(tx)` (EVM) | `signer.sign(ethersTx, opts)` | Map viem gas→gasLimit, transaction type to ethers type, retain chainId/nonce/to/data/value/accessList and legacy/EIP-1559 fee fields. Extract `{ raw, hash }.raw` as the SDK return value | +| `signTransaction(tx)` (TRON) | `signer.sign(tx, opts)` | Preserve complete transaction/protobuf identity; return the complete signed object | +| `signTypedData(payload)` | `signer.signTypedData(normalizeTypedData(payload), opts)` | Return the `signature` field with one 0x prefix; retain family-specific EIP-712/TIP-712 hashing | +| optional `signMessage` | `signer.signMessage(message, opts)` | Only expose after explicitly supporting the SDK's string/raw-bytes message semantics; never stringify arbitrary objects | + +All signing calls use `obtainSignature(signer, scope, callback)`, preserving Ledger +precheck, awaiting-device event, timeout and abort. Do not add a second device +prompt or a silent software fallback. Keep an absent optional capability absent. + +The bridge signs only. SDK direct submit APIs broadcast through the SDK; the +wallet's production command path broadcasts through TxPipeline. Do not plug a +SDK submit call inside TxPipeline, or use the bridge to impersonate a transaction +hash. Connecting direct SDK submission to CLI execution modes is a separate +architecture decision, not needed to finish this bridge. + +## Acceptance tests for the reserved work + +1. EVM legacy/EIP-1559 transactions preserve every signing field; recover the + signed transaction sender and chainId and compare to the selected account/network. +2. TRON signed transaction preserves raw_data/raw_data_hex/txID and permission ID; + verify the signature rather than testing only that a callback ran. +3. Typed-data signatures verify against the expected account for both families. +4. Device precheck failure, rejection, timeout and abort propagate; no broadcast + occurs from the bridge and no private key appears in output/errors. +5. Reject wrong network, malformed signing results and unsupported capability. +6. Run contract tests using the published beta SDK and this adapter, not a mock SDK. +7. Record real Ledger testing separately from software/mock tests; obtain a funded + test account explicitly for any new chain transaction validation. + +## Validation commands + +From `ts/`: + +```sh +npm ci +npm run typecheck +npm test +npm run lint +npm run depcruise +npm run build +``` + +Focused CLI integration: `npm test -- --run test/erc8004.test.ts`. +These tests spawn the real CLI against local EVM/TRON RPC servers and verify +configured RPC credentials and large agent IDs without requiring a wallet. diff --git a/ts/package-lock.json b/ts/package-lock.json index 8835ad27c..94e996a8d 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.0", "@bankofai/x402-core": "^1.1.0", "@bankofai/x402-evm": "^1.1.0", "@bankofai/x402-fetch": "^1.1.0", @@ -75,6 +76,20 @@ "node": ">=6.9.0" } }, + "node_modules/@bankofai/8004-sdk": { + "version": "1.2.0-beta.0", + "resolved": "https://registry.npmjs.org/@bankofai/8004-sdk/-/8004-sdk-1.2.0-beta.0.tgz", + "integrity": "sha512-bsJBGYOHb2WFeMMKMjIms8qsj7U8DOUHrMVE0RheORWPYnmCj3NLPrMkDAbghF/1sgoHFOBEAlWaAyZD6QY7zA==", + "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.0", "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.1.0.tgz", @@ -1128,6 +1143,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", @@ -4375,6 +4399,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", diff --git a/ts/package.json b/ts/package.json index 18dbbc838..ee4a8ff45 100644 --- a/ts/package.json +++ b/ts/package.json @@ -54,6 +54,7 @@ }, "license": "LGPL-3.0-or-later", "dependencies": { + "@bankofai/8004-sdk": "1.2.0-beta.0", "@bankofai/x402-core": "^1.1.0", "@bankofai/x402-evm": "^1.1.0", "@bankofai/x402-fetch": "^1.1.0", diff --git a/ts/src/adapters/inbound/cli/commands/bai.test.ts b/ts/src/adapters/inbound/cli/commands/bai.test.ts index 3c774e7a5..5b1b95c25 100644 --- a/ts/src/adapters/inbound/cli/commands/bai.test.ts +++ b/ts/src/adapters/inbound/cli/commands/bai.test.ts @@ -25,13 +25,10 @@ describe("B.AI command surface", () => { expect(registry.resolveNeutral(["bai", "recharge"])?.network).toBe("optional"); }); - it("exposes usage dates and bounded list pagination", () => { + 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([ - "from", - "to", - ]); + expect(Object.keys(registry.resolveNeutral(["bai", "usage"])!.fields.shape)).toEqual([]); expect( registry.resolveNeutral(["bai", "usage-list"])!.input.safeParse({ limit: 20, @@ -43,4 +40,37 @@ describe("B.AI command surface", () => { 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", + }); + }); }); diff --git a/ts/src/adapters/inbound/cli/commands/bai.ts b/ts/src/adapters/inbound/cli/commands/bai.ts index 38f822d7e..f6b7070ef 100644 --- a/ts/src/adapters/inbound/cli/commands/bai.ts +++ b/ts/src/adapters/inbound/cli/commands/bai.ts @@ -5,11 +5,6 @@ import type { BaiService } from "../../../../application/use-cases/bai-service.j const requires = ["config baiApiKey"]; -const date = z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/, "must use YYYY-MM-DD") - .optional(); - const listFields = z.object({ limit: z.coerce .number() @@ -29,8 +24,15 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi .string() .trim() .min(1) - .default("USDT") - .describe("recharge token symbol; currently USDT"), + .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"], @@ -41,20 +43,26 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi capability: "bai.recharge", requires, positionals: [{ field: "amount" }], - summary: "Recharge B.AI through an x402 payment", + summary: "Recharge your own or another B.AI account", description: - "Recharge B.AI using the selected network and token. Mainnet TRON supports exact and exact_gasfree; BSC supports exact.", + "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. Mainnet TRON supports exact and exact_gasfree; BSC and Base support exact. Minimums: TRX 15, USDT/USDC 1, ETH 0.0001, SOL 0.01; other tokens have no additional minimum. Token availability depends on the network and payment service.", 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, + token: input.token ?? (network.id === "eip155:8453" ? "USDC" : "USDT"), + to: input.to, apiKey: ctx.config.baiApiKey, }); }, @@ -76,26 +84,29 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi run: async () => service.status(), } satisfies CommandDefinition); - const usageFields = z.object({ - from: date.describe("first UTC day to include; defaults to 29 days before --to"), - to: date.describe("last UTC day to include; defaults to today"), - }); registry.add({ path: ["bai", "usage"], network: "none", wallet: "none", auth: "none", requires, - summary: "Summarize B.AI usage over a UTC date range", - fields: usageFields, - input: usageFields, - examples: [ - { cmd: "wallet-cli bai usage" }, - { cmd: "wallet-cli bai usage --from 2026-08-01 --to 2026-08-31" }, - ], - run: async (_context, _network, input) => service.usage(input), + 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", @@ -103,8 +114,8 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi auth: "none", requires, summary: "List individual B.AI usage records", - fields: listFields, - input: listFields, + fields: usageListFields, + input: usageListFields, examples: [{ cmd: "wallet-cli bai usage-list --limit 20" }], run: async (_context, _network, input) => service.usageList(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..9dc7b3d8b --- /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, 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 c7db5b51d..ba8f322c6 100644 --- a/ts/src/adapters/inbound/cli/commands/config.ts +++ b/ts/src/adapters/inbound/cli/commands/config.ts @@ -9,7 +9,11 @@ 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, + confirmBaiKey?: (key: string) => Promise, +): 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 @@ -31,6 +35,8 @@ export function registerConfigCommands(registry: CommandRegistry, service: Confi 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, @@ -40,7 +46,9 @@ 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" }, + { + cmd: "printf '%s\\n' \"$BAI_KEY\" | wallet-cli config baiApiKey --api-key-stdin --network tron", + }, ], formatText: TextFormatters.config, run: async (ctx, _network, input) => { @@ -60,6 +68,7 @@ export function registerConfigCommands(registry: CommandRegistry, service: Confi const effectiveInput = hasApiKeyInput ? { key: "baiApiKey", value: ctx.secrets.require("apiKey") } : input; + if (hasApiKeyInput && confirmBaiKey) await confirmBaiKey(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 index a78c579bb..58e6a2d02 100644 --- a/ts/src/adapters/inbound/cli/commands/erc8004.test.ts +++ b/ts/src/adapters/inbound/cli/commands/erc8004.test.ts @@ -33,3 +33,16 @@ describe("ERC-8004 command surface", () => { 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 index 862b1190b..01040c6de 100644 --- a/ts/src/adapters/inbound/cli/commands/erc8004.ts +++ b/ts/src/adapters/inbound/cli/commands/erc8004.ts @@ -17,7 +17,20 @@ const uri = z .trim() .min(1) .max(2048) - .regex(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, "must be an absolute URI such as https:// or ipfs://") + .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(); @@ -167,7 +180,11 @@ function addBoth( } export function registerAgentCommands(registry: CommandRegistry, service: AgentService): void { - addBoth(registry, showSpec, async (_ctx, net, input) => service.show(net, input.id)); + addBoth(registry, showSpec, async (ctx, net, input) => { + const result = await service.show(net, input.id); + for (const warning of result.warnings ?? []) ctx.warn(warning); + return result; + }); addBoth( registry, registerSpec, diff --git a/ts/src/adapters/inbound/cli/commands/x402.test.ts b/ts/src/adapters/inbound/cli/commands/x402.test.ts index 21545b402..b47ca6800 100644 --- a/ts/src/adapters/inbound/cli/commands/x402.test.ts +++ b/ts/src/adapters/inbound/cli/commands/x402.test.ts @@ -40,4 +40,50 @@ describe("x402 command surface", () => { 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 index f173499f8..1e3e2b1b1 100644 --- a/ts/src/adapters/inbound/cli/commands/x402.ts +++ b/ts/src/adapters/inbound/cli/commands/x402.ts @@ -2,6 +2,8 @@ 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() @@ -14,6 +16,8 @@ const payFields = z.object({ 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 @@ -30,6 +34,17 @@ const payFields = z.object({ .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) { @@ -39,6 +54,20 @@ const payInput = payFields.superRefine((value, context) => { 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" }); } @@ -59,6 +88,11 @@ const payCommand: CommandDefinition = { 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" }, @@ -102,17 +136,24 @@ export function registerX402Commands(registry: CommandRegistry, service: X402Ser ...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, - ...(input.body === undefined ? {} : { body: input.body }), + ...(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 }), }); }, }); @@ -204,3 +245,36 @@ export function registerX402Commands(registry: CommandRegistry, service: X402Ser 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/render/erc8004.test.ts b/ts/src/adapters/inbound/cli/render/erc8004.test.ts new file mode 100644 index 000000000..3811ba7ec --- /dev/null +++ b/ts/src/adapters/inbound/cli/render/erc8004.test.ts @@ -0,0 +1,38 @@ +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", + 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", + 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..61a440a72 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -395,6 +395,15 @@ 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.agentId !== undefined) rows.push(["Agent ID", r.agentId]); + if (r.operator !== undefined) rows.push(["Operator", r.operator]); + if (r.uri !== undefined) rows.push(["URI", r.uri]); + if (r.oldURI !== undefined) rows.push(["Previous URI", r.oldURI]); + if (r.requestedURI !== undefined) rows.push(["Requested URI", r.requestedURI]); + if (r.newURI !== undefined) rows.push(["Current URI", r.newURI]); + if (r.oldOwner !== undefined) rows.push(["Previous owner", r.oldOwner]); + if (r.requestedOwner !== undefined) rows.push(["Requested owner", r.requestedOwner]); + if (r.newOwner !== undefined) rows.push(["Current owner", r.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/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 index 20cc7f68d..00960238b 100644 --- a/ts/src/adapters/outbound/bai/client.test.ts +++ b/ts/src/adapters/outbound/bai/client.test.ts @@ -9,6 +9,33 @@ function response(body: unknown, status = 200): Response { } 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([ @@ -38,50 +65,10 @@ describe("BaiClient", () => { 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?batch=1&input="); + expect(String(url)).toContain("/trpc/lambda/usage.summary"); expect((init as RequestInit).headers).toMatchObject({ Authorization: "Bearer secret" }); }); - it("normalizes usage stats and preserves exact quantities as strings", async () => { - const fetcher = vi.fn(async () => - response([ - { - result: { - data: { - json: { - totalMessages: 5, - totalSessions: 2, - totalTokens: "9007199254740993", - totalCost: 8.25, - byModel: [{ model: "m", count: 5, tokens: 99, cost: 8.25 }], - byDate: [{ date: "2026-09-01", count: 5 }], - }, - }, - }, - }, - ]), - ); - const client = new BaiClient( - { baiApiKey: "secret" }, - 1000, - fetcher as typeof fetch, - "https://bai.example", - ); - - await expect(client.usage({ range: ["2026-08-06", "2026-09-04"] })).resolves.toMatchObject({ - totalMessages: "5", - totalSessions: "2", - totalTokens: "9007199254740993", - totalCost: "8.25", - byModel: [{ model: "m", count: "5", tokens: "99", cost: "8.25" }], - }); - const call = fetcher.mock.calls[0]! as unknown as [string | URL | Request, RequestInit]; - const input = new URL(String(call[0])).searchParams.get("input")!; - expect(JSON.parse(input)).toEqual({ - 0: { json: { range: ["2026-08-06", "2026-09-04"] } }, - }); - }); - it("normalizes usage records and recharge orders", async () => { const fetcher = vi .fn() diff --git a/ts/src/adapters/outbound/bai/client.ts b/ts/src/adapters/outbound/bai/client.ts index aae2ba54a..35cdf108c 100644 --- a/ts/src/adapters/outbound/bai/client.ts +++ b/ts/src/adapters/outbound/bai/client.ts @@ -4,8 +4,6 @@ import type { BaiPageInput, BaiPageView, BaiStatusView, - BaiUsageInput, - BaiUsageStatsView, } from "../../../application/ports/bai-api.js"; import type { Config } from "../../../domain/types/index.js"; import { TransportError, UsageError } from "../../../domain/errors/index.js"; @@ -17,7 +15,7 @@ const BatchItemSchema = z.looseObject({ const ObjectSchema = z.record(z.string(), z.unknown()); -export const DEFAULT_BAI_BASE_URL = "https://chat.ainft.com"; +export const DEFAULT_BAI_BASE_URL = "https://chat.bankofai.io"; export class BaiClient implements BaiApi { constructor( @@ -39,28 +37,12 @@ export class BaiClient implements BaiApi { }; } - async usage(input: BaiUsageInput): Promise { - const value = ObjectSchema.parse(await this.call("usage.getUsageStats", input)); - return { - totalMessages: scalar(value.totalMessages, "totalMessages"), - totalSessions: scalar(value.totalSessions, "totalSessions"), - totalTokens: scalar(value.totalTokens, "totalTokens"), - totalCost: scalar(value.totalCost, "totalCost"), - byModel: arrayOfObjects(value.byModel).map((row) => ({ - model: scalar(row.model, "byModel.model"), - count: scalar(row.count, "byModel.count"), - tokens: scalar(row.tokens, "byModel.tokens"), - cost: scalar(row.cost, "byModel.cost"), - })), - byDate: arrayOfObjects(value.byDate).map((row) => ({ - date: scalar(row.date, "byDate.date"), - count: scalar(row.count, "byDate.count"), - })), - }; - } - async usageList(input: BaiPageInput): Promise { - return page(await this.call("usage.records", input)); + 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 { @@ -68,7 +50,7 @@ export class BaiClient implements BaiApi { await this.call("order.listOrders", { page: input.page, pageSize: input.pageSize, - sortBy: input.sortBy, + sortBy: input.sortBy === "created_at" ? "createdAt" : input.sortBy, order: input.sortOrder, }), ); @@ -89,12 +71,25 @@ export class BaiClient implements BaiApi { : { 0: { json: input } }, ), ); - const url = `${this.baseUrl}/trpc/lambda/${procedure}?batch=1&input=${encoded}`; + 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}`; let response: Response; try { response = await this.fetcher(url, { method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${key}` }, + redirect: "error", signal: AbortSignal.timeout(this.timeoutMs), }); } catch (error) { @@ -120,6 +115,17 @@ export class BaiClient implements BaiApi { throw new TransportError("provider_error", "B.AI API returned malformed JSON"); } 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"); @@ -158,6 +164,10 @@ function page(raw: unknown): BaiPageView { 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 } + : {}), }; } 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..0915bc069 --- /dev/null +++ b/ts/src/adapters/outbound/bai/recharge-client.test.ts @@ -0,0 +1,195 @@ +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: "payer", chain: "bnb" }, + }); + const input = { + address: "payer", + chain: "bnb", + message: "documented message", + signature: "signature", + version: 2, + }; + await expect(client.bind(input)).resolves.toEqual({ + userId: "payer-id", + address: "payer", + 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("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 }); + 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..3ee52c82b --- /dev/null +++ b/ts/src/adapters/outbound/bai/recharge-client.ts @@ -0,0 +1,165 @@ +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({ + message: text, + 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: text.parse(identifier), + }), + ); + } + async isBound(input: BaiWalletBindingInput): Promise { + return this.decode( + z.boolean(), + await this.call("wallet.isRechargeBound", wallet.parse(input), "GET"), + ); + } + async bind(input: BaiBindWalletInput) { + const checked = bindInput.parse(input); + const result = this.decode( + binding, + await this.call("wallet.bindRechargeWallet", checked), + ).binding; + if (result.address !== checked.address || result.chain !== checked.chain) throw this.invalid(); + return result; + } + async createOrder(input: BaiCreateOrderInput): Promise> { + const result = this.decode( + object, + await this.call("order.createOrder", orderInput.parse(input)), + ); + if (result.success === false || Object.keys(result).length === 0) throw this.invalid(); + return result; + } + async reportTxHash(input: BaiReportTransactionInput): Promise { + return this.decode(report, await this.call("order.reportTxHash", reportInput.parse(input))); + } + 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); + 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: AbortSignal.timeout(this.timeoutMs), + ...(method === "POST" ? { body: payload } : {}), + }); + if (response.status === 401 || response.status === 403) + throw new TransportError("bai_auth_failed", "B.AI API rejected the configured credential"); + if (response.status === 429) + throw new TransportError("provider_rate_limited", "B.AI API rate limit exceeded"); + if (!response.ok) + throw new TransportError( + "provider_error", + `B.AI recharge API returned HTTP ${response.status}`, + ); + decoded = await response.json(); + } 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 request failed; mutation outcome may be unknown", + ); + } + 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/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index 0f1cfc8fa..279b17b18 100644 --- a/ts/src/adapters/outbound/config/builtins.ts +++ b/ts/src/adapters/outbound/config/builtins.ts @@ -133,6 +133,15 @@ 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: [], + }, }; /** The short name a person types, plus — for TRON only — the id this CLI carried before its @@ -158,6 +167,7 @@ export const BUILTIN_ALIASES: Record = { sepolia: "eip155:11155111", bsc: "eip155:56", "bsc-testnet": "eip155:97", + base: "eip155:8453", }; 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 1d029cda0..6d232a427 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/); }); }); @@ -159,20 +157,21 @@ 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"], ])("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"]) { expect(registry().resolve(id).httpEndpoint).toMatch(/^https:\/\//); } }); 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/x402/payment-client.test.ts b/ts/src/adapters/outbound/x402/payment-client.test.ts index 1a5f79bd4..e5dcb8442 100644 --- a/ts/src/adapters/outbound/x402/payment-client.test.ts +++ b/ts/src/adapters/outbound/x402/payment-client.test.ts @@ -2,6 +2,9 @@ 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 } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; const signer = { kind: "software", @@ -24,6 +27,75 @@ const scope = { } 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 () => @@ -65,4 +137,39 @@ describe("X402PaymentClient", () => { ).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" }); + }); }); diff --git a/ts/src/adapters/outbound/x402/payment-client.ts b/ts/src/adapters/outbound/x402/payment-client.ts index 33a14982f..5ad7718e8 100644 --- a/ts/src/adapters/outbound/x402/payment-client.ts +++ b/ts/src/adapters/outbound/x402/payment-client.ts @@ -9,12 +9,23 @@ 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 { CliError, TransportError, UsageError } from "../../../domain/errors/index.js"; -import { WalletX402Signer } from "./wallet-signer.js"; +import { + CliError, + 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, @@ -33,20 +44,43 @@ export class X402PaymentClient implements X402PaymentPort { async pay(scope: TransactionScope, network: NetworkDescriptor, input: X402PayInput) { const headers = parseHeaders(input.headers); - this.signers.assertCanSign(scope.activeAccount, network.family); - const signer = this.signers.resolve(scope.activeAccount, network.family); - const paidFetch = this.paidFetchFactory - ? await this.paidFetchFactory(network, signer, scope) - : await this.createPaidFetch(network, signer, scope, input); - - let response: Response; + const requestInit: RequestInit = { + method: input.method, + headers, + redirect: "error", + ...(input.body === undefined ? {} : { body: input.body }), + }; try { - response = await paidFetch(input.url, { - method: input.method, - headers, - redirect: "error", - ...(input.body === undefined ? {} : { body: input.body }), - }); + if (this.paidFetchFactory && !input.expectedPayTo && input.exactAmount === undefined) { + const signer = this.resolveSigner(scope, network); + const paidFetch = await this.paidFetchFactory(network, signer, scope); + return this.readResponse( + input.url, + await paidFetch(input.url, requestInit), + signer, + input.out, + ); + } + + const boundedFetch = this.boundedFetch(scope); + const initial = await boundedFetch(input.url, requestInit); + if (initial.status !== 402) + return this.readResponse(input.url, initial, undefined, input.out); + 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 this.readResponse( + input.url, + await paidFetch(input.url, requestInit), + signer, + input.out, + ); } catch (error) { if (error instanceof CliError) throw error; if (error instanceof Error && /timeout|aborted/i.test(`${error.name} ${error.message}`)) { @@ -54,7 +88,14 @@ export class X402PaymentClient implements X402PaymentPort { } throw new TransportError("provider_error", "x402 request or payment failed"); } + } + private async readResponse( + url: string, + response: Response, + signer?: Pick, + out?: 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"); @@ -63,16 +104,6 @@ export class X402PaymentClient implements X402PaymentPort { if (bytes.byteLength > MAX_RESPONSE_BYTES) { throw new TransportError("response_too_large", "x402 response exceeds the 10 MB limit"); } - 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"); - } - } const paymentHeader = response.headers.get("payment-response") ?? response.headers.get("x-payment-response"); let paymentResponse: unknown; @@ -86,64 +117,71 @@ export class X402PaymentClient implements X402PaymentPort { ); } } - return { - url: input.url, + const base = { + url, status: response.status, delivered: response.ok, settled: paymentResponse !== undefined, - payer: { address: signer.address }, + ...(signer ? { payer: { address: signer.address } } : {}), ...(paymentResponse === undefined ? {} : { paymentResponse }), - response: body, }; + 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"); + } + } + return { ...base, response: body }; } private async createPaidFetch( network: NetworkDescriptor, - signer: Signer, + signer: PayerSigner, scope: TransactionScope, input: X402PayInput, + initial: Response, ): Promise { - const bridge = new WalletX402Signer(signer, scope); + 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) => { - const matching = requirements.filter((requirement) => { - 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; - 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", - ); - } - return matching; - }); - const x402Network = - network.family === "tron" ? `tron:0x${BigInt(network.chainId).toString(16)}` : network.id; + client.registerPolicy((_version, requirements) => selectMatching(requirements, network, input)); + const x402Network = toX402Network(network); if (network.family === "evm") { registerExactEvmScheme(client, { signer: bridge as ClientEvmSigner, @@ -166,12 +204,41 @@ export class X402PaymentClient implements X402PaymentPort { networks: [x402Network as Network], }); } - const boundedFetch: typeof fetch = (request, init) => + const boundedFetch = this.boundedFetch(scope); + 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); + }; + return wrapFetchWithPayment(fetchWithInitial, client); + } + + private boundedFetch(scope: TransactionScope): typeof fetch { + return (request, init) => this.fetcher(request, { ...init, signal: init?.signal ?? AbortSignal.timeout(scope.timeoutMs), }); - return wrapFetchWithPayment(boundedFetch, client); + } + + 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}`); } } @@ -190,6 +257,9 @@ const TOKEN_METADATA: Record( + 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) { 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-signer-integration.test.ts b/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts new file mode 100644 index 000000000..b9c79f568 --- /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: "provider_error", + }); + 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 index 1e022f680..84cb246c1 100644 --- a/ts/src/adapters/outbound/x402/provider-catalog.test.ts +++ b/ts/src/adapters/outbound/x402/provider-catalog.test.ts @@ -2,6 +2,20 @@ 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 () => diff --git a/ts/src/adapters/outbound/x402/provider-catalog.ts b/ts/src/adapters/outbound/x402/provider-catalog.ts index 3036da21a..9752ac5b9 100644 --- a/ts/src/adapters/outbound/x402/provider-catalog.ts +++ b/ts/src/adapters/outbound/x402/provider-catalog.ts @@ -200,6 +200,7 @@ function normalizeNetworkAlias(value: string): string { 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/signer-bridge.test.ts b/ts/src/adapters/outbound/x402/signer-bridge.test.ts index 8233264c6..4ca9b8f21 100644 --- a/ts/src/adapters/outbound/x402/signer-bridge.test.ts +++ b/ts/src/adapters/outbound/x402/signer-bridge.test.ts @@ -84,7 +84,9 @@ describe("toX402Wallet", () => { 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(); + await expect( + wallet.signTypedData(evmPayload(EVM_ADDRESS.toLowerCase())), + ).resolves.toBeDefined(); }); it("refuses to sign for a different EVM payer", async () => { @@ -219,3 +221,19 @@ describe("toX402Wallet", () => { }); }); }); + +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 index 7aa2a3271..99a01e906 100644 --- a/ts/src/adapters/outbound/x402/signer-bridge.ts +++ b/ts/src/adapters/outbound/x402/signer-bridge.ts @@ -42,10 +42,16 @@ function declaredPayer(payload: TypedDataPayload, primaryType: string): unknown return primaryType === PERMIT_TRANSFER ? payload.message.user : payload.message.from; } -/** TRON addresses travel as base58 or as 41-prefixed hex; EVM addresses are case-insensitive. */ +/** 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" - ? tronHexToBase58(a) === tronHexToBase58(b) + ? canonicalTronPayer(a) === canonicalTronPayer(b) : a.toLowerCase() === b.toLowerCase(); } @@ -72,7 +78,11 @@ function assertPayerMatches( * 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 { +function assertFeeWithinCap( + payload: TypedDataPayload, + primaryType: string, + maxGasfreeFeeRaw?: string, +): void { if (maxGasfreeFeeRaw === undefined || primaryType !== PERMIT_TRANSFER) return; const declared = payload.message.maxFee; let fee: bigint; diff --git a/ts/src/adapters/outbound/x402/wallet-signer.test.ts b/ts/src/adapters/outbound/x402/wallet-signer.test.ts deleted file mode 100644 index 854358ae9..000000000 --- a/ts/src/adapters/outbound/x402/wallet-signer.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { WalletX402Signer } from "./wallet-signer.js"; -import type { Signer } from "../../../domain/types/index.js"; - -const payload = { - domain: { name: "Token", chainId: 56 }, - types: { Pay: [{ name: "value", type: "uint256" }] }, - primaryType: "Pay", - message: { value: "1" }, -}; - -describe("WalletX402Signer", () => { - it("returns only the wallet signature to the x402 SDK", async () => { - const signer = { - kind: "software", - address: "0x1111111111111111111111111111111111111111", - signTypedData: vi.fn(async () => ({ - signature: "0xsig", - digest: "0xhash", - primaryType: "Pay", - })), - } as unknown as Signer; - const scope = { timeoutMs: 1000, emit: vi.fn() }; - const bridge = new WalletX402Signer(signer, scope); - - await expect(bridge.signTypedData(payload)).resolves.toBe("0xsig"); - expect(bridge.address).toBe(signer.address); - expect(await bridge.getAddress()).toBe(signer.address); - }); - - it("preserves Ledger precheck and awaiting-device behavior", async () => { - const signer = { - kind: "device", - address: "TAddress", - precheck: vi.fn(async () => undefined), - signTypedData: vi.fn(async () => ({ signature: "abcd", digest: "hash", primaryType: "Pay" })), - } as unknown as Signer; - const scope = { timeoutMs: 1000, emit: vi.fn() }; - await new WalletX402Signer(signer, scope).signTypedData(payload); - expect(signer.precheck).toHaveBeenCalledOnce(); - expect(scope.emit).toHaveBeenCalledWith({ type: "awaiting_device", reason: "sign" }); - }); -}); diff --git a/ts/src/adapters/outbound/x402/wallet-signer.ts b/ts/src/adapters/outbound/x402/wallet-signer.ts deleted file mode 100644 index f6c62158e..000000000 --- a/ts/src/adapters/outbound/x402/wallet-signer.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Signer } from "../../../domain/types/index.js"; -import { normalizeTypedData } from "../../../domain/typed-data/index.js"; -import type { SigningScope } from "../../../application/services/signing/obtain-signature.js"; -import { obtainSignature } from "../../../application/services/signing/obtain-signature.js"; - -/** External-wallet bridge shared by the EVM and TRON x402 packages. */ -export class WalletX402Signer { - readonly address: string; - - constructor( - private readonly signer: Signer, - private readonly scope: SigningScope, - ) { - this.address = signer.address; - } - - getAddress(): string { - return this.address; - } - - async signTypedData(args: { - domain: Record; - types: Record; - primaryType: string; - message: Record; - }): Promise<`0x${string}`> { - const payload = normalizeTypedData(args); - const result = await obtainSignature(this.signer, this.scope, (options) => - this.signer.signTypedData(payload, options), - ); - return `0x${result.signature.replace(/^0x/, "")}`; - } -} diff --git a/ts/src/application/ports/agent-registry.ts b/ts/src/application/ports/agent-registry.ts index 98dd4bc69..163364e72 100644 --- a/ts/src/application/ports/agent-registry.ts +++ b/ts/src/application/ports/agent-registry.ts @@ -1,6 +1,7 @@ import type { NetworkDescriptor } from "../../domain/types/index.js"; import type { TransactionScope } from "../contracts/execution-scope.js"; import type { EvmContractWriteInput } from "../use-cases/evm/contract-service.js"; +import type { ApprovalKind } from "../services/approve-receipt.js"; import type { GovernanceTransactionInput } from "../use-cases/tron/governance-transaction.js"; import type { TronContractParameter } from "./chain/tron-gateway.js"; @@ -31,6 +32,7 @@ export interface TronContractPort { input: GovernanceTransactionInput & { contract: string; method: string; + approvalKind?: ApprovalKind; parameters: TronContractParameter[]; callValueSun: string; feeLimit: string; 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 index 09e20a971..089764a29 100644 --- a/ts/src/application/ports/bai-api.ts +++ b/ts/src/application/ports/bai-api.ts @@ -4,22 +4,8 @@ export interface BaiStatusView { monthlyChart: Array<{ month: string; points: string }>; } -export interface BaiUsageInput { - range?: [string, string]; - startDate?: string; - endDate?: string; -} - -export interface BaiUsageStatsView { - totalMessages: string; - totalSessions: string; - totalTokens: string; - totalCost: string; - byModel: Array<{ model: string; count: string; tokens: string; cost: string }>; - byDate: Array<{ date: string; count: string }>; -} - export interface BaiPageInput { + cursor?: string; page: number; pageSize: number; sortBy: string; @@ -31,11 +17,12 @@ export interface BaiPageView { page: number; pageSize: number; total?: number; + hasMore?: boolean; + nextCursor?: string | null; } export interface BaiApi { status(): Promise; - usage(input: BaiUsageInput): 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..9daccd434 --- /dev/null +++ b/ts/src/application/ports/bai-recharge.ts @@ -0,0 +1,54 @@ +/** 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 }; +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/x402-payment.ts b/ts/src/application/ports/x402-payment.ts index 937fa2cc3..5ae8b853d 100644 --- a/ts/src/application/ports/x402-payment.ts +++ b/ts/src/application/ports/x402-payment.ts @@ -12,6 +12,13 @@ export interface X402PayInput { 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 { diff --git a/ts/src/application/services/approve-receipt.test.ts b/ts/src/application/services/approve-receipt.test.ts index 001c18afe..f4d3fa593 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({ 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..cbdc5ed78 100644 --- a/ts/src/application/services/approve-receipt.ts +++ b/ts/src/application/services/approve-receipt.ts @@ -23,9 +23,13 @@ function normalizeSignature(signature?: string): string { return (signature ?? "").replace(/\s+/g, ""); } +export type ApprovalKind = "erc721"; + 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 { 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..abc27987d --- /dev/null +++ b/ts/src/application/services/bai-payment-result.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from "vitest"; +import { baiPaymentResult } from "./bai-payment-result.js"; +const hash = "0x" + "a".repeat(64); +const envelope = { + jsonrpc: "2.0", + id: 1, + result: { transaction_hash: hash, network: "eip155:56" }, +}; +it("extracts JSON and SSE MCP payment results", () => { + for (const response of [ + envelope, + JSON.stringify(envelope), + `event: message\ndata: ${JSON.stringify(envelope)}\n\n`, + ]) { + expect(baiPaymentResult({ response, payer: { address: "payer" } }, "eip155:56")).toEqual({ + txHash: hash, + payer: "payer", + }); + } +}); +it.each([ + { ...envelope, error: {} }, + { result: { transaction_hash: hash, network: "eip155:97" } }, + { result: { transaction_hash: "bad", network: "eip155:56" } }, + { result: { isError: true } }, + {}, +])("rejects wrong-chain, invalid or error responses", (response) => { + expect(() => baiPaymentResult({ response, payer: { address: "payer" } }, "eip155:56")).toThrow(); +}); +it("matches the recharge server's hexadecimal TRON network to the CLI decimal network", () => { + const txHash = "b".repeat(64); + expect( + baiPaymentResult( + { + payer: { address: "T-payer" }, + response: { id: 1, result: { transaction_hash: txHash, network: "tron:0x2b6653dc" } }, + }, + "tron:728126428", + ), + ).toEqual({ txHash, payer: "T-payer" }); +}); 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..4569e37f5 --- /dev/null +++ b/ts/src/application/services/bai-payment-result.ts @@ -0,0 +1,51 @@ +import { TransportError } from "../../domain/errors/index.js"; +/** Extract the recharge MCP result without confusing HTTP success with payment or credit success. */ +export function baiPaymentResult(payment: Record, network: string) { + let body: unknown = payment.response; + if (typeof body === "string") { + try { + if (body.trim().startsWith("{")) body = JSON.parse(body); + else { + const messages = body + .split(/\r?\n\r?\n/) + .map((block) => + block + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"), + ) + .filter(Boolean); + body = messages.map((message) => JSON.parse(message)).find((message) => message.id === 1); + } + } catch { + throw invalid(); + } + } + const envelope = record(body); + if (envelope.error) throw invalid(); + let result = record(envelope.result); + if (result.isError === true) throw invalid(); + if (result.structuredContent) result = record(result.structuredContent); + const txHash = result.transaction_hash; + const validHash = network.startsWith("tron:") ? /^[0-9a-fA-F]{64}$/ : /^0x[0-9a-fA-F]{64}$/; + if ( + typeof txHash !== "string" || + !validHash.test(txHash) || + (result.network === "tron:0x2b6653dc" ? "tron:728126428" : result.network) !== network + ) + throw invalid(); + const payer = record(payment.payer).address; + if (typeof payer !== "string" || !payer) throw invalid(); + return { txHash, payer }; +} +function record(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid(); + return value as Record; +} +function invalid() { + return new TransportError( + "invalid_x402_response", + "Recharge response does not identify a verifiable payment; reconcile before paying again", + ); +} diff --git a/ts/src/application/use-cases/agent-service.test.ts b/ts/src/application/use-cases/agent-service.test.ts index 8b053fe85..8422b4799 100644 --- a/ts/src/application/use-cases/agent-service.test.ts +++ b/ts/src/application/use-cases/agent-service.test.ts @@ -1,79 +1,195 @@ -import { Interface } from "ethers"; 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 ABI = new Interface([ - "function ownerOf(uint256 agentId) view returns (address)", - "function tokenURI(uint256 agentId) view returns (string)", - "function getApproved(uint256 agentId) view returns (address)", - "function isApprovedForAll(address owner,address operator) view returns (bool)", -]); +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 }; +} -const evmNet = { id: "eip155:56", family: "evm" } as NetworkDescriptor; - -describe("AgentService", () => { - it("loads one agent directly from the registry", async () => { - const call = vi - .fn() - .mockResolvedValueOnce({ - result: ABI.encodeFunctionResult("ownerOf", ["0x1111111111111111111111111111111111111111"]), - }) - .mockResolvedValueOnce({ result: ABI.encodeFunctionResult("tokenURI", ["ipfs://agent"]) }) - .mockResolvedValueOnce({ - result: ABI.encodeFunctionResult("getApproved", [ - "0x2222222222222222222222222222222222222222", - ]), - }); - const ports = { - evm: { call, send: vi.fn() }, - tron: { call: vi.fn(), send: vi.fn() }, - } as unknown as AgentContractPorts; - - await expect(new AgentService(ports).show(evmNet, "42")).resolves.toEqual({ +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: "0x1111111111111111111111111111111111111111", - uri: "ipfs://agent", - approved: "0x2222222222222222222222222222222222222222", - registry: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + owner, + uri: "ipfs://old", + approved: operator, + registry: registryAddress, + metadata: { name: "Example" }, }); }); - - it("builds register through the existing contract transaction port", async () => { - const send = vi.fn(async () => ({ stage: "submitted", txId: "0xabc" })); - const ports = { - evm: { call: vi.fn(), send }, - tron: { call: vi.fn(), send: vi.fn() }, - } as unknown as AgentContractPorts; - const scope = {} as never; - - await new AgentService(ports).register(scope, evmNet, { uri: "ipfs://agent" }); - expect(send).toHaveBeenCalledWith( - scope, + 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", + 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", + agentId: "9007199254740993", + }); + }); + 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({ + 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({ oldOwner: owner, newOwner: operator, agentId: "42" }); + expect(f.send).toHaveBeenCalledWith( + f.scope, evmNet, expect.objectContaining({ - method: "register(string)", - params: [{ type: "string", value: "ipfs://agent" }], + method: "transferFrom(address,address,uint256)", + params: [ + { type: "address", value: owner }, + { type: "address", value: operator }, + { type: "uint256", value: "42" }, + ], }), ); }); - - it("checks an owner-wide operator approval on chain", async () => { - const call = vi.fn(async () => ({ - result: ABI.encodeFunctionResult("isApprovedForAll", [true]), - })); - const ports = { - evm: { call, send: vi.fn() }, - tron: { call: vi.fn(), send: vi.fn() }, - } as unknown as AgentContractPorts; - - await expect( - new AgentService(ports).operatorCheck( - evmNet, - "0x1111111111111111111111111111111111111111", - "0x2222222222222222222222222222222222222222", - ), - ).resolves.toMatchObject({ approved: true }); + 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 index 2e93c6479..7a6128259 100644 --- a/ts/src/application/use-cases/agent-service.ts +++ b/ts/src/application/use-cases/agent-service.ts @@ -1,17 +1,10 @@ -import { Interface } from "ethers"; 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 { identityRegistryFor, resolveAgentId } from "../../domain/erc8004/index.js"; +import type { AgentRegistryReader, AgentRegistrationLoader } from "../ports/agent-sdk.js"; +import { resolveAgentId } from "../../domain/erc8004/index.js"; import { tronHexToBase58 } from "../../domain/address/index.js"; -const IDENTITY = new Interface([ - "function ownerOf(uint256 agentId) view returns (address)", - "function tokenURI(uint256 agentId) view returns (string)", - "function getApproved(uint256 agentId) view returns (address)", - "function isApprovedForAll(address owner,address operator) view returns (bool)", -]); - interface TransactionOptions { dryRun?: boolean; signOnly?: boolean; @@ -22,45 +15,85 @@ interface TransactionOptions { } export class AgentService { - constructor(private readonly contracts: AgentContractPorts) {} + 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 = identityRegistryFor(network); + 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] } : {}), }; } - register( + async register( scope: TransactionScope, network: NetworkDescriptor, input: TransactionOptions & { uri: string }, ) { - return this.write(scope, network, input, "register(string)", [ - { type: "string", value: input.uri }, - ]); + const result: Record = { + ...(await this.write(scope, network, input, "register(string)", [ + { type: "string", value: input.uri }, + ])), + 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, 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; } - update( + async update( scope: TransactionScope, network: NetworkDescriptor, input: TransactionOptions & { id: string; uri: string }, ) { const id = resolveAgentId(input.id, network).toString(); - return this.write(scope, network, input, "setAgentURI(uint256,string)", [ + 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, 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, newURI }; + } catch { + scope.warn("URI update confirmed, but the current Agent URI could not be read."); + return view; + } } async transfer( @@ -69,15 +102,37 @@ export class AgentService { input: TransactionOptions & { id: string; newOwner: string }, ) { const id = resolveAgentId(input.id, network).toString(); - const registry = identityRegistryFor(network); + const registry = this.registry.registry(network); const owner = String( await this.read(network, registry, "ownerOf(uint256)", [{ type: "uint256", value: id }]), ); - return 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 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, + 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, newOwner: this.address(network, newOwner) }; + } catch { + scope.warn("Transfer confirmed, but the current Agent owner could not be read."); + return view; + } } approve( @@ -109,7 +164,7 @@ export class AgentService { } async operatorCheck(network: NetworkDescriptor, owner: string, operator: string) { - const registry = identityRegistryFor(network); + const registry = this.registry.registry(network); const approved = await this.read(network, registry, "isApprovedForAll(address,address)", [ { type: "address", value: owner }, { type: "address", value: operator }, @@ -131,18 +186,12 @@ export class AgentService { private async read( network: NetworkDescriptor, - registry: string, + _registry: string, method: string, params: Array<{ type: string; value: unknown }>, ): Promise { - const response = await this.contracts[network.family].call(network, registry, method, params); - const raw = network.family === "tron" ? response.result[0] : response.result; - const data = String(raw ?? ""); - const [decoded] = IDENTITY.decodeFunctionResult( - method, - data.startsWith("0x") ? data : `0x${data}`, - ); - return decoded; + // Registry selection and ABI decoding are supplied by the published SDK adapter. + return this.registry.read(network, method, params); } private write( @@ -152,10 +201,11 @@ export class AgentService { method: string, params: Array<{ type: string; value: unknown }>, ) { - const contract = identityRegistryFor(network); + 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, @@ -163,6 +213,7 @@ export class AgentService { } return this.contracts.tron.send(scope, network, { ...input, + ...(method === "approve(address,uint256)" ? { approvalKind: "erc721" as const } : {}), contract, method, parameters: params, 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..c9ef8c030 --- /dev/null +++ b/ts/src/application/use-cases/bai-credential-setup.test.ts @@ -0,0 +1,37 @@ +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); + 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).confirm("key", "bnb", "payer"), + ).rejects.toThrow(); + expect(store.confirm).not.toHaveBeenCalled(); + }, +); 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..3d25ae4a7 --- /dev/null +++ b/ts/src/application/use-cases/bai-credential-setup.ts @@ -0,0 +1,34 @@ +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 { + 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"; + 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, + ) {} + 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..90b800214 --- /dev/null +++ b/ts/src/application/use-cases/bai-recharge-flow.test.ts @@ -0,0 +1,129 @@ +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 }), + ); +}); 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..c9c318f8b --- /dev/null +++ b/ts/src/application/use-cases/bai-recharge-flow.ts @@ -0,0 +1,77 @@ +import type { + BaiCreateOrderInput, + BaiRechargeApi, + BaiRechargePayment, + BaiReportTransactionInput, +} from "../ports/bai-recharge.js"; +import { 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 { + throw new TransportError( + "provider_error", + "Recharge payment outcome is unknown; reconcile the transaction before paying again", + ); + } + 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) { + const request = structuredClone(input); + const base = { ...request, retryPayment: false as const }; + try { + const result = await this.api.reportTxHash(structuredClone(request)); + if (!result.success) + return { ...base, creditStatus: "unconfirmed" as const, code: result.code }; + return { ...base, creditStatus: "credited" as const, order: result.order }; + } catch { + return { + ...base, + creditStatus: "unconfirmed" as const, + 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..6055cb694 --- /dev/null +++ b/ts/src/application/use-cases/bai-recharge-integration.test.ts @@ -0,0 +1,145 @@ +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() { + 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 = { + pay: vi.fn(async () => { + calls.push("pay"); + return { + payer: { address: payer }, + response: { + jsonrpc: "2.0", + id: 1, + result: { transaction_hash: txHash, network: "eip155:56", payment_status: "settled" }, + }, + }; + }), + }; + const service = new BaiService( + {} as BaiApi, + () => new Date(), + payments, + { isConfirmed: () => true } as unknown as BaiBindingStore, + api, + ); + 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.pay).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + expectedPayTo: "0x060f7fd9c9622bdcf9f2887c8171d6e6b4b4ba17", + exactAmount: 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.pay).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.pay).toHaveBeenCalledTimes(1); +}); diff --git a/ts/src/application/use-cases/bai-service.test.ts b/ts/src/application/use-cases/bai-service.test.ts index 43d586b1e..a0c23d3d4 100644 --- a/ts/src/application/use-cases/bai-service.test.ts +++ b/ts/src/application/use-cases/bai-service.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { BaiService } from "./bai-service.js"; import type { BaiApi } from "../ports/bai-api.js"; -import type { X402PaymentPort } from "../ports/x402-payment.js"; +import type { BaiBindingStore } from "../ports/bai-binding-store.js"; function api(): BaiApi { return { @@ -10,14 +10,6 @@ function api(): BaiApi { monthlySpent: "300000", monthlyChart: [{ month: "2026-09", points: "300000" }], })), - usage: vi.fn(async () => ({ - totalMessages: "4", - totalSessions: "2", - totalTokens: "100", - totalCost: "25", - byModel: [{ model: "m", count: "4", tokens: "100", cost: "25" }], - byDate: [{ date: "2026-09-01", count: "4" }], - })), usageList: vi.fn(async () => ({ page: 1, pageSize: 20, total: 1, items: [] })), rechargeList: vi.fn(async () => ({ page: 1, pageSize: 20, total: 1, items: [] })), }; @@ -32,45 +24,69 @@ describe("BaiService", () => { }); }); - it("defaults usage to the latest 30 UTC calendar days", async () => { + it("reads usage summary once without fetching records", async () => { const remote = api(); - const service = new BaiService(remote, () => new Date("2026-09-04T18:00:00Z")); - await service.usage({}); - expect(remote.usage).toHaveBeenCalledWith({ - startDate: "2026-08-06", - endDate: "2026-09-04", - range: ["2026-08-06", "2026-09-04"], + 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("rejects an inverted date range before calling B.AI", async () => { - const remote = api(); - await expect( - new BaiService(remote).usage({ from: "2026-09-02", to: "2026-09-01" }), - ).rejects.toMatchObject({ code: "invalid_value" }); - expect(remote.usage).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(), { 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(), { 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("sends recharge through x402 without exposing the API key in the result", async () => { - const payments = { - pay: vi.fn(async () => ({ delivered: true, settled: true })), - } as unknown as X402PaymentPort; - const service = new BaiService(api(), () => new Date(), payments); - const result = await service.recharge({} as never, { id: "tron:728126428" } as never, { - amount: "10", - token: "USDT", - apiKey: "secret-key", - }); - expect(result).toMatchObject({ network: "tron:728126428", token: "USDT", amount: "10" }); - expect(JSON.stringify(result)).not.toContain("secret-key"); - expect(payments.pay).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - url: "https://recharge.bankofai.io/mcp", - method: "POST", - headers: expect.arrayContaining(["Authorization: Bearer secret-key"]), - }), - ); +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 index c4416a70b..524922524 100644 --- a/ts/src/application/use-cases/bai-service.ts +++ b/ts/src/application/use-cases/bai-service.ts @@ -1,15 +1,20 @@ +import type { BaiRechargeApi, BaiRechargeTarget } from "../ports/bai-recharge.js"; +import { BaiRechargeFlow } from "./bai-recharge-flow.js"; +import { baiPaymentResult } from "../services/bai-payment-result.js"; +import { baiChain } 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, + BAI_RECHARGE_ADDRESSES, +} from "../../domain/bai/recharge-policy.js"; import type { X402PaymentPort } from "../ports/x402-payment.js"; import type { TransactionScope } from "../contracts/execution-scope.js"; import type { NetworkDescriptor } from "../../domain/types/index.js"; -export interface BaiUsageCommandInput { - from?: string; - to?: string; -} - export interface BaiListCommandInput { + cursor?: string; limit: number; offset: number; sort: "asc" | "desc"; @@ -20,12 +25,14 @@ export class BaiService { private readonly api: BaiApi, private readonly now: () => Date = () => new Date(), private readonly payments?: X402PaymentPort, + private readonly bindings?: BaiBindingStore, + private readonly rechargeApi?: BaiRechargeApi, ) {} async recharge( scope: TransactionScope, network: NetworkDescriptor, - input: { amount: string; token: string; apiKey?: string }, + input: { amount: string; token: string; to?: string; apiKey?: string }, ) { if (!input.apiKey) { throw new UsageError( @@ -33,28 +40,80 @@ export class BaiService { "configure baiApiKey before using B.AI recharge", ); } - if (!this.payments) { + if (!this.bindings) + throw new UsageError("invalid_option", "B.AI recharge binding verification is unavailable"); + const chain = baiChain(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) { throw new UsageError("invalid_option", "B.AI recharge is not available in this runtime"); } - const body = JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { name: "recharge", arguments: { amount: input.amount, token: input.token } }, + const normalizedAmount = input.amount.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ""); + const amount = Number(normalizedAmount); + if ( + !/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(input.amount) || + !Number.isFinite(amount) || + amount <= 0 || + String(amount) !== normalizedAmount + ) { + throw new UsageError( + "invalid_value", + "Recharge amount must be positive and exactly representable by the B.AI numeric API", + ); + } + assertBaiRechargeMinimum(input.token, input.amount); + 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 payment = await this.payments!.pay(scope, network, { + url: "https://recharge.bankofai.io/mcp", + method: "POST", + headers: [ + "Content-Type: application/json", + "Accept: application/json, text/event-stream", + ], + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "recharge", arguments: { amount: input.amount, token: input.token } }, + }), + token: input.token, + maxAmount: input.amount, + exactAmount: input.amount, + expectedPayTo: BAI_RECHARGE_ADDRESSES[chain], + }); + return { ...baiPaymentResult(payment, network.id), chain }; + }, }); - const result = await this.payments.pay(scope, network, { - url: "https://recharge.bankofai.io/mcp", - method: "POST", - headers: [ - "Content-Type: application/json", - "Accept: application/json, text/event-stream", - `Authorization: Bearer ${input.apiKey}`, - ], - body, - token: input.token, - maxAmount: input.amount, + const result = await flow.execute({ + channel: "crypto", + chain, + tokenName: input.token, + amount, + walletAddress: payer, + deviceType: "web", + ...(rechargeTarget ? { rechargeTarget } : {}), }); - return { network: network.id, token: input.token, amount: input.amount, ...result }; + return { ...result, network: network.id, token: input.token, amount: input.amount, payer }; } async status() { @@ -70,34 +129,15 @@ export class BaiService { }; } - async usage(input: BaiUsageCommandInput) { - const to = input.to ?? utcDate(this.now()); - const from = input.from ?? utcDate(addUtcDays(parseDate(to, "to"), -29)); - const fromDate = parseDate(from, "from"); - const toDate = parseDate(to, "to"); - if (fromDate.getTime() > toDate.getTime()) { - throw new UsageError("invalid_value", "--from must not be later than --to"); - } - const result = await this.api.usage({ startDate: from, endDate: to, range: [from, to] }); - return { - from, - to, - messages: result.totalMessages, - sessions: result.totalSessions, - tokens: result.totalTokens, - credits: result.totalCost, - byModel: result.byModel.map((row) => ({ - model: row.model, - messages: row.count, - tokens: row.tokens, - credits: row.cost, - })), - byDate: result.byDate.map((row) => ({ date: row.date, messages: row.count })), - }; + async usage() { + return this.status(); } async usageList(input: BaiListCommandInput) { - const result = await this.api.usageList(pageInput(input)); + const result = await this.api.usageList({ + ...pageInput(input), + ...(input.cursor ? { cursor: input.cursor } : {}), + }); return { records: result.items.map((row) => ({ id: optionalString(row.id), @@ -110,7 +150,13 @@ export class BaiService { latencyMs: secondsToMilliseconds(row.duration_sec ?? row.durationSec), source: optionalString(row.source_type ?? row.source), })), - pagination: { offset: input.offset, limit: input.limit, total: result.total }, + pagination: { + offset: input.offset, + limit: input.limit, + total: result.total, + ...(result.hasMore === undefined ? {} : { hasMore: result.hasMore }), + ...(result.nextCursor === undefined ? {} : { nextCursor: result.nextCursor }), + }, }; } @@ -138,25 +184,6 @@ function pageInput(input: BaiListCommandInput): BaiPageInput { }; } -function parseDate(value: string, field: string): Date { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { - throw new UsageError("invalid_value", `--${field} must use YYYY-MM-DD`); - } - const date = new Date(`${value}T00:00:00Z`); - if (Number.isNaN(date.getTime()) || utcDate(date) !== value) { - throw new UsageError("invalid_value", `--${field} must be a real UTC calendar date`); - } - return date; -} - -function addUtcDays(date: Date, days: number): Date { - return new Date(date.getTime() + days * 86_400_000); -} - -function utcDate(date: Date): string { - return date.toISOString().slice(0, 10); -} - function utcMonth(date: Date): string { return date.toISOString().slice(0, 7); } 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..6febced71 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,16 @@ 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({ + 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..8713ddd6e 100644 --- a/ts/src/application/use-cases/evm/contract-service.ts +++ b/ts/src/application/use-cases/evm/contract-service.ts @@ -2,7 +2,7 @@ import type { NetworkDescriptor } from "../../../domain/types/index.js"; import { FAMILIES } from "../../../domain/family/index.js"; import { fromBaseUnits, toBaseUnits } from "../../../domain/amounts/index.js"; import { evmConfirmation } from "../../services/evm-confirmation.js"; -import { approveRows } from "../../services/approve-receipt.js"; +import { approveRows, type ApprovalKind } from "../../services/approve-receipt.js"; import { buildEvmUnsignedTx } from "./tx-build.js"; import type { TransactionScope } from "../../contracts/execution-scope.js"; import type { @@ -21,6 +21,8 @@ import { 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). */ @@ -108,6 +110,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..3ecb7a380 --- /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({ 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/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index 2cd740ba9..70167dddf 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -1,3 +1,8 @@ +import { FileBaiBindingStore } from "../adapters/outbound/bai/binding-store.js"; +import { BaiCredentialSetup, baiChain } from "../application/use-cases/bai-credential-setup.js"; +import { walletAddress } from "../domain/wallet/index.js"; +import { UsageError } from "../domain/errors/index.js"; +import { BaiRechargeClient } from "../adapters/outbound/bai/recharge-client.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"; @@ -48,6 +53,8 @@ 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 { registerAgentCommands } from "../adapters/inbound/cli/commands/erc8004.js"; import { X402PaymentClient } from "../adapters/outbound/x402/payment-client.js"; @@ -117,7 +124,25 @@ 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), + ); + registerConfigCommands(registry, configService, async (apiKey) => { + const net = networkRegistry.resolve(options.globals.network ?? config.defaultNetwork); + const chain = baiChain(net); + const account = options.globals.account ?? keystore.activeAccount(); + if (!account) + throw new UsageError( + "invalid_value", + "Select a payer wallet before configuring the B.AI API key", + ); + const selected = keystore.resolveAccount(account, net.family); + const address = walletAddress(selected.wallet, net.family, selected.index); + if (!address) + throw new UsageError("family_mismatch", "Selected wallet has no address for this network"); + await baiSetup.confirm(apiKey, chain, address); + }); registerNetworkCommands(registry); registerContactCommands(registry, new ContactService(contactBook)); registerEncodingCommands(registry, new EncodingService()); @@ -125,14 +150,25 @@ export function composeCliRuntime(options: BootstrapOptions) { const x402Payments = new X402PaymentClient(signerResolver); registerBaiCommands( registry, - new BaiService(new BaiClient(config, timeoutMs), () => new Date(), x402Payments), + new BaiService( + new BaiClient(config, timeoutMs), + () => new Date(), + x402Payments, + baiBindings, + new BaiRechargeClient(config, timeoutMs), + ), ); + const agentContracts = { + evm: new EvmContractService(gatewayProvider, txPipeline), + tron: new TronContractService(gatewayProvider, txPipeline), + }; registerAgentCommands( registry, - new AgentService({ - evm: new EvmContractService(gatewayProvider, txPipeline), - tron: new TronContractService(gatewayProvider, txPipeline), - }), + new AgentService( + agentContracts, + new SdkAgentRegistry(agentContracts, gatewayProvider), + new RegistrationLoader(timeoutMs), + ), ); registerX402Commands( registry, @@ -181,6 +217,21 @@ 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", + }); + if ( + (network.family === "evm" && ["56", "8453"].includes(network.chainId)) || + (network.family === "tron" && network.chainId === "728126428") + ) { + 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/domain/bai/recharge-policy.ts b/ts/src/domain/bai/recharge-policy.ts new file mode 100644 index 000000000..210d8a80f --- /dev/null +++ b/ts/src/domain/bai/recharge-policy.ts @@ -0,0 +1,39 @@ +import { UsageError } from "../errors/index.js"; + +/** Recharge destinations supplied by the service owner on 2026-09-07. */ +export const BAI_RECHARGE_ADDRESSES: Readonly> = Object.freeze({ + tron: "TSNEPtuCagKEgF2EU4pAKWLzXLz1bekfTE", + solana: "AEtyXM8nwieUwSLifLQAmGFHwcuwSFVkfyF2XCWNUzmp", + bnb: "0x060f7fd9c9622bdcf9f2887c8171d6e6b4b4ba17", + ethereum: "0x3f380aaac9488aef452add4cc74b3d052dd02621", + arbitrum: "0x406c163d8dab7e313c76699597baa5b11bf8bbd9", + optimism: "0x6d32e2f9a38416f468f33a4fb14c4b80ef99007f", + polygon: "0xe6cb7671d26b62f9c4f423d76ce8a558d249f48e", + base: "0x10bf3d09bd80a00ddbbfe934c7dcc477b42ffdb0", +}); + +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 index dbbbb049a..a662e88de 100644 --- a/ts/src/domain/erc8004/erc8004.test.ts +++ b/ts/src/domain/erc8004/erc8004.test.ts @@ -1,23 +1,10 @@ import { describe, expect, it } from "vitest"; -import { identityRegistryFor, parseAgentId, resolveAgentId } from "./index.js"; +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("selects the exact registry for each supported BSC/TRON network", () => { - expect(identityRegistryFor(net("eip155:56"))).toBe( - "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", - ); - expect(identityRegistryFor(net("tron:2494104990"))).toBe("TH775ZzfJ5V25EZkFuX6SkbAP53ykXTcma"); - }); - - it("never falls back when the selected network has no registry", () => { - expect(() => identityRegistryFor(net("eip155:1"))).toThrowError( - expect.objectContaining({ code: "unsupported_network_capability" }), - ); - }); - it("parses arbitrarily large decimal agent ids without Number coercion", () => { expect(parseAgentId("9007199254740993")).toBe(9007199254740993n); expect(() => parseAgentId("1.5")).toThrowError(/unsigned decimal/); @@ -31,3 +18,10 @@ describe("ERC-8004 deployment selection", () => { ); }); }); + +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 index 18233a47f..8ebb19707 100644 --- a/ts/src/domain/erc8004/index.ts +++ b/ts/src/domain/erc8004/index.ts @@ -1,30 +1,13 @@ import type { NetworkDescriptor } from "../types/index.js"; import { UsageError } from "../errors/index.js"; -export const ERC8004_IDENTITY_REGISTRIES: Readonly> = { - "eip155:56": "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", - "eip155:97": "0x8004A818BFB912233c491871b3d84c89A494BD9e", - "tron:728126428": "TFLvivMdKsk6v2GrwyD2apEr9dU1w7p7Fy", - "tron:3448148188": "TDDk4vc69nzBCbsY4kfu7gw2jmvbinirj5", - "tron:2494104990": "TH775ZzfJ5V25EZkFuX6SkbAP53ykXTcma", -}; - -export function identityRegistryFor(network: NetworkDescriptor): string { - const address = ERC8004_IDENTITY_REGISTRIES[network.id]; - if (!address) { - throw new UsageError( - "unsupported_network_capability", - `ERC-8004 Identity Registry is not deployed on ${network.id}`, - ); - } - return address; -} - export function parseAgentId(value: string): bigint { if (!/^\d+$/.test(value)) { throw new UsageError("invalid_value", "agent id must be an unsigned decimal integer"); } - return BigInt(value); + 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 { diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index 95594cc4f..a49876664 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -149,6 +149,16 @@ export type TxReceiptKind = * the subset relevant to their action. */ export interface TxReceiptView { + /** ERC-8004 Identity Registry result fields, all IDs remain decimal strings. */ + 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/test/bai-recharge.test.ts b/ts/test/bai-recharge.test.ts new file mode 100644 index 000000000..5c20fb304 --- /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 {payer:{address:${JSON.stringify(payer)}}, response:{id:1, result:{transaction_hash:${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/erc8004.test.ts b/ts/test/erc8004.test.ts new file mode 100644 index 000000000..af8145698 --- /dev/null +++ b/ts/test/erc8004.test.ts @@ -0,0 +1,192 @@ +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 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 === "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: 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(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", + agentId: "9007199254740993", + operator: owner, + }); + expect(JSON.parse(r.stdout).data).not.toHaveProperty("allowance"); + expect( + f.calls.some((c) => ["decimals", "symbol", "eth_sendRawTransaction"].includes(c.method)), + ).toBe(false); +}); diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index e5fa066c5..ef8b6d8b4 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -114,8 +114,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 + 5 EVM, including Base mainnet. expect(ids).toEqual( expect.arrayContaining([ "tron:728126428", @@ -125,9 +124,10 @@ describe("golden CLI — meta & introspection", () => { "eip155:11155111", "eip155:56", "eip155:97", + "eip155:8453", ]), ); - expect(ids).toHaveLength(7); + expect(ids).toHaveLength(8); // 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/x402-provider-payment.test.ts b/ts/test/x402-provider-payment.test.ts new file mode 100644 index 000000000..186190d7b --- /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"], +])( + "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" ? "tron" : "evm", + address: + alias === "tron" + ? "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" + ? "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, + [ + "--import", + "tsx", + "--import", + pathToFileURL(preload).href, + "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, +); From 8d1d41158e3fa368bf5ab7aafd1791e74cd0e195 Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Tue, 8 Sep 2026 10:30:27 +0800 Subject: [PATCH 04/10] fix(ts): adopt 8004 beta.1 and keep signing in wallet --- ts/README.md | 6 +- ts/docs/development/8004-signer-handoff.md | 85 --------- .../development/erc8004-sdk-integration.md | 32 ++++ ts/package-lock.json | 8 +- ts/package.json | 2 +- .../outbound/x402/nile-compatibility.test.ts | 73 ++++++++ .../outbound/x402/nile-settlement.test.ts | 171 ++++++++++++++++++ ts/test/bai-nile-compatibility.test.ts | 75 ++++++++ ts/test/erc8004.test.ts | 44 ++++- ts/test/x402-provider-payment.test.ts | 7 +- 10 files changed, 405 insertions(+), 98 deletions(-) delete mode 100644 ts/docs/development/8004-signer-handoff.md create mode 100644 ts/docs/development/erc8004-sdk-integration.md create mode 100644 ts/src/adapters/outbound/x402/nile-compatibility.test.ts create mode 100644 ts/src/adapters/outbound/x402/nile-settlement.test.ts create mode 100644 ts/test/bai-nile-compatibility.test.ts diff --git a/ts/README.md b/ts/README.md index 41433cda5..9553a1488 100644 --- a/ts/README.md +++ b/ts/README.md @@ -202,7 +202,7 @@ A command errored or behaved unexpectedly? Common issues and how to diagnose the ## ERC-8004 beta integration -The `8004` command group uses `@bankofai/8004-sdk@1.2.0-beta.0`. +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 @@ -219,8 +219,8 @@ 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. -Development details and the reserved external signer bridge are documented in -[the signer handoff](docs/development/8004-signer-handoff.md). +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 diff --git a/ts/docs/development/8004-signer-handoff.md b/ts/docs/development/8004-signer-handoff.md deleted file mode 100644 index 8ffb04393..000000000 --- a/ts/docs/development/8004-signer-handoff.md +++ /dev/null @@ -1,85 +0,0 @@ -# ERC-8004 signer handoff - -## Development baseline - -Use the exact npm dependency `@bankofai/8004-sdk@1.2.0-beta.0` (PR #8 source -`5af14d90bea7c9c21aa52c51f32382b28a1fc02c`). Do not replace it with a local SDK -checkout or a floating beta tag. Production signer bridge implementation is the -only implementation explicitly reserved for the next developer. - -## 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`. - -## Remaining signer adapter - -Suggested location: `src/adapters/outbound/erc8004/wallet-signer.ts` and its tests. -Implement the SDK's exported `ExternalSigner` against the existing wallet `Signer`. -The factory should accept the selected `NetworkDescriptor`, wallet `Signer`, and -`SigningScope`; ensure the signer belongs to that selected family/account before -constructing the bridge. Do not export or decrypt a key in the bridge. - -| SDK boundary | Wallet boundary | Required behavior | -| ---------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `address` | `signer.address` | Preserve chain address representation; reject incompatible family/address | -| `signTransaction(tx)` (EVM) | `signer.sign(ethersTx, opts)` | Map viem gas→gasLimit, transaction type to ethers type, retain chainId/nonce/to/data/value/accessList and legacy/EIP-1559 fee fields. Extract `{ raw, hash }.raw` as the SDK return value | -| `signTransaction(tx)` (TRON) | `signer.sign(tx, opts)` | Preserve complete transaction/protobuf identity; return the complete signed object | -| `signTypedData(payload)` | `signer.signTypedData(normalizeTypedData(payload), opts)` | Return the `signature` field with one 0x prefix; retain family-specific EIP-712/TIP-712 hashing | -| optional `signMessage` | `signer.signMessage(message, opts)` | Only expose after explicitly supporting the SDK's string/raw-bytes message semantics; never stringify arbitrary objects | - -All signing calls use `obtainSignature(signer, scope, callback)`, preserving Ledger -precheck, awaiting-device event, timeout and abort. Do not add a second device -prompt or a silent software fallback. Keep an absent optional capability absent. - -The bridge signs only. SDK direct submit APIs broadcast through the SDK; the -wallet's production command path broadcasts through TxPipeline. Do not plug a -SDK submit call inside TxPipeline, or use the bridge to impersonate a transaction -hash. Connecting direct SDK submission to CLI execution modes is a separate -architecture decision, not needed to finish this bridge. - -## Acceptance tests for the reserved work - -1. EVM legacy/EIP-1559 transactions preserve every signing field; recover the - signed transaction sender and chainId and compare to the selected account/network. -2. TRON signed transaction preserves raw_data/raw_data_hex/txID and permission ID; - verify the signature rather than testing only that a callback ran. -3. Typed-data signatures verify against the expected account for both families. -4. Device precheck failure, rejection, timeout and abort propagate; no broadcast - occurs from the bridge and no private key appears in output/errors. -5. Reject wrong network, malformed signing results and unsupported capability. -6. Run contract tests using the published beta SDK and this adapter, not a mock SDK. -7. Record real Ledger testing separately from software/mock tests; obtain a funded - test account explicitly for any new chain transaction validation. - -## Validation commands - -From `ts/`: - -```sh -npm ci -npm run typecheck -npm test -npm run lint -npm run depcruise -npm run build -``` - -Focused CLI integration: `npm test -- --run test/erc8004.test.ts`. -These tests spawn the real CLI against local EVM/TRON RPC servers and verify -configured RPC credentials and large agent IDs without requiring a wallet. 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/package-lock.json b/ts/package-lock.json index 94e996a8d..7fbe23e8d 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -9,7 +9,7 @@ "version": "4.13.0", "license": "LGPL-3.0-or-later", "dependencies": { - "@bankofai/8004-sdk": "1.2.0-beta.0", + "@bankofai/8004-sdk": "1.2.0-beta.1", "@bankofai/x402-core": "^1.1.0", "@bankofai/x402-evm": "^1.1.0", "@bankofai/x402-fetch": "^1.1.0", @@ -77,9 +77,9 @@ } }, "node_modules/@bankofai/8004-sdk": { - "version": "1.2.0-beta.0", - "resolved": "https://registry.npmjs.org/@bankofai/8004-sdk/-/8004-sdk-1.2.0-beta.0.tgz", - "integrity": "sha512-bsJBGYOHb2WFeMMKMjIms8qsj7U8DOUHrMVE0RheORWPYnmCj3NLPrMkDAbghF/1sgoHFOBEAlWaAyZD6QY7zA==", + "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", diff --git a/ts/package.json b/ts/package.json index ee4a8ff45..d99a4f806 100644 --- a/ts/package.json +++ b/ts/package.json @@ -54,7 +54,7 @@ }, "license": "LGPL-3.0-or-later", "dependencies": { - "@bankofai/8004-sdk": "1.2.0-beta.0", + "@bankofai/8004-sdk": "1.2.0-beta.1", "@bankofai/x402-core": "^1.1.0", "@bankofai/x402-evm": "^1.1.0", "@bankofai/x402-fetch": "^1.1.0", 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..006641de1 --- /dev/null +++ b/ts/src/adapters/outbound/x402/nile-compatibility.test.ts @@ -0,0 +1,73 @@ +import { expect, it, vi } from "vitest"; +import { Wallet } from "ethers"; +import { TronWeb, 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"; + +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..5d510df4f --- /dev/null +++ b/ts/src/adapters/outbound/x402/nile-settlement.test.ts @@ -0,0 +1,171 @@ +import { expect, it, vi } from "vitest"; +import { Interface, Wallet } from "ethers"; +import { TronWeb, 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"; + +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: "provider_error" }); + expect(settlement).toMatchObject({ success: false }); + expect(broadcast).not.toHaveBeenCalled(); + expect(writeContract).not.toHaveBeenCalled(); + } + expect(signTypedData).toHaveBeenCalledOnce(); + expect(fetcher).toHaveBeenCalledTimes(2); + }, +); diff --git a/ts/test/bai-nile-compatibility.test.ts b/ts/test/bai-nile-compatibility.test.ts new file mode 100644 index 000000000..b6ba1ce46 --- /dev/null +++ b/ts/test/bai-nile-compatibility.test.ts @@ -0,0 +1,75 @@ +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, + [ + "--import", + "tsx", + "--import", + pathToFileURL(preload).href, + "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/erc8004.test.ts b/ts/test/erc8004.test.ts index af8145698..83d054c50 100644 --- a/ts/test/erc8004.test.ts +++ b/ts/test/erc8004.test.ts @@ -30,7 +30,28 @@ async function fixture(family: "evm" | "tron") { const server = createServer(async (req, res) => { let body = ""; for await (const chunk of req) body += chunk; - const rpc = JSON.parse(body); + 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" && rpc.function_selector === "register(string)") { + 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 = { @@ -122,7 +143,11 @@ async function fixture(family: "evm" | "tron") { const watch = () => new Keystore(home, new AtomicFileStore(), () => { throw new Error("watch needs no key"); - }).registerWatch({ family, address: owner, label: "observer" }); + }).registerWatch({ + family, + address: family === "tron" ? "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ" : owner, + label: "observer", + }); return { run, calls, network, watch }; } @@ -190,3 +215,18 @@ it("EVM approve dry-run renders the Agent ID without any fungible-token read", a 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); + }); +} diff --git a/ts/test/x402-provider-payment.test.ts b/ts/test/x402-provider-payment.test.ts index 186190d7b..ff0c8c550 100644 --- a/ts/test/x402-provider-payment.test.ts +++ b/ts/test/x402-provider-payment.test.ts @@ -12,6 +12,7 @@ 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) => { @@ -20,9 +21,9 @@ it.each([ new Keystore(home, new AtomicFileStore(), () => { throw new Error("dry-run must not request a password"); }).registerWatch({ - family: alias === "tron" ? "tron" : "evm", + family: alias === "tron" || alias === "nile" ? "tron" : "evm", address: - alias === "tron" + alias === "tron" || alias === "nile" ? "TSNEPtuCagKEgF2EU4pAKWLzXLz1bekfTE" : "0x1111111111111111111111111111111111111111", label: "inspection-only", @@ -38,7 +39,7 @@ it.each([ asset, amount: "1", payTo: - alias === "tron" + alias === "tron" || alias === "nile" ? "TSNEPtuCagKEgF2EU4pAKWLzXLz1bekfTE" : "0x1111111111111111111111111111111111111111", maxTimeoutSeconds: 300, From 527f74597ccc0241d0006015d11abf028021e627 Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Tue, 8 Sep 2026 10:55:36 +0800 Subject: [PATCH 05/10] chore(ts): pin x402 TRON and core beta dependencies --- ts/package-lock.json | 18 +++++++++--------- ts/package.json | 7 ++++--- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ts/package-lock.json b/ts/package-lock.json index 7fbe23e8d..10ade0054 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -10,10 +10,10 @@ "license": "LGPL-3.0-or-later", "dependencies": { "@bankofai/8004-sdk": "1.2.0-beta.1", - "@bankofai/x402-core": "^1.1.0", + "@bankofai/x402-core": "1.1.1-beta.1", "@bankofai/x402-evm": "^1.1.0", "@bankofai/x402-fetch": "^1.1.0", - "@bankofai/x402-tron": "^1.2.0", + "@bankofai/x402-tron": "2.0.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", @@ -91,9 +91,9 @@ } }, "node_modules/@bankofai/x402-core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-core/-/x402-core-1.1.0.tgz", - "integrity": "sha512-5cUUo2/4IdoRX78gZRRc4P5JmlVKwkEYfORZSY+eOkGjnguIwnFSfvshTIFi5Ngoy/JlJlf89f6szn+CyCHDVg==", + "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==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.2" @@ -138,12 +138,12 @@ } }, "node_modules/@bankofai/x402-tron": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@bankofai/x402-tron/-/x402-tron-1.2.0.tgz", - "integrity": "sha512-uUedJ+Qsh76/5VUTs1bLJ9XQZi1DLBskwd/qnXSlxB34BFsdiJ1hH93jH5zqCK9VrYdWfYxCvkOYo5+aGY8CxQ==", + "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==", "license": "Apache-2.0", "dependencies": { - "@bankofai/x402-core": "~1.1.0", + "@bankofai/x402-core": "~1.1.1-beta.1", "tronweb": "^6.1.0" } }, diff --git a/ts/package.json b/ts/package.json index d99a4f806..bbea05ff9 100644 --- a/ts/package.json +++ b/ts/package.json @@ -55,10 +55,10 @@ "license": "LGPL-3.0-or-later", "dependencies": { "@bankofai/8004-sdk": "1.2.0-beta.1", - "@bankofai/x402-core": "^1.1.0", + "@bankofai/x402-core": "1.1.1-beta.1", "@bankofai/x402-evm": "^1.1.0", "@bankofai/x402-fetch": "^1.1.0", - "@bankofai/x402-tron": "^1.2.0", + "@bankofai/x402-tron": "2.0.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", @@ -82,7 +82,8 @@ "overrides": { "axios": "^1.18.1", "ws": "8.21.1", - "esbuild": "^0.28.1" + "esbuild": "^0.28.1", + "@bankofai/x402-core": "$@bankofai/x402-core" }, "devDependencies": { "@eslint/js": "^10.0.1", From e9d6b968494b9dfa51ea0438590fcc10bc27b884 Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Tue, 8 Sep 2026 16:57:55 +0800 Subject: [PATCH 06/10] fix(ts): harden payment boundaries and verify installed CLI packages --- .github/workflows/ts-ci.yml | 4 +- ts/docs/concepts/provider-catalog.md | 7 + ts/docs/development/beta-release.md | 67 +++++++++ ts/package-lock.json | 20 ++- ts/package.json | 21 ++- ts/scripts/verify-package.mjs | 49 +++++++ ts/src/adapters/outbound/bai/client.ts | 13 +- .../outbound/bai/http-boundary.test.ts | 52 +++++++ .../adapters/outbound/bai/recharge-client.ts | 6 +- .../outbound/http/http-response.test.ts | 59 ++++++++ .../adapters/outbound/http/http-response.ts | 81 +++++++++++ .../outbound/x402/payment-client.test.ts | 54 +++++++ .../adapters/outbound/x402/payment-client.ts | 19 +-- .../outbound/x402/provider-catalog.test.ts | 11 ++ .../outbound/x402/provider-catalog.ts | 14 +- ts/src/adapters/outbound/x402/server.test.ts | 68 +++++++++ ts/src/adapters/outbound/x402/server.ts | 33 +++-- ts/src/adapters/outbound/x402/settlement.ts | 10 ++ ts/src/bootstrap/composition.ts | 13 +- ts/src/bootstrap/runner.ts | 3 +- ts/test/bai-nile-compatibility.test.ts | 5 +- ts/test/beta-artifact-signing.test.ts | 87 ++++++++++++ ts/test/beta-command-surface.test.ts | 134 ++++++++++++++++++ ts/test/beta-server-roundtrip.test.ts | 128 +++++++++++++++++ ts/test/erc8004.test.ts | 42 +++++- ts/test/golden.test.ts | 3 +- ts/test/x402-provider-payment.test.ts | 5 +- ts/tsup.config.ts | 4 +- 28 files changed, 962 insertions(+), 50 deletions(-) create mode 100644 ts/docs/concepts/provider-catalog.md create mode 100644 ts/docs/development/beta-release.md create mode 100644 ts/scripts/verify-package.mjs create mode 100644 ts/src/adapters/outbound/bai/http-boundary.test.ts create mode 100644 ts/src/adapters/outbound/http/http-response.test.ts create mode 100644 ts/src/adapters/outbound/http/http-response.ts create mode 100644 ts/src/adapters/outbound/x402/settlement.ts create mode 100644 ts/test/beta-artifact-signing.test.ts create mode 100644 ts/test/beta-command-surface.test.ts create mode 100644 ts/test/beta-server-roundtrip.test.ts 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/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/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/package-lock.json b/ts/package-lock.json index 10ade0054..8bdd0796e 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -10,10 +10,6 @@ "license": "LGPL-3.0-or-later", "dependencies": { "@bankofai/8004-sdk": "1.2.0-beta.1", - "@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", "@ledgerhq/hw-app-eth": "^7.8.15", "@ledgerhq/hw-app-trx": "^6.36.3", "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", @@ -38,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", @@ -94,6 +94,7 @@ "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" @@ -103,6 +104,7 @@ "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" @@ -112,6 +114,7 @@ "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", @@ -123,6 +126,7 @@ "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" @@ -132,6 +136,7 @@ "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" @@ -141,6 +146,7 @@ "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", @@ -5211,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": [ { diff --git a/ts/package.json b/ts/package.json index bbea05ff9..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,15 +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", - "@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", "@ledgerhq/hw-app-eth": "^7.8.15", "@ledgerhq/hw-app-trx": "^6.36.3", "@ledgerhq/hw-transport-node-hid-noevents": "^6.35.4", @@ -83,9 +85,14 @@ "axios": "^1.18.1", "ws": "8.21.1", "esbuild": "^0.28.1", - "@bankofai/x402-core": "$@bankofai/x402-core" + "@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..f93147e44 --- /dev/null +++ b/ts/scripts/verify-package.mjs @@ -0,0 +1,49 @@ +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/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/outbound/bai/client.ts b/ts/src/adapters/outbound/bai/client.ts index 35cdf108c..c65736cd6 100644 --- a/ts/src/adapters/outbound/bai/client.ts +++ b/ts/src/adapters/outbound/bai/client.ts @@ -1,3 +1,4 @@ +import { boundedResponse, MAX_HTTP_RESPONSE_BYTES } from "../http/http-response.js"; import { z } from "zod"; import type { BaiApi, @@ -84,16 +85,24 @@ export class BaiClient implements BaiApi { ? "" : `?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: AbortSignal.timeout(this.timeoutMs), + signal, }); + // Preserve status errors without reading an untrusted error body. + if (response.ok) response = await boundedResponse(response, MAX_HTTP_RESPONSE_BYTES, signal); + else await response.body?.cancel(); } catch (error) { - if (error instanceof Error && error.name === "TimeoutError") { + 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 request failed"); 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.ts b/ts/src/adapters/outbound/bai/recharge-client.ts index 3ee52c82b..92bc80a16 100644 --- a/ts/src/adapters/outbound/bai/recharge-client.ts +++ b/ts/src/adapters/outbound/bai/recharge-client.ts @@ -1,3 +1,4 @@ +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"; @@ -116,6 +117,7 @@ export class BaiRechargeClient implements BaiRechargeApi { ); 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 { @@ -127,9 +129,10 @@ export class BaiRechargeClient implements BaiRechargeApi { Accept: "application/json", }, redirect: "error", - signal: AbortSignal.timeout(this.timeoutMs), + signal, ...(method === "POST" ? { body: payload } : {}), }); + if (!response.ok) await response.body?.cancel(); if (response.status === 401 || response.status === 403) throw new TransportError("bai_auth_failed", "B.AI API rejected the configured credential"); if (response.status === 429) @@ -139,6 +142,7 @@ export class BaiRechargeClient implements BaiRechargeApi { "provider_error", `B.AI recharge API returned HTTP ${response.status}`, ); + response = await boundedResponse(response, MAX_HTTP_RESPONSE_BYTES, signal); decoded = await response.json(); } catch (error) { if (error instanceof TransportError) throw error; 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/payment-client.test.ts b/ts/src/adapters/outbound/x402/payment-client.test.ts index e5dcb8442..dc267bb6f 100644 --- a/ts/src/adapters/outbound/x402/payment-client.test.ts +++ b/ts/src/adapters/outbound/x402/payment-client.test.ts @@ -173,3 +173,57 @@ describe("X402PaymentClient", () => { ).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(); +}); diff --git a/ts/src/adapters/outbound/x402/payment-client.ts b/ts/src/adapters/outbound/x402/payment-client.ts index 5ad7718e8..7d34147b1 100644 --- a/ts/src/adapters/outbound/x402/payment-client.ts +++ b/ts/src/adapters/outbound/x402/payment-client.ts @@ -1,3 +1,5 @@ +import { successfulSettlement } from "./settlement.js"; +import { boundedResponse, fetchBounded, MAX_HTTP_RESPONSE_BYTES } from "../http/http-response.js"; import { x402Client, wrapFetchWithPayment, @@ -33,7 +35,7 @@ type PaidFetchFactory = ( scope: TransactionScope, ) => Promise; -const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; +const MAX_RESPONSE_BYTES = MAX_HTTP_RESPONSE_BYTES; export class X402PaymentClient implements X402PaymentPort { constructor( @@ -56,16 +58,17 @@ export class X402PaymentClient implements X402PaymentPort { const paidFetch = await this.paidFetchFactory(network, signer, scope); return this.readResponse( input.url, - await paidFetch(input.url, requestInit), + await boundedResponse(await paidFetch(input.url, requestInit), MAX_RESPONSE_BYTES), signer, input.out, + toX402Network(network), ); } const boundedFetch = this.boundedFetch(scope); const initial = await boundedFetch(input.url, requestInit); if (initial.status !== 402) - return this.readResponse(input.url, initial, undefined, input.out); + return 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) { @@ -80,6 +83,7 @@ export class X402PaymentClient implements X402PaymentPort { await paidFetch(input.url, requestInit), signer, input.out, + toX402Network(network), ); } catch (error) { if (error instanceof CliError) throw error; @@ -95,6 +99,7 @@ export class X402PaymentClient implements X402PaymentPort { response: Response, signer?: Pick, out?: string, + expectedNetwork?: string, ) { const declaredLength = Number(response.headers.get("content-length")); if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { @@ -121,7 +126,7 @@ export class X402PaymentClient implements X402PaymentPort { url, status: response.status, delivered: response.ok, - settled: paymentResponse !== undefined, + settled: successfulSettlement(paymentResponse, expectedNetwork), ...(signer ? { payer: { address: signer.address } } : {}), ...(paymentResponse === undefined ? {} : { paymentResponse }), }; @@ -218,11 +223,7 @@ export class X402PaymentClient implements X402PaymentPort { } private boundedFetch(scope: TransactionScope): typeof fetch { - return (request, init) => - this.fetcher(request, { - ...init, - signal: init?.signal ?? AbortSignal.timeout(scope.timeoutMs), - }); + return (request, init) => fetchBounded(this.fetcher, request, init, scope.timeoutMs); } private resolveSigner(scope: TransactionScope, network: NetworkDescriptor): Signer { diff --git a/ts/src/adapters/outbound/x402/provider-catalog.test.ts b/ts/src/adapters/outbound/x402/provider-catalog.test.ts index 84cb246c1..16466177a 100644 --- a/ts/src/adapters/outbound/x402/provider-catalog.test.ts +++ b/ts/src/adapters/outbound/x402/provider-catalog.test.ts @@ -48,3 +48,14 @@ describe("X402ProviderCatalog", () => { 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 index 9752ac5b9..67da7f04b 100644 --- a/ts/src/adapters/outbound/x402/provider-catalog.ts +++ b/ts/src/adapters/outbound/x402/provider-catalog.ts @@ -1,3 +1,4 @@ +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"; @@ -5,7 +6,7 @@ import type { ProviderCatalogPort, ProviderListInput, } from "../../../application/ports/provider-catalog.js"; -import { TransportError, UsageError } from "../../../domain/errors/index.js"; +import { CliError, TransportError, UsageError } from "../../../domain/errors/index.js"; const CATALOG_URL = "https://x402-catalog.bankofai.io/api/catalog.json"; @@ -13,6 +14,7 @@ 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) { @@ -80,8 +82,14 @@ export class X402ProviderCatalog implements ProviderCatalogPort { private async readJson(url: string): Promise> { let response: Response; try { - response = await this.fetcher(url, { headers: { accept: "application/json" } }); - } catch { + 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) { diff --git a/ts/src/adapters/outbound/x402/server.test.ts b/ts/src/adapters/outbound/x402/server.test.ts index 4945dc8ae..22f1e562a 100644 --- a/ts/src/adapters/outbound/x402/server.test.ts +++ b/ts/src/adapters/outbound/x402/server.test.ts @@ -7,3 +7,71 @@ describe("x402 server amount conversion", () => { 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(); + }); +}); diff --git a/ts/src/adapters/outbound/x402/server.ts b/ts/src/adapters/outbound/x402/server.ts index 1dce141c7..33f4acd4e 100644 --- a/ts/src/adapters/outbound/x402/server.ts +++ b/ts/src/adapters/outbound/x402/server.ts @@ -1,3 +1,5 @@ +import { successfulSettlement } from "./settlement.js"; +import { fetchBounded } from "../http/http-response.js"; import { createServer, type Server } from "node:http"; import { decodePaymentSignatureHeader, @@ -88,7 +90,10 @@ const TOKENS: Record> = { }; export class X402HttpServer implements X402ServerPort { - constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {} + constructor( + private readonly fetcher: typeof fetch = globalThis.fetch, + private readonly timeoutMs = 60000, + ) {} async start(network: NetworkDescriptor, input: X402ServeInput): Promise { if (input.scheme === "exact_gasfree" && network.family !== "tron") { @@ -123,7 +128,12 @@ export class X402HttpServer implements X402ServerPort { accepts: [requirement], }; const server = createServer(async (request, response) => { - const pathname = new URL(request.url ?? "/", resourceUrl).pathname; + 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"]; @@ -143,7 +153,7 @@ export class X402HttpServer implements X402ServerPort { paymentPayload, paymentRequirements: requirement, }); - if (settle.success !== true || typeof settle.transaction !== "string") + if (!successfulSettlement(settle, x402Network)) return json(response, 502, { error: "settlement failed" }); response.setHeader("payment-response", encodePaymentResponseHeader(settle as never)); return json(response, 200, { @@ -176,11 +186,18 @@ export class X402HttpServer implements X402ServerPort { path: string, body: unknown, ): Promise> { - const response = await this.fetcher(new URL(path, `${base.replace(/\/+$/, "")}/`), { - method: "POST", - headers: { "content-type": "application/json", accept: "application/json" }, - body: JSON.stringify(body), - }); + 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; 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/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index 70167dddf..75225c5ff 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -1,3 +1,4 @@ +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 { walletAddress } from "../domain/wallet/index.js"; @@ -71,6 +72,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; @@ -172,7 +175,11 @@ export function composeCliRuntime(options: BootstrapOptions) { ); registerX402Commands( registry, - new X402Service(x402Payments, new X402ProviderCatalog(), new X402HttpServer()), + new X402Service( + x402Payments, + new X402ProviderCatalog(undefined, undefined, timeoutMs), + new X402HttpServer(undefined, timeoutMs), + ), ); const accountBalances = new AccountBalanceService(gatewayProvider); const tokenBookService = new TokenBookService(tokenBook); @@ -223,6 +230,10 @@ export function composeCliRuntime(options: BootstrapOptions) { 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 ( (network.family === "evm" && ["56", "8453"].includes(network.chainId)) || (network.family === "tron" && network.chainId === "728126428") 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/test/bai-nile-compatibility.test.ts b/ts/test/bai-nile-compatibility.test.ts index b6ba1ce46..abfd62920 100644 --- a/ts/test/bai-nile-compatibility.test.ts +++ b/ts/test/bai-nile-compatibility.test.ts @@ -36,11 +36,10 @@ it("keeps BAI summary available but rejects recharge on Nile before any account spawnSync( process.execPath, [ - "--import", - "tsx", + ...(process.env.WALLET_CLI_TEST_ENTRY ? [] : ["--import", "tsx"]), "--import", pathToFileURL(preload).href, - "src/index.ts", + process.env.WALLET_CLI_TEST_ENTRY ?? "src/index.ts", "bai", ...args, "--network", diff --git a/ts/test/beta-artifact-signing.test.ts b/ts/test/beta-artifact-signing.test.ts new file mode 100644 index 000000000..53d156f7a --- /dev/null +++ b/ts/test/beta-artifact-signing.test.ts @@ -0,0 +1,87 @@ +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, + `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..cdc2acc44 --- /dev/null +++ b/ts/test/beta-command-surface.test.ts @@ -0,0 +1,134 @@ +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", "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..1f904b306 --- /dev/null +++ b/ts/test/beta-server-roundtrip.test.ts @@ -0,0 +1,128 @@ +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, + `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 index 83d054c50..5b58bf565 100644 --- a/ts/test/erc8004.test.ts +++ b/ts/test/erc8004.test.ts @@ -11,6 +11,9 @@ 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)", @@ -44,7 +47,16 @@ async function fixture(family: "evm" | "tron") { res.end(JSON.stringify(data)); return; } - if (family === "tron" && rpc.function_selector === "register(string)") { + 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( @@ -230,3 +242,31 @@ for (const mode of ["dry-run", "build-only"] as const) { 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 ef8b6d8b4..6cfdcd84a 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", () => { diff --git a/ts/test/x402-provider-payment.test.ts b/ts/test/x402-provider-payment.test.ts index ff0c8c550..8335990dd 100644 --- a/ts/test/x402-provider-payment.test.ts +++ b/ts/test/x402-provider-payment.test.ts @@ -53,11 +53,10 @@ it.each([ const result = spawnSync( process.execPath, [ - "--import", - "tsx", + ...(process.env.WALLET_CLI_TEST_ENTRY ? [] : ["--import", "tsx"]), "--import", pathToFileURL(preload).href, - "src/index.ts", + process.env.WALLET_CLI_TEST_ENTRY ?? "src/index.ts", "x402", "pay", "https://example.com/data", 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 From e3a11a7ff1bbb5f74983eeaa5beb40a9c1fe60c8 Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Wed, 9 Sep 2026 10:33:03 +0800 Subject: [PATCH 07/10] refactor(ts): align BAI and identity commands with application boundaries --- CLAUDE.md | 9 +- ts/.dependency-cruiser.cjs | 6 + ts/README.md | 4 +- ts/docs/commands/networks.md | 4 +- ts/docs/concepts/networks.md | 4 + .../inbound/cli/commands/config.bai.test.ts | 2 +- .../adapters/inbound/cli/commands/config.ts | 5 +- .../inbound/cli/commands/erc8004.test.ts | 17 +- .../adapters/inbound/cli/commands/erc8004.ts | 162 ++++++++++++------ .../cli/commands/text-formatters.test.ts | 4 +- ts/src/adapters/outbound/config/builtins.ts | 13 ++ .../adapters/outbound/config/config.test.ts | 21 ++- .../contracts/transaction-input.ts | 34 ++++ ts/src/application/ports/agent-registry.ts | 8 +- .../application/services/approve-receipt.ts | 4 +- .../application/services/transaction-mode.ts | 10 +- .../use-cases/bai-credential-setup.test.ts | 108 +++++++++++- .../use-cases/bai-credential-setup.ts | 29 +++- ts/src/application/use-cases/bai-service.ts | 4 +- .../use-cases/evm/contract-service.ts | 29 +--- .../use-cases/tron/governance-transaction.ts | 9 +- ts/src/bootstrap/composition.ts | 45 ++--- ts/src/bootstrap/families/evm.test.ts | 8 + ts/src/bootstrap/families/evm.ts | 31 +++- ts/src/bootstrap/families/tron.ts | 29 ++++ ts/test/golden.test.ts | 5 +- 26 files changed, 455 insertions(+), 149 deletions(-) create mode 100644 ts/src/application/contracts/transaction-input.ts 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 9553a1488..beb743d36 100644 --- a/ts/README.md +++ b/ts/README.md @@ -27,7 +27,7 @@ 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`. +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 | | ----------------- | ------ | ----------- | ---------------------------------------- | @@ -38,6 +38,8 @@ Seven built-in networks are supported. Networks use a canonical [CAIP-2](https:/ | `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). 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/src/adapters/inbound/cli/commands/config.bai.test.ts b/ts/src/adapters/inbound/cli/commands/config.bai.test.ts index 9dc7b3d8b..a9963fb0b 100644 --- a/ts/src/adapters/inbound/cli/commands/config.bai.test.ts +++ b/ts/src/adapters/inbound/cli/commands/config.bai.test.ts @@ -5,7 +5,7 @@ import type { ConfigService } from "../../../../application/use-cases/config-ser function fixture(check = vi.fn(async (_key: string) => {})) { const execute = vi.fn(() => ({ value: "********" })); const registry = new CommandRegistry(); - registerConfigCommands(registry, { execute } as unknown as ConfigService, check); + registerConfigCommands(registry, { execute } as unknown as ConfigService, { execute: check }); const ctx = { secrets: { has: () => true, require: () => "new-secret" }, config: {}, diff --git a/ts/src/adapters/inbound/cli/commands/config.ts b/ts/src/adapters/inbound/cli/commands/config.ts index ba8f322c6..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 { @@ -12,7 +13,7 @@ import { UsageError } from "../../../../domain/errors/index.js"; export function registerConfigCommands( registry: CommandRegistry, service: ConfigService, - confirmBaiKey?: (key: string) => Promise, + baiSetup: Pick, ): void { const fields = z.object({ // Not an enum: `networks.[.]` is a nested path, and the id segment is @@ -68,7 +69,7 @@ export function registerConfigCommands( const effectiveInput = hasApiKeyInput ? { key: "baiApiKey", value: ctx.secrets.require("apiKey") } : input; - if (hasApiKeyInput && confirmBaiKey) await confirmBaiKey(effectiveInput.value!); + 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 index 58e6a2d02..220fe2a42 100644 --- a/ts/src/adapters/inbound/cli/commands/erc8004.test.ts +++ b/ts/src/adapters/inbound/cli/commands/erc8004.test.ts @@ -1,6 +1,17 @@ import { describe, expect, it } from "vitest"; import { CommandRegistry } from "../registry/index.js"; -import { registerAgentCommands } from "./erc8004.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", () => { @@ -22,7 +33,9 @@ describe("ERC-8004 command surface", () => { expect(command?.spec.path).toEqual(["8004", verb]); expect(Object.keys(command?.families ?? {}).sort()).toEqual(["evm", "tron"]); } - expect(registry.all()).toHaveLength(8); + 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", () => { diff --git a/ts/src/adapters/inbound/cli/commands/erc8004.ts b/ts/src/adapters/inbound/cli/commands/erc8004.ts index 01040c6de..2722197e6 100644 --- a/ts/src/adapters/inbound/cli/commands/erc8004.ts +++ b/ts/src/adapters/inbound/cli/commands/erc8004.ts @@ -1,10 +1,9 @@ import { z } from "zod"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; -import type { CommandRegistry } from "../registry/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 } from "../render/index.js"; +import { TextFormatters, renderGenericText } from "../render/index.js"; const agentId = z .string() @@ -63,12 +62,13 @@ function writeSpec( }; } -const showSpec: ChainSpec = { +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 }), @@ -78,7 +78,7 @@ const showSpec: ChainSpec = { ], }; -const registerSpec = writeSpec( +export const registerSpec = writeSpec( "register", z.object({ uri }), [{ field: "uri" }], @@ -88,14 +88,14 @@ registerSpec.examples = [ { cmd: "wallet-cli 8004 register ipfs://bafy... --network nile --password-stdin" }, ]; -const updateSpec = writeSpec( +export const updateSpec = writeSpec( "update", z.object({ id: agentId, uri }), [{ field: "id" }, { field: "uri" }], "Update an Agent registration URI", ); -const transferSpec = writeSpec( +export const transferSpec = writeSpec( "transfer", z.object({ id: agentId, newOwner: address.describe("new owner address") }), [{ field: "id" }, { field: "newOwner" }], @@ -107,7 +107,7 @@ const approveFields = z.object({ operator: address.optional().describe("address approved for this Agent"), revoke: z.boolean().default(false).describe("clear the current per-Agent approval"), }); -const approveSpec = writeSpec( +export const approveSpec = writeSpec( "approve", approveFields, [{ field: "id" }, { field: "operator" }], @@ -131,26 +131,27 @@ approveSpec.baseRefine = (value, context) => { } }; -const operatorAddSpec = writeSpec( +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", ); -const operatorRemoveSpec = writeSpec( +export const operatorRemoveSpec = writeSpec( "operator-remove", z.object({ operator: address.describe("operator address") }), [{ field: "operator" }], "Remove an owner-wide Agent operator", ); -const operatorCheckSpec: ChainSpec = { +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({ @@ -163,74 +164,131 @@ const operatorCheckSpec: ChainSpec = { ], }; -function addBoth( - registry: CommandRegistry, - spec: ChainSpec, +function binding( + family: "evm" | "tron", run: FamilyBinding["run"], addressFields: string[] = [], write = false, -): void { - for (const family of ["evm", "tron"] as const) { - registry.addChain(spec, family, { - run, - ...(write && family === "tron" ? { fields: tronWriteFields } : {}), - ...(addressFields.length ? { refine: addressFieldsFor(family, ...addressFields) } : {}), - }); - } +): FamilyBinding { + return { + run, + ...(write && family === "tron" ? { fields: tronWriteFields } : {}), + ...(addressFields.length ? { refine: addressFieldsFor(family, ...addressFields) } : {}), + }; } - -export function registerAgentCommands(registry: CommandRegistry, service: AgentService): void { - addBoth(registry, showSpec, async (ctx, net, input) => { - const result = await service.show(net, input.id); - for (const warning of result.warnings ?? []) ctx.warn(warning); - return result; - }); - addBoth( - registry, - registerSpec, - async (ctx, net, input) => service.register(ctx, net, input), +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; + }, [], - true, + false, ); - addBoth( - registry, - updateSpec, - async (ctx, net, input) => service.update(ctx, net, input), +} +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, ); - addBoth( - registry, - transferSpec, +} +export function transferTronBinding(service: AgentService): FamilyBinding { + return binding( + "tron", async (ctx, net, input) => service.transfer(ctx, net, input), ["newOwner"], true, ); - addBoth( - registry, - approveSpec, +} +export function approveEvmBinding(service: AgentService): FamilyBinding { + return binding( + "evm", async (ctx, net, input) => service.approve(ctx, net, input), ["operator"], true, ); - addBoth( - registry, - operatorAddSpec, +} +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, ); - addBoth( - registry, - operatorRemoveSpec, +} +export function operatorRemoveEvmBinding(service: AgentService): FamilyBinding { + return binding( + "evm", async (ctx, net, input) => service.operatorRemove(ctx, net, input), ["operator"], true, ); - addBoth( - registry, - operatorCheckSpec, +} +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/outbound/config/builtins.ts b/ts/src/adapters/outbound/config/builtins.ts index 279b17b18..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", @@ -142,6 +144,16 @@ export const BUILTIN_NETWORKS: Record = { 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 @@ -168,6 +180,7 @@ export const BUILTIN_ALIASES: Record = { 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 6d232a427..0370362f9 100644 --- a/ts/src/adapters/outbound/config/config.test.ts +++ b/ts/src/adapters/outbound/config/config.test.ts @@ -165,17 +165,36 @@ describe("builtin EVM networks", () => { ["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", "eip155:8453"]) { + 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/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/ports/agent-registry.ts b/ts/src/application/ports/agent-registry.ts index 163364e72..5f26fcd81 100644 --- a/ts/src/application/ports/agent-registry.ts +++ b/ts/src/application/ports/agent-registry.ts @@ -1,8 +1,10 @@ import type { NetworkDescriptor } from "../../domain/types/index.js"; import type { TransactionScope } from "../contracts/execution-scope.js"; -import type { EvmContractWriteInput } from "../use-cases/evm/contract-service.js"; -import type { ApprovalKind } from "../services/approve-receipt.js"; -import type { GovernanceTransactionInput } from "../use-cases/tron/governance-transaction.js"; +import type { + EvmContractWriteInput, + ApprovalKind, + GovernanceTransactionInput, +} from "../contracts/transaction-input.js"; import type { TronContractParameter } from "./chain/tron-gateway.js"; export interface EvmContractPort { diff --git a/ts/src/application/services/approve-receipt.ts b/ts/src/application/services/approve-receipt.ts index cbdc5ed78..37210de05 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. * @@ -23,8 +25,6 @@ function normalizeSignature(signature?: string): string { return (signature ?? "").replace(/\s+/g, ""); } -export type ApprovalKind = "erc721"; - export interface ApproveContext { method?: string; params?: Array<{ value?: unknown }>; 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/use-cases/bai-credential-setup.test.ts b/ts/src/application/use-cases/bai-credential-setup.test.ts index c9ef8c030..a56acc461 100644 --- a/ts/src/application/use-cases/bai-credential-setup.test.ts +++ b/ts/src/application/use-cases/bai-credential-setup.test.ts @@ -11,7 +11,21 @@ it("checks the selected key and payer once, then reuses local confirmation", asy }, }; const check = vi.fn(async () => true); - const setup = new BaiCredentialSetup(store, check); + 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); @@ -30,8 +44,98 @@ it.each([false, new Error("unavailable")])( return result; }); await expect( - new BaiCredentialSetup(store, check).confirm("key", "bnb", "payer"), + 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 index 3d25ae4a7..831ab52db 100644 --- a/ts/src/application/use-cases/bai-credential-setup.ts +++ b/ts/src/application/use-cases/bai-credential-setup.ts @@ -1,12 +1,20 @@ +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 { +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", @@ -18,7 +26,26 @@ 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"); diff --git a/ts/src/application/use-cases/bai-service.ts b/ts/src/application/use-cases/bai-service.ts index 524922524..5f676a97e 100644 --- a/ts/src/application/use-cases/bai-service.ts +++ b/ts/src/application/use-cases/bai-service.ts @@ -1,7 +1,7 @@ import type { BaiRechargeApi, BaiRechargeTarget } from "../ports/bai-recharge.js"; import { BaiRechargeFlow } from "./bai-recharge-flow.js"; import { baiPaymentResult } from "../services/bai-payment-result.js"; -import { baiChain } from "./bai-credential-setup.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"; @@ -42,7 +42,7 @@ export class BaiService { } if (!this.bindings) throw new UsageError("invalid_option", "B.AI recharge binding verification is unavailable"); - const chain = baiChain(network); + const chain = requireBaiChain(network); const payer = scope.resolveAddress(network.family); if (!this.bindings.isConfirmed(input.apiKey, chain, payer)) { throw new UsageError( diff --git a/ts/src/application/use-cases/evm/contract-service.ts b/ts/src/application/use-cases/evm/contract-service.ts index 8713ddd6e..b74c1811b 100644 --- a/ts/src/application/use-cases/evm/contract-service.ts +++ b/ts/src/application/use-cases/evm/contract-service.ts @@ -1,41 +1,20 @@ +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"; import { evmConfirmation } from "../../services/evm-confirmation.js"; -import { approveRows, type ApprovalKind } from "../../services/approve-receipt.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; - /** 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; -} - /** * Contract reads and writes. * 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/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index 75225c5ff..bbb1ee356 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -1,8 +1,6 @@ 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 { walletAddress } from "../domain/wallet/index.js"; -import { UsageError } from "../domain/errors/index.js"; import { BaiRechargeClient } from "../adapters/outbound/bai/recharge-client.js"; import { isTronNetwork } from "../domain/types/network.js"; import type { OutputMode } from "../domain/types/index.js"; @@ -57,7 +55,6 @@ import { TronContractService } from "../application/use-cases/tron/contract-serv 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 { registerAgentCommands } from "../adapters/inbound/cli/commands/erc8004.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"; @@ -128,24 +125,14 @@ export function composeCliRuntime(options: BootstrapOptions) { qr: new TerminalQrEncoder(), }); const baiBindings = new FileBaiBindingStore(root, store); - const baiSetup = new BaiCredentialSetup(baiBindings, (apiKey, input) => - new BaiRechargeClient({ baiApiKey: apiKey }, timeoutMs).isBound(input), + 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, async (apiKey) => { - const net = networkRegistry.resolve(options.globals.network ?? config.defaultNetwork); - const chain = baiChain(net); - const account = options.globals.account ?? keystore.activeAccount(); - if (!account) - throw new UsageError( - "invalid_value", - "Select a payer wallet before configuring the B.AI API key", - ); - const selected = keystore.resolveAccount(account, net.family); - const address = walletAddress(selected.wallet, net.family, selected.index); - if (!address) - throw new UsageError("family_mismatch", "Selected wallet has no address for this network"); - await baiSetup.confirm(apiKey, chain, address); - }); + registerConfigCommands(registry, configService, baiSetup); registerNetworkCommands(registry); registerContactCommands(registry, new ContactService(contactBook)); registerEncodingCommands(registry, new EncodingService()); @@ -165,13 +152,10 @@ export function composeCliRuntime(options: BootstrapOptions) { evm: new EvmContractService(gatewayProvider, txPipeline), tron: new TronContractService(gatewayProvider, txPipeline), }; - registerAgentCommands( - registry, - new AgentService( - agentContracts, - new SdkAgentRegistry(agentContracts, gatewayProvider), - new RegistrationLoader(timeoutMs), - ), + const agents = new AgentService( + agentContracts, + new SdkAgentRegistry(agentContracts, gatewayProvider), + new RegistrationLoader(timeoutMs), ); registerX402Commands( registry, @@ -184,6 +168,7 @@ export function composeCliRuntime(options: BootstrapOptions) { const accountBalances = new AccountBalanceService(gatewayProvider); const tokenBookService = new TokenBookService(tokenBook); registerTronChainCommands(registry, { + agents, gateways: gatewayProvider, tokens: tokenBook, prices: priceProvider, @@ -198,6 +183,7 @@ export function composeCliRuntime(options: BootstrapOptions) { tokenBook: tokenBookService, }); registerEvmChainCommands(registry, { + agents, signers: signerResolver, gateways: gatewayProvider, balances: accountBalances, @@ -234,10 +220,7 @@ export function composeCliRuntime(options: BootstrapOptions) { key: "x402.serve", summary: "Serve a local x402 endpoint; the server validates network token support", }); - if ( - (network.family === "evm" && ["56", "8453"].includes(network.chainId)) || - (network.family === "tron" && network.chainId === "728126428") - ) { + if (baiChain(network)) { commandCapabilities.push({ key: "bai.recharge", summary: "Recharge B.AI from a configured payer wallet", 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/test/golden.test.ts b/ts/test/golden.test.ts index 6cfdcd84a..104e64510 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -115,7 +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 + 5 EVM, including Base mainnet. + // Both families ship: 3 TRON + 6 EVM; every EVM mainnet has a builtin testnet partner. expect(ids).toEqual( expect.arrayContaining([ "tron:728126428", @@ -126,9 +126,10 @@ describe("golden CLI — meta & introspection", () => { "eip155:56", "eip155:97", "eip155:8453", + "eip155:84532", ]), ); - expect(ids).toHaveLength(8); + 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); From 48918534eb7231e76a01446f3349ee0d0f357355 Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Wed, 9 Sep 2026 16:02:46 +0800 Subject: [PATCH 08/10] fix(ts): preserve payment evidence and add BAI recharge recovery --- ts/docs/development/bai-recharge.md | 84 +++++++++ ts/docs/machine-interface.md | 57 ++++++ ts/scripts/verify-package.mjs | 1 + .../adapters/inbound/cli/commands/bai.test.ts | 19 ++ ts/src/adapters/inbound/cli/commands/bai.ts | 59 ++++++- .../inbound/cli/render/erc8004.test.ts | 18 +- ts/src/adapters/inbound/cli/render/tx.ts | 22 ++- .../cli/shell/positional-contract.test.ts | 1 + .../adapters/outbound/config/bai-builtins.ts | 8 + .../adapters/outbound/config/x402-builtins.ts | 1 + .../adapters/outbound/x402/allowance.test.ts | 167 ++++++++++++++++++ .../x402/evm-approval-extension.test.ts | 113 ++++++++++++ .../outbound/x402/gasfree-errors.test.ts | 69 ++++++++ .../outbound/x402/nile-compatibility.test.ts | 13 +- .../outbound/x402/nile-settlement.test.ts | 15 +- .../outbound/x402/payment-client.test.ts | 141 ++++++++++++++- .../adapters/outbound/x402/payment-client.ts | 148 ++++++++++++---- .../adapters/outbound/x402/payment-error.ts | 71 ++++++++ .../x402/payment-signer-integration.test.ts | 2 +- .../adapters/outbound/x402/roundtrip.test.ts | 71 ++++++++ ts/src/adapters/outbound/x402/server.test.ts | 22 +++ ts/src/adapters/outbound/x402/server.ts | 46 ++++- .../outbound/x402/signer-bridge.test.ts | 27 +++ .../adapters/outbound/x402/signer-bridge.ts | 14 +- ts/src/application/ports/bai-recharge.ts | 6 + ts/src/application/ports/x402-server.ts | 11 ++ .../services/approve-receipt.test.ts | 2 +- .../application/services/approve-receipt.ts | 2 +- .../services/bai-payment-result.test.ts | 119 ++++++++++--- .../services/bai-payment-result.ts | 93 +++++----- .../use-cases/agent-service.test.ts | 16 +- ts/src/application/use-cases/agent-service.ts | 20 ++- .../use-cases/bai-recharge-flow.test.ts | 22 +++ .../use-cases/bai-recharge-flow.ts | 57 ++++-- .../bai-recharge-integration.test.ts | 59 +++++-- .../use-cases/bai-recharge-report.test.ts | 68 +++++++ .../application/use-cases/bai-service.test.ts | 4 +- ts/src/application/use-cases/bai-service.ts | 130 ++++++++++---- .../use-cases/evm/contract-service.test.ts | 3 +- .../tron/contract-service.approve.test.ts | 2 +- .../use-cases/x402-service.test.ts | 39 ++++ ts/src/application/use-cases/x402-service.ts | 10 ++ ts/src/bootstrap/composition.ts | 19 +- ts/src/domain/bai/recharge-policy.ts | 12 -- ts/src/domain/errors/codes.ts | 4 + ts/src/domain/types/tx.ts | 25 +-- ts/test/bai-recharge-report.test.ts | 76 ++++++++ ts/test/bai-recharge.test.ts | 2 +- ts/test/beta-artifact-signing.test.ts | 4 +- ts/test/beta-command-surface.test.ts | 5 +- ts/test/beta-server-roundtrip.test.ts | 4 +- ts/test/erc8004.test.ts | 7 +- ts/test/tron-allowance-preload.ts | 18 ++ 53 files changed, 1765 insertions(+), 263 deletions(-) create mode 100644 ts/docs/development/bai-recharge.md create mode 100644 ts/src/adapters/outbound/config/bai-builtins.ts create mode 100644 ts/src/adapters/outbound/config/x402-builtins.ts create mode 100644 ts/src/adapters/outbound/x402/allowance.test.ts create mode 100644 ts/src/adapters/outbound/x402/evm-approval-extension.test.ts create mode 100644 ts/src/adapters/outbound/x402/gasfree-errors.test.ts create mode 100644 ts/src/adapters/outbound/x402/payment-error.ts create mode 100644 ts/src/adapters/outbound/x402/roundtrip.test.ts create mode 100644 ts/src/application/use-cases/bai-recharge-report.test.ts create mode 100644 ts/test/bai-recharge-report.test.ts create mode 100644 ts/test/tron-allowance-preload.ts diff --git a/ts/docs/development/bai-recharge.md b/ts/docs/development/bai-recharge.md new file mode 100644 index 000000000..7aabc4bfd --- /dev/null +++ b/ts/docs/development/bai-recharge.md @@ -0,0 +1,84 @@ +# 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. + +## 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. diff --git a/ts/docs/machine-interface.md b/ts/docs/machine-interface.md index c32bcc6d7..ab4a4f118 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -324,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. @@ -405,3 +409,56 @@ 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. diff --git a/ts/scripts/verify-package.mjs b/ts/scripts/verify-package.mjs index f93147e44..83659e9b8 100644 --- a/ts/scripts/verify-package.mjs +++ b/ts/scripts/verify-package.mjs @@ -37,6 +37,7 @@ try { "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", ], diff --git a/ts/src/adapters/inbound/cli/commands/bai.test.ts b/ts/src/adapters/inbound/cli/commands/bai.test.ts index 5b1b95c25..212b6a598 100644 --- a/ts/src/adapters/inbound/cli/commands/bai.test.ts +++ b/ts/src/adapters/inbound/cli/commands/bai.test.ts @@ -74,3 +74,22 @@ describe("B.AI command surface", () => { }); }); }); + +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 index f6b7070ef..466fe4cbf 100644 --- a/ts/src/adapters/inbound/cli/commands/bai.ts +++ b/ts/src/adapters/inbound/cli/commands/bai.ts @@ -45,7 +45,7 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi 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. Mainnet TRON supports exact and exact_gasfree; BSC and Base support exact. Minimums: TRX 15, USDT/USDC 1, ETH 0.0001, SOL 0.01; other tokens have no additional minimum. Token availability depends on the network and payment service.", + "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: [ @@ -68,6 +68,63 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi }, } 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"], diff --git a/ts/src/adapters/inbound/cli/render/erc8004.test.ts b/ts/src/adapters/inbound/cli/render/erc8004.test.ts index 3811ba7ec..d25e52810 100644 --- a/ts/src/adapters/inbound/cli/render/erc8004.test.ts +++ b/ts/src/adapters/inbound/cli/render/erc8004.test.ts @@ -8,8 +8,10 @@ it("renders Agent approval as an operator and ID without an allowance", () => { kind: "contract-send", stage: "submitted", txId: "0xabc", - agentId: "9007199254740993", - operator: "0x2222222222222222222222222222222222222222", + identity: { + agentId: "9007199254740993", + operator: "0x2222222222222222222222222222222222222222", + }, }, ctx, ); @@ -23,11 +25,13 @@ it("shows URI and owner results only when supplied by the confirmed service resu kind: "contract-send", stage: "confirmed", txId: "0xabc", - oldURI: "ipfs://old", - newURI: "ipfs://actual", - requestedURI: "ipfs://requested", - oldOwner: "0x1111", - newOwner: "0x2222", + identity: { + oldURI: "ipfs://old", + newURI: "ipfs://actual", + requestedURI: "ipfs://requested", + oldOwner: "0x1111", + newOwner: "0x2222", + }, }, ctx, ); diff --git a/ts/src/adapters/inbound/cli/render/tx.ts b/ts/src/adapters/inbound/cli/render/tx.ts index 61a440a72..cd918f662 100644 --- a/ts/src/adapters/inbound/cli/render/tx.ts +++ b/ts/src/adapters/inbound/cli/render/tx.ts @@ -395,15 +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.agentId !== undefined) rows.push(["Agent ID", r.agentId]); - if (r.operator !== undefined) rows.push(["Operator", r.operator]); - if (r.uri !== undefined) rows.push(["URI", r.uri]); - if (r.oldURI !== undefined) rows.push(["Previous URI", r.oldURI]); - if (r.requestedURI !== undefined) rows.push(["Requested URI", r.requestedURI]); - if (r.newURI !== undefined) rows.push(["Current URI", r.newURI]); - if (r.oldOwner !== undefined) rows.push(["Previous owner", r.oldOwner]); - if (r.requestedOwner !== undefined) rows.push(["Requested owner", r.requestedOwner]); - if (r.newOwner !== undefined) rows.push(["Current owner", r.newOwner]); + 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 2175e29df..91287b4d4 100644 --- a/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts +++ b/ts/src/adapters/inbound/cli/shell/positional-contract.test.ts @@ -103,6 +103,7 @@ describe("every registered positional command rejects its -- spelling", ( "asset participate", "backup", "bai recharge", + "bai recharge-report", "block", "config", "contact add", 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/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/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 index 006641de1..628d58b11 100644 --- a/ts/src/adapters/outbound/x402/nile-compatibility.test.ts +++ b/ts/src/adapters/outbound/x402/nile-compatibility.test.ts @@ -1,10 +1,19 @@ -import { expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { Wallet } from "ethers"; -import { TronWeb, utils as tronUtils } from "tronweb"; +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; diff --git a/ts/src/adapters/outbound/x402/nile-settlement.test.ts b/ts/src/adapters/outbound/x402/nile-settlement.test.ts index 5d510df4f..4358f335e 100644 --- a/ts/src/adapters/outbound/x402/nile-settlement.test.ts +++ b/ts/src/adapters/outbound/x402/nile-settlement.test.ts @@ -1,6 +1,6 @@ -import { expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { Interface, Wallet } from "ethers"; -import { TronWeb, utils as tronUtils } from "tronweb"; +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"; @@ -8,6 +8,15 @@ 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) => { @@ -160,7 +169,7 @@ it.each([true, false])( expect(broadcast).toHaveBeenCalledOnce(); expect(waitForTransactionReceipt).toHaveBeenCalledWith({ hash: settlement?.transaction }); } else { - await expect(payment).rejects.toMatchObject({ code: "provider_error" }); + await expect(payment).rejects.toMatchObject({ code: "permit2_allowance_required" }); expect(settlement).toMatchObject({ success: false }); expect(broadcast).not.toHaveBeenCalled(); expect(writeContract).not.toHaveBeenCalled(); diff --git a/ts/src/adapters/outbound/x402/payment-client.test.ts b/ts/src/adapters/outbound/x402/payment-client.test.ts index dc267bb6f..ccbb84cbe 100644 --- a/ts/src/adapters/outbound/x402/payment-client.test.ts +++ b/ts/src/adapters/outbound/x402/payment-client.test.ts @@ -2,7 +2,7 @@ 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 } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -227,3 +227,142 @@ it("bounds oversized 402 bodies before the SDK or signer handles them", async () 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 index 7d34147b1..881315dbb 100644 --- a/ts/src/adapters/outbound/x402/payment-client.ts +++ b/ts/src/adapters/outbound/x402/payment-client.ts @@ -1,3 +1,4 @@ +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 { @@ -17,12 +18,7 @@ import type { X402PayInput, X402PaymentPort } from "../../../application/ports/x 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 { - CliError, - ExecutionError, - TransportError, - UsageError, -} from "../../../domain/errors/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"; @@ -56,19 +52,32 @@ export class X402PaymentClient implements X402PaymentPort { if (this.paidFetchFactory && !input.expectedPayTo && input.exactAmount === undefined) { const signer = this.resolveSigner(scope, network); const paidFetch = await this.paidFetchFactory(network, signer, scope); - return this.readResponse( + 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, - await boundedResponse(await paidFetch(input.url, requestInit), MAX_RESPONSE_BYTES), + bounded, signer, input.out, toX402Network(network), ); } - const boundedFetch = this.boundedFetch(scope); + const boundedFetch = this.boundedFetch(scope, network); const initial = await boundedFetch(input.url, requestInit); if (initial.status !== 402) - return this.readResponse(input.url, initial, undefined, input.out, toX402Network(network)); + 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) { @@ -78,7 +87,7 @@ export class X402PaymentClient implements X402PaymentPort { const signer = createPayerSigner(this.signers, scope, network.family); const paidFetch = await this.createPaidFetch(network, signer, scope, input, initial); - return this.readResponse( + return await this.readResponse( input.url, await paidFetch(input.url, requestInit), signer, @@ -86,11 +95,7 @@ export class X402PaymentClient implements X402PaymentPort { toX402Network(network), ); } catch (error) { - if (error instanceof CliError) throw error; - if (error instanceof Error && /timeout|aborted/i.test(`${error.name} ${error.message}`)) { - throw new TransportError("timeout", "x402 request timed out"); - } - throw new TransportError("provider_error", "x402 request or payment failed"); + throw sdkPaymentError(error); } } @@ -130,21 +135,34 @@ export class X402PaymentClient implements X402PaymentPort { ...(signer ? { payer: { address: signer.address } } : {}), ...(paymentResponse === undefined ? {} : { paymentResponse }), }; - 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"); + 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); } - return { ...base, response: body }; } private async createPaidFetch( @@ -198,7 +216,7 @@ export class X402PaymentClient implements X402PaymentPort { network: x402Network, ...(network.httpEndpoint ? { rpcUrl: network.httpEndpoint } : {}), ...(network.apiKey ? { apiKey: network.apiKey } : {}), - allowanceMode: "skip", + allowanceMode: "auto", }); registerExactTronScheme(client, { signer: tronSigner as ClientTronSigner, @@ -209,7 +227,12 @@ export class X402PaymentClient implements X402PaymentPort { networks: [x402Network as Network], }); } - const boundedFetch = this.boundedFetch(scope); + // 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) { @@ -219,11 +242,33 @@ export class X402PaymentClient implements X402PaymentPort { } return boundedFetch(request, init); }; - return wrapFetchWithPayment(fetchWithInitial, client); + const paidFetch = wrapFetchWithPayment(fetchWithInitial, client); + return async (request, init) => { + try { + return await paidFetch(request, init); + } catch (error) { + throw creationError ?? error; + } + }; } - private boundedFetch(scope: TransactionScope): typeof fetch { - return (request, init) => fetchBounded(this.fetcher, request, init, scope.timeoutMs); + 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 { @@ -424,3 +469,38 @@ function parseHeaders(values: string[]): Headers { } 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-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 index b9c79f568..f2d0fc71f 100644 --- a/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts +++ b/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts @@ -78,7 +78,7 @@ describe("x402 SDK payer integration", () => { 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: "provider_error", + code: "signed_payload_mismatch", }); expect(fetcher).toHaveBeenCalledOnce(); }); 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 index 22f1e562a..c8976eed1 100644 --- a/ts/src/adapters/outbound/x402/server.test.ts +++ b/ts/src/adapters/outbound/x402/server.test.ts @@ -75,3 +75,25 @@ it.each([ 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 index 33f4acd4e..514be5d21 100644 --- a/ts/src/adapters/outbound/x402/server.ts +++ b/ts/src/adapters/outbound/x402/server.ts @@ -1,3 +1,4 @@ +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"; @@ -71,6 +72,14 @@ const TOKENS: Record> = { permit2: true, }, }, + "eip155:8453": { + USDC: { + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + decimals: 6, + name: "USD Coin", + version: "2", + }, + }, "eip155:97": { USDT: { address: "0x337610d27c682E347C9cD60BD4b3b107C9d34dDd", @@ -95,7 +104,11 @@ export class X402HttpServer implements X402ServerPort { private readonly timeoutMs = 60000, ) {} - async start(network: NetworkDescriptor, input: X402ServeInput): Promise { + 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"); } @@ -104,9 +117,15 @@ export class X402HttpServer implements X402ServerPort { 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 resourceUrl = `http://${input.host}:${input.port}/pay`; + const host = input.host.includes(":") ? `[${input.host}]` : input.host; + let resourceUrl = `http://${host}:${input.port}/pay`; const requirement = { scheme: input.scheme, network: x402Network, @@ -141,6 +160,7 @@ export class X402HttpServer implements X402ServerPort { 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", { @@ -148,13 +168,14 @@ export class X402HttpServer implements X402ServerPort { paymentRequirements: requirement, }); if (!(verify.valid === true || verify.isValid === true)) - return json(response, 400, { error: "payment verification failed" }); + 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 json(response, 502, { error: "settlement failed" }); + return paymentFailure(response, 502, settle.errorReason, phase); response.setHeader("payment-response", encodePaymentResponseHeader(settle as never)); return json(response, 200, { success: true, @@ -163,10 +184,15 @@ export class X402HttpServer implements X402ServerPort { transaction: settle.transaction, }); } catch { - return json(response, 502, { error: "facilitator request failed" }); + 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, @@ -248,3 +274,13 @@ function close(server: Server): Promise { 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/signer-bridge.test.ts b/ts/src/adapters/outbound/x402/signer-bridge.test.ts index 4ca9b8f21..5826e76d4 100644 --- a/ts/src/adapters/outbound/x402/signer-bridge.test.ts +++ b/ts/src/adapters/outbound/x402/signer-bridge.test.ts @@ -8,6 +8,33 @@ 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 })), diff --git a/ts/src/adapters/outbound/x402/signer-bridge.ts b/ts/src/adapters/outbound/x402/signer-bridge.ts index 99a01e906..0ad876b9c 100644 --- a/ts/src/adapters/outbound/x402/signer-bridge.ts +++ b/ts/src/adapters/outbound/x402/signer-bridge.ts @@ -152,8 +152,20 @@ export function toX402Wallet(payer: PayerSigner, policy: PayerPolicy): X402Walle return prefixedHex(signed.signature); }, async signTransaction(tx) { - const signed = await payer.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/ports/bai-recharge.ts b/ts/src/application/ports/bai-recharge.ts index 9daccd434..8e44db374 100644 --- a/ts/src/application/ports/bai-recharge.ts +++ b/ts/src/application/ports/bai-recharge.ts @@ -1,3 +1,9 @@ +/** 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 }; diff --git a/ts/src/application/ports/x402-server.ts b/ts/src/application/ports/x402-server.ts index 1e0deb0bd..b07ba7236 100644 --- a/ts/src/application/ports/x402-server.ts +++ b/ts/src/application/ports/x402-server.ts @@ -1,4 +1,14 @@ 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; @@ -16,5 +26,6 @@ export interface X402ServerHandle { } 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 f4d3fa593..002fe803f 100644 --- a/ts/src/application/services/approve-receipt.test.ts +++ b/ts/src/application/services/approve-receipt.test.ts @@ -77,7 +77,7 @@ describe("approveRows", () => { metadata, }); - expect(rows).toEqual({ operator: SPENDER, agentId: "42" }); + expect(rows).toEqual({ identity: { operator: SPENDER, agentId: "42" } }); expect(metadata).not.toHaveBeenCalled(); }); diff --git a/ts/src/application/services/approve-receipt.ts b/ts/src/application/services/approve-receipt.ts index 37210de05..5126dad0b 100644 --- a/ts/src/application/services/approve-receipt.ts +++ b/ts/src/application/services/approve-receipt.ts @@ -57,7 +57,7 @@ export async function approveRows(ctx: ApproveContext): Promise v))(spenderRaw); if (ctx.approvalKind === "erc721") { - return { operator: spender, agentId: amount.toString(10) }; + return { identity: { operator: spender, agentId: amount.toString(10) } }; } if (amount === MAX_UINT256) return { spender, allowance: "unlimited" }; diff --git a/ts/src/application/services/bai-payment-result.test.ts b/ts/src/application/services/bai-payment-result.test.ts index abc27987d..70e179397 100644 --- a/ts/src/application/services/bai-payment-result.test.ts +++ b/ts/src/application/services/bai-payment-result.test.ts @@ -1,41 +1,102 @@ import { expect, it } from "vitest"; import { baiPaymentResult } from "./bai-payment-result.js"; -const hash = "0x" + "a".repeat(64); -const envelope = { - jsonrpc: "2.0", - id: 1, - result: { transaction_hash: hash, network: "eip155:56" }, -}; -it("extracts JSON and SSE MCP payment results", () => { - for (const response of [ - envelope, - JSON.stringify(envelope), - `event: message\ndata: ${JSON.stringify(envelope)}\n\n`, - ]) { - expect(baiPaymentResult({ response, payer: { address: "payer" } }, "eip155:56")).toEqual({ - txHash: hash, - payer: "payer", - }); - } + +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([ - { ...envelope, error: {} }, - { result: { transaction_hash: hash, network: "eip155:97" } }, - { result: { transaction_hash: "bad", network: "eip155:56" } }, - { result: { isError: true } }, - {}, -])("rejects wrong-chain, invalid or error responses", (response) => { - expect(() => baiPaymentResult({ response, payer: { address: "payer" } }, "eip155:56")).toThrow(); + { 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("matches the recharge server's hexadecimal TRON network to the CLI decimal network", () => { - const txHash = "b".repeat(64); +it("accepts TRON settlement network notation", () => { expect( baiPaymentResult( { - payer: { address: "T-payer" }, - response: { id: 1, result: { transaction_hash: txHash, network: "tron:0x2b6653dc" } }, + settled: true, + payer: { address: "tron-payer" }, + paymentResponse: { success: true, network: "tron:0x2b6653dc", transaction: "a".repeat(64) }, }, "tron:728126428", ), - ).toEqual({ txHash, payer: "T-payer" }); + ).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 index 4569e37f5..18efca634 100644 --- a/ts/src/application/services/bai-payment-result.ts +++ b/ts/src/application/services/bai-payment-result.ts @@ -1,51 +1,62 @@ import { TransportError } from "../../domain/errors/index.js"; -/** Extract the recharge MCP result without confusing HTTP success with payment or credit success. */ + +/** Only a successful x402 settlement can be reported to B.AI as a payment. */ export function baiPaymentResult(payment: Record, network: string) { - let body: unknown = payment.response; - if (typeof body === "string") { - try { - if (body.trim().startsWith("{")) body = JSON.parse(body); - else { - const messages = body - .split(/\r?\n\r?\n/) - .map((block) => - block - .split(/\r?\n/) - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice(5).trimStart()) - .join("\n"), - ) - .filter(Boolean); - body = messages.map((message) => JSON.parse(message)).find((message) => message.id === 1); - } - } catch { - throw invalid(); - } - } - const envelope = record(body); - if (envelope.error) throw invalid(); - let result = record(envelope.result); - if (result.isError === true) throw invalid(); - if (result.structuredContent) result = record(result.structuredContent); - const txHash = result.transaction_hash; + 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 ( - typeof txHash !== "string" || - !validHash.test(txHash) || - (result.network === "tron:0x2b6653dc" ? "tron:728126428" : result.network) !== network - ) - throw invalid(); - const payer = record(payment.payer).address; - if (typeof payer !== "string" || !payer) throw invalid(); + 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 record(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid(); - return value as Record; -} -function invalid() { + +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 response does not identify a verifiable payment; reconcile before paying again", + "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/use-cases/agent-service.test.ts b/ts/src/application/use-cases/agent-service.test.ts index 8422b4799..ff95beb76 100644 --- a/ts/src/application/use-cases/agent-service.test.ts +++ b/ts/src/application/use-cases/agent-service.test.ts @@ -73,7 +73,7 @@ describe("AgentService beta integration", () => { expect(await f.service.register(f.scope, evmNet, { uri: "ipfs://new" })).toMatchObject({ stage: "submitted", txId: "0xabc", - uri: "ipfs://new", + identity: { uri: "ipfs://new" }, }); expect(f.reader.registeredAgentId).not.toHaveBeenCalled(); expect(f.send).toHaveBeenCalledOnce(); @@ -82,7 +82,7 @@ describe("AgentService beta integration", () => { const f = fixture("confirmed"); expect(await f.service.register(f.scope, evmNet, { uri: "ipfs://new" })).toMatchObject({ stage: "confirmed", - agentId: "9007199254740993", + identity: { agentId: "9007199254740993", uri: "ipfs://new" }, }); }); it("retains confirmed tx evidence if the registration event is unavailable", async () => { @@ -106,10 +106,12 @@ describe("AgentService beta integration", () => { 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({ - oldURI: "ipfs://old", - newURI: "ipfs://actual", - requestedURI: "ipfs://new", - agentId: "42", + identity: { + oldURI: "ipfs://old", + newURI: "ipfs://actual", + requestedURI: "ipfs://new", + agentId: "42", + }, }); expect(f.send).toHaveBeenCalledOnce(); }); @@ -124,7 +126,7 @@ describe("AgentService beta integration", () => { f.reader.read.mockResolvedValueOnce(owner).mockResolvedValueOnce(operator); expect( await f.service.transfer(f.scope, evmNet, { id: "42", newOwner: operator }), - ).toMatchObject({ oldOwner: owner, newOwner: operator, agentId: "42" }); + ).toMatchObject({ identity: { oldOwner: owner, newOwner: operator, agentId: "42" } }); expect(f.send).toHaveBeenCalledWith( f.scope, evmNet, diff --git a/ts/src/application/use-cases/agent-service.ts b/ts/src/application/use-cases/agent-service.ts index 7a6128259..524ddea01 100644 --- a/ts/src/application/use-cases/agent-service.ts +++ b/ts/src/application/use-cases/agent-service.ts @@ -46,17 +46,17 @@ export class AgentService { network: NetworkDescriptor, input: TransactionOptions & { uri: string }, ) { - const result: Record = { + const result: Record & { identity: { uri: string } } = { ...(await this.write(scope, network, input, "register(string)", [ { type: "string", value: input.uri }, ])), - uri: 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, agentId }; + if (agentId !== undefined) return { ...result, identity: { ...result.identity, agentId } }; } catch { /* Keep the confirmed transaction even when receipt enrichment fails. */ } @@ -81,7 +81,7 @@ export class AgentService { { type: "uint256", value: id }, { type: "string", value: input.uri }, ]); - const view = { ...result, agentId: id, oldURI, requestedURI: input.uri }; + const view = { ...result, identity: { agentId: id, oldURI, requestedURI: input.uri } }; if (result.stage !== "confirmed") return view; try { const newURI = String( @@ -89,7 +89,7 @@ export class AgentService { { type: "uint256", value: id }, ]), ); - return { ...view, newURI }; + return { ...view, identity: { ...view.identity, newURI } }; } catch { scope.warn("URI update confirmed, but the current Agent URI could not be read."); return view; @@ -119,16 +119,18 @@ export class AgentService { ); const view = { ...result, - agentId: id, - oldOwner: this.address(network, owner), - requestedOwner: input.newOwner, + 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, newOwner: this.address(network, newOwner) }; + 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; diff --git a/ts/src/application/use-cases/bai-recharge-flow.test.ts b/ts/src/application/use-cases/bai-recharge-flow.test.ts index 90b800214..906e92173 100644 --- a/ts/src/application/use-cases/bai-recharge-flow.test.ts +++ b/ts/src/application/use-cases/bai-recharge-flow.test.ts @@ -1,3 +1,4 @@ +import { TransportError } from "../../domain/errors/index.js"; import { expect, it, vi } from "vitest"; import { BaiRechargeFlow } from "./bai-recharge-flow.js"; const input = { @@ -127,3 +128,24 @@ it("keeps the confirmed recipient stable if the payment adapter mutates its inpu 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(); +}); diff --git a/ts/src/application/use-cases/bai-recharge-flow.ts b/ts/src/application/use-cases/bai-recharge-flow.ts index c9c318f8b..ae72a4e5f 100644 --- a/ts/src/application/use-cases/bai-recharge-flow.ts +++ b/ts/src/application/use-cases/bai-recharge-flow.ts @@ -4,7 +4,7 @@ import type { BaiRechargePayment, BaiReportTransactionInput, } from "../ports/bai-recharge.js"; -import { UsageError, TransportError } from "../../domain/errors/index.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 { @@ -27,10 +27,23 @@ export class BaiRechargeFlow { let paid: Awaited>; try { paid = await this.payment.pay(order, structuredClone(request)); - } catch { + } 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()) { @@ -58,20 +71,30 @@ export class BaiRechargeFlow { /** Recovery entry point: only reports an existing hash; never creates an order or pays. */ async report(input: BaiReportTransactionInput) { - const request = structuredClone(input); - const base = { ...request, retryPayment: false as const }; - try { - const result = await this.api.reportTxHash(structuredClone(request)); - if (!result.success) - return { ...base, creditStatus: "unconfirmed" as const, code: result.code }; - return { ...base, creditStatus: "credited" as const, order: result.order }; - } catch { - return { - ...base, - creditStatus: "unconfirmed" as const, - warning: - "Recharge reporting failed; retain the transaction hash and reconcile before retrying reporting. Do not pay again", - }; - } + 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 }; + 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 } : {}), + 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 index 6055cb694..f4a6660b0 100644 --- a/ts/src/application/use-cases/bai-recharge-integration.test.ts +++ b/ts/src/application/use-cases/bai-recharge-integration.test.ts @@ -1,3 +1,4 @@ +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"; @@ -5,7 +6,9 @@ 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() { +function fixture( + payTo: Record = { bnb: "0x060f7fd9c9622bdcf9f2887c8171d6e6b4b4ba17" }, +) { const calls: string[] = []; const api = { resolveTarget: vi.fn(async () => { @@ -24,14 +27,15 @@ function fixture() { }), }; const payments = { - pay: vi.fn(async () => { + validate: vi.fn(), + roundtrip: vi.fn(async () => { calls.push("pay"); return { - payer: { address: payer }, - response: { - jsonrpc: "2.0", - id: 1, - result: { transaction_hash: txHash, network: "eip155:56", payment_status: "settled" }, + serve: {}, + pay: { + settled: true, + payer: { address: payer }, + paymentResponse: { success: true, transaction: txHash, network: "eip155:56" }, }, }; }), @@ -42,6 +46,7 @@ function fixture() { 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, { @@ -104,12 +109,16 @@ it.each([ async (token, amount) => { const { run, payments } = fixture(); await run(undefined, amount, token); - expect(payments.pay).toHaveBeenCalledWith( + expect(payments.roundtrip).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.objectContaining({ - expectedPayTo: "0x060f7fd9c9622bdcf9f2887c8171d6e6b4b4ba17", - exactAmount: amount, + facilitatorUrl: "https://facilitator.example", + host: "127.0.0.1", + port: 0, + scheme: "exact", + payTo: "0x060f7fd9c9622bdcf9f2887c8171d6e6b4b4ba17", + amount, }), ); }, @@ -131,7 +140,7 @@ it("does not create an order or pay when target validation fails", async () => { api.resolveTarget.mockRejectedValue(new Error("invalid target")); await expect(run("recipient")).rejects.toThrow("invalid target"); expect(api.createOrder).not.toHaveBeenCalled(); - expect(payments.pay).not.toHaveBeenCalled(); + expect(payments.roundtrip).not.toHaveBeenCalled(); }); it("retains paid hash and target when reporting fails", async () => { const { api, payments, run } = fixture(); @@ -141,5 +150,31 @@ it("retains paid hash and target when reporting fails", async () => { txHash, retryPayment: false, }); - expect(payments.pay).toHaveBeenCalledTimes(1); + 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 index a0c23d3d4..b771905b3 100644 --- a/ts/src/application/use-cases/bai-service.test.ts +++ b/ts/src/application/use-cases/bai-service.test.ts @@ -39,7 +39,7 @@ describe("BaiService", () => { 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(), { pay }, { + const service = new BaiService(api(), () => new Date(), { validate: vi.fn(), roundtrip: pay }, { isConfirmed, } as unknown as BaiBindingStore); await expect( @@ -57,7 +57,7 @@ it("does not proceed when local confirmation cannot be read", async () => { const isConfirmed = vi.fn(() => { throw new Error("API unavailable"); }); - const service = new BaiService(api(), () => new Date(), { pay }, { + const service = new BaiService(api(), () => new Date(), { validate: vi.fn(), roundtrip: pay }, { isConfirmed, } as unknown as BaiBindingStore); await expect( diff --git a/ts/src/application/use-cases/bai-service.ts b/ts/src/application/use-cases/bai-service.ts index 5f676a97e..16d5f57ec 100644 --- a/ts/src/application/use-cases/bai-service.ts +++ b/ts/src/application/use-cases/bai-service.ts @@ -1,15 +1,16 @@ -import type { BaiRechargeApi, BaiRechargeTarget } from "../ports/bai-recharge.js"; -import { BaiRechargeFlow } from "./bai-recharge-flow.js"; -import { baiPaymentResult } from "../services/bai-payment-result.js"; +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, - BAI_RECHARGE_ADDRESSES, -} from "../../domain/bai/recharge-policy.js"; -import type { X402PaymentPort } from "../ports/x402-payment.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"; @@ -24,9 +25,10 @@ export class BaiService { constructor( private readonly api: BaiApi, private readonly now: () => Date = () => new Date(), - private readonly payments?: X402PaymentPort, + private readonly payments?: X402RoundtripPort, private readonly bindings?: BaiBindingStore, private readonly rechargeApi?: BaiRechargeApi, + private readonly rechargeConfig?: BaiRechargeConfig, ) {} async recharge( @@ -50,23 +52,28 @@ export class BaiService { "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) { + if (!this.payments || !this.rechargeApi || !this.rechargeConfig) { throw new UsageError("invalid_option", "B.AI recharge is not available in this runtime"); } - const normalizedAmount = input.amount.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ""); - const amount = Number(normalizedAmount); - if ( - !/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(input.amount) || - !Number.isFinite(amount) || - amount <= 0 || - String(amount) !== normalizedAmount - ) { + const expectedPayTo = this.rechargeConfig.payTo[chain]; + if (!expectedPayTo?.trim()) { throw new UsageError( - "invalid_value", - "Recharge amount must be positive and exactly representable by the B.AI numeric API", + "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 || @@ -83,25 +90,8 @@ export class BaiService { } const flow = new BaiRechargeFlow(this.rechargeApi, { pay: async () => { - const payment = await this.payments!.pay(scope, network, { - url: "https://recharge.bankofai.io/mcp", - method: "POST", - headers: [ - "Content-Type: application/json", - "Accept: application/json, text/event-stream", - ], - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { name: "recharge", arguments: { amount: input.amount, token: input.token } }, - }), - token: input.token, - maxAmount: input.amount, - exactAmount: input.amount, - expectedPayTo: BAI_RECHARGE_ADDRESSES[chain], - }); - return { ...baiPaymentResult(payment, network.id), chain }; + const result = await this.payments!.roundtrip(scope, network, paymentInput); + return { ...baiPaymentResult(result.pay, network.id), chain }; }, }); const result = await flow.execute({ @@ -116,6 +106,50 @@ export class BaiService { 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); @@ -200,3 +234,21 @@ 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/evm/contract-service.test.ts b/ts/src/application/use-cases/evm/contract-service.test.ts index 6febced71..1be2ff812 100644 --- a/ts/src/application/use-cases/evm/contract-service.test.ts +++ b/ts/src/application/use-cases/evm/contract-service.test.ts @@ -288,8 +288,7 @@ describe("EvmContractService.send — approve", () => { const { service, gateway } = approveHarness(6); await expect(send(service, "42", "erc721")).resolves.toMatchObject({ - operator: SPENDER, - agentId: "42", + identity: { operator: SPENDER, agentId: "42" }, }); expect(gateway.getErc20Metadata).not.toHaveBeenCalled(); }); 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 index 3ecb7a380..a424df49f 100644 --- a/ts/src/application/use-cases/tron/contract-service.approve.test.ts +++ b/ts/src/application/use-cases/tron/contract-service.approve.test.ts @@ -59,7 +59,7 @@ describe("TronContractService.send — approve", () => { dryRun: true, approvalKind: "erc721", }), - ).resolves.toMatchObject({ operator: OPERATOR, agentId: "42" }); + ).resolves.toMatchObject({ identity: { operator: OPERATOR, agentId: "42" } }); expect(getTokenInfo).not.toHaveBeenCalled(); }); }); diff --git a/ts/src/application/use-cases/x402-service.test.ts b/ts/src/application/use-cases/x402-service.test.ts index 1a70efb2c..dd49f8a93 100644 --- a/ts/src/application/use-cases/x402-service.test.ts +++ b/ts/src/application/use-cases/x402-service.test.ts @@ -26,3 +26,42 @@ describe("X402Service", () => { 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 index faa9e7322..c70662437 100644 --- a/ts/src/application/use-cases/x402-service.ts +++ b/ts/src/application/use-cases/x402-service.ts @@ -31,6 +31,11 @@ export class X402Service { 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); @@ -45,6 +50,11 @@ export class X402Service { 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 { diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index bbb1ee356..f8588dedb 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -1,7 +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"; @@ -138,14 +140,20 @@ export function composeCliRuntime(options: BootstrapOptions) { 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(), - x402Payments, + x402Service, baiBindings, new BaiRechargeClient(config, timeoutMs), + { facilitatorUrl: DEFAULT_X402_FACILITATOR_URL, payTo: BAI_RECHARGE_ADDRESSES }, ), ); const agentContracts = { @@ -157,14 +165,7 @@ export function composeCliRuntime(options: BootstrapOptions) { new SdkAgentRegistry(agentContracts, gatewayProvider), new RegistrationLoader(timeoutMs), ); - registerX402Commands( - registry, - new X402Service( - x402Payments, - new X402ProviderCatalog(undefined, undefined, timeoutMs), - new X402HttpServer(undefined, timeoutMs), - ), - ); + registerX402Commands(registry, x402Service); const accountBalances = new AccountBalanceService(gatewayProvider); const tokenBookService = new TokenBookService(tokenBook); registerTronChainCommands(registry, { diff --git a/ts/src/domain/bai/recharge-policy.ts b/ts/src/domain/bai/recharge-policy.ts index 210d8a80f..b35afee7a 100644 --- a/ts/src/domain/bai/recharge-policy.ts +++ b/ts/src/domain/bai/recharge-policy.ts @@ -1,17 +1,5 @@ import { UsageError } from "../errors/index.js"; -/** Recharge destinations supplied by the service owner on 2026-09-07. */ -export const BAI_RECHARGE_ADDRESSES: Readonly> = Object.freeze({ - tron: "TSNEPtuCagKEgF2EU4pAKWLzXLz1bekfTE", - solana: "AEtyXM8nwieUwSLifLQAmGFHwcuwSFVkfyF2XCWNUzmp", - bnb: "0x060f7fd9c9622bdcf9f2887c8171d6e6b4b4ba17", - ethereum: "0x3f380aaac9488aef452add4cc74b3d052dd02621", - arbitrum: "0x406c163d8dab7e313c76699597baa5b11bf8bbd9", - optimism: "0x6d32e2f9a38416f468f33a4fb14c4b80ef99007f", - polygon: "0xe6cb7671d26b62f9c4f423d76ce8a558d249f48e", - base: "0x10bf3d09bd80a00ddbbfe934c7dcc477b42ffdb0", -}); - const minimums: Readonly> = Object.freeze({ TRX: "15", USDT: "1", diff --git a/ts/src/domain/errors/codes.ts b/ts/src/domain/errors/codes.ts index 6ece890d3..24706b1a9 100644 --- a/ts/src/domain/errors/codes.ts +++ b/ts/src/domain/errors/codes.ts @@ -125,6 +125,10 @@ 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" }, diff --git a/ts/src/domain/types/tx.ts b/ts/src/domain/types/tx.ts index a49876664..f46a0905d 100644 --- a/ts/src/domain/types/tx.ts +++ b/ts/src/domain/types/tx.ts @@ -144,21 +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 Identity Registry result fields, all IDs remain decimal strings. */ - agentId?: string; - operator?: string; - uri?: string; - oldURI?: string; - requestedURI?: string; - newURI?: string; - oldOwner?: string; - requestedOwner?: string; - newOwner?: string; + /** 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/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 index 5c20fb304..cbef77aec 100644 --- a/ts/test/bai-recharge.test.ts +++ b/ts/test/bai-recharge.test.ts @@ -50,7 +50,7 @@ it.each([ }; X402PaymentClient.prototype.pay = async () => { log({method:'pay'}); - return {payer:{address:${JSON.stringify(payer)}}, response:{id:1, result:{transaction_hash:${JSON.stringify(hash)}, network:${JSON.stringify(networkId)}}}}; + 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"]) { diff --git a/ts/test/beta-artifact-signing.test.ts b/ts/test/beta-artifact-signing.test.ts index 53d156f7a..d992f1327 100644 --- a/ts/test/beta-artifact-signing.test.ts +++ b/ts/test/beta-artifact-signing.test.ts @@ -1,3 +1,4 @@ +import { tronAllowancePreload } from "./tron-allowance-preload.js"; import { it, expect } from "vitest"; import { Wallet } from "ethers"; import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; @@ -37,7 +38,8 @@ it.skipIf(!process.env.WALLET_CLI_TEST_ENTRY)( const log = join(home, "signature.json"); writeFileSync( preload, - `import {writeFileSync} from 'node:fs'; + `${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'); diff --git a/ts/test/beta-command-surface.test.ts b/ts/test/beta-command-surface.test.ts index cdc2acc44..84a56a918 100644 --- a/ts/test/beta-command-surface.test.ts +++ b/ts/test/beta-command-surface.test.ts @@ -16,7 +16,10 @@ const commands = [ "provider-endpoints", "provider-update", ].map((v) => ["x402", v]), - ...["recharge", "status", "usage", "usage-list", "recharge-list"].map((v) => ["bai", v]), + ...["recharge", "recharge-report", "status", "usage", "usage-list", "recharge-list"].map((v) => [ + "bai", + v, + ]), ...[ "show", "register", diff --git a/ts/test/beta-server-roundtrip.test.ts b/ts/test/beta-server-roundtrip.test.ts index 1f904b306..6a318ed91 100644 --- a/ts/test/beta-server-roundtrip.test.ts +++ b/ts/test/beta-server-roundtrip.test.ts @@ -1,3 +1,4 @@ +import { tronAllowancePreload } from "./tron-allowance-preload.js"; import { it, expect } from "vitest"; import { Wallet } from "ethers"; import { createServer } from "node:net"; @@ -80,7 +81,8 @@ it.skipIf(!entry)( const preload = join(home, "fetch.mjs"); writeFileSync( preload, - `import {appendFileSync} from 'node:fs';const realFetch=globalThis.fetch;globalThis.fetch=async(input,init)=>{ + `${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'); diff --git a/ts/test/erc8004.test.ts b/ts/test/erc8004.test.ts index 5b58bf565..52d16438c 100644 --- a/ts/test/erc8004.test.ts +++ b/ts/test/erc8004.test.ts @@ -200,6 +200,8 @@ for (const mode of ["dry-run", "build-only"] as const) { 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); @@ -219,10 +221,11 @@ it("EVM approve dry-run renders the Agent ID without any fungible-token read", a expect(r.code, r.stderr || r.stdout).toBe(0); expect(JSON.parse(r.stdout).data).toMatchObject({ mode: "dry-run", - agentId: "9007199254740993", - operator: owner, + 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); 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'); +`; From 2a6789fb909fe84c158176c08c6ed54c3de33a99 Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Wed, 9 Sep 2026 17:08:08 +0800 Subject: [PATCH 09/10] fix(ts): accept canonical BAI wallet bindings and preserve signed messages --- ts/docs/development/bai-recharge.md | 68 +++++++++++++++++++ .../outbound/bai/recharge-client.test.ts | 64 ++++++++++++++++- .../adapters/outbound/bai/recharge-client.ts | 11 ++- 3 files changed, 138 insertions(+), 5 deletions(-) diff --git a/ts/docs/development/bai-recharge.md b/ts/docs/development/bai-recharge.md index 7aabc4bfd..384dbe3fa 100644 --- a/ts/docs/development/bai-recharge.md +++ b/ts/docs/development/bai-recharge.md @@ -28,6 +28,74 @@ 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 | diff --git a/ts/src/adapters/outbound/bai/recharge-client.test.ts b/ts/src/adapters/outbound/bai/recharge-client.test.ts index 0915bc069..d21186b92 100644 --- a/ts/src/adapters/outbound/bai/recharge-client.test.ts +++ b/ts/src/adapters/outbound/bai/recharge-client.test.ts @@ -32,10 +32,14 @@ describe("B.AI recharge API", () => { it("binds the payer with a supplied signed message", async () => { const { client, fetcher } = fixture({ success: true, - binding: { userId: "payer-id", address: "payer", chain: "bnb" }, + binding: { + userId: "payer-id", + address: "0x1234567890abcdef1234567890abcdef12345678", + chain: "bnb", + }, }); const input = { - address: "payer", + address: "0x1234567890abcdef1234567890abcdef12345678", chain: "bnb", message: "documented message", signature: "signature", @@ -43,7 +47,7 @@ describe("B.AI recharge API", () => { }; await expect(client.bind(input)).resolves.toEqual({ userId: "payer-id", - address: "payer", + address: "0x1234567890abcdef1234567890abcdef12345678", chain: "bnb", }); const [url, init] = fetcher.mock.calls[0]!; @@ -51,6 +55,60 @@ describe("B.AI recharge API", () => { 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 = { diff --git a/ts/src/adapters/outbound/bai/recharge-client.ts b/ts/src/adapters/outbound/bai/recharge-client.ts index 92bc80a16..263873b10 100644 --- a/ts/src/adapters/outbound/bai/recharge-client.ts +++ b/ts/src/adapters/outbound/bai/recharge-client.ts @@ -20,7 +20,8 @@ const target = z.object({ }); const amount = z.number().positive().finite().max(Number.MAX_SAFE_INTEGER); const bindInput = wallet.extend({ - message: text, + // 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(), }); @@ -76,7 +77,13 @@ export class BaiRechargeClient implements BaiRechargeApi { binding, await this.call("wallet.bindRechargeWallet", checked), ).binding; - if (result.address !== checked.address || result.chain !== checked.chain) throw this.invalid(); + 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 || !matchingChain) throw this.invalid(); return result; } async createOrder(input: BaiCreateOrderInput): Promise> { From 2e9a5ce23c6d3b55f25367bb25dd6193a478331a Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Wed, 9 Sep 2026 17:24:52 +0800 Subject: [PATCH 10/10] fix(ts): explain BAI API failures and preserve reporting diagnostics --- ts/docs/development/bai-recharge.md | 26 ++++ ts/docs/machine-interface.md | 12 ++ .../adapters/outbound/bai/api-error.test.ts | 138 ++++++++++++++++++ ts/src/adapters/outbound/bai/api-error.ts | 89 +++++++++++ ts/src/adapters/outbound/bai/client.ts | 47 +++--- .../outbound/bai/recharge-client.test.ts | 2 +- .../adapters/outbound/bai/recharge-client.ts | 92 +++++++++--- ts/src/application/ports/bai-recharge.ts | 3 +- .../use-cases/bai-recharge-flow.test.ts | 24 +++ .../use-cases/bai-recharge-flow.ts | 9 +- ts/src/domain/errors/codes.ts | 1 + 11 files changed, 404 insertions(+), 39 deletions(-) create mode 100644 ts/src/adapters/outbound/bai/api-error.test.ts create mode 100644 ts/src/adapters/outbound/bai/api-error.ts diff --git a/ts/docs/development/bai-recharge.md b/ts/docs/development/bai-recharge.md index 384dbe3fa..e8e77d017 100644 --- a/ts/docs/development/bai-recharge.md +++ b/ts/docs/development/bai-recharge.md @@ -150,3 +150,29 @@ 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/machine-interface.md b/ts/docs/machine-interface.md index ab4a4f118..52b313645 100644 --- a/ts/docs/machine-interface.md +++ b/ts/docs/machine-interface.md @@ -462,3 +462,15 @@ 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/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/client.ts b/ts/src/adapters/outbound/bai/client.ts index c65736cd6..74fa8e8ab 100644 --- a/ts/src/adapters/outbound/bai/client.ts +++ b/ts/src/adapters/outbound/bai/client.ts @@ -1,3 +1,4 @@ +import { baiApiError } from "./api-error.js"; import { boundedResponse, MAX_HTTP_RESPONSE_BYTES } from "../http/http-response.js"; import { z } from "zod"; import type { @@ -27,7 +28,7 @@ export class BaiClient implements BaiApi { ) {} async status(): Promise { - const value = ObjectSchema.parse(await this.call("usage.summary", null)); + const value = responseObject(await this.call("usage.summary", null)); return { pointsBalance: scalar(value.points_balance, "points_balance"), monthlySpent: scalar(value.monthly_spent, "monthly_spent"), @@ -94,9 +95,11 @@ export class BaiClient implements BaiApi { redirect: "error", signal, }); - // Preserve status errors without reading an untrusted error body. - if (response.ok) response = await boundedResponse(response, MAX_HTTP_RESPONSE_BYTES, signal); - else await response.body?.cancel(); + 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 ( @@ -105,24 +108,25 @@ export class BaiClient implements BaiApi { ) { throw new TransportError("timeout", "B.AI API request timed out"); } - throw new TransportError("provider_error", "B.AI API request failed"); - } - if (response.status === 401 || response.status === 403) { - throw new TransportError("bai_auth_failed", "B.AI API rejected the configured API key"); - } - if (response.status === 429) { - throw new TransportError("provider_rate_limited", "B.AI API rate limit exceeded"); - } - if (!response.ok) { - throw new TransportError("provider_error", `B.AI API returned HTTP ${response.status}`); + 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 { - throw new TransportError("provider_error", "B.AI API returned malformed JSON"); + 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 && @@ -163,7 +167,7 @@ function arrayOfObjects(value: unknown): Record[] { } function page(raw: unknown): BaiPageView { - const value = ObjectSchema.parse(raw); + 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; @@ -184,3 +188,12 @@ function finiteInteger(value: unknown, fallback: number | undefined): number | u 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/recharge-client.test.ts b/ts/src/adapters/outbound/bai/recharge-client.test.ts index d21186b92..71cf2c64e 100644 --- a/ts/src/adapters/outbound/bai/recharge-client.test.ts +++ b/ts/src/adapters/outbound/bai/recharge-client.test.ts @@ -137,7 +137,7 @@ describe("B.AI recharge API", () => { const { client, fetcher } = fixture({ success: false, code, message: "secret" }); await expect( client.reportTxHash({ chain: "bnb", txHash: "hash", rechargeTarget: target }), - ).resolves.toEqual({ success: false, code }); + ).resolves.toEqual({ success: false, code, message: expect.not.stringContaining("secret") }); expect(fetcher).toHaveBeenCalledTimes(1); }, ); diff --git a/ts/src/adapters/outbound/bai/recharge-client.ts b/ts/src/adapters/outbound/bai/recharge-client.ts index 263873b10..393f0f480 100644 --- a/ts/src/adapters/outbound/bai/recharge-client.ts +++ b/ts/src/adapters/outbound/bai/recharge-client.ts @@ -1,3 +1,4 @@ +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"; @@ -61,18 +62,18 @@ export class BaiRechargeClient implements BaiRechargeApi { z.object({ type: z.literal("personal"), targetId: text, displayLabel: text }), await this.call("order.resolveRechargeTarget", { type: "personal", - identifier: text.parse(identifier), + identifier: this.input(text, identifier), }), ); } async isBound(input: BaiWalletBindingInput): Promise { return this.decode( z.boolean(), - await this.call("wallet.isRechargeBound", wallet.parse(input), "GET"), + await this.call("wallet.isRechargeBound", this.input(wallet, input), "GET"), ); } async bind(input: BaiBindWalletInput) { - const checked = bindInput.parse(input); + const checked = this.input(bindInput, input); const result = this.decode( binding, await this.call("wallet.bindRechargeWallet", checked), @@ -83,19 +84,53 @@ export class BaiRechargeClient implements BaiRechargeApi { result.address.toLowerCase() === checked.address.toLowerCase() : result.address === checked.address; const matchingChain = result.chain === checked.chain || (evm && result.chain === "eth"); - if (!matchingAddress || !matchingChain) throw this.invalid(); + 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", orderInput.parse(input)), + 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 { - return this.decode(report, await this.call("order.reportTxHash", reportInput.parse(input))); + 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"); @@ -139,18 +174,38 @@ export class BaiRechargeClient implements BaiRechargeApi { signal, ...(method === "POST" ? { body: payload } : {}), }); - if (!response.ok) await response.body?.cancel(); - if (response.status === 401 || response.status === 403) - throw new TransportError("bai_auth_failed", "B.AI API rejected the configured credential"); - if (response.status === 429) - throw new TransportError("provider_rate_limited", "B.AI API rate limit exceeded"); - if (!response.ok) - throw new TransportError( - "provider_error", - `B.AI recharge API returned HTTP ${response.status}`, - ); + 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); - decoded = await response.json(); + 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)) @@ -160,7 +215,8 @@ export class BaiRechargeClient implements BaiRechargeApi { ); throw new TransportError( "provider_error", - "B.AI recharge API request failed; mutation outcome may be unknown", + "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); diff --git a/ts/src/application/ports/bai-recharge.ts b/ts/src/application/ports/bai-recharge.ts index 8e44db374..c89044ce5 100644 --- a/ts/src/application/ports/bai-recharge.ts +++ b/ts/src/application/ports/bai-recharge.ts @@ -35,7 +35,8 @@ export interface BaiReportTransactionInput { amount?: number; } export type BaiReportResult = - { success: true; order: Record } | { success: false; code: string }; + | { success: true; order: Record } + | { success: false; code: string; message?: string }; export interface BaiRechargeApi { resolveTarget( identifier: string, diff --git a/ts/src/application/use-cases/bai-recharge-flow.test.ts b/ts/src/application/use-cases/bai-recharge-flow.test.ts index 906e92173..890b42b72 100644 --- a/ts/src/application/use-cases/bai-recharge-flow.test.ts +++ b/ts/src/application/use-cases/bai-recharge-flow.test.ts @@ -149,3 +149,27 @@ it("preserves classified errors and settlement evidence without reporting or pay 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 index ae72a4e5f..44ae8cec1 100644 --- a/ts/src/application/use-cases/bai-recharge-flow.ts +++ b/ts/src/application/use-cases/bai-recharge-flow.ts @@ -85,14 +85,19 @@ export async function reportBaiTransaction( try { const result = await api.reportTxHash(structuredClone(request)); if (!result.success) - return { ...base, creditStatus: "unconfirmed" as const, code: result.code }; + 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 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/domain/errors/codes.ts b/ts/src/domain/errors/codes.ts index 24706b1a9..dbe603baf 100644 --- a/ts/src/domain/errors/codes.ts +++ b/ts/src/domain/errors/codes.ts @@ -143,6 +143,7 @@ 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" },