@exodus/ethereum-api 8.73.1 → 8.73.3

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.
@@ -1,12 +1,78 @@
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
+
1
64
  import { memoize } from '@exodus/basic-utils'
65
+ import NumberUnit from '@exodus/currency'
2
66
  import assert from 'minimalistic-assert'
3
67
 
4
68
  import { estimateGasLimit, scaleGasLimitEstimate } from '../../gas-estimation.js'
69
+ import { getOptimisticTxLogEffects } from '../../tx-log/get-optimistic-txlog-effects.js'
5
70
  import { createWatchTx as defaultCreateWatch } from '../../watch-tx.js'
6
71
  import { stakingProviderClientFactory } from '../staking-provider-client.js'
7
72
  import { amountToCurrency, DISABLE_BALANCE_CHECKS, resolveFeeData } from '../utils/index.js'
8
73
  import { EthereumStaking } from './api.js'
9
74
  import { getEverstakeValidatorsQueue } from './everstake.js'
75
+ import { simulateUndelegateTransactions } from './staking-utils.js'
10
76
 
11
77
  const WETH9_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
12
78
 
@@ -37,6 +103,74 @@ const getStakingApi = memoize(
37
103
  (asset) => asset.name
38
104
  )
39
105
 
106
+ export async function getUndelegatePendingData({
107
+ staking,
108
+ delegatorAddress,
109
+ requestedAmount,
110
+ pendingAmount,
111
+ minAmount,
112
+ resolvedFeeData,
113
+ estimateTxFee,
114
+ }) {
115
+ if (!pendingAmount.isPositive) return null
116
+
117
+ const leftOver = pendingAmount.sub(requestedAmount)
118
+
119
+ if (leftOver.isPositive && leftOver.lt(minAmount)) return null
120
+
121
+ // Essentially the min of the 2 amounts
122
+ const inactiveAmountToUnstake = pendingAmount.lte(requestedAmount)
123
+ ? pendingAmount
124
+ : requestedAmount
125
+
126
+ const { to, data } = await staking.unstakePending({
127
+ address: delegatorAddress,
128
+ amount: inactiveAmountToUnstake,
129
+ })
130
+
131
+ const feeInfo = estimateTxFee
132
+ ? await estimateTxFee({
133
+ from: delegatorAddress,
134
+ to,
135
+ amount: null,
136
+ txInput: data,
137
+ feeData: resolvedFeeData,
138
+ })
139
+ : null
140
+
141
+ return { plan: { to, txData: data, amount: inactiveAmountToUnstake }, feeInfo }
142
+ }
143
+
144
+ export async function getUndelegateData({
145
+ staking,
146
+ delegatorAddress,
147
+ requestedAmount,
148
+ pendingAmount,
149
+ resolvedFeeData,
150
+ estimateTxFee,
151
+ }) {
152
+ const activeAmountToUnstake = requestedAmount.sub(pendingAmount)
153
+
154
+ if (!activeAmountToUnstake.isPositive) return null
155
+
156
+ const { to, data } = await staking.unstake({
157
+ address: delegatorAddress,
158
+ amount: activeAmountToUnstake,
159
+ })
160
+
161
+ const feeInfo = estimateTxFee
162
+ ? await estimateTxFee({
163
+ from: delegatorAddress,
164
+ to,
165
+ amount: null,
166
+ txInput: data,
167
+ feeData: resolvedFeeData,
168
+ })
169
+ : null
170
+
171
+ return { plan: { to, txData: data, amount: activeAmountToUnstake }, feeInfo }
172
+ }
173
+
40
174
  export function createEthereumStakingService({
41
175
  asset: deprectedArg, // @deprecated use `assetName` instead
42
176
  assetName,
@@ -127,71 +261,40 @@ export function createEthereumStakingService({
127
261
  return txId
128
262
  }
129
263
 
130
- async function getUndelegatePendingData({
131
- delegatorAddress,
132
- resquestedAmount,
133
- pendingAmount,
134
- minAmount,
135
- feeData,
136
- }) {
264
+ async function prepareUndelegate({ walletAccount, amount, feeData }) {
265
+ assert(amount instanceof NumberUnit, 'expected amount to be a NumberUnit')
137
266
  const asset = await getAsset(assetName)
138
267
  const staking = getStakingApi(asset)
139
- const leftOver = pendingAmount.sub(resquestedAmount)
140
-
141
- if (leftOver.isPositive && leftOver.lt(minAmount)) {
142
- throw new Error(`Pending balance less than min stake amount ${minAmount}`)
143
- }
144
-
145
- const inactiveAmountToUnstake = pendingAmount.lte(resquestedAmount)
146
- ? pendingAmount
147
- : resquestedAmount
148
-
149
- feeData = await resolveFeeData({ asset, assetClientInterface, feeData })
150
-
151
- const { to, data } = await staking.unstakePending({
152
- address: delegatorAddress,
153
- amount: inactiveAmountToUnstake,
154
- })
268
+ const minAmount = getMinAmount(asset)
269
+ const requestedAmount = amount
155
270
 
156
- const { fee, gasLimit, gasPrice, tipGasPrice } = await estimateTxFee({
157
- from: delegatorAddress,
158
- to,
159
- amount: null,
160
- txInput: data,
161
- feeData,
271
+ const [address, resolvedFeeData] = await Promise.all([
272
+ assetClientInterface.getReceiveAddress({
273
+ assetName,
274
+ walletAccount,
275
+ }),
276
+ resolveFeeData({ asset, assetClientInterface, feeData }),
277
+ ])
278
+ const delegatorAddress = address.toLowerCase()
279
+ const pendingAmount = await staking.pendingBalanceOf(delegatorAddress)
280
+ const baseNonce = await asset.baseAsset.getNonce({
281
+ asset,
282
+ fromAddress: delegatorAddress,
283
+ walletAccount,
284
+ tag: 'latest',
285
+ forceFromNode: true,
162
286
  })
163
287
 
164
- return { to, txData: data, gasLimit, gasPrice, tipGasPrice, fee }
165
- }
166
-
167
- async function getUndelegateData({ delegatorAddress, resquestedAmount, pendingAmount, feeData }) {
168
- const asset = await getAsset(assetName)
169
- const staking = getStakingApi(asset)
170
- const canUnstake = resquestedAmount.gt(pendingAmount)
171
-
172
- if (!canUnstake) {
173
- console.warn('UnstakePending covered requested unstake. Nothing to unstake from validator')
174
- return Object.create(null)
288
+ return {
289
+ asset,
290
+ staking,
291
+ minAmount,
292
+ requestedAmount,
293
+ feeData: resolvedFeeData,
294
+ delegatorAddress,
295
+ pendingAmount,
296
+ baseNonce,
175
297
  }
176
-
177
- const activeAmountToUnstake = resquestedAmount.sub(pendingAmount)
178
-
179
- feeData = await resolveFeeData({ asset, assetClientInterface, feeData })
180
-
181
- const { to, data } = await staking.unstake({
182
- address: delegatorAddress,
183
- amount: activeAmountToUnstake,
184
- })
185
-
186
- const { fee, gasLimit, gasPrice, tipGasPrice } = await estimateTxFee({
187
- from: delegatorAddress,
188
- to,
189
- amount: null,
190
- txInput: data,
191
- feeData,
192
- })
193
-
194
- return { to, txData: data, gasLimit, gasPrice, tipGasPrice, fee }
195
298
  }
196
299
 
197
300
  /**
@@ -199,7 +302,8 @@ export function createEthereumStakingService({
199
302
  * Fee estimation depends on the executed txs. Can be both.
200
303
  * @returns total undelegete fee
201
304
  */
202
- async function estimateUndelegate({ walletAccount, amount: resquestedAmount, feeData }) {
305
+ async function estimateUndelegate({ walletAccount, amount: requestedAmount, feeData }) {
306
+ assert(requestedAmount instanceof NumberUnit, 'expected amount to be a NumberUnit')
203
307
  const asset = await getAsset(assetName)
204
308
  const staking = getStakingApi(asset)
205
309
  const minAmount = getMinAmount(asset)
@@ -209,114 +313,251 @@ export function createEthereumStakingService({
209
313
 
210
314
  const pendingAmount = await staking.pendingBalanceOf(delegatorAddress)
211
315
 
212
- let undelegatePendingFee = asset.currency.ZERO
213
-
214
- if (pendingAmount.isPositive) {
215
- try {
216
- // try to estimate unstakePending
217
- const { fee } = await getUndelegatePendingData({
218
- delegatorAddress,
219
- resquestedAmount,
220
- pendingAmount,
221
- minAmount,
222
- feeData,
223
- })
224
- undelegatePendingFee = fee
225
- } catch (err) {
226
- // useful to debug fee calculation
227
- console.warn('ETH unstake pending estimation failed, continuing with unstake', err)
228
- }
316
+ let pendingResult = null
317
+ try {
318
+ pendingResult = await getUndelegatePendingData({
319
+ staking,
320
+ delegatorAddress,
321
+ requestedAmount,
322
+ pendingAmount,
323
+ minAmount,
324
+ resolvedFeeData: feeData,
325
+ estimateTxFee,
326
+ })
327
+ } catch (err) {
328
+ console.warn('ETH unstake pending estimation failed, continuing with unstake', err)
229
329
  }
230
330
 
231
- const { fee: undelegateFee = asset.currency.ZERO } = await getUndelegateData({
331
+ const undelegateResult = await getUndelegateData({
332
+ staking,
232
333
  delegatorAddress,
233
- resquestedAmount,
334
+ requestedAmount,
234
335
  pendingAmount,
235
- minAmount,
236
- feeData,
336
+ resolvedFeeData: feeData,
337
+ estimateTxFee,
237
338
  })
238
339
 
239
- return undelegatePendingFee.add(undelegateFee)
240
- }
340
+ const pendingFee = pendingResult?.feeInfo?.fee ?? asset.currency.ZERO
341
+ const undelegateFee = undelegateResult?.feeInfo?.fee ?? asset.currency.ZERO
241
342
 
242
- async function undelegate({ walletAccount, amount, feeData, waitForConfirmation = true }) {
243
- const asset = await getAsset(assetName)
244
- const staking = getStakingApi(asset)
245
- const minAmount = getMinAmount(asset)
343
+ return pendingFee.add(undelegateFee)
344
+ }
246
345
 
247
- /*
248
- unstakePending balance (not yet in validator) + unstake balance (in validator)
249
- 1. give priority to unstakePending (based on the amount)
250
- 2. unstake amount in validator.
251
- */
252
- const resquestedAmount = amountToCurrency({ asset, amount })
346
+ // NOTE: Like the legacy `undelegate()` flow, this planner currently splits
347
+ // the request using `pendingAmount` only. It does not pre-validate that the
348
+ // validator-side remainder is actually available in active staked balance,
349
+ // so an oversized request can still fail later when building/sending the
350
+ // validator unstake step.
351
+ async function undelegate({ walletAccount, amount, feeData, revertOnSimulationError = true }) {
352
+ const {
353
+ asset,
354
+ staking,
355
+ minAmount,
356
+ requestedAmount,
357
+ feeData: resolvedFeeData,
358
+ delegatorAddress,
359
+ pendingAmount,
360
+ baseNonce,
361
+ } = await prepareUndelegate({ walletAccount, amount, feeData })
253
362
 
254
- let delegatorAddress
255
- ;({ feeData, delegatorAddress } = await getTransactionProps({ feeData, walletAccount }))
363
+ if (!requestedAmount.isPositive) {
364
+ throw new Error('Undelegate amount must be positive')
365
+ }
256
366
 
257
- const pendingAmount = await staking.pendingBalanceOf(delegatorAddress)
367
+ const txSteps = {
368
+ undelegatePending: {
369
+ plan: null,
370
+ gasLimit: null,
371
+ unsignedTx: null,
372
+ signedTx: null,
373
+ txId: null,
374
+ },
375
+ undelegate: {
376
+ plan: null,
377
+ gasLimit: null,
378
+ unsignedTx: null,
379
+ signedTx: null,
380
+ txId: null,
381
+ },
382
+ }
258
383
 
259
384
  console.log(
260
- `delegator address ${delegatorAddress} unstaking ${resquestedAmount.toDefaultString({
385
+ `delegator address ${delegatorAddress} unstaking ${requestedAmount.toDefaultString({
261
386
  unit: true,
262
387
  })} - pending amount: ${pendingAmount.toDefaultString({ unit: true })}`
263
388
  )
264
389
 
265
- let txId
266
- if (pendingAmount.isPositive) {
267
- const undelegatePendingData = await getUndelegatePendingData({
268
- delegatorAddress,
269
- resquestedAmount,
270
- pendingAmount,
271
- minAmount,
272
- feeData,
273
- })
390
+ // 1. Plan transactions and estimate fees
391
+ const pendingResult = await getUndelegatePendingData({
392
+ staking,
393
+ delegatorAddress,
394
+ requestedAmount,
395
+ pendingAmount,
396
+ minAmount,
397
+ resolvedFeeData,
398
+ estimateTxFee,
399
+ })
274
400
 
275
- txId = await prepareAndSendTx({
276
- asset,
277
- walletAccount,
278
- waitForConfirmation,
279
- ...undelegatePendingData,
280
- feeData,
281
- })
401
+ const undelegateResult = await getUndelegateData({
402
+ staking,
403
+ delegatorAddress,
404
+ requestedAmount,
405
+ pendingAmount,
406
+ resolvedFeeData,
407
+ estimateTxFee,
408
+ })
282
409
 
283
- // If we are waiting for confirmation, then sufficient time
284
- // will have elapsed for us to re-estimate `feeData`.
285
- //
286
- // NOTE: This will invalidate previous transaction fee estimates!
287
- if (waitForConfirmation) {
288
- feeData = await assetClientInterface.getFeeData({ assetName })
289
- }
410
+ if (!pendingResult && !undelegateResult) {
411
+ throw new Error('Requested undelegation produced no transactions')
290
412
  }
291
413
 
292
- // may need also to unstake
293
- const undelegateData = await getUndelegateData({
294
- delegatorAddress,
295
- resquestedAmount,
296
- pendingAmount,
297
- feeData,
414
+ let gasPrice
415
+ let tipGasPrice
416
+
417
+ if (pendingResult) {
418
+ txSteps.undelegatePending.plan = pendingResult.plan
419
+ txSteps.undelegatePending.gasLimit = pendingResult.feeInfo.gasLimit
420
+ gasPrice = pendingResult.feeInfo.gasPrice
421
+ tipGasPrice = pendingResult.feeInfo.tipGasPrice
422
+ }
423
+
424
+ if (undelegateResult) {
425
+ txSteps.undelegate.plan = undelegateResult.plan
426
+ txSteps.undelegate.gasLimit = undelegateResult.feeInfo.gasLimit
427
+ gasPrice = gasPrice || undelegateResult.feeInfo.gasPrice
428
+ tipGasPrice = tipGasPrice || undelegateResult.feeInfo.tipGasPrice
429
+ }
430
+
431
+ // 3. Create unsigned txs
432
+ const createTxBaseArgs = {
433
+ asset,
434
+ walletAccount,
435
+ fromAddress: delegatorAddress,
436
+ amount: asset.currency.ZERO,
437
+ tipGasPrice,
438
+ gasPrice,
439
+ }
440
+
441
+ const createdTxEntries = await Promise.all(
442
+ Object.entries(txSteps)
443
+ .filter(([, txStep]) => txStep.plan)
444
+ .map(async ([key, txStep], index) => {
445
+ const result = await asset.baseAsset.api.createTx({
446
+ ...createTxBaseArgs,
447
+ toAddress: txStep.plan.to,
448
+ txInput: txStep.plan.txData,
449
+ gasLimit: txStep.gasLimit,
450
+ nonce: baseNonce + index,
451
+ })
452
+ return [key, result.unsignedTx]
453
+ })
454
+ )
455
+
456
+ for (const [key, unsignedTx] of createdTxEntries) {
457
+ txSteps[key].unsignedTx = unsignedTx
458
+ }
459
+
460
+ await simulateUndelegateTransactions({
461
+ asset,
462
+ txSteps,
463
+ senderAddress: delegatorAddress,
464
+ gasPrice,
465
+ revertOnSimulationError,
298
466
  })
299
467
 
300
- if (undelegateData.fee) {
301
- txId = await prepareAndSendTx({
302
- asset,
303
- walletAccount,
304
- ...undelegateData,
305
- feeData,
306
- })
468
+ // 4. Sign transactions
469
+ const signedTxEntries = await Promise.all(
470
+ Object.entries(txSteps)
471
+ .filter(([, txStep]) => txStep.unsignedTx)
472
+ .map(async ([key, txStep]) => {
473
+ const signedTx = await assetClientInterface.signTransaction({
474
+ assetName: asset.baseAsset.name,
475
+ unsignedTx: txStep.unsignedTx,
476
+ walletAccount,
477
+ })
478
+ const txId = `0x${signedTx.txId.toString('hex')}`
479
+ return [key, { signedTx, txId }]
480
+ })
481
+ )
482
+
483
+ for (const [key, { signedTx, txId }] of signedTxEntries) {
484
+ txSteps[key].signedTx = signedTx
485
+ txSteps[key].txId = txId
486
+ }
487
+
488
+ // 5. Broadcast transactions via bundle and get bundle hash
489
+ const bundleResponse = await asset.broadcastPrivateBundle({
490
+ txs: Object.values(txSteps)
491
+ .filter((txStep) => txStep.signedTx)
492
+ .map(({ signedTx }) => signedTx.rawTx),
493
+ })
494
+ const bundleHash = bundleResponse?.bundleHash
495
+
496
+ // 6. Optimistic tx-log effects: reflect tx presence and fee consumption only.
497
+ // The ETH returned to the user via the contract's internal transfer is not
498
+ // captured here — it will be picked up once the tx is confirmed on-chain.
499
+ // It would also be incorrect to optimistically log the full requested ETH
500
+ // as immediately received: some portion may instead be routed into the
501
+ // undelegation queue / withdraw-request system and only become claimable
502
+ // much later after validator exit.
503
+ //
504
+ // We now have a more deterministic model for these incoming ETH amounts,
505
+ // but it requires staking-operation context that getOptimisticTxLogEffects()
506
+ // does not currently accept:
507
+ //
508
+ // - unstakePending: the ETH returned is exactly the amount passed to
509
+ // unstakePending(). In this flow that amount is chosen from the pre-send
510
+ // pending balance and the requested undelegation amount:
511
+ // amountPassedToUnstakePending = min(totalRequestedAmount, pendingBalanceBefore)
512
+ // - unstake: the ETH returned can be derived only from staking state before
513
+ // and after the operation:
514
+ // amountPassedToUnstake = totalRequestedAmount - pendingBalanceBefore
515
+ // queuedFromUnstake = unclaimedUndelegatedBalanceAfter - unclaimedUndelegatedBalanceBefore
516
+ // returnedImmediatelyFromUnstake = amountPassedToUnstake - queuedFromUnstake
517
+ //
518
+ // That is incompatible with the current tx-log side-effect hook, which only
519
+ // receives tx-local data (method id, calldata, nonce, gas, etc.) and not
520
+ // staking snapshots or precomputed ETH-return amounts.
521
+ const optimisticEffects = await Promise.all(
522
+ Object.values(txSteps)
523
+ .filter((txStep) => txStep.txId)
524
+ .map((txStep) =>
525
+ getOptimisticTxLogEffects({
526
+ asset: asset.baseAsset,
527
+ assetClientInterface,
528
+ fromAddress: delegatorAddress,
529
+ txId: txStep.txId,
530
+ unsignedTx: txStep.unsignedTx,
531
+ walletAccount,
532
+ bundleId: bundleHash ?? undefined,
533
+ })
534
+ )
535
+ )
536
+
537
+ for (const { optimisticTxLogEffects } of optimisticEffects) {
538
+ for (const effect of optimisticTxLogEffects) {
539
+ await assetClientInterface.updateTxLogAndNotify(effect)
540
+ }
307
541
  }
308
542
 
309
543
  // Testnet assets do not support delegations tracking
310
- if (txId && assetName === 'ethereum') {
311
- await stakingProvider.notifyUnstaking({
312
- txId,
313
- asset: assetName,
314
- delegator: delegatorAddress,
315
- amount: resquestedAmount.toBaseString(),
316
- })
544
+ if (assetName === 'ethereum') {
545
+ const notify = (txStep) =>
546
+ stakingProvider.notifyUnstaking({
547
+ txId: txStep.txId,
548
+ asset: assetName,
549
+ delegator: delegatorAddress,
550
+ amount: txStep.plan.amount.toBaseString(),
551
+ })
552
+
553
+ await Promise.all([
554
+ txSteps.undelegatePending.txId && notify(txSteps.undelegatePending),
555
+ txSteps.undelegate.txId && notify(txSteps.undelegate),
556
+ ])
317
557
  }
318
558
 
319
- return txId
559
+ // This is the most correct response, but it doesn't matter, it is not consumed by the apps
560
+ return bundleHash
320
561
  }
321
562
 
322
563
  async function claimUndelegatedBalance({ walletAccount, feeData }) {