diff --git a/ts/docs/commands/x402/pay.md b/ts/docs/commands/x402/pay.md index c5a5a2e34..7cc12c530 100644 --- a/ts/docs/commands/x402/pay.md +++ b/ts/docs/commands/x402/pay.md @@ -29,7 +29,11 @@ Sends the request. If the endpoint answers with a successful status (2xx), that While it runs, `pay` prints progress lines starting with `⏳` on stderr (in JSON mode, `{"type":"activity",...}` lines); stdout carries only the result. -The response body is returned in `data.response` — parsed when the endpoint says it is JSON — or written to a new file with `--out`. Responses are limited to 10 MB. +`--body-file` sends the original file bytes, or stdin bytes with `--body-file -`, up to 1 MiB. It does not decode text or infer a Content-Type; use `--header "Content-Type: application/json"` (or the endpoint's required type) when needed. Stdin cannot also supply a secret such as `--password-stdin`. Inline `--body` remains a text string. + +The response body is returned in `data.response` — parsed for `application/json` or a `+json` media type — or written byte-for-byte to a new file with `--out`. Binary media types such as `application/octet-stream`, images, audio, video, PDF, and archives require `--out`; without it the command returns `invalid_option` (exit 2). Text, missing Content-Type, and unrecognized media types use strict UTF-8 decoding; invalid UTF-8 returns `invalid_x402_response` instead of replacing bytes. `--out` bypasses text and JSON decoding. Responses are limited to 10 MB. + +A response format error can occur **after payment has settled**. In that case `error.details` retains `paymentStatus: "settled"`, `settled: true`, the transaction hash, and `retryPayment: false`. Do not pay again to recover the response; reconcile with the provider using the transaction hash. Choose `--out` before requesting a binary resource. **When a payment fails, read `error.details` before trying again.** `paymentStatus: "not_sent"` means nothing left the account; `"unknown"` means the CLI cannot tell, so treat it as possibly paid and reconcile first; `retryPayment: false` means do not pay again to recover. See [x402 and B.AI payment details](../../machine-interface.md#x402-and-bai-payment-details). @@ -142,7 +146,7 @@ printf '%s' "$PW" | wallet-cli x402 pay https://x402-gateway.bankofai.io/provide ## Exit status -`0` success, including a free response · `1` execution failure (`no_matching_requirement`, `amount_exceeds_limit` — both before signing, `paymentStatus: "not_sent"`; `gasfree_insufficient_balance` / `gasfree_not_activated`, `permit2_allowance_required` / `approval_reset_required`, `fee_cap_exceeded`, `payer_mismatch`, `invalid_settlement`, `invalid_x402_response`, `response_too_large`, `provider_rate_limited` — HTTP 429; `provider_error` — any other failed request or payment, with `error.details.phase` and `httpStatus`; `missing_wallet_address`; `auth_required` — a payment needs signing and no `--password-stdin` was given; `auth_failed`, `timeout`) · `2` usage error (`output_exists` — the `--out` file exists; `gasfree_credentials_missing`; `invalid_option` — two exclusive options, `--decimals` without `--asset`, or `--wait` / `--wait-timeout`, which this command does not take because the facilitator settles the payment; `invalid_amount` — a limit that is not a positive amount; `invalid_value` — e.g. a malformed `--header`, an unsupported `--method`, or a `--gasfree-relay` URL that is not HTTPS). +`0` success, including a free response · `1` execution failure (`no_matching_requirement`, `amount_exceeds_limit` — both before signing, `paymentStatus: "not_sent"`; `gasfree_insufficient_balance` / `gasfree_not_activated`, `permit2_allowance_required` / `approval_reset_required`, `fee_cap_exceeded`, `payer_mismatch`, `invalid_settlement`, `invalid_x402_response`, `response_too_large`, `provider_rate_limited` — HTTP 429; `provider_error` — any other failed request or payment, with `error.details.phase` and `httpStatus`; `missing_wallet_address`; `auth_required` — a payment needs signing and no `--password-stdin` was given; `auth_failed`, `timeout`) · `2` usage error (`output_exists` — the `--out` file exists; `gasfree_credentials_missing`; `invalid_option` — a binary response without `--out`, two exclusive options, `--decimals` without `--asset`, or `--wait` / `--wait-timeout`, which this command does not take because the facilitator settles the payment; `invalid_amount` — a limit that is not a positive amount; `invalid_value` — e.g. a malformed `--header`, an unsupported `--method`, or a `--gasfree-relay` URL that is not HTTPS). ## See also diff --git a/ts/src/adapters/inbound/cli/commands/artifact.ts b/ts/src/adapters/inbound/cli/commands/artifact.ts index 62aeaf2ce..ebc83c677 100644 --- a/ts/src/adapters/inbound/cli/commands/artifact.ts +++ b/ts/src/adapters/inbound/cli/commands/artifact.ts @@ -1,5 +1,5 @@ import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs"; -import { UsageError } from "../../../../domain/errors/index.js"; +import { errorMessage, UsageError } from "../../../../domain/errors/index.js"; export function readBoundedTextFile(path: string, maxBytes: number, label: string): string { let fd: number | undefined; @@ -9,9 +9,13 @@ export function readBoundedTextFile(path: string, maxBytes: number, label: strin constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), ); const stat = fstatSync(fd); - if (!stat.isFile()) throw new UsageError("invalid_value", `${label} must be a regular file`); + if (!stat.isFile()) + throw new UsageError("invalid_value", errorMessage`${label} must be a regular file`); if (stat.size > maxBytes) - throw new UsageError("invalid_value", `${label} exceeds the ${maxBytes}-byte limit`); + throw new UsageError( + "invalid_value", + errorMessage`${label} exceeds the ${maxBytes}-byte limit`, + ); // Trimmed, not stripped: a file saved by an editor, `echo` or `jq -r` always carries a // trailing newline, and every current caller (hex, JSON) treats surrounding whitespace as // insignificant — but the trim must stop at the edges. Removing *all* whitespace would @@ -20,7 +24,10 @@ export function readBoundedTextFile(path: string, maxBytes: number, label: strin return readFileSync(fd, "utf8").trim(); } catch (error) { if (error instanceof UsageError) throw error; - throw new UsageError("invalid_value", `could not read ${label}: ${(error as Error).message}`); + throw new UsageError( + "invalid_value", + errorMessage`could not read ${label}: ${(error as Error).message}`, + ); } finally { if (fd !== undefined) closeSync(fd); } diff --git a/ts/src/adapters/inbound/cli/commands/contract.ts b/ts/src/adapters/inbound/cli/commands/contract.ts index 46600d8d7..8d74a65c6 100644 --- a/ts/src/adapters/inbound/cli/commands/contract.ts +++ b/ts/src/adapters/inbound/cli/commands/contract.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { readFile } from "node:fs/promises"; import type { ChainSpec, FamilyBinding } from "../contracts/index.js"; -import { UsageError } from "../../../../domain/errors/index.js"; +import { errorMessage, UsageError } from "../../../../domain/errors/index.js"; import type { TronContractService } from "../../../../application/use-cases/tron/contract-service.js"; import type { DeployConstructorArgs } from "../../../../application/ports/chain/gateway-provider.js"; import type { EvmContractService } from "../../../../application/use-cases/evm/contract-service.js"; @@ -265,9 +265,9 @@ async function creationBytecode(input: { code?: string; codeFile?: string }): Pr text = await readFile(input.codeFile, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new UsageError("file_not_found", `code file not found: ${input.codeFile}`); + throw new UsageError("file_not_found", errorMessage`code file not found: ${input.codeFile}`); } - throw new UsageError("invalid_value", `cannot read code file: ${input.codeFile}`); + throw new UsageError("invalid_value", errorMessage`cannot read code file: ${input.codeFile}`); } return checkedCreationCode(text, `--code-file ${input.codeFile}`); } @@ -309,7 +309,7 @@ function checkedCreationCode(raw: string, source: string): string { if (code === "" || hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) { throw new UsageError( "invalid_value", - `${source} must hold even-length hex creation bytecode, or exactly 0x to deploy empty code; it must not be empty`, + errorMessage`${source} must hold even-length hex creation bytecode, or exactly 0x to deploy empty code; it must not be empty`, ); } return /^0x/i.test(code) ? `0x${hex}` : code; @@ -340,22 +340,22 @@ async function readArtifact(path: string): Promise { text = await readFile(path, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new UsageError("file_not_found", `artifact not found: ${path}`); + throw new UsageError("file_not_found", errorMessage`artifact not found: ${path}`); } - throw new UsageError("invalid_value", `cannot read artifact: ${path}`); + throw new UsageError("invalid_value", errorMessage`cannot read artifact: ${path}`); } let artifact: Record; try { artifact = JSON.parse(text); } catch { - throw new UsageError("invalid_value", `artifact is not valid JSON: ${path}`); + throw new UsageError("invalid_value", errorMessage`artifact is not valid JSON: ${path}`); } const bytecode = artifact?.bytecode?.object ?? artifact?.bytecode ?? artifact?.evm?.bytecode?.object; if (typeof bytecode !== "string") { throw new UsageError( "invalid_value", - `artifact has no creation bytecode: ${path} (looked at .bytecode.object, .bytecode and .evm.bytecode.object)`, + errorMessage`artifact has no creation bytecode: ${path} (looked at .bytecode.object, .bytecode and .evm.bytecode.object)`, ); } // solc emits "0x" for an interface or an abstract contract: a real artifact for something that @@ -363,7 +363,7 @@ async function readArtifact(path: string): Promise { if (bytecode.replace(/^0x/, "") === "") { throw new UsageError( "invalid_value", - `artifact holds no deployable bytecode: ${path} — an interface or abstract contract cannot be deployed`, + errorMessage`artifact holds no deployable bytecode: ${path} — an interface or abstract contract cannot be deployed`, ); } return { bytecode, ...(artifact.abi === undefined ? {} : { abi: artifact.abi }) }; @@ -867,8 +867,14 @@ export const contractCreate2TronBinding = (svc: TronContractService): FamilyBind } catch (error) { const codeValue = (error as NodeJS.ErrnoException).code; if (codeValue === "ENOENT") - throw new UsageError("file_not_found", `code file not found: ${input.codeFile}`); - throw new UsageError("invalid_value", `cannot read code file: ${input.codeFile}`); + throw new UsageError( + "file_not_found", + errorMessage`code file not found: ${input.codeFile}`, + ); + throw new UsageError( + "invalid_value", + errorMessage`cannot read code file: ${input.codeFile}`, + ); } } return svc.create2(input.deployer, code!, input.salt); diff --git a/ts/src/adapters/inbound/cli/commands/sunpump/launch.ts b/ts/src/adapters/inbound/cli/commands/sunpump/launch.ts index 305d62b88..6b89315b9 100644 --- a/ts/src/adapters/inbound/cli/commands/sunpump/launch.ts +++ b/ts/src/adapters/inbound/cli/commands/sunpump/launch.ts @@ -17,7 +17,7 @@ import { readFile } from "node:fs/promises"; import type { ChainSpec, FamilyBinding } from "../../contracts/command.js"; import type { SunPumpTokenLaunchService } from "../../../../../application/use-cases/tron/sunpump/token-launch-service.js"; import type { SunPumpLaunchImage } from "../../../../../application/use-cases/tron/sunpump/token-launch-service.js"; -import { UsageError } from "../../../../../domain/errors/index.js"; +import { errorMessage, UsageError } from "../../../../../domain/errors/index.js"; import { TextFormatters } from "../../render/index.js"; /** @@ -146,9 +146,9 @@ async function imageOf(input: { bytes = await readFile(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new UsageError("file_not_found", `image file not found: ${path}`); + throw new UsageError("file_not_found", errorMessage`image file not found: ${path}`); } - throw new UsageError("invalid_value", `cannot read image file: ${path}`); + throw new UsageError("invalid_value", errorMessage`cannot read image file: ${path}`); } return { image: { source: "file", path, bytes: bytes.byteLength, base64: bytes.toString("base64") }, diff --git a/ts/src/adapters/inbound/cli/commands/wallet.ts b/ts/src/adapters/inbound/cli/commands/wallet.ts index 20d14a9f4..d5414d840 100644 --- a/ts/src/adapters/inbound/cli/commands/wallet.ts +++ b/ts/src/adapters/inbound/cli/commands/wallet.ts @@ -17,7 +17,7 @@ import { selectLedgerPath, } from "../../../../application/services/ledger-account.js"; import { ChainFamily, CHAIN_FAMILIES, FAMILIES } from "../../../../domain/family/index.js"; -import { UsageError } from "../../../../domain/errors/index.js"; +import { errorMessage, UsageError } from "../../../../domain/errors/index.js"; import { passwordPolicyErrors } from "../input/prompt/validators.js"; import { readBoundedTextFile } from "./artifact.js"; import { TextFormatters } from "../render/index.js"; @@ -97,12 +97,13 @@ const KEYSTORE_MAX_BYTES = 64 * 1024; /** the parsed JSON of a keystore file. Distinguishes "no such file" from "not a keystore" so the * caller learns which of the two mistakes they made. */ function readKeystoreFile(path: string): unknown { - if (!existsSync(path)) throw new UsageError("keystore_not_found", `no keystore file at ${path}`); + if (!existsSync(path)) + throw new UsageError("keystore_not_found", errorMessage`no keystore file at ${path}`); const raw = readBoundedTextFile(path, KEYSTORE_MAX_BYTES, "keystore file"); try { return JSON.parse(raw) as unknown; } catch { - throw new UsageError("invalid_keystore", `${path} is not valid JSON`); + throw new UsageError("invalid_keystore", errorMessage`${path} is not valid JSON`); } } diff --git a/ts/src/adapters/inbound/cli/commands/x402.request-body.test.ts b/ts/src/adapters/inbound/cli/commands/x402.request-body.test.ts new file mode 100644 index 000000000..438c1fcf9 --- /dev/null +++ b/ts/src/adapters/inbound/cli/commands/x402.request-body.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import { createServer } from "node:http"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CommandRegistry } from "../registry/index.js"; +import { registerX402Commands } from "./x402.js"; +import { X402Service } from "../../../../application/use-cases/x402-service.js"; +import { X402PaymentClient } from "../../../outbound/x402/payment-client.js"; +import { StreamManager } from "../stream/index.js"; +import type { NetworkDescriptor } from "../../../../domain/types/index.js"; +import type { SignerResolver } from "../../../../application/services/signer/index.js"; + +// Replace only the operating-system stdin boundary; exercise the real StreamManager and CLI. +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readFileSync: vi.fn(actual.readFileSync) }; +}); + +const network = { id: "eip155:56", family: "evm", chainId: "56" } as NetworkDescriptor; +afterEach(() => vi.restoreAllMocks()); + +async function stdin(bytes: Buffer) { + const actual = await vi.importActual("node:fs"); + vi.mocked(fs.readFileSync).mockImplementation((file, options) => { + if (file !== 0) return actual.readFileSync(file, options); + return options === "utf8" ? bytes.toString("utf8") : bytes; + }); +} + +function command(client: X402PaymentClient) { + const registry = new CommandRegistry(); + registerX402Commands(registry, new X402Service(client, {} as never, {} as never)); + return registry.resolveNeutral(["x402", "pay"])!; +} + +function context() { + return { + timeoutMs: 1000, + emit: vi.fn(), + secrets: { has: () => false }, + streams: new StreamManager("json", false), + }; +} + +const samples = [ + ["empty", ""], + ["ASCII", "68656c6c6f"], + ["UTF-8", "e4b8ade69687f09f9880"], + ["CRLF and NUL", "00410d0a4200"], + ["non-UTF-8", "0041fffe800d0a42"], +] as const; + +describe.each(["file", "stdin"])("x402 request bytes from %s", (source) => { + it.each(samples)("sends original %s bytes to the HTTP endpoint", async (_name, hex) => { + await sendBody(hex, false); + }); + + it.each(samples)("preserves %s bytes across a controlled 402 retry", async (_name, hex) => { + await sendBody(hex, true); + }); + + async function sendBody(hex: string, retry: boolean) { + const bytes = Buffer.from(hex, "hex"); + const directory = await mkdtemp(join(tmpdir(), "x402-body-")); + const path = join(directory, "body.bin"); + const received: Buffer[] = []; + const authorization: (string | undefined)[] = []; + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + received.push(Buffer.concat(chunks)); + authorization.push(request.headers["payment-signature"] as string | undefined); + response.statusCode = retry && received.length === 1 ? 402 : 200; + response.setHeader("content-type", "text/plain"); + response.end("ok"); + }); + const resolver = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => ({ address: "0x1111111111111111111111111111111111111111" })), + } as unknown as SignerResolver; + // The injected payment boundary uses a fixed test header; no real signer, RPC or payment. + const paidFetch: typeof fetch = async (url, init) => { + const initial = await fetch(url, init); + if (initial.status !== 402) return initial; + await initial.arrayBuffer(); + const headers = new Headers(init?.headers); + headers.set("payment-signature", "test-only"); + return fetch(url, { ...init, headers }); + }; + try { + await writeFile(path, bytes); + if (source === "stdin") await stdin(bytes); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing server port"); + const client = new X402PaymentClient( + resolver, + globalThis.fetch, + retry ? async () => paidFetch : undefined, + ); + await command(client).run(context() as never, network, { + url: `http://127.0.0.1:${address.port}`, + method: "POST", + header: [], + bodyFile: source === "stdin" ? "-" : path, + }); + expect(received.map((body) => body.toString("hex"))).toEqual(retry ? [hex, hex] : [hex]); + expect(authorization).toEqual(retry ? [undefined, "test-only"] : [undefined]); + if (!retry) expect(resolver.resolve).not.toHaveBeenCalled(); + } finally { + if (server.listening) + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await rm(directory, { recursive: true, force: true }); + } + } + + it.each([1_048_576, 1_048_577])("checks the raw-byte limit for %i bytes", async (length) => { + // These bytes would triple in size if first decoded to replacement characters. + const bytes = Buffer.alloc(length, 0xff); + const directory = await mkdtemp(join(tmpdir(), "x402-body-limit-")); + const path = join(directory, "body.bin"); + const fetcher = vi.fn(async (_url, init) => { + expect(await new Response(init?.body).arrayBuffer()).toEqual(bytes.buffer); + return new Response("ok", { headers: { "content-type": "text/plain" } }); + }); + try { + await writeFile(path, bytes); + if (source === "stdin") await stdin(bytes); + const result = command(new X402PaymentClient({} as never, fetcher)).run( + context() as never, + network, + { + url: "http://example.test", + method: "POST", + header: [], + bodyFile: source === "stdin" ? "-" : path, + }, + ); + if (length === 1_048_576) { + await expect(result).resolves.toMatchObject({ delivered: true }); + expect(fetcher).toHaveBeenCalledOnce(); + } else { + await expect(result).rejects.toMatchObject({ code: "invalid_value" }); + expect(fetcher).not.toHaveBeenCalled(); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +it("keeps inline --body as text", async () => { + const fetcher = vi.fn(async (_url, init) => { + expect(init?.body).toBe("中文\r\n"); + return new Response("ok"); + }); + await command(new X402PaymentClient({} as never, fetcher)).run(context() as never, network, { + url: "http://example.test", + method: "POST", + header: [], + body: "中文\r\n", + }); + expect(fetcher).toHaveBeenCalledOnce(); +}); + +it.each(["text", "bytes"])( + "consuming stdin as %s prevents either kind of second read", + async (kind) => { + await stdin(Buffer.from("0041fffe800d0a42", "hex")); + const streams = new StreamManager("json", false); + if (kind === "text") expect(streams.readStdinOnce()).toBe("\0A���\r\nB"); + else expect(streams.readStdinBytesOnce().toString("hex")).toBe("0041fffe800d0a42"); + expect(() => streams.readStdinOnce()).toThrow("stdin already consumed"); + expect(() => streams.readStdinBytesOnce()).toThrow("stdin already consumed"); + }, +); diff --git a/ts/src/adapters/inbound/cli/commands/x402.test.ts b/ts/src/adapters/inbound/cli/commands/x402.test.ts index 4b9acb66e..402405160 100644 --- a/ts/src/adapters/inbound/cli/commands/x402.test.ts +++ b/ts/src/adapters/inbound/cli/commands/x402.test.ts @@ -103,7 +103,7 @@ it("does not advertise unsupported provider type or facilitator waiting", () => * `--password-stdin` let `--api-key-stdin` (or any other secret) be read as the request body and * posted to the endpoint. Every secret bound to stdin must refuse the body, before anything is read. */ -it.each(["password", "apiKey", "tx", "message"])( +it.each(["password", "privateKey", "mnemonic", "apiKey", "tx", "message"])( "refuses --body-file - when --%s-stdin also claims stdin, without reading or sending", async (kind) => { const registry = new CommandRegistry(); @@ -111,9 +111,10 @@ it.each(["password", "apiKey", "tx", "message"])( registerX402Commands(registry, svc); const pay = registry.resolveNeutral(["x402", "pay"])!; const readStdinOnce = vi.fn(() => "secret"); + const readStdinBytesOnce = vi.fn(() => Buffer.from("secret")); const ctx = { secrets: { has: (k: string) => k === kind }, - streams: { readStdinOnce }, + streams: { readStdinOnce, readStdinBytesOnce }, }; await expect( pay.run(ctx as never, { id: "eip155:56" } as never, { @@ -124,6 +125,7 @@ it.each(["password", "apiKey", "tx", "message"])( }), ).rejects.toMatchObject({ code: "invalid_option" }); expect(readStdinOnce).not.toHaveBeenCalled(); + expect(readStdinBytesOnce).not.toHaveBeenCalled(); expect(svc.pay).not.toHaveBeenCalled(); }, ); diff --git a/ts/src/adapters/inbound/cli/commands/x402.ts b/ts/src/adapters/inbound/cli/commands/x402.ts index cb182fef6..345a54677 100644 --- a/ts/src/adapters/inbound/cli/commands/x402.ts +++ b/ts/src/adapters/inbound/cli/commands/x402.ts @@ -14,7 +14,7 @@ import { sanitizeText } from "../render/scalars.js"; import type { CommandRegistry } from "../registry/index.js"; import type { X402Service } from "../../../../application/use-cases/x402-service.js"; import { readFile } from "node:fs/promises"; -import { UsageError } from "../../../../domain/errors/index.js"; +import { errorMessage, UsageError } from "../../../../domain/errors/index.js"; const url = z .string() @@ -329,7 +329,7 @@ async function requestBody( ctx: Parameters[0], inline?: string, path?: string, -): Promise { +): Promise { if (!path) return inline; if (path === "-") { // Every `---stdin` secret is bound to the same fd 0; reading it as the body would post @@ -341,22 +341,22 @@ async function requestBody( `--body-file - cannot share stdin with --${claimed.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}-stdin`, ); } - return checkedBody(ctx.streams.readStdinOnce(), "stdin"); + return checkedBody(ctx.streams.readStdinBytesOnce(), "stdin"); } try { - return checkedBody(await readFile(path, "utf8"), path); + return checkedBody(await readFile(path), path); } catch (error) { if (error instanceof UsageError) throw error; if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new UsageError("file_not_found", `request body file not found: ${path}`); + throw new UsageError("file_not_found", errorMessage`request body file not found: ${path}`); } - throw new UsageError("invalid_value", `cannot read request body file: ${path}`); + throw new UsageError("invalid_value", errorMessage`cannot read request body file: ${path}`); } } -function checkedBody(body: string, source: string): string { - if (Buffer.byteLength(body) > 1_048_576) { - throw new UsageError("invalid_value", `request body from ${source} exceeds 1 MiB`); +function checkedBody(body: Uint8Array, source: string): Uint8Array { + if (body.byteLength > 1_048_576) { + throw new UsageError("invalid_value", errorMessage`request body from ${source} exceeds 1 MiB`); } return body; } diff --git a/ts/src/adapters/inbound/cli/contracts/runtime.ts b/ts/src/adapters/inbound/cli/contracts/runtime.ts index 4076337b5..8b22ad951 100644 --- a/ts/src/adapters/inbound/cli/contracts/runtime.ts +++ b/ts/src/adapters/inbound/cli/contracts/runtime.ts @@ -12,6 +12,8 @@ export interface StreamManager { /** intermediate progress frame → stderr plain line; null is skipped (StreamManager). */ event(frame: string | null): void; readStdinOnce(): string; + /** Raw input bytes; shares the single-consumption guard with readStdinOnce. */ + readStdinBytesOnce(): Uint8Array; /** warnings accumulated for the JSON envelope's meta.warnings. */ warnings(): WarningItem[]; } diff --git a/ts/src/adapters/inbound/cli/output/error-inputs.test.ts b/ts/src/adapters/inbound/cli/output/error-inputs.test.ts new file mode 100644 index 000000000..8e5020c60 --- /dev/null +++ b/ts/src/adapters/inbound/cli/output/error-inputs.test.ts @@ -0,0 +1,217 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ContactService } from "../../../../application/use-cases/contact-service.js"; +import type { ContactRepository } from "../../../../application/ports/contact-repository.js"; +import { CliError, normalizeError, UsageError } from "../../../../domain/errors/index.js"; +import { Keystore } from "../../../outbound/keystore/index.js"; +import { AtomicFileStore } from "../../../outbound/persistence/fs/index.js"; +import { readBoundedTextFile } from "../commands/artifact.js"; +import { StreamManager } from "../stream/index.js"; +import { createOutputFormatter } from "./index.js"; +import { contractCreate2TronBinding, contractDeployEvmBinding } from "../commands/contract.js"; +import { sunpumpLaunchTronBinding } from "../commands/sunpump/launch.js"; +import { registerX402Commands } from "../commands/x402.js"; +import { registerWalletCommands } from "../commands/wallet.js"; +import { CommandRegistry } from "../registry/index.js"; +import { SignerResolver } from "../../../../application/services/signer/index.js"; +import { X402PaymentClient } from "../../../outbound/x402/payment-client.js"; + +function thrown(action: () => unknown): CliError { + try { + action(); + } catch (error) { + expect(error).toBeInstanceOf(CliError); + return error as CliError; + } + throw new Error("expected a CLI error"); +} + +function render(error: CliError, mode: "text" | "json"): string { + const frames: string[] = []; + const streams = new StreamManager( + mode, + false, + (s) => frames.push(s), + (s) => frames.push(s), + ); + // Runner normalizes before formatting; the original CliError must keep its safe text view. + createOutputFormatter(mode, streams, 0).error(normalizeError(error)); + return frames.join(""); +} + +describe("untrusted input in text errors (WALLETCLI-SEC-002)", () => { + const roots: string[] = []; + function root() { + const path = mkdtempSync(join(tmpdir(), "error-inputs-")); + roots.push(path); + return path; + } + afterEach(() => { + for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true }); + }); + + const service = { deploy: vi.fn(), create2: vi.fn(), pay: vi.fn(), launch: vi.fn() }; + const deploy = contractDeployEvmBinding(service as never); + const create2 = contractCreate2TronBinding(service as never); + const launch = sunpumpLaunchTronBinding(service as never); + const registry = new CommandRegistry(); + registerX402Commands(registry, service as never); + registerWalletCommands(registry, { walletService: {} as never, ledger: {} as never }); + const pay = registry.resolveNeutral(["x402", "pay"])!; + const importKeystore = registry.resolveNeutral(["import", "keystore"])!; + const fileCommands = [ + { + name: "contract deploy code", + run: (path: string) => deploy.run({} as never, {} as never, { codeFile: path }), + }, + { + name: "contract deploy artifact", + run: (path: string) => deploy.run({} as never, {} as never, { artifact: path }), + }, + { + name: "contract create2 code", + run: (path: string) => create2.run({} as never, {} as never, { codeFile: path }), + }, + { + name: "x402 request body", + run: (path: string) => pay.run({} as never, {} as never, { bodyFile: path }), + }, + { + name: "sunpump image", + run: (path: string) => launch.run({} as never, {} as never, { image: path }), + }, + ]; + + async function fileError(run: (path: string) => unknown, path: string) { + let error: unknown; + try { + await run(path); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(CliError); + const cliError = error as CliError; + expect(render(cliError, "text").trimEnd()).not.toMatch(/[\n\r\u2028\u2029]/); + expect(render(cliError, "text")).toContain("\\nFAKE_LOG"); + expect(JSON.parse(render(cliError, "json")).error.message).toContain(path); + for (const fn of Object.values(service)) expect(fn).not.toHaveBeenCalled(); + } + + it.each(["missing", "malformed JSON"])( + "escapes the keystore reader's %s filename error", + async (kind) => { + const path = join(root(), "input\nFAKE_LOG"); + if (kind === "malformed JSON") writeFileSync(path, "not JSON"); + await fileError((path) => importKeystore.run({} as never, undefined, { path }), path); + }, + ); + + describe.each(fileCommands)("$name filename errors", ({ run }) => { + it.each(["missing", "directory"])("escapes a %s path before formatting", async (kind) => { + const path = join(root(), "input\nFAKE_LOG"); + if (kind === "directory") mkdirSync(path); + await fileError(run, path); + }); + }); + + it.each([ + { name: "non-hex code", body: "not hex", run: fileCommands[0]!.run }, + { name: "malformed artifact", body: "not JSON", run: fileCommands[1]!.run }, + { name: "missing artifact bytecode", body: "{}", run: fileCommands[1]!.run }, + { name: "empty artifact bytecode", body: '{"bytecode":"0x"}', run: fileCommands[1]!.run }, + { name: "oversized request body", body: "x".repeat(1_048_577), run: fileCommands[3]!.run }, + ])("escapes an existing filename in $name validation", async ({ body, run }) => { + const path = join(root(), "input\nFAKE_LOG"); + writeFileSync(path, body); + await fileError(run, path); + }); + + const inputs = [ + "bad\nFAKE_LOG", + "bad\r\nFAKE_LOG", + "bad\u2028FAKE_LOG", + "bad\u2029FAKE_LOG", + "\u001b[31mANSI\nFAKE_LOG", + ]; + it.each(inputs)("keeps an invalid contact address on one physical line: %j", (address) => { + const service = new ContactService({} as ContactRepository); + const error = thrown(() => service.add("friend", address)); + expect(render(error, "text").trimEnd()).not.toMatch(/[\n\r\u2028\u2029]/); + expect(render(error, "text")).toContain("FAKE_LOG"); + expect(render(error, "text")).not.toContain("\u001b"); + expect(JSON.parse(render(error, "json")).error.message).toBe( + `not a recognised chain address: ${address}`, + ); + }); + + it("escapes the filename carried by the actual tx --file reader's OS error", () => { + const path = join(root(), "missing\nFAKE_LOG.hex"); + const error = thrown(() => readBoundedTextFile(path, 1024, "transaction hex file")); + expect(render(error, "text").trimEnd()).not.toContain("\n"); + expect(render(error, "text")).toContain("missing\\nFAKE_LOG.hex"); + expect(JSON.parse(render(error, "json")).error.message).toContain(path); + }); + + it.each(["missing\nFAKE_LOG", "wlt_missing\nFAKE_LOG"])( + "escapes an account lookup: %j", + (input) => { + const keystore = new Keystore(root(), new AtomicFileStore(), () => "unused"); + const error = thrown(() => keystore.resolveAccount(input)); + expect(render(error, "text").trimEnd()).not.toContain("\n"); + expect(render(error, "text")).toContain("missing\\nFAKE_LOG"); + expect(JSON.parse(render(error, "json")).error.message).toBe( + input.startsWith("wlt_") ? `unknown account ${input}` : `no account labelled '${input}'`, + ); + }, + ); + + it("preserves a keystore error's safe text through payment preparation", () => { + const keystore = new Keystore(root(), new AtomicFileStore(), () => "unused"); + const resolver = new SignerResolver(keystore, {} as never, {} as never); + const client = new X402PaymentClient(resolver); + const error = thrown(() => + client.prepare({ activeAccount: "missing\nFAKE_LOG" } as never, { family: "tron" } as never), + ); + expect(render(error, "text").trimEnd()).not.toContain("\n"); + expect(render(error, "text")).toContain("missing\\nFAKE_LOG"); + expect(JSON.parse(render(error, "json")).error).toMatchObject({ + message: "no account labelled 'missing\nFAKE_LOG'", + details: { phase: "sign", paymentStatus: "not_sent" }, + }); + }); + + it("preserves authored multiline guidance and candidate table layout", () => { + const error = new UsageError("ambiguous_account", "Choose an account.\nUse its accountId.", { + matches: [ + { accountId: "wlt_a", label: "first", type: "watch", index: null }, + { accountId: "wlt_b", label: "second", type: "watch", index: null }, + ], + }); + const text = render(error, "text"); + expect(text).toContain("Choose an account.\nUse its accountId.\n"); + expect(text).toContain("wlt_a"); + expect(text).toContain("wlt_b"); + expect(text.split("\n").length).toBeGreaterThan(4); + }); + + it("keeps a stored account label inside its candidate-table cell", () => { + const keystore = new Keystore(root(), new AtomicFileStore(), () => "unused"); + const address = "TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"; + keystore.registerWatch({ family: "tron", address, label: "watch\nFAKE_LOG" }); + keystore.registerLedger({ + family: "tron", + address, + label: "ledger", + path: "m/44'/195'/0'/0/0", + }); + const error = thrown(() => keystore.resolveAccount(address)); + const text = render(error, "text"); + expect(text).not.toContain("\nFAKE_LOG"); + expect(text).toContain("watch\\nFAKE_LOG"); + expect(JSON.parse(render(error, "json")).error.details.matches[0].label).toBe( + "watch\nFAKE_LOG", + ); + }); +}); diff --git a/ts/src/adapters/inbound/cli/output/index.ts b/ts/src/adapters/inbound/cli/output/index.ts index 7018fc9fd..ac5552336 100644 --- a/ts/src/adapters/inbound/cli/output/index.ts +++ b/ts/src/adapters/inbound/cli/output/index.ts @@ -163,7 +163,7 @@ class HumanOutputFormatter extends BaseOutputFormatter implements OutputFormatte error(err: CliError): void { // Errors that are a choice rather than a dead end append their candidate table (see // renderErrorDetails); everything else stays the single line it has always been. - const line = `error [${err.code}]: ${err.message}`; + const line = `error [${err.code}]: ${err.textMessage}`; const candidates = renderErrorDetails(err.details); this.streams.errorLine(sanitizeText(candidates ? `${line}\n${candidates}` : line)); } diff --git a/ts/src/adapters/inbound/cli/render/error-details.ts b/ts/src/adapters/inbound/cli/render/error-details.ts index da8baa737..5495f5267 100644 --- a/ts/src/adapters/inbound/cli/render/error-details.ts +++ b/ts/src/adapters/inbound/cli/render/error-details.ts @@ -12,6 +12,7 @@ * `details` verbatim. */ import { fromBaseUnits } from "../../../../domain/amounts/index.js"; +import { singleLineErrorValue } from "../../../../domain/errors/index.js"; import type { Obj } from "./layout.js"; import { asObj, table } from "./layout.js"; import { formatDecimal, formatInt, formatScalar, num } from "./scalars.js"; @@ -56,7 +57,7 @@ export function renderErrorDetails(details: unknown): string | null { const keys = Object.keys(objects[0] ?? {}); if (keys.length === 0) return null; return table( - keys.map((k) => HEADERS[k] ?? k), - objects.map((row) => keys.map((k) => cell(k, row))), + keys.map((k) => singleLineErrorValue(HEADERS[k] ?? k)), + objects.map((row) => keys.map((k) => singleLineErrorValue(cell(k, row)))), ); } diff --git a/ts/src/adapters/inbound/cli/stream/index.ts b/ts/src/adapters/inbound/cli/stream/index.ts index b1e245a53..6a163f29d 100644 --- a/ts/src/adapters/inbound/cli/stream/index.ts +++ b/ts/src/adapters/inbound/cli/stream/index.ts @@ -63,12 +63,16 @@ export class StreamManager implements IStreamManager { } readStdinOnce(): string { + return this.readStdinBytesOnce().toString("utf8"); + } + + readStdinBytesOnce(): Buffer { if (this.#stdinRead) { throw new ExecutionError("secret_source_error", "stdin already consumed"); } this.#stdinRead = true; try { - return readFileSync(0, "utf8"); + return readFileSync(0); } catch { throw new ExecutionError("secret_source_error", "no data available on stdin"); } diff --git a/ts/src/adapters/outbound/bai/client.test.ts b/ts/src/adapters/outbound/bai/client.test.ts index 00960238b..fcb43cccb 100644 --- a/ts/src/adapters/outbound/bai/client.test.ts +++ b/ts/src/adapters/outbound/bai/client.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { BaiClient } from "./client.js"; +import { BaiService } from "../../../application/use-cases/bai-service.js"; +import { baiUsageText } from "../../inbound/cli/render/bai.js"; function response(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -9,6 +11,35 @@ function response(body: unknown, status = 200): Response { } describe("BaiClient", () => { + describe.each(["direct", "tRPC", "tRPC JSON"])("%s summary precision", (envelope) => { + it.each([ + ["9007199254740991", "9007199254740991"], + ["9007199254740993", "9007199254740993"], + ["123.456789012345678901", "123.456789012345678901"], + ['"9007199254740993.123456789012345678901"', "9007199254740993.123456789012345678901"], + ])("preserves raw credits %s through JSON and text output", async (literal, expected) => { + // Construct raw HTTP JSON: converting the fixture to a JS number would lose precision first. + const summary = `{"points_balance":${literal},"monthly_spent":${literal},"monthly_chart":[{"month":"2026-09","points":${literal}}]}`; + const body = + envelope === "direct" + ? summary + : envelope === "tRPC" + ? `[{"result":{"data":${summary}}}]` + : `[{"result":{"data":{"json":${summary}}}}]`; + const client = new BaiClient({ baiApiKey: "test-key" }, 1000, async () => new Response(body)); + const usage = await new BaiService(client).usage(); + expect(JSON.parse(JSON.stringify(usage))).toEqual({ + credits: expected, + thisMonth: { month: "2026-09", credits: expected }, + trend: [{ month: "2026-09", credits: expected }], + }); + const text = baiUsageText(usage); + expect(text).toContain(`Credit balance: ${expected}\n`); + expect(text).toContain(`Credits spent this month: ${expected}\n`); + expect(text).toContain(`credits: ${expected}`); + }); + }); + it("uses the live B.AI origin and prevents credential-bearing redirects", async () => { const fetcher = vi.fn(async () => response([ @@ -101,12 +132,36 @@ describe("BaiClient", () => { await expect( client.usageList({ page: 2, pageSize: 10, sortBy: "createdAt", sortOrder: "desc" }), - ).resolves.toMatchObject({ items: [{ id: "u1" }], page: 2, pageSize: 10, total: 11 }); + ).resolves.toEqual({ + items: [{ id: "u1", total_tokens: 42 }], + page: 2, + pageSize: 10, + total: 11, + }); await expect( client.rechargeList({ page: 1, pageSize: 20, sortBy: "createdAt", sortOrder: "desc" }), ).resolves.toMatchObject({ items: [{ id: "o1", status: "paid" }], page: 1, pageSize: 20 }); }); + it.each([ + ['[{"error":{"json":{"code":-32001,"data":{"code":"UNAUTHORIZED"}}}}]', 200, "bai_auth_failed"], + [ + '[{"error":{"json":{"code":-32029,"data":{"code":"TOO_MANY_REQUESTS"}}}}]', + 200, + "provider_rate_limited", + ], + ['{"error":{"json":{"code":-32603}}}', 500, "provider_error"], + ['{"points_balance":', 200, "provider_error"], + ['{"points_balance":', 503, "provider_error"], + ])("classifies summary response %s at HTTP %s as %s", async (body, status, code) => { + const client = new BaiClient( + { baiApiKey: "test-key" }, + 1000, + async () => new Response(body, { status }), + ); + await expect(client.status()).rejects.toMatchObject({ code }); + }); + it("classifies missing credentials and rejected credentials without exposing the key", async () => { const missing = new BaiClient({}, 1000, vi.fn(), "https://bai.example"); await expect(missing.status()).rejects.toMatchObject({ code: "bai_credentials_missing" }); diff --git a/ts/src/adapters/outbound/bai/client.ts b/ts/src/adapters/outbound/bai/client.ts index 74fa8e8ab..d9b1e50c5 100644 --- a/ts/src/adapters/outbound/bai/client.ts +++ b/ts/src/adapters/outbound/bai/client.ts @@ -1,6 +1,7 @@ import { baiApiError } from "./api-error.js"; import { boundedResponse, MAX_HTTP_RESPONSE_BYTES } from "../http/http-response.js"; import { z } from "zod"; +import { parse as parseLosslessJson } from "lossless-json"; import type { BaiApi, BaiPageInput, @@ -116,7 +117,13 @@ export class BaiClient implements BaiApi { } let decoded: unknown; try { - decoded = JSON.parse(await response.text()); + const body = await response.text(); + // Summary scalars are exposed as strings; preserve numeric literals before any rounding. + // Lists retain their existing numeric fields and pagination contract. + decoded = + procedure === "usage.summary" + ? parseLosslessJson(body, undefined, (value) => value) + : JSON.parse(body); } catch { const statusError = baiApiError(undefined, procedure, response.status); if (statusError) throw statusError; diff --git a/ts/src/adapters/outbound/keystore/index.ts b/ts/src/adapters/outbound/keystore/index.ts index fc2ccee2a..ee54bb8ac 100644 --- a/ts/src/adapters/outbound/keystore/index.ts +++ b/ts/src/adapters/outbound/keystore/index.ts @@ -29,7 +29,12 @@ import { Derivation } from "../../../domain/derivation/index.js"; import { familyOf, canonicalAddress, CHAIN_FAMILIES } from "../../../domain/family/index.js"; import { SOURCE_KINDS, sourceFamily } from "../../../domain/sources/index.js"; import { AtomicFileStore } from "../persistence/fs/index.js"; -import { ExecutionError, UsageError, WalletError } from "../../../domain/errors/index.js"; +import { + errorMessage, + ExecutionError, + UsageError, + WalletError, +} from "../../../domain/errors/index.js"; import { accountIndices, accountRefOf, @@ -93,6 +98,7 @@ export class Keystore { const password = this.getPassword(); let source: Source; + let plaintext: Bytes; const walletId = this.#freshId("wlt", file); if (p.type === "seed") { @@ -113,20 +119,9 @@ export class Keystore { return { accountId: dup, created: false }; } const vaultId = this.#freshId("vlt", file); - // global master-password invariant: establish/verify the sentinel inside the - // SAME lock as the blob write, so two parallel first-imports can't seed two passwords. - this.#assertPassword({ createIfAbsent: true }); // persist passphrase inside the encrypted vault so decryptSeed reconstructs the SAME // seed (otherwise the displayed address and the signing key would diverge). - this.#writeBlob( - "vaults", - CryptoEnvelope.encrypt( - encodeVault(entropy, p.passphrase), - password, - vaultId, - "bip39-seed", - ), - ); + plaintext = encodeVault(entropy, p.passphrase); source = { type: "seed", vaultId, addresses: { "0": addr0 } }; } else { // hexToBytes throws its own Error on a non-hex character, which classifyError would turn @@ -159,8 +154,7 @@ export class Keystore { return { accountId: dup, created: false }; } const keyId = this.#freshId("key", file); - this.#assertPassword({ createIfAbsent: true }); // sentinel, inside this lock - this.#writeBlob("keys", CryptoEnvelope.encrypt(pk, password, keyId, "raw-privkey")); + plaintext = pk; source = { type: "privateKey", keyId, addresses: addr }; } @@ -169,6 +163,20 @@ export class Keystore { file.wallets.push(wallet); this.#assignLabel(file, ref, p.label); file.activeAccount = ref; + // Validate the new record before persisting anything: a rejected label must not leave + // an unindexed blob or establish the master password for a watch/ledger-only store. + // Keep the sentinel and blob writes in this same lock so parallel first imports cannot + // establish different master passwords. + this.#assertPassword({ createIfAbsent: true }); + this.#writeBlob( + source.type === "seed" ? "vaults" : "keys", + CryptoEnvelope.encrypt( + plaintext, + password, + source.type === "seed" ? source.vaultId : source.keyId, + source.type === "seed" ? "bip39-seed" : "raw-privkey", + ), + ); this.#write(file); return { accountId: ref, created: true }; }); @@ -241,7 +249,8 @@ export class Keystore { return this.store.withLock(this.walletsPath, () => { const file = this.#read(); const wallet = file.wallets.find((w) => w.id === walletId); - if (!wallet) throw new UsageError("account_not_found", `unknown wallet ${walletId}`); + if (!wallet) + throw new UsageError("account_not_found", errorMessage`unknown wallet ${walletId}`); // only seed wallets are HD: privateKey has no derivation, ledger must be re-imported per path. if (wallet.source.type !== "seed") { const hint = @@ -331,7 +340,8 @@ export class Keystore { const ref = this.#toRef(file, refOrLabel, family); const [walletId, idxStr] = ref.split("."); const wallet = file.wallets.find((w) => w.id === walletId); - if (!wallet) throw new UsageError("account_not_found", `unknown account ${refOrLabel}`); + if (!wallet) + throw new UsageError("account_not_found", errorMessage`unknown account ${refOrLabel}`); this.#assertReadable(wallet, refOrLabel); if (wallet.source.type !== "seed") return { wallet, index: -1 }; let index: number; @@ -342,14 +352,14 @@ export class Keystore { if (known.length > 1) { throw new UsageError( "invalid_value", - `'${refOrLabel}' selects a multi-account seed wallet; specify an account ref, e.g. ${wallet.id}.${known[0]}`, + errorMessage`'${refOrLabel}' selects a multi-account seed wallet; specify an account ref, e.g. ${wallet.id}.${known[0]}`, ); } index = known[0] ?? 0; } else { index = Number(idxStr); if (!Number.isInteger(index) || index < 0) { - throw new WalletError("invalid_value", `invalid account ref '${refOrLabel}'`); + throw new WalletError("invalid_value", errorMessage`invalid account ref '${refOrLabel}'`); } } return { wallet, index }; @@ -360,7 +370,8 @@ export class Keystore { const ref = this.#toRef(file, idOrLabel); const walletId = ref.split(".")[0]!; const wallet = file.wallets.find((w) => w.id === walletId); - if (!wallet) throw new UsageError("account_not_found", `unknown wallet ${idOrLabel}`); + if (!wallet) + throw new UsageError("account_not_found", errorMessage`unknown wallet ${idOrLabel}`); this.#assertReadable(wallet, idOrLabel); return wallet; } @@ -459,7 +470,8 @@ export class Keystore { const ref = this.#toRef(file, refOrWallet); const [walletId, idxStr] = ref.split("."); const wallet = file.wallets.find((w) => w.id === walletId); - if (!wallet) throw new WalletError("invalid_value", `unknown wallet ${refOrWallet}`); + if (!wallet) + throw new WalletError("invalid_value", errorMessage`unknown wallet ${refOrWallet}`); this.#assertReadable(wallet, refOrWallet); // account-level delete: a non-root HD sub-account ref (wlt_x.N, N>0) forgets just that index @@ -468,7 +480,7 @@ export class Keystore { // cascades to the whole wallet (all children + secret) via the wallet-level branch below. if (wallet.source.type === "seed" && idxStr !== undefined && idxStr !== "0") { if (!(idxStr in wallet.source.addresses)) { - throw new WalletError("invalid_value", `unknown account ${refOrWallet}`); + throw new WalletError("invalid_value", errorMessage`unknown account ${refOrWallet}`); } delete wallet.source.addresses[idxStr]; delete file.labels[ref]; @@ -697,7 +709,7 @@ export class Keystore { if (SOURCE_KINDS[wallet.source.type] !== undefined) return; throw new WalletError( "encoding_error", - `${input} uses a wallet format this version does not understand; upgrade wallet-cli to use it`, + errorMessage`${input} uses a wallet format this version does not understand; upgrade wallet-cli to use it`, ); } @@ -738,7 +750,7 @@ export class Keystore { if (family !== undefined && family !== addrFamily) { throw new UsageError( "family_mismatch", - `${input} is ${addrFamily === "evm" ? "an" : "a"} ${addrFamily} address, but this command runs on ${family}; address the account by its accountId or label to use it here`, + errorMessage`${input} is ${addrFamily === "evm" ? "an" : "a"} ${addrFamily} address, but this command runs on ${family}; address the account by its accountId or label to use it here`, ); } // Canonicalised on BOTH sides: enumerateAddresses already yields EIP-55, and the CLI accepts an @@ -758,7 +770,7 @@ export class Keystore { } } if (hits.length === 0) - throw new UsageError("account_not_found", `no account with address ${input}`); + throw new UsageError("account_not_found", errorMessage`no account with address ${input}`); const interchangeable = family === addrFamily && hits.every((h) => SOURCE_KINDS[h.wallet.source.type].hasSecret); if (hits.length > 1 && !interchangeable) { @@ -766,7 +778,7 @@ export class Keystore { // `matches` carries the columns a human needs to actually pick one. throw new UsageError( "ambiguous_account", - `address ${input} matches ${hits.length} accounts; address it by accountId`, + errorMessage`address ${input} matches ${hits.length} accounts; address it by accountId`, { address: wanted, accountIds: hits.map((h) => h.ref), @@ -790,11 +802,11 @@ export class Keystore { // The ambiguous cases below keep `invalid_value`: the reference IS valid, it just picks more // than one account, and the fix is to narrow it rather than to go looking for it. if (matches.length === 0) - throw new UsageError("account_not_found", `no account labelled '${input}'`); + throw new UsageError("account_not_found", errorMessage`no account labelled '${input}'`); if (matches.length > 1) { throw new UsageError( "invalid_value", - `label '${input}' is ambiguous: ${matches.map((m) => m[0]).join(", ")}`, + errorMessage`label '${input}' is ambiguous: ${matches.map((m) => m[0]).join(", ")}`, ); } return matches[0]![0]; @@ -806,7 +818,10 @@ export class Keystore { throw new UsageError("invalid_value", "label must not start with 'wlt_'"); for (const [k, existing] of Object.entries(file.labels)) { if (existing.trim().toLowerCase() === value.toLowerCase() && k !== ref) { - throw new UsageError("invalid_value", `label '${value}' already in use by ${k}`); + throw new UsageError( + "invalid_value", + errorMessage`label '${value}' already in use by ${k}`, + ); } } file.labels[ref] = value; diff --git a/ts/src/adapters/outbound/keystore/keystore.test.ts b/ts/src/adapters/outbound/keystore/keystore.test.ts index f79e3f940..08e91b3c3 100644 --- a/ts/src/adapters/outbound/keystore/keystore.test.ts +++ b/ts/src/adapters/outbound/keystore/keystore.test.ts @@ -10,7 +10,7 @@ vi.mock( ); import { mkdtempSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { bytesToHex } from "@noble/hashes/utils.js"; import { Keystore, walletAddress } from "./index.js"; import type { CliError } from "../../../domain/errors/index.js"; @@ -37,6 +37,28 @@ function freshKeystore() { return new Keystore(root, new AtomicFileStore(), () => "masterpw123A"); } +// Compare both the file set and exact persisted bytes, including unindexed blobs/verifiers. +function storageSnapshot(root: string): Record { + const snapshot: Record = {}; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) { + snapshot[entry.name] = null; + for (const [relative, bytes] of Object.entries(storageSnapshot(path))) { + snapshot[join(entry.name, relative)] = bytes; + } + } else { + snapshot[entry.name] = readFileSync(path); + } + } + return snapshot; +} + +const NEW_IMPORTS = [ + { type: "seed", secret: MNEMONIC, passphrase: "distinct seed" }, + { type: "privateKey", secret: EVM_KEY_OF_MNEMONIC_ACCOUNT_0 }, +] as const; + describe("Keystore", () => { let ks: Keystore; beforeEach(() => { @@ -277,6 +299,73 @@ describe("Keystore", () => { ); }); + it.each(NEW_IMPORTS)("rejects a duplicate label without persisting a $type blob", (input) => { + ks.import({ secret: MNEMONIC, type: "seed", label: "main" }); + const active = ks.activeAccount(); + const before = storageSnapshot(dirname(ks.walletsPath)); + + expect(() => ks.import({ ...input, label: " MAIN " })).toThrow(/already in use/); + + expect(ks.activeAccount()).toBe(active); + expect(storageSnapshot(dirname(ks.walletsPath))).toEqual(before); + }); + + describe.each(["watch", "ledger"] as const)("with only a %s account", (type) => { + it.each(NEW_IMPORTS)( + "rejects a duplicate label without establishing a $type password", + (input) => { + if (type === "watch") { + ks.registerWatch({ family: "tron", address: TRON0, label: "main" }); + } else { + ks.registerLedger({ family: "tron", path: LEDGER_PATH, address: TRON0, label: "main" }); + } + const active = ks.activeAccount(); + const before = storageSnapshot(dirname(ks.walletsPath)); + expect(before["verifier.json"]).toBeUndefined(); + + expect(() => ks.import({ ...input, label: "main" })).toThrow(/already in use/); + + expect(ks.activeAccount()).toBe(active); + expect(storageSnapshot(dirname(ks.walletsPath))).toEqual(before); + }, + ); + }); + + it.each(NEW_IMPORTS)( + "reactivates a duplicate $type before checking its requested label", + (input) => { + const original = ks.import({ ...input, label: "original" }); + const watch = ks.registerWatch({ family: "tron", address: TRON0, label: "occupied" }); + ks.setActive(watch.accountId); + const before = storageSnapshot(dirname(ks.walletsPath)); + const registry = JSON.parse(before["wallets.json"]!.toString()) as WalletsFile; + + expect(ks.import({ ...input, label: "occupied" })).toEqual({ + accountId: original.accountId, + created: false, + }); + + expect(ks.activeAccount()).toBe(original.accountId); + registry.activeAccount = original.accountId; + before["wallets.json"] = Buffer.from(JSON.stringify(registry, null, 2) + "\n"); + expect(storageSnapshot(dirname(ks.walletsPath))).toEqual(before); + }, + ); + + it("keeps default labels numbered from one and skips occupied names", () => { + ks.import({ secret: MNEMONIC, type: "seed" }); + ks.import({ secret: EVM_KEY_OF_MNEMONIC_ACCOUNT_0, type: "privateKey" }); + ks.registerWatch({ family: "tron", address: TRON0, label: "wallet-4" }); + ks.import({ secret: MNEMONIC, type: "seed", passphrase: "distinct seed" }); + + expect(ks.list().map((account) => account.label)).toEqual([ + "wallet-1", + "wallet-2", + "wallet-4", + "wallet-5", + ]); + }); + it("setActive switches the active account and reports the previous", () => { const a = ks.import({ secret: MNEMONIC, type: "seed", label: "main" }).accountId; const pk = "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; diff --git a/ts/src/adapters/outbound/x402/allowance.test.ts b/ts/src/adapters/outbound/x402/allowance.test.ts index 5fca79f9b..524487967 100644 --- a/ts/src/adapters/outbound/x402/allowance.test.ts +++ b/ts/src/adapters/outbound/x402/allowance.test.ts @@ -438,46 +438,73 @@ describe("x402 TRON auto approve keeps its evidence", () => { expect(warnings.some((w) => w.includes(broadcast[0]!))).toBe(true); }); - it("includes the approval in a successful payment result", async () => { - const { key, address, broadcast, challenge } = harness("auto"); - const sign = vi.fn(async (tx: unknown) => tronSignStrategy.sign(key, tx)); - const signTypedData = vi.fn(async (p: TypedDataPayload) => - tronSignStrategy.signTypedData(key, p), - ); - const fetcher = vi.fn(async (request, init) => { - const req = new Request(request, init); - if (!req.headers.get("payment-signature")) - return Response.json(challenge, { - status: 402, + it.each(["text", "binary"])( + "retains the approval after a settled %s response", + async (contentType) => { + const { key, address, broadcast, challenge } = harness("auto"); + const sign = vi.fn(async (tx: unknown) => tronSignStrategy.sign(key, tx)); + const signTypedData = vi.fn(async (p: TypedDataPayload) => + tronSignStrategy.signTypedData(key, p), + ); + const fetcher = vi.fn(async (request, init) => { + const req = new Request(request, init); + if (!req.headers.get("payment-signature")) + return Response.json(challenge, { + status: 402, + headers: { + "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }, + }); + return new Response("ok", { headers: { - "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64"), + "content-type": contentType === "binary" ? "application/octet-stream" : "text/plain", + "payment-response": Buffer.from( + JSON.stringify({ + success: true, + network: "tron:0xcd8690dc", + transaction: "a".repeat(64), + }), + ).toString("base64"), }, }); - return new Response("ok"); - }); - const client = new X402PaymentClient( - { - assertCanSign() {}, - resolve: () => ({ kind: "software", address, sign, signTypedData }), - } as never, - fetcher, - ); - const result = await client.pay( - { activeAccount: "payer", timeoutMs: 1000, emit() {}, warn() {} } as never, - net, - input, - ); - expect(broadcast).toHaveLength(1); - expect(result).toMatchObject({ - delivered: true, - approval: { + }); + const client = new X402PaymentClient( + { + assertCanSign() {}, + resolve: () => ({ kind: "software", address, sign, signTypedData }), + } as never, + fetcher, + ); + const pending = client.pay( + { activeAccount: "payer", timeoutMs: 1000, emit() {}, warn() {} } as never, + net, + input, + ); + const result = await pending.catch((error) => error); + expect(broadcast).toHaveLength(1); + const approval = { txId: broadcast[0], token: USDT_NILE, spender: PERMIT2_NILE, status: "confirmed", - }, - }); - }); + }; + if (contentType === "binary") { + expect(result).toMatchObject({ + code: "invalid_option", + details: { + paymentStatus: "settled", + settled: true, + txHash: "a".repeat(64), + retryPayment: false, + approval, + }, + }); + expect(result.exitCode()).toBe(2); + } else { + expect(result).toMatchObject({ delivered: true, settled: true, approval }); + } + }, + ); it("marks a sponsored approval as exported when the payment request then fails", async () => { const { key, address, broadcast, challenge } = harness("sponsored"); diff --git a/ts/src/adapters/outbound/x402/binary-response.test.ts b/ts/src/adapters/outbound/x402/binary-response.test.ts new file mode 100644 index 000000000..562df619c --- /dev/null +++ b/ts/src/adapters/outbound/x402/binary-response.test.ts @@ -0,0 +1,205 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { X402PaymentClient } from "./payment-client.js"; +import type { NetworkDescriptor, Signer } from "../../../domain/types/index.js"; +import type { SignerResolver } from "../../../application/services/signer/index.js"; +import { CliError } from "../../../domain/errors/index.js"; + +const net = { id: "eip155:56", family: "evm", chainId: "56" } as NetworkDescriptor; +const signer = { + kind: "software", + address: "0x1111111111111111111111111111111111111111", +} as Signer; +const scope = { activeAccount: "payer", timeoutMs: 1000, emit() {} } as never; +const input = { url: "https://api.example/resource", method: "GET", headers: [] }; +const transaction = "0x" + "a".repeat(64); +const receipt = { success: true, network: net.id, transaction }; +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +function harness( + bytes: Uint8Array, + contentType?: string, + mode: "free" | "settled" | "unknown" = "free", +) { + const headers = new Headers(); + if (contentType) headers.set("content-type", contentType); + if (mode === "settled") + headers.set("payment-response", Buffer.from(JSON.stringify(receipt)).toString("base64")); + const fetcher = vi.fn(async () => new Response(new Uint8Array(bytes), { headers })); + const resolver = { + assertCanSign: vi.fn(), + resolve: vi.fn(() => signer), + } as unknown as SignerResolver; + const client = + mode === "free" + ? new X402PaymentClient(resolver, fetcher) + : new X402PaymentClient(resolver, globalThis.fetch, async () => fetcher); + return { client, fetcher, resolver }; +} + +async function rejection(promise: Promise): Promise { + const result = await promise.catch((error: unknown) => error); + expect(result).toBeInstanceOf(CliError); + return result as CliError; +} + +describe("x402 response bytes", () => { + it.each([ + "application/octet-stream", + "image/png", + "audio/mpeg", + "video/mp4", + "application/pdf", + "application/zip", + ])("requires --out for %s even when the bytes are valid UTF-8", async (contentType) => { + const { client, fetcher, resolver } = harness( + new TextEncoder().encode("binary content"), + contentType, + ); + const error = await rejection(client.pay(scope, net, input)); + expect(error).toMatchObject({ + code: "invalid_option", + kind: "usage", + details: { paymentStatus: "not_sent", retryPayment: false }, + }); + expect(error.exitCode()).toBe(2); + expect(fetcher).toHaveBeenCalledOnce(); + expect(resolver.resolve).not.toHaveBeenCalled(); + }); + + it.each(["free", "settled", "unknown"] as const)( + "rejects binary without --out and preserves %s payment status", + async (mode) => { + const { client, fetcher } = harness( + Uint8Array.from([0, 255, 1, 254]), + "application/octet-stream", + mode, + ); + const error = await rejection(client.pay(scope, net, input)); + expect(error).toMatchObject({ + code: "invalid_option", + kind: "usage", + details: { paymentStatus: mode === "free" ? "not_sent" : mode, retryPayment: false }, + }); + expect(error.exitCode()).toBe(2); + if (mode === "settled") + expect(error.details).toMatchObject({ + phase: "response", + settled: true, + txHash: transaction, + paymentResponse: receipt, + payer: { address: signer.address }, + }); + expect(error.message).not.toMatch(/rerun|retry with|run again/i); + expect(fetcher).toHaveBeenCalledOnce(); + }, + ); + + it.each(["application/octet-stream", "application/json", "text/plain", undefined])( + "writes original bytes with --out regardless of encoding (%s)", + async (contentType) => { + const bytes = Uint8Array.from([0, 255, 1, 254]); + const { client } = harness(bytes, contentType, "settled"); + const directory = await mkdtemp(join(tmpdir(), "wallet-cli-response-")); + directories.push(directory); + const out = join(directory, "response.bin"); + const result = await client.pay(scope, net, { ...input, out }); + expect(await readFile(out)).toEqual(Buffer.from(bytes)); + expect(result).toMatchObject({ + settled: true, + paymentResponse: receipt, + output: { path: out, bytes: 4 }, + }); + expect(result).not.toHaveProperty("response"); + }, + ); + + it("writes a UTF-8-compatible binary payload without putting it in the result", async () => { + const bytes = new TextEncoder().encode("binary\u0000content\r\n"); + const { client } = harness(bytes, "application/octet-stream"); + const directory = await mkdtemp(join(tmpdir(), "wallet-cli-response-")); + directories.push(directory); + const out = join(directory, "response.bin"); + const result = await client.pay(scope, net, { ...input, out }); + expect(await readFile(out)).toEqual(Buffer.from(bytes)); + expect(result).toMatchObject({ output: { path: out, bytes: bytes.byteLength } }); + expect(result).not.toHaveProperty("response"); + }); + + it("preserves an initial response settlement even without resolving a signer", async () => { + const { fetcher, resolver } = harness( + Uint8Array.from([255]), + "application/octet-stream", + "settled", + ); + const client = new X402PaymentClient(resolver, fetcher); + const error = await rejection(client.pay(scope, net, input)); + expect(error).toMatchObject({ + code: "invalid_option", + details: { + paymentStatus: "settled", + settled: true, + txHash: transaction, + retryPayment: false, + }, + }); + expect(resolver.resolve).not.toHaveBeenCalled(); + }); + + it.each(["free", "unknown"] as const)("keeps %s status on malformed UTF-8", async (mode) => { + const { client } = harness(Uint8Array.from([255]), "text/plain", mode); + const error = await rejection(client.pay(scope, net, input)); + expect(error).toMatchObject({ + code: "invalid_x402_response", + details: { paymentStatus: mode === "free" ? "not_sent" : "unknown", retryPayment: false }, + }); + }); + + it.each([ + ["application/json; charset=utf-8", '{"value":"中文"}', { value: "中文" }], + ["Application/Problem+JSON", '{"value":7}', { value: 7 }], + ["text/plain", "hello 中文\r\n", "hello 中文\r\n"], + ["application/xml", "", ""], + ["application/vnd.example", "custom text", "custom text"], + [undefined, "untyped text", "untyped text"], + ])( + "preserves supported text/JSON response semantics (%s)", + async (contentType, text, response) => { + const { client } = harness( + new TextEncoder().encode(text as string), + contentType as string | undefined, + ); + await expect(client.pay(scope, net, input)).resolves.toMatchObject({ + response, + settled: false, + }); + }, + ); + + it.each(["application/json", "text/plain", undefined])( + "rejects malformed UTF-8 without silent replacements (%s)", + async (contentType) => { + const bytes = Uint8Array.from([34, 255, 34]); + const { client } = harness(bytes, contentType, "settled"); + const error = await rejection(client.pay(scope, net, input)); + expect(error).toMatchObject({ + code: "invalid_x402_response", + details: { + paymentStatus: "settled", + settled: true, + txHash: transaction, + retryPayment: false, + }, + }); + expect(error.message).toContain("UTF-8"); + expect(error.exitCode()).toBe(1); + }, + ); +}); diff --git a/ts/src/adapters/outbound/x402/payment-client.ts b/ts/src/adapters/outbound/x402/payment-client.ts index 169a5abd0..e10a6c6e7 100644 --- a/ts/src/adapters/outbound/x402/payment-client.ts +++ b/ts/src/adapters/outbound/x402/payment-client.ts @@ -96,7 +96,9 @@ export class X402PaymentClient implements X402PaymentPort { method: input.method, headers, redirect: "error", - ...(input.body === undefined ? {} : { body: input.body }), + ...(input.body === undefined + ? {} + : { body: typeof input.body === "string" ? input.body : new Uint8Array(input.body) }), }; scope.emit({ type: "activity", @@ -262,10 +264,38 @@ export class X402PaymentClient implements X402PaymentPort { await out.write(bytes); return { ...base, output: { path: out.path, bytes: bytes.byteLength } }; } - const text = new TextDecoder().decode(bytes); - const contentType = response.headers.get("content-type") ?? ""; + const contentType = (response.headers.get("content-type") ?? "") + .split(";", 1)[0]! + .trim() + .toLowerCase(); + const responseDetails = { + phase: "response", + paymentStatus: signer ? "unknown" : "not_sent", + retryPayment: false, + httpStatus: response.status, + delivered: response.ok, + settled: false, + }; + if (isBinaryContentType(contentType)) { + throw new UsageError( + "invalid_option", + "binary response requires --out to save its bytes; check payment details before any further payment", + responseDetails, + ); + } + let text: string; + try { + // Missing or unfamiliar MIME types retain their text fallback, without replacing bytes. + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new TransportError( + "invalid_x402_response", + "endpoint returned invalid UTF-8; --out is required to save raw bytes; check payment details before any further payment", + responseDetails, + ); + } let body: unknown = text; - if (/json/i.test(contentType) && text !== "") { + if ((contentType === "application/json" || contentType.endsWith("+json")) && text !== "") { try { body = JSON.parse(text); } catch { @@ -454,6 +484,15 @@ export class X402PaymentClient implements X402PaymentPort { } } +function isBinaryContentType(contentType: string): boolean { + return ( + /^(?:image|audio|video|font)\//.test(contentType) || + /^application\/(?:octet-stream|pdf|zip|gzip|x-gzip|x-tar|x-bzip2|x-7z-compressed|x-rar-compressed|vnd\.rar|wasm|cbor|protobuf|x-protobuf)$/.test( + contentType, + ) + ); +} + interface PaymentAuthorization { /** A payment authorization (typed data or EVM payment transaction) has been signed. */ signed: boolean; @@ -465,7 +504,7 @@ interface PaymentAuthorization { function withApproval(error: CliError, approval: PaymentAuthorization["approval"]): CliError { if (!approval) return error; const ErrorType = error.kind === "usage" ? UsageError : TransportError; - return new ErrorType(error.code, error.message, { ...error.details, approval }); + return new ErrorType(error.code, error, { ...error.details, approval }); } interface OutputReservation { @@ -724,7 +763,7 @@ function settlementError( if (!successfulSettlement(receipt, expectedNetwork)) return classified; const settlement = receipt as Record; const ErrorType = classified.kind === "usage" ? UsageError : TransportError; - return new ErrorType(classified.code, classified.message, { + return new ErrorType(classified.code, classified, { ...classified.details, phase: "response", paymentStatus: "settled", diff --git a/ts/src/adapters/outbound/x402/payment-error.test.ts b/ts/src/adapters/outbound/x402/payment-error.test.ts index e70ac169e..ca4e6fae7 100644 --- a/ts/src/adapters/outbound/x402/payment-error.test.ts +++ b/ts/src/adapters/outbound/x402/payment-error.test.ts @@ -1,6 +1,26 @@ import { expect, it } from "vitest"; -import { sdkPaymentError, providerPaymentError } from "./payment-error.js"; -import { UsageError, TransportError } from "../../../domain/errors/index.js"; +import { sdkPaymentError, providerPaymentError, unsentPaymentError } from "./payment-error.js"; +import { errorMessage, UsageError, TransportError } from "../../../domain/errors/index.js"; + +it.each([ + ["sdkPaymentError", sdkPaymentError], + ["unsentPaymentError", unsentPaymentError], +] as const)( + "preserves safe text and raw JSON when adding payment context (%s)", + (_name, classify) => { + const original = new UsageError( + "account_not_found", + errorMessage`unknown account ${"missing\nFAKE_LOG"}; reconcile before paying again`, + ); + const error = classify(original, "sign"); + expect(error.textMessage).toContain("missing\\nFAKE_LOG"); + expect(error.textMessage).not.toContain("\n"); + expect(error.toEnvelope().message).toContain("missing\nFAKE_LOG"); + expect(error.textMessage.endsWith("; no payment was sent")).toBe( + classify === unsentPaymentError, + ); + }, +); it.each([403, 429, 502])( "reports HTTP %s and stage without leaking SDK request contents", (status) => { diff --git a/ts/src/adapters/outbound/x402/payment-error.ts b/ts/src/adapters/outbound/x402/payment-error.ts index 74f99e085..14d44a8b3 100644 --- a/ts/src/adapters/outbound/x402/payment-error.ts +++ b/ts/src/adapters/outbound/x402/payment-error.ts @@ -81,7 +81,7 @@ export function sdkPaymentError(error: unknown, phase?: PaymentPhase): CliError ); if (!phase || details?.phase) return error; const ErrorType = error.kind === "usage" ? UsageError : TransportError; - return new ErrorType(error.code, error.message, { + return new ErrorType(error.code, error, { ...error.details, phase, retryPayment: false, @@ -254,7 +254,16 @@ export function unsentPaymentError(error: unknown, phase: PaymentPhase): CliErro const ErrorType = classified.kind === "usage" ? UsageError : TransportError; return new ErrorType( classified.code, - classified.message.replace(/; reconcile before paying again/g, "; no payment was sent"), + { + message: classified.message.replace( + /; reconcile before paying again/g, + "; no payment was sent", + ), + textMessage: classified.textMessage.replace( + /; reconcile before paying again/g, + "; no payment was sent", + ), + }, { ...details, paymentStatus: "not_sent", retryPayment: false }, ); } diff --git a/ts/src/application/ports/x402-payment.ts b/ts/src/application/ports/x402-payment.ts index 5b73ed529..3a7db9a3f 100644 --- a/ts/src/application/ports/x402-payment.ts +++ b/ts/src/application/ports/x402-payment.ts @@ -5,7 +5,7 @@ export interface X402PayInput { url: string; method: string; headers: string[]; - body?: string; + body?: string | Uint8Array; token?: string; asset?: string; decimals?: number; diff --git a/ts/src/application/use-cases/bai-recharge-flow.test.ts b/ts/src/application/use-cases/bai-recharge-flow.test.ts index c907007e5..b261d7ad6 100644 --- a/ts/src/application/use-cases/bai-recharge-flow.test.ts +++ b/ts/src/application/use-cases/bai-recharge-flow.test.ts @@ -1,4 +1,4 @@ -import { TransportError } from "../../domain/errors/index.js"; +import { errorMessage, TransportError, UsageError } from "../../domain/errors/index.js"; import { expect, it, vi } from "vitest"; import { BaiRechargeFlow } from "./bai-recharge-flow.js"; const input = { @@ -60,6 +60,18 @@ it("does not contact binding endpoints during an already configured recharge", a expect(api.isBound).not.toHaveBeenCalled(); expect(api.bind).not.toHaveBeenCalled(); }); +it("preserves safe text and raw JSON when attaching recharge context", async () => { + const { flow, pay, api } = fixture(); + pay.mockRejectedValue( + new UsageError("account_not_found", errorMessage`unknown account ${"missing\nFAKE_LOG"}`), + ); + const error = await flow.execute(input).catch((error: unknown) => error); + expect(error).toBeInstanceOf(UsageError); + expect((error as UsageError).textMessage).toBe("unknown account missing\\nFAKE_LOG"); + expect((error as UsageError).toEnvelope().message).toBe("unknown account missing\nFAKE_LOG"); + expect(api.reportTxHash).not.toHaveBeenCalled(); +}); + it("does not pay if preorder creation fails", async () => { const { flow, api, pay } = fixture(); api.createOrder.mockRejectedValue(new Error("unavailable")); diff --git a/ts/src/application/use-cases/bai-recharge-flow.ts b/ts/src/application/use-cases/bai-recharge-flow.ts index ea8b73427..d13466dac 100644 --- a/ts/src/application/use-cases/bai-recharge-flow.ts +++ b/ts/src/application/use-cases/bai-recharge-flow.ts @@ -36,7 +36,7 @@ export class BaiRechargeFlow { // Preserve classified payment failures and any settlement evidence. if (error instanceof CliError) { const ErrorType = error.kind === "usage" ? UsageError : TransportError; - throw new ErrorType(error.code, error.message, { + throw new ErrorType(error.code, error, { paymentStatus: "unknown", ...error.details, retryPayment: false, diff --git a/ts/src/application/use-cases/contact-service.ts b/ts/src/application/use-cases/contact-service.ts index 48f8f4325..3835b7b09 100644 --- a/ts/src/application/use-cases/contact-service.ts +++ b/ts/src/application/use-cases/contact-service.ts @@ -2,7 +2,7 @@ import type { ContactRepository } from "../ports/contact-repository.js"; import type { ContactEntry, ContactListView, ContactView } from "../../domain/types/index.js"; import { contactNameKey, createContact } from "../../domain/contact/index.js"; import { CHAIN_FAMILIES, familyOf } from "../../domain/family/index.js"; -import { UsageError } from "../../domain/errors/index.js"; +import { errorMessage, UsageError } from "../../domain/errors/index.js"; export class ContactService { constructor(private readonly contacts: ContactRepository) {} @@ -20,7 +20,10 @@ export class ContactService { const value = address.trim(); const family = familyOf(value); if (!family) { - throw new UsageError("invalid_address", `not a recognised chain address: ${address}`); + throw new UsageError( + "invalid_address", + errorMessage`not a recognised chain address: ${address}`, + ); } const key = contactNameKey(name); const clash = this.#entries().find((e) => e.nameKey === key || e.address === value); @@ -28,8 +31,8 @@ export class ContactService { throw new UsageError( "already_exists", clash.nameKey === key - ? `a contact named ${clash.name} already exists` - : `that address is already stored as ${clash.name}`, + ? errorMessage`a contact named ${clash.name} already exists` + : errorMessage`that address is already stored as ${clash.name}`, ); } return publicContact(this.contacts.add(createContact(family, name, value, note))); @@ -49,7 +52,7 @@ export class ContactService { const key = contactNameKey(name); const family = CHAIN_FAMILIES.find((f) => this.contacts.find(f, key)); if (!family) { - throw new UsageError("contact_not_found", `contact not found: ${name}`); + throw new UsageError("contact_not_found", errorMessage`contact not found: ${name}`); } return publicContact(this.contacts.remove(family, key)); } diff --git a/ts/src/domain/errors/errors.test.ts b/ts/src/domain/errors/errors.test.ts index 08423e539..c0f89ab56 100644 --- a/ts/src/domain/errors/errors.test.ts +++ b/ts/src/domain/errors/errors.test.ts @@ -1,5 +1,28 @@ import { describe, it, expect } from "vitest"; -import { classifyError, normalizeError, ChainError, UsageError } from "./index.js"; +import { classifyError, normalizeError, ChainError, UsageError, errorMessage } from "./index.js"; + +describe("errorMessage trusted layout and external values", () => { + it("escapes input line separators while preserving authored multiline guidance", () => { + const input = "first\r\nsecond\u0085third\u2028fourth\u2029last"; + const error = new UsageError("invalid_value", errorMessage`Invalid: ${input}\nTry again.`); + expect(error.textMessage).toBe( + "Invalid: first\\r\\nsecond\\u0085third\\u2028fourth\\u2029last\nTry again.", + ); + expect(error.toEnvelope().message).toBe(`Invalid: ${input}\nTry again.`); + }); + + it("keeps ordinary paths, quotes, amounts, and plain authored messages unchanged", () => { + const path = "C:\\wallet's files\\tx.hex"; + const error = new UsageError( + "invalid_value", + errorMessage`File '${path}' exceeds ${1024} bytes`, + ); + expect(error.textMessage).toBe(error.message); + expect(new UsageError("invalid_value", "First line\nSecond line").textMessage).toBe( + "First line\nSecond line", + ); + }); +}); describe("classifyError (classify half of the classify↔render split)", () => { it("passes a CliError through unchanged (already canonical)", () => { diff --git a/ts/src/domain/errors/index.ts b/ts/src/domain/errors/index.ts index 0c529d713..6960e0f58 100644 --- a/ts/src/domain/errors/index.ts +++ b/ts/src/domain/errors/index.ts @@ -6,14 +6,48 @@ import type { ExitCode } from "../types/primitives.js"; import { ERROR_CODES, type ErrorCodeEntry } from "./codes.js"; +/** Keep source-authored layout separate from untrusted, single-line interpolations. */ +interface ErrorMessage { + readonly message: string; + readonly textMessage: string; +} + +/** Escape physical line separators in one error field, before composing its text layout. */ +export function singleLineErrorValue(value: string): string { + return value.replace(/[\n\r\u0085\u2028\u2029]/g, (char) => { + if (char === "\n") return "\\n"; + if (char === "\r") return "\\r"; + return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`; + }); +} + +/** + * Tag messages containing external values. JSON/Error.message retain the original data; + * text output escapes line separators in interpolations only, preserving authored guidance. + * Ordinary inputs keep their existing spelling (including quotes and path separators). + */ +export function errorMessage(strings: TemplateStringsArray, ...values: unknown[]): ErrorMessage { + let message = strings[0]!; + let textMessage = message; + for (const [index, value] of values.entries()) { + const raw = String(value); + const literal = strings[index + 1]!; + message += raw + literal; + textMessage += singleLineErrorValue(raw) + literal; + } + return { message, textMessage }; +} + export abstract class CliError extends Error { abstract readonly kind: "usage" | "execution"; + readonly textMessage: string; constructor( public readonly code: string, - message: string, + message: string | ErrorMessage, public readonly details?: object, ) { - super(message); + super(typeof message === "string" ? message : message.message); + this.textMessage = typeof message === "string" ? message : message.textMessage; this.name = new.target.name; } /** diff --git a/ts/test/README.md b/ts/test/README.md index 7a006f0a0..34069f905 100644 --- a/ts/test/README.md +++ b/ts/test/README.md @@ -14,9 +14,18 @@ Golden tests build the CLI once and start a fresh process per invocation. A supp `WALLET_CLI_TEST_ENTRY` selects an independently built or installed artifact and skips the local build; callers must ensure that artifact is current. -The golden project uses at most four workers because each worker also starts a full CLI -process, and wallet operations run the production password KDF. Override with Vitest's -`--maxWorkers` flag when measuring a different machine. +Each Vitest invocation uses at most ten unit workers, leaving one available CPU core free +(with a minimum of one worker). Remaining test files wait for a free worker. The golden +project keeps its lower limit of four workers because each worker also starts a full CLI +process, and wallet operations run the production password KDF. `npm test` runs unit and golden projects in +sequence, so their worker pools do not overlap. + +These limits apply to test workers in a single Vitest invocation. They do not include the +coordinator, CLI or fixture subprocesses, or workers from other terminals or agents. Run +one test invocation at a time to avoid multiplying resource use. To reduce concurrency on a +memory-constrained machine, use `VITEST_MAX_WORKERS=2 npm test` (or `npm run test:unit` / +`npm run test:golden`). This overrides both project limits; keep the value at or below ten. +The root `--maxWorkers` flag does not override these explicit project settings in Vitest 4. The main golden suite uses a shared harness that creates an encrypted seed fixture once per label, then copies it diff --git a/ts/vitest.config.ts b/ts/vitest.config.ts index 02f1e557b..46871caa4 100644 --- a/ts/vitest.config.ts +++ b/ts/vitest.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ test: { name: "unit", environment: "node", + // Bound the per-run process pool while leaving one CPU for the coordinator/OS. + maxWorkers: Math.min(10, Math.max(1, availableParallelism() - 1)), include: ["src/**/*.test.ts"], testTimeout: 20_000, hookTimeout: 20_000,