@solard/cli 0.2.20 → 0.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -4
- package/src/index.ts +16 -25
- 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.architecture.test.ts +21 -0
- package/src/price-feed-server.ts +286 -1386
- 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.22",
|
|
4
4
|
"description": "Command-line interface for the Solard SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
"bun": ">=1.1.0"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@solard/core": "0.2.20",
|
|
22
|
-
"bs58": "^6.0.0",
|
|
23
21
|
"@solana/web3.js": "^1.98.4",
|
|
22
|
+
"@solard/core": "0.2.22",
|
|
23
|
+
"@solard/sdk": "0.2.22",
|
|
24
|
+
"bs58": "^6.0.0",
|
|
24
25
|
"measure-fn": "5.2.0"
|
|
25
26
|
},
|
|
26
27
|
"scripts": {
|
|
27
|
-
"typecheck": "tsc --noEmit -p ../../tsconfig.json"
|
|
28
|
+
"typecheck": "tsc --noEmit -p ../../tsconfig.json",
|
|
29
|
+
"test": "bun test src"
|
|
28
30
|
}
|
|
29
31
|
}
|
package/src/index.ts
CHANGED
|
@@ -385,17 +385,13 @@ Vanity mints
|
|
|
385
385
|
slrd vanity pool release <mint-address> Release an ambiguous/failed launch reservation
|
|
386
386
|
launch/vamp: add --mint-pool pump [--mint-pool-address <address>] to consume a pooled mint
|
|
387
387
|
|
|
388
|
-
Shared market feed
|
|
389
|
-
slrd feed serve [--host 127.0.0.1] [--port 8788] [--rpc-rps 5] [--rpc-read-rps 2] [--max-fallback-subs 200]
|
|
390
|
-
One upstream Pump/PumpSwap/LaunchLab feed shared by watchers, agents, and future UI clients
|
|
391
|
-
|
|
392
388
|
Launch discovery
|
|
393
|
-
slrd launch watch [--venue pump,raydium-launchlab] [--min-mcap 5000] [--ath-step-pct 20] [--track-ttl 30m] [--
|
|
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");
|
|
@@ -1082,11 +1072,10 @@ async function main() {
|
|
|
1082
1072
|
throw new Error(
|
|
1083
1073
|
"Usage: slrd export <wallet|address> [--json] [--out <path>]",
|
|
1084
1074
|
);
|
|
1085
|
-
const
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
: bs58.encode(signer.secretKey);
|
|
1075
|
+
const exported = slrd.exportWalletPrivateKey(
|
|
1076
|
+
ref,
|
|
1077
|
+
flags.has("json") ? "json" : "base58",
|
|
1078
|
+
);
|
|
1090
1079
|
|
|
1091
1080
|
const out = flags.get("out");
|
|
1092
1081
|
if (out && out !== "true") {
|
|
@@ -1094,18 +1083,20 @@ async function main() {
|
|
|
1094
1083
|
const { dirname, resolve } = await import("node:path");
|
|
1095
1084
|
const path = resolve(out);
|
|
1096
1085
|
mkdirSync(dirname(path), { recursive: true });
|
|
1097
|
-
writeFileSync(path, `${
|
|
1086
|
+
writeFileSync(path, `${exported.privateKey}\n`, {
|
|
1087
|
+
mode: 0o600,
|
|
1088
|
+
flag: "wx",
|
|
1089
|
+
});
|
|
1098
1090
|
emit(
|
|
1099
|
-
`${OWL} exported @${
|
|
1091
|
+
`${OWL} exported @${exported.wallet.name ?? "?"} ${exported.wallet.address} -> ${path}\n`,
|
|
1100
1092
|
);
|
|
1101
1093
|
return;
|
|
1102
1094
|
}
|
|
1103
1095
|
|
|
1104
|
-
// Secret to stdout only; metadata to stderr so piping stays clean.
|
|
1105
1096
|
process.stderr.write(
|
|
1106
|
-
`${OWL} @${
|
|
1097
|
+
`${OWL} @${exported.wallet.name ?? "?"} ${exported.wallet.address} (${exported.format === "json" ? "keypair-json" : "base58"})\n`,
|
|
1107
1098
|
);
|
|
1108
|
-
process.stdout.write(`${
|
|
1099
|
+
process.stdout.write(`${exported.privateKey}\n`);
|
|
1109
1100
|
return;
|
|
1110
1101
|
}
|
|
1111
1102
|
if (command === "import") {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { configure, createMeasure } from "measure-fn";
|
|
2
|
-
import {
|
|
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
|
+
}
|