@exodus/ethereum-api 8.64.4 → 8.64.6

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
+ ## [8.64.6](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.64.5...@exodus/ethereum-api@8.64.6) (2026-02-23)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: avoid race conditions resulting in a `tipGasPrice` of `0` where possible (#7458)
13
+
14
+
15
+
16
+ ## [8.64.5](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.64.4...@exodus/ethereum-api@8.64.5) (2026-02-11)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+
22
+ * fix: future transaction replaces pending evm transactions (#7406)
23
+
24
+
25
+
6
26
  ## [8.64.4](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.64.3...@exodus/ethereum-api@8.64.4) (2026-02-09)
7
27
 
8
28
  **Note:** Version bump only for package @exodus/ethereum-api
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.64.4",
3
+ "version": "8.64.6",
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": "ebcc3fe022dfc1e21e3ccdc00faf0769e8fb3092"
70
+ "gitHead": "05a14c42a3963199e2b0b11d0a643884ebae8e46"
71
71
  }
@@ -6,6 +6,7 @@ import lodash from 'lodash'
6
6
 
7
7
  import { fromHexToString } from '../number-utils.js'
8
8
  import { errorMessageToSafeHint } from './errors.js'
9
+ import { getFallbackGasPriceEstimation } from './utils.js'
9
10
 
10
11
  const { isEmpty } = lodash
11
12
 
@@ -166,6 +167,10 @@ export default class ApiCoinNodesServer extends EventEmitter {
166
167
  return this.sendRequest(request)
167
168
  }
168
169
 
170
+ async getGasPriceEstimation() {
171
+ return getFallbackGasPriceEstimation({ server: this })
172
+ }
173
+
169
174
  // for fee monitor
170
175
  getGasPrice = this.gasPrice
171
176
 
@@ -6,6 +6,7 @@ import SolidityContract from '@exodus/solidity-contract'
6
6
  import ms from 'ms'
7
7
 
8
8
  import { fromHexToString } from '../number-utils.js'
9
+ import { getFallbackGasPriceEstimation } from './utils.js'
9
10
  import createWebSocket from './ws.js'
10
11
 
11
12
  const RETRY_DELAYS = ['10s']
@@ -120,6 +121,10 @@ export function create(defaultURL, ensAssetName) {
120
121
  return requestWithRetry('proxy', { method: 'eth_gasPrice' })
121
122
  },
122
123
 
124
+ async getGasPriceEstimation() {
125
+ return getFallbackGasPriceEstimation({ server: this })
126
+ },
127
+
123
128
  // for fee monitor
124
129
  async getGasPrice() {
125
130
  return requestWithRetry('proxy', { method: 'eth_gasPrice' })
@@ -6,6 +6,7 @@ import io from 'socket.io-client'
6
6
 
7
7
  import { fromHexToString } from '../number-utils.js'
8
8
  import { errorMessageToSafeHint } from './errors.js'
9
+ import { getFallbackGasPriceEstimation } from './utils.js'
9
10
 
10
11
  export const RPC_REQUEST_TIMEOUT = 'RPC_REQUEST_TIMEOUT'
11
12
 
@@ -174,6 +175,10 @@ export default class ClarityServer extends EventEmitter {
174
175
  return fee?.gasPrice
175
176
  }
176
177
 
178
+ async getGasPriceEstimation() {
179
+ return getFallbackGasPriceEstimation({ server: this })
180
+ }
181
+
177
182
  async sendRpcRequest(rpcRequest) {
178
183
  const rpcSocket = this.connectRpc()
179
184
  return new Promise((resolve, reject) => {
@@ -0,0 +1,31 @@
1
+ import assert from 'minimalistic-assert'
2
+
3
+ const desc = (a, b) => (a > b ? -1 : b > a ? 1 : 0)
4
+
5
+ export const getFallbackGasPriceEstimation = async ({ server }) => {
6
+ const [latestBlock, gasPrice] = await Promise.all([server.getLatestBlock(), server.getGasPrice()])
7
+
8
+ assert(latestBlock, 'expected latestBlock')
9
+ assert(gasPrice, 'expected gasPrice')
10
+
11
+ const baseFeePerGas = latestBlock.baseFeePerGas
12
+ if (!baseFeePerGas) return { gasPrice }
13
+
14
+ const [max, min] = [BigInt(gasPrice), BigInt(baseFeePerGas)].sort(desc)
15
+
16
+ const toHex = (b) => `0x${b.toString(16)}`
17
+
18
+ // TODO: Use `eth_feeHistory` or `eth_maxPriorityFeePerGas`
19
+ // instead (requires allowlist at the RPC).
20
+ // HACK: Infer the RPC's implicit `tipGasPrice`:
21
+ // https://github.com/ethereum/go-ethereum/blob/d3dd48e59db28ea04bd92e4337cdd488ccb8fbec/internal/ethapi/api.go#L69C1-L79C2
22
+ const maxPriorityFeePerGas50Percentile = max - min
23
+
24
+ const rewardPercentiles = {
25
+ 25: toHex(maxPriorityFeePerGas50Percentile / BigInt(2)),
26
+ 50: toHex(maxPriorityFeePerGas50Percentile),
27
+ 75: toHex((maxPriorityFeePerGas50Percentile * BigInt(3)) / BigInt(2)),
28
+ }
29
+
30
+ return { gasPrice, baseFeePerGas, rewardPercentiles }
31
+ }
package/src/fee-utils.js CHANGED
@@ -1,14 +1,5 @@
1
1
  import assert from 'minimalistic-assert'
2
2
 
3
- export const shouldFetchEthLikeFallbackGasPrices = async ({ eip1559Enabled, server }) => {
4
- const [gasPrice, baseFeePerGas] = await Promise.all([
5
- server.getGasPrice(),
6
- eip1559Enabled ? server.getBaseFeePerGas() : undefined,
7
- ])
8
-
9
- return { gasPrice, baseFeePerGas }
10
- }
11
-
12
3
  export const applyMultiplierToPrice = ({ feeAsset, gasPriceMultiplier, price }) => {
13
4
  assert(typeof price === 'string', 'price should be a string')
14
5
  return feeAsset.currency
@@ -1,10 +1,7 @@
1
1
  import { FeeMonitor } from '@exodus/asset-lib'
2
2
  import assert from 'minimalistic-assert'
3
3
 
4
- import {
5
- calculateEthLikeFeeMonitorUpdate,
6
- shouldFetchEthLikeFallbackGasPrices,
7
- } from './fee-utils.js'
4
+ import { calculateEthLikeFeeMonitorUpdate } from './fee-utils.js'
8
5
 
9
6
  /**
10
7
  * Generic eth server based fee monitor.
@@ -25,11 +22,6 @@ export const serverBasedFeeMonitorFactoryFactory = ({ asset, interval, server, a
25
22
  assert(server, 'server is required')
26
23
  assert(aci, 'aci is required')
27
24
 
28
- const shouldFetchGasPrices = async () => {
29
- const { eip1559Enabled } = await aci.getFeeConfig({ assetName: asset.name })
30
- return shouldFetchEthLikeFallbackGasPrices({ eip1559Enabled, server })
31
- }
32
-
33
25
  const FeeMonitorClass = class ServerBaseEthereumFeeMonitor extends FeeMonitor {
34
26
  constructor({ updateFee }) {
35
27
  assert(updateFee, 'updateFee is required')
@@ -44,7 +36,7 @@ export const serverBasedFeeMonitorFactoryFactory = ({ asset, interval, server, a
44
36
  return calculateEthLikeFeeMonitorUpdate({
45
37
  assetClientInterface: aci,
46
38
  feeAsset: asset,
47
- fetchedGasPrices: await shouldFetchGasPrices(),
39
+ fetchedGasPrices: await server.getGasPriceEstimation(),
48
40
  })
49
41
  }
50
42
  }
@@ -6,7 +6,11 @@ import assert from 'minimalistic-assert'
6
6
  import WsGateway from '../exodus-eth-server/ws-gateway.js'
7
7
  import { executeEthLikeFeeMonitorUpdate } from '../fee-utils.js'
8
8
  import { fromHexToString } from '../number-utils.js'
9
- import { filterEffects, getLogItemsFromServerTx } from './clarity-utils/index.js'
9
+ import {
10
+ filterEffects,
11
+ getLogItemsFromServerTx,
12
+ normalizeTransactionsResponse,
13
+ } from './clarity-utils/index.js'
10
14
  import {
11
15
  checkPendingTransactions,
12
16
  excludeUnchangedTokenBalances,
@@ -160,7 +164,14 @@ export class ClarityMonitorV2 extends BaseMonitor {
160
164
  const { derivedData, tokensByAddress, assets, tokens, assetName } = walletAccountInfo
161
165
 
162
166
  const response = await this.getHistoryFromServer({ walletAccount, derivedData, refresh })
163
- const allTxs = [...response.transactions.pending, ...response.transactions.confirmed]
167
+
168
+ const { allTxs } = await normalizeTransactionsResponse({
169
+ asset: this.asset,
170
+ fromAddress: derivedData.ourWalletAddress,
171
+ response,
172
+ walletAccount,
173
+ })
174
+
164
175
  const cursor = response.cursor
165
176
 
166
177
  await this.processAndFillTransactionsToState({
@@ -5,7 +5,11 @@ import assert from 'minimalistic-assert'
5
5
 
6
6
  import { executeEthLikeFeeMonitorUpdate } from '../fee-utils.js'
7
7
  import { fromHexToString } from '../number-utils.js'
8
- import { filterEffects, getLogItemsFromServerTx } from './clarity-utils/index.js'
8
+ import {
9
+ filterEffects,
10
+ getLogItemsFromServerTx,
11
+ normalizeTransactionsResponse,
12
+ } from './clarity-utils/index.js'
9
13
  import {
10
14
  checkPendingTransactions,
11
15
  excludeUnchangedTokenBalances,
@@ -148,10 +152,16 @@ export class ClarityMonitor extends BaseMonitor {
148
152
  return map
149
153
  }, new Map())
150
154
  const assetName = this.asset.name
151
-
152
155
  const derivedData = await this.deriveData({ assetName, walletAccount, tokens })
153
156
  const response = await this.getHistoryFromServer({ walletAccount, derivedData, refresh })
154
- const allTxs = [...response.transactions.pending, ...response.transactions.confirmed]
157
+
158
+ const { allTxs } = await normalizeTransactionsResponse({
159
+ asset: this.asset,
160
+ fromAddress: derivedData.ourWalletAddress,
161
+ response,
162
+ walletAccount,
163
+ })
164
+
155
165
  const hasNewTxs = allTxs.length > 0
156
166
 
157
167
  const logItemsByAsset = this.getAllLogItemsByAsset({
@@ -6,3 +6,4 @@ export {
6
6
  getLatestCanonicalAbsoluteBalanceTx,
7
7
  getLatestCanonicalAbsoluteNonceTx,
8
8
  } from './absolute.js'
9
+ export { normalizeTransactionsResponse } from './normalize-transactions-response.js'
@@ -0,0 +1,59 @@
1
+ import assert from 'minimalistic-assert'
2
+
3
+ // Converts the `pending` and `confirmed` transactions returned
4
+ // by Clarity into a single contiguous array of transactions.
5
+ //
6
+ // Since it is possible for Clarity to return very old pending
7
+ // transactions that our own RPC has forgotten (see `--txpool.lifetime`),
8
+ // we ensure that all transactions we expose to the consumer have
9
+ // `nonce`s do not exceed the maximum pending nonce that's currently
10
+ // maintained at the RPC.
11
+ //
12
+ // This avoids the "future transaction replaces pending" error.
13
+ export const normalizeTransactionsResponse = async ({
14
+ asset,
15
+ fromAddress,
16
+ response,
17
+ walletAccount,
18
+ }) => {
19
+ assert(asset, 'expected asset')
20
+ assert(fromAddress, 'expected fromAddress')
21
+ assert(response, 'expected response')
22
+ assert(walletAccount, 'expected walletAccount')
23
+
24
+ const { baseAsset } = asset
25
+
26
+ // NOTE: We query the current pending `nonce` from the RPC
27
+ // to sanity check the contents of the `txLog`.
28
+ const pendingNonce = await baseAsset
29
+ .getNonce({
30
+ asset: baseAsset,
31
+ fromAddress,
32
+ walletAccount,
33
+ forceFromNode: true,
34
+ })
35
+ .catch(() => null)
36
+
37
+ const allTxs = [...response.transactions.pending, ...response.transactions.confirmed].filter(
38
+ (tx) => {
39
+ // If the transaction isn't one we've sent, then ignore.
40
+ if (tx.from?.toLowerCase() !== fromAddress.toLowerCase()) return true
41
+
42
+ const nonce = parseInt(tx.nonce, 10)
43
+ if (!Number.isFinite(nonce) || !Number.isInteger(nonce)) return false
44
+
45
+ // HACK: If we were unable to determine the `pendingNonce`, then
46
+ // allow send transactions through by default without
47
+ // further validation.
48
+ if (pendingNonce === null) return true
49
+
50
+ // NOTE: If the `tx` response contains pending transactions at
51
+ // a nonce offset that's greater than what's considered
52
+ // pending at the RPC, these transactions are no longer
53
+ // viable and should be replaced.
54
+ return nonce < pendingNonce
55
+ }
56
+ )
57
+
58
+ return { allTxs }
59
+ }
@@ -21,14 +21,11 @@ export const resolveNonce = async ({
21
21
  }) => {
22
22
  assertValidBlockTag(tag)
23
23
 
24
- const nonceFromNode =
25
- asset.baseAsset?.api?.features?.noHistory || forceFromNode
26
- ? await getNonce({ asset: asset.baseAsset, address: fromAddress, tag })
27
- : 0
28
-
29
- const nonceFromLog = getNonceFromTxLog({ txLog, useAbsoluteNonce, tag })
24
+ if (asset.baseAsset?.api?.features?.noHistory || forceFromNode) {
25
+ return getNonce({ asset: asset.baseAsset, address: fromAddress, tag })
26
+ }
30
27
 
31
- return Math.max(nonceFromNode, nonceFromLog)
28
+ return getNonceFromTxLog({ txLog, useAbsoluteNonce, tag })
32
29
  }
33
30
 
34
31
  const getLatestTxWithNonceChange = ({ reversedTxLog }) => {