@solard/cli 0.2.20 → 0.2.22

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solard/cli",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "Command-line interface for the Solard SDK.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -18,12 +18,14 @@
18
18
  "bun": ">=1.1.0"
19
19
  },
20
20
  "dependencies": {
21
- "@solard/core": "0.2.20",
22
- "bs58": "^6.0.0",
23
21
  "@solana/web3.js": "^1.98.4",
22
+ "@solard/core": "0.2.22",
23
+ "@solard/sdk": "0.2.22",
24
+ "bs58": "^6.0.0",
24
25
  "measure-fn": "5.2.0"
25
26
  },
26
27
  "scripts": {
27
- "typecheck": "tsc --noEmit -p ../../tsconfig.json"
28
+ "typecheck": "tsc --noEmit -p ../../tsconfig.json",
29
+ "test": "bun test src"
28
30
  }
29
31
  }
package/src/index.ts CHANGED
@@ -385,17 +385,13 @@ Vanity mints
385
385
  slrd vanity pool release <mint-address> Release an ambiguous/failed launch reservation
386
386
  launch/vamp: add --mint-pool pump [--mint-pool-address <address>] to consume a pooled mint
387
387
 
388
- Shared market feed
389
- slrd feed serve [--host 127.0.0.1] [--port 8788] [--rpc-rps 5] [--rpc-read-rps 2] [--max-fallback-subs 200]
390
- One upstream Pump/PumpSwap/LaunchLab feed shared by watchers, agents, and future UI clients
391
-
392
388
  Launch discovery
393
- slrd launch watch [--venue pump,raydium-launchlab] [--min-mcap 5000] [--ath-step-pct 20] [--track-ttl 30m] [--feed ws://127.0.0.1:8788/ws] [--show-new] [--prices] [--json]
394
- Consume the shared feed; ignore Pump Mayhem and notify on first $5k cross and each +20% notified ATH step
389
+ slrd launch watch [--venue pump,raydium-launchlab] [--min-mcap 5000] [--ath-step-pct 20] [--track-ttl 30m] [--show-new] [--prices] [--json]
390
+ Subscribe directly to launch/trade logs; ignore Pump Mayhem and notify on first $5k cross and each +20% notified ATH step
395
391
 
396
392
  Pump discovery
397
393
  slrd pump pairs [--json] Read Pump-supported quote mints from the on-chain Global account
398
- slrd pump watch [--min-mcap 5000] [--ath-step-pct 20] [--track-ttl 30m] [--feed ws://127.0.0.1:8788/ws] [--show-new] [--prices] [--json]
394
+ slrd pump watch [--min-mcap 5000] [--ath-step-pct 20] [--track-ttl 30m] [--show-new] [--prices] [--json]
399
395
  Compatibility alias for: slrd launch watch --venue pump
400
396
 
401
397
  Metadata and launching
@@ -474,8 +470,8 @@ Scripts and event strategies
474
470
  slrd scripts List scripts registered in slrd.config.ts
475
471
  slrd run <name-or-path> [script flags...] Execute a script that imports slrd
476
472
  slrd run snipe --name <exact_name> --group <group> --sol 0.05 --sender jito
477
- slrd strategy run <file.ts> --token <mint|alias> --wallet <wallet> [--params <json>] [--feed ws://127.0.0.1:8788/ws] [--live]
478
- Run an onPrice strategy from the shared feed; wallet/execution state remains isolated per agent
473
+ slrd strategy run <file.ts> --token <mint|alias> --wallet <wallet> [--params <json>] [--live]
474
+ Run an onPrice strategy from direct token trade subscriptions; wallet/execution state remains isolated per agent
479
475
  slrd strategy backtest <file.ts> --token <mint> [--params <json>] [--capital-sol 1] [--price-sample 5s] [--sol-usd N] [--json]
480
476
  Replay the same strategy file over durable token history
481
477
 
@@ -818,12 +814,6 @@ async function main() {
818
814
  process.exitCode = await runScript(script, scriptArgs);
819
815
  return;
820
816
  }
821
- if (command === "feed" && values[0] === "serve") {
822
- const { runPriceFeedServerCommand } =
823
- await import("./price-feed-server.ts");
824
- await runPriceFeedServerCommand({ flags, emit });
825
- return;
826
- }
827
817
  if (command === "strategy" && values[0] === "backtest") {
828
818
  const { runStrategyBacktestCommand } =
829
819
  await import("./strategy-backtest-command.ts");
@@ -1082,11 +1072,10 @@ async function main() {
1082
1072
  throw new Error(
1083
1073
  "Usage: slrd export <wallet|address> [--json] [--out <path>]",
1084
1074
  );
1085
- const bs58 = (await import("bs58")).default;
1086
- const { signer, row } = slrd.wallets.signer(ref);
1087
- const secret = flags.has("json")
1088
- ? JSON.stringify(Array.from(signer.secretKey))
1089
- : bs58.encode(signer.secretKey);
1075
+ const exported = slrd.exportWalletPrivateKey(
1076
+ ref,
1077
+ flags.has("json") ? "json" : "base58",
1078
+ );
1090
1079
 
1091
1080
  const out = flags.get("out");
1092
1081
  if (out && out !== "true") {
@@ -1094,18 +1083,20 @@ async function main() {
1094
1083
  const { dirname, resolve } = await import("node:path");
1095
1084
  const path = resolve(out);
1096
1085
  mkdirSync(dirname(path), { recursive: true });
1097
- writeFileSync(path, `${secret}\n`, { mode: 0o600, flag: "wx" });
1086
+ writeFileSync(path, `${exported.privateKey}\n`, {
1087
+ mode: 0o600,
1088
+ flag: "wx",
1089
+ });
1098
1090
  emit(
1099
- `${OWL} exported @${row?.name ?? "?"} ${signer.publicKey.toBase58()} -> ${path}\n`,
1091
+ `${OWL} exported @${exported.wallet.name ?? "?"} ${exported.wallet.address} -> ${path}\n`,
1100
1092
  );
1101
1093
  return;
1102
1094
  }
1103
1095
 
1104
- // Secret to stdout only; metadata to stderr so piping stays clean.
1105
1096
  process.stderr.write(
1106
- `${OWL} @${row?.name ?? "?"} ${signer.publicKey.toBase58()} (${flags.has("json") ? "keypair-json" : "base58"})\n`,
1097
+ `${OWL} @${exported.wallet.name ?? "?"} ${exported.wallet.address} (${exported.format === "json" ? "keypair-json" : "base58"})\n`,
1107
1098
  );
1108
- process.stdout.write(`${secret}\n`);
1099
+ process.stdout.write(`${exported.privateKey}\n`);
1109
1100
  return;
1110
1101
  }
1111
1102
  if (command === "import") {
@@ -1,6 +1,12 @@
1
1
  import { configure, createMeasure } from "measure-fn";
2
- import { connectPriceFeed } from "./price-feed-client.ts";
3
- import type { PriceFeedLaunch, PriceFeedPrice } from "./price-feed-protocol.ts";
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";
4
10
 
5
11
  type Flags = Map<string, string>;
6
12
  type Emit = (value: string) => void;
@@ -10,11 +16,15 @@ type WatchState = {
10
16
  venue: VenueId;
11
17
  createdAtMs: number;
12
18
  lastSeenAtMs: number;
19
+ supplyUi: number;
13
20
  qualified: boolean;
14
21
  athMarketCapUsd: number | null;
15
22
  lastNotifiedAthUsd: number | null;
16
23
  };
17
24
 
25
+ const WSOL_MINT = "So11111111111111111111111111111111111111112";
26
+ const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
27
+
18
28
  function flag(flags: Flags, key: string): string | undefined {
19
29
  const value = flags.get(key);
20
30
  return value && value !== "true" ? value : undefined;
@@ -56,14 +66,59 @@ function venueList(flags: Flags): VenueId[] {
56
66
  return out as VenueId[];
57
67
  }
58
68
 
59
- function feedUrl(flags: Flags): string {
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 {
60
83
  return (
61
- flag(flags, "feed") ??
62
- process.env.SOLARD_FEED_URL?.trim() ??
63
- "ws://127.0.0.1:8788/ws"
84
+ flag(flags, "ws") ??
85
+ process.env.SOLANA_WS_URL?.trim() ??
86
+ process.env.HELIUS_WS_URL?.trim()
64
87
  );
65
88
  }
66
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
+
67
122
  export async function runLaunchWatchCommand(args: {
68
123
  flags: Flags;
69
124
  emit: Emit;
@@ -98,7 +153,12 @@ export async function runLaunchWatchCommand(args: {
98
153
  const trackTtlMs = duration(flag(args.flags, "track-ttl"), 30 * 60_000);
99
154
  const showNew = args.flags.has("show-new");
100
155
  const showPrices = args.flags.has("trades") || args.flags.has("prices");
101
- const url = feedUrl(args.flags);
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
+ );
102
162
  const states = new Map<string, WatchState>();
103
163
  let launches = 0;
104
164
  let prices = 0;
@@ -106,11 +166,22 @@ export async function runLaunchWatchCommand(args: {
106
166
  let thresholdNotifications = 0;
107
167
  let athNotifications = 0;
108
168
  let ignoredMayhem = 0;
109
- let feedConnected = false;
169
+ let solUsd: number | null = null;
110
170
  let stopped = false;
111
- let feedClient: Awaited<ReturnType<typeof connectPriceFeed>> | null = null;
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
+ };
112
183
 
113
- const onLaunch = (event: PriceFeedLaunch) => {
184
+ const onLaunch = (event: LaunchEvent) => {
114
185
  if (!venues.includes(event.venue)) return;
115
186
  if (event.isMayhemMode === true) {
116
187
  ignoredMayhem += 1;
@@ -121,10 +192,12 @@ export async function runLaunchWatchCommand(args: {
121
192
  venue: event.venue,
122
193
  createdAtMs: event.atMs,
123
194
  lastSeenAtMs: event.atMs,
195
+ supplyUi: event.supplyUi,
124
196
  qualified: false,
125
197
  athMarketCapUsd: null,
126
198
  lastNotifiedAthUsd: null,
127
199
  });
200
+ void tradeSubscription?.addTokens(event.mint);
128
201
  launches += 1;
129
202
  if (showNew)
130
203
  report("new launch", {
@@ -137,92 +210,115 @@ export async function runLaunchWatchCommand(args: {
137
210
  });
138
211
  };
139
212
 
140
- const onPrice = (event: PriceFeedPrice) => {
141
- let state = states.get(event.mint);
142
- if (!state) return;
143
- if (!venues.includes(state.venue)) return;
213
+ const onTrade = (event: TradeEvent) => {
214
+ const state = states.get(event.mint);
215
+ if (!state || event.priceQuote == null) return;
144
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;
145
227
  prices += 1;
146
228
  if (showPrices)
147
229
  report("price", {
148
230
  at: new Date(event.atMs).toISOString(),
149
231
  venue: event.venue,
150
232
  mint: event.mint,
151
- marketCapUsd: event.marketCapUsd,
152
- priceUsd: event.priceUsd,
153
- priceSol: event.priceSol,
154
- source: event.source,
233
+ marketCapUsd,
234
+ priceUsd,
235
+ priceSol,
236
+ source: `${event.venue}-trade`,
155
237
  });
156
- const mcap = event.marketCapUsd;
157
- if (!(mcap != null && Number.isFinite(mcap) && mcap > 0)) return;
158
- state.athMarketCapUsd = Math.max(state.athMarketCapUsd ?? 0, mcap);
238
+ if (!(
239
+ marketCapUsd != null &&
240
+ Number.isFinite(marketCapUsd) &&
241
+ marketCapUsd > 0
242
+ ))
243
+ return;
244
+ state.athMarketCapUsd = Math.max(state.athMarketCapUsd ?? 0, marketCapUsd);
159
245
  if (!state.qualified) {
160
- if (mcap < minMarketCapUsd) return;
246
+ if (marketCapUsd < minMarketCapUsd) return;
161
247
  state.qualified = true;
162
- state.lastNotifiedAthUsd = mcap;
163
- if (state.venue === "raydium-launchlab")
164
- feedClient?.subscribeMints([event.mint]);
248
+ state.lastNotifiedAthUsd = marketCapUsd;
165
249
  qualified += 1;
166
250
  thresholdNotifications += 1;
167
- const nextAthNotifyUsd = mcap * (1 + athStepPct / 100);
251
+ const nextAthNotifyUsd = marketCapUsd * (1 + athStepPct / 100);
168
252
  if (!args.flags.has("no-bell") && !json) process.stderr.write("\x07");
169
253
  report("market cap threshold", {
170
254
  at: new Date(event.atMs).toISOString(),
171
255
  venue: state.venue,
172
256
  mint: event.mint,
173
- marketCapUsd: mcap,
257
+ marketCapUsd,
174
258
  thresholdMarketCapUsd: minMarketCapUsd,
175
259
  nextAthNotifyUsd,
176
260
  athStepPct,
177
- priceUsd: event.priceUsd,
178
- priceSol: event.priceSol,
261
+ priceUsd,
262
+ priceSol,
179
263
  signature: event.signature,
180
264
  });
181
265
  return;
182
266
  }
183
267
  const previous = state.lastNotifiedAthUsd;
184
268
  if (!(previous != null && previous > 0)) {
185
- state.lastNotifiedAthUsd = mcap;
269
+ state.lastNotifiedAthUsd = marketCapUsd;
186
270
  return;
187
271
  }
188
272
  const target = previous * (1 + athStepPct / 100);
189
- if (mcap + 1e-12 < target) return;
190
- state.lastNotifiedAthUsd = mcap;
273
+ if (marketCapUsd + 1e-12 < target) return;
274
+ state.lastNotifiedAthUsd = marketCapUsd;
191
275
  athNotifications += 1;
192
- const increasePct = (mcap / previous - 1) * 100;
193
- const nextAthNotifyUsd = mcap * (1 + athStepPct / 100);
276
+ const increasePct = (marketCapUsd / previous - 1) * 100;
277
+ const nextAthNotifyUsd = marketCapUsd * (1 + athStepPct / 100);
194
278
  if (!args.flags.has("no-bell") && !json) process.stderr.write("\x07");
195
279
  report("new ath", {
196
280
  at: new Date(event.atMs).toISOString(),
197
281
  venue: state.venue,
198
282
  mint: event.mint,
199
- marketCapUsd: mcap,
283
+ marketCapUsd,
200
284
  previousNotifiedAthUsd: previous,
201
285
  nextAthNotifyUsd,
202
286
  athStepPct,
203
287
  increasePct,
204
- priceUsd: event.priceUsd,
205
- priceSol: event.priceSol,
288
+ priceUsd,
289
+ priceSol,
206
290
  signature: event.signature,
207
291
  });
208
292
  };
209
293
 
210
294
  const controller = new AbortController();
211
- feedClient = await connectPriceFeed({
212
- url,
213
- subscribe: { op: "subscribe", launches: true, allPrices: true },
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
+ ],
214
304
  signal: controller.signal,
305
+ onTrade,
215
306
  onStatus(event, data) {
216
- if (event === "connected") feedConnected = true;
217
- if (event === "connect-error") feedConnected = false;
218
- report(`feed ${event}`, data ?? {});
307
+ report(`trades ${event}`, data ?? {});
219
308
  },
220
- onMessage(message) {
221
- if (message.type === "launch") onLaunch(message);
222
- if (message.type === "price") onPrice(message);
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 ?? {});
223
317
  },
224
318
  });
225
319
 
320
+ await refreshSolUsd();
321
+ const solTimer = setInterval(() => void refreshSolUsd(), 60_000);
226
322
  const stop = () => {
227
323
  stopped = true;
228
324
  controller.abort();
@@ -230,23 +326,25 @@ export async function runLaunchWatchCommand(args: {
230
326
  process.once("SIGINT", stop);
231
327
  process.once("SIGTERM", stop);
232
328
  report("ready", {
233
- feed: url,
329
+ rpc,
330
+ ws: ws ?? "connection-default",
234
331
  venues,
235
332
  minMarketCapUsd,
236
333
  athStepPct,
237
334
  excludeMayhem: true,
238
335
  trackTtlMs,
239
- upstreamSubscriptions: 0,
336
+ upstreamSubscriptions: venues.length,
240
337
  });
241
338
  const heartbeat = setInterval(
242
339
  () => {
243
340
  const now = Date.now();
244
341
  for (const [mint, state] of states) {
245
- if (!state.qualified && now - state.lastSeenAtMs > trackTtlMs)
342
+ if (!state.qualified && now - state.lastSeenAtMs > trackTtlMs) {
246
343
  states.delete(mint);
344
+ void tradeSubscription?.removeTokens(mint);
345
+ }
247
346
  }
248
347
  report("heartbeat", {
249
- feedConnected,
250
348
  launches,
251
349
  ignoredMayhem,
252
350
  tracked: states.size,
@@ -254,7 +352,8 @@ export async function runLaunchWatchCommand(args: {
254
352
  prices,
255
353
  thresholdNotifications,
256
354
  athNotifications,
257
- upstreamSubscriptions: 0,
355
+ watchedTokens: tradeSubscription?.listTokens().length ?? 0,
356
+ solUsd,
258
357
  });
259
358
  },
260
359
  Math.max(1_000, Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000))),
@@ -263,9 +362,11 @@ export async function runLaunchWatchCommand(args: {
263
362
  try {
264
363
  while (!stopped) await new Promise((resolve) => setTimeout(resolve, 250));
265
364
  } finally {
365
+ clearInterval(solTimer);
266
366
  clearInterval(heartbeat);
267
367
  controller.abort();
268
- feedClient?.close();
368
+ await launchSubscription.close();
369
+ await tradeSubscription.close();
269
370
  process.removeListener("SIGINT", stop);
270
371
  process.removeListener("SIGTERM", stop);
271
372
  report("stopped", {
@@ -1,2 +1,168 @@
1
- export { connectPriceFeed } from "@solard/core";
2
- export type { PriceFeedClient } from "@solard/core";
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
+ }