@solard/sdk 0.2.33 → 0.2.40

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/src/live.ts CHANGED
@@ -5,35 +5,53 @@ import {
5
5
  subscribeTrades as subscribeCoreTrades,
6
6
  type TradeEvent,
7
7
  type TradeSubscription,
8
- type MigrationEvent,
9
- createLiveTradeHistoryRecorder,
10
- type LiveTradeHistoryOptions,
8
+ type MigrationEvent,
9
+ createLiveTradeHistoryRecorder,
10
+ type LiveTradeHistoryOptions,
11
11
  } from "@solard/core";
12
12
 
13
13
  export type LiveEndpointOptions = {
14
14
  /** Ordered endpoints on the same cluster; public RPC can be supplied last. */
15
15
  rpcUrls?: readonly string[];
16
+ websocketUrls?: readonly string[];
16
17
  /** Fresh HTTP and WebSocket readiness probes for pooled trade streams (default 30s). */
17
18
  healthCheckIntervalMs?: number;
18
19
  };
19
20
  export type SubscribeLaunchesOptions = Omit<
20
21
  Parameters<typeof subscribeCoreLaunches>[0],
21
22
  "connection"
22
- > & LiveEndpointOptions;
23
+ > &
24
+ LiveEndpointOptions;
23
25
  export type SubscribeMigrationsOptions = Omit<
24
26
  Parameters<typeof subscribeCoreMigrations>[0],
25
27
  "connection"
26
- > & LiveEndpointOptions;
28
+ > &
29
+ LiveEndpointOptions;
30
+ /** Applications must explicitly revalidate fresh on-chain pool/mint state.
31
+ * Returning true asserts current state only, not historical coverage. */
32
+ export type FeedRecoveryVerification = (input: {
33
+ connection: Connection; tokens: readonly string[]; signal: AbortSignal;
34
+ }) => Promise<boolean>;
35
+ export type RecoverableTradeSubscription = TradeSubscription & {
36
+ readonly feedVerified: boolean;
37
+ verifyAndResume(verify: FeedRecoveryVerification, timeoutMs?: number): Promise<boolean>;
38
+ };
27
39
  export type SubscribeTradesOptions = Omit<
28
40
  Parameters<typeof subscribeCoreTrades>[0],
29
41
  "connection"
30
- > & LiveEndpointOptions;
42
+ > &
43
+ LiveEndpointOptions;
31
44
 
32
- export type ListenTradesOptions = Omit<SubscribeTradesOptions, "onTrade"> & {
33
- dbPath?: string;
34
- /** Enabled by default; collected live history is partial and retains commitment. */
35
- history?: false | Pick<LiveTradeHistoryOptions, "flushIntervalMs" | "batchSize" | "maxPending">;
36
- };
45
+ export type ListenTradesOptions = Omit<SubscribeTradesOptions, "onTrade"> & {
46
+ dbPath?: string;
47
+ /** Enabled by default; collected live history is partial and retains commitment. */
48
+ history?:
49
+ | false
50
+ | Pick<
51
+ LiveTradeHistoryOptions,
52
+ "flushIntervalMs" | "batchSize" | "maxPending"
53
+ >;
54
+ };
37
55
 
38
56
  export type TradeListener = {
39
57
  add(tokens: string | readonly string[]): Promise<void>;
@@ -41,11 +59,18 @@ export type TradeListener = {
41
59
  has(token: string): boolean;
42
60
  list(): string[];
43
61
  onTrade(callback: (event: TradeEvent) => void | Promise<void>): () => void;
44
- onMigration(callback: (event: TradeListenerMigration) => void | Promise<void>): () => void;
62
+ onMigration(
63
+ callback: (event: TradeListenerMigration) => void | Promise<void>,
64
+ ): () => void;
45
65
  close(): Promise<void>;
46
66
  readonly closed: Promise<void>;
67
+ readonly feedVerified: boolean;
68
+ verifyAndResume(verify: FeedRecoveryVerification, timeoutMs?: number): Promise<boolean>;
69
+ };
70
+ export type TradeListenerMigration = MigrationEvent & {
71
+ oldPool: string | null;
72
+ newPool: string;
47
73
  };
48
- export type TradeListenerMigration = MigrationEvent & { oldPool: string | null; newPool: string };
49
74
 
50
75
  type LiveConnection = {
51
76
  endpoint: string;
@@ -60,39 +85,77 @@ let readiness: { endpoint: string; promise: Promise<void> } | null = null;
60
85
  export class LiveEndpointPool {
61
86
  private active = 0;
62
87
  readonly endpoints: readonly string[];
63
- constructor(endpoints: readonly string[], private readonly probe: (endpoint: string) => Promise<void> = async (endpoint) => {
64
- await Promise.all([preflightRpc(endpoint), preflightWebSocket(endpoint)]);
65
- }) {
88
+ constructor(
89
+ endpoints: readonly string[],
90
+ private readonly probe: (endpoint: string) => Promise<void> = async (
91
+ endpoint,
92
+ ) => {
93
+ await Promise.all([preflightRpc(endpoint), preflightWebSocket(endpoint)]);
94
+ },
95
+ ) {
66
96
  this.endpoints = [...new Set(endpoints)];
67
- if (!this.endpoints.length) throw new Error("Live RPC endpoint pool cannot be empty");
97
+ if (!this.endpoints.length)
98
+ throw new Error("Live RPC endpoint pool cannot be empty");
68
99
  for (const endpoint of this.endpoints) deriveWebSocketEndpoint(endpoint);
69
100
  }
70
- get endpoint(): string { return this.endpoints[this.active]!; }
101
+ get endpoint(): string {
102
+ return this.endpoints[this.active]!;
103
+ }
71
104
  async select(skipCurrent = false): Promise<string> {
72
105
  const start = this.active;
73
106
  let lastError: unknown;
74
- for (let offset = skipCurrent ? 1 : 0; offset < this.endpoints.length + (skipCurrent ? 1 : 0); offset++) {
107
+ for (
108
+ let offset = skipCurrent ? 1 : 0;
109
+ offset < this.endpoints.length + (skipCurrent ? 1 : 0);
110
+ offset++
111
+ ) {
75
112
  const index = (start + offset) % this.endpoints.length;
76
- try { await this.probe(this.endpoints[index]!); this.active = index; return this.endpoint; }
77
- catch (error) { lastError = error; }
113
+ try {
114
+ await this.probe(this.endpoints[index]!);
115
+ this.active = index;
116
+ return this.endpoint;
117
+ } catch (error) {
118
+ lastError = error;
119
+ }
78
120
  }
79
121
  throw lastError;
80
122
  }
81
123
  async check(): Promise<string> {
82
- try { await this.probe(this.endpoint); return this.endpoint; }
83
- catch { return await this.select(true); }
124
+ try {
125
+ await this.probe(this.endpoint);
126
+ return this.endpoint;
127
+ } catch {
128
+ return await this.select(true);
129
+ }
84
130
  }
85
131
  }
86
132
  const livePools = new Map<string, LiveEndpointPool>();
87
- function endpointPool(options: LiveEndpointOptions): LiveEndpointPool | undefined {
88
- if (!options.rpcUrls?.length) return undefined;
89
- const key = JSON.stringify(options.rpcUrls);
133
+ function endpointPool(
134
+ options: LiveEndpointOptions,
135
+ ): LiveEndpointPool | undefined {
136
+ const env = process.env.SLRD_RPC_URLS;
137
+ const urls = options.rpcUrls ?? (env ? JSON.parse(env) as string[] :
138
+ wsCandidates(options).length ? [endpointFromEnv()] : []);
139
+ if (!urls.length) return undefined;
140
+ const key = JSON.stringify([urls, options.websocketUrls ?? process.env.SLRD_WS_URLS]);
90
141
  let pool = livePools.get(key);
91
- if (!pool) { pool = new LiveEndpointPool(options.rpcUrls); livePools.set(key, pool); }
142
+ if (!pool) {
143
+ pool = new LiveEndpointPool(urls, wsCandidates(options).length ? preflightRpc : undefined);
144
+ livePools.set(key, pool);
145
+ }
92
146
  return pool;
93
147
  }
94
- function connectionFor(endpoint: string): LiveConnection {
95
- return { endpoint, websocketEndpoint: deriveWebSocketEndpoint(endpoint), connection: new Connection(endpoint, "confirmed") };
148
+ function wsCandidates(options: LiveEndpointOptions): string[] {
149
+ const fallback = process.env.SLRD_WS_URLS ? JSON.parse(process.env.SLRD_WS_URLS) as unknown : [];
150
+ const urls = options.websocketUrls ?? fallback;
151
+ if (!Array.isArray(urls) || !urls.every(value => typeof value === "string" && /^wss?:\/\//.test(value)))
152
+ throw new Error("Invalid WebSocket endpoint list");
153
+ return [...new Set(urls)] as string[];
154
+ }
155
+ function connectionFor(endpoint: string, ws = deriveWebSocketEndpoint(endpoint)): LiveConnection {
156
+ return { endpoint, websocketEndpoint: ws,
157
+ connection: new Connection(endpoint, { commitment: "confirmed", wsEndpoint: ws,
158
+ disableRetryOnRateLimit: true }) };
96
159
  }
97
160
 
98
161
  function endpointFromEnv(): string {
@@ -121,21 +184,8 @@ export function deriveWebSocketEndpoint(endpoint: string): string {
121
184
  }
122
185
 
123
186
  export function redactRpcEndpoint(endpoint: string): string {
124
- try {
125
- const url = new URL(endpoint);
126
- const keys: string[] = [];
127
- url.searchParams.forEach((_value, key) => keys.push(key));
128
- for (const key of keys) {
129
- if (/key|token|secret|auth/i.test(key))
130
- url.searchParams.set(key, "<redacted>");
131
- }
132
- return url.toString().replaceAll("%3Credacted%3E", "<redacted>");
133
- } catch {
134
- return endpoint.replace(
135
- /([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&]+/gi,
136
- "$1<redacted>",
137
- );
138
- }
187
+ try { const url = new URL(endpoint); return `${url.protocol}//${url.host}/***`; }
188
+ catch { return "<invalid RPC endpoint>"; }
139
189
  }
140
190
 
141
191
  function providerName(endpoint: string): string {
@@ -150,12 +200,7 @@ function providerName(endpoint: string): string {
150
200
 
151
201
  function safeMessage(error: unknown): string {
152
202
  const value = error instanceof Error ? error.message : String(error);
153
- return value
154
- .replace(
155
- /([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&\s]+/gi,
156
- "$1<redacted>",
157
- )
158
- .slice(0, 400);
203
+ return value.replace(/(?:https?|wss?):\/\/[^\s]+/gi, redactRpcEndpoint).slice(0,400);
159
204
  }
160
205
 
161
206
  function rpcFailure(endpoint: string, detail: string): Error {
@@ -177,7 +222,7 @@ async function preflightRpc(endpoint: string): Promise<void> {
177
222
  const response = await fetch(endpoint, {
178
223
  method: "POST",
179
224
  headers: { "content-type": "application/json" },
180
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }),
225
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getGenesisHash" }),
181
226
  signal: controller.signal,
182
227
  });
183
228
  if (!response.ok) {
@@ -203,6 +248,16 @@ async function preflightRpc(endpoint: string): Promise<void> {
203
248
  `${code}${String(body.error.message ?? "RPC error")}`,
204
249
  );
205
250
  }
251
+ if (body.result !== "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d")
252
+ throw rpcFailure(endpoint, "Non-mainnet genesis hash");
253
+ const account = await fetch(endpoint, { method: "POST", signal: controller.signal,
254
+ headers: { "content-type": "application/json" },
255
+ body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "getBalance",
256
+ params: ["11111111111111111111111111111111"] }) });
257
+ if (!account.ok) throw rpcFailure(endpoint, `Balance HTTP ${account.status}`);
258
+ const balance = await account.json() as { error?: unknown; result?: { context?: {slot?:number}; value?:number } };
259
+ if (balance.error || !Number.isFinite(balance.result?.context?.slot) || !Number.isFinite(balance.result?.value))
260
+ throw rpcFailure(endpoint, "Balance reads unavailable");
206
261
  } catch (error) {
207
262
  if (
208
263
  error instanceof Error &&
@@ -218,7 +273,7 @@ async function preflightRpc(endpoint: string): Promise<void> {
218
273
  }
219
274
 
220
275
  async function preflightWebSocket(endpoint: string): Promise<void> {
221
- const websocketEndpoint = deriveWebSocketEndpoint(endpoint);
276
+ const websocketEndpoint = /^wss?:\/\//.test(endpoint) ? endpoint : deriveWebSocketEndpoint(endpoint);
222
277
  await new Promise<void>((resolve, reject) => {
223
278
  let settled = false;
224
279
  let socket: WebSocket | null = null;
@@ -245,16 +300,21 @@ async function preflightWebSocket(endpoint: string): Promise<void> {
245
300
  finish(websocketFailure(endpoint, safeMessage(error)));
246
301
  return;
247
302
  }
248
- socket.addEventListener(
249
- "open",
250
- () => {
251
- finish();
252
- try {
253
- socket?.close(1000);
254
- } catch {}
255
- },
256
- { once: true },
257
- );
303
+ socket.addEventListener("open", () => {
304
+ try { socket?.send(JSON.stringify({ jsonrpc: "2.0", id: 71, method: "slotSubscribe" })); }
305
+ catch { finish(websocketFailure(endpoint, "Subscription send failed")); }
306
+ }, { once: true });
307
+ socket.addEventListener("message", event => {
308
+ try { const packet = JSON.parse(String(event.data)) as { id?: number; result?: number; error?: unknown };
309
+ if (packet.id !== 71) return;
310
+ if (packet.error || !Number.isSafeInteger(packet.result)) {
311
+ finish(websocketFailure(endpoint, "Subscription was rejected")); return;
312
+ }
313
+ try { socket?.send(JSON.stringify({ jsonrpc: "2.0", id: 72,
314
+ method: "slotUnsubscribe", params: [packet.result] })); } catch {}
315
+ finish(); try { socket?.close(1000); } catch {}
316
+ } catch { finish(websocketFailure(endpoint, "Malformed subscription reply")); }
317
+ });
258
318
  socket.addEventListener(
259
319
  "error",
260
320
  (event) => {
@@ -295,17 +355,25 @@ async function ensureReady(endpoint: string): Promise<void> {
295
355
  }
296
356
  }
297
357
 
298
- async function liveConnection(options: LiveEndpointOptions = {}): Promise<LiveConnection> {
358
+ async function liveConnection(
359
+ options: LiveEndpointOptions = {},
360
+ ): Promise<LiveConnection> {
299
361
  const pool = endpointPool(options);
300
- if (pool) return connectionFor(await pool.select());
301
- const endpoint = endpointFromEnv();
362
+ const endpoint = pool ? await pool.select() : endpointFromEnv();
363
+ const independent = wsCandidates(options);
364
+ if (independent.length) {
365
+ await preflightRpc(endpoint);
366
+ let last: unknown;
367
+ for (const ws of independent) {
368
+ try { await preflightWebSocket(ws); return connectionFor(endpoint, ws); }
369
+ catch (error) { last = error; }
370
+ }
371
+ throw last ?? new Error("No usable WSS endpoint");
372
+ }
373
+ if (pool) return connectionFor(endpoint);
302
374
  await ensureReady(endpoint);
303
375
  if (shared?.endpoint === endpoint) return shared;
304
- shared = {
305
- endpoint,
306
- websocketEndpoint: deriveWebSocketEndpoint(endpoint),
307
- connection: new Connection(endpoint, "confirmed"),
308
- };
376
+ shared = connectionFor(endpoint);
309
377
  return shared;
310
378
  }
311
379
 
@@ -323,30 +391,56 @@ function notifyReady(
323
391
  } catch {}
324
392
  }
325
393
 
326
- export async function subscribeTrades(options: SubscribeTradesOptions) {
394
+ export async function subscribeTrades(options: SubscribeTradesOptions): Promise<RecoverableTradeSubscription> {
395
+ if (options.signal?.aborted) throw new DOMException("Trade subscription cancelled", "AbortError");
327
396
  let live = await liveConnection(options);
328
397
  notifyReady(options, live);
329
- const pool = endpointPool(options);
330
- if (!pool) return await subscribeCoreTrades({ ...options, connection: live.connection });
398
+ const pool = endpointPool(options) ?? new LiveEndpointPool([live.endpoint]);
331
399
  // Keep duplicate suppression across connection changes. Provider probes detect
332
400
  // dead credentials/quota; they cannot diagnose a silent old socket when a new
333
401
  // handshake to that provider remains healthy.
334
402
  const seen = new Set<string>();
403
+ let gapUnverified = false;
404
+ let lastAcceptedAt = Date.now();
405
+ let generation = 0;
406
+ const recoveries = new Set<AbortController>();
407
+ const invalidate = () => { gapUnverified = true; generation++; };
335
408
  const onTrade = async (event: TradeEvent) => {
336
- const key = [event.signature, event.venue, event.mint, event.pool, event.eventIndex ?? `${event.side}:${event.baseRaw}:${event.quoteRaw}`].join(":");
409
+ if (!gapUnverified && Date.now() - lastAcceptedAt >= 60_000) invalidate();
410
+ if (gapUnverified || !Number.isFinite(event.atMs) || Date.now() - event.atMs > 30_000 || event.atMs > Date.now() + 10_000) {
411
+ try { options.onStatus?.("feed-stale", { reason: gapUnverified ? "gap-unverified" : "event-outside-live-window" }); } catch {}
412
+ return;
413
+ }
414
+ const key = [
415
+ event.signature,
416
+ event.venue,
417
+ event.mint,
418
+ event.pool,
419
+ event.eventIndex ?? `${event.side}:${event.baseRaw}:${event.quoteRaw}`,
420
+ ].join(":");
337
421
  if (seen.has(key)) return;
338
422
  seen.add(key);
339
423
  if (seen.size > 4096) seen.delete(seen.values().next().value!);
424
+ lastAcceptedAt = Date.now();
340
425
  await options.onTrade(event);
341
426
  };
342
- let subscription = await subscribeCoreTrades({ ...options, signal: undefined, onTrade, connection: live.connection });
427
+ let subscription = await subscribeCoreTrades({
428
+ ...options,
429
+ signal: undefined,
430
+ onTrade,
431
+ connection: live.connection,
432
+ });
343
433
  let stopped = false;
344
434
  let tail: Promise<unknown> = Promise.resolve();
345
435
  const serial = <T>(work: () => Promise<T>): Promise<T> => {
346
- const pending = tail.then(work, work); tail = pending.catch(() => undefined); return pending;
436
+ const pending = tail.then(work, work);
437
+ tail = pending.catch(() => undefined);
438
+ return pending;
347
439
  };
348
440
  let settle!: () => void;
349
- const closed = new Promise<void>((resolve) => { settle = resolve; });
441
+ const closed = new Promise<void>((resolve) => {
442
+ settle = resolve;
443
+ });
350
444
  const requestedInterval = options.healthCheckIntervalMs ?? 30_000;
351
445
  if (!Number.isFinite(requestedInterval) || requestedInterval < 1000) {
352
446
  await subscription.close();
@@ -358,39 +452,136 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
358
452
  checking = true;
359
453
  void serial(async () => {
360
454
  if (stopped) return;
455
+ if (!gapUnverified && Date.now() - lastAcceptedAt >= 60_000) {
456
+ invalidate();
457
+ try { options.onStatus?.("feed-gap-unverified", {
458
+ reason: "no-recent-accepted-trades", historyComplete: false,
459
+ }); } catch {}
460
+ }
361
461
  try {
362
462
  const endpoint = await pool.check();
363
- if (stopped || endpoint === live.endpoint) return;
463
+ const independent = wsCandidates(options);
464
+ let ws = independent.length ? live.websocketEndpoint : deriveWebSocketEndpoint(endpoint);
465
+ if (independent.length) {
466
+ try { await preflightWebSocket(ws); }
467
+ catch {
468
+ const candidates = independent.filter(candidate => candidate !== ws);
469
+ let found = false;
470
+ for (const candidate of candidates) {
471
+ try { await preflightWebSocket(candidate); ws = candidate; found = true; break; }
472
+ catch {}
473
+ }
474
+ if (!found) throw new Error("All independent WebSocket endpoints are unavailable");
475
+ }
476
+ }
477
+ if (stopped || (endpoint === live.endpoint && ws === live.websocketEndpoint)) return;
478
+ invalidate();
479
+ try { options.onStatus?.("feed-gap-unverified", { endpoint: redactRpcEndpoint(endpoint),
480
+ websocket: redactRpcEndpoint(ws) }); } catch {}
364
481
  const tokens = subscription.listTokens();
365
- const nextLive = connectionFor(endpoint);
366
- const next = await subscribeCoreTrades({ ...options, signal: undefined, tokens, onTrade, connection: nextLive.connection });
482
+ const nextLive = connectionFor(endpoint, ws);
483
+ const next = await subscribeCoreTrades({
484
+ ...options,
485
+ signal: undefined,
486
+ tokens,
487
+ onTrade,
488
+ connection: nextLive.connection,
489
+ });
367
490
  await subscription.close();
368
491
  subscription = next;
369
492
  live = nextLive;
370
493
  notifyReady(options, live);
371
- } catch (error) {
372
- try { options.onStatus?.("rpc-unavailable", { error: safeMessage(error) }); } catch {}
494
+ } catch (error) {
495
+ invalidate();
496
+ try {
497
+ options.onStatus?.("feed-gap-unverified", { reason: "provider-readiness-failed", historyComplete: false });
498
+ options.onStatus?.("rpc-unavailable", { error: safeMessage(error) });
499
+ } catch {}
373
500
  }
374
- }).finally(() => { checking = false; });
501
+ }).finally(() => {
502
+ checking = false;
503
+ });
375
504
  }, Math.trunc(requestedInterval));
376
505
  timer.unref?.();
377
506
  const close = async () => {
378
507
  if (stopped) return await closed;
379
508
  stopped = true;
509
+ invalidate();
510
+ for (const controller of recoveries) controller.abort();
380
511
  clearInterval(timer);
381
512
  options.signal?.removeEventListener("abort", abort);
382
- await serial(async () => { await subscription.close(); settle(); });
513
+ await serial(async () => {
514
+ await subscription.close();
515
+ settle();
516
+ });
517
+ };
518
+ const abort = () => {
519
+ void close();
383
520
  };
384
- const abort = () => { void close(); };
385
521
  if (options.signal?.aborted) await close();
386
522
  else options.signal?.addEventListener("abort", abort, { once: true });
387
523
  return {
388
- addTokens: (tokens: string | readonly string[]) => serial(async () => { if (stopped) throw new Error("Trade subscription is closed"); await subscription.addTokens(tokens); }),
389
- removeTokens: (tokens: string | readonly string[]) => serial(async () => { if (!stopped) await subscription.removeTokens(tokens); }),
524
+ addTokens: (tokens: string | readonly string[]) => {
525
+ const values = typeof tokens === "string" ? [tokens] : tokens;
526
+ if (values.some(token => !subscription.hasToken(token))) invalidate();
527
+ return serial(async () => {
528
+ if (stopped) throw new Error("Trade subscription is closed");
529
+ await subscription.addTokens(tokens);
530
+ });
531
+ },
532
+ removeTokens: (tokens: string | readonly string[]) => {
533
+ const values = typeof tokens === "string" ? [tokens] : tokens;
534
+ if (values.some(token => subscription.hasToken(token))) invalidate();
535
+ return serial(async () => {
536
+ if (!stopped) await subscription.removeTokens(tokens);
537
+ });
538
+ },
390
539
  hasToken: (token: string) => !stopped && subscription.hasToken(token),
391
- listTokens: () => stopped ? [] : subscription.listTokens(),
392
- close, closed,
393
- } satisfies TradeSubscription;
540
+ listTokens: () => (stopped ? [] : subscription.listTokens()),
541
+ get feedVerified() { return !stopped && !gapUnverified &&
542
+ Date.now() - lastAcceptedAt < 60_000; },
543
+ verifyAndResume: (verify: FeedRecoveryVerification, timeoutMs = 5000) => {
544
+ if (stopped) return Promise.reject(new Error("Trade subscription is closed"));
545
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000)
546
+ return Promise.reject(new Error("Recovery timeout must be 1..30000ms"));
547
+ // The deadline includes time queued behind health checks or token edits.
548
+ invalidate();
549
+ const expected = generation;
550
+ const controller = new AbortController();
551
+ recoveries.add(controller);
552
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
553
+ const onAbort = () => controller.abort();
554
+ options.signal?.addEventListener("abort", onAbort, { once: true });
555
+ if (options.signal?.aborted) controller.abort();
556
+ let rejectAbort!: () => void;
557
+ const cancelled = new Promise<never>((_, reject) => {
558
+ rejectAbort = () => reject(new Error("Feed verification cancelled or timed out"));
559
+ controller.signal.addEventListener("abort", rejectAbort, { once: true });
560
+ if (controller.signal.aborted) rejectAbort();
561
+ });
562
+ const pending = serial(async () => {
563
+ if (controller.signal.aborted) throw new Error("Feed verification cancelled or timed out");
564
+ if (stopped) throw new Error("Trade subscription is closed");
565
+ if (generation !== expected) return false;
566
+ const verified = await Promise.race([
567
+ Promise.resolve().then(() => verify({ connection: live.connection,
568
+ tokens: subscription.listTokens(), signal: controller.signal })), cancelled,
569
+ ]);
570
+ if (verified !== true || stopped || generation !== expected || controller.signal.aborted) return false;
571
+ gapUnverified = false;
572
+ lastAcceptedAt = Date.now();
573
+ try { options.onStatus?.("feed-fresh-state-verified", { historyComplete: false }); } catch {}
574
+ return true;
575
+ });
576
+ return Promise.race([pending, cancelled]).finally(() => {
577
+ clearTimeout(timeout); recoveries.delete(controller);
578
+ controller.signal.removeEventListener("abort", rejectAbort);
579
+ options.signal?.removeEventListener("abort", onAbort);
580
+ });
581
+ },
582
+ close,
583
+ closed,
584
+ } satisfies RecoverableTradeSubscription;
394
585
  }
395
586
 
396
587
  export async function subscribeLaunches(options: SubscribeLaunchesOptions) {
@@ -413,25 +604,46 @@ export async function subscribeMigrations(options: SubscribeMigrationsOptions) {
413
604
  export async function listenTrades(
414
605
  options: ListenTradesOptions,
415
606
  ): Promise<TradeListener> {
416
- const callbacks = new Set<(event: TradeEvent) => void | Promise<void>>();
417
- const history = options.history === false ? undefined : createLiveTradeHistoryRecorder({ ...options.history, dbPath: options.dbPath, commitment: options.commitment, onStatus: options.onStatus });
607
+ const callbacks = new Set<(event: TradeEvent) => void | Promise<void>>();
608
+ const history =
609
+ options.history === false
610
+ ? undefined
611
+ : createLiveTradeHistoryRecorder({
612
+ ...options.history,
613
+ dbPath: options.dbPath,
614
+ commitment: options.commitment,
615
+ onStatus: options.onStatus,
616
+ });
418
617
  const pending: TradeEvent[] = [];
419
- const migrationCallbacks = new Set<(event: TradeListenerMigration) => void | Promise<void>>();
618
+ const migrationCallbacks = new Set<
619
+ (event: TradeListenerMigration) => void | Promise<void>
620
+ >();
420
621
  const pendingMigrations: TradeListenerMigration[] = [];
421
622
  const watched = new Set(options.tokens);
422
623
  const lastPools = new Map<string, string | null>();
423
- let subscription: TradeSubscription;
424
- let migrations: Awaited<ReturnType<typeof subscribeMigrations>> | undefined;
425
- let stopped = false;
426
- let resolveClosed!: () => void;
427
- let rejectClosed!: (error: unknown) => void;
428
- const closed = new Promise<void>((resolve, reject) => { resolveClosed = resolve; rejectClosed = reject; });
429
- void closed.catch(() => undefined);
624
+ let subscription: RecoverableTradeSubscription;
625
+ let migrations: Awaited<ReturnType<typeof subscribeMigrations>> | undefined;
626
+ let stopped = false;
627
+ let resolveClosed!: () => void;
628
+ let rejectClosed!: (error: unknown) => void;
629
+ const closed = new Promise<void>((resolve, reject) => {
630
+ resolveClosed = resolve;
631
+ rejectClosed = reject;
632
+ });
633
+ void closed.catch(() => undefined);
430
634
  let tail: Promise<unknown> = Promise.resolve();
431
- const serial = <T>(work: () => Promise<T>): Promise<T> => { const job = tail.then(work, work); tail = job.catch(() => undefined); return job; };
635
+ const serial = <T>(work: () => Promise<T>): Promise<T> => {
636
+ const job = tail.then(work, work);
637
+ tail = job.catch(() => undefined);
638
+ return job;
639
+ };
432
640
  const onMigration = async (event: MigrationEvent) => {
433
641
  if (stopped || !watched.has(event.mint)) return;
434
- const migration = { ...event, oldPool: lastPools.get(event.mint) ?? null, newPool: event.pool };
642
+ const migration = {
643
+ ...event,
644
+ oldPool: lastPools.get(event.mint) ?? null,
645
+ newPool: event.pool,
646
+ };
435
647
  lastPools.set(event.mint, event.pool);
436
648
  await serial(async () => {
437
649
  if (stopped || !watched.has(event.mint)) return;
@@ -440,14 +652,24 @@ export async function listenTrades(
440
652
  await subscription.removeTokens(event.mint);
441
653
  await subscription.addTokens(event.mint);
442
654
  });
443
- if (!migrationCallbacks.size) { pendingMigrations.push(migration); if (pendingMigrations.length > 1000) pendingMigrations.shift(); }
444
- else await Promise.all([...migrationCallbacks].map(callback => callback(migration)));
655
+ if (!migrationCallbacks.size) {
656
+ pendingMigrations.push(migration);
657
+ if (pendingMigrations.length > 1000) pendingMigrations.shift();
658
+ } else
659
+ await Promise.all(
660
+ [...migrationCallbacks].map((callback) => callback(migration)),
661
+ );
445
662
  };
446
- const dispatch = async (event: TradeEvent) => {
447
- if (!watched.has(event.mint) || stopped) return;
448
- lastPools.set(event.mint, event.pool);
449
- try { await history?.record(event); }
450
- catch (error) { options.onStatus?.("history-persistence-error", { error: safeMessage(error) }); }
663
+ const dispatch = async (event: TradeEvent) => {
664
+ if (!watched.has(event.mint) || stopped) return;
665
+ lastPools.set(event.mint, event.pool);
666
+ try {
667
+ await history?.record(event);
668
+ } catch (error) {
669
+ options.onStatus?.("history-persistence-error", {
670
+ error: safeMessage(error),
671
+ });
672
+ }
451
673
  if (callbacks.size === 0) {
452
674
  pending.push(event);
453
675
  if (pending.length > 1_000) pending.shift();
@@ -461,32 +683,99 @@ export async function listenTrades(
461
683
  };
462
684
  const onStatus = (event: string, data?: Record<string, unknown>) => {
463
685
  options.onStatus?.(event, data);
464
- if (event === "rpc-ready" && migrations && !stopped) void serial(async () => {
465
- if (stopped) return;
466
- const next = await subscribeMigrations({ rpcUrls: options.rpcUrls, tokens: [...watched], commitment: options.commitment, metadata: options.metadata, onMigration, onStatus: options.onStatus });
467
- await migrations?.close(); migrations = next;
468
- }).catch(error => options.onStatus?.("migration-resubscribe-error", { error: safeMessage(error) }));
686
+ if (event === "feed-gap-unverified" || event === "feed-stale") pending.length = 0;
687
+ if (event === "rpc-ready" && migrations && !stopped)
688
+ void serial(async () => {
689
+ if (stopped) return;
690
+ const next = await subscribeMigrations({
691
+ rpcUrls: options.rpcUrls,
692
+ websocketUrls: options.websocketUrls,
693
+ tokens: [...watched],
694
+ commitment: options.commitment,
695
+ metadata: options.metadata,
696
+ onMigration,
697
+ onStatus: options.onStatus,
698
+ });
699
+ await migrations?.close();
700
+ migrations = next;
701
+ }).catch((error) =>
702
+ options.onStatus?.("migration-resubscribe-error", {
703
+ error: safeMessage(error),
704
+ }),
705
+ );
706
+ };
707
+ try {
708
+ subscription = await subscribeTrades({
709
+ ...options,
710
+ signal: undefined,
711
+ onStatus,
712
+ onTrade: dispatch,
713
+ });
714
+ } catch (error) {
715
+ await history?.close();
716
+ throw error;
717
+ }
718
+ try {
719
+ migrations = await subscribeMigrations({
720
+ rpcUrls: options.rpcUrls,
721
+ websocketUrls: options.websocketUrls,
722
+ tokens: [...watched],
723
+ commitment: options.commitment,
724
+ metadata: options.metadata,
725
+ onMigration,
726
+ onStatus: options.onStatus,
727
+ });
728
+ } catch (error) {
729
+ await subscription.close();
730
+ await history?.close();
731
+ throw error;
732
+ }
733
+ const close = async () => {
734
+ if (stopped) return await closed;
735
+ stopped = true;
736
+ options.signal?.removeEventListener("abort", abort);
737
+ try {
738
+ await serial(async () => {
739
+ await Promise.all([subscription.close(), migrations?.close()]);
740
+ watched.clear();
741
+ await history?.close();
742
+ });
743
+ resolveClosed();
744
+ } catch (error) {
745
+ rejectClosed(error);
746
+ throw error;
747
+ }
469
748
  };
470
- try { subscription = await subscribeTrades({ ...options, signal: undefined, onStatus, onTrade: dispatch }); }
471
- catch (error) { await history?.close(); throw error; }
472
- try { migrations = await subscribeMigrations({ rpcUrls: options.rpcUrls, tokens: [...watched], commitment: options.commitment, metadata: options.metadata, onMigration, onStatus: options.onStatus }); }
473
- catch (error) { await subscription.close(); await history?.close(); throw error; }
474
- const close = async () => {
475
- if (stopped) return await closed;
476
- stopped = true; options.signal?.removeEventListener("abort", abort);
477
- try {
478
- await serial(async () => { await Promise.all([subscription.close(), migrations?.close()]); watched.clear(); await history?.close(); });
479
- resolveClosed();
480
- } catch (error) { rejectClosed(error); throw error; }
749
+ const abort = () => {
750
+ void close();
481
751
  };
482
- const abort = () => { void close(); };
483
752
  if (options.signal?.aborted) await close();
484
753
  else options.signal?.addEventListener("abort", abort, { once: true });
485
754
  return {
486
- add: (tokens) => serial(async () => { if (stopped) throw new Error("Trade listener is closed"); const values = typeof tokens === "string" ? [tokens] : [...tokens]; await subscription.addTokens(values); await migrations!.addTokens(values); values.forEach(token => watched.add(token)); }),
487
- remove: (tokens) => serial(async () => { const values = typeof tokens === "string" ? [tokens] : [...tokens]; values.forEach(token => { watched.delete(token); lastPools.delete(token); }); await Promise.all([subscription.removeTokens(values), migrations!.removeTokens(values)]); }),
755
+ add: (tokens) =>
756
+ serial(async () => {
757
+ if (stopped) throw new Error("Trade listener is closed");
758
+ const values = typeof tokens === "string" ? [tokens] : [...tokens];
759
+ await subscription.addTokens(values);
760
+ await migrations!.addTokens(values);
761
+ values.forEach((token) => watched.add(token));
762
+ }),
763
+ remove: (tokens) =>
764
+ serial(async () => {
765
+ const values = typeof tokens === "string" ? [tokens] : [...tokens];
766
+ values.forEach((token) => {
767
+ watched.delete(token);
768
+ lastPools.delete(token);
769
+ });
770
+ await Promise.all([
771
+ subscription.removeTokens(values),
772
+ migrations!.removeTokens(values),
773
+ ]);
774
+ }),
775
+ get feedVerified() { return !stopped && subscription.feedVerified === true; },
776
+ verifyAndResume: (verify, timeoutMs) => subscription.verifyAndResume(verify, timeoutMs),
488
777
  has: (token) => !stopped && watched.has(token),
489
- list: () => stopped ? [] : [...watched],
778
+ list: () => (stopped ? [] : [...watched]),
490
779
  onTrade(callback) {
491
780
  callbacks.add(callback);
492
781
  if (pending.length) {
@@ -499,12 +788,17 @@ export async function listenTrades(
499
788
  },
500
789
  onMigration(callback) {
501
790
  migrationCallbacks.add(callback);
502
- if (pendingMigrations.length) { const rows = pendingMigrations.splice(0); void Promise.resolve().then(async () => { for (const event of rows) await callback(event); }); }
791
+ if (pendingMigrations.length) {
792
+ const rows = pendingMigrations.splice(0);
793
+ void Promise.resolve().then(async () => {
794
+ for (const event of rows) await callback(event);
795
+ });
796
+ }
503
797
  return () => migrationCallbacks.delete(callback);
504
798
  },
505
- close,
506
- get closed() {
507
- return closed;
799
+ close,
800
+ get closed() {
801
+ return closed;
508
802
  },
509
803
  };
510
804
  }