From 1807f1e7ce3495bac3f5469d8a64bf1412d794ff Mon Sep 17 00:00:00 2001 From: tomasklim Date: Wed, 9 Oct 2024 15:22:55 +0200 Subject: [PATCH] feat(suite): add staking/unstaking instant amount forecasting --- .../UnstakeModal/UnstakeEthForm/Options.tsx | 5 +- .../UnstakeEthForm/UnstakeEthForm.tsx | 83 ++++++++++++------- .../src/hooks/wallet/useUnstakeEthForm.ts | 41 ++++++++- packages/suite/src/support/messages.ts | 10 +++ .../src/utils/suite/__fixtures__/stake.ts | 27 ++++++ .../src/utils/suite/__tests__/stake.test.ts | 18 ++++ packages/suite/src/utils/suite/stake.ts | 34 ++++++++ .../components/ApproximateEthAmount.tsx | 26 ++++++ 8 files changed, 208 insertions(+), 36 deletions(-) create mode 100644 packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/ApproximateEthAmount.tsx diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/Options.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/Options.tsx index 2e5969fc1263..a414bad5cb97 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/Options.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UnstakeModal/UnstakeEthForm/Options.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import styled from 'styled-components'; import { FiatValue, FormattedCryptoAmount, Translation } from 'src/components/suite'; import { Paragraph, Radio } from '@trezor/components'; @@ -61,16 +60,14 @@ const InputsWrapper = styled.div<{ $isShown: boolean }>` display: ${({ $isShown }) => ($isShown ? 'block' : 'none')}; `; -type UnstakeOptions = 'all' | 'rewards' | 'other'; - interface OptionsProps { symbol: NetworkSymbol; } export const Options = ({ symbol }: OptionsProps) => { const selectedAccount = useSelector(selectSelectedAccount); + const { unstakeOption, setUnstakeOption } = useUnstakeEthFormContext(); - const [unstakeOption, setUnstakeOption] = useState('all'); const isRewardsSelected = unstakeOption === 'rewards'; const isAllSelected = unstakeOption === 'all'; const isOtherAmountSelected = unstakeOption === 'other'; 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 09b78f549099..8464e947172b 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 @@ -1,6 +1,6 @@ import styled from 'styled-components'; -import { Divider, Paragraph, Banner } from '@trezor/components'; -import { spacingsPx } from '@trezor/theme'; +import { Divider, Icon, Paragraph, Tooltip, Banner, Row, Column } from '@trezor/components'; +import { spacings, spacingsPx } from '@trezor/theme'; import { Translation } from 'src/components/suite'; import { useSelector } from 'src/hooks/suite'; import { useUnstakeEthFormContext } from 'src/hooks/wallet/useUnstakeEthForm'; @@ -11,11 +11,8 @@ import { getUnstakingPeriodInDays } from 'src/utils/suite/stake'; import UnstakeFees from './Fees'; import { selectValidatorsQueueData } from '@suite-common/wallet-core'; import { getAccountEverstakeStakingPool } from '@suite-common/wallet-utils'; - -// eslint-disable-next-line local-rules/no-override-ds-component -const GreyP = styled(Paragraph)` - color: ${({ theme }) => theme.textSubdued}; -`; +import { ApproximateEthAmount } from 'src/views/wallet/staking/components/EthStakingDashboard/components/ApproximateEthAmount'; +import { BigNumber } from '@trezor/utils'; const DividerWrapper = styled.div` & > div { @@ -37,15 +34,6 @@ const WarningsWrapper = styled.div` gap: ${spacingsPx.md}; `; -const UpToDaysWrapper = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 16px; - padding: ${spacingsPx.lg} 0 ${spacingsPx.md}; - border-top: 1px solid ${({ theme }) => theme.borderElevation2}; -`; - export const UnstakeEthForm = () => { const selectedAccount = useSelector(selectSelectedAccount); @@ -54,6 +42,7 @@ export const UnstakeEthForm = () => { formState: { errors }, handleSubmit, signTx, + approximatedInstantEthAmount, } = useUnstakeEthFormContext(); const { symbol } = account; @@ -62,12 +51,13 @@ export const UnstakeEthForm = () => { selectValidatorsQueueData(state, account?.symbol), ); const unstakingPeriod = getUnstakingPeriodInDays(validatorWithdrawTime); - const { canClaim = false, claimableAmount = '0' } = getAccountEverstakeStakingPool(selectedAccount) ?? {}; const inputError = errors[CRYPTO_INPUT] || errors[FIAT_INPUT]; const showError = inputError && inputError.type === 'compose'; + const shouldShowInstantUnstakeEthAmount = + approximatedInstantEthAmount && BigNumber(approximatedInstantEthAmount).gt(0); return (
@@ -96,19 +86,52 @@ export const UnstakeEthForm = () => { - - - - - - - - + + + + + + + + + + + + {shouldShowInstantUnstakeEthAmount && ( + + + + + + + + } + > + + + + + + + )} + + ); }; diff --git a/packages/suite/src/hooks/wallet/useUnstakeEthForm.ts b/packages/suite/src/hooks/wallet/useUnstakeEthForm.ts index e84de39818c5..358daad29458 100644 --- a/packages/suite/src/hooks/wallet/useUnstakeEthForm.ts +++ b/packages/suite/src/hooks/wallet/useUnstakeEthForm.ts @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useEffect, useMemo } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useForm, useWatch } from 'react-hook-form'; import { @@ -22,7 +22,11 @@ import { selectLocalCurrency } from 'src/reducers/wallet/settingsReducer'; import { signTransaction } from 'src/actions/wallet/stakeActions'; import { PrecomposedTransactionFinal } from '@suite-common/wallet-types'; -import { getEthNetworkForWalletSdk, getStakeFormsDefaultValues } from 'src/utils/suite/stake'; +import { + getEthNetworkForWalletSdk, + getStakeFormsDefaultValues, + simulateUnstake, +} from 'src/utils/suite/stake'; import { useFormDraft } from './useFormDraft'; import useDebounce from 'react-use/lib/useDebounce'; import { isChanged } from '@suite-common/suite-utils'; @@ -35,8 +39,13 @@ import { import { selectNetwork } from '@everstake/wallet-sdk/ethereum'; import { useFees } from './form/useFees'; +type UnstakeOptions = 'all' | 'rewards' | 'other'; + type UnstakeContextValues = UnstakeContextValuesBase & { amountLimits: AmountLimitsString; + approximatedInstantEthAmount?: string | null; + unstakeOption: UnstakeOptions; + setUnstakeOption: (option: UnstakeOptions) => void; }; export const UnstakeEthFormContext = createContext(null); @@ -46,6 +55,10 @@ export const useUnstakeEthForm = ({ selectedAccount, }: UseStakeFormsProps): UnstakeContextValues => { const dispatch = useDispatch(); + const [approximatedInstantEthAmount, setApproximatedInstantEthAmount] = useState( + null, + ); + const [unstakeOption, setUnstakeOption] = useState('all'); const { account, network } = selectedAccount; const { symbol } = account; @@ -102,6 +115,27 @@ export const useUnstakeEthForm = ({ const values = useWatch({ control }); + useEffect(() => { + const { cryptoInput } = values; + + if (!cryptoInput || Object.keys(formState.errors).length) { + setApproximatedInstantEthAmount(null); + + return; + } + + const simulateUnstakeAmount = async () => { + const approximatedEthAmount = await simulateUnstake({ + amount: cryptoInput, + from: account.descriptor, + symbol: account.symbol, + }); + setApproximatedInstantEthAmount(approximatedEthAmount); + }; + + simulateUnstakeAmount(); + }, [account.symbol, account.descriptor, formState.errors, values]); + useEffect(() => { if (!isChanged(defaultValues, values)) { removeDraft(account.key); @@ -247,6 +281,9 @@ export const useUnstakeEthForm = ({ currentRate, feeInfo, changeFeeLevel, + approximatedInstantEthAmount, + unstakeOption, + setUnstakeOption, }; }; diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index 2d7d62c838f9..6d8de3f0f039 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -8827,6 +8827,16 @@ export default defineMessages({ id: 'TR_STAKE_UNSTAKING_PERIOD', defaultMessage: 'Unstaking period', }, + TR_STAKE_UNSTAKING_APPROXIMATE: { + id: 'TR_STAKE_UNSTAKING_APPROXIMATE', + defaultMessage: 'Approximate {symbol} available instantly', + }, + + TR_STAKE_UNSTAKING_APPROXIMATE_DESCRIPTION: { + id: 'TR_STAKE_UNSTAKING_APPROXIMATE_DESCRIPTION', + defaultMessage: + 'Liquidity of the staking pool can allow for instant unstake of some funds. Remaining funds will follow the unstaking period', + }, TR_UP_TO_DAYS: { id: 'TR_UP_TO_DAYS', defaultMessage: 'up to {count, plural, one {# day} other {# days}}', diff --git a/packages/suite/src/utils/suite/__fixtures__/stake.ts b/packages/suite/src/utils/suite/__fixtures__/stake.ts index 324f097c7d21..e0f4355c5754 100644 --- a/packages/suite/src/utils/suite/__fixtures__/stake.ts +++ b/packages/suite/src/utils/suite/__fixtures__/stake.ts @@ -863,3 +863,30 @@ export const getChangedInternalTxFixture = [ }, }, ]; + +export const simulateUnstakeFixture = [ + { + description: 'should return null for no coin, from, or data', + args: { + amount: undefined, + from: undefined, + symbol: undefined, + }, + ethereumCallResult: {}, + result: null, + }, + { + description: 'should return approximated amount for valid inputs', + args: { + amount: '0.1', + from: '0xfB0bc552ab5Fa1971E8530852753c957e29eEEFC', + to: '0xAFA848357154a6a624686b348303EF9a13F63264', + symbol: 'eth', + }, + ethereumCallResult: { + success: true, + payload: { data: '0x000000000000000000000000000000000000000000000000016345785d8a0000' }, + }, + result: '0.1', // 0.1 eth + }, +]; diff --git a/packages/suite/src/utils/suite/__tests__/stake.test.ts b/packages/suite/src/utils/suite/__tests__/stake.test.ts index 3eb0c11acfe3..496a907c23d6 100644 --- a/packages/suite/src/utils/suite/__tests__/stake.test.ts +++ b/packages/suite/src/utils/suite/__tests__/stake.test.ts @@ -2,6 +2,7 @@ import TrezorConnect, { AccountInfo, InternalTransfer, Success, + SuccessWithDevice, Unsuccessful, } from '@trezor/connect'; import { @@ -23,6 +24,7 @@ import { getInstantStakeTypeFixture, getChangedInternalTxFixture, getUnstakingAmountFixtures, + simulateUnstakeFixture, } from '../__fixtures__/stake'; import { getUnstakingAmount, @@ -43,6 +45,7 @@ import { getEthNetworkForWalletSdk, getInstantStakeType, getChangedInternalTx, + simulateUnstake, } from '../stake'; import { BlockchainEstimatedFee, @@ -265,3 +268,18 @@ describe('getChangedInternalTx', () => { }); }); }); + +type EthereumCallResult = Unsuccessful | SuccessWithDevice<{ data: string }>; +type SimulateUnstakeArgs = StakeTxBaseArgs & { amount: string }; + +describe('simulateUnstake', () => { + simulateUnstakeFixture.forEach(test => { + it(test.description, async () => { + jest.spyOn(TrezorConnect, 'ethereumCall').mockImplementation(() => + Promise.resolve(test.ethereumCallResult as EthereumCallResult), + ); + const result = await simulateUnstake(test.args as SimulateUnstakeArgs); + expect(result).toEqual(test.result); + }); + }); +}); diff --git a/packages/suite/src/utils/suite/stake.ts b/packages/suite/src/utils/suite/stake.ts index 1edb8f93283a..f0eb02bd58b5 100644 --- a/packages/suite/src/utils/suite/stake.ts +++ b/packages/suite/src/utils/suite/stake.ts @@ -641,3 +641,37 @@ export const validateStakingMax = }); } }; +export const simulateUnstake = async ({ + amount, + from, + symbol, +}: StakeTxBaseArgs & { amount: string }) => { + const ethNetwork = getEthNetworkForWalletSdk(symbol); + const { address_pool: poolAddress, contract_pool: contractPool } = selectNetwork(ethNetwork); + + if (!amount || !from || !symbol) return null; + + const amountWei = toWei(amount, 'ether'); + const interchanges = 0; + const coin = symbol?.toString(); + + const data = contractPool.methods + .unstake(amountWei, interchanges, WALLET_SDK_SOURCE) + .encodeABI(); + if (!data) return null; + + const ethereumData = await TrezorConnect.ethereumCall({ + coin, + from, + to: poolAddress, + data, + }); + + if (!ethereumData.success) { + throw new Error(ethereumData.payload.error); + } + + const approximatedAmount = ethereumData.payload.data; + + return fromWei(approximatedAmount, 'ether'); +}; diff --git a/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/ApproximateEthAmount.tsx b/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/ApproximateEthAmount.tsx new file mode 100644 index 000000000000..f668bb7ab1aa --- /dev/null +++ b/packages/suite/src/views/wallet/staking/components/EthStakingDashboard/components/ApproximateEthAmount.tsx @@ -0,0 +1,26 @@ +import { FormattedCryptoAmount } from 'src/components/suite'; +import { Tooltip } from '@trezor/components'; +import { BigNumber } from '@trezor/utils/src/bigNumber'; + +interface ApproximateEthAmountProps { + value: string | number; + symbol: string; +} + +const DEFAULT_MAX_DECIMAL_PLACES = 2; + +export const ApproximateEthAmount = ({ value, symbol }: ApproximateEthAmountProps) => { + const hasDecimals = value.toString().includes('.'); + + if (!hasDecimals) { + return ; + } + + const trimmedAmount = new BigNumber(value).toFixed(DEFAULT_MAX_DECIMAL_PLACES, 1); + + return ( + }> + + + ); +};