@exodus/ethereum-api 8.73.0 → 8.73.2

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [8.73.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.73.1...@exodus/ethereum-api@8.73.2) (2026-05-05)
7
+
8
+ **Note:** Version bump only for package @exodus/ethereum-api
9
+
10
+
11
+
12
+
13
+
14
+ ## [8.73.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.73.0...@exodus/ethereum-api@8.73.1) (2026-05-04)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+
20
+ * fix(ethereum-api): pass assertion message to assert(), not startsWith() (#7924)
21
+
22
+
23
+
6
24
  ## [8.73.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.72.0...@exodus/ethereum-api@8.73.0) (2026-05-01)
7
25
 
8
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.73.0",
3
+ "version": "8.73.2",
4
4
  "description": "Transaction monitors, fee monitors, RPC with the blockchain node, and other networking code for Ethereum and EVM-based blockchains",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -39,7 +39,7 @@
39
39
  "@exodus/simple-retry": "^0.0.6",
40
40
  "@exodus/solidity-contract": "^1.3.0",
41
41
  "@exodus/traceparent": "^3.0.1",
42
- "@exodus/web3-ethereum-utils": "^4.6.0",
42
+ "@exodus/web3-ethereum-utils": "^4.7.4",
43
43
  "bn.js": "^5.2.1",
44
44
  "delay": "^4.0.1",
45
45
  "eventemitter3": "^4.0.7",
@@ -68,5 +68,5 @@
68
68
  "type": "git",
69
69
  "url": "git+https://github.com/ExodusMovement/assets.git"
70
70
  },
71
- "gitHead": "d17ee07f3f8bf4cc67518f8a8395522578e8dae1"
71
+ "gitHead": "69553b4abf01b543d0e6006299166b8f7cc6271c"
72
72
  }
@@ -12,6 +12,11 @@ const RETRY_DELAYS = ['10s']
12
12
 
13
13
  const EVERSTAKE_API_URL = 'https://eth-clarity.a.exodus.io/everstake-rewards'
14
14
 
15
+ export const UNSTAKE_DEFAULTS = {
16
+ allowedInterchangeNum: 0,
17
+ source: '2',
18
+ }
19
+
15
20
  export class EthereumStaking {
16
21
  static addresses = {
17
22
  ethereum: {
@@ -229,8 +234,22 @@ export class EthereumStaking {
229
234
  throw new Error(`Min Amount ${this.minAmount}`)
230
235
  }
231
236
 
232
- /* Unstake funds that are staked into the validator. Once unstaked they need to be claimed (once ready to withdraw) */
233
- async unstake({ address, amount, allowedInterchangeNum = 0, source = '2' }) {
237
+ /**
238
+ * Unstake funds from the validator. The unstaked amount enters a withdrawal
239
+ * queue and must be claimed via `claimWithdrawRequest` after the validator exits.
240
+ *
241
+ * @param {number} allowedInterchangeNum - Max number of incoming stake requests
242
+ * the contract may use to immediately return ETH to the unstaker (interchange).
243
+ * When 0 (default), no interchange occurs and the full amount is queued.
244
+ * When > 0, the contract may match up to N new stakers' deposits against this
245
+ * unstake, returning ETH immediately without waiting for validator exit.
246
+ */
247
+ async unstake({
248
+ address,
249
+ amount,
250
+ allowedInterchangeNum = UNSTAKE_DEFAULTS.allowedInterchangeNum,
251
+ source = UNSTAKE_DEFAULTS.source,
252
+ }) {
234
253
  const amountWei = amount.toBaseString()
235
254
  const balance = await this.autocompoundBalanceOf(address) // amount staked into the validator (active balance)
236
255
 
@@ -2,7 +2,10 @@ import { fetch as exodusFetch } from '@exodus/fetch'
2
2
  import { TraceId } from '@exodus/traceparent'
3
3
  import assert from 'minimalistic-assert'
4
4
 
5
+ import { UNSTAKE_DEFAULTS } from './api.js'
6
+
5
7
  const BASE_URL = 'https://eth-clarity.a.exodus.io/api/v2/ethereum/proxy/everstake/'
8
+ const WALLET_SDK_BASE_URL = 'https://wallet-sdk-api.everstake.one/'
6
9
 
7
10
  const fetch = async (path, config = Object.create(null)) => {
8
11
  const url = new URL(path, BASE_URL).toString()
@@ -26,6 +29,56 @@ const fetch = async (path, config = Object.create(null)) => {
26
29
 
27
30
  const isFiniteInteger = (e) => Number.isInteger(e) && Number.isFinite(e)
28
31
 
32
+ // Simulate an unstake to predict how much ETH the interchange pool returns instantly.
33
+ // https://wallet-sdk-api.everstake.one/swagger/#/Ethereum/post_ethereum_pool_simulate_unstake
34
+ //
35
+ // Inputs:
36
+ // address: staker address (0x...)
37
+ // amount: unstake amount in ETH (default units), e.g. "1.5" — NOT wei
38
+ // allowedInterchangeNum: interchange pool parameter (default from UNSTAKE_DEFAULTS)
39
+ // source: source identifier (default from UNSTAKE_DEFAULTS)
40
+ //
41
+ // Success (200): { result: <number> } — ETH returned instantly (capped by pool liquidity)
42
+ // Error (500): { "Internal Server Error": "Error: Max Amount For Unstake <max_eth>" }
43
+ // when amount exceeds the staker's active balance
44
+ export const simulateEverstakeUnstake = async ({
45
+ address,
46
+ amount,
47
+ allowedInterchangeNum = UNSTAKE_DEFAULTS.allowedInterchangeNum,
48
+ source = UNSTAKE_DEFAULTS.source,
49
+ }) => {
50
+ try {
51
+ assert(typeof address === 'string' && address.startsWith('0x'), 'expected valid address')
52
+ assert(typeof amount === 'string' && amount.length > 0, 'expected amount in ETH as string')
53
+
54
+ const url = new URL('ethereum/pool/simulate_unstake', WALLET_SDK_BASE_URL).toString()
55
+
56
+ const response = await exodusFetch(url, {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify({ address, amount, allowedInterchangeNum, source }),
60
+ })
61
+
62
+ if (!response.ok) {
63
+ const body = await response.json().catch(() => null)
64
+ const serverMsg = body?.['Internal Server Error'] || response.statusText
65
+ console.warn(`simulate_unstake failed: ${serverMsg}`)
66
+ return null
67
+ }
68
+
69
+ const data = await response.json()
70
+ if (!data || typeof data.result !== 'number') {
71
+ console.warn('simulate_unstake: malformed response', data)
72
+ return null
73
+ }
74
+
75
+ return { instantReturnEth: data.result }
76
+ } catch (e) {
77
+ console.warn('simulate_unstake error:', e.message)
78
+ return null
79
+ }
80
+ }
81
+
29
82
  export const getEverstakeValidatorsQueue = async () => {
30
83
  // https://swagger.eth-api-b2c.everstake.one/#/Staking/validatorsQueue
31
84
  const result = await fetch('v1/validators/queue')
@@ -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 }) {
@@ -1,4 +1,10 @@
1
- import { EthereumStaking } from './api.js'
1
+ import NumberUnit from '@exodus/currency'
2
+ import { parseUnsignedTx } from '@exodus/ethereum-lib'
3
+ import { bufferToHex } from '@exodus/ethereumjs/util'
4
+ import assert from 'minimalistic-assert'
5
+
6
+ import { EthereumStaking, UNSTAKE_DEFAULTS } from './api.js'
7
+ import { simulateEverstakeUnstake } from './everstake.js'
2
8
 
3
9
  const { DELEGATE, UNSTAKE, UNSTAKE_PENDING, CLAIM_UNSTAKE } = EthereumStaking.METHODS_IDS
4
10
 
@@ -8,8 +14,13 @@ const STAKING_MANAGER_CONTRACTS = new Set([
8
14
  EthereumStaking.addresses.ethereumholesky.EVERSTAKE_ADDRESS_CONTRACT_POOL.toLowerCase(),
9
15
  ])
10
16
 
17
+ const METHOD_ID_CHAR_LENGTH = 10
18
+ const FIRST_UINT256_END = METHOD_ID_CHAR_LENGTH + 64
19
+
11
20
  export const isEthereumStakingTx = ({ coinName }) =>
12
21
  ['ethereum', 'ethereumgoerli', 'ethereumholesky'].includes(coinName)
22
+ export const isEthereumStakingPoolContract = (address) =>
23
+ typeof address === 'string' && STAKING_MANAGER_CONTRACTS.has(address.toLowerCase())
13
24
  export const isEthereumDelegate = (tx) =>
14
25
  isEthereumStakingTx(tx) && STAKING_MANAGER_CONTRACTS.has(tx.to) && tx.data?.methodId === DELEGATE
15
26
  export const isEthereumUndelegatePending = (tx) =>
@@ -18,3 +29,259 @@ export const isEthereumUndelegate = (tx) =>
18
29
  (isEthereumStakingTx(tx) && tx.data?.methodId === UNSTAKE) || isEthereumUndelegatePending(tx)
19
30
  export const isEthereumClaimUndelegate = (tx) =>
20
31
  isEthereumStakingTx(tx) && tx.data?.methodId === CLAIM_UNSTAKE
32
+
33
+ export function decodeEthereumStakingFirstUintArg(transactionData) {
34
+ const txInputHex =
35
+ (Buffer.isBuffer(transactionData) ? bufferToHex(transactionData) : transactionData) || '0x'
36
+ assert(typeof txInputHex === 'string', 'expected string transactionData')
37
+ assert(
38
+ txInputHex.length >= FIRST_UINT256_END,
39
+ 'expected staking transaction calldata with first uint256 arg'
40
+ )
41
+
42
+ return BigInt(`0x${txInputHex.slice(METHOD_ID_CHAR_LENGTH, FIRST_UINT256_END)}`)
43
+ }
44
+
45
+ export async function ethereumUnstakePendingOptimisticSideEffectTxLogs({
46
+ asset: baseAsset,
47
+ walletAccount,
48
+ feeAmount,
49
+ unstakePendingTxId,
50
+ nonce,
51
+ estimatedUnstakePendingGasLimit,
52
+ tipGasPrice,
53
+ transactionData,
54
+ txToAddress,
55
+ date,
56
+ bundleId,
57
+ }) {
58
+ let amount
59
+ try {
60
+ const decodedAmount = decodeEthereumStakingFirstUintArg(transactionData)
61
+ amount = baseAsset.currency.baseUnit(decodedAmount.toString())
62
+ } catch (e) {
63
+ console.warn(
64
+ 'Could not decode ETH unstakePending transaction data:',
65
+ unstakePendingTxId,
66
+ e.message,
67
+ transactionData
68
+ )
69
+ return []
70
+ }
71
+
72
+ return [
73
+ {
74
+ assetName: baseAsset.name,
75
+ walletAccount,
76
+ txs: [
77
+ {
78
+ confirmations: 0,
79
+ feeAmount,
80
+ feeCoinName: baseAsset.feeAsset.name,
81
+ selfSend: false,
82
+ to: txToAddress,
83
+ txId: unstakePendingTxId,
84
+ data: {
85
+ gasLimit: estimatedUnstakePendingGasLimit,
86
+ nonce,
87
+ ...(tipGasPrice && { tipGasPrice: tipGasPrice.toBaseString() }),
88
+ methodId: UNSTAKE_PENDING,
89
+ ...(bundleId ? { bundleId } : {}),
90
+ },
91
+ coinAmount: amount,
92
+ coinName: baseAsset.name,
93
+ currencies: {
94
+ [baseAsset.name]: baseAsset.currency,
95
+ [baseAsset.feeAsset.name]: baseAsset.feeAsset.currency,
96
+ },
97
+ date,
98
+ },
99
+ ],
100
+ },
101
+ ]
102
+ }
103
+
104
+ export async function ethereumUnstakeOptimisticSideEffectTxLogs({
105
+ asset: baseAsset,
106
+ walletAccount,
107
+ feeAmount,
108
+ unstakeTxId,
109
+ nonce,
110
+ estimatedUnstakeGasLimit,
111
+ tipGasPrice,
112
+ transactionData,
113
+ txToAddress,
114
+ fromAddress,
115
+ allowedInterchangeNum = UNSTAKE_DEFAULTS.allowedInterchangeNum,
116
+ date,
117
+ bundleId,
118
+ }) {
119
+ let amountEth
120
+ try {
121
+ const decodedAmountWei = decodeEthereumStakingFirstUintArg(transactionData)
122
+ amountEth = baseAsset.currency.baseUnit(decodedAmountWei.toString()).toDefaultString()
123
+ } catch (e) {
124
+ console.warn(
125
+ 'Could not decode ETH unstake transaction data:',
126
+ unstakeTxId,
127
+ e.message,
128
+ transactionData
129
+ )
130
+ return []
131
+ }
132
+
133
+ const result = await simulateEverstakeUnstake({
134
+ address: fromAddress,
135
+ amount: amountEth,
136
+ allowedInterchangeNum,
137
+ })
138
+
139
+ if (!result || result.instantReturnEth <= 0) return []
140
+
141
+ const instantAmount = baseAsset.currency.defaultUnit(String(result.instantReturnEth))
142
+
143
+ return [
144
+ {
145
+ assetName: baseAsset.name,
146
+ walletAccount,
147
+ txs: [
148
+ {
149
+ confirmations: 0,
150
+ feeAmount,
151
+ feeCoinName: baseAsset.feeAsset.name,
152
+ selfSend: false,
153
+ to: txToAddress,
154
+ txId: unstakeTxId,
155
+ data: {
156
+ gasLimit: estimatedUnstakeGasLimit,
157
+ nonce,
158
+ ...(tipGasPrice && { tipGasPrice: tipGasPrice.toBaseString() }),
159
+ methodId: UNSTAKE,
160
+ ...(bundleId ? { bundleId } : {}),
161
+ },
162
+ coinAmount: instantAmount,
163
+ coinName: baseAsset.name,
164
+ currencies: {
165
+ [baseAsset.name]: baseAsset.currency,
166
+ [baseAsset.feeAsset.name]: baseAsset.feeAsset.currency,
167
+ },
168
+ date,
169
+ },
170
+ ],
171
+ },
172
+ ]
173
+ }
174
+
175
+ export function buildSimulationRequest({ asset, unsignedTx, gasPrice }) {
176
+ const parsed = parseUnsignedTx({ asset, unsignedTx })
177
+ const chainId = unsignedTx.txData?.chainId
178
+ const from = unsignedTx.txMeta?.fromAddress
179
+ const to = unsignedTx.txMeta?.toAddress
180
+
181
+ assert(gasPrice instanceof NumberUnit, 'expected NumberUnit gasPrice')
182
+ assert(Number.isInteger(chainId), 'expected integer chainId')
183
+ assert(typeof from === 'string' && from, 'expected string fromAddress')
184
+ assert(typeof to === 'string' && to, 'expected string toAddress')
185
+
186
+ return {
187
+ chainId,
188
+ from,
189
+ to,
190
+ gas: `0x${parsed.gasLimit.toString(16)}`,
191
+ gasPrice: `0x${BigInt(gasPrice.toBaseString()).toString(16)}`,
192
+ value: `0x${BigInt(parsed.value.toBaseString()).toString(16)}`,
193
+ data: `0x${parsed.data?.toString('hex')}`,
194
+ nonce: `0x${parsed.nonce.toString(16)}`,
195
+ }
196
+ }
197
+
198
+ export async function simulateUndelegateTransactions({
199
+ asset,
200
+ txSteps,
201
+ senderAddress,
202
+ gasPrice,
203
+ blockNumber,
204
+ revertOnSimulationError,
205
+ }) {
206
+ const transactions = Object.values(txSteps)
207
+ .filter((txStep) => txStep.unsignedTx)
208
+ .map((txStep) =>
209
+ buildSimulationRequest({ asset: asset.baseAsset, unsignedTx: txStep.unsignedTx, gasPrice })
210
+ )
211
+
212
+ if (transactions.length === 0) return
213
+
214
+ const simulationResponse = await asset.baseAsset.api.web3.simulateTransactions({
215
+ baseAssetName: asset.baseAsset.name,
216
+ origin: 'exodus-staking',
217
+ senderAddress,
218
+ ...(blockNumber === undefined ? undefined : { blockNumber }),
219
+ transactions,
220
+ })
221
+
222
+ if (simulationResponse?.warnings?.length || simulationResponse?.metadata?.humanReadableError) {
223
+ if (revertOnSimulationError) {
224
+ const err = new Error('StakingEthUndelegateSimulationError')
225
+ err.message = simulationResponse?.metadata?.humanReadableError
226
+ err.reason = `warnings: ${JSON.stringify(simulationResponse?.warnings)}`
227
+ err.hint = 'undelegate-eth-simulation-error'
228
+ throw err
229
+ } else {
230
+ console.warn(
231
+ 'Simulation for ETH undelegate returned issues',
232
+ simulationResponse?.metadata?.humanReadableError
233
+ )
234
+ }
235
+ }
236
+
237
+ // Validate undelegatePending simulation results.
238
+ //
239
+ // Pass: every ETH entry in willReceive has a parseable balance and their
240
+ // sum is positive (i.e. we are actually receiving ETH back).
241
+ // Fail: any ETH entry has a malformed balance (shape error), or the
242
+ // summed ETH is zero/negative (no refund, or only non-ETH entries).
243
+ //
244
+ // Our on-chain analysis showed an apparent invariant: successful
245
+ // unstakePending() calls return exactly the input amount. We intentionally
246
+ // do not enforce that here because we were not able to prove it via
247
+ // simulation alone and there may be an off-chain component involved.
248
+ //
249
+ // When there's no pending balance (only unstake with
250
+ // allowedInterchangeNum=0), nothing may come back immediately — that's
251
+ // valid and we skip the check.
252
+ if (txSteps.undelegatePending.plan?.amount?.isPositive) {
253
+ const { balanceChanges = Object.create(null) } = simulationResponse || Object.create(null)
254
+
255
+ const asArray = (v) => (Array.isArray(v) ? v : [])
256
+ let invalidEthReceiveValue = false
257
+ let sumEthReceive = BigInt(0)
258
+
259
+ for (const receive of asArray(balanceChanges.willReceive)) {
260
+ const isEth = receive?.asset?.symbol === 'ETH' || receive?.asset?.name === 'Ether'
261
+ if (!isEth) continue
262
+
263
+ try {
264
+ sumEthReceive += BigInt(receive.balance.toBaseString())
265
+ } catch {
266
+ invalidEthReceiveValue = true
267
+ }
268
+ }
269
+
270
+ if (invalidEthReceiveValue || sumEthReceive <= BigInt(0)) {
271
+ if (revertOnSimulationError) {
272
+ const err = new Error('StakingEthUndelegateSimulationResultsError')
273
+ err.message = 'Simulation did not produce expected effects'
274
+ err.reason = invalidEthReceiveValue
275
+ ? 'invalid ETH balance.value in balanceChanges: willReceive'
276
+ : 'missing positive ETH in balanceChanges: willReceive (expected pending refund)'
277
+ err.hint = 'undelegate-eth-simulation-results-error'
278
+ throw err
279
+ } else {
280
+ console.warn(
281
+ 'Simulation for ETH undelegate returned issues',
282
+ simulationResponse?.metadata?.humanReadableError
283
+ )
284
+ }
285
+ }
286
+ }
287
+ }
@@ -8,6 +8,12 @@ import {
8
8
  import { bufferToHex } from '@exodus/ethereumjs/util'
9
9
  import assert from 'minimalistic-assert'
10
10
 
11
+ import { EthereumStaking } from '../staking/ethereum/api.js'
12
+ import {
13
+ ethereumUnstakeOptimisticSideEffectTxLogs,
14
+ ethereumUnstakePendingOptimisticSideEffectTxLogs,
15
+ isEthereumStakingPoolContract,
16
+ } from '../staking/ethereum/staking-utils.js'
11
17
  import { MaticStakingApi } from '../staking/matic/api.js'
12
18
  import {
13
19
  DELEGATE,
@@ -93,6 +99,7 @@ export const getOptimisticTxLogEffects = async ({
93
99
  data,
94
100
  date,
95
101
  bundleId,
102
+ fromAddress,
96
103
  })
97
104
 
98
105
  // Fallback to basic logic that only handles transfers + fee decoding
@@ -167,6 +174,7 @@ const operationTxLogSideEffects = async ({
167
174
  data,
168
175
  date,
169
176
  bundleId,
177
+ fromAddress,
170
178
  }) => {
171
179
  // Matic Delegate
172
180
  if (
@@ -188,6 +196,47 @@ const operationTxLogSideEffects = async ({
188
196
  })
189
197
  }
190
198
 
199
+ // Ethereum Undelegate
200
+ if (
201
+ isEthereumStakingPoolContract(txToAddress) &&
202
+ methodId === EthereumStaking.METHODS_IDS.UNSTAKE_PENDING
203
+ ) {
204
+ return ethereumUnstakePendingOptimisticSideEffectTxLogs({
205
+ asset,
206
+ walletAccount,
207
+ feeAmount,
208
+ unstakePendingTxId: txId,
209
+ nonce,
210
+ estimatedUnstakePendingGasLimit: gasLimit,
211
+ tipGasPrice,
212
+ transactionData: data,
213
+ txToAddress,
214
+ date,
215
+ bundleId,
216
+ })
217
+ }
218
+
219
+ // Ethereum Undelegate (unstake from active validator)
220
+ if (
221
+ isEthereumStakingPoolContract(txToAddress) &&
222
+ methodId === EthereumStaking.METHODS_IDS.UNSTAKE
223
+ ) {
224
+ return ethereumUnstakeOptimisticSideEffectTxLogs({
225
+ asset,
226
+ walletAccount,
227
+ feeAmount,
228
+ unstakeTxId: txId,
229
+ nonce,
230
+ estimatedUnstakeGasLimit: gasLimit,
231
+ tipGasPrice,
232
+ transactionData: data,
233
+ txToAddress,
234
+ fromAddress,
235
+ date,
236
+ bundleId,
237
+ })
238
+ }
239
+
191
240
  // Add other officially supported operations here
192
241
  // Then can fallback to simulation results (TODO)
193
242
  // Then finally to the default basic logic
@@ -35,7 +35,8 @@ export const assertCriticalTxAttributes = (criticalTxAttributes) => {
35
35
 
36
36
  assert(amount instanceof NumberUnit, 'expected NumberUnit amount')
37
37
  assert(
38
- typeof txInput === 'string' && txInput.startsWith('0x', 'expected hexadecimal string txInput')
38
+ typeof txInput === 'string' && txInput.startsWith('0x'),
39
+ 'expected hexadecimal string txInput'
39
40
  )
40
41
  assert(isValidTxType(txType), 'expected valid txType')
41
42
  assert(txValue instanceof NumberUnit, 'expected NumberUnit txValue')