@solard/cli 0.2.19 → 0.2.21

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,379 @@
1
+ import { configure, createMeasure } from "measure-fn";
2
+ import { Connection } from "@solana/web3.js";
3
+ import {
4
+ subscribeLaunches,
5
+ subscribeTrades,
6
+ type LaunchEvent,
7
+ type TradeEvent,
8
+ type TradeSubscription,
9
+ } from "@solard/core";
10
+
11
+ type Flags = Map<string, string>;
12
+ type Emit = (value: string) => void;
13
+ type VenueId = "pump" | "raydium-launchlab";
14
+ type WatchState = {
15
+ mint: string;
16
+ venue: VenueId;
17
+ createdAtMs: number;
18
+ lastSeenAtMs: number;
19
+ supplyUi: number;
20
+ qualified: boolean;
21
+ athMarketCapUsd: number | null;
22
+ lastNotifiedAthUsd: number | null;
23
+ };
24
+
25
+ const WSOL_MINT = "So11111111111111111111111111111111111111112";
26
+ const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
27
+
28
+ function flag(flags: Flags, key: string): string | undefined {
29
+ const value = flags.get(key);
30
+ return value && value !== "true" ? value : undefined;
31
+ }
32
+
33
+ function numberFlag(flags: Flags, key: string, fallback: number): number {
34
+ const value = flag(flags, key);
35
+ if (value == null) return fallback;
36
+ const parsed = Number(value);
37
+ if (!Number.isFinite(parsed)) throw new Error(`Invalid --${key}: ${value}`);
38
+ return parsed;
39
+ }
40
+
41
+ function duration(value: string | undefined, fallbackMs: number): number {
42
+ if (!value) return fallbackMs;
43
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i.exec(value.trim());
44
+ if (!match) throw new Error(`Invalid duration: ${value}`);
45
+ const scale = ({ ms: 1, s: 1_000, m: 60_000, h: 3_600_000 } as const)[
46
+ (match[2]?.toLowerCase() ?? "ms") as "ms" | "s" | "m" | "h"
47
+ ];
48
+ return Math.max(100, Math.floor(Number(match[1]) * scale));
49
+ }
50
+
51
+ function venueList(flags: Flags): VenueId[] {
52
+ const raw = flag(flags, "venue") ?? "pump,raydium-launchlab";
53
+ const values = raw
54
+ .split(",")
55
+ .map((value) => value.trim().toLowerCase())
56
+ .filter(Boolean)
57
+ .map((value) =>
58
+ value === "launchlab" || value === "raydium" || value === "stonkfun"
59
+ ? "raydium-launchlab"
60
+ : value,
61
+ );
62
+ const out = [...new Set(values)] as string[];
63
+ for (const value of out)
64
+ if (value !== "pump" && value !== "raydium-launchlab")
65
+ throw new Error(`Unsupported --venue value: ${value}`);
66
+ return out as VenueId[];
67
+ }
68
+
69
+ function rpcUrl(flags: Flags): string {
70
+ const value =
71
+ flag(flags, "rpc") ??
72
+ process.env.RPC_ENDPOINT?.trim() ??
73
+ process.env.SOLANA_RPC_URL?.trim() ??
74
+ process.env.HELIUS_RPC_URL?.trim();
75
+ if (!value)
76
+ throw new Error(
77
+ "Missing --rpc, RPC_ENDPOINT, SOLANA_RPC_URL, or HELIUS_RPC_URL",
78
+ );
79
+ return value;
80
+ }
81
+
82
+ function wsUrl(flags: Flags): string | undefined {
83
+ return (
84
+ flag(flags, "ws") ??
85
+ process.env.SOLANA_WS_URL?.trim() ??
86
+ process.env.HELIUS_WS_URL?.trim()
87
+ );
88
+ }
89
+
90
+ async function fetchJson(url: string): Promise<unknown> {
91
+ const controller = new AbortController();
92
+ const timer = setTimeout(() => controller.abort(), 5_000);
93
+ try {
94
+ const response = await fetch(url, {
95
+ headers: { accept: "application/json" },
96
+ signal: controller.signal,
97
+ });
98
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
99
+ return await response.json();
100
+ } finally {
101
+ clearTimeout(timer);
102
+ }
103
+ }
104
+
105
+ async function loadSolUsd(): Promise<number> {
106
+ try {
107
+ const raw = (await fetchJson(
108
+ "https://api.coinbase.com/v2/prices/SOL-USD/spot",
109
+ )) as { data?: { amount?: unknown } };
110
+ const value = Number(raw.data?.amount);
111
+ if (Number.isFinite(value) && value > 0) return value;
112
+ } catch {}
113
+ const raw = (await fetchJson(
114
+ "https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd",
115
+ )) as { solana?: { usd?: unknown } };
116
+ const value = Number(raw.solana?.usd);
117
+ if (!Number.isFinite(value) || value <= 0)
118
+ throw new Error("SOL/USD unavailable");
119
+ return value;
120
+ }
121
+
122
+ export async function runLaunchWatchCommand(args: {
123
+ flags: Flags;
124
+ emit: Emit;
125
+ forcedVenues?: VenueId[];
126
+ }): Promise<void> {
127
+ configure({
128
+ silent: false,
129
+ logger(_event: unknown, next?: () => void) {
130
+ next?.();
131
+ },
132
+ });
133
+ const json = args.flags.has("json");
134
+ const m = createMeasure("slrd:launch-watch", { maxResultLength: 1600 });
135
+ const report = <T extends Record<string, unknown>>(
136
+ label: string,
137
+ value: T,
138
+ ): T => {
139
+ if (json) {
140
+ args.emit(`${JSON.stringify({ type: label, ...value })}\n`);
141
+ return value;
142
+ }
143
+ return m.sync(
144
+ { start: () => label, end: (result: T) => result },
145
+ () => value,
146
+ );
147
+ };
148
+ const venues = args.forcedVenues ?? venueList(args.flags);
149
+ const minMarketCapUsd = numberFlag(args.flags, "min-mcap", 5_000);
150
+ const athStepPct = numberFlag(args.flags, "ath-step-pct", 20);
151
+ if (!(minMarketCapUsd > 0)) throw new Error("--min-mcap must be > 0");
152
+ if (!(athStepPct > 0)) throw new Error("--ath-step-pct must be > 0");
153
+ const trackTtlMs = duration(flag(args.flags, "track-ttl"), 30 * 60_000);
154
+ const showNew = args.flags.has("show-new");
155
+ const showPrices = args.flags.has("trades") || args.flags.has("prices");
156
+ const rpc = rpcUrl(args.flags);
157
+ const ws = wsUrl(args.flags);
158
+ const connection = new Connection(
159
+ rpc,
160
+ ws ? { commitment: "confirmed", wsEndpoint: ws } : "confirmed",
161
+ );
162
+ const states = new Map<string, WatchState>();
163
+ let launches = 0;
164
+ let prices = 0;
165
+ let qualified = 0;
166
+ let thresholdNotifications = 0;
167
+ let athNotifications = 0;
168
+ let ignoredMayhem = 0;
169
+ let solUsd: number | null = null;
170
+ let stopped = false;
171
+ let tradeSubscription: TradeSubscription | null = null;
172
+
173
+ const refreshSolUsd = async () => {
174
+ try {
175
+ solUsd = await loadSolUsd();
176
+ } catch (error) {
177
+ report("sol/usd unavailable", {
178
+ error: error instanceof Error ? error.message : String(error),
179
+ cached: solUsd,
180
+ });
181
+ }
182
+ };
183
+
184
+ const onLaunch = (event: LaunchEvent) => {
185
+ if (!venues.includes(event.venue)) return;
186
+ if (event.isMayhemMode === true) {
187
+ ignoredMayhem += 1;
188
+ return;
189
+ }
190
+ states.set(event.mint, {
191
+ mint: event.mint,
192
+ venue: event.venue,
193
+ createdAtMs: event.atMs,
194
+ lastSeenAtMs: event.atMs,
195
+ supplyUi: event.supplyUi,
196
+ qualified: false,
197
+ athMarketCapUsd: null,
198
+ lastNotifiedAthUsd: null,
199
+ });
200
+ void tradeSubscription?.addTokens(event.mint);
201
+ launches += 1;
202
+ if (showNew)
203
+ report("new launch", {
204
+ at: new Date(event.atMs).toISOString(),
205
+ venue: event.venue,
206
+ mint: event.mint,
207
+ symbol: event.symbol,
208
+ name: event.name,
209
+ supplyUi: event.supplyUi,
210
+ });
211
+ };
212
+
213
+ const onTrade = (event: TradeEvent) => {
214
+ const state = states.get(event.mint);
215
+ if (!state || event.priceQuote == null) return;
216
+ state.lastSeenAtMs = event.atMs;
217
+ let priceSol: number | null = null;
218
+ let priceUsd: number | null = null;
219
+ if (event.quoteMint === WSOL_MINT) {
220
+ priceSol = event.priceQuote;
221
+ priceUsd = solUsd == null ? null : event.priceQuote * solUsd;
222
+ } else if (event.quoteMint === USDC_MINT) {
223
+ priceUsd = event.priceQuote;
224
+ priceSol = solUsd == null ? null : event.priceQuote / solUsd;
225
+ }
226
+ const marketCapUsd = priceUsd == null ? null : priceUsd * state.supplyUi;
227
+ prices += 1;
228
+ if (showPrices)
229
+ report("price", {
230
+ at: new Date(event.atMs).toISOString(),
231
+ venue: event.venue,
232
+ mint: event.mint,
233
+ marketCapUsd,
234
+ priceUsd,
235
+ priceSol,
236
+ source: `${event.venue}-trade`,
237
+ });
238
+ if (!(
239
+ marketCapUsd != null &&
240
+ Number.isFinite(marketCapUsd) &&
241
+ marketCapUsd > 0
242
+ ))
243
+ return;
244
+ state.athMarketCapUsd = Math.max(state.athMarketCapUsd ?? 0, marketCapUsd);
245
+ if (!state.qualified) {
246
+ if (marketCapUsd < minMarketCapUsd) return;
247
+ state.qualified = true;
248
+ state.lastNotifiedAthUsd = marketCapUsd;
249
+ qualified += 1;
250
+ thresholdNotifications += 1;
251
+ const nextAthNotifyUsd = marketCapUsd * (1 + athStepPct / 100);
252
+ if (!args.flags.has("no-bell") && !json) process.stderr.write("\x07");
253
+ report("market cap threshold", {
254
+ at: new Date(event.atMs).toISOString(),
255
+ venue: state.venue,
256
+ mint: event.mint,
257
+ marketCapUsd,
258
+ thresholdMarketCapUsd: minMarketCapUsd,
259
+ nextAthNotifyUsd,
260
+ athStepPct,
261
+ priceUsd,
262
+ priceSol,
263
+ signature: event.signature,
264
+ });
265
+ return;
266
+ }
267
+ const previous = state.lastNotifiedAthUsd;
268
+ if (!(previous != null && previous > 0)) {
269
+ state.lastNotifiedAthUsd = marketCapUsd;
270
+ return;
271
+ }
272
+ const target = previous * (1 + athStepPct / 100);
273
+ if (marketCapUsd + 1e-12 < target) return;
274
+ state.lastNotifiedAthUsd = marketCapUsd;
275
+ athNotifications += 1;
276
+ const increasePct = (marketCapUsd / previous - 1) * 100;
277
+ const nextAthNotifyUsd = marketCapUsd * (1 + athStepPct / 100);
278
+ if (!args.flags.has("no-bell") && !json) process.stderr.write("\x07");
279
+ report("new ath", {
280
+ at: new Date(event.atMs).toISOString(),
281
+ venue: state.venue,
282
+ mint: event.mint,
283
+ marketCapUsd,
284
+ previousNotifiedAthUsd: previous,
285
+ nextAthNotifyUsd,
286
+ athStepPct,
287
+ increasePct,
288
+ priceUsd,
289
+ priceSol,
290
+ signature: event.signature,
291
+ });
292
+ };
293
+
294
+ const controller = new AbortController();
295
+ tradeSubscription = await subscribeTrades({
296
+ connection,
297
+ tokens: [],
298
+ venues: [
299
+ ...(venues.includes("pump") ? (["pump", "pumpswap"] as const) : []),
300
+ ...(venues.includes("raydium-launchlab")
301
+ ? (["raydium-launchlab"] as const)
302
+ : []),
303
+ ],
304
+ signal: controller.signal,
305
+ onTrade,
306
+ onStatus(event, data) {
307
+ report(`trades ${event}`, data ?? {});
308
+ },
309
+ });
310
+ const launchSubscription = await subscribeLaunches({
311
+ connection,
312
+ venues,
313
+ signal: controller.signal,
314
+ onLaunch,
315
+ onStatus(event, data) {
316
+ report(`launches ${event}`, data ?? {});
317
+ },
318
+ });
319
+
320
+ await refreshSolUsd();
321
+ const solTimer = setInterval(() => void refreshSolUsd(), 60_000);
322
+ const stop = () => {
323
+ stopped = true;
324
+ controller.abort();
325
+ };
326
+ process.once("SIGINT", stop);
327
+ process.once("SIGTERM", stop);
328
+ report("ready", {
329
+ rpc,
330
+ ws: ws ?? "connection-default",
331
+ venues,
332
+ minMarketCapUsd,
333
+ athStepPct,
334
+ excludeMayhem: true,
335
+ trackTtlMs,
336
+ upstreamSubscriptions: venues.length,
337
+ });
338
+ const heartbeat = setInterval(
339
+ () => {
340
+ const now = Date.now();
341
+ for (const [mint, state] of states) {
342
+ if (!state.qualified && now - state.lastSeenAtMs > trackTtlMs) {
343
+ states.delete(mint);
344
+ void tradeSubscription?.removeTokens(mint);
345
+ }
346
+ }
347
+ report("heartbeat", {
348
+ launches,
349
+ ignoredMayhem,
350
+ tracked: states.size,
351
+ qualified,
352
+ prices,
353
+ thresholdNotifications,
354
+ athNotifications,
355
+ watchedTokens: tradeSubscription?.listTokens().length ?? 0,
356
+ solUsd,
357
+ });
358
+ },
359
+ Math.max(1_000, Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000))),
360
+ );
361
+
362
+ try {
363
+ while (!stopped) await new Promise((resolve) => setTimeout(resolve, 250));
364
+ } finally {
365
+ clearInterval(solTimer);
366
+ clearInterval(heartbeat);
367
+ controller.abort();
368
+ await launchSubscription.close();
369
+ await tradeSubscription.close();
370
+ process.removeListener("SIGINT", stop);
371
+ process.removeListener("SIGTERM", stop);
372
+ report("stopped", {
373
+ launches,
374
+ prices,
375
+ thresholdNotifications,
376
+ athNotifications,
377
+ });
378
+ }
379
+ }
@@ -0,0 +1,168 @@
1
+ import type {
2
+ PriceFeedCommand,
3
+ PriceFeedMessage,
4
+ } from "./price-feed-protocol.ts";
5
+
6
+ function text(value: unknown): string {
7
+ if (typeof value === "string") return value;
8
+ if (value instanceof ArrayBuffer)
9
+ return new TextDecoder().decode(new Uint8Array(value));
10
+ if (ArrayBuffer.isView(value))
11
+ return new TextDecoder().decode(
12
+ new Uint8Array(value.buffer, value.byteOffset, value.byteLength),
13
+ );
14
+ return String(value ?? "");
15
+ }
16
+
17
+ export type PriceFeedClient = {
18
+ readonly connected: boolean;
19
+ send(command: PriceFeedCommand): void;
20
+ subscribeMints(mints: string[]): void;
21
+ unsubscribeMints(mints: string[]): void;
22
+ close(): void;
23
+ closed: Promise<void>;
24
+ };
25
+
26
+ export async function connectPriceFeed(args: {
27
+ url: string;
28
+ subscribe: Extract<PriceFeedCommand, { op: "subscribe" }>;
29
+ onMessage: (message: PriceFeedMessage) => void | Promise<void>;
30
+ onStatus?: (event: string, data?: Record<string, unknown>) => void;
31
+ signal?: AbortSignal;
32
+ }): Promise<PriceFeedClient> {
33
+ let stopped = false;
34
+ let connected = false;
35
+ let ws: WebSocket | null = null;
36
+ const desiredMints = new Set(args.subscribe.mints ?? []);
37
+ let desiredLaunches = args.subscribe.launches === true;
38
+ let desiredAllPrices = args.subscribe.allPrices === true;
39
+ let reconnectDelay = 250;
40
+ let resolveClosed!: () => void;
41
+ const closed = new Promise<void>((resolve) => {
42
+ resolveClosed = resolve;
43
+ });
44
+
45
+ const stop = () => {
46
+ if (stopped) return;
47
+ stopped = true;
48
+ try {
49
+ ws?.close();
50
+ } catch {}
51
+ resolveClosed();
52
+ };
53
+ args.signal?.addEventListener("abort", stop, { once: true });
54
+
55
+ const open = async (): Promise<void> => {
56
+ while (!stopped) {
57
+ try {
58
+ const socket = new WebSocket(args.url);
59
+ ws = socket;
60
+ await new Promise<void>((resolve, reject) => {
61
+ let settled = false;
62
+ const timeout = setTimeout(() => {
63
+ if (!settled) {
64
+ settled = true;
65
+ try {
66
+ socket.close();
67
+ } catch {}
68
+ reject(new Error("feed websocket handshake timed out"));
69
+ }
70
+ }, 5_000);
71
+ socket.addEventListener("open", () => {
72
+ if (settled) return;
73
+ settled = true;
74
+ clearTimeout(timeout);
75
+ connected = true;
76
+ reconnectDelay = 250;
77
+ socket.send(
78
+ JSON.stringify({
79
+ op: "subscribe",
80
+ mints: [...desiredMints],
81
+ launches: desiredLaunches,
82
+ allPrices: desiredAllPrices,
83
+ } satisfies PriceFeedCommand),
84
+ );
85
+ args.onStatus?.("connected", { url: args.url });
86
+ resolve();
87
+ });
88
+ socket.addEventListener("error", () => {
89
+ if (settled) return;
90
+ settled = true;
91
+ clearTimeout(timeout);
92
+ reject(new Error("feed websocket error"));
93
+ });
94
+ });
95
+
96
+ await new Promise<void>((resolve) => {
97
+ socket.addEventListener("message", (event) => {
98
+ let message: PriceFeedMessage;
99
+ try {
100
+ message = JSON.parse(text(event.data)) as PriceFeedMessage;
101
+ } catch {
102
+ return;
103
+ }
104
+ void Promise.resolve(args.onMessage(message)).catch((error) =>
105
+ args.onStatus?.("callback-error", {
106
+ error: error instanceof Error ? error.message : String(error),
107
+ }),
108
+ );
109
+ });
110
+ socket.addEventListener("close", () => {
111
+ connected = false;
112
+ resolve();
113
+ });
114
+ socket.addEventListener("error", () => {
115
+ connected = false;
116
+ });
117
+ });
118
+ } catch (error) {
119
+ connected = false;
120
+ args.onStatus?.("connect-error", {
121
+ error: error instanceof Error ? error.message : String(error),
122
+ retryInMs: reconnectDelay,
123
+ });
124
+ }
125
+ if (stopped) break;
126
+ await new Promise((resolve) => setTimeout(resolve, reconnectDelay));
127
+ reconnectDelay = Math.min(10_000, reconnectDelay * 2);
128
+ }
129
+ resolveClosed();
130
+ };
131
+
132
+ void open();
133
+ const startedAt = Date.now();
134
+ while (!connected && !stopped && Date.now() - startedAt < 6_000)
135
+ await new Promise((resolve) => setTimeout(resolve, 25));
136
+ if (!connected && !stopped) {
137
+ stop();
138
+ throw new Error(
139
+ `Could not connect to Solard price feed at ${args.url}. Start it with: slrd feed serve`,
140
+ );
141
+ }
142
+
143
+ const send = (command: PriceFeedCommand) => {
144
+ if (command.op === "subscribe") {
145
+ for (const mint of command.mints ?? []) desiredMints.add(mint);
146
+ if (command.launches === true) desiredLaunches = true;
147
+ if (command.allPrices === true) desiredAllPrices = true;
148
+ } else if (command.op === "unsubscribe") {
149
+ for (const mint of command.mints ?? []) desiredMints.delete(mint);
150
+ }
151
+ if (!connected || !ws || ws.readyState !== WebSocket.OPEN) return;
152
+ ws.send(JSON.stringify(command));
153
+ };
154
+ return {
155
+ get connected() {
156
+ return connected;
157
+ },
158
+ send,
159
+ subscribeMints(mints: string[]) {
160
+ send({ op: "subscribe", mints });
161
+ },
162
+ unsubscribeMints(mints: string[]) {
163
+ send({ op: "unsubscribe", mints });
164
+ },
165
+ close: stop,
166
+ closed,
167
+ };
168
+ }
@@ -0,0 +1,51 @@
1
+ export type PriceFeedVenue =
2
+ "pump" | "pumpswap" | "raydium-launchlab" | "rpc-fallback";
3
+
4
+ export type PriceFeedLaunch = {
5
+ type: "launch";
6
+ atMs: number;
7
+ signature: string | null;
8
+ slot: number | null;
9
+ mint: string;
10
+ venue: "pump" | "raydium-launchlab";
11
+ decimals: number;
12
+ supplyUi: number;
13
+ quoteMint: string | null;
14
+ pool: string | null;
15
+ name: string | null;
16
+ symbol: string | null;
17
+ isMayhemMode: boolean | null;
18
+ };
19
+
20
+ export type PriceFeedPrice = {
21
+ type: "price";
22
+ atMs: number;
23
+ signature: string | null;
24
+ slot: number | null;
25
+ mint: string;
26
+ venue: PriceFeedVenue;
27
+ priceSol: number | null;
28
+ priceUsd: number | null;
29
+ marketCapUsd: number | null;
30
+ source: string;
31
+ };
32
+
33
+ export type PriceFeedStatus = {
34
+ type: "status";
35
+ atMs: number;
36
+ event: string;
37
+ data?: Record<string, unknown>;
38
+ };
39
+
40
+ export type PriceFeedMessage =
41
+ PriceFeedLaunch | PriceFeedPrice | PriceFeedStatus;
42
+
43
+ export type PriceFeedCommand =
44
+ | {
45
+ op: "subscribe";
46
+ mints?: string[];
47
+ launches?: boolean;
48
+ allPrices?: boolean;
49
+ }
50
+ | { op: "unsubscribe"; mints?: string[] }
51
+ | { op: "ping" };