@exodus/solana-plugin 1.36.4 → 1.37.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
+ ## [1.37.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-plugin@1.36.5...@exodus/solana-plugin@1.37.0) (2026-04-27)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: add MoveFunds API for Solana (#7870)
13
+
14
+
15
+
16
+ ## [1.36.5](https://github.com/ExodusMovement/assets/compare/@exodus/solana-plugin@1.36.4...@exodus/solana-plugin@1.36.5) (2026-04-16)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+
22
+ * fix: solana devnet monitorType (#7816)
23
+
24
+
25
+
6
26
  ## [1.36.4](https://github.com/ExodusMovement/assets/compare/@exodus/solana-plugin@1.36.3...@exodus/solana-plugin@1.36.4) (2026-04-04)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-plugin",
3
- "version": "1.36.4",
3
+ "version": "1.37.0",
4
4
  "description": "Solana plugin for Exodus SDK powered wallets.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -28,7 +28,7 @@
28
28
  "@exodus/i18n-dummy": "^1.0.0",
29
29
  "@exodus/send-validation-model": "^1.0.0",
30
30
  "@exodus/solana-api": "^3.30.9",
31
- "@exodus/solana-lib": "^3.22.5",
31
+ "@exodus/solana-lib": "^3.24.0",
32
32
  "@exodus/solana-meta": "^2.9.0",
33
33
  "@exodus/web3-solana-utils": "^2.9.0",
34
34
  "minimalistic-assert": "^1.0.1",
@@ -44,5 +44,5 @@
44
44
  "type": "git",
45
45
  "url": "git+https://github.com/ExodusMovement/assets.git"
46
46
  },
47
- "gitHead": "a2cd3fbc02d07f1c3dc127be4d7896292d2dc967"
47
+ "gitHead": "a6dde437af961c72c62f64eff53207ccfaeacc95"
48
48
  }
@@ -32,6 +32,7 @@ import ms from 'ms'
32
32
 
33
33
  import { createHistoryMonitorFactory, securityChecks } from './create-asset-utils.js'
34
34
  import { createGetBalanceForAddress } from './get-balance-for-address.js'
35
+ import { moveFundsFactory } from './move-funds.js'
35
36
  import sendValidationsFactory from './send-validations.js'
36
37
  import { createWeb3API } from './web3/index.js'
37
38
 
@@ -44,7 +45,7 @@ export const createSolanaAssetFactory =
44
45
  ({
45
46
  assetClientInterface,
46
47
  config: {
47
- monitorType = 'ws-clarity', // 'rpc' | 'clarity' | 'ws-clarity'
48
+ monitorType,
48
49
  stakingFeatureAvailable = true,
49
50
  includeUnparsed = false,
50
51
  allowSendingAll = true,
@@ -62,6 +63,7 @@ export const createSolanaAssetFactory =
62
63
  } = {},
63
64
  overrideCallback = ({ asset }) => asset,
64
65
  } = {}) => {
66
+ const resolvedMonitorType = isTestnet ? 'rpc' : monitorType ?? 'ws-clarity'
65
67
  const assets = connectAssetsList(assetList)
66
68
  const { name: baseAssetName } = assetList.find((asset) => asset.baseAssetName === asset.name)
67
69
  const base = assets[baseAssetName]
@@ -69,7 +71,7 @@ export const createSolanaAssetFactory =
69
71
  const rpcApi = new Api({ rpcUrl, assets })
70
72
  const clarityApi = new ClarityApi({ assets })
71
73
  const wsApi = new WsApi({ assets })
72
- const defaultApi = ['ws-clarity', 'clarity'].includes(monitorType) ? clarityApi : rpcApi
74
+ const defaultApi = ['ws-clarity', 'clarity'].includes(resolvedMonitorType) ? clarityApi : rpcApi
73
75
 
74
76
  const smallTxAmount = base.currency.defaultUnit('0.0001')
75
77
  const accountReserve = base.currency.defaultUnit(
@@ -119,6 +121,13 @@ export const createSolanaAssetFactory =
119
121
  assetClientInterface,
120
122
  })
121
123
 
124
+ const moveFunds = moveFundsFactory({
125
+ baseAssetName,
126
+ solanaApi: rpcApi,
127
+ assetClientInterface,
128
+ createTx,
129
+ })
130
+
122
131
  const stakingApi = stakingApiFactory({
123
132
  assetName: baseAssetName,
124
133
  assetClientInterface,
@@ -162,6 +171,7 @@ export const createSolanaAssetFactory =
162
171
  family: ASSET_FAMILY.SOLANA,
163
172
  feeMonitor: false,
164
173
  feesApi: true,
174
+ moveFunds: true,
165
175
  nfts: true,
166
176
  staking: {},
167
177
  isTestnet,
@@ -209,7 +219,7 @@ export const createSolanaAssetFactory =
209
219
  })
210
220
 
211
221
  const createHistoryMonitor = createHistoryMonitorFactory({
212
- monitorType,
222
+ monitorType: resolvedMonitorType,
213
223
  assetClientInterface,
214
224
  interval: monitorInterval,
215
225
  shouldUpdateBalanceBeforeHistory,
@@ -247,6 +257,7 @@ export const createSolanaAssetFactory =
247
257
  .filter((asset) => asset.name !== base.name)
248
258
  .map(createToken),
249
259
  hasFeature: (feature) => !!features[feature], // @deprecated use api.features instead
260
+ moveFunds,
250
261
  privateKeyEncodingDefinition: { encoding: 'base58', data: 'priv|pub' },
251
262
  securityChecks,
252
263
  sendTx,
@@ -0,0 +1,159 @@
1
+ import {
2
+ getAddressFromMpcKey,
3
+ getAddressFromPrivateKey,
4
+ getPrivateKeyFromSecretKey,
5
+ isMpcKey,
6
+ isValidAddress,
7
+ signUnsignedTx,
8
+ signUnsignedTxWithMpcKey,
9
+ } from '@exodus/solana-lib'
10
+ import assert from 'minimalistic-assert'
11
+
12
+ const importers = {
13
+ seed: {
14
+ getAddress: (secretKey) => {
15
+ const privateKey = getPrivateKeyFromSecretKey(secretKey)
16
+ return getAddressFromPrivateKey(privateKey)
17
+ },
18
+ sign: (unsignedTx, secretKey) => {
19
+ const privateKey = getPrivateKeyFromSecretKey(secretKey)
20
+ return signUnsignedTx(unsignedTx, privateKey)
21
+ },
22
+ },
23
+ mpc: {
24
+ getAddress: getAddressFromMpcKey,
25
+ sign: signUnsignedTxWithMpcKey,
26
+ },
27
+ }
28
+
29
+ const getImporter = async (secretKey) => {
30
+ const isMpc = await isMpcKey(secretKey)
31
+ return isMpc ? importers.mpc : importers.seed
32
+ }
33
+
34
+ // Sentinel walletAccount used when calling baseAsset.api.createTx from the move-funds context.
35
+ // createTx requires a truthy walletAccount; for move-funds (external key import) there is no
36
+ // real wallet account, so we pass this value. The only wallet-account-specific call inside
37
+ // createTx (getAccountState for token delegation) is not relevant for move-funds.
38
+ const MOVE_FUNDS_WALLET_ACCOUNT = 'exodus:move-funds'
39
+
40
+ export const moveFundsFactory = ({ baseAssetName, solanaApi, assetClientInterface, createTx }) => {
41
+ assert(solanaApi, 'solanaApi is required')
42
+ assert(assetClientInterface, 'assetClientInterface is required')
43
+ assert(createTx, 'createTx is required')
44
+
45
+ async function prepareSendFundsTx({ assetName, input, toAddress, MoveFundsError }) {
46
+ assert(assetName, 'assetName is required')
47
+ assert(input, 'input is required')
48
+ assert(toAddress && isValidAddress(toAddress), 'valid toAddress is required')
49
+ assert(MoveFundsError, 'MoveFundsError is required')
50
+
51
+ // MPC keys are base58-encoded 64-byte values where the first 32 bytes are a scalar
52
+ // (not a seed). Check before the regular base58 path since both share the same encoding.
53
+ const secretKey = input.trim()
54
+
55
+ let fromAddress
56
+
57
+ try {
58
+ const importer = await getImporter(secretKey)
59
+ fromAddress = await importer.getAddress(secretKey)
60
+ } catch {
61
+ throw new MoveFundsError('private-key-invalid')
62
+ }
63
+
64
+ if (fromAddress === toAddress) {
65
+ throw new MoveFundsError('private-key-own-key', { fromAddress })
66
+ }
67
+
68
+ const assets = await assetClientInterface.getAssetsForNetwork({ baseAssetName })
69
+ const asset = assets[assetName]
70
+ assert(asset, 'asset is required')
71
+
72
+ const baseAsset = asset.baseAsset
73
+ const isToken = asset.name !== baseAssetName
74
+
75
+ const rawBalance = await solanaApi.getBalance(fromAddress)
76
+ const balance = baseAsset.currency.baseUnit(rawBalance)
77
+
78
+ if (!balance || balance.isZero) {
79
+ throw new MoveFundsError('balance-zero', { fromAddress })
80
+ }
81
+
82
+ let unsignedTx
83
+ let fee
84
+
85
+ if (isToken) {
86
+ const { balances } = await solanaApi.getTokensBalancesAndAccounts({ address: fromAddress })
87
+ const amount = asset.currency.baseUnit(balances?.[assetName] ?? 0)
88
+
89
+ // Token transfer: fee is paid in SOL, full token balance is sent.
90
+ try {
91
+ ;({ unsignedTx, fee } = await createTx({
92
+ asset,
93
+ walletAccount: MOVE_FUNDS_WALLET_ACCOUNT,
94
+ fromAddress,
95
+ toAddress,
96
+ amount,
97
+ useFeePayer: false,
98
+ }))
99
+ } catch {
100
+ throw new MoveFundsError('balance-negative', { fromAddress })
101
+ }
102
+
103
+ if (balance.lt(fee)) {
104
+ throw new MoveFundsError('token-fee-insufficient', { fromAddress })
105
+ }
106
+
107
+ return { fromAddress, toAddress, amount, fee, secretKey, unsignedTx }
108
+ }
109
+
110
+ // SOL transfer: estimate fee via a self-send (stable estimation, avoids destination
111
+ // rent-exemption check for new accounts), then create the real tx with balance - fee.
112
+ try {
113
+ ;({ fee } = await createTx({
114
+ asset,
115
+ walletAccount: MOVE_FUNDS_WALLET_ACCOUNT,
116
+ fromAddress,
117
+ toAddress: fromAddress,
118
+ amount: asset.currency.baseUnit(1),
119
+ useFeePayer: false,
120
+ }))
121
+ } catch {
122
+ throw new MoveFundsError('balance-negative', { fromAddress })
123
+ }
124
+
125
+ const amount = balance.sub(fee)
126
+ if (amount.isNegative || amount.isZero) {
127
+ throw new MoveFundsError('balance-negative', { fromAddress })
128
+ }
129
+
130
+ try {
131
+ ;({ unsignedTx, fee } = await createTx({
132
+ asset,
133
+ walletAccount: MOVE_FUNDS_WALLET_ACCOUNT,
134
+ fromAddress,
135
+ toAddress,
136
+ amount,
137
+ useFeePayer: false,
138
+ }))
139
+ } catch {
140
+ throw new MoveFundsError('balance-negative', { fromAddress })
141
+ }
142
+
143
+ return { fromAddress, toAddress, amount, fee, secretKey, unsignedTx }
144
+ }
145
+
146
+ const sendFunds = async ({ secretKey, unsignedTx }) => {
147
+ assert(secretKey, 'secretKey or mpcKey is required')
148
+ assert(unsignedTx, 'unsignedTx is required')
149
+
150
+ const importer = await getImporter(secretKey)
151
+ const { rawTx, txId } = await importer.sign(unsignedTx, secretKey)
152
+
153
+ await solanaApi.broadcastTransaction(rawTx)
154
+
155
+ return { txId }
156
+ }
157
+
158
+ return { prepareSendFundsTx, sendFunds }
159
+ }