@solard/cli 0.2.17 → 0.2.20
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 +2 -2
- package/src/index.ts +788 -208
- package/src/launch-watch-command.ts +278 -0
- package/src/price-feed-client.ts +2 -0
- package/src/price-feed-protocol.ts +8 -0
- package/src/price-feed-server.ts +1595 -0
- package/src/pump-watch-command.ts +11 -0
- package/src/reclaim-command.ts +335 -0
- package/src/research-history-cache.ts +331 -0
- package/src/strategy-backtest-command.ts +740 -0
- package/src/token-history-command.ts +183 -30
- package/src/trade-strategy-command.ts +1247 -0
- package/src/tx-stream.test.ts +38 -0
|
@@ -0,0 +1,1595 @@
|
|
|
1
|
+
import { configure, createMeasure } from "measure-fn";
|
|
2
|
+
import bs58 from "bs58";
|
|
3
|
+
import { bondingCurvePda } from "@solard/core";
|
|
4
|
+
import {
|
|
5
|
+
Connection,
|
|
6
|
+
PublicKey,
|
|
7
|
+
type ParsedTransactionWithMeta,
|
|
8
|
+
} from "@solana/web3.js";
|
|
9
|
+
import type {
|
|
10
|
+
PriceFeedCommand,
|
|
11
|
+
PriceFeedLaunch,
|
|
12
|
+
PriceFeedMessage,
|
|
13
|
+
PriceFeedPrice,
|
|
14
|
+
PriceFeedVenue,
|
|
15
|
+
} from "./price-feed-protocol.ts";
|
|
16
|
+
|
|
17
|
+
type Flags = Map<string, string>;
|
|
18
|
+
type Emit = (value: string) => void;
|
|
19
|
+
type Client = {
|
|
20
|
+
mints: Set<string>;
|
|
21
|
+
launches: boolean;
|
|
22
|
+
allPrices: boolean;
|
|
23
|
+
};
|
|
24
|
+
type MintState = {
|
|
25
|
+
mint: string;
|
|
26
|
+
venue: "pump" | "raydium-launchlab";
|
|
27
|
+
decimals: number;
|
|
28
|
+
supplyUi: number;
|
|
29
|
+
quoteMint: string | null;
|
|
30
|
+
quoteDecimals: number;
|
|
31
|
+
pool: string | null;
|
|
32
|
+
name: string | null;
|
|
33
|
+
symbol: string | null;
|
|
34
|
+
isMayhemMode: boolean | null;
|
|
35
|
+
lastSeenAtMs: number;
|
|
36
|
+
};
|
|
37
|
+
type PoolState = {
|
|
38
|
+
pool: string;
|
|
39
|
+
mint: string;
|
|
40
|
+
baseDecimals: number;
|
|
41
|
+
quoteMint: string;
|
|
42
|
+
quoteDecimals: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const PUMP_PROGRAM = new PublicKey(
|
|
46
|
+
"6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P",
|
|
47
|
+
);
|
|
48
|
+
const PUMP_AMM_PROGRAM = new PublicKey(
|
|
49
|
+
"pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA",
|
|
50
|
+
);
|
|
51
|
+
const LAUNCHLAB_PROGRAM = new PublicKey(
|
|
52
|
+
"LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj",
|
|
53
|
+
);
|
|
54
|
+
const WSOL_MINT = "So11111111111111111111111111111111111111112";
|
|
55
|
+
const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
56
|
+
const DEFAULT_PUBKEY = "11111111111111111111111111111111";
|
|
57
|
+
const LAMPORTS_PER_SOL = 1_000_000_000;
|
|
58
|
+
const PUMP_CREATE_EVENT = Buffer.from([27, 114, 169, 77, 222, 235, 99, 118]);
|
|
59
|
+
const PUMP_TRADE_EVENT = Buffer.from([189, 219, 127, 211, 78, 230, 97, 238]);
|
|
60
|
+
const PUMPSWAP_CREATE_EVENT = Buffer.from([
|
|
61
|
+
177, 49, 12, 210, 160, 118, 167, 116,
|
|
62
|
+
]);
|
|
63
|
+
const PUMPSWAP_BUY_EVENT = Buffer.from([103, 244, 82, 31, 44, 245, 119, 119]);
|
|
64
|
+
const PUMPSWAP_SELL_EVENT = Buffer.from([62, 47, 55, 10, 165, 3, 220, 42]);
|
|
65
|
+
const LAUNCHLAB_CREATE_EVENT = Buffer.from([
|
|
66
|
+
151, 215, 226, 9, 118, 161, 115, 174,
|
|
67
|
+
]);
|
|
68
|
+
const LAUNCHLAB_TRADE_EVENT = Buffer.from([
|
|
69
|
+
189, 219, 127, 211, 78, 230, 97, 238,
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
function flag(flags: Flags, key: string): string | undefined {
|
|
73
|
+
const value = flags.get(key);
|
|
74
|
+
return value && value !== "true" ? value : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function numberFlag(flags: Flags, key: string, fallback: number): number {
|
|
78
|
+
const value = flag(flags, key);
|
|
79
|
+
if (value == null) return fallback;
|
|
80
|
+
const parsed = Number(value);
|
|
81
|
+
if (!Number.isFinite(parsed)) throw new Error(`Invalid --${key}: ${value}`);
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function rpcUrl(flags: Flags): string {
|
|
86
|
+
const value =
|
|
87
|
+
flag(flags, "rpc") ??
|
|
88
|
+
process.env.RPC_ENDPOINT?.trim() ??
|
|
89
|
+
process.env.SOLANA_RPC_URL?.trim() ??
|
|
90
|
+
process.env.HELIUS_RPC_URL?.trim();
|
|
91
|
+
if (!value)
|
|
92
|
+
throw new Error(
|
|
93
|
+
"feed serve requires RPC_ENDPOINT, SOLANA_RPC_URL, HELIUS_RPC_URL, or --rpc <url>",
|
|
94
|
+
);
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function websocketUrl(flags: Flags, rpc: string): string {
|
|
99
|
+
const explicit =
|
|
100
|
+
flag(flags, "ws") ??
|
|
101
|
+
process.env.SOLANA_WS_URL?.trim() ??
|
|
102
|
+
process.env.HELIUS_WS_URL?.trim();
|
|
103
|
+
if (explicit) return explicit;
|
|
104
|
+
const url = new URL(rpc);
|
|
105
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
106
|
+
else if (url.protocol === "http:") url.protocol = "ws:";
|
|
107
|
+
else if (url.protocol !== "wss:" && url.protocol !== "ws:")
|
|
108
|
+
throw new Error(`Cannot derive WebSocket endpoint from ${url.protocol}`);
|
|
109
|
+
return url.toString();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function publicEndpoint(value: string): string {
|
|
113
|
+
try {
|
|
114
|
+
const url = new URL(value);
|
|
115
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
116
|
+
if (/key|token|secret|auth/i.test(key))
|
|
117
|
+
url.searchParams.set(key, "<redacted>");
|
|
118
|
+
}
|
|
119
|
+
return url.toString();
|
|
120
|
+
} catch {
|
|
121
|
+
return value.replace(
|
|
122
|
+
/([?&](?:api-?key|token|secret|auth)=)[^&]+/gi,
|
|
123
|
+
"$1<redacted>",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function feedUrl(flags: Flags): { host: string; port: number } {
|
|
129
|
+
const host = flag(flags, "host") ?? "127.0.0.1";
|
|
130
|
+
const port = Math.trunc(numberFlag(flags, "port", 8788));
|
|
131
|
+
if (!(port > 0 && port <= 65535)) throw new Error("--port must be 1..65535");
|
|
132
|
+
return { host, port };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sleep(ms: number): Promise<void> {
|
|
136
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function errorText(error: unknown): string {
|
|
140
|
+
return error instanceof Error ? error.message : String(error);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
class RpcPacer {
|
|
144
|
+
private tail: Promise<unknown> = Promise.resolve();
|
|
145
|
+
private nextAt = 0;
|
|
146
|
+
private count = 0;
|
|
147
|
+
constructor(readonly rps: number) {}
|
|
148
|
+
get queued(): number {
|
|
149
|
+
return this.count;
|
|
150
|
+
}
|
|
151
|
+
run<T>(fn: () => Promise<T>): Promise<T> {
|
|
152
|
+
this.count += 1;
|
|
153
|
+
const step = Math.ceil(1_000 / this.rps);
|
|
154
|
+
const run = async () => {
|
|
155
|
+
const wait = Math.max(0, this.nextAt - Date.now());
|
|
156
|
+
if (wait) await sleep(wait);
|
|
157
|
+
this.nextAt = Math.max(this.nextAt, Date.now()) + step;
|
|
158
|
+
try {
|
|
159
|
+
return await fn();
|
|
160
|
+
} finally {
|
|
161
|
+
this.count -= 1;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const result = this.tail.then(run, run);
|
|
165
|
+
this.tail = result.then(
|
|
166
|
+
() => undefined,
|
|
167
|
+
() => undefined,
|
|
168
|
+
);
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type LogsEvent = { signature: string; err: unknown; logs: string[] };
|
|
174
|
+
type LogsContext = { slot: number };
|
|
175
|
+
type LogsCallback = (event: LogsEvent, context: LogsContext) => void;
|
|
176
|
+
type DesiredLogSubscription = {
|
|
177
|
+
pubkey: string;
|
|
178
|
+
callbacks: Map<number, LogsCallback>;
|
|
179
|
+
remoteId: number | null;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
class StandardLogsMux {
|
|
183
|
+
private socket: WebSocket | null = null;
|
|
184
|
+
private desired = new Map<string, DesiredLogSubscription>();
|
|
185
|
+
private handles = new Map<number, string>();
|
|
186
|
+
private remoteToPubkey = new Map<number, string>();
|
|
187
|
+
private requests = new Map<
|
|
188
|
+
number,
|
|
189
|
+
{ op: "subscribe" | "unsubscribe"; pubkey?: string }
|
|
190
|
+
>();
|
|
191
|
+
private nextHandle = 1;
|
|
192
|
+
private nextRequest = 1;
|
|
193
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
194
|
+
private reconnectDelayMs = 500;
|
|
195
|
+
private stopped = false;
|
|
196
|
+
private connected = false;
|
|
197
|
+
private connecting = false;
|
|
198
|
+
private initialResolve: (() => void) | null = null;
|
|
199
|
+
private initialReject: ((error: Error) => void) | null = null;
|
|
200
|
+
private initialTimer: ReturnType<typeof setTimeout> | null = null;
|
|
201
|
+
private _reconnects = 0;
|
|
202
|
+
private _lastMessageAtMs = 0;
|
|
203
|
+
|
|
204
|
+
constructor(
|
|
205
|
+
private readonly url: string,
|
|
206
|
+
private readonly report: <T extends Record<string, unknown>>(
|
|
207
|
+
label: string,
|
|
208
|
+
value: T,
|
|
209
|
+
) => T,
|
|
210
|
+
) {}
|
|
211
|
+
|
|
212
|
+
get state(): "connected" | "connecting" | "disconnected" {
|
|
213
|
+
return this.connected
|
|
214
|
+
? "connected"
|
|
215
|
+
: this.connecting
|
|
216
|
+
? "connecting"
|
|
217
|
+
: "disconnected";
|
|
218
|
+
}
|
|
219
|
+
get reconnects(): number {
|
|
220
|
+
return this._reconnects;
|
|
221
|
+
}
|
|
222
|
+
get lastMessageAtMs(): number {
|
|
223
|
+
return this._lastMessageAtMs;
|
|
224
|
+
}
|
|
225
|
+
get subscriptionCount(): number {
|
|
226
|
+
return this.desired.size;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
subscribe(pubkey: PublicKey | string, callback: LogsCallback): number {
|
|
230
|
+
const key = typeof pubkey === "string" ? pubkey : pubkey.toBase58();
|
|
231
|
+
const handle = this.nextHandle++;
|
|
232
|
+
let row = this.desired.get(key);
|
|
233
|
+
if (!row) {
|
|
234
|
+
row = { pubkey: key, callbacks: new Map(), remoteId: null };
|
|
235
|
+
this.desired.set(key, row);
|
|
236
|
+
}
|
|
237
|
+
row.callbacks.set(handle, callback);
|
|
238
|
+
this.handles.set(handle, key);
|
|
239
|
+
if (this.connected && row.remoteId == null) this.sendSubscribe(row);
|
|
240
|
+
return handle;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
unsubscribe(handle: number): void {
|
|
244
|
+
const key = this.handles.get(handle);
|
|
245
|
+
if (!key) return;
|
|
246
|
+
this.handles.delete(handle);
|
|
247
|
+
const row = this.desired.get(key);
|
|
248
|
+
if (!row) return;
|
|
249
|
+
row.callbacks.delete(handle);
|
|
250
|
+
if (row.callbacks.size > 0) return;
|
|
251
|
+
this.desired.delete(key);
|
|
252
|
+
if (row.remoteId != null) {
|
|
253
|
+
this.remoteToPubkey.delete(row.remoteId);
|
|
254
|
+
this.sendRequest("logsUnsubscribe", [row.remoteId], {
|
|
255
|
+
op: "unsubscribe",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async start(timeoutMs = 15_000): Promise<void> {
|
|
261
|
+
if (this.connected) return;
|
|
262
|
+
if (!this.initialResolve) {
|
|
263
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
264
|
+
this.initialResolve = resolve;
|
|
265
|
+
this.initialReject = reject;
|
|
266
|
+
});
|
|
267
|
+
this.initialTimer = setTimeout(() => {
|
|
268
|
+
const reject = this.initialReject;
|
|
269
|
+
this.initialResolve = null;
|
|
270
|
+
this.initialReject = null;
|
|
271
|
+
const error = new Error(
|
|
272
|
+
`WebSocket did not connect within ${timeoutMs}ms`,
|
|
273
|
+
);
|
|
274
|
+
this.stop();
|
|
275
|
+
if (reject) reject(error);
|
|
276
|
+
}, timeoutMs);
|
|
277
|
+
this.connect();
|
|
278
|
+
return await ready;
|
|
279
|
+
}
|
|
280
|
+
return await new Promise<void>((resolve, reject) => {
|
|
281
|
+
const started = Date.now();
|
|
282
|
+
const timer = setInterval(() => {
|
|
283
|
+
if (this.connected) {
|
|
284
|
+
clearInterval(timer);
|
|
285
|
+
resolve();
|
|
286
|
+
} else if (Date.now() - started >= timeoutMs) {
|
|
287
|
+
clearInterval(timer);
|
|
288
|
+
reject(new Error(`WebSocket did not connect within ${timeoutMs}ms`));
|
|
289
|
+
}
|
|
290
|
+
}, 50);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
stop(): void {
|
|
295
|
+
this.stopped = true;
|
|
296
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
297
|
+
this.reconnectTimer = null;
|
|
298
|
+
if (this.initialTimer) clearTimeout(this.initialTimer);
|
|
299
|
+
this.initialTimer = null;
|
|
300
|
+
this.initialResolve = null;
|
|
301
|
+
this.initialReject = null;
|
|
302
|
+
const socket = this.socket;
|
|
303
|
+
this.socket = null;
|
|
304
|
+
this.connected = false;
|
|
305
|
+
this.connecting = false;
|
|
306
|
+
try {
|
|
307
|
+
socket?.close(1000, "shutdown");
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private connect(): void {
|
|
312
|
+
if (this.stopped || this.connected || this.connecting) return;
|
|
313
|
+
this.connecting = true;
|
|
314
|
+
let socket: WebSocket;
|
|
315
|
+
try {
|
|
316
|
+
socket = new WebSocket(this.url);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
this.connecting = false;
|
|
319
|
+
this.report("upstream websocket error", {
|
|
320
|
+
endpoint: publicEndpoint(this.url),
|
|
321
|
+
error: errorText(error),
|
|
322
|
+
});
|
|
323
|
+
this.scheduleReconnect();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
this.socket = socket;
|
|
327
|
+
socket.onopen = () => {
|
|
328
|
+
if (this.socket !== socket || this.stopped) return;
|
|
329
|
+
this.connecting = false;
|
|
330
|
+
this.connected = true;
|
|
331
|
+
this.reconnectDelayMs = 500;
|
|
332
|
+
this.remoteToPubkey.clear();
|
|
333
|
+
this.requests.clear();
|
|
334
|
+
for (const row of this.desired.values()) row.remoteId = null;
|
|
335
|
+
for (const row of this.desired.values()) this.sendSubscribe(row);
|
|
336
|
+
this.report("upstream websocket connected", {
|
|
337
|
+
endpoint: publicEndpoint(this.url),
|
|
338
|
+
subscriptions: this.desired.size,
|
|
339
|
+
reconnects: this._reconnects,
|
|
340
|
+
});
|
|
341
|
+
if (this.initialTimer) clearTimeout(this.initialTimer);
|
|
342
|
+
this.initialTimer = null;
|
|
343
|
+
const resolve = this.initialResolve;
|
|
344
|
+
this.initialResolve = null;
|
|
345
|
+
this.initialReject = null;
|
|
346
|
+
resolve?.();
|
|
347
|
+
};
|
|
348
|
+
socket.onmessage = (event) => {
|
|
349
|
+
if (this.socket !== socket) return;
|
|
350
|
+
this._lastMessageAtMs = Date.now();
|
|
351
|
+
let message: any;
|
|
352
|
+
try {
|
|
353
|
+
message = JSON.parse(String(event.data));
|
|
354
|
+
} catch {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (message && typeof message.id === "number") {
|
|
358
|
+
const request = this.requests.get(message.id);
|
|
359
|
+
this.requests.delete(message.id);
|
|
360
|
+
if (!request) return;
|
|
361
|
+
if (message.error) {
|
|
362
|
+
this.report("upstream subscription error", {
|
|
363
|
+
endpoint: publicEndpoint(this.url),
|
|
364
|
+
pubkey: request.pubkey ?? null,
|
|
365
|
+
code: message.error.code ?? null,
|
|
366
|
+
error: String(message.error.message ?? "subscription failed"),
|
|
367
|
+
});
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (
|
|
371
|
+
request.op === "subscribe" &&
|
|
372
|
+
request.pubkey &&
|
|
373
|
+
typeof message.result === "number"
|
|
374
|
+
) {
|
|
375
|
+
const row = this.desired.get(request.pubkey);
|
|
376
|
+
if (!row) {
|
|
377
|
+
this.sendRequest("logsUnsubscribe", [message.result], {
|
|
378
|
+
op: "unsubscribe",
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
row.remoteId = message.result;
|
|
383
|
+
this.remoteToPubkey.set(message.result, request.pubkey);
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (message?.method !== "logsNotification") return;
|
|
388
|
+
const remote = Number(message?.params?.subscription);
|
|
389
|
+
const key = this.remoteToPubkey.get(remote);
|
|
390
|
+
if (!key) return;
|
|
391
|
+
const row = this.desired.get(key);
|
|
392
|
+
if (!row) return;
|
|
393
|
+
const value = message?.params?.result?.value;
|
|
394
|
+
const slot = Number(message?.params?.result?.context?.slot ?? 0);
|
|
395
|
+
if (
|
|
396
|
+
!value ||
|
|
397
|
+
typeof value.signature !== "string" ||
|
|
398
|
+
!Array.isArray(value.logs)
|
|
399
|
+
)
|
|
400
|
+
return;
|
|
401
|
+
const normalized: LogsEvent = {
|
|
402
|
+
signature: value.signature,
|
|
403
|
+
err: value.err ?? null,
|
|
404
|
+
logs: value.logs.map(String),
|
|
405
|
+
};
|
|
406
|
+
const context: LogsContext = { slot };
|
|
407
|
+
for (const callback of row.callbacks.values()) {
|
|
408
|
+
try {
|
|
409
|
+
callback(normalized, context);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
this.report("upstream callback error", {
|
|
412
|
+
pubkey: key,
|
|
413
|
+
error: errorText(error),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
socket.onerror = (event: any) => {
|
|
419
|
+
if (this.socket !== socket || this.stopped) return;
|
|
420
|
+
this.report("upstream websocket error", {
|
|
421
|
+
endpoint: publicEndpoint(this.url),
|
|
422
|
+
error: String(
|
|
423
|
+
event?.message ?? event?.error?.message ?? "websocket error",
|
|
424
|
+
),
|
|
425
|
+
});
|
|
426
|
+
try {
|
|
427
|
+
socket.close();
|
|
428
|
+
} catch {}
|
|
429
|
+
};
|
|
430
|
+
socket.onclose = (event) => {
|
|
431
|
+
if (this.socket !== socket) return;
|
|
432
|
+
this.socket = null;
|
|
433
|
+
const wasConnected = this.connected;
|
|
434
|
+
this.connected = false;
|
|
435
|
+
this.connecting = false;
|
|
436
|
+
this.remoteToPubkey.clear();
|
|
437
|
+
this.requests.clear();
|
|
438
|
+
for (const row of this.desired.values()) row.remoteId = null;
|
|
439
|
+
if (this.stopped) return;
|
|
440
|
+
if (wasConnected) this._reconnects += 1;
|
|
441
|
+
this.report("upstream websocket disconnected", {
|
|
442
|
+
endpoint: publicEndpoint(this.url),
|
|
443
|
+
code: event.code,
|
|
444
|
+
reason: event.reason || null,
|
|
445
|
+
retryInMs: this.reconnectDelayMs,
|
|
446
|
+
});
|
|
447
|
+
this.scheduleReconnect();
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private scheduleReconnect(): void {
|
|
452
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
453
|
+
const delay = this.reconnectDelayMs;
|
|
454
|
+
this.reconnectTimer = setTimeout(() => {
|
|
455
|
+
this.reconnectTimer = null;
|
|
456
|
+
this.connect();
|
|
457
|
+
}, delay);
|
|
458
|
+
this.reconnectDelayMs = Math.min(30_000, this.reconnectDelayMs * 2);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private sendSubscribe(row: DesiredLogSubscription): void {
|
|
462
|
+
this.sendRequest(
|
|
463
|
+
"logsSubscribe",
|
|
464
|
+
[{ mentions: [row.pubkey] }, { commitment: "processed" }],
|
|
465
|
+
{ op: "subscribe", pubkey: row.pubkey },
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private sendRequest(
|
|
470
|
+
method: string,
|
|
471
|
+
params: unknown[],
|
|
472
|
+
request: { op: "subscribe" | "unsubscribe"; pubkey?: string },
|
|
473
|
+
): void {
|
|
474
|
+
const socket = this.socket;
|
|
475
|
+
if (!this.connected || !socket || socket.readyState !== WebSocket.OPEN)
|
|
476
|
+
return;
|
|
477
|
+
const id = this.nextRequest++;
|
|
478
|
+
this.requests.set(id, request);
|
|
479
|
+
try {
|
|
480
|
+
socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
|
|
481
|
+
} catch (error) {
|
|
482
|
+
this.requests.delete(id);
|
|
483
|
+
this.report("upstream websocket send error", {
|
|
484
|
+
endpoint: publicEndpoint(this.url),
|
|
485
|
+
error: errorText(error),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
class Cursor {
|
|
492
|
+
private offset: number;
|
|
493
|
+
constructor(
|
|
494
|
+
private readonly data: Buffer,
|
|
495
|
+
offset = 0,
|
|
496
|
+
) {
|
|
497
|
+
this.offset = offset;
|
|
498
|
+
}
|
|
499
|
+
get remaining(): number {
|
|
500
|
+
return this.data.length - this.offset;
|
|
501
|
+
}
|
|
502
|
+
skip(n: number): void {
|
|
503
|
+
if (this.remaining < n) throw new Error("truncated event");
|
|
504
|
+
this.offset += n;
|
|
505
|
+
}
|
|
506
|
+
u8(): number {
|
|
507
|
+
if (this.remaining < 1) throw new Error("truncated u8");
|
|
508
|
+
return this.data[this.offset++]!;
|
|
509
|
+
}
|
|
510
|
+
u16(): number {
|
|
511
|
+
if (this.remaining < 2) throw new Error("truncated u16");
|
|
512
|
+
const value = this.data.readUInt16LE(this.offset);
|
|
513
|
+
this.offset += 2;
|
|
514
|
+
return value;
|
|
515
|
+
}
|
|
516
|
+
u64(): bigint {
|
|
517
|
+
if (this.remaining < 8) throw new Error("truncated u64");
|
|
518
|
+
const value = this.data.readBigUInt64LE(this.offset);
|
|
519
|
+
this.offset += 8;
|
|
520
|
+
return value;
|
|
521
|
+
}
|
|
522
|
+
i64(): bigint {
|
|
523
|
+
if (this.remaining < 8) throw new Error("truncated i64");
|
|
524
|
+
const value = this.data.readBigInt64LE(this.offset);
|
|
525
|
+
this.offset += 8;
|
|
526
|
+
return value;
|
|
527
|
+
}
|
|
528
|
+
bool(): boolean {
|
|
529
|
+
return this.u8() !== 0;
|
|
530
|
+
}
|
|
531
|
+
pubkey(): string {
|
|
532
|
+
if (this.remaining < 32) throw new Error("truncated pubkey");
|
|
533
|
+
const value = bs58.encode(
|
|
534
|
+
this.data.subarray(this.offset, this.offset + 32),
|
|
535
|
+
);
|
|
536
|
+
this.offset += 32;
|
|
537
|
+
return value;
|
|
538
|
+
}
|
|
539
|
+
string(): string {
|
|
540
|
+
if (this.remaining < 4) throw new Error("truncated string length");
|
|
541
|
+
const length = this.data.readUInt32LE(this.offset);
|
|
542
|
+
this.offset += 4;
|
|
543
|
+
if (length > 16_384 || this.remaining < length)
|
|
544
|
+
throw new Error("truncated string");
|
|
545
|
+
const value = this.data
|
|
546
|
+
.subarray(this.offset, this.offset + length)
|
|
547
|
+
.toString("utf8");
|
|
548
|
+
this.offset += length;
|
|
549
|
+
return value;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function dataEvents(logs: readonly string[]): Buffer[] {
|
|
554
|
+
const rows: Buffer[] = [];
|
|
555
|
+
for (const line of logs) {
|
|
556
|
+
const match = /^(?:Program data|Program return: \S+):\s+(.+)$/.exec(
|
|
557
|
+
line.trim(),
|
|
558
|
+
);
|
|
559
|
+
if (!match) continue;
|
|
560
|
+
try {
|
|
561
|
+
rows.push(Buffer.from(match[1]!, "base64"));
|
|
562
|
+
} catch {}
|
|
563
|
+
}
|
|
564
|
+
return rows;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function starts(data: Buffer, discriminator: Buffer): boolean {
|
|
568
|
+
return (
|
|
569
|
+
data.length >= discriminator.length &&
|
|
570
|
+
data.subarray(0, discriminator.length).equals(discriminator)
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function pumpCreate(data: Buffer): PriceFeedLaunch | null {
|
|
575
|
+
if (!starts(data, PUMP_CREATE_EVENT)) return null;
|
|
576
|
+
try {
|
|
577
|
+
const c = new Cursor(data, 8);
|
|
578
|
+
const name = c.string();
|
|
579
|
+
const symbol = c.string();
|
|
580
|
+
c.string();
|
|
581
|
+
const mint = c.pubkey();
|
|
582
|
+
c.pubkey();
|
|
583
|
+
c.pubkey();
|
|
584
|
+
c.pubkey();
|
|
585
|
+
const timestamp = Number(c.i64()) * 1_000;
|
|
586
|
+
c.u64();
|
|
587
|
+
c.u64();
|
|
588
|
+
c.u64();
|
|
589
|
+
const supplyRaw = c.u64();
|
|
590
|
+
c.pubkey();
|
|
591
|
+
const isMayhemMode = c.bool();
|
|
592
|
+
if (c.remaining > 0) c.bool();
|
|
593
|
+
const rawQuoteMint = c.remaining >= 32 ? c.pubkey() : WSOL_MINT;
|
|
594
|
+
const quoteMint =
|
|
595
|
+
rawQuoteMint === DEFAULT_PUBKEY ? WSOL_MINT : rawQuoteMint;
|
|
596
|
+
const decimals = 6;
|
|
597
|
+
return {
|
|
598
|
+
type: "launch",
|
|
599
|
+
atMs: timestamp > 0 ? timestamp : Date.now(),
|
|
600
|
+
signature: null,
|
|
601
|
+
slot: null,
|
|
602
|
+
mint,
|
|
603
|
+
venue: "pump",
|
|
604
|
+
decimals,
|
|
605
|
+
supplyUi: Number(supplyRaw) / 10 ** decimals,
|
|
606
|
+
quoteMint,
|
|
607
|
+
pool: null,
|
|
608
|
+
name: name || null,
|
|
609
|
+
symbol: symbol || null,
|
|
610
|
+
isMayhemMode,
|
|
611
|
+
};
|
|
612
|
+
} catch {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function pumpTrade(data: Buffer): {
|
|
618
|
+
mint: string;
|
|
619
|
+
atMs: number;
|
|
620
|
+
virtualSol: bigint;
|
|
621
|
+
virtualToken: bigint;
|
|
622
|
+
} | null {
|
|
623
|
+
if (!starts(data, PUMP_TRADE_EVENT)) return null;
|
|
624
|
+
try {
|
|
625
|
+
const c = new Cursor(data, 8);
|
|
626
|
+
const mint = c.pubkey();
|
|
627
|
+
c.u64();
|
|
628
|
+
c.u64();
|
|
629
|
+
c.bool();
|
|
630
|
+
c.pubkey();
|
|
631
|
+
const atMs = Number(c.i64()) * 1_000;
|
|
632
|
+
const virtualSol = c.u64();
|
|
633
|
+
const virtualToken = c.u64();
|
|
634
|
+
if (virtualSol <= 0n || virtualToken <= 0n) return null;
|
|
635
|
+
return {
|
|
636
|
+
mint,
|
|
637
|
+
atMs: atMs > 0 ? atMs : Date.now(),
|
|
638
|
+
virtualSol,
|
|
639
|
+
virtualToken,
|
|
640
|
+
};
|
|
641
|
+
} catch {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function pumpSwapCreate(data: Buffer): (PoolState & { atMs: number }) | null {
|
|
647
|
+
if (!starts(data, PUMPSWAP_CREATE_EVENT)) return null;
|
|
648
|
+
try {
|
|
649
|
+
const c = new Cursor(data, 8);
|
|
650
|
+
const atMs = Number(c.i64()) * 1_000;
|
|
651
|
+
c.u16();
|
|
652
|
+
c.pubkey();
|
|
653
|
+
const mint = c.pubkey();
|
|
654
|
+
const quoteMint = c.pubkey();
|
|
655
|
+
const baseDecimals = c.u8();
|
|
656
|
+
const quoteDecimals = c.u8();
|
|
657
|
+
for (let i = 0; i < 7; i += 1) c.u64();
|
|
658
|
+
c.u8();
|
|
659
|
+
const pool = c.pubkey();
|
|
660
|
+
return { pool, mint, baseDecimals, quoteMint, quoteDecimals, atMs };
|
|
661
|
+
} catch {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function pumpSwapTrade(data: Buffer): {
|
|
667
|
+
pool: string;
|
|
668
|
+
atMs: number;
|
|
669
|
+
baseRaw: bigint;
|
|
670
|
+
quoteRaw: bigint;
|
|
671
|
+
} | null {
|
|
672
|
+
const buy = starts(data, PUMPSWAP_BUY_EVENT);
|
|
673
|
+
const sell = starts(data, PUMPSWAP_SELL_EVENT);
|
|
674
|
+
if (!buy && !sell) return null;
|
|
675
|
+
try {
|
|
676
|
+
const c = new Cursor(data, 8);
|
|
677
|
+
const atMs = Number(c.i64()) * 1_000;
|
|
678
|
+
c.u64();
|
|
679
|
+
c.u64();
|
|
680
|
+
c.u64();
|
|
681
|
+
c.u64();
|
|
682
|
+
const baseRaw = c.u64();
|
|
683
|
+
const quoteRaw = c.u64();
|
|
684
|
+
c.u64();
|
|
685
|
+
c.u64();
|
|
686
|
+
c.u64();
|
|
687
|
+
c.u64();
|
|
688
|
+
c.u64();
|
|
689
|
+
c.u64();
|
|
690
|
+
c.u64();
|
|
691
|
+
const pool = c.pubkey();
|
|
692
|
+
return { pool, atMs: atMs > 0 ? atMs : Date.now(), baseRaw, quoteRaw };
|
|
693
|
+
} catch {
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function launchLabPoolCreate(data: Buffer): string | null {
|
|
699
|
+
if (!starts(data, LAUNCHLAB_CREATE_EVENT)) return null;
|
|
700
|
+
try {
|
|
701
|
+
return new Cursor(data, 8).pubkey();
|
|
702
|
+
} catch {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function launchLabTrade(data: Buffer): {
|
|
708
|
+
pool: string;
|
|
709
|
+
virtualBase: bigint;
|
|
710
|
+
virtualQuote: bigint;
|
|
711
|
+
} | null {
|
|
712
|
+
if (!starts(data, LAUNCHLAB_TRADE_EVENT)) return null;
|
|
713
|
+
try {
|
|
714
|
+
const c = new Cursor(data, 8);
|
|
715
|
+
const pool = c.pubkey();
|
|
716
|
+
c.u64();
|
|
717
|
+
const virtualBase = c.u64();
|
|
718
|
+
const virtualQuote = c.u64();
|
|
719
|
+
return { pool, virtualBase, virtualQuote };
|
|
720
|
+
} catch {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function keyText(value: unknown): string | null {
|
|
726
|
+
if (typeof value === "string") return value;
|
|
727
|
+
if (value && typeof (value as any).toBase58 === "function")
|
|
728
|
+
return (value as any).toBase58();
|
|
729
|
+
if (value && typeof (value as any).pubkey?.toBase58 === "function")
|
|
730
|
+
return (value as any).pubkey.toBase58();
|
|
731
|
+
if (value && typeof (value as any).pubkey === "string")
|
|
732
|
+
return (value as any).pubkey;
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function instructions(tx: ParsedTransactionWithMeta): any[] {
|
|
737
|
+
const outer = ((tx.transaction.message as any).instructions ?? []) as any[];
|
|
738
|
+
const inner = (tx.meta?.innerInstructions ?? []).flatMap(
|
|
739
|
+
(group: any) => group.instructions ?? [],
|
|
740
|
+
);
|
|
741
|
+
return [...outer, ...inner];
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function initializedMints(tx: ParsedTransactionWithMeta): string[] {
|
|
745
|
+
const out = new Set<string>();
|
|
746
|
+
for (const ix of instructions(tx)) {
|
|
747
|
+
const parsed = (ix as any)?.parsed;
|
|
748
|
+
const type = String(parsed?.type ?? "").toLowerCase();
|
|
749
|
+
if (type !== "initializemint" && type !== "initializemint2") continue;
|
|
750
|
+
const mint =
|
|
751
|
+
keyText(parsed?.info?.mint) ?? String(parsed?.info?.mint ?? "");
|
|
752
|
+
if (mint && mint !== WSOL_MINT && mint !== USDC_MINT) out.add(mint);
|
|
753
|
+
}
|
|
754
|
+
return [...out];
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function tokenBalances(rows: readonly any[] | null | undefined) {
|
|
758
|
+
return (rows ?? []).map((row) => ({
|
|
759
|
+
accountIndex: Number(row.accountIndex),
|
|
760
|
+
mint: String(row.mint ?? ""),
|
|
761
|
+
owner: row.owner ? String(row.owner) : null,
|
|
762
|
+
raw: (() => {
|
|
763
|
+
try {
|
|
764
|
+
return BigInt(String(row?.uiTokenAmount?.amount ?? "0"));
|
|
765
|
+
} catch {
|
|
766
|
+
return 0n;
|
|
767
|
+
}
|
|
768
|
+
})(),
|
|
769
|
+
decimals: Number(row?.uiTokenAmount?.decimals ?? 0),
|
|
770
|
+
}));
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function supplyFromTransaction(
|
|
774
|
+
tx: ParsedTransactionWithMeta,
|
|
775
|
+
mint: string,
|
|
776
|
+
): { decimals: number; supplyUi: number } | null {
|
|
777
|
+
const rows = tokenBalances(tx.meta?.postTokenBalances).filter(
|
|
778
|
+
(row) => row.mint === mint,
|
|
779
|
+
);
|
|
780
|
+
if (!rows.length) return null;
|
|
781
|
+
const decimals = rows[0]!.decimals;
|
|
782
|
+
const byAccount = new Map<number, bigint>();
|
|
783
|
+
for (const row of rows) byAccount.set(row.accountIndex, row.raw);
|
|
784
|
+
const raw = [...byAccount.values()].reduce((sum, value) => sum + value, 0n);
|
|
785
|
+
const supplyUi = Number(raw) / 10 ** decimals;
|
|
786
|
+
return Number.isFinite(supplyUi) && supplyUi > 0
|
|
787
|
+
? { decimals, supplyUi }
|
|
788
|
+
: null;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function signerKeys(tx: ParsedTransactionWithMeta): Set<string> {
|
|
792
|
+
return new Set(
|
|
793
|
+
(((tx.transaction.message as any).accountKeys ?? []) as any[])
|
|
794
|
+
.filter((row) => row && typeof row === "object" && row.signer === true)
|
|
795
|
+
.map((row) => keyText(row))
|
|
796
|
+
.filter((row): row is string => Boolean(row)),
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function accountKeys(tx: ParsedTransactionWithMeta): string[] {
|
|
801
|
+
return (((tx.transaction.message as any).accountKeys ?? []) as any[])
|
|
802
|
+
.map((row) => keyText(row))
|
|
803
|
+
.filter((row): row is string => Boolean(row));
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function ownerDelta(
|
|
807
|
+
tx: ParsedTransactionWithMeta,
|
|
808
|
+
mint: string,
|
|
809
|
+
owner: string,
|
|
810
|
+
): bigint {
|
|
811
|
+
const pre = tokenBalances(tx.meta?.preTokenBalances);
|
|
812
|
+
const post = tokenBalances(tx.meta?.postTokenBalances);
|
|
813
|
+
const indexes = new Set<number>();
|
|
814
|
+
for (const row of [...pre, ...post])
|
|
815
|
+
if (row.mint === mint && row.owner === owner) indexes.add(row.accountIndex);
|
|
816
|
+
let total = 0n;
|
|
817
|
+
for (const index of indexes) {
|
|
818
|
+
const before =
|
|
819
|
+
pre.find((row) => row.accountIndex === index && row.mint === mint)?.raw ??
|
|
820
|
+
0n;
|
|
821
|
+
const after =
|
|
822
|
+
post.find((row) => row.accountIndex === index && row.mint === mint)
|
|
823
|
+
?.raw ?? 0n;
|
|
824
|
+
total += after - before;
|
|
825
|
+
}
|
|
826
|
+
return total;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function tokenAccountRentDelta(
|
|
830
|
+
tx: ParsedTransactionWithMeta,
|
|
831
|
+
owner: string,
|
|
832
|
+
): bigint {
|
|
833
|
+
if (!tx.meta) return 0n;
|
|
834
|
+
const pre = tokenBalances(tx.meta.preTokenBalances);
|
|
835
|
+
const post = tokenBalances(tx.meta.postTokenBalances);
|
|
836
|
+
const indices = new Set(
|
|
837
|
+
[...pre, ...post]
|
|
838
|
+
.filter((row) => row.owner === owner)
|
|
839
|
+
.map((row) => row.accountIndex),
|
|
840
|
+
);
|
|
841
|
+
let total = 0n;
|
|
842
|
+
for (const index of indices) {
|
|
843
|
+
const before = BigInt(Math.trunc(Number(tx.meta.preBalances[index] ?? 0)));
|
|
844
|
+
const after = BigInt(Math.trunc(Number(tx.meta.postBalances[index] ?? 0)));
|
|
845
|
+
const pairPre = pre.find((row) => row.accountIndex === index);
|
|
846
|
+
const pairPost = post.find((row) => row.accountIndex === index);
|
|
847
|
+
let delta = after - before;
|
|
848
|
+
if ((pairPost?.mint ?? pairPre?.mint) === WSOL_MINT)
|
|
849
|
+
delta -= (pairPost?.raw ?? 0n) - (pairPre?.raw ?? 0n);
|
|
850
|
+
total += delta;
|
|
851
|
+
}
|
|
852
|
+
return total;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function genericPrice(
|
|
856
|
+
tx: ParsedTransactionWithMeta,
|
|
857
|
+
mint: string,
|
|
858
|
+
decimals: number,
|
|
859
|
+
): { atMs: number; priceSol: number | null; priceUsd: number | null } | null {
|
|
860
|
+
if (!tx.meta || tx.meta.err) return null;
|
|
861
|
+
const pre = tokenBalances(tx.meta.preTokenBalances);
|
|
862
|
+
const post = tokenBalances(tx.meta.postTokenBalances);
|
|
863
|
+
const signers = signerKeys(tx);
|
|
864
|
+
const owners = new Set<string>();
|
|
865
|
+
for (const row of [...pre, ...post])
|
|
866
|
+
if (row.mint === mint && row.owner && signers.has(row.owner))
|
|
867
|
+
owners.add(row.owner);
|
|
868
|
+
const keys = accountKeys(tx);
|
|
869
|
+
for (const owner of owners) {
|
|
870
|
+
const tokenDelta = ownerDelta(tx, mint, owner);
|
|
871
|
+
if (tokenDelta === 0n) continue;
|
|
872
|
+
const tokenUi = Math.abs(Number(tokenDelta)) / 10 ** decimals;
|
|
873
|
+
if (!(tokenUi > 0)) continue;
|
|
874
|
+
const ownerIndex = keys.indexOf(owner);
|
|
875
|
+
if (ownerIndex >= 0) {
|
|
876
|
+
const before = BigInt(
|
|
877
|
+
Math.trunc(Number(tx.meta.preBalances[ownerIndex] ?? 0)),
|
|
878
|
+
);
|
|
879
|
+
const after = BigInt(
|
|
880
|
+
Math.trunc(Number(tx.meta.postBalances[ownerIndex] ?? 0)),
|
|
881
|
+
);
|
|
882
|
+
const fee = keys[0] === owner ? BigInt(tx.meta.fee ?? 0) : 0n;
|
|
883
|
+
const rent = tokenAccountRentDelta(tx, owner);
|
|
884
|
+
const wsol = ownerDelta(tx, WSOL_MINT, owner);
|
|
885
|
+
const delta = after - before + fee + rent + wsol;
|
|
886
|
+
const solUi = Math.abs(Number(delta)) / LAMPORTS_PER_SOL;
|
|
887
|
+
if (solUi > 0)
|
|
888
|
+
return {
|
|
889
|
+
atMs: (tx.blockTime ?? Math.floor(Date.now() / 1000)) * 1_000,
|
|
890
|
+
priceSol: solUi / tokenUi,
|
|
891
|
+
priceUsd: null,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
const usdc = ownerDelta(tx, USDC_MINT, owner);
|
|
895
|
+
const usdcUi = Math.abs(Number(usdc)) / 1_000_000;
|
|
896
|
+
if (usdcUi > 0)
|
|
897
|
+
return {
|
|
898
|
+
atMs: (tx.blockTime ?? Math.floor(Date.now() / 1000)) * 1_000,
|
|
899
|
+
priceSol: null,
|
|
900
|
+
priceUsd: usdcUi / tokenUi,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async function fetchJson(url: string): Promise<any> {
|
|
907
|
+
const controller = new AbortController();
|
|
908
|
+
const timer = setTimeout(() => controller.abort(), 5_000);
|
|
909
|
+
try {
|
|
910
|
+
const response = await fetch(url, {
|
|
911
|
+
headers: { accept: "application/json" },
|
|
912
|
+
signal: controller.signal,
|
|
913
|
+
});
|
|
914
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
915
|
+
return await response.json();
|
|
916
|
+
} finally {
|
|
917
|
+
clearTimeout(timer);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function loadSolUsd(): Promise<number> {
|
|
922
|
+
try {
|
|
923
|
+
const raw = await fetchJson(
|
|
924
|
+
"https://api.coinbase.com/v2/prices/SOL-USD/spot",
|
|
925
|
+
);
|
|
926
|
+
const value = Number(raw?.data?.amount);
|
|
927
|
+
if (Number.isFinite(value) && value > 0) return value;
|
|
928
|
+
} catch {}
|
|
929
|
+
const raw = await fetchJson(
|
|
930
|
+
"https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd",
|
|
931
|
+
);
|
|
932
|
+
const value = Number(raw?.solana?.usd);
|
|
933
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
934
|
+
throw new Error("SOL/USD unavailable");
|
|
935
|
+
return value;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export async function runPriceFeedServerCommand(args: {
|
|
939
|
+
flags: Flags;
|
|
940
|
+
emit: Emit;
|
|
941
|
+
}): Promise<void> {
|
|
942
|
+
configure({
|
|
943
|
+
silent: false,
|
|
944
|
+
logger(_event: unknown, next?: () => void) {
|
|
945
|
+
next?.();
|
|
946
|
+
},
|
|
947
|
+
});
|
|
948
|
+
const m = createMeasure("slrd:feed", { maxResultLength: 1600 });
|
|
949
|
+
const report = <T extends Record<string, unknown>>(
|
|
950
|
+
label: string,
|
|
951
|
+
value: T,
|
|
952
|
+
): T =>
|
|
953
|
+
m.sync({ start: () => label, end: (result: T) => result }, () => value);
|
|
954
|
+
const { host, port } = feedUrl(args.flags);
|
|
955
|
+
const rpc = rpcUrl(args.flags);
|
|
956
|
+
const ws = websocketUrl(args.flags, rpc);
|
|
957
|
+
const connection = new Connection(rpc, "confirmed");
|
|
958
|
+
const logsMux = new StandardLogsMux(ws, report);
|
|
959
|
+
const hardRps = Math.max(1, Math.trunc(numberFlag(args.flags, "rpc-rps", 5)));
|
|
960
|
+
const readRps = Math.max(
|
|
961
|
+
1,
|
|
962
|
+
Math.min(hardRps, Math.trunc(numberFlag(args.flags, "rpc-read-rps", 2))),
|
|
963
|
+
);
|
|
964
|
+
const pacer = new RpcPacer(readRps);
|
|
965
|
+
const includeMayhem = args.flags.has("include-mayhem");
|
|
966
|
+
const maxFallbackSubscriptions = Math.max(
|
|
967
|
+
1,
|
|
968
|
+
Math.trunc(numberFlag(args.flags, "max-fallback-subs", 200)),
|
|
969
|
+
);
|
|
970
|
+
const clients = new Map<any, Client>();
|
|
971
|
+
const mints = new Map<string, MintState>();
|
|
972
|
+
const pools = new Map<string, PoolState>();
|
|
973
|
+
const launchesByMint = new Map<string, PriceFeedLaunch>();
|
|
974
|
+
const latest = new Map<string, PriceFeedPrice>();
|
|
975
|
+
const fallbackSubs = new Map<string, { id: number; refs: number }>();
|
|
976
|
+
const fallbackOps = new Map<string, Promise<void>>();
|
|
977
|
+
const pendingFallback = new Map<string, string>();
|
|
978
|
+
const createFetches = new Set<string>();
|
|
979
|
+
const programSubs: number[] = [];
|
|
980
|
+
let launches = 0;
|
|
981
|
+
let prices = 0;
|
|
982
|
+
let clientConnections = 0;
|
|
983
|
+
let coalescedFallback = 0;
|
|
984
|
+
let rpcErrors = 0;
|
|
985
|
+
let stopped = false;
|
|
986
|
+
let solUsd: number | null = null;
|
|
987
|
+
let solUsdAt = 0;
|
|
988
|
+
let solUsdRetryAt = 0;
|
|
989
|
+
let solUsdFailures = 0;
|
|
990
|
+
let solUsdPromise: Promise<number | null> | null = null;
|
|
991
|
+
|
|
992
|
+
const refreshSolUsd = async () => {
|
|
993
|
+
const now = Date.now();
|
|
994
|
+
if (solUsd != null && now - solUsdAt < 60_000) return solUsd;
|
|
995
|
+
if (now < solUsdRetryAt) return solUsd;
|
|
996
|
+
if (solUsdPromise) return await solUsdPromise;
|
|
997
|
+
solUsdPromise = (async () => {
|
|
998
|
+
try {
|
|
999
|
+
solUsd = await loadSolUsd();
|
|
1000
|
+
solUsdAt = Date.now();
|
|
1001
|
+
solUsdRetryAt = 0;
|
|
1002
|
+
solUsdFailures = 0;
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
solUsdFailures += 1;
|
|
1005
|
+
const retry = Math.min(
|
|
1006
|
+
300_000,
|
|
1007
|
+
15_000 * 2 ** Math.min(5, solUsdFailures - 1),
|
|
1008
|
+
);
|
|
1009
|
+
solUsdRetryAt = Date.now() + retry;
|
|
1010
|
+
report("sol/usd unavailable", {
|
|
1011
|
+
error: errorText(error),
|
|
1012
|
+
failures: solUsdFailures,
|
|
1013
|
+
retryInMs: retry,
|
|
1014
|
+
usingCached: solUsd != null,
|
|
1015
|
+
});
|
|
1016
|
+
} finally {
|
|
1017
|
+
solUsdPromise = null;
|
|
1018
|
+
}
|
|
1019
|
+
return solUsd;
|
|
1020
|
+
})();
|
|
1021
|
+
return await solUsdPromise;
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
const send = (ws: any, value: PriceFeedMessage) => {
|
|
1025
|
+
try {
|
|
1026
|
+
ws.send(JSON.stringify(value));
|
|
1027
|
+
} catch {}
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
const broadcast = (value: PriceFeedMessage) => {
|
|
1031
|
+
for (const [ws, client] of clients) {
|
|
1032
|
+
if (value.type === "launch") {
|
|
1033
|
+
if (client.launches) send(ws, value);
|
|
1034
|
+
} else if (value.type === "price") {
|
|
1035
|
+
if (client.allPrices || client.mints.has(value.mint)) send(ws, value);
|
|
1036
|
+
} else send(ws, value);
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
const emitLaunch = (value: PriceFeedLaunch) => {
|
|
1041
|
+
if (value.isMayhemMode === true && !includeMayhem) return;
|
|
1042
|
+
const existing = mints.get(value.mint);
|
|
1043
|
+
mints.set(value.mint, {
|
|
1044
|
+
...(existing ?? {}),
|
|
1045
|
+
mint: value.mint,
|
|
1046
|
+
venue: value.venue,
|
|
1047
|
+
decimals: value.decimals,
|
|
1048
|
+
supplyUi: value.supplyUi,
|
|
1049
|
+
quoteMint: value.quoteMint,
|
|
1050
|
+
quoteDecimals: value.quoteMint === USDC_MINT ? 6 : 9,
|
|
1051
|
+
pool: value.pool ?? existing?.pool ?? null,
|
|
1052
|
+
name: value.name ?? existing?.name ?? null,
|
|
1053
|
+
symbol: value.symbol ?? existing?.symbol ?? null,
|
|
1054
|
+
isMayhemMode: value.isMayhemMode,
|
|
1055
|
+
lastSeenAtMs: value.atMs,
|
|
1056
|
+
});
|
|
1057
|
+
launchesByMint.set(value.mint, value);
|
|
1058
|
+
launches += 1;
|
|
1059
|
+
broadcast(value);
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
const emitPrice = async (
|
|
1063
|
+
input: Omit<PriceFeedPrice, "priceUsd" | "marketCapUsd"> & {
|
|
1064
|
+
priceUsd?: number | null;
|
|
1065
|
+
marketCapUsd?: number | null;
|
|
1066
|
+
},
|
|
1067
|
+
) => {
|
|
1068
|
+
const state = mints.get(input.mint);
|
|
1069
|
+
if (state?.isMayhemMode === true && !includeMayhem) return;
|
|
1070
|
+
const usd =
|
|
1071
|
+
input.priceUsd ??
|
|
1072
|
+
(input.priceSol != null
|
|
1073
|
+
? (await refreshSolUsd()) != null
|
|
1074
|
+
? input.priceSol * solUsd!
|
|
1075
|
+
: null
|
|
1076
|
+
: null);
|
|
1077
|
+
const marketCapUsd =
|
|
1078
|
+
input.marketCapUsd ??
|
|
1079
|
+
(usd != null && state?.supplyUi ? usd * state.supplyUi : null);
|
|
1080
|
+
const value: PriceFeedPrice = { ...input, priceUsd: usd, marketCapUsd };
|
|
1081
|
+
latest.set(value.mint, value);
|
|
1082
|
+
if (state) state.lastSeenAtMs = Math.max(state.lastSeenAtMs, value.atMs);
|
|
1083
|
+
prices += 1;
|
|
1084
|
+
broadcast(value);
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
const bootstrapMint = async (mint: string) => {
|
|
1088
|
+
if (mints.has(mint)) return;
|
|
1089
|
+
try {
|
|
1090
|
+
const key = new PublicKey(mint);
|
|
1091
|
+
const supply = await pacer.run(() =>
|
|
1092
|
+
connection.getTokenSupply(key, "confirmed"),
|
|
1093
|
+
);
|
|
1094
|
+
const curve = await pacer.run(() =>
|
|
1095
|
+
connection.getAccountInfo(bondingCurvePda(key), "confirmed"),
|
|
1096
|
+
);
|
|
1097
|
+
const supplyUi = Number(supply.value.uiAmountString ?? "0");
|
|
1098
|
+
const state: MintState = {
|
|
1099
|
+
mint,
|
|
1100
|
+
venue: "pump",
|
|
1101
|
+
decimals: supply.value.decimals,
|
|
1102
|
+
supplyUi,
|
|
1103
|
+
quoteMint: WSOL_MINT,
|
|
1104
|
+
quoteDecimals: 9,
|
|
1105
|
+
pool: null,
|
|
1106
|
+
name: null,
|
|
1107
|
+
symbol: null,
|
|
1108
|
+
isMayhemMode: null,
|
|
1109
|
+
lastSeenAtMs: Date.now(),
|
|
1110
|
+
};
|
|
1111
|
+
mints.set(mint, state);
|
|
1112
|
+
if (
|
|
1113
|
+
curve &&
|
|
1114
|
+
curve.owner.equals(PUMP_PROGRAM) &&
|
|
1115
|
+
curve.data.length >= 49
|
|
1116
|
+
) {
|
|
1117
|
+
const data = Buffer.from(curve.data);
|
|
1118
|
+
const base = data.readBigUInt64LE(8);
|
|
1119
|
+
const quote = data.readBigUInt64LE(16);
|
|
1120
|
+
if (base > 0n && quote > 0n) {
|
|
1121
|
+
const priceSol =
|
|
1122
|
+
Number(quote) /
|
|
1123
|
+
LAMPORTS_PER_SOL /
|
|
1124
|
+
(Number(base) / 10 ** state.decimals);
|
|
1125
|
+
await emitPrice({
|
|
1126
|
+
type: "price",
|
|
1127
|
+
atMs: Date.now(),
|
|
1128
|
+
signature: null,
|
|
1129
|
+
slot: null,
|
|
1130
|
+
mint,
|
|
1131
|
+
venue: "pump",
|
|
1132
|
+
priceSol,
|
|
1133
|
+
source: "bootstrap-curve",
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
report("mint bootstrap error", { mint, error: errorText(error) });
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
const refreshFallbackRef = async (mint: string) => {
|
|
1143
|
+
let refs = 0;
|
|
1144
|
+
for (const client of clients.values())
|
|
1145
|
+
if (client.mints.has(mint)) refs += 1;
|
|
1146
|
+
const existing = fallbackSubs.get(mint);
|
|
1147
|
+
if (refs === 0) {
|
|
1148
|
+
if (existing) {
|
|
1149
|
+
fallbackSubs.delete(mint);
|
|
1150
|
+
logsMux.unsubscribe(existing.id);
|
|
1151
|
+
}
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (existing) {
|
|
1155
|
+
existing.refs = refs;
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
if (fallbackSubs.size >= maxFallbackSubscriptions) {
|
|
1159
|
+
report("fallback subscription budget reached", {
|
|
1160
|
+
mint,
|
|
1161
|
+
active: fallbackSubs.size,
|
|
1162
|
+
limit: maxFallbackSubscriptions,
|
|
1163
|
+
});
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
await bootstrapMint(mint);
|
|
1167
|
+
const id = logsMux.subscribe(new PublicKey(mint), (event) => {
|
|
1168
|
+
if (event.err) return;
|
|
1169
|
+
if (
|
|
1170
|
+
pendingFallback.has(mint) &&
|
|
1171
|
+
pendingFallback.get(mint) !== event.signature
|
|
1172
|
+
)
|
|
1173
|
+
coalescedFallback += 1;
|
|
1174
|
+
pendingFallback.set(mint, event.signature);
|
|
1175
|
+
});
|
|
1176
|
+
fallbackSubs.set(mint, { id, refs });
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
const queueFallbackRefresh = (mint: string) => {
|
|
1180
|
+
const previous = fallbackOps.get(mint) ?? Promise.resolve();
|
|
1181
|
+
const next = previous
|
|
1182
|
+
.then(() => refreshFallbackRef(mint))
|
|
1183
|
+
.catch((error) =>
|
|
1184
|
+
report("fallback subscription error", {
|
|
1185
|
+
mint,
|
|
1186
|
+
error: errorText(error),
|
|
1187
|
+
}),
|
|
1188
|
+
)
|
|
1189
|
+
.then(() => undefined);
|
|
1190
|
+
fallbackOps.set(mint, next);
|
|
1191
|
+
void next.finally(() => {
|
|
1192
|
+
if (fallbackOps.get(mint) === next) fallbackOps.delete(mint);
|
|
1193
|
+
});
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
const applyClientCommand = (ws: any, command: PriceFeedCommand) => {
|
|
1197
|
+
const client = clients.get(ws);
|
|
1198
|
+
if (!client) return;
|
|
1199
|
+
if (command.op === "ping") {
|
|
1200
|
+
send(ws, { type: "status", atMs: Date.now(), event: "pong" });
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
const rows = (command.mints ?? []).filter(Boolean);
|
|
1204
|
+
if (command.op === "subscribe") {
|
|
1205
|
+
if (command.launches === true && !client.launches) {
|
|
1206
|
+
client.launches = true;
|
|
1207
|
+
for (const launch of launchesByMint.values()) send(ws, launch);
|
|
1208
|
+
}
|
|
1209
|
+
if (command.allPrices === true && !client.allPrices) {
|
|
1210
|
+
client.allPrices = true;
|
|
1211
|
+
for (const value of latest.values()) send(ws, value);
|
|
1212
|
+
}
|
|
1213
|
+
for (const mint of rows) client.mints.add(mint);
|
|
1214
|
+
for (const mint of rows) {
|
|
1215
|
+
const cached = latest.get(mint);
|
|
1216
|
+
if (cached) send(ws, cached);
|
|
1217
|
+
queueFallbackRefresh(mint);
|
|
1218
|
+
}
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
for (const mint of rows) {
|
|
1222
|
+
client.mints.delete(mint);
|
|
1223
|
+
queueFallbackRefresh(mint);
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
const server = Bun.serve({
|
|
1228
|
+
hostname: host,
|
|
1229
|
+
port,
|
|
1230
|
+
fetch(request, server) {
|
|
1231
|
+
const url = new URL(request.url);
|
|
1232
|
+
if (url.pathname === "/ws") {
|
|
1233
|
+
const ok = server.upgrade(request);
|
|
1234
|
+
return ok ? undefined : new Response("upgrade failed", { status: 400 });
|
|
1235
|
+
}
|
|
1236
|
+
if (url.pathname === "/health") {
|
|
1237
|
+
return Response.json({
|
|
1238
|
+
ok: true,
|
|
1239
|
+
clients: clients.size,
|
|
1240
|
+
upstreamState: logsMux.state,
|
|
1241
|
+
upstreamSubscriptions: logsMux.subscriptionCount,
|
|
1242
|
+
upstreamReconnects: logsMux.reconnects,
|
|
1243
|
+
upstreamLastMessageAgeMs: logsMux.lastMessageAtMs
|
|
1244
|
+
? Date.now() - logsMux.lastMessageAtMs
|
|
1245
|
+
: null,
|
|
1246
|
+
programSubscriptions: programSubs.length,
|
|
1247
|
+
fallbackSubscriptions: fallbackSubs.size,
|
|
1248
|
+
knownMints: mints.size,
|
|
1249
|
+
cachedLaunches: launchesByMint.size,
|
|
1250
|
+
latestPrices: latest.size,
|
|
1251
|
+
rpcQueue: pacer.queued,
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
return new Response("Solard price feed\n", { status: 200 });
|
|
1255
|
+
},
|
|
1256
|
+
websocket: {
|
|
1257
|
+
open(ws) {
|
|
1258
|
+
clients.set(ws, {
|
|
1259
|
+
mints: new Set(),
|
|
1260
|
+
launches: false,
|
|
1261
|
+
allPrices: false,
|
|
1262
|
+
});
|
|
1263
|
+
clientConnections += 1;
|
|
1264
|
+
send(ws, {
|
|
1265
|
+
type: "status",
|
|
1266
|
+
atMs: Date.now(),
|
|
1267
|
+
event: "ready",
|
|
1268
|
+
data: { programSubscriptions: programSubs.length },
|
|
1269
|
+
});
|
|
1270
|
+
},
|
|
1271
|
+
message(ws, message) {
|
|
1272
|
+
try {
|
|
1273
|
+
applyClientCommand(
|
|
1274
|
+
ws,
|
|
1275
|
+
JSON.parse(String(message)) as PriceFeedCommand,
|
|
1276
|
+
);
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
send(ws, {
|
|
1279
|
+
type: "status",
|
|
1280
|
+
atMs: Date.now(),
|
|
1281
|
+
event: "client-error",
|
|
1282
|
+
data: { error: errorText(error) },
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
1286
|
+
close(ws) {
|
|
1287
|
+
const client = clients.get(ws);
|
|
1288
|
+
clients.delete(ws);
|
|
1289
|
+
if (client) for (const mint of client.mints) queueFallbackRefresh(mint);
|
|
1290
|
+
},
|
|
1291
|
+
},
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
const createFromLaunchLab = async (
|
|
1295
|
+
signature: string,
|
|
1296
|
+
pool: string,
|
|
1297
|
+
slot: number | null,
|
|
1298
|
+
) => {
|
|
1299
|
+
if (createFetches.has(signature)) return;
|
|
1300
|
+
createFetches.add(signature);
|
|
1301
|
+
try {
|
|
1302
|
+
const tx = await pacer.run(() =>
|
|
1303
|
+
connection.getParsedTransaction(signature, {
|
|
1304
|
+
commitment: "confirmed",
|
|
1305
|
+
maxSupportedTransactionVersion: 1,
|
|
1306
|
+
} as any),
|
|
1307
|
+
);
|
|
1308
|
+
if (!tx) return;
|
|
1309
|
+
const candidates = initializedMints(tx as ParsedTransactionWithMeta);
|
|
1310
|
+
const mint = candidates[0];
|
|
1311
|
+
if (!mint) return;
|
|
1312
|
+
let supply = supplyFromTransaction(tx as ParsedTransactionWithMeta, mint);
|
|
1313
|
+
if (!supply) {
|
|
1314
|
+
const loaded = await pacer.run(() =>
|
|
1315
|
+
connection.getTokenSupply(new PublicKey(mint), "confirmed"),
|
|
1316
|
+
);
|
|
1317
|
+
const supplyUi = Number(loaded.value.uiAmountString ?? "0");
|
|
1318
|
+
supply = { decimals: loaded.value.decimals, supplyUi };
|
|
1319
|
+
}
|
|
1320
|
+
const balances = tokenBalances(
|
|
1321
|
+
(tx as ParsedTransactionWithMeta).meta?.postTokenBalances,
|
|
1322
|
+
);
|
|
1323
|
+
const quoteMint = balances.some((row) => row.mint === WSOL_MINT)
|
|
1324
|
+
? WSOL_MINT
|
|
1325
|
+
: balances.some((row) => row.mint === USDC_MINT)
|
|
1326
|
+
? USDC_MINT
|
|
1327
|
+
: WSOL_MINT;
|
|
1328
|
+
pools.set(pool, {
|
|
1329
|
+
pool,
|
|
1330
|
+
mint,
|
|
1331
|
+
baseDecimals: supply.decimals,
|
|
1332
|
+
quoteMint,
|
|
1333
|
+
quoteDecimals: quoteMint === USDC_MINT ? 6 : 9,
|
|
1334
|
+
});
|
|
1335
|
+
emitLaunch({
|
|
1336
|
+
type: "launch",
|
|
1337
|
+
atMs:
|
|
1338
|
+
((tx as ParsedTransactionWithMeta).blockTime ??
|
|
1339
|
+
Math.floor(Date.now() / 1000)) * 1_000,
|
|
1340
|
+
signature,
|
|
1341
|
+
slot: slot ?? (tx as ParsedTransactionWithMeta).slot,
|
|
1342
|
+
mint,
|
|
1343
|
+
venue: "raydium-launchlab",
|
|
1344
|
+
decimals: supply.decimals,
|
|
1345
|
+
supplyUi: supply.supplyUi,
|
|
1346
|
+
quoteMint,
|
|
1347
|
+
pool,
|
|
1348
|
+
name: null,
|
|
1349
|
+
symbol: null,
|
|
1350
|
+
isMayhemMode: false,
|
|
1351
|
+
});
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
rpcErrors += 1;
|
|
1354
|
+
report("launchlab create enrichment error", {
|
|
1355
|
+
signature,
|
|
1356
|
+
pool,
|
|
1357
|
+
error: errorText(error),
|
|
1358
|
+
});
|
|
1359
|
+
} finally {
|
|
1360
|
+
createFetches.delete(signature);
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
const pumpSub = logsMux.subscribe(PUMP_PROGRAM, (event, context) => {
|
|
1365
|
+
if (event.err) return;
|
|
1366
|
+
for (const data of dataEvents(event.logs)) {
|
|
1367
|
+
const created = pumpCreate(data);
|
|
1368
|
+
if (created) {
|
|
1369
|
+
created.signature = event.signature;
|
|
1370
|
+
created.slot = context.slot;
|
|
1371
|
+
emitLaunch(created);
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
const trade = pumpTrade(data);
|
|
1375
|
+
if (!trade) continue;
|
|
1376
|
+
const state = mints.get(trade.mint);
|
|
1377
|
+
if (!state || state.isMayhemMode === true) continue;
|
|
1378
|
+
if (state.quoteMint && state.quoteMint !== WSOL_MINT) continue;
|
|
1379
|
+
const virtualTokens = Number(trade.virtualToken) / 10 ** state.decimals;
|
|
1380
|
+
const virtualSol = Number(trade.virtualSol) / LAMPORTS_PER_SOL;
|
|
1381
|
+
const priceSol = virtualTokens > 0 ? virtualSol / virtualTokens : 0;
|
|
1382
|
+
if (!(priceSol > 0 && Number.isFinite(priceSol))) continue;
|
|
1383
|
+
void emitPrice({
|
|
1384
|
+
type: "price",
|
|
1385
|
+
atMs: trade.atMs,
|
|
1386
|
+
signature: event.signature,
|
|
1387
|
+
slot: context.slot,
|
|
1388
|
+
mint: trade.mint,
|
|
1389
|
+
venue: "pump",
|
|
1390
|
+
priceSol,
|
|
1391
|
+
source: "pump-trade-event",
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
programSubs.push(pumpSub);
|
|
1396
|
+
|
|
1397
|
+
const pumpSwapSub = logsMux.subscribe(PUMP_AMM_PROGRAM, (event, context) => {
|
|
1398
|
+
if (event.err) return;
|
|
1399
|
+
for (const data of dataEvents(event.logs)) {
|
|
1400
|
+
const created = pumpSwapCreate(data);
|
|
1401
|
+
if (created) {
|
|
1402
|
+
pools.set(created.pool, created);
|
|
1403
|
+
const state = mints.get(created.mint);
|
|
1404
|
+
if (state) {
|
|
1405
|
+
state.pool = created.pool;
|
|
1406
|
+
state.quoteMint = created.quoteMint;
|
|
1407
|
+
state.quoteDecimals = created.quoteDecimals;
|
|
1408
|
+
}
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
const trade = pumpSwapTrade(data);
|
|
1412
|
+
if (!trade) continue;
|
|
1413
|
+
const pool = pools.get(trade.pool);
|
|
1414
|
+
if (!pool || trade.baseRaw <= 0n || trade.quoteRaw <= 0n) continue;
|
|
1415
|
+
const priceQuote =
|
|
1416
|
+
Number(trade.quoteRaw) /
|
|
1417
|
+
10 ** pool.quoteDecimals /
|
|
1418
|
+
(Number(trade.baseRaw) / 10 ** pool.baseDecimals);
|
|
1419
|
+
if (!(priceQuote > 0 && Number.isFinite(priceQuote))) continue;
|
|
1420
|
+
void emitPrice({
|
|
1421
|
+
type: "price",
|
|
1422
|
+
atMs: trade.atMs,
|
|
1423
|
+
signature: event.signature,
|
|
1424
|
+
slot: context.slot,
|
|
1425
|
+
mint: pool.mint,
|
|
1426
|
+
venue: "pumpswap",
|
|
1427
|
+
priceSol: pool.quoteMint === WSOL_MINT ? priceQuote : null,
|
|
1428
|
+
priceUsd: pool.quoteMint === USDC_MINT ? priceQuote : null,
|
|
1429
|
+
source: "pumpswap-event",
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
programSubs.push(pumpSwapSub);
|
|
1434
|
+
|
|
1435
|
+
const launchLabSub = logsMux.subscribe(
|
|
1436
|
+
LAUNCHLAB_PROGRAM,
|
|
1437
|
+
(event, context) => {
|
|
1438
|
+
if (event.err) return;
|
|
1439
|
+
for (const data of dataEvents(event.logs)) {
|
|
1440
|
+
const createdPool = launchLabPoolCreate(data);
|
|
1441
|
+
if (createdPool) {
|
|
1442
|
+
void createFromLaunchLab(event.signature, createdPool, context.slot);
|
|
1443
|
+
continue;
|
|
1444
|
+
}
|
|
1445
|
+
const trade = launchLabTrade(data);
|
|
1446
|
+
if (!trade) continue;
|
|
1447
|
+
const pool = pools.get(trade.pool);
|
|
1448
|
+
if (!pool || trade.virtualBase <= 0n || trade.virtualQuote <= 0n)
|
|
1449
|
+
continue;
|
|
1450
|
+
const priceQuote =
|
|
1451
|
+
Number(trade.virtualQuote) /
|
|
1452
|
+
10 ** pool.quoteDecimals /
|
|
1453
|
+
(Number(trade.virtualBase) / 10 ** pool.baseDecimals);
|
|
1454
|
+
if (!(priceQuote > 0 && Number.isFinite(priceQuote))) continue;
|
|
1455
|
+
void emitPrice({
|
|
1456
|
+
type: "price",
|
|
1457
|
+
atMs: Date.now(),
|
|
1458
|
+
signature: event.signature,
|
|
1459
|
+
slot: context.slot,
|
|
1460
|
+
mint: pool.mint,
|
|
1461
|
+
venue: "raydium-launchlab",
|
|
1462
|
+
priceSol: pool.quoteMint === WSOL_MINT ? priceQuote : null,
|
|
1463
|
+
priceUsd: pool.quoteMint === USDC_MINT ? priceQuote : null,
|
|
1464
|
+
source: "launchlab-trade-event",
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
},
|
|
1468
|
+
);
|
|
1469
|
+
programSubs.push(launchLabSub);
|
|
1470
|
+
|
|
1471
|
+
const fallbackLoop = async () => {
|
|
1472
|
+
while (!stopped) {
|
|
1473
|
+
const entry = pendingFallback.entries().next().value as
|
|
1474
|
+
[string, string] | undefined;
|
|
1475
|
+
if (!entry) {
|
|
1476
|
+
await sleep(25);
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
const [mint, signature] = entry;
|
|
1480
|
+
pendingFallback.delete(mint);
|
|
1481
|
+
const state = mints.get(mint);
|
|
1482
|
+
if (!state) continue;
|
|
1483
|
+
try {
|
|
1484
|
+
const tx = await pacer.run(() =>
|
|
1485
|
+
connection.getParsedTransaction(signature, {
|
|
1486
|
+
commitment: "confirmed",
|
|
1487
|
+
maxSupportedTransactionVersion: 1,
|
|
1488
|
+
} as any),
|
|
1489
|
+
);
|
|
1490
|
+
if (!tx) continue;
|
|
1491
|
+
const parsed = genericPrice(
|
|
1492
|
+
tx as ParsedTransactionWithMeta,
|
|
1493
|
+
mint,
|
|
1494
|
+
state.decimals,
|
|
1495
|
+
);
|
|
1496
|
+
if (!parsed) continue;
|
|
1497
|
+
await emitPrice({
|
|
1498
|
+
type: "price",
|
|
1499
|
+
atMs: parsed.atMs,
|
|
1500
|
+
signature,
|
|
1501
|
+
slot: (tx as ParsedTransactionWithMeta).slot,
|
|
1502
|
+
mint,
|
|
1503
|
+
venue: "rpc-fallback",
|
|
1504
|
+
priceSol: parsed.priceSol,
|
|
1505
|
+
priceUsd: parsed.priceUsd,
|
|
1506
|
+
source: "explicit-mint-fallback",
|
|
1507
|
+
});
|
|
1508
|
+
} catch (error) {
|
|
1509
|
+
rpcErrors += 1;
|
|
1510
|
+
report("fallback enrichment error", {
|
|
1511
|
+
mint,
|
|
1512
|
+
signature,
|
|
1513
|
+
error: errorText(error),
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
};
|
|
1518
|
+
|
|
1519
|
+
const stop = () => {
|
|
1520
|
+
stopped = true;
|
|
1521
|
+
};
|
|
1522
|
+
process.once("SIGINT", stop);
|
|
1523
|
+
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
|
+
|
|
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);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
}, 60_000);
|
|
1580
|
+
|
|
1581
|
+
try {
|
|
1582
|
+
await fallbackLoop();
|
|
1583
|
+
} finally {
|
|
1584
|
+
clearInterval(heartbeat);
|
|
1585
|
+
clearInterval(solTimer);
|
|
1586
|
+
clearInterval(pruneTimer);
|
|
1587
|
+
process.removeListener("SIGINT", stop);
|
|
1588
|
+
process.removeListener("SIGTERM", stop);
|
|
1589
|
+
for (const id of programSubs) logsMux.unsubscribe(id);
|
|
1590
|
+
for (const row of fallbackSubs.values()) logsMux.unsubscribe(row.id);
|
|
1591
|
+
logsMux.stop();
|
|
1592
|
+
server.stop(true);
|
|
1593
|
+
report("stopped", { clients: clients.size, launches, prices, rpcErrors });
|
|
1594
|
+
}
|
|
1595
|
+
}
|