@drift-labs/sdk 2.136.0-beta.0 → 2.136.0-beta.2

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.
Files changed (77) hide show
  1. package/VERSION +1 -1
  2. package/lib/browser/accounts/types.d.ts +2 -0
  3. package/lib/browser/accounts/webSocketAccountSubscriberV2.d.ts +76 -3
  4. package/lib/browser/accounts/webSocketAccountSubscriberV2.js +211 -39
  5. package/lib/browser/accounts/webSocketDriftClientAccountSubscriberV2.d.ts +87 -0
  6. package/lib/browser/accounts/webSocketDriftClientAccountSubscriberV2.js +444 -0
  7. package/lib/browser/accounts/webSocketProgramAccountsSubscriberV2.d.ts +145 -0
  8. package/lib/browser/accounts/webSocketProgramAccountsSubscriberV2.js +744 -0
  9. package/lib/browser/accounts/websocketProgramUserAccountSubscriber.d.ts +22 -0
  10. package/lib/browser/accounts/websocketProgramUserAccountSubscriber.js +54 -0
  11. package/lib/browser/driftClient.js +22 -18
  12. package/lib/browser/driftClientConfig.d.ts +7 -2
  13. package/lib/browser/factory/bigNum.d.ts +2 -2
  14. package/lib/browser/factory/bigNum.js +20 -5
  15. package/lib/browser/index.d.ts +4 -0
  16. package/lib/browser/index.js +9 -1
  17. package/lib/browser/memcmp.d.ts +2 -0
  18. package/lib/browser/memcmp.js +19 -1
  19. package/lib/browser/oracles/oracleId.d.ts +5 -0
  20. package/lib/browser/oracles/oracleId.js +46 -1
  21. package/lib/browser/user.js +12 -5
  22. package/lib/browser/userConfig.d.ts +3 -0
  23. package/lib/node/accounts/types.d.ts +2 -0
  24. package/lib/node/accounts/types.d.ts.map +1 -1
  25. package/lib/node/accounts/webSocketAccountSubscriberV2.d.ts +76 -3
  26. package/lib/node/accounts/webSocketAccountSubscriberV2.d.ts.map +1 -1
  27. package/lib/node/accounts/webSocketAccountSubscriberV2.js +211 -39
  28. package/lib/node/accounts/webSocketDriftClientAccountSubscriberV2.d.ts +88 -0
  29. package/lib/node/accounts/webSocketDriftClientAccountSubscriberV2.d.ts.map +1 -0
  30. package/lib/node/accounts/webSocketDriftClientAccountSubscriberV2.js +444 -0
  31. package/lib/node/accounts/webSocketProgramAccountsSubscriberV2.d.ts +146 -0
  32. package/lib/node/accounts/webSocketProgramAccountsSubscriberV2.d.ts.map +1 -0
  33. package/lib/node/accounts/webSocketProgramAccountsSubscriberV2.js +744 -0
  34. package/lib/node/accounts/websocketProgramUserAccountSubscriber.d.ts +23 -0
  35. package/lib/node/accounts/websocketProgramUserAccountSubscriber.d.ts.map +1 -0
  36. package/lib/node/accounts/websocketProgramUserAccountSubscriber.js +54 -0
  37. package/lib/node/driftClient.d.ts.map +1 -1
  38. package/lib/node/driftClient.js +22 -18
  39. package/lib/node/driftClientConfig.d.ts +7 -2
  40. package/lib/node/driftClientConfig.d.ts.map +1 -1
  41. package/lib/node/factory/bigNum.d.ts +2 -2
  42. package/lib/node/factory/bigNum.d.ts.map +1 -1
  43. package/lib/node/factory/bigNum.js +20 -5
  44. package/lib/node/index.d.ts +4 -0
  45. package/lib/node/index.d.ts.map +1 -1
  46. package/lib/node/index.js +9 -1
  47. package/lib/node/memcmp.d.ts +2 -0
  48. package/lib/node/memcmp.d.ts.map +1 -1
  49. package/lib/node/memcmp.js +19 -1
  50. package/lib/node/oracles/oracleId.d.ts +5 -0
  51. package/lib/node/oracles/oracleId.d.ts.map +1 -1
  52. package/lib/node/oracles/oracleId.js +46 -1
  53. package/lib/node/user.d.ts.map +1 -1
  54. package/lib/node/user.js +12 -5
  55. package/lib/node/userConfig.d.ts +3 -0
  56. package/lib/node/userConfig.d.ts.map +1 -1
  57. package/package.json +1 -1
  58. package/src/accounts/README_WebSocketAccountSubscriberV2.md +41 -0
  59. package/src/accounts/types.ts +3 -0
  60. package/src/accounts/webSocketAccountSubscriberV2.ts +243 -42
  61. package/src/accounts/webSocketDriftClientAccountSubscriberV2.ts +745 -0
  62. package/src/accounts/webSocketProgramAccountsSubscriberV2.ts +995 -0
  63. package/src/accounts/websocketProgramUserAccountSubscriber.ts +94 -0
  64. package/src/driftClient.ts +13 -7
  65. package/src/driftClientConfig.ts +15 -8
  66. package/src/factory/bigNum.ts +22 -5
  67. package/src/index.ts +4 -0
  68. package/src/memcmp.ts +17 -0
  69. package/src/oracles/oracleId.ts +34 -0
  70. package/src/user.ts +21 -9
  71. package/src/userConfig.ts +3 -0
  72. package/lib/browser/accounts/webSocketProgramAccountSubscriberV2.d.ts +0 -53
  73. package/lib/browser/accounts/webSocketProgramAccountSubscriberV2.js +0 -453
  74. package/lib/node/accounts/webSocketProgramAccountSubscriberV2.d.ts +0 -54
  75. package/lib/node/accounts/webSocketProgramAccountSubscriberV2.d.ts.map +0 -1
  76. package/lib/node/accounts/webSocketProgramAccountSubscriberV2.js +0 -453
  77. package/src/accounts/webSocketProgramAccountSubscriberV2.ts +0 -596
@@ -0,0 +1,995 @@
1
+ import { BufferAndSlot, ProgramAccountSubscriber, ResubOpts } from './types';
2
+ import { AnchorProvider, Program } from '@coral-xyz/anchor';
3
+ import { Commitment, Context, MemcmpFilter, PublicKey } from '@solana/web3.js';
4
+ import {
5
+ AccountInfoBase,
6
+ AccountInfoWithBase58EncodedData,
7
+ AccountInfoWithBase64EncodedData,
8
+ createSolanaClient,
9
+ isAddress,
10
+ Lamports,
11
+ Slot,
12
+ type Address,
13
+ type Commitment as GillCommitment,
14
+ } from 'gill';
15
+ import bs58 from 'bs58';
16
+
17
+ type ProgramAccountSubscriptionAsyncIterable = AsyncIterable<
18
+ Readonly<{
19
+ context: Readonly<{
20
+ slot: Slot;
21
+ }>;
22
+ value: Readonly<{
23
+ account: Readonly<{
24
+ executable: boolean;
25
+ lamports: Lamports;
26
+ owner: Address;
27
+ rentEpoch: bigint;
28
+ space: bigint;
29
+ }> &
30
+ Readonly<any>;
31
+ pubkey: Address;
32
+ }>;
33
+ }>
34
+ >;
35
+ /**
36
+ * WebSocketProgramAccountsSubscriberV2
37
+ *
38
+ * High-level overview
39
+ * - WebSocket-first subscriber for Solana program accounts that also layers in
40
+ * targeted polling to detect missed updates reliably.
41
+ * - Emits decoded account updates via the provided `onChange` callback.
42
+ * - Designed to focus extra work on the specific accounts the consumer cares
43
+ * about ("monitored accounts") while keeping baseline WS behavior for the
44
+ * full program subscription.
45
+ *
46
+ * Why polling if this is a WebSocket subscriber?
47
+ * - WS infra can stall, drop, or reorder notifications under network stress or
48
+ * provider hiccups. When that happens, critical account changes can be missed.
49
+ * - To mitigate this, the class accepts a set of accounts (provided via constructor) to monitor
50
+ * and uses light polling to verify whether a WS change was missed.
51
+ * - If polling detects a newer slot with different data than the last seen
52
+ * buffer, a centralized resubscription is triggered to restore a clean stream.
53
+ *
54
+ * Initial fetch (on subscribe)
55
+ * - On `subscribe()`, we first perform a single batched fetch of all monitored
56
+ * accounts ("initial monitor fetch").
57
+ * - Purpose: seed the internal `bufferAndSlotMap` and emit the latest state so
58
+ * consumers have up-to-date data immediately, even before WS events arrive.
59
+ * - This step does not decide resubscription; it only establishes ground truth.
60
+ *
61
+ * Continuous polling (only for monitored accounts)
62
+ * - After seeding, each monitored account is put into a monitoring cycle:
63
+ * 1) If no WS notification for an account is observed for `pollingIntervalMs`,
64
+ * we enqueue it for a batched fetch (buffered for a short window).
65
+ * 2) Once an account enters the "currently polling" set, a shared batch poll
66
+ * runs every `pollingIntervalMs` across all such accounts.
67
+ * 3) If WS notifications resume for an account, that account is removed from
68
+ * the polling set and returns to passive monitoring.
69
+ * - Polling compares the newly fetched buffer with the last stored buffer at a
70
+ * later slot. A difference indicates a missed update; we schedule a single
71
+ * resubscription (coalesced across accounts) to re-sync.
72
+ *
73
+ * Accounts the consumer cares about
74
+ * - Provide accounts up-front via the constructor `accountsToMonitor`, or add
75
+ * them dynamically with `addAccountToMonitor()` and remove with
76
+ * `removeAccountFromMonitor()`.
77
+ * - Only these accounts incur additional polling safeguards; other accounts are
78
+ * still processed from the WS stream normally.
79
+ *
80
+ * Resubscription strategy
81
+ * - Missed updates from any monitored account are coalesced and trigger a single
82
+ * resubscription after a short delay. This avoids rapid churn.
83
+ * - If `resubOpts.resubTimeoutMs` is set, an inactivity timer also performs a
84
+ * batch check of monitored accounts. If a missed update is found, the same
85
+ * centralized resubscription flow is used.
86
+ *
87
+ * Tuning knobs
88
+ * - `setPollingInterval(ms)`: adjust how often monitoring/polling runs
89
+ * (default 30s). Shorter = faster detection, higher RPC load.
90
+ * - Debounced immediate poll (~100ms): batches accounts added to polling right after inactivity.
91
+ * - Batch size for `getMultipleAccounts` is limited to 100, requests are chunked
92
+ * and processed concurrently.
93
+ */
94
+
95
+ export class WebSocketProgramAccountsSubscriberV2<T>
96
+ implements ProgramAccountSubscriber<T>
97
+ {
98
+ subscriptionName: string;
99
+ accountDiscriminator: string;
100
+ bufferAndSlotMap: Map<string, BufferAndSlot> = new Map();
101
+ program: Program;
102
+ decodeBuffer: (accountName: string, ix: Buffer) => T;
103
+ onChange: (
104
+ accountId: PublicKey,
105
+ data: T,
106
+ context: Context,
107
+ buffer: Buffer
108
+ ) => void;
109
+ listenerId?: number;
110
+ resubOpts: ResubOpts;
111
+ isUnsubscribing = false;
112
+ timeoutId?: ReturnType<typeof setTimeout>;
113
+ options: { filters: MemcmpFilter[]; commitment?: Commitment };
114
+
115
+ receivingData = false;
116
+
117
+ // Gill client components
118
+ private rpc: ReturnType<typeof createSolanaClient>['rpc'];
119
+ private rpcSubscriptions: ReturnType<
120
+ typeof createSolanaClient
121
+ >['rpcSubscriptions'];
122
+ private abortController?: AbortController;
123
+
124
+ // Polling logic for specific accounts
125
+ private accountsToMonitor: Set<string> = new Set();
126
+ private pollingIntervalMs: number = 30000; // 30 seconds
127
+ private pollingTimeouts: Map<string, ReturnType<typeof setTimeout>> =
128
+ new Map();
129
+ private lastWsNotificationTime: Map<string, number> = new Map(); // Track last WS notification time per account
130
+ private accountsCurrentlyPolling: Set<string> = new Set(); // Track which accounts are being polled
131
+ private batchPollingTimeout?: ReturnType<typeof setTimeout>; // Single timeout for batch polling
132
+
133
+ // Debounced immediate poll to batch multiple additions within a short window
134
+ private debouncedImmediatePollTimeout?: ReturnType<typeof setTimeout>;
135
+ private debouncedImmediatePollMs: number = 100; // configurable short window
136
+
137
+ // Centralized resubscription handling
138
+ private missedChangeDetected = false; // Flag to track if any missed change was detected
139
+ private resubscriptionTimeout?: ReturnType<typeof setTimeout>; // Timeout for delayed resubscription
140
+ private accountsWithMissedUpdates: Set<string> = new Set(); // Track which accounts had missed updates
141
+
142
+ public constructor(
143
+ subscriptionName: string,
144
+ accountDiscriminator: string,
145
+ program: Program,
146
+ decodeBufferFn: (accountName: string, ix: Buffer) => T,
147
+ options: { filters: MemcmpFilter[]; commitment?: Commitment } = {
148
+ filters: [],
149
+ },
150
+ resubOpts?: ResubOpts,
151
+ accountsToMonitor?: PublicKey[] // Optional list of accounts to poll
152
+ ) {
153
+ this.subscriptionName = subscriptionName;
154
+ this.accountDiscriminator = accountDiscriminator;
155
+ this.program = program;
156
+ this.decodeBuffer = decodeBufferFn;
157
+ this.resubOpts = resubOpts ?? {
158
+ resubTimeoutMs: 30000,
159
+ usePollingInsteadOfResub: true,
160
+ logResubMessages: false,
161
+ };
162
+ if (this.resubOpts?.resubTimeoutMs < 1000) {
163
+ console.log(
164
+ 'resubTimeoutMs should be at least 1000ms to avoid spamming resub'
165
+ );
166
+ }
167
+ this.options = options;
168
+ this.receivingData = false;
169
+
170
+ // Initialize accounts to monitor
171
+ if (accountsToMonitor) {
172
+ accountsToMonitor.forEach((account) => {
173
+ this.accountsToMonitor.add(account.toBase58());
174
+ });
175
+ }
176
+
177
+ // Initialize gill client using the same RPC URL as the program provider
178
+ const rpcUrl = (this.program.provider as AnchorProvider).connection
179
+ .rpcEndpoint;
180
+ const { rpc, rpcSubscriptions } = createSolanaClient({
181
+ urlOrMoniker: rpcUrl,
182
+ });
183
+ this.rpc = rpc;
184
+ this.rpcSubscriptions = rpcSubscriptions;
185
+ }
186
+
187
+ private async handleNotificationLoop(
188
+ notificationPromise: Promise<ProgramAccountSubscriptionAsyncIterable>
189
+ ) {
190
+ try {
191
+ const subscriptionIterable = await notificationPromise;
192
+ for await (const notification of subscriptionIterable) {
193
+ try {
194
+ if (this.resubOpts?.resubTimeoutMs) {
195
+ this.receivingData = true;
196
+ clearTimeout(this.timeoutId);
197
+ this.handleRpcResponse(
198
+ notification.context,
199
+ notification.value.pubkey,
200
+ notification.value.account.data
201
+ );
202
+ this.setTimeout();
203
+ } else {
204
+ this.handleRpcResponse(
205
+ notification.context,
206
+ notification.value.pubkey,
207
+ notification.value.account.data
208
+ );
209
+ }
210
+ } catch (error) {
211
+ console.error(
212
+ `Error handling RPC response for pubkey ${notification.value.pubkey}:`,
213
+ error
214
+ );
215
+ }
216
+ }
217
+ } catch (error) {
218
+ console.error(
219
+ `[${this.subscriptionName}] Error in notification loop:`,
220
+ error
221
+ );
222
+ }
223
+ }
224
+
225
+ async subscribe(
226
+ onChange: (
227
+ accountId: PublicKey,
228
+ data: T,
229
+ context: Context,
230
+ buffer: Buffer
231
+ ) => void
232
+ ): Promise<void> {
233
+ /**
234
+ * Start the WebSocket subscription and initialize polling safeguards.
235
+ *
236
+ * Flow
237
+ * - Seeds all monitored accounts with a single batched RPC fetch and emits
238
+ * their current state.
239
+ * - Subscribes to program notifications via WS using gill.
240
+ * - If `resubOpts.resubTimeoutMs` is set, starts an inactivity timer that
241
+ * batch-checks monitored accounts when WS goes quiet.
242
+ * - Begins monitoring for accounts that may need polling when WS
243
+ * notifications are not observed within `pollingIntervalMs`.
244
+ *
245
+ * @param onChange Callback invoked with decoded account data when an update
246
+ * is detected (via WS or batch RPC fetch).
247
+ */
248
+ const startTime = performance.now();
249
+ if (this.listenerId != null || this.isUnsubscribing) {
250
+ return;
251
+ }
252
+
253
+ if (this.resubOpts?.logResubMessages) {
254
+ console.log(
255
+ `[${this.subscriptionName}] initializing subscription. This many monitored accounts: ${this.accountsToMonitor.size}`
256
+ );
257
+ }
258
+
259
+ this.onChange = onChange;
260
+
261
+ // initial fetch of monitored data - only fetch and populate, don't check for missed changes
262
+ await this.fetchAndPopulateAllMonitoredAccounts();
263
+
264
+ // Create abort controller for proper cleanup
265
+ const abortController = new AbortController();
266
+ this.abortController = abortController;
267
+
268
+ this.listenerId = Math.random(); // Unique ID for logging purposes
269
+
270
+ if (this.resubOpts?.resubTimeoutMs) {
271
+ this.receivingData = true;
272
+ this.setTimeout();
273
+ }
274
+
275
+ // Subscribe to program account changes using gill's rpcSubscriptions
276
+ const programId = this.program.programId.toBase58();
277
+ if (isAddress(programId)) {
278
+ const subscriptionPromise = this.rpcSubscriptions
279
+ .programNotifications(programId, {
280
+ commitment: this.options.commitment as GillCommitment,
281
+ encoding: 'base64',
282
+ filters: this.options.filters.map((filter) => {
283
+ // Convert filter bytes from base58 to base64 if needed
284
+ let bytes = filter.memcmp.bytes;
285
+ if (
286
+ typeof bytes === 'string' &&
287
+ /^[1-9A-HJ-NP-Za-km-z]+$/.test(bytes)
288
+ ) {
289
+ // Looks like base58 - convert to base64
290
+ const decoded = bs58.decode(bytes);
291
+ bytes = Buffer.from(decoded).toString('base64');
292
+ }
293
+
294
+ return {
295
+ memcmp: {
296
+ offset: BigInt(filter.memcmp.offset),
297
+ bytes: bytes as any,
298
+ encoding: 'base64' as const,
299
+ },
300
+ };
301
+ }),
302
+ })
303
+ .subscribe({
304
+ abortSignal: abortController.signal,
305
+ });
306
+
307
+ // Start notification loop without awaiting
308
+ this.handleNotificationLoop(subscriptionPromise);
309
+ // Start monitoring for accounts that may need polling if no WS event is received
310
+ this.startMonitoringForAccounts();
311
+ }
312
+ const endTime = performance.now();
313
+ console.log(
314
+ `[PROFILING] ${this.subscriptionName}.subscribe() completed in ${
315
+ endTime - startTime
316
+ }ms`
317
+ );
318
+ }
319
+
320
+ protected setTimeout(): void {
321
+ if (!this.onChange) {
322
+ throw new Error('onChange callback function must be set');
323
+ }
324
+ this.timeoutId = setTimeout(
325
+ async () => {
326
+ if (this.isUnsubscribing) {
327
+ // If we are in the process of unsubscribing, do not attempt to resubscribe
328
+ return;
329
+ }
330
+
331
+ if (this.receivingData) {
332
+ if (this.resubOpts?.logResubMessages) {
333
+ console.log(
334
+ `No ws data from ${this.subscriptionName} in ${this.resubOpts?.resubTimeoutMs}ms, checking for missed changes`
335
+ );
336
+ }
337
+
338
+ // Check for missed changes in monitored accounts
339
+ const missedChangeDetected = await this.fetchAllMonitoredAccounts();
340
+
341
+ if (missedChangeDetected) {
342
+ // Signal missed change with a generic identifier since we don't have specific account IDs from this context
343
+ this.signalMissedChange('timeout-check');
344
+ } else {
345
+ // No missed changes, continue monitoring
346
+ this.receivingData = false;
347
+ this.setTimeout();
348
+ }
349
+ }
350
+ },
351
+ this.resubOpts?.resubTimeoutMs
352
+ );
353
+ }
354
+
355
+ handleRpcResponse(
356
+ context: { slot: bigint },
357
+ accountId: Address,
358
+ accountInfo?: AccountInfoBase &
359
+ (
360
+ | AccountInfoWithBase58EncodedData
361
+ | AccountInfoWithBase64EncodedData
362
+ )['data']
363
+ ): void {
364
+ const newSlot = Number(context.slot);
365
+ let newBuffer: Buffer | undefined = undefined;
366
+
367
+ if (accountInfo) {
368
+ // Handle different data formats from gill
369
+ if (Array.isArray(accountInfo)) {
370
+ // If it's a tuple [data, encoding]
371
+ const [data, encoding] = accountInfo;
372
+
373
+ if (encoding === ('base58' as any)) {
374
+ // Convert base58 to buffer using bs58
375
+ newBuffer = Buffer.from(bs58.decode(data));
376
+ } else {
377
+ newBuffer = Buffer.from(data, 'base64');
378
+ }
379
+ }
380
+ }
381
+
382
+ const accountIdString = accountId.toString();
383
+ const existingBufferAndSlot = this.bufferAndSlotMap.get(accountIdString);
384
+
385
+ // Track WebSocket notification time for this account
386
+ this.lastWsNotificationTime.set(accountIdString, Date.now());
387
+
388
+ // If this account was being polled, stop polling it if the buffer has changed
389
+ if (
390
+ this.accountsCurrentlyPolling.has(accountIdString) &&
391
+ !existingBufferAndSlot?.buffer.equals(newBuffer)
392
+ ) {
393
+ this.accountsCurrentlyPolling.delete(accountIdString);
394
+
395
+ // If no more accounts are being polled, stop batch polling
396
+ if (
397
+ this.accountsCurrentlyPolling.size === 0 &&
398
+ this.batchPollingTimeout
399
+ ) {
400
+ clearTimeout(this.batchPollingTimeout);
401
+ this.batchPollingTimeout = undefined;
402
+ }
403
+ }
404
+
405
+ if (!existingBufferAndSlot) {
406
+ if (newBuffer) {
407
+ this.updateBufferAndHandleChange(newBuffer, newSlot, accountIdString);
408
+ }
409
+ return;
410
+ }
411
+
412
+ if (newSlot < existingBufferAndSlot.slot) {
413
+ return;
414
+ }
415
+
416
+ const oldBuffer = existingBufferAndSlot.buffer;
417
+ if (newBuffer && (!oldBuffer || !newBuffer.equals(oldBuffer))) {
418
+ this.updateBufferAndHandleChange(newBuffer, newSlot, accountIdString);
419
+ }
420
+ }
421
+
422
+ private startMonitoringForAccounts(): void {
423
+ // Clear any existing polling timeouts
424
+ this.clearPollingTimeouts();
425
+
426
+ // Start monitoring for each account in the accountsToMonitor set
427
+ this.accountsToMonitor.forEach((accountIdString) => {
428
+ this.startMonitoringForAccount(accountIdString);
429
+ });
430
+ }
431
+
432
+ private startMonitoringForAccount(accountIdString: string): void {
433
+ // Clear existing timeout for this account
434
+ const existingTimeout = this.pollingTimeouts.get(accountIdString);
435
+ if (existingTimeout) {
436
+ clearTimeout(existingTimeout);
437
+ }
438
+
439
+ // Set up monitoring timeout - only start polling if no WS notification in 30s
440
+ const timeoutId = setTimeout(async () => {
441
+ // Check if we've received a WS notification for this account recently
442
+ const lastNotificationTime =
443
+ this.lastWsNotificationTime.get(accountIdString) || 0;
444
+ const currentTime = Date.now();
445
+
446
+ if (
447
+ !lastNotificationTime ||
448
+ currentTime - lastNotificationTime >= this.pollingIntervalMs
449
+ ) {
450
+ if (this.resubOpts?.logResubMessages) {
451
+ console.debug(
452
+ `[${this.subscriptionName}] No recent WS notification for ${accountIdString}, adding to polling set`
453
+ );
454
+ }
455
+ // No recent WS notification: add to polling and schedule debounced poll
456
+ this.accountsCurrentlyPolling.add(accountIdString);
457
+ this.scheduleDebouncedImmediatePoll();
458
+ } else {
459
+ // We received a WS notification recently, continue monitoring
460
+ this.startMonitoringForAccount(accountIdString);
461
+ }
462
+ }, this.pollingIntervalMs);
463
+
464
+ this.pollingTimeouts.set(accountIdString, timeoutId);
465
+ }
466
+
467
+ private scheduleDebouncedImmediatePoll(): void {
468
+ if (this.debouncedImmediatePollTimeout) {
469
+ clearTimeout(this.debouncedImmediatePollTimeout);
470
+ }
471
+ this.debouncedImmediatePollTimeout = setTimeout(async () => {
472
+ try {
473
+ await this.pollAllAccounts();
474
+ // After the immediate poll, ensure continuous batch polling is active
475
+ if (
476
+ !this.batchPollingTimeout &&
477
+ this.accountsCurrentlyPolling.size > 0
478
+ ) {
479
+ this.startBatchPolling();
480
+ }
481
+ } catch (e) {
482
+ if (this.resubOpts?.logResubMessages) {
483
+ console.log(
484
+ `[${this.subscriptionName}] Error during debounced immediate poll:`,
485
+ e
486
+ );
487
+ }
488
+ }
489
+ }, this.debouncedImmediatePollMs);
490
+ }
491
+
492
+ private startBatchPolling(): void {
493
+ if (this.resubOpts?.logResubMessages) {
494
+ console.debug(`[${this.subscriptionName}] Scheduling batch polling`);
495
+ }
496
+ // Clear existing batch polling timeout
497
+ if (this.batchPollingTimeout) {
498
+ clearTimeout(this.batchPollingTimeout);
499
+ }
500
+
501
+ // Set up batch polling interval
502
+ this.batchPollingTimeout = setTimeout(async () => {
503
+ await this.pollAllAccounts();
504
+ // Schedule next batch poll
505
+ this.startBatchPolling();
506
+ }, this.pollingIntervalMs);
507
+ }
508
+
509
+ private async pollAllAccounts(): Promise<void> {
510
+ try {
511
+ // Get all accounts currently being polled
512
+ const accountsToPoll = Array.from(this.accountsCurrentlyPolling);
513
+ if (accountsToPoll.length === 0) {
514
+ return;
515
+ }
516
+
517
+ if (this.resubOpts?.logResubMessages) {
518
+ console.debug(
519
+ `[${this.subscriptionName}] Polling all accounts`,
520
+ accountsToPoll.length,
521
+ 'accounts'
522
+ );
523
+ }
524
+
525
+ // Use the shared batch fetch method
526
+ await this.fetchAccountsBatch(accountsToPoll);
527
+ } catch (error) {
528
+ if (this.resubOpts?.logResubMessages) {
529
+ console.log(
530
+ `[${this.subscriptionName}] Error batch polling accounts:`,
531
+ error
532
+ );
533
+ }
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Fetches and populates all monitored accounts data without checking for missed changes
539
+ * This is used during initial subscription to populate data
540
+ */
541
+ private async fetchAndPopulateAllMonitoredAccounts(): Promise<void> {
542
+ try {
543
+ // Get all accounts currently being polled
544
+ const accountsToMonitor = Array.from(this.accountsToMonitor);
545
+ if (accountsToMonitor.length === 0) {
546
+ return;
547
+ }
548
+
549
+ // Fetch all accounts in a single batch request
550
+ const accountAddresses = accountsToMonitor.map(
551
+ (accountId) => accountId as Address
552
+ );
553
+ const rpcResponse = await this.rpc
554
+ .getMultipleAccounts(accountAddresses, {
555
+ commitment: this.options.commitment as GillCommitment,
556
+ encoding: 'base64',
557
+ })
558
+ .send();
559
+
560
+ const currentSlot = Number(rpcResponse.context.slot);
561
+
562
+ // Process each account response
563
+ for (let i = 0; i < accountsToMonitor.length; i++) {
564
+ const accountIdString = accountsToMonitor[i];
565
+ const accountInfo = rpcResponse.value[i];
566
+
567
+ if (!accountInfo) {
568
+ continue;
569
+ }
570
+
571
+ const existingBufferAndSlot =
572
+ this.bufferAndSlotMap.get(accountIdString);
573
+
574
+ if (!existingBufferAndSlot) {
575
+ // Account not in our map yet, add it
576
+ let newBuffer: Buffer | undefined = undefined;
577
+ if (accountInfo) {
578
+ if (Array.isArray(accountInfo.data)) {
579
+ const [data, encoding] = accountInfo.data;
580
+ newBuffer = Buffer.from(data, encoding);
581
+ }
582
+ }
583
+
584
+ if (newBuffer) {
585
+ this.updateBufferAndHandleChange(
586
+ newBuffer,
587
+ currentSlot,
588
+ accountIdString
589
+ );
590
+ }
591
+ continue;
592
+ }
593
+
594
+ // For initial population, just update the slot if we have newer data
595
+ if (currentSlot > existingBufferAndSlot.slot) {
596
+ let newBuffer: Buffer | undefined = undefined;
597
+ if (accountInfo.data) {
598
+ if (Array.isArray(accountInfo.data)) {
599
+ const [data, encoding] = accountInfo.data;
600
+ if (encoding === ('base58' as any)) {
601
+ newBuffer = Buffer.from(bs58.decode(data));
602
+ } else {
603
+ newBuffer = Buffer.from(data, 'base64');
604
+ }
605
+ }
606
+ }
607
+
608
+ // Update with newer data if available
609
+ if (newBuffer) {
610
+ this.updateBufferAndHandleChange(
611
+ newBuffer,
612
+ currentSlot,
613
+ accountIdString
614
+ );
615
+ }
616
+ }
617
+ }
618
+ } catch (error) {
619
+ if (this.resubOpts?.logResubMessages) {
620
+ console.log(
621
+ `[${this.subscriptionName}] Error fetching and populating monitored accounts:`,
622
+ error
623
+ );
624
+ }
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Fetches all monitored accounts and checks for missed changes
630
+ * Returns true if a missed change was detected and resubscription is needed
631
+ */
632
+ private async fetchAllMonitoredAccounts(): Promise<boolean> {
633
+ try {
634
+ // Get all accounts currently being polled
635
+ const accountsToMonitor = Array.from(this.accountsToMonitor);
636
+ if (accountsToMonitor.length === 0) {
637
+ return false;
638
+ }
639
+
640
+ // Fetch all accounts in a single batch request
641
+ const accountAddresses = accountsToMonitor.map(
642
+ (accountId) => accountId as Address
643
+ );
644
+ const rpcResponse = await this.rpc
645
+ .getMultipleAccounts(accountAddresses, {
646
+ commitment: this.options.commitment as GillCommitment,
647
+ encoding: 'base64',
648
+ })
649
+ .send();
650
+
651
+ const currentSlot = Number(rpcResponse.context.slot);
652
+
653
+ // Process each account response
654
+ for (let i = 0; i < accountsToMonitor.length; i++) {
655
+ const accountIdString = accountsToMonitor[i];
656
+ const accountInfo = rpcResponse.value[i];
657
+
658
+ if (!accountInfo) {
659
+ continue;
660
+ }
661
+
662
+ const existingBufferAndSlot =
663
+ this.bufferAndSlotMap.get(accountIdString);
664
+
665
+ if (!existingBufferAndSlot) {
666
+ // Account not in our map yet, add it
667
+ let newBuffer: Buffer | undefined = undefined;
668
+ if (accountInfo.data) {
669
+ if (Array.isArray(accountInfo.data)) {
670
+ const [data, encoding] = accountInfo.data;
671
+ newBuffer = Buffer.from(data, encoding);
672
+ }
673
+ }
674
+
675
+ if (newBuffer) {
676
+ this.updateBufferAndHandleChange(
677
+ newBuffer,
678
+ currentSlot,
679
+ accountIdString
680
+ );
681
+ }
682
+ continue;
683
+ }
684
+
685
+ // Check if we missed an update
686
+ if (currentSlot > existingBufferAndSlot.slot) {
687
+ let newBuffer: Buffer | undefined = undefined;
688
+ if (accountInfo.data) {
689
+ if (Array.isArray(accountInfo.data)) {
690
+ const [data, encoding] = accountInfo.data;
691
+ if (encoding === ('base58' as any)) {
692
+ newBuffer = Buffer.from(bs58.decode(data));
693
+ } else {
694
+ newBuffer = Buffer.from(data, 'base64');
695
+ }
696
+ }
697
+ }
698
+
699
+ // Check if buffer has changed
700
+ if (
701
+ newBuffer &&
702
+ (!existingBufferAndSlot.buffer ||
703
+ !newBuffer.equals(existingBufferAndSlot.buffer))
704
+ ) {
705
+ if (this.resubOpts?.logResubMessages) {
706
+ console.log(
707
+ `[${this.subscriptionName}] Batch polling detected missed update for account ${accountIdString}, resubscribing`
708
+ );
709
+ }
710
+ // We missed an update, return true to indicate resubscription is needed
711
+ return true;
712
+ }
713
+ }
714
+ }
715
+
716
+ // No missed changes detected
717
+ return false;
718
+ } catch (error) {
719
+ if (this.resubOpts?.logResubMessages) {
720
+ console.log(
721
+ `[${this.subscriptionName}] Error batch polling accounts:`,
722
+ error
723
+ );
724
+ }
725
+ return false;
726
+ }
727
+ }
728
+
729
+ private async fetchAccountsBatch(accountIds: string[]): Promise<void> {
730
+ try {
731
+ // Chunk account IDs into groups of 100 (getMultipleAccounts limit)
732
+ const chunkSize = 100;
733
+ const chunks: string[][] = [];
734
+ for (let i = 0; i < accountIds.length; i += chunkSize) {
735
+ chunks.push(accountIds.slice(i, i + chunkSize));
736
+ }
737
+
738
+ // Process all chunks concurrently
739
+ await Promise.all(
740
+ chunks.map(async (chunk) => {
741
+ const accountAddresses = chunk.map(
742
+ (accountId) => accountId as Address
743
+ );
744
+ const rpcResponse = await this.rpc
745
+ .getMultipleAccounts(accountAddresses, {
746
+ commitment: this.options.commitment as GillCommitment,
747
+ encoding: 'base64',
748
+ })
749
+ .send();
750
+
751
+ const currentSlot = Number(rpcResponse.context.slot);
752
+
753
+ // Process each account response in this chunk
754
+ for (let i = 0; i < chunk.length; i++) {
755
+ const accountIdString = chunk[i];
756
+ const accountInfo = rpcResponse.value[i];
757
+
758
+ if (!accountInfo) {
759
+ continue;
760
+ }
761
+
762
+ const existingBufferAndSlot =
763
+ this.bufferAndSlotMap.get(accountIdString);
764
+
765
+ if (!existingBufferAndSlot) {
766
+ // Account not in our map yet, add it
767
+ let newBuffer: Buffer | undefined = undefined;
768
+ if (accountInfo.data) {
769
+ if (Array.isArray(accountInfo.data)) {
770
+ const [data, encoding] = accountInfo.data;
771
+ newBuffer = Buffer.from(data, encoding);
772
+ }
773
+ }
774
+
775
+ if (newBuffer) {
776
+ this.updateBufferAndHandleChange(
777
+ newBuffer,
778
+ currentSlot,
779
+ accountIdString
780
+ );
781
+ }
782
+ continue;
783
+ }
784
+
785
+ // Check if we missed an update
786
+ if (currentSlot > existingBufferAndSlot.slot) {
787
+ let newBuffer: Buffer | undefined = undefined;
788
+ if (accountInfo.data) {
789
+ if (Array.isArray(accountInfo.data)) {
790
+ const [data, encoding] = accountInfo.data;
791
+ if (encoding === ('base58' as any)) {
792
+ newBuffer = Buffer.from(bs58.decode(data));
793
+ } else {
794
+ newBuffer = Buffer.from(data, 'base64');
795
+ }
796
+ }
797
+ }
798
+
799
+ // Check if buffer has changed
800
+ if (
801
+ newBuffer &&
802
+ (!existingBufferAndSlot.buffer ||
803
+ !newBuffer.equals(existingBufferAndSlot.buffer))
804
+ ) {
805
+ if (this.resubOpts?.logResubMessages) {
806
+ console.log(
807
+ `[${this.subscriptionName}] Batch polling detected missed update for account ${accountIdString}, signaling resubscription`
808
+ );
809
+ }
810
+ // Signal missed change instead of immediately resubscribing
811
+ this.signalMissedChange(accountIdString);
812
+ return;
813
+ }
814
+ }
815
+ }
816
+ })
817
+ );
818
+ } catch (error) {
819
+ if (this.resubOpts?.logResubMessages) {
820
+ console.log(
821
+ `[${this.subscriptionName}] Error fetching accounts batch:`,
822
+ error
823
+ );
824
+ }
825
+ }
826
+ }
827
+
828
+ private clearPollingTimeouts(): void {
829
+ this.pollingTimeouts.forEach((timeoutId) => {
830
+ clearTimeout(timeoutId);
831
+ });
832
+ this.pollingTimeouts.clear();
833
+
834
+ // Clear batch polling timeout
835
+ if (this.batchPollingTimeout) {
836
+ clearTimeout(this.batchPollingTimeout);
837
+ this.batchPollingTimeout = undefined;
838
+ }
839
+
840
+ // Clear initial fetch timeout
841
+ // if (this.initialFetchTimeout) {
842
+ // clearTimeout(this.initialFetchTimeout);
843
+ // this.initialFetchTimeout = undefined;
844
+ // }
845
+
846
+ // Clear resubscription timeout
847
+ if (this.resubscriptionTimeout) {
848
+ clearTimeout(this.resubscriptionTimeout);
849
+ this.resubscriptionTimeout = undefined;
850
+ }
851
+
852
+ // Clear accounts currently polling
853
+ this.accountsCurrentlyPolling.clear();
854
+
855
+ // Clear accounts pending initial monitor fetch
856
+ // this.accountsPendingInitialMonitorFetch.clear();
857
+
858
+ // Reset missed change flag and clear accounts with missed updates
859
+ this.missedChangeDetected = false;
860
+ this.accountsWithMissedUpdates.clear();
861
+ }
862
+
863
+ /**
864
+ * Centralized resubscription handler that only resubscribes once after checking all accounts
865
+ */
866
+ private async handleResubscription(): Promise<void> {
867
+ if (this.missedChangeDetected) {
868
+ if (this.resubOpts?.logResubMessages) {
869
+ console.log(
870
+ `[${this.subscriptionName}] Missed change detected for ${
871
+ this.accountsWithMissedUpdates.size
872
+ } accounts: ${Array.from(this.accountsWithMissedUpdates).join(
873
+ ', '
874
+ )}, resubscribing`
875
+ );
876
+ }
877
+ await this.unsubscribe(true);
878
+ this.receivingData = false;
879
+ await this.subscribe(this.onChange);
880
+ this.missedChangeDetected = false;
881
+ this.accountsWithMissedUpdates.clear();
882
+ }
883
+ }
884
+
885
+ /**
886
+ * Signal that a missed change was detected and schedule resubscription
887
+ */
888
+ private signalMissedChange(accountIdString: string): void {
889
+ if (!this.missedChangeDetected) {
890
+ this.missedChangeDetected = true;
891
+ this.accountsWithMissedUpdates.add(accountIdString);
892
+
893
+ // Clear any existing resubscription timeout
894
+ if (this.resubscriptionTimeout) {
895
+ clearTimeout(this.resubscriptionTimeout);
896
+ }
897
+
898
+ // Schedule resubscription after a short delay to allow for batch processing
899
+ this.resubscriptionTimeout = setTimeout(async () => {
900
+ await this.handleResubscription();
901
+ }, 100); // 100ms delay to allow for batch processing
902
+ } else {
903
+ // If already detected, just add the account to the set
904
+ this.accountsWithMissedUpdates.add(accountIdString);
905
+ }
906
+ }
907
+
908
+ unsubscribe(onResub = false): Promise<void> {
909
+ if (!onResub) {
910
+ this.resubOpts.resubTimeoutMs = undefined;
911
+ }
912
+ this.isUnsubscribing = true;
913
+ clearTimeout(this.timeoutId);
914
+ this.timeoutId = undefined;
915
+
916
+ // Clear polling timeouts
917
+ this.clearPollingTimeouts();
918
+
919
+ // Abort the WebSocket subscription
920
+ if (this.abortController) {
921
+ this.abortController.abort('unsubscribing');
922
+ this.abortController = undefined;
923
+ }
924
+
925
+ this.listenerId = undefined;
926
+ this.isUnsubscribing = false;
927
+
928
+ return Promise.resolve();
929
+ }
930
+
931
+ // Method to add accounts to the polling list
932
+ /**
933
+ * Add an account to the monitored set.
934
+ * - Monitored accounts are subject to initial fetch and periodic batch polls
935
+ * if WS notifications are not observed within `pollingIntervalMs`.
936
+ */
937
+ addAccountToMonitor(accountId: PublicKey): void {
938
+ const accountIdString = accountId.toBase58();
939
+ this.accountsToMonitor.add(accountIdString);
940
+
941
+ // If already subscribed, start monitoring for this account
942
+ if (this.listenerId != null && !this.isUnsubscribing) {
943
+ this.startMonitoringForAccount(accountIdString);
944
+ }
945
+ }
946
+
947
+ // Method to remove accounts from the polling list
948
+ removeAccountFromMonitor(accountId: PublicKey): void {
949
+ const accountIdString = accountId.toBase58();
950
+ this.accountsToMonitor.delete(accountIdString);
951
+
952
+ // Clear monitoring timeout for this account
953
+ const timeoutId = this.pollingTimeouts.get(accountIdString);
954
+ if (timeoutId) {
955
+ clearTimeout(timeoutId);
956
+ this.pollingTimeouts.delete(accountIdString);
957
+ }
958
+
959
+ // Remove from currently polling set if it was being polled
960
+ this.accountsCurrentlyPolling.delete(accountIdString);
961
+
962
+ // If no more accounts are being polled, stop batch polling
963
+ if (this.accountsCurrentlyPolling.size === 0 && this.batchPollingTimeout) {
964
+ clearTimeout(this.batchPollingTimeout);
965
+ this.batchPollingTimeout = undefined;
966
+ }
967
+ }
968
+
969
+ // Method to set polling interval
970
+ /**
971
+ * Set the monitoring/polling interval for monitored accounts.
972
+ * Shorter intervals detect missed updates sooner but increase RPC load.
973
+ */
974
+ setPollingInterval(intervalMs: number): void {
975
+ this.pollingIntervalMs = intervalMs;
976
+ // Restart monitoring with new interval if already subscribed
977
+ if (this.listenerId != null && !this.isUnsubscribing) {
978
+ this.startMonitoringForAccounts();
979
+ }
980
+ }
981
+
982
+ private updateBufferAndHandleChange(
983
+ newBuffer: Buffer,
984
+ newSlot: number,
985
+ accountIdString: string
986
+ ) {
987
+ this.bufferAndSlotMap.set(accountIdString, {
988
+ buffer: newBuffer,
989
+ slot: newSlot,
990
+ });
991
+ const account = this.decodeBuffer(this.accountDiscriminator, newBuffer);
992
+ const accountIdPubkey = new PublicKey(accountIdString);
993
+ this.onChange(accountIdPubkey, account, { slot: newSlot }, newBuffer);
994
+ }
995
+ }