@solard/cli 0.2.20 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solard/cli",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "description": "Command-line interface for the Solard SDK.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -18,9 +18,9 @@
18
18
  "bun": ">=1.1.0"
19
19
  },
20
20
  "dependencies": {
21
- "@solard/core": "0.2.20",
22
- "bs58": "^6.0.0",
21
+ "@solard/core": "0.2.21",
23
22
  "@solana/web3.js": "^1.98.4",
23
+ "bs58": "^6.0.0",
24
24
  "measure-fn": "5.2.0"
25
25
  },
26
26
  "scripts": {
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");
@@ -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
+ }
@@ -1,8 +1,51 @@
1
- export type {
2
- PriceFeedCommand,
3
- PriceFeedLaunch,
4
- PriceFeedMessage,
5
- PriceFeedPrice,
6
- PriceFeedStatus,
7
- PriceFeedVenue,
8
- } from "@solard/core";
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" };
@@ -1,4 +1,5 @@
1
1
  import { configure, createMeasure } from "measure-fn";
2
+ import WebSocket, { type RawData } from "ws";
2
3
  import bs58 from "bs58";
3
4
  import { bondingCurvePda } from "@solard/core";
4
5
  import {
@@ -140,6 +141,16 @@ function errorText(error: unknown): string {
140
141
  return error instanceof Error ? error.message : String(error);
141
142
  }
142
143
 
144
+ function safeErrorText(error: unknown, endpoint?: string): string {
145
+ let value = errorText(error);
146
+ if (endpoint) value = value.split(endpoint).join(publicEndpoint(endpoint));
147
+ value = value.replace(
148
+ /([?&](?:api-?key|token|secret|auth)=)[^&\s'"]+/gi,
149
+ "$1<redacted>",
150
+ );
151
+ return value;
152
+ }
153
+
143
154
  class RpcPacer {
144
155
  private tail: Promise<unknown> = Promise.resolve();
145
156
  private nextAt = 0;
@@ -259,6 +270,7 @@ class StandardLogsMux {
259
270
 
260
271
  async start(timeoutMs = 15_000): Promise<void> {
261
272
  if (this.connected) return;
273
+ if (this.stopped) throw new Error("WebSocket transport is stopped");
262
274
  if (!this.initialResolve) {
263
275
  const ready = new Promise<void>((resolve, reject) => {
264
276
  this.initialResolve = resolve;
@@ -268,11 +280,12 @@ class StandardLogsMux {
268
280
  const reject = this.initialReject;
269
281
  this.initialResolve = null;
270
282
  this.initialReject = null;
283
+ this.initialTimer = null;
271
284
  const error = new Error(
272
285
  `WebSocket did not connect within ${timeoutMs}ms`,
273
286
  );
274
287
  this.stop();
275
- if (reject) reject(error);
288
+ reject?.(error);
276
289
  }, timeoutMs);
277
290
  this.connect();
278
291
  return await ready;
@@ -283,6 +296,9 @@ class StandardLogsMux {
283
296
  if (this.connected) {
284
297
  clearInterval(timer);
285
298
  resolve();
299
+ } else if (this.stopped) {
300
+ clearInterval(timer);
301
+ reject(new Error("WebSocket transport stopped"));
286
302
  } else if (Date.now() - started >= timeoutMs) {
287
303
  clearInterval(timer);
288
304
  reject(new Error(`WebSocket did not connect within ${timeoutMs}ms`));
@@ -292,20 +308,26 @@ class StandardLogsMux {
292
308
  }
293
309
 
294
310
  stop(): void {
311
+ if (this.stopped && !this.socket) return;
295
312
  this.stopped = true;
296
313
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
297
314
  this.reconnectTimer = null;
298
315
  if (this.initialTimer) clearTimeout(this.initialTimer);
299
316
  this.initialTimer = null;
317
+ const reject = this.initialReject;
300
318
  this.initialResolve = null;
301
319
  this.initialReject = null;
302
320
  const socket = this.socket;
303
321
  this.socket = null;
304
322
  this.connected = false;
305
323
  this.connecting = false;
324
+ reject?.(new Error("WebSocket transport stopped"));
306
325
  try {
307
326
  socket?.close(1000, "shutdown");
308
327
  } catch {}
328
+ try {
329
+ socket?.terminate();
330
+ } catch {}
309
331
  }
310
332
 
311
333
  private connect(): void {
@@ -313,18 +335,22 @@ class StandardLogsMux {
313
335
  this.connecting = true;
314
336
  let socket: WebSocket;
315
337
  try {
316
- socket = new WebSocket(this.url);
338
+ socket = new WebSocket(this.url, {
339
+ handshakeTimeout: 10_000,
340
+ perMessageDeflate: false,
341
+ });
317
342
  } catch (error) {
318
343
  this.connecting = false;
319
344
  this.report("upstream websocket error", {
320
345
  endpoint: publicEndpoint(this.url),
321
- error: errorText(error),
346
+ error: safeErrorText(error, this.url),
322
347
  });
323
348
  this.scheduleReconnect();
324
349
  return;
325
350
  }
326
351
  this.socket = socket;
327
- socket.onopen = () => {
352
+
353
+ socket.on("open", () => {
328
354
  if (this.socket !== socket || this.stopped) return;
329
355
  this.connecting = false;
330
356
  this.connected = true;
@@ -344,13 +370,27 @@ class StandardLogsMux {
344
370
  this.initialResolve = null;
345
371
  this.initialReject = null;
346
372
  resolve?.();
347
- };
348
- socket.onmessage = (event) => {
373
+ });
374
+
375
+ socket.on("unexpected-response", (_request, response) => {
376
+ if (this.socket !== socket || this.stopped) return;
377
+ this.report("upstream websocket handshake rejected", {
378
+ endpoint: publicEndpoint(this.url),
379
+ statusCode: response.statusCode ?? null,
380
+ statusMessage: response.statusMessage ?? null,
381
+ retryAfter: response.headers["retry-after"] ?? null,
382
+ });
383
+ try {
384
+ response.resume();
385
+ } catch {}
386
+ });
387
+
388
+ socket.on("message", (raw: RawData) => {
349
389
  if (this.socket !== socket) return;
350
390
  this._lastMessageAtMs = Date.now();
351
391
  let message: any;
352
392
  try {
353
- message = JSON.parse(String(event.data));
393
+ message = JSON.parse(raw.toString());
354
394
  } catch {
355
395
  return;
356
396
  }
@@ -410,24 +450,21 @@ class StandardLogsMux {
410
450
  } catch (error) {
411
451
  this.report("upstream callback error", {
412
452
  pubkey: key,
413
- error: errorText(error),
453
+ error: safeErrorText(error, this.url),
414
454
  });
415
455
  }
416
456
  }
417
- };
418
- socket.onerror = (event: any) => {
457
+ });
458
+
459
+ socket.on("error", (error) => {
419
460
  if (this.socket !== socket || this.stopped) return;
420
461
  this.report("upstream websocket error", {
421
462
  endpoint: publicEndpoint(this.url),
422
- error: String(
423
- event?.message ?? event?.error?.message ?? "websocket error",
424
- ),
463
+ error: safeErrorText(error, this.url),
425
464
  });
426
- try {
427
- socket.close();
428
- } catch {}
429
- };
430
- socket.onclose = (event) => {
465
+ });
466
+
467
+ socket.on("close", (code, reason) => {
431
468
  if (this.socket !== socket) return;
432
469
  this.socket = null;
433
470
  const wasConnected = this.connected;
@@ -440,12 +477,12 @@ class StandardLogsMux {
440
477
  if (wasConnected) this._reconnects += 1;
441
478
  this.report("upstream websocket disconnected", {
442
479
  endpoint: publicEndpoint(this.url),
443
- code: event.code,
444
- reason: event.reason || null,
480
+ code,
481
+ reason: reason.length ? reason.toString() : null,
445
482
  retryInMs: this.reconnectDelayMs,
446
483
  });
447
484
  this.scheduleReconnect();
448
- };
485
+ });
449
486
  }
450
487
 
451
488
  private scheduleReconnect(): void {
@@ -482,7 +519,7 @@ class StandardLogsMux {
482
519
  this.requests.delete(id);
483
520
  this.report("upstream websocket send error", {
484
521
  endpoint: publicEndpoint(this.url),
485
- error: errorText(error),
522
+ error: safeErrorText(error, this.url),
486
523
  });
487
524
  }
488
525
  }
@@ -1516,80 +1553,101 @@ export async function runPriceFeedServerCommand(args: {
1516
1553
  }
1517
1554
  };
1518
1555
 
1556
+ let heartbeat: ReturnType<typeof setInterval> | null = null;
1557
+ let solTimer: ReturnType<typeof setInterval> | null = null;
1558
+ let pruneTimer: ReturnType<typeof setInterval> | null = null;
1559
+
1519
1560
  const stop = () => {
1561
+ if (stopped) return;
1520
1562
  stopped = true;
1563
+ logsMux.stop();
1564
+ try {
1565
+ server.stop(true);
1566
+ } catch {}
1521
1567
  };
1522
1568
  process.once("SIGINT", stop);
1523
1569
  process.once("SIGTERM", stop);
1524
- await logsMux.start();
1525
- await refreshSolUsd();
1526
- report("ready", {
1527
- url: `ws://${host}:${port}/ws`,
1528
- health: `http://${host}:${port}/health`,
1529
- upstreamEndpoint: publicEndpoint(ws),
1530
- upstreamState: logsMux.state,
1531
- upstreamProgramSubscriptions: programSubs.length,
1532
- maxFallbackSubscriptions,
1533
- rpcReadRps: readRps,
1534
- rpcHardRps: hardRps,
1535
- includeMayhem,
1536
- });
1537
1570
 
1538
- const heartbeat = setInterval(
1539
- () => {
1540
- report("heartbeat", {
1541
- clients: clients.size,
1542
- clientConnections,
1543
- upstreamState: logsMux.state,
1544
- upstreamSubscriptions: logsMux.subscriptionCount,
1545
- upstreamProgramSubscriptions: programSubs.length,
1546
- upstreamReconnects: logsMux.reconnects,
1547
- upstreamLastMessageAgeMs: logsMux.lastMessageAtMs
1548
- ? Date.now() - logsMux.lastMessageAtMs
1549
- : null,
1550
- fallbackSubscriptions: fallbackSubs.size,
1551
- knownMints: mints.size,
1552
- cachedLaunches: launchesByMint.size,
1553
- pools: pools.size,
1554
- latestPrices: latest.size,
1555
- launches,
1556
- prices,
1557
- pendingFallbackMints: pendingFallback.size,
1558
- coalescedFallbackUpdates: coalescedFallback,
1559
- rpcQueue: pacer.queued,
1560
- rpcErrors,
1561
- solUsd,
1562
- solUsdAgeMs: solUsdAt ? Date.now() - solUsdAt : null,
1563
- });
1564
- },
1565
- Math.max(1_000, Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000))),
1566
- );
1567
- const solTimer = setInterval(() => void refreshSolUsd(), 30_000);
1568
- const pruneTimer = setInterval(() => {
1569
- const cutoff = Date.now() - 6 * 60 * 60_000;
1570
- if (mints.size > 50_000) {
1571
- for (const [mint, state] of mints) {
1572
- if (state.lastSeenAtMs < cutoff && !fallbackSubs.has(mint)) {
1573
- mints.delete(mint);
1574
- latest.delete(mint);
1575
- launchesByMint.delete(mint);
1571
+ try {
1572
+ try {
1573
+ await logsMux.start();
1574
+ } catch (error) {
1575
+ if (!stopped) throw error;
1576
+ return;
1577
+ }
1578
+ if (stopped) return;
1579
+ await refreshSolUsd();
1580
+ report("ready", {
1581
+ url: `ws://${host}:${port}/ws`,
1582
+ health: `http://${host}:${port}/health`,
1583
+ upstreamEndpoint: publicEndpoint(ws),
1584
+ upstreamState: logsMux.state,
1585
+ upstreamProgramSubscriptions: programSubs.length,
1586
+ maxFallbackSubscriptions,
1587
+ rpcReadRps: readRps,
1588
+ rpcHardRps: hardRps,
1589
+ includeMayhem,
1590
+ });
1591
+
1592
+ heartbeat = setInterval(
1593
+ () => {
1594
+ report("heartbeat", {
1595
+ clients: clients.size,
1596
+ clientConnections,
1597
+ upstreamState: logsMux.state,
1598
+ upstreamSubscriptions: logsMux.subscriptionCount,
1599
+ upstreamProgramSubscriptions: programSubs.length,
1600
+ upstreamReconnects: logsMux.reconnects,
1601
+ upstreamLastMessageAgeMs: logsMux.lastMessageAtMs
1602
+ ? Date.now() - logsMux.lastMessageAtMs
1603
+ : null,
1604
+ fallbackSubscriptions: fallbackSubs.size,
1605
+ knownMints: mints.size,
1606
+ cachedLaunches: launchesByMint.size,
1607
+ pools: pools.size,
1608
+ latestPrices: latest.size,
1609
+ launches,
1610
+ prices,
1611
+ pendingFallbackMints: pendingFallback.size,
1612
+ coalescedFallbackUpdates: coalescedFallback,
1613
+ rpcQueue: pacer.queued,
1614
+ rpcErrors,
1615
+ solUsd,
1616
+ solUsdAgeMs: solUsdAt ? Date.now() - solUsdAt : null,
1617
+ });
1618
+ },
1619
+ Math.max(
1620
+ 1_000,
1621
+ Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000)),
1622
+ ),
1623
+ );
1624
+ solTimer = setInterval(() => void refreshSolUsd(), 30_000);
1625
+ pruneTimer = setInterval(() => {
1626
+ const cutoff = Date.now() - 6 * 60 * 60_000;
1627
+ if (mints.size > 50_000) {
1628
+ for (const [mint, state] of mints) {
1629
+ if (state.lastSeenAtMs < cutoff && !fallbackSubs.has(mint)) {
1630
+ mints.delete(mint);
1631
+ latest.delete(mint);
1632
+ launchesByMint.delete(mint);
1633
+ }
1576
1634
  }
1577
1635
  }
1578
- }
1579
- }, 60_000);
1636
+ }, 60_000);
1580
1637
 
1581
- try {
1582
1638
  await fallbackLoop();
1583
1639
  } finally {
1584
- clearInterval(heartbeat);
1585
- clearInterval(solTimer);
1586
- clearInterval(pruneTimer);
1640
+ if (heartbeat) clearInterval(heartbeat);
1641
+ if (solTimer) clearInterval(solTimer);
1642
+ if (pruneTimer) clearInterval(pruneTimer);
1587
1643
  process.removeListener("SIGINT", stop);
1588
1644
  process.removeListener("SIGTERM", stop);
1589
1645
  for (const id of programSubs) logsMux.unsubscribe(id);
1590
1646
  for (const row of fallbackSubs.values()) logsMux.unsubscribe(row.id);
1591
1647
  logsMux.stop();
1592
- server.stop(true);
1648
+ try {
1649
+ server.stop(true);
1650
+ } catch {}
1593
1651
  report("stopped", { clients: clients.size, launches, prices, rpcErrors });
1594
1652
  }
1595
1653
  }
@@ -7,13 +7,13 @@ import {
7
7
  type Logs,
8
8
  type ParsedTransactionWithMeta,
9
9
  } from "@solana/web3.js";
10
- import { connectPriceFeed } from "./price-feed-client.ts";
11
- import type { PriceFeedPrice } from "./price-feed-protocol.ts";
12
10
  import {
13
11
  RaydiumService,
14
12
  createTraderSolard,
15
13
  resolveTradeAsset,
16
14
  sol,
15
+ subscribeTrades,
16
+ type TradeEvent,
17
17
  } from "@solard/core";
18
18
 
19
19
  type Flags = Map<string, string>;
@@ -1003,14 +1003,10 @@ export async function runTradeStrategyCommand(args: {
1003
1003
  Math.trunc(numberFlag(args.flags, "heartbeat-ms", 15_000)),
1004
1004
  );
1005
1005
  const rpc = rpcUrl(args.flags);
1006
- const feed =
1007
- flag(args.flags, "feed") ??
1008
- process.env.SOLARD_FEED_URL?.trim() ??
1009
- "ws://127.0.0.1:8788/ws";
1010
1006
  const strategy = await loadStrategy(path);
1011
1007
  if (typeof strategy.onPrice !== "function")
1012
1008
  throw new Error(
1013
- `Live shared-feed strategy ${path} must define onPrice(ctx, price). ` +
1009
+ `Live trade strategy ${path} must define onPrice(ctx, price). ` +
1014
1010
  `Legacy onTrade is retained for historical exact-trade backtests, not per-agent live RPC subscriptions.`,
1015
1011
  );
1016
1012
  const state = await initialState(strategy);
@@ -1021,8 +1017,7 @@ export async function runTradeStrategyCommand(args: {
1021
1017
  let priceCount = 0;
1022
1018
  let lastPrice: StrategyPriceTick | null = null;
1023
1019
  let callbackErrors = 0;
1024
- let feedErrors = 0;
1025
- let feedConnected = false;
1020
+ let marketErrors = 0;
1026
1021
  let callbackQueue: Promise<void> = Promise.resolve();
1027
1022
 
1028
1023
  try {
@@ -1139,18 +1134,20 @@ export async function runTradeStrategyCommand(args: {
1139
1134
  }
1140
1135
  };
1141
1136
 
1142
- const onPrice = async (event: PriceFeedPrice) => {
1143
- if (event.mint !== mint || stopped) return;
1137
+ const onPrice = async (event: TradeEvent) => {
1138
+ if (event.mint !== mint || stopped || event.priceQuote == null) return;
1139
+ const priceSol = event.quoteMint === WSOL_MINT ? event.priceQuote : null;
1140
+ const priceUsd = event.quoteMint === USDC_MINT ? event.priceQuote : null;
1141
+ if (priceSol == null && priceUsd == null) return;
1144
1142
  const price: StrategyPriceTick = {
1145
- signature: event.signature ?? "feed",
1146
- slot: event.slot ?? 0,
1143
+ signature: event.signature,
1144
+ slot: event.slot,
1147
1145
  atMs: event.atMs,
1148
1146
  mint,
1149
- priceSol: event.priceSol,
1150
- priceUsd: event.priceUsd,
1151
- marketCapUsd: event.marketCapUsd,
1147
+ priceSol,
1148
+ priceUsd,
1149
+ marketCapUsd: null,
1152
1150
  };
1153
- if (price.priceSol == null && price.priceUsd == null) return;
1154
1151
  priceCount += 1;
1155
1152
  lastPrice = price;
1156
1153
  if (json) report("price", price as any);
@@ -1162,21 +1159,16 @@ export async function runTradeStrategyCommand(args: {
1162
1159
  };
1163
1160
 
1164
1161
  const controller = new AbortController();
1165
- const feedClient = await connectPriceFeed({
1166
- url: feed,
1167
- subscribe: { op: "subscribe", mints: [mint] },
1162
+ const marketSubscription = await subscribeTrades({
1163
+ connection: slrd.connection(),
1164
+ tokens: [mint],
1168
1165
  signal: controller.signal,
1169
- onStatus(event, data) {
1170
- if (event === "connected") feedConnected = true;
1171
- if (event === "connect-error") {
1172
- feedConnected = false;
1173
- feedErrors += 1;
1174
- }
1175
- report(`feed ${event}`, data ?? {});
1166
+ onTrade(event) {
1167
+ callbackQueue = callbackQueue.then(() => onPrice(event));
1176
1168
  },
1177
- onMessage(message) {
1178
- if (message.type !== "price" || message.mint !== mint) return;
1179
- callbackQueue = callbackQueue.then(() => onPrice(message));
1169
+ onStatus(event, data) {
1170
+ if (event.includes("error")) marketErrors += 1;
1171
+ report(`market ${event}`, data ?? {});
1180
1172
  },
1181
1173
  });
1182
1174
 
@@ -1186,9 +1178,8 @@ export async function runTradeStrategyCommand(args: {
1186
1178
  wallet: walletRef,
1187
1179
  walletAddress,
1188
1180
  live,
1189
- feed,
1190
- event: "shared-price-feed",
1191
- upstreamMarketSubscriptions: 0,
1181
+ event: "direct-trade-subscription",
1182
+ upstreamMarketSubscriptions: marketSubscription.listTokens().length,
1192
1183
  });
1193
1184
 
1194
1185
  if (strategy.onStart) {
@@ -1208,11 +1199,10 @@ export async function runTradeStrategyCommand(args: {
1208
1199
  process.once("SIGTERM", stopSignal);
1209
1200
  const heartbeatTimer = setInterval(() => {
1210
1201
  report("strategy heartbeat", {
1211
- feedConnected,
1212
1202
  prices: priceCount,
1213
1203
  callbackErrors,
1214
- feedErrors,
1215
- upstreamMarketSubscriptions: 0,
1204
+ marketErrors,
1205
+ upstreamMarketSubscriptions: marketSubscription.listTokens().length,
1216
1206
  executionVenue: executor.venue ?? "unresolved",
1217
1207
  });
1218
1208
  }, heartbeatMs);
@@ -1224,7 +1214,7 @@ export async function runTradeStrategyCommand(args: {
1224
1214
  process.removeListener("SIGINT", stopSignal);
1225
1215
  process.removeListener("SIGTERM", stopSignal);
1226
1216
  controller.abort();
1227
- feedClient.close();
1217
+ await marketSubscription.close();
1228
1218
  await callbackQueue.catch(() => undefined);
1229
1219
  if (strategy.onStop) {
1230
1220
  try {
@@ -1238,7 +1228,7 @@ export async function runTradeStrategyCommand(args: {
1238
1228
  reason: stopReason,
1239
1229
  prices: priceCount,
1240
1230
  callbackErrors,
1241
- feedErrors,
1231
+ marketErrors,
1242
1232
  });
1243
1233
  }
1244
1234
  } finally {