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/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..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 { @@ -111,6 +111,18 @@ 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); }); + +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 42218f4a2..9ce555e40 100644 --- a/ts/src/adapters/inbound/cli/commands/bai.ts +++ b/ts/src/adapters/inbound/cli/commands/bai.ts @@ -1,4 +1,8 @@ -import { integerLiteral } from "../schemas/payment-values.js"; +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"; import type { CommandRegistry } from "../registry/index.js"; @@ -14,8 +18,28 @@ 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({ - amount: z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/, "must be a decimal amount"), + 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: rechargeAmount, token: z .string() .trim() @@ -42,7 +66,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 +81,24 @@ 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", + 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 +106,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, }); }, }; @@ -76,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() @@ -177,6 +220,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/facilitator-network.test.ts b/ts/src/adapters/outbound/x402/facilitator-network.test.ts new file mode 100644 index 000000000..57b54a0e8 --- /dev/null +++ b/ts/src/adapters/outbound/x402/facilitator-network.test.ts @@ -0,0 +1,73 @@ +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(); +}); + +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 new file mode 100644 index 000000000..692dd1c55 --- /dev/null +++ b/ts/src/adapters/outbound/x402/facilitator-network.ts @@ -0,0 +1,68 @@ +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"; +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, + facilitatorUrl(base, "supported"), + { + 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/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 new file mode 100644 index 000000000..95f43e371 --- /dev/null +++ b/ts/src/adapters/outbound/x402/legacy-facilitator.test.ts @@ -0,0 +1,98 @@ +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 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({ + 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/x402", + }, + ); + 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/payment-client.ts b/ts/src/adapters/outbound/x402/payment-client.ts index d3908d8db..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"; @@ -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); @@ -292,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 } : {}), @@ -371,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 { @@ -450,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 3c4f6d58a..089181738 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,40 @@ 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"); +}); + +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 fefe464f4..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: () => @@ -36,7 +41,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 +67,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 +88,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 +125,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, @@ -142,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 { @@ -196,3 +216,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..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: {}, }); @@ -225,3 +227,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..6eec6a696 100644 --- a/ts/src/adapters/outbound/x402/server.ts +++ b/ts/src/adapters/outbound/x402/server.ts @@ -1,5 +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"; @@ -21,6 +23,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,17 +36,102 @@ 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 = 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) + 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 }; } 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 = { @@ -50,7 +140,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 +151,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; @@ -104,7 +209,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, }); @@ -118,14 +223,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, @@ -141,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" }, @@ -152,9 +261,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/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/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..c7f41e182 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,151 @@ 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, + { validate: vi.fn(), roundtrip: vi.fn() }, + { isConfirmed } as never, + remote as never, + { facilitatorUrl: "https://facilitator.example", payTo: { bnb: "destination" } }, + 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(); +}); + +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 ef79bd6bc..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, @@ -90,3 +110,39 @@ 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); + }, +); + +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 27afc6491..c57b51632 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,13 +33,27 @@ 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; + }, ) { + const amount = baiRechargeAmount(input.amount); + assertBaiRechargeMinimum(input.token, amount); if (!input.apiKey) { throw new UsageError( "bai_credentials_missing", @@ -47,13 +63,6 @@ export class BaiService { if (!this.bindings) throw new UsageError("invalid_option", "B.AI recharge binding verification is unavailable"); const chain = requireBaiChain(network); - const payer = scope.resolveAddress(network.family); - if (!this.bindings.isConfirmed(input.apiKey, chain, payer)) { - throw new UsageError( - "invalid_value", - "Confirm this API key and payer wallet first by configuring baiApiKey with --api-key-stdin for the selected account/network. No payment was sent", - ); - } if (!this.payments || !this.rechargeApi || !this.rechargeConfig) { throw new UsageError("invalid_option", "B.AI recharge is not available in this runtime"); } @@ -64,18 +73,29 @@ 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, 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, }; 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 || @@ -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/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/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 62d2dba48..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"; @@ -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) => @@ -69,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 { @@ -89,7 +96,10 @@ 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'); - const data=JSON.parse(init.body);if(!data.paymentPayload.payload.signature)throw new Error('missing signature'); + if(url.pathname==='/supported')return Response.json({kinds:[{x402Version:2,scheme:'exact',network:'tron:0xcd8690dc'}]}); + 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'})); @@ -109,6 +119,8 @@ import {appendFileSync} from 'node:fs';const realFetch=globalThis.fetch;globalTh "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", "--port", String(p), + "--token", + token, "--account", "payer", "--password-stdin", @@ -131,3 +143,165 @@ import {appendFileSync} from 'node:fs';const realFetch=globalThis.fetch;globalTh }, 30000, ); + +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({ + 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:${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:${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!==${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!==${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}); + } + 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", + token, + "--scheme", + "exact_gasfree", + "--account", + "payer", + "--password-stdin", + "-o", + "json", + ], + { + env: { ...process.env, WALLET_CLI_HOME: home }, + input: "test-password\n", + encoding: "utf8", + 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(scenario === "USDD-fallback" ? "10000.00%" : "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 }); + } + }, +); 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); +});