@exodus/solana-api 3.12.1 → 3.13.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,26 @@
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.13.1](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.13.0...@exodus/solana-api@3.13.1) (2025-01-09)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: missing token transaction when parsing solana transactions (#4788)
13
+
14
+
15
+
16
+ ## [3.13.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.12.1...@exodus/solana-api@3.13.0) (2025-01-08)
17
+
18
+
19
+ ### Features
20
+
21
+
22
+ * feat: add SOL waitForTransactionStatus method (#4800)
23
+
24
+
25
+
6
26
  ## [3.12.1](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.12.0...@exodus/solana-api@3.12.1) (2025-01-02)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "3.12.1",
3
+ "version": "3.13.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",
@@ -47,7 +47,7 @@
47
47
  "@exodus/assets-testing": "^1.0.0",
48
48
  "@exodus/solana-web3.js": "^1.63.1-exodus.9-rc3"
49
49
  },
50
- "gitHead": "8a6a0d1a70d14e86a9cf4d47b8884939f10be724",
50
+ "gitHead": "8593d378a2c8054dea5d94dac3bf96b124842903",
51
51
  "bugs": {
52
52
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
53
53
  },
package/src/api.js CHANGED
@@ -16,13 +16,13 @@ import {
16
16
  } from '@exodus/solana-lib'
17
17
  import BN from 'bn.js'
18
18
  import lodash from 'lodash'
19
- import assert from 'minimalistic-assert'
20
19
  import ms from 'ms'
21
20
  import urljoin from 'url-join'
22
21
  import wretch from 'wretch'
23
22
 
24
23
  import { Connection } from './connection.js'
25
24
  import { getStakeActivation } from './get-stake-activation/index.js'
25
+ import { isSplTransferInstruction } from './txs-utils.js'
26
26
 
27
27
  const createApi = createApiCJS.default || createApiCJS
28
28
 
@@ -178,6 +178,32 @@ export class Api {
178
178
  return this.rpcCall('getBlockTime', [slot])
179
179
  }
180
180
 
181
+ async waitForTransactionStatus(txIds, status = 'finalized', timeoutMs = ms('1m')) {
182
+ if (!Array.isArray(txIds)) txIds = [txIds]
183
+ const startTime = Date.now()
184
+
185
+ while (true) {
186
+ const response = await this.rpcCall('getSignatureStatuses', [
187
+ txIds,
188
+ { searchTransactionHistory: true },
189
+ ])
190
+ const data = response.value
191
+ const allTxsAreConfirmed = data.every((elem) => elem?.confirmationStatus === status)
192
+ if (allTxsAreConfirmed) {
193
+ return true
194
+ }
195
+
196
+ // Check if the timeout has elapsed
197
+ if (Date.now() - startTime >= timeoutMs) {
198
+ // timeout
199
+ throw new Error('waitForTransactionStatus timeout')
200
+ }
201
+
202
+ // Wait for the specified interval before the next request
203
+ await new Promise((resolve) => setTimeout(resolve, ms('10s')))
204
+ }
205
+ }
206
+
181
207
  async getSignaturesForAddress(address, { until, before, limit } = {}) {
182
208
  until = until || undefined
183
209
  return this.rpcCall('getSignaturesForAddress', [address, { until, before, limit }], { address })
@@ -224,7 +250,8 @@ export class Api {
224
250
  const parsedTx = this.parseTransaction(address, txDetail, tokenAccountsByOwner, {
225
251
  includeUnparsed,
226
252
  })
227
- if (!parsedTx.from && !includeUnparsed) return // cannot parse it
253
+
254
+ if (!parsedTx.from && parsedTx.tokenTxs?.length === 0 && !includeUnparsed) return // cannot parse it
228
255
 
229
256
  // split dexTx in separate txs
230
257
  if (parsedTx.dexTxs) {
@@ -238,11 +265,24 @@ export class Api {
238
265
  delete parsedTx.dexTxs
239
266
  }
240
267
 
241
- transactions.push({
242
- timestamp,
243
- date: new Date(timestamp),
244
- ...parsedTx,
245
- })
268
+ if (parsedTx.tokenTxs?.length > 0) {
269
+ parsedTx.tokenTxs.forEach((tx) => {
270
+ transactions.push({
271
+ timestamp,
272
+ date: new Date(timestamp),
273
+ ...tx,
274
+ })
275
+ })
276
+ delete parsedTx.tokenTxs
277
+ }
278
+
279
+ if (parsedTx.from) {
280
+ transactions.push({
281
+ timestamp,
282
+ date: new Date(timestamp),
283
+ ...parsedTx,
284
+ })
285
+ }
246
286
  })
247
287
  } catch (err) {
248
288
  console.warn('Solana error:', err)
@@ -375,30 +415,22 @@ export class Api {
375
415
  .reduce((acc, val) => {
376
416
  return [...acc, ...val.instructions]
377
417
  }, [])
418
+ .filter(
419
+ (ix) => ix.parsed && isSplTransferInstruction({ program: ix.program, type: ix.parsed.type })
420
+ )
378
421
  .map((ix) => {
379
- const type = lodash.get(ix, 'parsed.type')
380
- const isTransferTx =
381
- ix.parsed &&
382
- ix.program === 'spl-token' &&
383
- ['transfer', 'transferChecked', 'transferCheckedWithFee'].includes(type)
384
-
385
- if (!isTransferTx) return null
386
-
387
422
  const source = lodash.get(ix, 'parsed.info.source')
388
423
  const destination = lodash.get(ix, 'parsed.info.destination')
389
424
  const amount = Number(
390
425
  lodash.get(ix, 'parsed.info.amount', 0) ||
391
426
  lodash.get(ix, 'parsed.info.tokenAmount.amount', 0)
392
427
  )
393
- const txId = txDetails.transaction.signatures[0]
394
- if (accountToRedeemToOwner && destination === accountToRedeemToOwner) {
395
- const getOwnerOrAccount = (account) =>
396
- account && account === accountToRedeemToOwner ? ownerAddress : account
428
+ const authority = lodash.get(ix, 'parsed.info.authority')
397
429
 
430
+ if (accountToRedeemToOwner && destination === accountToRedeemToOwner) {
398
431
  solanaTransferTx = {
399
- id: txId,
400
- from: getOwnerOrAccount(source),
401
- to: getOwnerOrAccount(destination),
432
+ from: authority || source,
433
+ to: ownerAddress,
402
434
  amount,
403
435
  fee,
404
436
  }
@@ -415,17 +447,14 @@ export class Api {
415
447
  })
416
448
 
417
449
  // owner if it's a send tx
418
- const instruction = {
419
- id: txId,
420
- slot: txDetails.slot,
450
+ return {
421
451
  owner: isSending ? ownerAddress : null,
422
- from: source,
423
- to: destination,
452
+ from: isSending ? ownerAddress : source,
453
+ to: isSending ? destination : ownerAddress,
424
454
  amount,
425
455
  token: tokenAccount,
426
456
  fee: isSending ? fee : 0,
427
457
  }
428
- return tokenAccount ? instruction : null
429
458
  })
430
459
  .filter((ix) => !!ix)
431
460
 
@@ -434,21 +463,9 @@ export class Api {
434
463
  const stakeTx = lodash.find(instructions, { program: 'system', type: 'createAccountWithSeed' })
435
464
  const stakeWithdraw = lodash.find(instructions, { program: 'stake', type: 'withdraw' })
436
465
  const stakeUndelegate = lodash.find(instructions, { program: 'stake', type: 'deactivate' })
437
- const hasOnlySolanaTx =
438
- solanaTransferTx && preTokenBalances.length === 0 && postTokenBalances.length === 0 // only SOL moved and no tokens movements
439
466
 
440
467
  let tx = {}
441
- if (hasOnlySolanaTx) {
442
- // Solana tx
443
- const isSending = ownerAddress === solanaTransferTx.source
444
- tx = {
445
- owner: solanaTransferTx.source,
446
- from: solanaTransferTx.source,
447
- to: solanaTransferTx.destination,
448
- amount: solanaTransferTx.lamports, // number
449
- fee: isSending ? fee : 0,
450
- }
451
- } else if (stakeTx) {
468
+ if (stakeTx) {
452
469
  // start staking
453
470
  tx = {
454
471
  owner: stakeTx.base,
@@ -497,64 +514,49 @@ export class Api {
497
514
  },
498
515
  }
499
516
  } else {
500
- // Token tx
501
- assert(
502
- Array.isArray(tokenAccountsByOwner),
503
- 'tokenAccountsByOwner is required when parsing token tx'
504
- )
517
+ if (solanaTransferTx) {
518
+ const isSending = ownerAddress === solanaTransferTx.source
519
+ tx = {
520
+ owner: solanaTransferTx.source,
521
+ from: solanaTransferTx.source,
522
+ to: solanaTransferTx.destination,
523
+ amount: solanaTransferTx.lamports, // number
524
+ fee: isSending ? fee : 0,
525
+ }
526
+ }
505
527
 
506
- const tokenTxs = lodash
507
- .filter(instructions, ({ program, type }) => {
508
- return (
509
- program === 'spl-token' &&
510
- ['transfer', 'transferChecked', 'transferCheckedWithFee'].includes(type)
511
- )
512
- }) // get Token transfer: could have more than 1 instructions
513
- .map((ix) => {
514
- // add token details based on source/destination address
515
- let tokenAccount = lodash.find(tokenAccountsByOwner, { tokenAccountAddress: ix.source })
516
- const isSending = !!tokenAccount
517
- if (!isSending)
518
- tokenAccount = lodash.find(tokenAccountsByOwner, {
519
- tokenAccountAddress: ix.destination,
520
- }) // receiving
521
- if (!tokenAccount) return null // no transfers with our addresses involved
522
- const owner = isSending ? ownerAddress : null
523
-
524
- delete tokenAccount.balance
525
- delete tokenAccount.owner
526
- return {
527
- owner,
528
- token: tokenAccount,
529
- from: ix.source,
530
- to: ix.destination,
531
- amount: Number(ix.amount || lodash.get(ix, 'tokenAmount.amount', 0)), // supporting types: transfer, transferChecked, transferCheckedWithFee
532
- fee: isSending ? fee : 0, // in lamports
533
- }
534
- })
528
+ // Parse Token txs
529
+ const tokenTxs = this._parseTokenTransfers({
530
+ instructions,
531
+ tokenAccountsByOwner,
532
+ ownerAddress,
533
+ fee,
534
+ preTokenBalances,
535
+ postTokenBalances,
536
+ })
535
537
 
536
538
  if (tokenTxs.length > 0) {
537
539
  // found spl-token simple transfer/transferChecked instruction
538
- // .reduce to sum/sub (based on isSending) all the same tokens amount (From instructions -> 1 single tx)
539
- tx = tokenTxs.reduce((finalTx, ix) => {
540
- if (!ix) return finalTx // skip null instructions
541
- if (!finalTx.token) return ix // init finalTx (support just 1 token type per tx)
542
- if (finalTx.token.ticker === ix.token.ticker) finalTx.amount += ix.amount
543
- return finalTx
544
- }, {})
540
+ tx.tokenTxs = tokenTxs.map((tx) => ({
541
+ id: txDetails.transaction.signatures[0],
542
+ slot: txDetails.slot,
543
+ ...tx,
544
+ }))
545
545
  } else if (preTokenBalances && postTokenBalances) {
546
546
  // probably a DEX program is involved (multiple instructions), compute balance changes
547
547
 
548
- const accountIndexes = lodash.mapKeys(accountKeys, (x, i) => i)
549
- Object.values(accountIndexes).forEach((acc) => {
550
- // filter by ownerAddress
551
- // eslint-disable-next-line unicorn/prefer-array-some
552
- const hasKnownOwner = !!lodash.find(tokenAccountsByOwner, {
553
- tokenAccountAddress: acc.pubkey,
554
- })
555
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
556
- acc.owner = hasKnownOwner ? ownerAddress : null
557
- })
548
+ const accountIndexes = accountKeys.reduce((acc, key, i) => {
549
+ const hasKnownOwner = tokenAccountsByOwner.some(
550
+ (tokenAccount) => tokenAccount.tokenAccountAddress === key.pubkey
551
+ )
552
+
553
+ acc[i] = {
554
+ ...key,
555
+ owner: hasKnownOwner ? ownerAddress : null,
556
+ }
557
+
558
+ return acc
559
+ }, Object.create(null))
558
560
 
559
561
  // group by owner and supported token
560
562
  const preBalances = preTokenBalances.filter((t) => {
@@ -627,6 +629,54 @@ export class Api {
627
629
  }
628
630
  }
629
631
 
632
+ _parseTokenTransfers({
633
+ instructions,
634
+ tokenAccountsByOwner,
635
+ ownerAddress,
636
+ fee,
637
+ preTokenBalances,
638
+ postTokenBalances,
639
+ }) {
640
+ if (
641
+ preTokenBalances.length === 0 &&
642
+ postTokenBalances.length === 0 &&
643
+ !Array.isArray(tokenAccountsByOwner)
644
+ )
645
+ return []
646
+
647
+ const tokenTxs = []
648
+
649
+ instructions.forEach((instruction) => {
650
+ const { type, program, source, destination, amount, tokenAmount } = instruction
651
+
652
+ if (isSplTransferInstruction({ program, type })) {
653
+ let tokenAccount = lodash.find(tokenAccountsByOwner, { tokenAccountAddress: source })
654
+ const isSending = !!tokenAccount
655
+ if (!isSending)
656
+ tokenAccount = lodash.find(tokenAccountsByOwner, {
657
+ tokenAccountAddress: destination,
658
+ }) // receiving
659
+ if (!tokenAccount) return // no transfers with our addresses involved
660
+
661
+ const owner = isSending ? ownerAddress : null
662
+
663
+ delete tokenAccount.balance
664
+ delete tokenAccount.owner
665
+
666
+ tokenTxs.push({
667
+ owner,
668
+ token: tokenAccount,
669
+ from: isSending ? ownerAddress : source,
670
+ to: isSending ? destination : ownerAddress,
671
+ amount: Number(amount || tokenAmount?.amount || 0), // supporting types: transfer, transferChecked, transferCheckedWithFee
672
+ fee: isSending ? fee : 0, // in lamports
673
+ })
674
+ }
675
+ })
676
+
677
+ return tokenTxs
678
+ }
679
+
630
680
  async getWalletTokensList({ tokenAccounts }) {
631
681
  const tokensMint = []
632
682
  for (const account of tokenAccounts) {
package/src/txs-utils.js CHANGED
@@ -1,3 +1,9 @@
1
+ const TRANSFER_INSTRUCTION_TYPES = new Set([
2
+ 'transfer',
3
+ 'transferChecked',
4
+ 'transferCheckedWithFee',
5
+ ])
6
+
1
7
  const isSolanaTx = (tx) => tx.coinName === 'solana'
2
8
  export const isSolanaStaking = (tx) =>
3
9
  isSolanaTx(tx) && ['createAccountWithSeed', 'delegate'].includes(tx?.data?.staking?.method)
@@ -6,3 +12,6 @@ export const isSolanaUnstaking = (tx) =>
6
12
  export const isSolanaWithdrawn = (tx) => isSolanaTx(tx) && tx?.data?.staking?.method === 'withdraw'
7
13
  export const isSolanaRewardsActivityTx = (tx) =>
8
14
  [isSolanaStaking, isSolanaUnstaking, isSolanaWithdrawn].some((fn) => fn(tx))
15
+
16
+ export const isSplTransferInstruction = ({ program, type }) =>
17
+ program === 'spl-token' && TRANSFER_INSTRUCTION_TYPES.has(type)