@solard/cli 0.2.25 → 0.2.27

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.25",
3
+ "version": "0.2.27",
4
4
  "description": "Command-line interface for the Solard SDK.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -19,8 +19,8 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@solana/web3.js": "^1.98.4",
22
- "@solard/core": "0.2.25",
23
- "@solard/sdk": "0.2.25",
22
+ "@solard/core": "0.2.27",
23
+ "@solard/sdk": "0.2.27",
24
24
  "bs58": "^6.0.0",
25
25
  "measure-fn": "5.2.0"
26
26
  },
@@ -1,12 +1,9 @@
1
1
  import { configure, createMeasure } from "measure-fn";
2
- import { Connection } from "@solana/web3.js";
3
- import {
4
- subscribeLaunches,
5
- subscribeTrades,
2
+ import slrd, {
6
3
  type LaunchEvent,
7
4
  type TradeEvent,
8
- type TradeSubscription,
9
- } from "@solard/core";
5
+ type TradeListener,
6
+ } from "@solard/sdk";
10
7
 
11
8
  type Flags = Map<string, string>;
12
9
  type Emit = (value: string) => void;
@@ -63,25 +60,15 @@ function venueList(flags: Flags): VenueId[] {
63
60
  return out as VenueId[];
64
61
  }
65
62
 
66
- function rpcUrl(flags: Flags): string {
67
- const value =
68
- flag(flags, "rpc") ??
69
- process.env.RPC_ENDPOINT?.trim() ??
70
- process.env.SOLANA_RPC_URL?.trim() ??
71
- process.env.HELIUS_RPC_URL?.trim();
72
- if (!value)
63
+ function requireSingleEndpoint(flags: Flags): void {
64
+ if (flags.has("rpc") || flags.has("ws"))
73
65
  throw new Error(
74
- "Missing --rpc, RPC_ENDPOINT, SOLANA_RPC_URL, or HELIUS_RPC_URL",
66
+ "Set only RPC_ENDPOINT in the environment. --rpc and --ws are intentionally unsupported.",
67
+ );
68
+ if (!process.env.RPC_ENDPOINT?.trim())
69
+ throw new Error(
70
+ "Missing RPC_ENDPOINT. Set one Solana RPC URL in RPC_ENDPOINT; WebSocket access is derived from the same endpoint.",
75
71
  );
76
- return value;
77
- }
78
-
79
- function wsUrl(flags: Flags): string | undefined {
80
- return (
81
- flag(flags, "ws") ??
82
- process.env.SOLANA_WS_URL?.trim() ??
83
- process.env.HELIUS_WS_URL?.trim()
84
- );
85
72
  }
86
73
 
87
74
  export async function runLaunchWatchCommand(args: {
@@ -118,12 +105,7 @@ export async function runLaunchWatchCommand(args: {
118
105
  const trackTtlMs = duration(flag(args.flags, "track-ttl"), 30 * 60_000);
119
106
  const showNew = args.flags.has("show-new");
120
107
  const showPrices = args.flags.has("trades") || args.flags.has("prices");
121
- const rpc = rpcUrl(args.flags);
122
- const ws = wsUrl(args.flags);
123
- const connection = new Connection(
124
- rpc,
125
- ws ? { commitment: "confirmed", wsEndpoint: ws } : "confirmed",
126
- );
108
+ requireSingleEndpoint(args.flags);
127
109
  const states = new Map<string, WatchState>();
128
110
  let launches = 0;
129
111
  let prices = 0;
@@ -132,7 +114,7 @@ export async function runLaunchWatchCommand(args: {
132
114
  let athNotifications = 0;
133
115
  let ignoredMayhem = 0;
134
116
  let stopped = false;
135
- let tradeSubscription: TradeSubscription | null = null;
117
+ let tradeSubscription: TradeListener | null = null;
136
118
 
137
119
  const onLaunch = (event: LaunchEvent) => {
138
120
  if (!venues.includes(event.venue)) return;
@@ -150,7 +132,7 @@ export async function runLaunchWatchCommand(args: {
150
132
  athMarketCapUsd: null,
151
133
  lastNotifiedAthUsd: null,
152
134
  });
153
- void tradeSubscription?.addTokens(event.mint);
135
+ void tradeSubscription?.add(event.mint);
154
136
  launches += 1;
155
137
  if (showNew)
156
138
  report("new launch", {
@@ -238,8 +220,7 @@ export async function runLaunchWatchCommand(args: {
238
220
  };
239
221
 
240
222
  const controller = new AbortController();
241
- tradeSubscription = await subscribeTrades({
242
- connection,
223
+ tradeSubscription = await slrd.listenTrades({
243
224
  tokens: [],
244
225
  venues: [
245
226
  ...(venues.includes("pump") ? (["pump", "pumpswap"] as const) : []),
@@ -248,13 +229,12 @@ export async function runLaunchWatchCommand(args: {
248
229
  : []),
249
230
  ],
250
231
  signal: controller.signal,
251
- onTrade,
252
232
  onStatus(event, data) {
253
233
  report(`trades ${event}`, data ?? {});
254
234
  },
255
235
  });
256
- const launchSubscription = await subscribeLaunches({
257
- connection,
236
+ tradeSubscription.onTrade(onTrade);
237
+ const launchSubscription = await slrd.subscribeLaunches({
258
238
  venues,
259
239
  signal: controller.signal,
260
240
  onLaunch,
@@ -270,8 +250,8 @@ export async function runLaunchWatchCommand(args: {
270
250
  process.once("SIGINT", stop);
271
251
  process.once("SIGTERM", stop);
272
252
  report("ready", {
273
- rpc,
274
- ws: ws ?? "connection-default",
253
+ rpc: "RPC_ENDPOINT",
254
+ websocket: "derived-from-RPC_ENDPOINT",
275
255
  venues,
276
256
  minMarketCapUsd,
277
257
  athStepPct,
@@ -285,7 +265,7 @@ export async function runLaunchWatchCommand(args: {
285
265
  for (const [mint, state] of states) {
286
266
  if (!state.qualified && now - state.lastSeenAtMs > trackTtlMs) {
287
267
  states.delete(mint);
288
- void tradeSubscription?.removeTokens(mint);
268
+ void tradeSubscription?.remove(mint);
289
269
  }
290
270
  }
291
271
  report("heartbeat", {
@@ -296,7 +276,7 @@ export async function runLaunchWatchCommand(args: {
296
276
  prices,
297
277
  thresholdNotifications,
298
278
  athNotifications,
299
- watchedTokens: tradeSubscription?.listTokens().length ?? 0,
279
+ watchedTokens: tradeSubscription?.list().length ?? 0,
300
280
  });
301
281
  },
302
282
  Math.max(1_000, Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000))),
@@ -14,18 +14,22 @@ function text(value: unknown): string {
14
14
  return String(value ?? "");
15
15
  }
16
16
 
17
+ function errorText(error: unknown): string {
18
+ return error instanceof Error ? error.message : String(error);
19
+ }
20
+
17
21
  export type PriceFeedClient = {
18
22
  readonly connected: boolean;
19
- send(command: PriceFeedCommand): void;
20
- subscribeMints(mints: string[]): void;
21
- unsubscribeMints(mints: string[]): void;
23
+ subscribeMints(mints: string | readonly string[]): void;
24
+ unsubscribeMints(mints: string | readonly string[]): void;
25
+ listMints(): string[];
22
26
  close(): void;
23
27
  closed: Promise<void>;
24
28
  };
25
29
 
26
30
  export async function connectPriceFeed(args: {
27
31
  url: string;
28
- subscribe: Extract<PriceFeedCommand, { op: "subscribe" }>;
32
+ mints?: string | readonly string[];
29
33
  onMessage: (message: PriceFeedMessage) => void | Promise<void>;
30
34
  onStatus?: (event: string, data?: Record<string, unknown>) => void;
31
35
  signal?: AbortSignal;
@@ -33,18 +37,26 @@ export async function connectPriceFeed(args: {
33
37
  let stopped = false;
34
38
  let connected = false;
35
39
  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;
40
+ const desiredMints = new Set(
41
+ (typeof args.mints === "string" ? [args.mints] : [...(args.mints ?? [])])
42
+ .map((mint) => mint.trim())
43
+ .filter(Boolean),
44
+ );
39
45
  let reconnectDelay = 250;
40
46
  let resolveClosed!: () => void;
41
47
  const closed = new Promise<void>((resolve) => {
42
48
  resolveClosed = resolve;
43
49
  });
44
50
 
45
- const stop = () => {
51
+ const send = (command: PriceFeedCommand): void => {
52
+ if (!connected || !ws || ws.readyState !== WebSocket.OPEN) return;
53
+ ws.send(JSON.stringify(command));
54
+ };
55
+
56
+ const stop = (): void => {
46
57
  if (stopped) return;
47
58
  stopped = true;
59
+ connected = false;
48
60
  try {
49
61
  ws?.close();
50
62
  } catch {}
@@ -60,13 +72,12 @@ export async function connectPriceFeed(args: {
60
72
  await new Promise<void>((resolve, reject) => {
61
73
  let settled = false;
62
74
  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
- }
75
+ if (settled) return;
76
+ settled = true;
77
+ try {
78
+ socket.close();
79
+ } catch {}
80
+ reject(new Error("price feed websocket handshake timed out"));
70
81
  }, 5_000);
71
82
  socket.addEventListener("open", () => {
72
83
  if (settled) return;
@@ -74,22 +85,17 @@ export async function connectPriceFeed(args: {
74
85
  clearTimeout(timeout);
75
86
  connected = true;
76
87
  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 });
88
+ const mints = [...desiredMints];
89
+ if (mints.length)
90
+ socket.send(JSON.stringify({ op: "subscribe", mints }));
91
+ args.onStatus?.("connected", { url: args.url, mints });
86
92
  resolve();
87
93
  });
88
94
  socket.addEventListener("error", () => {
89
95
  if (settled) return;
90
96
  settled = true;
91
97
  clearTimeout(timeout);
92
- reject(new Error("feed websocket error"));
98
+ reject(new Error("price feed websocket error"));
93
99
  });
94
100
  });
95
101
 
@@ -102,13 +108,12 @@ export async function connectPriceFeed(args: {
102
108
  return;
103
109
  }
104
110
  void Promise.resolve(args.onMessage(message)).catch((error) =>
105
- args.onStatus?.("callback-error", {
106
- error: error instanceof Error ? error.message : String(error),
107
- }),
111
+ args.onStatus?.("callback-error", { error: errorText(error) }),
108
112
  );
109
113
  });
110
114
  socket.addEventListener("close", () => {
111
115
  connected = false;
116
+ args.onStatus?.("disconnected", { url: args.url });
112
117
  resolve();
113
118
  });
114
119
  socket.addEventListener("error", () => {
@@ -118,7 +123,7 @@ export async function connectPriceFeed(args: {
118
123
  } catch (error) {
119
124
  connected = false;
120
125
  args.onStatus?.("connect-error", {
121
- error: error instanceof Error ? error.message : String(error),
126
+ error: errorText(error),
122
127
  retryInMs: reconnectDelay,
123
128
  });
124
129
  }
@@ -136,32 +141,33 @@ export async function connectPriceFeed(args: {
136
141
  if (!connected && !stopped) {
137
142
  stop();
138
143
  throw new Error(
139
- `Could not connect to Solard price feed at ${args.url}. Start it with: slrd feed serve`,
144
+ `Could not connect to Solard price feed at ${args.url}. Start the shared price feed before starting traders.`,
140
145
  );
141
146
  }
142
147
 
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
148
  return {
155
149
  get connected() {
156
150
  return connected;
157
151
  },
158
- send,
159
- subscribeMints(mints: string[]) {
152
+ subscribeMints(value) {
153
+ const mints = (typeof value === "string" ? [value] : [...value])
154
+ .map((mint) => mint.trim())
155
+ .filter((mint) => mint && !desiredMints.has(mint));
156
+ if (!mints.length) return;
157
+ for (const mint of mints) desiredMints.add(mint);
160
158
  send({ op: "subscribe", mints });
161
159
  },
162
- unsubscribeMints(mints: string[]) {
160
+ unsubscribeMints(value) {
161
+ const mints = (typeof value === "string" ? [value] : [...value])
162
+ .map((mint) => mint.trim())
163
+ .filter((mint) => mint && desiredMints.has(mint));
164
+ if (!mints.length) return;
165
+ for (const mint of mints) desiredMints.delete(mint);
163
166
  send({ op: "unsubscribe", mints });
164
167
  },
168
+ listMints() {
169
+ return [...desiredMints];
170
+ },
165
171
  close: stop,
166
172
  closed,
167
173
  };
@@ -1,33 +1,33 @@
1
- export type PriceFeedVenue =
2
- "pump" | "pumpswap" | "raydium-launchlab" | "rpc-fallback";
1
+ import type { SolUsdSource, TradeSide, TradeVenue } from "@solard/sdk";
3
2
 
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;
3
+ export type PriceFeedMarket = {
4
+ quoteMint: string;
5
+ baseDecimals: number;
6
+ quoteDecimals: number;
7
+ supply: number;
8
+ baseReserve: number;
9
+ quoteReserve: number;
10
+ priceQuotePerToken: number;
11
+ marketCapQuote: number;
12
+ priceSol: number | null;
13
+ marketCapSol: number | null;
14
+ solUsd: number | null;
15
+ solUsdSource: SolUsdSource | null;
16
+ solUsdAtMs: number | null;
17
+ priceUsd: number | null;
18
+ marketCapUsd: number | null;
18
19
  };
19
20
 
20
21
  export type PriceFeedPrice = {
21
22
  type: "price";
22
23
  atMs: number;
23
- signature: string | null;
24
- slot: number | null;
24
+ signature: string;
25
+ slot: number;
25
26
  mint: string;
26
- venue: PriceFeedVenue;
27
- priceSol: number | null;
28
- priceUsd: number | null;
29
- marketCapUsd: number | null;
30
- source: string;
27
+ pool: string | null;
28
+ venue: TradeVenue;
29
+ side: TradeSide | null;
30
+ market: PriceFeedMarket;
31
31
  };
32
32
 
33
33
  export type PriceFeedStatus = {
@@ -37,15 +37,9 @@ export type PriceFeedStatus = {
37
37
  data?: Record<string, unknown>;
38
38
  };
39
39
 
40
- export type PriceFeedMessage =
41
- PriceFeedLaunch | PriceFeedPrice | PriceFeedStatus;
40
+ export type PriceFeedMessage = PriceFeedPrice | PriceFeedStatus;
42
41
 
43
42
  export type PriceFeedCommand =
44
- | {
45
- op: "subscribe";
46
- mints?: string[];
47
- launches?: boolean;
48
- allPrices?: boolean;
49
- }
50
- | { op: "unsubscribe"; mints?: string[] }
43
+ | { op: "subscribe"; mints: string[] }
44
+ | { op: "unsubscribe"; mints: string[] }
51
45
  | { op: "ping" };
@@ -8,14 +8,13 @@ const source = readFileSync(
8
8
  );
9
9
 
10
10
  describe("price feed application boundary", () => {
11
- test("consumes sdk subscriptions instead of protocol internals", () => {
12
- expect(source).toContain("subscribeLaunches");
13
- expect(source).toContain("subscribeTrades");
14
- expect(source).toContain('from "@solard/sdk"');
11
+ test("owns one sdk trade subscription and changes its token set dynamically", () => {
12
+ expect(source).toContain("slrd.listenTrades");
13
+ expect(source).toContain("tradeSubscription.add");
14
+ expect(source).toContain("tradeSubscription?.remove");
15
+ expect(source).toContain("const refs = new Map<string, number>()");
16
+ expect(source).not.toContain("subscribeLaunches");
15
17
  expect(source).not.toContain('from "@solard/core"');
16
- expect(source).not.toMatch(
17
- /PUMP_(?:CREATE|TRADE)_EVENT|PUMPSWAP_(?:BUY|SELL|CREATE)_EVENT|LAUNCHLAB_(?:CREATE|TRADE)_EVENT/,
18
- );
19
18
  expect(source).not.toMatch(/logsSubscribe|onLogs\(/);
20
19
  });
21
20
  });
@@ -1,31 +1,14 @@
1
1
  import { configure, createMeasure } from "measure-fn";
2
- import { Connection } from "@solana/web3.js";
3
- import {
4
- getSolUsdPrice,
5
- subscribeLaunches,
6
- subscribeTrades,
7
- type LaunchEvent,
8
- type TradeEvent,
9
- type TradeSubscription,
10
- } from "@solard/sdk";
2
+ import slrd, { type TradeEvent, type TradeListener } from "@solard/sdk";
11
3
  import type {
12
4
  PriceFeedCommand,
13
- PriceFeedLaunch,
14
5
  PriceFeedMessage,
15
6
  PriceFeedPrice,
16
7
  } from "./price-feed-protocol.ts";
17
8
 
18
9
  type Flags = Map<string, string>;
19
10
  type Emit = (value: string) => void;
20
- type Client = {
21
- mints: Set<string>;
22
- launches: boolean;
23
- allPrices: boolean;
24
- };
25
- type MintState = {
26
- supplyUi: number | null;
27
- isMayhemMode: boolean | null;
28
- };
11
+ type Client = { mints: Set<string> };
29
12
 
30
13
  function flag(flags: Flags, key: string): string | undefined {
31
14
  const value = flags.get(key);
@@ -40,56 +23,60 @@ function numberFlag(flags: Flags, key: string, fallback: number): number {
40
23
  return value;
41
24
  }
42
25
 
43
- function rpcUrl(flags: Flags): string {
44
- const value =
45
- flag(flags, "rpc") ??
46
- process.env.RPC_ENDPOINT?.trim() ??
47
- process.env.SOLANA_RPC_URL?.trim() ??
48
- process.env.HELIUS_RPC_URL?.trim();
49
- if (!value) {
26
+ function feedAddress(flags: Flags): { host: string; port: number } {
27
+ if (flags.has("rpc") || flags.has("ws")) {
50
28
  throw new Error(
51
- "feed serve requires RPC_ENDPOINT, SOLANA_RPC_URL, HELIUS_RPC_URL, or --rpc <url>",
29
+ "Set only RPC_ENDPOINT in the environment. --rpc and --ws are intentionally unsupported.",
52
30
  );
53
31
  }
54
- return value;
55
- }
56
-
57
- function websocketUrl(flags: Flags): string | undefined {
58
- return (
59
- flag(flags, "ws") ??
60
- process.env.SOLANA_WS_URL?.trim() ??
61
- process.env.HELIUS_WS_URL?.trim()
62
- );
63
- }
64
-
65
- function feedAddress(flags: Flags): { host: string; port: number } {
66
32
  const host = flag(flags, "host") ?? "127.0.0.1";
33
+ if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") {
34
+ throw new Error("The trader price feed is loopback-only");
35
+ }
67
36
  const port = Math.trunc(numberFlag(flags, "port", 8788));
68
37
  if (!(port > 0 && port <= 65_535)) throw new Error("--port must be 1..65535");
69
38
  return { host, port };
70
39
  }
71
40
 
72
- function publicEndpoint(value: string): string {
73
- try {
74
- const url = new URL(value);
75
- for (const key of [...url.searchParams.keys()]) {
76
- if (/key|token|secret|auth/i.test(key)) {
77
- url.searchParams.set(key, "<redacted>");
78
- }
79
- }
80
- return url.toString();
81
- } catch {
82
- return value.replace(
83
- /([?&](?:api-?key|token|secret|auth)=)[^&]+/gi,
84
- "$1<redacted>",
85
- );
86
- }
87
- }
88
-
89
41
  function errorText(error: unknown): string {
90
42
  return error instanceof Error ? error.message : String(error);
91
43
  }
92
44
 
45
+ function uniqueMints(value: unknown): string[] {
46
+ if (!Array.isArray(value)) throw new Error("mints must be an array");
47
+ return [...new Set(value.map((mint) => String(mint).trim()).filter(Boolean))];
48
+ }
49
+
50
+ function priceMessage(event: TradeEvent): PriceFeedPrice {
51
+ return {
52
+ type: "price",
53
+ atMs: event.atMs,
54
+ signature: event.signature,
55
+ slot: event.slot,
56
+ mint: event.mint,
57
+ pool: event.pool,
58
+ venue: event.venue,
59
+ side: event.side,
60
+ market: {
61
+ quoteMint: event.market.quoteMint,
62
+ baseDecimals: event.market.baseDecimals,
63
+ quoteDecimals: event.market.quoteDecimals,
64
+ supply: event.market.supply,
65
+ baseReserve: event.market.baseReserve,
66
+ quoteReserve: event.market.quoteReserve,
67
+ priceQuotePerToken: event.market.priceQuotePerToken,
68
+ marketCapQuote: event.market.marketCapQuote,
69
+ priceSol: event.market.priceSol,
70
+ marketCapSol: event.market.marketCapSol,
71
+ solUsd: event.market.solUsd,
72
+ solUsdSource: event.market.solUsdSource,
73
+ solUsdAtMs: event.market.solUsdAtMs,
74
+ priceUsd: event.market.priceUsd,
75
+ marketCapUsd: event.market.marketCapUsd,
76
+ },
77
+ };
78
+ }
79
+
93
80
  export async function runPriceFeedServerCommand(args: {
94
81
  flags: Flags;
95
82
  emit: Emit;
@@ -107,34 +94,14 @@ export async function runPriceFeedServerCommand(args: {
107
94
  ): T =>
108
95
  m.sync({ start: () => label, end: (result: T) => result }, () => value);
109
96
  const { host, port } = feedAddress(args.flags);
110
- const rpc = rpcUrl(args.flags);
111
- const ws = websocketUrl(args.flags);
112
- const includeMayhem = args.flags.has("include-mayhem");
113
- const connection = new Connection(
114
- rpc,
115
- ws ? { commitment: "confirmed", wsEndpoint: ws } : "confirmed",
116
- );
117
97
  const controller = new AbortController();
118
98
  const clients = new Map<unknown, Client>();
119
- const launches = new Map<string, PriceFeedLaunch>();
99
+ const refs = new Map<string, number>();
120
100
  const latest = new Map<string, PriceFeedPrice>();
121
- const mintState = new Map<string, MintState>();
122
- const discoveredMints = new Set<string>();
123
- const explicitMintRefs = new Map<string, number>();
124
- let tradeSubscription: TradeSubscription | null = null;
125
- let allPriceClients = 0;
126
- let solUsd: number | null = null;
127
- try {
128
- const quote = await getSolUsdPrice();
129
- solUsd = quote.price;
130
- report("sol-usd", quote);
131
- } catch (error) {
132
- report("sol-usd-unavailable", { error: errorText(error) });
133
- }
134
- let launchCount = 0;
101
+ let tradeSubscription: TradeListener | null = null;
135
102
  let tradeCount = 0;
136
- let priceCount = 0;
137
103
  let stopped = false;
104
+ let commandQueue = Promise.resolve();
138
105
 
139
106
  const send = (
140
107
  socket: { send(value: string): unknown },
@@ -145,112 +112,40 @@ export async function runPriceFeedServerCommand(args: {
145
112
  } catch {}
146
113
  };
147
114
 
148
- const broadcast = (value: PriceFeedMessage): void => {
149
- for (const [socket, client] of clients) {
150
- const target = socket as { send(value: string): unknown };
151
- if (value.type === "launch") {
152
- if (client.launches) send(target, value);
153
- continue;
154
- }
155
- if (value.type === "price") {
156
- if (client.allPrices || client.mints.has(value.mint))
157
- send(target, value);
158
- continue;
159
- }
160
- send(target, value);
161
- }
162
- };
163
-
164
- const shouldWatchMint = (mint: string): boolean =>
165
- (explicitMintRefs.get(mint) ?? 0) > 0 ||
166
- (allPriceClients > 0 && discoveredMints.has(mint));
167
-
168
- const syncMintSubscription = async (mint: string): Promise<void> => {
169
- if (!tradeSubscription) return;
170
- const watched = tradeSubscription.hasToken(mint);
171
- const desired = shouldWatchMint(mint);
172
- if (desired && !watched) await tradeSubscription.addTokens(mint);
173
- if (!desired && watched) await tradeSubscription.removeTokens(mint);
174
- };
175
-
176
- const syncDiscoveredSubscriptions = async (): Promise<void> => {
177
- await Promise.allSettled(
178
- [...discoveredMints].map((mint) => syncMintSubscription(mint)),
179
- );
180
- };
181
-
182
- const onLaunch = async (event: LaunchEvent): Promise<void> => {
183
- if (event.isMayhemMode === true && !includeMayhem) return;
184
- const value: PriceFeedLaunch = {
185
- type: "launch",
186
- atMs: event.atMs,
187
- signature: event.signature,
188
- slot: event.slot,
189
- mint: event.mint,
190
- venue: event.venue,
191
- decimals: event.decimals,
192
- supplyUi: event.supplyUi,
193
- quoteMint: event.quoteMint,
194
- pool: event.pool,
195
- name: event.name,
196
- symbol: event.symbol,
197
- isMayhemMode: event.isMayhemMode,
198
- };
199
- mintState.set(event.mint, {
200
- supplyUi: event.supplyUi,
201
- isMayhemMode: event.isMayhemMode,
202
- });
203
- launches.set(event.mint, value);
204
- discoveredMints.add(event.mint);
205
- launchCount += 1;
206
- broadcast(value);
207
- await syncMintSubscription(event.mint);
208
- };
115
+ const status = (
116
+ socket: { send(value: string): unknown },
117
+ event: string,
118
+ data?: Record<string, unknown>,
119
+ ): void => send(socket, { type: "status", atMs: Date.now(), event, data });
209
120
 
210
121
  const onTrade = (event: TradeEvent): void => {
211
122
  tradeCount += 1;
212
- const state = mintState.get(event.mint);
213
- if (state?.isMayhemMode === true && !includeMayhem) return;
214
- solUsd = event.market.solUsd ?? solUsd;
215
- const value: PriceFeedPrice = {
216
- type: "price",
217
- atMs: event.atMs,
218
- signature: event.signature,
219
- slot: event.slot,
220
- mint: event.mint,
221
- venue: event.venue,
222
- priceSol: event.market.priceSol,
223
- priceUsd: event.market.priceUsd,
224
- marketCapUsd: event.market.marketCapUsd,
225
- source: `${event.venue}-trade`,
226
- };
123
+ const value = priceMessage(event);
227
124
  latest.set(event.mint, value);
228
- priceCount += 1;
229
- broadcast(value);
230
- };
231
-
232
- const updateExplicitMintRef = async (
233
- mint: string,
234
- delta: 1 | -1,
235
- ): Promise<void> => {
236
- const next = Math.max(0, (explicitMintRefs.get(mint) ?? 0) + delta);
237
- if (next === 0) explicitMintRefs.delete(mint);
238
- else explicitMintRefs.set(mint, next);
239
- await syncMintSubscription(mint);
125
+ for (const [socket, client] of clients) {
126
+ if (!client.mints.has(event.mint)) continue;
127
+ send(socket as { send(value: string): unknown }, value);
128
+ }
240
129
  };
241
130
 
242
- const enableAllPrices = async (client: Client): Promise<void> => {
243
- if (client.allPrices) return;
244
- client.allPrices = true;
245
- allPriceClients += 1;
246
- if (allPriceClients === 1) await syncDiscoveredSubscriptions();
131
+ const addRef = async (mint: string): Promise<void> => {
132
+ const current = refs.get(mint) ?? 0;
133
+ if (current === 0) {
134
+ if (!tradeSubscription) throw new Error("Price feed is not ready");
135
+ await tradeSubscription.add(mint);
136
+ }
137
+ refs.set(mint, current + 1);
247
138
  };
248
139
 
249
- const disableAllPrices = async (client: Client): Promise<void> => {
250
- if (!client.allPrices) return;
251
- client.allPrices = false;
252
- allPriceClients = Math.max(0, allPriceClients - 1);
253
- if (allPriceClients === 0) await syncDiscoveredSubscriptions();
140
+ const removeRef = async (mint: string): Promise<void> => {
141
+ const current = refs.get(mint) ?? 0;
142
+ if (current <= 0) return;
143
+ if (current === 1) {
144
+ refs.delete(mint);
145
+ await tradeSubscription?.remove(mint);
146
+ return;
147
+ }
148
+ refs.set(mint, current - 1);
254
149
  };
255
150
 
256
151
  const applyCommand = async (
@@ -261,65 +156,56 @@ export async function runPriceFeedServerCommand(args: {
261
156
  if (!client) return;
262
157
  const target = socket as { send(value: string): unknown };
263
158
  if (command.op === "ping") {
264
- send(target, { type: "status", atMs: Date.now(), event: "pong" });
159
+ status(target, "pong", {
160
+ watchedMints: client.mints.size,
161
+ upstreamMints: refs.size,
162
+ });
265
163
  return;
266
164
  }
267
- const mints: string[] = [...new Set((command.mints ?? []).filter(Boolean))];
165
+ const mints = uniqueMints(command.mints);
268
166
  if (command.op === "subscribe") {
269
- if (command.launches === true && !client.launches) {
270
- client.launches = true;
271
- for (const value of launches.values()) send(target, value);
272
- }
273
- if (command.allPrices === true && !client.allPrices) {
274
- await enableAllPrices(client);
275
- for (const value of latest.values()) send(target, value);
276
- }
277
167
  for (const mint of mints) {
278
168
  if (!client.mints.has(mint)) {
169
+ await addRef(mint);
279
170
  client.mints.add(mint);
280
- await updateExplicitMintRef(mint, 1);
281
171
  }
282
- const value = latest.get(mint);
283
- if (value) send(target, value);
172
+ const cached = latest.get(mint);
173
+ if (cached) send(target, cached);
284
174
  }
175
+ status(target, "subscribed", {
176
+ mints,
177
+ watchedMints: client.mints.size,
178
+ upstreamMints: refs.size,
179
+ });
285
180
  return;
286
181
  }
287
182
  for (const mint of mints) {
288
183
  if (!client.mints.delete(mint)) continue;
289
- await updateExplicitMintRef(mint, -1);
184
+ await removeRef(mint);
290
185
  }
186
+ status(target, "unsubscribed", {
187
+ mints,
188
+ watchedMints: client.mints.size,
189
+ upstreamMints: refs.size,
190
+ });
291
191
  };
292
192
 
293
193
  const releaseClient = async (socket: unknown): Promise<void> => {
294
194
  const client = clients.get(socket);
295
195
  clients.delete(socket);
296
196
  if (!client) return;
297
- await disableAllPrices(client);
298
- await Promise.allSettled(
299
- [...client.mints].map((mint) => updateExplicitMintRef(mint, -1)),
300
- );
197
+ for (const mint of client.mints) await removeRef(mint);
301
198
  };
302
199
 
303
- tradeSubscription = await subscribeTrades({
304
- connection,
200
+ tradeSubscription = await slrd.listenTrades({
305
201
  tokens: [],
306
202
  commitment: "confirmed",
307
203
  signal: controller.signal,
308
- onTrade,
309
204
  onStatus(event, data) {
310
205
  report(`trades ${event}`, data ?? {});
311
206
  },
312
207
  });
313
- const launchSubscription = await subscribeLaunches({
314
- connection,
315
- commitment: "confirmed",
316
- metadata: false,
317
- signal: controller.signal,
318
- onLaunch,
319
- onStatus(event, data) {
320
- report(`launches ${event}`, data ?? {});
321
- },
322
- });
208
+ tradeSubscription.onTrade(onTrade);
323
209
 
324
210
  const server = Bun.serve({
325
211
  hostname: host,
@@ -335,54 +221,52 @@ export async function runPriceFeedServerCommand(args: {
335
221
  if (url.pathname === "/health") {
336
222
  return Response.json({
337
223
  ok: true,
224
+ service: "solard-price-feed",
338
225
  clients: clients.size,
339
- launchCount,
226
+ upstreamMints: refs.size,
227
+ watchedTokens: tradeSubscription?.list().length ?? 0,
228
+ cachedPrices: latest.size,
340
229
  tradeCount,
341
- priceCount,
342
- cachedLaunches: launches.size,
343
- latestPrices: latest.size,
344
- discoveredMints: discoveredMints.size,
345
- explicitMints: explicitMintRefs.size,
346
- allPriceClients,
347
- watchedTokens: tradeSubscription?.listTokens().length ?? 0,
348
- solUsd,
230
+ subscriptions: Object.fromEntries(refs),
349
231
  });
350
232
  }
351
- return new Response("Solard price feed\n");
233
+ return new Response("Solard trader price feed\n");
352
234
  },
353
235
  websocket: {
354
236
  open(socket) {
355
- clients.set(socket, {
356
- mints: new Set(),
357
- launches: false,
358
- allPrices: false,
237
+ clients.set(socket, { mints: new Set() });
238
+ status(socket, "ready", {
239
+ upstreamMints: refs.size,
359
240
  });
360
- send(socket, { type: "status", atMs: Date.now(), event: "ready" });
361
241
  },
362
242
  message(socket, message) {
243
+ let command: PriceFeedCommand;
363
244
  try {
364
- void applyCommand(
365
- socket,
366
- JSON.parse(String(message)) as PriceFeedCommand,
367
- ).catch((error) => {
368
- send(socket, {
369
- type: "status",
370
- atMs: Date.now(),
371
- event: "client-error",
372
- data: { error: errorText(error) },
373
- });
374
- });
245
+ command = JSON.parse(String(message)) as PriceFeedCommand;
246
+ if (
247
+ !command ||
248
+ (command.op !== "subscribe" &&
249
+ command.op !== "unsubscribe" &&
250
+ command.op !== "ping")
251
+ ) {
252
+ throw new Error("op must be subscribe, unsubscribe, or ping");
253
+ }
375
254
  } catch (error) {
376
- send(socket, {
377
- type: "status",
378
- atMs: Date.now(),
379
- event: "client-error",
380
- data: { error: errorText(error) },
381
- });
255
+ status(socket, "client-error", { error: errorText(error) });
256
+ return;
382
257
  }
258
+ commandQueue = commandQueue
259
+ .then(() => applyCommand(socket, command))
260
+ .catch((error) => {
261
+ status(socket, "client-error", { error: errorText(error) });
262
+ });
383
263
  },
384
264
  close(socket) {
385
- void releaseClient(socket);
265
+ commandQueue = commandQueue
266
+ .then(() => releaseClient(socket))
267
+ .catch((error) => {
268
+ report("release-client-error", { error: errorText(error) });
269
+ });
386
270
  },
387
271
  },
388
272
  });
@@ -391,16 +275,11 @@ export async function runPriceFeedServerCommand(args: {
391
275
  () =>
392
276
  report("heartbeat", {
393
277
  clients: clients.size,
394
- launchCount,
278
+ upstreamMints: refs.size,
279
+ watchedTokens: tradeSubscription?.list().length ?? 0,
280
+ cachedPrices: latest.size,
395
281
  tradeCount,
396
- priceCount,
397
- cachedLaunches: launches.size,
398
- latestPrices: latest.size,
399
- discoveredMints: discoveredMints.size,
400
- explicitMints: explicitMintRefs.size,
401
- allPriceClients,
402
- watchedTokens: tradeSubscription?.listTokens().length ?? 0,
403
- solUsd,
282
+ subscriptions: Object.fromEntries(refs),
404
283
  }),
405
284
  Math.max(1_000, Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000))),
406
285
  );
@@ -408,9 +287,6 @@ export async function runPriceFeedServerCommand(args: {
408
287
  report("ready", {
409
288
  url: `ws://${host}:${server.port}/ws`,
410
289
  health: `http://${host}:${server.port}/health`,
411
- rpc: publicEndpoint(rpc),
412
- ws: ws ? publicEndpoint(ws) : "connection-default",
413
- includeMayhem,
414
290
  });
415
291
 
416
292
  const stop = (): void => {
@@ -422,19 +298,18 @@ export async function runPriceFeedServerCommand(args: {
422
298
  process.once("SIGTERM", stop);
423
299
 
424
300
  try {
425
- await Promise.all([launchSubscription.closed, tradeSubscription.closed]);
301
+ await tradeSubscription.closed;
426
302
  } finally {
427
303
  clearInterval(heartbeat);
428
304
  process.removeListener("SIGINT", stop);
429
305
  process.removeListener("SIGTERM", stop);
430
- await launchSubscription.close();
431
306
  await tradeSubscription.close();
307
+ await commandQueue.catch(() => undefined);
432
308
  server.stop(true);
433
309
  report("stopped", {
434
- launchCount,
435
- tradeCount,
436
- priceCount,
437
310
  clients: clients.size,
311
+ upstreamMints: refs.size,
312
+ tradeCount,
438
313
  });
439
314
  }
440
315
  }
@@ -208,14 +208,14 @@ function required(flags: Flags, key: string): string {
208
208
  }
209
209
 
210
210
  function rpcUrl(flags: Flags): string {
211
- const value =
212
- flag(flags, "rpc") ??
213
- process.env.RPC_ENDPOINT?.trim() ??
214
- process.env.SOLANA_RPC_URL?.trim() ??
215
- process.env.HELIUS_RPC_URL?.trim();
211
+ if (flags.has("rpc") || flags.has("ws"))
212
+ throw new Error(
213
+ "Set only RPC_ENDPOINT in the environment. --rpc and --ws are intentionally unsupported.",
214
+ );
215
+ const value = process.env.RPC_ENDPOINT?.trim();
216
216
  if (!value)
217
217
  throw new Error(
218
- "strategy run requires RPC_ENDPOINT, SOLANA_RPC_URL, HELIUS_RPC_URL, or --rpc <url>",
218
+ "strategy run requires RPC_ENDPOINT. WebSocket access is derived from the same endpoint.",
219
219
  );
220
220
  return value;
221
221
  }