diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx
index 9351d57b785..1244a3ccff8 100644
--- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx
+++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx
@@ -45,6 +45,7 @@ const StakeInputView = () => {
annualRewardsFiat,
annualRewardRate,
isLoadingVaultData,
+ handleMaxPress,
} = useStakingInputHandlers(balanceWei);
const navigateToLearnMoreModal = () => {
@@ -73,6 +74,15 @@ const StakeInputView = () => {
annualRewardRate,
]);
+ const handleMaxButtonPress = () => {
+ navigation.navigate('StakeModals', {
+ screen: Routes.STAKING.MODALS.MAX_INPUT,
+ params: {
+ handleMaxPress,
+ },
+ });
+ };
+
const balanceText = strings('stake.balance');
const buttonLabel = !isNonZeroAmount
@@ -121,6 +131,7 @@ const StakeInputView = () => {
{
const title = strings('stake.unstake_eth');
@@ -43,7 +43,7 @@ const UnstakeInputView = () => {
handleAmountPress,
handleKeypadChange,
conversionRate,
- } = useStakingInputHandlers(new BN(stakedBalanceWei));
+ } = useUnstakingInputHandlers(new BN(stakedBalanceWei));
const stakeBalanceInEth = renderFromWei(stakedBalanceWei, 5);
const stakeBalanceFiatNumber = weiToFiatNumber(
diff --git a/app/components/UI/Stake/components/MaxInputModal/MaxInputModal.styles.ts b/app/components/UI/Stake/components/MaxInputModal/MaxInputModal.styles.ts
new file mode 100644
index 00000000000..ae5cd275a1e
--- /dev/null
+++ b/app/components/UI/Stake/components/MaxInputModal/MaxInputModal.styles.ts
@@ -0,0 +1,23 @@
+import { StyleSheet } from 'react-native';
+
+const createMaxInputModalStyles = () =>
+ 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..333fe5cc7a2
--- /dev/null
+++ b/app/components/UI/Stake/components/MaxInputModal/MaxInputModal.test.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import renderWithProvider from '../../../../../util/test/renderWithProvider';
+import MaxInputModal from '.';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
+
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => {
+ const actualReactNavigation = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualReactNavigation,
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+ };
+});
+
+const renderMaxInputModal = () =>
+ renderWithProvider(
+
+ ,
+ ,
+ );
+
+describe('MaxInputModal', () => {
+ it('render matches snapshot', () => {
+ const { toJSON } = renderMaxInputModal();
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
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..20d4c8177a4
--- /dev/null
+++ b/app/components/UI/Stake/components/MaxInputModal/__snapshots__/MaxInputModal.test.tsx.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`MaxInputModal render matches snapshot 1`] = `
+
+`;
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')}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default MaxInputModal;
diff --git a/app/components/UI/Stake/components/QuickAmounts.tsx b/app/components/UI/Stake/components/QuickAmounts.tsx
index 6adc535be6f..fc089600ae4 100644
--- a/app/components/UI/Stake/components/QuickAmounts.tsx
+++ b/app/components/UI/Stake/components/QuickAmounts.tsx
@@ -39,44 +39,61 @@ const createStyles = (colors: Colors) =>
interface AmountProps {
amount: QuickAmount;
onPress: (amount: QuickAmount) => void;
-
+ onMaxPress?: () => void;
disabled?: boolean;
}
-const Amount = ({ amount, onPress }: AmountProps) => {
+const Amount = ({ amount, onPress, onMaxPress }: AmountProps) => {
const { value, label } = amount;
const { colors } = useTheme();
const styles = createStyles(colors);
+
const handlePress = useCallback(() => {
+ if (value === 1 && onMaxPress) {
+ onMaxPress();
+ return;
+ }
onPress(amount);
- }, [onPress, amount]);
+ }, [value, onMaxPress, amount, onPress]);
return (
-
+ <>
+
+ >
);
};
interface QuickAmountsProps {
amounts: QuickAmount[];
onAmountPress: (amount: QuickAmount) => void;
+ onMaxPress?: () => void;
}
-const QuickAmounts = ({ amounts, onAmountPress }: QuickAmountsProps) => {
+const QuickAmounts = ({
+ amounts,
+ onAmountPress,
+ onMaxPress,
+}: QuickAmountsProps) => {
const { colors } = useTheme();
const styles = createStyles(colors);
return (
{amounts.map((amount, index: number) => (
-
+
))}
);
diff --git a/app/components/UI/Stake/hooks/useStakingGasFee.ts b/app/components/UI/Stake/hooks/useStakingGasFee.ts
new file mode 100644
index 00000000000..eeb6f8e7f9c
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useStakingGasFee.ts
@@ -0,0 +1,59 @@
+import { useState, useEffect } from 'react';
+import { useStakeContext } from './useStakeContext';
+import type { PooledStakingContract } from '@metamask/stake-sdk';
+import useGasPriceEstimation from '../../Ramp/hooks/useGasPriceEstimation';
+import { useSelector } from 'react-redux';
+import { selectSelectedInternalAccountChecksummedAddress } from '../../../../selectors/accountsController';
+import { formatEther } from 'ethers/lib/utils';
+
+interface StakingGasFee {
+ estimatedGasFeeETH: string;
+ gasLimit: number;
+}
+
+const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
+const DEFAULT_GAS_LIMIT = 21000;
+const GAS_LIMIT_BUFFER = 1.3;
+
+const useStakingGasFee = (depositValueWei: string): StakingGasFee => {
+ const sdk = useStakeContext();
+ const selectedAddress =
+ useSelector(selectSelectedInternalAccountChecksummedAddress) || '';
+ const pooledStakingContract = sdk.stakingContract as PooledStakingContract;
+ const [gasLimit, setGasLimit] = useState(0);
+
+ useEffect(() => {
+ const fetchDepositGasLimit = async () => {
+ try {
+ const depositGasLimit = await pooledStakingContract.estimateDepositGas(
+ formatEther(depositValueWei),
+ selectedAddress,
+ ZERO_ADDRESS,
+ );
+
+ const gasLimitWithBuffer = Math.ceil(
+ depositGasLimit * GAS_LIMIT_BUFFER,
+ );
+
+ setGasLimit(gasLimitWithBuffer);
+ } catch (error) {
+ console.error('Error fetching gas price or gas limit:', error);
+ setGasLimit(DEFAULT_GAS_LIMIT);
+ }
+ };
+
+ fetchDepositGasLimit();
+ }, [depositValueWei, pooledStakingContract, selectedAddress]);
+
+ const gasPriceEstimation = useGasPriceEstimation({
+ gasLimit,
+ estimateRange: 'high',
+ });
+
+ const estimatedGasFeeETH =
+ gasPriceEstimation?.estimatedGasFee?.toString() || '0';
+
+ return { estimatedGasFeeETH, gasLimit };
+};
+
+export default useStakingGasFee;
diff --git a/app/components/UI/Stake/hooks/useStakingInput.ts b/app/components/UI/Stake/hooks/useStakingInput.ts
index 7424703e0f1..b4874ced7ed 100644
--- a/app/components/UI/Stake/hooks/useStakingInput.ts
+++ b/app/components/UI/Stake/hooks/useStakingInput.ts
@@ -16,17 +16,28 @@ import {
} from '../../../../util/number';
import { strings } from '../../../../../locales/i18n';
import useVaultData from './useVaultData';
+import useStakingGasFee from './useStakingGasFee';
const useStakingInputHandlers = (balance: BN) => {
const [amountEth, setAmountEth] = useState('0');
const [amountWei, setAmountWei] = useState(new BN(0));
const [estimatedAnnualRewards, setEstimatedAnnualRewards] = useState('-');
+ const { estimatedGasFeeETH } = useStakingGasFee(amountWei.toString());
+
+ const maxStakeableAmountWei = useMemo(
+ () =>
+ balance.gt(new BN(estimatedGasFeeETH))
+ ? balance.sub(new BN(estimatedGasFeeETH))
+ : new BN(0),
+ [balance, estimatedGasFeeETH],
+ );
+
const isNonZeroAmount = useMemo(() => amountWei.gt(new BN(0)), [amountWei]);
const isOverMaximum = useMemo(() => {
- const additionalFundsRequired = amountWei.sub(balance || new BN(0));
+ const additionalFundsRequired = amountWei.sub(maxStakeableAmountWei);
return isNonZeroAmount && additionalFundsRequired.gt(new BN(0));
- }, [amountWei, balance, isNonZeroAmount]);
+ }, [amountWei, isNonZeroAmount, maxStakeableAmountWei]);
const [fiatAmount, setFiatAmount] = useState('0');
const [isEth, setIsEth] = useState(true);
@@ -92,6 +103,7 @@ const useStakingInputHandlers = (balance: BN) => {
({ value }: { value: number }) => {
if (!balance) return;
const percentage = value * 100;
+
const amountPercentage = balance.mul(new BN(percentage)).div(new BN(100));
const newAmountString = fromTokenMinimalUnitString(
@@ -115,6 +127,28 @@ const useStakingInputHandlers = (balance: BN) => {
[balance, conversionRate],
);
+ const handleMaxPress = useCallback(() => {
+ if (!balance) return;
+
+ const newAmountString = fromTokenMinimalUnitString(
+ maxStakeableAmountWei.toString(10),
+ 18,
+ );
+ const newEthAmount = limitToMaximumDecimalPlaces(
+ Number(newAmountString),
+ 5,
+ );
+ setAmountEth(newEthAmount);
+ setAmountWei(maxStakeableAmountWei);
+
+ const newFiatAmount = weiToFiatNumber(
+ toWei(newEthAmount.toString(), 'ether'),
+ conversionRate,
+ 2,
+ ).toString();
+ setFiatAmount(newFiatAmount);
+ }, [balance, conversionRate, maxStakeableAmountWei]);
+
const annualRewardsETH = useMemo(
() =>
`${limitToMaximumDecimalPlaces(
@@ -174,6 +208,7 @@ const useStakingInputHandlers = (balance: BN) => {
annualRewardsFiat,
annualRewardRate,
isLoadingVaultData,
+ handleMaxPress,
};
};
diff --git a/app/components/UI/Stake/hooks/useUnstakingInput.ts b/app/components/UI/Stake/hooks/useUnstakingInput.ts
new file mode 100644
index 00000000000..dfb8b30d096
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useUnstakingInput.ts
@@ -0,0 +1,181 @@
+import { BN } from 'ethereumjs-util';
+import { useState, useMemo, useCallback } from 'react';
+import { useSelector } from 'react-redux';
+import {
+ selectCurrentCurrency,
+ selectConversionRate,
+} from '../../../../selectors/currencyRateController';
+import {
+ toWei,
+ weiToFiatNumber,
+ renderFromTokenMinimalUnit,
+ fiatNumberToWei,
+ fromTokenMinimalUnitString,
+ limitToMaximumDecimalPlaces,
+ renderFiat,
+} from '../../../../util/number';
+import { strings } from '../../../../../locales/i18n';
+import useVaultData from './useVaultData';
+
+const useUnstakingInputHandlers = (balance: BN) => {
+ const [amountEth, setAmountEth] = useState('0');
+ const [amountWei, setAmountWei] = useState(new BN(0));
+ const [estimatedAnnualRewards, setEstimatedAnnualRewards] = useState('-');
+
+ const isNonZeroAmount = useMemo(() => amountWei.gt(new BN(0)), [amountWei]);
+ const isOverMaximum = useMemo(() => {
+ const additionalFundsRequired = amountWei.sub(balance);
+ return isNonZeroAmount && additionalFundsRequired.gt(new BN(0));
+ }, [amountWei, balance, isNonZeroAmount]);
+
+ const [fiatAmount, setFiatAmount] = useState('0');
+ const [isEth, setIsEth] = useState(true);
+ const currentCurrency = useSelector(selectCurrentCurrency);
+ const conversionRate = useSelector(selectConversionRate) || 1;
+
+ const { annualRewardRate, annualRewardRateDecimal, isLoadingVaultData } =
+ useVaultData();
+
+ const currencyToggleValue = isEth
+ ? `${fiatAmount} ${currentCurrency.toUpperCase()}`
+ : `${amountEth} ETH`;
+
+ const handleEthInput = useCallback(
+ (value: string) => {
+ setAmountEth(value);
+ setAmountWei(toWei(value, 'ether'));
+ const fiatValue = weiToFiatNumber(
+ toWei(value, 'ether'),
+ conversionRate,
+ 2,
+ ).toString();
+ setFiatAmount(fiatValue);
+ },
+ [conversionRate],
+ );
+
+ const handleFiatInput = useCallback(
+ (value: string) => {
+ setFiatAmount(value);
+ const ethValue = renderFromTokenMinimalUnit(
+ fiatNumberToWei(value, conversionRate).toString(),
+ 18,
+ 5,
+ );
+
+ setAmountEth(ethValue);
+ setAmountWei(toWei(ethValue, 'ether'));
+ },
+ [conversionRate],
+ );
+
+ /* Keypad Handlers */
+ const handleKeypadChange = useCallback(
+ ({ value }) => {
+ isEth ? handleEthInput(value) : handleFiatInput(value);
+ },
+ [handleEthInput, handleFiatInput, isEth],
+ );
+
+ const handleCurrencySwitch = useCallback(() => {
+ setIsEth(!isEth);
+ }, [isEth]);
+
+ const percentageOptions = [
+ { value: 0.25, label: '25%' },
+ { value: 0.5, label: '50%' },
+ { value: 0.75, label: '75%' },
+ { value: 1, label: strings('stake.max') },
+ ];
+
+ const handleAmountPress = useCallback(
+ ({ value }: { value: number }) => {
+ if (!balance) return;
+ const percentage = value * 100;
+
+ const amountPercentage = balance.mul(new BN(percentage)).div(new BN(100));
+
+ const newAmountString = fromTokenMinimalUnitString(
+ amountPercentage.toString(10),
+ 18,
+ );
+ const newEthAmount = limitToMaximumDecimalPlaces(
+ Number(newAmountString),
+ 5,
+ );
+ setAmountEth(newEthAmount);
+ setAmountWei(amountPercentage);
+
+ const newFiatAmount = weiToFiatNumber(
+ toWei(newEthAmount.toString(), 'ether'),
+ conversionRate,
+ 2,
+ ).toString();
+ setFiatAmount(newFiatAmount);
+ },
+ [balance, conversionRate],
+ );
+
+ const annualRewardsETH = useMemo(
+ () =>
+ `${limitToMaximumDecimalPlaces(
+ parseFloat(amountEth) * annualRewardRateDecimal,
+ 5,
+ )} ETH`,
+ [amountEth, annualRewardRateDecimal],
+ );
+
+ const annualRewardsFiat = useMemo(
+ () =>
+ renderFiat(
+ parseFloat(fiatAmount) * annualRewardRateDecimal,
+ currentCurrency,
+ 2,
+ ),
+ [fiatAmount, annualRewardRateDecimal, currentCurrency],
+ );
+
+ const calculateEstimatedAnnualRewards = useCallback(() => {
+ if (isNonZeroAmount) {
+ if (isEth) {
+ setEstimatedAnnualRewards(annualRewardsETH);
+ } else {
+ setEstimatedAnnualRewards(annualRewardsFiat);
+ }
+ } else {
+ setEstimatedAnnualRewards(annualRewardRate);
+ }
+ }, [
+ isNonZeroAmount,
+ isEth,
+ annualRewardsETH,
+ annualRewardsFiat,
+ annualRewardRate,
+ ]);
+
+ return {
+ amountEth,
+ amountWei,
+ fiatAmount,
+ isEth,
+ currencyToggleValue,
+ isNonZeroAmount,
+ isOverMaximum,
+ handleEthInput,
+ handleFiatInput,
+ handleKeypadChange,
+ handleCurrencySwitch,
+ percentageOptions,
+ handleAmountPress,
+ currentCurrency,
+ conversionRate,
+ estimatedAnnualRewards,
+ calculateEstimatedAnnualRewards,
+ annualRewardsETH,
+ annualRewardsFiat,
+ annualRewardRate,
+ isLoadingVaultData,
+ };
+};
+
+export default useUnstakingInputHandlers;
diff --git a/app/components/UI/Stake/routes/index.tsx b/app/components/UI/Stake/routes/index.tsx
index 4937bb548d9..c6b98f24d2e 100644
--- a/app/components/UI/Stake/routes/index.tsx
+++ b/app/components/UI/Stake/routes/index.tsx
@@ -7,6 +7,7 @@ import StakeConfirmationView from '../Views/StakeConfirmationView/StakeConfirmat
import UnstakeInputView from '../Views/UnstakeInputView/UnstakeInputView';
import UnstakeConfirmationView from '../Views/UnstakeConfirmationView/UnstakeConfirmationView';
import { StakeSDKProvider } from '../sdk/stakeSdkProvider';
+import MaxInputModal from '../components/MaxInputModal';
const Stack = createStackNavigator();
const ModalStack = createStackNavigator();
@@ -31,10 +32,10 @@ const StakeScreenStack = () => (
name={Routes.STAKING.STAKE_CONFIRMATION}
component={StakeConfirmationView}
/>
-
+
);
@@ -51,6 +52,11 @@ const StakeModalStack = () => (
component={LearnMoreModal}
options={{ headerShown: false }}
/>
+
);
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index 1d95bc387f7..47354c006c3 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -150,6 +150,7 @@ const Routes = {
CLAIM: 'Claim',
MODALS: {
LEARN_MORE: 'LearnMore',
+ MAX_INPUT: 'MaxInput',
},
},
///: BEGIN:ONLY_INCLUDE_IF(external-snaps)
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 22e9e313922..11ede0f4895 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -3389,7 +3389,12 @@
"estimated_changes": "Estimated changes",
"you_receive": "You receive",
"up_to_n": "Up to {{count}}",
- "unstaking_to": "Unstaking to"
+ "unstaking_to": "Unstaking to",
+ "max_modal": {
+ "title": "Max",
+ "description": "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."
+ },
+ "use_max": "Use max"
},
"default_settings": {
"title": "Your Wallet is ready",