From 036c3f3b9bf0cee3376da634b773dc0667221e02 Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 12 Oct 2026 17:38:16 +0800 Subject: [PATCH 1/2] fix(ci): verify standalone Ledger errors and build release branches --- .github/workflows/ts-standalone-release.yml | 1 + ts/scripts/verify-standalone.mjs | 9 +++------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ts-standalone-release.yml b/.github/workflows/ts-standalone-release.yml index 4d31ad40b..f79f372c2 100644 --- a/.github/workflows/ts-standalone-release.yml +++ b/.github/workflows/ts-standalone-release.yml @@ -4,6 +4,7 @@ on: push: branches: - master + - "release_*" workflow_dispatch: permissions: diff --git a/ts/scripts/verify-standalone.mjs b/ts/scripts/verify-standalone.mjs index 3ae3ebdfd..1157baa43 100644 --- a/ts/scripts/verify-standalone.mjs +++ b/ts/scripts/verify-standalone.mjs @@ -36,8 +36,8 @@ if (help.status !== 0 || !help.stdout.includes("Usage: wallet-cli")) { } // Loading the Ledger command forces the embedded node-hid N-API addon to load. CI normally reaches -// `NoDevice`; a developer machine may instead enumerate a busy Ledger or complete the import. All -// three outcomes prove the addon loaded, while binding/dynamic-library failures remain rejected. +// `device_not_found`; a developer machine may instead complete the import. Match the stable error +// code, not message wording. Reject `device_unavailable`, which can also wrap addon load failures. const isolatedHome = mkdtempSync(join(tmpdir(), "wallet-cli-standalone-")); try { const ledger = run(["import", "ledger", "--app", "tron", "--index", "0", "--output", "json"], { @@ -53,10 +53,7 @@ try { } const imported = ledger.status === 0 && result?.success === true; const expectedDeviceError = - ledger.status === 1 && - result?.success === false && - typeof result?.error?.message === "string" && - /NoDevice|cannot open device with path/.test(result.error.message); + ledger.status === 1 && result?.success === false && result?.error?.code === "device_not_found"; if (!imported && !expectedDeviceError) { throw new Error( `Ledger native-addon smoke test failed: status=${ledger.status}, output=${JSON.stringify(output)}`, From 6820f5f9e4c4ad0dba02043c413bf6809536953f Mon Sep 17 00:00:00 2001 From: Steven Lin Date: Mon, 12 Oct 2026 17:58:07 +0800 Subject: [PATCH 2/2] test(ci): isolate SunSwap CLI checks from live services Serve launchpad, token metadata, and router reads from a local fixture so RPC latency cannot time out account and flag validation tests. Reject unexpected external requests and report subprocess failures with diagnostics. --- ts/test/sunswap-swap-preload.mjs | 97 ++++++++++++++++++++++++++++++++ ts/test/sunswap-swap.test.ts | 38 ++++++++----- 2 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 ts/test/sunswap-swap-preload.mjs diff --git a/ts/test/sunswap-swap-preload.mjs b/ts/test/sunswap-swap-preload.mjs new file mode 100644 index 000000000..624cffc3b --- /dev/null +++ b/ts/test/sunswap-swap-preload.mjs @@ -0,0 +1,97 @@ +/** Serve the real CLI's read-only router preconditions without reaching public services. */ +import { createServer } from "node:http"; +import https from "node:https"; +import { syncBuiltinESMExports } from "node:module"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import process from "node:process"; +import { Buffer } from "node:buffer"; +import { URL } from "node:url"; + +const USDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; +const TRX = "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb"; +const word = (value) => BigInt(value).toString(16).padStart(64, "0"); +const replies = { + "getTokenState(address)": word(0), // USDT is not a SunPump token. + "decimals()": word(6), + "symbol()": word(32) + word(4) + Buffer.from("USDT").toString("hex").padEnd(64, "0"), +}; + +function unexpected(detail) { + const message = `Unexpected fixture request: ${detail}`; + process.stderr.write(`${message}\n`); + return new Error(message); +} + +// Fail closed if a config regression sends the RPC or route query to a public service. +https.request = () => { + throw unexpected("external HTTPS"); +}; +syncBuiltinESMExports(); + +const server = createServer(async (request, response) => { + response.setHeader("content-type", "application/json"); + response.setHeader("connection", "close"); + try { + const url = new URL(request.url, "http://localhost"); + if (request.method === "POST" && url.pathname === "/wallet/triggerconstantcontract") { + let body = ""; + for await (const chunk of request) body += chunk; + const { function_selector: selector } = JSON.parse(body); + if (!(selector in replies)) throw unexpected(selector); + response.end( + JSON.stringify({ result: { result: true }, constant_result: [replies[selector]] }), + ); + return; + } + if (request.method === "GET" && url.pathname === "/swap/routerUniversal") { + const from = url.searchParams.get("fromToken"); + const to = url.searchParams.get("toToken"); + if (!((from === USDT && to === TRX) || (from === TRX && to === USDT))) { + throw unexpected(`pair ${from}/${to}`); + } + response.end( + JSON.stringify({ + code: 0, + data: [ + { + amountInRaw: url.searchParams.get("amountIn"), + amountOutRaw: "1000000", + fee: "0", + impact: "0", + tokens: [from, to], + symbols: from === USDT ? ["USDT", "TRX"] : ["TRX", "USDT"], + poolFees: ["3000", "0"], + poolVersions: ["V2"], + }, + ], + }), + ); + return; + } + throw unexpected(`${request.method} ${url.pathname}`); + } catch (error) { + response.writeHead(500); + response.end(JSON.stringify({ error: String(error) })); + } +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +server.unref(); +const origin = `http://127.0.0.1:${server.address().port}`; +const fetch = globalThis.fetch; +globalThis.fetch = (input, init) => { + const url = new URL(input instanceof globalThis.Request ? input.url : input); + if (url.origin !== origin) throw unexpected(`external fetch ${url.origin}`); + return fetch(input, init); +}; +writeFileSync( + join(process.env.WALLET_CLI_HOME, "config.yaml"), + JSON.stringify({ + networks: { + "tron:728126428": { + httpEndpoint: origin, + sunswap: { routerApiBaseUrl: origin }, + }, + }, + }), +); diff --git a/ts/test/sunswap-swap.test.ts b/ts/test/sunswap-swap.test.ts index 442ceddb5..30b454bd3 100644 --- a/ts/test/sunswap-swap.test.ts +++ b/ts/test/sunswap-swap.test.ts @@ -1,16 +1,16 @@ /** * Black-box checks for `sunswap swap`. * - * The flag matrix and the network gate are refused before a request would be made. The market - * decision and the pricing are covered by unit tests and by the live mainnet smoke, because both - * markets' prices move with every trade — a curve's with every buy, the router's with every - * block. + * The flag matrix and the network gate are refused before a request would be made. The + * router preconditions use a local RPC/API fixture: reaching those refusals requires market, + * token and route reads, which must not depend on mainnet availability or prices. */ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { DETACHED } from "./detached.js"; const ENTRY_ARGS = process.env.WALLET_CLI_TEST_ENTRY @@ -19,20 +19,31 @@ const ENTRY_ARGS = process.env.WALLET_CLI_TEST_ENTRY const TOKEN = "TBCjrpTjwjF61J8pYY6DKa8JvevbmBah1E"; -let HOME: string; +const RPC_FIXTURE = fileURLToPath(new URL("./sunswap-swap-preload.mjs", import.meta.url)); + +let walletHome: string; beforeEach(() => { - HOME = mkdtempSync(join(tmpdir(), "wcli-swap-")); + walletHome = mkdtempSync(join(tmpdir(), "wcli-swap-")); +}); +afterEach(() => { + rmSync(walletHome, { recursive: true, force: true }); }); function run(args: string[]) { - const env = { ...process.env, WALLET_CLI_HOME: HOME } as Record; + const env = { ...process.env, WALLET_CLI_HOME: walletHome } as Record; delete env.MASTER_PASSWORD; - const r = spawnSync(process.execPath, [...ENTRY_ARGS, ...args], { + const r = spawnSync(process.execPath, ["--import", RPC_FIXTURE, ...ENTRY_ARGS, ...args], { encoding: "utf8", env, timeout: 25_000, ...DETACHED, } as SpawnSyncOptionsWithStringEncoding); + if (r.error || r.signal) { + throw new Error( + `CLI subprocess failed: ${r.error?.message ?? r.signal}\nstdout: ${r.stdout}\nstderr: ${r.stderr}`, + ); + } + expect(r.stderr).not.toContain("Unexpected fixture request"); let json: any; try { json = JSON.parse(r.stdout); @@ -197,14 +208,13 @@ describe("a router pair that spends a token", () => { }); /** - * Every other sending mode needs an account — and REACHES THE NETWORK FIRST. + * Every other sending mode needs an account, after reading the market, token facts and route. * * The command declares `wallet: "none"` so that `--quote` works with no wallet at all, which means * the use case resolves the account itself. It cannot do that until it knows which market the pair * is on, and that is an on-chain read. So a caller with no account gets a launchpad read before the - * refusal. The ordering is deliberate — the market decision is made once, before anything else, so - * a quote and a fill cannot land on different markets — and the cost is one read on a path that was - * going to fail. Recorded here because the previous version of this comment claimed the opposite. + * refusal. The ordering is deliberate — the market decision is made once, before anything else, + * so a quote and a fill cannot land on different markets. The preload serves these reads locally. */ it.each([ ["execute", []],