@exodus/ethereum-api 8.64.0 → 8.64.2

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.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.64.1...@exodus/ethereum-api@8.64.2) (2026-01-23)
7
+
8
+ **Note:** Version bump only for package @exodus/ethereum-api
9
+
10
+
11
+
12
+
13
+
14
+ ## [8.64.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.64.0...@exodus/ethereum-api@8.64.1) (2026-01-21)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+
20
+ * fix: monitors should check rpc before dropping (#7277)
21
+
22
+ * fix: prevent transactions from being dropped from no history monitor whilst they are still served at the rpc (#7299)
23
+
24
+
25
+
6
26
  ## [8.64.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.63.0...@exodus/ethereum-api@8.64.0) (2026-01-14)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.64.0",
3
+ "version": "8.64.2",
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": "11f40d9da07e1e70a4e07f94defa3042c6fb21cc"
70
+ "gitHead": "d6aa274a62e489f5dd27e11088c56b3a64903779"
71
71
  }
@@ -1,7 +1,7 @@
1
1
  import assert from 'minimalistic-assert'
2
2
  import ms from 'ms'
3
3
 
4
- import { createEvmServer, ValidMonitorTypes } from './exodus-eth-server/index.js'
4
+ import { createEvmServer, createWsGateway, ValidMonitorTypes } from './exodus-eth-server/index.js'
5
5
  import { createEthereumHooks } from './hooks/index.js'
6
6
  import { ClarityMonitor } from './tx-log/clarity-monitor.js'
7
7
  import { ClarityMonitorV2 } from './tx-log/clarity-monitor-v2.js'
@@ -18,6 +18,7 @@ export const resolveMonitorSettings = (
18
18
  defaultMonitorInterval,
19
19
  defaultMonitorType,
20
20
  defaultServerUrl,
21
+ defaultWsGatewayUri,
21
22
  } = Object.create(null)
22
23
  ) => {
23
24
  assert(asset, 'expected asset')
@@ -27,9 +28,17 @@ export const resolveMonitorSettings = (
27
28
  )
28
29
  assert(defaultServerUrl, `expected default serverUrl for ${asset.name}`)
29
30
 
31
+ // NOTE: When trying to enable `clarity-v3` as an asset
32
+ // default config, there must exist a default
33
+ // gateway definition.
34
+ if (defaultMonitorType === 'clarity-v3') {
35
+ assert(defaultWsGatewayUri, 'expected defaultWsGatewayUri')
36
+ }
37
+
30
38
  const overrideMonitorType = configWithOverrides?.monitorType
31
39
  const overrideServerUrl = configWithOverrides?.serverUrl
32
40
  const overrideMonitorInterval = configWithOverrides?.monitorInterval
41
+ const overrideWsGatewayUri = configWithOverrides?.wsGatewayUri
33
42
 
34
43
  const defaultResolution = {
35
44
  // NOTE: Regardless of the `monitorType`, the `monitorInterval`
@@ -37,13 +46,18 @@ export const resolveMonitorSettings = (
37
46
  monitorInterval: overrideMonitorInterval ?? defaultMonitorInterval,
38
47
  monitorType: defaultMonitorType,
39
48
  serverUrl: defaultServerUrl,
49
+ wsGatewayUri: defaultWsGatewayUri,
50
+ }
51
+
52
+ const fallbackWithWarning = (msg) => {
53
+ console.log([msg, `Falling back to ${defaultMonitorType}<${defaultServerUrl}>.`].join(' '))
54
+ return defaultResolution
40
55
  }
41
56
 
42
57
  if (!overrideMonitorType && overrideServerUrl) {
43
- console.warn(
44
- `Received an \`overrideServerUrl\`, but not the \`monitorType\` for ${asset.name}. Falling back to ${defaultMonitorType}<${defaultServerUrl}>.`
58
+ return fallbackWithWarning(
59
+ `Received an \`overrideServerUrl\`, but not the \`monitorType\` for ${asset.name}.`
45
60
  )
46
- return defaultResolution
47
61
  }
48
62
 
49
63
  // If we don't attempt to override the `monitorType`, we resort
@@ -51,14 +65,24 @@ export const resolveMonitorSettings = (
51
65
  if (!overrideMonitorType) return defaultResolution
52
66
 
53
67
  if (!ValidMonitorTypes.includes(overrideMonitorType)) {
54
- console.warn(
55
- `"${overrideMonitorType}" is not a valid \`MonitorType\`. Falling back to ${defaultMonitorType}<${defaultServerUrl}>.`
68
+ return fallbackWithWarning(`"${overrideMonitorType}" is not a valid \`MonitorType\`.`)
69
+ }
70
+
71
+ const wsGatewayUri = overrideWsGatewayUri || defaultWsGatewayUri
72
+
73
+ if (overrideMonitorType === 'clarity-v3' && !wsGatewayUri) {
74
+ return fallbackWithWarning(
75
+ "Attempted to use 'clarity-v3' without a defining a supporting wsGatewayUri."
56
76
  )
57
- return defaultResolution
58
77
  }
59
78
 
60
79
  // Permit the `monitorType` and `serverUrl` to be overrided.
61
- return { ...defaultResolution, monitorType: overrideMonitorType, serverUrl: overrideServerUrl }
80
+ return {
81
+ ...defaultResolution,
82
+ monitorType: overrideMonitorType,
83
+ serverUrl: overrideServerUrl,
84
+ wsGatewayUri,
85
+ }
62
86
  }
63
87
 
64
88
  export const stringifyPrivateTx = (tx) => {
@@ -116,6 +140,7 @@ export const createHistoryMonitorFactory = ({
116
140
  server,
117
141
  stakingAssetNames,
118
142
  rpcBalanceAssetNames,
143
+ wsGatewayUri,
119
144
  }) => {
120
145
  assert(assetName, 'expected assetName')
121
146
  assert(assetClientInterface, 'expected assetClientInterface')
@@ -142,6 +167,7 @@ export const createHistoryMonitorFactory = ({
142
167
  interval: ms(monitorInterval || '5m'),
143
168
  server,
144
169
  rpcBalanceAssetNames,
170
+ wsGatewayClient: createWsGateway({ uri: wsGatewayUri }),
145
171
  ...args,
146
172
  })
147
173
  break
@@ -71,6 +71,7 @@ export const createAssetFactory = ({
71
71
  useAbsoluteBalanceAndNonce = false,
72
72
  delisted = false,
73
73
  privacyRpcUrl: defaultPrivacyRpcUrl,
74
+ wsGatewayUri: defaultWsGatewayUri,
74
75
  }) => {
75
76
  assert(assetsList, 'assetsList is required')
76
77
  assert(providedFeeData || feeDataConfig, 'feeData or feeDataConfig is required')
@@ -118,12 +119,13 @@ export const createAssetFactory = ({
118
119
 
119
120
  const asset = assets[base.name]
120
121
 
121
- const { monitorType, serverUrl, monitorInterval } = resolveMonitorSettings({
122
+ const { monitorType, serverUrl, monitorInterval, wsGatewayUri } = resolveMonitorSettings({
122
123
  asset,
123
124
  configWithOverrides,
124
125
  defaultMonitorInterval,
125
126
  defaultMonitorType,
126
127
  defaultServerUrl,
128
+ defaultWsGatewayUri,
127
129
  })
128
130
 
129
131
  if (overrideUseAbsoluteBalanceAndNonce !== undefined) {
@@ -220,6 +222,7 @@ export const createAssetFactory = ({
220
222
  server,
221
223
  stakingAssetNames,
222
224
  rpcBalanceAssetNames,
225
+ wsGatewayUri,
223
226
  })
224
227
 
225
228
  const defaultAddressPath = 'm/0/0'
@@ -175,11 +175,12 @@ export default class ClarityServerV2 extends ClarityServer {
175
175
  const newCursor = Buffer.alloc(8)
176
176
  newCursor.writeBigUInt64LE(BigInt(blockNumber), 0)
177
177
 
178
+ // Separate confirmed (has blockNumber) from pending (blockNumber is null)
179
+ const confirmed = transactions.filter((tx) => tx.blockNumber != null)
180
+ const pending = transactions.filter((tx) => tx.blockNumber == null)
181
+
178
182
  return {
179
- transactions: {
180
- confirmed: transactions,
181
- pending: [],
182
- },
183
+ transactions: { confirmed, pending },
183
184
  cursor: newCursor,
184
185
  }
185
186
  }
@@ -12,6 +12,7 @@ import { create } from './api.js'
12
12
  import ApiCoinNodesServer from './api-coin-nodes.js'
13
13
  import ClarityServer from './clarity.js'
14
14
  import ClarityServerV2 from './clarity-v2.js'
15
+ import WsGateway from './ws-gateway.js'
15
16
 
16
17
  export const ValidMonitorTypes = ['no-history', 'clarity', 'clarity-v2', 'clarity-v3', 'magnifier']
17
18
 
@@ -34,6 +35,39 @@ export function createEvmServer({ assetName, serverUrl, monitorType }) {
34
35
  }
35
36
  }
36
37
 
38
+ const gatewayUriToWsGateway = Object.create(null)
39
+
40
+ export const createWsGateway = ({ uri } = Object.create(null)) => {
41
+ assert(typeof uri === 'string', 'expected string uri')
42
+
43
+ uri = new URL(uri).toString()
44
+
45
+ const wsGateway = gatewayUriToWsGateway[uri]
46
+ if (wsGateway) return wsGateway
47
+
48
+ // NOTE: We disable the ability to `setServer` on the
49
+ // `WsGateway`, since we assume a mapping between
50
+ // `gatewayUri -> WsGateway`.
51
+ //
52
+ // This is a safe modification since a `WsGateway`
53
+ // monitor isn't rigged up to the same life cycle
54
+ // as other servers.
55
+ class FrozenWsGateway extends WsGateway {
56
+ constructor() {
57
+ super({ uri })
58
+ }
59
+
60
+ setServer() {
61
+ assert(false, 'setServer is disabled')
62
+ }
63
+ }
64
+
65
+ const frozenWsGateway = new FrozenWsGateway()
66
+ gatewayUriToWsGateway[uri] = frozenWsGateway
67
+
68
+ return frozenWsGateway
69
+ }
70
+
37
71
  // @Deprecated
38
72
  const serverMap = Object.fromEntries(
39
73
  // eslint-disable-next-line @exodus/import/no-deprecated
@@ -26,15 +26,21 @@ const parseSubscriptionKey = (subscriptionKey) => {
26
26
  * @property {string} address
27
27
  */
28
28
 
29
- class WsGateway extends EventEmitter {
29
+ export default class WsGateway extends EventEmitter {
30
30
  #socket = null
31
31
  // Dedup subscriptions to reduce workload on server and resubscribe after closing
32
32
  #subscriptions = new Set()
33
33
  #reconnectionTimeoutId = null
34
- #defaultUri = 'wss://ws-gateway-clarity.a.exodus.io'
35
34
  #uri = null
36
35
  #logger = createConsoleLogger(`@exodus/ws-gateway`)
37
36
 
37
+ constructor({ uri }) {
38
+ super()
39
+ assert(typeof uri === 'string', 'expected string uri')
40
+
41
+ this.#uri = uri
42
+ }
43
+
38
44
  _handlers = {
39
45
  message: ({ target, data }) => {
40
46
  if (target !== this.#socket) return
@@ -77,7 +83,7 @@ class WsGateway extends EventEmitter {
77
83
  }
78
84
 
79
85
  setServer(uri) {
80
- this.#uri = uri || this.#uri || this.#defaultUri
86
+ this.#uri = uri || this.#uri
81
87
  }
82
88
 
83
89
  start() {
@@ -266,8 +272,3 @@ class WsGateway extends EventEmitter {
266
272
  this.#socket = null
267
273
  }
268
274
  }
269
-
270
- const wsGateway = new WsGateway()
271
- const createWsGateway = () => wsGateway
272
-
273
- export { createWsGateway, WsGateway }
@@ -1,8 +1,9 @@
1
1
  import { BaseMonitor } from '@exodus/asset-lib'
2
2
  import { getAssetAddresses } from '@exodus/ethereum-lib'
3
3
  import lodash from 'lodash'
4
+ import assert from 'minimalistic-assert'
4
5
 
5
- import { createWsGateway } from '../exodus-eth-server/ws-gateway.js'
6
+ import WsGateway from '../exodus-eth-server/ws-gateway.js'
6
7
  import { executeEthLikeFeeMonitorUpdate } from '../fee-utils.js'
7
8
  import { fromHexToString } from '../number-utils.js'
8
9
  import { filterEffects, getLogItemsFromServerTx } from './clarity-utils/index.js'
@@ -13,6 +14,7 @@ import {
13
14
  getCurrentEIP7702Delegation,
14
15
  getDeriveDataNeededForTick,
15
16
  getDeriveTransactionsToCheck,
17
+ verifyRpcPendingTxStatusBatch,
16
18
  } from './monitor-utils/index.js'
17
19
 
18
20
  const { isEmpty } = lodash
@@ -22,14 +24,10 @@ export class ClarityMonitorV2 extends BaseMonitor {
22
24
  #walletAccountByAddress = new Map()
23
25
  #walletAccountInfo = new Map()
24
26
  #rpcBalanceAssetNames = []
25
- constructor({
26
- server,
27
- wsGatewayClient = createWsGateway(),
28
- rpcBalanceAssetNames,
29
- config,
30
- ...args
31
- } = {}) {
27
+ constructor({ server, wsGatewayClient, rpcBalanceAssetNames, config, ...args } = {}) {
32
28
  super(args)
29
+ assert(wsGatewayClient instanceof WsGateway, 'expected WsGateway wsGatewayClient')
30
+
33
31
  this.config = { GAS_PRICE_FROM_WEBSOCKET: true, ...config }
34
32
  this.server = server
35
33
  this.#wsClient = wsGatewayClient
@@ -47,8 +45,6 @@ export class ClarityMonitorV2 extends BaseMonitor {
47
45
  setServer(config) {
48
46
  const uri = config?.server || this.server.defaultUri
49
47
 
50
- this.#wsClient.setServer(config.wsGatewayUrl?.v1)
51
-
52
48
  this.#wsClient.on('connected', () => this.subscribeAllWalletAccounts())
53
49
  this.#wsClient.start()
54
50
 
@@ -127,9 +123,26 @@ export class ClarityMonitorV2 extends BaseMonitor {
127
123
  }
128
124
  }
129
125
 
126
+ // Batch verify all pending txs with a single RPC call (skip if refresh)
127
+ const txIds = Object.keys(pendingTransactionsToCheck)
128
+ const statuses = params.refresh
129
+ ? {}
130
+ : await verifyRpcPendingTxStatusBatch({
131
+ baseAsset: this.asset,
132
+ logger: this.logger,
133
+ txIds,
134
+ })
135
+
130
136
  for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
131
- if (params.refresh) await updateTx(tx, assetName, { remove: true })
132
- else await updateTx(tx, assetName, { error: 'Dropped' })
137
+ if (params.refresh) {
138
+ await updateTx(tx, assetName, { remove: true })
139
+ } else {
140
+ const txStatus = statuses[tx.txId]
141
+ if (txStatus?.status === 'dropped') {
142
+ await updateTx(tx, assetName, { error: 'Dropped' })
143
+ }
144
+ // status === 'confirmed' or 'pending' - tx is fine, wait for Clarity to confirm
145
+ }
133
146
  }
134
147
 
135
148
  return { txsToRemove }
@@ -13,6 +13,7 @@ import {
13
13
  getCurrentEIP7702Delegation,
14
14
  getDeriveDataNeededForTick,
15
15
  getDeriveTransactionsToCheck,
16
+ verifyRpcPendingTxStatusBatch,
16
17
  } from './monitor-utils/index.js'
17
18
 
18
19
  const { isEmpty } = lodash
@@ -111,9 +112,26 @@ export class ClarityMonitor extends BaseMonitor {
111
112
  }
112
113
  }
113
114
 
115
+ // Batch verify all pending txs with a single RPC call (skip if refresh)
116
+ const txIds = Object.keys(pendingTransactionsToCheck)
117
+ const statuses = params.refresh
118
+ ? {}
119
+ : await verifyRpcPendingTxStatusBatch({
120
+ baseAsset: this.asset,
121
+ logger: this.logger,
122
+ txIds,
123
+ })
124
+
114
125
  for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
115
- if (params.refresh) await updateTx(tx, assetName, { remove: true })
116
- else await updateTx(tx, assetName, { error: 'Dropped' })
126
+ if (params.refresh) {
127
+ await updateTx(tx, assetName, { remove: true })
128
+ } else {
129
+ const txStatus = statuses[tx.txId]
130
+ if (txStatus?.status === 'dropped') {
131
+ await updateTx(tx, assetName, { error: 'Dropped' })
132
+ }
133
+ // status === 'confirmed' or 'pending' - tx is fine, wait for Clarity to confirm
134
+ }
117
135
  }
118
136
 
119
137
  return { txsToRemove }
@@ -12,6 +12,7 @@ import {
12
12
  getDeriveTransactionsToCheck,
13
13
  getHistoryFromServer,
14
14
  getLogItemsFromServerTx,
15
+ verifyRpcPendingTxStatusBatch,
15
16
  } from './monitor-utils/index.js'
16
17
  import {
17
18
  enableWSUpdates,
@@ -118,17 +119,27 @@ export class EthereumMonitor extends BaseMonitor {
118
119
  }
119
120
  }
120
121
 
122
+ // Batch verify all pending txs with a single RPC call (skip if refresh)
123
+ const txIds = Object.keys(pendingTransactionsToCheck)
124
+ const statuses = params.refresh
125
+ ? {}
126
+ : await verifyRpcPendingTxStatusBatch({
127
+ baseAsset: this.asset,
128
+ logger: this.logger,
129
+ txIds,
130
+ })
131
+
121
132
  for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
122
133
  if (params.refresh) {
123
134
  await updateTx(tx, assetName, { remove: true })
124
135
  } else {
125
- // FIXME: Fetching unconfirmed txs from node helps to avoid
126
- // flagging on-chain confirmed txs as dropped in the wallet.
127
- // Once the real bug is found, remove this logic
128
- const rpcTx = await this.server.getTransactionByHash(tx.txId)
129
- const isTxConfirmed = !!rpcTx?.blockNumber
130
- this.logger.info(`tx ${tx.txId} confirmed: ${isTxConfirmed}`)
131
- await updateTx(tx, assetName, { isTxConfirmed })
136
+ const txStatus = statuses[tx.txId]
137
+ if (txStatus?.status === 'confirmed') {
138
+ await updateTx(tx, assetName, { isTxConfirmed: true })
139
+ } else if (txStatus?.status === 'dropped') {
140
+ await updateTx(tx, assetName, { isTxConfirmed: false })
141
+ }
142
+ // status === 'pending' or missing - tx still in mempool, do nothing
132
143
  }
133
144
  }
134
145
 
@@ -135,7 +135,7 @@ export class EthereumNoHistoryMonitor extends BaseMonitor {
135
135
  for (const { tx, assetName } of pendingTransactions) {
136
136
  const txFromNode = pendingTxsFromNode[tx.txId]
137
137
  const isConfirmed = Boolean(txFromNode?.blockHash)
138
- if (now - tx.date.getTime() > UNCONFIRMED_TX_LIMIT && !isConfirmed) {
138
+ if (now - tx.date.getTime() > UNCONFIRMED_TX_LIMIT && !txFromNode) {
139
139
  txsToRemove.push({
140
140
  tx,
141
141
  assetSource: { asset: assetName, walletAccount },
@@ -6,4 +6,5 @@ export { default as getHistoryFromServer } from './get-history-from-server.js'
6
6
  export { default as checkPendingTransactions } from './check-pending-transactions.js'
7
7
  export { default as getDeriveTransactionsToCheck } from './get-derive-transactions-to-check.js'
8
8
  export { default as getCurrentEIP7702Delegation } from './get-current-eip7702-delegation.js'
9
+ export { default as verifyRpcPendingTxStatusBatch } from './verify-pending-tx-status-rpc.js'
9
10
  export * from './exclude-unchanged-token-balances.js'
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Determines the status of an RPC transaction response.
3
+ *
4
+ * @param {Object|null} rpcTx - The RPC transaction response
5
+ * @returns {{status: 'confirmed' | 'pending' | 'dropped', blockNumber?: number}}
6
+ */
7
+ function getTxStatus(rpcTx) {
8
+ if (rpcTx?.blockNumber) {
9
+ return { status: 'confirmed', blockNumber: parseInt(rpcTx.blockNumber, 16) }
10
+ }
11
+
12
+ if (rpcTx) {
13
+ return { status: 'pending' }
14
+ }
15
+
16
+ return { status: 'dropped' }
17
+ }
18
+
19
+ /**
20
+ * Batch verify pending transactions against the RPC node before marking them as dropped.
21
+ *
22
+ * Any lag in the Clarity indexer can cause confirmed transactions to be incorrectly
23
+ * flagged as dropped if Clarity hasn't indexed their block yet.
24
+ * By checking the RPC directly, we avoid this race condition.
25
+ *
26
+ * Uses a single batch RPC call for efficiency and graceful error handling.
27
+ *
28
+ * @param {Object} params
29
+ * @param {Object} params.baseAsset - The base asset to get the server from
30
+ * @param {Object} params.logger - Logger instance for info/debug output
31
+ * @param {string[]} params.txIds - Array of transaction IDs/hashes to verify
32
+ * @returns {Promise<Object<string, {status: 'confirmed' | 'pending' | 'dropped', blockNumber?: number}>>}
33
+ */
34
+ export default async function verifyRpcPendingTxStatusBatch({ baseAsset, logger, txIds }) {
35
+ if (txIds.length === 0) return {}
36
+
37
+ const server = baseAsset.server
38
+
39
+ // Build batch request
40
+ const requests = txIds.map((txId) => server.getTransactionByHashRequest(txId))
41
+
42
+ let responses
43
+ try {
44
+ responses = await server.sendBatchRequest(requests)
45
+ } catch (error) {
46
+ // If batch request fails, skip all drop checks (safe default)
47
+ logger.warn('Batch RPC request failed, skipping drop checks', error)
48
+ return {}
49
+ }
50
+
51
+ // Map responses to statuses
52
+ const results = {}
53
+ txIds.forEach((txId, idx) => {
54
+ const rpcTx = responses[idx]
55
+ const txStatus = getTxStatus(rpcTx)
56
+ results[txId] = txStatus
57
+
58
+ // Log each tx status
59
+ if (txStatus.status === 'confirmed') {
60
+ logger.info(`tx ${txId} is confirmed on-chain (block ${txStatus.blockNumber}), skipping drop`)
61
+ } else if (txStatus.status === 'pending') {
62
+ logger.info(`tx ${txId} is still in mempool, skipping drop`)
63
+ } else {
64
+ logger.info(`tx ${txId} not found on-chain, marking as dropped`)
65
+ }
66
+ })
67
+
68
+ return results
69
+ }