@exodus/ethereum-plugin 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [3.1.1](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-plugin@3.1.0...@exodus/ethereum-plugin@3.1.1) (2026-10-05)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **ethereum-plugin:** require ethereum-api ^10.1.0 for staking exports ([#8859](https://github.com/ExodusMovement/assets/issues/8859)) ([fecb514](https://github.com/ExodusMovement/assets/commit/fecb5140617cd9700ad28541e720c8dfc2bbd46e))
12
+
13
+
14
+
15
+ ## [3.1.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-plugin@3.0.0...@exodus/ethereum-plugin@3.1.0) (2026-10-05)
16
+
17
+
18
+ ### Features
19
+
20
+ * **ethereum:** add evm staking api core with Everstake ETH and polygon adapters ([#8775](https://github.com/ExodusMovement/assets/issues/8775)) ([2d90106](https://github.com/ExodusMovement/assets/commit/2d901069989be82eb56aa253d5171e4f6af97356))
21
+
22
+
23
+
6
24
  ## [3.0.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-plugin@2.35.2...@exodus/ethereum-plugin@3.0.0) (2026-10-05)
7
25
 
8
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-plugin",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "Ethereum plugin for Exodus SDK powered wallets",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -22,7 +22,7 @@
22
22
  "@exodus/asset-lib": "^5.9.2",
23
23
  "@exodus/basic-utils": "^3.0.1",
24
24
  "@exodus/currency": "^6.0.1",
25
- "@exodus/ethereum-api": "^10.0.0",
25
+ "@exodus/ethereum-api": "^10.1.0",
26
26
  "@exodus/ethereum-lib": "^5.25.0",
27
27
  "@exodus/ethereum-meta": "^2.9.0",
28
28
  "@exodus/ethereumjs": "^1.0.0",
@@ -36,10 +36,10 @@
36
36
  "@exodus/assets": "^11.4.0",
37
37
  "@exodus/assets-testing": "^1.0.0",
38
38
  "@exodus/crypto": "^1.0.0-rc.13",
39
- "@exodus/evm-fork-testing": "^0.7.2",
39
+ "@exodus/evm-fork-testing": "^0.7.3",
40
40
  "@exodus/models": "^13.0.0",
41
41
  "@exodus/solidity-contract": "^1.3.0",
42
- "@exodus/web3-ethereum-utils": "^5.0.1",
42
+ "@exodus/web3-ethereum-utils": "^5.0.2",
43
43
  "delay": "^4.0.1",
44
44
  "ms": "^2.1.1"
45
45
  },
@@ -51,5 +51,5 @@
51
51
  "type": "git",
52
52
  "url": "git+https://github.com/ExodusMovement/assets.git"
53
53
  },
54
- "gitHead": "83a0e678debb92fbfcefd92e7bd0453d1a92136b"
54
+ "gitHead": "a7072c68dd2d7254a2670e0eea6a48660751a585"
55
55
  }
@@ -4,6 +4,7 @@ import { stakingAccountState } from './account-state.js'
4
4
  import { stakingServerFactory } from './api.js'
5
5
  import { mainnetContracts, methodIds } from './contracts/index.js'
6
6
  import { stakingServiceFactory } from './service.js'
7
+ import { createPolygonStakingAdapter } from './staking-adapter.js'
7
8
  import { txUtilsFactory } from './staking-utils.js'
8
9
 
9
10
  const polygon = tokens.find(({ name: tokenName }) => tokenName === 'polygon')
@@ -30,4 +31,6 @@ export const polygonStakingDeps = {
30
31
  txUtilsFactory,
31
32
  stakingServiceFactory,
32
33
  stakingServerFactory,
34
+ createStakingAdapter: ({ service }) => createPolygonStakingAdapter({ service }),
35
+ stakingApiEnabled: false,
33
36
  }
@@ -1,11 +1,14 @@
1
1
  import { memoize } from '@exodus/basic-utils'
2
2
  import NumberUnit, { isNumberUnit } from '@exodus/currency'
3
3
  import {
4
+ createStakingTxs,
4
5
  estimateGasLimit,
5
6
  EthLikeError,
6
7
  EVM_ERROR_TYPES,
7
8
  getAggregateTransactionPricing,
8
9
  scaleGasLimitEstimate,
10
+ sendStakingTxs,
11
+ STAKING_TX_MODE,
9
12
  stakingProviderClientFactory,
10
13
  } from '@exodus/ethereum-api'
11
14
  import { safeString } from '@exodus/safe-string'
@@ -189,7 +192,48 @@ export function stakingServiceFactory({ assetClientInterface, server: _server, s
189
192
  }
190
193
  }
191
194
 
192
- async function approveDelegateAmount({ walletAccount, amount, feeData } = {}) {
195
+ const planSingleTx = async ({ delegatorAddress, to, txInput, feeData }) => {
196
+ const { feeAsset } = await getStakeAssets()
197
+ const { gasPrice, gasLimit, tipGasPrice, fee } = await estimateTxFee({
198
+ from: delegatorAddress,
199
+ to,
200
+ txInput,
201
+ feeData,
202
+ })
203
+
204
+ return {
205
+ asset: feeAsset,
206
+ delegatorAddress,
207
+ fee,
208
+ feeData,
209
+ gasPrice,
210
+ tipGasPrice,
211
+ steps: [{ to, txInput, gasLimit }],
212
+ mode: STAKING_TX_MODE.single,
213
+ }
214
+ }
215
+
216
+ const sendPlan = async ({ walletAccount, plan }) => {
217
+ const createdTxs = await createStakingTxs({
218
+ asset: plan.asset,
219
+ walletAccount,
220
+ fromAddress: plan.delegatorAddress,
221
+ steps: plan.steps,
222
+ mode: plan.mode,
223
+ gasPrice: plan.gasPrice,
224
+ tipGasPrice: plan.tipGasPrice,
225
+ feeData: plan.feeData,
226
+ })
227
+ const { txIds } = await sendStakingTxs({
228
+ asset: plan.asset,
229
+ walletAccount,
230
+ unsignedTxs: createdTxs.map(({ unsignedTx }) => unsignedTx),
231
+ mode: plan.mode,
232
+ })
233
+ return txIds[txIds.length - 1]
234
+ }
235
+
236
+ async function planApproveDelegateAmount({ walletAccount, amount, feeData } = {}) {
193
237
  feeData = await resolveOptionalFeeData({ feeData })
194
238
 
195
239
  const [delegatorAddress, { asset }] = await Promise.all([
@@ -199,34 +243,20 @@ export function stakingServiceFactory({ assetClientInterface, server: _server, s
199
243
 
200
244
  amount = amountToCurrency({ asset, amount })
201
245
 
202
- const txApproveData = await stakingServer.approveStakeManager(amount)
203
- const { gasPrice, gasLimit, tipGasPrice } = await estimateTxFee({
204
- from: delegatorAddress,
246
+ return planSingleTx({
247
+ delegatorAddress,
205
248
  to: stakingServer.polygonContract.address,
206
- txInput: txApproveData,
249
+ txInput: await stakingServer.approveStakeManager(amount),
207
250
  feeData,
208
251
  })
252
+ }
209
253
 
210
- return prepareAndSendTx({
211
- walletAccount,
212
- waitForConfirmation: true,
213
- to: stakingServer.polygonContract.address,
214
- txData: txApproveData,
215
- gasPrice,
216
- gasLimit,
217
- tipGasPrice,
218
- feeData,
219
- })
254
+ async function approveDelegateAmount(params) {
255
+ const plan = await planApproveDelegateAmount(params)
256
+ return sendPlan({ walletAccount: params.walletAccount, plan })
220
257
  }
221
258
 
222
- /**
223
- * Delegate MATIC tokens for staking.
224
- * @param {walletAccount} params.walletAccount - The walletAccount for the delegator (required).
225
- * @param {NumberUnit} params.amount - The amount of MATIC to delegate (required).
226
- * @param {FeeData} params.feeData - Optional feeData to use for the transaction (optional, if not provided will be obtained from the asset client interface).
227
- * @param {Boolean} params.revertOnSimulationError - Optional boolean to revert on simulation error (optional, default is true). Allows us to quickly react to simulator issues.
228
- */
229
- async function delegate({ walletAccount, amount, feeData, revertOnSimulationError = true } = {}) {
259
+ async function planDelegate({ walletAccount, amount, feeData } = {}) {
230
260
  const [delegatorAddress, { asset, feeAsset }, resolvedFeeData] = await Promise.all([
231
261
  getDelegatorAddress({ walletAccount }),
232
262
  getStakeAssets(),
@@ -237,12 +267,6 @@ export function stakingServiceFactory({ assetClientInterface, server: _server, s
237
267
 
238
268
  amount = amountToCurrency({ asset, amount })
239
269
 
240
- const baseNonce = await feeAsset.getNonce({
241
- asset: feeAsset,
242
- fromAddress: delegatorAddress.toString(),
243
- walletAccount,
244
- })
245
-
246
270
  const [txApproveData, txDelegateData] = await Promise.all([
247
271
  stakingServer.approveStakeManager(amount),
248
272
  stakingServer.delegate({ amount }),
@@ -264,66 +288,83 @@ export function stakingServiceFactory({ assetClientInterface, server: _server, s
264
288
  feeData,
265
289
  })
266
290
 
267
- const createTxBaseArgs = {
291
+ return {
268
292
  asset: feeAsset,
269
- walletAccount,
270
- fromAddress: delegatorAddress.toString(),
271
- amount: feeAsset.currency.ZERO,
272
- tipGasPrice,
293
+ stakeAsset: asset,
294
+ delegatorAddress: delegatorAddress.toString(),
295
+ amount,
296
+ fee: gasPrice.mul(approveGasLimit).add(gasPrice.mul(estimatedDelegateGasLimit)),
297
+ feeData,
273
298
  gasPrice,
299
+ tipGasPrice,
300
+ steps: [
301
+ {
302
+ to: stakingServer.polygonContract.address,
303
+ txInput: txApproveData,
304
+ gasLimit: approveGasLimit,
305
+ },
306
+ {
307
+ to: stakingServer.validatorShareContract.address,
308
+ txInput: txDelegateData,
309
+ gasLimit: estimatedDelegateGasLimit,
310
+ },
311
+ ],
312
+ mode: STAKING_TX_MODE.bundle,
274
313
  }
275
- const [unsignedTxApprove, unsignedTxDelegate] = await Promise.all([
276
- feeAsset.api.createTx({
277
- ...createTxBaseArgs,
278
- toAddress: stakingServer.polygonContract.address,
279
- txInput: txApproveData,
280
- gasLimit: approveGasLimit,
281
- nonce: baseNonce,
282
- }),
283
- feeAsset.api.createTx({
284
- ...createTxBaseArgs,
285
- toAddress: stakingServer.validatorShareContract.address,
286
- txInput: txDelegateData,
287
- gasLimit: estimatedDelegateGasLimit,
288
- nonce: baseNonce + 1,
289
- }),
290
- ])
314
+ }
291
315
 
316
+ async function simulateDelegate({ plan, createdTxs, revertOnSimulationError = true }) {
317
+ const [unsignedTxApprove, unsignedTxDelegate] = createdTxs
292
318
  await maticDelegateSimulateTransactions({
293
- asset,
319
+ asset: plan.stakeAsset,
294
320
  unsignedTxApprove,
295
321
  unsignedTxDelegate,
296
- senderAddress: delegatorAddress.toString(),
322
+ senderAddress: plan.delegatorAddress,
297
323
  revertOnSimulationError,
298
324
  })
325
+ }
299
326
 
300
- const { txIds } = await asset.baseAsset.sendPrivateBundle({
301
- unsignedTxBundle: [
302
- {
303
- asset: feeAsset,
304
- unsignedTx: unsignedTxApprove.unsignedTx,
305
- walletAccount,
306
- },
307
- {
308
- asset: feeAsset,
309
- unsignedTx: unsignedTxDelegate.unsignedTx,
310
- walletAccount,
311
- },
312
- ],
327
+ /**
328
+ * Delegate MATIC tokens for staking.
329
+ * @param {walletAccount} params.walletAccount - The walletAccount for the delegator (required).
330
+ * @param {NumberUnit} params.amount - The amount of MATIC to delegate (required).
331
+ * @param {FeeData} params.feeData - Optional feeData to use for the transaction (optional, if not provided will be obtained from the asset client interface).
332
+ * @param {Boolean} params.revertOnSimulationError - Optional boolean to revert on simulation error (optional, default is true). Allows us to quickly react to simulator issues.
333
+ */
334
+ async function delegate({ walletAccount, amount, feeData, revertOnSimulationError = true } = {}) {
335
+ const plan = await planDelegate({ walletAccount, amount, feeData })
336
+ const createdTxs = await createStakingTxs({
337
+ asset: plan.asset,
338
+ walletAccount,
339
+ fromAddress: plan.delegatorAddress,
340
+ steps: plan.steps,
341
+ mode: plan.mode,
342
+ gasPrice: plan.gasPrice,
343
+ tipGasPrice: plan.tipGasPrice,
344
+ })
345
+
346
+ await simulateDelegate({ plan, createdTxs, revertOnSimulationError })
347
+
348
+ const unsignedTxs = createdTxs.map(({ unsignedTx }) => unsignedTx)
349
+ const { txIds } = await sendStakingTxs({
350
+ asset: plan.asset,
351
+ walletAccount,
352
+ unsignedTxs,
353
+ mode: plan.mode,
313
354
  })
314
355
 
315
356
  const delegateTxId = txIds[txIds.length - 1]
316
357
 
317
358
  await stakingProvider.notifyStaking({
318
359
  txId: delegateTxId,
319
- asset: asset.name,
320
- delegator: delegatorAddress,
321
- amount: amount.toBaseString(),
360
+ asset: plan.stakeAsset.name,
361
+ delegator: plan.delegatorAddress,
362
+ amount: plan.amount.toBaseString(),
322
363
  })
323
364
  return delegateTxId
324
365
  }
325
366
 
326
- async function undelegate({ walletAccount, amount, feeData, waitForConfirmation = false } = {}) {
367
+ async function planUndelegate({ walletAccount, amount, feeData } = {}) {
327
368
  feeData = await resolveOptionalFeeData({ feeData })
328
369
 
329
370
  const [delegatorAddress, { asset }] = await Promise.all([
@@ -340,49 +381,39 @@ export function stakingServiceFactory({ assetClientInterface, server: _server, s
340
381
  asset,
341
382
  })
342
383
 
343
- const txUndelegateData = await stakingServer.undelegate(undelegateParams)
344
- const { gasPrice, gasLimit, tipGasPrice } = await estimateTxFee({
345
- from: delegatorAddress.toLowerCase(),
346
- to: stakingServer.validatorShareContract.address,
347
- txInput: txUndelegateData,
348
- feeData,
349
- })
350
- return prepareAndSendTx({
351
- walletAccount,
384
+ const plan = await planSingleTx({
385
+ delegatorAddress: delegatorAddress.toLowerCase(),
352
386
  to: stakingServer.validatorShareContract.address,
353
- txData: txUndelegateData,
354
- gasPrice,
355
- gasLimit,
356
- tipGasPrice,
357
- waitForConfirmation,
387
+ txInput: await stakingServer.undelegate(undelegateParams),
358
388
  feeData,
359
389
  })
390
+ return { ...plan, amount }
360
391
  }
361
392
 
362
- async function claimRewards({ walletAccount, feeData } = {}) {
393
+ async function undelegate(params = {}) {
394
+ const plan = await planUndelegate(params)
395
+ return sendPlan({ walletAccount: params.walletAccount, plan })
396
+ }
397
+
398
+ async function planClaimRewards({ walletAccount, feeData } = {}) {
363
399
  feeData = await resolveOptionalFeeData({ feeData })
364
400
 
365
401
  const delegatorAddress = await getDelegatorAddress({ walletAccount })
366
402
 
367
- const txWithdrawRewardsData = await stakingServer.withdrawRewards()
368
- const { gasPrice, gasLimit, tipGasPrice } = await estimateTxFee({
369
- from: delegatorAddress,
370
- to: stakingServer.validatorShareContract.address,
371
- txInput: txWithdrawRewardsData,
372
- feeData,
373
- })
374
- return prepareAndSendTx({
375
- walletAccount,
403
+ return planSingleTx({
404
+ delegatorAddress,
376
405
  to: stakingServer.validatorShareContract.address,
377
- txData: txWithdrawRewardsData,
378
- gasPrice,
379
- gasLimit,
380
- tipGasPrice,
406
+ txInput: await stakingServer.withdrawRewards(),
381
407
  feeData,
382
408
  })
383
409
  }
384
410
 
385
- async function claimUndelegatedBalance({ walletAccount, unbondNonce, feeData } = {}) {
411
+ async function claimRewards(params = {}) {
412
+ const plan = await planClaimRewards(params)
413
+ return sendPlan({ walletAccount: params.walletAccount, plan })
414
+ }
415
+
416
+ async function planClaimUndelegatedBalance({ walletAccount, unbondNonce, feeData } = {}) {
386
417
  feeData = await resolveOptionalFeeData({ feeData })
387
418
 
388
419
  const [delegatorAddress, { asset }] = await Promise.all([
@@ -396,28 +427,24 @@ export function stakingServiceFactory({ assetClientInterface, server: _server, s
396
427
  unbondNonce,
397
428
  })
398
429
 
399
- const txClaimUndelegatedData = await stakingServer.claimUndelegatedBalance({ unbondNonce })
400
- const { gasPrice, gasLimit, tipGasPrice } = await estimateTxFee({
401
- from: delegatorAddress,
402
- to: stakingServer.validatorShareContract.address,
403
- txInput: txClaimUndelegatedData,
404
- feeData,
405
- })
406
- const txId = await prepareAndSendTx({
407
- walletAccount,
430
+ const plan = await planSingleTx({
431
+ delegatorAddress,
408
432
  to: stakingServer.validatorShareContract.address,
409
- txData: txClaimUndelegatedData,
410
- gasPrice,
411
- gasLimit,
412
- tipGasPrice,
433
+ txInput: await stakingServer.claimUndelegatedBalance({ unbondNonce }),
413
434
  feeData,
414
435
  })
436
+ return { ...plan, amount: unclaimedUndelegatedBalance, stakeAsset: asset }
437
+ }
438
+
439
+ async function claimUndelegatedBalance(params = {}) {
440
+ const plan = await planClaimUndelegatedBalance(params)
441
+ const txId = await sendPlan({ walletAccount: params.walletAccount, plan })
415
442
 
416
443
  await stakingProvider.notifyUnstaking({
417
444
  txId,
418
- asset: asset.name,
419
- delegator: delegatorAddress,
420
- amount: unclaimedUndelegatedBalance.toBaseString(),
445
+ asset: plan.stakeAsset.name,
446
+ delegator: plan.delegatorAddress,
447
+ amount: plan.amount.toBaseString(),
421
448
  })
422
449
 
423
450
  return txId
@@ -538,46 +565,21 @@ export function stakingServiceFactory({ assetClientInterface, server: _server, s
538
565
  return { ...fee, gasLimit }
539
566
  }
540
567
 
541
- async function prepareAndSendTx({
542
- walletAccount,
543
- to,
544
- txData: txInput,
545
- gasPrice,
546
- gasLimit,
547
- tipGasPrice,
548
- waitForConfirmation = false,
549
- feeData,
550
- } = {}) {
551
- const { feeAsset } = await getStakeAssets()
552
- const { unsignedTx } = await feeAsset.api.createTx({
553
- asset: feeAsset,
554
- walletAccount,
555
- address: to,
556
- amount: feeAsset.currency.ZERO,
557
- txInput,
558
- gasPrice,
559
- gasLimit,
560
- tipGasPrice,
561
- waitForConfirmation,
562
- feeData,
563
- })
564
-
565
- const { txId } = await feeAsset.api.sendTx({ asset: feeAsset, walletAccount, unsignedTx })
566
-
567
- return txId
568
- }
569
-
570
568
  const getStakingInfo = getPolygonStakingInfo({ assetClientInterface, stakingServer })
571
569
 
572
570
  return {
573
- // Unified API (used by stakingApiFactory when exposed on asset.api.staking)
571
+ planDelegate,
572
+ simulateDelegate,
573
+ planUndelegate,
574
+ planClaimRewards,
575
+ planClaimUndelegatedBalance,
576
+ getStakingInfo,
577
+ // @deprecated legacy staking api, remove once evm staking unification is done
574
578
  approveStake: approveDelegateAmount,
575
579
  stake: delegate,
576
580
  unstake: undelegate,
577
581
  claimUnstaked: claimUndelegatedBalance,
578
582
  claimRewards,
579
- getStakingInfo,
580
- // Legacy names (for direct service usage)
581
583
  approveDelegateAmount,
582
584
  delegate,
583
585
  undelegate,
@@ -0,0 +1,394 @@
1
+ import {
2
+ createStakingRejectionError,
3
+ failStakingCheck as fail,
4
+ isPendingPolygonUndelegateTxInEthereumTxLog,
5
+ isPolygonClaimUndelegate,
6
+ isPolygonDelegate,
7
+ isPolygonReward,
8
+ isPolygonTx,
9
+ isPolygonUndelegate,
10
+ isPolygonUndelegateTxInEthereumTxLog,
11
+ passStakingCheck as pass,
12
+ stakingProviderClientFactory,
13
+ sumStakingOperationFees as sumOperationFees,
14
+ warnIfStakingBalanceBelow as warnIfBelow,
15
+ } from '@exodus/ethereum-api'
16
+ import { getMethodIdFromEthTx } from '@exodus/ethereum-lib'
17
+ import assert from 'minimalistic-assert'
18
+
19
+ import { mainnetContracts, methodIds } from './contracts/index.js'
20
+ import { POLYGON_STAKING_PREFLIGHT_ERROR_REASONS } from './service.js'
21
+
22
+ export const POL_STAKING_GAS_LIMITS = Object.freeze({
23
+ stake: 430_000,
24
+ unstake: 450_000,
25
+ claimUnstaked: 350_000,
26
+ claimRewards: 350_000,
27
+ })
28
+
29
+ const POL_UNBONDING_MS = 4 * 24 * 60 * 60 * 1000
30
+ const VALIDATOR_NAME = 'Everstake'
31
+ const DEFAULT_MIN_AMOUNT = '1'
32
+
33
+ const REASON_CODE_BY_PREFLIGHT_REASON = new Map([
34
+ [
35
+ POLYGON_STAKING_PREFLIGHT_ERROR_REASONS.polygonUnstakeAmountInvalid.reason,
36
+ 'UNSTAKE_BELOW_MINIMUM',
37
+ ],
38
+ [
39
+ POLYGON_STAKING_PREFLIGHT_ERROR_REASONS.polygonUnstakeAmountUnavailable.reason,
40
+ 'UNSTAKE_EXCEEDS_STAKED',
41
+ ],
42
+ [
43
+ POLYGON_STAKING_PREFLIGHT_ERROR_REASONS.polygonUnstakeClaimNonceInvalid.reason,
44
+ 'NOTHING_TO_CLAIM',
45
+ ],
46
+ [
47
+ POLYGON_STAKING_PREFLIGHT_ERROR_REASONS.polygonUnstakeClaimUnavailable.reason,
48
+ 'NOTHING_TO_CLAIM',
49
+ ],
50
+ [
51
+ POLYGON_STAKING_PREFLIGHT_ERROR_REASONS.polygonUnstakeClaimNotReady.reason,
52
+ 'UNBONDING_IN_PROGRESS',
53
+ ],
54
+ ])
55
+
56
+ const OPERATION_BY_VALIDATOR_METHOD_ID = new Map([
57
+ [methodIds.DELEGATE, 'stake'],
58
+ [methodIds.UNDELEGATE, 'unstake'],
59
+ [methodIds.CLAIM_UNDELEGATE, 'claimUnstaked'],
60
+ [methodIds.CLAIM_REWARD, 'claimRewards'],
61
+ ])
62
+
63
+ const toAmount = (asset, value) =>
64
+ typeof value === 'string' ? asset.currency.defaultUnit(value) : undefined
65
+
66
+ const isUnstakeTx = (tx) => Boolean(tx.data?.undelegate) || isPolygonUndelegate(tx)
67
+
68
+ const getLatestTxTime = (txLogs, matches) => {
69
+ let latest
70
+ for (const txLog of txLogs) {
71
+ for (const tx of txLog ?? []) {
72
+ if (!matches(tx)) continue
73
+ const time = new Date(tx.date).getTime()
74
+ if (latest === undefined || time > latest) latest = time
75
+ }
76
+ }
77
+
78
+ return latest
79
+ }
80
+
81
+ const resolveUnstakeAmount = ({ state, intent }) =>
82
+ intent.isMax ? state.balances.staked : intent.amount
83
+
84
+ export const createPolygonStakingAdapter = ({
85
+ service,
86
+ validatorAddress = mainnetContracts.VALIDATOR_SHARES_CONTRACT_ADDR,
87
+ stakingProvider = stakingProviderClientFactory(),
88
+ }) => {
89
+ assert(service, '`service` required')
90
+
91
+ const target = { kind: 'validator', id: validatorAddress.toLowerCase(), name: VALIDATOR_NAME }
92
+
93
+ const getCapabilities = ({ asset }) => ({
94
+ participation: 'amount',
95
+ liquid: false,
96
+ positions: 'aggregate',
97
+ operations: {
98
+ stake: { amount: 'required', supportsMax: true, positionSelectable: false, multiTx: true },
99
+ unstake: { amount: 'required', supportsMax: true, positionSelectable: false, multiTx: false },
100
+ claimUnstaked: {
101
+ amount: 'none',
102
+ supportsMax: false,
103
+ positionSelectable: false,
104
+ multiTx: false,
105
+ },
106
+ claimRewards: {
107
+ amount: 'none',
108
+ supportsMax: false,
109
+ positionSelectable: false,
110
+ multiTx: false,
111
+ },
112
+ },
113
+ target: { kind: 'validator', selection: 'fixed', default: target },
114
+ unbonding: { kind: 'epoch', approxDurationMs: POL_UNBONDING_MS },
115
+ claimUnstaked: 'manual',
116
+ rewards: { distribution: 'claimable' },
117
+ feeAssetName: asset.feeAsset.name,
118
+ maxPositions: 1,
119
+ hardwareWallets: ['ledger'],
120
+ })
121
+
122
+ const toPositions = ({ balances, info, txLog, feeAssetTxLog }) => {
123
+ const unstakedAt = getLatestTxTime(
124
+ [txLog, feeAssetTxLog],
125
+ (tx) => isUnstakeTx(tx) || isPolygonUndelegateTxInEthereumTxLog(tx)
126
+ )
127
+ const unbondId = `unbond-${info.unbondNonce ?? 0}`
128
+ const position = (id, status, amount, availableAt) =>
129
+ amount.isPositive ? [{ id, status, amount, target, ...(availableAt && { availableAt }) }] : []
130
+
131
+ return [
132
+ ...position('active', 'active', balances.staked),
133
+ ...position(
134
+ unbondId,
135
+ 'deactivating',
136
+ balances.unstaking,
137
+ unstakedAt === undefined ? undefined : unstakedAt + POL_UNBONDING_MS
138
+ ),
139
+ ...position(unbondId, 'withdrawable', balances.unstaked),
140
+ ]
141
+ }
142
+
143
+ const readState = ({ asset, accountState, txLog, feeAssetTxLog }) => {
144
+ const { currency } = asset
145
+ const zero = currency.ZERO
146
+ const info = accountState.staking?.[asset.name] || {}
147
+ const loaded = Boolean(accountState.stakingSnapshotOwners?.[asset.name])
148
+ const balances = asset.api.getBalances({ asset, accountState, txLog })
149
+ const { feeAsset } = asset
150
+ const feeBalance = feeAssetTxLog
151
+ ? feeAsset.api.getBalances({ asset: feeAsset, accountState, txLog: feeAssetTxLog }).spendable
152
+ : undefined
153
+ const hasPendingUndelegate = [...(feeAssetTxLog ?? [])].some((tx) =>
154
+ isPendingPolygonUndelegateTxInEthereumTxLog(tx)
155
+ )
156
+ const liquidRewards = info.liquidRewards || zero
157
+ const { staking, staked, unstaking, unstaked } = balances
158
+
159
+ return {
160
+ loaded,
161
+ isStaking: staked.isPositive,
162
+ hasPosition: [staked, unstaking, unstaked, liquidRewards].some((amount) => amount.isPositive),
163
+ totals: { staking, staked, unstaking, unstaked, rewards: liquidRewards },
164
+ ...(loaded && info.rewardsBalance && { earned: info.rewardsBalance }),
165
+ positions: toPositions({ balances, info, txLog, feeAssetTxLog }),
166
+ target,
167
+ ...(hasPendingUndelegate && { pendingOperation: 'unstake' }),
168
+ balances,
169
+ feeBalance,
170
+ minAmount: info.minDelegateAmount || currency.defaultUnit(DEFAULT_MIN_AMOUNT),
171
+ minRewardsToWithdraw: info.minRewardsToWithdraw || currency.defaultUnit(DEFAULT_MIN_AMOUNT),
172
+ liquidRewards,
173
+ unbondNonce: Number(info.unbondNonce || 0),
174
+ isUndelegateInProgress: Boolean(info.isUndelegateInProgress),
175
+ canClaimUndelegatedBalance: Boolean(info.canClaimUndelegatedBalance),
176
+ unclaimedUndelegatedBalance: info.unclaimedUndelegatedBalance || zero,
177
+ }
178
+ }
179
+
180
+ const getGasLimit = ({ operation }) => POL_STAKING_GAS_LIMITS[operation]
181
+
182
+ const getLimits = ({ operation, state }) => {
183
+ switch (operation) {
184
+ case 'stake':
185
+ return { min: state.minAmount, max: state.balances.spendable }
186
+ case 'unstake':
187
+ return { max: state.balances.staked }
188
+ case 'claimUnstaked':
189
+ return { max: state.balances.unstaked }
190
+ case 'claimRewards':
191
+ return { min: state.minRewardsToWithdraw, max: state.liquidRewards }
192
+ }
193
+ }
194
+
195
+ const checkFee = ({ asset, state, fee }) =>
196
+ fee && state.feeBalance && state.feeBalance.lt(fee)
197
+ ? fail('INSUFFICIENT_FEE_BALANCE', fee.sub(state.feeBalance), asset.feeAsset.name)
198
+ : pass()
199
+
200
+ const warnLaterFee = ({ state, fee, estimateOperationFee, operations, warning }) =>
201
+ warnIfBelow({
202
+ balance: state.feeBalance && fee && state.feeBalance.sub(fee),
203
+ required: sumOperationFees({ estimateOperationFee, operations }),
204
+ warning,
205
+ })
206
+
207
+ const checkStake = ({ asset, intent, state, fee, estimateOperationFee }) => {
208
+ const { spendable } = state.balances
209
+ const amount = intent.isMax ? spendable : intent.amount
210
+ if (!amount || amount.lt(state.minAmount)) {
211
+ return fail('BELOW_MINIMUM', state.minAmount.sub(amount ?? asset.currency.ZERO), asset.name)
212
+ }
213
+
214
+ if (spendable.lt(amount)) return fail('INSUFFICIENT_BALANCE', amount.sub(spendable), asset.name)
215
+ const feeCheck = checkFee({ asset, state, fee })
216
+ if (!feeCheck.ok) return feeCheck
217
+
218
+ return warnLaterFee({
219
+ state,
220
+ fee,
221
+ estimateOperationFee,
222
+ operations: ['unstake', 'claimUnstaked'],
223
+ warning: 'LOW_FEE_BALANCE',
224
+ })
225
+ }
226
+
227
+ const checkUnstake = ({ asset, intent, state, fee, estimateOperationFee }) => {
228
+ const { staked } = state.balances
229
+ if (!staked.isPositive) return fail('NOTHING_TO_UNSTAKE')
230
+ if (state.isUndelegateInProgress || state.unclaimedUndelegatedBalance.isPositive) {
231
+ return fail('MAX_POSITIONS_REACHED')
232
+ }
233
+
234
+ const amount = resolveUnstakeAmount({ state, intent })
235
+ if (!amount?.isPositive) return fail('UNSTAKE_BELOW_MINIMUM')
236
+ if (amount.gt(staked)) return fail('UNSTAKE_EXCEEDS_STAKED', amount.sub(staked), asset.name)
237
+ const feeCheck = checkFee({ asset, state, fee })
238
+ if (!feeCheck.ok) return feeCheck
239
+
240
+ return warnLaterFee({
241
+ state,
242
+ fee,
243
+ estimateOperationFee,
244
+ operations: ['claimUnstaked'],
245
+ warning: 'LOW_FEE_BALANCE_FOR_CLAIM',
246
+ })
247
+ }
248
+
249
+ const checkClaimUnstaked = ({ asset, state, fee }) => {
250
+ if (!state.unclaimedUndelegatedBalance.isPositive) return fail('NOTHING_TO_CLAIM')
251
+ if (!state.canClaimUndelegatedBalance) return fail('UNBONDING_IN_PROGRESS')
252
+ return checkFee({ asset, state, fee })
253
+ }
254
+
255
+ const checkClaimRewards = ({ asset, state, fee }) => {
256
+ const { liquidRewards, minRewardsToWithdraw } = state
257
+ if (!liquidRewards.isPositive) return fail('NO_REWARDS_TO_CLAIM')
258
+ if (liquidRewards.lt(minRewardsToWithdraw)) {
259
+ return fail('REWARDS_BELOW_MINIMUM', minRewardsToWithdraw.sub(liquidRewards), asset.name)
260
+ }
261
+
262
+ return checkFee({ asset, state, fee })
263
+ }
264
+
265
+ const CHECKS = {
266
+ stake: checkStake,
267
+ unstake: checkUnstake,
268
+ claimUnstaked: checkClaimUnstaked,
269
+ claimRewards: checkClaimRewards,
270
+ }
271
+
272
+ const checkOperation = ({ asset, operation, intent, state, fee, estimateOperationFee }) =>
273
+ CHECKS[operation]?.({ asset, intent, state, fee, estimateOperationFee }) ??
274
+ fail('OPERATION_UNSUPPORTED')
275
+
276
+ const planOperation = ({ walletAccount, operation, intent, state, feeData }) => {
277
+ switch (operation) {
278
+ case 'stake':
279
+ return service.planDelegate({
280
+ walletAccount,
281
+ amount: intent.isMax ? state.balances.spendable : intent.amount,
282
+ feeData,
283
+ })
284
+ case 'unstake':
285
+ return service.planUndelegate({
286
+ walletAccount,
287
+ amount: resolveUnstakeAmount({ state, intent }),
288
+ feeData,
289
+ })
290
+ case 'claimUnstaked':
291
+ return service.planClaimUndelegatedBalance({
292
+ walletAccount,
293
+ unbondNonce: state.unbondNonce,
294
+ feeData,
295
+ })
296
+ case 'claimRewards':
297
+ return service
298
+ .planClaimRewards({ walletAccount, feeData })
299
+ .then((plan) => ({ ...plan, amount: state.liquidRewards }))
300
+ default:
301
+ throw new Error(`Unsupported polygon staking operation: ${operation}`)
302
+ }
303
+ }
304
+
305
+ const toRejection = ({ operation, error }) => {
306
+ const reasonCode = REASON_CODE_BY_PREFLIGHT_REASON.get(error.reason)
307
+ if (!reasonCode) return error
308
+ return createStakingRejectionError({ operation, check: fail(reasonCode) })
309
+ }
310
+
311
+ const buildSteps = async ({ asset, walletAccount, operation, intent, state, feeData }) => {
312
+ let plan
313
+ try {
314
+ plan = await planOperation({ walletAccount, operation, intent, state, feeData })
315
+ } catch (error) {
316
+ throw toRejection({ operation, error })
317
+ }
318
+
319
+ const check = checkFee({ asset, state, fee: plan.fee })
320
+ if (!check.ok) throw createStakingRejectionError({ operation, check })
321
+ return plan
322
+ }
323
+
324
+ const simulate = async ({ operation, built, createdTxs }) => {
325
+ if (operation !== 'stake') return
326
+ await service.simulateDelegate({ plan: built, createdTxs })
327
+ }
328
+
329
+ const notify = async ({ asset, plan, txIds, delegator }) => {
330
+ if (plan.operation === 'stake') {
331
+ await stakingProvider.notifyStaking({
332
+ txId: txIds[txIds.length - 1],
333
+ asset: asset.name,
334
+ delegator,
335
+ amount: plan.amount.toBaseString(),
336
+ })
337
+ return
338
+ }
339
+
340
+ if (plan.operation !== 'claimUnstaked') return
341
+ await stakingProvider.notifyUnstaking({
342
+ txId: txIds[0],
343
+ asset: asset.name,
344
+ delegator,
345
+ amount: plan.amount.toBaseString(),
346
+ })
347
+ }
348
+
349
+ const classifyValidatorCall = ({ tx }) => {
350
+ if (tx.to?.toLowerCase() !== target.id) return
351
+
352
+ const operation = OPERATION_BY_VALIDATOR_METHOD_ID.get(
353
+ getMethodIdFromEthTx(tx, methodIds.DELEGATE)
354
+ )
355
+ if (!operation) return
356
+
357
+ const amount = operation === 'stake' && isPolygonTx(tx) ? tx.coinAmount?.abs() : undefined
358
+ return { operation, ...(amount?.isPositive && { amount }) }
359
+ }
360
+
361
+ const classifyPolygonTx = ({ asset, tx }) => {
362
+ const data = tx.data || {}
363
+ const withAmount = (operation, amount) => ({ operation, ...(amount && { amount }) })
364
+
365
+ if (typeof data.delegate === 'string' || isPolygonDelegate(tx)) {
366
+ return withAmount('stake', toAmount(asset, data.delegate))
367
+ }
368
+
369
+ if (isUnstakeTx(tx)) return withAmount('unstake', toAmount(asset, data.undelegate))
370
+
371
+ if (typeof data.claimUndelegate === 'string' || isPolygonClaimUndelegate(tx)) {
372
+ return withAmount('claimUnstaked', toAmount(asset, data.claimUndelegate))
373
+ }
374
+
375
+ if (isPolygonReward(tx)) return withAmount('claimRewards', tx.coinAmount?.abs())
376
+ }
377
+
378
+ const classifyTx = ({ asset, tx }) => {
379
+ if (isPolygonTx(tx)) return classifyPolygonTx({ asset, tx }) ?? classifyValidatorCall({ tx })
380
+ if (tx.coinName === asset.baseAsset.name) return classifyValidatorCall({ tx })
381
+ }
382
+
383
+ return {
384
+ getCapabilities,
385
+ readState,
386
+ getGasLimit,
387
+ getLimits,
388
+ checkOperation,
389
+ buildSteps,
390
+ simulate,
391
+ notify,
392
+ classifyTx,
393
+ }
394
+ }