@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 +18 -0
- package/package.json +3 -3
- package/src/staking/ethereum/api.js +21 -2
- package/src/staking/ethereum/everstake.js +53 -0
- package/src/staking/ethereum/service.js +382 -141
- package/src/staking/ethereum/staking-utils.js +268 -1
- package/src/tx-log/get-optimistic-txlog-effects.js +49 -0
- package/src/tx-type/index.js +2 -1
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.
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
233
|
-
|
|
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
|
|
131
|
-
|
|
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
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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:
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
331
|
+
const undelegateResult = await getUndelegateData({
|
|
332
|
+
staking,
|
|
232
333
|
delegatorAddress,
|
|
233
|
-
|
|
334
|
+
requestedAmount,
|
|
234
335
|
pendingAmount,
|
|
235
|
-
|
|
236
|
-
|
|
336
|
+
resolvedFeeData: feeData,
|
|
337
|
+
estimateTxFee,
|
|
237
338
|
})
|
|
238
339
|
|
|
239
|
-
|
|
240
|
-
|
|
340
|
+
const pendingFee = pendingResult?.feeInfo?.fee ?? asset.currency.ZERO
|
|
341
|
+
const undelegateFee = undelegateResult?.feeInfo?.fee ?? asset.currency.ZERO
|
|
241
342
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const staking = getStakingApi(asset)
|
|
245
|
-
const minAmount = getMinAmount(asset)
|
|
343
|
+
return pendingFee.add(undelegateFee)
|
|
344
|
+
}
|
|
246
345
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
|
|
363
|
+
if (!requestedAmount.isPositive) {
|
|
364
|
+
throw new Error('Undelegate amount must be positive')
|
|
365
|
+
}
|
|
256
366
|
|
|
257
|
-
const
|
|
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 ${
|
|
385
|
+
`delegator address ${delegatorAddress} unstaking ${requestedAmount.toDefaultString({
|
|
261
386
|
unit: true,
|
|
262
387
|
})} - pending amount: ${pendingAmount.toDefaultString({ unit: true })}`
|
|
263
388
|
)
|
|
264
389
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
401
|
+
const undelegateResult = await getUndelegateData({
|
|
402
|
+
staking,
|
|
403
|
+
delegatorAddress,
|
|
404
|
+
requestedAmount,
|
|
405
|
+
pendingAmount,
|
|
406
|
+
resolvedFeeData,
|
|
407
|
+
estimateTxFee,
|
|
408
|
+
})
|
|
282
409
|
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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 (
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/src/tx-type/index.js
CHANGED
|
@@ -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',
|
|
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')
|