@exodus/solana-api 3.30.2 → 3.30.3

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,16 @@
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.3](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.2...@exodus/solana-api@3.30.3) (2026-03-20)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: solana zero balance on restore under transient Clarity/RPC failures (#7628)
13
+
14
+
15
+
6
16
  ## [3.30.2](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.1...@exodus/solana-api@3.30.2) (2026-03-18)
7
17
 
8
18
  **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.2",
3
+ "version": "3.30.3",
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": "e746a88ef78a0e7167587bfa14068ceb494d6fd7",
52
+ "gitHead": "f1f7c3ad9d55f774c9c6a09434139d46f605e43c",
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
@@ -215,13 +215,23 @@ export class Api {
215
215
  try {
216
216
  const until = cursor
217
217
 
218
- const tokenAccountsByOwner = tokenAccounts || (await this.getTokenAccountsByOwner(address)) // Array
218
+ let tokenAccountsByOwner
219
+ try {
220
+ tokenAccountsByOwner = tokenAccounts || (await this.getTokenAccountsByOwner(address))
221
+ } catch (err) {
222
+ console.warn('Solana getTokenAccountsByOwner failed, continuing with main address only', {
223
+ address,
224
+ error: err,
225
+ })
226
+ tokenAccountsByOwner = []
227
+ }
228
+
219
229
  const tokenAccountAddresses = tokenAccountsByOwner
220
230
  .filter(({ tokenName }) => tokenName !== 'unknown')
221
231
  .map(({ tokenAccountAddress }) => tokenAccountAddress)
222
232
  const accountsToCheck = [address, ...tokenAccountAddresses]
223
233
 
224
- const txsResultsByAccount = await Promise.all(
234
+ const signatureSettled = await Promise.allSettled(
225
235
  accountsToCheck.map((addr) =>
226
236
  this.getSignaturesForAddress(addr, {
227
237
  until,
@@ -230,14 +240,32 @@ export class Api {
230
240
  })
231
241
  )
232
242
  )
243
+ const txsResultsByAccount = signatureSettled.map((result, index) => {
244
+ if (result.status === 'fulfilled') return result.value
245
+ console.warn('Solana getSignaturesForAddress failed', {
246
+ address: accountsToCheck[index],
247
+ error: result.reason,
248
+ })
249
+ return []
250
+ })
233
251
  let txsId = txsResultsByAccount.flat() // merge arrays
234
252
  txsId = lodash.uniqBy(txsId, 'signature')
235
253
 
236
- // get txs details with concurrency limit to avoid overwhelming the RPC server
254
+ // Per-tx fetch: one failure must not drop the whole page. Concurrency still capped.
237
255
  const fetchWithLimit = makeConcurrent((signature) => this.getTransactionById(signature), {
238
256
  concurrency: TX_DETAIL_FETCH_CONCURRENCY,
239
257
  })
240
- const txsDetails = await Promise.all(txsId.map((tx) => fetchWithLimit(tx.signature)))
258
+ const txsDetails = await Promise.all(
259
+ txsId.map((tx) =>
260
+ fetchWithLimit(tx.signature).catch((error) => {
261
+ console.warn('Solana getTransactionById failed', {
262
+ signature: tx.signature,
263
+ error,
264
+ })
265
+ return null
266
+ })
267
+ )
268
+ )
241
269
  txsDetails.forEach((txDetail) => {
242
270
  if (!txDetail) return
243
271
 
@@ -1,5 +1,6 @@
1
1
  import { BaseMonitor } from '@exodus/asset-lib'
2
2
  import { mapValues, pickBy } from '@exodus/basic-utils'
3
+ import { retry } from '@exodus/simple-retry'
3
4
  import lodash from 'lodash'
4
5
  import assert from 'minimalistic-assert'
5
6
  import ms from 'ms'
@@ -88,6 +89,33 @@ export class SolanaClarityMonitor extends BaseMonitor {
88
89
  )
89
90
  }
90
91
 
92
+ #normalizeAccountInfoPayload(accountInfo) {
93
+ if (!accountInfo) return accountInfo
94
+ const inner = accountInfo.value
95
+ if (!inner || typeof inner !== 'object') return accountInfo
96
+ return {
97
+ ...accountInfo,
98
+ lamports: accountInfo.lamports ?? inner.lamports,
99
+ space: accountInfo.space ?? inner.space,
100
+ owner: accountInfo.owner ?? inner.owner,
101
+ }
102
+ }
103
+
104
+ async #fetchAccountInfoWithRetry(address) {
105
+ const fetchRetry = retry(() => this.clarityApi.getAccountInfo(address), {
106
+ delayTimesMs: ['200ms', '400ms', '800ms'],
107
+ })
108
+
109
+ try {
110
+ return await fetchRetry()
111
+ } catch (error) {
112
+ console.warn('SolanaClarityMonitor getAccountInfo failed after retries', {
113
+ address,
114
+ error,
115
+ })
116
+ }
117
+ }
118
+
91
119
  async markStaleTransactions({ walletAccount, logItemsByAsset = Object.create(null) }) {
92
120
  // mark stale txs as dropped in logItemsByAsset
93
121
  const clearedLogItems = logItemsByAsset
@@ -137,85 +165,81 @@ export class SolanaClarityMonitor extends BaseMonitor {
137
165
  refresh || isHistoryUpdateTick || balanceChanged || hasUnconfirmedSentTx
138
166
  const shouldUpdateOnlyBalance = balanceChanged && !shouldUpdateLatestHistory
139
167
 
140
- // start a batch
141
168
  const batch = this.aci.createOperationsBatch()
142
- // getHistory is more likely to fail/be rate limited, so we want to update users balance only on a lot of ticks
143
- if (this.shouldUpdateBalanceBeforeHistory || shouldUpdateOnlyBalance) {
144
- // update all state at once
145
- this.updateState({ account, walletAccount, staking, batch })
146
- await this.emitUnknownTokensEvent({ tokenAccounts })
147
- }
169
+ try {
170
+ if (this.shouldUpdateBalanceBeforeHistory || shouldUpdateOnlyBalance) {
171
+ this.updateState({ account, walletAccount, staking, batch })
172
+ await this.emitUnknownTokensEvent({ tokenAccounts })
173
+ }
148
174
 
149
- const newCursorState = {
150
- cursor: accountState.cursor,
151
- historyCursor: accountState.historyCursor,
152
- }
175
+ const newCursorState = {
176
+ cursor: accountState.cursor,
177
+ historyCursor: accountState.historyCursor,
178
+ }
153
179
 
154
- const newTransactions = []
180
+ const newTransactions = []
155
181
 
156
- if (shouldUpdateLatestHistory) {
157
- const { transactions: latestTransactions, cursorState: latestHistoryCursorState } =
158
- await this.getLatestHistory({
159
- address,
160
- accountState,
161
- walletAccount,
162
- refresh,
163
- tokenAccounts,
164
- })
182
+ if (shouldUpdateLatestHistory) {
183
+ const { transactions: latestTransactions, cursorState: latestHistoryCursorState } =
184
+ await this.getLatestHistory({
185
+ address,
186
+ accountState,
187
+ walletAccount,
188
+ refresh,
189
+ tokenAccounts,
190
+ })
165
191
 
166
- if (latestHistoryCursorState.cursor) {
167
- newCursorState.cursor = latestHistoryCursorState.cursor
168
- }
192
+ if (latestHistoryCursorState.cursor) {
193
+ newCursorState.cursor = latestHistoryCursorState.cursor
194
+ }
195
+
196
+ if (latestHistoryCursorState.historyCursor) {
197
+ newCursorState.historyCursor = latestHistoryCursorState.historyCursor
198
+ }
169
199
 
170
- if (latestHistoryCursorState.historyCursor) {
171
- // refresh case will have historyCursor available
172
- // but if user has 0 txs, it won't be available, therefore need to check
173
- newCursorState.historyCursor = latestHistoryCursorState.historyCursor
200
+ newTransactions.push(...latestTransactions)
174
201
  }
175
202
 
176
- newTransactions.push(...latestTransactions)
177
- }
203
+ const shouldFetchOldHistory = !refresh && newCursorState.historyCursor // on refresh request, wait until next tick to fetch old history
204
+ if (shouldFetchOldHistory) {
205
+ const { transactions: historyTransactions, historyCursor } = await this.fetchOldHistory({
206
+ address,
207
+ historyCursor: newCursorState.historyCursor,
208
+ })
178
209
 
179
- const shouldFetchOldHistory = !refresh && newCursorState.historyCursor // on refresh request, wait until next tick to fetch old history
180
- if (shouldFetchOldHistory) {
181
- const { transactions: historyTransactions, historyCursor } = await this.fetchOldHistory({
182
- address,
183
- historyCursor: newCursorState.historyCursor,
184
- })
210
+ newTransactions.push(...historyTransactions)
211
+ newCursorState.historyCursor = historyCursor
212
+ }
185
213
 
186
- newTransactions.push(...historyTransactions)
187
- // Always update so we persist historyCursor = undefined when history is exhausted
188
- newCursorState.historyCursor = historyCursor
189
- }
214
+ await this.emitUnknownTokensEvent({ tokenAccounts })
190
215
 
191
- await this.emitUnknownTokensEvent({ tokenAccounts })
216
+ if (newTransactions.length > 0) {
217
+ const clearedLogItems = await this.markStaleTransactions({
218
+ walletAccount,
219
+ logItemsByAsset: this.mapTransactionsToLogItems({
220
+ transactions: newTransactions,
221
+ address,
222
+ }),
223
+ })
192
224
 
193
- if (newTransactions.length > 0) {
194
- // update all state at once
195
- const clearedLogItems = await this.markStaleTransactions({
196
- walletAccount,
197
- logItemsByAsset: this.mapTransactionsToLogItems({ transactions: newTransactions, address }),
198
- })
225
+ this.updateTxLogByAssetBatch({
226
+ logItemsByAsset: clearedLogItems,
227
+ walletAccount,
228
+ refresh,
229
+ batch,
230
+ })
231
+ }
199
232
 
200
- this.updateTxLogByAssetBatch({
201
- logItemsByAsset: clearedLogItems,
233
+ this.updateState({
234
+ account,
235
+ cursorState: newCursorState,
202
236
  walletAccount,
203
- refresh,
237
+ staking,
204
238
  batch,
205
239
  })
240
+ } finally {
241
+ await this.aci.executeOperationsBatch(batch)
206
242
  }
207
-
208
- // Always persist cursor state so historyCursor advances (and is cleared when history exhausted)
209
- this.updateState({
210
- account,
211
- cursorState: newCursorState,
212
- walletAccount,
213
- staking,
214
- batch,
215
- })
216
-
217
- // close batch
218
- await this.aci.executeOperationsBatch(batch)
219
243
  }
220
244
 
221
245
  async fetchOldHistory({ address, historyCursor }) {
@@ -229,7 +253,6 @@ export class SolanaClarityMonitor extends BaseMonitor {
229
253
  })
230
254
 
231
255
  if (!transactions || transactions.length === 0 || !before) {
232
- // no more transactions to fetch, return undefined to stop fetching old history
233
256
  return { transactions: [], historyCursor: undefined }
234
257
  }
235
258
 
@@ -288,7 +311,6 @@ export class SolanaClarityMonitor extends BaseMonitor {
288
311
  const mappedTransactions = []
289
312
 
290
313
  for (const tx of transactions) {
291
- // we get the token name using the token.mintAddress
292
314
  const assetName = tx.token
293
315
  ? this.clarityApi.tokens.get(tx.token.mintAddress)?.name ?? 'unknown'
294
316
  : baseAsset.name
@@ -304,7 +326,7 @@ export class SolanaClarityMonitor extends BaseMonitor {
304
326
  txId: tx.id,
305
327
  from: [tx.from],
306
328
  coinAmount,
307
- confirmations: 1, // tx.confirmations, // avoid multiple notifications
329
+ confirmations: 1,
308
330
  date: tx.date,
309
331
  error: tx.error,
310
332
  data: {
@@ -317,7 +339,6 @@ export class SolanaClarityMonitor extends BaseMonitor {
317
339
  }
318
340
 
319
341
  if (tx.owner === address) {
320
- // send transaction
321
342
  item.to = Array.isArray(tx.to) ? undefined : tx.to
322
343
  item.feeAmount = baseAsset.currency.baseUnit(tx.fee) // in SOL
323
344
  item.feeCoinName = baseAsset.name
@@ -380,7 +401,6 @@ export class SolanaClarityMonitor extends BaseMonitor {
380
401
  }
381
402
 
382
403
  if (!after) {
383
- // guard against missing cursor on non-empty transactions
384
404
  console.warn('SolanaClarityMonitor missing cursor with transactions', {
385
405
  address,
386
406
  cursor,
@@ -417,60 +437,94 @@ export class SolanaClarityMonitor extends BaseMonitor {
417
437
 
418
438
  async getAccountsAndBalances({ refresh, address, accountState, walletAccount }) {
419
439
  const delegatedAccounts = accountState.tokenDelegationInfo?.delegatedAccounts || []
420
- const [accountInfo, { balances: splBalances, accounts: tokenAccounts }, delegatedBalances] =
421
- await Promise.all([
422
- this.clarityApi.getAccountInfo(address).catch(() => {}),
423
- this.clarityApi.getTokensBalancesAndAccounts({
424
- address,
425
- }),
426
- this.clarityApi.fetchDelegatedBalances({
427
- delegatedAccounts,
440
+ const [accountInfoRaw, tokensPayload, delegatedBalances] = await Promise.all([
441
+ this.#fetchAccountInfoWithRetry(address),
442
+ this.clarityApi.getTokensBalancesAndAccounts({ address }).catch((error) => {
443
+ console.warn('SolanaClarityMonitor getTokensBalancesAndAccounts failed', { address, error })
444
+ return null
445
+ }),
446
+ this.clarityApi.fetchDelegatedBalances({ delegatedAccounts, address }).catch((error) => {
447
+ console.warn('SolanaClarityMonitor fetchDelegatedBalances failed', { address, error })
448
+ return accountState.tokenDelegationInfo?.delegatedBalances ?? Object.create(null)
449
+ }),
450
+ ])
451
+
452
+ const accountInfo = this.#normalizeAccountInfoPayload(accountInfoRaw)
453
+ const hasAccountInfo = accountInfo != null
454
+ const accountSize = hasAccountInfo ? accountInfo.space ?? 0 : accountState.accountSize ?? 0
455
+ const baseBalanceWithoutStaking = hasAccountInfo
456
+ ? this.asset.currency.baseUnit(accountInfo.lamports ?? 0)
457
+ : accountState.balance ?? this.asset.currency.ZERO
458
+
459
+ let rentExemptAmount = accountState.rentExemptAmount ?? this.asset.currency.ZERO
460
+ if (hasAccountInfo) {
461
+ try {
462
+ rentExemptAmount = this.asset.currency.baseUnit(
463
+ await this.clarityApi.getMinimumBalanceForRentExemption(accountSize)
464
+ )
465
+ } catch (error) {
466
+ console.warn('SolanaClarityMonitor getMinimumBalanceForRentExemption failed', {
428
467
  address,
429
- }),
430
- ])
431
-
432
- const solBalance = accountInfo?.lamports || 0
433
-
434
- const accountSize = accountInfo?.space || 0
468
+ accountSize,
469
+ error,
470
+ })
471
+ }
472
+ }
435
473
 
436
- const rentExemptAmount = this.asset.currency.baseUnit(
437
- await this.clarityApi.getMinimumBalanceForRentExemption(accountSize)
438
- )
474
+ let ownerChanged = accountState.ownerChanged ?? false
475
+ if (hasAccountInfo) {
476
+ try {
477
+ ownerChanged = await this.clarityApi.ownerChanged(address, accountInfo)
478
+ } catch (error) {
479
+ console.warn('SolanaClarityMonitor ownerChanged failed', { address, error })
480
+ }
481
+ }
439
482
 
440
- const ownerChanged = await this.clarityApi.ownerChanged(address, accountInfo)
483
+ const splBalances =
484
+ tokensPayload?.balances && typeof tokensPayload.balances === 'object'
485
+ ? tokensPayload.balances
486
+ : Object.create(null)
487
+ const tokenAccounts = Array.isArray(tokensPayload?.accounts) ? tokensPayload.accounts : []
441
488
 
442
- const tokenBalances = mapValues(
443
- pickBy(splBalances, (_balance, name) => this.assets[name]), // filter unknown tokens
444
- (balance, name) => this.assets[name].currency.baseUnit(balance)
445
- )
489
+ const tokenBalances = tokensPayload
490
+ ? mapValues(
491
+ pickBy(splBalances, (_balance, name) => this.assets[name]), // filter unknown tokens
492
+ (balance, name) => this.assets[name].currency.baseUnit(balance)
493
+ )
494
+ : accountState.tokenBalances ?? Object.create(null)
446
495
 
447
496
  const solBalanceChanged = this.#balanceChanged({
448
497
  account: accountState,
449
498
  newAccount: {
450
- balance: this.asset.currency.baseUnit(solBalance), // balance without staking
499
+ balance: baseBalanceWithoutStaking,
451
500
  tokenBalances,
452
501
  },
453
502
  })
454
503
  const fetchStakingInfo =
455
- refresh ||
456
- solBalanceChanged ||
457
- this.tickCount[walletAccount] % this.ticksBetweenStakeFetches === 0
504
+ hasAccountInfo &&
505
+ (refresh ||
506
+ solBalanceChanged ||
507
+ this.tickCount[walletAccount] % this.ticksBetweenStakeFetches === 0)
458
508
 
459
509
  const staking =
460
510
  this.isStakingEnabled() && fetchStakingInfo
461
- ? await this.getStakingInfo({ address, accountState, walletAccount })
511
+ ? await this.getStakingInfo({ address, accountState, walletAccount }).catch((error) => {
512
+ console.warn('SolanaClarityMonitor getStakingInfo failed', { address, error })
513
+ return { ...accountState.stakingInfo, staking: this.staking }
514
+ })
462
515
  : { ...accountState.stakingInfo, staking: this.staking }
463
516
 
464
517
  const stakedBalance = this.asset.currency.baseUnit(staking.locked)
465
518
  const activatingBalance = this.asset.currency.baseUnit(staking.activating)
466
519
  const withdrawableBalance = this.asset.currency.baseUnit(staking.withdrawable)
467
520
  const pendingBalance = this.asset.currency.baseUnit(staking.pending)
468
- const balance = this.asset.currency
469
- .baseUnit(solBalance)
470
- .add(stakedBalance)
471
- .add(activatingBalance)
472
- .add(withdrawableBalance)
473
- .add(pendingBalance)
521
+ const balance = hasAccountInfo
522
+ ? baseBalanceWithoutStaking
523
+ .add(stakedBalance)
524
+ .add(activatingBalance)
525
+ .add(withdrawableBalance)
526
+ .add(pendingBalance)
527
+ : baseBalanceWithoutStaking
474
528
 
475
529
  return {
476
530
  account: {
@@ -535,9 +589,9 @@ export class SolanaClarityMonitor extends BaseMonitor {
535
589
  locked: this.asset.currency.baseUnit(stakingInfo.locked),
536
590
  activating: this.asset.currency.baseUnit(stakingInfo.activating),
537
591
  withdrawable: this.asset.currency.baseUnit(stakingInfo.withdrawable),
538
- pending: this.asset.currency.baseUnit(stakingInfo.pending), // still undelegating (not yet available for withdraw)
592
+ pending: this.asset.currency.baseUnit(stakingInfo.pending),
539
593
  earned: this.asset.currency.baseUnit(earned),
540
- accounts: stakingInfo.accounts, // Obj
594
+ accounts: stakingInfo.accounts,
541
595
  }
542
596
  }
543
597
  }
@@ -12,6 +12,16 @@ const DEFAULT_REMOTE_CONFIG = {
12
12
  }
13
13
 
14
14
  export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
15
+ #clarityInitialSyncDone = Object.create(null)
16
+
17
+ async #hasLoadedStakingInfo(walletAccount) {
18
+ const accountState = await this.aci.getAccountState({
19
+ assetName: this.asset.name,
20
+ walletAccount,
21
+ })
22
+ return accountState.stakingInfo?.loaded === true
23
+ }
24
+
15
25
  constructor({ wsApi, ...args }) {
16
26
  assert(wsApi, 'wsApi is required')
17
27
  super(args)
@@ -49,9 +59,16 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
49
59
  useCache: true,
50
60
  })
51
61
 
52
- const { accounts: tokenAccountsByOwner } = await this.clarityApi.getTokensBalancesAndAccounts({
53
- address,
54
- })
62
+ const tokenAccountsByOwner = await this.clarityApi
63
+ .getTokensBalancesAndAccounts({ address })
64
+ .then((result) => result.accounts ?? [])
65
+ .catch((error) => {
66
+ console.warn('SolanaWebsocketMonitor getTokensBalancesAndAccounts failed', {
67
+ address,
68
+ error,
69
+ })
70
+ return []
71
+ })
55
72
  this.tokenAccountsByOwner[walletAccount] = tokenAccountsByOwner
56
73
  const tokensAddresses = tokenAccountsByOwner.map((acc) => acc.tokenAccountAddress)
57
74
 
@@ -81,10 +98,14 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
81
98
  }
82
99
 
83
100
  async tick({ walletAccount, refresh }) {
84
- // we tick using Clarity only on startup or explicit refresh; otherwise we rely on WS events
85
- // and programSubscribe for new SPL / Token-2022 accounts (no periodic getTokensBalancesAndAccounts).
86
- if (refresh || this.tickCount[walletAccount] === 0) {
87
- return super.tick({ walletAccount, refresh }) // Clarity refresh or first tick
101
+ const shouldRunClarityTick = refresh || !this.#clarityInitialSyncDone[walletAccount]
102
+ if (!shouldRunClarityTick) return
103
+
104
+ // BaseMonitor increments tickCount even when tick throws.
105
+ // Keep retrying full sync until staking info is actually loaded.
106
+ await super.tick({ walletAccount, refresh })
107
+ if (await this.#hasLoadedStakingInfo(walletAccount)) {
108
+ this.#clarityInitialSyncDone[walletAccount] = true
88
109
  }
89
110
  }
90
111
 
@@ -181,7 +202,6 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
181
202
  }
182
203
 
183
204
  const newData = {
184
- // ...accountState, // we don't wanna update SOL "balance"
185
205
  tokenBalances: {
186
206
  ...accountState.tokenBalances,
187
207
  [tokenName]: this.assets[tokenName].currency.baseUnit(amount),
@@ -219,7 +239,6 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
219
239
  const balance = this.#computeTotalBalance({ amount, address, stakingInfo, walletAccount })
220
240
 
221
241
  const newData = {
222
- // ...accountState, // we don't wanna update "tokenBalances" with old values (after sending an SPL token we have 2 events one updating SOL balance and one updating tokenBalances)
223
242
  balance,
224
243
  }
225
244