From bf165dc6bcdca3618e8a97b895f3f42a1bd70cc2 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 21 Oct 2026 11:43:05 +0800 Subject: [PATCH 1/5] fix(ts): pin x402 raw payment limits to a token `x402 pay --max-raw-amount` without `--token`/`--asset` turned off the SDK spend controls (its $1 cap and its default-asset allowlist) and put a unitless raw cap in their place. A seller could then name any token, or the same stablecoin at another precision, under a cap sized for a different one, and the CLI would sign a Permit2 authorization for it. - Reject `--max-raw-amount` without `--token` or `--asset` in the pay schema (invalid_option), and again in selectMatching before anything is signed. - `x402 roundtrip --raw-amount` pins its payment to the asset its own paywall serves. - Spend-control hints no longer suggest an unpinned raw limit. - The pay result reports what the created payment pays in `data.payment` (asset, token, decimals, amount, rawAmount), taken from the requirement the SDK built the payment for; text output shows Amount and Asset. --- ts/docs/commands/x402/pay.md | 13 +- .../cli/commands/r4-validation.test.ts | 24 +- .../inbound/cli/commands/x402.test.ts | 1 + ts/src/adapters/inbound/cli/commands/x402.ts | 14 +- .../adapters/inbound/cli/render/x402.test.ts | 22 ++ ts/src/adapters/inbound/cli/render/x402.ts | 6 +- .../outbound/x402/nile-compatibility.test.ts | 1 + .../outbound/x402/payment-client.test.ts | 2 +- .../adapters/outbound/x402/payment-client.ts | 41 ++- .../adapters/outbound/x402/payment-error.ts | 4 +- .../x402/payment-signer-integration.test.ts | 6 +- .../outbound/x402/raw-limit-asset.test.ts | 251 ++++++++++++++++++ .../adapters/outbound/x402/roundtrip.test.ts | 48 ++++ ts/src/application/use-cases/x402-service.ts | 5 +- ts/test/beta-command-surface.test.ts | 22 +- 15 files changed, 442 insertions(+), 18 deletions(-) create mode 100644 ts/src/adapters/outbound/x402/raw-limit-asset.test.ts diff --git a/ts/docs/commands/x402/pay.md b/ts/docs/commands/x402/pay.md index 7cc12c530..15436a877 100644 --- a/ts/docs/commands/x402/pay.md +++ b/ts/docs/commands/x402/pay.md @@ -16,7 +16,7 @@ wallet-cli x402 pay [--method ] [--header "Name: value"]... [--body Sends the request. If the endpoint answers with a successful status (2xx), that response is returned as-is and nothing is paid; any other status except `402` fails with `provider_error`, carrying `httpStatus` and `phase: "request"`. If it answers `402 Payment Required`, `pay` reads the payment routes it offers, picks one that matches the selected `--network` and your filters, signs a payment authorization with the active account (or `--account`), and sends the request again with it. The endpoint's facilitator settles the payment on chain. -**Nothing is signed until a route matches.** Routes on other networks are ignored; `--token`, `--asset` and `--scheme` narrow the choice further, and `--max-amount` (whole tokens) or `--max-raw-amount` (smallest units) rules out a route priced above it. If no route matches, the command fails with `no_matching_requirement`; if the matching route is priced over the limit, with `amount_exceeds_limit`. Both are raised before signing, with `paymentStatus: "not_sent"`, and no password is asked for — with or without `--dry-run`. Without a limit of your own, a built-in ceiling of **$1 per payment** applies to the known stablecoins; a route priced above it also fails with `amount_exceeds_limit`, and passing `--max-amount` or `--max-raw-amount` replaces that ceiling with yours. +**Nothing is signed until a route matches.** Routes on other networks are ignored; `--token`, `--asset` and `--scheme` narrow the choice further, and `--max-amount` (whole tokens) or `--max-raw-amount` (smallest units) rules out a route priced above it. If no route matches, the command fails with `no_matching_requirement`; if the matching route is priced over the limit, with `amount_exceeds_limit`. Both are raised before signing, with `paymentStatus: "not_sent"`, and no password is asked for — with or without `--dry-run`. A raw amount has no unit, so `--max-raw-amount` requires `--token` or `--asset` to name the token it counts (`invalid_option` otherwise). Without a limit of your own, only the known stablecoins are accepted, under a built-in ceiling of **$1 per payment**; a route priced above it also fails with `amount_exceeds_limit`. A limit of your own replaces that ceiling and the stablecoin restriction, but other tokens are still refused: `--max-amount` needs a token whose decimals are known (or `--asset` with `--decimals`), and `--max-raw-amount` pays only the token you pinned. **Two payment schemes:** @@ -52,7 +52,7 @@ Requires an account, even for an endpoint that turns out to be free. The master | `--body ` | Request body; excludes `--body-file` | | `--body-file ` | Read the request body from a file, or from stdin with `-`; excludes `--body` | | `--max-amount ` | Refuse a route priced above this, in whole tokens; excludes `--max-raw-amount` | -| `--max-raw-amount ` | The same limit in smallest units | +| `--max-raw-amount ` | The same limit in smallest units; requires `--token` or `--asset`, since a raw amount has no unit | | `--token ` | Accept only routes paying in this token | | `--asset
` | Accept only routes paying in this token contract | | `--decimals ` | Decimals of the `--asset` token; requires `--asset` | @@ -114,6 +114,8 @@ URL https://x402-gateway.bankofai.io/providers/dia-price-tron/v1/quotat Status 200 Settled Yes Delivered Yes +Amount 0.000001 (1 base units) +Asset USDT From TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6 Transaction 9b41c7e2d05f83a6e1c4b8d27f9a03e5c6d8b1f4a2e7c9d0b3f5a8e1c6d2b7f4 Output btc-quote.json @@ -124,10 +126,10 @@ printf '%s' "$PW" | wallet-cli x402 pay https://x402-gateway.bankofai.io/provide ``` ```json -{"schema":"wallet-cli.result.v1","success":true,"command":"x402.pay","data":{"url":"https://x402-gateway.bankofai.io/providers/dia-price-tron/v1/quotation/BTC","status":200,"delivered":true,"settled":true,"payer":{"address":"TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"},"paymentResponse":{"success":true,"transaction":"9b41c7e2d05f83a6e1c4b8d27f9a03e5c6d8b1f4a2e7c9d0b3f5a8e1c6d2b7f4","network":"tron:0x2b6653dc","payer":"0xe2e1a54926527fbb4e4420de4c6bab82beaee24d"},"output":{"path":"btc-quote.json","bytes":214}},"meta":{"durationMs":6412,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} +{"schema":"wallet-cli.result.v1","success":true,"command":"x402.pay","data":{"url":"https://x402-gateway.bankofai.io/providers/dia-price-tron/v1/quotation/BTC","status":200,"delivered":true,"settled":true,"payer":{"address":"TWer2Ygk5TEheHp3TPuYeqxmB6SsGZmaL6"},"paymentResponse":{"success":true,"transaction":"9b41c7e2d05f83a6e1c4b8d27f9a03e5c6d8b1f4a2e7c9d0b3f5a8e1c6d2b7f4","network":"tron:0x2b6653dc","payer":"0xe2e1a54926527fbb4e4420de4c6bab82beaee24d"},"output":{"path":"btc-quote.json","bytes":214},"payment":{"asset":"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t","token":"USDT","decimals":6,"amount":"0.000001","rawAmount":"1"}},"meta":{"durationMs":6412,"warnings":[]},"chain":{"family":"tron","network":"tron:728126428","chainId":"728126428"}} ``` -`Settled Yes` means the payment went through on chain; `Delivered Yes` means the paid response arrived and was written to `btc-quote.json`. `From` is the paying account and `Transaction` the payment's transaction ID (JSON: `data.payer.address` and `data.paymentResponse.transaction`). The facilitator submits the payment transaction and pays its energy, so the payment itself costs the account only the quoted price. This account already had a Permit2 allowance; on a first payment without one, see the Permit2 approve note above. +`Settled Yes` means the payment went through on chain; `Delivered Yes` means the paid response arrived and was written to `btc-quote.json`. `Amount` and `Asset` are what was paid (JSON: `data.payment`). `From` is the paying account and `Transaction` the payment's transaction ID (JSON: `data.payer.address` and `data.paymentResponse.transaction`). The facilitator submits the payment transaction and pays its energy, so the payment itself costs the account only the quoted price. This account already had a Permit2 allowance; on a first payment without one, see the Permit2 approve note above. ## Output @@ -138,6 +140,7 @@ printf '%s' "$PW" | wallet-cli x402 pay https://x402-gateway.bankofai.io/provide | `delivered` | boolean | Whether the final response was successful (2xx) | | `settled` | boolean | Whether a valid settlement receipt for the selected network came back | | `payer` | object | `{address}` of the paying account; present when a payment was signed | +| `payment` | object | What the signed payment pays, present when one was created: `asset` (token contract), `token` (symbol, when the token is registered), `decimals` and `amount` (whole tokens, when the decimals are known), and `rawAmount` (smallest units) | | `paymentResponse` | object | The facilitator's settlement receipt, when the endpoint sent one: `success`, `transaction` (the payment's transaction ID), `network`, and `payer` — both as the facilitator writes them, e.g. `tron:0xcd8690dc` and a hex address | | `approval` | object | TRON only, when the payment needed a one-time Permit2 approve: `{txId, token, spender, allowance: "unlimited", feeLimitSun, status}`. `status` is `confirmed` (broadcast and mined before the payment was signed) or `exported` (signed into the payment package for the endpoint to sponsor). The same object appears in `error.details.approval` when anything after the approve fails | | `response` | any | The response body — parsed JSON, or text; absent with `--out` | @@ -146,7 +149,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` — 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). +`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`, `--max-raw-amount` without `--token` or `--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/r4-validation.test.ts b/ts/src/adapters/inbound/cli/commands/r4-validation.test.ts index 2b1b7027a..8d0281ba4 100644 --- a/ts/src/adapters/inbound/cli/commands/r4-validation.test.ts +++ b/ts/src/adapters/inbound/cli/commands/r4-validation.test.ts @@ -12,9 +12,9 @@ describe("R4 payment input contracts", () => { it.each([ [{ maxAmount: "0" }, "invalid_amount"], [{ maxAmount: "1e3" }, "invalid_amount"], - [{ maxRawAmount: "0" }, "invalid_amount"], - [{ maxRawAmount: (1n << 256n).toString() }, "invalid_amount"], - [{ maxAmount: "1", maxRawAmount: "1" }, "invalid_option"], + [{ maxRawAmount: "0", token: "USDT" }, "invalid_amount"], + [{ maxRawAmount: (1n << 256n).toString(), token: "USDT" }, "invalid_amount"], + [{ maxAmount: "1", maxRawAmount: "1", token: "USDT" }, "invalid_option"], [{ body: "", bodyFile: "body.json" }, "invalid_option"], [{ maxGasfreeFee: "1", maxGasfreeFeeRaw: "1" }, "invalid_option"], ])("rejects %j with %s", (input, code) => { @@ -22,6 +22,24 @@ describe("R4 payment input contracts", () => { expect.objectContaining({ code }), ); }); + it("rejects a raw payment limit that names no token, before anything is requested", () => { + expect(() => + parse("pay", { url: "https://example.test", maxRawAmount: "1000000000000000000" }), + ).toThrow( + expect.objectContaining({ + code: "invalid_option", + message: expect.stringContaining("--max-raw-amount: requires --token or --asset"), + }), + ); + }); + it.each([{ token: "USDT" }, { asset: "0x55d398326f99059fF775485246999027B3197955" }])( + "accepts a raw payment limit pinned by %j", + (pin) => { + expect( + parse("pay", { url: "https://example.test", maxRawAmount: "1000000", ...pin }), + ).toMatchObject({ maxRawAmount: "1000000", ...pin }); + }, + ); it.each(["1e3", "1.5", "-1"])("rejects port literal %s", (port) => { expect(() => parse("serve", { payTo: "x", port })).toThrow(); }); diff --git a/ts/src/adapters/inbound/cli/commands/x402.test.ts b/ts/src/adapters/inbound/cli/commands/x402.test.ts index 402405160..5e6ede3a9 100644 --- a/ts/src/adapters/inbound/cli/commands/x402.test.ts +++ b/ts/src/adapters/inbound/cli/commands/x402.test.ts @@ -53,6 +53,7 @@ describe("x402 command surface", () => { header: [], maxAmount: "1", maxRawAmount: "1", + token: "USDT", }).success, ).toBe(false); }); diff --git a/ts/src/adapters/inbound/cli/commands/x402.ts b/ts/src/adapters/inbound/cli/commands/x402.ts index 345a54677..6cf4c0226 100644 --- a/ts/src/adapters/inbound/cli/commands/x402.ts +++ b/ts/src/adapters/inbound/cli/commands/x402.ts @@ -34,7 +34,9 @@ const payFields = z.object({ decimals: integerLiteral(0, 18).optional().describe("decimals for an explicit asset"), scheme: z.enum(["exact", "exact_gasfree"]).optional(), maxAmount: paymentAmount.optional().describe("maximum payment in whole tokens"), - maxRawAmount: rawPaymentAmount.optional().describe("maximum payment in smallest units"), + maxRawAmount: rawPaymentAmount + .optional() + .describe("maximum payment in smallest units; requires --token or --asset"), dryRun: z.boolean().default(false).describe("inspect the payment challenge without signing"), gasfreeRelay: z .string() @@ -60,6 +62,16 @@ const payInput = payFields.superRefine((value, context) => { message: "cannot be combined with --max-raw-amount", }); } + // A raw limit has no unit: unpinned, it would cap whatever token the seller names. + if (value.maxRawAmount !== undefined && !value.token && !value.asset) { + context.addIssue({ + code: "custom", + params: { errorCode: "invalid_option" }, + path: ["maxRawAmount"], + message: + "requires --token or --asset; a raw limit has no unit, so pin it to one token or use --max-amount", + }); + } if (value.body !== undefined && value.bodyFile !== undefined) { context.addIssue({ code: "custom", diff --git a/ts/src/adapters/inbound/cli/render/x402.test.ts b/ts/src/adapters/inbound/cli/render/x402.test.ts index ff975a206..ac16d216a 100644 --- a/ts/src/adapters/inbound/cli/render/x402.test.ts +++ b/ts/src/adapters/inbound/cli/render/x402.test.ts @@ -72,6 +72,28 @@ it("separates response text while output-file receipts omit body", () => { "--- response ---", ); }); +it("shows the paid asset and amount after a payment", () => { + const rendered = paymentText({ + status: 200, + settled: true, + payment: { + asset: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + token: "USDT", + decimals: 6, + amount: "0.01", + rawAmount: "10000", + }, + }); + expect(rendered).toMatch(/^Amount\s+0\.01 \(10000 base units\)$/m); + expect(rendered).toMatch(/^Asset\s+USDT$/m); + const unregistered = paymentText({ + status: 200, + payment: { asset: "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c", rawAmount: "5" }, + }); + expect(unregistered).toMatch(/^Amount\s+5 base units$/m); + expect(unregistered).toMatch(/^Asset\s+0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c$/m); + expect(paymentText({ status: 200 })).not.toMatch(/Amount|Asset/); +}); it("wraps a wide-character summary with aligned continuation lines", () => { const original = Object.getOwnPropertyDescriptor(process.stdout, "columns"); Object.defineProperty(process.stdout, "columns", { value: 32, configurable: true }); diff --git a/ts/src/adapters/inbound/cli/render/x402.ts b/ts/src/adapters/inbound/cli/render/x402.ts index 1935ac30e..2e06a9d83 100644 --- a/ts/src/adapters/inbound/cli/render/x402.ts +++ b/ts/src/adapters/inbound/cli/render/x402.ts @@ -104,12 +104,16 @@ export function providerEndpointsText(value: unknown): string { ); } export function paymentText(value: unknown): string { - const p = asObj(value); + const p = asObj(value), + paid = asObj(p.payment); + const raw = typeof paid.rawAmount === "string" ? `${text(paid.rawAmount)} base units` : ""; const details = query([ ["URL", text(p.url)], ["Status", text(p.status)], ["Settled", p.settled === true ? "Yes" : "No"], ["Delivered", p.delivered === true ? "Yes" : "No"], + ["Amount", typeof paid.amount === "string" && raw ? `${text(paid.amount)} (${raw})` : raw], + ["Asset", text(paid.token ?? paid.asset)], ["From", text(asObj(p.payer).address)], ["Transaction", text(asObj(p.paymentResponse).transaction)], ["Approval", text(asObj(p.approval).txId)], diff --git a/ts/src/adapters/outbound/x402/nile-compatibility.test.ts b/ts/src/adapters/outbound/x402/nile-compatibility.test.ts index 0384494f4..df2ca7dc0 100644 --- a/ts/src/adapters/outbound/x402/nile-compatibility.test.ts +++ b/ts/src/adapters/outbound/x402/nile-compatibility.test.ts @@ -165,6 +165,7 @@ it("uses the selected relay to construct and sign the final GasFree permit", asy scheme: "exact_gasfree", gasfreeRelay: "https://relay.example/nile", maxRawAmount: "10000", + asset, }, ), ).resolves.toMatchObject({ delivered: true }); diff --git a/ts/src/adapters/outbound/x402/payment-client.test.ts b/ts/src/adapters/outbound/x402/payment-client.test.ts index 494d6855d..613137c5a 100644 --- a/ts/src/adapters/outbound/x402/payment-client.test.ts +++ b/ts/src/adapters/outbound/x402/payment-client.test.ts @@ -594,7 +594,7 @@ it.each(["tron:3448148188", "tron:0xcd8690dc"])( ); it.each([ - [{ maxRawAmount: "99" }, "amount_exceeds_limit"], + [{ maxRawAmount: "99", token: "USDT" }, "amount_exceeds_limit"], [{ maxAmount: "0.000000000000000099" }, "amount_exceeds_limit"], [{ token: "USDD" }, "no_matching_requirement"], [{ scheme: "exact_gasfree" as const }, "no_matching_requirement"], diff --git a/ts/src/adapters/outbound/x402/payment-client.ts b/ts/src/adapters/outbound/x402/payment-client.ts index e10a6c6e7..9fd8c441e 100644 --- a/ts/src/adapters/outbound/x402/payment-client.ts +++ b/ts/src/adapters/outbound/x402/payment-client.ts @@ -43,6 +43,7 @@ import { toX402Wallet } from "./signer-bridge.js"; import { createPayerSigner } from "../../../application/services/x402/payer-signer.js"; import type { PayerSigner } from "../../../application/contracts/x402-payer.js"; import { toX402Network, sameX402Network } from "../../../domain/x402/network-id.js"; +import { fromBaseUnits } from "../../../domain/amounts/index.js"; type PaidFetchFactory = ( network: NetworkDescriptor, @@ -159,7 +160,13 @@ export class X402PaymentClient implements X402PaymentPort { out, toX402Network(network), ); - return authorization.approval ? { ...result, approval: authorization.approval } : result; + return { + ...result, + ...(authorization.requirement + ? { payment: paidAmount(network, authorization.requirement, input) } + : {}), + ...(authorization.approval ? { approval: authorization.approval } : {}), + }; } catch (error) { await out?.release(); const classified = authorization.signed @@ -439,6 +446,10 @@ export class X402PaymentClient implements X402PaymentPort { client.onPaymentCreationFailure(async ({ error }) => { creationError = sdkPaymentError(error, "create_payment"); }); + // Report the requirement the SDK actually built the payment for, not an earlier candidate. + client.onAfterPaymentCreation(async ({ selectedRequirements }) => { + authorization.requirement = selectedRequirements; + }); const boundedFetch = this.boundedFetch(scope, network, signer); let first: Response | undefined = initial; const fetchWithInitial: typeof fetch = (request, init) => { @@ -498,6 +509,27 @@ interface PaymentAuthorization { signed: boolean; /** The one-time Permit2 approve, once signed, and how far the SDK has taken it. */ approval?: SignedApproval & { status: "submitted" | "confirmed" | "exported" }; + /** The requirement the created payment pays. */ + requirement?: { asset: string; amount: string }; +} + +/** What the signed payment pays: the token (symbol when registered) and its amount. */ +function paidAmount( + network: NetworkDescriptor, + requirement: { asset: string; amount: string }, + input: X402PayInput, +) { + const known = metadata(network.id, requirement.asset); + // An explicit --decimals is only accepted beside --asset, which this requirement matched. + const decimals = known?.decimals ?? input.decimals; + return { + asset: requirement.asset, + ...(known ? { token: known.symbol } : {}), + ...(decimals === undefined + ? {} + : { decimals, amount: fromBaseUnits(requirement.amount, decimals) }), + rawAmount: requirement.amount, + }; } /** Never lose the approve's evidence to whatever failed after it. */ @@ -624,6 +656,13 @@ function selectMatching( network: NetworkDescriptor, input: X402PayInput, ): T[] { + // A raw limit has no unit; without a pinned token it would cap whatever asset the seller names. + if (input.maxRawAmount !== undefined && !input.asset && !input.token) { + throw new UsageError( + "invalid_option", + "--max-raw-amount requires --token or --asset; a raw limit has no unit, so pin it to one token or use --max-amount", + ); + } const expectedNetwork = network.family === "tron" ? `tron:0x${BigInt(network.chainId).toString(16)}` : network.id; const matching = requirements.filter((requirement) => { diff --git a/ts/src/adapters/outbound/x402/payment-error.ts b/ts/src/adapters/outbound/x402/payment-error.ts index 14d44a8b3..5696b1bb1 100644 --- a/ts/src/adapters/outbound/x402/payment-error.ts +++ b/ts/src/adapters/outbound/x402/payment-error.ts @@ -165,8 +165,8 @@ export function sdkPaymentError(error: unknown, phase?: PaymentPhase): CliError return new ExecutionError( overLimit ? "amount_exceeds_limit" : "no_matching_requirement", overLimit - ? "payment exceeds the built-in $1 per-payment ceiling; pass --max-amount or --max-raw-amount to authorize it" - : "no offered payment route uses an asset the built-in spend control allows; pass --max-amount or --max-raw-amount to set the ceiling yourself", + ? "payment exceeds the built-in $1 per-payment ceiling; pass --max-amount, or --max-raw-amount with --token or --asset, to authorize it" + : "no offered payment route uses an asset the built-in spend control allows; pass --max-amount, or --max-raw-amount with --token or --asset, to set the ceiling yourself", { ...(phase ? { phase } : {}), paymentStatus: "not_sent", retryPayment: false }, ); } diff --git a/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts b/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts index f2d0fc71f..5d1669340 100644 --- a/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts +++ b/ts/src/adapters/outbound/x402/payment-signer-integration.test.ts @@ -86,6 +86,10 @@ describe("x402 SDK payer integration", () => { it("keeps the SDK on the requirement whose spend limit was checked", async () => { const { client, signTypedData } = fixture(false, true); - await client.pay(scope, network, { ...input, maxRawAmount: "10" }); + await client.pay(scope, network, { + ...input, + maxRawAmount: "10", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + }); expect(signTypedData.mock.calls[0]![0].message.value).toBe(1n); }); diff --git a/ts/src/adapters/outbound/x402/raw-limit-asset.test.ts b/ts/src/adapters/outbound/x402/raw-limit-asset.test.ts new file mode 100644 index 000000000..6e6ccea26 --- /dev/null +++ b/ts/src/adapters/outbound/x402/raw-limit-asset.test.ts @@ -0,0 +1,251 @@ +// A raw payment limit has no unit: it must be pinned to one token, or a seller can price the +// route in any asset (or the same asset at another precision) under a cap sized for a stablecoin. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Wallet } from "ethers"; +import { TronWeb, providers } from "tronweb"; +import { X402PaymentClient } from "./payment-client.js"; +import { evmSignStrategy } from "../chain/evm/signing-strategy.js"; +import { tronSignStrategy } from "../chain/tron/signing-strategy.js"; +import type { X402PayInput } from "../../../application/ports/x402-payment.js"; +import type { SignerResolver } from "../../../application/services/signer/index.js"; +import type { TransactionScope } from "../../../application/contracts/execution-scope.js"; +import type { NetworkDescriptor, TypedDataPayload } from "../../../domain/types/index.js"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const BSC_USDT = "0x55d398326f99059fF775485246999027B3197955"; // 18 decimals +const BSC_BTCB = "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c"; // 18 decimals, not registered +const TRON_USDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; // 6 decimals +const ONE_E18 = "1000000000000000000"; + +const scope = { + activeAccount: "payer", + timeoutMs: 2000, + emit() {}, + warn() {}, +} as unknown as TransactionScope; + +interface Outcome { + result?: Record; + error?: { code?: string }; + typedData: TypedDataPayload[]; + txs: unknown[]; + requests: number; + paidRequests: number; +} + +function seller(requirement: Record, out: Outcome): typeof fetch { + const challenge = { + x402Version: 2, + resource: { url: "https://seller.example" }, + accepts: [{ maxTimeoutSeconds: 300, ...requirement }], + }; + return async (request, init) => { + out.requests++; + if (!new Request(request, init).headers.get("payment-signature")) + return new Response(null, { + status: 402, + headers: { "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64") }, + }); + out.paidRequests++; + return new Response("ok"); + }; +} + +async function payEvm(asset: string, input: Partial): Promise { + const wallet = Wallet.createRandom(); + // Permit2 already holds an allowance, so the only signature is the payment authorization. + vi.stubGlobal( + "fetch", + vi.fn(async (request, init) => { + const body = (await new Request(request, init).json()) as { method: string; id: number }; + const result = + body.method === "eth_call" + ? "0x" + "f".repeat(64) + : body.method === "eth_getTransactionCount" + ? "0x3" + : null; + return Response.json({ jsonrpc: "2.0", id: body.id, result }); + }), + ); + const out: Outcome = { typedData: [], txs: [], requests: 0, paidRequests: 0 }; + const signers = { + assertCanSign() {}, + prepare() {}, + resolve: () => ({ + address: wallet.address, + kind: "software", + sign: (tx: unknown) => { + out.txs.push(tx); + return evmSignStrategy.sign(wallet.privateKey, tx as never); + }, + signTypedData: (payload: TypedDataPayload) => { + out.typedData.push(payload); + return evmSignStrategy.signTypedData(wallet.privateKey, payload); + }, + }), + } as unknown as SignerResolver; + const fetcher = seller( + { + scheme: "exact", + network: "eip155:56", + amount: ONE_E18, + asset, + payTo: "0x2222222222222222222222222222222222222222", + extra: { assetTransferMethod: "permit2", name: "x", version: "1" }, + }, + out, + ); + try { + out.result = await new X402PaymentClient(signers, fetcher).pay( + scope, + { + family: "evm", + chainId: "56", + id: "eip155:56", + httpEndpoint: "https://rpc.example", + } as NetworkDescriptor, + { url: "https://seller.example", method: "GET", headers: [], ...input }, + ); + } catch (error) { + out.error = error as { code?: string }; + } + return out; +} + +async function payTron(amount: string, input: Partial): Promise { + const key = Wallet.createRandom().privateKey; + const address = TronWeb.address.fromPrivateKey(key.slice(2)) as string; + // Permit2 already holds an allowance, so no approve is built. + 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 RPC ${String(path)}`); + }); + const out: Outcome = { typedData: [], txs: [], requests: 0, paidRequests: 0 }; + const signers = { + assertCanSign() {}, + prepare() {}, + resolve: () => ({ + address, + kind: "software", + sign: async (tx: unknown) => { + out.txs.push(tx); + return tronSignStrategy.sign(key, tx as never); + }, + signTypedData: async (payload: TypedDataPayload) => { + out.typedData.push(payload); + return tronSignStrategy.signTypedData(key, payload); + }, + }), + } as unknown as SignerResolver; + const fetcher = seller( + { + scheme: "exact", + network: "tron:0x2b6653dc", + amount, + asset: TRON_USDT, + payTo: "TCLBgkbfVkJroVBJVqBEsxtPNQEQMTQCLQ", + extra: { assetTransferMethod: "permit2" }, + }, + out, + ); + try { + out.result = await new X402PaymentClient(signers, fetcher).pay( + scope, + { + id: "tron:728126428", + chainId: "728126428", + family: "tron", + httpEndpoint: "http://127.0.0.1:1", + } as NetworkDescriptor, + { url: "https://seller.example", method: "GET", headers: [], ...input }, + ); + } catch (error) { + out.error = error as { code?: string }; + } + return out; +} + +function expectRefusedBeforeSigning(out: Outcome) { + expect(out.error).toMatchObject({ + code: "invalid_option", + details: { paymentStatus: "not_sent", retryPayment: false }, + }); + expect(out.typedData).toHaveLength(0); + expect(out.txs).toHaveLength(0); + expect(out.paidRequests).toBe(0); +} + +describe("x402 raw payment limit pinning", () => { + it("EVM: refuses an unpinned raw limit when the seller names an unregistered token", async () => { + const out = await payEvm(BSC_BTCB, { maxRawAmount: ONE_E18 }); + expectRefusedBeforeSigning(out); + }); + + it("EVM: refuses an unpinned raw limit in --dry-run as well", async () => { + const out = await payEvm(BSC_BTCB, { maxRawAmount: ONE_E18, dryRun: true }); + expect(out.error).toMatchObject({ code: "invalid_option" }); + expect(out.result).toBeUndefined(); + }); + + it("TRON: refuses a raw limit sized for USDD when the seller asks 1e12 raw USDT", async () => { + const out = await payTron("1000000000000", { maxRawAmount: ONE_E18 }); + expectRefusedBeforeSigning(out); + }); + + it.each([{ token: "USDT" }, { asset: BSC_USDT }])( + "EVM: pays an honest route with a raw limit pinned by %j and reports what it paid", + async (pin) => { + const out = await payEvm(BSC_USDT, { maxRawAmount: ONE_E18, ...pin }); + expect(out.error).toBeUndefined(); + expect(out.typedData).toHaveLength(1); + expect(out.paidRequests).toBe(1); + expect(out.result).toMatchObject({ + delivered: true, + payment: { + asset: BSC_USDT, + token: "USDT", + decimals: 18, + amount: "1", + rawAmount: ONE_E18, + }, + }); + }, + ); + + it.each([{ token: "USDT" }, { asset: TRON_USDT }])( + "TRON: pays an honest route with a raw limit pinned by %j and reports what it paid", + async (pin) => { + const out = await payTron("1000000", { maxRawAmount: "1000000", ...pin }); + expect(out.error).toBeUndefined(); + expect(out.typedData).toHaveLength(1); + expect(out.paidRequests).toBe(1); + expect(out.result).toMatchObject({ + delivered: true, + payment: { + asset: TRON_USDT, + token: "USDT", + decimals: 6, + amount: "1", + rawAmount: "1000000", + }, + }); + }, + ); + + it("still refuses a pinned token's route priced above the raw limit", async () => { + const out = await payTron("1000000000000", { maxRawAmount: "1000000", token: "USDT" }); + expect(out.error).toMatchObject({ code: "amount_exceeds_limit" }); + expect(out.typedData).toHaveLength(0); + }); + + it("reports an unregistered pinned asset by address and raw amount only", async () => { + const out = await payEvm(BSC_BTCB, { maxRawAmount: ONE_E18, asset: BSC_BTCB }); + expect(out.error).toBeUndefined(); + expect(out.result?.payment).toEqual({ asset: BSC_BTCB, rawAmount: ONE_E18 }); + }); +}); diff --git a/ts/src/adapters/outbound/x402/roundtrip.test.ts b/ts/src/adapters/outbound/x402/roundtrip.test.ts index c696addd5..4f0247998 100644 --- a/ts/src/adapters/outbound/x402/roundtrip.test.ts +++ b/ts/src/adapters/outbound/x402/roundtrip.test.ts @@ -129,3 +129,51 @@ it.each([ expect(signTypedData).toHaveBeenCalledOnce(); expect(calls).toEqual(["/verify", "/settle"]); }); + +it("pins a raw-amount roundtrip to the asset its own paywall serves", async () => { + const payer = "0x1111111111111111111111111111111111111111"; + const transaction = "0x" + "a".repeat(64); + const signTypedData = vi.fn(async (payload) => ({ + primaryType: payload.primaryType, + signature: "0x" + "a".repeat(130), + })); + const resolver = { + assertCanSign: vi.fn(), + resolve: () => ({ address: payer, kind: "software", signTypedData }), + } as unknown as SignerResolver; + const facilitator: typeof fetch = async (url) => + Response.json( + String(url).endsWith("/verify") + ? { isValid: true, payer } + : { success: true, transaction, network: "eip155:56", payer }, + ); + const service = new X402Service( + new X402PaymentClient(resolver), + {} as ProviderCatalogPort, + {} as never, + new X402HttpServer(facilitator), + ); + // No --token or --asset: the paywall's default token must still bound the raw limit. + const result = await service.roundtrip( + { activeAccount: "payer", timeoutMs: 2000, emit: vi.fn() } as never, + { id: "eip155:56", chainId: "56", family: "evm" } as never, + { + host: "127.0.0.1", + port: 0, + payTo: "0x2222222222222222222222222222222222222222", + rawAmount: "10000000000000000", + scheme: "exact", + facilitatorUrl: "https://facilitator.example", + }, + ); + expect(result.pay).toMatchObject({ + settled: true, + payment: { + asset: "0x55d398326f99059fF775485246999027B3197955", + token: "USDT", + amount: "0.01", + rawAmount: "10000000000000000", + }, + }); + expect(signTypedData).toHaveBeenCalledOnce(); +}); diff --git a/ts/src/application/use-cases/x402-service.ts b/ts/src/application/use-cases/x402-service.ts index 3e564802c..1066d1097 100644 --- a/ts/src/application/use-cases/x402-service.ts +++ b/ts/src/application/use-cases/x402-service.ts @@ -71,7 +71,10 @@ export class X402Service { token: input.token, scheme: input.scheme, expectedPayTo: input.payTo, - asset: input.asset, + // Pin the route to the asset this paywall serves; a raw limit has no unit of its own. + asset: + input.asset ?? + (typeof handle.details.asset === "string" ? handle.details.asset : undefined), decimals: input.decimals, exactAmount: input.rawAmount === undefined ? (input.amount ?? "0.0001") : undefined, maxAmount: input.rawAmount === undefined ? (input.amount ?? "0.0001") : undefined, diff --git a/ts/test/beta-command-surface.test.ts b/ts/test/beta-command-surface.test.ts index 66c6b4297..f812e7245 100644 --- a/ts/test/beta-command-surface.test.ts +++ b/ts/test/beta-command-surface.test.ts @@ -150,12 +150,30 @@ it.skipIf(!entry).each(["usage-summary", "usage-records", "recharge-orders"])( it.skipIf(!entry).each([ [["x402", "pay", "https://example.test", "--max-amount", "0"], "invalid_amount", 2], [ - ["x402", "pay", "https://example.test", "--max-raw-amount", (1n << 256n).toString()], + [ + "x402", + "pay", + "https://example.test", + "--max-raw-amount", + (1n << 256n).toString(), + "--token", + "USDT", + ], "invalid_amount", 2, ], [ - ["x402", "pay", "https://example.test", "--max-amount", "1", "--max-raw-amount", "1"], + [ + "x402", + "pay", + "https://example.test", + "--max-amount", + "1", + "--max-raw-amount", + "1", + "--token", + "USDT", + ], "invalid_option", 2, ], From fd6a161adddf706616a4823cdd0c11d37fec1364 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Wed, 21 Oct 2026 15:14:48 +0800 Subject: [PATCH 2/5] chore: ignore local agent tooling and audit output Keep Codex/MCP config, the CodeGraph index and local security audit output out of the repository. --- .gitignore | 6 ++++++ ts/.gitignore | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c4a893e36..03b3ed20d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ graphify-out/ # Scoped registry credentials for the private @sun-sdk packages; local-only until the SDK # is published to public npm (see ts/docs/superpowers/specs, D6). ts/.npmrc + +# Codex +.codex/ + +# MCP +.mcp.json \ No newline at end of file diff --git a/ts/.gitignore b/ts/.gitignore index 427ac3700..5f3868829 100644 --- a/ts/.gitignore +++ b/ts/.gitignore @@ -8,4 +8,6 @@ docs/superpowers docs/qa docs/adr CONTEXT.md -.boundary \ No newline at end of file +.boundary +audit-claude/ +.codegraph/ From 4110c68f5dee3d3562ed763b2c53c8d01331991e Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 22 Oct 2026 14:22:33 +0800 Subject: [PATCH 3/5] ops(ts): require the October 2026 Node.js security releases Node 20 is end-of-life and gets no fix for the October 2026 security releases; the Brotli out-of-bounds write reaches the CLI through fetch and axios responses from RPC, x402 and other user-chosen endpoints. - engines: >=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1 - standalone workflow: Node 20 -> 22 - CI: add Node 26 to the matrix; check-latest so a lagging runner image cannot test below the engines floor - README / CLAUDE.md: state the new minimum versions --- .github/workflows/ts-ci.yml | 3 +++ .github/workflows/ts-standalone-release.yml | 6 +++++- CLAUDE.md | 2 +- ts/README.md | 2 +- ts/package-lock.json | 2 +- ts/package.json | 2 +- 6 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ts-ci.yml b/.github/workflows/ts-ci.yml index 7e0a1a810..79404c2c1 100644 --- a/.github/workflows/ts-ci.yml +++ b/.github/workflows/ts-ci.yml @@ -32,6 +32,7 @@ jobs: node: - "22" - "24" + - "26" defaults: run: @@ -45,6 +46,8 @@ jobs: uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} + # The runner image can lag behind the security releases that `engines` requires. + check-latest: true cache: npm cache-dependency-path: ts/package-lock.json diff --git a/.github/workflows/ts-standalone-release.yml b/.github/workflows/ts-standalone-release.yml index f79f372c2..d0fd65b08 100644 --- a/.github/workflows/ts-standalone-release.yml +++ b/.github/workflows/ts-standalone-release.yml @@ -11,7 +11,7 @@ permissions: contents: read env: - NODE_VERSION: "20" + NODE_VERSION: "22" jobs: quality: @@ -29,6 +29,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} + # The runner image can lag behind the security releases that `engines` requires. + check-latest: true cache: npm cache-dependency-path: ts/package-lock.json @@ -94,6 +96,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} + # The runner image can lag behind the security releases that `engines` requires. + check-latest: true cache: npm cache-dependency-path: ts/package-lock.json diff --git a/CLAUDE.md b/CLAUDE.md index c5c4d3539..dc63e814f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Everything below (except the TypeScript Implementation section) refers to the Ja ## TypeScript Implementation -The `ts/` package is a self-contained, agent-first CLI (Node.js 20+, ESM, TypeScript). Every command +The `ts/` package is a self-contained, agent-first CLI (Node.js 22.23.4+ / 24.22.1+ / 26.12.1+, ESM, TypeScript). Every command has a stable JSON envelope, deterministic exit codes, and discoverable schemas; interactive prompts are used only for secret input (create / import / backup / delete). **All `ts/` commands run from the `ts/` directory.** diff --git a/ts/README.md b/ts/README.md index efcf8d8ef..35693e8ff 100644 --- a/ts/README.md +++ b/ts/README.md @@ -48,7 +48,7 @@ The TRON ids used before CAIP-2 (`tron:mainnet`, `tron:nile`, `tron:shasta`) rem ## Install -**Prerequisites**: [Node.js](https://nodejs.org) **20 or later** (`node --version` to check). Ledger signing additionally needs a supported Ledger device with the TRON or Ethereum app installed — see the [Ledger guide](docs/guide/ledger.md). +**Prerequisites**: [Node.js](https://nodejs.org) **22.23.4+, 24.22.1+ or 26.12.1+** — the October 2026 security releases (`node --version` to check). Ledger signing additionally needs a supported Ledger device with the TRON or Ethereum app installed — see the [Ledger guide](docs/guide/ledger.md). ```bash npm install -g @tron-walletcli/wallet-cli diff --git a/ts/package-lock.json b/ts/package-lock.json index f354a078b..da3ba39a6 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -65,7 +65,7 @@ "vitest": "^4.1.9" }, "engines": { - "node": ">=20" + "node": ">=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1" } }, "node_modules/@adraffy/ens-normalize": { diff --git a/ts/package.json b/ts/package.json index 3970afe65..006b5d0f6 100644 --- a/ts/package.json +++ b/ts/package.json @@ -41,7 +41,7 @@ "access": "public" }, "engines": { - "node": ">=20" + "node": ">=22.23.4 <23 || >=24.22.1 <25 || >=26.12.1" }, "scripts": { "build": "tsup", From bfa10672d85b6f3c6dbc232ac7eca8b7801e0ad4 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 22 Oct 2026 14:22:33 +0800 Subject: [PATCH 4/5] fix(ts): close the connection after an x402 402 challenge x402 roundtrip runs the paywall and the payer in one process. Between the 402 and the paid retry the payer decrypts its key (scrypt) and signs, which blocks the event loop. When that block outlasts the server's keep-alive timeout, the payer writes the retry on the idle socket just before the server's timer destroys it, and the payment fails with ECONNRESET and an unknown payment status. This surfaced as intermittent golden test failures on loaded CI runners. Send Connection: close with the 402 so the paid retry always opens a fresh connection. --- ts/src/adapters/outbound/x402/server.test.ts | 18 ++++++++++++++++++ ts/src/adapters/outbound/x402/server.ts | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/ts/src/adapters/outbound/x402/server.test.ts b/ts/src/adapters/outbound/x402/server.test.ts index 8e971ffd8..b950aaecb 100644 --- a/ts/src/adapters/outbound/x402/server.test.ts +++ b/ts/src/adapters/outbound/x402/server.test.ts @@ -64,6 +64,24 @@ it("rejects malformed request URLs and keeps serving health requests", async () }); }); +it("closes the connection after a 402 challenge so the paid retry cannot reuse a stale socket", async () => { + await withServer({}, async (port) => { + const response = await new Promise<{ status?: number; connection?: string }>( + (resolve, reject) => { + const req = request({ hostname: "127.0.0.1", port, path: "/pay" }, (res) => { + res.resume(); + res.on("end", () => + resolve({ status: res.statusCode, connection: res.headers.connection }), + ); + }); + req.on("error", reject); + req.end(); + }, + ); + expect(response).toEqual({ status: 402, connection: "close" }); + }); +}); + it.each([ [{ success: true, transaction: "", network: "tron:0xcd8690dc" }, 502], [{ success: true, transaction: "a".repeat(64), network: "eip155:1" }, 502], diff --git a/ts/src/adapters/outbound/x402/server.ts b/ts/src/adapters/outbound/x402/server.ts index 161a78282..1f4d1322a 100644 --- a/ts/src/adapters/outbound/x402/server.ts +++ b/ts/src/adapters/outbound/x402/server.ts @@ -200,6 +200,10 @@ export class X402HttpServer implements X402ServerPort { if (pathname !== "/pay") return json(response, 404, { error: "not found" }); const signature = request.headers["payment-signature"]; if (!signature || Array.isArray(signature)) { + // The paid retry follows key decryption and signing, which can block the client's event + // loop past keepAliveTimeout (roundtrip runs both ends in one process). A reused socket + // would then be reset under the retry, so make the client open a fresh one. + response.setHeader("connection", "close"); response.setHeader("payment-required", encodePaymentRequiredHeader(challenge as never)); return json(response, 402, challenge); } From 2fa52457a5a774ce7f02e71c031ce66e2f5dd7bf Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 22 Oct 2026 14:22:33 +0800 Subject: [PATCH 5/5] test(ts): widen golden budgets that ran out on loaded CI runners Golden cases spawn the CLI with real scrypt, and on a loaded 4-vCPU runner several of them measured close to or past their limits: the backup case hit 31.1s and derive 29.9s against the project's 30s budget. - golden project testTimeout: 30s -> 60s - x402 roundtrip subprocess guard: 20s -> 40s, test timeout 30s -> 60s - bai recharge: 60s -> 120s --- ts/test/bai-recharge.test.ts | 2 +- ts/test/beta-server-roundtrip.test.ts | 8 ++++---- ts/vitest.config.ts | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ts/test/bai-recharge.test.ts b/ts/test/bai-recharge.test.ts index a9545b969..c03931896 100644 --- a/ts/test/bai-recharge.test.ts +++ b/ts/test/bai-recharge.test.ts @@ -144,5 +144,5 @@ it.each([ rmSync(home, { recursive: true, force: true }); } }, - 60000, + 120000, ); diff --git a/ts/test/beta-server-roundtrip.test.ts b/ts/test/beta-server-roundtrip.test.ts index d41dfe667..4967db568 100644 --- a/ts/test/beta-server-roundtrip.test.ts +++ b/ts/test/beta-server-roundtrip.test.ts @@ -138,7 +138,7 @@ import {appendFileSync} from 'node:fs';const realFetch=globalThis.fetch;globalTh env: { ...process.env, WALLET_CLI_HOME: home }, input: "test-password\n", encoding: "utf8", - timeout: 20000, + timeout: 40000, }, ); expect(r.status, r.stderr + r.stdout).toBe(0); @@ -159,7 +159,7 @@ import {appendFileSync} from 'node:fs';const realFetch=globalThis.fetch;globalTh rmSync(home, { recursive: true, force: true }); } }, - 30000, + 60000, ); it @@ -262,7 +262,7 @@ globalThis.fetch = async(input,init) => { env: { ...process.env, WALLET_CLI_HOME: home }, input: "test-password\n", encoding: "utf8", - timeout: 20000, + timeout: 40000, }, ); if (missing) { @@ -296,7 +296,7 @@ globalThis.fetch = async(input,init) => { rmSync(home, { recursive: true, force: true }); } }, - 30000, + 60000, ); it.skipIf(!entry).each(["0", "0.000", "0.5", "9007199254740992"])( diff --git a/ts/vitest.config.ts b/ts/vitest.config.ts index 46871caa4..f9a0a5276 100644 --- a/ts/vitest.config.ts +++ b/ts/vitest.config.ts @@ -30,7 +30,9 @@ export default defineConfig({ sequence: { groupOrder: 1 }, environment: "node", include: ["test/**/*.test.ts"], - testTimeout: 30_000, + // Cases that spawn the CLI several times with real scrypt have measured ~30s on a loaded + // 4-vCPU CI runner, so 30s left no headroom. + testTimeout: 60_000, hookTimeout: 30_000, // Build once, then every case spawns `node dist/index.js` instead of cold-transpiling // the whole CLI through tsx. `verify:package` sets WALLET_CLI_TEST_ENTRY to the