@exodus/solana-api 3.30.8 → 3.30.10

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/CHANGELOG.md CHANGED
@@ -3,6 +3,26 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [3.30.10](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.9...@exodus/solana-api@3.30.10) (2026-04-13)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: SOL ws single subscription and reconnect backoff (#7727)
13
+
14
+
15
+
16
+ ## [3.30.9](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.8...@exodus/solana-api@3.30.9) (2026-04-04)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+
22
+ * fix: sponsored Solana token sends when CU simulation fails (#7707)
23
+
24
+
25
+
6
26
  ## [3.30.8](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.7...@exodus/solana-api@3.30.8) (2026-04-02)
7
27
 
8
28
  **Note:** Version bump only for package @exodus/solana-api
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "3.30.8",
3
+ "version": "3.30.10",
4
4
  "description": "Transaction monitors, fee monitors, RPC with the blockchain node, and other networking code for Solana",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -33,8 +33,8 @@
33
33
  "@exodus/fetch": "^1.7.3",
34
34
  "@exodus/models": "^13.0.0",
35
35
  "@exodus/simple-retry": "^0.0.6",
36
- "@exodus/solana-lib": "^3.22.3",
37
- "@exodus/solana-meta": "^2.0.2",
36
+ "@exodus/solana-lib": "^3.22.5",
37
+ "@exodus/solana-meta": "^2.9.0",
38
38
  "@exodus/timer": "^1.1.1",
39
39
  "debug": "^4.1.1",
40
40
  "delay": "^4.0.1",
@@ -49,7 +49,7 @@
49
49
  "@exodus/assets-testing": "^1.0.0",
50
50
  "@exodus/solana-web3.js": "^1.63.1-exodus.9-rc3"
51
51
  },
52
- "gitHead": "c4e253a7ee39a0ee2524ff3c0e528fe3614d15de",
52
+ "gitHead": "786b5cff109a9a2a3c385e8e3140be4c72120577",
53
53
  "bugs": {
54
54
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
55
55
  },
package/src/connection.js CHANGED
@@ -4,7 +4,8 @@ import delay from 'delay'
4
4
  import assert from 'minimalistic-assert'
5
5
  import ms from 'ms'
6
6
 
7
- const DEFAULT_RECONNECT_DELAY = ms('15s')
7
+ const BASE_RECONNECT_DELAY = ms('15s')
8
+ const MAX_RECONNECT_DELAY = ms('5m')
8
9
  const PING_INTERVAL = ms('25s')
9
10
 
10
11
  const debug = debugLogger('exodus:solana-api')
@@ -19,12 +20,10 @@ export class Connection {
19
20
 
20
21
  this.shutdown = false
21
22
  this.ws = null
22
- this.messageQueue = []
23
- this.inProcessMessages = false
24
23
  this.pingTimeout = null
25
24
  this.reconnectTimeout = null
26
- this.txCache = {}
27
25
  this.seq = 0
26
+ this.reconnectAttempt = 0
28
27
  }
29
28
 
30
29
  newSocket(reqUrl) {
@@ -57,19 +56,12 @@ export class Connection {
57
56
  }
58
57
 
59
58
  get running() {
60
- return !!(!this.isClosed || this.inProcessMessages || this.messageQueue.length > 0)
61
- }
62
-
63
- get connectionState() {
64
- if (this.isConnecting) return 'CONNECTING'
65
- if (this.isOpen) return 'OPEN'
66
- if (this.isClosing) return 'CLOSING'
67
- if (this.isClosed) return 'CLOSED'
68
- return 'NONE'
59
+ return !this.isClosed
69
60
  }
70
61
 
71
62
  startPing() {
72
63
  if (this.ws) {
64
+ clearInterval(this.pingTimeout)
73
65
  this.pingTimeout = setInterval(() => {
74
66
  if (this.isOpen) {
75
67
  if (typeof this.ws.ping === 'function') {
@@ -90,16 +82,27 @@ export class Connection {
90
82
  }
91
83
  }
92
84
 
85
+ #nextReconnectDelayMs() {
86
+ const exp = Math.min(MAX_RECONNECT_DELAY, BASE_RECONNECT_DELAY * 2 ** this.reconnectAttempt)
87
+ const jitter = Math.floor(Math.random() * Math.min(exp, BASE_RECONNECT_DELAY))
88
+ return exp + jitter
89
+ }
90
+
93
91
  doRestart() {
94
- // debug('Restarting WS:')
92
+ if (this.shutdown || this.reconnectTimeout) return
93
+
94
+ const delayMs = this.#nextReconnectDelayMs()
95
+ this.reconnectAttempt += 1
96
+ console.log(`SOL scheduling ws reconnect in ${delayMs}ms (attempt ${this.reconnectAttempt})`)
95
97
  this.reconnectTimeout = setTimeout(async () => {
98
+ this.reconnectTimeout = null
96
99
  try {
97
100
  console.log('SOL reconnecting ws...')
98
101
  this.start()
99
102
  } catch (e) {
100
103
  console.log(`Error in reconnect callback: ${e.message}`)
101
104
  }
102
- }, DEFAULT_RECONNECT_DELAY)
105
+ }, delayMs)
103
106
  }
104
107
 
105
108
  onMessage(evt) {
@@ -115,6 +118,9 @@ export class Connection {
115
118
 
116
119
  onOpen(evt) {
117
120
  debug('Opened WS')
121
+ clearTimeout(this.reconnectTimeout)
122
+ this.reconnectTimeout = null
123
+ this.reconnectAttempt = 0
118
124
  this.onConnectionReady(evt)
119
125
  this.startPing()
120
126
  }
@@ -132,7 +138,10 @@ export class Connection {
132
138
  onClose(evt) {
133
139
  debug('Closing WS', evt)
134
140
  clearInterval(this.pingTimeout)
141
+ this.pingTimeout = null
135
142
  clearTimeout(this.reconnectTimeout)
143
+ this.reconnectTimeout = null
144
+ this.ws = null
136
145
  this.onConnectionClose(evt)
137
146
  if (!this.shutdown) {
138
147
  this.doRestart()
@@ -141,7 +150,9 @@ export class Connection {
141
150
 
142
151
  async close() {
143
152
  clearTimeout(this.reconnectTimeout)
153
+ this.reconnectTimeout = null
144
154
  clearInterval(this.pingTimeout)
155
+ this.pingTimeout = null
145
156
  if (this.ws && (this.isConnecting || this.isOpen)) {
146
157
  // this.ws.send(JSON.stringify({ method: 'close' }))
147
158
  // Not sending the method above so just no need to wait below
@@ -10,7 +10,9 @@ import assert from 'minimalistic-assert'
10
10
 
11
11
  import { maybeAddFeePayerWithAuth } from './fee-payer.js'
12
12
 
13
- const CU_FOR_COMPUTE_BUDGET_INSTRUCTIONS = 300
13
+ const COMPUTE_BUDGET_INSTRUCTIONS_CU = 300
14
+ const SOL_TRANSFER_CU = 150 + COMPUTE_BUDGET_INSTRUCTIONS_CU
15
+ const DEFAULT_COMPUTE_UNIT_LIMIT = 200_000
14
16
  const TOKEN_ACCOUNT_CREATION_SIZE = 165 // size of the token account
15
17
 
16
18
  export const createTxFactory = ({ assetClientInterface, api, feePayerClient }) => {
@@ -65,6 +67,7 @@ export const createTxFactory = ({ assetClientInterface, api, feePayerClient }) =
65
67
 
66
68
  const feeData =
67
69
  providedFeeData ?? (await assetClientInterface.getFeeConfig({ assetName: baseAssetName }))
70
+ const shouldTryFeePayer = feeData.enableFeePayer && useFeePayer
68
71
 
69
72
  const fromAddress =
70
73
  providedFromAddress ??
@@ -211,18 +214,22 @@ export const createTxFactory = ({ assetClientInterface, api, feePayerClient }) =
211
214
  reference,
212
215
  memo,
213
216
  // Effective: platform enable AND per-tx intent
214
- useFeePayer,
217
+ useFeePayer: shouldTryFeePayer,
215
218
  ...tokenParams,
216
219
  ...stakingParams,
217
220
  ...magicEdenParams,
218
221
  })
219
222
 
220
223
  unsignedTx.txMeta.stakingParams = stakingParams
224
+ const isFeeSponsoredTokenTransfer = isToken && unsignedTx.txMeta.useFeePayer && !nft && !method
225
+ const shouldUseSafeComputeUnitLimit =
226
+ isFeeSponsoredTokenTransfer &&
227
+ (!unsignedTx.txData.isAssociatedTokenAccountActive || isExchange)
221
228
 
222
229
  const resolveUnitConsumed = async () => {
223
230
  // this avoids unnecessary simulations. Also the simulation fails with InsufficientFundsForRent when sending all.
224
231
  if (asset.name === asset.baseAsset.name && amount && !nft && !method) {
225
- return 150 + CU_FOR_COMPUTE_BUDGET_INSTRUCTIONS
232
+ return SOL_TRANSFER_CU
226
233
  }
227
234
 
228
235
  // Simulate with unsigned transaction. The fee payer service is deterministic -
@@ -237,10 +244,31 @@ export const createTxFactory = ({ assetClientInterface, api, feePayerClient }) =
237
244
  // we use this method to compute unitsConsumed
238
245
  // we can throw error here and fallback to ~0.025 SOL or estimate fee based on the method
239
246
  console.log('error getting units consumed:', err)
247
+ const serializedError = typeof err === 'string' ? err : JSON.stringify(err)
248
+ const isRentOrBalanceError = /insufficientfunds|rent/i.test(serializedError)
249
+ const isAccountNotFoundError = /accountnotfound/i.test(serializedError)
250
+
251
+ // Fee sponsorship is injected after this simulation step. Overestimate CU to
252
+ // avoid on-chain failures; the fee payer absorbs the cost.
253
+ if (!unitsConsumed && isFeeSponsoredTokenTransfer && isRentOrBalanceError) {
254
+ return DEFAULT_COMPUTE_UNIT_LIMIT
255
+ }
256
+
257
+ if (!unitsConsumed && isFeeSponsoredTokenTransfer && isAccountNotFoundError) {
258
+ const senderBaseBalanceLamports = BigInt(await api.getBalance(fromAddress))
259
+ if (senderBaseBalanceLamports === BigInt(0)) return DEFAULT_COMPUTE_UNIT_LIMIT
260
+ }
261
+
240
262
  if (!unitsConsumed) throw new Error(err)
241
263
  }
242
264
 
243
- return unitsConsumed + CU_FOR_COMPUTE_BUDGET_INSTRUCTIONS
265
+ const estimatedComputeUnitLimit = unitsConsumed + COMPUTE_BUDGET_INSTRUCTIONS_CU
266
+
267
+ if (shouldUseSafeComputeUnitLimit && estimatedComputeUnitLimit < DEFAULT_COMPUTE_UNIT_LIMIT) {
268
+ return DEFAULT_COMPUTE_UNIT_LIMIT
269
+ }
270
+
271
+ return estimatedComputeUnitLimit
244
272
  }
245
273
 
246
274
  const priorityFee = feeData.priorityFee
@@ -272,7 +300,6 @@ export const createTxFactory = ({ assetClientInterface, api, feePayerClient }) =
272
300
  const tx = await maybeAddFeePayerWithAuth({
273
301
  unsignedTx,
274
302
  feePayerClient,
275
- enableFeePayer: feeData.enableFeePayer,
276
303
  })
277
304
 
278
305
  const fee = tx.txMeta.usedFeePayer ? asset.feeAsset.currency.ZERO : calculatedFee
package/src/fee-payer.js CHANGED
@@ -145,11 +145,11 @@ export const feePayerClientFactory = ({
145
145
  * @param {Object} params.unsignedTx - The unsigned transaction
146
146
  * @param {Object} params.feePayerClient - The fee payer client instance
147
147
  */
148
- export const maybeAddFeePayerWithAuth = async ({ unsignedTx, feePayerClient, enableFeePayer }) => {
148
+ export const maybeAddFeePayerWithAuth = async ({ unsignedTx, feePayerClient }) => {
149
149
  let unsignedTxWithFeePayer = unsignedTx
150
150
 
151
- // Skip if no client or explicitly disabled
152
- if (!feePayerClient || !enableFeePayer || !unsignedTx.txMeta.useFeePayer) {
151
+ // `txMeta.useFeePayer` already combines platform-level and per-tx intent.
152
+ if (!feePayerClient || !unsignedTx.txMeta.useFeePayer) {
153
153
  unsignedTxWithFeePayer.txMeta.usedFeePayer = false
154
154
  return unsignedTxWithFeePayer
155
155
  }
package/src/index.js CHANGED
@@ -33,5 +33,10 @@ const serverApi = new Api({ assets }) // TODO: remove it, clean every use from p
33
33
  export default serverApi // TODO: remove it
34
34
 
35
35
  export { Api } from './api.js'
36
- export { WsApi, normalizeTransactionNotificationResult } from './ws-api.js'
36
+ export {
37
+ mergeUniqueWatchAddresses,
38
+ normalizeTransactionNotificationResult,
39
+ sameWatchAddressSet,
40
+ WsApi,
41
+ } from './ws-api.js'
37
42
  export { ClarityApi } from './clarity-api.js'
@@ -22,17 +22,19 @@ This monitor will use a Mix of Clarity RPC calls and WS events (using Helius Enh
22
22
 
23
23
  - Both Laserstream gRPC and Geyser enhanced websockets are serviced by Laserstream under the hood.
24
24
 
25
- For `Richat`:
26
- If you have 10 ATA you will have
25
+ For **Helius Enhanced WSS** (`WsApi` when the endpoint URL matches Helius — see [transactionSubscribe](https://www.helius.dev/docs/enhanced-websockets/transaction-subscribe)):
27
26
 
28
- - 11 `accountSubscribe` - 1 for wallet and 10 for ATA
29
- - 1 `transactionSubscribe` - all 11 addresses in account.include
30
- - 1 `tokenInitSubscribe` - 1 with wallet address
27
+ - Filter uses `accountInclude`: transactions match if **any** listed account appears in the tx (OR). Up to 50,000 addresses per docs.
31
28
 
32
- For `transactionSubscribe`, once you have new ATA unfortunately you need to send new `transactionSubscribe`, once you receive messages that subscription is set you need to unsubscribe the old subscription, at that moment you will be able to receive duplicated transactions for a short period of time.
33
- (But this way you will not miss any transaction and will be easy to filter out duplicates on client side by txId)
29
+ For **Triton / standard-style** RPC WebSockets (non-Helius URL), the same logical filter is sent as `accounts: { include: [...] }` (Whirligig / pool-specific docs mirror Solana’s JSON-RPC shape).
34
30
 
35
- Note that `tokenInitSubscribe` is a Richat-only method (in development). It doesn't exist in Laserstream/Geyser. It's used to check for new token accounts. For that we still need to rely on the `getTokenAccountsByOwner` RPC (or clarity call).
31
+ If you have 10 ATAs you will have:
32
+
33
+ - 11 `accountSubscribe` — 1 for the SOL owner and 10 for ATAs
34
+ - **1 active** `transactionSubscribe` at a time — the merged list of all 11 addresses in `accountInclude` / `accounts.include`
35
+ - 2 `programSubscribe` — SPL Token + Token-2022 (memcmp on owner), for new ATA discovery
36
+
37
+ When new ATAs appear, `WsApi` sends a **new** `transactionSubscribe` with the **full** merged address list, waits for the subscription id, then `transactionUnsubscribe`s the **previous** tx subscription. You may see duplicate `transactionNotification`s for a short window; dedupe by tx id (same as before).
36
38
 
37
39
  ## WS RPC
38
40
 
@@ -256,12 +256,7 @@ export class SolanaClarityMonitor extends BaseMonitor {
256
256
  return { transactions: [], historyCursor: undefined }
257
257
  }
258
258
 
259
- const transactionsWithSilentSound = transactions.map((tx) => ({
260
- ...tx,
261
- data: { ...tx.data, silentSound: true },
262
- }))
263
-
264
- return { transactions: transactionsWithSilentSound, historyCursor: before }
259
+ return { transactions, historyCursor: before }
265
260
  } catch (error) {
266
261
  console.warn('SolanaClarityMonitor fetchOldHistory failed', {
267
262
  address,
@@ -333,7 +328,6 @@ export class SolanaClarityMonitor extends BaseMonitor {
333
328
  staking: tx.staking || null,
334
329
  unparsed: !!tx.unparsed,
335
330
  swapTx: !!(tx.data && tx.data.inner),
336
- silentSound: !!tx.data?.silentSound,
337
331
  },
338
332
  currencies: { [assetName]: asset.currency, [feeAsset.name]: feeAsset.currency },
339
333
  }
@@ -113,13 +113,65 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
113
113
  }
114
114
  }
115
115
 
116
- async #handleMessage({ address, walletAccount, data }) {
117
- const accountState = await this.aci.getAccountState({
118
- assetName: this.asset.name,
119
- walletAccount,
120
- })
121
- const tokenAccountsByOwner = this.tokenAccountsByOwner[walletAccount]
116
+ #buildTrackedTokenAccount({
117
+ tokenAccountAddress,
118
+ owner,
119
+ mintAddress,
120
+ balance = '0',
121
+ tokenProgram,
122
+ }) {
123
+ const tokenMeta = this.clarityApi.tokens.get(mintAddress)
124
+ return {
125
+ tokenAccountAddress,
126
+ owner,
127
+ tokenName: tokenMeta?.name ?? 'unknown',
128
+ ticker: tokenMeta?.ticker ?? 'UNKNOWN',
129
+ balance,
130
+ mintAddress,
131
+ tokenProgram,
132
+ decimals: tokenMeta?.decimals ?? 0,
133
+ feeBasisPoints: 0,
134
+ maximumFee: 0,
135
+ }
136
+ }
137
+
138
+ async #subscribeTokenAccountsForOwner({ address, tokenAccountAddresses }) {
139
+ if (tokenAccountAddresses.length === 0) return
140
+
141
+ for (const tokenAccountAddress of tokenAccountAddresses) {
142
+ await this.wsApi.accountSubscribe({ owner: address, account: tokenAccountAddress })
143
+ }
144
+
145
+ // Do not await `transactionSubscribe`: confirmation can take up to 30s and would stall
146
+ // programNotification / transactionNotification handling after we've already extended
147
+ // `tokenAccountsByOwner` (tx-log parse + postTokenBalances patch must run immediately).
148
+ this.wsApi
149
+ .transactionSubscribe({
150
+ owner: address,
151
+ accounts: tokenAccountAddresses,
152
+ })
153
+ .catch((err) => {
154
+ this.logger.error(
155
+ `SOL WS transactionSubscribe failed for ${this.asset.name} owner ${address}`,
156
+ err
157
+ )
158
+ })
159
+ }
160
+
161
+ async #flushBatch(walletAccount) {
162
+ const batch = this.batch[walletAccount]
163
+ this.batch[walletAccount] = null
164
+ if (!batch) return
165
+
166
+ try {
167
+ await this.aci.executeOperationsBatch(batch)
168
+ await this.#flushPendingTokenBalancePatches(walletAccount)
169
+ } catch (error) {
170
+ this.logger.error(`SOL ws batch flush failed for ${this.asset.name}:${walletAccount}`, error)
171
+ }
172
+ }
122
173
 
174
+ async #handleMessage({ address, walletAccount, data }) {
123
175
  /*
124
176
  1. A new event arrives.
125
177
  2. Open a 2-second batch window.
@@ -152,31 +204,28 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
152
204
  return
153
205
  }
154
206
 
155
- const tokenMeta = this.clarityApi.tokens.get(parsed.mintAddress)
156
- const tokenName = tokenMeta?.name
157
- const newAccount = {
207
+ const newAccount = this.#buildTrackedTokenAccount({
158
208
  tokenAccountAddress: parsed.tokenAccountAddress,
159
209
  owner: parsed.owner,
160
- tokenName: tokenName ?? 'unknown',
161
- ticker: tokenMeta?.ticker ?? 'UNKNOWN',
162
- balance: parsed.amount,
163
210
  mintAddress: parsed.mintAddress,
211
+ balance: parsed.amount,
164
212
  tokenProgram: parsed.tokenProgram,
165
- decimals: tokenMeta?.decimals ?? 0,
166
- feeBasisPoints: 0,
167
- maximumFee: 0,
168
- }
213
+ })
169
214
  this.tokenAccountsByOwner[walletAccount] = [...tokenAccountsByOwnerList, newAccount]
170
215
 
171
- await this.wsApi.accountSubscribe({ owner: address, account: parsed.tokenAccountAddress })
216
+ await this.#subscribeTokenAccountsForOwner({
217
+ address,
218
+ tokenAccountAddresses: [parsed.tokenAccountAddress],
219
+ })
172
220
 
173
221
  const unknownTokensList = await this.emitUnknownTokensEvent({
174
222
  tokenAccounts: this.tokenAccountsByOwner[walletAccount],
175
223
  })
176
- if (!unknownTokensList.includes(parsed.mintAddress) && tokenName) {
224
+ const tokenAsset = newAccount.tokenName && this.assets[newAccount.tokenName]
225
+ if (!unknownTokensList.includes(parsed.mintAddress) && tokenAsset) {
177
226
  this.#queueTokenBalancePatchAfterBatch({
178
227
  tokenPatch: {
179
- [tokenName]: this.assets[tokenName].currency.baseUnit(parsed.amount),
228
+ [newAccount.tokenName]: tokenAsset.currency.baseUnit(parsed.amount),
180
229
  },
181
230
  extraNewData: {},
182
231
  walletAccount,
@@ -188,26 +237,29 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
188
237
 
189
238
  case 'accountNotification':
190
239
  // balance changed events for known tokens or SOL address
191
-
192
- const { amount, tokenMintAddress } = this.wsApi.parseAccountNotification({
240
+ const parsedAccountNotification = this.wsApi.parseAccountNotification({
193
241
  address,
194
242
  walletAccount,
195
- tokenAccountsByOwner,
243
+ tokenAccountsByOwner: this.tokenAccountsByOwner[walletAccount],
196
244
  result: data.params.result,
197
245
  })
246
+ if (!parsedAccountNotification) return
247
+
248
+ const { amount, tokenMintAddress } = parsedAccountNotification
198
249
 
199
250
  // update account state balance for SOL or Token
200
251
  if (tokenMintAddress) {
201
252
  // token balance changed
202
253
  const tokenName = this.clarityApi.tokens.get(tokenMintAddress)?.name
203
- if (!tokenName) {
254
+ const tokenAsset = tokenName && this.assets[tokenName]
255
+ if (!tokenAsset) {
204
256
  console.log(`Unknown token mint address: ${tokenMintAddress}`)
205
257
  return
206
258
  }
207
259
 
208
260
  this.#queueTokenBalancePatchAfterBatch({
209
261
  tokenPatch: {
210
- [tokenName]: this.assets[tokenName].currency.baseUnit(amount),
262
+ [tokenName]: tokenAsset.currency.baseUnit(amount),
211
263
  },
212
264
  extraNewData: {},
213
265
  walletAccount,
@@ -235,10 +287,14 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
235
287
  }
236
288
  }
237
289
 
238
- await new Promise((resolve) => setTimeout(resolve, watchDelay))
290
+ await delay(watchDelay)
239
291
  }
240
292
 
241
293
  // otherwise regular SOL update:
294
+ const accountState = await this.aci.getAccountState({
295
+ assetName: this.asset.name,
296
+ walletAccount,
297
+ })
242
298
  const stakingInfo = accountState.stakingInfo // NB. we cannot call this.getStakingInfo(...) since it's not in sync with the ws event! we must wait a lot of seconds.
243
299
  const balance = this.#computeTotalBalance({ amount, address, stakingInfo, walletAccount })
244
300
 
@@ -258,6 +314,7 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
258
314
  const txDetails = normalizeTransactionNotificationResult(data.params.result)
259
315
  const txTokenAccounts = this.wsApi.getTokenAccountsFromTxMeta(txDetails, address)
260
316
  let tokenAccountsByOwnerList = this.tokenAccountsByOwner[walletAccount] || []
317
+ const newTokenAccountPubkeys = []
261
318
  for (const txAcc of txTokenAccounts) {
262
319
  if (
263
320
  tokenAccountsByOwnerList.some(
@@ -267,30 +324,24 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
267
324
  continue
268
325
  }
269
326
 
270
- const tokenMeta = this.clarityApi.tokens.get(txAcc.mintAddress)
271
- const newAccount = {
327
+ const newAccount = this.#buildTrackedTokenAccount({
272
328
  tokenAccountAddress: txAcc.tokenAccountAddress,
273
329
  owner: txAcc.owner,
274
- tokenName: tokenMeta?.name ?? 'unknown',
275
- ticker: tokenMeta?.ticker ?? 'UNKNOWN',
276
- balance: '0',
277
330
  mintAddress: txAcc.mintAddress,
331
+ balance: '0',
278
332
  tokenProgram: null,
279
- decimals: tokenMeta?.decimals ?? 0,
280
- feeBasisPoints: 0,
281
- maximumFee: 0,
282
- }
283
- tokenAccountsByOwnerList = [...tokenAccountsByOwnerList, newAccount]
284
- await this.wsApi.accountSubscribe({ owner: address, account: txAcc.tokenAccountAddress })
285
- // we need also to perform a transactionSubscribe to the new token account address
286
- await this.wsApi.transactionSubscribe({
287
- owner: address,
288
- accounts: txAcc.tokenAccountAddress,
289
333
  })
334
+ tokenAccountsByOwnerList = [...tokenAccountsByOwnerList, newAccount]
335
+ newTokenAccountPubkeys.push(txAcc.tokenAccountAddress)
290
336
  }
291
337
 
292
338
  this.tokenAccountsByOwner[walletAccount] = tokenAccountsByOwnerList
293
339
 
340
+ await this.#subscribeTokenAccountsForOwner({
341
+ address,
342
+ tokenAccountAddresses: newTokenAccountPubkeys,
343
+ })
344
+
294
345
  const { logItemsByAsset, cursorState = {} } = this.wsApi.parseTransactionNotification({
295
346
  address,
296
347
  walletAccount,
@@ -340,6 +391,10 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
340
391
  if (stakingTx) {
341
392
  // for staking the balance is not updated by the balance handler
342
393
  // staking operations won't spend or modify the "total" wallet balance.
394
+ const accountState = await this.aci.getAccountState({
395
+ assetName: this.asset.name,
396
+ walletAccount,
397
+ })
343
398
 
344
399
  // we update stakingInfo (before fetching the new one)
345
400
  const temporaryStakingInfo = { ...accountState.stakingInfo }
@@ -431,11 +486,8 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
431
486
  // open a new operations batch window if it's not opened already
432
487
  if (this.batch[walletAccount]) return this.batch[walletAccount]
433
488
  this.batch[walletAccount] = this.aci.createOperationsBatch()
434
- setTimeout(async () => {
435
- const batch = this.batch[walletAccount]
436
- this.batch[walletAccount] = null
437
- await this.aci.executeOperationsBatch(batch)
438
- await this.#flushPendingTokenBalancePatches(walletAccount)
489
+ setTimeout(() => {
490
+ this.#flushBatch(walletAccount)
439
491
  }, 2000)
440
492
  return this.batch[walletAccount]
441
493
  }
package/src/ws-api.js CHANGED
@@ -19,6 +19,26 @@ import { isSolAddressPoisoningTx } from './txs-utils.js'
19
19
  // Helius Enhanced WebSocket (https://www.helius.dev/docs/enhanced-websockets)
20
20
  const HELIUS_WS_URL = 'wss://solana-helius-wss.a.exodus.io/ws'
21
21
 
22
+ /** Merge `extra` addresses onto `base` without duplicates (order preserved). */
23
+ export function mergeUniqueWatchAddresses(base, extra) {
24
+ const seen = new Set(base)
25
+ const out = [...base]
26
+ for (const addr of extra) {
27
+ if (seen.has(addr)) continue
28
+ seen.add(addr)
29
+ out.push(addr)
30
+ }
31
+
32
+ return out
33
+ }
34
+
35
+ /** True if both arrays contain the same set of addresses (order-independent). */
36
+ export function sameWatchAddressSet(a, b) {
37
+ if (a.length !== b.length) return false
38
+ const set = new Set(a)
39
+ return b.every((addr) => set.has(addr))
40
+ }
41
+
22
42
  /**
23
43
  * Normalize `transactionSubscribe` / JSON-RPC `params.result` into `getTransaction`-shaped txDetails:
24
44
  * `{ slot?, transaction: { message, signatures }, meta }`.
@@ -56,6 +76,10 @@ export function normalizeTransactionNotificationResult(result) {
56
76
  }
57
77
 
58
78
  export class WsApi {
79
+ #cancelledSubscriptionRequests = Object.create(null)
80
+ #ownerStates = Object.create(null)
81
+ #subscriptionConfirmHandlers = Object.create(null)
82
+
59
83
  constructor({ rpcUrl, wsUrl, assets }) {
60
84
  this.setWsEndpoint(wsUrl)
61
85
  this.connection = null
@@ -65,20 +89,69 @@ export class WsApi {
65
89
 
66
90
  /** Reset subscription maps (used on init and when the WS connection closes). */
67
91
  #resetSubscriptionState() {
68
- this.accountSubscriptions = Object.create(null)
69
- this.transactionSubscriptions = Object.create(null)
70
- this.programSubscriptions = Object.create(null)
92
+ for (const entry of Object.values(this.#subscriptionConfirmHandlers)) {
93
+ clearTimeout(entry.timeout)
94
+ entry.reject(new Error('SOL WS connection reset'))
95
+ }
96
+
97
+ this.#cancelledSubscriptionRequests = Object.create(null)
98
+ this.#subscriptionConfirmHandlers = Object.create(null)
99
+ this.#ownerStates = Object.create(null)
71
100
  // subscription id (from RPC response) -> { owner, type: 'account'|'transaction'|'program' }
72
101
  this.subscriptionIdToMeta = Object.create(null)
73
102
  // request id (our conn.seq) -> { owner, type }, until we receive the subscription id
74
103
  this.pendingSubscriptionRequests = Object.create(null)
75
104
  }
76
105
 
106
+ get accountSubscriptions() {
107
+ const out = Object.create(null)
108
+ for (const [owner, state] of Object.entries(this.#ownerStates)) {
109
+ if (state.accountSubscriptions.length > 0) out[owner] = [...state.accountSubscriptions]
110
+ }
111
+
112
+ return out
113
+ }
114
+
115
+ get transactionSubscriptions() {
116
+ const out = Object.create(null)
117
+ for (const [owner, state] of Object.entries(this.#ownerStates)) {
118
+ if (state.transactionSubscriptions.length > 0) {
119
+ out[owner] = [...state.transactionSubscriptions]
120
+ }
121
+ }
122
+
123
+ return out
124
+ }
125
+
126
+ get programSubscriptions() {
127
+ const out = Object.create(null)
128
+ for (const [owner, state] of Object.entries(this.#ownerStates)) {
129
+ if (state.programSubscribed) out[owner] = true
130
+ }
131
+
132
+ return out
133
+ }
134
+
135
+ get activeTransactionSubscriptionId() {
136
+ const out = Object.create(null)
137
+ for (const [owner, state] of Object.entries(this.#ownerStates)) {
138
+ if (state.activeTransactionSubscriptionId != null) {
139
+ out[owner] = state.activeTransactionSubscriptionId
140
+ }
141
+ }
142
+
143
+ return out
144
+ }
145
+
77
146
  setWsEndpoint(wsUrl) {
78
147
  this.wsUrl = wsUrl || HELIUS_WS_URL
79
148
  }
80
149
 
81
- /** True when using Helius Enhanced WebSocket (different transactionSubscribe params and notification shape). */
150
+ /**
151
+ * True when using Helius Enhanced WebSocket (`accountInclude` filter).
152
+ * Triton / standard Solana-style RPC uses `accounts.include` in the filter object.
153
+ * @see https://www.helius.dev/docs/enhanced-websockets/transaction-subscribe
154
+ */
82
155
  #isHelius() {
83
156
  if (!this.wsUrl) return false
84
157
  return this.wsUrl.includes('helius') || this.wsUrl === HELIUS_WS_URL
@@ -96,16 +169,29 @@ export class WsApi {
96
169
  if (json?.id != null && typeof json?.result === 'number') {
97
170
  const pending = this.pendingSubscriptionRequests[json.id]
98
171
  if (pending != null) {
99
- this.subscriptionIdToMeta[json.result] = { owner: pending.owner, type: pending.type }
172
+ if (this.#cancelledSubscriptionRequests[json.id]) {
173
+ delete this.#cancelledSubscriptionRequests[json.id]
174
+ this.#sendRpcUnsubscribe({ subId: json.result, type: pending.type })
175
+ } else {
176
+ this.subscriptionIdToMeta[json.result] = { owner: pending.owner, type: pending.type }
177
+ }
178
+
100
179
  delete this.pendingSubscriptionRequests[json.id]
101
180
  }
102
181
 
182
+ const confirm = this.#subscriptionConfirmHandlers[json.id]
183
+ if (confirm != null) {
184
+ clearTimeout(confirm.timeout)
185
+ delete this.#subscriptionConfirmHandlers[json.id]
186
+ confirm.resolve(json.result)
187
+ }
188
+
103
189
  return []
104
190
  }
105
191
 
106
192
  if (method === 'accountNotification' || method === 'transactionNotification') {
107
193
  const subId = params?.subscription
108
- if (subId !== null) {
194
+ if (subId != null) {
109
195
  const owner = this.subscriptionIdToMeta[subId]?.owner
110
196
  return owner && this.watchedAddresses[owner] ? [owner] : []
111
197
  }
@@ -140,9 +226,24 @@ export class WsApi {
140
226
  }
141
227
 
142
228
  #dispatchMessage(json) {
229
+ if (json?.id != null && json.error) {
230
+ const confirm = this.#subscriptionConfirmHandlers[json.id]
231
+ if (confirm != null) {
232
+ clearTimeout(confirm.timeout)
233
+ delete this.#subscriptionConfirmHandlers[json.id]
234
+ delete this.pendingSubscriptionRequests[json.id]
235
+ const msg = json.error?.message || 'SOL WS subscription RPC error'
236
+ confirm.reject(new Error(msg))
237
+ }
238
+
239
+ delete this.#cancelledSubscriptionRequests[json.id]
240
+
241
+ return
242
+ }
243
+
143
244
  const addresses = this.#getAddressesForMessage(json)
144
245
  for (const addr of addresses) {
145
- this.watchedAddresses[addr].onMessage(json)
246
+ this.watchedAddresses[addr]?.onMessage?.(json)
146
247
  }
147
248
  }
148
249
 
@@ -153,8 +254,74 @@ export class WsApi {
153
254
  }
154
255
  }
155
256
 
257
+ #sendRpcUnsubscribe({ subId, type }) {
258
+ const methodByType = {
259
+ account: 'accountUnsubscribe',
260
+ transaction: 'transactionUnsubscribe',
261
+ program: 'programUnsubscribe',
262
+ }
263
+ const method = type && methodByType[type]
264
+ if (!method || !this.connection?.isOpen) return
265
+
266
+ this.connection.send({
267
+ jsonrpc: '2.0',
268
+ id: ++this.connection.seq,
269
+ method,
270
+ params: [Number(subId)],
271
+ })
272
+ }
273
+
274
+ #getOwnerState(owner) {
275
+ let state = this.#ownerStates[owner]
276
+ if (state) return state
277
+
278
+ state = {
279
+ accountSubscriptions: [],
280
+ transactionSubscriptions: [],
281
+ programSubscribed: false,
282
+ activeTransactionSubscriptionId: undefined,
283
+ transactionSubscribeChain: Promise.resolve(),
284
+ }
285
+ this.#ownerStates[owner] = state
286
+ return state
287
+ }
288
+
289
+ #cancelPendingSubscriptionsForOwner(owner) {
290
+ for (const [requestId, meta] of Object.entries(this.pendingSubscriptionRequests)) {
291
+ if (meta.owner !== owner) continue
292
+
293
+ this.#cancelledSubscriptionRequests[requestId] = true
294
+ const confirm = this.#subscriptionConfirmHandlers[requestId]
295
+ if (confirm != null) {
296
+ clearTimeout(confirm.timeout)
297
+ delete this.#subscriptionConfirmHandlers[requestId]
298
+ confirm.reject(new Error(`SOL WS ${meta.type} subscription cancelled for ${owner}`))
299
+ }
300
+ }
301
+ }
302
+
156
303
  async watchAddress({ address, tokensAddresses = [], onMessage }) {
157
- if (this.watchedAddresses[address]) return // already subscribed
304
+ if (this.watchedAddresses[address]) {
305
+ const w = this.watchedAddresses[address]
306
+ if (onMessage) w.onMessage = onMessage
307
+
308
+ const prev = new Set(w.tokensAddresses)
309
+ const added = tokensAddresses.filter((t) => !prev.has(t))
310
+ w.tokensAddresses = mergeUniqueWatchAddresses(w.tokensAddresses, tokensAddresses)
311
+
312
+ if (added.length > 0 && this.connection?.isOpen) {
313
+ for (const addr of added) {
314
+ await this.accountSubscribe({ owner: address, account: addr })
315
+ }
316
+
317
+ await this.transactionSubscribe({
318
+ owner: address,
319
+ accounts: mergeUniqueWatchAddresses([address], w.tokensAddresses),
320
+ })
321
+ }
322
+
323
+ return
324
+ }
158
325
 
159
326
  this.watchedAddresses[address] = { tokensAddresses, onMessage }
160
327
 
@@ -189,8 +356,8 @@ export class WsApi {
189
356
  return
190
357
  }
191
358
 
192
- const subscriptions = this.accountSubscriptions[owner] || []
193
- if (subscriptions?.includes(account)) return // already subscribed
359
+ const ownerState = this.#getOwnerState(owner)
360
+ if (ownerState.accountSubscriptions.includes(account)) return // already subscribed
194
361
 
195
362
  const id = ++conn.seq
196
363
  this.pendingSubscriptionRequests[id] = { owner, type: 'account' }
@@ -207,44 +374,106 @@ export class WsApi {
207
374
  id,
208
375
  })
209
376
 
210
- this.accountSubscriptions[owner] = [...subscriptions, account]
377
+ ownerState.accountSubscriptions = [...ownerState.accountSubscriptions, account]
211
378
  }
212
379
 
380
+ /**
381
+ * Subscribe to transactions touching any of the owner's watched accounts (OR semantics).
382
+ * Merges `accounts` into the existing set, sends **one** `transactionSubscribe` with the full
383
+ * merged `accountInclude` / `accounts.include` list, then unsubscribes the previous tx
384
+ * subscription (Helius + Triton safe; brief duplicate notifications possible — dedupe by tx id).
385
+ */
213
386
  async transactionSubscribe({ owner, accounts }) {
214
- if (!Array.isArray(accounts)) {
215
- accounts = [accounts]
387
+ const conn = this.connection
388
+ if (!conn || !conn.isOpen) {
389
+ console.warn('SOL Connection is not open, cannot subscribe to', owner)
390
+ return
216
391
  }
217
392
 
393
+ const ownerState = this.#getOwnerState(owner)
394
+ const normalized = Array.isArray(accounts) ? accounts : [accounts]
395
+ const prevChain = ownerState.transactionSubscribeChain
396
+ const done = prevChain.then(() =>
397
+ this.#transactionSubscribeStep({ owner, accounts: normalized })
398
+ )
399
+ ownerState.transactionSubscribeChain = done.catch((err) => {
400
+ console.error('SOL WS transactionSubscribe failed:', err)
401
+ })
402
+
403
+ return done
404
+ }
405
+
406
+ async #transactionSubscribeStep({ owner, accounts }) {
218
407
  const conn = this.connection
219
408
  if (!conn || !conn.isOpen) {
220
409
  console.warn('SOL Connection is not open, cannot subscribe to', owner)
221
410
  return
222
411
  }
223
412
 
224
- const subscriptions = this.transactionSubscriptions[owner] || []
225
- // compute the difference between subscriptions and accounts
226
- const difference = accounts.filter((account) => !subscriptions.includes(account))
227
- if (difference.length === 0) return // already subscribed
413
+ const ownerState = this.#getOwnerState(owner)
414
+ const prevList = ownerState.transactionSubscriptions
415
+ const merged = mergeUniqueWatchAddresses(prevList, accounts)
416
+ const activeId = ownerState.activeTransactionSubscriptionId
417
+ const sameSet = sameWatchAddressSet(merged, prevList)
228
418
 
419
+ if (sameSet && activeId != null) {
420
+ return
421
+ }
422
+
423
+ if (sameSet && activeId == null && merged.length === 0) {
424
+ return
425
+ }
426
+
427
+ const oldTxSubId = activeId
229
428
  const id = ++conn.seq
230
- this.pendingSubscriptionRequests[id] = { owner, type: 'transaction' }
231
- const options = {
232
- commitment: 'confirmed',
233
- encoding: 'jsonParsed',
234
- transactionDetails: 'full',
235
- showRewards: false,
236
- maxSupportedTransactionVersion: 255,
237
- }
238
- const filter = this.#isHelius()
239
- ? { vote: false, accountInclude: difference }
240
- : { vote: false, accounts: { include: difference } }
241
- conn.send({
242
- jsonrpc: '2.0',
243
- id,
244
- method: 'transactionSubscribe',
245
- params: [filter, options],
246
- })
247
- this.transactionSubscriptions[owner] = [...subscriptions, ...difference]
429
+
430
+ let subId
431
+ try {
432
+ subId = await new Promise((resolve, reject) => {
433
+ const timeout = setTimeout(() => {
434
+ delete this.#subscriptionConfirmHandlers[id]
435
+ delete this.pendingSubscriptionRequests[id]
436
+ reject(new Error(`transactionSubscribe confirmation timeout for ${owner}`))
437
+ }, 30_000)
438
+
439
+ this.#subscriptionConfirmHandlers[id] = { resolve, reject, timeout }
440
+ this.pendingSubscriptionRequests[id] = { owner, type: 'transaction' }
441
+
442
+ const options = {
443
+ commitment: 'confirmed',
444
+ encoding: 'jsonParsed',
445
+ transactionDetails: 'full',
446
+ showRewards: false,
447
+ // Helius docs use 0 for legacy + v0; 255 keeps prior Exodus behavior on all providers.
448
+ maxSupportedTransactionVersion: 255,
449
+ }
450
+ const filter = this.#isHelius()
451
+ ? { vote: false, accountInclude: merged }
452
+ : { vote: false, accounts: { include: merged } }
453
+ conn.send({
454
+ jsonrpc: '2.0',
455
+ id,
456
+ method: 'transactionSubscribe',
457
+ params: [filter, options],
458
+ })
459
+ })
460
+ } catch (e) {
461
+ console.error('SOL WS transactionSubscribe:', e)
462
+ throw e
463
+ }
464
+
465
+ if (oldTxSubId != null && conn.isOpen) {
466
+ conn.send({
467
+ jsonrpc: '2.0',
468
+ id: ++conn.seq,
469
+ method: 'transactionUnsubscribe',
470
+ params: [Number(oldTxSubId)],
471
+ })
472
+ delete this.subscriptionIdToMeta[String(oldTxSubId)]
473
+ }
474
+
475
+ ownerState.transactionSubscriptions = merged
476
+ ownerState.activeTransactionSubscriptionId = subId
248
477
  }
249
478
 
250
479
  async programSubscribe({ owner }) {
@@ -254,7 +483,8 @@ export class WsApi {
254
483
  return
255
484
  }
256
485
 
257
- if (this.programSubscriptions[owner]) return // already subscribed (SPL + Token-2022)
486
+ const ownerState = this.#getOwnerState(owner)
487
+ if (ownerState.programSubscribed) return // already subscribed (SPL + Token-2022)
258
488
 
259
489
  const splTokenProgramId = TOKEN_PROGRAM_ID.toBase58()
260
490
  const token2022ProgramId = TOKEN_2022_PROGRAM_ID.toBase58()
@@ -294,7 +524,7 @@ export class WsApi {
294
524
  ],
295
525
  })
296
526
 
297
- this.programSubscriptions[owner] = true
527
+ ownerState.programSubscribed = true
298
528
  }
299
529
 
300
530
  async sendSubscriptions({ address, tokensAddresses = [] }) {
@@ -316,30 +546,17 @@ export class WsApi {
316
546
  if (!this.watchedAddresses[address]) return
317
547
 
318
548
  const conn = this.connection
319
- const unsubMethods = {
320
- account: 'accountUnsubscribe',
321
- transaction: 'transactionUnsubscribe',
322
- program: 'programUnsubscribe',
323
- }
549
+ this.#cancelPendingSubscriptionsForOwner(address)
550
+
324
551
  for (const [subId, meta] of Object.entries(this.subscriptionIdToMeta)) {
325
552
  if (meta.owner !== address) continue
326
- const method = meta.type && unsubMethods[meta.type]
327
- if (method && conn?.isOpen) {
328
- conn.send({
329
- jsonrpc: '2.0',
330
- id: ++conn.seq,
331
- method,
332
- params: [Number(subId)],
333
- })
334
- }
553
+ this.#sendRpcUnsubscribe({ subId, type: meta.type })
335
554
 
336
555
  delete this.subscriptionIdToMeta[subId]
337
556
  }
338
557
 
339
558
  delete this.watchedAddresses[address]
340
- delete this.accountSubscriptions[address]
341
- delete this.transactionSubscriptions[address]
342
- delete this.programSubscriptions[address]
559
+ delete this.#ownerStates[address]
343
560
 
344
561
  if (Object.keys(this.watchedAddresses).length === 0 && conn) {
345
562
  await conn.stop()