@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 +3 -3
- package/src/index.ts +5 -15
- package/src/launch-watch-command.ts +153 -52
- package/src/price-feed-client.ts +168 -2
- package/src/price-feed-protocol.ts +51 -8
- package/src/price-feed-server.ts +138 -80
- package/src/trade-strategy-command.ts +28 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solard/cli",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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] [--
|
|
394
|
-
|
|
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] [--
|
|
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>] [--
|
|
478
|
-
Run an onPrice strategy from
|
|
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 {
|
|
3
|
-
import
|
|
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
|
|
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, "
|
|
62
|
-
process.env.
|
|
63
|
-
|
|
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
|
|
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
|
|
169
|
+
let solUsd: number | null = null;
|
|
110
170
|
let stopped = false;
|
|
111
|
-
let
|
|
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:
|
|
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
|
|
141
|
-
|
|
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
|
|
152
|
-
priceUsd
|
|
153
|
-
priceSol
|
|
154
|
-
source: event.
|
|
233
|
+
marketCapUsd,
|
|
234
|
+
priceUsd,
|
|
235
|
+
priceSol,
|
|
236
|
+
source: `${event.venue}-trade`,
|
|
155
237
|
});
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 (
|
|
246
|
+
if (marketCapUsd < minMarketCapUsd) return;
|
|
161
247
|
state.qualified = true;
|
|
162
|
-
state.lastNotifiedAthUsd =
|
|
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 =
|
|
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
|
|
257
|
+
marketCapUsd,
|
|
174
258
|
thresholdMarketCapUsd: minMarketCapUsd,
|
|
175
259
|
nextAthNotifyUsd,
|
|
176
260
|
athStepPct,
|
|
177
|
-
priceUsd
|
|
178
|
-
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 =
|
|
269
|
+
state.lastNotifiedAthUsd = marketCapUsd;
|
|
186
270
|
return;
|
|
187
271
|
}
|
|
188
272
|
const target = previous * (1 + athStepPct / 100);
|
|
189
|
-
if (
|
|
190
|
-
state.lastNotifiedAthUsd =
|
|
273
|
+
if (marketCapUsd + 1e-12 < target) return;
|
|
274
|
+
state.lastNotifiedAthUsd = marketCapUsd;
|
|
191
275
|
athNotifications += 1;
|
|
192
|
-
const increasePct = (
|
|
193
|
-
const nextAthNotifyUsd =
|
|
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
|
|
283
|
+
marketCapUsd,
|
|
200
284
|
previousNotifiedAthUsd: previous,
|
|
201
285
|
nextAthNotifyUsd,
|
|
202
286
|
athStepPct,
|
|
203
287
|
increasePct,
|
|
204
|
-
priceUsd
|
|
205
|
-
priceSol
|
|
288
|
+
priceUsd,
|
|
289
|
+
priceSol,
|
|
206
290
|
signature: event.signature,
|
|
207
291
|
});
|
|
208
292
|
};
|
|
209
293
|
|
|
210
294
|
const controller = new AbortController();
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
if (event === "connect-error") feedConnected = false;
|
|
218
|
-
report(`feed ${event}`, data ?? {});
|
|
307
|
+
report(`trades ${event}`, data ?? {});
|
|
219
308
|
},
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
368
|
+
await launchSubscription.close();
|
|
369
|
+
await tradeSubscription.close();
|
|
269
370
|
process.removeListener("SIGINT", stop);
|
|
270
371
|
process.removeListener("SIGTERM", stop);
|
|
271
372
|
report("stopped", {
|
package/src/price-feed-client.ts
CHANGED
|
@@ -1,2 +1,168 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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" };
|
package/src/price-feed-server.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
346
|
+
error: safeErrorText(error, this.url),
|
|
322
347
|
});
|
|
323
348
|
this.scheduleReconnect();
|
|
324
349
|
return;
|
|
325
350
|
}
|
|
326
351
|
this.socket = socket;
|
|
327
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
453
|
+
error: safeErrorText(error, this.url),
|
|
414
454
|
});
|
|
415
455
|
}
|
|
416
456
|
}
|
|
417
|
-
};
|
|
418
|
-
|
|
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:
|
|
423
|
-
event?.message ?? event?.error?.message ?? "websocket error",
|
|
424
|
-
),
|
|
463
|
+
error: safeErrorText(error, this.url),
|
|
425
464
|
});
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
444
|
-
reason:
|
|
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:
|
|
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
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
1146
|
-
slot: event.slot
|
|
1143
|
+
signature: event.signature,
|
|
1144
|
+
slot: event.slot,
|
|
1147
1145
|
atMs: event.atMs,
|
|
1148
1146
|
mint,
|
|
1149
|
-
priceSol
|
|
1150
|
-
priceUsd
|
|
1151
|
-
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
|
|
1166
|
-
|
|
1167
|
-
|
|
1162
|
+
const marketSubscription = await subscribeTrades({
|
|
1163
|
+
connection: slrd.connection(),
|
|
1164
|
+
tokens: [mint],
|
|
1168
1165
|
signal: controller.signal,
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1178
|
-
if (
|
|
1179
|
-
|
|
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
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
1215
|
-
upstreamMarketSubscriptions:
|
|
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
|
-
|
|
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
|
-
|
|
1231
|
+
marketErrors,
|
|
1242
1232
|
});
|
|
1243
1233
|
}
|
|
1244
1234
|
} finally {
|