@exodus/solana-api 3.27.8 → 3.28.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,16 @@
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.28.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.27.8...@exodus/solana-api@3.28.0) (2026-02-02)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat(solana): reintroduce solana SPL delegation (#7353)
13
+
14
+
15
+
6
16
  ## [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
17
 
8
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "3.27.8",
3
+ "version": "3.28.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.0",
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": "3cea3bc89ff5c4aaa461ac148e384ea24dbe2d38",
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
@@ -21,6 +21,10 @@ import ms from 'ms'
21
21
  import urljoin from 'url-join'
22
22
 
23
23
  import { getStakeActivation } from './get-stake-activation/index.js'
24
+ import {
25
+ fetchDelegatedBalances as _fetchDelegatedBalances,
26
+ fetchValidatedDelegation as _fetchValidatedDelegation,
27
+ } from './tx-log/delegation-utils.js'
24
28
  import { parseTransaction } from './tx-parser.js'
25
29
  import { isSolAddressPoisoningTx } from './txs-utils.js'
26
30
 
@@ -67,8 +71,10 @@ export class Api {
67
71
  }
68
72
 
69
73
  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]))
74
+ this.tokensByName = pickBy(assets, (asset) => asset.name !== asset.baseAsset.name)
75
+ this.tokens = new Map(
76
+ Object.values(this.tokensByName).map((token) => [token.mintAddress, token])
77
+ )
72
78
  }
73
79
 
74
80
  request(path, contentType = 'application/json') {
@@ -420,6 +426,23 @@ export class Api {
420
426
  ].includes(owner)
421
427
  }
422
428
 
429
+ async fetchValidatedDelegation({ delegatedAddress, expectedDelegate }) {
430
+ return _fetchValidatedDelegation({
431
+ rpcCall: (method, params) => this.rpcCall(method, params),
432
+ delegatedAddress,
433
+ expectedDelegate,
434
+ })
435
+ }
436
+
437
+ async fetchDelegatedBalances({ delegatedAccounts, address }) {
438
+ return _fetchDelegatedBalances({
439
+ rpcCall: (method, params) => this.rpcCall(method, params),
440
+ delegatedAccounts,
441
+ address,
442
+ assets: this.tokensByName,
443
+ })
444
+ }
445
+
423
446
  ataOwnershipChangedCached = memoizeLruCache(
424
447
  (...args) => this.ataOwnershipChanged(...args),
425
448
  (address, tokenAddress) => `${address}:${tokenAddress}`,
@@ -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
@@ -17,6 +17,10 @@ import assert from 'minimalistic-assert'
17
17
  import ms from 'ms'
18
18
 
19
19
  import { getStakeActivation } from './get-stake-activation/index.js'
20
+ import {
21
+ fetchDelegatedBalances as _fetchDelegatedBalances,
22
+ fetchValidatedDelegation as _fetchValidatedDelegation,
23
+ } from './tx-log/delegation-utils.js'
20
24
 
21
25
  const [SYSTEM_PROGRAM_ID, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID] = [
22
26
  SYSTEM_PROGRAM_ID_KEY.toBase58(),
@@ -62,8 +66,10 @@ export class RpcApi {
62
66
  }
63
67
 
64
68
  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]))
69
+ this.tokensByName = pickBy(assets, (asset) => asset.name !== asset.baseAsset.name)
70
+ this.tokens = new Map(
71
+ Object.values(this.tokensByName).map((token) => [token.mintAddress, token])
72
+ )
67
73
  }
68
74
 
69
75
  async rpcCall(method, params = []) {
@@ -406,6 +412,23 @@ export class RpcApi {
406
412
  }
407
413
  }
408
414
 
415
+ async fetchValidatedDelegation({ delegatedAddress, expectedDelegate }) {
416
+ return _fetchValidatedDelegation({
417
+ rpcCall: (method, params) => this.rpcCall(method, params),
418
+ delegatedAddress,
419
+ expectedDelegate,
420
+ })
421
+ }
422
+
423
+ async fetchDelegatedBalances({ delegatedAccounts, address }) {
424
+ return _fetchDelegatedBalances({
425
+ rpcCall: (method, params) => this.rpcCall(method, params),
426
+ delegatedAccounts,
427
+ address,
428
+ assets: this.tokensByName,
429
+ })
430
+ }
431
+
409
432
  simulateUnsignedTransaction = async ({ message, transactionMessage }) => {
410
433
  const { config, accountAddresses } = getTransactionSimulationParams(
411
434
  transactionMessage || message
@@ -0,0 +1,80 @@
1
+ import { createApproveDelegationTx, createRevokeDelegationTx } from '@exodus/solana-lib'
2
+ import assert from 'minimalistic-assert'
3
+
4
+ export const createTokenDelegationFactory = ({ api, assetClientInterface }) => {
5
+ assert(api, 'api is required')
6
+ assert(assetClientInterface, 'assetClientInterface is required')
7
+
8
+ const approveDelegation = async ({
9
+ asset,
10
+ walletAccount,
11
+ delegateAddress,
12
+ amount,
13
+ tokenProgram,
14
+ }) => {
15
+ assert(asset, 'asset is required')
16
+ assert(walletAccount, 'walletAccount is required')
17
+ assert(delegateAddress, 'delegateAddress is required')
18
+
19
+ const baseAssetName = asset.baseAsset.name
20
+ const assetName = asset.name
21
+ const tokenMintAddress = asset.mintAddress
22
+ assert(tokenMintAddress, 'asset must be a token with mintAddress')
23
+
24
+ const ownerAddress = await assetClientInterface.getReceiveAddress({
25
+ assetName: baseAssetName,
26
+ walletAccount,
27
+ })
28
+
29
+ const recentBlockhash = await api.getRecentBlockHash()
30
+
31
+ const transaction = createApproveDelegationTx({
32
+ ownerAddress,
33
+ tokenMintAddress,
34
+ delegateAddress,
35
+ amount,
36
+ recentBlockhash,
37
+ tokenProgram,
38
+ })
39
+
40
+ return {
41
+ unsignedTx: {
42
+ txData: { transaction },
43
+ txMeta: { assetName },
44
+ },
45
+ }
46
+ }
47
+
48
+ const revokeDelegation = async ({ asset, walletAccount, tokenProgram }) => {
49
+ assert(asset, 'asset is required')
50
+ assert(walletAccount, 'walletAccount is required')
51
+
52
+ const baseAssetName = asset.baseAsset.name
53
+ const assetName = asset.name
54
+ const tokenMintAddress = asset.mintAddress
55
+ assert(tokenMintAddress, 'asset must be a token with mintAddress')
56
+
57
+ const ownerAddress = await assetClientInterface.getReceiveAddress({
58
+ assetName: baseAssetName,
59
+ walletAccount,
60
+ })
61
+
62
+ const recentBlockhash = await api.getRecentBlockHash()
63
+
64
+ const transaction = createRevokeDelegationTx({
65
+ ownerAddress,
66
+ tokenMintAddress,
67
+ recentBlockhash,
68
+ tokenProgram,
69
+ })
70
+
71
+ return {
72
+ unsignedTx: {
73
+ txData: { transaction },
74
+ txMeta: { assetName },
75
+ },
76
+ }
77
+ }
78
+
79
+ return { approveDelegation, revokeDelegation }
80
+ }
@@ -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
  }