From 4110c68f5dee3d3562ed763b2c53c8d01331991e Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Thu, 22 Oct 2026 14:22:33 +0800 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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