@exodus/solana-api 3.18.2 → 3.20.0

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,36 @@
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.20.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.19.0...@exodus/solana-api@3.20.0) (2025-06-11)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat(solana): implement unconfirmedSent/unconfirmedReceived in solana balances (#5802)
13
+
14
+
15
+
16
+ ## [3.19.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.18.2...@exodus/solana-api@3.19.0) (2025-06-10)
17
+
18
+
19
+ ### Features
20
+
21
+
22
+ * feat: support SPL batch send monitoring (#5848)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+
28
+ * fix: SOL amount NaN case (#5713)
29
+
30
+ * fix: SOL check fot NFT rent (#5792)
31
+
32
+ * fix: SOL from/to addresses for token txs (#5751)
33
+
34
+
35
+
6
36
  ## [3.18.2](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.18.1...@exodus/solana-api@3.18.2) (2025-05-07)
7
37
 
8
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "3.18.2",
3
+ "version": "3.20.0",
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,7 +33,6 @@
33
33
  "@exodus/solana-lib": "^3.11.1",
34
34
  "@exodus/solana-meta": "^2.0.2",
35
35
  "@exodus/timer": "^1.1.1",
36
- "bn.js": "^4.11.0",
37
36
  "debug": "^4.1.1",
38
37
  "delay": "^4.0.1",
39
38
  "lodash": "^4.17.11",
@@ -47,7 +46,7 @@
47
46
  "@exodus/assets-testing": "^1.0.0",
48
47
  "@exodus/solana-web3.js": "^1.63.1-exodus.9-rc3"
49
48
  },
50
- "gitHead": "dcfc8ac65a86aad50baf92955a8cf18c0e641c4d",
49
+ "gitHead": "a11096f37c71032ca704876059c11b2e55cc592a",
51
50
  "bugs": {
52
51
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
53
52
  },
package/src/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import createApiCJS from '@exodus/asset-json-rpc'
2
2
  import { memoizeLruCache } from '@exodus/asset-lib'
3
- import { memoize } from '@exodus/basic-utils'
3
+ import { memoize, pickBy } from '@exodus/basic-utils'
4
4
  import wretch from '@exodus/fetch/wretch'
5
5
  import { retry } from '@exodus/simple-retry'
6
6
  import {
@@ -16,7 +16,6 @@ import {
16
16
  TOKEN_2022_PROGRAM_ID,
17
17
  TOKEN_PROGRAM_ID,
18
18
  } from '@exodus/solana-lib'
19
- import BN from 'bn.js'
20
19
  import lodash from 'lodash'
21
20
  import ms from 'ms'
22
21
  import urljoin from 'url-join'
@@ -32,6 +31,7 @@ const createApi = createApiCJS.default || createApiCJS
32
31
  const RPC_URL = 'https://solana.a.exodus.io' // https://vip-api.mainnet-beta.solana.com/, https://api.mainnet-beta.solana.com
33
32
  const WS_ENDPOINT = 'wss://solana.a.exodus.io/ws' // not standard across all node providers (we're compatible only with Quicknode)
34
33
  const FORCE_HTTP = true // use https over ws
34
+ const ZERO = BigInt(0)
35
35
 
36
36
  const errorMessagesToRetry = [
37
37
  'Blockhash not found',
@@ -77,7 +77,7 @@ export class Api {
77
77
  }
78
78
 
79
79
  setTokens(assets = {}) {
80
- const solTokens = lodash.pickBy(assets, (asset) => asset.assetType === this.tokenAssetType)
80
+ const solTokens = pickBy(assets, (asset) => asset.assetType === this.tokenAssetType)
81
81
  this.tokens = new Map(Object.values(solTokens).map((v) => [v.mintAddress, v]))
82
82
  }
83
83
 
@@ -399,18 +399,18 @@ export class Api {
399
399
  (balance) => balance.accountIndex === postBalance.accountIndex
400
400
  )
401
401
 
402
- const preAmount = new BN(lodash.get(preBalance, 'uiTokenAmount.amount', '0'), 10)
403
- const postAmount = new BN(lodash.get(postBalance, 'uiTokenAmount.amount', '0'), 10)
402
+ const preAmount = BigInt(preBalance?.uiTokenAmount?.amount ?? '0')
403
+ const postAmount = BigInt(postBalance?.uiTokenAmount?.amount ?? '0')
404
404
 
405
- const amount = postAmount.sub(preAmount)
405
+ const amount = postAmount - preAmount
406
406
 
407
- if (!tokenAccount || amount.isZero()) return null
407
+ if (!tokenAccount || amount === ZERO) return null
408
408
 
409
409
  // This is not perfect as there could be multiple same-token transfers in single
410
410
  // transaction, but our wallet only supports one transaction with single txId
411
411
  // so we are picking first that matches (correct token + type - send or receive)
412
412
  const match = innerInstructions.find((inner) => {
413
- const targetOwner = amount.isNeg() ? ownerAddress : null
413
+ const targetOwner = amount < ZERO ? ownerAddress : null
414
414
  return (
415
415
  inner.token.mintAddress === tokenAccount.mintAddress && targetOwner === inner.owner
416
416
  )
@@ -433,7 +433,7 @@ export class Api {
433
433
  owner,
434
434
  from,
435
435
  to,
436
- amount: amount.abs().toString(), // inconsistent with the rest, but it can and did overflow
436
+ amount: (amount < ZERO ? -amount : amount).toString(), // inconsistent with the rest, but it can and did overflow
437
437
  fee: 0,
438
438
  token: tokenAccount,
439
439
  data: {
@@ -563,6 +563,38 @@ export class Api {
563
563
  })
564
564
  .filter((ix) => !!ix)
565
565
 
566
+ // Collect inner instructions into batch sends
567
+ for (let i = 0; i < innerInstructions.length - 1; i++) {
568
+ const tx = innerInstructions[i]
569
+
570
+ for (let j = i + 1; j < innerInstructions.length; j++) {
571
+ const next = innerInstructions[j]
572
+ if (
573
+ tx.id === next.id &&
574
+ tx.token === next.token &&
575
+ tx.owner === ownerAddress &&
576
+ tx.from === next.from
577
+ ) {
578
+ if (!tx.data) {
579
+ tx.data = { sent: [{ address: tx.to, amount: tx.amount }] }
580
+ tx.to = [tx.to]
581
+ tx.fee = 0
582
+ }
583
+
584
+ tx.data.sent.push({
585
+ address: next.to,
586
+ amount: next.amount,
587
+ })
588
+ tx.to.push(next.to)
589
+
590
+ tx.amount += next.amount
591
+
592
+ innerInstructions.splice(j, 1)
593
+ j--
594
+ }
595
+ }
596
+ }
597
+
566
598
  // program:type tells us if it's a SOL or Token transfer
567
599
 
568
600
  const stakeTx = lodash.find(instructions, { program: 'system', type: 'createAccountWithSeed' })
@@ -631,12 +663,26 @@ export class Api {
631
663
  }
632
664
  }
633
665
 
666
+ const accountIndexes = accountKeys.reduce((acc, key, i) => {
667
+ const hasKnownOwner = tokenAccountsByOwner.some(
668
+ (tokenAccount) => tokenAccount.tokenAccountAddress === key.pubkey
669
+ )
670
+
671
+ acc[i] = {
672
+ ...key,
673
+ owner: hasKnownOwner ? ownerAddress : null, // not know (like in an outgoing tx)
674
+ }
675
+
676
+ return acc
677
+ }, Object.create(null)) // { 0: { pubkey, owner }, 1: { ... }, ... }
678
+
634
679
  // Parse Token txs
635
680
  const tokenTxs = this._parseTokenTransfers({
636
681
  instructions,
637
682
  tokenAccountsByOwner,
638
683
  ownerAddress,
639
684
  fee,
685
+ accountIndexes,
640
686
  preTokenBalances,
641
687
  postTokenBalances,
642
688
  })
@@ -650,20 +696,6 @@ export class Api {
650
696
  }))
651
697
  } else if (preTokenBalances && postTokenBalances) {
652
698
  // probably a DEX program is involved (multiple instructions), compute balance changes
653
-
654
- const accountIndexes = accountKeys.reduce((acc, key, i) => {
655
- const hasKnownOwner = tokenAccountsByOwner.some(
656
- (tokenAccount) => tokenAccount.tokenAccountAddress === key.pubkey
657
- )
658
-
659
- acc[i] = {
660
- ...key,
661
- owner: hasKnownOwner ? ownerAddress : null,
662
- }
663
-
664
- return acc
665
- }, Object.create(null))
666
-
667
699
  // group by owner and supported token
668
700
  const preBalances = preTokenBalances.filter((t) => {
669
701
  return (
@@ -740,6 +772,7 @@ export class Api {
740
772
  tokenAccountsByOwner,
741
773
  ownerAddress,
742
774
  fee,
775
+ accountIndexes = {},
743
776
  preTokenBalances,
744
777
  postTokenBalances,
745
778
  }) {
@@ -769,11 +802,27 @@ export class Api {
769
802
  delete tokenAccount.balance
770
803
  delete tokenAccount.owner
771
804
 
805
+ // If it's a sending tx we want to have the destination's owner as "to" address
806
+ let to = ownerAddress
807
+ let from = ownerAddress
808
+ if (isSending) {
809
+ to = destination // token account address (trying to get the owner below, we don't always have postTokenBalances...)
810
+ postTokenBalances.forEach((t) => {
811
+ if (accountIndexes[t.accountIndex].pubkey === destination) to = t.owner
812
+ })
813
+ } else {
814
+ // is receiving tx
815
+ from = source // token account address
816
+ preTokenBalances.forEach((t) => {
817
+ if (accountIndexes[t.accountIndex].pubkey === source) from = t.owner
818
+ })
819
+ }
820
+
772
821
  tokenTxs.push({
773
822
  owner,
774
823
  token: tokenAccount,
775
- from: isSending ? ownerAddress : source,
776
- to: isSending ? destination : ownerAddress,
824
+ from,
825
+ to,
777
826
  amount: Number(amount || tokenAmount?.amount || 0), // supporting types: transfer, transferChecked, transferCheckedWithFee
778
827
  fee: isSending ? fee : 0, // in lamports
779
828
  })
@@ -1034,7 +1083,7 @@ export class Api {
1034
1083
  accounts[addr].canWithdraw = state === 'inactive'
1035
1084
  accounts[addr].stake = Number(accounts[addr].stake) || 0 // active staked amount
1036
1085
  totalStake += accounts[addr].stake
1037
- locked += ['active', 'activating'].includes(accounts[addr].state) ? lamports : 0
1086
+ locked += accounts[addr].state === 'active' ? lamports : 0
1038
1087
  activating += accounts[addr].state === 'activating' ? lamports : 0
1039
1088
  withdrawable += accounts[addr].canWithdraw ? lamports : 0
1040
1089
  pending += accounts[addr].isDeactivating ? lamports : 0
@@ -210,7 +210,7 @@ export const createUnsignedTxForSend = async ({
210
210
 
211
211
  // differentiate between SOL and Solana token
212
212
  let isEnoughForRent = false
213
- if (asset.name === baseAsset.name) {
213
+ if (asset.name === baseAsset.name && !nft) {
214
214
  // sending SOL
215
215
  isEnoughForRent = amount.gte(rentExemptAmount)
216
216
  } else {
@@ -8,7 +8,15 @@ export const getBalancesFactory =
8
8
  ({ asset, accountState, txLog }) => {
9
9
  const zero = asset.currency.ZERO
10
10
 
11
- const { balance, locked, activating, withdrawable, pending } = fixBalances({
11
+ const {
12
+ balance,
13
+ locked,
14
+ activating,
15
+ withdrawable,
16
+ pending,
17
+ unconfirmedSent,
18
+ unconfirmedReceived,
19
+ } = fixBalances({
12
20
  txLog,
13
21
  balance: getBalanceFromAccountState({ asset, accountState }),
14
22
  locked: accountState.stakingInfo?.locked || zero,
@@ -16,6 +24,8 @@ export const getBalancesFactory =
16
24
  activating: accountState.stakingInfo?.activating || zero,
17
25
  pending: accountState.stakingInfo?.pending || zero,
18
26
  asset,
27
+ unconfirmedSent: zero,
28
+ unconfirmedReceived: zero,
19
29
  })
20
30
  if (asset.baseAsset.name !== asset.name) {
21
31
  return {
@@ -30,6 +40,7 @@ export const getBalancesFactory =
30
40
 
31
41
  const balanceWithoutStaking = balance
32
42
  .sub(locked)
43
+ .sub(activating)
33
44
  .sub(withdrawable)
34
45
  .sub(pending)
35
46
  .clampLowerZero()
@@ -50,6 +61,7 @@ export const getBalancesFactory =
50
61
  const stakeable = spendable
51
62
 
52
63
  const staked = locked
64
+ const unstaked = withdrawable
53
65
  const unstaking = pending
54
66
 
55
67
  const staking = activating || zero
@@ -63,10 +75,13 @@ export const getBalancesFactory =
63
75
  spendable,
64
76
  stakeable,
65
77
  staked,
78
+ unstaked,
66
79
  staking,
67
80
  unstaking,
68
81
  networkReserve,
69
82
  walletReserve: zero,
83
+ unconfirmedSent,
84
+ unconfirmedReceived,
70
85
  }
71
86
  }
72
87
 
@@ -78,40 +93,56 @@ const fixBalances = ({
78
93
  activating,
79
94
  pending,
80
95
  asset,
96
+ unconfirmedSent,
97
+ unconfirmedReceived,
81
98
  }) => {
82
99
  for (const tx of txLog) {
83
- if ((tx.sent || tx.data.staking) && tx.pending && !tx.error) {
100
+ if (!tx.pending || tx.error) {
101
+ continue
102
+ }
103
+
104
+ if (tx.data.staking) {
105
+ if (tx.coinAmount.unitType.equals(tx.feeAmount.unitType)) {
106
+ unconfirmedSent = unconfirmedSent.add(tx.feeAmount)
107
+ }
108
+
109
+ // staking tx
110
+ switch (tx.data.staking?.method) {
111
+ case 'delegate':
112
+ activating = activating.add(tx.coinAmount.abs())
113
+ break
114
+ case 'withdraw':
115
+ withdrawable = asset.currency.ZERO
116
+ break
117
+ case 'undelegate':
118
+ pending = pending.add(locked).add(activating)
119
+ locked = asset.currency.ZERO
120
+ activating = asset.currency.ZERO
121
+ break
122
+ }
123
+ } else if (tx.sent) {
84
124
  if (tx.coinAmount.unitType.equals(tx.feeAmount.unitType)) {
85
- balance = balance.sub(tx.feeAmount)
125
+ unconfirmedSent = unconfirmedSent.add(tx.feeAmount)
86
126
  }
87
127
 
88
- if (tx.data.staking) {
89
- // staking tx
90
- switch (tx.data.staking?.method) {
91
- case 'delegate':
92
- locked = locked.add(tx.coinAmount.abs())
93
- break
94
- case 'withdraw':
95
- withdrawable = asset.currency.ZERO
96
- break
97
- case 'undelegate':
98
- pending = pending.add(locked)
99
- locked = asset.currency.ZERO
100
- break
101
- }
102
- } else {
103
- // coinAmount is negative for sent tx
104
- balance = balance.sub(tx.coinAmount.abs())
128
+ unconfirmedSent = unconfirmedSent.add(tx.coinAmount.abs())
129
+ } else if (tx.received) {
130
+ if (tx.coinAmount.unitType.equals(tx.feeAmount.unitType)) {
131
+ unconfirmedReceived = unconfirmedReceived.sub(tx.feeAmount)
105
132
  }
133
+
134
+ unconfirmedReceived = unconfirmedReceived.add(tx.coinAmount.abs())
106
135
  }
107
136
  }
108
137
 
109
138
  return {
110
- balance: balance.clampLowerZero(),
139
+ balance: balance.sub(unconfirmedSent).clampLowerZero(),
111
140
  locked: locked.clampLowerZero(),
112
141
  withdrawable: withdrawable.clampLowerZero(),
113
142
  activating: activating.clampLowerZero(),
114
143
  pending: pending.clampLowerZero(),
144
+ unconfirmedSent,
145
+ unconfirmedReceived,
115
146
  }
116
147
  }
117
148
 
@@ -217,7 +217,7 @@ export class SolanaMonitor extends BaseMonitor {
217
217
  if (assetName === 'unknown' || !asset) continue // skip unknown tokens
218
218
  const feeAsset = asset.feeAsset
219
219
 
220
- const coinAmount = asset.currency.baseUnit(tx.amount)
220
+ const coinAmount = tx.amount ? asset.currency.baseUnit(tx.amount) : asset.currency.ZERO
221
221
 
222
222
  const item = {
223
223
  coinName: assetName,
@@ -245,7 +245,7 @@ export class SolanaMonitor extends BaseMonitor {
245
245
  if (tx.data?.sent) {
246
246
  item.data.sent = tx.data.sent.map((s) => ({
247
247
  address: s.address,
248
- amount: baseAsset.currency.baseUnit(s.amount).toDefaultString({ unit: true }),
248
+ amount: asset.currency.baseUnit(s.amount).toDefaultString({ unit: true }),
249
249
  }))
250
250
  }
251
251
 
@@ -279,10 +279,9 @@ export class SolanaMonitor extends BaseMonitor {
279
279
  mappedTransactions.push(item)
280
280
  }
281
281
 
282
- // logItemsByAsset = { 'solana:': [...], 'serum': [...] }
283
-
282
+ const logItemsByAsset = _.groupBy(mappedTransactions, (item) => item.coinName)
284
283
  return {
285
- logItemsByAsset: _.groupBy(mappedTransactions, (item) => item.coinName),
284
+ logItemsByAsset,
286
285
  hasNewTxs: transactions.length > 0,
287
286
  cursorState: { cursor: newCursor },
288
287
  }
@@ -328,11 +327,13 @@ export class SolanaMonitor extends BaseMonitor {
328
327
  : { ...accountState.stakingInfo, staking: this.staking }
329
328
 
330
329
  const stakedBalance = this.asset.currency.baseUnit(staking.locked)
330
+ const activatingBalance = this.asset.currency.baseUnit(staking.activating)
331
331
  const withdrawableBalance = this.asset.currency.baseUnit(staking.withdrawable)
332
332
  const pendingBalance = this.asset.currency.baseUnit(staking.pending)
333
333
  const balance = this.asset.currency
334
334
  .baseUnit(solBalance)
335
335
  .add(stakedBalance)
336
+ .add(activatingBalance)
336
337
  .add(withdrawableBalance)
337
338
  .add(pendingBalance)
338
339