@solard/sdk 0.2.37 → 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
@@ -13,6 +13,7 @@ import {
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
  };
@@ -26,6 +27,15 @@ export type SubscribeMigrationsOptions = Omit<
26
27
  "connection"
27
28
  > &
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
+ };
29
39
  export type SubscribeTradesOptions = Omit<
30
40
  Parameters<typeof subscribeCoreTrades>[0],
31
41
  "connection"
@@ -54,6 +64,8 @@ export type TradeListener = {
54
64
  ): () => void;
55
65
  close(): Promise<void>;
56
66
  readonly closed: Promise<void>;
67
+ readonly feedVerified: boolean;
68
+ verifyAndResume(verify: FeedRecoveryVerification, timeoutMs?: number): Promise<boolean>;
57
69
  };
58
70
  export type TradeListenerMigration = MigrationEvent & {
59
71
  oldPool: string | null;
@@ -121,21 +133,29 @@ const livePools = new Map<string, LiveEndpointPool>();
121
133
  function endpointPool(
122
134
  options: LiveEndpointOptions,
123
135
  ): LiveEndpointPool | undefined {
124
- if (!options.rpcUrls?.length) return undefined;
125
- const key = JSON.stringify(options.rpcUrls);
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]);
126
141
  let pool = livePools.get(key);
127
142
  if (!pool) {
128
- pool = new LiveEndpointPool(options.rpcUrls);
143
+ pool = new LiveEndpointPool(urls, wsCandidates(options).length ? preflightRpc : undefined);
129
144
  livePools.set(key, pool);
130
145
  }
131
146
  return pool;
132
147
  }
133
- function connectionFor(endpoint: string): LiveConnection {
134
- return {
135
- endpoint,
136
- websocketEndpoint: deriveWebSocketEndpoint(endpoint),
137
- connection: new Connection(endpoint, "confirmed"),
138
- };
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 }) };
139
159
  }
140
160
 
141
161
  function endpointFromEnv(): string {
@@ -164,21 +184,8 @@ export function deriveWebSocketEndpoint(endpoint: string): string {
164
184
  }
165
185
 
166
186
  export function redactRpcEndpoint(endpoint: string): string {
167
- try {
168
- const url = new URL(endpoint);
169
- const keys: string[] = [];
170
- url.searchParams.forEach((_value, key) => keys.push(key));
171
- for (const key of keys) {
172
- if (/key|token|secret|auth/i.test(key))
173
- url.searchParams.set(key, "<redacted>");
174
- }
175
- return url.toString().replaceAll("%3Credacted%3E", "<redacted>");
176
- } catch {
177
- return endpoint.replace(
178
- /([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&]+/gi,
179
- "$1<redacted>",
180
- );
181
- }
187
+ try { const url = new URL(endpoint); return `${url.protocol}//${url.host}/***`; }
188
+ catch { return "<invalid RPC endpoint>"; }
182
189
  }
183
190
 
184
191
  function providerName(endpoint: string): string {
@@ -193,12 +200,7 @@ function providerName(endpoint: string): string {
193
200
 
194
201
  function safeMessage(error: unknown): string {
195
202
  const value = error instanceof Error ? error.message : String(error);
196
- return value
197
- .replace(
198
- /([?&](?:api-?key|api_key|apikey|key|token|secret|auth)=)[^&\s]+/gi,
199
- "$1<redacted>",
200
- )
201
- .slice(0, 400);
203
+ return value.replace(/(?:https?|wss?):\/\/[^\s]+/gi, redactRpcEndpoint).slice(0,400);
202
204
  }
203
205
 
204
206
  function rpcFailure(endpoint: string, detail: string): Error {
@@ -220,7 +222,7 @@ async function preflightRpc(endpoint: string): Promise<void> {
220
222
  const response = await fetch(endpoint, {
221
223
  method: "POST",
222
224
  headers: { "content-type": "application/json" },
223
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }),
225
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getGenesisHash" }),
224
226
  signal: controller.signal,
225
227
  });
226
228
  if (!response.ok) {
@@ -246,6 +248,16 @@ async function preflightRpc(endpoint: string): Promise<void> {
246
248
  `${code}${String(body.error.message ?? "RPC error")}`,
247
249
  );
248
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");
249
261
  } catch (error) {
250
262
  if (
251
263
  error instanceof Error &&
@@ -261,7 +273,7 @@ async function preflightRpc(endpoint: string): Promise<void> {
261
273
  }
262
274
 
263
275
  async function preflightWebSocket(endpoint: string): Promise<void> {
264
- const websocketEndpoint = deriveWebSocketEndpoint(endpoint);
276
+ const websocketEndpoint = /^wss?:\/\//.test(endpoint) ? endpoint : deriveWebSocketEndpoint(endpoint);
265
277
  await new Promise<void>((resolve, reject) => {
266
278
  let settled = false;
267
279
  let socket: WebSocket | null = null;
@@ -288,16 +300,21 @@ async function preflightWebSocket(endpoint: string): Promise<void> {
288
300
  finish(websocketFailure(endpoint, safeMessage(error)));
289
301
  return;
290
302
  }
291
- socket.addEventListener(
292
- "open",
293
- () => {
294
- finish();
295
- try {
296
- socket?.close(1000);
297
- } catch {}
298
- },
299
- { once: true },
300
- );
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
+ });
301
318
  socket.addEventListener(
302
319
  "error",
303
320
  (event) => {
@@ -342,15 +359,21 @@ async function liveConnection(
342
359
  options: LiveEndpointOptions = {},
343
360
  ): Promise<LiveConnection> {
344
361
  const pool = endpointPool(options);
345
- if (pool) return connectionFor(await pool.select());
346
- 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);
347
374
  await ensureReady(endpoint);
348
375
  if (shared?.endpoint === endpoint) return shared;
349
- shared = {
350
- endpoint,
351
- websocketEndpoint: deriveWebSocketEndpoint(endpoint),
352
- connection: new Connection(endpoint, "confirmed"),
353
- };
376
+ shared = connectionFor(endpoint);
354
377
  return shared;
355
378
  }
356
379
 
@@ -368,20 +391,26 @@ function notifyReady(
368
391
  } catch {}
369
392
  }
370
393
 
371
- 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");
372
396
  let live = await liveConnection(options);
373
397
  notifyReady(options, live);
374
- const pool = endpointPool(options);
375
- if (!pool)
376
- return await subscribeCoreTrades({
377
- ...options,
378
- connection: live.connection,
379
- });
398
+ const pool = endpointPool(options) ?? new LiveEndpointPool([live.endpoint]);
380
399
  // Keep duplicate suppression across connection changes. Provider probes detect
381
400
  // dead credentials/quota; they cannot diagnose a silent old socket when a new
382
401
  // handshake to that provider remains healthy.
383
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++; };
384
408
  const onTrade = async (event: TradeEvent) => {
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
+ }
385
414
  const key = [
386
415
  event.signature,
387
416
  event.venue,
@@ -392,6 +421,7 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
392
421
  if (seen.has(key)) return;
393
422
  seen.add(key);
394
423
  if (seen.size > 4096) seen.delete(seen.values().next().value!);
424
+ lastAcceptedAt = Date.now();
395
425
  await options.onTrade(event);
396
426
  };
397
427
  let subscription = await subscribeCoreTrades({
@@ -422,11 +452,34 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
422
452
  checking = true;
423
453
  void serial(async () => {
424
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
+ }
425
461
  try {
426
462
  const endpoint = await pool.check();
427
- 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 {}
428
481
  const tokens = subscription.listTokens();
429
- const nextLive = connectionFor(endpoint);
482
+ const nextLive = connectionFor(endpoint, ws);
430
483
  const next = await subscribeCoreTrades({
431
484
  ...options,
432
485
  signal: undefined,
@@ -438,9 +491,11 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
438
491
  subscription = next;
439
492
  live = nextLive;
440
493
  notifyReady(options, live);
441
- } catch (error) {
442
- try {
443
- options.onStatus?.("rpc-unavailable", { error: safeMessage(error) });
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) });
444
499
  } catch {}
445
500
  }
446
501
  }).finally(() => {
@@ -451,6 +506,8 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
451
506
  const close = async () => {
452
507
  if (stopped) return await closed;
453
508
  stopped = true;
509
+ invalidate();
510
+ for (const controller of recoveries) controller.abort();
454
511
  clearInterval(timer);
455
512
  options.signal?.removeEventListener("abort", abort);
456
513
  await serial(async () => {
@@ -464,20 +521,67 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
464
521
  if (options.signal?.aborted) await close();
465
522
  else options.signal?.addEventListener("abort", abort, { once: true });
466
523
  return {
467
- addTokens: (tokens: string | readonly string[]) =>
468
- serial(async () => {
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 () => {
469
528
  if (stopped) throw new Error("Trade subscription is closed");
470
529
  await subscription.addTokens(tokens);
471
- }),
472
- removeTokens: (tokens: string | readonly string[]) =>
473
- serial(async () => {
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 () => {
474
536
  if (!stopped) await subscription.removeTokens(tokens);
475
- }),
537
+ });
538
+ },
476
539
  hasToken: (token: string) => !stopped && subscription.hasToken(token),
477
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
+ },
478
582
  close,
479
583
  closed,
480
- } satisfies TradeSubscription;
584
+ } satisfies RecoverableTradeSubscription;
481
585
  }
482
586
 
483
587
  export async function subscribeLaunches(options: SubscribeLaunchesOptions) {
@@ -517,7 +621,7 @@ export async function listenTrades(
517
621
  const pendingMigrations: TradeListenerMigration[] = [];
518
622
  const watched = new Set(options.tokens);
519
623
  const lastPools = new Map<string, string | null>();
520
- let subscription: TradeSubscription;
624
+ let subscription: RecoverableTradeSubscription;
521
625
  let migrations: Awaited<ReturnType<typeof subscribeMigrations>> | undefined;
522
626
  let stopped = false;
523
627
  let resolveClosed!: () => void;
@@ -579,11 +683,13 @@ export async function listenTrades(
579
683
  };
580
684
  const onStatus = (event: string, data?: Record<string, unknown>) => {
581
685
  options.onStatus?.(event, data);
686
+ if (event === "feed-gap-unverified" || event === "feed-stale") pending.length = 0;
582
687
  if (event === "rpc-ready" && migrations && !stopped)
583
688
  void serial(async () => {
584
689
  if (stopped) return;
585
690
  const next = await subscribeMigrations({
586
691
  rpcUrls: options.rpcUrls,
692
+ websocketUrls: options.websocketUrls,
587
693
  tokens: [...watched],
588
694
  commitment: options.commitment,
589
695
  metadata: options.metadata,
@@ -612,6 +718,7 @@ export async function listenTrades(
612
718
  try {
613
719
  migrations = await subscribeMigrations({
614
720
  rpcUrls: options.rpcUrls,
721
+ websocketUrls: options.websocketUrls,
615
722
  tokens: [...watched],
616
723
  commitment: options.commitment,
617
724
  metadata: options.metadata,
@@ -665,6 +772,8 @@ export async function listenTrades(
665
772
  migrations!.removeTokens(values),
666
773
  ]);
667
774
  }),
775
+ get feedVerified() { return !stopped && subscription.feedVerified === true; },
776
+ verifyAndResume: (verify, timeoutMs) => subscription.verifyAndResume(verify, timeoutMs),
668
777
  has: (token) => !stopped && watched.has(token),
669
778
  list: () => (stopped ? [] : [...watched]),
670
779
  onTrade(callback) {