@solard/sdk 0.2.33 → 0.2.37

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,9 +5,9 @@ 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 = {
@@ -19,21 +19,29 @@ export type LiveEndpointOptions = {
19
19
  export type SubscribeLaunchesOptions = Omit<
20
20
  Parameters<typeof subscribeCoreLaunches>[0],
21
21
  "connection"
22
- > & LiveEndpointOptions;
22
+ > &
23
+ LiveEndpointOptions;
23
24
  export type SubscribeMigrationsOptions = Omit<
24
25
  Parameters<typeof subscribeCoreMigrations>[0],
25
26
  "connection"
26
- > & LiveEndpointOptions;
27
+ > &
28
+ LiveEndpointOptions;
27
29
  export type SubscribeTradesOptions = Omit<
28
30
  Parameters<typeof subscribeCoreTrades>[0],
29
31
  "connection"
30
- > & LiveEndpointOptions;
32
+ > &
33
+ LiveEndpointOptions;
31
34
 
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
- };
35
+ export type ListenTradesOptions = Omit<SubscribeTradesOptions, "onTrade"> & {
36
+ dbPath?: string;
37
+ /** Enabled by default; collected live history is partial and retains commitment. */
38
+ history?:
39
+ | false
40
+ | Pick<
41
+ LiveTradeHistoryOptions,
42
+ "flushIntervalMs" | "batchSize" | "maxPending"
43
+ >;
44
+ };
37
45
 
38
46
  export type TradeListener = {
39
47
  add(tokens: string | readonly string[]): Promise<void>;
@@ -41,11 +49,16 @@ export type TradeListener = {
41
49
  has(token: string): boolean;
42
50
  list(): string[];
43
51
  onTrade(callback: (event: TradeEvent) => void | Promise<void>): () => void;
44
- onMigration(callback: (event: TradeListenerMigration) => void | Promise<void>): () => void;
52
+ onMigration(
53
+ callback: (event: TradeListenerMigration) => void | Promise<void>,
54
+ ): () => void;
45
55
  close(): Promise<void>;
46
56
  readonly closed: Promise<void>;
47
57
  };
48
- export type TradeListenerMigration = MigrationEvent & { oldPool: string | null; newPool: string };
58
+ export type TradeListenerMigration = MigrationEvent & {
59
+ oldPool: string | null;
60
+ newPool: string;
61
+ };
49
62
 
50
63
  type LiveConnection = {
51
64
  endpoint: string;
@@ -60,39 +73,69 @@ let readiness: { endpoint: string; promise: Promise<void> } | null = null;
60
73
  export class LiveEndpointPool {
61
74
  private active = 0;
62
75
  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
- }) {
76
+ constructor(
77
+ endpoints: readonly string[],
78
+ private readonly probe: (endpoint: string) => Promise<void> = async (
79
+ endpoint,
80
+ ) => {
81
+ await Promise.all([preflightRpc(endpoint), preflightWebSocket(endpoint)]);
82
+ },
83
+ ) {
66
84
  this.endpoints = [...new Set(endpoints)];
67
- if (!this.endpoints.length) throw new Error("Live RPC endpoint pool cannot be empty");
85
+ if (!this.endpoints.length)
86
+ throw new Error("Live RPC endpoint pool cannot be empty");
68
87
  for (const endpoint of this.endpoints) deriveWebSocketEndpoint(endpoint);
69
88
  }
70
- get endpoint(): string { return this.endpoints[this.active]!; }
89
+ get endpoint(): string {
90
+ return this.endpoints[this.active]!;
91
+ }
71
92
  async select(skipCurrent = false): Promise<string> {
72
93
  const start = this.active;
73
94
  let lastError: unknown;
74
- for (let offset = skipCurrent ? 1 : 0; offset < this.endpoints.length + (skipCurrent ? 1 : 0); offset++) {
95
+ for (
96
+ let offset = skipCurrent ? 1 : 0;
97
+ offset < this.endpoints.length + (skipCurrent ? 1 : 0);
98
+ offset++
99
+ ) {
75
100
  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; }
101
+ try {
102
+ await this.probe(this.endpoints[index]!);
103
+ this.active = index;
104
+ return this.endpoint;
105
+ } catch (error) {
106
+ lastError = error;
107
+ }
78
108
  }
79
109
  throw lastError;
80
110
  }
81
111
  async check(): Promise<string> {
82
- try { await this.probe(this.endpoint); return this.endpoint; }
83
- catch { return await this.select(true); }
112
+ try {
113
+ await this.probe(this.endpoint);
114
+ return this.endpoint;
115
+ } catch {
116
+ return await this.select(true);
117
+ }
84
118
  }
85
119
  }
86
120
  const livePools = new Map<string, LiveEndpointPool>();
87
- function endpointPool(options: LiveEndpointOptions): LiveEndpointPool | undefined {
121
+ function endpointPool(
122
+ options: LiveEndpointOptions,
123
+ ): LiveEndpointPool | undefined {
88
124
  if (!options.rpcUrls?.length) return undefined;
89
125
  const key = JSON.stringify(options.rpcUrls);
90
126
  let pool = livePools.get(key);
91
- if (!pool) { pool = new LiveEndpointPool(options.rpcUrls); livePools.set(key, pool); }
127
+ if (!pool) {
128
+ pool = new LiveEndpointPool(options.rpcUrls);
129
+ livePools.set(key, pool);
130
+ }
92
131
  return pool;
93
132
  }
94
133
  function connectionFor(endpoint: string): LiveConnection {
95
- return { endpoint, websocketEndpoint: deriveWebSocketEndpoint(endpoint), connection: new Connection(endpoint, "confirmed") };
134
+ return {
135
+ endpoint,
136
+ websocketEndpoint: deriveWebSocketEndpoint(endpoint),
137
+ connection: new Connection(endpoint, "confirmed"),
138
+ };
96
139
  }
97
140
 
98
141
  function endpointFromEnv(): string {
@@ -295,7 +338,9 @@ async function ensureReady(endpoint: string): Promise<void> {
295
338
  }
296
339
  }
297
340
 
298
- async function liveConnection(options: LiveEndpointOptions = {}): Promise<LiveConnection> {
341
+ async function liveConnection(
342
+ options: LiveEndpointOptions = {},
343
+ ): Promise<LiveConnection> {
299
344
  const pool = endpointPool(options);
300
345
  if (pool) return connectionFor(await pool.select());
301
346
  const endpoint = endpointFromEnv();
@@ -327,26 +372,45 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
327
372
  let live = await liveConnection(options);
328
373
  notifyReady(options, live);
329
374
  const pool = endpointPool(options);
330
- if (!pool) return await subscribeCoreTrades({ ...options, connection: live.connection });
375
+ if (!pool)
376
+ return await subscribeCoreTrades({
377
+ ...options,
378
+ connection: live.connection,
379
+ });
331
380
  // Keep duplicate suppression across connection changes. Provider probes detect
332
381
  // dead credentials/quota; they cannot diagnose a silent old socket when a new
333
382
  // handshake to that provider remains healthy.
334
383
  const seen = new Set<string>();
335
384
  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(":");
385
+ const key = [
386
+ event.signature,
387
+ event.venue,
388
+ event.mint,
389
+ event.pool,
390
+ event.eventIndex ?? `${event.side}:${event.baseRaw}:${event.quoteRaw}`,
391
+ ].join(":");
337
392
  if (seen.has(key)) return;
338
393
  seen.add(key);
339
394
  if (seen.size > 4096) seen.delete(seen.values().next().value!);
340
395
  await options.onTrade(event);
341
396
  };
342
- let subscription = await subscribeCoreTrades({ ...options, signal: undefined, onTrade, connection: live.connection });
397
+ let subscription = await subscribeCoreTrades({
398
+ ...options,
399
+ signal: undefined,
400
+ onTrade,
401
+ connection: live.connection,
402
+ });
343
403
  let stopped = false;
344
404
  let tail: Promise<unknown> = Promise.resolve();
345
405
  const serial = <T>(work: () => Promise<T>): Promise<T> => {
346
- const pending = tail.then(work, work); tail = pending.catch(() => undefined); return pending;
406
+ const pending = tail.then(work, work);
407
+ tail = pending.catch(() => undefined);
408
+ return pending;
347
409
  };
348
410
  let settle!: () => void;
349
- const closed = new Promise<void>((resolve) => { settle = resolve; });
411
+ const closed = new Promise<void>((resolve) => {
412
+ settle = resolve;
413
+ });
350
414
  const requestedInterval = options.healthCheckIntervalMs ?? 30_000;
351
415
  if (!Number.isFinite(requestedInterval) || requestedInterval < 1000) {
352
416
  await subscription.close();
@@ -363,15 +427,25 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
363
427
  if (stopped || endpoint === live.endpoint) return;
364
428
  const tokens = subscription.listTokens();
365
429
  const nextLive = connectionFor(endpoint);
366
- const next = await subscribeCoreTrades({ ...options, signal: undefined, tokens, onTrade, connection: nextLive.connection });
430
+ const next = await subscribeCoreTrades({
431
+ ...options,
432
+ signal: undefined,
433
+ tokens,
434
+ onTrade,
435
+ connection: nextLive.connection,
436
+ });
367
437
  await subscription.close();
368
438
  subscription = next;
369
439
  live = nextLive;
370
440
  notifyReady(options, live);
371
441
  } catch (error) {
372
- try { options.onStatus?.("rpc-unavailable", { error: safeMessage(error) }); } catch {}
442
+ try {
443
+ options.onStatus?.("rpc-unavailable", { error: safeMessage(error) });
444
+ } catch {}
373
445
  }
374
- }).finally(() => { checking = false; });
446
+ }).finally(() => {
447
+ checking = false;
448
+ });
375
449
  }, Math.trunc(requestedInterval));
376
450
  timer.unref?.();
377
451
  const close = async () => {
@@ -379,17 +453,30 @@ export async function subscribeTrades(options: SubscribeTradesOptions) {
379
453
  stopped = true;
380
454
  clearInterval(timer);
381
455
  options.signal?.removeEventListener("abort", abort);
382
- await serial(async () => { await subscription.close(); settle(); });
456
+ await serial(async () => {
457
+ await subscription.close();
458
+ settle();
459
+ });
460
+ };
461
+ const abort = () => {
462
+ void close();
383
463
  };
384
- const abort = () => { void close(); };
385
464
  if (options.signal?.aborted) await close();
386
465
  else options.signal?.addEventListener("abort", abort, { once: true });
387
466
  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); }),
467
+ addTokens: (tokens: string | readonly string[]) =>
468
+ serial(async () => {
469
+ if (stopped) throw new Error("Trade subscription is closed");
470
+ await subscription.addTokens(tokens);
471
+ }),
472
+ removeTokens: (tokens: string | readonly string[]) =>
473
+ serial(async () => {
474
+ if (!stopped) await subscription.removeTokens(tokens);
475
+ }),
390
476
  hasToken: (token: string) => !stopped && subscription.hasToken(token),
391
- listTokens: () => stopped ? [] : subscription.listTokens(),
392
- close, closed,
477
+ listTokens: () => (stopped ? [] : subscription.listTokens()),
478
+ close,
479
+ closed,
393
480
  } satisfies TradeSubscription;
394
481
  }
395
482
 
@@ -413,25 +500,46 @@ export async function subscribeMigrations(options: SubscribeMigrationsOptions) {
413
500
  export async function listenTrades(
414
501
  options: ListenTradesOptions,
415
502
  ): 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 });
503
+ const callbacks = new Set<(event: TradeEvent) => void | Promise<void>>();
504
+ const history =
505
+ options.history === false
506
+ ? undefined
507
+ : createLiveTradeHistoryRecorder({
508
+ ...options.history,
509
+ dbPath: options.dbPath,
510
+ commitment: options.commitment,
511
+ onStatus: options.onStatus,
512
+ });
418
513
  const pending: TradeEvent[] = [];
419
- const migrationCallbacks = new Set<(event: TradeListenerMigration) => void | Promise<void>>();
514
+ const migrationCallbacks = new Set<
515
+ (event: TradeListenerMigration) => void | Promise<void>
516
+ >();
420
517
  const pendingMigrations: TradeListenerMigration[] = [];
421
518
  const watched = new Set(options.tokens);
422
519
  const lastPools = new Map<string, string | null>();
423
520
  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);
521
+ let migrations: Awaited<ReturnType<typeof subscribeMigrations>> | undefined;
522
+ let stopped = false;
523
+ let resolveClosed!: () => void;
524
+ let rejectClosed!: (error: unknown) => void;
525
+ const closed = new Promise<void>((resolve, reject) => {
526
+ resolveClosed = resolve;
527
+ rejectClosed = reject;
528
+ });
529
+ void closed.catch(() => undefined);
430
530
  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; };
531
+ const serial = <T>(work: () => Promise<T>): Promise<T> => {
532
+ const job = tail.then(work, work);
533
+ tail = job.catch(() => undefined);
534
+ return job;
535
+ };
432
536
  const onMigration = async (event: MigrationEvent) => {
433
537
  if (stopped || !watched.has(event.mint)) return;
434
- const migration = { ...event, oldPool: lastPools.get(event.mint) ?? null, newPool: event.pool };
538
+ const migration = {
539
+ ...event,
540
+ oldPool: lastPools.get(event.mint) ?? null,
541
+ newPool: event.pool,
542
+ };
435
543
  lastPools.set(event.mint, event.pool);
436
544
  await serial(async () => {
437
545
  if (stopped || !watched.has(event.mint)) return;
@@ -440,14 +548,24 @@ export async function listenTrades(
440
548
  await subscription.removeTokens(event.mint);
441
549
  await subscription.addTokens(event.mint);
442
550
  });
443
- if (!migrationCallbacks.size) { pendingMigrations.push(migration); if (pendingMigrations.length > 1000) pendingMigrations.shift(); }
444
- else await Promise.all([...migrationCallbacks].map(callback => callback(migration)));
551
+ if (!migrationCallbacks.size) {
552
+ pendingMigrations.push(migration);
553
+ if (pendingMigrations.length > 1000) pendingMigrations.shift();
554
+ } else
555
+ await Promise.all(
556
+ [...migrationCallbacks].map((callback) => callback(migration)),
557
+ );
445
558
  };
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) }); }
559
+ const dispatch = async (event: TradeEvent) => {
560
+ if (!watched.has(event.mint) || stopped) return;
561
+ lastPools.set(event.mint, event.pool);
562
+ try {
563
+ await history?.record(event);
564
+ } catch (error) {
565
+ options.onStatus?.("history-persistence-error", {
566
+ error: safeMessage(error),
567
+ });
568
+ }
451
569
  if (callbacks.size === 0) {
452
570
  pending.push(event);
453
571
  if (pending.length > 1_000) pending.shift();
@@ -461,32 +579,94 @@ export async function listenTrades(
461
579
  };
462
580
  const onStatus = (event: string, data?: Record<string, unknown>) => {
463
581
  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) }));
582
+ if (event === "rpc-ready" && migrations && !stopped)
583
+ void serial(async () => {
584
+ if (stopped) return;
585
+ const next = await subscribeMigrations({
586
+ rpcUrls: options.rpcUrls,
587
+ tokens: [...watched],
588
+ commitment: options.commitment,
589
+ metadata: options.metadata,
590
+ onMigration,
591
+ onStatus: options.onStatus,
592
+ });
593
+ await migrations?.close();
594
+ migrations = next;
595
+ }).catch((error) =>
596
+ options.onStatus?.("migration-resubscribe-error", {
597
+ error: safeMessage(error),
598
+ }),
599
+ );
469
600
  };
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; }
601
+ try {
602
+ subscription = await subscribeTrades({
603
+ ...options,
604
+ signal: undefined,
605
+ onStatus,
606
+ onTrade: dispatch,
607
+ });
608
+ } catch (error) {
609
+ await history?.close();
610
+ throw error;
611
+ }
612
+ try {
613
+ migrations = await subscribeMigrations({
614
+ rpcUrls: options.rpcUrls,
615
+ tokens: [...watched],
616
+ commitment: options.commitment,
617
+ metadata: options.metadata,
618
+ onMigration,
619
+ onStatus: options.onStatus,
620
+ });
621
+ } catch (error) {
622
+ await subscription.close();
623
+ await history?.close();
624
+ throw error;
625
+ }
626
+ const close = async () => {
627
+ if (stopped) return await closed;
628
+ stopped = true;
629
+ options.signal?.removeEventListener("abort", abort);
630
+ try {
631
+ await serial(async () => {
632
+ await Promise.all([subscription.close(), migrations?.close()]);
633
+ watched.clear();
634
+ await history?.close();
635
+ });
636
+ resolveClosed();
637
+ } catch (error) {
638
+ rejectClosed(error);
639
+ throw error;
640
+ }
641
+ };
642
+ const abort = () => {
643
+ void close();
481
644
  };
482
- const abort = () => { void close(); };
483
645
  if (options.signal?.aborted) await close();
484
646
  else options.signal?.addEventListener("abort", abort, { once: true });
485
647
  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)]); }),
648
+ add: (tokens) =>
649
+ serial(async () => {
650
+ if (stopped) throw new Error("Trade listener is closed");
651
+ const values = typeof tokens === "string" ? [tokens] : [...tokens];
652
+ await subscription.addTokens(values);
653
+ await migrations!.addTokens(values);
654
+ values.forEach((token) => watched.add(token));
655
+ }),
656
+ remove: (tokens) =>
657
+ serial(async () => {
658
+ const values = typeof tokens === "string" ? [tokens] : [...tokens];
659
+ values.forEach((token) => {
660
+ watched.delete(token);
661
+ lastPools.delete(token);
662
+ });
663
+ await Promise.all([
664
+ subscription.removeTokens(values),
665
+ migrations!.removeTokens(values),
666
+ ]);
667
+ }),
488
668
  has: (token) => !stopped && watched.has(token),
489
- list: () => stopped ? [] : [...watched],
669
+ list: () => (stopped ? [] : [...watched]),
490
670
  onTrade(callback) {
491
671
  callbacks.add(callback);
492
672
  if (pending.length) {
@@ -499,12 +679,17 @@ export async function listenTrades(
499
679
  },
500
680
  onMigration(callback) {
501
681
  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); }); }
682
+ if (pendingMigrations.length) {
683
+ const rows = pendingMigrations.splice(0);
684
+ void Promise.resolve().then(async () => {
685
+ for (const event of rows) await callback(event);
686
+ });
687
+ }
503
688
  return () => migrationCallbacks.delete(callback);
504
689
  },
505
- close,
506
- get closed() {
507
- return closed;
690
+ close,
691
+ get closed() {
692
+ return closed;
508
693
  },
509
694
  };
510
695
  }
@@ -14,7 +14,9 @@ describe("operator api", () => {
14
14
  test("uses one object-form trade api", () => {
15
15
  expect(client).toContain("input: SolardBuyInput");
16
16
  expect(client).toContain("input: SolardSellInput");
17
- expect(client).toContain("SolardTradeExecutionOptions = TradeExecutionOptions");
17
+ expect(client).toContain(
18
+ "SolardTradeExecutionOptions = TradeExecutionOptions",
19
+ );
18
20
  expect(client).toContain("exportPrivateKey");
19
21
  expect(client).toContain("getTransaction(");
20
22
  });