@exodus/solana-plugin 1.36.5 → 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,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
+ ## [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
+
6
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)
7
17
 
8
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-plugin",
3
- "version": "1.36.5",
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": "4109b7a0cfe42fbfd8915543fac4264f478ce2dc"
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
 
@@ -120,6 +121,13 @@ export const createSolanaAssetFactory =
120
121
  assetClientInterface,
121
122
  })
122
123
 
124
+ const moveFunds = moveFundsFactory({
125
+ baseAssetName,
126
+ solanaApi: rpcApi,
127
+ assetClientInterface,
128
+ createTx,
129
+ })
130
+
123
131
  const stakingApi = stakingApiFactory({
124
132
  assetName: baseAssetName,
125
133
  assetClientInterface,
@@ -163,6 +171,7 @@ export const createSolanaAssetFactory =
163
171
  family: ASSET_FAMILY.SOLANA,
164
172
  feeMonitor: false,
165
173
  feesApi: true,
174
+ moveFunds: true,
166
175
  nfts: true,
167
176
  staking: {},
168
177
  isTestnet,
@@ -248,6 +257,7 @@ export const createSolanaAssetFactory =
248
257
  .filter((asset) => asset.name !== base.name)
249
258
  .map(createToken),
250
259
  hasFeature: (feature) => !!features[feature], // @deprecated use api.features instead
260
+ moveFunds,
251
261
  privateKeyEncodingDefinition: { encoding: 'base58', data: 'priv|pub' },
252
262
  securityChecks,
253
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
+ }