@exodus/ethereum-api 9.0.0-alpha.3 → 9.0.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 (143) hide show
  1. package/CHANGELOG.md +2139 -0
  2. package/LICENSE +7 -0
  3. package/README.md +124 -0
  4. package/package.json +34 -33
  5. package/src/address-has-history.js +6 -2
  6. package/src/allowance/constants.js +4 -5
  7. package/src/allowance/index.js +159 -40
  8. package/src/check-tx/create-assess-transaction.js +23 -0
  9. package/src/check-tx/create-check-tx.js +104 -0
  10. package/src/check-tx/index.js +1 -0
  11. package/src/create-asset-plugin-factory.js +115 -0
  12. package/src/create-asset-utils.js +520 -0
  13. package/src/create-asset.js +273 -97
  14. package/src/create-token-factory.js +47 -17
  15. package/src/custom-fees.js +57 -0
  16. package/src/eip7702-whitelist.js +62 -0
  17. package/src/ens/index.js +12 -17
  18. package/src/error-wrapper.js +383 -0
  19. package/src/eth-like-util.js +172 -33
  20. package/src/exodus-eth-server/api-coin-nodes.js +27 -196
  21. package/src/exodus-eth-server/api.js +23 -14
  22. package/src/exodus-eth-server/clarity-v2.js +255 -0
  23. package/src/exodus-eth-server/clarity.js +67 -265
  24. package/src/exodus-eth-server/errors.js +40 -0
  25. package/src/exodus-eth-server/eth-like-server.js +385 -0
  26. package/src/exodus-eth-server/fetch-json.js +48 -0
  27. package/src/exodus-eth-server/index.js +77 -0
  28. package/src/exodus-eth-server/utils.js +31 -0
  29. package/src/exodus-eth-server/ws-gateway.js +306 -0
  30. package/src/exodus-eth-server/ws.js +1 -2
  31. package/src/fee-data/EthLikeFeeData.js +81 -0
  32. package/src/fee-data/index.js +54 -0
  33. package/src/fee-utils.js +229 -0
  34. package/src/gas-estimation.js +183 -66
  35. package/src/get-balance-for-address.js +1 -1
  36. package/src/get-balances.js +285 -27
  37. package/src/get-fee.js +92 -56
  38. package/src/get-historical-balance.js +58 -0
  39. package/src/hooks/index.js +1 -1
  40. package/src/hooks/monitor.js +182 -43
  41. package/src/hooks/staking-freshness.js +58 -0
  42. package/src/index.js +118 -12
  43. package/src/move-funds.js +153 -0
  44. package/src/multicall3/index.js +212 -0
  45. package/src/multisig-address.js +146 -0
  46. package/src/nft-utils.js +60 -0
  47. package/src/number-utils.js +34 -2
  48. package/src/optimism-gas/index.js +13 -5
  49. package/src/send-validations.js +58 -0
  50. package/src/server-based-fee-monitor.js +12 -19
  51. package/src/simulate-tx/fetch-tx-preview.js +0 -2
  52. package/src/simulate-tx/index.js +2 -2
  53. package/src/simulate-tx/simulate-eth-tx.js +24 -11
  54. package/src/simulation/common.js +34 -0
  55. package/src/simulation/create-simulate-message.js +49 -0
  56. package/src/simulation/create-simulate-transactions.js +108 -0
  57. package/src/simulation/estimate-fee.js +14 -0
  58. package/src/simulation/estimate-simple-transfer.js +15 -0
  59. package/src/simulation/get-message-type.js +18 -0
  60. package/src/simulation/simulate-message-api.js +68 -0
  61. package/src/simulation/simulate-transactions-api.js +266 -0
  62. package/src/simulation/simulate-transactions.js +16 -0
  63. package/src/simulation/transactions.js +52 -0
  64. package/src/simulation/try-estimating-changes-locally.js +26 -0
  65. package/src/staking/api/index.js +43 -0
  66. package/src/staking/cached-read.js +33 -0
  67. package/src/staking/ethereum/api.js +166 -34
  68. package/src/staking/ethereum/deps.js +37 -0
  69. package/src/staking/ethereum/everstake.js +113 -0
  70. package/src/staking/ethereum/index.js +4 -3
  71. package/src/staking/ethereum/service.js +682 -181
  72. package/src/staking/ethereum/staking-utils.js +275 -4
  73. package/src/staking/fantom-staking.js +9 -9
  74. package/src/staking/index.js +4 -4
  75. package/src/staking/matic/addresses.js +2 -0
  76. package/src/staking/matic/api.js +69 -49
  77. package/src/staking/matic/index.js +3 -3
  78. package/src/staking/matic/matic-staking-utils.js +243 -5
  79. package/src/staking/matic/matic-staking.md +53 -0
  80. package/src/staking/staking-provider-client.js +1 -1
  81. package/src/staking/utils/index.js +39 -0
  82. package/src/tx-create.js +666 -0
  83. package/src/tx-log/clarity-monitor.js +276 -103
  84. package/src/tx-log/clarity-truncated-history-monitor.js +130 -0
  85. package/src/tx-log/clarity-truncated-history-monitor.md +219 -0
  86. package/src/tx-log/clarity-utils/absolute.js +96 -0
  87. package/src/tx-log/clarity-utils/get-log-items-from-server-tx.js +94 -31
  88. package/src/tx-log/clarity-utils/get-names-of-tokens-transferred-by-server-tx.js +3 -2
  89. package/src/tx-log/clarity-utils/index.js +9 -3
  90. package/src/tx-log/clarity-utils/is-spam-pending-tx.js +13 -0
  91. package/src/tx-log/clarity-utils/normalize-transactions-response.js +65 -0
  92. package/src/tx-log/ethereum-monitor.js +93 -47
  93. package/src/tx-log/ethereum-no-history-monitor.js +52 -41
  94. package/src/tx-log/get-optimistic-txlog-effects.js +379 -0
  95. package/src/tx-log/index.js +5 -3
  96. package/src/tx-log/monitor-utils/check-pending-transactions.js +14 -5
  97. package/src/tx-log/monitor-utils/exclude-unchanged-token-balances.js +1 -1
  98. package/src/tx-log/monitor-utils/extract-balance-from-clarity-account-info.js +108 -0
  99. package/src/tx-log/monitor-utils/get-balance-updates.js +44 -0
  100. package/src/tx-log/monitor-utils/get-batched-rpc-balances.js +47 -0
  101. package/src/tx-log/monitor-utils/get-current-blacklist-status.js +38 -0
  102. package/src/tx-log/monitor-utils/get-current-eip7702-delegation.js +79 -0
  103. package/src/tx-log/monitor-utils/get-derive-transactions-to-check.js +10 -5
  104. package/src/tx-log/monitor-utils/get-fee-amount.js +1 -1
  105. package/src/tx-log/monitor-utils/get-history-from-server.js +1 -1
  106. package/src/tx-log/monitor-utils/get-log-items-from-server-tx.js +75 -33
  107. package/src/tx-log/monitor-utils/get-names-of-tokens-transferred-by-server-tx.js +3 -2
  108. package/src/tx-log/monitor-utils/get-raw-transfer-amount.js +29 -0
  109. package/src/tx-log/monitor-utils/get-transfers-by-token-name.js +3 -2
  110. package/src/tx-log/monitor-utils/get-value-of-transfers.js +10 -12
  111. package/src/tx-log/monitor-utils/index.js +12 -7
  112. package/src/tx-log/monitor-utils/is-unverifiable-pending-tx-expired.js +19 -0
  113. package/src/tx-log/monitor-utils/verify-pending-tx-status-rpc.js +90 -0
  114. package/src/tx-log-staking-processor/asset-staking-tx-data.js +82 -24
  115. package/src/tx-log-staking-processor/index.js +28 -14
  116. package/src/tx-log-staking-processor/utils.js +21 -20
  117. package/src/tx-send/broadcast-error-handler.js +63 -0
  118. package/src/tx-send/index.js +1 -2
  119. package/src/tx-send/nonce-utils.js +97 -0
  120. package/src/tx-send/tx-send.js +94 -288
  121. package/src/tx-sign/index.js +17 -0
  122. package/src/tx-type/index.js +280 -0
  123. package/src/watch-tx.js +36 -0
  124. package/src/web3/createSimulateMessage.js +3 -2
  125. package/src/web3/createSimulateTransactions.js +11 -0
  126. package/src/web3/index.js +3 -1
  127. package/src/etherscan/account.js +0 -49
  128. package/src/etherscan/index.js +0 -28
  129. package/src/etherscan/logs.js +0 -16
  130. package/src/etherscan/proxy.js +0 -47
  131. package/src/etherscan/request.js +0 -26
  132. package/src/etherscan/ws.js +0 -88
  133. package/src/exodus-eth-server/server-factory.js +0 -28
  134. package/src/exodus-eth-server/server-map.js +0 -21
  135. package/src/get-fee-async.js +0 -97
  136. package/src/staking/matic/service.js +0 -437
  137. package/src/staking-api.js +0 -5
  138. package/src/tx-log/clarity-utils/get-derive-data-needed-for-tick.js +0 -12
  139. package/src/tx-log-staking-processor/get-asset-tx-amount.js +0 -26
  140. package/src/tx-send/get-fee-info.js +0 -41
  141. package/src/websocket/index.android.js +0 -2
  142. package/src/websocket/index.ios.js +0 -2
  143. package/src/websocket/index.js +0 -21
@@ -0,0 +1,79 @@
1
+ import { getEIP7702Delegation } from '../../eth-like-util.js'
2
+
3
+ const NOT_DELEGATED = {
4
+ isDelegated: false,
5
+ delegatedAddress: null,
6
+ delegatedName: null,
7
+ isWhitelisted: null,
8
+ }
9
+
10
+ function hasDelegationChanged(currentDelegation, newDelegation) {
11
+ return (
12
+ currentDelegation?.isDelegated !== newDelegation.isDelegated ||
13
+ currentDelegation?.delegatedAddress !== newDelegation.delegatedAddress ||
14
+ currentDelegation?.delegatedName !== newDelegation.delegatedName ||
15
+ currentDelegation?.isWhitelisted !== newDelegation.isWhitelisted
16
+ )
17
+ }
18
+
19
+ /**
20
+ * Checks if the address has an EIP-7702 delegation and returns the delegation state.
21
+ * Returns the new state if changed, or undefined if unchanged (monitors treat undefined as "no write").
22
+ * On error, conservatively returns NOT_DELEGATED to avoid showing stale isDelegated: true,
23
+ * but only if the current state actually needs clearing.
24
+ *
25
+ * @param {Object} params
26
+ * @param {Object} params.server - The server instance to use for getCode
27
+ * @param {string} params.address - The wallet address to check
28
+ * @param {Array<{address: string, name: string}>} [params.eip7702Supported] - Whitelist of trusted delegation targets.
29
+ * If not an array, the check is skipped entirely (chain does not support EIP-7702).
30
+ * An empty array means the check runs but every delegation will be isWhitelisted: false.
31
+ * @param {Object} [params.currentDelegation] - The current delegation state from accountState
32
+ * @param {Object} [params.logger] - Optional logger for warnings
33
+ * @returns {Promise<Object|undefined>} The new delegation state, or undefined if unchanged
34
+ */
35
+ export async function getCurrentEIP7702Delegation({
36
+ server,
37
+ address,
38
+ eip7702Supported,
39
+ currentDelegation,
40
+ logger,
41
+ }) {
42
+ // Non-array (undefined, false, etc.) → chain doesn't support EIP-7702, skip entirely
43
+ if (!Array.isArray(eip7702Supported)) {
44
+ return hasDelegationChanged(currentDelegation, NOT_DELEGATED) ? NOT_DELEGATED : undefined
45
+ }
46
+
47
+ try {
48
+ const result = await getEIP7702Delegation({ address, server })
49
+
50
+ if (!result.isDelegated) {
51
+ return hasDelegationChanged(currentDelegation, NOT_DELEGATED) ? NOT_DELEGATED : undefined
52
+ }
53
+
54
+ // [] → check runs but nothing is trusted; populated array → whitelist check
55
+ const matchedDelegation = eip7702Supported.find(
56
+ ({ address }) => address.toLowerCase() === result.delegatedAddress.toLowerCase()
57
+ )
58
+ const isWhitelisted = Boolean(matchedDelegation)
59
+
60
+ const newDelegation = {
61
+ isDelegated: true,
62
+ delegatedAddress: result.delegatedAddress,
63
+ delegatedName: matchedDelegation?.name ?? null,
64
+ isWhitelisted,
65
+ }
66
+
67
+ return hasDelegationChanged(currentDelegation, newDelegation) ? newDelegation : undefined
68
+ } catch (error) {
69
+ if (logger) {
70
+ logger.warn('Failed to check EIP-7702 delegation:', error)
71
+ }
72
+
73
+ // On error, conservatively clear delegation state — only hard RPC confirmation
74
+ // should result in isDelegated: true being shown to the user.
75
+ return hasDelegationChanged(currentDelegation, NOT_DELEGATED) ? NOT_DELEGATED : undefined
76
+ }
77
+ }
78
+
79
+ export default getCurrentEIP7702Delegation
@@ -1,6 +1,7 @@
1
- import ms from 'ms'
2
- import getSenderNonceKey from './get-sender-nonce-key'
3
1
  import { SynchronizedTime } from '@exodus/basic-utils'
2
+ import ms from 'ms'
3
+
4
+ import getSenderNonceKey from './get-sender-nonce-key.js'
4
5
 
5
6
  export const UNCONFIRMED_TX_LIMIT = ms('5m')
6
7
 
@@ -9,6 +10,7 @@ const mapToObject = (map) => Object.fromEntries(map.entries()) // only for strin
9
10
  export default function getDeriveTransactionsToCheck({ getTxLog }) {
10
11
  return async ({ assetName: _assetName, walletAccount, tokens, ourWalletAddress }) => {
11
12
  const pendingTransactionsToCheck = new Map()
13
+ const recentPendingTransactions = new Map()
12
14
  const pendingTransactionsGroupedByAddressAndNonce = new Map()
13
15
  const simulatedTransactions = new Map()
14
16
  const now = SynchronizedTime.now()
@@ -18,11 +20,13 @@ export default function getDeriveTransactionsToCheck({ getTxLog }) {
18
20
  for (const tx of txSet) {
19
21
  // ERC20 sends have an entry in ETH and one in the ERC20 log so we just want the ETH one
20
22
  if (
21
- !pendingTransactionsToCheck.has(tx.txId) &&
22
23
  tx.pending &&
23
- now - tx.date.getTime() > UNCONFIRMED_TX_LIMIT
24
+ !pendingTransactionsToCheck.has(tx.txId) &&
25
+ !recentPendingTransactions.has(tx.txId)
24
26
  ) {
25
- 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 })
26
30
  }
27
31
 
28
32
  if (tx.meta.simulated) simulatedTransactions.set(tx.txId, tx)
@@ -44,6 +48,7 @@ export default function getDeriveTransactionsToCheck({ getTxLog }) {
44
48
 
45
49
  return {
46
50
  pendingTransactionsToCheck: mapToObject(pendingTransactionsToCheck),
51
+ recentPendingTransactions: mapToObject(recentPendingTransactions),
47
52
  pendingTransactionsGroupedByAddressAndNonce: mapToObject(
48
53
  pendingTransactionsGroupedByAddressAndNonce
49
54
  ),
@@ -10,5 +10,5 @@ export default function getFeeAmount(asset, serverTx) {
10
10
  return asset.currency
11
11
  .baseUnit(gasUsed || gasLimit)
12
12
  .mul(gasPrice)
13
- .toDefault()
13
+ .add(asset.currency.baseUnit(serverTx.extraData?.l1Fee || 0))
14
14
  }
@@ -1,4 +1,4 @@
1
- import isConfirmedServerTx from './is-confirmed-server-tx'
1
+ import isConfirmedServerTx from './is-confirmed-server-tx.js'
2
2
 
3
3
  // Fetches JSON transaction history from the given Ethereum server
4
4
  // object (from @exodus/ethereum-api). Starts fetching from the given
@@ -1,14 +1,38 @@
1
- import getValueOfTransfers from './get-value-of-transfers'
2
- import getTransfersByTokenName from './get-transfers-by-token-name'
3
- import getNamesOfTokensTransferredByServerTx from './get-names-of-tokens-transferred-by-server-tx'
4
- import getFeeAmount from './get-fee-amount'
5
- import isConsideredSentTokenTx from './is-considered-sent-token-tx'
6
- import isConfirmedServerTx from './is-confirmed-server-tx'
7
1
  import lodash from 'lodash'
8
2
 
3
+ import getFeeAmount from './get-fee-amount.js'
4
+ import getNamesOfTokensTransferredByServerTx from './get-names-of-tokens-transferred-by-server-tx.js'
5
+ import getRawTransferAmount from './get-raw-transfer-amount.js'
6
+ import getTransfersByTokenName from './get-transfers-by-token-name.js'
7
+ import getValueOfTransfers from './get-value-of-transfers.js'
8
+ import isConfirmedServerTx from './is-confirmed-server-tx.js'
9
+ import isConsideredSentTokenTx from './is-considered-sent-token-tx.js'
10
+
9
11
  // This function takes a server transaction object fetched from magnifier,
10
12
  // and transforms it into Tx models to update the exodus state.
11
13
 
14
+ const tryFindExternalRecipient = (transfers, ourWalletAddress) =>
15
+ transfers.find(({ to }) => to !== ourWalletAddress)?.to || ourWalletAddress
16
+
17
+ function isSelfSendTx({
18
+ coinAmount,
19
+ ourWalletWasSender,
20
+ sendingTransferPresent,
21
+ receivingTransferPresent,
22
+ }) {
23
+ return (
24
+ coinAmount.isZero && sendingTransferPresent && receivingTransferPresent && ourWalletWasSender
25
+ )
26
+ }
27
+
28
+ function isNftTransfer({ serverTx, ourWalletAddress }) {
29
+ if (!Array.isArray(serverTx.erc721) || serverTx.erc721.length === 0) return false
30
+
31
+ return serverTx.erc721.some(
32
+ (transfer) => transfer.to === ourWalletAddress || transfer.from === ourWalletAddress
33
+ )
34
+ }
35
+
12
36
  export default function getLogItemsFromServerTx({
13
37
  serverTx,
14
38
  asset,
@@ -49,12 +73,36 @@ export default function getLogItemsFromServerTx({
49
73
 
50
74
  if (sendingTransferPresent || receivingTransferPresent || nftTransferPresent) {
51
75
  const coinAmount = getValueOfTransfers(ourWalletAddress, asset, ethereumTransfers)
76
+ // `rawCoinAmount` mirrors the field written by `getOptimisticTxLogEffects`
77
+ // and is consumed by the bump path (`tx-create.js`) to recover the
78
+ // user-signed on-chain `value` when accelerating a self-send (where
79
+ // `coinAmount` is zero). For the base-asset entry the raw amount is the
80
+ // top-level `value` field of the tx itself, not the sum of internal
81
+ // transfers — that's what the user signed and what we must restore on
82
+ // bump.
83
+ const rawCoinAmount = getRawTransferAmount({
84
+ ourWalletAddress,
85
+ asset,
86
+ transfers: [serverTx],
87
+ }).toBaseString()
52
88
  const selfSend = isSelfSendTx({
53
89
  coinAmount,
54
90
  ourWalletWasSender,
55
91
  sendingTransferPresent,
56
92
  receivingTransferPresent,
57
93
  })
94
+ let sent
95
+ if (sendingTransferPresent) {
96
+ sent = serverTx.internal
97
+ .map(({ from, to, value }) => {
98
+ if (from === ourWalletAddress) {
99
+ const amount = asset.currency.baseUnit(value).toDefaultString({ unit: true })
100
+ return { address: to, amount }
101
+ }
102
+ })
103
+ .filter(Boolean)
104
+ }
105
+
58
106
  logItemsForServerTxEntries.push([
59
107
  asset.name,
60
108
  {
@@ -65,7 +113,9 @@ export default function getLogItemsFromServerTx({
65
113
  data,
66
114
  nonce,
67
115
  gasLimit,
116
+ rawCoinAmount,
68
117
  ...methodId,
118
+ ...(sent?.length > 0 ? { sent } : undefined),
69
119
  },
70
120
  ...(ourWalletWasSender
71
121
  ? { from: [], to: toAddress, feeAmount, feeCoinName: asset.feeAsset.name }
@@ -94,11 +144,25 @@ export default function getLogItemsFromServerTx({
94
144
 
95
145
  const token = assets[tokenName]
96
146
  const tokenTransferToAddress = tryFindExternalRecipient(tokenTransfers, ourWalletAddress)
97
- const coinAmount = getValueOfTransfers(
147
+ // Transfers from this reader's server source are tagged with
148
+ // `events: true` once the tx is included in a block (event-log derived)
149
+ // and `events: false` while still pending (tx-data derived). Only one
150
+ // flavor is real for a given lifecycle state; filtering avoids
151
+ // double-counting if both ever coexist for the same transfer. Both
152
+ // `coinAmount` and `rawCoinAmount` must see the same set so they stay
153
+ // consistent.
154
+ const filteredTokenTransfers = lodash.filter(tokenTransfers, {
155
+ events: confirmations > 0,
156
+ })
157
+ const coinAmount = getValueOfTransfers(ourWalletAddress, token, filteredTokenTransfers)
158
+ // See base-entry comment above; for token entries the raw amount is the
159
+ // absolute total tokens our wallet moved in this tx (sum of outgoing, or
160
+ // incoming for receive-only entries).
161
+ const rawCoinAmount = getRawTransferAmount({
98
162
  ourWalletAddress,
99
- token,
100
- lodash.filter(tokenTransfers, { events: confirmations > 0 })
101
- )
163
+ asset: token,
164
+ transfers: filteredTokenTransfers,
165
+ }).toBaseString()
102
166
  const tokenFromAddresses = lodash.uniq(
103
167
  tokenTransfers.filter(({ to }) => to === ourWalletAddress).map(({ from }) => from)
104
168
  )
@@ -122,7 +186,7 @@ export default function getLogItemsFromServerTx({
122
186
  ...logItemCommonProperties,
123
187
  coinAmount,
124
188
  coinName: tokenName,
125
- data: { data, nonce, gasLimit, ...methodId },
189
+ data: { data, nonce, gasLimit, rawCoinAmount, ...methodId },
126
190
  ...(isConsideredSent
127
191
  ? { from: [], to: tokenTransferToAddress, feeAmount, feeCoinName: asset.feeAsset.name }
128
192
  : { from: tokenFromAddresses }),
@@ -137,25 +201,3 @@ export default function getLogItemsFromServerTx({
137
201
 
138
202
  return Object.fromEntries(logItemsForServerTxEntries)
139
203
  }
140
-
141
- const tryFindExternalRecipient = (transfers, ourWalletAddress) =>
142
- transfers.find(({ to }) => to !== ourWalletAddress)?.to || ourWalletAddress
143
-
144
- function isSelfSendTx({
145
- coinAmount,
146
- ourWalletWasSender,
147
- sendingTransferPresent,
148
- receivingTransferPresent,
149
- }) {
150
- return (
151
- coinAmount.isZero && sendingTransferPresent && receivingTransferPresent && ourWalletWasSender
152
- )
153
- }
154
-
155
- function isNftTransfer({ serverTx, ourWalletAddress }) {
156
- if (!Array.isArray(serverTx.erc721) || serverTx.erc721.length === 0) return false
157
-
158
- return serverTx.erc721.some(
159
- (transfer) => transfer.to === ourWalletAddress || transfer.from === ourWalletAddress
160
- )
161
- }
@@ -1,6 +1,7 @@
1
- import lodash from 'lodash'
2
- import getValueOfTransfers from './get-value-of-transfers'
3
1
  import { FEE_PAYMENT_PREFIX } from '@exodus/ethereum-lib'
2
+ import lodash from 'lodash'
3
+
4
+ import getValueOfTransfers from './get-value-of-transfers.js'
4
5
 
5
6
  // For ETH transactions, we store an array of token names inside
6
7
  // the TX model. This array is used to display text in the UI for
@@ -0,0 +1,29 @@
1
+ // Returns the absolute raw transferred amount for `ourWalletAddress`, in
2
+ // `asset` base units, as a NumberUnit. Unlike `getValueOfTransfers` (which
3
+ // returns the signed net delta), this preserves the raw transfer value even
4
+ // when sender and receiver net to zero — e.g. self-sends. Used by tx-log
5
+ // writers to persist `data.rawCoinAmount` so the bump path can reconstruct
6
+ // the original on-chain value when accelerating a self-send.
7
+ //
8
+ // Each transfer is expected to have the shape `{ from, to, value }`. For the
9
+ // base-asset entry, pass `[serverTx]` (the tx itself has the same shape at
10
+ // its top level); for token entries, pass the array of token transfers for
11
+ // that token.
12
+ //
13
+ // Outgoing transfers (from === ourWalletAddress) take precedence: when our
14
+ // wallet sent anything, the raw amount is the sum of what we sent. We fall
15
+ // back to incoming so receiver-only entries still report a non-zero raw
16
+ // amount for consistency, even though the bump path only consults it on the
17
+ // sender side.
18
+
19
+ export default function getRawTransferAmount({ ourWalletAddress, asset, transfers }) {
20
+ const outgoing = transfers
21
+ .filter(({ from }) => from === ourWalletAddress)
22
+ .reduce((sum, { value }) => sum.add(asset.currency.baseUnit(value)), asset.currency.ZERO)
23
+
24
+ if (!outgoing.isZero) return outgoing
25
+
26
+ return transfers
27
+ .filter(({ to }) => to === ourWalletAddress)
28
+ .reduce((sum, { value }) => sum.add(asset.currency.baseUnit(value)), asset.currency.ZERO)
29
+ }
@@ -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
  }
@@ -4,19 +4,17 @@
4
4
  // array is a transfer for that asset. If we sent more than we received, the value is negative.
5
5
 
6
6
  export default function getValueOfTransfers(ourWalletAddress, asset, transfers) {
7
- return transfers
8
- .reduce((balanceDifference, { from, to, value }) => {
9
- const transferAmount = asset.currency.baseUnit(value)
7
+ return transfers.reduce((balanceDifference, { from, to, value }) => {
8
+ const transferAmount = asset.currency.baseUnit(value)
10
9
 
11
- if (from === ourWalletAddress) {
12
- balanceDifference = balanceDifference.sub(transferAmount)
13
- }
10
+ if (from === ourWalletAddress) {
11
+ balanceDifference = balanceDifference.sub(transferAmount)
12
+ }
14
13
 
15
- if (to === ourWalletAddress) {
16
- balanceDifference = balanceDifference.add(transferAmount)
17
- }
14
+ if (to === ourWalletAddress) {
15
+ balanceDifference = balanceDifference.add(transferAmount)
16
+ }
18
17
 
19
- return balanceDifference
20
- }, asset.currency.ZERO)
21
- .to(asset.ticker)
18
+ return balanceDifference
19
+ }, asset.currency.ZERO)
22
20
  }
@@ -1,8 +1,13 @@
1
- export { default as getDeriveDataNeededForTick } from './get-derive-data-needed-for-tick'
1
+ export { default as getDeriveDataNeededForTick } from './get-derive-data-needed-for-tick.js'
2
2
 
3
- export { default as getAllLogItemsByAsset } from './get-all-log-items-by-asset'
4
- export { default as getLogItemsFromServerTx } from './get-log-items-from-server-tx'
5
- export { default as getHistoryFromServer } from './get-history-from-server'
6
- export { default as checkPendingTransactions } from './check-pending-transactions'
7
- export { default as getDeriveTransactionsToCheck } from './get-derive-transactions-to-check'
8
- export * from './exclude-unchanged-token-balances'
3
+ export { default as getAllLogItemsByAsset } from './get-all-log-items-by-asset.js'
4
+ export { default as getLogItemsFromServerTx } from './get-log-items-from-server-tx.js'
5
+ export { default as getHistoryFromServer } from './get-history-from-server.js'
6
+ export { default as checkPendingTransactions } from './check-pending-transactions.js'
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 getCurrentBlackListStatus } from './get-current-blacklist-status.js'
10
+ export { default as verifyRpcPendingTxStatusBatch } from './verify-pending-tx-status-rpc.js'
11
+ export { default as isUnverifiablePendingTxExpired } from './is-unverifiable-pending-tx-expired.js'
12
+ export { extractBalanceFromClarityAccountInfo } from './extract-balance-from-clarity-account-info.js'
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
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Determines the status of an RPC transaction response.
3
+ *
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}}
10
+ */
11
+ function getTxStatus(rpcTx) {
12
+ if (rpcTx === undefined) {
13
+ return { status: 'unknown' }
14
+ }
15
+
16
+ if (rpcTx?.blockNumber) {
17
+ return { status: 'confirmed', blockNumber: parseInt(rpcTx.blockNumber, 16) }
18
+ }
19
+
20
+ if (rpcTx) {
21
+ return { status: 'pending' }
22
+ }
23
+
24
+ return { status: 'dropped' }
25
+ }
26
+
27
+ /**
28
+ * Batch verify pending transactions against the RPC node before marking them as dropped.
29
+ *
30
+ * Any lag in the Clarity indexer can cause confirmed transactions to be incorrectly
31
+ * flagged as dropped if Clarity hasn't indexed their block yet.
32
+ * By checking the RPC directly, we avoid this race condition.
33
+ *
34
+ * Uses a single batch RPC call for efficiency and graceful error handling.
35
+ *
36
+ * @param {Object} params
37
+ * @param {Object} params.server - RPC server used to verify pending transactions
38
+ * @param {Object} params.logger - Logger instance for info/debug output
39
+ * @param {string[]} params.txIds - Array of transaction IDs/hashes to verify
40
+ * @returns {Promise<Object<string, {status: 'confirmed' | 'pending' | 'dropped' | 'unknown', blockNumber?: number}>>}
41
+ */
42
+ export default async function verifyRpcPendingTxStatusBatch({ server, logger, txIds }) {
43
+ if (txIds.length === 0) return {}
44
+
45
+ // Build batch request
46
+ const requests = txIds.map((txId) => server.getTransactionByHashRequest(txId))
47
+
48
+ let responses
49
+ try {
50
+ // Tolerant parsing: one bad hash must not void the verdicts for every other tx
51
+ responses = await server.sendBatchRequest(requests, { tolerant: true })
52
+ } catch (error) {
53
+ // If batch request fails, skip all drop checks (safe default)
54
+ logger.warn('Batch RPC request failed, skipping drop checks', error)
55
+ return {}
56
+ }
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
+
64
+ // Map responses to statuses
65
+ const results = Object.create(null)
66
+ txIds.forEach((txId, idx) => {
67
+ const rpcTx = responses[idx]
68
+ const txStatus = getTxStatus(rpcTx)
69
+ results[txId] = txStatus
70
+
71
+ // Log each tx status
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`)
86
+ }
87
+ })
88
+
89
+ return results
90
+ }
@@ -3,57 +3,115 @@ import {
3
3
  isEthereumDelegate,
4
4
  isEthereumUndelegate,
5
5
  isEthereumUndelegatePending,
6
- } from '../staking/ethereum/staking-utils'
7
-
8
- import { isPolygonClaimUndelegate, isPolygonDelegate } from '../staking/matic'
9
-
10
- import { decodePolygonStakingTxInputAmount } from './utils.js'
6
+ } from '../staking/ethereum/staking-utils.js'
7
+ import {
8
+ isPolygonClaimUndelegate,
9
+ isPolygonDelegate,
10
+ isPolygonUndelegate,
11
+ } from '../staking/matic/index.js'
12
+ import {
13
+ calculateRewardsFromStakeTx,
14
+ decodeEthLikeStakingTxInputAmount,
15
+ decodePolygonStakingTxInputAmount,
16
+ } from './utils.js'
11
17
 
12
- const getEthereumStakeTxType = ({ tx }) => {
18
+ const getEthereumStakingTxData = ({ tx, currency }) => {
13
19
  if (
14
20
  ['delegate', 'undelegatePending', 'undelegate', 'claimUndelegate'].some(
15
21
  (stakeTx) => tx.data?.[stakeTx]
16
- )
17
- )
22
+ ) &&
23
+ tx.coinAmount.isZero
24
+ ) {
18
25
  return
26
+ }
27
+
28
+ const txAmount = tx.coinAmount.toDefaultString()
19
29
 
20
30
  if (isEthereumDelegate(tx)) {
21
- return { delegate: tx.coinAmount.toDefaultString() }
31
+ return {
32
+ coinAmount: currency.ZERO,
33
+ data: { delegate: txAmount, txAmount },
34
+ }
22
35
  }
23
36
 
24
37
  // undelegate must be taken in consideration, if unstaked ETH is still
25
38
  // in the pool queue, undelgate transfers staked funds back inmediatly to the user
26
- if (isEthereumUndelegatePending(tx))
39
+ if (isEthereumUndelegatePending(tx)) {
40
+ const undelegatePending = currency
41
+ .baseUnit(decodeEthLikeStakingTxInputAmount(tx))
42
+ .toDefaultString()
27
43
  return {
28
- undelegatePending: tx.coinAmount.toDefaultString(),
44
+ coinAmount: currency.ZERO,
45
+ data: { undelegatePending, txAmount },
29
46
  }
47
+ }
30
48
 
31
49
  if (isEthereumUndelegate(tx)) {
32
- return { undelegate: tx.coinAmount.toDefaultString() }
50
+ const undelegate = currency.baseUnit(decodeEthLikeStakingTxInputAmount(tx)).toDefaultString()
51
+ return {
52
+ coinAmount: currency.ZERO,
53
+ data: { undelegate, txAmount },
54
+ }
33
55
  }
34
56
 
35
57
  // In the case of the ETH being actually staked and earning,
36
58
  // unstake has a withdraw period, after that, unstaked can be claimed.
37
59
  if (isEthereumClaimUndelegate(tx)) {
38
- return { claimUndelegate: tx.coinAmount.toDefaultString() }
60
+ return {
61
+ coinAmount: currency.ZERO,
62
+ data: { claimUndelegate: txAmount, txAmount },
63
+ }
39
64
  }
40
65
  }
41
66
 
42
- const getPolygonStakeTxType = ({ tx, currency, server }) => {
43
- if (tx.coinAmount.isZero) return
67
+ const getPolygonStakingTxData = ({ tx, currency }) => {
68
+ if (tx.data?.claimUndelegate) return
69
+
70
+ if (['delegate', 'undelegate'].some((stakeTx) => tx.data?.[stakeTx]) && tx.coinAmount.isZero) {
71
+ return
72
+ }
73
+
74
+ const txAmount = tx.coinAmount.toDefaultString()
44
75
 
45
76
  if (isPolygonDelegate(tx)) {
46
- const stakeTxAmount = currency
47
- .baseUnit(decodePolygonStakingTxInputAmount({ tx, server }))
48
- .toDefaultString()
49
- return { delegate: stakeTxAmount }
77
+ const delegate = currency.baseUnit(decodePolygonStakingTxInputAmount(tx)).toDefaultString()
78
+ // MATIC returned in unstake tx is always reward
79
+ const rewards = calculateRewardsFromStakeTx({ tx, currency })
80
+ return {
81
+ coinAmount: currency.ZERO,
82
+ data: {
83
+ delegate,
84
+ txAmount,
85
+ ...(rewards ? { rewards } : {}),
86
+ },
87
+ }
50
88
  }
51
89
 
52
- if (isPolygonClaimUndelegate(tx)) return { undelegate: tx.coinAmount.toDefaultString() }
90
+ if (isPolygonUndelegate(tx)) {
91
+ const undelegate = currency.baseUnit(decodePolygonStakingTxInputAmount(tx)).toDefaultString()
92
+ // MATIC returned in unstake tx is always reward
93
+ const rewards = txAmount
94
+ return {
95
+ coinAmount: currency.ZERO,
96
+ data: { undelegate, txAmount, rewards },
97
+ }
98
+ }
99
+
100
+ if (isPolygonClaimUndelegate(tx)) {
101
+ return {
102
+ // NOTE: We intentionally omit for compatibility with `rx`:
103
+ // https://github.com/ExodusMovement/exodus-mobile/blob/229c9c0634af3e8e17eb1624019682c6fffe29bf/src/utils/getTxTag.js#L186
104
+ //
105
+ // coinAmount: currency.ZERO,
106
+ data: {
107
+ claimUndelegate: txAmount,
108
+ txAmount,
109
+ },
110
+ }
111
+ }
53
112
  }
54
113
 
55
- export const assetStakingTxData = {
56
- polygon: getPolygonStakeTxType,
57
- ethereum: getEthereumStakeTxType,
58
- ethereumholesky: getEthereumStakeTxType,
114
+ export const assetStakingTxProps = {
115
+ polygon: getPolygonStakingTxData,
116
+ ethereum: getEthereumStakingTxData,
59
117
  }