@exodus/ethereum-api 8.73.6 → 8.74.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
+ ## [8.74.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.73.6...@exodus/ethereum-api@8.74.0) (2026-05-12)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: add MoveFunds API for EVM assets (#7868)
13
+
14
+
15
+
6
16
  ## [8.73.6](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.73.5...@exodus/ethereum-api@8.73.6) (2026-05-11)
7
17
 
8
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.73.6",
3
+ "version": "8.74.0",
4
4
  "description": "Transaction monitors, fee monitors, RPC with the blockchain node, and other networking code for Ethereum and EVM-based blockchains",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -67,5 +67,5 @@
67
67
  "type": "git",
68
68
  "url": "git+https://github.com/ExodusMovement/assets.git"
69
69
  },
70
- "gitHead": "79dabe78d5a3652b509c7cc1d1496ac2404104b8"
70
+ "gitHead": "f841266053c82af6c3aecb3196b9ae20f4d5340c"
71
71
  }
@@ -37,6 +37,7 @@ import { createFeeData } from './fee-data/index.js'
37
37
  import { createGetBalanceForAddress } from './get-balance-for-address.js'
38
38
  import { getBalancesFactory } from './get-balances.js'
39
39
  import { getFeeFactory } from './get-fee.js'
40
+ import { moveFundsFactory } from './move-funds.js'
40
41
  import { estimateL1DataFeeFactory, getL1GetFeeFactory } from './optimism-gas/index.js'
41
42
  import { serverBasedFeeMonitorFactoryFactory } from './server-based-fee-monitor.js'
42
43
  import { stakingApiFactory } from './staking/api/index.js'
@@ -208,6 +209,7 @@ export const createAssetFactory = ({
208
209
  feesApi: true,
209
210
  isMaxFeeAsset,
210
211
  isTestnet,
212
+ moveFunds: true,
211
213
  nfts,
212
214
  noHistory: monitorType === 'no-history',
213
215
  signWithSigner: true,
@@ -281,6 +283,13 @@ export const createAssetFactory = ({
281
283
 
282
284
  const securityChecks = createSecurityChecks({ eip7702Supported })
283
285
 
286
+ const moveFunds = moveFundsFactory({
287
+ baseAssetName: asset.name,
288
+ assetClientInterface,
289
+ createTx,
290
+ server,
291
+ })
292
+
284
293
  const api = {
285
294
  addressHasHistory,
286
295
  broadcastTx: (...args) => server.sendRawTransaction(...args),
@@ -311,6 +320,7 @@ export const createAssetFactory = ({
311
320
  getSupportedPurposes: () => [44],
312
321
  getTokens,
313
322
  hasFeature: (feature) => !!features[feature], // @deprecated use api.features instead
323
+ moveFunds,
314
324
  privateKeyEncodingDefinition: { encoding: 'hex' },
315
325
  sendTx,
316
326
  signTx: ({ unsignedTx, privateKey, signer }) =>
@@ -1,5 +1,4 @@
1
1
  import { createConsoleLogger } from '@exodus/asset-lib'
2
- import WebSocket from '@exodus/fetch/websocket'
3
2
  import EventEmitter from 'eventemitter3'
4
3
  import assert from 'minimalistic-assert'
5
4
 
@@ -1,4 +1,3 @@
1
- import WebSocket from '@exodus/fetch/websocket'
2
1
  import EventEmitter from 'events/events.js' // forces it to use the module from node_modules
3
2
  import ms from 'ms'
4
3
 
package/src/index.js CHANGED
@@ -99,6 +99,8 @@ export { txSendFactory } from './tx-send/index.js'
99
99
 
100
100
  export { createAssetFactory } from './create-asset.js'
101
101
 
102
+ export { moveFundsFactory } from './move-funds.js'
103
+
102
104
  export { createContractBlackListCheck } from './create-asset-utils.js'
103
105
 
104
106
  export {
@@ -0,0 +1,120 @@
1
+ import { signUnsignedTx } from '@exodus/ethereum-lib'
2
+ import { addHexPrefix, isValidPrivate, privateToAddress, toBuffer } from '@exodus/ethereumjs/util'
3
+ import assert from 'minimalistic-assert'
4
+
5
+ import { getNonce, getTokenBalanceFromNode } from './eth-like-util.js'
6
+ import { fetchGasLimit } from './gas-estimation.js'
7
+
8
+ export const moveFundsFactory = ({ baseAssetName, assetClientInterface, createTx, server }) => {
9
+ assert(baseAssetName, 'baseAssetName is required')
10
+ assert(assetClientInterface, 'assetClientInterface is required')
11
+ assert(createTx, 'createTx is required')
12
+ assert(server, 'server is required')
13
+
14
+ async function prepareSendFundsTx({
15
+ assetName,
16
+ input,
17
+ toAddress,
18
+ walletAccount,
19
+ MoveFundsError,
20
+ }) {
21
+ assert(assetName, 'assetName is required')
22
+ assert(input, 'input is required')
23
+
24
+ assert(walletAccount, 'walletAccount is required')
25
+ assert(MoveFundsError, 'MoveFundsError is required')
26
+
27
+ const assets = await assetClientInterface.getAssetsForNetwork({ baseAssetName })
28
+ const asset = assets[assetName]
29
+ assert(asset, 'asset is required')
30
+
31
+ assert(toAddress && asset.baseAsset.address.validate(toAddress), 'valid toAddress is required')
32
+
33
+ let privateKey
34
+ try {
35
+ const hexKey = addHexPrefix(input.trim())
36
+ if (typeof hexKey !== 'string' || hexKey.length !== 66) throw new Error('invalid length')
37
+ privateKey = toBuffer(hexKey)
38
+ if (!isValidPrivate(privateKey)) throw new Error('invalid key')
39
+ } catch {
40
+ throw new MoveFundsError('private-key-invalid')
41
+ }
42
+
43
+ const fromAddress = `0x${privateToAddress(privateKey).toString('hex')}`
44
+
45
+ if (fromAddress === toAddress.toLowerCase()) {
46
+ throw new MoveFundsError('private-key-own-key', { fromAddress })
47
+ }
48
+
49
+ const isToken = asset.name !== baseAssetName
50
+
51
+ const [nonce, rawEthBalance] = await Promise.all([
52
+ getNonce({ asset: asset.baseAsset, address: fromAddress }),
53
+ server.getBalanceProxied(fromAddress),
54
+ ])
55
+
56
+ const ethBalance = asset.baseAsset.currency.baseUnit(rawEthBalance)
57
+
58
+ let amount = ethBalance
59
+ if (isToken) {
60
+ const rawTokenBalance = await getTokenBalanceFromNode({ asset, address: fromAddress })
61
+ amount = asset.currency.baseUnit(rawTokenBalance)
62
+ }
63
+
64
+ if (amount.isZero) {
65
+ throw new MoveFundsError('balance-zero', { fromAddress })
66
+ }
67
+
68
+ const feeData = await assetClientInterface.getFeeData({ assetName })
69
+
70
+ const gasLimit = await fetchGasLimit({
71
+ asset,
72
+ feeData,
73
+ fromAddress,
74
+ toAddress,
75
+ amount,
76
+ })
77
+
78
+ const fee = feeData.gasPrice.mul(gasLimit)
79
+
80
+ const afterFee = ethBalance.sub(fee)
81
+ if (isToken) {
82
+ if (afterFee.isNegative) {
83
+ throw new MoveFundsError('token-fee-insufficient', { fromAddress })
84
+ }
85
+ } else {
86
+ if (!afterFee.isPositive) {
87
+ throw new MoveFundsError('balance-negative', { fromAddress })
88
+ }
89
+
90
+ amount = amount.sub(fee)
91
+ }
92
+
93
+ const { unsignedTx } = await createTx({
94
+ asset,
95
+ walletAccount,
96
+ fromAddress,
97
+ address: toAddress,
98
+ amount,
99
+ nonce,
100
+ gasLimit,
101
+ gasPrice: feeData.gasPrice,
102
+ customFee: feeData.gasPrice,
103
+ isSendAll: true,
104
+ })
105
+
106
+ return { fromAddress, toAddress, amount, fee, privateKey, unsignedTx }
107
+ }
108
+
109
+ const sendFunds = async ({ privateKey, unsignedTx }) => {
110
+ assert(privateKey, 'privateKey is required')
111
+ assert(unsignedTx, 'unsignedTx is required')
112
+
113
+ const { rawTx } = await signUnsignedTx(unsignedTx, privateKey)
114
+ const txId = await server.sendRawTransaction(rawTx.toString('hex'))
115
+
116
+ return { txId }
117
+ }
118
+
119
+ return { prepareSendFundsTx, sendFunds }
120
+ }
@@ -15,7 +15,7 @@ export const resolveNonce = async ({
15
15
  asset,
16
16
  forceFromNode,
17
17
  fromAddress,
18
- txLog = [],
18
+ txLog, // TxSet expected. Array only for testing purposes.
19
19
  accountState,
20
20
  tag = BLOCK_TAG_LATEST,
21
21
  useAbsoluteNonce,
@@ -51,13 +51,27 @@ const getLatestTxWithNonceChange = ({ reversedTxLog }) => {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * Computes the next nonce from the given `txLog`.
56
+ *
57
+ * @param {Object} options
58
+ * @param {import('@exodus/models').TxSet} options.txLog - A `TxSet` from
59
+ * `@exodus/models`, NOT a plain `Array`.
60
+ * @param {boolean} [options.useAbsoluteNonce]
61
+ * @param {string} [options.tag]
62
+ * @returns {number}
63
+ */
54
64
  const getNonceFromTxLog = ({ txLog, useAbsoluteNonce, tag }) => {
65
+ if (!txLog || txLog.size === 0) return 0
66
+
55
67
  let absoluteNonce = 0
56
68
 
57
69
  if (useAbsoluteNonce) {
58
- // NOTE: Use a copy to avoid mutating the caller's `txLog` array, since
59
- // `Array.prototype.reverse()` reverses in-place.
60
- const reversedTxLog = [...txLog].reverse()
70
+ // NOTE: `TxSet#reverse()` returns a one-shot iterable yielding txs
71
+ // in reverse order — it is NOT an in-place reversal like
72
+ // `Array.prototype.reverse()`, so the caller's `txLog` is
73
+ // left untouched.
74
+ const reversedTxLog = txLog.reverse()
61
75
  const maybeLatestTxWithNonceChange = getLatestTxWithNonceChange({ reversedTxLog })
62
76
 
63
77
  if (maybeLatestTxWithNonceChange) {
@@ -70,6 +84,7 @@ const getNonceFromTxLog = ({ txLog, useAbsoluteNonce, tag }) => {
70
84
  }
71
85
  }
72
86
 
87
+ // TODO: can't we iterate in reverse up to the first (few) nonce(s)?
73
88
  const nonceFromLog = [...txLog]
74
89
  .filter((tx) => tx.sent && !tx.dropped && tx.data.nonce != null)
75
90
  // NOTE: If we're only considering the `'latest'` block `tag`,