@drift-labs/sdk 2.145.0 → 2.146.0-alpha.13

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 (99) hide show
  1. package/.env +4 -0
  2. package/VERSION +1 -1
  3. package/lib/browser/accounts/grpcMultiUserAccountSubscriber.js +8 -1
  4. package/lib/browser/accounts/webSocketProgramAccountSubscriberV2.d.ts +99 -7
  5. package/lib/browser/accounts/webSocketProgramAccountSubscriberV2.js +435 -144
  6. package/lib/browser/adminClient.d.ts +5 -1
  7. package/lib/browser/adminClient.js +57 -23
  8. package/lib/browser/constants/numericConstants.d.ts +2 -0
  9. package/lib/browser/constants/numericConstants.js +5 -1
  10. package/lib/browser/constants/perpMarkets.js +0 -2
  11. package/lib/browser/decode/user.js +4 -0
  12. package/lib/browser/driftClient.d.ts +25 -10
  13. package/lib/browser/driftClient.js +238 -41
  14. package/lib/browser/driftClientConfig.d.ts +7 -2
  15. package/lib/browser/idl/drift.json +245 -22
  16. package/lib/browser/index.d.ts +4 -0
  17. package/lib/browser/index.js +9 -1
  18. package/lib/browser/marginCalculation.d.ts +86 -0
  19. package/lib/browser/marginCalculation.js +209 -0
  20. package/lib/browser/math/margin.d.ts +1 -1
  21. package/lib/browser/math/margin.js +8 -1
  22. package/lib/browser/math/position.d.ts +1 -0
  23. package/lib/browser/math/position.js +10 -2
  24. package/lib/browser/math/spotPosition.d.ts +1 -1
  25. package/lib/browser/math/spotPosition.js +3 -2
  26. package/lib/browser/math/superStake.d.ts +3 -2
  27. package/lib/browser/types.d.ts +13 -0
  28. package/lib/browser/types.js +12 -1
  29. package/lib/browser/user.d.ts +59 -11
  30. package/lib/browser/user.js +348 -43
  31. package/lib/node/accounts/grpcMultiUserAccountSubscriber.d.ts.map +1 -1
  32. package/lib/node/accounts/grpcMultiUserAccountSubscriber.js +8 -1
  33. package/lib/node/accounts/webSocketProgramAccountSubscriberV2.d.ts +99 -7
  34. package/lib/node/accounts/webSocketProgramAccountSubscriberV2.d.ts.map +1 -1
  35. package/lib/node/accounts/webSocketProgramAccountSubscriberV2.js +435 -144
  36. package/lib/node/adminClient.d.ts +5 -1
  37. package/lib/node/adminClient.d.ts.map +1 -1
  38. package/lib/node/adminClient.js +57 -23
  39. package/lib/node/constants/numericConstants.d.ts +2 -0
  40. package/lib/node/constants/numericConstants.d.ts.map +1 -1
  41. package/lib/node/constants/numericConstants.js +5 -1
  42. package/lib/node/constants/perpMarkets.d.ts.map +1 -1
  43. package/lib/node/constants/perpMarkets.js +0 -2
  44. package/lib/node/decode/user.d.ts.map +1 -1
  45. package/lib/node/decode/user.js +4 -0
  46. package/lib/node/driftClient.d.ts +25 -10
  47. package/lib/node/driftClient.d.ts.map +1 -1
  48. package/lib/node/driftClient.js +238 -41
  49. package/lib/node/driftClientConfig.d.ts +7 -2
  50. package/lib/node/driftClientConfig.d.ts.map +1 -1
  51. package/lib/node/idl/drift.json +245 -22
  52. package/lib/node/index.d.ts +4 -0
  53. package/lib/node/index.d.ts.map +1 -1
  54. package/lib/node/index.js +9 -1
  55. package/lib/node/marginCalculation.d.ts +87 -0
  56. package/lib/node/marginCalculation.d.ts.map +1 -0
  57. package/lib/node/marginCalculation.js +209 -0
  58. package/lib/node/math/margin.d.ts +1 -1
  59. package/lib/node/math/margin.d.ts.map +1 -1
  60. package/lib/node/math/margin.js +8 -1
  61. package/lib/node/math/position.d.ts +1 -0
  62. package/lib/node/math/position.d.ts.map +1 -1
  63. package/lib/node/math/position.js +10 -2
  64. package/lib/node/math/spotPosition.d.ts +1 -1
  65. package/lib/node/math/spotPosition.d.ts.map +1 -1
  66. package/lib/node/math/spotPosition.js +3 -2
  67. package/lib/node/math/superStake.d.ts +3 -2
  68. package/lib/node/math/superStake.d.ts.map +1 -1
  69. package/lib/node/types.d.ts +13 -0
  70. package/lib/node/types.d.ts.map +1 -1
  71. package/lib/node/types.js +12 -1
  72. package/lib/node/user.d.ts +59 -11
  73. package/lib/node/user.d.ts.map +1 -1
  74. package/lib/node/user.js +348 -43
  75. package/package.json +1 -1
  76. package/scripts/deposit-isolated-positions.ts +110 -0
  77. package/scripts/single-grpc-client-test.ts +71 -21
  78. package/scripts/withdraw-isolated-positions.ts +174 -0
  79. package/src/accounts/grpcMultiUserAccountSubscriber.ts +8 -1
  80. package/src/accounts/webSocketProgramAccountSubscriberV2.ts +566 -167
  81. package/src/adminClient.ts +74 -25
  82. package/src/constants/numericConstants.ts +5 -0
  83. package/src/constants/perpMarkets.ts +0 -3
  84. package/src/decode/user.ts +7 -1
  85. package/src/driftClient.ts +465 -52
  86. package/src/driftClientConfig.ts +15 -8
  87. package/src/idl/drift.json +246 -23
  88. package/src/index.ts +4 -0
  89. package/src/margin/README.md +143 -0
  90. package/src/marginCalculation.ts +306 -0
  91. package/src/math/margin.ts +13 -1
  92. package/src/math/position.ts +12 -2
  93. package/src/math/spotPosition.ts +6 -2
  94. package/src/types.ts +16 -0
  95. package/src/user.ts +623 -81
  96. package/tests/amm/test.ts +1 -1
  97. package/tests/dlob/helpers.ts +6 -3
  98. package/tests/user/getMarginCalculation.ts +405 -0
  99. package/tests/user/test.ts +0 -7
@@ -7,17 +7,96 @@ import {
7
7
  AccountInfoWithBase64EncodedData,
8
8
  createSolanaClient,
9
9
  isAddress,
10
- type Address,
11
- type Commitment as GillCommitment,
10
+ Lamports,
11
+ Slot,
12
+ Address,
13
+ Commitment as GillCommitment,
12
14
  } from 'gill';
13
15
  import bs58 from 'bs58';
14
16
 
15
- export class WebSocketProgramAccountSubscriberV2<T>
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>
16
96
  implements ProgramAccountSubscriber<T>
17
97
  {
18
98
  subscriptionName: string;
19
99
  accountDiscriminator: string;
20
- bufferAndSlot?: BufferAndSlot;
21
100
  bufferAndSlotMap: Map<string, BufferAndSlot> = new Map();
22
101
  program: Program;
23
102
  decodeBuffer: (accountName: string, ix: Buffer) => T;
@@ -28,7 +107,7 @@ export class WebSocketProgramAccountSubscriberV2<T>
28
107
  buffer: Buffer
29
108
  ) => void;
30
109
  listenerId?: number;
31
- resubOpts?: ResubOpts;
110
+ resubOpts: ResubOpts;
32
111
  isUnsubscribing = false;
33
112
  timeoutId?: ReturnType<typeof setTimeout>;
34
113
  options: { filters: MemcmpFilter[]; commitment?: Commitment };
@@ -51,6 +130,15 @@ export class WebSocketProgramAccountSubscriberV2<T>
51
130
  private accountsCurrentlyPolling: Set<string> = new Set(); // Track which accounts are being polled
52
131
  private batchPollingTimeout?: ReturnType<typeof setTimeout>; // Single timeout for batch polling
53
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
+
54
142
  public constructor(
55
143
  subscriptionName: string,
56
144
  accountDiscriminator: string,
@@ -66,7 +154,11 @@ export class WebSocketProgramAccountSubscriberV2<T>
66
154
  this.accountDiscriminator = accountDiscriminator;
67
155
  this.program = program;
68
156
  this.decodeBuffer = decodeBufferFn;
69
- this.resubOpts = resubOpts;
157
+ this.resubOpts = resubOpts ?? {
158
+ resubTimeoutMs: 30000,
159
+ usePollingInsteadOfResub: true,
160
+ logResubMessages: false,
161
+ };
70
162
  if (this.resubOpts?.resubTimeoutMs < 1000) {
71
163
  console.log(
72
164
  'resubTimeoutMs should be at least 1000ms to avoid spamming resub'
@@ -92,6 +184,44 @@ export class WebSocketProgramAccountSubscriberV2<T>
92
184
  this.rpcSubscriptions = rpcSubscriptions;
93
185
  }
94
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
+
95
225
  async subscribe(
96
226
  onChange: (
97
227
  accountId: PublicKey,
@@ -100,62 +230,91 @@ export class WebSocketProgramAccountSubscriberV2<T>
100
230
  buffer: Buffer
101
231
  ) => void
102
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();
103
249
  if (this.listenerId != null || this.isUnsubscribing) {
104
250
  return;
105
251
  }
106
252
 
253
+ if (this.resubOpts?.logResubMessages) {
254
+ console.log(
255
+ `[${this.subscriptionName}] initializing subscription. This many monitored accounts: ${this.accountsToMonitor.size}`
256
+ );
257
+ }
258
+
107
259
  this.onChange = onChange;
108
260
 
261
+ // initial fetch of monitored data - only fetch and populate, don't check for missed changes
262
+ await this.fetchAndPopulateAllMonitoredAccounts();
263
+
109
264
  // Create abort controller for proper cleanup
110
265
  const abortController = new AbortController();
111
266
  this.abortController = abortController;
112
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
+
113
275
  // Subscribe to program account changes using gill's rpcSubscriptions
114
276
  const programId = this.program.programId.toBase58();
115
277
  if (isAddress(programId)) {
116
- const subscription = await this.rpcSubscriptions
278
+ const subscriptionPromise = this.rpcSubscriptions
117
279
  .programNotifications(programId, {
118
280
  commitment: this.options.commitment as GillCommitment,
119
281
  encoding: 'base64',
120
- filters: this.options.filters.map((filter) => ({
121
- memcmp: {
122
- offset: BigInt(filter.memcmp.offset),
123
- bytes: filter.memcmp.bytes as any,
124
- encoding: 'base64' as const,
125
- },
126
- })),
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
+ }),
127
302
  })
128
303
  .subscribe({
129
304
  abortSignal: abortController.signal,
130
305
  });
131
306
 
132
- for await (const notification of subscription) {
133
- if (this.resubOpts?.resubTimeoutMs) {
134
- this.receivingData = true;
135
- clearTimeout(this.timeoutId);
136
- this.handleRpcResponse(
137
- notification.context,
138
- notification.value.account
139
- );
140
- this.setTimeout();
141
- } else {
142
- this.handleRpcResponse(
143
- notification.context,
144
- notification.value.account
145
- );
146
- }
147
- }
148
- }
149
-
150
- this.listenerId = Math.random(); // Unique ID for logging purposes
151
-
152
- if (this.resubOpts?.resubTimeoutMs) {
153
- this.receivingData = true;
154
- this.setTimeout();
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();
155
311
  }
156
-
157
- // Start monitoring for accounts that may need polling if no WS event is received
158
- this.startMonitoringForAccounts();
312
+ const endTime = performance.now();
313
+ console.log(
314
+ `[PROFILING] ${this.subscriptionName}.subscribe() completed in ${
315
+ endTime - startTime
316
+ }ms`
317
+ );
159
318
  }
160
319
 
161
320
  protected setTimeout(): void {
@@ -172,12 +331,21 @@ export class WebSocketProgramAccountSubscriberV2<T>
172
331
  if (this.receivingData) {
173
332
  if (this.resubOpts?.logResubMessages) {
174
333
  console.log(
175
- `No ws data from ${this.subscriptionName} in ${this.resubOpts?.resubTimeoutMs}ms, resubscribing`
334
+ `No ws data from ${this.subscriptionName} in ${this.resubOpts?.resubTimeoutMs}ms, checking for missed changes`
176
335
  );
177
336
  }
178
- await this.unsubscribe(true);
179
- this.receivingData = false;
180
- await this.subscribe(this.onChange);
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
+ }
181
349
  }
182
350
  },
183
351
  this.resubOpts?.resubTimeoutMs
@@ -186,43 +354,42 @@ export class WebSocketProgramAccountSubscriberV2<T>
186
354
 
187
355
  handleRpcResponse(
188
356
  context: { slot: bigint },
357
+ accountId: Address,
189
358
  accountInfo?: AccountInfoBase &
190
- (AccountInfoWithBase58EncodedData | AccountInfoWithBase64EncodedData)
359
+ (
360
+ | AccountInfoWithBase58EncodedData
361
+ | AccountInfoWithBase64EncodedData
362
+ )['data']
191
363
  ): void {
192
364
  const newSlot = Number(context.slot);
193
365
  let newBuffer: Buffer | undefined = undefined;
194
366
 
195
367
  if (accountInfo) {
196
- // Extract data from gill response
197
- if (accountInfo.data) {
198
- // Handle different data formats from gill
199
- if (Array.isArray(accountInfo.data)) {
200
- // If it's a tuple [data, encoding]
201
- const [data, encoding] = accountInfo.data;
202
-
203
- if (encoding === ('base58' as any)) {
204
- // Convert base58 to buffer using bs58
205
- newBuffer = Buffer.from(bs58.decode(data));
206
- } else {
207
- newBuffer = Buffer.from(data, 'base64');
208
- }
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');
209
378
  }
210
379
  }
211
380
  }
212
381
 
213
- // Convert gill's account key to PublicKey
214
- // Note: accountInfo doesn't have a key property, we need to get it from the notification
215
- // For now, we'll use a placeholder - this needs to be fixed based on the actual gill API
216
- const accountId = new PublicKey('11111111111111111111111111111111'); // Placeholder
217
- const accountIdString = accountId.toBase58();
218
-
382
+ const accountIdString = accountId.toString();
219
383
  const existingBufferAndSlot = this.bufferAndSlotMap.get(accountIdString);
220
384
 
221
385
  // Track WebSocket notification time for this account
222
386
  this.lastWsNotificationTime.set(accountIdString, Date.now());
223
387
 
224
- // If this account was being polled, stop polling it
225
- if (this.accountsCurrentlyPolling.has(accountIdString)) {
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
+ ) {
226
393
  this.accountsCurrentlyPolling.delete(accountIdString);
227
394
 
228
395
  // If no more accounts are being polled, stop batch polling
@@ -237,12 +404,7 @@ export class WebSocketProgramAccountSubscriberV2<T>
237
404
 
238
405
  if (!existingBufferAndSlot) {
239
406
  if (newBuffer) {
240
- this.bufferAndSlotMap.set(accountIdString, {
241
- buffer: newBuffer,
242
- slot: newSlot,
243
- });
244
- const account = this.decodeBuffer(this.accountDiscriminator, newBuffer);
245
- this.onChange(accountId, account, { slot: newSlot }, newBuffer);
407
+ this.updateBufferAndHandleChange(newBuffer, newSlot, accountIdString);
246
408
  }
247
409
  return;
248
410
  }
@@ -253,12 +415,7 @@ export class WebSocketProgramAccountSubscriberV2<T>
253
415
 
254
416
  const oldBuffer = existingBufferAndSlot.buffer;
255
417
  if (newBuffer && (!oldBuffer || !newBuffer.equals(oldBuffer))) {
256
- this.bufferAndSlotMap.set(accountIdString, {
257
- buffer: newBuffer,
258
- slot: newSlot,
259
- });
260
- const account = this.decodeBuffer(this.accountDiscriminator, newBuffer);
261
- this.onChange(accountId, account, { slot: newSlot }, newBuffer);
418
+ this.updateBufferAndHandleChange(newBuffer, newSlot, accountIdString);
262
419
  }
263
420
  }
264
421
 
@@ -283,17 +440,21 @@ export class WebSocketProgramAccountSubscriberV2<T>
283
440
  const timeoutId = setTimeout(async () => {
284
441
  // Check if we've received a WS notification for this account recently
285
442
  const lastNotificationTime =
286
- this.lastWsNotificationTime.get(accountIdString);
443
+ this.lastWsNotificationTime.get(accountIdString) || 0;
287
444
  const currentTime = Date.now();
288
445
 
289
446
  if (
290
447
  !lastNotificationTime ||
291
448
  currentTime - lastNotificationTime >= this.pollingIntervalMs
292
449
  ) {
293
- // No recent WS notification, start polling
294
- await this.pollAccount(accountIdString);
295
- // Schedule next poll
296
- this.startPollingForAccount(accountIdString);
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();
297
458
  } else {
298
459
  // We received a WS notification recently, continue monitoring
299
460
  this.startMonitoringForAccount(accountIdString);
@@ -303,17 +464,35 @@ export class WebSocketProgramAccountSubscriberV2<T>
303
464
  this.pollingTimeouts.set(accountIdString, timeoutId);
304
465
  }
305
466
 
306
- private startPollingForAccount(accountIdString: string): void {
307
- // Add account to polling set
308
- this.accountsCurrentlyPolling.add(accountIdString);
309
-
310
- // If this is the first account being polled, start batch polling
311
- if (this.accountsCurrentlyPolling.size === 1) {
312
- this.startBatchPolling();
467
+ private scheduleDebouncedImmediatePoll(): void {
468
+ if (this.debouncedImmediatePollTimeout) {
469
+ clearTimeout(this.debouncedImmediatePollTimeout);
313
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);
314
490
  }
315
491
 
316
492
  private startBatchPolling(): void {
493
+ if (this.resubOpts?.logResubMessages) {
494
+ console.debug(`[${this.subscriptionName}] Scheduling batch polling`);
495
+ }
317
496
  // Clear existing batch polling timeout
318
497
  if (this.batchPollingTimeout) {
319
498
  clearTimeout(this.batchPollingTimeout);
@@ -335,8 +514,40 @@ export class WebSocketProgramAccountSubscriberV2<T>
335
514
  return;
336
515
  }
337
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
+
338
549
  // Fetch all accounts in a single batch request
339
- const accountAddresses = accountsToPoll.map(
550
+ const accountAddresses = accountsToMonitor.map(
340
551
  (accountId) => accountId as Address
341
552
  );
342
553
  const rpcResponse = await this.rpc
@@ -349,8 +560,8 @@ export class WebSocketProgramAccountSubscriberV2<T>
349
560
  const currentSlot = Number(rpcResponse.context.slot);
350
561
 
351
562
  // Process each account response
352
- for (let i = 0; i < accountsToPoll.length; i++) {
353
- const accountIdString = accountsToPoll[i];
563
+ for (let i = 0; i < accountsToMonitor.length; i++) {
564
+ const accountIdString = accountsToMonitor[i];
354
565
  const accountInfo = rpcResponse.value[i];
355
566
 
356
567
  if (!accountInfo) {
@@ -363,7 +574,7 @@ export class WebSocketProgramAccountSubscriberV2<T>
363
574
  if (!existingBufferAndSlot) {
364
575
  // Account not in our map yet, add it
365
576
  let newBuffer: Buffer | undefined = undefined;
366
- if (accountInfo.data) {
577
+ if (accountInfo) {
367
578
  if (Array.isArray(accountInfo.data)) {
368
579
  const [data, encoding] = accountInfo.data;
369
580
  newBuffer = Buffer.from(data, encoding);
@@ -371,21 +582,16 @@ export class WebSocketProgramAccountSubscriberV2<T>
371
582
  }
372
583
 
373
584
  if (newBuffer) {
374
- this.bufferAndSlotMap.set(accountIdString, {
375
- buffer: newBuffer,
376
- slot: currentSlot,
377
- });
378
- const account = this.decodeBuffer(
379
- this.accountDiscriminator,
380
- newBuffer
585
+ this.updateBufferAndHandleChange(
586
+ newBuffer,
587
+ currentSlot,
588
+ accountIdString
381
589
  );
382
- const accountId = new PublicKey(accountIdString);
383
- this.onChange(accountId, account, { slot: currentSlot }, newBuffer);
384
590
  }
385
591
  continue;
386
592
  }
387
593
 
388
- // Check if we missed an update
594
+ // For initial population, just update the slot if we have newer data
389
595
  if (currentSlot > existingBufferAndSlot.slot) {
390
596
  let newBuffer: Buffer | undefined = undefined;
391
597
  if (accountInfo.data) {
@@ -399,83 +605,89 @@ export class WebSocketProgramAccountSubscriberV2<T>
399
605
  }
400
606
  }
401
607
 
402
- // Check if buffer has changed
403
- if (
404
- newBuffer &&
405
- (!existingBufferAndSlot.buffer ||
406
- !newBuffer.equals(existingBufferAndSlot.buffer))
407
- ) {
408
- if (this.resubOpts?.logResubMessages) {
409
- console.log(
410
- `[${this.subscriptionName}] Batch polling detected missed update for account ${accountIdString}, resubscribing`
411
- );
412
- }
413
- // We missed an update, resubscribe
414
- await this.unsubscribe(true);
415
- this.receivingData = false;
416
- await this.subscribe(this.onChange);
417
- return;
608
+ // Update with newer data if available
609
+ if (newBuffer) {
610
+ this.updateBufferAndHandleChange(
611
+ newBuffer,
612
+ currentSlot,
613
+ accountIdString
614
+ );
418
615
  }
419
616
  }
420
617
  }
421
618
  } catch (error) {
422
619
  if (this.resubOpts?.logResubMessages) {
423
620
  console.log(
424
- `[${this.subscriptionName}] Error batch polling accounts:`,
621
+ `[${this.subscriptionName}] Error fetching and populating monitored accounts:`,
425
622
  error
426
623
  );
427
624
  }
428
625
  }
429
626
  }
430
627
 
431
- private async pollAccount(accountIdString: string): Promise<void> {
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> {
432
633
  try {
433
- // Fetch current account data using gill's rpc
434
- const accountAddress = accountIdString as Address;
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
+ );
435
644
  const rpcResponse = await this.rpc
436
- .getAccountInfo(accountAddress, {
645
+ .getMultipleAccounts(accountAddresses, {
437
646
  commitment: this.options.commitment as GillCommitment,
438
647
  encoding: 'base64',
439
648
  })
440
649
  .send();
441
650
 
442
651
  const currentSlot = Number(rpcResponse.context.slot);
443
- const existingBufferAndSlot = this.bufferAndSlotMap.get(accountIdString);
444
652
 
445
- if (!existingBufferAndSlot) {
446
- // Account not in our map yet, add it
447
- if (rpcResponse.value) {
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
448
667
  let newBuffer: Buffer | undefined = undefined;
449
- if (rpcResponse.value.data) {
450
- if (Array.isArray(rpcResponse.value.data)) {
451
- const [data, encoding] = rpcResponse.value.data;
668
+ if (accountInfo.data) {
669
+ if (Array.isArray(accountInfo.data)) {
670
+ const [data, encoding] = accountInfo.data;
452
671
  newBuffer = Buffer.from(data, encoding);
453
672
  }
454
673
  }
455
674
 
456
675
  if (newBuffer) {
457
- this.bufferAndSlotMap.set(accountIdString, {
458
- buffer: newBuffer,
459
- slot: currentSlot,
460
- });
461
- const account = this.decodeBuffer(
462
- this.accountDiscriminator,
463
- newBuffer
676
+ this.updateBufferAndHandleChange(
677
+ newBuffer,
678
+ currentSlot,
679
+ accountIdString
464
680
  );
465
- const accountId = new PublicKey(accountIdString);
466
- this.onChange(accountId, account, { slot: currentSlot }, newBuffer);
467
681
  }
682
+ continue;
468
683
  }
469
- return;
470
- }
471
684
 
472
- // Check if we missed an update
473
- if (currentSlot > existingBufferAndSlot.slot) {
474
- let newBuffer: Buffer | undefined = undefined;
475
- if (rpcResponse.value) {
476
- if (rpcResponse.value.data) {
477
- if (Array.isArray(rpcResponse.value.data)) {
478
- const [data, encoding] = rpcResponse.value.data;
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;
479
691
  if (encoding === ('base58' as any)) {
480
692
  newBuffer = Buffer.from(bs58.decode(data));
481
693
  } else {
@@ -483,30 +695,130 @@ export class WebSocketProgramAccountSubscriberV2<T>
483
695
  }
484
696
  }
485
697
  }
486
- }
487
698
 
488
- // Check if buffer has changed
489
- if (
490
- newBuffer &&
491
- (!existingBufferAndSlot.buffer ||
492
- !newBuffer.equals(existingBufferAndSlot.buffer))
493
- ) {
494
- if (this.resubOpts?.logResubMessages) {
495
- console.log(
496
- `[${this.subscriptionName}] Polling detected missed update for account ${accountIdString}, resubscribing`
497
- );
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;
498
712
  }
499
- // We missed an update, resubscribe
500
- await this.unsubscribe(true);
501
- this.receivingData = false;
502
- await this.subscribe(this.onChange);
503
- return;
504
713
  }
505
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
+ );
506
818
  } catch (error) {
507
819
  if (this.resubOpts?.logResubMessages) {
508
820
  console.log(
509
- `[${this.subscriptionName}] Error polling account ${accountIdString}:`,
821
+ `[${this.subscriptionName}] Error fetching accounts batch:`,
510
822
  error
511
823
  );
512
824
  }
@@ -525,8 +837,72 @@ export class WebSocketProgramAccountSubscriberV2<T>
525
837
  this.batchPollingTimeout = undefined;
526
838
  }
527
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
+
528
852
  // Clear accounts currently polling
529
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
+ }
530
906
  }
531
907
 
532
908
  unsubscribe(onResub = false): Promise<void> {
@@ -553,6 +929,11 @@ export class WebSocketProgramAccountSubscriberV2<T>
553
929
  }
554
930
 
555
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
+ */
556
937
  addAccountToMonitor(accountId: PublicKey): void {
557
938
  const accountIdString = accountId.toBase58();
558
939
  this.accountsToMonitor.add(accountIdString);
@@ -586,6 +967,10 @@ export class WebSocketProgramAccountSubscriberV2<T>
586
967
  }
587
968
 
588
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
+ */
589
974
  setPollingInterval(intervalMs: number): void {
590
975
  this.pollingIntervalMs = intervalMs;
591
976
  // Restart monitoring with new interval if already subscribed
@@ -593,4 +978,18 @@ export class WebSocketProgramAccountSubscriberV2<T>
593
978
  this.startMonitoringForAccounts();
594
979
  }
595
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
+ }
596
995
  }