From 82aec27ccc3328db00973e0a1b0d38fcbc64eb41 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Tue, 29 Oct 2024 12:49:32 -0500 Subject: [PATCH] feat: add max tooltip for staking with gas fee consideration (#12025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR renders the max input modal component when the user clicks on the max button on the stake input screen. This is also responsible for subtracting the gas fees from the available balance before letting the user continue to the deposit flow. This PR also makes some minor changes and refactors as described below: - Fixes the misaligned staking navbar on Android devices. - The claim stake button will now be disabled until the transaction-controllers has closed. This prevents users from initiating multiple claims simultaneously. ## **Related issues** Fixes: [STAKE-847](https://consensyssoftware.atlassian.net/jira/software/projects/STAKE/boards/550/backlog?selectedIssue=STAKE-847) ## **Manual testing steps** 1. Add export MM_POOLED_STAKING_UI_ENABLED=true to .js.env file. 2. Click on Earn CTA 3. Click on Max button 4. Max input bottom sheet should appear and clicking on use max will calculate the maximum amount that can be staked by subtracting gas fees. ## **Screenshots/Recordings** ### **New : Max Input Modal** https://github.com/user-attachments/assets/a48ed3f2-f165-4fe7-9dba-133c63cf9d44 ### **Navbar alignment Fix** ### **Before** image ### **After** image ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [STAKE-847]: https://consensyssoftware.atlassian.net/browse/STAKE-847?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Matthew Grainger Co-authored-by: Matthew Grainger <46547583+Matt561@users.noreply.github.com> --- app/components/UI/Navbar/index.js | 16 +- .../StakeInputView/StakeInputView.test.tsx | 19 +- .../Views/StakeInputView/StakeInputView.tsx | 20 +- .../StakeInputView.test.tsx.snap | 45 +- .../UnstakeInputView.test.tsx | 1 + .../UnstakeInputView/UnstakeInputView.tsx | 20 +- .../UnstakeInputView.test.tsx.snap | 45 +- .../MaxInputModal/MaxInputModal.styles.ts | 23 + .../MaxInputModal/MaxInputModal.test.tsx | 67 ++ .../__snapshots__/MaxInputModal.test.tsx.snap | 656 ++++++++++++++++++ .../Stake/components/MaxInputModal/index.tsx | 79 +++ .../UI/Stake/components/QuickAmounts.tsx | 47 +- .../StakeButton/StakeButton.test.tsx | 4 +- .../UI/Stake/components/StakeButton/index.tsx | 5 +- .../ClaimBanner/ClaimBanner.test.tsx | 63 ++ .../ClaimBanner/ClaimBanner.tsx | 57 +- .../__snapshots__/ClaimBanner.test.tsx.snap | 103 +++ .../StakingBalance.test.tsx.snap | 3 + .../UI/Stake/hooks/useBalance.test.tsx | 32 +- app/components/UI/Stake/hooks/useBalance.ts | 4 +- .../UI/Stake/hooks/useInputHandler.ts | 160 +++++ .../UI/Stake/hooks/useStakingEligibility.ts | 2 + .../UI/Stake/hooks/useStakingGasFee.test.tsx | 162 +++++ .../UI/Stake/hooks/useStakingGasFee.ts | 94 +++ .../UI/Stake/hooks/useStakingInput.ts | 146 ++-- .../UI/Stake/hooks/useUnstakingInput.ts | 48 ++ app/components/UI/Stake/routes/index.tsx | 14 +- app/components/UI/Tokens/index.test.tsx | 40 +- app/constants/navigation/Routes.ts | 1 + locales/languages/en.json | 7 +- 30 files changed, 1777 insertions(+), 206 deletions(-) create mode 100644 app/components/UI/Stake/components/MaxInputModal/MaxInputModal.styles.ts create mode 100644 app/components/UI/Stake/components/MaxInputModal/MaxInputModal.test.tsx create mode 100644 app/components/UI/Stake/components/MaxInputModal/__snapshots__/MaxInputModal.test.tsx.snap create mode 100644 app/components/UI/Stake/components/MaxInputModal/index.tsx create mode 100644 app/components/UI/Stake/components/StakingBalance/StakingBanners/ClaimBanner/ClaimBanner.test.tsx create mode 100644 app/components/UI/Stake/components/StakingBalance/StakingBanners/ClaimBanner/__snapshots__/ClaimBanner.test.tsx.snap create mode 100644 app/components/UI/Stake/hooks/useInputHandler.ts create mode 100644 app/components/UI/Stake/hooks/useStakingGasFee.test.tsx create mode 100644 app/components/UI/Stake/hooks/useStakingGasFee.ts create mode 100644 app/components/UI/Stake/hooks/useUnstakingInput.ts diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index aeda7315f1d..07f352786e0 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -52,7 +52,6 @@ import Icon, { IconColor, } from '../../../component-library/components/Icons/Icon'; import { AddContactViewSelectorsIDs } from '../../../../e2e/selectors/Settings/Contacts/AddContactView.selectors'; -import { ImportTokenViewSelectorsIDs } from '../../../../e2e/selectors/wallet/ImportTokenView.selectors'; const trackEvent = (event, params = {}) => { MetaMetrics.getInstance().trackEvent(event, params); @@ -1849,6 +1848,9 @@ export function getStakingNavbar(title, navigation, themeColors, options) { fontSize: 14, ...fontStyles.normal, }, + headerTitle: { + alignItems: 'center', + }, }); function navigationPop() { @@ -1857,7 +1859,9 @@ export function getStakingNavbar(title, navigation, themeColors, options) { return { headerTitle: () => ( - {title} + + {title} + ), headerStyle: innerStyles.headerStyle, headerLeft: () => @@ -1868,7 +1872,9 @@ export function getStakingNavbar(title, navigation, themeColors, options) { onPress={navigationPop} style={innerStyles.headerLeft} /> - ) : null, + ) : ( + <> + ), headerRight: () => hasCancelButton ? ( - ) : null, + ) : ( + <> + ), }; } diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx index 90639128471..c7c3005ad89 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx @@ -4,11 +4,11 @@ import StakeInputView from './StakeInputView'; import { renderScreen } from '../../../../../util/test/renderWithProvider'; import Routes from '../../../../../constants/navigation/Routes'; import { backgroundState } from '../../../../../util/test/initial-root-state'; -import { BN } from 'ethereumjs-util'; import { Stake } from '../../sdk/stakeSdkProvider'; import { ChainId, PooledStakingContract } from '@metamask/stake-sdk'; import { Contract } from 'ethers'; import { MOCK_GET_VAULT_RESPONSE } from '../../__mocks__/mockData'; +import { toWei } from '../../../../../util/number'; function render(Component: React.ComponentType) { return renderScreen( @@ -54,7 +54,7 @@ jest.mock('../../../../../selectors/currencyRateController.ts', () => ({ selectCurrentCurrency: jest.fn(() => 'USD'), })); -const mockBalanceBN = new BN('1500000000000000000'); +const mockBalanceBN = toWei('1.5'); // 1.5 ETH const mockPooledStakingContractService: PooledStakingContract = { chainId: ChainId.ETHEREUM, @@ -84,12 +84,25 @@ jest.mock('../../hooks/useStakeContext.ts', () => ({ jest.mock('../../hooks/useBalance', () => ({ __esModule: true, default: () => ({ - balance: '1.5', + balanceETH: '1.5', balanceWei: mockBalanceBN, balanceFiatNumber: '3000', }), })); +const mockGasFee = toWei('0.0001'); + +jest.mock('../../hooks/useStakingGasFee', () => ({ + __esModule: true, + default: () => ({ + estimatedGasFeeWei: mockGasFee, + gasLimit: 70122, + isLoadingStakingGasFee: false, + isStakingGasFeeError: false, + refreshGasValues: jest.fn(), + }), +})); + const mockVaultData = MOCK_GET_VAULT_RESPONSE; // Mock hooks diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx index 9351d57b785..9dd1fdfaff5 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx @@ -17,14 +17,12 @@ import EstimatedAnnualRewardsCard from '../../components/EstimatedAnnualRewardsC import Routes from '../../../../../constants/navigation/Routes'; import styleSheet from './StakeInputView.styles'; import useStakingInputHandlers from '../../hooks/useStakingInput'; -import useBalance from '../../hooks/useBalance'; import InputDisplay from '../../components/InputDisplay'; const StakeInputView = () => { const title = strings('stake.stake_eth'); const navigation = useNavigation(); const { styles, theme } = useStyles(styleSheet, {}); - const { balance, balanceFiatNumber, balanceWei } = useBalance(); const { isEth, @@ -45,7 +43,9 @@ const StakeInputView = () => { annualRewardsFiat, annualRewardRate, isLoadingVaultData, - } = useStakingInputHandlers(balanceWei); + handleMax, + balanceValue, + } = useStakingInputHandlers(); const navigateToLearnMoreModal = () => { navigation.navigate('StakeModals', { @@ -73,6 +73,15 @@ const StakeInputView = () => { annualRewardRate, ]); + const handleMaxButtonPress = () => { + navigation.navigate('StakeModals', { + screen: Routes.STAKING.MODALS.MAX_INPUT, + params: { + handleMaxPress: handleMax, + }, + }); + }; + const balanceText = strings('stake.balance'); const buttonLabel = !isNonZeroAmount @@ -81,10 +90,6 @@ const StakeInputView = () => { ? strings('stake.not_enough_eth') : strings('stake.review'); - const balanceValue = isEth - ? `${balance} ETH` - : `${balanceFiatNumber?.toString()} ${currentCurrency.toUpperCase()}`; - useEffect(() => { navigation.setOptions( getStakingNavbar(title, navigation, theme.colors, { @@ -121,6 +126,7 @@ const StakeInputView = () => { + - - Stake ETH - + + Stake ETH + + ({ default: () => ({ stakedBalanceWei: mockPooledStakeData.assets, stakedBalanceFiat: MOCK_STAKED_ETH_ASSET.balanceFiat, + formattedStakedBalanceETH: '5.79133 ETH', }), })); diff --git a/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.tsx b/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.tsx index ddfa676d158..269151a431b 100644 --- a/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.tsx +++ b/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.tsx @@ -1,6 +1,5 @@ import { useNavigation } from '@react-navigation/native'; import React, { useCallback, useEffect } from 'react'; -import { BN } from 'ethereumjs-util'; import UnstakeInputViewBanner from './UnstakeBanner'; import { strings } from '../../../../../../locales/i18n'; import Button, { @@ -9,26 +8,22 @@ import Button, { ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; import { TextVariant } from '../../../../../component-library/components/Texts/Text'; -import { renderFromWei, weiToFiatNumber } from '../../../../../util/number'; import Keypad from '../../../../Base/Keypad'; import { useStyles } from '../../../../hooks/useStyles'; import { getStakingNavbar } from '../../../Navbar'; import ScreenLayout from '../../../Ramp/components/ScreenLayout'; import QuickAmounts from '../../components/QuickAmounts'; import { View } from 'react-native'; -import useStakingInputHandlers from '../../hooks/useStakingInput'; import styleSheet from './UnstakeInputView.styles'; import InputDisplay from '../../components/InputDisplay'; -import useBalance from '../../hooks/useBalance'; import Routes from '../../../../../constants/navigation/Routes'; +import useUnstakingInputHandlers from '../../hooks/useUnstakingInput'; const UnstakeInputView = () => { const title = strings('stake.unstake_eth'); const navigation = useNavigation(); const { styles, theme } = useStyles(styleSheet, {}); - const { stakedBalanceWei } = useBalance(); - const { isEth, currentCurrency, @@ -42,19 +37,10 @@ const UnstakeInputView = () => { percentageOptions, handleAmountPress, handleKeypadChange, - conversionRate, - } = useStakingInputHandlers(new BN(stakedBalanceWei)); - - const stakeBalanceInEth = renderFromWei(stakedBalanceWei, 5); - const stakeBalanceFiatNumber = weiToFiatNumber( - stakedBalanceWei, - conversionRate, - ); + stakedBalanceValue, + } = useUnstakingInputHandlers(); const stakedBalanceText = strings('stake.staked_balance'); - const stakedBalanceValue = isEth - ? `${stakeBalanceInEth} ETH` - : `${stakeBalanceFiatNumber?.toString()} ${currentCurrency.toUpperCase()}`; const buttonLabel = !isNonZeroAmount ? strings('stake.enter_amount') diff --git a/app/components/UI/Stake/Views/UnstakeInputView/__snapshots__/UnstakeInputView.test.tsx.snap b/app/components/UI/Stake/Views/UnstakeInputView/__snapshots__/UnstakeInputView.test.tsx.snap index 0e1816f6621..263d1be6b4e 100644 --- a/app/components/UI/Stake/Views/UnstakeInputView/__snapshots__/UnstakeInputView.test.tsx.snap +++ b/app/components/UI/Stake/Views/UnstakeInputView/__snapshots__/UnstakeInputView.test.tsx.snap @@ -102,26 +102,49 @@ exports[`UnstakeInputView render matches snapshot 1`] = ` pointerEvents="box-none" style={ { - "marginHorizontal": 16, + "alignItems": "flex-start", + "bottom": 0, + "justifyContent": "center", + "left": 0, + "opacity": 1, + "position": "absolute", + "top": 0, + } + } + /> + - - Unstake ETH - + + Unstake ETH + + + StyleSheet.create({ + container: { + paddingHorizontal: 16, + }, + textContainer: { + paddingBottom: 16, + paddingRight: 16, + }, + buttonContainer: { + flexDirection: 'row', + gap: 16, + paddingHorizontal: 16, + paddingBottom: 16, + }, + button: { + flex: 1, + }, + }); + +export default createMaxInputModalStyles; diff --git a/app/components/UI/Stake/components/MaxInputModal/MaxInputModal.test.tsx b/app/components/UI/Stake/components/MaxInputModal/MaxInputModal.test.tsx new file mode 100644 index 00000000000..8092f9d9fc5 --- /dev/null +++ b/app/components/UI/Stake/components/MaxInputModal/MaxInputModal.test.tsx @@ -0,0 +1,67 @@ +import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import MaxInputModal from '.'; +import { fireEvent } from '@testing-library/react-native'; +import Routes from '../../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockHandleMaxPress = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useRoute: () => ({ + params: { + handleMaxPress: mockHandleMaxPress, + }, + }), + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), + }; +}); + +const renderMaxInputModal = () => + renderScreen(MaxInputModal, { name: Routes.STAKING.MODALS.MAX_INPUT }); + +describe('MaxInputModal', () => { + it('render matches snapshot', () => { + const { toJSON } = renderMaxInputModal(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('calls handleMaxPress when "Use max" button is pressed', () => { + const { getByText } = renderMaxInputModal(); + + // Press the "Use Max" button + const useMaxButton = getByText('Use max'); + fireEvent.press(useMaxButton); + + // Check if handleMaxPress was called + expect(mockHandleMaxPress).toHaveBeenCalledTimes(1); + }); + + it('closes the BottomSheet when "Cancel" button is pressed', () => { + const { getByText } = renderMaxInputModal(); + + // Press the "Cancel" button + const cancelButton = getByText('Cancel'); + fireEvent.press(cancelButton); + + // Check if the BottomSheet's close function was called + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('closes the BottomSheet when "Use Max" button is pressed', () => { + const { getByText } = renderMaxInputModal(); + + // Press the "Use Max" button + const useMaxButton = getByText('Use max'); + fireEvent.press(useMaxButton); + + // Check if the BottomSheet's close function was called + expect(mockGoBack).toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Stake/components/MaxInputModal/__snapshots__/MaxInputModal.test.tsx.snap b/app/components/UI/Stake/components/MaxInputModal/__snapshots__/MaxInputModal.test.tsx.snap new file mode 100644 index 00000000000..c3aa8b2abf5 --- /dev/null +++ b/app/components/UI/Stake/components/MaxInputModal/__snapshots__/MaxInputModal.test.tsx.snap @@ -0,0 +1,656 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MaxInputModal render matches snapshot 1`] = ` + + + + + + + + + + + + + MaxInput + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Max + + + + + + + + + + + + + Max is the total amount of ETH you have, minus the gas fee required to stake. It’s a good idea to keep some extra ETH in your wallet for future transactions. + + + + + + + + Cancel + + + + + + + Use max + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Stake/components/MaxInputModal/index.tsx b/app/components/UI/Stake/components/MaxInputModal/index.tsx new file mode 100644 index 00000000000..a7534a2214c --- /dev/null +++ b/app/components/UI/Stake/components/MaxInputModal/index.tsx @@ -0,0 +1,79 @@ +import React, { useRef } from 'react'; +import { View } from 'react-native'; +import BottomSheet, { + type BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import Text, { + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import Button, { + ButtonSize, + ButtonVariants, + ButtonWidthTypes, +} from '../../../../../component-library/components/Buttons/Button'; +import { strings } from '../../../../../../locales/i18n'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import createMaxInputModalStyles from './MaxInputModal.styles'; +import { useRoute, RouteProp } from '@react-navigation/native'; + +const styles = createMaxInputModalStyles(); + +interface MaxInputModalRouteParams { + handleMaxPress: () => void; +} + +const MaxInputModal = () => { + const route = + useRoute>(); + const sheetRef = useRef(null); + + const { handleMaxPress } = route.params; + + const handleCancel = () => { + sheetRef.current?.onCloseBottomSheet(); + }; + + const handleConfirm = () => { + sheetRef.current?.onCloseBottomSheet(); + handleMaxPress(); + }; + + return ( + + + + + {strings('stake.max_modal.title')} + + + + + {strings('stake.max_modal.description')} + + + + + +