From 1cdfdd04ed7ba1a9bee7dd80befbed4fac144a8e Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Wed, 16 Sep 2026 18:23:28 +0800 Subject: [PATCH 1/5] fix(ts): address R7 QA findings and validate payment runtimes --- ts/README.md | 17 +- ts/docs/concepts/provider-catalog.md | 4 + ts/docs/development/bai-recharge.md | 17 ++ .../development/erc8004-sdk-integration.md | 9 ++ ts/scripts/verify-package.mjs | 3 + .../adapters/inbound/cli/commands/bai.test.ts | 2 +- ts/src/adapters/inbound/cli/commands/bai.ts | 31 +++- ts/src/adapters/inbound/cli/commands/x402.ts | 51 ++++-- .../erc8004/registration-loader.test.ts | 106 +++++++------ .../outbound/erc8004/registration-loader.ts | 150 ++++++------------ .../adapters/outbound/x402/payment-client.ts | 7 + .../outbound/x402/payment-error.test.ts | 25 ++- .../adapters/outbound/x402/payment-error.ts | 25 ++- .../outbound/x402/provider-catalog.test.ts | 24 +++ .../outbound/x402/provider-catalog.ts | 11 +- ts/src/adapters/outbound/x402/server.test.ts | 38 +++++ ts/src/adapters/outbound/x402/server.ts | 127 +++++++++++++-- ts/src/application/ports/x402-payment.ts | 5 + ts/src/application/ports/x402-server.ts | 11 +- .../use-cases/agent-service.test.ts | 15 ++ ts/src/application/use-cases/agent-service.ts | 4 + .../bai-recharge-integration.test.ts | 119 ++++++++++++++ .../application/use-cases/bai-service.test.ts | 21 +++ ts/src/application/use-cases/bai-service.ts | 85 +++++++++- .../use-cases/config-service.test.ts | 23 ++- .../application/use-cases/config-service.ts | 14 +- ts/src/application/use-cases/x402-service.ts | 9 +- ts/src/bootstrap/composition.ts | 2 + ts/src/bootstrap/x402-daemon.ts | 65 ++++++++ ts/src/bootstrap/x402-server-lifecycle.ts | 4 + ts/test/erc8004.test.ts | 5 +- ts/test/x402-daemon.test.ts | 102 ++++++++++++ 32 files changed, 930 insertions(+), 201 deletions(-) create mode 100644 ts/src/bootstrap/x402-daemon.ts create mode 100644 ts/test/x402-daemon.test.ts diff --git a/ts/README.md b/ts/README.md index 24c70cc43..6390743ba 100644 --- a/ts/README.md +++ b/ts/README.md @@ -254,7 +254,7 @@ 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. -For `x402 pay` and `x402 roundtrip`, `--gasfree-relay official` (the default) +For `x402 pay`, `x402 roundtrip` and `bai recharge`, `--gasfree-relay official` (the default) uses the SDK's credential-free proxy. `--gasfree-relay gasfree` reads the configured GasFree Open API using `gasfreeApiKey` and `gasfreeApiSecret`; missing credentials fail before payment. An HTTPS URL selects a custom relay @@ -269,3 +269,18 @@ maximum authorized fee is not evidence of the actual fee charged. Provider queries prefer the local snapshot. Run `x402 update-catalog` to refresh it; see [catalog caching](docs/concepts/provider-catalog.md). + +### Local x402 payment server + +`x402 serve` and `x402 roundtrip` accept either `--amount` or `--raw-amount`, and +either `--token` or `--asset` (with `--decimals` for an unregistered asset). +Registered token precision cannot be overridden. `--valid-for-seconds` sets the +payment authorization lifetime (default 300 seconds). + +`x402 serve --network base-sepolia --token USDC --raw-amount 1000000 --pay-to
--daemon` +starts a loopback server and returns its PID, payment URL and private access-log +path after the listener is ready. Stop it with `kill -TERM `; remove the log +after use if no longer needed. Foreground requests log to stderr, while stdout +retains the command result. Logs exclude query strings, headers and payment bodies. +`--resource-url` overrides the advertised resource URL only; `--host` controls the +loopback listener. Roundtrip always uses loopback and closes its server on completion. diff --git a/ts/docs/concepts/provider-catalog.md b/ts/docs/concepts/provider-catalog.md index ac59671bd..d87de2427 100644 --- a/ts/docs/concepts/provider-catalog.md +++ b/ts/docs/concepts/provider-catalog.md @@ -7,3 +7,7 @@ Text output shows selected fields, a first-sentence summary and network aliases. Endpoint tables show exact prices or ranges; missing routes are shown as `——`, not a zero price. JSON retains complete provider data. 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. + +Provider lists omit search-only `query`, `score`, `matchedFields` and inline +`endpoints`; use `endpoint-list` to inspect endpoints. Lists retain `endpointCount` +and normalize extension metadata keys to camelCase. diff --git a/ts/docs/development/bai-recharge.md b/ts/docs/development/bai-recharge.md index 0af3d874e..ca46b2b47 100644 --- a/ts/docs/development/bai-recharge.md +++ b/ts/docs/development/bai-recharge.md @@ -194,3 +194,20 @@ CLI 在支付成功后立即上报;如果 BAI 返回 `TX_NOT_FOUND_OR_INVALID` 该流程只等待并重试 BAI 对原交易的核验,不轮询链上 RPC,也不会重新创建订单、解析目标或付款。认证失败、付款人不匹配、其他拒绝及网络异常不自动重试。耗尽预算后返回 `creditStatus=unconfirmed`,保留原交易、链、金额、目标用户和 `retryPayment=false`,供后续补报。 x402 错误新增阶段信息:`request`、`challenge`、`create_payment`、`sign`、`payment_request`、`verify`、`settle`。能够识别的 HTTP 错误保留状态码,连接错误保留白名单中的错误码;结算失败保留合法的候选交易哈希和网络,不直接回显 SDK 消息、请求内容或凭证。 + +## Preview and fee controls + +`bai recharge 1 --network base --token USDC --dry-run` validates binding, amount, +destination and the x402 challenge without creating an order, unlocking, signing, +paying or reporting a transaction. Recipient recharge still resolves `--to`. +The preview includes payer wallet balances when RPC is available. It does not +claim a final fee quote or a GasFree account balance; unavailable estimates are +explicitly null. Base and recipient recharge remain supported. + +TRON supports `--scheme exact_gasfree`, `--gasfree-relay`, and mutually exclusive +`--max-gasfree-fee` / `--max-gasfree-fee-raw`. The same signer bridge enforces fee +limits before signing. An unknown settlement outcome never triggers repayment. + +`bai recharge-orders` accepts limits through 200, but caps the effective limit at +100 for the order API, with a warning and accurate pagination metadata. Offsets +are preserved across backend page boundaries; usage records retain their own limit. diff --git a/ts/docs/development/erc8004-sdk-integration.md b/ts/docs/development/erc8004-sdk-integration.md index 884965dd9..dc1d293ef 100644 --- a/ts/docs/development/erc8004-sdk-integration.md +++ b/ts/docs/development/erc8004-sdk-integration.md @@ -30,3 +30,12 @@ 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. + +## Registration metadata limits + +Writing a registration URI still accepts HTTPS, IPFS and JSON data URIs. Reading +metadata accepts only HTTP/HTTPS and never follows redirects or resolves IPFS +through a third-party gateway. Unsupported URIs produce a warning; authoritative +on-chain fields remain available. Remote JSON is limited to 1 MiB (both compressed +and expanded), 20 object/array levels with the root at level 1, and the shorter of +the global timeout and 10 seconds. DNS addresses remain validated and pinned. diff --git a/ts/scripts/verify-package.mjs b/ts/scripts/verify-package.mjs index c2efd9718..bc5cf3677 100644 --- a/ts/scripts/verify-package.mjs +++ b/ts/scripts/verify-package.mjs @@ -14,6 +14,8 @@ const call = ( env = { ...process.env, WALLET_CLI_HOME: join(temp, "wallet") }, ) => execFileSync(cmd, args, { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }); try { + // Never validate a stale dist left by an earlier build. + call(npm, ["run", "build"]); const [packed] = JSON.parse(call(npm, ["pack", "--json", "--pack-destination", temp])); const forbidden = packed.files.filter( ({ path }) => @@ -42,6 +44,7 @@ try { "run", "test/beta-command-surface.test.ts", "test/beta-server-roundtrip.test.ts", + "test/x402-daemon.test.ts", "test/erc8004.test.ts", "test/x402-provider-payment.test.ts", "test/bai-nile-compatibility.test.ts", diff --git a/ts/src/adapters/inbound/cli/commands/bai.test.ts b/ts/src/adapters/inbound/cli/commands/bai.test.ts index 16a86d14e..3e85a19f6 100644 --- a/ts/src/adapters/inbound/cli/commands/bai.test.ts +++ b/ts/src/adapters/inbound/cli/commands/bai.test.ts @@ -111,6 +111,6 @@ it("shares recharge schema across families and selects the Base token in its bin command.spec.baseFields.parse({ amount: "1" }), ), ).resolves.toMatchObject({ amount: "1", token: "USDC" }); - for (const field of ["dryRun", "signOnly", "buildOnly"]) + for (const field of ["signOnly", "buildOnly"]) expect(command.spec.baseFields.shape).not.toHaveProperty(field); }); diff --git a/ts/src/adapters/inbound/cli/commands/bai.ts b/ts/src/adapters/inbound/cli/commands/bai.ts index 42218f4a2..542d89f18 100644 --- a/ts/src/adapters/inbound/cli/commands/bai.ts +++ b/ts/src/adapters/inbound/cli/commands/bai.ts @@ -1,4 +1,4 @@ -import { integerLiteral } from "../schemas/payment-values.js"; +import { integerLiteral, rawPaymentAmount, paymentAmount } from "../schemas/payment-values.js"; import { z } from "zod"; import type { CommandDefinition, ChainSpec, FamilyBinding } from "../contracts/index.js"; import type { CommandRegistry } from "../registry/index.js"; @@ -15,6 +15,14 @@ const listFields = z.object({ }); const rechargeFields = z.object({ + dryRun: z + .boolean() + .default(false) + .describe("preview without creating an order, unlocking, signing or paying"), + scheme: z.enum(["exact", "exact_gasfree"]).default("exact"), + gasfreeRelay: z.string().optional().describe("GasFree relay: official, gasfree, or HTTPS URL"), + maxGasfreeFee: paymentAmount.optional().describe("maximum GasFree fee in whole tokens"), + maxGasfreeFeeRaw: rawPaymentAmount.optional().describe("maximum GasFree fee in smallest units"), amount: z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/, "must be a decimal amount"), token: z .string() @@ -42,7 +50,7 @@ export const baiRechargeSpec: ChainSpec = { positionals: [{ field: "amount" }], summary: "Recharge your own or another B.AI account", description: - "Recharge B.AI using the selected network and token. Omit --to to recharge the API-key account, or set --to to the recipient's email or wallet address. Both modes use the same recharge flow. Recharge uses local x402 exact on mainnet: TRON USDT/USDD, BSC USDT, or Base USDC. USDT/USDC minimum: 1. Token and amount precision are checked before an order is created.", + "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 on mainnet (exact, or TRON exact_gasfree): TRON USDT/USDD, BSC USDT, or Base USDC. USDT/USDC minimum: 1. Token and amount precision are checked before an order is created.", baseFields: rechargeFields, examples: [ { cmd: "wallet-cli bai recharge 10 --token USDT --network tron --password-stdin" }, @@ -57,6 +65,14 @@ export const baiRechargeSpec: ChainSpec = { export function baiRechargeBinding(service: BaiService): FamilyBinding { return { + refine: (input, ctx) => { + if (input.maxGasfreeFee !== undefined && input.maxGasfreeFeeRaw !== undefined) + ctx.addIssue({ + code: "custom", + message: "GasFree fee limits are mutually exclusive", + params: { errorCode: "invalid_option" }, + }); + }, run: async (ctx, network, input) => { if (!network) throw new Error("B.AI recharge requires a resolved network"); return service.recharge(ctx, network, { @@ -64,6 +80,11 @@ export function baiRechargeBinding(service: BaiService): FamilyBinding { token: input.token ?? (network.id === "eip155:8453" ? "USDC" : "USDT"), to: input.to, apiKey: ctx.config.baiApiKey, + dryRun: input.dryRun, + scheme: input.scheme, + gasfreeRelay: input.gasfreeRelay, + maxGasfreeFee: input.maxGasfreeFee, + maxGasfreeFeeRaw: input.maxGasfreeFeeRaw, }); }, }; @@ -177,6 +198,10 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi fields: listFields, input: listFields, examples: [{ cmd: "wallet-cli bai recharge-orders --limit 20" }], - run: async (_context, _network, input) => service.rechargeList(input), + run: async (context, _network, input) => { + const { warnings, ...result } = await service.rechargeList(input); + for (const warning of warnings ?? []) context.warn(warning); + return result; + }, } satisfies CommandDefinition); } diff --git a/ts/src/adapters/inbound/cli/commands/x402.ts b/ts/src/adapters/inbound/cli/commands/x402.ts index 3cf59e609..2d789c3a8 100644 --- a/ts/src/adapters/inbound/cli/commands/x402.ts +++ b/ts/src/adapters/inbound/cli/commands/x402.ts @@ -126,8 +126,21 @@ const listFields = z.object({ const serveFields = z.object({ payTo: z.string().trim().min(1).describe("recipient address on the selected network"), - amount: paymentAmount.default("0.0001").describe("human-readable token amount"), - token: z.string().trim().min(1).default("USDT").describe("payment token symbol"), + amount: paymentAmount.optional().describe("human-readable token amount"), + token: z.string().trim().min(1).optional().describe("payment token symbol (default USDT)"), + rawAmount: rawPaymentAmount + .optional() + .describe("payment in smallest units; mutually exclusive with amount"), + asset: z.string().trim().min(1).optional().describe("explicit payment asset address"), + decimals: integerLiteral(0, 18).optional().describe("precision for an explicit asset"), + resourceUrl: url.optional().describe("advertised resource URL"), + validForSeconds: integerLiteral(1, 86400) + .default(300) + .describe("payment authorization validity in seconds"), + daemon: z + .boolean() + .default(false) + .describe("run in the background and return its PID and log path"), 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: integerLiteral(1, 65535).default(4020), @@ -138,18 +151,36 @@ const serveFields = z.object({ .default("https://facilitator.bankofai.io"), }); -const roundtripFields = serveFields.extend({ +function serveRefinement( + value: { amount?: string; rawAmount?: string; token?: string; asset?: string; decimals?: number }, + ctx: z.RefinementCtx, +) { + for (const [invalid, message] of [ + [ + value.amount !== undefined && value.rawAmount !== undefined, + "--amount and --raw-amount are mutually exclusive", + ], + [ + value.token !== undefined && value.asset !== undefined, + "--token and --asset are mutually exclusive", + ], + [value.decimals !== undefined && value.asset === undefined, "--decimals requires --asset"], + ] as const) { + if (invalid) ctx.addIssue({ code: "custom", message, params: { errorCode: "invalid_option" } }); + } +} + +const roundtripFields = serveFields.omit({ host: true, resourceUrl: true, daemon: true }).extend({ gasfreeRelay: payFields.shape.gasfreeRelay, maxGasfreeFee: payFields.shape.maxGasfreeFee, maxGasfreeFeeRaw: payFields.shape.maxGasfreeFeeRaw, }); -const roundtripInput = roundtripFields.refine( - (value) => !(value.maxGasfreeFee !== undefined && value.maxGasfreeFeeRaw !== undefined), - { +const roundtripInput = roundtripFields + .superRefine(serveRefinement) + .refine((value) => !(value.maxGasfreeFee !== undefined && value.maxGasfreeFeeRaw !== undefined), { message: "GasFree fee limits are mutually exclusive", params: { errorCode: "invalid_option" }, - }, -); + }); export function registerX402Commands(registry: CommandRegistry, service: X402Service): void { registry.add({ @@ -188,7 +219,7 @@ export function registerX402Commands(registry: CommandRegistry, service: X402Ser capability: "x402.serve", summary: "Run a local x402-protected endpoint", fields: serveFields, - input: serveFields, + input: serveFields.superRefine(serveRefinement), examples: [ { cmd: "wallet-cli x402 serve --pay-to T... --amount 1 --token USDT --network tron" }, ], @@ -211,7 +242,7 @@ export function registerX402Commands(registry: CommandRegistry, service: X402Ser 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); + return service.roundtrip(ctx, network, { ...input, host: "127.0.0.1" }); }, } satisfies CommandDefinition); diff --git a/ts/src/adapters/outbound/erc8004/registration-loader.test.ts b/ts/src/adapters/outbound/erc8004/registration-loader.test.ts index d0172228c..140efd5d6 100644 --- a/ts/src/adapters/outbound/erc8004/registration-loader.test.ts +++ b/ts/src/adapters/outbound/erc8004/registration-loader.test.ts @@ -72,12 +72,12 @@ describe("RegistrationLoader", () => { vi.stubGlobal("fetch", fetchMock); }); - it("decodes a strict base64 JSON data URI", async () => { + it("rejects a data URI without resolving or requesting it", 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 } }); + ).resolves.toEqual({ warning: "Registration metadata URI is invalid or unsupported" }); expect(fetchMock).not.toHaveBeenCalled(); expect(httpRequestMock).not.toHaveBeenCalled(); expect(httpsRequestMock).not.toHaveBeenCalled(); @@ -103,11 +103,11 @@ describe("RegistrationLoader", () => { ["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"); - + ])("requires HTTP metadata to be a JSON object: %s", async (_label, value) => { + allowPublicDns(); + replyHttps(JSON.stringify(value)); await expect( - new RegistrationLoader(1_000).load(`data:application/json;base64,${encoded}`), + new RegistrationLoader(1_000).load("https://metadata.example/agent.json"), ).resolves.toEqual({ warning: "Registration metadata is not a JSON object" }); }); @@ -221,53 +221,63 @@ describe("RegistrationLoader", () => { expect(httpsRequestMock).not.toHaveBeenCalled(); }); - it("maps an IPFS URI through the fixed HTTPS gateway", async () => { - allowPublicDns(); - replyHttps('{"name":"ipfs"}'); - + it("rejects IPFS without using a gateway or DNS", async () => { 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); + new RegistrationLoader(1_000).load("ipfs://QmAgentCID/agent.json"), + ).resolves.toEqual({ warning: "Registration metadata URI is invalid or unsupported" }); + expect(dnsLookup).not.toHaveBeenCalled(); + expect(httpsRequestMock).not.toHaveBeenCalled(); }); - it("bounds redirect chains", async () => { + it.each([301, 302, 303, 307, 308])( + "stops HTTP %s before resolving any Location", + async (status) => { + allowPublicDns(); + replyHttps("", status, { location: "http://internal.example/admin?token=secret" }); + await expect( + new RegistrationLoader(1_000).load("https://metadata.example/agent.json"), + ).resolves.toEqual({ warning: "Registration metadata redirects are not allowed" }); + expect(dnsLookup).toHaveBeenCalledTimes(1); + expect(httpsRequestMock).toHaveBeenCalledTimes(1); + expect(httpRequestMock).not.toHaveBeenCalled(); + }, + ); + + it.each([20, 21, 10000])("limits object/array depth at %s", async (depth) => { 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); + replyHttps('{"child":'.repeat(depth - 1) + "{}" + "}".repeat(depth - 1)); + const result = await new RegistrationLoader(1000).load("https://metadata.example/agent.json"); + if (depth <= 20) expect(result.metadata).toBeDefined(); + else + expect(result).toEqual({ + warning: "Registration metadata exceeds the maximum JSON depth of 20", + }); + }); - 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.each([ + [5000, 5000], + [10000, 10000], + [30000, 10000], + ])("caps timeout %s at %s", async (configured, effective) => { + vi.useFakeTimers(); + try { + allowPublicDns(); + httpsRequestMock.mockImplementation(() => clientRequest()); + let settled = false; + const pending = new RegistrationLoader(configured) + .load("https://metadata.example/agent.json") + .then((result) => { + settled = true; + return result; + }); + await vi.advanceTimersByTimeAsync(effective - 1); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(await pending).toEqual({ warning: "Registration metadata request timed out" }); + expect((httpsRequestMock.mock.calls[0]![1] as RequestOptions).signal?.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } }); it("rejects a declared response larger than 1 MiB without reading it", async () => { diff --git a/ts/src/adapters/outbound/erc8004/registration-loader.ts b/ts/src/adapters/outbound/erc8004/registration-loader.ts index 00e431d7a..437f034bf 100644 --- a/ts/src/adapters/outbound/erc8004/registration-loader.ts +++ b/ts/src/adapters/outbound/erc8004/registration-loader.ts @@ -8,9 +8,7 @@ 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/"; +const MAX_JSON_DEPTH = 20; export interface RegistrationLoadResult { metadata?: Record; @@ -22,7 +20,8 @@ type FailureKind = | "invalid_encoding" | "invalid_uri" | "restricted_address" - | "redirect_limit" + | "redirect_disallowed" + | "too_deep" | "too_large" | "malformed_json" | "not_object" @@ -83,14 +82,12 @@ export class RegistrationLoader { if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { throw new RangeError("Registration metadata timeout must be positive"); } - this.#timeoutMs = timeoutMs; + this.#timeoutMs = Math.min(timeoutMs, 10_000); } 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); + return await this.#loadRemote(uri); } catch (error) { return { warning: warningFor(error) }; } @@ -122,51 +119,34 @@ 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 validated = await validatedRemoteUrl(initialTarget); + 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)) { + response.destroy(); + throw new LoaderFailure("redirect_disallowed"); } - - 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); - } - - const contentType = headerValue(response, "content-type") ?? ""; - if (!/^application\/json(?:\s*;\s*charset\s*=\s*(?:utf-8|"utf-8"))?\s*$/i.test(contentType)) { - response.destroy(); - throw new LoaderFailure("invalid_content_type"); - } - return parseMetadata(await readBoundedText(response)); - } finally { - signal.removeEventListener("abort", destroyOnAbort); + if (status < 200 || status >= 300) { + response.destroy(); + throw new LoaderFailure("http_status", status); } + const contentType = headerValue(response, "content-type") ?? ""; + if (!/^application\/json(?:\s*;\s*charset\s*=\s*(?:utf-8|"utf-8"))?\s*$/i.test(contentType)) { + response.destroy(); + throw new LoaderFailure("invalid_content_type"); + } + return parseMetadata(await readBoundedText(response)); + } finally { + signal.removeEventListener("abort", destroyOnAbort); } } @@ -265,58 +245,6 @@ function assertPublicAddress(address: string, family: number): void { } } -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) { @@ -382,6 +310,16 @@ function parseMetadata(text: string): RegistrationLoadResult { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new LoaderFailure("not_object"); } + const pending: Array<{ value: object; depth: number }> = [{ value, depth: 1 }]; + while (pending.length) { + const current = pending.pop()!; + if (current.depth > MAX_JSON_DEPTH) throw new LoaderFailure("too_deep"); + for (const child of Object.values(current.value)) { + if (child !== null && typeof child === "object") { + pending.push({ value: child, depth: current.depth + 1 }); + } + } + } return { metadata: value as Record }; } @@ -396,8 +334,10 @@ function warningFor(error: unknown): string { 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 "redirect_disallowed": + return "Registration metadata redirects are not allowed"; + case "too_deep": + return "Registration metadata exceeds the maximum JSON depth of 20"; case "too_large": return "Registration metadata response exceeds the 1 MiB limit"; case "malformed_json": diff --git a/ts/src/adapters/outbound/x402/payment-client.ts b/ts/src/adapters/outbound/x402/payment-client.ts index d3908d8db..7f4154328 100644 --- a/ts/src/adapters/outbound/x402/payment-client.ts +++ b/ts/src/adapters/outbound/x402/payment-client.ts @@ -44,6 +44,13 @@ export class X402PaymentClient implements X402PaymentPort { private readonly config: Pick = {}, ) {} + validateConfiguration( + network: NetworkDescriptor, + input: Pick, + ): void { + gasfreeRelayClient(network, input.gasfreeRelay, this.config, 60000, this.fetcher); + } + async pay(scope: TransactionScope, network: NetworkDescriptor, input: X402PayInput) { if (input.asset && input.decimals !== undefined) paymentDecimals(network.id, input.asset, input.decimals); diff --git a/ts/src/adapters/outbound/x402/payment-error.test.ts b/ts/src/adapters/outbound/x402/payment-error.test.ts index 3c4f6d58a..4fa253599 100644 --- a/ts/src/adapters/outbound/x402/payment-error.test.ts +++ b/ts/src/adapters/outbound/x402/payment-error.test.ts @@ -10,7 +10,7 @@ it.each([403, 429, 502])( }); const result = sdkPaymentError(error, "create_payment"); expect(result).toMatchObject({ - code: "provider_error", + code: status === 429 ? "provider_rate_limited" : "provider_error", details: { phase: "create_payment", reason: "http_error", @@ -70,3 +70,26 @@ it.each([ details: { phase: "sign", retryPayment: false }, }); }); + +it.each(["challenge", "create_payment", "payment_request", "verify", "settle"] as const)( + "maps %s rate limits without authorizing repayment", + (phase) => { + expect(sdkPaymentError({ response: { status: 429 } }, phase)).toMatchObject({ + code: "provider_rate_limited", + details: { phase, retryPayment: false, httpStatus: 429 }, + }); + }, +); + +it("retains only numeric Retry-After hints", () => { + const result = sdkPaymentError( + { response: { status: 429, headers: { "retry-after": "30" } } }, + "settle", + ); + expect(result.details).toMatchObject({ retryAfterSeconds: 30, retryPayment: false }); + const unsafe = sdkPaymentError( + { response: { status: 429, headers: { "retry-after": "https://secret.example/token" } } }, + "settle", + ); + expect(JSON.stringify(unsafe.toEnvelope())).not.toContain("secret"); +}); diff --git a/ts/src/adapters/outbound/x402/payment-error.ts b/ts/src/adapters/outbound/x402/payment-error.ts index fefe464f4..263e293b9 100644 --- a/ts/src/adapters/outbound/x402/payment-error.ts +++ b/ts/src/adapters/outbound/x402/payment-error.ts @@ -36,7 +36,7 @@ export function providerPaymentError( const known = typeof key === "string" && Object.hasOwn(reasons, key) ? reasons[key]!() : undefined; return new TransportError( - known?.code ?? "provider_error", + evidence?.httpStatus === 429 ? "provider_rate_limited" : (known?.code ?? "provider_error"), known?.message ?? `x402 payment ${phase === "verify" ? "verification" : "settlement"} failed`, { phase, @@ -62,7 +62,14 @@ export type PaymentPhase = "request" | "challenge" | "create_payment" | "sign" | "payment_request" | "verify" | "settle"; export function sdkPaymentError(error: unknown, phase?: PaymentPhase): CliError { if (error instanceof CliError) { - if (!phase || (error.details as Record | undefined)?.phase) return error; + const details = error.details as Record | undefined; + if (error.code === "provider_error" && details?.httpStatus === 429) + return new TransportError( + "provider_rate_limited", + "x402 upstream rate limited the request; reconcile before paying again", + { ...details, ...(phase ? { phase } : {}), retryPayment: false }, + ); + if (!phase || details?.phase) return error; const ErrorType = error.kind === "usage" ? UsageError : TransportError; return new ErrorType(error.code, error.message, { ...error.details, @@ -76,7 +83,7 @@ export function sdkPaymentError(error: unknown, phase?: PaymentPhase): CliError const record = error && typeof error === "object" ? (error as { - response?: { status?: unknown }; + response?: { status?: unknown; headers?: Record }; status?: unknown; cause?: { code?: unknown }; code?: unknown; @@ -113,11 +120,12 @@ export function sdkPaymentError(error: unknown, phase?: PaymentPhase): CliError httpStatus <= 599 ) return new TransportError( - "provider_error", + httpStatus === 429 ? "provider_rate_limited" : "provider_error", `x402 upstream request returned HTTP ${httpStatus}; reconcile before paying again`, { ...(phase ? { phase } : {}), httpStatus, + ...safeRetryAfter(record?.response?.headers?.["retry-after"]), reason: "http_error", paymentStatus: "unknown", retryPayment: false, @@ -196,3 +204,12 @@ function candidateEvidence(value?: Record) { : {}), }; } + +function safeRetryAfter(value: unknown): { retryAfterSeconds?: number } { + if ( + (typeof value === "string" && /^\d{1,9}$/.test(value)) || + (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 999999999) + ) + return { retryAfterSeconds: Number(value) }; + return {}; +} diff --git a/ts/src/adapters/outbound/x402/provider-catalog.test.ts b/ts/src/adapters/outbound/x402/provider-catalog.test.ts index ef8547b8f..2a2c8e258 100644 --- a/ts/src/adapters/outbound/x402/provider-catalog.test.ts +++ b/ts/src/adapters/outbound/x402/provider-catalog.test.ts @@ -149,3 +149,27 @@ it("classifies missing providers and filesystem failures without leaking paths", await rm(root, { recursive: true, force: true }); } }); + +it("omits search internals and endpoint bodies from lists while preserving extension metadata", async () => { + const catalog = new X402ProviderCatalog(async () => + Response.json({ + version: 1, + providers: [ + { + fqn: "demo/provider", + query: "internal", + score: 42, + matched_fields: ["title"], + endpoints: [{ path: "/pay" }], + extra_metadata: { billing_mode: "usage" }, + }, + ], + }), + ); + const result = await catalog.list({ limit: 20, offset: 0 }); + expect(result.results[0]).toEqual({ + fqn: "demo/provider", + endpointCount: 1, + extraMetadata: { billingMode: "usage" }, + }); +}); diff --git a/ts/src/adapters/outbound/x402/provider-catalog.ts b/ts/src/adapters/outbound/x402/provider-catalog.ts index 85448ebf9..c3949747c 100644 --- a/ts/src/adapters/outbound/x402/provider-catalog.ts +++ b/ts/src/adapters/outbound/x402/provider-catalog.ts @@ -48,7 +48,16 @@ export class X402ProviderCatalog implements ProviderCatalogPort { network: wantedNetwork, }).filter((entry) => entry[1] !== undefined), ), - results: providers.slice(input.offset, input.offset + input.limit), + results: providers.slice(input.offset, input.offset + input.limit).map((provider) => + Object.fromEntries( + Object.entries({ + ...provider, + endpointCount: + provider.endpointCount ?? + (Array.isArray(provider.endpoints) ? provider.endpoints.length : 0), + }).filter(([key]) => !["query", "score", "matchedFields", "endpoints"].includes(key)), + ), + ), pagination: { offset: input.offset, limit: input.limit, total }, }; } diff --git a/ts/src/adapters/outbound/x402/server.test.ts b/ts/src/adapters/outbound/x402/server.test.ts index 1907650fd..0bcbf3362 100644 --- a/ts/src/adapters/outbound/x402/server.test.ts +++ b/ts/src/adapters/outbound/x402/server.test.ts @@ -225,3 +225,41 @@ it("advertises canonical TRON IDs and an empty GasFree extra", async () => { "exact_gasfree", ); }); + +it("serves raw amounts, explicit registered assets and validity, with sanitized access logs", async () => { + const logs: string[] = []; + const server = new X402HttpServer(undefined, 1000, (line) => logs.push(line)); + const net = { id: "eip155:84532", family: "evm", chainId: "84532" } as NetworkDescriptor; + const input = { + host: "127.0.0.1", + port: 0, + payTo: "0x1111111111111111111111111111111111111111", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + decimals: 6, + rawAmount: "1000001", + validForSeconds: 60, + scheme: "exact" as const, + resourceUrl: "https://resource.example/pay", + facilitatorUrl: "https://fake.invalid", + }; + expect(() => server.validate(net, { ...input, decimals: 18 })).toThrow(/precision/); + const handle = await server.start(net, input); + try { + const base = String(handle.details.payUrl).replace(/\/pay$/, ""); + for (const path of ["/health", "/.well-known/x402", "/pay", "/secret-path"]) { + const response = await fetch(`${base}${path}?secret=never-log`, { + headers: { Authorization: "never-log" }, + }); + const body = await response.json(); + if (path === "/pay") + expect(body).toMatchObject({ + resource: { url: input.resourceUrl }, + accepts: [{ amount: "1000001", maxTimeoutSeconds: 60, asset: input.asset }], + }); + } + expect(logs.map((line) => JSON.parse(line).status)).toEqual([200, 200, 402, 404]); + expect(logs.join("")).not.toMatch(/never-log|secret-path|Authorization/); + } finally { + await handle.close(); + } +}); diff --git a/ts/src/adapters/outbound/x402/server.ts b/ts/src/adapters/outbound/x402/server.ts index 40424353c..12f39d200 100644 --- a/ts/src/adapters/outbound/x402/server.ts +++ b/ts/src/adapters/outbound/x402/server.ts @@ -21,6 +21,9 @@ export class X402HttpServer implements X402ServerPort { constructor( private readonly fetcher: typeof fetch = globalThis.fetch, private readonly timeoutMs = 60000, + private readonly log: (line: string) => void = (line) => { + process.stderr.write(`${line}\n`); + }, ) {} validate(network: NetworkDescriptor, input: X402ServeInput): void { @@ -31,11 +34,90 @@ export class X402HttpServer implements X402ServerPort { if (input.scheme === "exact_gasfree" && network.family !== "tron") { throw new UsageError("invalid_value", "exact_gasfree is supported only on TRON"); } - const token = X402_TOKENS[network.id]?.[input.token.toUpperCase()]; - if (!token) - throw new UsageError("invalid_value", `${input.token} is not registered on ${network.id}`); + if (input.maxGasfreeFee !== undefined && input.maxGasfreeFeeRaw !== undefined) + throw new UsageError("invalid_option", "GasFree fee limits are mutually exclusive"); + if ( + input.maxGasfreeFeeRaw !== undefined && + (!/^\d{1,78}$/.test(input.maxGasfreeFeeRaw) || + BigInt(input.maxGasfreeFeeRaw) <= 0n || + BigInt(input.maxGasfreeFeeRaw) >= 1n << 256n) + ) + throw new UsageError("invalid_amount", "GasFree fee limit must be a positive uint256"); + if (input.gasfreeRelay && !["official", "gasfree"].includes(input.gasfreeRelay)) { + let relay: URL; + try { + relay = new URL(input.gasfreeRelay); + } catch { + throw new UsageError("invalid_option", "GasFree relay must be official, gasfree or HTTPS"); + } + if ( + relay.protocol !== "https:" || + relay.username || + relay.password || + relay.search || + relay.hash + ) + throw new UsageError( + "invalid_option", + "GasFree relay must be HTTPS without credentials, query or fragment", + ); + } + const registered = X402_TOKENS[network.id] ?? {}; + if (input.amount !== undefined && input.rawAmount !== undefined) + throw new UsageError("invalid_option", "amount and raw amount are mutually exclusive"); + if (input.token !== undefined && input.asset !== undefined) + throw new UsageError("invalid_option", "token and asset are mutually exclusive"); + if ( + input.decimals !== undefined && + (!input.asset || + !Number.isInteger(input.decimals) || + input.decimals < 0 || + input.decimals > 18) + ) + throw new UsageError("invalid_option", "decimals requires an asset and must be from 0 to 18"); + const known = input.asset + ? Object.values(registered).find((item) => + network.family === "evm" + ? item.address.toLowerCase() === input.asset!.toLowerCase() + : item.address === input.asset, + ) + : registered[(input.token ?? "USDT").toUpperCase()]; + if (known && input.decimals !== undefined && input.decimals !== known.decimals) + throw new UsageError( + "invalid_option", + "explicit decimals must match the registered token precision", + ); + if (input.asset && !addressCodec(network.family).validate(input.asset)) + throw new UsageError("invalid_address", "invalid payment asset address"); + if (!known && (!input.asset || input.decimals === undefined)) + throw new UsageError( + "invalid_value", + "use a registered token or an explicit asset with decimals", + ); + const token = known ?? { + address: input.asset!, + decimals: input.decimals!, + name: "", + version: "1", + permit2: true, + }; + if (input.maxGasfreeFee !== undefined) toSmallestUnit(input.maxGasfreeFee, token.decimals); validatePayTo(network, input.payTo); - const rawAmount = toSmallestUnit(input.amount, token.decimals); + const validity = input.validForSeconds ?? 300; + if (!Number.isInteger(validity) || validity < 1 || validity > 86400) + throw new UsageError("invalid_value", "valid-for-seconds must be from 1 to 86400"); + if (input.resourceUrl) { + const resource = new URL(input.resourceUrl); + if ( + !["http:", "https:"].includes(resource.protocol) || + resource.username || + resource.password + ) + throw new UsageError("invalid_value", "resource-url must be HTTP(S) without credentials"); + } + const rawAmount = input.rawAmount ?? toSmallestUnit(input.amount ?? "0.0001", token.decimals); + if (!/^\d{1,78}$/.test(rawAmount) || BigInt(rawAmount) <= 0n || BigInt(rawAmount) >= 1n << 256n) + throw new UsageError("invalid_amount", "raw amount must be a positive uint256"); return { token, rawAmount }; } @@ -50,7 +132,7 @@ export class X402HttpServer implements X402ServerPort { amount: rawAmount, asset: token.address, payTo: input.payTo, - maxTimeoutSeconds: 300, + maxTimeoutSeconds: input.validForSeconds ?? 300, extra: input.scheme === "exact_gasfree" ? {} @@ -61,10 +143,25 @@ export class X402HttpServer implements X402ServerPort { const challenge = { x402Version: 2, error: "Payment required", - resource: { url: resourceUrl }, + resource: { url: input.resourceUrl ?? resourceUrl }, accepts: [requirement], }; const server = createServer(async (request, response) => { + const started = performance.now(); + response.once("finish", () => { + // Do not log URLs, queries, headers, bodies or payment signatures. + const path = (request.url ?? "").split("?")[0]; + const route = ["/health", "/.well-known/x402", "/pay"].includes(path!) ? path : "other"; + this.log( + JSON.stringify({ + event: "x402.request", + method: request.method, + route, + status: response.statusCode, + durationMs: Math.round(performance.now() - started), + }), + ); + }); let pathname: string; try { pathname = new URL(request.url ?? "/", resourceUrl).pathname; @@ -118,14 +215,18 @@ export class X402HttpServer implements X402ServerPort { const address = server.address(); if (address && typeof address === "object") { resourceUrl = `http://${host}:${address.port}/pay`; - challenge.resource.url = resourceUrl; + challenge.resource.url = input.resourceUrl ?? resourceUrl; } return { details: { payUrl: resourceUrl, network: network.id, scheme: input.scheme, - token: input.token.toUpperCase(), + token: input.token?.toUpperCase() ?? (input.asset ? undefined : "USDT"), + asset: token.address, + decimals: token.decimals, + validForSeconds: input.validForSeconds ?? 300, + resourceUrl: challenge.resource.url, amount: input.amount, rawAmount, payTo: input.payTo, @@ -152,9 +253,13 @@ export class X402HttpServer implements X402ServerPort { 1024 * 1024, ); if (!response.ok) - throw new TransportError("provider_error", `facilitator returned HTTP ${response.status}`, { - httpStatus: response.status, - }); + throw new TransportError( + response.status === 429 ? "provider_rate_limited" : "provider_error", + `facilitator returned HTTP ${response.status}`, + { + httpStatus: response.status, + }, + ); return (await response.json()) as Record; } } diff --git a/ts/src/application/ports/x402-payment.ts b/ts/src/application/ports/x402-payment.ts index 2e410001a..2d228bedf 100644 --- a/ts/src/application/ports/x402-payment.ts +++ b/ts/src/application/ports/x402-payment.ts @@ -23,6 +23,11 @@ export interface X402PayInput { } export interface X402PaymentPort { + /** Local configuration checks only; no signing or I/O. */ + validateConfiguration?( + network: NetworkDescriptor, + input: Pick, + ): void; pay( scope: TransactionScope, network: NetworkDescriptor, diff --git a/ts/src/application/ports/x402-server.ts b/ts/src/application/ports/x402-server.ts index a66bd31bd..31d43c494 100644 --- a/ts/src/application/ports/x402-server.ts +++ b/ts/src/application/ports/x402-server.ts @@ -12,8 +12,15 @@ export interface X402RoundtripPort { export interface X402ServeInput { payTo: string; - amount: string; - token: string; + amount?: string; + rawAmount?: string; + token?: string; + asset?: string; + decimals?: number; + resourceUrl?: string; + validForSeconds?: number; + daemon?: boolean; + dryRun?: boolean; scheme: "exact" | "exact_gasfree"; host: string; port: number; diff --git a/ts/src/application/use-cases/agent-service.test.ts b/ts/src/application/use-cases/agent-service.test.ts index d7d093909..d47d15c2f 100644 --- a/ts/src/application/use-cases/agent-service.test.ts +++ b/ts/src/application/use-cases/agent-service.test.ts @@ -197,3 +197,18 @@ describe("AgentService beta integration", () => { expect(f.reader.read).not.toHaveBeenCalled(); }); }); + +it.each([ + [evmNet, "0x0000000000000000000000000000000000000000"], + [ + { id: "tron:3448148188", family: "tron", chainId: "3448148188" } as NetworkDescriptor, + "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb", + ], +])("rejects zero owner on %s before RPC", async (network, newOwner) => { + const f = fixture(); + await expect(f.service.transfer(f.scope, network, { id: "1", newOwner })).rejects.toMatchObject({ + code: "invalid_address", + }); + expect(f.reader.read).not.toHaveBeenCalled(); + expect(f.send).not.toHaveBeenCalled(); +}); diff --git a/ts/src/application/use-cases/agent-service.ts b/ts/src/application/use-cases/agent-service.ts index 524ddea01..04a12326a 100644 --- a/ts/src/application/use-cases/agent-service.ts +++ b/ts/src/application/use-cases/agent-service.ts @@ -1,3 +1,4 @@ +import { UsageError } from "../../domain/errors/index.js"; import type { NetworkDescriptor } from "../../domain/types/index.js"; import type { TransactionScope } from "../contracts/execution-scope.js"; import type { AgentContractPorts } from "../ports/agent-registry.js"; @@ -101,6 +102,9 @@ export class AgentService { network: NetworkDescriptor, input: TransactionOptions & { id: string; newOwner: string }, ) { + if (input.newOwner.toLowerCase() === zeroAddress(network).toLowerCase()) { + throw new UsageError("invalid_address", "Agent owner must not be the zero address"); + } const id = resolveAgentId(input.id, network).toString(); const registry = this.registry.registry(network); const owner = String( 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 0dfca0fc2..068acfa89 100644 --- a/ts/src/application/use-cases/bai-recharge-integration.test.ts +++ b/ts/src/application/use-cases/bai-recharge-integration.test.ts @@ -180,3 +180,122 @@ it.each([ expect(payments.roundtrip).not.toHaveBeenCalled(); }, ); + +it("previews a recipient recharge without creating an order or obtaining any signature", async () => { + const { X402Service } = await import("./x402-service.js"); + const { X402PaymentClient } = await import("../../adapters/outbound/x402/payment-client.js"); + const signing = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => { + throw new Error("must not unlock"); + }), + }; + const payments = new X402Service( + new X402PaymentClient(signing as never), + {} as never, + new X402HttpServer(undefined, 1000, () => {}), + ); + const api = { + resolveTarget: vi.fn(async () => ({ targetId: "recipient-id" })), + createOrder: vi.fn(), + reportTxHash: vi.fn(), + }; + const service = new BaiService( + {} as never, + () => new Date(), + payments, + { isConfirmed: () => true } as never, + api as never, + { facilitatorUrl: "https://fake.invalid", payTo: { bnb: payer } }, + ); + const result = await service.recharge( + { resolveAddress: () => payer, timeoutMs: 1000 } as never, + network as never, + { amount: "1", token: "USDT", apiKey: "test-key", dryRun: true, to: "recipient@example.com" }, + ); + expect(result).toMatchObject({ + dryRun: true, + rawAmount: "1000000000000000000", + payer, + payTo: payer, + payment: { dryRun: true, settled: false }, + rechargeTarget: { confirmedTarget: { targetId: "recipient-id" } }, + }); + expect(api.createOrder).not.toHaveBeenCalled(); + expect(api.reportTxHash).not.toHaveBeenCalled(); + expect(signing.resolve).not.toHaveBeenCalled(); + expect(signing.assertCanSign).not.toHaveBeenCalled(); +}); + +it("reports account ambiguity before checking binding or contacting B.AI", async () => { + const { UsageError } = await import("../../domain/errors/index.js"); + const isConfirmed = vi.fn(); + const remote = { createOrder: vi.fn(), resolveTarget: vi.fn() }; + const service = new BaiService( + {} as never, + undefined, + undefined, + { isConfirmed } as never, + remote as never, + undefined, + undefined, + undefined, + { + resolveAccount: () => { + throw new UsageError("ambiguous_account", "Select an account", { + accountIds: ["software", "watch"], + }); + }, + }, + ); + await expect( + service.recharge({ activeAccount: payer } as never, network as never, { + amount: "1", + token: "USDT", + apiKey: "test-key", + }), + ).rejects.toMatchObject({ + code: "ambiguous_account", + details: { accountIds: ["software", "watch"] }, + }); + expect(isConfirmed).not.toHaveBeenCalled(); + expect(remote.createOrder).not.toHaveBeenCalled(); + expect(remote.resolveTarget).not.toHaveBeenCalled(); +}); + +it("checks relay configuration before creating any preorder", async () => { + const { X402Service } = await import("./x402-service.js"); + const { X402PaymentClient } = await import("../../adapters/outbound/x402/payment-client.js"); + const { X402HttpServer } = await import("../../adapters/outbound/x402/server.js"); + const api = { createOrder: vi.fn(), reportTxHash: vi.fn() }; + const payments = new X402Service( + new X402PaymentClient({} as never), + {} as never, + new X402HttpServer(), + ); + const service = new BaiService( + {} as never, + undefined, + payments, + { isConfirmed: () => true } as never, + api as never, + { + facilitatorUrl: "https://fake.invalid", + payTo: { tron: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ" }, + }, + ); + await expect( + service.recharge( + { resolveAddress: () => "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ" } as never, + { id: "tron:728126428", family: "tron", chainId: "728126428" } as never, + { + amount: "1", + token: "USDT", + apiKey: "test-key", + gasfreeRelay: "gasfree", + scheme: "exact_gasfree", + }, + ), + ).rejects.toMatchObject({ code: "gasfree_credentials_missing" }); + expect(api.createOrder).not.toHaveBeenCalled(); +}); diff --git a/ts/src/application/use-cases/bai-service.test.ts b/ts/src/application/use-cases/bai-service.test.ts index ef79bd6bc..e09be6955 100644 --- a/ts/src/application/use-cases/bai-service.test.ts +++ b/ts/src/application/use-cases/bai-service.test.ts @@ -90,3 +90,24 @@ it("passes the usage cursor through and exposes continuation metadata", async () cursor: "previous", }); }); + +it.each([100, 101, 200])( + "caps recharge list %s without losing an unaligned offset", + async (limit) => { + const remote = api(); + const rows = Array.from({ length: 400 }, (_, id) => ({ id })); + vi.mocked(remote.rechargeList).mockImplementation(async ({ page, pageSize }) => ({ + items: rows.slice((page - 1) * pageSize, page * pageSize), + page, + pageSize, + total: rows.length, + })); + const result = await new BaiService(remote).rechargeList({ limit, offset: 101, sort: "asc" }); + expect(result.orders).toEqual(rows.slice(101, 201)); + expect(result.pagination).toEqual({ offset: 101, limit: 100, total: 400 }); + expect(result.warnings.length).toBe(limit > 100 ? 1 : 0); + expect( + vi.mocked(remote.rechargeList).mock.calls.every(([input]) => input.pageSize <= 100), + ).toBe(true); + }, +); diff --git a/ts/src/application/use-cases/bai-service.ts b/ts/src/application/use-cases/bai-service.ts index 27afc6491..814a818d1 100644 --- a/ts/src/application/use-cases/bai-service.ts +++ b/ts/src/application/use-cases/bai-service.ts @@ -1,3 +1,5 @@ +import type { AccountStore } from "../ports/account-store.js"; +import type { ChainGatewayProvider } from "../ports/chain/gateway-provider.js"; import type { BaiRechargeApi, BaiReportRetry, @@ -31,12 +33,24 @@ export class BaiService { private readonly rechargeApi?: BaiRechargeApi, private readonly rechargeConfig?: BaiRechargeConfig, private readonly reportRetry?: BaiReportRetry, + private readonly gateways?: ChainGatewayProvider, + private readonly accounts?: Pick, ) {} async recharge( scope: TransactionScope, network: NetworkDescriptor, - input: { amount: string; token: string; to?: string; apiKey?: string }, + input: { + amount: string; + token: string; + to?: string; + apiKey?: string; + dryRun?: boolean; + scheme?: "exact" | "exact_gasfree"; + gasfreeRelay?: string; + maxGasfreeFee?: string; + maxGasfreeFeeRaw?: string; + }, ) { if (!input.apiKey) { throw new UsageError( @@ -47,6 +61,8 @@ export class BaiService { if (!this.bindings) throw new UsageError("invalid_option", "B.AI recharge binding verification is unavailable"); const chain = requireBaiChain(network); + // Resolve the signing account before binding checks, including ambiguous address selectors. + this.accounts?.resolveAccount(scope.activeAccount, network.family); const payer = scope.resolveAddress(network.family); if (!this.bindings.isConfirmed(input.apiKey, chain, payer)) { throw new UsageError( @@ -70,7 +86,11 @@ export class BaiService { payTo: expectedPayTo, amount: input.amount, token: input.token, - scheme: "exact", + scheme: input.scheme ?? "exact", + dryRun: input.dryRun, + gasfreeRelay: input.gasfreeRelay, + maxGasfreeFee: input.maxGasfreeFee, + maxGasfreeFeeRaw: input.maxGasfreeFeeRaw, host: "127.0.0.1", port: 0, facilitatorUrl: this.rechargeConfig.facilitatorUrl, @@ -90,6 +110,42 @@ export class BaiService { confirmedTarget: { type: "personal", targetId: resolved.targetId }, }; } + if (input.dryRun) { + const inspection = await this.payments.roundtrip(scope, network, paymentInput); + let balance: { tokenRaw: string; nativeRaw: string } | null = null; + const asset = inspection.serve.asset; + if (this.gateways && typeof asset === "string") { + try { + const tokenRaw = + network.family === "evm" + ? await this.gateways.get(network, "evm").getErc20Balance(asset, payer) + : await this.gateways.get(network, "tron").getTrc20Balance(asset, payer); + balance = { + tokenRaw, + nativeRaw: await this.gateways.client(network).getNativeBalance(payer), + }; + } catch { + scope.warn("Wallet balance is unavailable; preview does not establish sufficient funds."); + } + } + return { + dryRun: true, + network: network.id, + token: input.token, + amount: input.amount, + payer, + payTo: expectedPayTo, + scheme: paymentInput.scheme, + rawAmount: inspection.serve.rawAmount, + rechargeTarget: rechargeTarget ?? { type: "self", walletAddress: payer }, + payment: inspection.pay, + balance, + estimatedFee: null, + feeLimit: { amount: input.maxGasfreeFee, rawAmount: input.maxGasfreeFeeRaw }, + warning: + "Preview only; final network/relay fee is unavailable until payment authorization. Balance refers to the payer wallet, not its GasFree account. No order or payment was created.", + }; + } const flow = new BaiRechargeFlow( this.rechargeApi, { @@ -201,10 +257,29 @@ export class BaiService { } async rechargeList(input: BaiListCommandInput) { - const result = await this.api.rechargeList(pageInput(input)); + const limit = Math.min(input.limit, 100); + const page = Math.floor(input.offset / limit) + 1; + const skip = input.offset % limit; + const request = { page, pageSize: limit, sortBy: "created_at" as const, sortOrder: input.sort }; + const result = await this.api.rechargeList(request); + let orders = result.items.slice(skip); + if ( + skip > 0 && + result.items.length === limit && + (result.total === undefined || input.offset + orders.length < result.total) + ) { + const next = await this.api.rechargeList({ ...request, page: page + 1 }); + orders = orders.concat(next.items).slice(0, limit); + } return { - orders: result.items, - pagination: { offset: input.offset, limit: input.limit, total: result.total }, + orders, + pagination: { offset: input.offset, limit, total: result.total }, + warnings: + input.limit > limit + ? [ + `Recharge order limit reduced from ${input.limit} to ${limit} to match the B.AI server limit.`, + ] + : [], }; } } diff --git a/ts/src/application/use-cases/config-service.test.ts b/ts/src/application/use-cases/config-service.test.ts index a1f4c086a..6b1cf8fdc 100644 --- a/ts/src/application/use-cases/config-service.test.ts +++ b/ts/src/application/use-cases/config-service.test.ts @@ -71,7 +71,7 @@ describe("ConfigService TronLink credentials", () => { const { svc } = service(); expect( svc.execute({ key: "tronlinkSecretId", value: "TEST" }, effective, networks), - ).toMatchObject({ key: "tronlinkSecretId", value: "TEST" }); + ).toMatchObject({ key: "tronlinkSecretId", value: "********" }); expect( svc.execute({ key: "tronlinkSecretKey", value: "TESTTESTTEST" }, effective, networks), ).toMatchObject({ key: "tronlinkSecretKey", value: "********" }); @@ -100,7 +100,7 @@ describe("ConfigService GasFree credentials", () => { it("writes the documented flat keys and masks the API secret", () => { const { svc } = service(); expect(svc.execute({ key: "gasfreeApiKey", value: "TEST" }, effective, networks)).toMatchObject( - { key: "gasfreeApiKey", value: "TEST" }, + { key: "gasfreeApiKey", value: "********" }, ); expect( svc.execute({ key: "gasfreeApiSecret", value: "TESTTESTTEST" }, effective, networks), @@ -487,3 +487,22 @@ describe("ConfigService writes the API-key pair", () => { ).toThrow(/apiKeyHeader/); }); }); + +it.each([ + "tronlinkSecretId", + "tronlinkSecretKey", + "gasfreeApiKey", + "gasfreeApiSecret", + "baiApiKey", +])("never echoes %s in config outputs", (key) => { + const { svc } = service(); + const value = "sentinel-secret-123"; + const configured = { ...effective, [key]: value }; + for (const result of [ + svc.execute({}, configured, networks), + svc.execute({ key }, configured, networks), + svc.execute({ key, value }, configured, networks), + ]) { + expect(JSON.stringify(result)).not.toContain(value); + } +}); diff --git a/ts/src/application/use-cases/config-service.ts b/ts/src/application/use-cases/config-service.ts index a953536cb..71cd8d155 100644 --- a/ts/src/application/use-cases/config-service.ts +++ b/ts/src/application/use-cases/config-service.ts @@ -90,10 +90,10 @@ export class ConfigService { // Read-only, and the book's only visibility surface: there is no `config set aliases.*`, // so without this the only way to see what a short name resolves to is to open config.yaml. aliases: effective.aliases, - tronlinkSecretId: effective.tronlinkSecretId, + tronlinkSecretId: maskSecret(effective.tronlinkSecretId), tronlinkSecretKey: maskSecret(effective.tronlinkSecretKey), tronlinkChannel: effective.tronlinkChannel, - gasfreeApiKey: effective.gasfreeApiKey, + gasfreeApiKey: maskSecret(effective.gasfreeApiKey), gasfreeApiSecret: maskSecret(effective.gasfreeApiSecret), baiApiKey: maskSecret(effective.baiApiKey), }; @@ -116,7 +116,15 @@ export class ConfigService { const key = input.key as WritableConfigKey; const value = this.normalize(key, input.value, networks); - if (key === "tronlinkSecretKey" || key === "gasfreeApiSecret" || key === "baiApiKey") { + if ( + [ + "tronlinkSecretId", + "tronlinkSecretKey", + "gasfreeApiKey", + "gasfreeApiSecret", + "baiApiKey", + ].includes(key) + ) { return this.documents.update((current) => ({ document: { ...current, [key]: value }, result: { key, value: maskSecret(String(value)), input: "********" }, diff --git a/ts/src/application/use-cases/x402-service.ts b/ts/src/application/use-cases/x402-service.ts index f599dbec9..8f001bd31 100644 --- a/ts/src/application/use-cases/x402-service.ts +++ b/ts/src/application/use-cases/x402-service.ts @@ -34,6 +34,7 @@ export class X402Service { 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); + this.payments.validateConfiguration?.(network, input); } async serve(network: NetworkDescriptor, input: X402ServeInput) { @@ -53,8 +54,12 @@ export class X402Service { token: input.token, scheme: input.scheme, expectedPayTo: input.payTo, - exactAmount: input.amount, - maxAmount: input.amount, + asset: input.asset, + decimals: input.decimals, + exactAmount: input.rawAmount === undefined ? (input.amount ?? "0.0001") : undefined, + maxAmount: input.rawAmount === undefined ? (input.amount ?? "0.0001") : undefined, + maxRawAmount: input.rawAmount, + dryRun: input.dryRun, gasfreeRelay: input.gasfreeRelay, maxGasfreeFee: input.maxGasfreeFee, maxGasfreeFeeRaw: input.maxGasfreeFeeRaw, diff --git a/ts/src/bootstrap/composition.ts b/ts/src/bootstrap/composition.ts index 295b34454..ef0eb72be 100644 --- a/ts/src/bootstrap/composition.ts +++ b/ts/src/bootstrap/composition.ts @@ -162,6 +162,8 @@ export function composeCliRuntime(options: BootstrapOptions) { now: () => performance.now(), wait: delay, }, + gatewayProvider, + keystore, ), ); const agentContracts = { diff --git a/ts/src/bootstrap/x402-daemon.ts b/ts/src/bootstrap/x402-daemon.ts new file mode 100644 index 000000000..4b9f3a0a4 --- /dev/null +++ b/ts/src/bootstrap/x402-daemon.ts @@ -0,0 +1,65 @@ +import { fork, spawn } from "node:child_process"; +import { mkdtemp, open } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ExecutionError } from "../domain/errors/index.js"; +import type { X402ServerHandle } from "../application/ports/x402-server.js"; + +/** Start the same CLI in a detached process; only announce success after its listener is ready. */ +export async function startX402Daemon(): Promise { + const directory = await mkdtemp(join(tmpdir(), "wallet-cli-x402-")); + const logFile = join(directory, "access.log"); + const log = await open(logFile, "wx", 0o600); + // Bun executables have a virtual entrypoint that cannot be forked as a script. + const standalone = + Boolean(process.versions.bun) && + /^(?:\/\$bunfs\/|[A-Za-z]:[\\/]~BUN[\\/])/.test(process.argv[1] ?? ""); + const options = { + detached: true, + stdio: ["ignore", "ignore", log.fd, "ipc"] as ["ignore", "ignore", number, "ipc"], + env: { ...process.env, WALLET_CLI_X402_DAEMON_CHILD: "1" }, + }; + const child = standalone + ? spawn(process.execPath, process.argv.slice(2), options) + : fork(process.argv[1]!, process.argv.slice(2), options); + await log.close(); + try { + const details = await new Promise>((resolve, reject) => { + const timer = setTimeout(() => fail(), 30_000); + const cleanup = () => { + clearTimeout(timer); + child.off("error", fail); + child.off("exit", fail); + child.off("message", ready); + }; + const fail = () => { + cleanup(); + reject( + new ExecutionError("provider_error", "x402 daemon failed to start; inspect its log", { + logFile, + }), + ); + }; + const ready = (message: unknown) => { + if (!message || typeof message !== "object" || !("x402Ready" in message)) return; + cleanup(); + resolve((message as { x402Ready: Record }).x402Ready); + }; + child.once("error", fail); + child.once("exit", fail); + child.on("message", ready); + }); + if (child.connected) child.disconnect(); + child.unref(); + return { + details: { ...details, daemon: true, pid: child.pid, logFile }, + close: async () => { + child.kill("SIGTERM"); + }, + }; + } catch (error) { + child.kill("SIGTERM"); + if (child.connected) child.disconnect(); + throw error; + } +} diff --git a/ts/src/bootstrap/x402-server-lifecycle.ts b/ts/src/bootstrap/x402-server-lifecycle.ts index 66b7d171f..ac66ea873 100644 --- a/ts/src/bootstrap/x402-server-lifecycle.ts +++ b/ts/src/bootstrap/x402-server-lifecycle.ts @@ -1,3 +1,4 @@ +import { startX402Daemon } from "./x402-daemon.js"; import type { X402ServerPort, X402ServerHandle, @@ -12,6 +13,8 @@ export class ManagedX402Server implements X402ServerPort { this.server.validate(network, input); } async start(network: NetworkDescriptor, input: X402ServeInput): Promise { + this.server.validate(network, input); + if (input.daemon && process.env.WALLET_CLI_X402_DAEMON_CHILD !== "1") return startX402Daemon(); const handle = await this.server.start(network, input); let closing: Promise | undefined; const close = () => { @@ -31,6 +34,7 @@ export class ManagedX402Server implements X402ServerPort { }; process.once("SIGINT", stop); process.once("SIGTERM", stop); + if (input.daemon && process.send) process.send({ x402Ready: handle.details }); return { details: handle.details, close }; } } diff --git a/ts/test/erc8004.test.ts b/ts/test/erc8004.test.ts index 3944ed922..1742c2c3c 100644 --- a/ts/test/erc8004.test.ts +++ b/ts/test/erc8004.test.ts @@ -165,15 +165,16 @@ async function fixture(family: "evm" | "tron") { 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 () => { + it(`${family} show preserves chain fields and RPC credentials while warning on 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(result.data.metadata).toBeUndefined(); + expect(JSON.stringify(result.meta.warnings)).toContain("URI is invalid or unsupported"); expect(f.calls.map((c) => c.method).sort()).toEqual(["getApproved", "ownerOf", "tokenURI"]); expect(f.calls.every((c) => c.header === "test-only-key")).toBe(true); }); diff --git a/ts/test/x402-daemon.test.ts b/ts/test/x402-daemon.test.ts new file mode 100644 index 000000000..7a9f72c67 --- /dev/null +++ b/ts/test/x402-daemon.test.ts @@ -0,0 +1,102 @@ +import { afterEach, expect, it } from "vitest"; +import { spawn } from "node:child_process"; +import { createServer } from "node:net"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +const homes: string[] = []; +const pids: number[] = []; +afterEach(async () => { + for (const pid of pids.splice(0)) { + try { + process.kill(pid, "SIGTERM"); + } catch { + /* already stopped */ + } + } + for (const path of homes.splice(0)) await rm(path, { recursive: true, force: true }); +}); + +it("detaches only after readiness, keeps serving, and closes its listener on SIGTERM", async () => { + const home = await mkdtemp(join(tmpdir(), "wallet-daemon-test-")); + homes.push(home); + 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 executable = process.env.WALLET_CLI_TEST_EXECUTABLE; + const entry = executable + ? [] + : process.env.WALLET_CLI_TEST_ENTRY + ? [process.env.WALLET_CLI_TEST_ENTRY] + : ["--import", "tsx", join(process.cwd(), "src/index.ts")]; + const parent = spawn( + executable ?? process.execPath, + [ + ...entry, + "x402", + "serve", + "--network", + "base-sepolia", + "--pay-to", + "0x1111111111111111111111111111111111111111", + "--token", + "USDC", + "--raw-amount", + "1000000", + "--port", + String(port), + "--daemon", + "-o", + "json", + ], + { + env: { ...process.env, WALLET_CLI_HOME: home }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = "", + stderr = ""; + parent.stdout.on("data", (chunk) => { + stdout += chunk; + }); + parent.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const code = await new Promise((resolve, reject) => { + const guard = setTimeout(() => { + parent.kill("SIGKILL"); + reject(new Error("daemon parent did not exit")); + }, 20000); + parent.once("error", (error) => { + clearTimeout(guard); + reject(error); + }); + parent.once("close", (code) => { + clearTimeout(guard); + resolve(code); + }); + }); + expect(code, stderr || stdout).toBe(0); + const result = JSON.parse(stdout); + const { pid, logFile, payUrl } = result.data; + pids.push(pid); + homes.push(dirname(logFile)); + expect(result).toMatchObject({ success: true, data: { daemon: true, rawAmount: "1000000" } }); + const healthUrl = new URL("/health", payUrl); + expect((await fetch(healthUrl)).status).toBe(200); + expect((await stat(logFile)).mode & 0o777).toBe(0o600); + await expect.poll(async () => readFile(logFile, "utf8")).toContain('"route":"/health"'); + process.kill(pid, "SIGTERM"); + await expect + .poll(async () => { + try { + await fetch(healthUrl, { signal: AbortSignal.timeout(200) }); + return false; + } catch { + return true; + } + }) + .toBe(true); +}); From 777dc7daf45cf1b23c620e44bc1dfc3683a2f8f0 Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Wed, 16 Sep 2026 18:28:57 +0800 Subject: [PATCH 2/5] chore: exclude documentation changes from R7 PR --- ts/README.md | 17 +---------------- ts/docs/concepts/provider-catalog.md | 4 ---- ts/docs/development/bai-recharge.md | 17 ----------------- ts/docs/development/erc8004-sdk-integration.md | 9 --------- 4 files changed, 1 insertion(+), 46 deletions(-) diff --git a/ts/README.md b/ts/README.md index 6390743ba..24c70cc43 100644 --- a/ts/README.md +++ b/ts/README.md @@ -254,7 +254,7 @@ 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. -For `x402 pay`, `x402 roundtrip` and `bai recharge`, `--gasfree-relay official` (the default) +For `x402 pay` and `x402 roundtrip`, `--gasfree-relay official` (the default) uses the SDK's credential-free proxy. `--gasfree-relay gasfree` reads the configured GasFree Open API using `gasfreeApiKey` and `gasfreeApiSecret`; missing credentials fail before payment. An HTTPS URL selects a custom relay @@ -269,18 +269,3 @@ maximum authorized fee is not evidence of the actual fee charged. Provider queries prefer the local snapshot. Run `x402 update-catalog` to refresh it; see [catalog caching](docs/concepts/provider-catalog.md). - -### Local x402 payment server - -`x402 serve` and `x402 roundtrip` accept either `--amount` or `--raw-amount`, and -either `--token` or `--asset` (with `--decimals` for an unregistered asset). -Registered token precision cannot be overridden. `--valid-for-seconds` sets the -payment authorization lifetime (default 300 seconds). - -`x402 serve --network base-sepolia --token USDC --raw-amount 1000000 --pay-to
--daemon` -starts a loopback server and returns its PID, payment URL and private access-log -path after the listener is ready. Stop it with `kill -TERM `; remove the log -after use if no longer needed. Foreground requests log to stderr, while stdout -retains the command result. Logs exclude query strings, headers and payment bodies. -`--resource-url` overrides the advertised resource URL only; `--host` controls the -loopback listener. Roundtrip always uses loopback and closes its server on completion. diff --git a/ts/docs/concepts/provider-catalog.md b/ts/docs/concepts/provider-catalog.md index d87de2427..ac59671bd 100644 --- a/ts/docs/concepts/provider-catalog.md +++ b/ts/docs/concepts/provider-catalog.md @@ -7,7 +7,3 @@ Text output shows selected fields, a first-sentence summary and network aliases. Endpoint tables show exact prices or ranges; missing routes are shown as `——`, not a zero price. JSON retains complete provider data. 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. - -Provider lists omit search-only `query`, `score`, `matchedFields` and inline -`endpoints`; use `endpoint-list` to inspect endpoints. Lists retain `endpointCount` -and normalize extension metadata keys to camelCase. diff --git a/ts/docs/development/bai-recharge.md b/ts/docs/development/bai-recharge.md index ca46b2b47..0af3d874e 100644 --- a/ts/docs/development/bai-recharge.md +++ b/ts/docs/development/bai-recharge.md @@ -194,20 +194,3 @@ CLI 在支付成功后立即上报;如果 BAI 返回 `TX_NOT_FOUND_OR_INVALID` 该流程只等待并重试 BAI 对原交易的核验,不轮询链上 RPC,也不会重新创建订单、解析目标或付款。认证失败、付款人不匹配、其他拒绝及网络异常不自动重试。耗尽预算后返回 `creditStatus=unconfirmed`,保留原交易、链、金额、目标用户和 `retryPayment=false`,供后续补报。 x402 错误新增阶段信息:`request`、`challenge`、`create_payment`、`sign`、`payment_request`、`verify`、`settle`。能够识别的 HTTP 错误保留状态码,连接错误保留白名单中的错误码;结算失败保留合法的候选交易哈希和网络,不直接回显 SDK 消息、请求内容或凭证。 - -## Preview and fee controls - -`bai recharge 1 --network base --token USDC --dry-run` validates binding, amount, -destination and the x402 challenge without creating an order, unlocking, signing, -paying or reporting a transaction. Recipient recharge still resolves `--to`. -The preview includes payer wallet balances when RPC is available. It does not -claim a final fee quote or a GasFree account balance; unavailable estimates are -explicitly null. Base and recipient recharge remain supported. - -TRON supports `--scheme exact_gasfree`, `--gasfree-relay`, and mutually exclusive -`--max-gasfree-fee` / `--max-gasfree-fee-raw`. The same signer bridge enforces fee -limits before signing. An unknown settlement outcome never triggers repayment. - -`bai recharge-orders` accepts limits through 200, but caps the effective limit at -100 for the order API, with a warning and accurate pagination metadata. Offsets -are preserved across backend page boundaries; usage records retain their own limit. diff --git a/ts/docs/development/erc8004-sdk-integration.md b/ts/docs/development/erc8004-sdk-integration.md index dc1d293ef..884965dd9 100644 --- a/ts/docs/development/erc8004-sdk-integration.md +++ b/ts/docs/development/erc8004-sdk-integration.md @@ -30,12 +30,3 @@ 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. - -## Registration metadata limits - -Writing a registration URI still accepts HTTPS, IPFS and JSON data URIs. Reading -metadata accepts only HTTP/HTTPS and never follows redirects or resolves IPFS -through a third-party gateway. Unsupported URIs produce a warning; authoritative -on-chain fields remain available. Remote JSON is limited to 1 MiB (both compressed -and expanded), 20 object/array levels with the root at level 1, and the shorter of -the global timeout and 10 seconds. DNS addresses remain validated and pinned. From cc122e9af5c54ce40a921305b247df5a40463224 Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Wed, 16 Sep 2026 22:55:15 +0800 Subject: [PATCH 3/5] fix(ts): negotiate TRON facilitator capabilities before signing --- ts/README.md | 85 +++++++++++++++- .../outbound/x402/facilitator-network.test.ts | 60 ++++++++++++ .../outbound/x402/facilitator-network.ts | 67 +++++++++++++ .../outbound/x402/legacy-facilitator.test.ts | 96 +++++++++++++++++++ ts/src/adapters/outbound/x402/server.test.ts | 16 ++-- ts/src/adapters/outbound/x402/server.ts | 11 ++- ts/test/beta-server-roundtrip.test.ts | 10 +- 7 files changed, 330 insertions(+), 15 deletions(-) create mode 100644 ts/src/adapters/outbound/x402/facilitator-network.test.ts create mode 100644 ts/src/adapters/outbound/x402/facilitator-network.ts create mode 100644 ts/src/adapters/outbound/x402/legacy-facilitator.test.ts diff --git a/ts/README.md b/ts/README.md index 24c70cc43..f4c2ec194 100644 --- a/ts/README.md +++ b/ts/README.md @@ -228,10 +228,10 @@ transaction pipeline. See [the SDK integration](docs/development/erc8004-sdk-int The v4.14 command names are: -| Group | Commands | -| --- | --- | -| `x402` | `pay`, `serve`, `roundtrip`, `provider-list`, `provider-show`, `endpoint-list`, `update-catalog` | -| `bai` | `usage-summary`, `usage-records`, `recharge`, `report-recharge`, `recharge-orders` | +| Group | Commands | +| ------ | -------------------------------------------------------------------------------------------------------- | +| `x402` | `pay`, `serve`, `roundtrip`, `provider-list`, `provider-show`, `endpoint-list`, `update-catalog` | +| `bai` | `usage-summary`, `usage-records`, `recharge`, `report-recharge`, `recharge-orders` | | `8004` | `show`, `register`, `update`, `transfer`, `approve`, `add-operator`, `remove-operator`, `operator-check` | JSON command identifiers use these names, for example `bai.usage-summary`. @@ -254,7 +254,7 @@ 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. -For `x402 pay` and `x402 roundtrip`, `--gasfree-relay official` (the default) +For `x402 pay`, `x402 roundtrip` and `bai recharge`, `--gasfree-relay official` (the default) uses the SDK's credential-free proxy. `--gasfree-relay gasfree` reads the configured GasFree Open API using `gasfreeApiKey` and `gasfreeApiSecret`; missing credentials fail before payment. An HTTPS URL selects a custom relay @@ -269,3 +269,78 @@ maximum authorized fee is not evidence of the actual fee charged. Provider queries prefer the local snapshot. Run `x402 update-catalog` to refresh it; see [catalog caching](docs/concepts/provider-catalog.md). + +### Facilitator compatibility + +For TRON, local `x402 serve`, `x402 roundtrip` and `bai recharge` query the +configured facilitator's `/supported` endpoint before advertising the payment +requirement. The CLI matches the network, scheme and x402 version 2, then uses the +network spelling that the facilitator supports: + +- Decimal only: use the decimal ID, such as `tron:3448148188`. +- Hexadecimal only: use the advertised hexadecimal ID, such as `tron:0xcd8690dc`. +- Both: prefer the canonical decimal ID. + +Capability lookup failures, malformed responses and missing matching capabilities +stop the flow before signing. The selected representation stays consistent through +the challenge, payment payload, verify and settle requests. The CLI does not rewrite +signed payloads or retry settlement with another format after an error. CLI network +selection and server result fields retain canonical decimal IDs; settlement receipts +accept either representation of the same chain. EVM network IDs remain unchanged. +External providers remain responsible for their own facilitator compatibility. + +### Local x402 server + +`serve` and `roundtrip` accept either `--amount` or `--raw-amount`, and either +`--token` or `--asset`. An unregistered asset requires `--decimals`; registered +precision cannot be overridden. `--valid-for-seconds` sets authorization validity +(default 300 seconds). + +```sh +wallet-cli x402 serve --network nile --token USDT --raw-amount 100 \ + --pay-to --valid-for-seconds 300 --daemon --output json +``` + +The daemon returns its PID, payment URL and access-log path after the listener is +ready. Stop it with `kill -TERM `. Foreground access logs go to stderr; daemon +logs are written to a private file. Logs exclude query strings, headers and payment +bodies. `serve --resource-url` changes the advertised resource URL and `--host` +selects a loopback bind address. `roundtrip` always binds to loopback and closes its +server on completion; it does not accept `--host`, `--resource-url` or `--daemon`. + +### B.AI setup, recharge and recovery + +Before the first recharge, the selected wallet must already be bound to the B.AI +account. Configure the personal API key through stdin; setup verifies the binding +for the selected account and network before saving the key: + +```sh +wallet-cli config baiApiKey --network tron --account payer --api-key-stdin +wallet-cli bai recharge 1 --network base --token USDC --dry-run --output json +wallet-cli bai recharge 1 --network tron --token USDT --to recipient@example.com --dry-run +``` + +Base, BSC and TRON recharge routes remain supported. Omit `--to` to recharge your +own account; recipient recharge resolves the target before using the same preorder, +payment and transaction-report flow. The on-chain destination remains the platform +address, not the recipient's wallet. + +Dry-run checks binding, amount, recipient and the payment challenge without creating +an order, unlocking, signing, paying or reporting a transaction. It reads payer wallet +balances when RPC is available; these are not GasFree account balances. Final network +or relay fees may be unavailable and are explicitly reported as unestimated. For TRON, +`--scheme exact_gasfree` supports the relay and fee-limit options described above. + +If payment succeeded but reporting failed, retain the original transaction hash and +use `bai report-recharge` with the original chain and recipient information. Reconcile +an unknown payment outcome before proceeding; do not pay again to retry reporting. +`bai recharge-orders` caps requests above 100 at the backend's 100-row limit and returns +an effective limit plus a warning. `usage-records` retains its independent limit. + +### ERC-8004 registration metadata + +Register/update URI validation is separate from metadata loading: HTTPS, IPFS and +JSON data URIs remain supported for writing. Metadata reads accept only HTTP/HTTPS, +never follow redirects, and enforce a 1 MiB limit on compressed and expanded content, +a maximum JSON depth of 20, and the shorter of the global timeout and 10 seconds. +Unsupported or unavailable metadata produces a warning while preserving chain data. diff --git a/ts/src/adapters/outbound/x402/facilitator-network.test.ts b/ts/src/adapters/outbound/x402/facilitator-network.test.ts new file mode 100644 index 000000000..5c412d7cd --- /dev/null +++ b/ts/src/adapters/outbound/x402/facilitator-network.test.ts @@ -0,0 +1,60 @@ +import { expect, it, vi } from "vitest"; +import { facilitatorNetwork } from "./facilitator-network.js"; +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +const network = { + id: "tron:3448148188", + family: "tron", + chainId: "3448148188", +} as NetworkDescriptor; +it("selects by version, scheme and chain, not another advertised capability", async () => { + const fetcher = vi.fn(async () => + Response.json({ + kinds: [ + { x402Version: 1, scheme: "exact", network: network.id }, + { x402Version: 2, scheme: "exact", network: "tron:728126428" }, + { x402Version: 2, scheme: "exact_gasfree", network: network.id }, + { x402Version: 2, scheme: "exact", network: "tron:0xcd8690dc" }, + ], + }), + ); + expect( + await facilitatorNetwork(network, "exact", "https://facilitator.example", fetcher, 1000), + ).toBe("tron:0xcd8690dc"); + expect( + await facilitatorNetwork( + network, + "exact_gasfree", + "https://facilitator.example", + fetcher, + 1000, + ), + ).toBe(network.id); + expect(fetcher.mock.calls).toHaveLength(2); +}); +it.each([ + [() => Response.json({ kinds: [] }), "unsupported_network_capability"], + [() => Response.json({}), "invalid_x402_response"], + [() => new Response("invalid"), "invalid_x402_response"], + [() => new Response(null, { status: 404 }), "provider_error"], + [() => new Response(null, { status: 500 }), "provider_error"], + [() => new Response(null, { status: 429 }), "provider_rate_limited"], +] as const)("does not guess or retry on an unusable supported response", async (reply, code) => { + const fetcher = vi.fn(async () => reply()); + await expect( + facilitatorNetwork(network, "exact", "https://facilitator.example", fetcher, 1000), + ).rejects.toMatchObject({ code }); + expect(fetcher).toHaveBeenCalledOnce(); +}); +it("leaves EVM representation unchanged without a negotiation request", async () => { + const fetcher = vi.fn(); + expect( + await facilitatorNetwork( + { id: "eip155:8453", family: "evm" } as NetworkDescriptor, + "exact", + "https://facilitator.example", + fetcher, + 1000, + ), + ).toBe("eip155:8453"); + expect(fetcher).not.toHaveBeenCalled(); +}); diff --git a/ts/src/adapters/outbound/x402/facilitator-network.ts b/ts/src/adapters/outbound/x402/facilitator-network.ts new file mode 100644 index 000000000..82d2dbbb8 --- /dev/null +++ b/ts/src/adapters/outbound/x402/facilitator-network.ts @@ -0,0 +1,67 @@ +import type { NetworkDescriptor } from "../../../domain/types/index.js"; +import { sameX402Network } from "../../../domain/x402/network-id.js"; +import { TransportError, UsageError } from "../../../domain/errors/index.js"; +import { fetchBounded } from "../http/http-response.js"; +import { sdkPaymentError } from "./payment-error.js"; + +/** Negotiate TRON's wire representation before advertising or signing any requirement. */ +export async function facilitatorNetwork( + network: NetworkDescriptor, + scheme: string, + base: string, + fetcher: typeof fetch, + timeoutMs: number, +): Promise { + if (network.family !== "tron") return network.id; + let response: Response; + try { + response = await fetchBounded( + fetcher, + new URL("/supported", base), + { + method: "GET", + headers: { accept: "application/json" }, + redirect: "error", + }, + timeoutMs, + 1024 * 1024, + ); + } catch (error) { + throw sdkPaymentError(error, "challenge"); + } + if (!response.ok) + throw new TransportError( + response.status === 429 ? "provider_rate_limited" : "provider_error", + "Could not determine facilitator support; no payment was authorized", + { httpStatus: response.status, phase: "challenge", retryPayment: false }, + ); + let data: unknown; + try { + data = await response.json(); + } catch { + /* handled below */ + } + const kinds = data && typeof data === "object" ? (data as { kinds?: unknown }).kinds : undefined; + if (!Array.isArray(kinds)) + throw new TransportError( + "invalid_x402_response", + "Facilitator returned invalid supported capabilities; no payment was authorized", + ); + const candidates = kinds.filter( + (kind): kind is { network: string } => + kind && + typeof kind === "object" && + kind.x402Version === 2 && + kind.scheme === scheme && + typeof kind.network === "string" && + sameX402Network(kind.network, network.id), + ); + // Prefer canonical decimal if supported; otherwise use the facilitator's exact advertised ID. + const selected = candidates.find((kind) => kind.network === network.id) ?? candidates[0]; + if (!selected) + throw new UsageError( + "unsupported_network_capability", + "Facilitator does not support this network and payment scheme in x402 v2", + ); + return selected.network; +} diff --git a/ts/src/adapters/outbound/x402/legacy-facilitator.test.ts b/ts/src/adapters/outbound/x402/legacy-facilitator.test.ts new file mode 100644 index 000000000..8462a578b --- /dev/null +++ b/ts/src/adapters/outbound/x402/legacy-facilitator.test.ts @@ -0,0 +1,96 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { Wallet } from "ethers"; +import { TronWeb, providers } from "tronweb"; +import { X402Service } from "../../../application/use-cases/x402-service.js"; +import { tronSignStrategy } from "../chain/tron/signing-strategy.js"; +import { X402PaymentClient } from "./payment-client.js"; +import { X402HttpServer } from "./server.js"; +import type { TypedDataPayload } from "../../../domain/types/index.js"; + +afterEach(() => vi.restoreAllMocks()); + +it.each( + ["hex", "decimal", "both"].flatMap((capability) => + ["tron:0xcd8690dc", "tron:3448148188", "verify-failure", "settle-failure"].map( + (outcome) => [capability, outcome] as const, + ), + ), +)("roundtrip negotiates %s support (%s) without retrying payment", async (capability, outcome) => { + const networks = + capability === "hex" + ? ["tron:0xcd8690dc"] + : capability === "decimal" + ? ["tron:3448148188"] + : ["tron:0xcd8690dc", "tron:3448148188"]; + const expectedNetwork = capability === "hex" ? "tron:0xcd8690dc" : "tron:3448148188"; + 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}`); + }); + const key = Wallet.createRandom().privateKey; + const payer = TronWeb.address.fromPrivateKey(key.slice(2)) as string; + const signTypedData = vi.fn((payload: TypedDataPayload) => + tronSignStrategy.signTypedData(key, payload), + ); + const signers = { + assertCanSign: vi.fn(), + resolve: () => ({ kind: "software", address: payer, signTypedData }), + }; + const calls: string[] = []; + const payloads: unknown[] = []; + const facilitator: typeof fetch = async (url, init) => { + const path = new URL(String(url)).pathname; + calls.push(path); + if (path === "/supported") + return Response.json({ + kinds: networks.map((network) => ({ x402Version: 2, scheme: "exact", network })), + }); + const body = JSON.parse(String(init!.body)); + // Routing uses exactly the representation advertised for this scheme. + expect(body.paymentRequirements.network).toBe(expectedNetwork); + expect(body.paymentPayload.accepted).toEqual(body.paymentRequirements); + payloads.push(body.paymentPayload); + if (outcome === `${path.slice(1)}-failure`) return new Response(null, { status: 500 }); + return Response.json( + path === "/verify" + ? { isValid: true, payer } + : { success: true, transaction: "a".repeat(64), network: outcome, payer }, + ); + }; + const service = new X402Service( + new X402PaymentClient(signers as never), + {} as never, + new X402HttpServer(facilitator, 2000, () => {}), + ); + const pending = service.roundtrip( + { activeAccount: "payer", timeoutMs: 2000, emit: vi.fn(), warn: vi.fn() } as never, + { + id: "tron:3448148188", + family: "tron", + chainId: "3448148188", + httpEndpoint: "http://127.0.0.1:1", + } as never, + { + host: "127.0.0.1", + port: 0, + payTo: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + amount: "1", + token: "USDT", + scheme: "exact", + facilitatorUrl: "https://legacy.example", + }, + ); + if (outcome.endsWith("failure")) { + await expect(pending).rejects.toMatchObject({ details: { retryPayment: false } }); + } else { + const result = await pending; + expect(result.serve.network).toBe("tron:3448148188"); + expect(result.pay).toMatchObject({ settled: true, delivered: true }); + } + expect(signTypedData).toHaveBeenCalledOnce(); + expect(calls).toEqual( + outcome === "verify-failure" ? ["/supported", "/verify"] : ["/supported", "/verify", "/settle"], + ); + if (payloads.length === 2) expect(payloads[1]).toEqual(payloads[0]); +}); diff --git a/ts/src/adapters/outbound/x402/server.test.ts b/ts/src/adapters/outbound/x402/server.test.ts index 0bcbf3362..8e971ffd8 100644 --- a/ts/src/adapters/outbound/x402/server.test.ts +++ b/ts/src/adapters/outbound/x402/server.test.ts @@ -23,11 +23,13 @@ async function withServer( 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( - fetcher ?? - (async (url) => - Response.json(String(url).endsWith("/verify") ? { isValid: true } : settlement)), - ); + const server = new X402HttpServer(async (url, init) => { + if (String(url).endsWith("/supported")) + return Response.json({ kinds: [{ x402Version: 2, scheme, network: "tron:0xcd8690dc" }] }); + return fetcher + ? fetcher(url, init) + : Response.json(String(url).endsWith("/verify") ? { isValid: true } : settlement); + }); const handle = await server.start( { id: "tron:3448148188", family: "tron", chainId: "3448148188" } as NetworkDescriptor, { @@ -208,14 +210,14 @@ it("reports a port collision without stopping the first server", async () => { }); }); -it("advertises canonical TRON IDs and an empty GasFree extra", async () => { +it("advertises legacy-compatible TRON wire IDs and an empty GasFree extra", async () => { await withServer( {}, async (port) => { const response = await fetch(`http://127.0.0.1:${port}/.well-known/x402`); const body = (await response.json()) as { accepts: Array> }; expect(body.accepts[0]).toMatchObject({ - network: "tron:3448148188", + network: "tron:0xcd8690dc", scheme: "exact_gasfree", extra: {}, }); diff --git a/ts/src/adapters/outbound/x402/server.ts b/ts/src/adapters/outbound/x402/server.ts index 12f39d200..944c460a6 100644 --- a/ts/src/adapters/outbound/x402/server.ts +++ b/ts/src/adapters/outbound/x402/server.ts @@ -1,3 +1,4 @@ +import { facilitatorNetwork } from "./facilitator-network.js"; import { addressCodec } from "../../../domain/family/index.js"; import { X402_TOKENS } from "./tokens.js"; import { providerPaymentError, sdkPaymentError } from "./payment-error.js"; @@ -123,7 +124,13 @@ export class X402HttpServer implements X402ServerPort { async start(network: NetworkDescriptor, input: X402ServeInput): Promise { const { token, rawAmount } = this.requirement(network, input); - const x402Network = network.id; + const x402Network = await facilitatorNetwork( + network, + input.scheme, + input.facilitatorUrl, + this.fetcher, + this.timeoutMs, + ); const host = input.host.includes(":") ? `[${input.host}]` : input.host; let resourceUrl = `http://${host}:${input.port}/pay`; const requirement = { @@ -201,7 +208,7 @@ export class X402HttpServer implements X402ServerPort { response.setHeader("payment-response", encodePaymentResponseHeader(settle as never)); return json(response, 200, { success: true, - network: x402Network, + network: network.id, scheme: input.scheme, transaction: settle.transaction, }); diff --git a/ts/test/beta-server-roundtrip.test.ts b/ts/test/beta-server-roundtrip.test.ts index 62d2dba48..213bee2e8 100644 --- a/ts/test/beta-server-roundtrip.test.ts +++ b/ts/test/beta-server-roundtrip.test.ts @@ -22,9 +22,16 @@ it.skipIf(!entry).each(["SIGINT", "SIGTERM"] as const)( async (signal) => { const home = mkdtempSync(join(tmpdir(), "beta-serve-")); const p = await port(); + const preload = join(home, "supported.mjs"); + writeFileSync( + preload, + `globalThis.fetch = async () => Response.json({kinds:[{x402Version:2,scheme:'exact',network:'tron:0xcd8690dc'}]});`, + ); const child = spawn( process.execPath, [ + "--import", + pathToFileURL(preload).href, entry!, "x402", "serve", @@ -56,7 +63,7 @@ it.skipIf(!entry).each(["SIGINT", "SIGTERM"] as const)( 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:3448148188", + "tron:0xcd8690dc", ); } finally { const stopped = new Promise<{ code: number | null; signal: string | null }>((resolve) => @@ -89,6 +96,7 @@ import {appendFileSync} from 'node:fs';const realFetch=globalThis.fetch;globalTh 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'); + if(url.pathname==='/supported')return Response.json({kinds:[{x402Version:2,scheme:'exact',network:'tron:0xcd8690dc'}]}); 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})); From 7c2be07cffe48ceab5ced9e1bb7bd068e4c94534 Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Thu, 17 Sep 2026 10:49:45 +0800 Subject: [PATCH 4/5] fix(ts): validate BAI amounts early and verify GasFree warnings --- .../adapters/inbound/cli/commands/bai.test.ts | 14 +- ts/src/adapters/inbound/cli/commands/bai.ts | 34 ++++- .../bai-recharge-integration.test.ts | 33 ++++- .../use-cases/bai-recharge-report.test.ts | 2 +- .../application/use-cases/bai-service.test.ts | 47 ++++++- ts/src/application/use-cases/bai-service.ts | 22 +-- ts/src/domain/bai/recharge-policy.ts | 8 +- ts/test/beta-server-roundtrip.test.ts | 127 ++++++++++++++++++ 8 files changed, 256 insertions(+), 31 deletions(-) diff --git a/ts/src/adapters/inbound/cli/commands/bai.test.ts b/ts/src/adapters/inbound/cli/commands/bai.test.ts index 3e85a19f6..2fd2af85f 100644 --- a/ts/src/adapters/inbound/cli/commands/bai.test.ts +++ b/ts/src/adapters/inbound/cli/commands/bai.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { CommandRegistry } from "../registry/index.js"; -import { registerBaiCommands } from "./bai.js"; +import { baiRechargeSpec, registerBaiCommands } from "./bai.js"; import type { BaiService } from "../../../../application/use-cases/bai-service.js"; function service(): BaiService { @@ -114,3 +114,15 @@ it("shares recharge schema across families and selects the Base token in its bin for (const field of ["signOnly", "buildOnly"]) expect(command.spec.baseFields.shape).not.toHaveProperty(field); }); + +it.each(["0", "0.000", "-1", "1e3", "9007199254740992", "1." + "0".repeat(100)])( + "rejects invalid recharge amount %s at the command boundary", + (amount) => { + const result = baiRechargeSpec.baseFields.safeParse({ amount }); + expect(result.success).toBe(false); + if (!result.success) + expect(result.error.issues[0]).toMatchObject({ + params: { errorCode: "invalid_amount" }, + }); + }, +); diff --git a/ts/src/adapters/inbound/cli/commands/bai.ts b/ts/src/adapters/inbound/cli/commands/bai.ts index 542d89f18..9ce555e40 100644 --- a/ts/src/adapters/inbound/cli/commands/bai.ts +++ b/ts/src/adapters/inbound/cli/commands/bai.ts @@ -1,3 +1,7 @@ +import { + assertBaiRechargeMinimum, + baiRechargeAmount, +} from "../../../../domain/bai/recharge-policy.js"; import { integerLiteral, rawPaymentAmount, paymentAmount } from "../schemas/payment-values.js"; import { z } from "zod"; import type { CommandDefinition, ChainSpec, FamilyBinding } from "../contracts/index.js"; @@ -14,6 +18,18 @@ const listFields = z.object({ sort: z.enum(["asc", "desc"]).default("desc").describe("creation-time sort direction"), }); +const rechargeAmount = z.string().superRefine((value, ctx) => { + try { + baiRechargeAmount(value); + } catch { + ctx.addIssue({ + code: "custom", + message: "must be a positive recharge amount within the supported range", + params: { errorCode: "invalid_amount" }, + }); + } +}); + const rechargeFields = z.object({ dryRun: z .boolean() @@ -23,7 +39,7 @@ const rechargeFields = z.object({ gasfreeRelay: z.string().optional().describe("GasFree relay: official, gasfree, or HTTPS URL"), maxGasfreeFee: paymentAmount.optional().describe("maximum GasFree fee in whole tokens"), maxGasfreeFeeRaw: rawPaymentAmount.optional().describe("maximum GasFree fee in smallest units"), - amount: z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/, "must be a decimal amount"), + amount: rechargeAmount, token: z .string() .trim() @@ -66,6 +82,16 @@ export const baiRechargeSpec: ChainSpec = { export function baiRechargeBinding(service: BaiService): FamilyBinding { return { refine: (input, ctx) => { + try { + assertBaiRechargeMinimum(input.token ?? "USDT", baiRechargeAmount(input.amount)); + } catch (error) { + ctx.addIssue({ + code: "custom", + path: ["amount"], + message: error instanceof Error ? error.message : "Invalid recharge amount", + params: { errorCode: "invalid_amount" }, + }); + } if (input.maxGasfreeFee !== undefined && input.maxGasfreeFeeRaw !== undefined) ctx.addIssue({ code: "custom", @@ -97,11 +123,7 @@ export function registerBaiCommands(registry: CommandRegistry, service: BaiServi 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"), + amount: rechargeAmount.optional().describe("original recharge amount, when available"), to: z .string() .trim() 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 068acfa89..c7f41e182 100644 --- a/ts/src/application/use-cases/bai-recharge-integration.test.ts +++ b/ts/src/application/use-cases/bai-recharge-integration.test.ts @@ -234,10 +234,10 @@ it("reports account ambiguity before checking binding or contacting B.AI", async const service = new BaiService( {} as never, undefined, - undefined, + { validate: vi.fn(), roundtrip: vi.fn() }, { isConfirmed } as never, remote as never, - undefined, + { facilitatorUrl: "https://facilitator.example", payTo: { bnb: "destination" } }, undefined, undefined, { @@ -299,3 +299,32 @@ it("checks relay configuration before creating any preorder", async () => { ).rejects.toMatchObject({ code: "gasfree_credentials_missing" }); expect(api.createOrder).not.toHaveBeenCalled(); }); + +it("rejects token precision before account and binding checks", async () => { + const isConfirmed = vi.fn(); + const resolveAddress = vi.fn(); + const resolveAccount = vi.fn(); + const createOrder = vi.fn(); + const roundtrip = vi.fn(); + const server = new X402HttpServer(); + const service = new BaiService( + {} as never, + undefined, + { validate: (net, input) => server.validate(net, input), roundtrip }, + { isConfirmed } as never, + { createOrder } as never, + { facilitatorUrl: "https://facilitator.example", payTo: { bnb: payer } }, + undefined, + undefined, + { resolveAccount }, + ); + await expect( + service.recharge({ resolveAddress } as never, network as never, { + amount: "1.0000000000000000001", + token: "USDT", + apiKey: "test-key", + }), + ).rejects.toMatchObject({ code: "invalid_amount" }); + for (const operation of [isConfirmed, resolveAddress, resolveAccount, createOrder, roundtrip]) + expect(operation).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 index e57929e4c..38c4e8f71 100644 --- a/ts/src/application/use-cases/bai-recharge-report.test.ts +++ b/ts/src/application/use-cases/bai-recharge-report.test.ts @@ -48,7 +48,7 @@ it.each([ ])("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", + code: "amount" in override ? "invalid_amount" : "invalid_value", }); expect(api.reportTxHash).not.toHaveBeenCalled(); }); diff --git a/ts/src/application/use-cases/bai-service.test.ts b/ts/src/application/use-cases/bai-service.test.ts index e09be6955..1b1f8b516 100644 --- a/ts/src/application/use-cases/bai-service.test.ts +++ b/ts/src/application/use-cases/bai-service.test.ts @@ -39,9 +39,19 @@ 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(), { validate: vi.fn(), roundtrip: pay }, { - isConfirmed, - } as unknown as BaiBindingStore); + const service = new BaiService( + api(), + () => new Date(), + { validate: vi.fn(), roundtrip: pay }, + { + isConfirmed, + } as unknown as BaiBindingStore, + {} as never, + { + facilitatorUrl: "https://facilitator.example", + payTo: { bnb: "destination", tron: "destination" }, + }, + ); await expect( service.recharge( { resolveAddress: () => "payer" } as never, @@ -57,9 +67,19 @@ 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(), { validate: vi.fn(), roundtrip: pay }, { - isConfirmed, - } as unknown as BaiBindingStore); + const service = new BaiService( + api(), + () => new Date(), + { validate: vi.fn(), roundtrip: pay }, + { + isConfirmed, + } as unknown as BaiBindingStore, + {} as never, + { + facilitatorUrl: "https://facilitator.example", + payTo: { bnb: "destination", tron: "destination" }, + }, + ); await expect( service.recharge( { resolveAddress: () => "payer" } as never, @@ -111,3 +131,18 @@ it.each([100, 101, 200])( ).toBe(true); }, ); + +it.each(["0", "0.000", "-1", "1e3", "9007199254740992", "0.5"])( + "rejects invalid USDT amount %s before credentials or wallet resolution", + async (amount) => { + const resolveAddress = vi.fn(); + await expect( + new BaiService(api()).recharge( + { resolveAddress } as never, + { id: "eip155:56", family: "evm", chainId: "56" } as never, + { amount, token: "USDT" }, + ), + ).rejects.toMatchObject({ code: "invalid_amount" }); + expect(resolveAddress).not.toHaveBeenCalled(); + }, +); diff --git a/ts/src/application/use-cases/bai-service.ts b/ts/src/application/use-cases/bai-service.ts index 814a818d1..c57b51632 100644 --- a/ts/src/application/use-cases/bai-service.ts +++ b/ts/src/application/use-cases/bai-service.ts @@ -52,6 +52,8 @@ export class BaiService { maxGasfreeFeeRaw?: string; }, ) { + const amount = baiRechargeAmount(input.amount); + assertBaiRechargeMinimum(input.token, amount); if (!input.apiKey) { throw new UsageError( "bai_credentials_missing", @@ -61,15 +63,6 @@ export class BaiService { if (!this.bindings) throw new UsageError("invalid_option", "B.AI recharge binding verification is unavailable"); const chain = requireBaiChain(network); - // Resolve the signing account before binding checks, including ambiguous address selectors. - this.accounts?.resolveAccount(scope.activeAccount, network.family); - const payer = scope.resolveAddress(network.family); - if (!this.bindings.isConfirmed(input.apiKey, chain, payer)) { - throw new UsageError( - "invalid_value", - "Confirm this API key and payer wallet first by configuring baiApiKey with --api-key-stdin for the selected account/network. No payment was sent", - ); - } if (!this.payments || !this.rechargeApi || !this.rechargeConfig) { throw new UsageError("invalid_option", "B.AI recharge is not available in this runtime"); } @@ -80,8 +73,6 @@ export class BaiService { "No trusted B.AI recharge destination for this network", ); } - const amount = baiRechargeAmount(input.amount); - assertBaiRechargeMinimum(input.token, input.amount); const paymentInput: X402ServeInput = { payTo: expectedPayTo, amount: input.amount, @@ -96,6 +87,15 @@ export class BaiService { facilitatorUrl: this.rechargeConfig.facilitatorUrl, }; this.payments.validate(network, paymentInput); + // Resolve the signing account before binding checks, including ambiguous address selectors. + this.accounts?.resolveAccount(scope.activeAccount, network.family); + 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", + ); + } const identifier = input.to?.trim(); const self = !identifier || diff --git a/ts/src/domain/bai/recharge-policy.ts b/ts/src/domain/bai/recharge-policy.ts index d18ed2ea9..81ed93262 100644 --- a/ts/src/domain/bai/recharge-policy.ts +++ b/ts/src/domain/bai/recharge-policy.ts @@ -20,7 +20,7 @@ export function assertBaiRechargeMinimum(token: string, amount: string): void { BigInt(minimumWhole + minimumFraction.padEnd(scale, "0")) ) { throw new UsageError( - "invalid_value", + "invalid_amount", `${token.toUpperCase()} minimum recharge is ${minimum}; no preorder or payment was sent`, ); } @@ -28,8 +28,8 @@ export function assertBaiRechargeMinimum(token: string, amount: string): void { /** Validate decimal quantities without converting payment amounts to floating point. */ export function baiRechargeAmount(value: string): string { - if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) - throw new UsageError("invalid_value", "Recharge amount must be a positive decimal string"); + if (value.length > 100 || !/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) + throw new UsageError("invalid_amount", "Recharge amount must be a positive decimal string"); const normalized = value.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ""); const [whole = "0", fraction = ""] = normalized.split("."); if ( @@ -37,6 +37,6 @@ export function baiRechargeAmount(value: string): string { BigInt(whole) > 9007199254740991n || (whole === "9007199254740991" && fraction !== "") ) - throw new UsageError("invalid_value", "Recharge amount exceeds the supported positive range"); + throw new UsageError("invalid_amount", "Recharge amount exceeds the supported positive range"); return normalized; } diff --git a/ts/test/beta-server-roundtrip.test.ts b/ts/test/beta-server-roundtrip.test.ts index 213bee2e8..83cabfa70 100644 --- a/ts/test/beta-server-roundtrip.test.ts +++ b/ts/test/beta-server-roundtrip.test.ts @@ -139,3 +139,130 @@ import {appendFileSync} from 'node:fs';const realFetch=globalThis.fetch;globalTh }, 30000, ); + +it.skipIf(!entry).each(["pay", "roundtrip"])( + "installed x402 %s returns the real SDK GasFree fee warning", + async (command) => { + const home = mkdtempSync(join(tmpdir(), "beta-gasfree-warning-")); + 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, "gasfree.mjs"); + writeFileSync( + preload, + `${tronAllowancePreload} +import {appendFileSync} from 'node:fs'; +const realFetch = globalThis.fetch; +const network = 'tron:0xcd8690dc'; +const payTo = 'TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ'; +const requirement = {scheme:'exact_gasfree',network,amount:'10000',asset:'TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf',payTo,maxTimeoutSeconds:300,extra:{}}; +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.pathname.includes('/api/v1/address/')) return Response.json({code:200,data:{gasFreeAddress:payTo,active:true,nonce:0,assets:[{tokenAddress:requirement.asset,transferFee:'1300000'}]}}); + if(url.pathname.endsWith('/api/v1/config/provider/all')) return Response.json({code:200,data:{providers:[{address:payTo}]}}); + if(url.hostname==='paywall.example') { + if(!new Headers(input instanceof Request ? input.headers : init?.headers).get('PAYMENT-SIGNATURE')) return Response.json({x402Version:2,resource:{url:url.href},accepts:[requirement]}, {status:402,headers:{'PAYMENT-REQUIRED':Buffer.from(JSON.stringify({x402Version:2,resource:{url:url.href},accepts:[requirement]})).toString('base64')}}); + const payment=JSON.parse(Buffer.from(new Headers(input instanceof Request ? input.headers : init?.headers).get('PAYMENT-SIGNATURE'),'base64').toString()); + if(!payment.payload.signature || payment.payload.gasfree.maxFee!=='1300000') throw new Error('missing SDK fee/signature'); + appendFileSync(${JSON.stringify(log)},'paid\\n'); + return Response.json({ok:true},{headers:{'PAYMENT-RESPONSE':Buffer.from(JSON.stringify({success:true,transaction:'a'.repeat(64),network})).toString('base64')}}); + } + if(url.origin==='https://facilitator.bankofai.io') { + if(url.pathname==='/supported')return Response.json({kinds:[{x402Version:2,scheme:'exact_gasfree',network}]}); + const data=JSON.parse(init.body); const payload=data.paymentPayload.payload; + if(!payload.signature || payload.gasfree.maxFee!=='1300000' || payload.gasfree.value!=='10000')throw new Error('missing SDK fee/signature'); + appendFileSync(${JSON.stringify(log)},url.pathname+'\\n'); + if(url.pathname==='/verify')return Response.json({isValid:true}); + if(url.pathname==='/settle')return Response.json({success:true,transaction:'a'.repeat(64),network}); + } + throw new Error('unexpected network request'); +};`, + ); + const args = + command === "pay" + ? ["https://paywall.example/pay", "--max-amount", "0.01"] + : [ + "--pay-to", + "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + "--amount", + "0.01", + "--port", + String(await port()), + ]; + const result = spawnSync( + process.execPath, + [ + "--import", + pathToFileURL(preload).href, + entry!, + "x402", + command, + ...args, + "--network", + "nile", + "--token", + "USDT", + "--scheme", + "exact_gasfree", + "--account", + "payer", + "--password-stdin", + "-o", + "json", + ], + { + 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 envelope = JSON.parse(result.stdout); + expect(envelope.success).toBe(true); + expect(envelope.meta.warnings).toEqual( + expect.arrayContaining([expect.stringContaining("13000.00%")]), + ); + expect(command === "pay" ? envelope.data : envelope.data.pay).toMatchObject({ + settled: true, + delivered: true, + }); + expect(readFileSync(log, "utf8").trim().split("\n")).toEqual( + command === "pay" ? ["paid"] : ["/verify", "/settle"], + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, + 30000, +); + +it.skipIf(!entry).each(["0", "0.000", "0.5", "9007199254740992"])( + "installed bai rejects amount %s before requesting credentials", + (amount) => { + const home = mkdtempSync(join(tmpdir(), "beta-bai-invalid-")); + try { + const result = spawnSync( + process.execPath, + [entry!, "bai", "recharge", amount, "--network", "bsc", "--token", "USDT", "-o", "json"], + { + env: { ...process.env, WALLET_CLI_HOME: home }, + encoding: "utf8", + timeout: 10000, + }, + ); + expect(result.status, result.stdout + result.stderr).toBe(2); + expect(JSON.parse(result.stdout)).toMatchObject({ + success: false, + error: { code: "invalid_amount" }, + }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, +); From cd8986a654fcb1091f8be37343df7de8d0d15671 Mon Sep 17 00:00:00 2001 From: boboliu-1010 Date: Thu, 17 Sep 2026 11:24:51 +0800 Subject: [PATCH 5/5] fix(ts): preserve facilitator paths and adapt Nile GasFree USDD --- .../outbound/x402/facilitator-network.test.ts | 13 ++++ .../outbound/x402/facilitator-network.ts | 3 +- .../adapters/outbound/x402/facilitator-url.ts | 4 ++ .../outbound/x402/legacy-facilitator.test.ts | 6 +- .../adapters/outbound/x402/payment-client.ts | 30 ++++++--- .../outbound/x402/payment-error.test.ts | 14 ++++ .../adapters/outbound/x402/payment-error.ts | 12 ++++ ts/src/adapters/outbound/x402/server.ts | 7 +- ts/src/adapters/outbound/x402/tokens.ts | 19 ++++++ ts/src/domain/errors/codes.ts | 1 + ts/test/beta-server-roundtrip.test.ts | 67 +++++++++++++++---- 11 files changed, 147 insertions(+), 29 deletions(-) create mode 100644 ts/src/adapters/outbound/x402/facilitator-url.ts diff --git a/ts/src/adapters/outbound/x402/facilitator-network.test.ts b/ts/src/adapters/outbound/x402/facilitator-network.test.ts index 5c412d7cd..57b54a0e8 100644 --- a/ts/src/adapters/outbound/x402/facilitator-network.test.ts +++ b/ts/src/adapters/outbound/x402/facilitator-network.test.ts @@ -58,3 +58,16 @@ it("leaves EVM representation unchanged without a negotiation request", async () ).toBe("eip155:8453"); expect(fetcher).not.toHaveBeenCalled(); }); + +it.each([ + "https://host.example", + "https://host.example/", + "https://host.example/x402", + "https://host.example/x402/", +])("preserves supported endpoint prefix for %s", async (base) => { + const fetcher = vi.fn(async (_url: unknown, _init?: unknown) => + Response.json({ kinds: [{ x402Version: 2, scheme: "exact", network: network.id }] }), + ); + await facilitatorNetwork(network, "exact", base, fetcher, 1000); + expect(String(fetcher.mock.calls[0]![0])).toBe(base.replace(/\/+$/, "") + "/supported"); +}); diff --git a/ts/src/adapters/outbound/x402/facilitator-network.ts b/ts/src/adapters/outbound/x402/facilitator-network.ts index 82d2dbbb8..692dd1c55 100644 --- a/ts/src/adapters/outbound/x402/facilitator-network.ts +++ b/ts/src/adapters/outbound/x402/facilitator-network.ts @@ -1,3 +1,4 @@ +import { facilitatorUrl } from "./facilitator-url.js"; import type { NetworkDescriptor } from "../../../domain/types/index.js"; import { sameX402Network } from "../../../domain/x402/network-id.js"; import { TransportError, UsageError } from "../../../domain/errors/index.js"; @@ -17,7 +18,7 @@ export async function facilitatorNetwork( try { response = await fetchBounded( fetcher, - new URL("/supported", base), + facilitatorUrl(base, "supported"), { method: "GET", headers: { accept: "application/json" }, diff --git a/ts/src/adapters/outbound/x402/facilitator-url.ts b/ts/src/adapters/outbound/x402/facilitator-url.ts new file mode 100644 index 000000000..d818a47fd --- /dev/null +++ b/ts/src/adapters/outbound/x402/facilitator-url.ts @@ -0,0 +1,4 @@ +/** Append an endpoint without discarding a facilitator deployment's path prefix. */ +export function facilitatorUrl(base: string, path: string): URL { + return new URL(path.replace(/^\/+/, ""), `${base.replace(/\/+$/, "")}/`); +} diff --git a/ts/src/adapters/outbound/x402/legacy-facilitator.test.ts b/ts/src/adapters/outbound/x402/legacy-facilitator.test.ts index 8462a578b..95f43e371 100644 --- a/ts/src/adapters/outbound/x402/legacy-facilitator.test.ts +++ b/ts/src/adapters/outbound/x402/legacy-facilitator.test.ts @@ -40,7 +40,9 @@ it.each( const calls: string[] = []; const payloads: unknown[] = []; const facilitator: typeof fetch = async (url, init) => { - const path = new URL(String(url)).pathname; + const fullPath = new URL(String(url)).pathname; + expect(fullPath.startsWith("/x402/")).toBe(true); + const path = fullPath.slice("/x402".length); calls.push(path); if (path === "/supported") return Response.json({ @@ -78,7 +80,7 @@ it.each( amount: "1", token: "USDT", scheme: "exact", - facilitatorUrl: "https://legacy.example", + facilitatorUrl: "https://legacy.example/x402", }, ); if (outcome.endsWith("failure")) { diff --git a/ts/src/adapters/outbound/x402/payment-client.ts b/ts/src/adapters/outbound/x402/payment-client.ts index 7f4154328..cd262d273 100644 --- a/ts/src/adapters/outbound/x402/payment-client.ts +++ b/ts/src/adapters/outbound/x402/payment-client.ts @@ -1,6 +1,6 @@ import { gasfreeRelayClient } from "./gasfree-relay.js"; import type { Config } from "../../../domain/types/index.js"; -import { X402_TOKENS } from "./tokens.js"; +import { X402_TOKENS, GASFREE_TOKENS, tokensForScheme } from "./tokens.js"; import { sdkPaymentError, providerPaymentError, type PaymentPhase } from "./payment-error.js"; import { successfulSettlement } from "./settlement.js"; import { boundedResponse, fetchBounded, MAX_HTTP_RESPONSE_BYTES } from "../http/http-response.js"; @@ -10,7 +10,7 @@ import { decodePaymentResponseHeader, } from "@bankofai/x402-fetch"; import { registerExactEvmScheme } from "@bankofai/x402-evm/exact/client"; -import { createClientTronSigner, type ClientTronSigner } from "@bankofai/x402-tron"; +import { createClientTronSigner, registerToken, 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"; @@ -299,6 +299,10 @@ export class X402PaymentClient implements X402PaymentPort { schemeOptions: network.httpEndpoint ? { rpcUrl: network.httpEndpoint } : undefined, }); } else { + // SDK metadata is keyed by symbol: use an internal alias to retain exact's USDD entry. + for (const [symbol, token] of Object.entries(GASFREE_TOKENS[network.id] ?? {})) { + registerToken(network.id as Network, { ...token, symbol: `${symbol}_GASFREE` }); + } const tronSigner = await createClientTronSigner(bridge, { network: x402Network, ...(network.httpEndpoint ? { rpcUrl: network.httpEndpoint } : {}), @@ -378,16 +382,21 @@ async function writeOutput(path: string, bytes: Uint8Array): Promise { function metadata(network: string, asset: string) { const tokens = X402_TOKENS[network] ?? {}; - const entry = Object.entries(tokens).find(([, token]) => - network.startsWith("eip155:") - ? token.address.toLowerCase() === asset.toLowerCase() - : token.address === asset, + const entry = [...Object.entries(tokens), ...Object.entries(GASFREE_TOKENS[network] ?? {})].find( + ([, token]) => + network.startsWith("eip155:") + ? token.address.toLowerCase() === asset.toLowerCase() + : token.address === asset, ); return entry ? { ...entry[1], symbol: entry[0] } : undefined; } -function tokenSymbol(network: string, asset: string): string | undefined { - return metadata(network, asset)?.symbol; +function tokenSymbol(network: string, asset: string, scheme: string): string | undefined { + return Object.entries(tokensForScheme(network, scheme)).find(([, token]) => + network.startsWith("eip155:") + ? token.address.toLowerCase() === asset.toLowerCase() + : token.address === asset, + )?.[0]; } function paymentDecimals(network: string, asset: string, explicit?: number): number { @@ -457,7 +466,10 @@ function selectMatching( 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()) + if ( + input.token && + tokenSymbol(network.id, requirement.asset, requirement.scheme) !== input.token.toUpperCase() + ) return false; if (input.expectedPayTo) { if (typeof requirement.payTo !== "string") return false; diff --git a/ts/src/adapters/outbound/x402/payment-error.test.ts b/ts/src/adapters/outbound/x402/payment-error.test.ts index 4fa253599..089181738 100644 --- a/ts/src/adapters/outbound/x402/payment-error.test.ts +++ b/ts/src/adapters/outbound/x402/payment-error.test.ts @@ -93,3 +93,17 @@ it("retains only numeric Retry-After hints", () => { ); expect(JSON.stringify(unsafe.toEnvelope())).not.toContain("secret"); }); + +it("classifies a missing GasFree asset before payment creation without exposing SDK text", () => { + const error = sdkPaymentError( + new Error( + "Asset TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK not found in GasFree account TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ.", + ), + "create_payment", + ); + expect(error).toMatchObject({ + code: "gasfree_asset_unsupported", + details: { paymentStatus: "not_sent", retryPayment: false }, + }); + expect(error.message).not.toContain("TGjgvd"); +}); diff --git a/ts/src/adapters/outbound/x402/payment-error.ts b/ts/src/adapters/outbound/x402/payment-error.ts index 263e293b9..dadb4bb64 100644 --- a/ts/src/adapters/outbound/x402/payment-error.ts +++ b/ts/src/adapters/outbound/x402/payment-error.ts @@ -11,6 +11,11 @@ const reasons: Record TransportError> = { "gasfree_insufficient_balance", "GasFree wallet balance cannot cover the payment and maximum fee", ), + gasfree_asset_unsupported: () => + new TransportError( + "gasfree_asset_unsupported", + "The selected asset is unavailable in the GasFree account; check the network, token contract and relay configuration", + ), gasfree_not_activated: () => new TransportError("gasfree_not_activated", "GasFree account is not activated"), permit2_allowance_required: () => @@ -150,6 +155,13 @@ export function sdkPaymentError(error: unknown, phase?: PaymentPhase): CliError let reason: string | undefined; if (/^Insufficient balance in GasFree wallet /.test(cause)) { reason = "gasfree_insufficient_balance"; + } else if ( + phase === "create_payment" && + /^Asset T[1-9A-HJ-NP-Za-km-z]{33} not found in GasFree account T[1-9A-HJ-NP-Za-km-z]{33}\.$/.test( + cause, + ) + ) { + reason = "gasfree_asset_unsupported"; } else if (/^GasFree account for .* is not activated\.$/.test(cause)) { reason = "gasfree_not_activated"; } else { diff --git a/ts/src/adapters/outbound/x402/server.ts b/ts/src/adapters/outbound/x402/server.ts index 944c460a6..6eec6a696 100644 --- a/ts/src/adapters/outbound/x402/server.ts +++ b/ts/src/adapters/outbound/x402/server.ts @@ -1,6 +1,7 @@ +import { facilitatorUrl } from "./facilitator-url.js"; import { facilitatorNetwork } from "./facilitator-network.js"; import { addressCodec } from "../../../domain/family/index.js"; -import { X402_TOKENS } from "./tokens.js"; +import { tokensForScheme } from "./tokens.js"; import { providerPaymentError, sdkPaymentError } from "./payment-error.js"; import { successfulSettlement } from "./settlement.js"; import { fetchBounded } from "../http/http-response.js"; @@ -63,7 +64,7 @@ export class X402HttpServer implements X402ServerPort { "GasFree relay must be HTTPS without credentials, query or fragment", ); } - const registered = X402_TOKENS[network.id] ?? {}; + const registered = tokensForScheme(network.id, input.scheme); if (input.amount !== undefined && input.rawAmount !== undefined) throw new UsageError("invalid_option", "amount and raw amount are mutually exclusive"); if (input.token !== undefined && input.asset !== undefined) @@ -249,7 +250,7 @@ export class X402HttpServer implements X402ServerPort { ): Promise> { const response = await fetchBounded( this.fetcher, - new URL(path, `${base.replace(/\/+$/, "")}/`), + facilitatorUrl(base, path), { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, diff --git a/ts/src/adapters/outbound/x402/tokens.ts b/ts/src/adapters/outbound/x402/tokens.ts index 1be1c32f2..065106406 100644 --- a/ts/src/adapters/outbound/x402/tokens.ts +++ b/ts/src/adapters/outbound/x402/tokens.ts @@ -88,3 +88,22 @@ export const X402_TOKENS: Record> = { }, }, }; + +// Nile's GasFree relay uses a different USDD deployment from the Permit2 exact route. +export const GASFREE_TOKENS: Record> = { + "tron:3448148188": { + USDD: { + address: "TYQF9cAeJ3Faq8QXpHxTcFco72DRCQbgFt", + decimals: 18, + name: "Decentralized USD", + version: "1", + }, + }, +}; + +export function tokensForScheme(network: string, scheme: string): Record { + return { + ...X402_TOKENS[network], + ...(scheme === "exact_gasfree" ? GASFREE_TOKENS[network] : {}), + }; +} diff --git a/ts/src/domain/errors/codes.ts b/ts/src/domain/errors/codes.ts index a16631411..c3a1ac01e 100644 --- a/ts/src/domain/errors/codes.ts +++ b/ts/src/domain/errors/codes.ts @@ -136,6 +136,7 @@ export const ERROR_CODES = { 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_asset_unsupported: { exit: 1, retry: "changed", meaning: "the selected token contract is unavailable in the GasFree account; check the asset and relay" }, 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" }, diff --git a/ts/test/beta-server-roundtrip.test.ts b/ts/test/beta-server-roundtrip.test.ts index 83cabfa70..fa8f72f61 100644 --- a/ts/test/beta-server-roundtrip.test.ts +++ b/ts/test/beta-server-roundtrip.test.ts @@ -3,7 +3,7 @@ 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 { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -76,9 +76,9 @@ it.skipIf(!entry).each(["SIGINT", "SIGTERM"] as const)( }, 20000, ); -it.skipIf(!entry)( - "installed x402 roundtrip signs and uses mocked facilitator settlement", - async () => { +it.skipIf(!entry).each(["USDT", "USDD"])( + "installed x402 exact roundtrip signs %s and uses mocked facilitator settlement", + async (token) => { const home = mkdtempSync(join(tmpdir(), "beta-roundtrip-")); const p = await port(); try { @@ -97,7 +97,9 @@ import {appendFileSync} from 'node:fs';const realFetch=globalThis.fetch;globalTh if(url.hostname==='127.0.0.1')return realFetch(input,init); if(url.origin!=='https://facilitator.bankofai.io')throw new Error('unexpected network'); if(url.pathname==='/supported')return Response.json({kinds:[{x402Version:2,scheme:'exact',network:'tron:0xcd8690dc'}]}); - const data=JSON.parse(init.body);if(!data.paymentPayload.payload.signature)throw new Error('missing signature'); + const data=JSON.parse(init.body); + if(data.paymentRequirements.asset!==${JSON.stringify(token === "USDD" ? "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK" : "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf")})throw new Error('wrong exact asset'); + 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'})); @@ -117,6 +119,8 @@ import {appendFileSync} from 'node:fs';const realFetch=globalThis.fetch;globalTh "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", "--port", String(p), + "--token", + token, "--account", "payer", "--password-stdin", @@ -140,9 +144,30 @@ import {appendFileSync} from 'node:fs';const realFetch=globalThis.fetch;globalTh 30000, ); -it.skipIf(!entry).each(["pay", "roundtrip"])( - "installed x402 %s returns the real SDK GasFree fee warning", - async (command) => { +it + .skipIf(!entry) + .each( + ["pay", "roundtrip"].flatMap((command) => + ["USDT", "USDD", "USDD-fallback", "USDD-missing"].map( + (scenario) => [command, scenario] as const, + ), + ), + )( + "installed x402 %s handles GasFree %s using the real SDK", + async (command, scenario) => { + const token = scenario.startsWith("USDD") ? "USDD" : "USDT"; + const asset = + token === "USDD" + ? "TYQF9cAeJ3Faq8QXpHxTcFco72DRCQbgFt" + : "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"; + const raw = token === "USDD" ? "10000000000000000" : "10000"; + const fee = + scenario === "USDD-fallback" + ? "1000000000000000000" + : token === "USDD" + ? "1300000000000000000" + : "1300000"; + const missing = scenario === "USDD-missing"; const home = mkdtempSync(join(tmpdir(), "beta-gasfree-warning-")); try { new Keystore(home, new AtomicFileStore(), () => "test-password").import({ @@ -159,23 +184,23 @@ import {appendFileSync} from 'node:fs'; const realFetch = globalThis.fetch; const network = 'tron:0xcd8690dc'; const payTo = 'TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ'; -const requirement = {scheme:'exact_gasfree',network,amount:'10000',asset:'TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf',payTo,maxTimeoutSeconds:300,extra:{}}; +const requirement = {scheme:'exact_gasfree',network,amount:${JSON.stringify(raw)},asset:${JSON.stringify(asset)},payTo,maxTimeoutSeconds:300,extra:{}}; 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.pathname.includes('/api/v1/address/')) return Response.json({code:200,data:{gasFreeAddress:payTo,active:true,nonce:0,assets:[{tokenAddress:requirement.asset,transferFee:'1300000'}]}}); + if(url.pathname.includes('/api/v1/address/')) return Response.json({code:200,data:{gasFreeAddress:payTo,active:true,nonce:0,assets:${missing ? "[]" : JSON.stringify([{ tokenAddress: asset, transferFee: scenario === "USDD-fallback" ? "0" : fee }])}}}); if(url.pathname.endsWith('/api/v1/config/provider/all')) return Response.json({code:200,data:{providers:[{address:payTo}]}}); if(url.hostname==='paywall.example') { if(!new Headers(input instanceof Request ? input.headers : init?.headers).get('PAYMENT-SIGNATURE')) return Response.json({x402Version:2,resource:{url:url.href},accepts:[requirement]}, {status:402,headers:{'PAYMENT-REQUIRED':Buffer.from(JSON.stringify({x402Version:2,resource:{url:url.href},accepts:[requirement]})).toString('base64')}}); const payment=JSON.parse(Buffer.from(new Headers(input instanceof Request ? input.headers : init?.headers).get('PAYMENT-SIGNATURE'),'base64').toString()); - if(!payment.payload.signature || payment.payload.gasfree.maxFee!=='1300000') throw new Error('missing SDK fee/signature'); + if(!payment.payload.signature || payment.payload.gasfree.maxFee!==${JSON.stringify(fee)}) throw new Error('missing SDK fee/signature'); appendFileSync(${JSON.stringify(log)},'paid\\n'); return Response.json({ok:true},{headers:{'PAYMENT-RESPONSE':Buffer.from(JSON.stringify({success:true,transaction:'a'.repeat(64),network})).toString('base64')}}); } if(url.origin==='https://facilitator.bankofai.io') { if(url.pathname==='/supported')return Response.json({kinds:[{x402Version:2,scheme:'exact_gasfree',network}]}); const data=JSON.parse(init.body); const payload=data.paymentPayload.payload; - if(!payload.signature || payload.gasfree.maxFee!=='1300000' || payload.gasfree.value!=='10000')throw new Error('missing SDK fee/signature'); + if(!payload.signature || payload.gasfree.maxFee!==${JSON.stringify(fee)} || payload.gasfree.value!==${JSON.stringify(raw)})throw new Error('missing SDK fee/signature'); appendFileSync(${JSON.stringify(log)},url.pathname+'\\n'); if(url.pathname==='/verify')return Response.json({isValid:true}); if(url.pathname==='/settle')return Response.json({success:true,transaction:'a'.repeat(64),network}); @@ -206,7 +231,7 @@ globalThis.fetch = async(input,init) => { "--network", "nile", "--token", - "USDT", + token, "--scheme", "exact_gasfree", "--account", @@ -222,11 +247,25 @@ globalThis.fetch = async(input,init) => { timeout: 20000, }, ); + if (missing) { + expect(result.status, result.stdout + result.stderr).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + success: false, + error: { + code: "gasfree_asset_unsupported", + details: { paymentStatus: "not_sent", retryPayment: false }, + }, + }); + expect(existsSync(log)).toBe(false); + return; + } expect(result.status, result.stdout + result.stderr).toBe(0); const envelope = JSON.parse(result.stdout); expect(envelope.success).toBe(true); expect(envelope.meta.warnings).toEqual( - expect.arrayContaining([expect.stringContaining("13000.00%")]), + expect.arrayContaining([ + expect.stringContaining(scenario === "USDD-fallback" ? "10000.00%" : "13000.00%"), + ]), ); expect(command === "pay" ? envelope.data : envelope.data.pay).toMatchObject({ settled: true,