@exodus/ethereum-api 8.57.1 → 8.59.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,40 @@
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.59.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.57.0...@exodus/ethereum-api@8.59.0) (2025-11-10)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: enable implicit transaction bumps (#6798)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+
18
+ * fix: correct response to insufficient evm nonce during txSend (#6901)
19
+
20
+ * fix: enable replacement transaction underpriced errors to satisfy evm transaction existence checks (#6900)
21
+
22
+
23
+
24
+ ## [8.58.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.57.0...@exodus/ethereum-api@8.58.0) (2025-11-10)
25
+
26
+
27
+ ### Features
28
+
29
+
30
+ * feat: enable implicit transaction bumps (#6798)
31
+
32
+
33
+ ### Bug Fixes
34
+
35
+
36
+ * fix: correct response to insufficient evm nonce during txSend (#6901)
37
+
38
+
39
+
6
40
  ## [8.57.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.57.0...@exodus/ethereum-api@8.57.1) (2025-11-10)
7
41
 
8
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.57.1",
3
+ "version": "8.59.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",
@@ -29,7 +29,7 @@
29
29
  "@exodus/bip44-constants": "^195.0.0",
30
30
  "@exodus/crypto": "^1.0.0-rc.26",
31
31
  "@exodus/currency": "^6.0.1",
32
- "@exodus/ethereum-lib": "^5.18.6",
32
+ "@exodus/ethereum-lib": "^5.19.0",
33
33
  "@exodus/ethereum-meta": "^2.9.1",
34
34
  "@exodus/ethereumholesky-meta": "^2.0.5",
35
35
  "@exodus/ethereumjs": "^1.8.0",
@@ -67,5 +67,5 @@
67
67
  "type": "git",
68
68
  "url": "git+https://github.com/ExodusMovement/assets.git"
69
69
  },
70
- "gitHead": "342bc790d18d382ec34f846446d678a3b6342ab7"
70
+ "gitHead": "94c7625c1fb029ef261d0ef61e32c7c66d359812"
71
71
  }
@@ -7,6 +7,7 @@ export const reasons = {
7
7
  ethCallErc20Failed: 'Eth call erc20 failed',
8
8
  insufficientFunds: 'Insufficient funds',
9
9
  bumpTxFailed: 'Bump tx failed',
10
+ transactionUnderpriced: 'Transaction underpriced',
10
11
  }
11
12
 
12
13
  const MAX_HINT_LENGTH = 100
package/src/fee-utils.js CHANGED
@@ -197,6 +197,7 @@ export const executeEthLikeFeeMonitorUpdate = async ({
197
197
  return nextFeeConfig
198
198
  }
199
199
 
200
+ // TODO: rename to getEffectiveGasPriceForFeeData
200
201
  export const resolveGasPrice = ({ feeData }) => {
201
202
  assert(feeData, 'feeData is required')
202
203
 
package/src/get-fee.js CHANGED
@@ -1,4 +1,9 @@
1
- import { calculateBumpedGasPrice, calculateExtraEth } from '@exodus/ethereum-lib'
1
+ import {
2
+ calculateBumpedGasPrice,
3
+ calculateBumpedGasPriceForFeeData,
4
+ calculateExtraEth,
5
+ } from '@exodus/ethereum-lib'
6
+ import assert from 'minimalistic-assert'
2
7
 
3
8
  import {
4
9
  ensureSaneEip1559GasPriceForTipGasPrice,
@@ -26,27 +31,68 @@ export const getExtraFeeData = ({ asset, amount }) => {
26
31
  }
27
32
  }
28
33
 
29
- export const getFeeFactoryGasPrices = ({ customFee, feeData }) => {
30
- feeData = getNormalizedFeeDataForCustomFee({ customFee, feeData })
31
- const gasPrice = customFee || resolveGasPrice({ feeData })
34
+ const getGasPricesResult = ({ gasPrice, feeData }) => {
32
35
  const { tipGasPrice, eip1559Enabled } = feeData
33
-
34
- // The `gasPrice` must be at least the `tipGasPrice`.
35
36
  return {
36
37
  gasPrice: eip1559Enabled
37
- ? ensureSaneEip1559GasPriceForTipGasPrice({ gasPrice, tipGasPrice })
38
+ ? // NOTE: The `gasPrice` must be at least the `tipGasPrice`.
39
+ ensureSaneEip1559GasPriceForTipGasPrice({ gasPrice, tipGasPrice })
38
40
  : gasPrice,
39
41
  feeData,
40
42
  }
41
43
  }
42
44
 
45
+ const getGasPricesForTxToReplace = ({ baseAsset, feeData, txToReplace }) => {
46
+ const { eip1559Enabled } = feeData
47
+
48
+ const effectiveGasPrice = resolveGasPrice({ feeData })
49
+ const { tipGasPrice: effectiveTipGasPrice } = feeData
50
+
51
+ const { bumpedGasPrice, bumpedTipGasPrice } = calculateBumpedGasPriceForFeeData({
52
+ baseAsset,
53
+ feeData,
54
+ tx: txToReplace,
55
+ })
56
+
57
+ const gasPrice = bumpedGasPrice.gt(effectiveGasPrice) ? bumpedGasPrice : effectiveGasPrice
58
+ if (!eip1559Enabled) return getGasPricesResult({ gasPrice, feeData })
59
+
60
+ const tipGasPrice = bumpedTipGasPrice.gt(effectiveTipGasPrice)
61
+ ? bumpedTipGasPrice
62
+ : effectiveTipGasPrice
63
+
64
+ return getGasPricesResult({
65
+ gasPrice,
66
+ feeData: { ...feeData, tipGasPrice },
67
+ })
68
+ }
69
+
70
+ export const getAggregateTransactionPricing = ({ baseAsset, customFee, feeData, txToReplace }) => {
71
+ assert(baseAsset, 'expected baseAsset')
72
+
73
+ if (customFee) {
74
+ return getGasPricesResult({
75
+ gasPrice: customFee,
76
+ feeData: getNormalizedFeeDataForCustomFee({ customFee, feeData }),
77
+ })
78
+ }
79
+
80
+ if (txToReplace) return getGasPricesForTxToReplace({ baseAsset, feeData, txToReplace })
81
+
82
+ return getGasPricesResult({
83
+ gasPrice: resolveGasPrice({ feeData }),
84
+ feeData,
85
+ })
86
+ }
87
+
43
88
  export const getFeeFactory =
44
89
  () =>
45
90
  ({ asset, feeData, customFee, txInput, gasLimit: providedGasLimit, amount }) => {
46
91
  const {
47
92
  feeData: { tipGasPrice, eip1559Enabled },
48
93
  gasPrice,
49
- } = getFeeFactoryGasPrices({
94
+ } = getAggregateTransactionPricing({
95
+ baseAsset: asset.baseAsset,
50
96
  customFee,
51
97
  feeData,
52
98
  })
@@ -63,6 +109,7 @@ export const getFeeFactory =
63
109
  return { ...maybeReturnTipGasPrice, fee, gasPrice, extraFeeData }
64
110
  }
65
111
 
112
+ // TODO: sanity check this usage
66
113
  // Used in Mobile
67
114
  export const getExtraFeeForBump = ({ tx, feeData, balance, unconfirmedBalance }) => {
68
115
  if (!balance || !unconfirmedBalance) return null
@@ -1,7 +1,7 @@
1
1
  import { memoize } from '@exodus/basic-utils'
2
2
 
3
3
  import { estimateGasLimit, scaleGasLimitEstimate } from '../../gas-estimation.js'
4
- import { getFeeFactoryGasPrices } from '../../get-fee.js'
4
+ import { getAggregateTransactionPricing } from '../../get-fee.js'
5
5
  import { createWatchTx as defaultCreateWatch } from '../../watch-tx.js'
6
6
  import { stakingProviderClientFactory } from '../staking-provider-client.js'
7
7
  import {
@@ -315,7 +315,7 @@ export function createPolygonStakingService({
315
315
  const erc20ApproveGas = 80_000
316
316
  const delegateGas = 250_000
317
317
 
318
- const { gasPrice } = getFeeFactoryGasPrices({ feeData })
318
+ const { gasPrice } = getAggregateTransactionPricing({ baseAsset: ethereum, feeData })
319
319
 
320
320
  const gasLimitWithBuffer = scaleGasLimitEstimate({
321
321
  estimatedGasLimit: BigInt(erc20ApproveGas + delegateGas),
package/src/tx-create.js CHANGED
@@ -1,17 +1,18 @@
1
- import { calculateBumpedGasPrice, currency2buffer, isEthereumLikeToken } from '@exodus/ethereum-lib'
1
+ import { currency2buffer, isEthereumLikeToken } from '@exodus/ethereum-lib'
2
2
  import createEthereumJsTx from '@exodus/ethereum-lib/src/unsigned-tx/create-ethereumjs-tx.js'
3
3
  import assert from 'minimalistic-assert'
4
4
 
5
5
  import { ensureSaneEip1559GasPriceForTipGasPrice } from './fee-utils.js'
6
6
  import { fetchGasLimit } from './gas-estimation.js'
7
- import { getExtraFeeData, getFeeFactoryGasPrices } from './get-fee.js'
7
+ import { getAggregateTransactionPricing, getExtraFeeData } from './get-fee.js'
8
8
  import { getNftArguments } from './nft-utils.js'
9
- import { getHighestIncentivePendingTxByNonce } from './tx-log/index.js'
9
+ import { getHighestIncentiveTxByNonce } from './tx-log/index.js'
10
10
  import {
11
11
  assertTxAttributes,
12
12
  isValidTxType,
13
13
  resolveTxAttributesByTxType,
14
14
  resolveTxFromAddress,
15
+ resolveTxNonce,
15
16
  TX_TYPE_TRANSFER,
16
17
  } from './tx-type/index.js'
17
18
 
@@ -105,19 +106,40 @@ async function createUnsignedTxWithFees({
105
106
  }
106
107
  }
107
108
 
108
- const resolveTxFactoryGasPrices = ({
109
+ const resolveTxFactoryGasPrices = async ({
110
+ assetClientInterface,
111
+ baseAsset,
109
112
  customFee: providedCustomFee,
110
113
  feeData,
111
114
  gasPrice: providedGasPrice,
112
115
  tipGasPrice: providedTipGasPrice,
116
+ txToReplace,
117
+ nonce,
118
+ walletAccount,
113
119
  }) => {
114
- assert(feeData)
120
+ assert(assetClientInterface, 'expected assetClientInterface')
121
+ assert(baseAsset, 'expected baseAsset')
122
+ assert(feeData, 'expected feeData')
123
+ assert(Number.isInteger(nonce), 'expected integer nonce')
124
+ assert(walletAccount, 'expected walletAccount')
115
125
 
116
126
  const {
117
127
  gasPrice: maybeGasPrice,
118
128
  feeData: { tipGasPrice: maybeTipGasPrice, eip1559Enabled },
119
- } = getFeeFactoryGasPrices({ customFee: providedCustomFee, feeData })
129
+ } = getAggregateTransactionPricing({
130
+ baseAsset,
131
+ customFee: providedCustomFee,
132
+ feeData,
133
+ txToReplace,
134
+ })
120
135
 
136
+ // When we determine the `gasPrice` for a transaction, we must be
137
+ // cognizant of any transactions which are pending for the current
138
+ // `nonce`, as this may be higher compared to the current `gasPrice`
139
+ // that we are recommending from the network.
140
+ //
141
+ // In this instance, the `gasPrice` we recommend should be
142
+ // sufficient to avoid `replacement transaction underpriced` errors.
121
143
  const resolvedGasPrice = providedGasPrice ?? maybeGasPrice
122
144
 
123
145
  if (!eip1559Enabled) return { eip1559Enabled, resolvedGasPrice }
@@ -212,14 +234,7 @@ const createBumpUnsignedTx = async ({
212
234
 
213
235
  const gasLimit = replacedTx.data.gasLimit
214
236
 
215
- const {
216
- gasPrice: currentGasPrice,
217
- baseFeePerGas: currentBaseFee,
218
- eip1559Enabled,
219
- tipGasPrice: currentTipGasPrice,
220
- } = feeData
221
-
222
- const maybeHighestIncentivePendingTxForNonce = await getHighestIncentivePendingTxByNonce({
237
+ const maybeTxToReplace = await getHighestIncentiveTxByNonce({
223
238
  assetClientInterface,
224
239
  asset,
225
240
  nonce: resolvedTxAttributes.nonce,
@@ -227,31 +242,23 @@ const createBumpUnsignedTx = async ({
227
242
  })
228
243
 
229
244
  assert(
230
- maybeHighestIncentivePendingTxForNonce,
245
+ maybeTxToReplace,
231
246
  `unable to resolve pending transaction for nonce ${resolvedTxAttributes.nonce}`
232
247
  )
233
248
 
234
- const { bumpedGasPrice, bumpedTipGasPrice } = calculateBumpedGasPrice({
235
- baseAsset,
236
- // HACK: Although the `bumpTxId` defines the characteristics of
237
- // of a transaction the user would like to accelerate, for
238
- // acceleration to be successful, the transaction must be
239
- // priced to exceed the miner incentive of whichever
240
- // transaction is currently pending at the specified nonce.
241
- tx: maybeHighestIncentivePendingTxForNonce,
242
- currentGasPrice,
243
- currentBaseFee,
244
- currentTipGasPrice,
249
+ const {
245
250
  eip1559Enabled,
251
+ resolvedGasPrice: gasPrice,
252
+ resolvedTipGasPrice: tipGasPrice,
253
+ } = await resolveTxFactoryGasPrices({
254
+ assetClientInterface,
255
+ baseAsset,
256
+ feeData,
257
+ nonce,
258
+ txToReplace: maybeTxToReplace,
259
+ walletAccount,
246
260
  })
247
261
 
248
- const { resolvedGasPrice: gasPrice, resolvedTipGasPrice: tipGasPrice } =
249
- resolveTxFactoryGasPrices({
250
- feeData,
251
- gasPrice: bumpedGasPrice,
252
- tipGasPrice: bumpedTipGasPrice,
253
- })
254
-
255
262
  return createUnsignedTxWithFees({
256
263
  ...resolvedTxAttributes,
257
264
  asset,
@@ -289,10 +296,11 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
289
296
  assert(walletAccount, 'walletAccount is required')
290
297
  assert(isValidTxType(txType), 'invalid txType')
291
298
 
292
- const feeData = await assetClientInterface.getFeeConfig({ assetName: asset.baseAsset.name })
299
+ const baseAsset = asset.baseAsset
300
+ const feeData = await assetClientInterface.getFeeConfig({ assetName: baseAsset.name })
293
301
 
294
302
  const baseAssetTxLog = await assetClientInterface.getTxLog({
295
- assetName: asset.baseAsset.name,
303
+ assetName: baseAsset.name,
296
304
  walletAccount,
297
305
  })
298
306
 
@@ -318,15 +326,34 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
318
326
  })
319
327
  }
320
328
 
329
+ const nonce = await resolveTxNonce({
330
+ asset,
331
+ fromAddress,
332
+ nonce: providedNonce,
333
+ walletAccount,
334
+ })
335
+
336
+ const maybeTxToReplace = await getHighestIncentiveTxByNonce({
337
+ assetClientInterface,
338
+ asset: baseAsset,
339
+ nonce,
340
+ walletAccount,
341
+ })
342
+
321
343
  const {
322
344
  eip1559Enabled,
323
345
  resolvedGasPrice: gasPrice,
324
346
  resolvedTipGasPrice: tipGasPrice,
325
- } = resolveTxFactoryGasPrices({
347
+ } = await resolveTxFactoryGasPrices({
348
+ assetClientInterface,
349
+ baseAsset,
326
350
  customFee: providedCustomFee,
327
351
  feeData,
328
352
  gasPrice: providedGasPrice,
353
+ nonce,
329
354
  tipGasPrice: providedTipGasPrice,
355
+ txToReplace: maybeTxToReplace,
356
+ walletAccount,
330
357
  })
331
358
 
332
359
  if (nft) {
@@ -342,10 +369,10 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
342
369
  })
343
370
 
344
371
  const resolvedTxAttributes = await resolveTxAttributesByTxType({
345
- asset: asset.baseAsset,
372
+ asset: baseAsset,
346
373
  assetClientInterface,
347
374
  fromAddress,
348
- nonce: providedNonce,
375
+ nonce,
349
376
  txInput,
350
377
  toAddress: providedToAddress,
351
378
  txToAddress,
@@ -369,7 +396,7 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
369
396
  assetClientInterface,
370
397
  amount: providedAmount,
371
398
  fromAddress,
372
- nonce: providedNonce,
399
+ nonce,
373
400
  txInput: providedTxInput,
374
401
  toAddress: providedToAddress,
375
402
  txType,
@@ -6,7 +6,12 @@ import assert from 'minimalistic-assert'
6
6
 
7
7
  // Returns the most competitively priced pending
8
8
  // transaction from the `TxLog` for a given `nonce`.
9
- export const getHighestIncentivePendingTxByNonce = async ({
9
+ //
10
+ // NOTE: If a transaction was successfully included,
11
+ // it is the de-facto highest incentive
12
+ // transaction - irrespective of other
13
+ // attempts for that nonce.
14
+ export const getHighestIncentiveTxByNonce = async ({
10
15
  assetClientInterface,
11
16
  asset,
12
17
  nonce,
@@ -17,17 +22,29 @@ export const getHighestIncentivePendingTxByNonce = async ({
17
22
  assert(Number.isInteger(nonce), 'expected integer nonce')
18
23
  assert(walletAccount, 'expected walletAccount')
19
24
 
20
- // https://github.com/ExodusMovement/assets/blob/fbe3702861cba3b21885a65b15f038fcd8541891/shield/asset-lib/src/balances-utils.js#L26
21
- const isUnconfirmed = (tx) => !tx.failed && tx.pending
22
-
23
- const [maybeHighestIncentiveTx] = [
25
+ const txLogSendsByFeeAmountDesc = [
24
26
  ...(await assetClientInterface.getTxLog({ assetName: asset.name, walletAccount })),
25
27
  ]
26
- .filter(isUnconfirmed)
27
28
  .filter((tx) => tx.data.nonce === nonce && tx.sent)
28
29
  .sort((a, b) => (a.feeAmount.gt(b.feeAmount) ? -1 : b.feeAmount.gt(a.feeAmount) ? 1 : 0))
29
30
 
30
- return maybeHighestIncentiveTx
31
+ // If any of the transactions competing for this `nonce`
32
+ // were successful, then we can return this transaction
33
+ // as it effectively had the highest game-theoretical
34
+ // incentive regardless of other (potentially higher fee)
35
+ // transactions that were sent.
36
+ //
37
+ // https://github.com/ExodusMovement/exodus-hydra/blob/e59004097f15974a975d14e1823de5d7b1c28308/features/activity-txs/redux/utils/activity-formatters/format-tx-activity.js#L13
38
+ const maybeConfirmedTx = txLogSendsByFeeAmountDesc.find((tx) => !tx.failed && !tx.pending)
39
+ if (maybeConfirmedTx) return maybeConfirmedTx
40
+
41
+ // https://github.com/ExodusMovement/assets/blob/fbe3702861cba3b21885a65b15f038fcd8541891/shield/asset-lib/src/balances-utils.js#L26
42
+ const isUnconfirmed = (tx) => !tx.failed && tx.pending
43
+
44
+ // NOTE: When trying to find the highest incentive of a
45
+ // transaction, consider those which are either still
46
+ // pending.
47
+ return txLogSendsByFeeAmountDesc.find(isUnconfirmed)
31
48
  }
32
49
 
33
50
  export const getOptimisticTxLogEffects = async ({
@@ -77,7 +94,7 @@ export const getOptimisticTxLogEffects = async ({
77
94
 
78
95
  const baseAsset = asset.baseAsset
79
96
 
80
- const maybeTxToReplace = await getHighestIncentivePendingTxByNonce({
97
+ const maybeTxToReplace = await getHighestIncentiveTxByNonce({
81
98
  asset,
82
99
  assetClientInterface,
83
100
  nonce,
@@ -3,5 +3,5 @@ export { EthereumNoHistoryMonitor } from './ethereum-no-history-monitor.js'
3
3
  export { ClarityMonitor } from './clarity-monitor.js'
4
4
  export {
5
5
  getOptimisticTxLogEffects,
6
- getHighestIncentivePendingTxByNonce,
6
+ getHighestIncentiveTxByNonce,
7
7
  } from './get-optimistic-txlog-effects.js'
@@ -59,11 +59,19 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
59
59
  try {
60
60
  await baseAsset.api.broadcastTx(rawTx.toString('hex'))
61
61
  } catch (err) {
62
+ const transactionUnderpricedErr = err.message.match(/transaction underpriced/i)
62
63
  const nonceTooLowErr = err.message.match(/nonce (is |)too low/i)
63
64
  const insufficientFundsErr = err.message.match(/insufficient funds/i)
64
- const txAlreadyExists = nonceTooLowErr
65
- ? await transactionExists({ asset, txId })
66
- : err.message.match(/already known/i)
65
+
66
+ // NOTE: We've found that `geth` can return the following errors
67
+ // for transactions which may be already known. In this
68
+ // case, we validate that the transaction is known to the
69
+ // network.
70
+ const txAlreadyExists =
71
+ nonceTooLowErr || transactionUnderpricedErr
72
+ ? await transactionExists({ asset, txId })
73
+ : err.message.match(/already known/i) ||
74
+ err.message.match(/transaction already imported/i)
67
75
 
68
76
  if (txAlreadyExists) {
69
77
  console.info('tx already broadcast') // inject logger factory from platform
@@ -121,7 +121,12 @@ export const resolveTxFromAddress = async ({
121
121
  })
122
122
  }
123
123
 
124
- const resolveTxNonce = async ({ asset, fromAddress, nonce: providedNonce, walletAccount }) => {
124
+ export const resolveTxNonce = async ({
125
+ asset,
126
+ fromAddress,
127
+ nonce: providedNonce,
128
+ walletAccount,
129
+ }) => {
125
130
  assert(asset, 'expected asset')
126
131
  assert(typeof fromAddress === 'string', 'expected string fromAddress')
127
132
  assert(walletAccount, 'expected walletAccount')