@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
@@ -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
  ),
@@ -1,4 +1,5 @@
1
- import lodash from 'lodash'
1
+ import { omit } from '@exodus/basic-utils'
2
+ import lodash from 'lodash' // oxlint-disable-line @exodus/oxlint-plugin-restricted-imports/prefer-basic-utils -- groupBy not in basic-utils
2
3
 
3
4
  // Sorts an array of token transfer events into an object of transfer event arrays,
4
5
  // whose keys are the names of the tokens which are being transferred in the corresponding array.
@@ -12,5 +13,5 @@ export default function getTransfersByTokenName(transfers, tokensByAddress) {
12
13
  transfers,
13
14
  (transfer) => tokensByAddress.get(transfer.address.toLowerCase())?.name || 'UNKNOWN'
14
15
  )
15
- return lodash.omit(transfersByTokenName, 'UNKNOWN')
16
+ return omit(transfersByTokenName, ['UNKNOWN'])
16
17
  }
@@ -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