@exodus/ethereum-api 8.78.2 → 8.79.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/package.json +3 -3
  3. package/src/allowance/index.js +1 -3
  4. package/src/create-asset-utils.js +5 -5
  5. package/src/create-asset.js +8 -7
  6. package/src/create-token-factory.js +2 -0
  7. package/src/ens/index.js +0 -2
  8. package/src/exodus-eth-server/api-coin-nodes.js +7 -2
  9. package/src/exodus-eth-server/api.js +3 -3
  10. package/src/exodus-eth-server/clarity.js +3 -3
  11. package/src/exodus-eth-server/eth-like-server.js +17 -9
  12. package/src/exodus-eth-server/index.js +0 -6
  13. package/src/fee-data/EthLikeFeeData.js +0 -2
  14. package/src/hooks/monitor.js +161 -33
  15. package/src/hooks/staking-freshness.js +65 -0
  16. package/src/index.js +2 -0
  17. package/src/simulation/simulate-message-api.js +1 -1
  18. package/src/simulation/simulate-transactions-api.js +1 -1
  19. package/src/staking/api/index.js +9 -1
  20. package/src/staking/cached-read.js +33 -0
  21. package/src/staking/ethereum/api.js +52 -1
  22. package/src/staking/ethereum/deps.js +5 -1
  23. package/src/staking/ethereum/everstake.js +7 -0
  24. package/src/staking/ethereum/service.js +31 -23
  25. package/src/staking/utils/index.js +0 -2
  26. package/src/tx-log/clarity-monitor.js +59 -23
  27. package/src/tx-log/ethereum-monitor.js +54 -23
  28. package/src/tx-log/get-optimistic-txlog-effects.js +1 -1
  29. package/src/tx-log/monitor-utils/check-pending-transactions.js +12 -3
  30. package/src/tx-log/monitor-utils/get-derive-transactions-to-check.js +7 -3
  31. package/src/tx-log/monitor-utils/get-transfers-by-token-name.js +3 -2
  32. package/src/tx-log/monitor-utils/index.js +1 -0
  33. package/src/tx-log/monitor-utils/is-unverifiable-pending-tx-expired.js +19 -0
  34. package/src/tx-log/monitor-utils/verify-pending-tx-status-rpc.js +34 -11
  35. package/src/tx-log-staking-processor/utils.js +1 -1
  36. package/src/tx-send/broadcast-error-handler.js +2 -2
  37. package/src/tx-send/tx-send.js +3 -3
@@ -0,0 +1,65 @@
1
+ import { isPendingTxInLog } from '@exodus/ethereum-lib'
2
+
3
+ import { EthereumStaking } from '../staking/ethereum/api.js'
4
+ import { MaticStakingApi } from '../staking/matic/api.js'
5
+
6
+ const STAKING_FIELDS = ['delegate', 'undelegate', 'undelegatePending', 'claimUndelegate', 'rewards']
7
+
8
+ // Inspect both logs: Polygon calls without token transfers only live in ETH's
9
+ // log. Any interaction with a staking contract is conservatively relevant.
10
+ export const getStakingActivity = ({ assetName, txLogs }) => {
11
+ const addresses =
12
+ assetName === 'polygon'
13
+ ? [MaticStakingApi.EVERSTAKE_VALIDATOR_CONTRACT_ADDR, MaticStakingApi.STAKING_MANAGER_ADDR]
14
+ : Object.values(EthereumStaking.addresses[assetName] || {})
15
+ const contracts = new Set(addresses.map((address) => address.toLowerCase()))
16
+ const isContract = (address) =>
17
+ typeof address === 'string' && contracts.has(address.toLowerCase())
18
+ const entries = new Map()
19
+ let pending = false
20
+ for (const txs of txLogs) {
21
+ for (const tx of txs) {
22
+ const relevant =
23
+ isContract(tx.to) ||
24
+ (Array.isArray(tx.from) ? tx.from.some(isContract) : isContract(tx.from)) ||
25
+ (tx.coinName === assetName &&
26
+ STAKING_FIELDS.some((field) => tx.data?.[field] !== undefined))
27
+ if (!relevant) continue
28
+ const isPending = isPendingTxInLog(tx)
29
+ pending ||= isPending
30
+ // Do not include increasing confirmation counts or processor-added metadata:
31
+ // neither constitutes a new staking event. Include block identity for reorgs.
32
+ entries.set(`${tx.coinName}:${tx.txId}`, [
33
+ tx.txId,
34
+ isPending,
35
+ tx.confirmations > 0,
36
+ tx.error || null,
37
+ !!tx.dropped,
38
+ tx.data?.replacedBy || null,
39
+ tx.data?.blockHash || tx.blockHash || null,
40
+ tx.data?.blockNumber || tx.blockHeight || null,
41
+ ])
42
+ }
43
+ }
44
+ return {
45
+ pending,
46
+ fingerprint: JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b))),
47
+ }
48
+ }
49
+
50
+ export const stakingStateInterval = (info) => {
51
+ // A pending validator activation/exit is chain state, not a pending local tx.
52
+ // It must never keep a wallet on the one-minute transaction cadence forever.
53
+ const active =
54
+ info.isDelegating ||
55
+ info.isUndelegateInProgress ||
56
+ info.canClaimUndelegatedBalance ||
57
+ [
58
+ 'delegatedBalance',
59
+ 'pendingBalance',
60
+ 'pendingDepositedBalance',
61
+ 'unclaimedUndelegatedBalance',
62
+ 'liquidRewards',
63
+ ].some((key) => info[key]?.isPositive)
64
+ return (active ? 5 : 15) * 60_000
65
+ }
package/src/index.js CHANGED
@@ -42,6 +42,8 @@ export { getHistoricalBalanceFactory } from './get-historical-balance.js'
42
42
  export { createEncodeMultisigContract } from './multisig-address.js'
43
43
 
44
44
  export { FantomStaking, stakingProviderClientFactory } from './staking/index.js'
45
+ export { EthLikeRpcRequestAccumulator } from './multicall3/index.js'
46
+ export { createCachedStakingRead } from './staking/cached-read.js'
45
47
  export { ethereumStakingDeps } from './staking/ethereum/deps.js'
46
48
  export { getEthereumStakingInfo, createEthereumStakingService } from './staking/ethereum/service.js'
47
49
  export { EthereumStaking } from './staking/ethereum/api.js'
@@ -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
@@ -7,6 +7,7 @@ export const stakingApiFactory = ({
7
7
  server, // coin node server
8
8
  stakingConfiguration,
9
9
  stakingDependencies,
10
+ createRpcRequestAccumulator,
10
11
  }) => {
11
12
  assert(assetName, '"assetName" is required')
12
13
  assert(currency, '"currency" is required')
@@ -18,7 +19,14 @@ export const stakingApiFactory = ({
18
19
  const { contracts, minAmount } = stakingConfiguration
19
20
  const { stakingServerFactory, stakingServiceFactory } = stakingDependencies
20
21
 
21
- const stakingServer = stakingServerFactory({ assetName, currency, server, minAmount, contracts })
22
+ const stakingServer = stakingServerFactory({
23
+ assetName,
24
+ currency,
25
+ server,
26
+ minAmount,
27
+ contracts,
28
+ createRpcRequestAccumulator,
29
+ })
22
30
 
23
31
  const stakingService = stakingServiceFactory({
24
32
  assetClientInterface,
@@ -0,0 +1,33 @@
1
+ // Background-only caching. Callers outside the monitor always request fresh data.
2
+ // Keep this state in memory: restored accounts must be discovered again immediately.
3
+ export const createCachedStakingRead = ({
4
+ read,
5
+ interval = 15 * 60_000,
6
+ now = () => performance.now(),
7
+ }) => {
8
+ const entries = new Map()
9
+ return async (key, { useCache = false } = {}) => {
10
+ let entry = entries.get(key)
11
+ if (!entry) {
12
+ entry = { generation: 0 }
13
+ entries.set(key, entry)
14
+ }
15
+ if (useCache) {
16
+ if (entry.pending) return entry.pending
17
+ if (entry.deadline > now()) return entry.value
18
+ }
19
+ const generation = ++entry.generation
20
+ const pending = Promise.resolve().then(() => read(key))
21
+ entry.pending = pending
22
+ try {
23
+ const value = await pending
24
+ if (entry.generation === generation) {
25
+ entry.value = value
26
+ entry.deadline = now() + interval
27
+ }
28
+ return value
29
+ } finally {
30
+ if (entry.generation === generation) entry.pending = undefined
31
+ }
32
+ }
33
+ }
@@ -5,6 +5,8 @@ import { retry } from '@exodus/simple-retry'
5
5
 
6
6
  import { EVM_ERROR_REASONS, withErrorReason } from '../../error-wrapper.js'
7
7
  import { getServerByName } from '../../exodus-eth-server/index.js'
8
+ import { EthLikeRpcRequestAccumulator } from '../../multicall3/index.js'
9
+ import { createCachedStakingRead } from '../cached-read.js'
8
10
 
9
11
  // TODO: Shouldn't this be a function of `ethereumStakingState.minDelegateAmount`?
10
12
  const MIN_AMOUNT = '0.1'
@@ -93,6 +95,45 @@ export class EthereumStaking {
93
95
  })
94
96
  }
95
97
 
98
+ // Do not use Multicall for these reads without verifying the deployed Accounting
99
+ // implementation (currently unverified). JSON-RPC batching preserves msg.sender
100
+ // and isolated eth_call execution, including autocompound simulation semantics.
101
+ async getStakingState(address) {
102
+ const read = () => {
103
+ const requests = new EthLikeRpcRequestAccumulator()
104
+ for (const method of [
105
+ 'autocompoundBalanceOf',
106
+ 'pendingBalanceOf',
107
+ 'pendingDepositedBalanceOf',
108
+ 'withdrawRequest',
109
+ ]) {
110
+ requests.contractStaticCallRequest(this.contractAccounting, method, address)
111
+ }
112
+ return requests.flush(this.server || getServerByName(this.asset.name))
113
+ }
114
+ const [[active], [pending], [deposited], [requested, readyForClaim]] = await withErrorReason({
115
+ promise: retry(read, { delayTimesMs: RETRY_DELAYS })(),
116
+ errorReasonInfo: EVM_ERROR_REASONS.ethCallFailed,
117
+ hint: safeString`stakingEthReadFunctionContract`,
118
+ baseAssetName: this.asset.name,
119
+ })
120
+ const { currency } = this.asset
121
+ return {
122
+ activeStakedBalance: currency.baseUnit(active),
123
+ pendingBalance: currency.baseUnit(pending),
124
+ pendingDepositedBalance: currency.baseUnit(deposited),
125
+ withdrawRequest: {
126
+ requested: currency.baseUnit(requested),
127
+ readyForClaim: currency.baseUnit(readyForClaim),
128
+ canSendClaimRequest: requested !== '0' && requested === readyForClaim,
129
+ },
130
+ }
131
+ }
132
+
133
+ getCachedTotalRewards = createCachedStakingRead({
134
+ read: (address) => this.getTotalRewards(address, { throwOnError: true }),
135
+ })
136
+
96
137
  // === ACCOUNTING ===
97
138
 
98
139
  /** Return total deposited and activated pool balance */
@@ -163,7 +204,7 @@ export class EthereumStaking {
163
204
  return compoundBalance.sub(depositedBalance)
164
205
  }
165
206
 
166
- async getTotalRewards(address) {
207
+ async getTotalRewards(address, { throwOnError = false } = {}) {
167
208
  // use external everstake API to get total rewards
168
209
  const url = new URL(EVERSTAKE_API_URL)
169
210
 
@@ -177,11 +218,21 @@ export class EthereumStaking {
177
218
 
178
219
  try {
179
220
  const response = await fetch(url.toString(), options)
221
+ if (!response.ok) throw new Error('Failed to fetch ETH total rewards')
180
222
  const rewards = await response.json()
181
223
  if (rewards === 'address') return this.asset.currency.ZERO // address not found
224
+ if (
225
+ !(
226
+ (typeof rewards === 'string' && /^\d+$/.test(rewards)) ||
227
+ (Number.isSafeInteger(rewards) && rewards >= 0)
228
+ )
229
+ ) {
230
+ throw new Error('Malformed ETH total rewards')
231
+ }
182
232
  return this.asset.currency.baseUnit(rewards)
183
233
  } catch (error) {
184
234
  console.warn('Error fetching ETH total rewards:', error)
235
+ if (throwOnError) throw error
185
236
  return this.asset.currency.ZERO
186
237
  }
187
238
  }
@@ -14,11 +14,15 @@ export const stakingServiceFactory = ({ assetClientInterface, server, stakingSer
14
14
 
15
15
  return {
16
16
  ...service,
17
- getStakingInfo: ({ address }) =>
17
+ getStakingInfo: ({ address, useCache = false, useQueueCache = useCache, background = false }) =>
18
18
  getEthereumStakingInfo({
19
19
  address,
20
20
  asset: stakingServer.asset,
21
21
  server,
22
+ staking: stakingServer,
23
+ useCache,
24
+ useQueueCache,
25
+ background,
22
26
  }),
23
27
  stake: service.delegate,
24
28
  unstake: service.undelegate,
@@ -2,6 +2,7 @@ import { fetch as exodusFetch } from '@exodus/fetch'
2
2
  import { TraceId } from '@exodus/traceparent'
3
3
  import assert from 'minimalistic-assert'
4
4
 
5
+ import { createCachedStakingRead } from '../cached-read.js'
5
6
  import { UNSTAKE_DEFAULTS } from './api.js'
6
7
 
7
8
  const BASE_URL = 'https://eth-clarity.a.exodus.io/api/v2/ethereum/proxy/everstake/'
@@ -79,6 +80,12 @@ export const simulateEverstakeUnstake = async ({
79
80
  }
80
81
  }
81
82
 
83
+ // The queue is global, not delegator-specific. Share successful background reads
84
+ // across wallets; explicit refreshes bypass both its deadline and in-flight work.
85
+ export const getCachedEverstakeValidatorsQueue = createCachedStakingRead({
86
+ read: () => getEverstakeValidatorsQueue(),
87
+ })
88
+
82
89
  export const getEverstakeValidatorsQueue = async () => {
83
90
  // https://swagger.eth-api-b2c.everstake.one/#/Staking/validatorsQueue
84
91
  const result = await fetch('v1/validators/queue')
@@ -70,7 +70,7 @@ import { createWatchTx as defaultCreateWatch } from '../../watch-tx.js'
70
70
  import { stakingProviderClientFactory } from '../staking-provider-client.js'
71
71
  import { amountToCurrency, DISABLE_BALANCE_CHECKS, resolveFeeData } from '../utils/index.js'
72
72
  import { EthereumStaking } from './api.js'
73
- import { getEverstakeValidatorsQueue } from './everstake.js'
73
+ import { getCachedEverstakeValidatorsQueue } from './everstake.js'
74
74
  import { simulateUndelegateTransactions } from './staking-utils.js'
75
75
 
76
76
  const WETH9_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
@@ -99,7 +99,7 @@ const STAKING_OPERATION_FALLBACK_FEE_ESTIMATES = {
99
99
 
100
100
  const getStakingApi = memoize(
101
101
  (asset) => new EthereumStaking(asset.name, asset.currency, undefined, asset.server),
102
- (asset) => asset.name
102
+ (asset) => asset
103
103
  )
104
104
 
105
105
  export async function getUndelegatePendingData({
@@ -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 })}`
@@ -775,25 +775,29 @@ export function createEthereumStakingService({
775
775
  }
776
776
  }
777
777
 
778
- export async function getEthereumStakingInfo({ address, asset, server }) {
779
- const { name: assetName, currency } = asset
778
+ export async function getEthereumStakingInfo({
779
+ address,
780
+ asset,
781
+ server,
782
+ staking = new EthereumStaking(asset.name, asset.currency, undefined, server),
783
+ useCache = false,
784
+ useQueueCache = useCache,
785
+ background = false,
786
+ }) {
787
+ const { currency } = asset
780
788
  const delegator = address.toLowerCase()
781
- const staking = new EthereumStaking(assetName, currency, undefined, server)
782
-
783
789
  const [
784
- activeStakedBalance,
785
- pendingBalance,
786
- pendingDepositedBalance,
787
- withdrawRequest,
790
+ { activeStakedBalance, pendingBalance, pendingDepositedBalance, withdrawRequest },
788
791
  rewardsBalance,
789
792
  everstakeValidatorsQueue,
790
793
  ] = await Promise.all([
791
- staking.autocompoundBalanceOf(delegator),
792
- staking.pendingBalanceOf(delegator),
793
- staking.pendingDepositedBalanceOf(delegator),
794
- staking.withdrawRequest(delegator),
795
- staking.getTotalRewards(delegator),
796
- getEverstakeValidatorsQueue().catch(() => null),
794
+ staking.getStakingState(delegator),
795
+ // Provider availability must not prevent restoring on-chain stake. Omit
796
+ // failed metadata so the monitor preserves the last successful rewards.
797
+ staking
798
+ .getCachedTotalRewards(delegator, { useCache })
799
+ .catch(() => (background ? null : currency.ZERO)),
800
+ getCachedEverstakeValidatorsQueue('ethereum', { useCache: useQueueCache }).catch(() => null),
797
801
  ])
798
802
 
799
803
  const delegatedBalance = activeStakedBalance.add(pendingBalance).add(pendingDepositedBalance)
@@ -813,7 +817,7 @@ export async function getEthereumStakingInfo({ address, asset, server }) {
813
817
  const unclaimedUndelegatedBalance = requested
814
818
 
815
819
  return {
816
- rewardsBalance,
820
+ ...(rewardsBalance !== null && { rewardsBalance }),
817
821
  isDelegating,
818
822
  activeStakedBalance, // balance staked into the validator
819
823
  delegatedBalance, // balance staked into the validator + pending
@@ -823,8 +827,12 @@ export async function getEthereumStakingInfo({ address, asset, server }) {
823
827
  unclaimedUndelegatedBalance,
824
828
  canClaimUndelegatedBalance,
825
829
  isUndelegateInProgress,
826
- validatorActivationTime: everstakeValidatorsQueue?.validatorActivationTime,
827
- validatorExitTime: everstakeValidatorsQueue?.validatorExitTime,
828
- validatorWithdrawTime: everstakeValidatorsQueue?.validatorWithdrawTime,
830
+ // Do not replace previous queue estimates with undefined on a transient failure.
831
+ ...(!background && {
832
+ validatorActivationTime: undefined,
833
+ validatorExitTime: undefined,
834
+ validatorWithdrawTime: undefined,
835
+ }),
836
+ ...everstakeValidatorsQueue,
829
837
  }
830
838
  }
@@ -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.