@exodus/solana-api 3.30.6 → 3.30.8

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,24 @@
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.8](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.7...@exodus/solana-api@3.30.8) (2026-04-02)
7
+
8
+ **Note:** Version bump only for package @exodus/solana-api
9
+
10
+
11
+
12
+
13
+
14
+ ## [3.30.7](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.6...@exodus/solana-api@3.30.7) (2026-04-01)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+
20
+ * fix: addressIsActive users tx history (#7708)
21
+
22
+
23
+
6
24
  ## [3.30.6](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.5...@exodus/solana-api@3.30.6) (2026-03-30)
7
25
 
8
26
  **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.6",
3
+ "version": "3.30.8",
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",
@@ -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": "0d8639ef50e192df1f302f0ac7ff378d919cc536",
52
+ "gitHead": "c4e253a7ee39a0ee2524ff3c0e528fe3614d15de",
53
53
  "bugs": {
54
54
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
55
55
  },
package/src/api.js CHANGED
@@ -497,14 +497,13 @@ export class Api {
497
497
  }
498
498
 
499
499
  /**
500
- * Returns true if the account exists (has not been reclaimed).
501
- * Accounts may be reclaimed by the rent collector after their balance dropped below the
502
- * rent-exempt reserve. getAccountInfo only returns the current account state; if the
503
- * runtime deleted the account, RPC returns null.
500
+ * Returns true if the address has any transaction history.
501
+ * Solana system accounts can be reclaimed after staying below rent exemption, so
502
+ * current account info is not a reliable signal for whether an address was ever used.
504
503
  */
505
504
  async addressIsActive(address) {
506
- const value = await this.getAccountInfo(address)
507
- return !!value?.data
505
+ const signatures = await this.getSignaturesForAddress(address, { limit: 1 })
506
+ return signatures.length > 0
508
507
  }
509
508
 
510
509
  async isSpl(address) {
package/src/connection.js CHANGED
@@ -74,17 +74,17 @@ export class Connection {
74
74
  if (this.isOpen) {
75
75
  if (typeof this.ws.ping === 'function') {
76
76
  this.ws.ping()
77
- } else {
78
- // Some WebSocket implementations (like in browser or Electron renderer) don't have a ping method
79
- this.ws.send(
80
- JSON.stringify({
81
- jsonrpc: '2.0',
82
- id: Date.now(),
83
- method: 'getVersion',
84
- params: [],
85
- })
86
- )
87
77
  }
78
+
79
+ // Application-level keepalive (always); native ping is optional per WebSocket impl
80
+ this.ws.send(
81
+ JSON.stringify({
82
+ jsonrpc: '2.0',
83
+ id: Date.now(),
84
+ method: 'getVersion',
85
+ params: [],
86
+ })
87
+ )
88
88
  }
89
89
  }, PING_INTERVAL)
90
90
  }
package/src/rpc-api.js CHANGED
@@ -190,6 +190,32 @@ export class RpcApi {
190
190
  }
191
191
  }
192
192
 
193
+ async getSignaturesForAddress(address, { until, before, limit } = {}) {
194
+ until = until || undefined
195
+
196
+ const fetchRetry = retry(
197
+ async () => {
198
+ try {
199
+ return await this.rpcCall('getSignaturesForAddress', [address, { until, before, limit }])
200
+ } catch (error) {
201
+ if (
202
+ error.message &&
203
+ !errorMessagesToRetry.some((errorMessage) => error.message.includes(errorMessage))
204
+ ) {
205
+ error.finalError = true
206
+ }
207
+
208
+ console.warn(`Error getting signatures. Retrying...`, error)
209
+
210
+ throw error
211
+ }
212
+ },
213
+ { delayTimesMs: ['8s', '10s', '15s'] }
214
+ )
215
+
216
+ return fetchRetry()
217
+ }
218
+
193
219
  async getWalletTokensList({ tokenAccounts }) {
194
220
  const tokensMint = []
195
221
  for (const account of tokenAccounts) {
@@ -244,14 +270,13 @@ export class RpcApi {
244
270
  }
245
271
 
246
272
  /**
247
- * Returns true if the account exists (has not been reclaimed).
248
- * Accounts may be reclaimed by the rent collector after their balance dropped below the
249
- * rent-exempt reserve. getAccountInfo only returns the current account state; if the
250
- * runtime deleted the account, RPC returns null.
273
+ * Returns true if the address has any transaction history.
274
+ * Solana system accounts can be reclaimed after staying below rent exemption, so
275
+ * current account info is not a reliable signal for whether an address was ever used.
251
276
  */
252
277
  async addressIsActive(address) {
253
- const value = await this.getAccountInfo(address)
254
- return !!value?.data
278
+ const signatures = await this.getSignaturesForAddress(address, { limit: 1 })
279
+ return signatures.length > 0
255
280
  }
256
281
 
257
282
  async getTokenFeeBasisPoints(address) {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Builds `newData` for a token-balance patch applied after `executeOperationsBatch`.
3
+ * `AccountState.merge` is shallow at the top level: keys omitted from `newData` are left unchanged
4
+ * on the existing state, so an empty `extraNewData` (e.g. account/program WS) does not clear
5
+ * `cursor`, `historyCursor`, etc. set by an earlier patch in the same flush queue.
6
+ *
7
+ * @param {{ tokenBalances?: Record<string, unknown> } & Record<string, unknown>} current - Latest account state from `getAccountState`
8
+ * @param {{ tokenPatch: Record<string, unknown>, extraNewData: Record<string, unknown> }} patch
9
+ */
10
+ export function buildTokenBalancePatchNewData(current, { tokenPatch, extraNewData }) {
11
+ return {
12
+ ...extraNewData,
13
+ tokenBalances: { ...(current.tokenBalances ?? Object.create(null)), ...tokenPatch },
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Applies a queue of token balance patches sequentially. After each `updateAccountState`, the next
19
+ * patch reads fresh state via `getAccountState` so multiple SPL mints in one tx (and mixed WS events)
20
+ * merge correctly.
21
+ *
22
+ * On failure, throws an `Error` with `cause` set to the underlying error, `remainingPatches` set to
23
+ * patches not yet applied (including the one that failed), and `appliedPatchCount` set to how many
24
+ * completed successfully. Callers can re-queue `remainingPatches` for retry.
25
+ *
26
+ * @param {object} aci - Asset client interface with `getAccountState` and `updateAccountState`
27
+ * @param {{ assetName: string, walletAccount: string, patches: Array<{ tokenPatch: Record<string, unknown>, extraNewData: Record<string, unknown> }> }} params
28
+ */
29
+ export async function flushTokenBalancePatchesQueue(aci, { assetName, walletAccount, patches }) {
30
+ if (!patches?.length) return
31
+
32
+ let i = 0
33
+ try {
34
+ for (; i < patches.length; i++) {
35
+ const { tokenPatch, extraNewData } = patches[i]
36
+ const current = await aci.getAccountState({ assetName, walletAccount })
37
+ await aci.updateAccountState({
38
+ assetName,
39
+ walletAccount,
40
+ newData: buildTokenBalancePatchNewData(current, { tokenPatch, extraNewData }),
41
+ })
42
+ }
43
+ } catch (cause) {
44
+ const err = new Error('Token balance patch flush failed', { cause })
45
+ err.remainingPatches = patches.slice(i)
46
+ err.appliedPatchCount = i
47
+ throw err
48
+ }
49
+ }
@@ -4,6 +4,7 @@ import assert from 'minimalistic-assert'
4
4
  import { DEFAULT_POOL_ADDRESS } from '../account-state.js'
5
5
  import { normalizeTransactionNotificationResult } from '../ws-api.js'
6
6
  import { SolanaClarityMonitor } from './clarity-monitor.js'
7
+ import { flushTokenBalancePatchesQueue } from './token-balance-patch.js'
7
8
 
8
9
  const DEFAULT_REMOTE_CONFIG = {
9
10
  clarityUrl: [],
@@ -14,6 +15,8 @@ const DEFAULT_REMOTE_CONFIG = {
14
15
 
15
16
  export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
16
17
  #clarityInitialSyncDone = Object.create(null)
18
+ /** Queued after each `executeOperationsBatch` so token deltas merge onto latest state (fluent batch has no `push`). */
19
+ #pendingTokenBalancePatches = Object.create(null)
17
20
 
18
21
  async #hasLoadedStakingInfo(walletAccount) {
19
22
  const accountState = await this.aci.getAccountState({
@@ -171,13 +174,13 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
171
174
  tokenAccounts: this.tokenAccountsByOwner[walletAccount],
172
175
  })
173
176
  if (!unknownTokensList.includes(parsed.mintAddress) && tokenName) {
174
- const newData = {
175
- tokenBalances: {
176
- ...accountState.tokenBalances,
177
+ this.#queueTokenBalancePatchAfterBatch({
178
+ tokenPatch: {
177
179
  [tokenName]: this.assets[tokenName].currency.baseUnit(parsed.amount),
178
180
  },
179
- }
180
- await this.#updateStateBatch({ newData, walletAccount })
181
+ extraNewData: {},
182
+ walletAccount,
183
+ })
181
184
  }
182
185
 
183
186
  return
@@ -202,13 +205,13 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
202
205
  return
203
206
  }
204
207
 
205
- const newData = {
206
- tokenBalances: {
207
- ...accountState.tokenBalances,
208
+ this.#queueTokenBalancePatchAfterBatch({
209
+ tokenPatch: {
208
210
  [tokenName]: this.assets[tokenName].currency.baseUnit(amount),
209
211
  },
210
- }
211
- await this.#updateStateBatch({ newData, walletAccount })
212
+ extraNewData: {},
213
+ walletAccount,
214
+ })
212
215
  } else {
213
216
  // SOL balance changed
214
217
 
@@ -332,16 +335,7 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
332
335
  tokenBalancesFromTx[tokenName] = this.assets[tokenName].currency.baseUnit(amount)
333
336
  }
334
337
 
335
- const newData =
336
- Object.keys(tokenBalancesFromTx).length > 0
337
- ? {
338
- ...cursorState,
339
- tokenBalances: {
340
- ...accountState.tokenBalances,
341
- ...tokenBalancesFromTx,
342
- },
343
- }
344
- : { ...cursorState }
338
+ const hasTokenBalancePatch = Object.keys(tokenBalancesFromTx).length > 0
345
339
 
346
340
  if (stakingTx) {
347
341
  // for staking the balance is not updated by the balance handler
@@ -369,10 +363,18 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
369
363
  break
370
364
  }
371
365
 
372
- newData.stakingInfo = temporaryStakingInfo
366
+ const stakingStateUpdate = { ...cursorState, stakingInfo: temporaryStakingInfo }
373
367
 
374
368
  // we update only stakingInfo, so that in this 12sec window, the spendable balance is valid. In the meanwhile we can fetch the complete one from RPC and update it in background (this one has the "staking accounts" references).
375
- await this.#updateStateBatch({ newData, walletAccount })
369
+ if (hasTokenBalancePatch) {
370
+ this.#queueTokenBalancePatchAfterBatch({
371
+ tokenPatch: tokenBalancesFromTx,
372
+ extraNewData: stakingStateUpdate,
373
+ walletAccount,
374
+ })
375
+ } else {
376
+ await this.#updateStateBatch({ newData: stakingStateUpdate, walletAccount })
377
+ }
376
378
 
377
379
  await delay(12_000) // we introduce a delay to make sure the getStakingInfo RPC returns updated data
378
380
  // because we NEED at a certain point to have an updated stakingInfo in the state to know what's the new solana stake account address.
@@ -395,8 +397,16 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
395
397
  return
396
398
  }
397
399
 
398
- // NB. we don't update any balances here to avoid race conditions with the accountNotification event above
399
- await this.#updateStateBatch({ newData, walletAccount })
400
+ // Token deltas are applied after `executeOperationsBatch` so we merge onto latest state (swap race).
401
+ if (hasTokenBalancePatch) {
402
+ this.#queueTokenBalancePatchAfterBatch({
403
+ tokenPatch: tokenBalancesFromTx,
404
+ extraNewData: cursorState,
405
+ walletAccount,
406
+ })
407
+ } else if (Object.keys(cursorState).length > 0) {
408
+ await this.#updateStateBatch({ newData: cursorState, walletAccount })
409
+ }
400
410
 
401
411
  return
402
412
  }
@@ -425,10 +435,46 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
425
435
  const batch = this.batch[walletAccount]
426
436
  this.batch[walletAccount] = null
427
437
  await this.aci.executeOperationsBatch(batch)
438
+ await this.#flushPendingTokenBalancePatches(walletAccount)
428
439
  }, 2000)
429
440
  return this.batch[walletAccount]
430
441
  }
431
442
 
443
+ /**
444
+ * Production `createOperationsBatch()` returns a fluent batch (`updateAccountState` / `commit`), not
445
+ * `{ push, exec }` from tests. Token balance deltas are queued here and applied after `executeOperationsBatch`
446
+ * so each patch merges `{ ...current.tokenBalances, ...tokenPatch }` against up-to-date state.
447
+ */
448
+ #queueTokenBalancePatchAfterBatch({ tokenPatch, extraNewData, walletAccount }) {
449
+ if (!this.#pendingTokenBalancePatches[walletAccount]) {
450
+ this.#pendingTokenBalancePatches[walletAccount] = []
451
+ }
452
+
453
+ this.#pendingTokenBalancePatches[walletAccount].push({ tokenPatch, extraNewData })
454
+ this.#ensureBatch(walletAccount)
455
+ }
456
+
457
+ async #flushPendingTokenBalancePatches(walletAccount) {
458
+ const queue = this.#pendingTokenBalancePatches[walletAccount]
459
+ if (!queue?.length) return
460
+
461
+ const snapshot = [...queue]
462
+ try {
463
+ await flushTokenBalancePatchesQueue(this.aci, {
464
+ assetName: this.asset.name,
465
+ walletAccount,
466
+ patches: snapshot,
467
+ })
468
+ this.#pendingTokenBalancePatches[walletAccount] = []
469
+ } catch (e) {
470
+ const remaining = e.remainingPatches
471
+ this.#pendingTokenBalancePatches[walletAccount] = Array.isArray(remaining)
472
+ ? remaining
473
+ : snapshot
474
+ throw e
475
+ }
476
+ }
477
+
432
478
  async #updateStateBatch({ newData, walletAccount }) {
433
479
  const assetName = this.asset.name
434
480
  const batch = this.#ensureBatch(walletAccount)