@exodus/ethereum-api 9.0.3 → 10.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/package.json +2 -2
- package/src/create-asset-utils.js +6 -16
- package/src/create-asset.js +32 -10
- package/src/create-token-factory.js +18 -7
- package/src/error-wrapper.js +9 -1
- package/src/eth-like-util.js +7 -2
- package/src/exodus-eth-server/index.js +0 -4
- package/src/get-balance-for-address.js +1 -1
- package/src/index.js +15 -1
- package/src/nft-utils.js +6 -2
- package/src/server-based-fee-monitor.js +1 -1
- package/src/staking/api/index.js +2 -1
- package/src/staking/ethereum/api.js +2 -3
- package/src/staking/ethereum/deps.js +3 -0
- package/src/staking/ethereum/min-amount.js +1 -0
- package/src/staking/ethereum/service.js +148 -123
- package/src/staking/ethereum/staking-adapter.js +382 -0
- package/src/staking/evm/compose-staking-api.js +42 -0
- package/src/staking/evm/create-evm-staking-api.js +269 -0
- package/src/staking/evm/staking-errors.js +37 -0
- package/src/staking/evm/staking-txs.js +107 -0
- package/src/staking/matic/index.js +1 -0
- package/src/tx-log/index.js +0 -1
- package/src/tx-log/monitor-utils/index.js +0 -2
- package/src/exodus-eth-server/api.js +0 -276
- package/src/exodus-eth-server/ws.js +0 -126
- package/src/tx-log/ethereum-monitor.js +0 -351
- package/src/tx-log/monitor-utils/get-history-from-server.js +0 -43
- package/src/tx-log/monitor-utils/get-log-items-from-server-tx.js +0 -203
- package/src/tx-log/monitor-utils/get-names-of-tokens-transferred-by-server-tx.js +0 -40
- package/src/tx-log/ws-updates.js +0 -71
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { parseUnsignedTx } from '@exodus/ethereum-lib'
|
|
2
|
+
import { TxSet } from '@exodus/models'
|
|
3
|
+
import assert from 'minimalistic-assert'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createRejectionError,
|
|
7
|
+
fail,
|
|
8
|
+
pass,
|
|
9
|
+
sumOperationFees,
|
|
10
|
+
warnIfBelow,
|
|
11
|
+
} from '../evm/staking-errors.js'
|
|
12
|
+
import { stakingProviderClientFactory } from '../staking-provider-client.js'
|
|
13
|
+
import { EthereumStaking } from './api.js'
|
|
14
|
+
import { ETH_STAKING_MIN_AMOUNT } from './min-amount.js'
|
|
15
|
+
import {
|
|
16
|
+
decodeEthereumStakingFirstUintArg,
|
|
17
|
+
isEthereumClaimUndelegate,
|
|
18
|
+
isEthereumDelegate,
|
|
19
|
+
isEthereumStakingTx,
|
|
20
|
+
isEthereumUndelegate,
|
|
21
|
+
isEthereumUndelegatePending,
|
|
22
|
+
} from './staking-utils.js'
|
|
23
|
+
|
|
24
|
+
export const ETH_STAKING_GAS_LIMITS = Object.freeze({
|
|
25
|
+
stake: 500_000,
|
|
26
|
+
unstakeStep: 400_000,
|
|
27
|
+
claimUnstaked: 350_000,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const VALIDATOR_NAME = 'Everstake'
|
|
31
|
+
const NOTIFIED_ASSET_NAME = 'ethereum'
|
|
32
|
+
|
|
33
|
+
const getPoolAddress = (asset) => {
|
|
34
|
+
const addresses = EthereumStaking.addresses[asset.name]
|
|
35
|
+
assert(addresses, `no Everstake pool for ${asset.name}`)
|
|
36
|
+
return addresses.EVERSTAKE_ADDRESS_CONTRACT_POOL.toLowerCase()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const getLatestTxTime = ({ txLog, matches }) => {
|
|
40
|
+
let latest
|
|
41
|
+
for (const tx of txLog) {
|
|
42
|
+
if (!matches(tx)) continue
|
|
43
|
+
const time = new Date(tx.date).getTime()
|
|
44
|
+
if (latest === undefined || time > latest) latest = time
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return latest
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const addDuration = (time, ...durations) =>
|
|
51
|
+
time === undefined || durations.some((duration) => typeof duration !== 'number')
|
|
52
|
+
? undefined
|
|
53
|
+
: durations.reduce((total, duration) => total + duration, time)
|
|
54
|
+
|
|
55
|
+
const toAmount = (asset, value) =>
|
|
56
|
+
typeof value === 'string' ? asset.currency.defaultUnit(value).abs() : undefined
|
|
57
|
+
|
|
58
|
+
const isUnstakeTx = (tx) =>
|
|
59
|
+
Boolean(tx.data?.undelegate || tx.data?.undelegatePending) || isEthereumUndelegate(tx)
|
|
60
|
+
|
|
61
|
+
const isClaimTx = (tx) => Boolean(tx.data?.claimUndelegate) || isEthereumClaimUndelegate(tx)
|
|
62
|
+
|
|
63
|
+
const countUnstakeSteps = ({ state, amount }) => {
|
|
64
|
+
const { pendingBalance, minAmount } = state
|
|
65
|
+
const leftOver = pendingBalance.sub(amount)
|
|
66
|
+
const hasPendingStep =
|
|
67
|
+
pendingBalance.isPositive && !(leftOver.isPositive && leftOver.lt(minAmount))
|
|
68
|
+
const hasActiveStep = amount.sub(pendingBalance).isPositive
|
|
69
|
+
return Math.max(Number(hasPendingStep) + Number(hasActiveStep), 1)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const resolveUnstakeAmount = ({ state, intent }) =>
|
|
73
|
+
intent.isMax ? state.unstakeable : intent.amount
|
|
74
|
+
|
|
75
|
+
export const createEverstakeStakingAdapter = ({
|
|
76
|
+
service,
|
|
77
|
+
stakingProvider = stakingProviderClientFactory(),
|
|
78
|
+
}) => {
|
|
79
|
+
assert(service, '`service` required')
|
|
80
|
+
|
|
81
|
+
const toTarget = (asset) => ({ kind: 'pool', id: getPoolAddress(asset), name: VALIDATOR_NAME })
|
|
82
|
+
|
|
83
|
+
const getCapabilities = ({ asset }) => ({
|
|
84
|
+
participation: 'amount',
|
|
85
|
+
liquid: false,
|
|
86
|
+
positions: 'aggregate',
|
|
87
|
+
operations: {
|
|
88
|
+
stake: { amount: 'required', supportsMax: true, positionSelectable: false, multiTx: false },
|
|
89
|
+
unstake: { amount: 'required', supportsMax: true, positionSelectable: false, multiTx: true },
|
|
90
|
+
claimUnstaked: {
|
|
91
|
+
amount: 'none',
|
|
92
|
+
supportsMax: false,
|
|
93
|
+
positionSelectable: false,
|
|
94
|
+
multiTx: false,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
target: { kind: 'pool', selection: 'fixed', default: toTarget(asset) },
|
|
98
|
+
unbonding: { kind: 'queue' },
|
|
99
|
+
claimUnstaked: 'manual',
|
|
100
|
+
rewards: { distribution: 'autoCompound' },
|
|
101
|
+
feeAssetName: asset.feeAsset.name,
|
|
102
|
+
hardwareWallets: ['ledger'],
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const toPositions = ({ asset, balances, info, txLog }) => {
|
|
106
|
+
const stakedAt = getLatestTxTime({ txLog, matches: isEthereumDelegate })
|
|
107
|
+
const unstakedAt = getLatestTxTime({
|
|
108
|
+
txLog,
|
|
109
|
+
matches: (tx) => isUnstakeTx(tx) && !isEthereumUndelegatePending(tx),
|
|
110
|
+
})
|
|
111
|
+
const activatesAt = addDuration(stakedAt, info.validatorActivationTime)
|
|
112
|
+
const withdrawableAt = addDuration(
|
|
113
|
+
unstakedAt,
|
|
114
|
+
info.validatorExitTime,
|
|
115
|
+
info.validatorWithdrawTime ?? 0
|
|
116
|
+
)
|
|
117
|
+
const position = (id, status, amount, availableAt) =>
|
|
118
|
+
amount.isPositive
|
|
119
|
+
? [{ id, status, amount, target: toTarget(asset), ...(availableAt && { availableAt }) }]
|
|
120
|
+
: []
|
|
121
|
+
|
|
122
|
+
return [
|
|
123
|
+
...position('pending', 'activating', balances.staking, activatesAt),
|
|
124
|
+
...position('active', 'active', balances.staked),
|
|
125
|
+
...position('withdraw-request-pending', 'deactivating', balances.unstaking, withdrawableAt),
|
|
126
|
+
...position('withdraw-request-ready', 'withdrawable', balances.unstaked),
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const readState = ({ asset, accountState, txLog = TxSet.EMPTY }) => {
|
|
131
|
+
const zero = asset.currency.ZERO
|
|
132
|
+
const info = accountState.staking?.[asset.name] || {}
|
|
133
|
+
const loaded = Boolean(accountState.stakingSnapshotOwners?.[asset.name])
|
|
134
|
+
const balances = asset.api.getBalances({ asset, accountState, txLog })
|
|
135
|
+
const pendingBalance = info.pendingBalance || zero
|
|
136
|
+
const activeStakedBalance = info.activeStakedBalance || zero
|
|
137
|
+
const { staking, staked, unstaking, unstaked } = balances
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
loaded,
|
|
141
|
+
isStaking: staked.add(staking).isPositive,
|
|
142
|
+
hasPosition: [staking, staked, unstaking, unstaked].some((amount) => amount.isPositive),
|
|
143
|
+
totals: { staking, staked, unstaking, unstaked, rewards: zero },
|
|
144
|
+
...(loaded && info.rewardsBalance && { earned: info.rewardsBalance }),
|
|
145
|
+
positions: toPositions({ asset, balances, info, txLog }),
|
|
146
|
+
target: toTarget(asset),
|
|
147
|
+
balances,
|
|
148
|
+
minAmount: asset.currency.defaultUnit(ETH_STAKING_MIN_AMOUNT),
|
|
149
|
+
pendingBalance,
|
|
150
|
+
unstakeable: activeStakedBalance.add(pendingBalance),
|
|
151
|
+
unclaimedUndelegatedBalance: info.unclaimedUndelegatedBalance || zero,
|
|
152
|
+
canClaimUndelegatedBalance: Boolean(info.canClaimUndelegatedBalance),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const getGasLimit = ({ operation, state, intent }) => {
|
|
157
|
+
switch (operation) {
|
|
158
|
+
case 'stake':
|
|
159
|
+
return ETH_STAKING_GAS_LIMITS.stake
|
|
160
|
+
case 'unstake':
|
|
161
|
+
return (
|
|
162
|
+
countUnstakeSteps({
|
|
163
|
+
state,
|
|
164
|
+
amount: resolveUnstakeAmount({ state, intent }) ?? state.unstakeable,
|
|
165
|
+
}) * ETH_STAKING_GAS_LIMITS.unstakeStep
|
|
166
|
+
)
|
|
167
|
+
case 'claimUnstaked':
|
|
168
|
+
return ETH_STAKING_GAS_LIMITS.claimUnstaked
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const getLimits = ({ asset, operation, state, fee = asset.currency.ZERO }) => {
|
|
173
|
+
switch (operation) {
|
|
174
|
+
case 'stake':
|
|
175
|
+
return {
|
|
176
|
+
min: state.minAmount,
|
|
177
|
+
max: state.balances.spendable.sub(fee).clampLowerZero(),
|
|
178
|
+
}
|
|
179
|
+
case 'unstake':
|
|
180
|
+
return { min: state.minAmount, max: state.unstakeable }
|
|
181
|
+
case 'claimUnstaked':
|
|
182
|
+
return { max: state.balances.unstaked }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const checkFee = ({ asset, state, fee }) =>
|
|
187
|
+
fee && state.balances.spendable.lt(fee)
|
|
188
|
+
? fail('INSUFFICIENT_FEE_BALANCE', fee.sub(state.balances.spendable), asset.feeAsset.name)
|
|
189
|
+
: pass()
|
|
190
|
+
|
|
191
|
+
const checkStake = ({
|
|
192
|
+
asset,
|
|
193
|
+
intent,
|
|
194
|
+
state,
|
|
195
|
+
fee = asset.currency.ZERO,
|
|
196
|
+
estimateOperationFee,
|
|
197
|
+
}) => {
|
|
198
|
+
const { spendable } = state.balances
|
|
199
|
+
const stakeAmount = intent.isMax ? spendable.sub(fee).clampLowerZero() : intent.amount
|
|
200
|
+
if (!stakeAmount) return fail('BELOW_MINIMUM', state.minAmount, asset.name)
|
|
201
|
+
if (stakeAmount.lt(state.minAmount)) {
|
|
202
|
+
return fail('BELOW_MINIMUM', state.minAmount.sub(stakeAmount), asset.name)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const required = stakeAmount.add(fee)
|
|
206
|
+
if (spendable.lt(required)) {
|
|
207
|
+
return fail('INSUFFICIENT_BALANCE', required.sub(spendable), asset.name)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return warnIfBelow({
|
|
211
|
+
balance: spendable.sub(required),
|
|
212
|
+
required: sumOperationFees({
|
|
213
|
+
estimateOperationFee,
|
|
214
|
+
operations: ['unstake', 'claimUnstaked'],
|
|
215
|
+
}),
|
|
216
|
+
warning: 'LOW_FEE_BALANCE',
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const checkUnstake = ({ asset, intent, state, fee, estimateOperationFee }) => {
|
|
221
|
+
const { unstakeable, minAmount } = state
|
|
222
|
+
if (!unstakeable.isPositive) return fail('NOTHING_TO_UNSTAKE')
|
|
223
|
+
|
|
224
|
+
const amount = resolveUnstakeAmount({ state, intent })
|
|
225
|
+
if (!amount) return fail('UNSTAKE_BELOW_MINIMUM', minAmount, asset.name)
|
|
226
|
+
if (amount.lt(minAmount)) {
|
|
227
|
+
return fail('UNSTAKE_BELOW_MINIMUM', minAmount.sub(amount), asset.name)
|
|
228
|
+
}
|
|
229
|
+
if (amount.gt(unstakeable)) {
|
|
230
|
+
return fail('UNSTAKE_EXCEEDS_STAKED', amount.sub(unstakeable), asset.name)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const remainder = unstakeable.sub(amount)
|
|
234
|
+
if (remainder.isPositive && remainder.lt(minAmount)) {
|
|
235
|
+
return fail('REMAINING_STAKE_BELOW_MINIMUM', minAmount.sub(remainder), asset.name)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const feeCheck = checkFee({ asset, state, fee })
|
|
239
|
+
if (!feeCheck.ok || !fee) return feeCheck
|
|
240
|
+
|
|
241
|
+
return warnIfBelow({
|
|
242
|
+
balance: state.balances.spendable.sub(fee),
|
|
243
|
+
required: sumOperationFees({ estimateOperationFee, operations: ['claimUnstaked'] }),
|
|
244
|
+
warning: 'LOW_FEE_BALANCE_FOR_CLAIM',
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const checkClaim = ({ asset, state, fee }) => {
|
|
249
|
+
if (!state.unclaimedUndelegatedBalance.isPositive) return fail('NOTHING_TO_CLAIM')
|
|
250
|
+
if (!state.canClaimUndelegatedBalance) return fail('UNBONDING_IN_PROGRESS')
|
|
251
|
+
return checkFee({ asset, state, fee })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const checkOperation = ({ asset, operation, intent, state, fee, estimateOperationFee }) => {
|
|
255
|
+
switch (operation) {
|
|
256
|
+
case 'stake':
|
|
257
|
+
return checkStake({ asset, intent, state, fee, estimateOperationFee })
|
|
258
|
+
case 'unstake':
|
|
259
|
+
return checkUnstake({ asset, intent, state, fee, estimateOperationFee })
|
|
260
|
+
case 'claimUnstaked':
|
|
261
|
+
return checkClaim({ asset, state, fee })
|
|
262
|
+
default:
|
|
263
|
+
return fail('OPERATION_UNSUPPORTED')
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const rejectUnless = ({ operation, check }) => {
|
|
268
|
+
if (!check.ok) throw createRejectionError({ operation, check })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const buildStake = async ({ asset, walletAccount, intent, state, feeData }) => {
|
|
272
|
+
const plan = await service.planDelegate({
|
|
273
|
+
walletAccount,
|
|
274
|
+
amount: intent.isMax ? state.balances.spendable : intent.amount,
|
|
275
|
+
feeData,
|
|
276
|
+
isDelegateAll: Boolean(intent.isMax),
|
|
277
|
+
})
|
|
278
|
+
rejectUnless({
|
|
279
|
+
operation: 'stake',
|
|
280
|
+
check: checkStake({ asset, intent: { amount: plan.amount }, state, fee: plan.fee }),
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
return plan
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const buildUnstake = async ({ asset, walletAccount, intent, state, feeData }) => {
|
|
287
|
+
const plan = await service.planUndelegate({
|
|
288
|
+
walletAccount,
|
|
289
|
+
amount: resolveUnstakeAmount({ state, intent }),
|
|
290
|
+
feeData,
|
|
291
|
+
})
|
|
292
|
+
rejectUnless({ operation: 'unstake', check: checkFee({ asset, state, fee: plan.fee }) })
|
|
293
|
+
return plan
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const buildClaim = async ({ asset, walletAccount, state, feeData }) => {
|
|
297
|
+
const plan = await service.planClaimUndelegatedBalance({ walletAccount, feeData })
|
|
298
|
+
if (!plan) {
|
|
299
|
+
throw createRejectionError({ operation: 'claimUnstaked', check: fail('NOTHING_TO_CLAIM') })
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
rejectUnless({ operation: 'claimUnstaked', check: checkFee({ asset, state, fee: plan.fee }) })
|
|
303
|
+
return { ...plan, amount: state.unclaimedUndelegatedBalance }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const buildSteps = async ({ asset, walletAccount, operation, intent, state, feeData }) => {
|
|
307
|
+
switch (operation) {
|
|
308
|
+
case 'stake':
|
|
309
|
+
return buildStake({ asset, walletAccount, intent, state, feeData })
|
|
310
|
+
case 'unstake':
|
|
311
|
+
return buildUnstake({ asset, walletAccount, intent, state, feeData })
|
|
312
|
+
case 'claimUnstaked':
|
|
313
|
+
return buildClaim({ asset, walletAccount, state, feeData })
|
|
314
|
+
default:
|
|
315
|
+
throw new Error(`Unsupported ethereum staking operation: ${operation}`)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const simulate = async ({ operation, built, unsignedTxs }) => {
|
|
320
|
+
if (operation !== 'unstake') return
|
|
321
|
+
await service.simulateUndelegate({ plan: built, unsignedTxs })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const notify = async ({ asset, plan, txIds, delegator }) => {
|
|
325
|
+
if (asset.name !== NOTIFIED_ASSET_NAME) return
|
|
326
|
+
|
|
327
|
+
if (plan.operation === 'stake') {
|
|
328
|
+
await stakingProvider.notifyStaking({
|
|
329
|
+
txId: txIds[0],
|
|
330
|
+
asset: asset.name,
|
|
331
|
+
delegator,
|
|
332
|
+
amount: plan.amount.toBaseString(),
|
|
333
|
+
})
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (plan.operation !== 'unstake') return
|
|
338
|
+
await Promise.all(
|
|
339
|
+
plan.txs.map((unsignedTx, index) => {
|
|
340
|
+
const { data } = parseUnsignedTx({ asset, unsignedTx })
|
|
341
|
+
return stakingProvider.notifyUnstaking({
|
|
342
|
+
txId: txIds[index],
|
|
343
|
+
asset: asset.name,
|
|
344
|
+
delegator,
|
|
345
|
+
amount: decodeEthereumStakingFirstUintArg(data).toString(),
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const classifyTx = ({ asset, tx }) => {
|
|
352
|
+
if (!isEthereumStakingTx(tx)) return
|
|
353
|
+
|
|
354
|
+
const data = tx.data || {}
|
|
355
|
+
if (isEthereumDelegate(tx)) {
|
|
356
|
+
const amount = toAmount(asset, data.delegate) ?? tx.coinAmount?.abs()
|
|
357
|
+
return { operation: 'stake', ...(amount && { amount }) }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (isUnstakeTx(tx)) {
|
|
361
|
+
const amount = toAmount(asset, data.undelegate) ?? toAmount(asset, data.undelegatePending)
|
|
362
|
+
return { operation: 'unstake', ...(amount && { amount }) }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (isClaimTx(tx)) {
|
|
366
|
+
const amount = toAmount(asset, data.claimUndelegate)
|
|
367
|
+
return { operation: 'claimUnstaked', ...(amount && { amount }) }
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
getCapabilities,
|
|
373
|
+
readState,
|
|
374
|
+
getGasLimit,
|
|
375
|
+
getLimits,
|
|
376
|
+
checkOperation,
|
|
377
|
+
buildSteps,
|
|
378
|
+
simulate,
|
|
379
|
+
notify,
|
|
380
|
+
classifyTx,
|
|
381
|
+
}
|
|
382
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from 'minimalistic-assert'
|
|
2
|
+
|
|
3
|
+
import { createEvmStakingApi } from './create-evm-staking-api.js'
|
|
4
|
+
|
|
5
|
+
export const isStakingApiEnabled = (stakingDependencies) =>
|
|
6
|
+
Boolean(stakingDependencies?.createStakingAdapter) &&
|
|
7
|
+
stakingDependencies.stakingApiEnabled !== false
|
|
8
|
+
|
|
9
|
+
export const applyStakingApiOverrides = ({
|
|
10
|
+
stakingDependencies,
|
|
11
|
+
overrides = Object.create(null),
|
|
12
|
+
}) => {
|
|
13
|
+
for (const [assetName, enabled] of Object.entries(overrides)) {
|
|
14
|
+
assert(typeof enabled === 'boolean', `stakingApiEnabled.${assetName} must be a boolean`)
|
|
15
|
+
assert(stakingDependencies[assetName], `${assetName} has no staking dependencies`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return Object.fromEntries(
|
|
19
|
+
Object.entries(stakingDependencies).map(([assetName, dependencies]) => [
|
|
20
|
+
assetName,
|
|
21
|
+
assetName in overrides
|
|
22
|
+
? { ...dependencies, stakingApiEnabled: overrides[assetName] }
|
|
23
|
+
: dependencies,
|
|
24
|
+
])
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const composeStakingApi = ({
|
|
29
|
+
assetClientInterface,
|
|
30
|
+
legacyStakingApi,
|
|
31
|
+
stakingDependencies,
|
|
32
|
+
}) => {
|
|
33
|
+
if (!isStakingApiEnabled(stakingDependencies)) return legacyStakingApi
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
...legacyStakingApi,
|
|
37
|
+
...createEvmStakingApi({
|
|
38
|
+
assetClientInterface,
|
|
39
|
+
adapter: stakingDependencies.createStakingAdapter({ service: legacyStakingApi }),
|
|
40
|
+
}),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { isPendingTxInLog, parseUnsignedTx } from '@exodus/ethereum-lib'
|
|
2
|
+
import { TxSet } from '@exodus/models'
|
|
3
|
+
import assert from 'minimalistic-assert'
|
|
4
|
+
import ms from 'ms'
|
|
5
|
+
|
|
6
|
+
import { createRejectionError, createSubmitError, fail } from './staking-errors.js'
|
|
7
|
+
import { createStakingTxs, sendStakingTxs, STAKING_TX_MODE } from './staking-txs.js'
|
|
8
|
+
|
|
9
|
+
const PLAN_TTL_MS = ms('60s')
|
|
10
|
+
|
|
11
|
+
const SUBMIT_BY_MODE = {
|
|
12
|
+
[STAKING_TX_MODE.single]: 'single',
|
|
13
|
+
[STAKING_TX_MODE.bundle]: 'bundle',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const toIntent = ({ amount, isMax, positionIds, target, enabled }) =>
|
|
17
|
+
Object.fromEntries(
|
|
18
|
+
Object.entries({ amount, isMax, positionIds, target, enabled }).filter(
|
|
19
|
+
([, value]) => value !== undefined
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const MODE_BY_SUBMIT = {
|
|
24
|
+
single: STAKING_TX_MODE.single,
|
|
25
|
+
bundle: STAKING_TX_MODE.bundle,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const STATE_KEYS = [
|
|
29
|
+
'loaded',
|
|
30
|
+
'isStaking',
|
|
31
|
+
'hasPosition',
|
|
32
|
+
'totals',
|
|
33
|
+
'earned',
|
|
34
|
+
'nextRewardAt',
|
|
35
|
+
'positions',
|
|
36
|
+
'target',
|
|
37
|
+
'autoCompound',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const pickStateKeys = (fullState) =>
|
|
41
|
+
Object.fromEntries(
|
|
42
|
+
STATE_KEYS.filter((key) => fullState[key] !== undefined).map((key) => [key, fullState[key]])
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
export const createEvmStakingApi = ({ assetClientInterface, adapter }) => {
|
|
46
|
+
assert(assetClientInterface, '`assetClientInterface` required')
|
|
47
|
+
assert(adapter, '`adapter` required')
|
|
48
|
+
|
|
49
|
+
const getCapabilities = ({ asset }) => adapter.getCapabilities({ asset })
|
|
50
|
+
|
|
51
|
+
const isDeclared = ({ asset, operation }) =>
|
|
52
|
+
Boolean(getCapabilities({ asset }).operations[operation])
|
|
53
|
+
|
|
54
|
+
const findPendingOperation = ({ asset, txLog }) => {
|
|
55
|
+
for (const tx of txLog ?? []) {
|
|
56
|
+
if (!isPendingTxInLog(tx)) continue
|
|
57
|
+
const classification = adapter.classifyTx({ asset, tx })
|
|
58
|
+
if (classification && classification.operation !== 'reward') return classification.operation
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const getPendingOperation = ({ asset, txLog, feeAssetTxLog, state }) =>
|
|
63
|
+
state.pendingOperation ??
|
|
64
|
+
findPendingOperation({ asset, txLog }) ??
|
|
65
|
+
findPendingOperation({ asset, txLog: feeAssetTxLog })
|
|
66
|
+
|
|
67
|
+
const estimateFee = ({ asset, feeData, operation, state, intent }) => {
|
|
68
|
+
const gasLimit = adapter.getGasLimit({ asset, operation, state, intent })
|
|
69
|
+
if (!gasLimit) return
|
|
70
|
+
|
|
71
|
+
const { feeAsset } = asset
|
|
72
|
+
return feeAsset.api.getFee({
|
|
73
|
+
asset: feeAsset,
|
|
74
|
+
feeData,
|
|
75
|
+
gasLimit,
|
|
76
|
+
amount: feeAsset.currency.ZERO,
|
|
77
|
+
}).fee
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const readState = ({ asset, accountState, txLog = TxSet.EMPTY, feeAssetTxLog }) =>
|
|
81
|
+
adapter.readState({ asset, accountState, txLog, feeAssetTxLog })
|
|
82
|
+
|
|
83
|
+
const getStakingState = ({ asset, accountState, txLog, feeAssetTxLog }) => {
|
|
84
|
+
const fullState = readState({ asset, accountState, txLog, feeAssetTxLog })
|
|
85
|
+
const pendingOperation = getPendingOperation({ asset, txLog, feeAssetTxLog, state: fullState })
|
|
86
|
+
return { ...pickStateKeys(fullState), ...(pendingOperation && { pendingOperation }) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const getLimits = ({ asset, accountState, txLog, feeAssetTxLog, feeData, operation }) => {
|
|
90
|
+
if (!isDeclared({ asset, operation })) return { max: asset.currency.ZERO }
|
|
91
|
+
|
|
92
|
+
const state = readState({ asset, accountState, txLog, feeAssetTxLog })
|
|
93
|
+
const fee = estimateFee({ asset, feeData, operation, state, intent: {} })
|
|
94
|
+
return {
|
|
95
|
+
...adapter.getLimits({ asset, operation, state, fee }),
|
|
96
|
+
...(fee && { estimatedFee: fee }),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const canPerform = ({
|
|
101
|
+
asset,
|
|
102
|
+
accountState,
|
|
103
|
+
txLog,
|
|
104
|
+
feeAssetTxLog,
|
|
105
|
+
feeData,
|
|
106
|
+
operation,
|
|
107
|
+
...params
|
|
108
|
+
}) => {
|
|
109
|
+
const intent = toIntent(params)
|
|
110
|
+
if (!isDeclared({ asset, operation })) return fail('OPERATION_UNSUPPORTED')
|
|
111
|
+
|
|
112
|
+
const state = readState({ asset, accountState, txLog, feeAssetTxLog })
|
|
113
|
+
if (!state.loaded) return fail('NOT_LOADED')
|
|
114
|
+
if (getPendingOperation({ asset, txLog, feeAssetTxLog, state })) {
|
|
115
|
+
return fail('PENDING_TRANSACTION')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const fee = estimateFee({ asset, feeData, operation, state, intent })
|
|
119
|
+
const estimateOperationFee = (laterOperation) =>
|
|
120
|
+
estimateFee({ asset, feeData, operation: laterOperation, state, intent: {} })
|
|
121
|
+
return adapter.checkOperation({ asset, operation, intent, state, fee, estimateOperationFee })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const classifyTx = ({ asset, tx }) => adapter.classifyTx({ asset, tx })
|
|
125
|
+
|
|
126
|
+
const loadState = async ({ asset, walletAccount }) => {
|
|
127
|
+
const baseAssetName = asset.baseAsset.name
|
|
128
|
+
const feeAssetName = asset.feeAsset.name
|
|
129
|
+
const [accountState, txLog, feeAssetTxLog, feeData, address] = await Promise.all([
|
|
130
|
+
assetClientInterface.getAccountState({ assetName: baseAssetName, walletAccount }),
|
|
131
|
+
assetClientInterface.getTxLog({ assetName: asset.name, walletAccount }),
|
|
132
|
+
feeAssetName === asset.name
|
|
133
|
+
? undefined
|
|
134
|
+
: assetClientInterface.getTxLog({ assetName: feeAssetName, walletAccount }),
|
|
135
|
+
assetClientInterface.getFeeData({ assetName: feeAssetName }),
|
|
136
|
+
assetClientInterface.getReceiveAddress({ assetName: baseAssetName, walletAccount }),
|
|
137
|
+
])
|
|
138
|
+
return {
|
|
139
|
+
accountState,
|
|
140
|
+
txLog,
|
|
141
|
+
feeAssetTxLog,
|
|
142
|
+
feeData,
|
|
143
|
+
address: address.toString().toLowerCase(),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const prepare = async ({ asset, walletAccount, operation, ...params }) => {
|
|
148
|
+
const intent = toIntent(params)
|
|
149
|
+
const { accountState, txLog, feeAssetTxLog, feeData, address } = await loadState({
|
|
150
|
+
asset,
|
|
151
|
+
walletAccount,
|
|
152
|
+
})
|
|
153
|
+
const check = canPerform({
|
|
154
|
+
asset,
|
|
155
|
+
accountState,
|
|
156
|
+
txLog,
|
|
157
|
+
feeAssetTxLog,
|
|
158
|
+
feeData,
|
|
159
|
+
operation,
|
|
160
|
+
...intent,
|
|
161
|
+
})
|
|
162
|
+
if (!check.ok) throw createRejectionError({ operation, check })
|
|
163
|
+
|
|
164
|
+
const state = readState({ asset, accountState, txLog, feeAssetTxLog })
|
|
165
|
+
const built = await adapter.buildSteps({
|
|
166
|
+
asset,
|
|
167
|
+
walletAccount,
|
|
168
|
+
address,
|
|
169
|
+
operation,
|
|
170
|
+
intent,
|
|
171
|
+
state,
|
|
172
|
+
feeData,
|
|
173
|
+
})
|
|
174
|
+
const createdTxs = await createStakingTxs({
|
|
175
|
+
asset: asset.baseAsset,
|
|
176
|
+
walletAccount,
|
|
177
|
+
fromAddress: address,
|
|
178
|
+
steps: built.steps,
|
|
179
|
+
mode: built.mode,
|
|
180
|
+
gasPrice: built.gasPrice,
|
|
181
|
+
tipGasPrice: built.tipGasPrice,
|
|
182
|
+
feeData,
|
|
183
|
+
})
|
|
184
|
+
const unsignedTxs = createdTxs.map(({ unsignedTx }) => unsignedTx)
|
|
185
|
+
await adapter.simulate?.({ asset, operation, built, createdTxs, unsignedTxs })
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
operation,
|
|
189
|
+
assetName: asset.name,
|
|
190
|
+
walletAccount: walletAccount.toString(),
|
|
191
|
+
txs: unsignedTxs,
|
|
192
|
+
submit: SUBMIT_BY_MODE[built.mode],
|
|
193
|
+
amount: built.amount,
|
|
194
|
+
fee: built.fee,
|
|
195
|
+
feeAssetName: asset.feeAsset.name,
|
|
196
|
+
expiresAt: Date.now() + PLAN_TTL_MS,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const notifyProvider = async ({ asset, walletAccount, plan, txIds }) => {
|
|
201
|
+
if (!adapter.notify) return
|
|
202
|
+
try {
|
|
203
|
+
const receiveAddress = await assetClientInterface.getReceiveAddress({
|
|
204
|
+
assetName: asset.baseAsset.name,
|
|
205
|
+
walletAccount,
|
|
206
|
+
})
|
|
207
|
+
const delegator = receiveAddress.toString().toLowerCase()
|
|
208
|
+
await adapter.notify({ asset, plan, txIds, delegator })
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error('Failed to notify staking service:', error)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const isPlanNonceCurrent = async ({ asset, walletAccount, plan }) => {
|
|
215
|
+
const [firstTx] = plan.txs
|
|
216
|
+
const { nonce: planNonce } = parseUnsignedTx({ asset: asset.baseAsset, unsignedTx: firstTx })
|
|
217
|
+
const nonce = await asset.baseAsset.getNonce({
|
|
218
|
+
asset: asset.baseAsset,
|
|
219
|
+
fromAddress: firstTx.txMeta.fromAddress,
|
|
220
|
+
walletAccount,
|
|
221
|
+
})
|
|
222
|
+
return nonce === planNonce
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const submit = async ({ asset, walletAccount, plan, onProgress }) => {
|
|
226
|
+
assert(plan.assetName === asset.name, 'plan was prepared for another asset')
|
|
227
|
+
assert(
|
|
228
|
+
plan.walletAccount === walletAccount.toString(),
|
|
229
|
+
'plan was prepared for another wallet account'
|
|
230
|
+
)
|
|
231
|
+
if (plan.expiresAt && Date.now() > plan.expiresAt) {
|
|
232
|
+
throw createRejectionError({ operation: plan.operation, check: fail('PLAN_EXPIRED') })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const mode = MODE_BY_SUBMIT[plan.submit]
|
|
236
|
+
assert(mode, `unsupported submit mode ${plan.submit}`)
|
|
237
|
+
|
|
238
|
+
if (!(await isPlanNonceCurrent({ asset, walletAccount, plan }))) {
|
|
239
|
+
throw createRejectionError({ operation: plan.operation, check: fail('PLAN_EXPIRED') })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let sent
|
|
243
|
+
try {
|
|
244
|
+
sent = await sendStakingTxs({
|
|
245
|
+
asset: asset.baseAsset,
|
|
246
|
+
walletAccount,
|
|
247
|
+
unsignedTxs: plan.txs,
|
|
248
|
+
mode,
|
|
249
|
+
onProgress,
|
|
250
|
+
})
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throw createSubmitError({ error, txIds: [], failedIndex: 0 })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
void notifyProvider({ asset, walletAccount, plan, txIds: sent.txIds })
|
|
256
|
+
|
|
257
|
+
return { txIds: sent.txIds, ...(sent.bundleHash && { bundleId: sent.bundleHash }) }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
getCapabilities,
|
|
262
|
+
getLimits,
|
|
263
|
+
getStakingState,
|
|
264
|
+
canPerform,
|
|
265
|
+
classifyTx,
|
|
266
|
+
prepare,
|
|
267
|
+
submit,
|
|
268
|
+
}
|
|
269
|
+
}
|