@exodus/ethereum-api 8.63.0 → 8.64.1

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,28 @@
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.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.64.0...@exodus/ethereum-api@8.64.1) (2026-01-21)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: monitors should check rpc before dropping (#7277)
13
+
14
+ * fix: prevent transactions from being dropped from no history monitor whilst they are still served at the rpc (#7299)
15
+
16
+
17
+
18
+ ## [8.64.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.63.0...@exodus/ethereum-api@8.64.0) (2026-01-14)
19
+
20
+
21
+ ### Features
22
+
23
+
24
+ * feat: EIP delegation status on all monitors (#7193)
25
+
26
+
27
+
6
28
  ## [8.63.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.62.4...@exodus/ethereum-api@8.63.0) (2026-01-14)
7
29
 
8
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.63.0",
3
+ "version": "8.64.1",
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.20.2",
32
+ "@exodus/ethereum-lib": "^5.21.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": "db8bd0629b287cd2031a12ff2a3fed3fc765bbab"
70
+ "gitHead": "d828ba4e4dc9f986cfa563653101410e6e81685a"
71
71
  }
@@ -303,7 +303,7 @@ export const createAssetFactory = ({
303
303
  broadcastPrivateBundle,
304
304
  broadcastPrivateTx,
305
305
  forceGasLimitEstimation,
306
- getEIP7702Delegation: (addr) => getEIP7702Delegation({ asset: base, address: addr }),
306
+ getEIP7702Delegation: (addr) => getEIP7702Delegation({ address: addr, server }),
307
307
  getNonce,
308
308
  privacyServer,
309
309
  server,
@@ -180,8 +180,7 @@ export const getERC20Params = async ({ asset, address, paramNames = DEFAULT_PARA
180
180
  return response
181
181
  }
182
182
 
183
- export async function getEIP7702Delegation({ asset, address }) {
184
- const server = getServer(asset)
183
+ export async function getEIP7702Delegation({ address, server }) {
185
184
  const code = await server.getCode(address)
186
185
 
187
186
  // No code at all
@@ -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
  }
@@ -10,8 +10,10 @@ import {
10
10
  checkPendingTransactions,
11
11
  excludeUnchangedTokenBalances,
12
12
  getAllLogItemsByAsset,
13
+ getCurrentEIP7702Delegation,
13
14
  getDeriveDataNeededForTick,
14
15
  getDeriveTransactionsToCheck,
16
+ verifyRpcPendingTxStatusBatch,
15
17
  } from './monitor-utils/index.js'
16
18
 
17
19
  const { isEmpty } = lodash
@@ -126,9 +128,26 @@ export class ClarityMonitorV2 extends BaseMonitor {
126
128
  }
127
129
  }
128
130
 
131
+ // Batch verify all pending txs with a single RPC call (skip if refresh)
132
+ const txIds = Object.keys(pendingTransactionsToCheck)
133
+ const statuses = params.refresh
134
+ ? {}
135
+ : await verifyRpcPendingTxStatusBatch({
136
+ baseAsset: this.asset,
137
+ logger: this.logger,
138
+ txIds,
139
+ })
140
+
129
141
  for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
130
- if (params.refresh) await updateTx(tx, assetName, { remove: true })
131
- else await updateTx(tx, assetName, { error: 'Dropped' })
142
+ if (params.refresh) {
143
+ await updateTx(tx, assetName, { remove: true })
144
+ } else {
145
+ const txStatus = statuses[tx.txId]
146
+ if (txStatus?.status === 'dropped') {
147
+ await updateTx(tx, assetName, { error: 'Dropped' })
148
+ }
149
+ // status === 'confirmed' or 'pending' - tx is fine, wait for Clarity to confirm
150
+ }
132
151
  }
133
152
 
134
153
  return { txsToRemove }
@@ -199,6 +218,13 @@ export class ClarityMonitorV2 extends BaseMonitor {
199
218
  ourWalletAddress: derivedData.ourWalletAddress,
200
219
  })
201
220
 
221
+ const eip7702Delegation = await getCurrentEIP7702Delegation({
222
+ server: this.server,
223
+ address: derivedData.ourWalletAddress,
224
+ currentDelegation: derivedData.currentAccountState?.eip7702Delegation,
225
+ logger: this.logger,
226
+ })
227
+
202
228
  const batch = this.aci.createOperationsBatch()
203
229
 
204
230
  this.aci.removeTxLogBatch({
@@ -218,7 +244,11 @@ export class ClarityMonitorV2 extends BaseMonitor {
218
244
  })
219
245
  }
220
246
 
221
- const newData = { ...accountState }
247
+ // All updates must go through newData (accountState param is only used for mem merging)
248
+ const newData = {
249
+ ...accountState,
250
+ ...(eip7702Delegation && { eip7702Delegation }),
251
+ }
222
252
 
223
253
  if (cursor) {
224
254
  newData.clarityCursor = cursor
@@ -10,8 +10,10 @@ import {
10
10
  checkPendingTransactions,
11
11
  excludeUnchangedTokenBalances,
12
12
  getAllLogItemsByAsset,
13
+ getCurrentEIP7702Delegation,
13
14
  getDeriveDataNeededForTick,
14
15
  getDeriveTransactionsToCheck,
16
+ verifyRpcPendingTxStatusBatch,
15
17
  } from './monitor-utils/index.js'
16
18
 
17
19
  const { isEmpty } = lodash
@@ -110,9 +112,26 @@ export class ClarityMonitor extends BaseMonitor {
110
112
  }
111
113
  }
112
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
+
113
125
  for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
114
- if (params.refresh) await updateTx(tx, assetName, { remove: true })
115
- 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
+ }
116
135
  }
117
136
 
118
137
  return { txsToRemove }
@@ -159,6 +178,13 @@ export class ClarityMonitor extends BaseMonitor {
159
178
  ourWalletAddress: derivedData.ourWalletAddress,
160
179
  })
161
180
 
181
+ const eip7702Delegation = await getCurrentEIP7702Delegation({
182
+ server: this.server,
183
+ address: derivedData.ourWalletAddress,
184
+ currentDelegation: derivedData.currentAccountState?.eip7702Delegation,
185
+ logger: this.logger,
186
+ })
187
+
162
188
  const batch = this.aci.createOperationsBatch()
163
189
 
164
190
  this.aci.removeTxLogBatch({
@@ -178,11 +204,18 @@ export class ClarityMonitor extends BaseMonitor {
178
204
  })
179
205
  }
180
206
 
207
+ // All updates must go through newData (accountState param is only used for mem merging)
208
+ const newData = {
209
+ ...accountState,
210
+ clarityCursor: response.cursor,
211
+ ...(eip7702Delegation && { eip7702Delegation }),
212
+ }
213
+
181
214
  this.aci.updateAccountStateBatch({
182
215
  assetName,
183
216
  walletAccount,
184
217
  accountState,
185
- newData: { clarityCursor: response.cursor, ...accountState },
218
+ newData,
186
219
  batch,
187
220
  })
188
221
 
@@ -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
 
@@ -7,6 +7,7 @@ import { fromHexToString } from '../number-utils.js'
7
7
  import { UNCONFIRMED_TX_LIMIT } from './monitor-utils/get-derive-transactions-to-check.js'
8
8
  import {
9
9
  excludeUnchangedTokenBalances,
10
+ getCurrentEIP7702Delegation,
10
11
  getDeriveDataNeededForTick,
11
12
  getDeriveTransactionsToCheck,
12
13
  } from './monitor-utils/index.js'
@@ -134,7 +135,7 @@ export class EthereumNoHistoryMonitor extends BaseMonitor {
134
135
  for (const { tx, assetName } of pendingTransactions) {
135
136
  const txFromNode = pendingTxsFromNode[tx.txId]
136
137
  const isConfirmed = Boolean(txFromNode?.blockHash)
137
- if (now - tx.date.getTime() > UNCONFIRMED_TX_LIMIT && !isConfirmed) {
138
+ if (now - tx.date.getTime() > UNCONFIRMED_TX_LIMIT && !txFromNode) {
138
139
  txsToRemove.push({
139
140
  tx,
140
141
  assetSource: { asset: assetName, walletAccount },
@@ -186,7 +187,20 @@ export class EthereumNoHistoryMonitor extends BaseMonitor {
186
187
  ourWalletAddress,
187
188
  })
188
189
 
189
- await this.updateAccountState({ newData: { ...accountState }, walletAccount })
190
+ const eip7702Delegation = await getCurrentEIP7702Delegation({
191
+ server: this.server,
192
+ address: ourWalletAddress,
193
+ currentDelegation: currentAccountState?.eip7702Delegation,
194
+ logger: this.logger,
195
+ })
196
+
197
+ // All updates must go through newData (accountState param is only used for mem merging)
198
+ const newData = {
199
+ ...accountState,
200
+ ...(eip7702Delegation && { eip7702Delegation }),
201
+ }
202
+
203
+ await this.updateAccountState({ accountState, newData, walletAccount })
190
204
 
191
205
  await this.removeFromTxLog(txsToRemove)
192
206
  await this.updateTxLogByAsset({ logItemsByAsset, walletAccount, refresh })
@@ -0,0 +1,39 @@
1
+ import { getEIP7702Delegation } from '../../eth-like-util.js'
2
+
3
+ /**
4
+ * Checks if the address has an EIP-7702 delegation and returns the delegation state.
5
+ * Returns the new state if changed, or the current state if unchanged.
6
+ * On error, returns the current state to preserve existing data.
7
+ *
8
+ * @param {Object} params
9
+ * @param {Object} params.server - The server instance to use for getCode
10
+ * @param {string} params.address - The wallet address to check
11
+ * @param {Object} [params.currentDelegation] - The current delegation state from accountState
12
+ * @param {Object} [params.logger] - Optional logger for warnings
13
+ * @returns {Promise<Object|undefined>} The delegation state to use
14
+ */
15
+ export async function getCurrentEIP7702Delegation({ server, address, currentDelegation, logger }) {
16
+ try {
17
+ const result = await getEIP7702Delegation({ address, server })
18
+
19
+ // Return new state if changed
20
+ if (
21
+ currentDelegation?.isDelegated !== result.isDelegated ||
22
+ currentDelegation?.delegatedAddress !== result.delegatedAddress
23
+ ) {
24
+ return {
25
+ isDelegated: result.isDelegated,
26
+ delegatedAddress: result.delegatedAddress,
27
+ }
28
+ }
29
+ } catch (error) {
30
+ if (logger) {
31
+ logger.warn('Failed to check EIP-7702 delegation:', error)
32
+ }
33
+ }
34
+
35
+ // Return current state if unchanged or on error
36
+ return currentDelegation
37
+ }
38
+
39
+ export default getCurrentEIP7702Delegation
@@ -5,4 +5,6 @@ export { default as getLogItemsFromServerTx } from './get-log-items-from-server-
5
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
+ export { default as getCurrentEIP7702Delegation } from './get-current-eip7702-delegation.js'
9
+ export { default as verifyRpcPendingTxStatusBatch } from './verify-pending-tx-status-rpc.js'
8
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
+ }