@exodus/solana-api 3.29.6 → 3.30.1

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,34 @@
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.1](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.0...@exodus/solana-api@3.30.1) (2026-03-13)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix(solana-api): add RPC concurrency limit and fix historyCursor loss on error (#7550)
13
+
14
+ * fix(solana-api): improve fetchival error handling in fee payer client (#7570)
15
+
16
+
17
+
18
+ ## [3.30.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.6...@exodus/solana-api@3.30.0) (2026-03-10)
19
+
20
+
21
+ ### Features
22
+
23
+
24
+ * feat: switch Solana txs to Clarity (#7449)
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+
30
+ * fix: helius vote false param (#7486)
31
+
32
+
33
+
6
34
  ## [3.29.6](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.29.5...@exodus/solana-api@3.29.6) (2026-02-23)
7
35
 
8
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "3.29.6",
3
+ "version": "3.30.1",
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",
@@ -45,11 +45,11 @@
45
45
  "url-join": "^4.0.0"
46
46
  },
47
47
  "devDependencies": {
48
- "@exodus/asset": "^2.2.0",
48
+ "@exodus/asset": "^2.3.0",
49
49
  "@exodus/assets-testing": "^1.0.0",
50
50
  "@exodus/solana-web3.js": "^1.63.1-exodus.9-rc3"
51
51
  },
52
- "gitHead": "6ac0fce9b0cc8dc2fe3cb5b940f077ec36cfcae3",
52
+ "gitHead": "51cc10fa866ae472882aa09353e2425bc7d7031f",
53
53
  "bugs": {
54
54
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
55
55
  },
@@ -17,6 +17,7 @@ export const createAccountState = ({ assetList }) => {
17
17
  return class SolanaAccountState extends AccountState {
18
18
  static defaults = {
19
19
  cursor: '',
20
+ historyCursor: '',
20
21
  balance: asset.currency.ZERO,
21
22
  tokenBalances: Object.create(null),
22
23
  rentExemptAmount: asset.currency.ZERO,
package/src/api.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  TOKEN_PROGRAM_ID,
20
20
  } from '@exodus/solana-lib'
21
21
  import lodash from 'lodash'
22
+ import makeConcurrent from 'make-concurrent'
22
23
  import ms from 'ms'
23
24
  import urljoin from 'url-join'
24
25
 
@@ -41,6 +42,8 @@ const errorMessagesToRetry = [
41
42
  'Failed to query long-term storage; please try again',
42
43
  ]
43
44
 
45
+ const TX_DETAIL_FETCH_CONCURRENCY = 10
46
+
44
47
  // Tokens + SOL api support
45
48
  export class Api {
46
49
  constructor({ rpcUrl, wsUrl, assets, txsLimit }) {
@@ -230,8 +233,11 @@ export class Api {
230
233
  let txsId = txsResultsByAccount.flat() // merge arrays
231
234
  txsId = lodash.uniqBy(txsId, 'signature')
232
235
 
233
- // get txs details in parallel
234
- const txsDetails = await Promise.all(txsId.map((tx) => this.getTransactionById(tx.signature)))
236
+ // get txs details with concurrency limit to avoid overwhelming the RPC server
237
+ const fetchWithLimit = makeConcurrent((signature) => this.getTransactionById(signature), {
238
+ concurrency: TX_DETAIL_FETCH_CONCURRENCY,
239
+ })
240
+ const txsDetails = await Promise.all(txsId.map((tx) => fetchWithLimit(tx.signature)))
235
241
  txsDetails.forEach((txDetail) => {
236
242
  if (!txDetail) return
237
243
 
@@ -7,7 +7,7 @@ import urljoin from 'url-join'
7
7
 
8
8
  import { RpcApi } from './rpc-api.js'
9
9
 
10
- const CLARITY_URL = 'https://solana-clarity.a.exodus.io/api/v2/solana'
10
+ const CLARITY_URL = 'https://assets-gateway-clarity-api.a.exodus.io/assets/api/v2/solana'
11
11
 
12
12
  // Filter out nil and empty string values to prevent API validation errors (e.g., empty cursor)
13
13
  const cleanQuery = (obj) => omitBy(obj, (v) => isNil(v) || v === '')
@@ -47,11 +47,15 @@ export class ClarityApi extends RpcApi {
47
47
  })
48
48
  }
49
49
 
50
- async getTransactions(address, { cursor, limit, includeUnparsed = false } = Object.create(null)) {
50
+ async getTransactions(
51
+ address,
52
+ { after, before, limit, includeUnparsed = false } = Object.create(null)
53
+ ) {
51
54
  const result = await this.request(`/addresses/${encodeURIComponent(address)}/transactions`)
52
55
  .query(
53
56
  cleanQuery({
54
- cursor,
57
+ before,
58
+ after,
55
59
  limit,
56
60
  includeUnparsed,
57
61
  })
package/src/fee-payer.js CHANGED
@@ -64,14 +64,30 @@ export const feePayerClientFactory = ({
64
64
  }
65
65
  }
66
66
 
67
- return fetchival(`${feePayerApiUrl}/transactions/sponsor`, {
68
- mode: 'cors',
69
- cache: 'no-cache',
70
- timeout: ms('10s'),
71
- headers,
72
- }).post({
73
- transaction: encodedTransaction,
74
- })
67
+ try {
68
+ return await fetchival(`${feePayerApiUrl}/transactions/sponsor`, {
69
+ mode: 'cors',
70
+ cache: 'no-cache',
71
+ timeout: ms('10s'),
72
+ headers,
73
+ }).post({
74
+ transaction: encodedTransaction,
75
+ })
76
+ } catch (err) {
77
+ let nerr = err
78
+ if (err.response) {
79
+ try {
80
+ const data = await err.response.text()
81
+ nerr = new Error(`${err.response.status}: ${data}`)
82
+ } catch {}
83
+ }
84
+
85
+ if (err.response?.status) {
86
+ nerr.status = err.response.status
87
+ }
88
+
89
+ throw nerr
90
+ }
75
91
  }
76
92
 
77
93
  /**
@@ -84,10 +100,7 @@ export const feePayerClientFactory = ({
84
100
  try {
85
101
  response = await makeSponsorRequest({ encodedTransaction })
86
102
  } catch (error) {
87
- if (
88
- requireAuthentication &&
89
- (error.response?.status === 401 || error.response?.status === 403)
90
- ) {
103
+ if (requireAuthentication && (error.status === 401 || error.status === 403)) {
91
104
  console.warn('Authentication failed, retrying...')
92
105
  if (authClient) {
93
106
  await authClient._authenticate()
@@ -18,21 +18,21 @@ const TICKS_BETWEEN_STAKE_FETCHES = 5
18
18
  const TX_STALE_AFTER = ms('2m') // mark txs as dropped after N minutes
19
19
 
20
20
  export class SolanaClarityMonitor extends BaseMonitor {
21
+ #maxFetchLimitPerTick
22
+
21
23
  constructor({
22
24
  clarityApi,
23
- rpcApi,
24
25
  includeUnparsed = false,
25
26
  ticksBetweenHistoryFetches = TICKS_BETWEEN_HISTORY_FETCHES,
26
27
  ticksBetweenStakeFetches = TICKS_BETWEEN_STAKE_FETCHES,
27
28
  txsLimit,
29
+ maxFetchLimitPerTick = 5,
28
30
  shouldUpdateBalanceBeforeHistory = true,
29
31
  ...args
30
32
  }) {
31
33
  super(args)
32
34
  assert(clarityApi, 'clarityApi is required')
33
35
  this.clarityApi = clarityApi
34
- this.rpcApi = rpcApi
35
- this.cursors = Object.create(null)
36
36
  this.assets = Object.create(null)
37
37
  this.staking = DEFAULT_REMOTE_CONFIG.staking
38
38
  this.ticksBetweenStakeFetches = ticksBetweenStakeFetches
@@ -40,6 +40,7 @@ export class SolanaClarityMonitor extends BaseMonitor {
40
40
  this.shouldUpdateBalanceBeforeHistory = shouldUpdateBalanceBeforeHistory
41
41
  this.includeUnparsed = includeUnparsed
42
42
  this.txsLimit = txsLimit
43
+ this.#maxFetchLimitPerTick = maxFetchLimitPerTick
43
44
  }
44
45
 
45
46
  setServer(config = Object.create(null)) {
@@ -53,11 +54,6 @@ export class SolanaClarityMonitor extends BaseMonitor {
53
54
  this.staking = staking
54
55
  }
55
56
 
56
- hasNewCursor({ walletAccount, cursorState }) {
57
- const { cursor } = cursorState
58
- return this.cursors[walletAccount] !== cursor
59
- }
60
-
61
57
  async emitUnknownTokensEvent({ tokenAccounts }) {
62
58
  const tokensList = await this.clarityApi.getWalletTokensList({ tokenAccounts })
63
59
  const unknownTokensList = tokensList.filter((mintAddress) => {
@@ -101,7 +97,7 @@ export class SolanaClarityMonitor extends BaseMonitor {
101
97
  const txSet = await this.aci.getTxLog({ assetName, walletAccount })
102
98
  const { stale } = this.getUnconfirmed({ txSet, staleTxAge: TX_STALE_AFTER })
103
99
  if (stale.length > 0) {
104
- clearedLogItems[assetName] = lodash.unionBy(logItemsByAsset[assetName], stale, 'txId')
100
+ clearedLogItems[assetName] = lodash.unionBy(logItemsByAsset[assetName] ?? [], stale, 'txId')
105
101
  }
106
102
  }
107
103
 
@@ -137,9 +133,9 @@ export class SolanaClarityMonitor extends BaseMonitor {
137
133
  })
138
134
  const hasUnconfirmedSentTx = [...baseAssetTxLog].some((tx) => tx.pending && tx.sent)
139
135
 
140
- const shouldUpdateHistory =
136
+ const shouldUpdateLatestHistory =
141
137
  refresh || isHistoryUpdateTick || balanceChanged || hasUnconfirmedSentTx
142
- const shouldUpdateOnlyBalance = balanceChanged && !shouldUpdateHistory
138
+ const shouldUpdateOnlyBalance = balanceChanged && !shouldUpdateLatestHistory
143
139
 
144
140
  // start a batch
145
141
  const batch = this.aci.createOperationsBatch()
@@ -150,50 +146,147 @@ export class SolanaClarityMonitor extends BaseMonitor {
150
146
  await this.emitUnknownTokensEvent({ tokenAccounts })
151
147
  }
152
148
 
153
- if (shouldUpdateHistory) {
154
- const { logItemsByAsset, cursorState } = await this.getHistory({
149
+ const newCursorState = {
150
+ cursor: accountState.cursor,
151
+ historyCursor: accountState.historyCursor,
152
+ }
153
+
154
+ const newTransactions = []
155
+
156
+ if (shouldUpdateLatestHistory) {
157
+ const { transactions: latestTransactions, cursorState: latestHistoryCursorState } =
158
+ await this.getLatestHistory({
159
+ address,
160
+ accountState,
161
+ walletAccount,
162
+ refresh,
163
+ tokenAccounts,
164
+ })
165
+
166
+ if (latestHistoryCursorState.cursor) {
167
+ newCursorState.cursor = latestHistoryCursorState.cursor
168
+ }
169
+
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
174
+ }
175
+
176
+ newTransactions.push(...latestTransactions)
177
+ }
178
+
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({
155
182
  address,
156
- accountState,
157
- walletAccount,
158
- refresh,
159
- tokenAccounts,
183
+ historyCursor: newCursorState.historyCursor,
160
184
  })
161
185
 
162
- const cursorChanged = this.hasNewCursor({ walletAccount, cursorState })
186
+ newTransactions.push(...historyTransactions)
187
+ // Always update so we persist historyCursor = undefined when history is exhausted
188
+ newCursorState.historyCursor = historyCursor
189
+ }
190
+
191
+ await this.emitUnknownTokensEvent({ tokenAccounts })
163
192
 
193
+ if (newTransactions.length > 0) {
164
194
  // update all state at once
165
- const clearedLogItems = await this.markStaleTransactions({ walletAccount, logItemsByAsset })
195
+ const clearedLogItems = await this.markStaleTransactions({
196
+ walletAccount,
197
+ logItemsByAsset: this.mapTransactionsToLogItems({ transactions: newTransactions, address }),
198
+ })
199
+
166
200
  this.updateTxLogByAssetBatch({
167
201
  logItemsByAsset: clearedLogItems,
168
202
  walletAccount,
169
203
  refresh,
170
204
  batch,
171
205
  })
172
- this.updateState({ account, cursorState, walletAccount, staking, batch })
173
- await this.emitUnknownTokensEvent({ tokenAccounts })
174
- if (refresh || cursorChanged) {
175
- this.cursors[walletAccount] = cursorState.cursor
176
- }
177
206
  }
178
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
+
179
217
  // close batch
180
218
  await this.aci.executeOperationsBatch(batch)
181
219
  }
182
220
 
183
- async getHistory({ address, accountState, refresh, tokenAccounts } = Object.create(null)) {
184
- const cursor = refresh ? '' : accountState.cursor
185
- const baseAsset = this.asset
221
+ async fetchOldHistory({ address, historyCursor }) {
222
+ if (!historyCursor) return { transactions: [], historyCursor: undefined }
186
223
 
187
- // TODO: switch to this.clarityApi.getTransactions once ready!!
188
- // and remove this.rpcApi from constructor and create-asset-utils.js
189
- const { transactions, newCursor } = await this.rpcApi.getTransactions(address, {
190
- cursor,
191
- includeUnparsed: this.includeUnparsed,
192
- limit: this.txsLimit,
193
- tokenAccounts,
224
+ try {
225
+ const { transactions, before } = await this.clarityApi.getTransactions(address, {
226
+ before: historyCursor,
227
+ includeUnparsed: this.includeUnparsed,
228
+ limit: this.txsLimit,
229
+ })
230
+
231
+ if (!transactions || transactions.length === 0 || !before) {
232
+ // no more transactions to fetch, return undefined to stop fetching old history
233
+ return { transactions: [], historyCursor: undefined }
234
+ }
235
+
236
+ const transactionsWithSilentSound = transactions.map((tx) => ({
237
+ ...tx,
238
+ data: { ...tx.data, silentSound: true },
239
+ }))
240
+
241
+ return { transactions: transactionsWithSilentSound, historyCursor: before }
242
+ } catch (error) {
243
+ console.warn('SolanaClarityMonitor fetchOldHistory failed', {
244
+ address,
245
+ historyCursor,
246
+ error,
247
+ })
248
+
249
+ // Preserve cursor on transient errors so fetching retries on the next tick
250
+ return { transactions: [], historyCursor }
251
+ }
252
+ }
253
+
254
+ async getLatestHistory({ address, accountState, refresh, tokenAccounts } = Object.create(null)) {
255
+ if (refresh || !accountState.cursor) {
256
+ const { transactions, before, after } = await this.clarityApi.getTransactions(address, {
257
+ after: undefined,
258
+ includeUnparsed: this.includeUnparsed,
259
+ limit: this.txsLimit,
260
+ })
261
+
262
+ return {
263
+ transactions,
264
+ hasNewTxs: transactions.length > 0,
265
+ cursorState: {
266
+ cursor: after,
267
+ historyCursor: before,
268
+ },
269
+ }
270
+ }
271
+
272
+ const { transactions, after: newAfterCursor } = await this.fetchLatestTransactions({
273
+ address,
274
+ afterCursor: accountState.cursor,
194
275
  })
195
276
 
277
+ return {
278
+ transactions,
279
+ hasNewTxs: transactions.length > 0,
280
+ cursorState: {
281
+ cursor: newAfterCursor,
282
+ },
283
+ }
284
+ }
285
+
286
+ mapTransactionsToLogItems({ transactions, address }) {
287
+ const baseAsset = this.asset
196
288
  const mappedTransactions = []
289
+
197
290
  for (const tx of transactions) {
198
291
  // we get the token name using the token.mintAddress
199
292
  const assetName = tx.token
@@ -218,6 +311,7 @@ export class SolanaClarityMonitor extends BaseMonitor {
218
311
  staking: tx.staking || null,
219
312
  unparsed: !!tx.unparsed,
220
313
  swapTx: !!(tx.data && tx.data.inner),
314
+ silentSound: !!tx.data?.silentSound,
221
315
  },
222
316
  currencies: { [assetName]: asset.currency, [feeAsset.name]: feeAsset.currency },
223
317
  }
@@ -262,12 +356,63 @@ export class SolanaClarityMonitor extends BaseMonitor {
262
356
  mappedTransactions.push(item)
263
357
  }
264
358
 
265
- const logItemsByAsset = lodash.groupBy(mergeByTxId(mappedTransactions), 'coinName')
266
- return {
267
- logItemsByAsset,
268
- hasNewTxs: transactions.length > 0,
269
- cursorState: { cursor: newCursor },
359
+ return lodash.groupBy(mergeByTxId(mappedTransactions), 'coinName')
360
+ }
361
+
362
+ async fetchLatestTransactions({ address, afterCursor }) {
363
+ let cursor = afterCursor
364
+ let allTransactions = []
365
+ let newAfterCursor = afterCursor
366
+ let fetchCount = 0
367
+
368
+ while (fetchCount < this.#maxFetchLimitPerTick) {
369
+ try {
370
+ const { transactions, after } = await this.clarityApi.getTransactions(address, {
371
+ after: cursor,
372
+ includeUnparsed: this.includeUnparsed,
373
+ limit: this.txsLimit,
374
+ })
375
+
376
+ fetchCount += 1
377
+
378
+ if (!transactions || transactions.length === 0) {
379
+ break
380
+ }
381
+
382
+ if (!after) {
383
+ // guard against missing cursor on non-empty transactions
384
+ console.warn('SolanaClarityMonitor missing cursor with transactions', {
385
+ address,
386
+ cursor,
387
+ afterCursor,
388
+ fetchCount,
389
+ transactionsCount: transactions.length,
390
+ })
391
+ break
392
+ }
393
+
394
+ allTransactions = [...allTransactions, ...transactions]
395
+
396
+ if (after === cursor) {
397
+ newAfterCursor = after
398
+ break
399
+ }
400
+
401
+ cursor = after
402
+ newAfterCursor = after
403
+ } catch (error) {
404
+ console.warn('SolanaClarityMonitor fetchLatestTransactions failed', {
405
+ address,
406
+ cursor,
407
+ afterCursor,
408
+ fetchCount,
409
+ error,
410
+ })
411
+ break
412
+ }
270
413
  }
414
+
415
+ return { transactions: allTransactions, after: newAfterCursor }
271
416
  }
272
417
 
273
418
  async getAccountsAndBalances({ refresh, address, accountState, walletAccount }) {
@@ -367,17 +512,18 @@ export class SolanaClarityMonitor extends BaseMonitor {
367
512
  stakingInfo: staking,
368
513
  ...cursorState,
369
514
  }
515
+
370
516
  return this.updateAccountStateBatch({ assetName, walletAccount, newData, batch })
371
517
  }
372
518
 
373
519
  async getStakingInfo({ address, accountState, walletAccount }) {
374
520
  const stakingInfo = await this.clarityApi.getStakeAccountsInfo(address)
375
- let earned = accountState.stakingInfo.earned.toBaseString()
521
+ let earned = accountState.stakingInfo?.earned?.toBaseString() ?? '0'
376
522
  try {
377
523
  const rewards = await this.clarityApi.getRewards(address)
378
524
  earned = rewards
379
525
  } catch (error) {
380
- console.warn(error)
526
+ console.warn('SolanaClarityMonitor getStakingInfo failed', { address, error })
381
527
  }
382
528
 
383
529
  return {
@@ -26,7 +26,6 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
26
26
  async beforeStart() {
27
27
  this.assets = await this.aci.getAssetsForNetwork({ baseAssetName: this.asset.name })
28
28
  this.clarityApi.setTokens(this.assets)
29
- this.rpcApi.setTokens(this.assets)
30
29
 
31
30
  const walletAccounts = await this.aci.getWalletAccounts({ assetName: this.asset.name })
32
31
  await Promise.all(
@@ -50,7 +49,7 @@ export class SolanaWebsocketMonitor extends SolanaClarityMonitor {
50
49
  useCache: true,
51
50
  })
52
51
 
53
- const { accounts: tokenAccountsByOwner } = await this.rpcApi.getTokensBalancesAndAccounts({
52
+ const { accounts: tokenAccountsByOwner } = await this.clarityApi.getTokensBalancesAndAccounts({
54
53
  address,
55
54
  })
56
55
  this.tokenAccountsByOwner[walletAccount] = tokenAccountsByOwner
package/src/ws-api.js CHANGED
@@ -7,7 +7,7 @@ import { parseTransaction } from './tx-parser.js'
7
7
  import { isSolAddressPoisoningTx } from './txs-utils.js'
8
8
 
9
9
  // Triton Whirligig WebSocket
10
- const TRITON_WS_ENDPOINT = 'wss://solana-triton.a.exodus.io/whirligig' // pointing to: wss://exodus-solanama-6db3.mainnet.rpcpool.com/<token>/whirligig
10
+ // const TRITON_WS_ENDPOINT = 'wss://solana-triton.a.exodus.io/whirligig' // pointing to: wss://exodus-solanama-6db3.mainnet.rpcpool.com/<token>/whirligig
11
11
  // Helius Enhanced WebSocket (https://www.helius.dev/docs/enhanced-websockets)
12
12
  const HELIUS_WS_URL = 'wss://solana-helius-wss.a.exodus.io/ws'
13
13
 
@@ -31,7 +31,7 @@ export class WsApi {
31
31
  }
32
32
 
33
33
  setWsEndpoint(wsUrl) {
34
- this.wsUrl = wsUrl || TRITON_WS_ENDPOINT
34
+ this.wsUrl = wsUrl || HELIUS_WS_URL
35
35
  }
36
36
 
37
37
  /** True when using Helius Enhanced WebSocket (different transactionSubscribe params and notification shape). */
@@ -189,7 +189,7 @@ export class WsApi {
189
189
  maxSupportedTransactionVersion: 255,
190
190
  }
191
191
  const filter = this.#isHelius()
192
- ? { accountInclude: difference }
192
+ ? { vote: false, accountInclude: difference }
193
193
  : { vote: false, accounts: { include: difference } }
194
194
  conn.send({
195
195
  jsonrpc: '2.0',