@exodus/solana-api 2.5.31-alpha.3 → 2.5.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "2.5.31-alpha.3",
3
+ "version": "2.5.32",
4
4
  "description": "Exodus internal Solana asset API wrapper",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -14,11 +14,17 @@
14
14
  "publishConfig": {
15
15
  "access": "restricted"
16
16
  },
17
+ "scripts": {
18
+ "test": "run -T jest",
19
+ "lint": "run -T eslint ./src",
20
+ "lint:fix": "yarn lint --fix"
21
+ },
17
22
  "dependencies": {
18
23
  "@exodus/asset-json-rpc": "^1.0.0",
19
- "@exodus/asset-lib": "^4.0.0",
24
+ "@exodus/asset-lib": "^4.1.0",
20
25
  "@exodus/assets": "^9.0.1",
21
26
  "@exodus/basic-utils": "^2.1.0",
27
+ "@exodus/currency": "^2.3.2",
22
28
  "@exodus/fetch": "^1.2.0",
23
29
  "@exodus/models": "^10.1.0",
24
30
  "@exodus/nfts-core": "^0.5.0",
@@ -27,12 +33,16 @@
27
33
  "@exodus/solana-meta": "^1.0.7",
28
34
  "bn.js": "^4.11.0",
29
35
  "debug": "^4.1.1",
36
+ "delay": "^4.0.1",
30
37
  "lodash": "^4.17.11",
38
+ "make-concurrent": "^4.0.0",
39
+ "minimalistic-assert": "^1.0.1",
40
+ "ms": "^2.1.3",
31
41
  "url-join": "4.0.0",
32
42
  "wretch": "^1.5.2"
33
43
  },
34
44
  "devDependencies": {
35
- "@exodus/assets-testing": "file:../../../__testing__"
45
+ "@exodus/assets-testing": "^1.0.0"
36
46
  },
37
- "gitHead": "9ec7084bcdfe14f1c6f11df393351885773046a6"
47
+ "gitHead": "6b37a481bbf82a7ff63a453ca66446fbd45fb4a8"
38
48
  }
@@ -27,6 +27,7 @@ export class SolanaAccountState extends AccountState {
27
27
  accounts: {}, // stake accounts
28
28
  },
29
29
  }
30
+
30
31
  static _tokens = [asset, ...tokens] // deprecated - will be removed
31
32
 
32
33
  static _postParse(data) {
package/src/api.js CHANGED
@@ -25,15 +25,15 @@ import { Connection } from './connection'
25
25
  const RPC_URL = 'https://solana.a.exodus.io' // https://vip-api.mainnet-beta.solana.com/, https://api.mainnet-beta.solana.com
26
26
  const WS_ENDPOINT = 'wss://solana.a.exodus.io/ws' // not standard across all node providers (we're compatible only with Quicknode)
27
27
  const FORCE_HTTP = true // use https over ws
28
- const TXS_LIMIT = 100
29
28
 
30
29
  // Tokens + SOL api support
31
30
  export class Api {
32
- constructor({ rpcUrl, wsUrl, assets }) {
31
+ constructor({ rpcUrl, wsUrl, assets, txsLimit }) {
33
32
  this.setServer(rpcUrl)
34
33
  this.setWsEndpoint(wsUrl)
35
34
  this.setTokens(assets)
36
35
  this.tokensToSkip = {}
36
+ this.txsLimit = txsLimit
37
37
  this.connections = {}
38
38
  }
39
39
 
@@ -60,16 +60,18 @@ export class Api {
60
60
  async watchAddress({
61
61
  address,
62
62
  tokensAddresses = [],
63
+ onMessage,
63
64
  handleAccounts,
64
65
  handleTransfers,
65
66
  handleReconnect,
66
67
  reconnectDelay,
67
68
  }) {
68
- if (FORCE_HTTP) return false
69
+ if (this.connections[address]) return // already subscribed
69
70
  const conn = new Connection({
70
71
  endpoint: this.wsUrl,
71
72
  address,
72
73
  tokensAddresses,
74
+ onMsg: (json) => onMessage(json),
73
75
  callback: (updates) =>
74
76
  this.handleUpdates({ updates, address, handleAccounts, handleTransfers }),
75
77
  reconnectCallback: handleReconnect,
@@ -98,6 +100,7 @@ export class Api {
98
100
  if (lodash.get(connection, 'isOpen') && !lodash.get(connection, 'shutdown') && !forceHttp) {
99
101
  return connection.sendMessage(method, params)
100
102
  }
103
+
101
104
  // http fallback
102
105
  return this.api.post({ method, params })
103
106
  }
@@ -120,7 +123,7 @@ export class Api {
120
123
  return state
121
124
  }
122
125
 
123
- async getRecentBlockHash(commitment?) {
126
+ async getRecentBlockHash(commitment) {
124
127
  const result = await this.rpcCall(
125
128
  'getLatestBlockhash',
126
129
  [{ commitment: commitment || 'finalized', encoding: 'jsonParsed' }],
@@ -165,11 +168,12 @@ export class Api {
165
168
  * Get transactions from an address
166
169
  */
167
170
  async getTransactions(address, { cursor, before, limit, includeUnparsed = false } = {}) {
171
+ limit = limit || this.txsLimit
168
172
  let transactions = []
169
173
  // cursor is a txHash
170
174
 
171
175
  try {
172
- let until = cursor
176
+ const until = cursor
173
177
 
174
178
  const tokenAccountsByOwner = await this.getTokenAccountsByOwner(address) // Array
175
179
  const tokenAccountAddresses = tokenAccountsByOwner
@@ -186,7 +190,7 @@ export class Api {
186
190
  })
187
191
  )
188
192
  )
189
- let txsId = txsResultsByAccount.reduce((arr, row) => arr.concat(row), []).slice(0, TXS_LIMIT) // merge arrays
193
+ let txsId = txsResultsByAccount.flat() // merge arrays
190
194
  txsId = lodash.uniqBy(txsId, 'signature')
191
195
 
192
196
  // get txs details in parallel
@@ -236,14 +240,8 @@ export class Api {
236
240
  tokenAccountsByOwner,
237
241
  { includeUnparsed = false } = {}
238
242
  ) {
239
- let {
240
- fee,
241
- preBalances,
242
- postBalances,
243
- preTokenBalances,
244
- postTokenBalances,
245
- innerInstructions,
246
- } = txDetails.meta
243
+ let { fee, preBalances, postBalances, preTokenBalances, postTokenBalances, innerInstructions } =
244
+ txDetails.meta
247
245
  preBalances = preBalances || []
248
246
  postBalances = postBalances || []
249
247
  preTokenBalances = preTokenBalances || []
@@ -339,7 +337,7 @@ export class Api {
339
337
  }))
340
338
  innerInstructions = innerInstructions
341
339
  .reduce((acc, val) => {
342
- return acc.concat(val.instructions)
340
+ return [...acc, ...val.instructions]
343
341
  }, [])
344
342
  .map((ix) => {
345
343
  const type = lodash.get(ix, 'parsed.type')
@@ -355,7 +353,7 @@ export class Api {
355
353
  const tokenAccount = tokenAccountsByOwner.find(({ tokenAccountAddress }) => {
356
354
  return [source, destination].includes(tokenAccountAddress)
357
355
  })
358
- const isSending = !!tokenAccountsByOwner.find(({ tokenAccountAddress }) => {
356
+ const isSending = tokenAccountsByOwner.some(({ tokenAccountAddress }) => {
359
357
  return [source].includes(tokenAccountAddress)
360
358
  })
361
359
 
@@ -382,7 +380,7 @@ export class Api {
382
380
  const stakeTx = lodash.find(instructions, { program: 'system', type: 'createAccountWithSeed' })
383
381
  const stakeWithdraw = lodash.find(instructions, { program: 'stake', type: 'withdraw' })
384
382
  const stakeUndelegate = lodash.find(instructions, { program: 'stake', type: 'deactivate' })
385
- const hasSolanaTx = solanaTx && !preTokenBalances.length && !postTokenBalances.length // only SOL moved and no tokens movements
383
+ const hasSolanaTx = solanaTx && preTokenBalances.length === 0 && postTokenBalances.length === 0 // only SOL moved and no tokens movements
386
384
 
387
385
  let tx = {}
388
386
  if (hasSolanaTx) {
@@ -449,7 +447,7 @@ export class Api {
449
447
  Array.isArray(tokenAccountsByOwner),
450
448
  'tokenAccountsByOwner is required when parsing token tx'
451
449
  )
452
- let tokenTxs = lodash
450
+ const tokenTxs = lodash
453
451
  .filter(instructions, ({ program, type }) => {
454
452
  return program === 'spl-token' && ['transfer', 'transferChecked'].includes(type)
455
453
  }) // get Token transfer: could have more than 1 instructions
@@ -476,7 +474,7 @@ export class Api {
476
474
  }
477
475
  })
478
476
 
479
- if (tokenTxs.length) {
477
+ if (tokenTxs.length > 0) {
480
478
  // found spl-token simple transfer/transferChecked instruction
481
479
  // .reduce to sum/sub (based on isSending) all the same tokens amount (From instructions -> 1 single tx)
482
480
  tx = tokenTxs.reduce((finalTx, ix) => {
@@ -491,9 +489,11 @@ export class Api {
491
489
  const accountIndexes = lodash.mapKeys(accountKeys, (x, i) => i)
492
490
  Object.values(accountIndexes).forEach((acc) => {
493
491
  // filter by ownerAddress
492
+ // eslint-disable-next-line unicorn/prefer-array-some
494
493
  const hasKnownOwner = !!lodash.find(tokenAccountsByOwner, {
495
494
  tokenAccountAddress: acc.pubkey,
496
495
  })
496
+ // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
497
497
  acc.owner = hasKnownOwner ? ownerAddress : null
498
498
  })
499
499
 
@@ -509,15 +509,15 @@ export class Api {
509
509
  )
510
510
  })
511
511
 
512
- if (preBalances.length || postBalances.length) {
512
+ if (preBalances.length > 0 || postBalances.length > 0) {
513
513
  tx = {}
514
514
 
515
- if (includeUnparsed && innerInstructions.length) {
515
+ if (includeUnparsed && innerInstructions.length > 0) {
516
516
  // when using includeUnparsed for DEX tx we want to keep SOL tx as "unparsed"
517
517
  // 1. we want to treat all SOL dex transactions as "Contract transaction", not "Sent SOL"
518
518
  // 2. default behavior is not perfect. For example it doesn't see SOL-side tx in
519
519
  // SOL->SPL swaps on Raydium and Orca.
520
- tx = getUnparsedTx(tx)
520
+ tx = getUnparsedTx()
521
521
  tx.dexTxs = getInnerTxsFromBalanceChanges()
522
522
  } else {
523
523
  if (solanaTx) {
@@ -532,7 +532,7 @@ export class Api {
532
532
  }
533
533
 
534
534
  // If it has inner instructions then it's a DEX tx that moved SPL -> SPL
535
- if (innerInstructions.length) {
535
+ if (innerInstructions.length > 0) {
536
536
  tx.dexTxs = innerInstructions
537
537
  // if tx involves only SPL swaps. Expand DEX ix (first element as tx base and the other kept there)
538
538
  if (!tx.from && !solanaTx) {
@@ -548,7 +548,7 @@ export class Api {
548
548
  const unparsed = Object.keys(tx).length === 0
549
549
 
550
550
  if (unparsed && includeUnparsed) {
551
- tx = getUnparsedTx(tx)
551
+ tx = getUnparsedTx()
552
552
  }
553
553
 
554
554
  // How tokens tx are parsed:
@@ -572,7 +572,7 @@ export class Api {
572
572
 
573
573
  async getWalletTokensList({ tokenAccounts }) {
574
574
  const tokensMint = []
575
- for (let account of tokenAccounts) {
575
+ for (const account of tokenAccounts) {
576
576
  const mint = account.mintAddress
577
577
 
578
578
  // skip cached NFT
@@ -585,6 +585,7 @@ export class Api {
585
585
  this.tokensToSkip[mint] = true
586
586
  continue
587
587
  }
588
+
588
589
  // OK
589
590
  tokensMint.push(mint)
590
591
  }
@@ -600,7 +601,7 @@ export class Api {
600
601
  )
601
602
 
602
603
  const tokenAccounts = []
603
- for (let entry of accountsList) {
604
+ for (const entry of accountsList) {
604
605
  const { pubkey, account } = entry
605
606
 
606
607
  const mint = lodash.get(account, 'data.parsed.info.mint')
@@ -618,6 +619,7 @@ export class Api {
618
619
  mintAddress: mint,
619
620
  })
620
621
  }
622
+
621
623
  // eventually filter by token
622
624
  return tokenTicker
623
625
  ? tokenAccounts.filter(({ ticker }) => ticker === tokenTicker)
@@ -625,18 +627,24 @@ export class Api {
625
627
  }
626
628
 
627
629
  async getTokensBalance({ address, filterByTokens = [], tokenAccounts }) {
628
- let accounts = tokenAccounts || (await this.getTokenAccountsByOwner(address))
630
+ const accounts = tokenAccounts || (await this.getTokenAccountsByOwner(address))
629
631
 
630
- const tokensBalance = accounts.reduce((acc, { tokenName, balance }) => {
631
- if (tokenName === 'unknown' || (filterByTokens.length && !filterByTokens.includes(tokenName)))
632
+ return accounts.reduce((acc, { tokenName, balance }) => {
633
+ if (
634
+ tokenName === 'unknown' ||
635
+ (filterByTokens.length > 0 && !filterByTokens.includes(tokenName))
636
+ )
632
637
  return acc // filter by supported tokens only
633
- if (!acc[tokenName]) acc[tokenName] = Number(balance)
638
+ if (acc[tokenName]) {
639
+ acc[tokenName] += Number(balance)
640
+ }
634
641
  // e.g { 'serum': 123 }
635
- else acc[tokenName] += Number(balance) // merge same token account balance
642
+ else {
643
+ acc[tokenName] = Number(balance)
644
+ } // merge same token account balance
645
+
636
646
  return acc
637
647
  }, {})
638
-
639
- return tokensBalance
640
648
  }
641
649
 
642
650
  async isAssociatedTokenAccountActive(tokenAddress) {
@@ -644,7 +652,7 @@ export class Api {
644
652
  try {
645
653
  await this.rpcCall('getTokenAccountBalance', [tokenAddress])
646
654
  return true
647
- } catch (e) {
655
+ } catch {
648
656
  return false
649
657
  }
650
658
  }
@@ -697,20 +705,19 @@ export class Api {
697
705
  return account.owner === SYSTEM_PROGRAM_ID.toBase58()
698
706
  ? 'solana'
699
707
  : account.owner === TOKEN_PROGRAM_ID.toBase58()
700
- ? 'token'
701
- : null
708
+ ? 'token'
709
+ : null
702
710
  }
703
711
 
704
712
  async getTokenAddressOwner(address) {
705
713
  const value = await this.getAccountInfo(address)
706
- const owner = lodash.get(value, 'data.parsed.info.owner', null)
707
- return owner
714
+ return lodash.get(value, 'data.parsed.info.owner', null)
708
715
  }
709
716
 
710
717
  async getAddressMint(address) {
711
718
  const value = await this.getAccountInfo(address)
712
- const mintAddress = lodash.get(value, 'data.parsed.info.mint', null) // token mint
713
- return mintAddress
719
+ // token mint
720
+ return lodash.get(value, 'data.parsed.info.mint', null)
714
721
  }
715
722
 
716
723
  async isTokenAddress(address) {
@@ -745,7 +752,7 @@ export class Api {
745
752
  let locked = 0
746
753
  let withdrawable = 0
747
754
  let pending = 0
748
- for (let entry of res) {
755
+ for (const entry of res) {
749
756
  const addr = entry.pubkey
750
757
  const lamports = lodash.get(entry, 'account.lamports', 0)
751
758
  const delegation = lodash.get(entry, 'account.data.parsed.info.stake.delegation', {})
@@ -766,11 +773,12 @@ export class Api {
766
773
  withdrawable += accounts[addr].canWithdraw ? lamports : 0
767
774
  pending += accounts[addr].isDeactivating ? lamports : 0
768
775
  }
776
+
769
777
  return { accounts, totalStake, locked, withdrawable, pending }
770
778
  }
771
779
 
772
780
  async getRewards(stakingAddresses = []) {
773
- if (!stakingAddresses.length) return 0
781
+ if (stakingAddresses.length === 0) return 0
774
782
 
775
783
  // custom endpoint!
776
784
  const rewards = await this.request('rewards')
@@ -780,11 +788,9 @@ export class Api {
780
788
  .json()
781
789
 
782
790
  // sum rewards for all addresses
783
- const earnings = Object.values(rewards).reduce((total, x) => {
791
+ return Object.values(rewards).reduce((total, x) => {
784
792
  return total + x
785
793
  }, 0)
786
-
787
- return earnings
788
794
  }
789
795
 
790
796
  async getMinimumBalanceForRentExemption(size) {
@@ -820,7 +826,7 @@ export class Api {
820
826
  } catch (error) {
821
827
  if (
822
828
  error.message &&
823
- !errorMessagesToRetry.find((errorMessage) => error.message.includes(errorMessage))
829
+ !errorMessagesToRetry.some((errorMessage) => error.message.includes(errorMessage))
824
830
  ) {
825
831
  error.finalError = true
826
832
  }
@@ -868,6 +874,7 @@ export class Api {
868
874
  if (error.message && error.message.includes('could not find account')) {
869
875
  return defaultValue
870
876
  }
877
+
871
878
  throw error
872
879
  }
873
880
  }
@@ -959,11 +966,12 @@ export class Api {
959
966
  simulateAndRetrieveSideEffects = async (
960
967
  message,
961
968
  publicKey,
962
- transactionMessage? // decompiled TransactionMessage
969
+ transactionMessage // decompiled TransactionMessage
963
970
  ) => {
964
971
  const { config, accountAddresses } = getTransactionSimulationParams(
965
972
  transactionMessage || message
966
973
  )
974
+ // eslint-disable-next-line unicorn/no-new-array
967
975
  const signatures = new Array(message.header.numRequiredSignatures || 1).fill(null)
968
976
  const encodedTransaction = buildRawTransaction(
969
977
  Buffer.from(message.serialize()),
package/src/connection.js CHANGED
@@ -20,6 +20,7 @@ export class Connection {
20
20
  address,
21
21
  tokensAddresses = [],
22
22
  callback,
23
+ onMsg,
23
24
  reconnectCallback = () => {},
24
25
  reconnectDelay = DEFAULT_RECONNECT_DELAY,
25
26
  }) {
@@ -27,6 +28,7 @@ export class Connection {
27
28
  this.tokensAddresses = tokensAddresses
28
29
  this.endpoint = endpoint
29
30
  this.callback = callback
31
+ this.onMsg = onMsg
30
32
  this.reconnectCallback = reconnectCallback
31
33
  this.reconnectDelay = reconnectDelay
32
34
 
@@ -66,10 +68,10 @@ export class Connection {
66
68
  reqUrl = `${obj}`
67
69
  debug('Opening WS to:', reqUrl)
68
70
  const ws = new WebSocket(`${reqUrl}`)
69
- ws.onmessage = this.onMessage.bind(this)
70
- ws.onopen = this.onOpen.bind(this)
71
- ws.onclose = this.onClose.bind(this)
72
- ws.onerror = this.onError.bind(this)
71
+ ws.addEventListener('message', this.onMessage.bind(this))
72
+ ws.addEventListener('open', this.onOpen.bind(this))
73
+ ws.addEventListener('close', this.onClose.bind(this))
74
+ ws.addEventListener('error', this.onError.bind(this))
73
75
  return ws
74
76
  }
75
77
 
@@ -90,14 +92,14 @@ export class Connection {
90
92
  }
91
93
 
92
94
  get running() {
93
- return !!(!this.isClosed || this.inProcessMessages || this.messageQueue.length)
95
+ return !!(!this.isClosed || this.inProcessMessages || this.messageQueue.length > 0)
94
96
  }
95
97
 
96
98
  get connectionState() {
97
99
  if (this.isConnecting) return 'CONNECTING'
98
- else if (this.isOpen) return 'OPEN'
99
- else if (this.isClosing) return 'CLOSING'
100
- else if (this.isClosed) return 'CLOSED'
100
+ if (this.isOpen) return 'OPEN'
101
+ if (this.isClosing) return 'CLOSING'
102
+ if (this.isClosed) return 'CLOSED'
101
103
  return 'NONE'
102
104
  }
103
105
 
@@ -125,7 +127,13 @@ export class Connection {
125
127
  try {
126
128
  const json = JSON.parse(evt.data)
127
129
  debug('new ws msg:', json)
128
- if (!json.error) {
130
+ if (json.error) {
131
+ if (lodash.get(this.rpcQueue, json.id)) {
132
+ this.rpcQueue[json.id].reject(new Error(json.error.message))
133
+ clearTimeout(this.rpcQueue[json.id].timeout)
134
+ delete this.rpcQueue[json.id]
135
+ } else debug('Unsupported WS message:', json.error.message)
136
+ } else {
129
137
  if (lodash.get(this.rpcQueue, json.id)) {
130
138
  // json-rpc reply
131
139
  clearTimeout(this.rpcQueue[json.id].timeout)
@@ -136,13 +144,8 @@ export class Connection {
136
144
  debug('pushing msg to queue', msg)
137
145
  this.messageQueue.push(msg) // sub results
138
146
  }
139
- this.processMessages()
140
- } else {
141
- if (lodash.get(this.rpcQueue, json.id)) {
142
- this.rpcQueue[json.id].reject(new Error(json.error.message))
143
- clearTimeout(this.rpcQueue[json.id].timeout)
144
- delete this.rpcQueue[json.id]
145
- } else debug('Unsupported WS message:', json.error.message)
147
+
148
+ this.processMessages(json)
146
149
  }
147
150
  } catch (e) {
148
151
  debug(e)
@@ -153,8 +156,8 @@ export class Connection {
153
156
  onOpen(evt) {
154
157
  debug('Opened WS')
155
158
  // subscribe to each addresses (SOL and ASA addr)
156
-
157
- this.tokensAddresses.concat(this.address).forEach((address) => {
159
+ const addresses = [...this.tokensAddresses, this.address]
160
+ addresses.forEach((address) => {
158
161
  // sub for account state changes
159
162
  this.ws.send(
160
163
  JSON.stringify({
@@ -195,11 +198,12 @@ export class Connection {
195
198
  }
196
199
  }
197
200
 
198
- async processMessages() {
201
+ async processMessages(json) {
202
+ if (this.onMsg) await this.onMsg(json)
199
203
  if (this.inProcessMessages) return null
200
204
  this.inProcessMessages = true
201
205
  try {
202
- while (this.messageQueue.length) {
206
+ while (this.messageQueue.length > 0) {
203
207
  const items = this.messageQueue.splice(0, this.messageQueue.length)
204
208
  await this.callback(items)
205
209
  }
@@ -3,35 +3,33 @@ import { TxSet } from '@exodus/models'
3
3
  // staking may be a feature that may not be available for a given wallet.
4
4
  // In this case, The wallet should exclude the staking balance from the general balance
5
5
 
6
- export const getBalancesFactory = ({ stakingFeatureAvailable }) => ({
7
- asset,
8
- accountState,
9
- txLog,
10
- }) => {
11
- const zero = asset.currency.ZERO
12
- const { balance, locked, withdrawable, pending } = fixBalances({
13
- txLog,
14
- balance: getBalanceFromAccountState({ asset, accountState }),
15
- locked: accountState.mem?.locked || zero,
16
- withdrawable: accountState.mem?.withdrawable || zero,
17
- pending: accountState.mem?.pending || zero,
18
- asset,
19
- })
20
- if (asset.baseAsset.name !== asset.name) {
21
- return { balance, spendableBalance: balance }
22
- }
6
+ export const getBalancesFactory =
7
+ ({ stakingFeatureAvailable }) =>
8
+ ({ asset, accountState, txLog }) => {
9
+ const zero = asset.currency.ZERO
10
+ const { balance, locked, withdrawable, pending } = fixBalances({
11
+ txLog,
12
+ balance: getBalanceFromAccountState({ asset, accountState }),
13
+ locked: accountState.mem?.locked || zero,
14
+ withdrawable: accountState.mem?.withdrawable || zero,
15
+ pending: accountState.mem?.pending || zero,
16
+ asset,
17
+ })
18
+ if (asset.baseAsset.name !== asset.name) {
19
+ return { balance, spendableBalance: balance }
20
+ }
23
21
 
24
- const balanceWithoutStaking = balance
25
- .sub(locked)
26
- .sub(withdrawable)
27
- .sub(pending)
28
- .clampLowerZero()
22
+ const balanceWithoutStaking = balance
23
+ .sub(locked)
24
+ .sub(withdrawable)
25
+ .sub(pending)
26
+ .clampLowerZero()
29
27
 
30
- return {
31
- balance: stakingFeatureAvailable ? balance : balanceWithoutStaking,
32
- spendableBalance: balanceWithoutStaking.sub(asset.accountReserve || zero).clampLowerZero(),
28
+ return {
29
+ balance: stakingFeatureAvailable ? balance : balanceWithoutStaking,
30
+ spendableBalance: balanceWithoutStaking.sub(asset.accountReserve || zero).clampLowerZero(),
31
+ }
33
32
  }
34
- }
35
33
 
36
34
  const fixBalances = ({ txLog = TxSet.EMPTY, balance, locked, withdrawable, pending, asset }) => {
37
35
  for (const tx of txLog) {
@@ -40,10 +38,7 @@ const fixBalances = ({ txLog = TxSet.EMPTY, balance, locked, withdrawable, pendi
40
38
  balance = balance.sub(tx.feeAmount)
41
39
  }
42
40
 
43
- if (!tx.data.staking) {
44
- // coinAmount is negative for sent tx
45
- balance = balance.sub(tx.coinAmount.abs())
46
- } else {
41
+ if (tx.data.staking) {
47
42
  // staking tx
48
43
  switch (tx.data.staking?.method) {
49
44
  case 'delegate':
@@ -57,9 +52,13 @@ const fixBalances = ({ txLog = TxSet.EMPTY, balance, locked, withdrawable, pendi
57
52
  locked = asset.currency.ZERO
58
53
  break
59
54
  }
55
+ } else {
56
+ // coinAmount is negative for sent tx
57
+ balance = balance.sub(tx.coinAmount.abs())
60
58
  }
61
59
  }
62
60
  }
61
+
63
62
  return {
64
63
  balance: balance.clampLowerZero(),
65
64
  locked: locked.clampLowerZero(),
package/src/index.js CHANGED
@@ -4,7 +4,6 @@ import assetsList from '@exodus/solana-meta'
4
4
 
5
5
  import { Api } from './api'
6
6
 
7
- export { Api }
8
7
  export { default as SolanaFeeMonitor } from './fee-monitor'
9
8
  export { SolanaMonitor } from './tx-log'
10
9
  export { SolanaAccountState } from './account-state'
@@ -26,3 +25,5 @@ const assets = connectAssets(keyBy(assetsList, (asset) => asset.name))
26
25
  // Clients should not call an specific server api directly.
27
26
  const serverApi = new Api({ assets })
28
27
  export default serverApi
28
+
29
+ export { Api } from './api'
@@ -9,9 +9,7 @@ export const getSolStakedFee = ({ asset, stakingInfo, fee }) => {
9
9
  const { accounts } = stakingInfo
10
10
 
11
11
  const allPending = Object.entries(accounts).length
12
- const pendingFee = allPending > 0 ? fee.mul(allPending) : currency.ZERO
13
-
14
- return pendingFee
12
+ return allPending > 0 ? fee.mul(allPending) : currency.ZERO
15
13
  }
16
14
 
17
15
  export const getStakingInfo = (accountMem) => {
@@ -1,6 +1,7 @@
1
1
  import { BaseMonitor } from '@exodus/asset-lib'
2
2
  import _ from 'lodash'
3
3
  import assert from 'minimalistic-assert'
4
+ import ms from 'ms'
4
5
 
5
6
  const DEFAULT_POOL_ADDRESS = '9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF' // Everstake
6
7
 
@@ -12,6 +13,7 @@ const DEFAULT_REMOTE_CONFIG = {
12
13
 
13
14
  const TICKS_BETWEEN_HISTORY_FETCHES = 10
14
15
  const TICKS_BETWEEN_STAKE_FETCHES = 5
16
+ const TX_STALE_AFTER = ms('2m') // mark txs as dropped after N minutes
15
17
 
16
18
  export class SolanaMonitor extends BaseMonitor {
17
19
  constructor({
@@ -52,15 +54,10 @@ export class SolanaMonitor extends BaseMonitor {
52
54
  })
53
55
  return this.api.watchAddress({
54
56
  address,
55
- /*
56
- // OPTIONAL. Relying on polling through ws
57
- tokensAddresses: [], // needed for ASA subs
58
- handleAccounts: (updates) => this.accountsCallback({ updates, walletAccount }),
59
- handleTransfers: (txs) => {
60
- // new SOL tx, ticking monitor
61
- this.tick({ walletAccount }) // it will cause refresh for both sender/receiver. Without necessarily fetching the tx if it's not finalized in the node.
57
+ onMessage: (json) => {
58
+ // new SOL tx event, tick monitor with 15 sec delay (to avoid hitting delayed nodes)
59
+ setTimeout(() => this.tick({ walletAccount }), 15_000)
62
60
  },
63
- */
64
61
  })
65
62
  }
66
63
 
@@ -97,24 +94,41 @@ export class SolanaMonitor extends BaseMonitor {
97
94
 
98
95
  async getStakingAddressesFromTxLog({ assetName, walletAccount }) {
99
96
  const txLog = await this.aci.getTxLog({ assetName: this.asset.name, walletAccount })
100
- const stakingAddresses = Array.from(txLog)
97
+ const stakingAddresses = [...txLog]
101
98
  .filter((tx) => _.get(tx, 'data.staking.stakeAddresses'))
102
99
  .map((tx) => tx.data.staking.stakeAddresses)
103
- return _.uniq(_.flatten(stakingAddresses))
100
+ return _.uniq(stakingAddresses.flat())
104
101
  }
105
102
 
106
- balanceChanged({ account, newAccount }) {
103
+ #balanceChanged({ account, newAccount }) {
107
104
  const solBalanceChanged = !account.balance || !account.balance.equals(newAccount.balance)
108
105
  if (solBalanceChanged) return true
109
106
 
110
- const tokenBalanceChanged =
107
+ // token balance changed
108
+ return (
111
109
  !account.tokenBalances ||
112
110
  Object.entries(newAccount.tokenBalances).some(
113
111
  ([token, balance]) =>
114
112
  !account.tokenBalances[token] || !account.tokenBalances[token].equals(balance)
115
113
  )
114
+ )
115
+ }
116
+
117
+ async markStaleTransactions({ walletAccount, logItemsByAsset = {} }) {
118
+ // mark stale txs as dropped in logItemsByAsset
119
+ const clearedLogItems = logItemsByAsset
120
+ const tokenNames = [...this.api.tokens.values()].map(({ name }) => name)
121
+ const assets = [this.asset.name, ...tokenNames]
122
+
123
+ for (const assetName of assets) {
124
+ const txSet = await this.aci.getTxLog({ assetName, walletAccount })
125
+ const { stale } = this.getUnconfirmed({ txSet, staleTxAge: TX_STALE_AFTER })
126
+ if (stale.length > 0) {
127
+ clearedLogItems[assetName] = _.unionBy(stale, logItemsByAsset[assetName], 'txId')
128
+ }
129
+ }
116
130
 
117
- return tokenBalanceChanged
131
+ return clearedLogItems
118
132
  }
119
133
 
120
134
  async tick({ walletAccount, refresh }) {
@@ -137,7 +151,7 @@ export class SolanaMonitor extends BaseMonitor {
137
151
  const tokenAccounts = await this.api.getTokenAccountsByOwner(address)
138
152
  const account = await this.getAccount({ address, staking, tokenAccounts })
139
153
 
140
- const balanceChanged = this.balanceChanged({ account: accountState, newAccount: account })
154
+ const balanceChanged = this.#balanceChanged({ account: accountState, newAccount: account })
141
155
 
142
156
  const isHistoryUpdateTick =
143
157
  this.tickCount[walletAccount] % this.ticksBetweenHistoryFetches === 0
@@ -152,6 +166,7 @@ export class SolanaMonitor extends BaseMonitor {
152
166
  await this.updateState({ account, walletAccount, staking })
153
167
  await this.emitUnknownTokensEvent({ tokenAccounts })
154
168
  }
169
+
155
170
  if (shouldUpdateHistory) {
156
171
  const { logItemsByAsset, cursorState } = await this.getHistory({
157
172
  address,
@@ -163,7 +178,8 @@ export class SolanaMonitor extends BaseMonitor {
163
178
  const cursorChanged = this.hasNewCursor({ walletAccount, cursorState })
164
179
 
165
180
  // update all state at once
166
- await this.updateTxLogByAsset({ walletAccount, logItemsByAsset, refresh })
181
+ const clearedLogItems = await this.markStaleTransactions({ walletAccount, logItemsByAsset })
182
+ await this.updateTxLogByAsset({ walletAccount, logItemsByAsset: clearedLogItems, refresh })
167
183
  await this.updateState({ account, cursorState, walletAccount, staking })
168
184
  await this.emitUnknownTokensEvent({ tokenAccounts })
169
185
  if (refresh || cursorChanged) {
@@ -173,7 +189,7 @@ export class SolanaMonitor extends BaseMonitor {
173
189
  }
174
190
 
175
191
  async getHistory({ address, accountState, refresh } = {}) {
176
- let cursor = refresh ? '' : accountState.cursor
192
+ const cursor = refresh ? '' : accountState.cursor
177
193
  const baseAsset = this.asset
178
194
 
179
195
  const { transactions, newCursor } = await this.api.getTransactions(address, {
@@ -226,6 +242,7 @@ export class SolanaMonitor extends BaseMonitor {
226
242
 
227
243
  item.data.meta = tx.data.meta
228
244
  }
245
+
229
246
  if (asset.assetType === 'SOLANA_TOKEN' && item.feeAmount && item.feeAmount.isPositive) {
230
247
  const feeItem = {
231
248
  ..._.clone(item),
@@ -235,6 +252,7 @@ export class SolanaMonitor extends BaseMonitor {
235
252
  }
236
253
  mappedTransactions.push(feeItem)
237
254
  }
255
+
238
256
  mappedTransactions.push(item)
239
257
  }
240
258
 
package/src/tx-send.js CHANGED
@@ -1,183 +1,181 @@
1
1
  import { createUnsignedTx, findAssociatedTokenAddress } from '@exodus/solana-lib'
2
2
  import assert from 'minimalistic-assert'
3
3
 
4
- export const createAndBroadcastTXFactory = (api) => async (
5
- { asset, walletAccount, address, amount, options = {} },
6
- { assetClientInterface }
7
- ) => {
8
- const assetName = asset.name
9
- assert(assetClientInterface, `assetClientInterface must be supplied in sendTx for ${assetName}`)
10
-
11
- const {
12
- feeAmount,
13
- method,
14
- stakeAddresses,
15
- seed,
16
- pool,
17
- customMintAddress,
18
- tokenStandard,
19
- // <MagicEden>
20
- initializerAddress,
21
- initializerDepositTokenAddress,
22
- takerAmount,
23
- escrowAddress,
24
- escrowBump,
25
- pdaAddress,
26
- takerAddress,
27
- expectedTakerAmount,
28
- expectedMintAddress,
29
- metadataAddress,
30
- creators,
31
- // </MagicEden>
32
- reference,
33
- memo,
34
- } = options
35
- const { baseAsset } = asset
36
- const from = await assetClientInterface.getReceiveAddress({
37
- assetName: baseAsset.name,
38
- walletAccount,
39
- })
40
-
41
- const isToken = asset.assetType === 'SOLANA_TOKEN'
42
-
43
- // Check if receiver has address active when sending tokens.
44
- if (isToken) {
45
- // check address mint is the same
46
- const targetMint = await api.getAddressMint(address) // null if it's a SOL address
47
- if (targetMint && targetMint !== asset.mintAddress) {
48
- const err = new Error('Wrong Destination Wallet')
49
- err.reason = { mintAddressMismatch: true }
50
- throw err
4
+ export const createAndBroadcastTXFactory =
5
+ (api) =>
6
+ async ({ asset, walletAccount, address, amount, options = {} }, { assetClientInterface }) => {
7
+ const assetName = asset.name
8
+ assert(assetClientInterface, `assetClientInterface must be supplied in sendTx for ${assetName}`)
9
+
10
+ const {
11
+ feeAmount,
12
+ method,
13
+ stakeAddresses,
14
+ seed,
15
+ pool,
16
+ customMintAddress,
17
+ tokenStandard,
18
+ // <MagicEden>
19
+ initializerAddress,
20
+ initializerDepositTokenAddress,
21
+ takerAmount,
22
+ escrowAddress,
23
+ escrowBump,
24
+ pdaAddress,
25
+ takerAddress,
26
+ expectedTakerAmount,
27
+ expectedMintAddress,
28
+ metadataAddress,
29
+ creators,
30
+ // </MagicEden>
31
+ reference,
32
+ memo,
33
+ } = options
34
+ const { baseAsset } = asset
35
+ const from = await assetClientInterface.getReceiveAddress({
36
+ assetName: baseAsset.name,
37
+ walletAccount,
38
+ })
39
+
40
+ const isToken = asset.assetType === 'SOLANA_TOKEN'
41
+
42
+ // Check if receiver has address active when sending tokens.
43
+ if (isToken) {
44
+ // check address mint is the same
45
+ const targetMint = await api.getAddressMint(address) // null if it's a SOL address
46
+ if (targetMint && targetMint !== asset.mintAddress) {
47
+ const err = new Error('Wrong Destination Wallet')
48
+ err.reason = { mintAddressMismatch: true }
49
+ throw err
50
+ }
51
+ } else {
52
+ // sending SOL
53
+ const addressType = await api.getAddressType(address)
54
+ if (addressType === 'token') {
55
+ const err = new Error('Destination Wallet is a Token address')
56
+ err.reason = { wrongAddressType: true }
57
+ throw err
58
+ }
51
59
  }
52
- } else {
53
- // sending SOL
54
- const addressType = await api.getAddressType(address)
55
- if (addressType === 'token') {
56
- const err = new Error('Destination Wallet is a Token address')
57
- err.reason = { wrongAddressType: true }
58
- throw err
60
+
61
+ const recentBlockhash = options.recentBlockhash || (await api.getRecentBlockHash())
62
+
63
+ let tokenParams = Object.create(null)
64
+ if (isToken || customMintAddress) {
65
+ const tokenMintAddress = customMintAddress || asset.mintAddress
66
+ const tokenAddress = findAssociatedTokenAddress(address, tokenMintAddress)
67
+ const [destinationAddressType, isAssociatedTokenAccountActive, fromTokenAccountAddresses] =
68
+ await Promise.all([
69
+ api.getAddressType(address),
70
+ api.isAssociatedTokenAccountActive(tokenAddress),
71
+ api.getTokenAccountsByOwner(from),
72
+ ])
73
+
74
+ const fromTokenAddresses = fromTokenAccountAddresses.filter(
75
+ ({ mintAddress }) => mintAddress === tokenMintAddress
76
+ )
77
+
78
+ tokenParams = {
79
+ tokenMintAddress,
80
+ destinationAddressType,
81
+ isAssociatedTokenAccountActive,
82
+ fromTokenAddresses,
83
+ tokenStandard,
84
+ }
59
85
  }
60
- }
61
86
 
62
- const recentBlockhash = options.recentBlockhash || (await api.getRecentBlockHash())
63
-
64
- let tokenParams = Object.create(null)
65
- if (isToken || customMintAddress) {
66
- const tokenMintAddress = customMintAddress || asset.mintAddress
67
- const tokenAddress = findAssociatedTokenAddress(address, tokenMintAddress)
68
- const [
69
- destinationAddressType,
70
- isAssociatedTokenAccountActive,
71
- fromTokenAccountAddresses,
72
- ] = await Promise.all([
73
- api.getAddressType(address),
74
- api.isAssociatedTokenAccountActive(tokenAddress),
75
- api.getTokenAccountsByOwner(from),
76
- ])
77
-
78
- const fromTokenAddresses = fromTokenAccountAddresses.filter(
79
- ({ mintAddress }) => mintAddress === tokenMintAddress
80
- )
81
-
82
- tokenParams = {
83
- tokenMintAddress,
84
- destinationAddressType,
85
- isAssociatedTokenAccountActive,
86
- fromTokenAddresses,
87
- tokenStandard,
87
+ const stakingParams = {
88
+ method,
89
+ stakeAddresses,
90
+ seed,
91
+ pool,
88
92
  }
89
- }
90
93
 
91
- const stakingParams = {
92
- method,
93
- stakeAddresses,
94
- seed,
95
- pool,
96
- }
94
+ const magicEdenParams = {
95
+ method,
96
+ initializerAddress,
97
+ initializerDepositTokenAddress,
98
+ takerAmount,
99
+ escrowAddress,
100
+ escrowBump,
101
+ pdaAddress,
102
+ takerAddress,
103
+ expectedTakerAmount,
104
+ expectedMintAddress,
105
+ metadataAddress,
106
+ creators,
107
+ }
97
108
 
98
- const magicEdenParams = {
99
- method,
100
- initializerAddress,
101
- initializerDepositTokenAddress,
102
- takerAmount,
103
- escrowAddress,
104
- escrowBump,
105
- pdaAddress,
106
- takerAddress,
107
- expectedTakerAmount,
108
- expectedMintAddress,
109
- metadataAddress,
110
- creators,
111
- }
109
+ const unsignedTransaction = createUnsignedTx({
110
+ asset,
111
+ from,
112
+ to: address,
113
+ amount,
114
+ fee: feeAmount,
115
+ recentBlockhash,
116
+ reference,
117
+ memo,
118
+ ...tokenParams,
119
+ ...stakingParams,
120
+ ...magicEdenParams,
121
+ })
112
122
 
113
- const unsignedTransaction = createUnsignedTx({
114
- asset,
115
- from,
116
- to: address,
117
- amount,
118
- fee: feeAmount,
119
- recentBlockhash,
120
- reference,
121
- memo,
122
- ...tokenParams,
123
- ...stakingParams,
124
- ...magicEdenParams,
125
- })
126
-
127
- const { txId, rawTx } = await assetClientInterface.signTransaction({
128
- assetName: baseAsset.name,
129
- unsignedTx: unsignedTransaction,
130
- walletAccount,
131
- })
132
-
133
- await baseAsset.api.broadcastTx(rawTx)
134
-
135
- const selfSend = from === address
136
- const isStakingTx = ['delegate', 'undelegate', 'withdraw'].includes(method)
137
- const coinAmount = isStakingTx
138
- ? amount.abs()
139
- : selfSend
140
- ? asset.currency.ZERO
141
- : amount.abs().negate()
142
-
143
- const data = isStakingTx ? { staking: stakingParams } : Object.create(null)
144
- const tx = {
145
- txId,
146
- confirmations: 0,
147
- coinName: assetName,
148
- coinAmount,
149
- feeAmount,
150
- feeCoinName: asset.feeAsset.name,
151
- selfSend,
152
- to: address,
153
- data,
154
- currencies: { [assetName]: asset.currency, [asset.feeAsset.name]: asset.feeAsset.currency },
155
- }
156
- await assetClientInterface.updateTxLogAndNotify({ assetName, walletAccount, txs: [tx] })
123
+ const { txId, rawTx } = await assetClientInterface.signTransaction({
124
+ assetName: baseAsset.name,
125
+ unsignedTx: unsignedTransaction,
126
+ walletAccount,
127
+ })
128
+
129
+ await baseAsset.api.broadcastTx(rawTx)
157
130
 
158
- if (isToken) {
159
- // write tx entry in solana for token fee
160
- const txForFee = {
131
+ const selfSend = from === address
132
+ const isStakingTx = ['delegate', 'undelegate', 'withdraw'].includes(method)
133
+ const coinAmount = isStakingTx
134
+ ? amount.abs()
135
+ : selfSend
136
+ ? asset.currency.ZERO
137
+ : amount.abs().negate()
138
+
139
+ const data = isStakingTx
140
+ ? { staking: { ...stakingParams, stake: coinAmount.toBaseNumber() } }
141
+ : Object.create(null)
142
+ const tx = {
161
143
  txId,
162
144
  confirmations: 0,
163
- coinName: baseAsset.name,
164
- coinAmount: baseAsset.currency.ZERO,
165
- tokens: [assetName],
145
+ coinName: assetName,
146
+ coinAmount,
166
147
  feeAmount,
167
- feeCoinName: baseAsset.feeAsset.name,
168
- to: address,
148
+ feeCoinName: asset.feeAsset.name,
169
149
  selfSend,
170
- currencies: {
171
- [baseAsset.name]: baseAsset.currency,
172
- [baseAsset.feeAsset.name]: baseAsset.feeAsset.currency,
173
- },
150
+ to: address,
151
+ data,
152
+ currencies: { [assetName]: asset.currency, [asset.feeAsset.name]: asset.feeAsset.currency },
153
+ }
154
+ await assetClientInterface.updateTxLogAndNotify({ assetName, walletAccount, txs: [tx] })
155
+
156
+ if (isToken) {
157
+ // write tx entry in solana for token fee
158
+ const txForFee = {
159
+ txId,
160
+ confirmations: 0,
161
+ coinName: baseAsset.name,
162
+ coinAmount: baseAsset.currency.ZERO,
163
+ tokens: [assetName],
164
+ feeAmount,
165
+ feeCoinName: baseAsset.feeAsset.name,
166
+ to: address,
167
+ selfSend,
168
+ currencies: {
169
+ [baseAsset.name]: baseAsset.currency,
170
+ [baseAsset.feeAsset.name]: baseAsset.feeAsset.currency,
171
+ },
172
+ }
173
+ await assetClientInterface.updateTxLogAndNotify({
174
+ assetName: baseAsset.name,
175
+ walletAccount,
176
+ txs: [txForFee],
177
+ })
174
178
  }
175
- await assetClientInterface.updateTxLogAndNotify({
176
- assetName: baseAsset.name,
177
- walletAccount,
178
- txs: [txForFee],
179
- })
180
- }
181
179
 
182
- return { txId }
183
- }
180
+ return { txId }
181
+ }