From 7f137a7b5832aac62fa6bac415d26b71b7cc63d1 Mon Sep 17 00:00:00 2001 From: shotgunofdeath Date: Fri, 1 Mar 2024 21:28:11 +0100 Subject: [PATCH] chore(eth-staking): use blockbook for staking actions --- packages/suite-desktop-core/src/config.ts | 3 - packages/suite/package.json | 2 +- .../wallet/stake/stakeFormEthereumActions.ts | 12 +- .../ClaimModal/ClaimEthForm.tsx | 10 +- .../UnstakeEthForm/UnstakeEthForm.tsx | 42 +-- .../suite/src/constants/suite/ethStaking.ts | 6 - .../src/hooks/suite/useEverstakePoolStats.ts | 17 +- packages/suite/src/support/messages.ts | 4 + packages/suite/src/utils/suite/stake.ts | 287 +++++++++++++++--- suite-common/wallet-types/src/transaction.ts | 3 +- yarn.lock | 10 +- 11 files changed, 306 insertions(+), 90 deletions(-) diff --git a/packages/suite-desktop-core/src/config.ts b/packages/suite-desktop-core/src/config.ts index 40ddd452eb4b..7b380eeebee5 100644 --- a/packages/suite-desktop-core/src/config.ts +++ b/packages/suite-desktop-core/src/config.ts @@ -25,9 +25,6 @@ export const allowedDomains = [ 'trezor-cardano-mainnet.blockfrost.io', 'trezor-cardano-preview.blockfrost.io', 'blockfrost.dev', - 'eth-goerli.public.blastapi.io', - 'ethereum-holesky.publicnode.com', - 'mainnet.infura.io', 'eth-api-b2c-stage.everstake.one', 'eth-api-b2c.everstake.one', ]; diff --git a/packages/suite/package.json b/packages/suite/package.json index 3a62a670e49b..029defc8806b 100644 --- a/packages/suite/package.json +++ b/packages/suite/package.json @@ -19,7 +19,7 @@ "test-unit:watch": "yarn g:jest -o --watch" }, "dependencies": { - "@everstake/wallet-sdk": "^0.3.39", + "@everstake/wallet-sdk": "^0.3.40", "@formatjs/intl": "2.10.0", "@hookform/resolvers": "3.3.4", "@mobily/ts-belt": "^3.13.1", diff --git a/packages/suite/src/actions/wallet/stake/stakeFormEthereumActions.ts b/packages/suite/src/actions/wallet/stake/stakeFormEthereumActions.ts index 4445e0cc0b83..389348d43a3b 100644 --- a/packages/suite/src/actions/wallet/stake/stakeFormEthereumActions.ts +++ b/packages/suite/src/actions/wallet/stake/stakeFormEthereumActions.ts @@ -33,12 +33,14 @@ import { // @ts-expect-error import { Ethereum } from '@everstake/wallet-sdk'; import { MIN_ETH_FOR_WITHDRAWALS } from 'src/constants/suite/ethStaking'; +import { NetworkSymbol } from '@suite-common/wallet-config'; const calculate = ( availableBalance: string, output: ExternalOutput, feeLevel: FeeLevel, compareWithAmount = true, + symbol: NetworkSymbol, ): PrecomposedTransaction => { const feeInSatoshi = calculateEthFee( toWei(feeLevel.feePerUnit, 'gwei'), @@ -64,10 +66,14 @@ const calculate = ( new BigNumber(feeInSatoshi).gt(availableBalance) || (compareWithAmount && totalSpent.isGreaterThan(availableBalance)) ) { - const error = 'AMOUNT_IS_NOT_ENOUGH'; + const error = 'TR_STAKE_NOT_ENOUGH_FUNDS'; // errorMessage declared later - return { type: 'error', error, errorMessage: { id: error } } as const; + return { + type: 'error', + error, + errorMessage: { id: error, values: { symbol: symbol.toUpperCase() } }, + } as const; } const payloadData = { @@ -152,7 +158,7 @@ export const composeTransaction = const wrappedResponse: PrecomposedLevels = {}; const compareWithAmount = formValues.ethereumStakeType === 'stake'; const response = predefinedLevels.map(level => - calculate(availableBalance, output, level, compareWithAmount), + calculate(availableBalance, output, level, compareWithAmount, account.symbol), ); response.forEach((tx, index) => { const feeLabel = predefinedLevels[index].label as FeeLevel['label']; diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/ClaimModal/ClaimEthForm.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/ClaimModal/ClaimEthForm.tsx index 9f4c2d0b6b54..b133df2c30ff 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/ClaimModal/ClaimEthForm.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/ClaimModal/ClaimEthForm.tsx @@ -27,7 +27,7 @@ const GreenTxt = styled.span` `; const StyledWarning = styled(Warning)` - margin-top: ${spacingsPx.sm}; + margin: ${spacingsPx.sm} 0 ${spacingsPx.sm} 0; justify-content: flex-start; `; @@ -83,16 +83,16 @@ export const ClaimEthForm = () => { + {errors[CRYPTO_INPUT] && ( + {errors[CRYPTO_INPUT]?.message} + )} + } /> - {errors[CRYPTO_INPUT] && ( - {errors[CRYPTO_INPUT]?.message} - )} - diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/UnstakeEthForm.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/UnstakeEthForm.tsx index 92d1edd7bf42..8d793a175f98 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/UnstakeEthForm.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/UnstakeEthForm.tsx @@ -70,8 +70,29 @@ export const UnstakeEthForm = () => { return (
+ {canClaim && ( + + , + }} + /> + + )} + + + {errors[CRYPTO_INPUT] && ( + + {errors[CRYPTO_INPUT]?.message} + + )} + + @@ -82,27 +103,6 @@ export const UnstakeEthForm = () => { helperText={} /> - - {errors[CRYPTO_INPUT] && ( - - {errors[CRYPTO_INPUT]?.message} - - )} - - {canClaim && ( - - , - }} - /> - - )} - - {!Number.isNaN(unstakingPeriod) && ( <> diff --git a/packages/suite/src/constants/suite/ethStaking.ts b/packages/suite/src/constants/suite/ethStaking.ts index 7d3ce08deee7..cf2dc2ce6396 100644 --- a/packages/suite/src/constants/suite/ethStaking.ts +++ b/packages/suite/src/constants/suite/ethStaking.ts @@ -10,12 +10,6 @@ export const MIN_ETH_AMOUNT_FOR_STAKING = new BigNumber(0.1); export const MIN_ETH_FOR_WITHDRAWALS = new BigNumber(0.03); export const MIN_ETH_BALANCE_FOR_STAKING = MIN_ETH_AMOUNT_FOR_STAKING.plus(MIN_ETH_FOR_WITHDRAWALS); -// source is a required parameter for some functions in the Everstake Wallet SDK. -// This parameter is used for some contract calls. -// It is a constant which allows the SDK to define which app calls its functions. -// Each app which integrates the SDK has its own source, e.g. source for Trezor Suite is '1'. -export const WALLET_SDK_SOURCE = '1'; - // Used when Everstake pool stats are not available from the API. export const BACKUP_ETH_APY = '4.13'; diff --git a/packages/suite/src/hooks/suite/useEverstakePoolStats.ts b/packages/suite/src/hooks/suite/useEverstakePoolStats.ts index 0b2b8826f861..832c8318a360 100644 --- a/packages/suite/src/hooks/suite/useEverstakePoolStats.ts +++ b/packages/suite/src/hooks/suite/useEverstakePoolStats.ts @@ -1,8 +1,15 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import BigNumber from 'bignumber.js'; -import { BACKUP_ETH_APY } from 'src/constants/suite/ethStaking'; +import { BACKUP_ETH_APY, STAKE_SYMBOLS } from 'src/constants/suite/ethStaking'; +import { selectEnabledNetworks } from 'src/reducers/wallet/settingsReducer'; +import { useSelector } from './useSelector'; export const useEverstakePoolStats = () => { + const enabledNetworks = useSelector(selectEnabledNetworks); + const areEthNetworksEnabled = useMemo( + () => enabledNetworks.some(symbol => STAKE_SYMBOLS.includes(symbol)), + [enabledNetworks], + ); const [poolStats, setPoolStats] = useState<{ ethApy: string; nextRewardPayout: number | null }>( { ethApy: BACKUP_ETH_APY, @@ -12,6 +19,8 @@ export const useEverstakePoolStats = () => { const [isPoolStatsLoading, setIsPoolStatsLoading] = useState(false); useEffect(() => { + if (!areEthNetworksEnabled) return; + const abortController = new AbortController(); const getEverstakePoolStats = async () => { @@ -39,7 +48,7 @@ export const useEverstakePoolStats = () => { ethApy: new BigNumber(stats.apr) .times(100) .toPrecision(3, BigNumber.ROUND_DOWN), - nextRewardPayout: Math.round(stats.next_reward_payout_in / 60 / 60 / 24), + nextRewardPayout: Math.ceil(stats.next_reward_payout_in / 60 / 60 / 24), }); } catch (e) { if (!abortController.signal.aborted) { @@ -55,7 +64,7 @@ export const useEverstakePoolStats = () => { return () => { abortController.abort(); }; - }, []); + }, [areEthNetworksEnabled]); return { ethApy: poolStats.ethApy, diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index d0e647aab352..1afce9f0baa5 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -8778,4 +8778,8 @@ export default defineMessages({ id: 'TR_STAKE_CLAIM_IN_NEXT_BLOCK', defaultMessage: 'in the next block', }, + TR_STAKE_NOT_ENOUGH_FUNDS: { + id: 'TR_STAKE_NOT_ENOUGH_FUNDS', + defaultMessage: 'Not enough {symbol} to pay network fees', + }, }); diff --git a/packages/suite/src/utils/suite/stake.ts b/packages/suite/src/utils/suite/stake.ts index 5c1d5539d602..eb977d9df29c 100644 --- a/packages/suite/src/utils/suite/stake.ts +++ b/packages/suite/src/utils/suite/stake.ts @@ -3,10 +3,206 @@ import { DEFAULT_PAYMENT } from '@suite-common/wallet-constants'; import { NetworkSymbol } from '@suite-common/wallet-config'; // @ts-expect-error import { Ethereum } from '@everstake/wallet-sdk'; -import { toHex, toWei } from 'web3-utils'; -import { sanitizeHex } from '@suite-common/wallet-utils'; -import { EthereumTransaction } from '@trezor/connect'; -import { WALLET_SDK_SOURCE } from 'src/constants/suite/ethStaking'; +import { fromWei, toHex, toWei } from 'web3-utils'; +import { getEthereumEstimateFeeParams, sanitizeHex } from '@suite-common/wallet-utils'; +import TrezorConnect, { EthereumTransaction } from '@trezor/connect'; +import BigNumber from 'bignumber.js'; + +// Gas reserve ensuring txs are processed +const GAS_RESERVE = 120000; +// source is a required parameter for some functions in the Everstake Wallet SDK. +// This parameter is used for some contract calls. +// It is a constant which allows the SDK to define which app calls its functions. +// Each app which integrates the SDK has its own source, e.g. source for Trezor Suite is '1'. +export const WALLET_SDK_SOURCE = '1'; + +export const getEthNetworkForWalletSdk = (symbol: NetworkSymbol) => { + const ethNetworks = { + thol: 'holesky', + tgor: 'goerli', + eth: 'mainnet', + }; + + if (!(symbol in ethNetworks)) return ethNetworks.eth; + + return ethNetworks[symbol as keyof typeof ethNetworks]; +}; + +type StakeTxBaseArgs = { + from: string; + symbol: NetworkSymbol; +}; + +const stake = async ({ + from, + amount, + symbol, +}: StakeTxBaseArgs & { + amount: string; +}) => { + const MIN_AMOUNT = new BigNumber('100000000000000000'); + const amountWei = toWei(amount, 'ether'); + + if (new BigNumber(amountWei).lt(MIN_AMOUNT)) throw new Error(`Min Amount ${MIN_AMOUNT} wei`); + + try { + const ethNetwork = getEthNetworkForWalletSdk(symbol); + const { contract_pool: contractPool } = Ethereum.selectNetwork(ethNetwork); + const contractPoolAddress = contractPool.options.address; + const data = contractPool.methods.stake(WALLET_SDK_SOURCE).encodeABI(); + + // gasLimit calculation based on address, amount and data size + // amount is essential for a proper calculation of gasLimit (via blockbook/geth) + const estimatedFee = await TrezorConnect.blockchainEstimateFee({ + coin: symbol, + request: { + blocks: [2], + specific: { + from, + ...getEthereumEstimateFeeParams(contractPoolAddress, amount, undefined, data), + }, + }, + }); + + if (!estimatedFee.success) { + throw new Error(estimatedFee.payload.error); + } + + const gasConsumption = Number(estimatedFee.payload.levels[0].feeLimit); + + // Create the transaction + return { + from, + to: contractPoolAddress, + value: amountWei, + gasLimit: gasConsumption + GAS_RESERVE, + data, + }; + } catch (e) { + throw new Error(e); + } +}; + +const unstake = async ({ + from, + amount, + interchanges, + symbol, +}: StakeTxBaseArgs & { + amount: string; + interchanges: number; +}) => { + try { + const ethNetwork = getEthNetworkForWalletSdk(symbol); + const { contract_pool: contractPool, contract_accounting: contractAccounting } = + Ethereum.selectNetwork(ethNetwork); + const contractPoolAddress = contractPool.options.address; + + const autocompoundBalance = await contractAccounting.methods + .autocompoundBalanceOf(from) + .call(); + const balance = new BigNumber(fromWei(autocompoundBalance, 'ether')); + + if (balance.lt(amount)) { + throw new Error(`Max Amount For Unstake ${balance}`); + } + + const UINT16_MAX = 65535 | 0; // asm type annotation + // Check for type overflow + if (interchanges > UINT16_MAX) { + interchanges = UINT16_MAX; + } + + const amountWei = toWei(amount, 'ether'); + const data = contractPool.methods + .unstake(amountWei, interchanges, WALLET_SDK_SOURCE) + .encodeABI(); + + // gasLimit calculation based on address, amount and data size + // amount is essential for a proper calculation of gasLimit (via blockbook/geth) + const estimatedFee = await TrezorConnect.blockchainEstimateFee({ + coin: symbol, + request: { + blocks: [2], + specific: { + from, + ...getEthereumEstimateFeeParams(contractPoolAddress, '0', undefined, data), + }, + }, + }); + + if (!estimatedFee.success) { + throw new Error(estimatedFee.payload.error); + } + + const gasConsumption = Number(estimatedFee.payload.levels[0].feeLimit); + + // Create the transaction + return { + from, + value: '0', + to: contractPoolAddress, + gasLimit: gasConsumption + GAS_RESERVE, + data, + }; + } catch (error) { + throw new Error(error); + } +}; + +const claimWithdrawRequest = async ({ from, symbol }: StakeTxBaseArgs) => { + try { + const ethNetwork = getEthNetworkForWalletSdk(symbol); + const { contract_accounting: contractAccounting } = Ethereum.selectNetwork(ethNetwork); + const contractAccountingAddress = contractAccounting.options.address; + + const rewards = await contractAccounting.methods.withdrawRequest(from).call(); + const requested = new BigNumber(fromWei(rewards[0], 'ether')); + const readyForClaim = new BigNumber(fromWei(rewards[1], 'ether')); + + if (requested.isZero()) { + throw new Error('No amount requested for unstake'); + } + + if (!readyForClaim.eq(requested)) throw new Error('Unstake request not filled yet'); + + const data = contractAccounting.methods.claimWithdrawRequest().encodeABI(); + + // gasLimit calculation based on address, amount and data size + // amount is essential for a proper calculation of gasLimit (via blockbook/geth) + const estimatedFee = await TrezorConnect.blockchainEstimateFee({ + coin: symbol, + request: { + blocks: [2], + specific: { + from, + ...getEthereumEstimateFeeParams( + contractAccountingAddress, + '0', + undefined, + data, + ), + }, + }, + }); + + if (!estimatedFee.success) { + throw new Error(estimatedFee.payload.error); + } + + const gasConsumption = Number(estimatedFee.payload.levels[0].feeLimit); + + return { + from, + to: contractAccountingAddress, + value: '0', + gasLimit: gasConsumption + GAS_RESERVE, + data, + }; + } catch (error) { + throw new Error(error); + } +}; interface GetStakeFormsDefaultValuesParams { address: string; @@ -38,26 +234,24 @@ export const getStakeFormsDefaultValues = ({ selectedFee: undefined, }); -export const getEthNetworkForWalletSdk = (symbol: NetworkSymbol) => { - const ethNetworks = { - thol: 'holesky', - tgor: 'goerli', - eth: 'mainnet', +const transformTx = ( + tx: any, + gasPrice: string, + nonce: string, + chainId: number, +): EthereumTransaction => { + const transformedTx = { + ...tx, + gasLimit: toHex(tx.gasLimit), + gasPrice: toHex(toWei(gasPrice, 'gwei')), + nonce: toHex(nonce), + chainId, + data: sanitizeHex(tx.data), + value: toHex(tx.value), }; + delete transformedTx.from; - if (!(symbol in ethNetworks)) return ethNetworks.eth; - - return ethNetworks[symbol as keyof typeof ethNetworks]; -}; - -const transformTx = (tx: any, gasPrice: string, nonce: string, chainId: number) => { - tx.gasLimit = toHex(tx.gasLimit); - tx.gasPrice = toHex(toWei(gasPrice, 'gwei')); - tx.nonce = toHex(nonce); - tx.chainId = chainId; - tx.data = sanitizeHex(tx.data); - tx.value = toHex(tx.value); - delete tx.from; + return transformedTx; }; interface PrepareStakeEthTxParams { @@ -87,14 +281,16 @@ export const prepareStakeEthTx = async ({ chainId, }: PrepareStakeEthTxParams): Promise => { try { - const ethNetwork = getEthNetworkForWalletSdk(symbol); - Ethereum.selectNetwork(ethNetwork); - const tx = await Ethereum.stake(from, amount, WALLET_SDK_SOURCE); - transformTx(tx, gasPrice, nonce, chainId); + const tx = await stake({ + from, + amount, + symbol, + }); + const transformedTx = transformTx(tx, gasPrice, nonce, chainId); return { success: true, - tx, + tx: transformedTx, }; } catch (e) { console.error(e); @@ -120,14 +316,17 @@ export const prepareUnstakeEthTx = async ({ interchanges = 0, }: PrepareUnstakeEthTxParams): Promise => { try { - const ethNetwork = getEthNetworkForWalletSdk(symbol); - Ethereum.selectNetwork(ethNetwork); - const tx = await Ethereum.unstake(from, amount, interchanges, WALLET_SDK_SOURCE); - transformTx(tx, gasPrice, nonce, chainId); + const tx = await unstake({ + from, + amount, + interchanges, + symbol, + }); + const transformedTx = transformTx(tx, gasPrice, nonce, chainId); return { success: true, - tx, + tx: transformedTx, }; } catch (e) { console.error(e); @@ -149,15 +348,12 @@ export const prepareClaimEthTx = async ({ chainId, }: PrepareClaimEthTxParams): Promise => { try { - const ethNetwork = getEthNetworkForWalletSdk(symbol); - Ethereum.selectNetwork(ethNetwork); - const tx = await Ethereum.claimWithdrawRequest(from); - - transformTx(tx, gasPrice, nonce, chainId); + const tx = await claimWithdrawRequest({ from, symbol }); + const transformedTx = transformTx(tx, gasPrice, nonce, chainId); return { success: true, - tx, + tx: transformedTx, }; } catch (e) { console.error(e); @@ -212,14 +408,23 @@ export const getStakeTxGasLimit = async ({ let txData; if (ethereumStakeType === 'stake') { - txData = await Ethereum.stake(from, amount, WALLET_SDK_SOURCE); + txData = await stake({ from, amount, symbol }); } if (ethereumStakeType === 'unstake') { // Increase allowedInterchangeNum to enable instant unstaking. - txData = await Ethereum.unstake(from, amount, 0, WALLET_SDK_SOURCE); + txData = await unstake({ + from, + amount, + interchanges: 0, + symbol, + }); } if (ethereumStakeType === 'claim') { - txData = await Ethereum.claimWithdrawRequest(from); + txData = await claimWithdrawRequest({ from, symbol }); + } + + if (!txData) { + throw new Error('No tx data'); } return { diff --git a/suite-common/wallet-types/src/transaction.ts b/suite-common/wallet-types/src/transaction.ts index f4c31601e849..4b34972e0e3c 100644 --- a/suite-common/wallet-types/src/transaction.ts +++ b/suite-common/wallet-types/src/transaction.ts @@ -29,7 +29,8 @@ type PrecomposedTransactionErrorExtended = | 'AMOUNT_NOT_ENOUGH_CURRENCY_FEE' | 'AMOUNT_IS_NOT_ENOUGH' | 'AMOUNT_IS_TOO_LOW' - | 'AMOUNT_IS_LESS_THAN_RESERVE'; + | 'AMOUNT_IS_LESS_THAN_RESERVE' + | 'TR_STAKE_NOT_ENOUGH_FUNDS'; }; export type TxNonFinalCardano = PrecomposedTransactionNonFinalCardano & { diff --git a/yarn.lock b/yarn.lock index 00fae9f8584d..2cab769d401e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3268,9 +3268,9 @@ __metadata: languageName: node linkType: hard -"@everstake/wallet-sdk@npm:^0.3.39": - version: 0.3.39 - resolution: "@everstake/wallet-sdk@npm:0.3.39" +"@everstake/wallet-sdk@npm:^0.3.40": + version: 0.3.40 + resolution: "@everstake/wallet-sdk@npm:0.3.40" dependencies: "@cosmjs/stargate": "npm:^0.30.1" "@mysten/sui.js": "npm:^0.46.1" @@ -3282,7 +3282,7 @@ __metadata: bip39: "npm:^3.1.0" bs58: "npm:^5.0.0" web3: "npm:^1.9.0" - checksum: be8548bffc072fe2af62340d5037141a9a476ea4b2b83293b1d670cdeade48146b41cbb24ac0f5b341d357e7c3df7443b6030a6ba0af050db95f7e19cbd7c0fa + checksum: bc673d0e8de4e630ac6a721a1c5b8184ce6ad2e01d1c95c6b61e5b95b39cc1b9e10839080b735ac84cd49409cd97260241115a6ef574d7a0c0bebfad78812be7 languageName: node linkType: hard @@ -10385,7 +10385,7 @@ __metadata: resolution: "@trezor/suite@workspace:packages/suite" dependencies: "@crowdin/cli": "npm:^3.18.0" - "@everstake/wallet-sdk": "npm:^0.3.39" + "@everstake/wallet-sdk": "npm:^0.3.40" "@formatjs/cli": "npm:^6.2.7" "@formatjs/intl": "npm:2.10.0" "@hookform/resolvers": "npm:3.3.4"