@exodus/ethereum-plugin 2.2.3 → 2.4.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 CHANGED
@@ -3,6 +3,29 @@
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.4.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-plugin@2.3.0...@exodus/ethereum-plugin@2.4.0) (2024-09-11)
7
+
8
+
9
+ ### Features
10
+
11
+ * switch ethereum to ESM ([#3374](https://github.com/ExodusMovement/assets/issues/3374)) ([d3a86c3](https://github.com/ExodusMovement/assets/commit/d3a86c3202754a0e6ab988d454d3e006ec11d9e4))
12
+
13
+
14
+
15
+ ## [2.3.0](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-plugin@2.2.3...@exodus/ethereum-plugin@2.3.0) (2024-08-24)
16
+
17
+
18
+ ### Features
19
+
20
+ * **ethereum-plugin:** bump `@exodus/web3-ethereum-utils` ([#3234](https://github.com/ExodusMovement/assets/issues/3234)) ([07329be](https://github.com/ExodusMovement/assets/commit/07329beef080533a9ebf79e434b8a5d54151e669))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * migrate to safe report matcher for cosmos and ethereum ([#3210](https://github.com/ExodusMovement/assets/issues/3210)) ([da094a1](https://github.com/ExodusMovement/assets/commit/da094a12d3013933e03ca484d472fb84505b57b2))
26
+
27
+
28
+
6
29
  ## [2.2.3](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-plugin@2.2.2...@exodus/ethereum-plugin@2.2.3) (2024-08-13)
7
30
 
8
31
  **Note:** Version bump only for package @exodus/ethereum-plugin
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-plugin",
3
- "version": "2.2.3",
3
+ "version": "2.4.0",
4
4
  "description": "Exodus ethereum-plugin",
5
+ "type": "module",
5
6
  "main": "src/index.js",
6
7
  "files": [
7
8
  "src",
@@ -14,24 +15,25 @@
14
15
  "access": "restricted"
15
16
  },
16
17
  "scripts": {
17
- "test": "run -T exodus-test --jest --esbuild",
18
- "lint": "run -T eslint .",
18
+ "test": "run -T exodus-test --jest",
19
+ "lint": "run -T eslintc .",
19
20
  "lint:fix": "yarn lint --fix"
20
21
  },
21
22
  "dependencies": {
22
- "@exodus/asset-lib": "^4.2.2",
23
- "@exodus/ethereum-api": "^8.13.3",
24
- "@exodus/ethereum-lib": "^5.0.1",
25
- "@exodus/ethereum-meta": "^1.5.1",
26
- "@exodus/web3-ethereum-utils": "^3.27.1"
23
+ "@exodus/asset-lib": "^5.0.0",
24
+ "@exodus/currency": "^2.1.3",
25
+ "@exodus/ethereum-api": "^8.18.1",
26
+ "@exodus/ethereum-lib": "^5.4.0",
27
+ "@exodus/ethereum-meta": "^2.0.0",
28
+ "@exodus/ethereumjs-util": "^7.1.0-exodus.7",
29
+ "@exodus/simple-retry": "^0.0.6",
30
+ "bn.js": "^5.2.1",
31
+ "minimalistic-assert": "^1.0.1"
27
32
  },
28
33
  "devDependencies": {
29
- "@exodus/assets": "^9.1.1",
34
+ "@exodus/assets": "^11.0.0",
30
35
  "@exodus/assets-testing": "^1.0.0",
31
- "@exodus/ethereumjs-util": "^7.1.0-exodus.7",
32
- "@exodus/fetch": "^1.3.0",
33
- "@exodus/models": "^11.0.0",
34
- "lodash": "^4.17.21"
36
+ "@exodus/models": "^12.0.1"
35
37
  },
36
38
  "bugs": {
37
39
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Aethereum-plugin"
@@ -41,5 +43,5 @@
41
43
  "type": "git",
42
44
  "url": "git+https://github.com/ExodusMovement/assets.git"
43
45
  },
44
- "gitHead": "71031eb3482dd28419a5b53df1ba83c45011095f"
46
+ "gitHead": "f35119e354e7bf4555b3bb6b453293c61e44fdd9"
45
47
  }
package/src/index.js CHANGED
@@ -2,8 +2,8 @@
2
2
  import { createAssetFactory } from '@exodus/ethereum-api'
3
3
  import assetsList from '@exodus/ethereum-meta'
4
4
 
5
- import feeData from './fee-data'
6
- import { stakingConfiguration } from './staking'
5
+ import feeData from './fee-data.js'
6
+ import { stakingConfiguration, stakingDependencies } from './staking.js'
7
7
 
8
8
  const createAsset = createAssetFactory({
9
9
  assetsList,
@@ -13,6 +13,7 @@ const createAsset = createAssetFactory({
13
13
  erc20FuelBuffer: 1.1, // 10% more than the required fee
14
14
  fuelThreshold: '0.005',
15
15
  stakingConfiguration,
16
+ stakingDependencies,
16
17
  serverUrl: 'https://geth.a.exodus.io/wallet/v1/',
17
18
  confirmationsNumber: 30,
18
19
  })
@@ -0,0 +1,71 @@
1
+ ## Staking in Polygon (MATIC)
2
+
3
+ ## How it works?
4
+
5
+ Polygon Staking happens in the **Ethereum network**, it is implemented by a set of smart contracts deployed on the ETH mainnet.
6
+ Staking is made by delegating the `ERC20 MATIC` token to those smart contracts which handle the `(un)staking/rewards` process and it shouldn't be confused with the Native asset in **Polygon network** (`MATIC NATIVE`).
7
+
8
+ Stakers are divided into `validators`, `delegators`, and watchers (for fraud reporting).
9
+
10
+ ## Contracts
11
+
12
+ ### StakeManager contract
13
+
14
+ `StakeManager` is the main contract for handling validator related activities like checkPoint signature verification, reward distribution, and stake management. Since the contract is using NFT ID as a source of ownership, change of ownership and signer won't affect anything in the system. [see](https://wiki.polygon.technology/docs/pos/contracts/stakingmanager).
15
+
16
+ ### ValidatorShare contract
17
+
18
+ For delegation staking each validator has its own deployed **contract**, this contract has the logic to `stake/unstake` as delegators, but it also acts as an `ERC20`, this `ERC20` token is what we know as the shares token. Shares token are calculated based on the total amount staked in the contract, varying from time to time
19
+ When delegators stake matic, they call `buyVoucher()` , the contract receives the MATIC tokens to stake (approval is needed on Matic token contract) and it calculates and mints the number of token shares that correspond to that staked amount. [see](https://wiki.polygon.technology/docs/pos/contracts/delegation).
20
+
21
+ ### Staking Anatomy
22
+
23
+ Example taken from the docs:
24
+
25
+ _Polygon supports delegation via validator shares. By using this design, it is easier to distribute rewards and slash with scale (thousands of delegators) on Ethereum contracts without much computation.
26
+ Delegators delegate by purchasing shares of a finite pool from validators. Each validator will have their own validator share token. Let's call these fungible tokens `VATIC` for a validator `A`. As soon as a user delegates to a validator `A`, they will be issued `VATIC` based on an exchange rate of `MATIC/VATIC` pair. As users accrue value the exchange rate indicates that they can now withdraw more `MATIC` for each `VATIC` and when users get slashed, users withdraw less `MATIC` for their `VATIC`.
27
+ Note that `MATIC` is a staking token. A delegator needs to have `MATIC` tokens to participate in the delegation.
28
+ Initially, a delegator `D` buys tokens from validator `A` specific pool when `1 MATIC per 1 VATIC`.
29
+ When a validator gets rewarded with more `MATIC` tokens, new tokens are added to the pool. Let's say with the current pool of `100 MATIC` tokens, `10 MATIC` rewards are added to the pool. But since the total supply of `VATIC` tokens didn't change due to rewards, the exchange rate becomes `1 MATIC per 0.9 VATIC`. Now, delegator `D` gets more `MATIC` for the same shares.
30
+ `VATIC`: Validator specific minted validator share tokens (ERC20 tokens)_
31
+
32
+ #### Rewards
33
+
34
+ Delegators can do with their rewards the following:
35
+ withdraw via `withdrawRewards()` or
36
+ `restake` (earned rewards are put as stake in the contract via `restake()`)
37
+
38
+ #### Unstaking
39
+
40
+ For delegators to unstake, there are two steps:
41
+
42
+ 1. `sellVoucher_new()`
43
+ 2. `unstakeClaimTokens()`
44
+
45
+ **Note**: there are some methods in the validator share contract with the suffix `_new`, for instance:
46
+ `sellVoucher()` and `sellVoucher_new()` this is because the recent changes on the smart contract to support the new exit API (**unstake** and **claim** tokens)\*
47
+
48
+ **sellVoucher** method calculates the token shares that correspond to the staked MATIC at that time, and burns those share tokens, it also transfers the rewards, and makes changes in the contract to update the total stake held in the contract .
49
+ Basically it prepares the contract to let delegators withdraw their staked MATIC in a second step once the **withdrawal delay** it's ben fulfilled.
50
+
51
+ #### Unstake Claim Tokens
52
+
53
+ **unstakeClaimTokens** makes the actual withdraw of the staked tokens, sellVoucher does not transfer the staked MATIC back to the delegator, we need to call this function after the withdrawal delay has been fulfilled, so that delegators can get their stake amount back to their wallet.
54
+
55
+ ### Staking Bussines Rules
56
+
57
+ All of these rules can be queried from the smart contracts, except `minimum amount to stake`, this is defined by Exodus.
58
+
59
+ - **Minimum rewards to withdraw**: 1 MATIC (contract rule)
60
+ - **Minimum amount to stake**: 1 MATIC (exodus rule)
61
+ - **Withdrawal delay**: The amount of time delegators must wait before claiming their staked amount. Varies depending on the contract governance
62
+ - **Withdrawal exchange rate**: The exchange rate used to convert tokens to shares and vice-versa. Varies depending on the number of MATIC in the withdraw pool share (affected by the earned rewards).
63
+ - **Unstaking period**: immediately, but staked MATIC tokens are not available to be withdrawn before withdrawal delay
64
+ - **Claim unstaked tokens**: after 3-4 days has passed since unstaked was called, the unstaked tokens can be claimed by the delegator.
65
+
66
+ Useful resources:
67
+
68
+ [Polygon docs](https://wiki.polygon.technology/docs/pos/polygon-architecture/)
69
+ [Polygon Staking](https://wiki.polygon.technology/docs/pos/contracts/delegation)
70
+ [StakeManager contract](https://github.com/maticnetwork/contracts/blob/main/contracts/staking/stakeManager/StakeManager.sol)
71
+ [Validator share contract](https://github.com/maticnetwork/contracts/blob/main/contracts/staking/stakeManager/StakeManager.sol)
@@ -0,0 +1,12 @@
1
+ export const stakingAccountState = ({ currency }) => ({
2
+ isDelegating: false,
3
+ isUndelegateInProgress: false,
4
+ canClaimUndelegatedBalance: false,
5
+ minRewardsToWithdraw: currency.defaultUnit(1),
6
+ minDelegateAmount: currency.defaultUnit(1),
7
+ unclaimedUndelegatedBalance: currency.ZERO,
8
+ delegatedBalance: currency.ZERO,
9
+ rewardsBalance: currency.ZERO,
10
+ withdrawable: currency.ZERO,
11
+ unbondNonce: '0',
12
+ })
@@ -0,0 +1,201 @@
1
+ import { createContract } from '@exodus/ethereum-lib'
2
+ import { bufferToHex } from '@exodus/ethereumjs-util'
3
+ import { retry } from '@exodus/simple-retry'
4
+ import BN from 'bn.js'
5
+
6
+ import { mainnetContracts } from './contracts/index.js'
7
+
8
+ const RETRY_DELAYS = ['10s']
9
+
10
+ class StakingServer {
11
+ constructor({ asset, server, contracts = mainnetContracts }) {
12
+ this.asset = asset
13
+ // harcoded exchange rate from the validator share contract
14
+ // in order to calculate claim unstake amount off-chain
15
+ this.EXCHANGE_RATE_PRECISION = new BN(10).pow(new BN(29))
16
+ this.validatorShareContract = createContract(
17
+ contracts.VALIDATOR_SHARES_CONTRACT_ADDR,
18
+ 'maticValidatorShare'
19
+ )
20
+ this.stakingManagerContract = createContract(
21
+ contracts.STAKING_MANAGER_ADDR,
22
+ 'maticStakingManager'
23
+ )
24
+ this.polygonContract = createContract(contracts.TOKEN_CONTRACT, 'polygon')
25
+ this.server = server
26
+ }
27
+
28
+ buildTxData = (contract, method, ...args) => {
29
+ return contract[method].build(...args)
30
+ }
31
+
32
+ callReadFunctionContract = (contract, method, ...args) => {
33
+ const callData = this.buildTxData(contract, method, ...args)
34
+ const data = {
35
+ data: bufferToHex(callData),
36
+ to: contract.address,
37
+ tag: 'latest',
38
+ }
39
+
40
+ const eth = this.server
41
+ return retry(eth.ethCall, { delayTimesMs: RETRY_DELAYS })(data)
42
+ }
43
+
44
+ getWithdrawalDelay = async () => {
45
+ const withdrawalDelay = await this.callReadFunctionContract(
46
+ this.stakingManagerContract,
47
+ 'withdrawalDelay'
48
+ )
49
+ return toBN(withdrawalDelay)
50
+ }
51
+
52
+ getMinRewardsToWithdraw = async () => {
53
+ const minRewardsToWithdraw = await this.callReadFunctionContract(
54
+ this.validatorShareContract,
55
+ 'minAmount'
56
+ )
57
+ return this.asset.currency.baseUnit(minRewardsToWithdraw)
58
+ }
59
+
60
+ /**
61
+ * A checkpoint is an epoch value that increments in the contract, every epoch is
62
+ * stored in a list and points the events that happened in that epoch. (timeline)
63
+ */
64
+ getCurrentCheckpoint = async () => {
65
+ const currentEpoch = await this.callReadFunctionContract(this.stakingManagerContract, 'epoch')
66
+ return toBN(currentEpoch)
67
+ }
68
+
69
+ /**
70
+ * Unbond is a struct in the staking contract that holds the number of shares and
71
+ * the withdrawEpoch for a delegator (used when delegators unstaked their tokens)
72
+ * @returns {number}
73
+ */
74
+ getUnboundInfo = async (address, nonce) => {
75
+ let _nonce = nonce
76
+ if (!Number.isInteger(_nonce)) {
77
+ _nonce = await this.getCurrentUnbondNonce(address)
78
+ }
79
+
80
+ const unboundInfo = await this.callReadFunctionContract(
81
+ this.validatorShareContract,
82
+ 'unbonds_new',
83
+ address,
84
+ _nonce
85
+ )
86
+
87
+ const [shares, withdrawEpoch] = splitIn32BytesArray(unboundInfo)
88
+ return {
89
+ withdrawEpoch: toBN(withdrawEpoch),
90
+ shares: toBN(shares),
91
+ }
92
+ }
93
+
94
+ /**
95
+ * UnbondNonce is a counter stored in the contract that tracks each time a delegator unstakes
96
+ * @param address delegator address
97
+ * @returns current unbonded nonce
98
+ */
99
+ getCurrentUnbondNonce = async (address) => {
100
+ const unbondNonce = await this.callReadFunctionContract(
101
+ this.validatorShareContract,
102
+ 'unbondNonces',
103
+ address
104
+ )
105
+ return parseInt(unbondNonce, 16)
106
+ }
107
+
108
+ getLiquidRewards = async (address) => {
109
+ const liquidRewards = await this.callReadFunctionContract(
110
+ this.validatorShareContract,
111
+ 'getLiquidRewards',
112
+ address
113
+ )
114
+ return this.asset.currency.baseUnit(liquidRewards)
115
+ }
116
+
117
+ getTotalStake = async (address) => {
118
+ const stakeInfo = await this.callReadFunctionContract(
119
+ this.validatorShareContract,
120
+ 'getTotalStake',
121
+ address
122
+ )
123
+ const [amount] = splitIn32BytesArray(stakeInfo)
124
+ return this.asset.currency.baseUnit(toBN(amount).toString())
125
+ }
126
+
127
+ getWithdrawExchangeRate = async () => {
128
+ const withdrawExchangeRate = await this.callReadFunctionContract(
129
+ this.validatorShareContract,
130
+ 'withdrawExchangeRate'
131
+ )
132
+
133
+ return toBN(withdrawExchangeRate)
134
+ }
135
+
136
+ /**
137
+ * Approves StakeManager contract for withdrawing {amount} in Matic tokens
138
+ * when users stakes.
139
+ * This function needs to be called before calling delegate function so that staking
140
+ * can properly work
141
+ * This also partially address the front running attack on ERC20 approve function:
142
+ * https://github.com/OpenZeppelin/openzeppelin-contracts/blob/f959d7e4e6ee0b022b41e5b644c79369869d8411/contracts/token/ERC20/ERC20.sol#L165-L206
143
+ * @param amount Matic tokens to be approved for StakeManager to withdraw (polygon currency)
144
+ */
145
+ approveStakeManager = (amount) => {
146
+ return this.buildTxData(
147
+ this.polygonContract,
148
+ 'increaseAllowance',
149
+ this.stakingManagerContract.address,
150
+ amount.toBaseString()
151
+ )
152
+ }
153
+
154
+ restakeReward = () => {
155
+ return this.buildTxData(this.validatorShareContract, 'restake')
156
+ }
157
+
158
+ withdrawRewards = () => {
159
+ return this.buildTxData(this.validatorShareContract, 'withdrawRewards')
160
+ }
161
+
162
+ delegate = ({ amount }) => {
163
+ return this.buildTxData(this.validatorShareContract, 'buyVoucher', amount.toBaseString(), '0')
164
+ }
165
+
166
+ undelegate = ({ amount, maximumSharesToBurn }) => {
167
+ const _maximumSharesToBurn = maximumSharesToBurn || amount
168
+ return this.buildTxData(
169
+ this.validatorShareContract,
170
+ 'sellVoucher_new',
171
+ amount.toBaseString(),
172
+ _maximumSharesToBurn.toBaseString()
173
+ )
174
+ }
175
+
176
+ /**
177
+ * @param {number} unbondNonce the unbond nonce from where delegator claim its staked tokens
178
+ */
179
+ claimUndelegatedBalance = ({ unbondNonce }) => {
180
+ return this.buildTxData(this.validatorShareContract, 'unstakeClaimTokens_new', unbondNonce)
181
+ }
182
+ }
183
+
184
+ // see arguments encoding standard (padded 32 bytes)
185
+ // https://docs.soliditylang.org/en/v0.8.15/abi-spec.html?highlight=abi.encode#function-selector-and-argument-encoding
186
+ const splitIn32BytesArray = (output) => removeHexPrefix(output).match(/[\da-f]{1,64}/gi) || []
187
+
188
+ const toBN = (str) => new BN(removeLeadingZeroes(removeHexPrefix(str)), 16)
189
+
190
+ const removeLeadingZeroes = (str) => str.replace(/^0+/, '')
191
+
192
+ const removeHexPrefix = (str) => {
193
+ if (typeof str !== 'string' || str === '') {
194
+ return str
195
+ }
196
+
197
+ return str.startsWith('0x') ? str.slice(2) : str
198
+ }
199
+
200
+ export const stakingServerFactory = ({ asset, contracts, server }) =>
201
+ new StakingServer({ asset, contracts, server })
@@ -0,0 +1,12 @@
1
+ export const mainnetContracts = {
2
+ VALIDATOR_SHARES_CONTRACT_ADDR: '0xf30cf4ed712d3734161fdaab5b1dbb49fd2d0e5c',
3
+ STAKING_MANAGER_ADDR: '0x5e3ef299fddf15eaa0432e6e66473ace8c13d908',
4
+ TOKEN_CONTRACT: '0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0',
5
+ }
6
+
7
+ export const methodIds = {
8
+ DELEGATE: '0x6ab15071', // buyVoucher(uint256 _amount, uint256 _minSharesToMint)
9
+ UNDELEGATE: '0xc83ec04d', // sellVoucher_new(uint256 claimAmount, uint256 maximumSharesToBurn)
10
+ CLAIM_REWARD: '0xc7b8981c', // withdrawRewards()
11
+ CLAIM_UNDELEGATE: '0xe97fddc2', // unstakeClaimTokens_new(uint256 unbondNonce)
12
+ }
@@ -0,0 +1,27 @@
1
+ import { tokens } from '@exodus/ethereum-meta'
2
+
3
+ import { stakingAccountState } from './account-state.js'
4
+ import { stakingServerFactory } from './api.js'
5
+ import { mainnetContracts, methodIds } from './contracts/index.js'
6
+ import { stakingServiceFactory } from './service.js'
7
+ import { txUtilsFactory } from './staking-utils.js'
8
+
9
+ const polygon = tokens.find(({ name: tokenName }) => tokenName === 'polygon')
10
+
11
+ export const polygonStakingConfig = {
12
+ accountStateExtraData: stakingAccountState(polygon),
13
+ features: {
14
+ stake: true,
15
+ unstake: true,
16
+ claimUnstaked: true,
17
+ claimRewards: true,
18
+ },
19
+ contracts: mainnetContracts,
20
+ methodIds,
21
+ }
22
+
23
+ export const polygonStakingDeps = {
24
+ txUtilsFactory,
25
+ stakingServiceFactory,
26
+ stakingServerFactory,
27
+ }
@@ -0,0 +1,467 @@
1
+ import { isNumberUnit } from '@exodus/currency'
2
+ import { estimateGasLimit, stakingProviderClientFactory } from '@exodus/ethereum-api'
3
+ import BN from 'bn.js'
4
+
5
+ export function stakingServiceFactory({ assetClientInterface, server, stakingServer }) {
6
+ const stakingProvider = stakingProviderClientFactory()
7
+
8
+ function amountToCurrency({ asset, amount }) {
9
+ return isNumberUnit(amount) ? amount : asset.currency.parse(amount)
10
+ }
11
+
12
+ async function getStakeAssets() {
13
+ const { polygon: asset, ethereum: feeAsset } = await assetClientInterface.getAssetsForNetwork({
14
+ baseAssetName: 'ethereum', // Polygon token lives in ETH network
15
+ })
16
+ return { asset, feeAsset }
17
+ }
18
+
19
+ async function approveDelegateAmount({ walletAccount, amount } = {}) {
20
+ const { asset } = await getStakeAssets()
21
+ const address = await assetClientInterface.getReceiveAddress({
22
+ assetName: asset.name,
23
+ walletAccount,
24
+ })
25
+ const delegatorAddress = address.toLowerCase()
26
+
27
+ amount = amountToCurrency({ asset, amount })
28
+
29
+ const txApproveData = await stakingServer.approveStakeManager(amount)
30
+ const { gasPrice, gasLimit, fee } = await estimateTxFee(
31
+ delegatorAddress,
32
+ asset.contracts.TOKEN_CONTRACT,
33
+ txApproveData
34
+ )
35
+
36
+ return prepareAndSendTx({
37
+ walletAccount,
38
+ waitForConfirmation: true,
39
+ to: asset.contracts.TOKEN_CONTRACT,
40
+ txData: txApproveData,
41
+ gasPrice,
42
+ gasLimit,
43
+ fee,
44
+ })
45
+ }
46
+
47
+ async function delegate({ walletAccount, amount } = {}) {
48
+ const { asset } = await getStakeAssets()
49
+ const address = await assetClientInterface.getReceiveAddress({
50
+ assetName: asset.name,
51
+ walletAccount,
52
+ })
53
+ const delegatorAddress = address.toLowerCase()
54
+
55
+ amount = amountToCurrency({ asset, amount })
56
+
57
+ // const txApproveData = await stakingServer.approveStakeManager(amount)
58
+ // let { gasPrice, gasLimit, fee } = await estimateTxFee(
59
+ // delegatorAddress,
60
+ // contracts.TOKEN_CONTRACT,
61
+ // txApproveData
62
+ // )
63
+ // await prepareAndSendTx({
64
+ // walletAccount,
65
+ // waitForConfirmation: true,
66
+ // to: contracts.TOKEN_CONTRACT,
67
+ // txData: txApproveData,
68
+ // gasPrice,
69
+ // gasLimit,
70
+ // fee,
71
+ // })
72
+
73
+ const txDelegateData = await stakingServer.delegate({ amount })
74
+ const { gasPrice, gasLimit, fee } = await estimateTxFee(
75
+ delegatorAddress,
76
+ asset.contracts.EVERSTAKE_VALIDATOR_CONTRACT_ADDR,
77
+ txDelegateData
78
+ )
79
+
80
+ const txId = await prepareAndSendTx({
81
+ walletAccount,
82
+ to: asset.contracts.EVERSTAKE_VALIDATOR_CONTRACT_ADDR,
83
+ txData: txDelegateData,
84
+ gasPrice,
85
+ gasLimit,
86
+ fee,
87
+ })
88
+
89
+ await stakingProvider.notifyStaking({
90
+ txId,
91
+ asset: asset.name,
92
+ delegator: delegatorAddress,
93
+ amount: amount.toBaseString(),
94
+ })
95
+
96
+ return txId
97
+ }
98
+
99
+ async function undelegate({ walletAccount, amount } = {}) {
100
+ const { asset } = await getStakeAssets()
101
+ const address = await assetClientInterface.getReceiveAddress({
102
+ assetName: asset.name,
103
+ walletAccount,
104
+ })
105
+ const delegatorAddress = address.toLowerCase()
106
+
107
+ amount = amountToCurrency({ asset, amount })
108
+
109
+ const txUndelegateData = await stakingServer.undelegate({ amount })
110
+ const { gasPrice, gasLimit, fee } = await estimateTxFee(
111
+ delegatorAddress.toLowerCase(),
112
+ asset.contracts.EVERSTAKE_VALIDATOR_CONTRACT_ADDR,
113
+ txUndelegateData
114
+ )
115
+ return prepareAndSendTx({
116
+ walletAccount,
117
+ to: asset.contracts.EVERSTAKE_VALIDATOR_CONTRACT_ADDR,
118
+ txData: txUndelegateData,
119
+ gasPrice,
120
+ gasLimit,
121
+ fee,
122
+ })
123
+ }
124
+
125
+ async function claimRewards({ walletAccount } = {}) {
126
+ const { asset } = await getStakeAssets()
127
+ const address = await assetClientInterface.getReceiveAddress({
128
+ assetName: asset.name,
129
+ walletAccount,
130
+ })
131
+ const delegatorAddress = address.toLowerCase()
132
+
133
+ const txWithdrawRewardsData = await stakingServer.withdrawRewards()
134
+ const { gasPrice, gasLimit, fee } = await estimateTxFee(
135
+ delegatorAddress,
136
+ asset.contracts.EVERSTAKE_VALIDATOR_CONTRACT_ADDR,
137
+ txWithdrawRewardsData
138
+ )
139
+ return prepareAndSendTx({
140
+ walletAccount,
141
+ to: asset.contracts.EVERSTAKE_VALIDATOR_CONTRACT_ADDR,
142
+ txData: txWithdrawRewardsData,
143
+ gasPrice,
144
+ gasLimit,
145
+ fee,
146
+ })
147
+ }
148
+
149
+ async function claimUndelegatedBalance({ walletAccount, unbondNonce } = {}) {
150
+ const { asset } = await getStakeAssets()
151
+ const address = await assetClientInterface.getReceiveAddress({
152
+ assetName: asset.name,
153
+ walletAccount,
154
+ })
155
+ const delegatorAddress = address.toLowerCase()
156
+
157
+ const { currency } = asset
158
+ const unstakedClaimInfo = await fetchUnstakedClaimInfo({
159
+ stakingServer,
160
+ delegator: delegatorAddress,
161
+ })
162
+
163
+ const { unclaimedUndelegatedBalance } = await getUnstakedUnclaimedInfo({
164
+ stakingServer,
165
+ currency,
166
+ delegator: delegatorAddress,
167
+ ...unstakedClaimInfo,
168
+ })
169
+
170
+ const txClaimUndelegatedData = await stakingServer.claimUndelegatedBalance({ unbondNonce })
171
+ const { gasPrice, gasLimit, fee } = await estimateTxFee(
172
+ delegatorAddress,
173
+ asset.contracts.EVERSTAKE_VALIDATOR_CONTRACT_ADDR,
174
+ txClaimUndelegatedData
175
+ )
176
+ const txId = await prepareAndSendTx({
177
+ walletAccount,
178
+ to: asset.contracts.EVERSTAKE_VALIDATOR_CONTRACT_ADDR,
179
+ txData: txClaimUndelegatedData,
180
+ gasPrice,
181
+ gasLimit,
182
+ fee,
183
+ })
184
+
185
+ await stakingProvider.notifyUnstaking({
186
+ txId,
187
+ asset: asset.name,
188
+ delegator: delegatorAddress,
189
+ amount: unclaimedUndelegatedBalance.toBaseString(),
190
+ })
191
+
192
+ return txId
193
+ }
194
+
195
+ async function estimateDelegateOperation({ walletAccount, operation, args }) {
196
+ const delegateOperation = stakingServer[operation]
197
+
198
+ if (!delegateOperation) {
199
+ return
200
+ }
201
+
202
+ const { asset } = await getStakeAssets()
203
+ const address = await assetClientInterface.getReceiveAddress({
204
+ assetName: asset.name,
205
+ walletAccount,
206
+ })
207
+ const delegatorAddress = address.toLowerCase()
208
+
209
+ const { amount } = args
210
+ if (amount) {
211
+ args = { ...args, amount: amountToCurrency({ asset, amount }) }
212
+ }
213
+
214
+ const operationTxData = await delegateOperation({ ...args, walletAccount })
215
+ const { fee } = await estimateTxFee(
216
+ delegatorAddress,
217
+ asset.contracts.EVERSTAKE_VALIDATOR_CONTRACT_ADDR,
218
+ operationTxData
219
+ )
220
+
221
+ return fee
222
+ }
223
+
224
+ /**
225
+ * Estimating delegete tx using {estimateGasLimit} function does not work
226
+ * as the execution reverts (due to missing MATIC approval).
227
+ * Instead, a fixed gas limit is use, which is:
228
+ *
229
+ * delegateGasLimit = ERC20ApproveGas + delegateGas
230
+ *
231
+ * This is just for displaying purposes and it's just an aproximation of the delegate gas cost,
232
+ * NOT the real fee cost
233
+ */
234
+ async function estimateDelegateTxFee() {
235
+ // approx gas limits
236
+ const { feeAsset } = await getStakeAssets()
237
+ const erc20ApproveGas = 4900
238
+ const delegateGas = 240_000
239
+ const gasPrice = parseInt(await server.gasPrice(), 16)
240
+ const extraPercentage = 20
241
+
242
+ const gasLimit = erc20ApproveGas + delegateGas
243
+ const gasLimitWithBuffer = new BN(gasLimit)
244
+ .imuln(100 + extraPercentage)
245
+ .idivn(100)
246
+ .toString()
247
+
248
+ const fee = new BN(gasLimitWithBuffer).mul(new BN(gasPrice))
249
+
250
+ return {
251
+ gasLimit: gasLimitWithBuffer,
252
+ gasPrice,
253
+ fee: feeAsset.currency.baseUnit(fee.toString()),
254
+ }
255
+ }
256
+
257
+ async function estimateTxFee(from, to, txInput, gasPrice = '0x0') {
258
+ const { feeAsset } = await getStakeAssets()
259
+
260
+ const amount = feeAsset.currency.ZERO
261
+ const gasLimit = await estimateGasLimit(
262
+ feeAsset,
263
+ from,
264
+ to,
265
+ amount, // staking contracts does not require ETH amount to interact with
266
+ txInput,
267
+ gasPrice
268
+ )
269
+
270
+ if (gasPrice === '0x0') {
271
+ gasPrice = await server.gasPrice()
272
+ }
273
+
274
+ gasPrice = parseInt(gasPrice, 16)
275
+ const fee = new BN(gasPrice).mul(new BN(gasLimit))
276
+
277
+ return {
278
+ gasLimit,
279
+ gasPrice: feeAsset.currency.baseUnit(gasPrice),
280
+ fee: feeAsset.currency.baseUnit(fee.toString()),
281
+ }
282
+ }
283
+
284
+ async function prepareAndSendTx({
285
+ walletAccount,
286
+ to,
287
+ txData: txInput,
288
+ gasPrice,
289
+ gasLimit,
290
+ fee,
291
+ waitForConfirmation = false,
292
+ } = {}) {
293
+ const { asset, feeAsset } = await getStakeAssets()
294
+
295
+ const sendTxArgs = {
296
+ asset: feeAsset,
297
+ walletAccount,
298
+ address: to,
299
+ amount: feeAsset.currency.ZERO,
300
+ // used in desktop,
301
+ // remove once, txSend is unified
302
+ receiver: {
303
+ address: to,
304
+ amount: feeAsset.currency.ZERO,
305
+ },
306
+ options: {
307
+ shouldLog: true,
308
+ txInput,
309
+ gasPrice,
310
+ gasLimit,
311
+ feeAmount: fee,
312
+ },
313
+ waitForConfirmation,
314
+ }
315
+
316
+ const { txId } = await asset.api.sendTx(sendTxArgs)
317
+
318
+ return txId
319
+ }
320
+
321
+ const getStakingInfo = getPolygonStakingInfo({ assetClientInterface, stakingServer })
322
+
323
+ return {
324
+ approveDelegateAmount,
325
+ delegate,
326
+ undelegate,
327
+ claimRewards,
328
+ claimUndelegatedBalance,
329
+ getStakingInfo,
330
+ estimateDelegateTxFee,
331
+ estimateDelegateOperation,
332
+ }
333
+ }
334
+
335
+ async function fetchUnstakedClaimInfo({ stakingServer, delegator }) {
336
+ const [unbondNonce, withdrawalDelay, currentEpoch, withdrawExchangeRate] = await Promise.all([
337
+ stakingServer.getCurrentUnbondNonce(delegator),
338
+ stakingServer.getWithdrawalDelay(),
339
+ stakingServer.getCurrentCheckpoint(),
340
+ stakingServer.getWithdrawExchangeRate(),
341
+ ])
342
+
343
+ return {
344
+ unbondNonce,
345
+ withdrawalDelay,
346
+ currentEpoch,
347
+ withdrawExchangeRate,
348
+ }
349
+ }
350
+
351
+ function calculateUnclaimedTokens({
352
+ currency,
353
+ exchangeRatePrecision,
354
+ withdrawExchangeRate,
355
+ shares,
356
+ canClaimUndelegatedBalance,
357
+ isUndelegateInProgress,
358
+ }) {
359
+ // see contract implementation
360
+ // https://github.com/maticnetwork/contracts/blob/1eb6960e511a967c15d4936904570a890d134fa6/contracts/staking/validatorShare/ValidatorShare.sol#L304
361
+ if (canClaimUndelegatedBalance || isUndelegateInProgress) {
362
+ const unclaimedTokens = withdrawExchangeRate
363
+ .mul(shares) // shares === validator tokens
364
+ .div(exchangeRatePrecision)
365
+ .toString()
366
+
367
+ return currency.baseUnit(unclaimedTokens)
368
+ }
369
+
370
+ return currency.ZERO
371
+ }
372
+
373
+ function canClaimUndelegatedBalance({ shares, withdrawEpoch, isUndelegateInProgress }) {
374
+ const undelegateNotStarted = shares.isZero() && withdrawEpoch.isZero()
375
+ return !(isUndelegateInProgress || undelegateNotStarted)
376
+ }
377
+
378
+ async function getUnstakedUnclaimedInfo({
379
+ stakingServer,
380
+ currency,
381
+ delegator,
382
+ unbondNonce,
383
+ currentEpoch,
384
+ withdrawalDelay,
385
+ withdrawExchangeRate,
386
+ }) {
387
+ const { withdrawEpoch, shares } = await stakingServer.getUnboundInfo(delegator, unbondNonce)
388
+ const exchangeRatePrecision = stakingServer.EXCHANGE_RATE_PRECISION
389
+ const isUndelegateInProgress =
390
+ !withdrawEpoch.isZero() && withdrawEpoch.add(withdrawalDelay).gte(currentEpoch)
391
+ const isUndelegatedBalanceClaimable = canClaimUndelegatedBalance({
392
+ shares,
393
+ withdrawEpoch,
394
+ isUndelegateInProgress,
395
+ })
396
+ const unclaimedUndelegatedBalance = calculateUnclaimedTokens({
397
+ currency,
398
+ exchangeRatePrecision,
399
+ withdrawExchangeRate,
400
+ shares,
401
+ canClaimUndelegatedBalance: isUndelegatedBalanceClaimable,
402
+ isUndelegateInProgress,
403
+ })
404
+ return {
405
+ isUndelegateInProgress,
406
+ canClaimUndelegatedBalance: isUndelegatedBalanceClaimable,
407
+ unclaimedUndelegatedBalance,
408
+ }
409
+ }
410
+
411
+ async function fetchRewardsInfo({ stakingServer, delegator, currency }) {
412
+ const [minRewardsToWithdraw, rewardsBalance] = await Promise.all([
413
+ stakingServer.getMinRewardsToWithdraw(),
414
+ stakingServer.getLiquidRewards(delegator),
415
+ ])
416
+ const withdrawable = rewardsBalance.sub(minRewardsToWithdraw).gte(currency.ZERO)
417
+ ? rewardsBalance
418
+ : currency.ZERO
419
+
420
+ return {
421
+ rewardsBalance,
422
+ minRewardsToWithdraw,
423
+ withdrawable,
424
+ }
425
+ }
426
+
427
+ export function getPolygonStakingInfo({ assetClientInterface, stakingServer }) {
428
+ return async function ({ address }) {
429
+ const { polygon: asset } = await assetClientInterface.getAssetsForNetwork({
430
+ baseAssetName: 'ethereum', // Polygon token lives in ETH network
431
+ })
432
+ const { currency } = asset
433
+ const delegator = address.toLowerCase()
434
+ const [
435
+ delegatedBalance,
436
+ { rewardsBalance, minRewardsToWithdraw, withdrawable },
437
+ { unbondNonce, withdrawalDelay, currentEpoch, withdrawExchangeRate },
438
+ ] = await Promise.all([
439
+ stakingServer.getTotalStake(delegator),
440
+ fetchRewardsInfo({ stakingServer, delegator, currency }),
441
+ fetchUnstakedClaimInfo({ stakingServer, delegator }),
442
+ ])
443
+ const minDelegateAmount = currency.defaultUnit(1)
444
+ const isDelegating = !delegatedBalance.isZero
445
+
446
+ const unclaimedUndelegatedInfo = await getUnstakedUnclaimedInfo({
447
+ stakingServer,
448
+ currency,
449
+ delegator,
450
+ unbondNonce,
451
+ currentEpoch,
452
+ withdrawalDelay,
453
+ withdrawExchangeRate,
454
+ })
455
+
456
+ return {
457
+ rewardsBalance,
458
+ withdrawable,
459
+ unbondNonce,
460
+ isDelegating,
461
+ delegatedBalance,
462
+ minRewardsToWithdraw,
463
+ minDelegateAmount,
464
+ ...unclaimedUndelegatedInfo,
465
+ }
466
+ }
467
+ }
@@ -0,0 +1,117 @@
1
+ import assert from 'minimalistic-assert'
2
+
3
+ import { mainnetContracts, methodIds as methodIds_ } from './contracts/index.js'
4
+ import { txFiltersFactory } from './tx-filters/index.js'
5
+
6
+ /**
7
+ * Used in ethereum-hooks to extract the final tx amount and the staking type
8
+ *
9
+ * tx.amount is altered for staking txs
10
+ *
11
+ * - amount is set to ZERO
12
+ * - rewards are derived from the tx (only applicable to Polygon. Ethereum has more complicated rewards distribution)
13
+ * -
14
+ */
15
+ function getStakingTxDetailsFactory({
16
+ isDelegateTx,
17
+ isUndelegateTx,
18
+ isClaimUndelegateTx,
19
+ decodeStakingTxAmount,
20
+ calculateRewardsFromStakeTx,
21
+ }) {
22
+ return function ({ tx, currency }) {
23
+ if (
24
+ ['delegate', 'undelegate', 'claimUndelegate'].some((stakingType) => tx.data?.[stakingType]) &&
25
+ tx.coinAmount.isZero
26
+ ) {
27
+ return
28
+ }
29
+
30
+ const txAmount = tx.coinAmount.toDefaultString()
31
+
32
+ if (isDelegateTx(tx)) {
33
+ const delegate = currency.baseUnit(decodeStakingTxAmount(tx)).toDefaultString()
34
+ // MATIC returned in unstake tx is always reward
35
+ const rewards = calculateRewardsFromStakeTx({ tx, currency })
36
+ return { delegate, txAmount, ...(rewards ? { rewards } : {}) }
37
+ }
38
+
39
+ if (isUndelegateTx(tx)) {
40
+ const undelegate = currency.baseUnit(decodeStakingTxAmount(tx)).toDefaultString()
41
+ // MATIC returned in unstake tx is always reward
42
+ const rewards = txAmount
43
+ return { undelegate, txAmount, rewards }
44
+ }
45
+
46
+ if (isClaimUndelegateTx(tx)) {
47
+ return { claimUndelegate: txAmount, txAmount }
48
+ }
49
+
50
+ // not a staking tx
51
+ }
52
+ }
53
+
54
+ const calculateRewardsFromStakeTxFactory =
55
+ ({ decodeStakingTxAmount }) =>
56
+ ({ tx, currency }) => {
57
+ const stakedAmount = currency.baseUnit(decodeStakingTxAmount(tx))
58
+ const { reward } = tx.data
59
+ // stake tx might have rewards in it,
60
+ // i.e: https://etherscan.io/tx/0x0a81d266109034a3a70c6f1b9601c105d8caebbd0de652a0619344f9559ae4fa
61
+ // thus reward = txInputAmount(amount to stake) - tx.coinAmount
62
+ // tx.coinAmount is already computed from monitor; incoming - outgoing ERC20 token txs
63
+ const stakeTxContainsReward = !stakedAmount.equals(tx.coinAmount.abs())
64
+
65
+ if (reward) {
66
+ // cache rewards
67
+ return currency.baseUnit(reward)
68
+ }
69
+
70
+ if (stakeTxContainsReward) {
71
+ const txAmount = stakedAmount.sub(tx.coinAmount.abs()).abs()
72
+ // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only -- TODO: Fix this the next time the file is edited.
73
+ return txAmount.toDefaultString()
74
+ }
75
+ }
76
+
77
+ const decodeStakingTxAmountFactory =
78
+ ({ validatorShareContract }) =>
79
+ (tx) => {
80
+ const {
81
+ data: { data: txInput },
82
+ } = tx
83
+
84
+ const {
85
+ values: [amount], // stake or unstake amount
86
+ } = validatorShareContract.decodeInput(txInput)
87
+
88
+ return amount
89
+ }
90
+
91
+ export const txUtilsFactory = ({
92
+ stakingServer,
93
+ contracts = mainnetContracts,
94
+ methodIds = methodIds_,
95
+ }) => {
96
+ assert(stakingServer, 'stakingServer is required')
97
+
98
+ const { isDelegateTx, isUndelegateTx, isClaimUndelegateTx, getStakingTxLogFilter } =
99
+ txFiltersFactory({ contracts, methodIds })
100
+
101
+ const { validatorShareContract } = stakingServer
102
+ const decodeStakingTxAmount = decodeStakingTxAmountFactory({ validatorShareContract })
103
+ const calculateRewardsFromStakeTx = calculateRewardsFromStakeTxFactory({ decodeStakingTxAmount })
104
+
105
+ const getStakingTxDetails = getStakingTxDetailsFactory({
106
+ isDelegateTx,
107
+ isUndelegateTx,
108
+ isClaimUndelegateTx,
109
+ decodeStakingTxAmount,
110
+ calculateRewardsFromStakeTx,
111
+ })
112
+
113
+ return {
114
+ getStakingTxLogFilter,
115
+ getStakingTxDetails,
116
+ }
117
+ }
@@ -0,0 +1,34 @@
1
+ export const txFiltersFactory = ({ contracts, methodIds }) => {
2
+ const isAssetTx = ({ coinName }) => coinName === 'polygon'
3
+ const isDelegateTx = (tx) =>
4
+ isAssetTx(tx) &&
5
+ tx.to === contracts.STAKING_MANAGER_ADDR &&
6
+ tx.data?.methodId === methodIds.DELEGATE
7
+ const isUndelegateTx = (tx) =>
8
+ isAssetTx(tx) &&
9
+ tx.from[0] === contracts.STAKING_MANAGER_ADDR &&
10
+ tx.data?.methodId === methodIds.UNDELEGATE
11
+ const isRewardTx = (tx) =>
12
+ isAssetTx(tx) &&
13
+ tx.from[0] === contracts.STAKING_MANAGER_ADDR &&
14
+ tx.data?.methodId === methodIds.CLAIM_REWARD
15
+ const isClaimUndelegateTx = (tx) =>
16
+ isAssetTx(tx) &&
17
+ tx.from[0] === contracts.STAKING_MANAGER_ADDR &&
18
+ tx.data?.methodId === methodIds.CLAIM_UNDELEGATE
19
+
20
+ const isStakingTx = (tx) => tx.to === contracts.EVERSTAKE_VALIDATOR_CONTRACT_ADDR
21
+
22
+ const getStakingTxLogFilter = (tx) =>
23
+ isDelegateTx(tx) || isClaimUndelegateTx(tx) || isUndelegateTx(tx)
24
+
25
+ return {
26
+ isAssetTx,
27
+ isStakingTx,
28
+ isDelegateTx,
29
+ isUndelegateTx,
30
+ isClaimUndelegateTx,
31
+ isRewardTx,
32
+ getStakingTxLogFilter,
33
+ }
34
+ }
package/src/staking.js CHANGED
@@ -1,26 +1,13 @@
1
1
  import { ethStakeAccountState } from '@exodus/ethereum-lib'
2
- import { asset, tokens } from '@exodus/ethereum-meta'
2
+ import { asset } from '@exodus/ethereum-meta'
3
3
 
4
- const polygon = tokens.find(({ name: tokenName }) => tokenName === 'polygon')
5
-
6
- export const polygonStakeAccountState = ({ currency }) => ({
7
- isDelegating: false,
8
- // Unstaking takes ~3-4 days, after that, user needs to
9
- // claim the unstaked amount (initial staked amount)
10
- isUndelegateInProgress: false,
11
- canClaimUndelegatedBalance: false,
12
- minRewardsToWithdraw: currency.defaultUnit(1),
13
- minDelegateAmount: currency.defaultUnit(1),
14
- unclaimedUndelegatedBalance: currency.ZERO,
15
- delegatedBalance: currency.ZERO,
16
- rewardsBalance: currency.ZERO,
17
- withdrawable: currency.ZERO,
18
- unbondNonce: '0',
19
- })
4
+ import { polygonStakingConfig, polygonStakingDeps } from './staking/polygon/index.js'
20
5
 
21
6
  export const stakingConfiguration = {
22
7
  ethereum: { accountStateExtraData: ethStakeAccountState(asset) },
23
- polygon: {
24
- accountStateExtraData: polygonStakeAccountState(polygon),
25
- },
8
+ polygon: polygonStakingConfig,
9
+ }
10
+
11
+ export const stakingDependencies = {
12
+ polygon: polygonStakingDeps,
26
13
  }