@exodus/ethereum-api 10.0.0 → 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.
@@ -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
+ }