@exodus/ethereum-plugin 2.30.2 → 2.30.4

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,25 @@
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
+ ## [2.30.4](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-plugin@2.30.3...@exodus/ethereum-plugin@2.30.4) (2026-05-12)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * validate polygon unstake state before gas estimation ([#8002](https://github.com/ExodusMovement/assets/issues/8002)) ([f63a244](https://github.com/ExodusMovement/assets/commit/f63a2441868c6226fe14085c0f7d9587131e69e0))
12
+
13
+
14
+
15
+ ## [2.30.3](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-plugin@2.30.2...@exodus/ethereum-plugin@2.30.3) (2026-05-11)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+
21
+ * fix: correct `getTokenBalance` eth-like-util (#8047)
22
+
23
+
24
+
6
25
  ## [2.30.2](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-plugin@2.30.1...@exodus/ethereum-plugin@2.30.2) (2026-05-11)
7
26
 
8
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-plugin",
3
- "version": "2.30.2",
3
+ "version": "2.30.4",
4
4
  "description": "Ethereum plugin for Exodus SDK powered wallets",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -24,8 +24,8 @@
24
24
  "@exodus/asset-lib": "^5.9.0",
25
25
  "@exodus/basic-utils": "^3.0.1",
26
26
  "@exodus/currency": "^6.0.1",
27
- "@exodus/ethereum-api": "^8.73.5",
28
- "@exodus/ethereum-lib": "^5.24.0",
27
+ "@exodus/ethereum-api": "^8.74.1",
28
+ "@exodus/ethereum-lib": "^5.24.1",
29
29
  "@exodus/ethereum-meta": "^2.9.0",
30
30
  "@exodus/ethereumjs": "^1.0.0",
31
31
  "@exodus/safe-string": "^1.4.0",
@@ -53,5 +53,5 @@
53
53
  "type": "git",
54
54
  "url": "git+https://github.com/ExodusMovement/assets.git"
55
55
  },
56
- "gitHead": "7238e1dc3c4d73cabd4b4a19509de6ee0aa87068"
56
+ "gitHead": "7f184588091acba3b358e4af13783ee1e2bab4ba"
57
57
  }
package/src/index.js CHANGED
@@ -5,6 +5,8 @@ import assetsList from '@exodus/ethereum-meta'
5
5
  import { tetherusdBlackListCheckFactory, usdcoinBlackListCheckFactory } from './blacklist-checks.js'
6
6
  import { stakingConfiguration, stakingDependencies } from './staking.js'
7
7
 
8
+ export { POLYGON_STAKING_PREFLIGHT_ERROR_REASONS } from './staking/polygon/service.js'
9
+
8
10
  const createAsset = createAssetFactory({
9
11
  assetsList,
10
12
  feeDataConfig: {
@@ -1,15 +1,42 @@
1
1
  import { memoize } from '@exodus/basic-utils'
2
2
  // eslint-disable-next-line @exodus/import/no-deprecated
3
- import { isNumberUnit } from '@exodus/currency'
3
+ import NumberUnit, { isNumberUnit } from '@exodus/currency'
4
4
  import {
5
5
  estimateGasLimit,
6
+ EthLikeError,
7
+ EVM_ERROR_TYPES,
6
8
  getAggregateTransactionPricing,
7
9
  getOptimisticTxLogEffects,
8
10
  scaleGasLimitEstimate,
9
11
  stakingProviderClientFactory,
10
12
  } from '@exodus/ethereum-api'
13
+ import { safeString } from '@exodus/safe-string'
11
14
 
12
15
  import { maticDelegateSimulateTransactions } from './matic-staking-utils.js'
16
+ import { getPendingUndelegateAmountFromEthereumTxLog } from './staking-utils.js'
17
+
18
+ export const POLYGON_STAKING_PREFLIGHT_ERROR_REASONS = {
19
+ polygonUnstakeAmountInvalid: {
20
+ reason: safeString`polygon unstake amount invalid`,
21
+ type: EVM_ERROR_TYPES.PREFLIGHT_VALIDATION,
22
+ },
23
+ polygonUnstakeAmountUnavailable: {
24
+ reason: safeString`polygon unstake amount unavailable`,
25
+ type: EVM_ERROR_TYPES.PREFLIGHT_VALIDATION,
26
+ },
27
+ polygonUnstakeClaimNonceInvalid: {
28
+ reason: safeString`polygon unstake claim nonce invalid`,
29
+ type: EVM_ERROR_TYPES.PREFLIGHT_VALIDATION,
30
+ },
31
+ polygonUnstakeClaimUnavailable: {
32
+ reason: safeString`polygon unstake claim unavailable`,
33
+ type: EVM_ERROR_TYPES.PREFLIGHT_VALIDATION,
34
+ },
35
+ polygonUnstakeClaimNotReady: {
36
+ reason: safeString`polygon unstake claim not ready`,
37
+ type: EVM_ERROR_TYPES.PREFLIGHT_VALIDATION,
38
+ },
39
+ }
13
40
 
14
41
  export function stakingServiceFactory({ assetClientInterface, server: _server, stakingServer }) {
15
42
  const stakingProvider = stakingProviderClientFactory()
@@ -66,6 +93,105 @@ export function stakingServiceFactory({ assetClientInterface, server: _server, s
66
93
  return address.toLowerCase()
67
94
  }
68
95
 
96
+ const getPendingUndelegateAmount = async ({ walletAccount, asset }) => {
97
+ if (typeof assetClientInterface.getTxLog !== 'function') return asset.currency.ZERO
98
+
99
+ const ethereumTxLog = await assetClientInterface.getTxLog({ assetName, walletAccount })
100
+ return getPendingUndelegateAmountFromEthereumTxLog({
101
+ ethereumTxLog,
102
+ currency: asset.currency,
103
+ validatorShareContract: stakingServer.validatorShareContract,
104
+ })
105
+ }
106
+
107
+ const getUndelegateParams = async ({ walletAccount, amount, delegatorAddress, asset }) => {
108
+ if (!(amount instanceof NumberUnit) || !amount.gt(asset.currency.ZERO)) {
109
+ throw new EthLikeError({
110
+ message: 'Undelegate amount must be positive',
111
+ errorReasonInfo: POLYGON_STAKING_PREFLIGHT_ERROR_REASONS.polygonUnstakeAmountInvalid,
112
+ hint: safeString`polygonStaking:undelegate:amount`,
113
+ baseAssetName: asset.baseAsset.name,
114
+ })
115
+ }
116
+
117
+ const [delegatedBalance, pendingUndelegateAmount] = await Promise.all([
118
+ stakingServer.getTotalStake(delegatorAddress),
119
+ getPendingUndelegateAmount({ walletAccount, asset }),
120
+ ])
121
+
122
+ if (delegatedBalance.isZero) {
123
+ throw new EthLikeError({
124
+ message: 'No active Polygon stake to undelegate',
125
+ errorReasonInfo: POLYGON_STAKING_PREFLIGHT_ERROR_REASONS.polygonUnstakeAmountUnavailable,
126
+ hint: safeString`polygonStaking:undelegate:activeStake`,
127
+ baseAssetName: asset.baseAsset.name,
128
+ })
129
+ }
130
+
131
+ const availableActiveStake = delegatedBalance.sub(pendingUndelegateAmount)
132
+
133
+ if (amount.gt(availableActiveStake)) {
134
+ throw new EthLikeError({
135
+ message: 'Undelegate amount exceeds active Polygon stake',
136
+ errorReasonInfo: POLYGON_STAKING_PREFLIGHT_ERROR_REASONS.polygonUnstakeAmountUnavailable,
137
+ hint: safeString`polygonStaking:undelegate:availableStake`,
138
+ baseAssetName: asset.baseAsset.name,
139
+ })
140
+ }
141
+
142
+ return { amount }
143
+ }
144
+
145
+ const getClaimUndelegatedParams = async ({ asset, delegatorAddress, unbondNonce }) => {
146
+ const { currency } = asset
147
+
148
+ if (!Number.isInteger(unbondNonce) || unbondNonce <= 0) {
149
+ throw new EthLikeError({
150
+ message: 'Polygon unstake claim nonce is invalid',
151
+ errorReasonInfo: POLYGON_STAKING_PREFLIGHT_ERROR_REASONS.polygonUnstakeClaimNonceInvalid,
152
+ hint: safeString`polygonStaking:claimUnstaked:nonce`,
153
+ baseAssetName: asset.baseAsset.name,
154
+ })
155
+ }
156
+
157
+ const [{ shares, withdrawEpoch }, withdrawalDelay, currentEpoch, withdrawExchangeRate] =
158
+ await Promise.all([
159
+ stakingServer.getUnboundInfo(delegatorAddress, unbondNonce),
160
+ stakingServer.getWithdrawalDelay(),
161
+ stakingServer.getCurrentCheckpoint(),
162
+ stakingServer.getWithdrawExchangeRate(),
163
+ ])
164
+
165
+ if (shares.isZero()) {
166
+ throw new EthLikeError({
167
+ message: 'No Polygon unstake claim available',
168
+ errorReasonInfo: POLYGON_STAKING_PREFLIGHT_ERROR_REASONS.polygonUnstakeClaimUnavailable,
169
+ hint: safeString`polygonStaking:claimUnstaked:shares`,
170
+ baseAssetName: asset.baseAsset.name,
171
+ })
172
+ }
173
+
174
+ if (withdrawEpoch.add(withdrawalDelay).gt(currentEpoch)) {
175
+ throw new EthLikeError({
176
+ message: 'Polygon unstake claim is not ready',
177
+ errorReasonInfo: POLYGON_STAKING_PREFLIGHT_ERROR_REASONS.polygonUnstakeClaimNotReady,
178
+ hint: safeString`polygonStaking:claimUnstaked:withdrawalDelay`,
179
+ baseAssetName: asset.baseAsset.name,
180
+ })
181
+ }
182
+
183
+ return {
184
+ unclaimedUndelegatedBalance: calculateUnclaimedTokens({
185
+ currency,
186
+ exchangeRatePrecision: stakingServer.EXCHANGE_RATE_PRECISION,
187
+ withdrawExchangeRate,
188
+ shares,
189
+ canClaimUndelegatedBalance: true,
190
+ isUndelegateInProgress: false,
191
+ }),
192
+ }
193
+ }
194
+
69
195
  async function approveDelegateAmount({ walletAccount, amount, feeData } = {}) {
70
196
  feeData = await resolveOptionalFeeData({ feeData })
71
197
 
@@ -247,7 +373,14 @@ export function stakingServiceFactory({ assetClientInterface, server: _server, s
247
373
 
248
374
  amount = amountToCurrency({ asset, amount })
249
375
 
250
- const txUndelegateData = await stakingServer.undelegate({ amount })
376
+ const undelegateParams = await getUndelegateParams({
377
+ walletAccount,
378
+ amount,
379
+ delegatorAddress,
380
+ asset,
381
+ })
382
+
383
+ const txUndelegateData = await stakingServer.undelegate(undelegateParams)
251
384
  const { gasPrice, gasLimit, tipGasPrice } = await estimateTxFee({
252
385
  from: delegatorAddress.toLowerCase(),
253
386
  to: stakingServer.validatorShareContract.address,
@@ -297,17 +430,10 @@ export function stakingServiceFactory({ assetClientInterface, server: _server, s
297
430
  getStakeAssets(),
298
431
  ])
299
432
 
300
- const { currency } = asset
301
- const unstakedClaimInfo = await fetchUnstakedClaimInfo({
302
- stakingServer,
303
- delegator: delegatorAddress,
304
- })
305
-
306
- const { unclaimedUndelegatedBalance } = await getUnstakedUnclaimedInfo({
307
- stakingServer,
308
- currency,
309
- delegator: delegatorAddress,
310
- ...unstakedClaimInfo,
433
+ const { unclaimedUndelegatedBalance } = await getClaimUndelegatedParams({
434
+ asset,
435
+ delegatorAddress,
436
+ unbondNonce,
311
437
  })
312
438
 
313
439
  const txClaimUndelegatedData = await stakingServer.claimUndelegatedBalance({ unbondNonce })
@@ -366,6 +492,26 @@ export function stakingServiceFactory({ assetClientInterface, server: _server, s
366
492
  args = { ...args, amount: amountToCurrency({ asset, amount }) }
367
493
  }
368
494
 
495
+ if (operation === 'undelegate') {
496
+ args = {
497
+ ...args,
498
+ ...(await getUndelegateParams({
499
+ walletAccount,
500
+ amount: args.amount,
501
+ delegatorAddress,
502
+ asset,
503
+ })),
504
+ }
505
+ }
506
+
507
+ if (operation === 'claimUndelegatedBalance') {
508
+ await getClaimUndelegatedParams({
509
+ asset,
510
+ delegatorAddress,
511
+ unbondNonce: args.unbondNonce,
512
+ })
513
+ }
514
+
369
515
  const operationTxData = await delegateOperation({ ...args, walletAccount })
370
516
  const { fee } = await estimateTxFee({
371
517
  from: delegatorAddress,
@@ -536,7 +682,7 @@ async function getUnstakedUnclaimedInfo({
536
682
  const { withdrawEpoch, shares } = await stakingServer.getUnboundInfo(delegator, unbondNonce)
537
683
  const exchangeRatePrecision = stakingServer.EXCHANGE_RATE_PRECISION
538
684
  const isUndelegateInProgress =
539
- !withdrawEpoch.isZero() && withdrawEpoch.add(withdrawalDelay).gte(currentEpoch)
685
+ !withdrawEpoch.isZero() && withdrawEpoch.add(withdrawalDelay).gt(currentEpoch)
540
686
  const isUndelegatedBalanceClaimable = canClaimUndelegatedBalance({
541
687
  shares,
542
688
  withdrawEpoch,
@@ -1,8 +1,116 @@
1
+ import { getMethodIdFromEthTx, isConfirmedTxInLog, isPendingTxInLog } from '@exodus/ethereum-lib'
1
2
  import assert from 'minimalistic-assert'
2
3
 
3
4
  import { mainnetContracts, methodIds as methodIds_ } from './contracts/index.js'
4
5
  import { txFiltersFactory } from './tx-filters/index.js'
5
6
 
7
+ // Every ethereum txLog writer stores the nonce at `tx.data.nonce` as an
8
+ // integer (the optimistic outgoing-tx effects, the clarity + monitor-utils
9
+ // server-tx normalisers, and the staking optimistic side-effect builders
10
+ // all converge on this shape). There is no top-level `tx.nonce` field on
11
+ // the Tx model, so we only read from `tx.data.nonce`.
12
+ const getTxNonce = (tx) => (Number.isInteger(tx.data?.nonce) ? tx.data.nonce : undefined)
13
+
14
+ // Identifies txs sent by our wallet. Every ethereum txLog writer produces
15
+ // `from: []` for an outgoing tx: the optimistic outgoing-tx effects set it
16
+ // explicitly, both server-tx normalisers (clarity-utils and monitor-utils)
17
+ // collapse to `from: []` when `serverTx.from === ourWalletAddress`, and the
18
+ // staking optimistic side-effect builders omit `from` so the Tx constructor
19
+ // defaults it to `[]`. Incoming txs always carry `from: [theirAddress]`. We
20
+ // do not check the element against `ourAddress` because no writer produces
21
+ // `from: [ourAddress]` for an outgoing tx — the empty-array convention is
22
+ // the contract.
23
+ const isOurOutgoingTx = (tx) => Array.isArray(tx.from) && tx.from.length === 0
24
+
25
+ const isPolygonUndelegateTx = ({ tx, validatorShareAddress, methodIds }) =>
26
+ tx.to?.toLowerCase() === validatorShareAddress &&
27
+ getMethodIdFromEthTx(tx, methodIds.UNDELEGATE) === methodIds.UNDELEGATE.toLowerCase()
28
+
29
+ const decodePendingUndelegateAmount = ({ tx, currency, validatorShareContract }) => {
30
+ const {
31
+ values: [pendingUndelegateAmount],
32
+ } = validatorShareContract.decodeInput(tx.data.data)
33
+
34
+ return currency.baseUnit(pendingUndelegateAmount)
35
+ }
36
+
37
+ export const getPendingUndelegateAmountFromEthereumTxLog = ({
38
+ ethereumTxLog = [],
39
+ currency,
40
+ validatorShareContract,
41
+ methodIds = methodIds_,
42
+ }) => {
43
+ const validatorShareAddress = validatorShareContract.address.toLowerCase()
44
+ const txsByNonce = new Map()
45
+ let pendingAmount = currency.ZERO
46
+
47
+ // Only our own txs can affect our delegated stake, so filter early. Bucket
48
+ // by nonce alone because all surviving txs share a single sender and only
49
+ // one tx per nonce can ever mine. The txLog is already scoped to the
50
+ // active wallet account by assetClientInterface.getTxLog, so within that
51
+ // scope `from: []` uniquely identifies our outgoing txs.
52
+ for (const tx of ethereumTxLog ?? []) {
53
+ if (!(isPendingTxInLog(tx) || isConfirmedTxInLog(tx))) continue
54
+ if (!isOurOutgoingTx(tx)) continue
55
+
56
+ const nonce = getTxNonce(tx)
57
+ if (!Number.isInteger(nonce)) continue
58
+
59
+ const txs = txsByNonce.get(nonce) || []
60
+ txs.push(tx)
61
+ txsByNonce.set(nonce, txs)
62
+ }
63
+
64
+ for (const txs of txsByNonce.values()) {
65
+ // Confirmed same-nonce txs mean the pending attempt can no longer execute.
66
+ if (txs.some(isConfirmedTxInLog)) continue
67
+
68
+ const pendingUndelegateTxs = txs.filter(
69
+ (tx) =>
70
+ isPendingTxInLog(tx) && isPolygonUndelegateTx({ tx, validatorShareAddress, methodIds })
71
+ )
72
+
73
+ // If the bucket has no pending undelegate, nothing here can reduce our
74
+ // stake — whatever else lives at this nonce (a cancel, an unrelated tx)
75
+ // is someone else's problem.
76
+ //
77
+ // If there IS a pending undelegate, reserve it even when it shares the
78
+ // bucket with a cancel/replacement. The cancel almost always wins
79
+ // (higher fee is a precondition for tx-acceleration), but in the rare
80
+ // race where the original undelegate mines first we'd otherwise let the
81
+ // user broadcast a follow-up unstake that would revert on-chain. The
82
+ // cost of being conservative is a ~1-block UX delay after a successful
83
+ // cancel until clarity-monitor evicts the dead undelegate from the
84
+ // txLog.
85
+ if (pendingUndelegateTxs.length === 0) continue
86
+
87
+ // Reserve the largest decoded amount in the bucket. Same-nonce attempts
88
+ // are racing for the same on-chain slot, so at most one will mine — but we
89
+ // don't know which. Picking the max means the preflight stays safe even
90
+ // if an accelerated/replaced tx somehow carries a larger claim than the
91
+ // original, and it preserves the known-good amount when one of several
92
+ // attempts has corrupt input data.
93
+ let amountForNonce
94
+
95
+ for (const tx of pendingUndelegateTxs) {
96
+ try {
97
+ const amount = decodePendingUndelegateAmount({ tx, currency, validatorShareContract })
98
+ if (!amountForNonce || amount.gt(amountForNonce)) {
99
+ amountForNonce = amount
100
+ }
101
+ } catch (err) {
102
+ console.warn('Could not decode pending Polygon unstake amount', tx.txId, err)
103
+ }
104
+ }
105
+
106
+ if (!amountForNonce) continue
107
+
108
+ pendingAmount = pendingAmount.add(amountForNonce)
109
+ }
110
+
111
+ return pendingAmount
112
+ }
113
+
6
114
  /**
7
115
  * Used in ethereum-hooks to extract the final tx amount and the staking type
8
116
  *