Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ts-standalone-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- master
- "release_*"
workflow_dispatch:

permissions:
Expand Down
9 changes: 3 additions & 6 deletions ts/scripts/verify-standalone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"], {
Expand All @@ -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)}`,
Expand Down
97 changes: 97 additions & 0 deletions ts/test/sunswap-swap-preload.mjs
Original file line number Diff line number Diff line change
@@ -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 },
},
},
}),
);
38 changes: 24 additions & 14 deletions ts/test/sunswap-swap.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, string>;
const env = { ...process.env, WALLET_CLI_HOME: walletHome } as Record<string, string>;
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);
Expand Down Expand Up @@ -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", []],
Expand Down