@exodus/solana-api 3.27.8 → 3.29.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,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.29.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.28.0...@exodus/solana-api@3.29.0) (2026-02-03)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat(solana): add getDelegatedAddresses to full asset (#7377)
13
+
14
+
15
+
16
+ ## [3.28.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.27.8...@exodus/solana-api@3.28.0) (2026-02-02)
17
+
18
+
19
+ ### Features
20
+
21
+
22
+ * feat(solana): reintroduce solana SPL delegation (#7353)
23
+
24
+
25
+
6
26
  ## [3.27.8](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.27.7...@exodus/solana-api@3.27.8) (2026-01-27)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "3.27.8",
3
+ "version": "3.29.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,7 @@
33
33
  "@exodus/fetch": "^1.7.3",
34
34
  "@exodus/models": "^12.0.1",
35
35
  "@exodus/simple-retry": "^0.0.6",
36
- "@exodus/solana-lib": "^3.19.2",
36
+ "@exodus/solana-lib": "^3.20.1",
37
37
  "@exodus/solana-meta": "^2.0.2",
38
38
  "@exodus/timer": "^1.1.1",
39
39
  "debug": "^4.1.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": "8975d39bf67554be2555effb3d3cd9d9e0b2d84b",
52
+ "gitHead": "d844d6fa5e5eec3c7b279ee5317f676357eb82b2",
53
53
  "bugs": {
54
54
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
55
55
  },
@@ -37,6 +37,11 @@ export const createAccountState = ({ assetList }) => {
37
37
  earned: asset.currency.defaultUnit(0),
38
38
  accounts: Object.create(null), // stake accounts
39
39
  },
40
+ tokenDelegationInfo: {
41
+ loaded: false,
42
+ delegatedAccounts: [],
43
+ delegatedBalances: Object.create(null),
44
+ },
40
45
  }
41
46
 
42
47
  static _tokens = [asset, ...tokens] // deprecated - will be removed
package/src/api.js CHANGED
@@ -7,7 +7,9 @@ import {
7
7
  buildRawTransaction,
8
8
  computeBalance,
9
9
  deserializeMetaplexMetadata,
10
+ EXOD_SHARES_MINT_ADDRESS,
10
11
  filterAccountsByOwner,
12
+ findAssociatedTokenAddress,
11
13
  getMetadataAccount,
12
14
  getTransactionSimulationParams,
13
15
  SOL_DECIMAL,
@@ -21,6 +23,10 @@ import ms from 'ms'
21
23
  import urljoin from 'url-join'
22
24
 
23
25
  import { getStakeActivation } from './get-stake-activation/index.js'
26
+ import {
27
+ fetchDelegatedBalances as _fetchDelegatedBalances,
28
+ fetchValidatedDelegation as _fetchValidatedDelegation,
29
+ } from './tx-log/delegation-utils.js'
24
30
  import { parseTransaction } from './tx-parser.js'
25
31
  import { isSolAddressPoisoningTx } from './txs-utils.js'
26
32
 
@@ -67,8 +73,10 @@ export class Api {
67
73
  }
68
74
 
69
75
  setTokens(assets = {}) {
70
- const solTokens = pickBy(assets, (asset) => asset.name !== asset.baseAsset.name)
71
- this.tokens = new Map(Object.values(solTokens).map((v) => [v.mintAddress, v]))
76
+ this.tokensByName = pickBy(assets, (asset) => asset.name !== asset.baseAsset.name)
77
+ this.tokens = new Map(
78
+ Object.values(this.tokensByName).map((token) => [token.mintAddress, token])
79
+ )
72
80
  }
73
81
 
74
82
  request(path, contentType = 'application/json') {
@@ -420,6 +428,23 @@ export class Api {
420
428
  ].includes(owner)
421
429
  }
422
430
 
431
+ async fetchValidatedDelegation({ delegatedAddress, expectedDelegate }) {
432
+ return _fetchValidatedDelegation({
433
+ rpcCall: (method, params) => this.rpcCall(method, params),
434
+ delegatedAddress,
435
+ expectedDelegate,
436
+ })
437
+ }
438
+
439
+ async fetchDelegatedBalances({ delegatedAccounts, address }) {
440
+ return _fetchDelegatedBalances({
441
+ rpcCall: (method, params) => this.rpcCall(method, params),
442
+ delegatedAccounts,
443
+ address,
444
+ assets: this.tokensByName,
445
+ })
446
+ }
447
+
423
448
  ataOwnershipChangedCached = memoizeLruCache(
424
449
  (...args) => this.ataOwnershipChanged(...args),
425
450
  (address, tokenAddress) => `${address}:${tokenAddress}`,
@@ -504,6 +529,45 @@ export class Api {
504
529
  return lodash.get(value, 'data.parsed.info.mint', null)
505
530
  }
506
531
 
532
+ async isWhitelisted({ address, tokenMintAddress = EXOD_SHARES_MINT_ADDRESS }) {
533
+ // check if address is whitelisted for a specific token (e.g. EXOD shares)
534
+ // Returns true if the ATA exists, is not frozen, and can receive the token
535
+ try {
536
+ // Derive the associated token account address for the given owner and mint
537
+ // EXOD is a Token-2022, so we use TOKEN_2022_PROGRAM_ID
538
+ const associatedTokenAddress = findAssociatedTokenAddress(
539
+ address,
540
+ tokenMintAddress,
541
+ TOKEN_2022_PROGRAM_ID.toBase58()
542
+ )
543
+
544
+ // Get the token account info to check if it exists and its state
545
+ const accountInfo = await this.getAccountInfo(associatedTokenAddress)
546
+
547
+ // If account doesn't exist, it's not whitelisted
548
+ if (!accountInfo || !accountInfo.data) {
549
+ return false
550
+ }
551
+
552
+ const parsedInfo = accountInfo.data?.parsed?.info
553
+
554
+ // Check if the mint matches
555
+ if (parsedInfo?.mint !== tokenMintAddress) {
556
+ return false
557
+ }
558
+
559
+ // Check if the account is frozen
560
+ // State can be "initialized" (not frozen) or "frozen"
561
+ const state = parsedInfo?.state
562
+
563
+ // Account exists, mint matches, and is not frozen - whitelisted
564
+ return state !== 'frozen'
565
+ } catch (error) {
566
+ console.warn('isWhitelisted error:', error)
567
+ return false
568
+ }
569
+ }
570
+
507
571
  async isTokenAddress(address) {
508
572
  const type = await this.getAddressType(address)
509
573
  return ['token', 'token-2022'].includes(type)
@@ -119,17 +119,54 @@ export const createTxFactory = ({ assetClientInterface, api, feePayerClient }) =
119
119
  throw err
120
120
  }
121
121
 
122
- const [destinationAddressType, isAssociatedTokenAccountActive, fromTokenAccountAddresses] =
123
- await Promise.all([
124
- api.getAddressType(toAddress),
125
- api.isAssociatedTokenAccountActive(tokenAddress),
126
- api.getTokenAccountsByOwner(fromAddress),
127
- ])
128
-
129
- const fromTokenAddresses = fromTokenAccountAddresses.filter(
122
+ const [
123
+ destinationAddressType,
124
+ isAssociatedTokenAccountActive,
125
+ fromTokenAccountAddresses,
126
+ accountState,
127
+ ] = await Promise.all([
128
+ api.getAddressType(toAddress),
129
+ api.isAssociatedTokenAccountActive(tokenAddress),
130
+ api.getTokenAccountsByOwner(fromAddress),
131
+ assetClientInterface.getAccountState({ walletAccount, assetName: baseAssetName }),
132
+ ])
133
+
134
+ const ownedForMint = fromTokenAccountAddresses.filter(
130
135
  ({ mintAddress }) => mintAddress === tokenMintAddress
131
136
  )
132
137
 
138
+ const delegatedAccounts = accountState.tokenDelegationInfo?.delegatedAccounts || []
139
+ const delegatedAccountsResults = await Promise.all(
140
+ delegatedAccounts
141
+ .filter(({ assetName }) => assetName === asset.name)
142
+ .map(async ({ delegatedAddress }) => {
143
+ try {
144
+ const delegation = await api.fetchValidatedDelegation({
145
+ delegatedAddress,
146
+ expectedDelegate: fromAddress,
147
+ })
148
+
149
+ if (!delegation) return null
150
+
151
+ return {
152
+ tokenAccountAddress: delegatedAddress,
153
+ mintAddress: tokenMintAddress,
154
+ balance: delegation.balance,
155
+ decimals: delegation.decimals,
156
+ tokenProgram: delegation.tokenProgram,
157
+ isDelegated: true,
158
+ delegatedAmount: delegation.delegatedAmount,
159
+ }
160
+ } catch (error) {
161
+ console.warn(`Failed to fetch delegated account ${delegatedAddress}:`, error)
162
+ return null
163
+ }
164
+ })
165
+ )
166
+ const validDelegatedAccounts = delegatedAccountsResults.filter(Boolean)
167
+
168
+ const fromTokenAddresses = [...ownedForMint, ...validDelegatedAccounts]
169
+
133
170
  tokenParams = {
134
171
  tokenMintAddress,
135
172
  destinationAddressType,
@@ -149,11 +149,15 @@ const fixBalances = ({
149
149
  }
150
150
 
151
151
  const getBalanceFromAccountState = ({ asset, accountState }) => {
152
- const isBase = asset.name === asset.baseAsset.name
153
- return (
154
- (isBase ? accountState?.balance : accountState?.tokenBalances?.[asset.name]) ||
155
- asset.currency.ZERO
156
- )
152
+ if (asset.name === asset.baseAsset.name) {
153
+ return accountState.balance || asset.currency.ZERO
154
+ }
155
+
156
+ const ownedBalance = accountState.tokenBalances?.[asset.name] || asset.currency.ZERO
157
+ const delegatedBalance =
158
+ accountState.tokenDelegationInfo?.delegatedBalances?.[asset.name] || asset.currency.ZERO
159
+
160
+ return ownedBalance.add(delegatedBalance)
157
161
  }
158
162
 
159
163
  const hasStakedFunds = ({ locked, activating, withdrawable, pending }) =>
package/src/index.js CHANGED
@@ -22,6 +22,7 @@ export { stakingProviderClientFactory } from './staking-provider-client.js'
22
22
  export { createTxFactory } from './create-unsigned-tx-for-send.js'
23
23
  export { feePayerClientFactory } from './fee-payer.js'
24
24
  export { createInitAgentWalletFactory } from './init-agent-wallet.js'
25
+ export { createTokenDelegationFactory } from './token-delegation.js'
25
26
 
26
27
  // These are not the same asset objects as the wallet creates, so they should never be returned to the wallet.
27
28
  // Initially this may be violated by the Solana code until the first monitor tick updates assets with setTokens()
package/src/rpc-api.js CHANGED
@@ -5,7 +5,9 @@ import {
5
5
  buildRawTransaction,
6
6
  computeBalance,
7
7
  deserializeMetaplexMetadata,
8
+ EXOD_SHARES_MINT_ADDRESS,
8
9
  filterAccountsByOwner,
10
+ findAssociatedTokenAddress,
9
11
  getMetadataAccount,
10
12
  getTransactionSimulationParams,
11
13
  SOL_DECIMAL,
@@ -17,6 +19,10 @@ import assert from 'minimalistic-assert'
17
19
  import ms from 'ms'
18
20
 
19
21
  import { getStakeActivation } from './get-stake-activation/index.js'
22
+ import {
23
+ fetchDelegatedBalances as _fetchDelegatedBalances,
24
+ fetchValidatedDelegation as _fetchValidatedDelegation,
25
+ } from './tx-log/delegation-utils.js'
20
26
 
21
27
  const [SYSTEM_PROGRAM_ID, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID] = [
22
28
  SYSTEM_PROGRAM_ID_KEY.toBase58(),
@@ -62,8 +68,10 @@ export class RpcApi {
62
68
  }
63
69
 
64
70
  setTokens(assets) {
65
- const solTokens = pickBy(assets, (asset) => asset.name !== asset.baseAsset.name)
66
- this.tokens = new Map(Object.values(solTokens).map((v) => [v.mintAddress, v]))
71
+ this.tokensByName = pickBy(assets, (asset) => asset.name !== asset.baseAsset.name)
72
+ this.tokens = new Map(
73
+ Object.values(this.tokensByName).map((token) => [token.mintAddress, token])
74
+ )
67
75
  }
68
76
 
69
77
  async rpcCall(method, params = []) {
@@ -117,6 +125,45 @@ export class RpcApi {
117
125
  return this.rpcCall('getBlockTime', [slot])
118
126
  }
119
127
 
128
+ async isWhitelisted({ address, tokenMintAddress = EXOD_SHARES_MINT_ADDRESS }) {
129
+ // check if address is whitelisted for a specific token (e.g. EXOD shares)
130
+ // Returns true if the ATA exists, is not frozen, and can receive the token
131
+ try {
132
+ // Derive the associated token account address for the given owner and mint
133
+ // EXOD is a Token-2022, so we use TOKEN_2022_PROGRAM_ID
134
+ const associatedTokenAddress = findAssociatedTokenAddress(
135
+ address,
136
+ tokenMintAddress,
137
+ TOKEN_2022_PROGRAM_ID
138
+ )
139
+
140
+ // Get the token account info to check if it exists and its state
141
+ const accountInfo = await this.getRawAccountInfo({ address: associatedTokenAddress })
142
+
143
+ // If account doesn't exist, it's not whitelisted
144
+ if (!accountInfo || !accountInfo.data) {
145
+ return false
146
+ }
147
+
148
+ const parsedInfo = accountInfo.data?.parsed?.info
149
+
150
+ // Check if the mint matches
151
+ if (parsedInfo?.mint !== tokenMintAddress) {
152
+ return false
153
+ }
154
+
155
+ // Check if the account is frozen
156
+ // State can be "initialized" (not frozen) or "frozen"
157
+ const state = parsedInfo?.state
158
+
159
+ // Account exists, mint matches, and is not frozen - whitelisted
160
+ return state !== 'frozen'
161
+ } catch (error) {
162
+ console.warn('isWhitelisted error:', error)
163
+ return false
164
+ }
165
+ }
166
+
120
167
  async waitForTransactionStatus(txIds, status = 'finalized', timeoutMs = ms('1m')) {
121
168
  if (!Array.isArray(txIds)) txIds = [txIds]
122
169
  const startTime = Date.now()
@@ -173,6 +220,14 @@ export class RpcApi {
173
220
  return [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID].includes(owner)
174
221
  }
175
222
 
223
+ async getRawAccountInfo({ address, encoding = 'jsonParsed' }) {
224
+ const { value } = await this.rpcCall('getAccountInfo', [
225
+ address,
226
+ { encoding, commitment: 'confirmed' },
227
+ ])
228
+ return value
229
+ }
230
+
176
231
  async getTokenBalance(tokenAddress) {
177
232
  // Returns account balance of a SPL Token account.
178
233
  const result = await this.rpcCall('getTokenAccountBalance', [tokenAddress])
@@ -406,6 +461,23 @@ export class RpcApi {
406
461
  }
407
462
  }
408
463
 
464
+ async fetchValidatedDelegation({ delegatedAddress, expectedDelegate }) {
465
+ return _fetchValidatedDelegation({
466
+ rpcCall: (method, params) => this.rpcCall(method, params),
467
+ delegatedAddress,
468
+ expectedDelegate,
469
+ })
470
+ }
471
+
472
+ async fetchDelegatedBalances({ delegatedAccounts, address }) {
473
+ return _fetchDelegatedBalances({
474
+ rpcCall: (method, params) => this.rpcCall(method, params),
475
+ delegatedAccounts,
476
+ address,
477
+ assets: this.tokensByName,
478
+ })
479
+ }
480
+
409
481
  simulateUnsignedTransaction = async ({ message, transactionMessage }) => {
410
482
  const { config, accountAddresses } = getTransactionSimulationParams(
411
483
  transactionMessage || message
@@ -0,0 +1,129 @@
1
+ import {
2
+ createApproveDelegationTx,
3
+ createRevokeDelegationTx,
4
+ TOKEN_2022_PROGRAM_ID,
5
+ TOKEN_PROGRAM_ID,
6
+ } from '@exodus/solana-lib'
7
+ import assert from 'minimalistic-assert'
8
+
9
+ // SPL Token account layout offsets
10
+ const MINT_OFFSET = 0
11
+ const DELEGATE_OFFSET = 76 // mint (32) + owner (32) + amount (8) + delegateOption (4)
12
+
13
+ async function fetchDelegatedAccountsForMint({ api, programId, mintAddress, delegateAddress }) {
14
+ const config = {
15
+ filters: [
16
+ { memcmp: { offset: MINT_OFFSET, bytes: mintAddress } },
17
+ { memcmp: { offset: DELEGATE_OFFSET, bytes: delegateAddress } },
18
+ ],
19
+ encoding: 'jsonParsed',
20
+ }
21
+
22
+ return api.getProgramAccounts(programId, config)
23
+ }
24
+
25
+ export const createTokenDelegationFactory = ({ api, assetClientInterface }) => {
26
+ assert(api, 'api is required')
27
+ assert(assetClientInterface, 'assetClientInterface is required')
28
+
29
+ const approveDelegation = async ({
30
+ asset,
31
+ walletAccount,
32
+ delegateAddress,
33
+ amount,
34
+ tokenProgram,
35
+ }) => {
36
+ assert(asset, 'asset is required')
37
+ assert(walletAccount, 'walletAccount is required')
38
+ assert(delegateAddress, 'delegateAddress is required')
39
+
40
+ const baseAssetName = asset.baseAsset.name
41
+ const assetName = asset.name
42
+ const tokenMintAddress = asset.mintAddress
43
+ assert(tokenMintAddress, 'asset must be a token with mintAddress')
44
+
45
+ const ownerAddress = await assetClientInterface.getReceiveAddress({
46
+ assetName: baseAssetName,
47
+ walletAccount,
48
+ })
49
+
50
+ const recentBlockhash = await api.getRecentBlockHash()
51
+
52
+ const transaction = createApproveDelegationTx({
53
+ ownerAddress,
54
+ tokenMintAddress,
55
+ delegateAddress,
56
+ amount,
57
+ recentBlockhash,
58
+ tokenProgram,
59
+ })
60
+
61
+ return {
62
+ unsignedTx: {
63
+ txData: { transaction },
64
+ txMeta: { assetName },
65
+ },
66
+ }
67
+ }
68
+
69
+ const revokeDelegation = async ({ asset, walletAccount, tokenProgram }) => {
70
+ assert(asset, 'asset is required')
71
+ assert(walletAccount, 'walletAccount is required')
72
+
73
+ const baseAssetName = asset.baseAsset.name
74
+ const assetName = asset.name
75
+ const tokenMintAddress = asset.mintAddress
76
+ assert(tokenMintAddress, 'asset must be a token with mintAddress')
77
+
78
+ const ownerAddress = await assetClientInterface.getReceiveAddress({
79
+ assetName: baseAssetName,
80
+ walletAccount,
81
+ })
82
+
83
+ const recentBlockhash = await api.getRecentBlockHash()
84
+
85
+ const transaction = createRevokeDelegationTx({
86
+ ownerAddress,
87
+ tokenMintAddress,
88
+ recentBlockhash,
89
+ tokenProgram,
90
+ })
91
+
92
+ return {
93
+ unsignedTx: {
94
+ txData: { transaction },
95
+ txMeta: { assetName },
96
+ },
97
+ }
98
+ }
99
+
100
+ const getDelegatedAddresses = async ({ address, mintAddress }) => {
101
+ assert(address, 'address is required')
102
+ assert(mintAddress, 'mintAddress is required')
103
+
104
+ const token = api.tokens.get(mintAddress)
105
+ if (!token) return []
106
+
107
+ const [tokenAccounts, token2022Accounts] = await Promise.all([
108
+ fetchDelegatedAccountsForMint({
109
+ api,
110
+ programId: TOKEN_PROGRAM_ID.toBase58(),
111
+ mintAddress,
112
+ delegateAddress: address,
113
+ }),
114
+ fetchDelegatedAccountsForMint({
115
+ api,
116
+ programId: TOKEN_2022_PROGRAM_ID.toBase58(),
117
+ mintAddress,
118
+ delegateAddress: address,
119
+ }),
120
+ ])
121
+
122
+ return [...tokenAccounts, ...token2022Accounts].map((account) => ({
123
+ delegatedAddress: account.pubkey,
124
+ assetName: token.name,
125
+ }))
126
+ }
127
+
128
+ return { approveDelegation, revokeDelegation, getDelegatedAddresses }
129
+ }
@@ -271,12 +271,18 @@ export class SolanaClarityMonitor extends BaseMonitor {
271
271
  }
272
272
 
273
273
  async getAccountsAndBalances({ refresh, address, accountState, walletAccount }) {
274
- const [accountInfo, { balances: splBalances, accounts: tokenAccounts }] = await Promise.all([
275
- this.clarityApi.getAccountInfo(address).catch(() => {}),
276
- this.clarityApi.getTokensBalancesAndAccounts({
277
- address,
278
- }),
279
- ])
274
+ const delegatedAccounts = accountState.tokenDelegationInfo?.delegatedAccounts || []
275
+ const [accountInfo, { balances: splBalances, accounts: tokenAccounts }, delegatedBalances] =
276
+ await Promise.all([
277
+ this.clarityApi.getAccountInfo(address).catch(() => {}),
278
+ this.clarityApi.getTokensBalancesAndAccounts({
279
+ address,
280
+ }),
281
+ this.clarityApi.fetchDelegatedBalances({
282
+ delegatedAccounts,
283
+ address,
284
+ }),
285
+ ])
280
286
 
281
287
  const solBalance = accountInfo?.lamports || 0
282
288
 
@@ -332,6 +338,8 @@ export class SolanaClarityMonitor extends BaseMonitor {
332
338
  account: {
333
339
  balance,
334
340
  tokenBalances,
341
+ delegatedAccounts,
342
+ delegatedBalances,
335
343
  rentExemptAmount,
336
344
  accountSize,
337
345
  ownerChanged,
@@ -343,13 +351,26 @@ export class SolanaClarityMonitor extends BaseMonitor {
343
351
 
344
352
  updateState({ account, cursorState = {}, walletAccount, staking, batch }) {
345
353
  const assetName = this.asset.name
346
- const { balance, tokenBalances, rentExemptAmount, accountSize, ownerChanged } = account
354
+ const {
355
+ balance,
356
+ tokenBalances,
357
+ delegatedAccounts,
358
+ delegatedBalances,
359
+ rentExemptAmount,
360
+ accountSize,
361
+ ownerChanged,
362
+ } = account
347
363
  const newData = {
348
364
  balance,
349
365
  rentExemptAmount,
350
366
  accountSize,
351
367
  ownerChanged,
352
368
  tokenBalances,
369
+ tokenDelegationInfo: {
370
+ loaded: true,
371
+ delegatedAccounts: delegatedAccounts || [],
372
+ delegatedBalances: delegatedBalances || Object.create(null),
373
+ },
353
374
  stakingInfo: staking,
354
375
  ...cursorState,
355
376
  }
@@ -0,0 +1,63 @@
1
+ async function fetchDelegatedAccountInfo({ rpcCall, delegatedAddress }) {
2
+ return rpcCall('getAccountInfo', [delegatedAddress, { encoding: 'jsonParsed' }], {
3
+ address: delegatedAddress,
4
+ })
5
+ }
6
+
7
+ function parseDelegationInfo({ accountInfo, expectedDelegate }) {
8
+ if (!accountInfo?.value?.data?.parsed) return null
9
+
10
+ const info = accountInfo.value.data.parsed.info
11
+ if (info.delegate !== expectedDelegate) return null
12
+
13
+ return {
14
+ balance: info.tokenAmount?.amount || '0',
15
+ decimals: info.tokenAmount?.decimals || 0,
16
+ delegatedAmount: info.delegatedAmount?.amount || '0',
17
+ tokenProgram: accountInfo.value.owner,
18
+ }
19
+ }
20
+
21
+ function calculateSpendableAmount({ balance, delegatedAmount }) {
22
+ return BigInt(balance) < BigInt(delegatedAmount) ? balance : delegatedAmount
23
+ }
24
+
25
+ export async function fetchValidatedDelegation({ rpcCall, delegatedAddress, expectedDelegate }) {
26
+ const accountInfo = await fetchDelegatedAccountInfo({ rpcCall, delegatedAddress })
27
+ return parseDelegationInfo({ accountInfo, expectedDelegate })
28
+ }
29
+
30
+ export async function fetchDelegatedBalances({ delegatedAccounts, address, assets, rpcCall }) {
31
+ const delegatedBalances = Object.create(null)
32
+
33
+ if (delegatedAccounts.length === 0) return delegatedBalances
34
+
35
+ for (const { delegatedAddress, assetName } of delegatedAccounts) {
36
+ try {
37
+ const delegation = await fetchValidatedDelegation({
38
+ rpcCall,
39
+ delegatedAddress,
40
+ expectedDelegate: address,
41
+ })
42
+
43
+ if (!delegation) continue
44
+ if (!assets[assetName]) continue
45
+
46
+ const spendable = calculateSpendableAmount({
47
+ balance: delegation.balance,
48
+ delegatedAmount: delegation.delegatedAmount,
49
+ })
50
+ const amount = assets[assetName].currency.baseUnit(spendable)
51
+
52
+ if (delegatedBalances[assetName]) {
53
+ delegatedBalances[assetName] = delegatedBalances[assetName].add(amount)
54
+ } else {
55
+ delegatedBalances[assetName] = amount
56
+ }
57
+ } catch (error) {
58
+ console.warn(`Failed to fetch delegated account ${delegatedAddress}:`, error)
59
+ }
60
+ }
61
+
62
+ return delegatedBalances
63
+ }
@@ -254,14 +254,20 @@ export class SolanaMonitor extends BaseMonitor {
254
254
  }
255
255
 
256
256
  async getAccountsAndBalances({ refresh, address, accountState, walletAccount }) {
257
+ const delegatedAccounts = accountState.tokenDelegationInfo?.delegatedAccounts || []
257
258
  const tokens = Object.keys(this.assets).filter((name) => name !== this.asset.name)
258
- const [accountInfo, { balances: splBalances, accounts: tokenAccounts }] = await Promise.all([
259
- this.rpcApi.getAccountInfo(address).catch(() => {}),
260
- this.rpcApi.getTokensBalancesAndAccounts({
261
- address,
262
- filterByTokens: tokens,
263
- }),
264
- ])
259
+ const [accountInfo, { balances: splBalances, accounts: tokenAccounts }, delegatedBalances] =
260
+ await Promise.all([
261
+ this.rpcApi.getAccountInfo(address).catch(() => {}),
262
+ this.rpcApi.getTokensBalancesAndAccounts({
263
+ address,
264
+ filterByTokens: tokens,
265
+ }),
266
+ this.rpcApi.fetchDelegatedBalances({
267
+ delegatedAccounts,
268
+ address,
269
+ }),
270
+ ])
265
271
 
266
272
  const solBalance = accountInfo?.lamports || 0
267
273
 
@@ -309,6 +315,8 @@ export class SolanaMonitor extends BaseMonitor {
309
315
  account: {
310
316
  balance,
311
317
  tokenBalances,
318
+ delegatedAccounts,
319
+ delegatedBalances,
312
320
  rentExemptAmount,
313
321
  accountSize,
314
322
  ownerChanged,
@@ -320,13 +328,26 @@ export class SolanaMonitor extends BaseMonitor {
320
328
 
321
329
  updateState({ account, cursorState = {}, walletAccount, staking, batch }) {
322
330
  const assetName = this.asset.name
323
- const { balance, tokenBalances, rentExemptAmount, accountSize, ownerChanged } = account
331
+ const {
332
+ balance,
333
+ tokenBalances,
334
+ delegatedAccounts,
335
+ delegatedBalances,
336
+ rentExemptAmount,
337
+ accountSize,
338
+ ownerChanged,
339
+ } = account
324
340
  const newData = {
325
341
  balance,
326
342
  rentExemptAmount,
327
343
  accountSize,
328
344
  ownerChanged,
329
345
  tokenBalances,
346
+ tokenDelegationInfo: {
347
+ loaded: true,
348
+ delegatedAccounts: delegatedAccounts || [],
349
+ delegatedBalances: delegatedBalances || Object.create(null),
350
+ },
330
351
  stakingInfo: staking,
331
352
  ...cursorState,
332
353
  }