@exodus/solana-api 3.30.7 → 3.30.9

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.9](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.8...@exodus/solana-api@3.30.9) (2026-04-04)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: sponsored Solana token sends when CU simulation fails (#7707)
13
+
14
+
15
+
16
+ ## [3.30.8](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.7...@exodus/solana-api@3.30.8) (2026-04-02)
17
+
18
+ **Note:** Version bump only for package @exodus/solana-api
19
+
20
+
21
+
22
+
23
+
6
24
  ## [3.30.7](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.30.6...@exodus/solana-api@3.30.7) (2026-04-01)
7
25
 
8
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "3.30.7",
3
+ "version": "3.30.9",
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": "3a8c58c8457a1d096ee5d3ecb2eea9e82b69ecb7",
52
+ "gitHead": "a2cd3fbc02d07f1c3dc127be4d7896292d2dc967",
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
@@ -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
  }
@@ -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
  }
@@ -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)