@exodus/ethereum-api 8.78.2 → 8.79.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,48 @@
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.79.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.77.1...@exodus/ethereum-api@8.79.0) (2026-08-18)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: source eip7702 delegation whitelist from remote config (#8356)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+
18
+ * fix: disable @exodus/hydra/no-param-reassign-prop-only warnings (#8531)
19
+
20
+ * fix(ethereum-api): detect legacy delegate transactions (#8357)
21
+
22
+ * fix(ethereum-api): preserve live pending txs across refresh to prevent nonce reuse (#8430)
23
+
24
+ * fix(ethereum-api): preserve precision for wei values in tx simulation (#7920)
25
+
26
+ * fix(ethereum-api): reference defined ethereum asset in simulate-tx tests (#8525)
27
+
28
+ * fix(ethereum): prevent silently dropped zero-tip private bundles (#8493)
29
+
30
+ * fix(lint): add explicit process imports and enforce no-implicit-process (#8530)
31
+
32
+ * fix(lint): convert console.log/info to console.debug and enforce no-console (#8529)
33
+
34
+ * fix(lint): fix import/named warnings and upgrade to error (#8496)
35
+
36
+ * fix(lint): fix JSDoc warnings across multiple packages (#8519)
37
+
38
+ * fix(lint): fix no-throw-literal warnings and upgrade to error (#8498)
39
+
40
+ * fix(lint): fix oxlint warnings and convert rules to error (#8489)
41
+
42
+ * fix(simulation): route recipient changes to dedicated field, guard asset-less entries (#8370)
43
+
44
+ * fix: upgrade low-complexity lint rules to error (#8510)
45
+
46
+
47
+
6
48
  ## [8.78.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.77.1...@exodus/ethereum-api@8.78.2) (2026-08-14)
7
49
 
8
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.78.2",
3
+ "version": "8.79.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",
@@ -17,7 +17,7 @@
17
17
  "provenance": false
18
18
  },
19
19
  "scripts": {
20
- "test": "run -T exodus-test --jest"
20
+ "test": "exodus-test --jest"
21
21
  },
22
22
  "dependencies": {
23
23
  "@exodus/asset": "^2.0.4",
@@ -67,5 +67,5 @@
67
67
  "type": "git",
68
68
  "url": "git+https://github.com/ExodusMovement/assets.git"
69
69
  },
70
- "gitHead": "07b29ff2dc71fb1358ad6b1b6748c3f9fe66c10e"
70
+ "gitHead": "85b01e122492dcdd4205ed06df8f4c0af545d25b"
71
71
  }
@@ -1,4 +1,3 @@
1
- // eslint-disable-next-line @exodus/import/no-deprecated
2
1
  import { isNumberUnit } from '@exodus/currency'
3
2
  import { buffer2currency, createUnsignedTxFactory } from '@exodus/ethereum-lib'
4
3
  import { bufferToInt } from '@exodus/ethereumjs/util'
@@ -46,7 +45,6 @@ export const buildApproveTx = async ({
46
45
  }) => {
47
46
  assert(asset, 'expected asset')
48
47
  assert(feeData, 'expected feeData')
49
- // eslint-disable-next-line @exodus/import/no-deprecated
50
48
  assert(isNumberUnit(approveAmount), 'expected approveAmount')
51
49
  assert(typeof spenderAddress === 'string', 'expected spenderAddress')
52
50
  assert(walletAccount, 'expected walletAccount')
@@ -231,7 +229,7 @@ export const createApproveSpendingTokens =
231
229
 
232
230
  txIds.push(revokeTokenApprovalTxId)
233
231
  // Hardcoded min gas limit. Otherwise `fetchGasLimit` will fail since `approve(_spender, 0)` is not yet confirmed
234
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
232
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
235
233
  data.gasLimit = Math.max(data.gasLimit || 0, APPROVAL_GAS_LIMIT)
236
234
  }
237
235
 
@@ -62,7 +62,7 @@ export const resolveMonitorSettings = (
62
62
  }
63
63
 
64
64
  const fallbackWithWarning = (msg) => {
65
- console.log([msg, `Falling back to ${defaultMonitorType}<${defaultServerUrl}>.`].join(' '))
65
+ console.debug([msg, `Falling back to ${defaultMonitorType}<${defaultServerUrl}>.`].join(' '))
66
66
  return defaultResolution
67
67
  }
68
68
 
@@ -317,7 +317,7 @@ export const createHistoryMonitorFactory = ({
317
317
  let monitor
318
318
  switch (monitorType) {
319
319
  case 'clarity-v3':
320
- console.log('clarity-v3 is no longer supported, falling back to clarity-v2')
320
+ console.debug('clarity-v3 is no longer supported, falling back to clarity-v2')
321
321
  // eslint-disable-next-line no-fallthrough
322
322
  case 'clarity':
323
323
  case 'clarity-v2':
package/src/ens/index.js CHANGED
@@ -1,4 +1,3 @@
1
- // eslint-disable-next-line @exodus/import/no-deprecated
2
1
  import { hashSync } from '@exodus/crypto/hash'
3
2
  import { ABI } from '@exodus/ethereum-lib'
4
3
  import SolidityContract from '@exodus/solidity-contract'
@@ -25,7 +24,6 @@ function namehash(inputName) {
25
24
  const name = normalize(inputName)
26
25
  if (name) {
27
26
  for (const label of name.split('.').reverse()) {
28
- // eslint-disable-next-line @exodus/import/no-deprecated
29
27
  node = hashSync('keccak256', [node, hashSync('keccak256', label, 'uint8')], 'buffer')
30
28
  }
31
29
  }
@@ -29,10 +29,15 @@ export default class ApiCoinNodesServer extends EthLikeServer {
29
29
  return fetchJsonRetry(this.uri, options)
30
30
  }
31
31
 
32
- async sendBatchRequest(batch) {
32
+ async sendBatchRequest(batch, { tolerant } = {}) {
33
33
  if (isEmpty(batch)) return batch
34
34
  const responses = await this.request(batch)
35
- return this.parseBatchResponse({ batch, responses, errorMessage: 'Bad rpc batch response' })
35
+ return this.parseBatchResponse({
36
+ batch,
37
+ responses,
38
+ errorMessage: 'Bad rpc batch response',
39
+ tolerant,
40
+ })
36
41
  }
37
42
 
38
43
  async sendRequest(request) {
@@ -263,11 +263,11 @@ export function create(defaultURL, ensAssetName) {
263
263
  },
264
264
 
265
265
  async proxyToCoinNode(requestData) {
266
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
266
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
267
267
  if (!requestData.jsonrpc) requestData.jsonrpc = '2.0'
268
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
268
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
269
269
  if (!requestData.id) requestData.id = randomUUID()
270
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
270
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
271
271
  if (!requestData.params) requestData.params = []
272
272
 
273
273
  return request('proxy', requestData, { version: 'v2', method: 'post' })
@@ -198,7 +198,7 @@ export default class ClarityServer extends EthLikeServer {
198
198
  clearTimeout(timeout)
199
199
  const rawTraceparent = response?.traceparent || response?.[0]?.traceparent
200
200
  if (rawTraceparent) {
201
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
201
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
202
202
  response.traceparent = TraceId.fromRawTraceparent(rawTraceparent)
203
203
  }
204
204
 
@@ -208,9 +208,9 @@ export default class ClarityServer extends EthLikeServer {
208
208
  }
209
209
 
210
210
  // Transport: WS only in ClarityServer, WS first → HTTP fallback in ClarityServerV2
211
- async sendBatchRequest(batch) {
211
+ async sendBatchRequest(batch, { tolerant } = {}) {
212
212
  const responses = await this.sendRpcRequest(batch)
213
- return this.parseBatchResponse({ batch, responses, errorMessage: 'Bad Response' })
213
+ return this.parseBatchResponse({ batch, responses, errorMessage: 'Bad Response', tolerant })
214
214
  }
215
215
 
216
216
  // Transport: Uses sendRpcRequest - WS only in ClarityServer, WS first → HTTP fallback in ClarityServerV2
@@ -165,10 +165,11 @@ export class EthLikeServer extends EthLikeServerBase {
165
165
  * @param {Array<{ id: number|string }>} args.batch - Requests sent, in the order results are expected.
166
166
  * @param {unknown} args.responses - Raw transport payload; expected `[{ id, result }]` but treated as untrusted (gateways may return `{ message }` on error).
167
167
  * @param {string} args.errorMessage - Thrown-error prefix per transport (e.g. `'Bad rpc batch response'`, `'Bad Response'`).
168
- * @returns {unknown[]} Each request's `result`, ordered to match `batch`.
169
- * @throws {Error} When `responses` isn't an array, length mismatches `batch`, or any entry lacks a numeric `id` / defined `result`.
168
+ * @param {boolean} [args.tolerant] - When true, a request without a valid entry (JSON-RPC error entry, non-numeric `id`, duplicated or out-of-batch `id`, missing from a short response) maps to `undefined` instead of failing the whole batch.
169
+ * @returns {unknown[]} Each request's `result`, ordered to match `batch`. Entries with a duplicated or out-of-batch `id` are discarded in both modes, so an affected request maps to `undefined` rather than another request's result.
170
+ * @throws {Error} When `responses` isn't an array; in strict mode also when length mismatches `batch` or any entry lacks a numeric `id` / defined `result`.
170
171
  */
171
- parseBatchResponse({ batch, responses, errorMessage }) {
172
+ parseBatchResponse({ batch, responses, errorMessage, tolerant = false }) {
172
173
  // Gateways can respond with a JSON object (e.g. `{ message }`) instead of a batch array.
173
174
  if (!Array.isArray(responses)) {
174
175
  // Untrusted server text: keep only a short string preview, developer-facing (no `hint`).
@@ -179,16 +180,23 @@ export class EthLikeServer extends EthLikeServerBase {
179
180
  throw new Error(`${errorMessage}${preview}`)
180
181
  }
181
182
 
182
- const isValid = responses.every(
183
- (response) => !isNaN(response?.id) && response?.result !== undefined
184
- )
185
- if (responses.length !== batch.length || !isValid) {
183
+ const isValidEntry = (response) => !isNaN(response?.id) && response?.result !== undefined
184
+
185
+ if (!tolerant && (responses.length !== batch.length || !responses.every(isValidEntry))) {
186
186
  throw new Error(errorMessage)
187
187
  }
188
188
 
189
- // Safe against prototype keys: every `id` is validated numeric by the `isValid` check above.
189
+ // Safe against prototype keys: only entries with a validated numeric `id` are kept.
190
190
  const keyed = Object.fromEntries(
191
- responses.map((response) => [`${response.id}`, response.result])
191
+ responses
192
+ .filter(isValidEntry)
193
+ .filter(({ id }, _i, all) => {
194
+ const key = `${id}`
195
+ const inBatch = batch.some((request) => `${request.id}` === key)
196
+ const unique = all.filter(({ id: other }) => `${other}` === key).length === 1
197
+ return inBatch && unique
198
+ })
199
+ .map((response) => [`${response.id}`, response.result])
192
200
  )
193
201
  return batch.map((request) => keyed[`${request.id}`])
194
202
  }
@@ -1,9 +1,6 @@
1
1
  import {
2
- // eslint-disable-next-line @exodus/import/no-deprecated
3
2
  DEFAULT_SERVER_URLS,
4
- // eslint-disable-next-line @exodus/import/no-deprecated
5
3
  ETHEREUM_LIKE_ASSETS,
6
- // eslint-disable-next-line @exodus/import/no-deprecated
7
4
  ETHEREUM_LIKE_MONITOR_TYPES,
8
5
  } from '@exodus/ethereum-lib'
9
6
  import assert from 'minimalistic-assert'
@@ -80,11 +77,8 @@ export const createWsGateway = ({ uri } = Object.create(null)) => {
80
77
 
81
78
  // @Deprecated
82
79
  const serverMap = Object.fromEntries(
83
- // eslint-disable-next-line @exodus/import/no-deprecated
84
80
  ETHEREUM_LIKE_ASSETS.map((assetName) => {
85
- // eslint-disable-next-line @exodus/import/no-deprecated
86
81
  const monitorType = ETHEREUM_LIKE_MONITOR_TYPES[assetName]
87
- // eslint-disable-next-line @exodus/import/no-deprecated
88
82
  const serverUrl = DEFAULT_SERVER_URLS[assetName]
89
83
  return [assetName, createEvmServer({ assetName, serverUrl, monitorType })]
90
84
  })
@@ -3,7 +3,6 @@ import {
3
3
  mapCurrency as defaultMapCurrency,
4
4
  modelToJSON,
5
5
  } from '@exodus/asset-lib/src/utils/index.js'
6
- // eslint-disable-next-line @exodus/import/no-deprecated
7
6
  import { isNumberUnit } from '@exodus/currency'
8
7
  import lodash from 'lodash'
9
8
 
@@ -13,7 +12,6 @@ const PRE_EIP1559_UNIT_KEYS_GUARD = ['gasPrice']
13
12
  const EIP1559_UNIT_KEYS_GUARD = ['gasPrice', 'baseFeePerGas']
14
13
 
15
14
  export const bound = ({ val, multiplier, max, min }) => {
16
- // eslint-disable-next-line @exodus/import/no-deprecated
17
15
  if (!isNumberUnit(val)) return val
18
16
 
19
17
  if (multiplier) {
@@ -30,7 +30,7 @@ const convertToAPIPayload = ({ address, message, url }) => {
30
30
 
31
31
  export const handleActionResponse = ({ simulationResult, response }) => {
32
32
  if (['BLOCK', 'WARN'].includes(response.action)) {
33
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
33
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
34
34
  simulationResult.action = response.action
35
35
  }
36
36
  }
@@ -194,7 +194,7 @@ export const simulateTransactionsApi = async ({
194
194
  }
195
195
  }
196
196
 
197
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
197
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
198
198
  simulationResult.metadata.simulatedLocally = false
199
199
 
200
200
  const { expectedStateChanges = {}, error } = response.simulationResults.aggregated
@@ -178,7 +178,7 @@ export function createEthereumStakingService({
178
178
  stakingProvider = stakingProviderClientFactory(),
179
179
  }) {
180
180
  if (deprectedArg && !assetName) {
181
- console.log('using deprecated ARG `asset` instead of `assetName`')
181
+ console.debug('using deprecated ARG `asset` instead of `assetName`')
182
182
  assetName = deprectedArg.name
183
183
  }
184
184
 
@@ -212,7 +212,7 @@ export function createEthereumStakingService({
212
212
 
213
213
  const { to, data } = await staking.stake({ amount })
214
214
 
215
- console.log(
215
+ console.debug(
216
216
  `delegator address ${delegatorAddress} staking ${amount.toDefaultString({
217
217
  unit: true,
218
218
  })}`
@@ -376,7 +376,7 @@ export function createEthereumStakingService({
376
376
  },
377
377
  }
378
378
 
379
- console.log(
379
+ console.debug(
380
380
  `delegator address ${delegatorAddress} unstaking ${requestedAmount.toDefaultString({
381
381
  unit: true,
382
382
  })} - pending amount: ${pendingAmount.toDefaultString({ unit: true })}`
@@ -1,10 +1,8 @@
1
- // eslint-disable-next-line @exodus/import/no-deprecated
2
1
  import { isNumberUnit } from '@exodus/currency'
3
2
 
4
3
  export const DISABLE_BALANCE_CHECKS = '0x0'
5
4
 
6
5
  export function amountToCurrency({ asset, amount }) {
7
- // eslint-disable-next-line @exodus/import/no-deprecated
8
6
  return isNumberUnit(amount) ? amount : asset.currency.parse(amount)
9
7
  }
10
8
 
@@ -19,6 +19,7 @@ import {
19
19
  getCurrentEIP7702Delegation,
20
20
  getDeriveDataNeededForTick,
21
21
  getDeriveTransactionsToCheck,
22
+ isUnverifiablePendingTxExpired,
22
23
  verifyRpcPendingTxStatusBatch,
23
24
  } from './monitor-utils/index.js'
24
25
 
@@ -90,19 +91,23 @@ export class ClarityMonitor extends BaseMonitor {
90
91
 
91
92
  // eslint-disable-next-line no-undef
92
93
  async checkPendingTransactions(params) {
93
- const { pendingTransactionsToCheck, pendingTransactionsGroupedByAddressAndNonce } =
94
- checkPendingTransactions(params)
94
+ const {
95
+ pendingTransactionsToCheck,
96
+ pendingTransactionsGroupedByAddressAndNonce,
97
+ recentPendingTransactions,
98
+ } = checkPendingTransactions(params)
95
99
  const txsToRemove = []
96
100
  const { walletAccount } = params
97
101
 
98
- const updateTx = (tx, asset, { error, remove }) => {
102
+ const updateTx = (tx, asset, { error, remove, preserve, isTxConfirmed }) => {
99
103
  if (remove) {
100
104
  txsToRemove.push({ tx, assetSource: { asset, walletAccount } })
105
+ } else if (preserve) {
106
+ params.logItemsByAsset[asset].push(tx)
101
107
  } else {
102
108
  params.logItemsByAsset[asset].push({
103
109
  ...tx,
104
- dropped: true,
105
- error,
110
+ ...(isTxConfirmed ? { confirmations: 1 } : { dropped: true, error }),
106
111
  })
107
112
  }
108
113
 
@@ -115,11 +120,15 @@ export class ClarityMonitor extends BaseMonitor {
115
120
  assetSource: { asset: assetName, walletAccount },
116
121
  })
117
122
  } else if (tokenTxSet && tokenTxSet.has(tx.txId)) {
118
- params.logItemsByAsset[assetName].push({
119
- ...tokenTxSet.get(tx.txId),
120
- error,
121
- dropped: true,
122
- })
123
+ // Optional: the stored tx may name a token since removed from the registry
124
+ params.logItemsByAsset[assetName]?.push(
125
+ preserve
126
+ ? tokenTxSet.get(tx.txId)
127
+ : {
128
+ ...tokenTxSet.get(tx.txId),
129
+ ...(isTxConfirmed ? { confirmations: 1 } : { dropped: true, error }),
130
+ }
131
+ )
123
132
  }
124
133
  })
125
134
  return Promise.all(promises)
@@ -131,28 +140,55 @@ export class ClarityMonitor extends BaseMonitor {
131
140
  if (replaced) {
132
141
  await updateTx(tx, assetName, { remove: true })
133
142
  delete pendingTransactionsToCheck[tx.txId]
143
+ delete recentPendingTransactions[tx.txId]
134
144
  }
135
145
  }
136
146
 
137
- // Batch verify all pending txs with a single RPC call (skip if refresh)
147
+ // Batch verify all pending txs with a single RPC call
138
148
  const txIds = Object.keys(pendingTransactionsToCheck)
139
- const statuses = params.refresh
140
- ? {}
141
- : await verifyRpcPendingTxStatusBatch({
142
- server: this.server,
143
- logger: this.logger,
144
- txIds,
145
- })
149
+ const statuses = await verifyRpcPendingTxStatusBatch({
150
+ server: this.server,
151
+ logger: this.logger,
152
+ txIds,
153
+ })
146
154
 
147
155
  for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
156
+ const txStatus = statuses[tx.txId]
148
157
  if (params.refresh) {
149
- await updateTx(tx, assetName, { remove: true })
150
- } else {
151
- const txStatus = statuses[tx.txId]
152
158
  if (txStatus?.status === 'dropped') {
153
- await updateTx(tx, assetName, { error: 'Dropped' })
159
+ await updateTx(tx, assetName, { remove: true })
160
+ } else if (txStatus?.status === 'confirmed') {
161
+ // Confirmed on-chain but missing from the indexer response: mark it
162
+ // confirmed so it stops being a pending entry that would otherwise be
163
+ // preserved (exempt from the age ceiling) on every refresh forever.
164
+ await updateTx(tx, assetName, { isTxConfirmed: true })
165
+ } else if (
166
+ // No txStatus at all means the whole verification batch failed - evidence
167
+ // about nothing, so preserve unconditionally; the age ceiling only applies
168
+ // to a per-entry 'unknown' verdict (evidence about that one hash).
169
+ !txStatus ||
170
+ txStatus.status === 'pending' ||
171
+ !isUnverifiablePendingTxExpired(tx)
172
+ ) {
173
+ // Still live on-chain (or not verifiable yet) but missing from the indexer
174
+ // response: re-add it so the refresh overwrite cannot wipe it and break
175
+ // tx-log-based nonce resolution.
176
+ await updateTx(tx, assetName, { preserve: true })
177
+ } else {
178
+ // Unverifiable past the age ceiling: purge so a hash the node keeps
179
+ // erroring on cannot pin nonce derivation forever.
180
+ await updateTx(tx, assetName, { remove: true })
154
181
  }
155
- // status === 'confirmed' or 'pending' - tx is fine, wait for Clarity to confirm
182
+ } else if (txStatus?.status === 'dropped') {
183
+ await updateTx(tx, assetName, { error: 'Dropped' })
184
+ }
185
+ // non-refresh: 'confirmed', 'pending' or unknown - tx is fine, wait for Clarity to confirm
186
+ }
187
+
188
+ if (params.refresh) {
189
+ // Too recent for a reliable on-chain verdict - always survive the refresh overwrite
190
+ for (const { tx, assetName } of Object.values(recentPendingTransactions)) {
191
+ await updateTx(tx, assetName, { preserve: true })
156
192
  }
157
193
  }
158
194
 
@@ -12,6 +12,7 @@ import {
12
12
  getDeriveTransactionsToCheck,
13
13
  getHistoryFromServer,
14
14
  getLogItemsFromServerTx,
15
+ isUnverifiablePendingTxExpired,
15
16
  verifyRpcPendingTxStatusBatch,
16
17
  } from './monitor-utils/index.js'
17
18
  import {
@@ -74,16 +75,21 @@ export class EthereumMonitor extends BaseMonitor {
74
75
 
75
76
  // eslint-disable-next-line no-undef
76
77
  async checkPendingTransactions(params) {
77
- const { pendingTransactionsToCheck, pendingTransactionsGroupedByAddressAndNonce } =
78
- checkPendingTransactions(params)
78
+ const {
79
+ pendingTransactionsToCheck,
80
+ pendingTransactionsGroupedByAddressAndNonce,
81
+ recentPendingTransactions,
82
+ } = checkPendingTransactions(params)
79
83
  const txsToRemove = []
80
84
  const {
81
85
  assetSource: { walletAccount },
82
86
  } = params
83
87
 
84
- const updateTx = (tx, asset, { isTxConfirmed, remove }) => {
88
+ const updateTx = (tx, asset, { isTxConfirmed, remove, preserve }) => {
85
89
  if (remove) {
86
90
  txsToRemove.push({ tx, assetSource: { asset, walletAccount } })
91
+ } else if (preserve) {
92
+ params.logItemsByAsset[asset].push(tx)
87
93
  } else {
88
94
  params.logItemsByAsset[asset].push({
89
95
  ...tx,
@@ -100,10 +106,15 @@ export class EthereumMonitor extends BaseMonitor {
100
106
  assetSource: { asset: assetName, walletAccount },
101
107
  })
102
108
  } else if (tokenTxSet && tokenTxSet.has(tx.txId)) {
103
- params.logItemsByAsset[assetName].push({
104
- ...tokenTxSet.get(tx.txId),
105
- ...(isTxConfirmed ? { confirmations: 1 } : { dropped: true, error: 'Dropped' }),
106
- })
109
+ // Optional: the stored tx may name a token since removed from the registry
110
+ params.logItemsByAsset[assetName]?.push(
111
+ preserve
112
+ ? tokenTxSet.get(tx.txId)
113
+ : {
114
+ ...tokenTxSet.get(tx.txId),
115
+ ...(isTxConfirmed ? { confirmations: 1 } : { dropped: true, error: 'Dropped' }),
116
+ }
117
+ )
107
118
  }
108
119
  })
109
120
 
@@ -116,30 +127,50 @@ export class EthereumMonitor extends BaseMonitor {
116
127
  if (replaced) {
117
128
  await updateTx(tx, assetName, { remove: true })
118
129
  delete pendingTransactionsToCheck[tx.txId]
130
+ delete recentPendingTransactions[tx.txId]
119
131
  }
120
132
  }
121
133
 
122
- // Batch verify all pending txs with a single RPC call (skip if refresh)
134
+ // Batch verify all pending txs with a single RPC call
123
135
  const txIds = Object.keys(pendingTransactionsToCheck)
124
- const statuses = params.refresh
125
- ? {}
126
- : await verifyRpcPendingTxStatusBatch({
127
- server: this.server,
128
- logger: this.logger,
129
- txIds,
130
- })
136
+ const statuses = await verifyRpcPendingTxStatusBatch({
137
+ server: this.server,
138
+ logger: this.logger,
139
+ txIds,
140
+ })
131
141
 
132
142
  for (const { tx, assetName } of Object.values(pendingTransactionsToCheck)) {
133
- if (params.refresh) {
134
- await updateTx(tx, assetName, { remove: true })
135
- } else {
136
- const txStatus = statuses[tx.txId]
137
- if (txStatus?.status === 'confirmed') {
138
- await updateTx(tx, assetName, { isTxConfirmed: true })
139
- } else if (txStatus?.status === 'dropped') {
143
+ const txStatus = statuses[tx.txId]
144
+ if (txStatus?.status === 'confirmed') {
145
+ await updateTx(tx, assetName, { isTxConfirmed: true })
146
+ } else if (txStatus?.status === 'dropped') {
147
+ if (params.refresh) {
148
+ await updateTx(tx, assetName, { remove: true })
149
+ } else {
140
150
  await updateTx(tx, assetName, { isTxConfirmed: false })
141
151
  }
142
- // status === 'pending' or missing - tx still in mempool, do nothing
152
+ } else if (params.refresh) {
153
+ // No txStatus at all means the whole verification batch failed - evidence
154
+ // about nothing, so preserve unconditionally; the age ceiling only applies
155
+ // to a per-entry 'unknown' verdict (evidence about that one hash).
156
+ if (!txStatus || txStatus.status === 'pending' || !isUnverifiablePendingTxExpired(tx)) {
157
+ // Still in the mempool (or not verifiable yet) but missing from the history
158
+ // server response: re-add it so the refresh overwrite cannot wipe it and
159
+ // break tx-log-based nonce resolution.
160
+ await updateTx(tx, assetName, { preserve: true })
161
+ } else {
162
+ // Unverifiable past the age ceiling: purge so a hash the node keeps
163
+ // erroring on cannot pin nonce derivation forever.
164
+ await updateTx(tx, assetName, { remove: true })
165
+ }
166
+ }
167
+ // non-refresh: 'pending' or unknown - tx still in mempool, do nothing
168
+ }
169
+
170
+ if (params.refresh) {
171
+ // Too recent for a reliable on-chain verdict - always survive the refresh overwrite
172
+ for (const { tx, assetName } of Object.values(recentPendingTransactions)) {
173
+ await updateTx(tx, assetName, { preserve: true })
143
174
  }
144
175
  }
145
176
 
@@ -82,7 +82,7 @@ export const getOptimisticTxLogEffects = async ({
82
82
  maybeTxToReplace && maybeTxToReplace.feeAmount.lt(feeAmount) ? maybeTxToReplace.txId : undefined
83
83
 
84
84
  if (!replacedTxId && maybeTxToReplace) {
85
- console.log('Attempting to replace a transaction using an insufficient fee!')
85
+ console.debug('Attempting to replace a transaction using an insufficient fee!')
86
86
  }
87
87
 
88
88
  // Contains effects for smart-contract initiated token movements.
@@ -4,13 +4,18 @@ import getSenderNonceKey from './get-sender-nonce-key.js'
4
4
  export default function checkPendingTransactions({
5
5
  pendingTransactionsGroupedByAddressAndNonce = {},
6
6
  pendingTransactionsToCheck = {},
7
+ recentPendingTransactions = {},
7
8
  txlist,
8
9
  ourWalletAddress,
9
10
  asset,
10
11
  }) {
11
12
  for (const tx of txlist) {
12
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
13
- delete pendingTransactionsToCheck[tx.txId || tx.hash] // Found this transaction, do not mark it as dropped
13
+ const txId = tx.txId || tx.hash
14
+ // Found this transaction on the server, do not mark it as dropped or re-add it
15
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
16
+ delete pendingTransactionsToCheck[txId]
17
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
18
+ delete recentPendingTransactions[txId]
14
19
 
15
20
  // check if this TX replaces another pending TX
16
21
  const replacedTx =
@@ -29,5 +34,9 @@ export default function checkPendingTransactions({
29
34
  }
30
35
  }
31
36
 
32
- return { pendingTransactionsGroupedByAddressAndNonce, pendingTransactionsToCheck }
37
+ return {
38
+ pendingTransactionsGroupedByAddressAndNonce,
39
+ pendingTransactionsToCheck,
40
+ recentPendingTransactions,
41
+ }
33
42
  }
@@ -10,6 +10,7 @@ const mapToObject = (map) => Object.fromEntries(map.entries()) // only for strin
10
10
  export default function getDeriveTransactionsToCheck({ getTxLog }) {
11
11
  return async ({ assetName: _assetName, walletAccount, tokens, ourWalletAddress }) => {
12
12
  const pendingTransactionsToCheck = new Map()
13
+ const recentPendingTransactions = new Map()
13
14
  const pendingTransactionsGroupedByAddressAndNonce = new Map()
14
15
  const simulatedTransactions = new Map()
15
16
  const now = SynchronizedTime.now()
@@ -19,11 +20,13 @@ export default function getDeriveTransactionsToCheck({ getTxLog }) {
19
20
  for (const tx of txSet) {
20
21
  // ERC20 sends have an entry in ETH and one in the ERC20 log so we just want the ETH one
21
22
  if (
22
- !pendingTransactionsToCheck.has(tx.txId) &&
23
23
  tx.pending &&
24
- now - tx.date.getTime() > UNCONFIRMED_TX_LIMIT
24
+ !pendingTransactionsToCheck.has(tx.txId) &&
25
+ !recentPendingTransactions.has(tx.txId)
25
26
  ) {
26
- pendingTransactionsToCheck.set(tx.txId, { tx, assetName })
27
+ const isRecent = now - tx.date.getTime() <= UNCONFIRMED_TX_LIMIT
28
+ const map = isRecent ? recentPendingTransactions : pendingTransactionsToCheck
29
+ map.set(tx.txId, { tx, assetName })
27
30
  }
28
31
 
29
32
  if (tx.meta.simulated) simulatedTransactions.set(tx.txId, tx)
@@ -45,6 +48,7 @@ export default function getDeriveTransactionsToCheck({ getTxLog }) {
45
48
 
46
49
  return {
47
50
  pendingTransactionsToCheck: mapToObject(pendingTransactionsToCheck),
51
+ recentPendingTransactions: mapToObject(recentPendingTransactions),
48
52
  pendingTransactionsGroupedByAddressAndNonce: mapToObject(
49
53
  pendingTransactionsGroupedByAddressAndNonce
50
54
  ),
@@ -8,5 +8,6 @@ export { default as getDeriveTransactionsToCheck } from './get-derive-transactio
8
8
  export { default as getCurrentEIP7702Delegation } from './get-current-eip7702-delegation.js'
9
9
  export { default as getCurrentBlackListStatus } from './get-current-blacklist-status.js'
10
10
  export { default as verifyRpcPendingTxStatusBatch } from './verify-pending-tx-status-rpc.js'
11
+ export { default as isUnverifiablePendingTxExpired } from './is-unverifiable-pending-tx-expired.js'
11
12
  export { extractBalanceFromClarityAccountInfo } from './extract-balance-from-clarity-account-info.js'
12
13
  export * from './exclude-unchanged-token-balances.js'
@@ -0,0 +1,19 @@
1
+ import { SynchronizedTime } from '@exodus/basic-utils'
2
+ import ms from 'ms'
3
+
4
+ // Upper bound on how long a pending tx with a per-entry 'unknown' verdict
5
+ // (a hash the node persistently errors on) keeps being preserved across
6
+ // refreshes. Without it, "cannot verify this hash" would mean "keep forever"
7
+ // and a phantom tx would pin nonce derivation indefinitely.
8
+ // Positively verified txs ('pending'/'confirmed') are exempt: their lifetime
9
+ // is bounded by the node itself (mempool eviction yields a 'dropped' verdict).
10
+ // A full batch failure is also exempt: it is evidence about nothing, so the
11
+ // monitors preserve unconditionally until the batch path recovers.
12
+ export const UNVERIFIABLE_PENDING_TX_MAX_AGE = ms('1h')
13
+
14
+ export default function isUnverifiablePendingTxExpired(tx) {
15
+ const txTime = tx.date?.getTime()
16
+ // Fail closed: a tx with a missing or invalid date cannot prove it is young
17
+ if (!Number.isFinite(txTime)) return true
18
+ return SynchronizedTime.now() - txTime > UNVERIFIABLE_PENDING_TX_MAX_AGE
19
+ }
@@ -1,10 +1,18 @@
1
1
  /**
2
2
  * Determines the status of an RPC transaction response.
3
3
  *
4
- * @param {Object|null} rpcTx - The RPC transaction response
5
- * @returns {{status: 'confirmed' | 'pending' | 'dropped', blockNumber?: number}}
4
+ * `undefined` means the node returned a JSON-RPC error entry for this hash
5
+ * (tolerant batch parsing) - no verdict, as opposed to `null` which is a
6
+ * definitive "not found".
7
+ *
8
+ * @param {Object|null|undefined} rpcTx - The RPC transaction response
9
+ * @returns {{status: 'confirmed' | 'pending' | 'dropped' | 'unknown', blockNumber?: number}}
6
10
  */
7
11
  function getTxStatus(rpcTx) {
12
+ if (rpcTx === undefined) {
13
+ return { status: 'unknown' }
14
+ }
15
+
8
16
  if (rpcTx?.blockNumber) {
9
17
  return { status: 'confirmed', blockNumber: parseInt(rpcTx.blockNumber, 16) }
10
18
  }
@@ -29,7 +37,7 @@ function getTxStatus(rpcTx) {
29
37
  * @param {Object} params.server - RPC server used to verify pending transactions
30
38
  * @param {Object} params.logger - Logger instance for info/debug output
31
39
  * @param {string[]} params.txIds - Array of transaction IDs/hashes to verify
32
- * @returns {Promise<Object<string, {status: 'confirmed' | 'pending' | 'dropped', blockNumber?: number}>>}
40
+ * @returns {Promise<Object<string, {status: 'confirmed' | 'pending' | 'dropped' | 'unknown', blockNumber?: number}>>}
33
41
  */
34
42
  export default async function verifyRpcPendingTxStatusBatch({ server, logger, txIds }) {
35
43
  if (txIds.length === 0) return {}
@@ -39,27 +47,42 @@ export default async function verifyRpcPendingTxStatusBatch({ server, logger, tx
39
47
 
40
48
  let responses
41
49
  try {
42
- responses = await server.sendBatchRequest(requests)
50
+ // Tolerant parsing: one bad hash must not void the verdicts for every other tx
51
+ responses = await server.sendBatchRequest(requests, { tolerant: true })
43
52
  } catch (error) {
44
53
  // If batch request fails, skip all drop checks (safe default)
45
54
  logger.warn('Batch RPC request failed, skipping drop checks', error)
46
55
  return {}
47
56
  }
48
57
 
58
+ if (!Array.isArray(responses) || responses.length !== txIds.length) {
59
+ // A malformed batch response must not produce 'dropped' verdicts (safe default)
60
+ logger.warn('Batch RPC response shape mismatch, skipping drop checks')
61
+ return {}
62
+ }
63
+
49
64
  // Map responses to statuses
50
- const results = {}
65
+ const results = Object.create(null)
51
66
  txIds.forEach((txId, idx) => {
52
67
  const rpcTx = responses[idx]
53
68
  const txStatus = getTxStatus(rpcTx)
54
69
  results[txId] = txStatus
55
70
 
56
71
  // Log each tx status
57
- if (txStatus.status === 'confirmed') {
58
- logger.info(`tx ${txId} is confirmed on-chain (block ${txStatus.blockNumber}), skipping drop`)
59
- } else if (txStatus.status === 'pending') {
60
- logger.info(`tx ${txId} is still in mempool, skipping drop`)
61
- } else {
62
- logger.info(`tx ${txId} not found on-chain, marking as dropped`)
72
+ switch (txStatus.status) {
73
+ case 'confirmed':
74
+ logger.info(
75
+ `tx ${txId} is confirmed on-chain (block ${txStatus.blockNumber}), skipping drop`
76
+ )
77
+ break
78
+ case 'pending':
79
+ logger.info(`tx ${txId} is still in mempool, skipping drop`)
80
+ break
81
+ case 'unknown':
82
+ logger.warn(`tx ${txId} could not be verified (RPC error entry), skipping drop`)
83
+ break
84
+ default:
85
+ logger.info(`tx ${txId} not found on-chain, marking as dropped`)
63
86
  }
64
87
  })
65
88
 
@@ -47,7 +47,7 @@ export const calculateRewardsFromStakeTx = ({ tx, currency }) => {
47
47
 
48
48
  if (stakeTxContainsReward) {
49
49
  const txAmount = stakedAmount.sub(tx.coinAmount.abs()).abs()
50
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only -- TODO: Fix this the next time the file is edited.
50
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only -- TODO: Fix this the next time the file is edited.
51
51
  return txAmount.toDefaultString()
52
52
  }
53
53
  }
@@ -29,7 +29,7 @@ export const handleBroadcastError = async (
29
29
  isNonceTooLow || errorInfo.reason === EVM_ERROR_REASONS.transactionUnderpriced.reason
30
30
 
31
31
  if (errorInfo.reason === EVM_ERROR_REASONS.alreadyKnown.reason) {
32
- console.info('tx already broadcast')
32
+ console.debug('tx already broadcast')
33
33
  return { shouldRetry: false }
34
34
  }
35
35
 
@@ -44,7 +44,7 @@ export const handleBroadcastError = async (
44
44
  }
45
45
 
46
46
  if (txAlreadyExists) {
47
- console.info('tx already broadcast')
47
+ console.debug('tx already broadcast')
48
48
  return { shouldRetry: false }
49
49
  }
50
50
 
@@ -50,7 +50,7 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
50
50
  })
51
51
 
52
52
  if (shouldRetry) {
53
- console.info('trying to send again...') // inject logger factory from platform
53
+ console.debug('trying to send again...') // inject logger factory from platform
54
54
 
55
55
  // let's try to fix the nonce issue
56
56
  let newNonce = await baseAsset.getNonce({
@@ -73,14 +73,14 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
73
73
  //
74
74
  // TODO: should return an unsignedTx -> i.e. completely replicate the transaction
75
75
  if (unsignedTx.txData.transactionBuffer) {
76
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
76
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
77
77
  unsignedTx.txData.transactionBuffer = updateNonce({
78
78
  chainId: baseAsset.chainId,
79
79
  newNonce,
80
80
  unsignedTx,
81
81
  })
82
82
  } else {
83
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
83
+ // eslint-disable-next-line @exodus/hydra/no-param-reassign-prop-only
84
84
  unsignedTx.txData.nonce = newNonce
85
85
  }
86
86