@solard/cli 0.2.37 → 0.2.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ import { expect, test } from "bun:test";
2
+ import { retryLiquidationRead, runLiquidationWallets } from "./liquidation-wallets.ts";
3
+
4
+ test("read quota failures back off, keep completed wallets, never replay execution", async () => {
5
+ const starts: string[] = [], executions: string[] = [], waits: number[] = [];
6
+ let reads = 0;
7
+ const failures = await runLiquidationWallets(["a", "a", "b", "c"], async wallet => {
8
+ await retryLiquidationRead(async () => {
9
+ if (wallet === "b" && reads++ < 2) throw Error("RPC_PROVIDER_UNAVAILABLE");
10
+ }, () => {}, async ms => { waits.push(ms); });
11
+ executions.push(wallet);
12
+ if (wallet === "b") throw Error("Submission outcome uncertain");
13
+ }, wallet => starts.push(wallet), () => {});
14
+ expect(starts).toEqual(["a", "b", "c"]);
15
+ expect(executions).toEqual(["a", "b", "c"]);
16
+ expect(failures).toEqual(["b"]);
17
+ expect(waits).toEqual([5000, 10000]);
18
+ });
19
+
20
+ test("invalid metadata is not retried; provider failures are bounded", async () => {
21
+ let calls = 0;
22
+ await expect(retryLiquidationRead(async () => { calls++; throw Error("Unknown wallet"); }, () => {}, async () => {})).rejects.toThrow("Unknown wallet");
23
+ expect(calls).toBe(1);
24
+ calls = 0;
25
+ await expect(retryLiquidationRead(async () => { calls++; throw Error("HTTP 429"); }, () => {}, async () => {})).rejects.toThrow("429");
26
+ expect(calls).toBe(7);
27
+ });
@@ -0,0 +1,33 @@
1
+ /** Retry reads only. An execution callback is never repeated by this runner. */
2
+ export async function retryLiquidationRead<T>(
3
+ read: () => Promise<T>,
4
+ onRetry: (attempt: number, delayMs: number) => void,
5
+ sleep: (ms: number) => Promise<unknown> = ms => Bun.sleep(ms),
6
+ ): Promise<T> {
7
+ for (let attempt = 0; ; attempt++) {
8
+ try { return await read(); }
9
+ catch (error) {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ if (attempt >= 6 || !/RPC_PROVIDER_UNAVAILABLE|RPC_QUEUE_TIMEOUT|RPC_TIMEOUT|429|rate.limit|fetch failed|ECONNRESET|timed? ?out/i.test(message)) throw error;
12
+ const delayMs = Math.min(60_000, 5_000 * 2 ** attempt);
13
+ onRetry(attempt + 1, delayMs);
14
+ await sleep(delayMs);
15
+ }
16
+ }
17
+ }
18
+
19
+ export async function runLiquidationWallets(
20
+ wallets: string[],
21
+ run: (wallet: string) => Promise<unknown>,
22
+ onStart: (wallet: string, index: number, total: number) => void,
23
+ onFailure: (wallet: string, error: unknown) => void,
24
+ ): Promise<string[]> {
25
+ const failures: string[] = [];
26
+ const unique = [...new Set(wallets)];
27
+ for (const [index, wallet] of unique.entries()) {
28
+ onStart(wallet, index + 1, unique.length);
29
+ try { await run(wallet); }
30
+ catch (error) { failures.push(wallet); onFailure(wallet, error); }
31
+ }
32
+ return failures;
33
+ }
@@ -41,7 +41,7 @@ import {
41
41
  type SendReceipt,
42
42
  type SimulationResult,
43
43
  type Solard,
44
- type TokenMetadata,
44
+ type PumpLaunchTokenMetadata as TokenMetadata,
45
45
  type TraderSubmitMode,
46
46
  type VanityMintPoolReservation,
47
47
  } from "@solard/core";
@@ -0,0 +1,108 @@
1
+ import { fileURLToPath } from "url";
2
+ import { afterAll, afterEach, expect, spyOn, test } from "bun:test";
3
+ const mockFetch = (implementation: (...args: Parameters<typeof fetch>) => ReturnType<typeof fetch>) =>
4
+ spyOn(globalThis, "fetch").mockImplementation(implementation as typeof fetch);
5
+ import * as fs from "fs";
6
+ import { mkdtempSync, readFileSync } from "fs";
7
+ const rmSync = Reflect.get(fs, "rmSync") as (path: string, options: {force?: boolean; recursive?: boolean}) => void;
8
+ import { tmpdir } from "os";
9
+ import { join } from "path";
10
+ import { activateRpc, effectiveRpc, loadRpcConfig, saveRpcConfig, probeHttp, safeUrl } from "./rpc-management.ts";
11
+ const dir = mkdtempSync(join(tmpdir(), "slrd-rpc-r8-"));
12
+ const keys = ["SLRD_RPC_CONFIG_PATH", "RPC_ENDPOINT", "SLRD_RPC_URLS", "SLRD_WS_URLS", "SLRD_RPC_PROVIDER_LIMITS", "SLRD_RPC_AGGREGATE_RPS", "SLRD_RPC_VERIFY_MAINNET"] as const;
13
+ const before = Object.fromEntries(keys.map(key => [key, process.env[key]]));
14
+ process.env.SLRD_RPC_CONFIG_PATH = join(dir, "rpc.json");
15
+ afterEach(() => { rmSync(process.env.SLRD_RPC_CONFIG_PATH!, { force: true }); for (const key of keys) {
16
+ const value = before[key]; if (value === undefined) delete process.env[key]; else process.env[key] = value;
17
+ } process.env.SLRD_RPC_CONFIG_PATH = join(dir, "rpc.json"); });
18
+ afterAll(() => { rmSync(dir, { recursive: true, force: true }); for (const key of keys) {
19
+ const value = before[key]; if (value === undefined) delete process.env[key]; else process.env[key] = value;
20
+ } });
21
+
22
+ test("environment-only configuration appears in effective probes", () => {
23
+ process.env.RPC_ENDPOINT = "https://env-only.example/?api-key=secret";
24
+ const state = effectiveRpc();
25
+ expect(state.http).toHaveLength(1);
26
+ expect(state.source).toBe("environment");
27
+ expect(safeUrl(state.http[0]!)).not.toContain("secret");
28
+ });
29
+ test("flags outrank environment but preserve saved read fallbacks", () => {
30
+ const config = loadRpcConfig();
31
+ config.http = [{ url: "https://saved.example/", group: "saved", rps: 12, concurrency: 2 }];
32
+ saveRpcConfig(config);
33
+ process.env.RPC_ENDPOINT = "https://env.example/";
34
+ const effective = effectiveRpc(new Map([["rpc-url", "https://explicit.example/"]]));
35
+ expect(effective.http[0]).toContain("explicit.example");
36
+ expect(effective.http).toContain("https://saved.example/");
37
+ activateRpc(new Map([["rpc-url", "https://explicit.example/"]]));
38
+ expect(JSON.parse(process.env.SLRD_RPC_URLS!)).toEqual(effective.http);
39
+ const limits = JSON.parse(process.env.SLRD_RPC_PROVIDER_LIMITS!) as Record<string, {rps:number}>;
40
+ expect(Object.values(limits).some(value => value.rps === 12)).toBe(true);
41
+ expect(Object.keys(limits).every(key => /^[a-f0-9]{64}$/.test(key))).toBe(true);
42
+ });
43
+ test("credentials never appear in redacted display; stored file keeps supplied URL", () => {
44
+ const config = loadRpcConfig(); config.http = [{ url: "https://secured.example/?api-key=private", group: "one", rps: 5, concurrency: 1 }];
45
+ saveRpcConfig(config);
46
+ expect(readFileSync(process.env.SLRD_RPC_CONFIG_PATH!, "utf8")).toContain("private");
47
+ expect(safeUrl(config.http[0]!.url)).not.toContain("private");
48
+ });
49
+ test("mainnet readiness rejects quota-exhausted account reads", async () => {
50
+ const calls: string[] = [];
51
+ const mock = mockFetch(async (_url, init) => {
52
+ const method = JSON.parse(String(init?.body)).method;
53
+ calls.push(method);
54
+ return new Response(JSON.stringify(method === "getGenesisHash"
55
+ ? { result: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d" }
56
+ : { error: { code: -32005, message: "max usage reached" } }));
57
+ });
58
+ try { expect((await probeHttp("https://testing.example/?api-key=private")).state).toBe("unavailable");
59
+ expect(calls).toEqual(["getGenesisHash", "getBalance"]);
60
+ } finally { mock.mockRestore(); }
61
+ });
62
+ test("mainnet readiness accepts zero balance and null account with valid context", async () => {
63
+ const mock = mockFetch(async (_url, init) => {
64
+ const method = JSON.parse(String(init?.body)).method;
65
+ const result = method === "getGenesisHash" ? "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"
66
+ : { context: { slot: 44 }, value: method === "getBalance" ? 0 : null };
67
+ return new Response(JSON.stringify({ result }));
68
+ });
69
+ try { expect((await probeHttp("https://healthy.example/")).state).toBe("healthy"); }
70
+ finally { mock.mockRestore(); }
71
+ });
72
+
73
+
74
+ test("CLI add/status/test honor effective endpoints and redact credentials without wallet access", async () => {
75
+ const calls: string[] = [];
76
+ const server = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(request) {
77
+ const body = await request.json() as { id: number; method: string };
78
+ calls.push(body.method);
79
+ const result = body.method === "getGenesisHash"
80
+ ? "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"
81
+ : { context: { slot: 123 }, value: body.method === "getBalance" ? 0 : null };
82
+ return Response.json({ jsonrpc: "2.0", id: body.id, result });
83
+ } });
84
+ const endpoint = `http://127.0.0.1:${server.port}/?api-key=cli-fixture-secret`;
85
+ const run = async (...args: string[]) => {
86
+ const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./index.ts", import.meta.url)), ...args], {
87
+ env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
88
+ SLRD_RPC_CONFIG_PATH: join(dir, "rpc-cli.json"),
89
+ RPC_ENDPOINT: endpoint, SLRD_RPC_URLS: "[]", SLRD_WS_URLS: "[]" },
90
+ stdout: "pipe", stderr: "pipe",
91
+ });
92
+ const [stdout, stderr, code] = await Promise.all([
93
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
94
+ ]);
95
+ expect(code).toBe(0);
96
+ expect(stdout + stderr).not.toContain("cli-fixture-secret");
97
+ return stdout;
98
+ };
99
+ try {
100
+ await run("rpc", "add", "http", endpoint, "--group", "cli-fixture");
101
+ const status = JSON.parse(await run("rpc", "status"));
102
+ expect(status.source).toBe("environment");
103
+ expect(status.health[0].state).toBe("healthy");
104
+ expect(status.active).toContain("127.0.0.1");
105
+ await run("rpc", "test", "--rpc-url", endpoint);
106
+ expect(calls.filter(method => method === "getAccountInfo")).toHaveLength(2);
107
+ } finally { server.stop(true); }
108
+ }, 30000); // Three CLI subprocesses exceed Bun's 5s default on a cold Windows start.
@@ -0,0 +1,109 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { dirname, join } from "path";
4
+ import { createHash } from "crypto";
5
+
6
+ export type RpcEntry = { url: string; group: string; rps: number; concurrency: number };
7
+ export type RpcConfig = { version: 1; auto: boolean; http: RpcEntry[]; websocket: string[]; aggregateRps: number | null };
8
+ export function rpcConfigPath() { return process.env.SLRD_RPC_CONFIG_PATH ?? join(homedir(), ".solard", "rpc.json"); }
9
+ const empty = (): RpcConfig => ({ version: 1, auto: true, http: [], websocket: [], aggregateRps: null });
10
+ export function rpcUrl(raw: string, transport: "http" | "ws"): string {
11
+ const u = new URL(raw);
12
+ if (u.username || u.password || u.hash || !((transport === "http" ? ["http:","https:"] : ["ws:","wss:"]).includes(u.protocol)))
13
+ throw new Error("Invalid RPC URL transport or URL contains userinfo/fragment");
14
+ return u.toString();
15
+ }
16
+ export function safeUrl(raw: string) { try { const u = new URL(raw); return `${u.protocol}//${u.host}/***`; } catch { return "<invalid URL>"; } }
17
+ export function loadRpcConfig(): RpcConfig {
18
+ let data: RpcConfig;
19
+ try { data = JSON.parse(readFileSync(rpcConfigPath(), "utf8")); }
20
+ catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return empty(); throw new Error("Cannot read RPC configuration"); }
21
+ if (data.version !== 1 || !Array.isArray(data.http) || !Array.isArray(data.websocket)) throw new Error("Invalid RPC configuration");
22
+ for (const entry of data.http) {
23
+ entry.url = rpcUrl(entry.url, "http");
24
+ if (!/^[a-zA-Z0-9._:-]{1,100}$/.test(entry.group) || !Number.isSafeInteger(entry.rps) || entry.rps < 1 ||
25
+ !Number.isSafeInteger(entry.concurrency) || entry.concurrency < 1) throw new Error("Invalid RPC budget");
26
+ }
27
+ data.websocket = data.websocket.map(url => rpcUrl(url, "ws"));
28
+ return data;
29
+ }
30
+ export function saveRpcConfig(config: RpcConfig) {
31
+ const path = rpcConfigPath(); mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
32
+ const temp = `${path}.${process.pid}.tmp`;
33
+ writeFileSync(temp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600, flag: "wx" });
34
+ renameSync(temp, path);
35
+ }
36
+ export function effectiveRpc(flags: ReadonlyMap<string,string> = new Map()) {
37
+ const saved = loadRpcConfig();
38
+ const manual = flags.get("rpc-url") ?? process.env.RPC_ENDPOINT;
39
+ let envUrls: string[] = [];
40
+ if (process.env.SLRD_RPC_URLS) {
41
+ const parsed: unknown = JSON.parse(process.env.SLRD_RPC_URLS);
42
+ if (!Array.isArray(parsed) || !parsed.every(value => typeof value === "string")) throw new Error("SLRD_RPC_URLS must be an array");
43
+ envUrls = parsed as string[];
44
+ }
45
+ const initial = [...(manual ? [manual] : []), ...envUrls, ...saved.http.map(e => e.url)];
46
+ const http = [...new Set(initial.map(url => rpcUrl(url, "http")))];
47
+ const wsEnv: unknown = process.env.SLRD_WS_URLS ? JSON.parse(process.env.SLRD_WS_URLS) : [];
48
+ if (!Array.isArray(wsEnv) || !wsEnv.every(v => typeof v === "string")) throw new Error("Invalid SLRD_WS_URLS");
49
+ const websocket = [...new Set([...(wsEnv as string[]), ...saved.websocket].map(url => rpcUrl(url, "ws")))];
50
+ const enabledHttp = saved.auto ? http : http.slice(0,1);
51
+ const limits: Record<string, { group: string; rps: number; concurrency: number }> = {};
52
+ for (const entry of saved.http) {
53
+ const identity = createHash("sha256").update(entry.url).digest("hex");
54
+ limits[identity] = { group: entry.group, rps: entry.rps, concurrency: entry.concurrency };
55
+ }
56
+ return { saved, http: enabledHttp, websocket, limits, source: flags.has("rpc-url") ? "flag" : manual ? "environment" : envUrls.length ? "SLRD_RPC_URLS" : "file" };
57
+ }
58
+ export function activateRpc(flags: ReadonlyMap<string,string>) {
59
+ const result = effectiveRpc(flags);
60
+ if (result.http.length) { process.env.RPC_ENDPOINT = result.http[0]; process.env.SLRD_RPC_URLS = JSON.stringify(result.http); }
61
+ process.env.SLRD_WS_URLS = JSON.stringify(result.websocket);
62
+ process.env.SLRD_RPC_PROVIDER_LIMITS = JSON.stringify(result.limits);
63
+ if (result.saved.aggregateRps !== null) process.env.SLRD_RPC_AGGREGATE_RPS = String(result.saved.aggregateRps);
64
+ process.env.SLRD_RPC_VERIFY_MAINNET = "1";
65
+ return result;
66
+ }
67
+ const GENESIS = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d";
68
+ export async function probeHttp(url: string) {
69
+ const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 6500);
70
+ const start = Date.now();
71
+ const result = (state: string, reason: string) => ({ endpoint: safeUrl(url), state, reason, latencyMs: Date.now() - start });
72
+ try {
73
+ for (const method of ["getGenesisHash", "getBalance", "getAccountInfo"]) {
74
+ const response = await fetch(url, { method: "POST", signal: controller.signal, headers: { "content-type": "application/json" },
75
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: method === "getGenesisHash" ? [] :
76
+ method === "getBalance" ? ["11111111111111111111111111111111"] :
77
+ ["11111111111111111111111111111111", { encoding: "base64" }] }) });
78
+ if (!response.ok) return result("unavailable", `HTTP ${response.status}`);
79
+ const body = await response.json() as { error?: { code?: number; message?: string }; result?: any };
80
+ if (body.error) return result("unavailable", `RPC ${body.error.code ?? "error"}`);
81
+ if (method === "getGenesisHash" && body.result !== GENESIS) return result("wrong-cluster", "Mainnet genesis mismatch");
82
+ if (method === "getBalance" && (!Number.isFinite(body.result?.value) || !Number.isFinite(body.result?.context?.slot)))
83
+ return result("unavailable", "Balance read missing");
84
+ if (method === "getAccountInfo" && !Number.isFinite(body.result?.context?.slot))
85
+ return result("unavailable", "Account read missing");
86
+ }
87
+ return result("healthy", "Mainnet and account reads verified");
88
+ } catch { return result("unavailable", "Timeout or transport failure"); }
89
+ finally { clearTimeout(timeout); }
90
+ }
91
+ export async function probeWs(url: string) {
92
+ return await new Promise<{ endpoint: string; state: string; reason: string }>(resolve => {
93
+ let socket: WebSocket | undefined, finished = false;
94
+ function done(state: string, reason: string) {
95
+ if (finished) return; finished = true; clearTimeout(timer);
96
+ try { socket?.close(); } catch {} resolve({ endpoint: safeUrl(url), state, reason });
97
+ }
98
+ const timer = setTimeout(() => done("unavailable", "Subscription timeout"), 6500);
99
+ try {
100
+ socket = new WebSocket(url);
101
+ socket.addEventListener("open", () => socket?.send(JSON.stringify({ jsonrpc: "2.0", id: 71, method: "slotSubscribe" })));
102
+ socket.addEventListener("message", event => { try { const data = JSON.parse(String(event.data));
103
+ if (data.id === 71) done(Number.isInteger(data.result) ? "healthy" : "unavailable", "Subscription ACK (not delivery guarantee)");
104
+ } catch { done("unavailable", "Invalid WebSocket reply"); } });
105
+ socket.addEventListener("error", () => done("unavailable", "WebSocket failure"));
106
+ socket.addEventListener("close", () => done("unavailable", "Disconnected"));
107
+ } catch { done("unavailable", "WebSocket construction failed"); }
108
+ });
109
+ }
@@ -0,0 +1,10 @@
1
+ import {expect,test}from"bun:test";
2
+ import {excludeWalletGroups}from"./wallet-group-filter.ts";
3
+ test("group exclusion handles overlapping memberships and virtual ungrouped without hiding unknown groups",()=>{
4
+ const wallets=[{address:"a"},{address:"b"},{address:"c"}];const memberships=[{groupName:"oct23-empty",walletAddress:"a"},{groupName:"default",walletAddress:"a"},{groupName:"keep",walletAddress:"b"}];const known=new Set(["oct23-empty","default","keep","empty"]);
5
+ expect(excludeWalletGroups(wallets,["oct23-empty"],memberships,known)).toEqual([{address:"b"},{address:"c"}]);
6
+ expect(excludeWalletGroups(wallets,["ungrouped"],memberships,known)).toEqual([{address:"a"},{address:"b"}]);
7
+ expect(excludeWalletGroups(wallets,["oct23-empty","keep"],memberships,known)).toEqual([{address:"c"}]);
8
+ expect(excludeWalletGroups(wallets,["empty"],memberships,known)).toEqual(wallets);
9
+ expect(()=>excludeWalletGroups(wallets,["typo"],memberships,known)).toThrow("Unknown excluded");
10
+ });
@@ -0,0 +1,12 @@
1
+ export function excludeWalletGroups<T extends { address: string }>(
2
+ wallets:T[], names:string[], memberships:ReadonlyArray<{groupName:string;walletAddress:string}>, knownGroups:ReadonlySet<string>,
3
+ ):T[] {
4
+ const grouped=new Set(memberships.filter(row=>row.groupName.toLowerCase()!=="ungrouped").map(row=>row.walletAddress));
5
+ const excluded=new Set<string>();
6
+ for(const name of names){
7
+ if(name.toLowerCase()==="ungrouped"){for(const wallet of wallets)if(!grouped.has(wallet.address))excluded.add(wallet.address);continue;}
8
+ if(!knownGroups.has(name))throw Error(`Unknown excluded wallet group: ${name}`);
9
+ for(const row of memberships)if(row.groupName===name)excluded.add(row.walletAddress);
10
+ }
11
+ return wallets.filter(wallet=>!excluded.has(wallet.address));
12
+ }