@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
package/src/get-fee.js CHANGED
@@ -1,25 +1,28 @@
1
- import { calculateBumpedGasPrice, calculateExtraEth, isEthereumLike } from '@exodus/ethereum-lib'
1
+ import {
2
+ calculateBumpedGasPrice,
3
+ calculateBumpedGasPriceForFeeData,
4
+ calculateExtraEth,
5
+ isEthereumLikeToken,
6
+ } from '@exodus/ethereum-lib'
7
+ import assert from 'minimalistic-assert'
8
+
9
+ import {
10
+ ensureSaneEip1559GasPriceForTipGasPrice,
11
+ getNormalizedFeeDataForCustomFee,
12
+ resolveGasPrice,
13
+ } from './fee-utils.js'
14
+ import { defaultGasLimit } from './gas-estimation.js'
2
15
 
3
16
  // Move to meta?
4
17
  const taxes = {
5
18
  paxgold: 0.0002,
6
19
  }
7
20
 
8
- const getGasPriceMultiplier = ({ asset, feeData, isExchange, isSendAll, isRbfAllowed }) => {
9
- // exchanges quotes expire, do not risk having a stuck tx
10
- if (isExchange) return 1
11
-
12
- // if eip1559 enabled, do not risk not leaving enough ETH to cover base fee (applies only for native asset)
13
- // (gasPrice difference will be reimbursed anyway: users do not overpay)
14
- if (isSendAll && isEthereumLike(asset) && feeData.eip1559Enabled) return 1
15
-
16
- // do not risk having a stuck tx if we're not able to accelerate it
17
- if (!isRbfAllowed || !feeData.rbfEnabled) return 1
18
-
19
- return feeData.gasPriceEconomicalRate
20
- }
21
+ export const getExtraFeeData = ({ asset, amount, txValue }) => {
22
+ if (isEthereumLikeToken(asset) && txValue?.gt(asset.baseAsset.currency.ZERO)) {
23
+ return { type: 'tax', extraFee: txValue }
24
+ }
21
25
 
22
- const getExtraFeeData = ({ asset, amount }) => {
23
26
  const tax = taxes[asset.name]
24
27
  if (!amount || !tax || amount.isZero) {
25
28
  return {}
@@ -33,62 +36,95 @@ const getExtraFeeData = ({ asset, amount }) => {
33
36
  }
34
37
  }
35
38
 
36
- export const getFeeFactory =
37
- ({ gasLimit: defaultGasLimit }) =>
38
- ({
39
- asset,
39
+ const getGasPricesResult = ({ gasPrice, feeData }) => {
40
+ const { tipGasPrice, eip1559Enabled } = feeData
41
+ return {
42
+ gasPrice: eip1559Enabled
43
+ ? // NOTE: The `gasPrice` must be at least the `tipGasPrice`.
44
+ ensureSaneEip1559GasPriceForTipGasPrice({ gasPrice, tipGasPrice })
45
+ : gasPrice,
40
46
  feeData,
41
- customFee,
42
- gasLimit = defaultGasLimit,
43
- isExchange,
44
- isSendAll,
45
- amount,
46
- isRbfAllowed = true, // Destkop, isRbfAllowed=true when advanced panel is on
47
- calculateEffectiveFee,
48
- }) => {
49
- const { gasPrice, eip1559Enabled, baseFeePerGas, tipGasPrice } = feeData
50
- const gasPriceMultiplier = getGasPriceMultiplier({
51
- asset,
52
- isRbfAllowed,
53
- feeData,
54
- isExchange,
55
- isSendAll,
56
- })
47
+ }
48
+ }
57
49
 
58
- const extraFeeData = getExtraFeeData({ asset, amount })
59
- if (calculateEffectiveFee && eip1559Enabled) {
60
- const maxFeePerGas = customFee || gasPrice
61
- // effective_gas_price = min(base_fee_per_gas + tip_gas_price, max_fee_per_gas)
62
- const feePerGas = baseFeePerGas.add(tipGasPrice)
63
- const effectiveGasPrice = feePerGas.lt(maxFeePerGas) ? feePerGas : maxFeePerGas
50
+ const getGasPricesForTxToReplace = ({ baseAsset, feeData, txToReplace }) => {
51
+ const { eip1559Enabled } = feeData
64
52
 
65
- return { fee: effectiveGasPrice.mul(gasLimit), extraFeeData }
66
- }
53
+ const effectiveGasPrice = resolveGasPrice({ feeData })
54
+ const { tipGasPrice: effectiveTipGasPrice } = feeData
67
55
 
68
- const fee = (customFee || gasPrice.mul(gasPriceMultiplier)).mul(gasLimit || asset.gasLimit)
69
- return { fee, extraFeeData }
70
- }
56
+ const { bumpedGasPrice, bumpedTipGasPrice } = calculateBumpedGasPriceForFeeData({
57
+ baseAsset,
58
+ feeData,
59
+ tx: txToReplace,
60
+ })
61
+
62
+ const gasPrice = bumpedGasPrice.gt(effectiveGasPrice) ? bumpedGasPrice : effectiveGasPrice
63
+ if (!eip1559Enabled) return getGasPricesResult({ gasPrice, feeData })
64
+
65
+ const tipGasPrice = bumpedTipGasPrice.gt(effectiveTipGasPrice)
66
+ ? bumpedTipGasPrice
67
+ : effectiveTipGasPrice
71
68
 
72
- // Used in BE
73
- export const getEffectiveGasPrice = ({ feeData }) => {
74
- const { baseFeePerGas, tipGasPrice, gasPrice: maxFeePerGas, eip1559Enabled } = feeData
69
+ return getGasPricesResult({
70
+ gasPrice,
71
+ feeData: { ...feeData, tipGasPrice },
72
+ })
73
+ }
74
+
75
+ export const getAggregateTransactionPricing = ({ baseAsset, customFee, feeData, txToReplace }) => {
76
+ assert(baseAsset, 'expected baseAsset')
75
77
 
76
- if (!eip1559Enabled) {
77
- return maxFeePerGas
78
+ if (customFee) {
79
+ return getGasPricesResult({
80
+ gasPrice: customFee,
81
+ feeData: getNormalizedFeeDataForCustomFee({ customFee, feeData }),
82
+ })
78
83
  }
79
84
 
80
- const gasPrice = baseFeePerGas.add(tipGasPrice)
81
- return gasPrice.lt(maxFeePerGas) ? gasPrice : maxFeePerGas
85
+ if (txToReplace) return getGasPricesForTxToReplace({ baseAsset, feeData, txToReplace })
86
+
87
+ return getGasPricesResult({
88
+ gasPrice: resolveGasPrice({ feeData }),
89
+ feeData,
90
+ })
82
91
  }
83
92
 
93
+ export const getFeeFactory =
94
+ () =>
95
+ ({ asset, feeData, customFee, txInput, gasLimit: providedGasLimit, amount, txValue }) => {
96
+ const {
97
+ feeData: { tipGasPrice, eip1559Enabled },
98
+ gasPrice,
99
+ } = getAggregateTransactionPricing({
100
+ baseAsset: asset.baseAsset,
101
+ customFee,
102
+ feeData,
103
+ })
104
+
105
+ const gasLimit = providedGasLimit ?? defaultGasLimit({ asset, txInput })
106
+
107
+ // When explicitly opting into EIP-1559 transactions,
108
+ // lock in the `tipGasPrice` we used to compute the fees.
109
+ const maybeReturnTipGasPrice = eip1559Enabled ? { tipGasPrice } : null
110
+
111
+ const extraFeeData = getExtraFeeData({ asset, amount, txValue })
112
+
113
+ const fee = gasPrice.mul(gasLimit)
114
+ return { ...maybeReturnTipGasPrice, fee, gasPrice, extraFeeData }
115
+ }
116
+
84
117
  // Used in Mobile
85
- export const getExtraFeeForBump = ({ tx, feeData, balance, unconfirmedBalance }) => {
118
+ export const getExtraFeeForBump = ({ baseAsset, tx, feeData, balance, unconfirmedBalance }) => {
119
+ assert(baseAsset, 'expected baseAsset')
120
+
86
121
  if (!balance || !unconfirmedBalance) return null
87
- const { gasPrice: currentGasPrice, eip1559Enabled } = feeData
122
+ const { gasPrice: currentGasPrice, eip1559Enabled, baseFeePerGas: currentBaseFee } = feeData
88
123
  const { bumpedGasPrice } = calculateBumpedGasPrice({
89
- baseAsset: 'ethereum',
124
+ baseAsset,
90
125
  tx,
91
126
  currentGasPrice,
127
+ currentBaseFee,
92
128
  eip1559Enabled,
93
129
  })
94
130
  return calculateExtraEth({
@@ -0,0 +1,58 @@
1
+ import { TxSet } from '@exodus/models'
2
+ import assert from 'minimalistic-assert'
3
+
4
+ import { getLatestCanonicalAbsoluteBalanceTx } from './tx-log/clarity-utils/index.js'
5
+
6
+ const walkBackFromBalance = ({ txLog, deadline, balance }) => {
7
+ let result = balance
8
+
9
+ for (const tx of txLog) {
10
+ if (new Date(tx.date).getTime() <= deadline) continue
11
+ if (tx.failed || tx.dropped || tx.pending || tx.data?.replacedBy) continue
12
+
13
+ result = result.sub(tx.coinAmount)
14
+ if (tx.feeAmount?.unitType.equals(tx.coinAmount.unitType)) {
15
+ result = result.add(tx.feeAmount)
16
+ }
17
+ }
18
+
19
+ return result.clampLowerZero()
20
+ }
21
+
22
+ /**
23
+ * Builds the Ethereum-like balance as of `date`. Anchors on the latest Clarity
24
+ * absolute-balance snapshot at or before the date, falling back to a walk-back
25
+ * from the current balance when no snapshot is available.
26
+ */
27
+ export const getHistoricalBalanceFactory = ({ assetClientInterface, getBalances }) => {
28
+ assert(assetClientInterface, 'assetClientInterface is required')
29
+ assert(getBalances, 'getBalances is required')
30
+
31
+ return async ({ asset, walletAccount, date }) => {
32
+ assert(asset, 'asset is required')
33
+ assert(walletAccount, 'walletAccount is required')
34
+ assert(date instanceof Date, 'date is required')
35
+
36
+ const txLog = await assetClientInterface.getTxLog({ assetName: asset.name, walletAccount })
37
+ const deadline = date.getTime()
38
+ const anchor = getLatestCanonicalAbsoluteBalanceTx({ reversedTxLog: txLog.reverse(), deadline })
39
+
40
+ if (anchor) {
41
+ const total = asset.currency.baseUnit(anchor.data.balanceChange.to)
42
+ return { total, entries: [{ amount: total, date: deadline }] }
43
+ }
44
+
45
+ const accountState = await assetClientInterface.getAccountState({
46
+ assetName: asset.baseAsset.name,
47
+ walletAccount,
48
+ })
49
+ const confirmedTxLog = TxSet.fromArray([...txLog].filter((tx) => !tx.pending))
50
+ const balance = getBalances({ asset, accountState, txLog: confirmedTxLog }).spendable
51
+ const total = walkBackFromBalance({ txLog: confirmedTxLog, deadline, balance })
52
+
53
+ return {
54
+ total,
55
+ entries: [{ amount: total, date: deadline }],
56
+ }
57
+ }
58
+ }
@@ -1 +1 @@
1
- export { createEthereumHooks } from './monitor'
1
+ export { createEthereumHooks } from './monitor.js'
@@ -1,72 +1,211 @@
1
- import { getEthereumStakingInfo, getPolygonStakingInfo } from '../staking'
2
- import processTxLog from '../tx-log-staking-processor'
3
1
  import assert from 'minimalistic-assert'
4
2
 
3
+ import processTxLog from '../tx-log-staking-processor/index.js'
4
+ import { getStakingActivity, stakingStateInterval } from './staking-freshness.js'
5
+
6
+ // Service-owned fields must never leak between delegator snapshots. Keep host
7
+ // annotations outside this set; new service fields must be added here as well.
8
+ const STAKING_SNAPSHOT_FIELDS = new Set([
9
+ 'rewardsBalance',
10
+ 'isDelegating',
11
+ 'activeStakedBalance',
12
+ 'delegatedBalance',
13
+ 'pendingBalance',
14
+ 'pendingDepositedBalance',
15
+ 'minDelegateAmount',
16
+ 'unclaimedUndelegatedBalance',
17
+ 'canClaimUndelegatedBalance',
18
+ 'isUndelegateInProgress',
19
+ 'validatorActivationTime',
20
+ 'validatorExitTime',
21
+ 'validatorWithdrawTime',
22
+ 'liquidRewards',
23
+ 'withdrawable',
24
+ 'unbondNonce',
25
+ 'minRewardsToWithdraw',
26
+ ])
27
+
28
+ const isPlainObject = (value) =>
29
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value)
30
+
5
31
  export const createEthereumHooks = ({
6
32
  assetClientInterface,
7
33
  assetName,
8
- stakingAssetNames,
34
+ stakingConfiguration,
9
35
  server,
36
+ now = () => performance.now(),
10
37
  }) => {
11
38
  assert(assetClientInterface, 'assetClientInterface is required')
12
39
  assert(server, 'server is required')
13
40
  assert(assetName, 'assetName is required')
14
- const afterTickHook = async ({ walletAccount }) => {
15
- // args passed from monitor tick method ({ monitor, error })
16
- const assets = await assetClientInterface.getAssetsForNetwork({
17
- baseAssetName: assetName,
18
- })
19
-
20
- // some assets may not be available
21
- const stakingAssets = stakingAssetNames.map((assetName) => assets[assetName]).filter(Boolean)
22
-
41
+ assert(isPlainObject(stakingConfiguration), 'object stakingConfiguration is required')
42
+ const stakingAssetNames = Object.keys(stakingConfiguration)
43
+ for (const name of stakingAssetNames) {
44
+ const config = stakingConfiguration[name]
45
+ assert(isPlainObject(config), `expected object for ${name} staking configuration`)
46
+ const contracts = config.activityContracts
47
+ assert(
48
+ contracts === undefined ||
49
+ (Array.isArray(contracts) && contracts.every((address) => typeof address === 'string')),
50
+ `expected array of strings for ${name} activityContracts`
51
+ )
52
+ }
53
+ const freshness = new Map()
54
+ const addresses = new Map()
55
+ const generations = new Map()
56
+ const commits = new Map()
57
+ const afterTickHook = async ({ walletAccount, refresh = false }) => {
58
+ // Order requests at entry, not after address discovery: discovery itself can
59
+ // be delayed while a newer explicit refresh is already running.
60
+ const accountKey = String(walletAccount)
61
+ const generation = {}
62
+ generations.set(accountKey, generation)
63
+ const assets = await assetClientInterface.getAssetsForNetwork({ baseAssetName: assetName })
64
+ const stakingAssets = stakingAssetNames.map((name) => assets[name]).filter(Boolean)
23
65
  const userAddress = await assetClientInterface.getReceiveAddress({
24
66
  assetName,
25
67
  walletAccount,
26
68
  useCache: true,
27
69
  })
28
-
29
- const stakingInfo = {
30
- staking: Object.create(null),
31
- }
70
+ const address = userAddress.toString().toLowerCase()
71
+ if (generations.get(accountKey) !== generation) return
72
+ const addressChanged = addresses.get(accountKey) !== address
73
+ addresses.set(accountKey, address)
74
+ const baseTxLog = await assetClientInterface.getTxLog({ assetName, walletAccount })
75
+ const patches = Object.create(null)
76
+ const completed = []
32
77
 
33
78
  for (const asset of stakingAssets) {
34
- const stakingAssetName = asset.name
35
- const getStakingInfo =
36
- stakingAssetName === 'polygon' ? getPolygonStakingInfo : getEthereumStakingInfo
79
+ const stakingApi = asset.api?.staking
80
+ if (!stakingApi?.getStakingInfo) continue
81
+ const key = JSON.stringify([String(walletAccount), assetName, asset.name, address])
82
+ const previous = freshness.get(key)
83
+ try {
84
+ const txLog =
85
+ asset.name === assetName
86
+ ? baseTxLog
87
+ : await assetClientInterface.getTxLog({ assetName: asset.name, walletAccount })
88
+ const { pending, fingerprint } = getStakingActivity({
89
+ assetName: asset.name,
90
+ activityContracts: stakingConfiguration[asset.name].activityContracts,
91
+ txLogs: [baseTxLog, txLog],
92
+ })
93
+ const force =
94
+ refresh || addressChanged || pending || !previous || previous.fingerprint !== fingerprint
95
+ if (!force && now() < previous.deadline) continue
37
96
 
38
- // asset may not be enabled in the wallet
39
- const assetStakingInfo = await getStakingInfo({
40
- address: userAddress.toString(),
41
- asset,
42
- server,
43
- })
44
- stakingInfo.staking[stakingAssetName] = assetStakingInfo
97
+ patches[asset.name] = await stakingApi.getStakingInfo({
98
+ address,
99
+ background: true,
100
+ useCache: !force,
101
+ // Initial per-address discovery is full, but the queue is global.
102
+ useQueueCache:
103
+ !refresh && !pending && (!previous || previous.fingerprint === fingerprint),
104
+ })
105
+ completed.push([
106
+ key,
107
+ { fingerprint, deadline: now() + stakingStateInterval(patches[asset.name]) },
108
+ ])
109
+ } catch (error) {
110
+ // Keep the last successful account state and retry on the next normal
111
+ // tick. Never interpret transport/contract/provider failures as zero.
112
+ if (generations.get(accountKey) === generation) freshness.delete(key)
113
+ console.warn(`Failed to refresh ${asset.name} staking state`, error)
114
+ }
45
115
  }
46
116
 
47
- const batch = assetClientInterface.createOperationsBatch()
48
- assetClientInterface.updateAccountStateBatch({
49
- assetName,
50
- walletAccount,
51
- newData: stakingInfo,
52
- batch,
53
- })
54
-
55
- const processTxLogsPromises = stakingAssets.map((asset) => {
56
- return processTxLog({
57
- asset,
58
- assetClientInterface,
117
+ const publish = async () => {
118
+ if (generations.get(accountKey) !== generation) return
119
+ const batch = assetClientInterface.createOperationsBatch()
120
+ // All asynchronous preparation is inside the per-account commit queue.
121
+ // Newer reads can run concurrently, but cannot commit ahead of an already
122
+ // executing batch. A superseded preparation is discarded before execute.
123
+ const current = await assetClientInterface.getAccountState({ assetName, walletAccount })
124
+ await Promise.all(
125
+ stakingAssets.map((asset) =>
126
+ processTxLog({
127
+ asset,
128
+ assetClientInterface,
129
+ walletAccount,
130
+ batch,
131
+ })
132
+ )
133
+ )
134
+ const latestAddress = await assetClientInterface.getReceiveAddress({
135
+ assetName,
59
136
  walletAccount,
60
- batch,
61
- server,
137
+ useCache: true,
62
138
  })
63
- })
139
+ if (
140
+ latestAddress.toString().toLowerCase() !== address ||
141
+ generations.get(accountKey) !== generation
142
+ ) {
143
+ return
144
+ }
64
145
 
65
- await Promise.all(processTxLogsPromises)
66
- await assetClientInterface.executeOperationsBatch(batch)
146
+ const staking = { ...current.staking }
147
+ const owners = { ...current.stakingSnapshotOwners }
148
+ let changed = false
149
+ // Ownership is persisted with the snapshot, not inferred from in-memory
150
+ // deadlines. Unknown legacy owners cannot supply failed fields on restore.
151
+ // Preserve only non-service host metadata when changing snapshot owners.
152
+ for (const name of new Set([...stakingAssetNames, ...Object.keys(owners)])) {
153
+ if (owners[name]?.address !== address || owners[name]?.network !== assetName) {
154
+ staking[name] = Object.fromEntries(
155
+ Object.entries(staking[name] || {}).filter(([key]) => !STAKING_SNAPSHOT_FIELDS.has(key))
156
+ )
157
+ delete owners[name]
158
+ changed = true
159
+ }
160
+ }
161
+ for (const [name, patch] of Object.entries(patches)) {
162
+ staking[name] = { ...staking[name], ...patch }
163
+ owners[name] = { address, network: assetName }
164
+ changed = true
165
+ }
166
+ if (changed) {
167
+ assetClientInterface.updateAccountStateBatch({
168
+ assetName,
169
+ walletAccount,
170
+ newData: { staking, stakingSnapshotOwners: owners },
171
+ batch,
172
+ })
173
+ }
174
+ // No await between the final generation/address check and batch execution.
175
+ await assetClientInterface.executeOperationsBatch(batch)
176
+ // An in-flight storage commit cannot be cancelled through ACI. It is
177
+ // serialized before newer commits; never let it publish a stale deadline.
178
+ const committedAddress = await assetClientInterface.getReceiveAddress({
179
+ assetName,
180
+ walletAccount,
181
+ useCache: true,
182
+ })
183
+ if (
184
+ generations.get(accountKey) === generation &&
185
+ committedAddress.toString().toLowerCase() === address
186
+ ) {
187
+ for (const [key, entry] of completed) freshness.set(key, entry)
188
+ }
189
+ }
190
+ const previousCommit = commits.get(accountKey) || Promise.resolve()
191
+ const commit = previousCommit.catch(() => {}).then(publish)
192
+ commits.set(accountKey, commit)
193
+ try {
194
+ await commit
195
+ } finally {
196
+ if (commits.get(accountKey) === commit) commits.delete(accountKey)
197
+ }
67
198
  }
68
199
 
69
200
  return {
201
+ // A restart/resume exposed through monitor.start must rediscover restored
202
+ // accounts. No host screen lifecycle is assumed; update({refresh:true}) and
203
+ // direct getStakingInfo({address}) are explicit uncached refresh paths.
204
+ 'before-start': () => {
205
+ freshness.clear()
206
+ addresses.clear()
207
+ generations.clear()
208
+ },
70
209
  'after-tick': afterTickHook,
71
210
  }
72
211
  }
@@ -0,0 +1,58 @@
1
+ import { isPendingTxInLog } from '@exodus/ethereum-lib'
2
+
3
+ const STAKING_FIELDS = ['delegate', 'undelegate', 'undelegatePending', 'claimUndelegate', 'rewards']
4
+
5
+ // Inspect both logs: Polygon calls without token transfers only live in ETH's
6
+ // log. Any interaction with a staking contract is conservatively relevant.
7
+ export const getStakingActivity = ({ assetName, activityContracts = [], txLogs }) => {
8
+ const contracts = new Set(activityContracts.map((address) => address.toLowerCase()))
9
+ const isContract = (address) =>
10
+ typeof address === 'string' && contracts.has(address.toLowerCase())
11
+ const entries = new Map()
12
+ let pending = false
13
+ for (const txs of txLogs) {
14
+ for (const tx of txs) {
15
+ const relevant =
16
+ isContract(tx.to) ||
17
+ (Array.isArray(tx.from) ? tx.from.some(isContract) : isContract(tx.from)) ||
18
+ (tx.coinName === assetName &&
19
+ STAKING_FIELDS.some((field) => tx.data?.[field] !== undefined))
20
+ if (!relevant) continue
21
+ const isPending = isPendingTxInLog(tx)
22
+ pending ||= isPending
23
+ // Do not include increasing confirmation counts or processor-added metadata:
24
+ // neither constitutes a new staking event. Include block identity for reorgs.
25
+ entries.set(`${tx.coinName}:${tx.txId}`, [
26
+ tx.txId,
27
+ isPending,
28
+ tx.confirmations > 0,
29
+ tx.error || null,
30
+ !!tx.dropped,
31
+ tx.data?.replacedBy || null,
32
+ tx.data?.blockHash || tx.blockHash || null,
33
+ tx.data?.blockNumber || tx.blockHeight || null,
34
+ ])
35
+ }
36
+ }
37
+ return {
38
+ pending,
39
+ fingerprint: JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b))),
40
+ }
41
+ }
42
+
43
+ export const stakingStateInterval = (info) => {
44
+ // A pending validator activation/exit is chain state, not a pending local tx.
45
+ // It must never keep a wallet on the one-minute transaction cadence forever.
46
+ const active =
47
+ info.isDelegating ||
48
+ info.isUndelegateInProgress ||
49
+ info.canClaimUndelegatedBalance ||
50
+ [
51
+ 'delegatedBalance',
52
+ 'pendingBalance',
53
+ 'pendingDepositedBalance',
54
+ 'unclaimedUndelegatedBalance',
55
+ 'liquidRewards',
56
+ ].some((key) => info[key]?.isPositive)
57
+ return (active ? 5 : 15) * 60_000
58
+ }