@exodus/ethereum-api 9.0.0-alpha.2 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +2121 -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 +272 -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 +273 -103
  84. package/src/tx-log/clarity-truncated-history-monitor.js +109 -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
@@ -1,43 +1,240 @@
1
- import { estimateGasLimit } from '../../gas-estimation'
2
- import { isNumberUnit } from '@exodus/currency'
3
- import { stakingProviderClientFactory } from '../staking-provider-client'
4
- import { EthereumStaking } from './api'
5
- import { fromHexToBigInt } from '../../number-utils'
1
+ /**
2
+ * Everstake ETH Staking — Balance States
3
+ *
4
+ * When a user stakes ETH via Everstake, their funds move through several
5
+ * buckets tracked by the Accounting contract. Each bucket has different
6
+ * rules about what operations are allowed.
7
+ *
8
+ * User Wallet (ETH)
9
+ * |
10
+ * | stake()
11
+ * v
12
+ * pendingBalance
13
+ * Pool waiting room. ETH sits here until enough accumulates
14
+ * (32 ETH across all users) to fund a new validator.
15
+ * Can be withdrawn instantly via unstakePending().
16
+ * |
17
+ * | pool reaches 32 ETH, deposits to Beacon chain
18
+ * v
19
+ * pendingDepositedBalance
20
+ * Sent to Beacon chain but validator not yet activated.
21
+ * LOCKED — cannot be unstaked until validator activates.
22
+ * |
23
+ * | validator activates
24
+ * v
25
+ * activeStakedBalance (autocompoundBalanceOf)
26
+ * User's share of active validator ETH, including compounded
27
+ * rewards. Can be unstaked via unstake().
28
+ * |
29
+ * | unstake()
30
+ * |-----> ETH returned immediately (if pool has liquidity / interchange)
31
+ * | goes directly to wallet
32
+ * v
33
+ * unclaimedUndelegatedBalance
34
+ * Withdrawal queue. Waiting for validator exit.
35
+ * Global per-user — all unstake() calls accumulate into a single
36
+ * balance. Cannot claim until the entire balance is ready.
37
+ * |
38
+ * | validator(s) exit
39
+ * v
40
+ * readyForClaim (withdrawRequest.readyForClaim)
41
+ * When requested == readyForClaim, user can call
42
+ * claimWithdrawRequest() to get ETH back to wallet.
43
+ * |
44
+ * | claimWithdrawRequest()
45
+ * v
46
+ * User Wallet (ETH)
47
+ *
48
+ * Additional derived values:
49
+ * - delegatedBalance = activeStakedBalance + pendingBalance + pendingDepositedBalance
50
+ * - depositedBalanceOf = the user's original deposit amount tracked by the
51
+ * Accounting contract. Does NOT include compounded rewards — only what the
52
+ * user explicitly staked. Grows when user stakes, shrinks when user unstakes.
53
+ * - liquidRewards = activeStakedBalance - depositedBalanceOf
54
+ * The difference between the user's current share of validator ETH
55
+ * (which grows as validators earn rewards and autocompound) and what
56
+ * they originally deposited. This is the "unrealized" profit that
57
+ * would be forfeited if the user unstakes only their deposited amount.
58
+ * - rewardsBalance (getTotalRewards) = historical total rewards from the
59
+ * Everstake API, including already-claimed and compounded rewards.
60
+ * Unlike liquidRewards, this is NOT derived on-chain — it comes from
61
+ * an external Everstake endpoint and may lag behind real-time state.
62
+ */
63
+
64
+ import { memoize } from '@exodus/basic-utils'
65
+ import NumberUnit from '@exodus/currency'
66
+ import assert from 'minimalistic-assert'
67
+
68
+ import { estimateGasLimit, scaleGasLimitEstimate } from '../../gas-estimation.js'
69
+ import { createWatchTx as defaultCreateWatch } from '../../watch-tx.js'
70
+ import { stakingProviderClientFactory } from '../staking-provider-client.js'
71
+ import { amountToCurrency, DISABLE_BALANCE_CHECKS, resolveFeeData } from '../utils/index.js'
72
+ import { EthereumStaking } from './api.js'
73
+ import { getCachedEverstakeValidatorsQueue } from './everstake.js'
74
+ import { simulateUndelegateTransactions } from './staking-utils.js'
75
+
76
+ const WETH9_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
77
+
78
+ // Transactions will usually stake values up to `1 ether`, so
79
+ // we'll assume this amount as a slippage protection mechanism,
80
+ // the idea being that this is a value of ether that we risk
81
+ // being mined whilst our transaction is in-flight.
82
+ const SLOT_ACTIVATION_PROXIMITY_ETH = '1.0'
83
+
84
+ // We've observed that most transactions which offer
85
+ // a healthy ratio between `gasUsed` had a `gasLimit`
86
+ // over `170_000`. Transactions which have an unhealthy
87
+ // ratio commonly fall under this limit.
88
+ const MINIMUM_DELEGATION_GAS_LIMIT = 180_000
89
+
90
+ const EXTRA_GAS_LIMIT = 20_000 // extra gas Limit to prevent tx failing if something change on pool state (till tx is in mempool)
91
+
92
+ // Estimates based on real life transaction averages.
93
+ // Overestimated averages by 4x.
94
+ const STAKING_OPERATION_FALLBACK_FEE_ESTIMATES = {
95
+ delegate: '0.00081512',
96
+ undelegate: '0.000554',
97
+ claimUndelegatedBalance: '0.0001800912',
98
+ }
99
+
100
+ const getStakingApi = memoize(
101
+ (asset) => new EthereumStaking(asset.name, asset.currency, undefined, asset.server),
102
+ (asset) => asset
103
+ )
104
+
105
+ export async function getUndelegatePendingData({
106
+ staking,
107
+ delegatorAddress,
108
+ requestedAmount,
109
+ pendingAmount,
110
+ minAmount,
111
+ resolvedFeeData,
112
+ estimateTxFee,
113
+ }) {
114
+ if (!pendingAmount.isPositive) return null
115
+
116
+ const leftOver = pendingAmount.sub(requestedAmount)
117
+
118
+ if (leftOver.isPositive && leftOver.lt(minAmount)) return null
119
+
120
+ // Essentially the min of the 2 amounts
121
+ const inactiveAmountToUnstake = pendingAmount.lte(requestedAmount)
122
+ ? pendingAmount
123
+ : requestedAmount
6
124
 
7
- const extraGasLimit = 20_000 // extra gas Limit to prevent tx failing if something change on pool state (till tx is in mempool)
125
+ const { to, data } = await staking.unstakePending({
126
+ address: delegatorAddress,
127
+ amount: inactiveAmountToUnstake,
128
+ })
129
+
130
+ const feeInfo = estimateTxFee
131
+ ? await estimateTxFee({
132
+ from: delegatorAddress,
133
+ to,
134
+ amount: null,
135
+ txInput: data,
136
+ feeData: resolvedFeeData,
137
+ })
138
+ : null
139
+
140
+ return { plan: { to, txData: data, amount: inactiveAmountToUnstake }, feeInfo }
141
+ }
142
+
143
+ export async function getUndelegateData({
144
+ staking,
145
+ delegatorAddress,
146
+ requestedAmount,
147
+ pendingAmount,
148
+ resolvedFeeData,
149
+ estimateTxFee,
150
+ }) {
151
+ const activeAmountToUnstake = requestedAmount.sub(pendingAmount)
152
+
153
+ if (!activeAmountToUnstake.isPositive) return null
154
+
155
+ const { to, data } = await staking.unstake({
156
+ address: delegatorAddress,
157
+ amount: activeAmountToUnstake,
158
+ })
159
+
160
+ const feeInfo = estimateTxFee
161
+ ? await estimateTxFee({
162
+ from: delegatorAddress,
163
+ to,
164
+ amount: null,
165
+ txInput: data,
166
+ feeData: resolvedFeeData,
167
+ })
168
+ : null
169
+
170
+ return { plan: { to, txData: data, amount: activeAmountToUnstake }, feeInfo }
171
+ }
8
172
 
9
173
  export function createEthereumStakingService({
10
- asset,
174
+ asset: deprectedArg, // @deprecated use `assetName` instead
175
+ assetName,
11
176
  assetClientInterface,
12
- createAndBroadcastTX,
13
- server,
177
+ createWatchTx = defaultCreateWatch,
178
+ stakingProvider = stakingProviderClientFactory(),
14
179
  }) {
15
- const staking = new EthereumStaking(asset)
16
- const stakingProvider = stakingProviderClientFactory()
17
-
18
- function amountToCurrency({ asset, amount }) {
19
- return isNumberUnit(amount) ? amount : asset.currency.parse(amount)
180
+ if (deprectedArg && !assetName) {
181
+ console.debug('using deprecated ARG `asset` instead of `assetName`')
182
+ assetName = deprectedArg.name
20
183
  }
21
184
 
22
- async function delegate({ walletAccount, amount } = {}) {
23
- const address = await assetClientInterface.getReceiveAddress({
24
- assetName: asset.name,
25
- walletAccount,
26
- })
185
+ const getAsset = memoize(async (baseAssetName) => {
186
+ const { [assetName]: asset } = await assetClientInterface.getAssetsForNetwork({ baseAssetName })
187
+ return asset
188
+ })
189
+
190
+ const getTransactionProps = async ({ feeData, walletAccount }) => {
191
+ const asset = await getAsset(assetName)
192
+ let address
193
+ ;[address, feeData] = await Promise.all([
194
+ assetClientInterface.getReceiveAddress({
195
+ assetName,
196
+ walletAccount,
197
+ }),
198
+ resolveFeeData({ asset, assetClientInterface, feeData }),
199
+ ])
200
+
27
201
  const delegatorAddress = address.toLowerCase()
202
+ return { delegatorAddress, feeData }
203
+ }
204
+
205
+ async function delegate({ walletAccount, amount, feeData, isDelegateAll }) {
206
+ const asset = await getAsset(assetName)
207
+ const staking = getStakingApi(asset)
28
208
  amount = amountToCurrency({ asset, amount })
29
209
 
30
- const { to, data } = await staking.stake({
31
- amount,
32
- })
210
+ let delegatorAddress
211
+ ;({ feeData, delegatorAddress } = await getTransactionProps({ feeData, walletAccount }))
33
212
 
34
- const { gasPrice, gasLimit, fee } = await estimateTxFee(delegatorAddress, to, amount, data)
213
+ const { to, data } = await staking.stake({ amount })
35
214
 
36
- console.log(
215
+ console.debug(
37
216
  `delegator address ${delegatorAddress} staking ${amount.toDefaultString({
38
217
  unit: true,
39
218
  })}`
40
219
  )
220
+
221
+ const { gasPrice, gasLimit, tipGasPrice, fee } = await estimateTxFee({
222
+ from: delegatorAddress,
223
+ to,
224
+ amount,
225
+ txInput: data,
226
+ feeData,
227
+ })
228
+
229
+ // If we are delegating all, we need to subtract the estimated ETH cost from the amount
230
+ // Better to do it here than from the mobile app where fees can change before the transaction is sent
231
+ if (isDelegateAll) {
232
+ const minAmount = getMinAmount(asset)
233
+ amount = amount.sub(fee)
234
+ // Re-validate that the amount after fee subtraction is still above the minimum
235
+ assert(amount.gte(minAmount), `Min Amount ${minAmount}`)
236
+ }
237
+
41
238
  const txId = await prepareAndSendTx({
42
239
  asset,
43
240
  walletAccount,
@@ -46,204 +243,484 @@ export function createEthereumStakingService({
46
243
  txData: data,
47
244
  gasPrice,
48
245
  gasLimit,
49
- fee,
246
+ tipGasPrice,
247
+ feeData,
50
248
  })
51
249
 
52
250
  // Goerli is not supported
53
- if (asset.name === 'ethereum')
251
+ if (assetName === 'ethereum') {
54
252
  await stakingProvider.notifyStaking({
55
253
  txId,
56
- asset: asset.name,
254
+ asset: assetName,
57
255
  delegator: delegatorAddress,
58
256
  amount: amount.toBaseString(),
59
257
  })
258
+ }
60
259
 
61
260
  return txId
62
261
  }
63
262
 
64
- async function undelegate({ walletAccount, amount } = {}) {
65
- /*
66
- unstakePending balance (not yet in validator) + unstake balance (in validator)
67
- 1. give priority to unstakePending (based on the amount)
68
- 2. unstake amount in validator.
69
- */
70
- amount = amountToCurrency({ asset, amount })
263
+ async function prepareUndelegate({ walletAccount, amount, feeData }) {
264
+ assert(amount instanceof NumberUnit, 'expected amount to be a NumberUnit')
265
+ const asset = await getAsset(assetName)
266
+ const staking = getStakingApi(asset)
267
+ const minAmount = getMinAmount(asset)
268
+ const requestedAmount = amount
71
269
 
72
- const address = await assetClientInterface.getReceiveAddress({
73
- assetName: asset.name,
270
+ const [address, resolvedFeeData] = await Promise.all([
271
+ assetClientInterface.getReceiveAddress({
272
+ assetName,
273
+ walletAccount,
274
+ }),
275
+ resolveFeeData({ asset, assetClientInterface, feeData }),
276
+ ])
277
+ const delegatorAddress = address.toLowerCase()
278
+ const pendingAmount = await staking.pendingBalanceOf(delegatorAddress)
279
+ const baseNonce = await asset.baseAsset.getNonce({
280
+ asset,
281
+ fromAddress: delegatorAddress,
74
282
  walletAccount,
75
283
  })
76
- const delegatorAddress = address.toLowerCase()
284
+
285
+ return {
286
+ asset,
287
+ staking,
288
+ minAmount,
289
+ requestedAmount,
290
+ feeData: resolvedFeeData,
291
+ delegatorAddress,
292
+ pendingAmount,
293
+ baseNonce,
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Unstaking may involve 2 txs; unstaking from pending and unstaking from validator.
299
+ * Fee estimation depends on the executed txs. Can be both.
300
+ * @returns total undelegete fee
301
+ */
302
+ async function estimateUndelegate({ walletAccount, amount: requestedAmount, feeData }) {
303
+ assert(requestedAmount instanceof NumberUnit, 'expected amount to be a NumberUnit')
304
+ const asset = await getAsset(assetName)
305
+ const staking = getStakingApi(asset)
306
+ const minAmount = getMinAmount(asset)
307
+
308
+ let delegatorAddress
309
+ ;({ feeData, delegatorAddress } = await getTransactionProps({ feeData, walletAccount }))
77
310
 
78
311
  const pendingAmount = await staking.pendingBalanceOf(delegatorAddress)
79
312
 
80
- console.log(
81
- `delegator address ${delegatorAddress} unstaking ${amount.toDefaultString({
313
+ let pendingResult = null
314
+ try {
315
+ pendingResult = await getUndelegatePendingData({
316
+ staking,
317
+ delegatorAddress,
318
+ requestedAmount,
319
+ pendingAmount,
320
+ minAmount,
321
+ resolvedFeeData: feeData,
322
+ estimateTxFee,
323
+ })
324
+ } catch (err) {
325
+ console.warn('ETH unstake pending estimation failed, continuing with unstake', err)
326
+ }
327
+
328
+ const undelegateResult = await getUndelegateData({
329
+ staking,
330
+ delegatorAddress,
331
+ requestedAmount,
332
+ pendingAmount,
333
+ resolvedFeeData: feeData,
334
+ estimateTxFee,
335
+ })
336
+
337
+ const pendingFee = pendingResult?.feeInfo?.fee ?? asset.currency.ZERO
338
+ const undelegateFee = undelegateResult?.feeInfo?.fee ?? asset.currency.ZERO
339
+
340
+ return pendingFee.add(undelegateFee)
341
+ }
342
+
343
+ // NOTE: Like the legacy `undelegate()` flow, this planner currently splits
344
+ // the request using `pendingAmount` only. It does not pre-validate that the
345
+ // validator-side remainder is actually available in active staked balance,
346
+ // so an oversized request can still fail later when building/sending the
347
+ // validator unstake step.
348
+ async function undelegate({ walletAccount, amount, feeData, revertOnSimulationError = true }) {
349
+ const {
350
+ asset,
351
+ staking,
352
+ minAmount,
353
+ requestedAmount,
354
+ feeData: resolvedFeeData,
355
+ delegatorAddress,
356
+ pendingAmount,
357
+ baseNonce,
358
+ } = await prepareUndelegate({ walletAccount, amount, feeData })
359
+
360
+ if (!requestedAmount.isPositive) {
361
+ throw new Error('Undelegate amount must be positive')
362
+ }
363
+
364
+ const txSteps = {
365
+ undelegatePending: {
366
+ plan: null,
367
+ gasLimit: null,
368
+ unsignedTx: null,
369
+ txId: null,
370
+ },
371
+ undelegate: {
372
+ plan: null,
373
+ gasLimit: null,
374
+ unsignedTx: null,
375
+ txId: null,
376
+ },
377
+ }
378
+
379
+ console.debug(
380
+ `delegator address ${delegatorAddress} unstaking ${requestedAmount.toDefaultString({
82
381
  unit: true,
83
382
  })} - pending amount: ${pendingAmount.toDefaultString({ unit: true })}`
84
383
  )
85
384
 
86
- let txId
87
- if (!pendingAmount.isZero) {
88
- const inactiveAmountToUnstake = pendingAmount.lte(amount) ? pendingAmount : amount
89
- const leftOver = pendingAmount.sub(amount)
90
- if (leftOver.gt(asset.currency.ZERO)) {
91
- const minStake = staking.minAmount
92
- if (leftOver.lt(minStake)) {
93
- throw new Error(`Pending balance less than min stake amount ${minStake}`)
94
- }
95
- }
385
+ // 1. Plan transactions and estimate fees
386
+ const pendingResult = await getUndelegatePendingData({
387
+ staking,
388
+ delegatorAddress,
389
+ requestedAmount,
390
+ pendingAmount,
391
+ minAmount,
392
+ resolvedFeeData,
393
+ estimateTxFee,
394
+ })
96
395
 
97
- const { to, data } = await staking.unstakePending({
98
- address: delegatorAddress,
99
- amount: inactiveAmountToUnstake,
100
- })
396
+ const undelegateResult = await getUndelegateData({
397
+ staking,
398
+ delegatorAddress,
399
+ requestedAmount,
400
+ pendingAmount,
401
+ resolvedFeeData,
402
+ estimateTxFee,
403
+ })
101
404
 
102
- const { gasPrice, gasLimit, fee } = await estimateTxFee(
103
- delegatorAddress.toLowerCase(),
104
- to,
105
- null,
106
- data
107
- )
108
- txId = await prepareAndSendTx({
109
- asset,
110
- walletAccount,
111
- to,
112
- txData: data,
113
- gasPrice,
114
- gasLimit,
115
- fee,
116
- })
405
+ if (!pendingResult && !undelegateResult) {
406
+ throw new Error('Requested undelegation produced no transactions')
117
407
  }
118
408
 
119
- // need also to unstake
120
- if (amount.gt(pendingAmount)) {
121
- const activeAmountToUnstake = amount.sub(pendingAmount)
122
- const { to, data } = await staking.unstake({
123
- address: delegatorAddress,
124
- amount: activeAmountToUnstake,
125
- })
409
+ let gasPrice
410
+ let tipGasPrice
126
411
 
127
- const { gasPrice, gasLimit, fee } = await estimateTxFee(delegatorAddress, to, null, data)
128
- txId = await prepareAndSendTx({
129
- asset,
130
- walletAccount,
131
- to,
132
- txData: data,
133
- gasPrice,
134
- gasLimit,
135
- fee,
136
- })
412
+ if (pendingResult) {
413
+ txSteps.undelegatePending.plan = pendingResult.plan
414
+ txSteps.undelegatePending.gasLimit = pendingResult.feeInfo.gasLimit
415
+ gasPrice = pendingResult.feeInfo.gasPrice
416
+ tipGasPrice = pendingResult.feeInfo.tipGasPrice
137
417
  }
138
418
 
139
- // Goerli is not supported
140
- if (asset.name === 'ethereum')
141
- await stakingProvider.notifyUnstaking({
142
- txId,
143
- asset: asset.name,
144
- delegator: delegatorAddress,
145
- amount: amount.toBaseString(),
146
- })
147
-
148
- return txId
149
- }
419
+ if (undelegateResult) {
420
+ txSteps.undelegate.plan = undelegateResult.plan
421
+ txSteps.undelegate.gasLimit = undelegateResult.feeInfo.gasLimit
422
+ gasPrice = gasPrice || undelegateResult.feeInfo.gasPrice
423
+ tipGasPrice = tipGasPrice || undelegateResult.feeInfo.tipGasPrice
424
+ }
150
425
 
151
- async function claimUndelegatedBalance({ walletAccount } = {}) {
152
- // withdraw withdrawable balance (of a previous unstake)
153
- const address = await assetClientInterface.getReceiveAddress({
154
- assetName: asset.name,
426
+ // 3. Create unsigned txs
427
+ const createTxBaseArgs = {
428
+ asset,
155
429
  walletAccount,
430
+ fromAddress: delegatorAddress,
431
+ amount: asset.currency.ZERO,
432
+ tipGasPrice,
433
+ gasPrice,
434
+ }
435
+
436
+ const createdTxEntries = await Promise.all(
437
+ Object.entries(txSteps)
438
+ .filter(([, txStep]) => txStep.plan)
439
+ .map(async ([key, txStep], index) => {
440
+ const result = await asset.baseAsset.api.createTx({
441
+ ...createTxBaseArgs,
442
+ toAddress: txStep.plan.to,
443
+ txInput: txStep.plan.txData,
444
+ gasLimit: txStep.gasLimit,
445
+ nonce: baseNonce + index,
446
+ })
447
+ return [key, result.unsignedTx]
448
+ })
449
+ )
450
+
451
+ for (const [key, unsignedTx] of createdTxEntries) {
452
+ txSteps[key].unsignedTx = unsignedTx
453
+ }
454
+
455
+ await simulateUndelegateTransactions({
456
+ asset,
457
+ txSteps,
458
+ senderAddress: delegatorAddress,
459
+ gasPrice,
460
+ revertOnSimulationError,
156
461
  })
157
- const delegatorAddress = address.toLowerCase()
462
+
463
+ const unsignedTxBundle = createdTxEntries.map(([_, unsignedTx]) => ({
464
+ asset,
465
+ unsignedTx,
466
+ walletAccount,
467
+ }))
468
+
469
+ // 4. Sign transactions
470
+ //
471
+ // 5. Broadcast transactions via bundle and get bundle hash
472
+ //
473
+ // 6. Optimistic tx-log effects: reflect tx presence and fee consumption only.
474
+ // The ETH returned to the user via the contract's internal transfer is not
475
+ // captured here — it will be picked up once the tx is confirmed on-chain.
476
+ // It would also be incorrect to optimistically log the full requested ETH
477
+ // as immediately received: some portion may instead be routed into the
478
+ // undelegation queue / withdraw-request system and only become claimable
479
+ // much later after validator exit.
480
+ //
481
+ // We now have a more deterministic model for these incoming ETH amounts,
482
+ // but it requires staking-operation context that getOptimisticTxLogEffects()
483
+ // does not currently accept:
484
+ //
485
+ // - unstakePending: the ETH returned is exactly the amount passed to
486
+ // unstakePending(). In this flow that amount is chosen from the pre-send
487
+ // pending balance and the requested undelegation amount:
488
+ // amountPassedToUnstakePending = min(totalRequestedAmount, pendingBalanceBefore)
489
+ // - unstake: the ETH returned can be derived only from staking state before
490
+ // and after the operation:
491
+ // amountPassedToUnstake = totalRequestedAmount - pendingBalanceBefore
492
+ // queuedFromUnstake = unclaimedUndelegatedBalanceAfter - unclaimedUndelegatedBalanceBefore
493
+ // returnedImmediatelyFromUnstake = amountPassedToUnstake - queuedFromUnstake
494
+ //
495
+ // That is incompatible with the current tx-log side-effect hook, which only
496
+ // receives tx-local data (method id, calldata, nonce, gas, etc.) and not
497
+ // staking snapshots or precomputed ETH-return amounts.
498
+ const {
499
+ bundleHash,
500
+ txIds: [...txIds],
501
+ } = await asset.baseAsset.sendPrivateBundle({ unsignedTxBundle })
502
+
503
+ for (const txStep of Object.values(txSteps)) {
504
+ if (!txStep.unsignedTx) continue
505
+ assert((txStep.txId = txIds.shift()), 'expected txId')
506
+ }
507
+
508
+ // Testnet assets do not support delegations tracking
509
+ if (assetName === 'ethereum') {
510
+ const notify = (txStep) =>
511
+ stakingProvider.notifyUnstaking({
512
+ txId: txStep.txId,
513
+ asset: assetName,
514
+ delegator: delegatorAddress,
515
+ amount: txStep.plan.amount.toBaseString(),
516
+ })
517
+
518
+ await Promise.all([
519
+ txSteps.undelegatePending.txId && notify(txSteps.undelegatePending),
520
+ txSteps.undelegate.txId && notify(txSteps.undelegate),
521
+ ])
522
+ }
523
+
524
+ // This is the most correct response, but it doesn't matter, it is not consumed by the apps
525
+ return bundleHash
526
+ }
527
+
528
+ async function claimUndelegatedBalance({ walletAccount, feeData }) {
529
+ const asset = await getAsset(assetName)
530
+ const staking = getStakingApi(asset)
531
+
532
+ let delegatorAddress
533
+ ;({ feeData, delegatorAddress } = await getTransactionProps({ feeData, walletAccount }))
158
534
 
159
535
  const withdrawRequest = await staking.claimWithdrawRequest({
160
536
  address: delegatorAddress,
161
537
  })
538
+
162
539
  if (withdrawRequest) {
163
540
  const { to, data } = withdrawRequest
164
- const { gasPrice, gasLimit, fee } = await estimateTxFee(delegatorAddress, to, null, data)
541
+
542
+ const { gasLimit, gasPrice, tipGasPrice } = await estimateTxFee({
543
+ from: delegatorAddress,
544
+ to,
545
+ amount: null,
546
+ txInput: data,
547
+ feeData,
548
+ })
165
549
  return prepareAndSendTx({
166
550
  asset,
167
551
  walletAccount,
168
552
  to,
169
553
  txData: data,
170
- gasPrice,
171
554
  gasLimit,
172
- fee,
555
+ gasPrice,
556
+ tipGasPrice,
557
+ feeData,
173
558
  })
174
559
  }
175
-
176
- return null // -> no withdrawable balance
177
560
  }
178
561
 
179
- async function estimateDelegateOperation({ walletAccount, operation, args }) {
180
- const address = await assetClientInterface.getReceiveAddress({
181
- assetName: asset.name,
182
- walletAccount,
183
- })
184
- const delegatorAddress = address.toLowerCase()
562
+ async function estimateDelegateOperation({ walletAccount, operation, args, feeData }) {
563
+ const asset = await getAsset(assetName)
185
564
 
186
- const NAMING_MAP = {
187
- delegate: 'stake',
188
- undelegate: 'unstake',
189
- claimUndelegatedBalance: 'claimWithdrawRequest',
190
- }
191
- const delegateOperation = staking[NAMING_MAP[operation]].bind(staking)
565
+ try {
566
+ const staking = getStakingApi(asset)
192
567
 
193
- if (!delegateOperation) {
194
- return
195
- }
568
+ const requestedAmount = args.amount
569
+ ? amountToCurrency({ asset, amount: args.amount })
570
+ : asset.currency.ZERO
196
571
 
197
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
198
- if (args.amount) args.amount = amountToCurrency({ asset, amount: args.amount })
572
+ let delegatorAddress
573
+ ;({ delegatorAddress, feeData } = await getTransactionProps({ feeData, walletAccount }))
199
574
 
200
- let amount, data, fee
201
- try {
202
- ;({ amount, data } = await delegateOperation(args))
203
- const address =
204
- operation === 'claimUndelegatedBalance' ? staking.accountingAddress : staking.poolAddress
205
- ;({ fee } = await estimateTxFee(delegatorAddress, address, amount, data))
206
- } catch (e) {
207
- // If the operation is to unstake and failed retry with unstakePending
208
575
  if (operation === 'undelegate') {
209
- ;({ amount, data } = await staking.unstakePending(args))
210
- ;({ fee } = await estimateTxFee(delegatorAddress, staking.poolAddress, amount, data))
211
- } else {
212
- throw e
576
+ return estimateUndelegate({ walletAccount, amount: requestedAmount, feeData })
213
577
  }
214
- }
215
578
 
216
- return fee
579
+ const NAMING_MAP = {
580
+ delegate: 'stake',
581
+ undelegate: 'unstake',
582
+ claimUndelegatedBalance: 'claimWithdrawRequest',
583
+ }
584
+
585
+ const delegateOperation = staking[NAMING_MAP[operation]].bind(staking)
586
+
587
+ if (!delegateOperation) throw new Error('Invalid staking operation')
588
+
589
+ const { amount, data } = await delegateOperation({ ...args, amount: requestedAmount })
590
+
591
+ const { fee } = await (operation === 'claimUndelegatedBalance'
592
+ ? estimateTxFee({
593
+ from: delegatorAddress,
594
+ to: staking.accountingAddress,
595
+ amount,
596
+ txInput: data,
597
+ feeData,
598
+ })
599
+ : // The `gasUsed` of a delegation transaction can vary
600
+ // significantly depending upon whether it will result
601
+ // in the activation of new slots (i.e. validator creation).
602
+ //
603
+ // This can result in transaction `revert` due to slippage
604
+ // from incompatible transaction ordering.
605
+ //
606
+ // To mitigate this, we:
607
+ // 1. Simulate the delegation at an amplified deposit amount
608
+ // to show enhanced fees close to a proximity buffer.
609
+ // 2. Originate the transaction from the WETH contract,
610
+ // which guarantees deep native ether liquidity which
611
+ // exceeds any rational user deposit.
612
+ estimateTxFee({
613
+ from: WETH9_ADDRESS,
614
+ to: staking.poolAddress,
615
+ amount: amount.add(asset.currency.defaultUnit(SLOT_ACTIVATION_PROXIMITY_ETH)),
616
+ txInput: data,
617
+ feeData,
618
+ }))
619
+
620
+ return fee
621
+ } catch (error) {
622
+ const fallbackFee = STAKING_OPERATION_FALLBACK_FEE_ESTIMATES[operation]
623
+ if (fallbackFee) {
624
+ console.warn(
625
+ `ETH staking fee estimation failed for '${operation}', using fallback:`,
626
+ error.message
627
+ )
628
+ return asset.currency.defaultUnit(fallbackFee)
629
+ }
630
+
631
+ error.message = `StakingFeeEstimationError: Failed to estimate fee for '${operation}' (and no fallback configured). Cause: ${error.message}`
632
+ throw error
633
+ }
217
634
  }
218
635
 
219
- async function estimateTxFee(from, to, amount, txInput, gasPrice = '0x0') {
636
+ async function estimateTxFee({ from, to, amount, txInput, feeData }) {
637
+ const asset = await getAsset(assetName)
638
+
220
639
  amount = amount || asset.currency.ZERO
640
+ from = from.toLowerCase()
221
641
 
222
- const gasLimit = await estimateGasLimit(
642
+ const estimatedGasLimit = await estimateGasLimit({
223
643
  asset,
224
- from.toLowerCase(),
225
- to.toLowerCase(),
644
+ fromAddress: from,
645
+ toAddress: to.toLowerCase(),
226
646
  amount, // staking contracts does not always require ETH amount to interact with
227
- txInput,
228
- gasPrice
229
- )
647
+ data: txInput,
648
+ gasPrice: DISABLE_BALANCE_CHECKS,
649
+ })
230
650
 
231
- if (gasPrice === '0x0') {
232
- gasPrice = await server.gasPrice()
233
- }
651
+ const scaledGasLimit = scaleGasLimitEstimate({ estimatedGasLimit })
234
652
 
235
- gasPrice = fromHexToBigInt(gasPrice)
236
- const fee = gasPrice * BigInt(gasLimit + extraGasLimit)
653
+ const gasLimit = Math.max(
654
+ scaledGasLimit + EXTRA_GAS_LIMIT,
237
655
 
238
- return {
656
+ // For delgation transactions, we enforce an empirical
657
+ // `MINIMUM_DELEGATE_GAS_LIMIT`, since the majority of
658
+ // transactions which possess a healthy ratio of
659
+ // `gasUsed` to `gasLimit` operate at this boundary.
660
+ EthereumStaking.isDelegationTransactonCalldata(txInput) ? MINIMUM_DELEGATION_GAS_LIMIT : 0
661
+ )
662
+
663
+ const getFeeResult = await asset.api.getFee({
664
+ asset,
665
+ feeData,
239
666
  gasLimit,
240
- gasPrice: asset.currency.baseUnit(gasPrice.toString()),
241
- fee: asset.currency.baseUnit(fee.toString()),
242
- }
667
+ amount,
668
+ })
669
+
670
+ return { ...getFeeResult, gasLimit }
243
671
  }
244
672
 
245
- function getMinAmount() {
246
- return staking.minAmount
673
+ /** Returns the minimum possible amount that can be staked. */
674
+ function getMinAmount(asset) {
675
+ assert(asset, 'getMinAmount: `asset` is required')
676
+ return getStakingApi(asset).minAmount
677
+ }
678
+
679
+ /** Determine the maximum possible stake for a given spendable amount. */
680
+ async function getDelegateSelectAllAmount({ walletAccount, spendableForStaking, feeData }) {
681
+ const asset = await getAsset(assetName)
682
+ const minAmount = getMinAmount(asset)
683
+
684
+ if (!spendableForStaking) spendableForStaking = asset.currency.ZERO
685
+ ;({ feeData } = await getTransactionProps({ feeData, walletAccount }))
686
+
687
+ // If the caller hasn't specified a value of `spendableForStaking`
688
+ // which satisfies the minimum amount, then we'll coerce this value
689
+ // up to the minimum amount so we can at least provide a rational
690
+ // estimate for the `calculatedFee`, so they know how much ether
691
+ // to purchase.
692
+ spendableForStaking = spendableForStaking.lt(minAmount) ? minAmount : spendableForStaking
693
+
694
+ // Compute the cost of delegation. Even though the `walletAccount`'s
695
+ // balance is potentially insufficient, we can still provide
696
+ // rational fee estimates because the estimation process will mock
697
+ // the transaction from an account with deep liquidity.
698
+ const calculatedFee = await estimateDelegateOperation({
699
+ walletAccount,
700
+ operation: 'delegate',
701
+ args: {
702
+ amount: spendableForStaking,
703
+ },
704
+ feeData,
705
+ })
706
+
707
+ // If the `spendableForStaking` is insufficient to cover both the
708
+ // transaction fee and the minimum stake, then there is no reasonable
709
+ // value we can recommend.
710
+ if (calculatedFee.add(minAmount).gt(spendableForStaking)) {
711
+ return {
712
+ calculatedFee,
713
+ selectAllAmount: asset.currency.ZERO,
714
+ }
715
+ }
716
+
717
+ // At this stage, we've confirmed that the remaining `spendableForStaking`
718
+ // after transactions is sufficient to cover the minimum stake, so any
719
+ // excess amounts over this can be.
720
+ return {
721
+ calculatedFee,
722
+ selectAllAmount: spendableForStaking.sub(calculatedFee),
723
+ }
247
724
  }
248
725
 
249
726
  async function prepareAndSendTx({
@@ -254,25 +731,35 @@ export function createEthereumStakingService({
254
731
  txData: txInput,
255
732
  gasPrice,
256
733
  gasLimit,
257
- fee,
258
- } = {}) {
259
- const sendTxArgs = {
734
+ tipGasPrice,
735
+ waitForConfirmation = false,
736
+ feeData,
737
+ }) {
738
+ const { unsignedTx } = await asset.baseAsset.api.createTx({
260
739
  asset,
261
740
  walletAccount,
262
741
  address: to,
263
742
  amount: amount || asset.currency.ZERO,
264
- options: {
265
- shouldLog: true,
266
- txInput,
267
- gasPrice,
268
- gasLimit,
269
- feeAmount: fee,
270
- },
271
- }
743
+ txInput,
744
+ gasPrice,
745
+ gasLimit,
746
+ // HACK: Override the `tipGasPrice` to use a custom `maxPriorityFeePerGas`.
747
+ tipGasPrice,
748
+ feeData,
749
+ })
272
750
 
273
- console.log('sending staking tx:', sendTxArgs)
751
+ const { txId } = await asset.baseAsset.api.sendTx({ asset, walletAccount, unsignedTx })
274
752
 
275
- const { txId } = await createAndBroadcastTX(sendTxArgs)
753
+ const baseAsset = asset.baseAsset
754
+ if (waitForConfirmation) {
755
+ const getTxLog = (...args) => assetClientInterface.getTxLog(...args)
756
+ const watchTx = createWatchTx({
757
+ walletAccount,
758
+ getTxLog,
759
+ baseAsset,
760
+ })
761
+ await watchTx(txId)
762
+ }
276
763
 
277
764
  return txId
278
765
  }
@@ -284,26 +771,33 @@ export function createEthereumStakingService({
284
771
  getEthereumStakingInfo,
285
772
  estimateDelegateOperation,
286
773
  getMinAmount,
774
+ getDelegateSelectAllAmount,
287
775
  }
288
776
  }
289
777
 
290
- export async function getEthereumStakingInfo({ address, asset, server }) {
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
+ }) {
291
787
  const { currency } = asset
292
788
  const delegator = address.toLowerCase()
293
- const staking = new EthereumStaking({ asset, server })
294
-
295
789
  const [
296
- activeStakedBalance,
297
- pendingBalance,
298
- pendingDepositedBalance,
299
- withdrawRequest,
790
+ { activeStakedBalance, pendingBalance, pendingDepositedBalance, withdrawRequest },
300
791
  rewardsBalance,
792
+ everstakeValidatorsQueue,
301
793
  ] = await Promise.all([
302
- staking.autocompoundBalanceOf(delegator),
303
- staking.pendingBalanceOf(delegator),
304
- staking.pendingDepositedBalanceOf(delegator),
305
- staking.withdrawRequest(delegator),
306
- staking.getLiquidRewards(delegator),
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),
307
801
  ])
308
802
 
309
803
  const delegatedBalance = activeStakedBalance.add(pendingBalance).add(pendingDepositedBalance)
@@ -323,7 +817,7 @@ export async function getEthereumStakingInfo({ address, asset, server }) {
323
817
  const unclaimedUndelegatedBalance = requested
324
818
 
325
819
  return {
326
- rewardsBalance,
820
+ ...(rewardsBalance !== null && { rewardsBalance }),
327
821
  isDelegating,
328
822
  activeStakedBalance, // balance staked into the validator
329
823
  delegatedBalance, // balance staked into the validator + pending
@@ -333,5 +827,12 @@ export async function getEthereumStakingInfo({ address, asset, server }) {
333
827
  unclaimedUndelegatedBalance,
334
828
  canClaimUndelegatedBalance,
335
829
  isUndelegateInProgress,
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,
336
837
  }
337
838
  }