diff --git a/app/components/UI/AssetOverview/Balance/Balance.tsx b/app/components/UI/AssetOverview/Balance/Balance.tsx
index 02c89071051..fed53bd539a 100644
--- a/app/components/UI/AssetOverview/Balance/Balance.tsx
+++ b/app/components/UI/AssetOverview/Balance/Balance.tsx
@@ -34,7 +34,7 @@ interface BalanceProps {
secondaryBalance?: string;
}
-const NetworkBadgeSource = (chainId: string, ticker: string) => {
+export const NetworkBadgeSource = (chainId: string, ticker: string) => {
const isMainnet = isMainnetByChainId(chainId);
const isLineaMainnet = isLineaMainnetByChainId(chainId);
@@ -88,7 +88,9 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => {
{asset.name || asset.symbol}
- {isPooledStakingFeatureEnabled() && asset?.isETH && }
+ {isPooledStakingFeatureEnabled() && asset?.isETH && (
+
+ )}
);
};
diff --git a/app/components/UI/AssetOverview/StakingEarnings/StakingEarnings.test.tsx b/app/components/UI/AssetOverview/StakingEarnings/StakingEarnings.test.tsx
deleted file mode 100644
index f9a8c51b5a5..00000000000
--- a/app/components/UI/AssetOverview/StakingEarnings/StakingEarnings.test.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import StakingEarnings from './';
-import renderWithProvider from '../../../../util/test/renderWithProvider';
-import { strings } from '../../../../../locales/i18n';
-
-jest.mock('../../Stake/constants', () => ({
- isPooledStakingFeatureEnabled: jest.fn().mockReturnValue(true),
-}));
-
-const mockNavigate = jest.fn();
-
-jest.mock('@react-navigation/native', () => {
- const actualReactNavigation = jest.requireActual('@react-navigation/native');
- return {
- ...actualReactNavigation,
- useNavigation: () => ({
- navigate: mockNavigate,
- }),
- };
-});
-
-describe('Staking Earnings', () => {
- it('should render correctly', () => {
- const { toJSON, getByText } = renderWithProvider();
-
- expect(getByText(strings('stake.your_earnings'))).toBeDefined();
- expect(getByText(strings('stake.annual_rate'))).toBeDefined();
- expect(getByText(strings('stake.lifetime_rewards'))).toBeDefined();
- expect(getByText(strings('stake.estimated_annual_earnings'))).toBeDefined();
- expect(toJSON()).toMatchSnapshot();
- });
-});
diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx
index 476ab653953..368e2352d23 100644
--- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx
+++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx
@@ -1,5 +1,5 @@
import { zeroAddress } from 'ethereumjs-util';
-import React, { useState } from 'react';
+import React from 'react';
import { View } from 'react-native';
import { useSelector } from 'react-redux';
import i18n from '../../../../../locales/i18n';
@@ -21,8 +21,8 @@ import Logger from '../../../../util/Logger';
import TokenDetailsList from './TokenDetailsList';
import MarketDetailsList from './MarketDetailsList';
import { TokenI } from '../../Tokens/types';
-import StakingEarnings from '../StakingEarnings';
import { isPooledStakingFeatureEnabled } from '../../Stake/constants';
+import StakingEarnings from '../../Stake/components/StakingEarnings';
export interface TokenDetails {
contractAddress: string | null;
@@ -52,9 +52,6 @@ const TokenDetails: React.FC = ({ asset }) => {
const currentCurrency = useSelector(selectCurrentCurrency);
const tokenContractAddress = safeToChecksumAddress(asset.address);
- // TEMP: Remove once component has been implemented.
- const [hasStakingPositions] = useState(true);
-
let tokenMetadata;
let marketData;
@@ -126,9 +123,7 @@ const TokenDetails: React.FC = ({ asset }) => {
return (
- {asset.isETH &&
- hasStakingPositions &&
- isPooledStakingFeatureEnabled() && }
+ {asset.isETH && isPooledStakingFeatureEnabled() && }
{(asset.isETH || tokenMetadata) && (
)}
diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx
index a0c36a05079..86c5c82e1e3 100644
--- a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx
+++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx
@@ -62,12 +62,25 @@ jest.mock('../../hooks/usePoolStakedDeposit', () => ({
}),
}));
+jest.mock('../../hooks/usePooledStakes', () => ({
+ __esModule: true,
+ default: () => ({
+ refreshPooledStakes: jest.fn(),
+ }),
+}));
+
describe('StakeConfirmationView', () => {
it('render matches snapshot', () => {
const props: StakeConfirmationViewProps = {
route: {
key: '1',
- params: { amountWei: '3210000000000000', amountFiat: '7.46' },
+ params: {
+ amountWei: '3210000000000000',
+ amountFiat: '7.46',
+ annualRewardRate: '2.5%',
+ annualRewardsETH: '2.5 ETH',
+ annualRewardsFiat: '$5000',
+ },
name: 'params',
},
};
diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx
index 857752feafb..48a7277df07 100644
--- a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx
+++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx
@@ -9,17 +9,9 @@ import AccountHeaderCard from '../../components/StakingConfirmation/AccountHeade
import RewardsCard from '../../components/StakingConfirmation/RewardsCard/RewardsCard';
import ConfirmationFooter from '../../components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter';
import { StakeConfirmationViewProps } from './StakeConfirmationView.types';
-import { MOCK_GET_VAULT_RESPONSE } from '../../components/StakingBalance/mockData';
import { strings } from '../../../../../../locales/i18n';
import { FooterButtonGroupActions } from '../../components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.types';
-const MOCK_REWARD_DATA = {
- REWARDS: {
- ETH: '0.13 ETH',
- FIAT: '$334.93',
- },
-};
-
const MOCK_STAKING_CONTRACT_NAME = 'MM Pooled Staking';
const StakeConfirmationView = ({ route }: StakeConfirmationViewProps) => {
@@ -47,9 +39,9 @@ const StakeConfirmationView = ({ route }: StakeConfirmationViewProps) => {
diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts
index 8c723135f4f..20214a0fc52 100644
--- a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts
+++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts
@@ -3,6 +3,9 @@ import { RouteProp } from '@react-navigation/native';
interface StakeConfirmationViewRouteParams {
amountWei: string;
amountFiat: string;
+ annualRewardsETH: string;
+ annualRewardsFiat: string;
+ annualRewardRate: string;
}
export interface StakeConfirmationViewProps {
diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/__snapshots__/StakeConfirmationView.test.tsx.snap b/app/components/UI/Stake/Views/StakeConfirmationView/__snapshots__/StakeConfirmationView.test.tsx.snap
index 9d14c100f63..eedfba7fe99 100644
--- a/app/components/UI/Stake/Views/StakeConfirmationView/__snapshots__/StakeConfirmationView.test.tsx.snap
+++ b/app/components/UI/Stake/Views/StakeConfirmationView/__snapshots__/StakeConfirmationView.test.tsx.snap
@@ -989,7 +989,7 @@ exports[`StakeConfirmationView render matches snapshot 1`] = `
}
testID="label"
>
- 2.8%
+ 2.5%
@@ -1100,7 +1100,7 @@ exports[`StakeConfirmationView render matches snapshot 1`] = `
}
}
>
- $334.93
+ $5000
- 0.13 ETH
+ 2.5 ETH
@@ -1415,7 +1415,7 @@ exports[`StakeConfirmationView render matches snapshot 1`] = `
}
}
>
- Confirm
+ Continue
diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx
index a44c0971a53..90639128471 100644
--- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx
+++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx
@@ -8,6 +8,7 @@ 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';
function render(Component: React.ComponentType) {
return renderScreen(
@@ -89,6 +90,31 @@ jest.mock('../../hooks/useBalance', () => ({
}),
}));
+const mockVaultData = MOCK_GET_VAULT_RESPONSE;
+// Mock hooks
+
+jest.mock('../../hooks/useStakingEligibility', () => ({
+ __esModule: true,
+ default: () => ({
+ isEligible: true,
+ loading: false,
+ error: null,
+ refreshPooledStakingEligibility: jest.fn(),
+ }),
+}));
+
+jest.mock('../../hooks/useVaultData', () => ({
+ __esModule: true,
+ default: () => ({
+ vaultData: mockVaultData,
+ loading: false,
+ error: null,
+ refreshVaultData: jest.fn(),
+ annualRewardRate: '2.5%',
+ annualRewardRateDecimal: 0.025,
+ }),
+}));
+
describe('StakeInputView', () => {
it('render matches snapshot', () => {
render(StakeInputView);
@@ -122,7 +148,7 @@ describe('StakeInputView', () => {
fireEvent.press(screen.getByText('2'));
- expect(screen.getByText('0.052 ETH')).toBeTruthy();
+ expect(screen.getByText('0.05 ETH')).toBeTruthy();
});
});
diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx
index 0431e67a77f..9351d57b785 100644
--- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx
+++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx
@@ -41,6 +41,10 @@ const StakeInputView = () => {
handleKeypadChange,
calculateEstimatedAnnualRewards,
estimatedAnnualRewards,
+ annualRewardsETH,
+ annualRewardsFiat,
+ annualRewardRate,
+ isLoadingVaultData,
} = useStakingInputHandlers(balanceWei);
const navigateToLearnMoreModal = () => {
@@ -55,9 +59,19 @@ const StakeInputView = () => {
params: {
amountWei: amountWei.toString(),
amountFiat: fiatAmount,
+ annualRewardsETH,
+ annualRewardsFiat,
+ annualRewardRate,
},
});
- }, [amountWei, fiatAmount, navigation]);
+ }, [
+ navigation,
+ amountWei,
+ fiatAmount,
+ annualRewardsETH,
+ annualRewardsFiat,
+ annualRewardRate,
+ ]);
const balanceText = strings('stake.balance');
@@ -101,6 +115,7 @@ const StakeInputView = () => {
- 2.6%
+ 2.5%
({
selectCurrentCurrency: jest.fn(() => 'USD'),
}));
+const mockVaultData = MOCK_GET_VAULT_RESPONSE;
+const mockPooledStakeData = MOCK_GET_POOLED_STAKES_API_RESPONSE.accounts[0];
+
+jest.mock('../../hooks/useStakingEligibility', () => ({
+ __esModule: true,
+ default: () => ({
+ isEligible: true,
+ loading: false,
+ error: null,
+ refreshPooledStakingEligibility: jest.fn(),
+ }),
+}));
+
+jest.mock('../../hooks/useVaultData', () => ({
+ __esModule: true,
+ default: () => ({
+ vaultData: mockVaultData,
+ loading: false,
+ error: null,
+ annualRewardRate: '2.5%',
+ annualRewardRateDecimal: 0.025,
+ }),
+}));
+
+jest.mock('../../hooks/useBalance', () => ({
+ __esModule: true,
+ default: () => ({
+ stakedBalanceWei: mockPooledStakeData.assets,
+ stakedBalanceFiat: MOCK_STAKED_ETH_ASSET.balanceFiat,
+ }),
+}));
+
describe('UnstakeInputView', () => {
it('render matches snapshot', () => {
render(UnstakeInputView);
@@ -81,7 +118,7 @@ describe('UnstakeInputView', () => {
fireEvent.press(screen.getByText('25%'));
- expect(screen.getByText('1.14999')).toBeTruthy();
+ expect(screen.getByText('1.44783')).toBeTruthy();
});
});
@@ -96,13 +133,14 @@ describe('UnstakeInputView', () => {
render(UnstakeInputView);
fireEvent.press(screen.getByText('1'));
+
expect(screen.getByText('Review')).toBeTruthy();
});
it('displays `Not enough ETH` when input exceeds balance', () => {
render(UnstakeInputView);
- fireEvent.press(screen.getByText('6'));
+ fireEvent.press(screen.getByText('8'));
expect(screen.queryAllByText('Not enough ETH')).toHaveLength(2);
});
});
diff --git a/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.tsx b/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.tsx
index c109940d3a5..8adb580eade 100644
--- a/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.tsx
+++ b/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.tsx
@@ -19,13 +19,14 @@ 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';
const UnstakeInputView = () => {
const title = strings('stake.unstake_eth');
const navigation = useNavigation();
const { styles, theme } = useStyles(styleSheet, {});
- const stakeBalance = '4599964000000000000'; //TODO: Replace with actual balance - STAKE-806
+ const { stakedBalanceWei } = useBalance();
const {
isEth,
@@ -40,10 +41,13 @@ const UnstakeInputView = () => {
handleAmountPress,
handleKeypadChange,
conversionRate,
- } = useStakingInputHandlers(new BN(stakeBalance));
+ } = useStakingInputHandlers(new BN(stakedBalanceWei));
- const stakeBalanceInEth = renderFromWei(stakeBalance, 5);
- const stakeBalanceFiatNumber = weiToFiatNumber(stakeBalance, conversionRate);
+ const stakeBalanceInEth = renderFromWei(stakedBalanceWei, 5);
+ const stakeBalanceFiatNumber = weiToFiatNumber(
+ stakedBalanceWei,
+ conversionRate,
+ );
const stakedBalanceText = strings('stake.staked_balance');
const stakedBalanceValue = isEth
diff --git a/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.types.ts b/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.types.ts
new file mode 100644
index 00000000000..e58d20f58af
--- /dev/null
+++ b/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.types.ts
@@ -0,0 +1,9 @@
+import { RouteProp } from '@react-navigation/native';
+
+interface UnstakeInputViewRouteParams {
+ stakedBalanceWei: string;
+}
+
+export interface UnstakeInputViewProps {
+ route: RouteProp<{ params: UnstakeInputViewRouteParams }, 'params'>;
+}
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 5e7927b0b5c..0e1816f6621 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
@@ -386,7 +386,7 @@ exports[`UnstakeInputView render matches snapshot 1`] = `
>
Staked balance
:
- 4.59996 ETH
+ 5.79133 ETH
StyleSheet.create({
@@ -42,12 +43,14 @@ const createStyles = (colors: Colors) =>
interface EstimatedAnnualRewardsCardProps {
estimatedAnnualRewards: string;
+ isLoading?: boolean;
onIconPress: () => void;
}
const EstimatedAnnualRewardsCard = ({
estimatedAnnualRewards,
onIconPress,
+ isLoading = false,
}: EstimatedAnnualRewardsCardProps) => {
const { colors } = useTheme();
const styles = createStyles(colors);
@@ -67,9 +70,19 @@ const EstimatedAnnualRewardsCard = ({
-
- {estimatedAnnualRewards}
-
+ {isLoading ? (
+
+
+
+ ) : (
+
+ {estimatedAnnualRewards}
+
+ )}
{
+ const actualReactNavigation = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualReactNavigation,
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+ };
+});
+
+jest.mock('../../constants', () => ({
+ isPooledStakingFeatureEnabled: jest.fn().mockReturnValue(true),
+}));
+
+jest.mock('../../../../hooks/useMetrics', () => ({
+ MetaMetricsEvents: {
+ STAKE_BUTTON_CLICKED: 'Stake Button Clicked',
+ },
+ useMetrics: () => ({
+ trackEvent: jest.fn(),
+ }),
+}));
+
+jest.mock('../../../../../core/Engine', () => ({
+ context: {
+ NetworkController: {
+ getNetworkClientById: () => ({
+ configuration: {
+ chainId: '0x1',
+ rpcUrl: 'https://mainnet.infura.io/v3',
+ ticker: 'ETH',
+ type: 'custom',
+ },
+ }),
+ findNetworkClientIdByChainId: () => 'mainnet',
+ },
+ },
+}));
+
+jest.mock('../../hooks/useStakingEligibility', () => ({
+ __esModule: true,
+ default: () => ({
+ isEligible: true,
+ loading: false,
+ error: null,
+ refreshPooledStakingEligibility: jest.fn(),
+ }),
+}));
+
+const renderComponent = () =>
+ renderWithProvider();
+
+describe('StakeButton', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly', () => {
+ const { getByTestId } = renderComponent();
+ expect(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeDefined();
+ });
+
+ it('navigates to Stake Input screen when stake button is pressed and user is eligible', async () => {
+ const { getByTestId } = renderComponent();
+
+ fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON));
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', {
+ screen: Routes.STAKING.STAKE,
+ });
+ });
+ });
+});
diff --git a/app/components/UI/Tokens/TokenList/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx
similarity index 74%
rename from app/components/UI/Tokens/TokenList/StakeButton/index.tsx
rename to app/components/UI/Stake/components/StakeButton/index.tsx
index b0decefd026..7a92f67d3cb 100644
--- a/app/components/UI/Tokens/TokenList/StakeButton/index.tsx
+++ b/app/components/UI/Stake/components/StakeButton/index.tsx
@@ -1,14 +1,11 @@
import React from 'react';
-import { TokenI, BrowserTab } from '../../types';
+import { TokenI, BrowserTab } from '../../../Tokens/types';
import { useNavigation } from '@react-navigation/native';
-import { isPooledStakingFeatureEnabled } from '../../../Stake/constants';
+import { isPooledStakingFeatureEnabled } from '../../constants';
import Routes from '../../../../../constants/navigation/Routes';
import { useSelector } from 'react-redux';
import AppConstants from '../../../../../core/AppConstants';
-import {
- MetaMetricsEvents,
- useMetrics,
-} from '../../../../../components/hooks/useMetrics';
+import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
import { getDecimalChainId } from '../../../../../util/networks';
import { selectChainId } from '../../../../../selectors/networkController';
import { Pressable } from 'react-native';
@@ -18,7 +15,7 @@ import Text, {
} from '../../../../../component-library/components/Texts/Text';
import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/WalletView.selectors';
import { useTheme } from '../../../../../util/theme';
-import createStyles from '../../styles';
+import createStyles from '../../../Tokens/styles';
import Icon, {
IconColor,
IconName,
@@ -26,12 +23,13 @@ import Icon, {
} from '../../../../../component-library/components/Icons/Icon';
import { strings } from '../../../../../../locales/i18n';
import { RootState } from '../../../../../reducers';
+import useStakingEligibility from '../../hooks/useStakingEligibility';
+import { StakeSDKProvider } from '../../sdk/stakeSdkProvider';
interface StakeButtonProps {
asset: TokenI;
}
-
-export const StakeButton = ({ asset }: StakeButtonProps) => {
+const StakeButtonContent = ({ asset }: StakeButtonProps) => {
const { colors } = useTheme();
const styles = createStyles(colors);
const navigation = useNavigation();
@@ -40,8 +38,12 @@ export const StakeButton = ({ asset }: StakeButtonProps) => {
const browserTabs = useSelector((state: RootState) => state.browser.tabs);
const chainId = useSelector(selectChainId);
- const onStakeButtonPress = () => {
- if (isPooledStakingFeatureEnabled()) {
+ const { isEligible, refreshPooledStakingEligibility } =
+ useStakingEligibility();
+
+ const onStakeButtonPress = async () => {
+ await refreshPooledStakingEligibility();
+ if (isPooledStakingFeatureEnabled() && isEligible) {
navigation.navigate('StakeScreens', { screen: Routes.STAKING.STAKE });
} else {
const existingStakeTab = browserTabs.find((tab: BrowserTab) =>
@@ -82,7 +84,7 @@ export const StakeButton = ({ asset }: StakeButtonProps) => {
{' • '}
- {`${strings('stake.stake')} `}
+ {`${strings('stake.earn')} `}
{
);
};
+
+export const StakeButton = (props: StakeButtonProps) => (
+
+
+
+);
+
+export default StakeButton;
diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx b/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx
index 2aae28643bf..e02cd932b65 100644
--- a/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx
+++ b/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx
@@ -5,6 +5,11 @@ import StakingBalance from './StakingBalance';
import { strings } from '../../../../../../locales/i18n';
import Routes from '../../../../../constants/navigation/Routes';
import { Image } from 'react-native';
+import {
+ MOCK_GET_POOLED_STAKES_API_RESPONSE,
+ MOCK_GET_VAULT_RESPONSE,
+ MOCK_STAKED_ETH_ASSET,
+} from '../../__mocks__/mockData';
jest.mock('../../../../hooks/useIpfsGateway', () => jest.fn());
@@ -24,20 +29,92 @@ jest.mock('@react-navigation/native', () => {
};
});
+const mockPooledStakeData = MOCK_GET_POOLED_STAKES_API_RESPONSE.accounts[0];
+const mockExchangeRate = MOCK_GET_POOLED_STAKES_API_RESPONSE.exchangeRate;
+
+const mockVaultData = MOCK_GET_VAULT_RESPONSE;
+// Mock hooks
+jest.mock('../../hooks/usePooledStakes', () => ({
+ __esModule: true,
+ default: () => ({
+ pooledStakesData: mockPooledStakeData,
+ exchangeRate: mockExchangeRate,
+ loading: false,
+ error: null,
+ refreshPooledStakes: jest.fn(),
+ hasStakedPositions: true,
+ hasEthToUnstake: true,
+ hasNeverStaked: false,
+ hasRewards: true,
+ hasRewardsOnly: false,
+ }),
+}));
+
+jest.mock('../../hooks/useStakingEligibility', () => ({
+ __esModule: true,
+ default: () => ({
+ isEligible: true,
+ loading: false,
+ error: null,
+ refreshPooledStakingEligibility: jest.fn(),
+ }),
+}));
+
+jest.mock('../../hooks/useVaultData', () => ({
+ __esModule: true,
+ default: () => ({
+ vaultData: mockVaultData,
+ loading: false,
+ error: null,
+ annualRewardRate: '2.5%',
+ annualRewardRateDecimal: 0.025,
+ }),
+}));
+
+jest.mock('../../hooks/useBalance', () => ({
+ __esModule: true,
+ default: () => ({
+ stakedBalanceWei: MOCK_STAKED_ETH_ASSET.balance,
+ stakedBalanceFiat: MOCK_STAKED_ETH_ASSET.balanceFiat,
+ }),
+}));
+
+jest.mock('../../../../../core/Engine', () => ({
+ context: {
+ NetworkController: {
+ getNetworkClientById: () => ({
+ configuration: {
+ chainId: '0x1',
+ rpcUrl: 'https://mainnet.infura.io/v3',
+ ticker: 'ETH',
+ type: 'custom',
+ },
+ }),
+ findNetworkClientIdByChainId: () => 'mainnet',
+ },
+ },
+}));
+
afterEach(() => {
jest.clearAllMocks();
});
describe('StakingBalance', () => {
- beforeEach(() => jest.resetAllMocks());
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
it('render matches snapshot', () => {
- const { toJSON } = renderWithProvider();
+ const { toJSON } = renderWithProvider(
+ ,
+ );
expect(toJSON()).toMatchSnapshot();
});
it('redirects to StakeInputView on stake button click', () => {
- const { getByText } = renderWithProvider();
+ const { getByText } = renderWithProvider(
+ ,
+ );
fireEvent.press(getByText(strings('stake.stake_more')));
@@ -48,7 +125,9 @@ describe('StakingBalance', () => {
});
it('redirects to UnstakeInputView on unstake button click', () => {
- const { getByText } = renderWithProvider();
+ const { getByText } = renderWithProvider(
+ ,
+ );
fireEvent.press(getByText(strings('stake.unstake')));
diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx b/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx
index 8884688694b..e70630ab901 100644
--- a/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx
+++ b/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useState } from 'react';
+import React, { useMemo } from 'react';
import Badge, {
BadgeVariant,
} from '../../../../../component-library/components/Badges/Badge';
@@ -11,7 +11,6 @@ import AssetElement from '../../../AssetElement';
import NetworkMainAssetLogo from '../../../NetworkMainAssetLogo';
import { selectNetworkName } from '../../../../../selectors/networkInfos';
import { useSelector } from 'react-redux';
-import images from '../../../../../images/image-icons';
import styleSheet from './StakingBalance.styles';
import { View } from 'react-native';
import StakingButtons from './StakingButtons/StakingButtons';
@@ -35,28 +34,48 @@ import {
} from '../../utils/value';
import { multiplyValueByPowerOfTen } from '../../utils/bignumber';
import StakingCta from './StakingCta/StakingCta';
-import {
- MOCK_GET_POOLED_STAKES_API_RESPONSE,
- MOCK_GET_VAULT_RESPONSE,
- MOCK_STAKED_ETH_ASSET,
-} from './mockData';
+import useStakingEligibility from '../../hooks/useStakingEligibility';
+import useStakingChain from '../../hooks/useStakingChain';
+import usePooledStakes from '../../hooks/usePooledStakes';
+import useVaultData from '../../hooks/useVaultData';
+import { StakeSDKProvider } from '../../sdk/stakeSdkProvider';
+import type { TokenI } from '../../../Tokens/types';
+import useBalance from '../../hooks/useBalance';
+import { NetworkBadgeSource } from '../../../AssetOverview/Balance/Balance';
+import { selectChainId } from '../../../../../selectors/networkController';
-const StakingBalance = () => {
- const { styles } = useStyles(styleSheet, {});
+export interface StakingBalanceProps {
+ asset: TokenI;
+}
+const StakingBalanceContent = ({ asset }: StakingBalanceProps) => {
+ const { styles } = useStyles(styleSheet, {});
+ const chainId = useSelector(selectChainId);
const networkName = useSelector(selectNetworkName);
- const [isGeoBlocked] = useState(false);
- const [hasStakedPositions] = useState(false);
+ const { isEligible: isEligibleForPooledStaking } = useStakingEligibility();
- const { unstakingRequests, claimableRequests } = useMemo(
- () =>
- filterExitRequests(
- MOCK_GET_POOLED_STAKES_API_RESPONSE.accounts[0].exitRequests,
- MOCK_GET_POOLED_STAKES_API_RESPONSE.exchangeRate,
- ),
- [],
- );
+ const { isStakingSupportedChain } = useStakingChain();
+
+ const {
+ pooledStakesData,
+ exchangeRate,
+ hasStakedPositions,
+ hasEthToUnstake,
+ isLoadingPooledStakesData,
+ } = usePooledStakes();
+ const { vaultData } = useVaultData();
+ const annualRewardRate = vaultData?.apy || '';
+
+ const {
+ formattedStakedBalanceETH: stakedBalanceETH,
+ formattedStakedBalanceFiat: stakedBalanceFiat,
+ } = useBalance();
+
+ const { unstakingRequests, claimableRequests } = useMemo(() => {
+ const exitRequests = pooledStakesData?.exitRequests ?? [];
+ return filterExitRequests(exitRequests, exchangeRate);
+ }, [pooledStakesData, exchangeRate]);
const claimableEth = useMemo(
() =>
@@ -72,20 +91,24 @@ const StakingBalance = () => {
const hasClaimableEth = !!Number(claimableEth);
+ if (!isStakingSupportedChain || isLoadingPooledStakesData) {
+ return <>>;
+ }
+
return (
- {Boolean(MOCK_STAKED_ETH_ASSET.balance) && !isGeoBlocked && (
+ {hasStakedPositions && isEligibleForPooledStaking && (
}
@@ -93,13 +116,13 @@ const StakingBalance = () => {
- {MOCK_STAKED_ETH_ASSET.name || MOCK_STAKED_ETH_ASSET.symbol}
+ {strings('stake.staked_ethereum')}
)}
- {isGeoBlocked ? (
+ {!isEligibleForPooledStaking ? (
{
{!hasStakedPositions && (
)}
-
+
>
)}
@@ -156,4 +180,10 @@ const StakingBalance = () => {
);
};
+export const StakingBalance = ({ asset }: StakingBalanceProps) => (
+
+
+
+);
+
export default StakingBalance;
diff --git a/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx b/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx
index 51ba3ea15f9..2cec44d4baf 100644
--- a/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx
+++ b/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React from 'react';
import Button, {
ButtonVariants,
} from '../../../../../../component-library/components/Buttons/Button';
@@ -9,16 +9,23 @@ import styleSheet from './StakingButtons.styles';
import { useNavigation } from '@react-navigation/native';
import Routes from '../../../../../../constants/navigation/Routes';
-interface StakingButtonsProps extends Pick {}
+interface StakingButtonsProps extends Pick {
+ hasStakedPositions: boolean;
+ hasEthToUnstake: boolean;
+}
-const StakingButtons = ({ style }: StakingButtonsProps) => {
- const [hasStakedPosition] = useState(true);
- const [hasEthToUnstake] = useState(true);
+const StakingButtons = ({
+ style,
+ hasStakedPositions,
+ hasEthToUnstake,
+}: StakingButtonsProps) => {
const { navigate } = useNavigation();
const { styles } = useStyles(styleSheet, {});
const onUnstakePress = () =>
- navigate('StakeScreens', { screen: Routes.STAKING.UNSTAKE });
+ navigate('StakeScreens', {
+ screen: Routes.STAKING.UNSTAKE,
+ });
const onStakePress = () =>
navigate('StakeScreens', { screen: Routes.STAKING.STAKE });
@@ -37,7 +44,7 @@ const StakingButtons = ({ style }: StakingButtonsProps) => {
style={styles.balanceActionButton}
variant={ButtonVariants.Secondary}
label={
- hasStakedPosition
+ hasStakedPositions
? strings('stake.stake_more')
: strings('stake.stake')
}
diff --git a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap
index ee2fbde051b..c161fdb50d6 100644
--- a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap
+++ b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap
@@ -200,40 +200,7 @@ exports[`StakingBalance render matches snapshot 1`] = `
"flex": 1,
}
}
- >
-
- $13,292.20
-
-
- 4.9999 ETH
-
-
+ />
-
-
- Stake ETH and earn
-
-
-
- Stake your ETH with MetaMask Pool and earn
-
-
- 2.9%
-
-
- annually.
-
-
-
- Learn more.
-
-
-
-
({
}),
}));
+jest.mock('../../../hooks/usePooledStakes', () => ({
+ __esModule: true,
+ default: () => ({
+ refreshPooledStakes: jest.fn(),
+ }),
+}));
+
describe('ConfirmationFooter', () => {
it('render matches snapshot', () => {
const props: ConfirmationFooterProps = {
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.test.tsx b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.test.tsx
index ffaec56ae87..509c2054dcb 100644
--- a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.test.tsx
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.test.tsx
@@ -53,6 +53,13 @@ jest.mock('../../../../hooks/usePoolStakedDeposit', () => ({
}),
}));
+jest.mock('../../../../hooks/usePooledStakes', () => ({
+ __esModule: true,
+ default: () => ({
+ refreshPooledStakes: jest.fn(),
+ }),
+}));
+
describe('FooterButtonGroup', () => {
beforeEach(() => {
jest.resetAllMocks();
@@ -70,7 +77,7 @@ describe('FooterButtonGroup', () => {
);
expect(getByText(strings('stake.cancel'))).toBeDefined();
- expect(getByText(strings('stake.confirm'))).toBeDefined();
+ expect(getByText(strings('stake.continue'))).toBeDefined();
expect(toJSON()).toMatchSnapshot();
});
@@ -89,8 +96,7 @@ describe('FooterButtonGroup', () => {
fireEvent.press(getByText(strings('stake.cancel')));
- expect(mockNavigate).toHaveBeenCalledTimes(1);
- expect(mockNavigate).toHaveBeenCalledWith('Asset');
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
expect(toJSON()).toMatchSnapshot();
});
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx
index 00464468daa..5a98d66c48b 100644
--- a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx
@@ -22,15 +22,18 @@ import {
FooterButtonGroupProps,
} from './FooterButtonGroup.types';
import Routes from '../../../../../../../constants/navigation/Routes';
+import usePooledStakes from '../../../../hooks/usePooledStakes';
const FooterButtonGroup = ({ valueWei, action }: FooterButtonGroupProps) => {
const { styles } = useStyles(styleSheet, {});
- const { navigate } = useNavigation();
+ const navigation = useNavigation();
+ const { navigate } = navigation;
const activeAccount = useSelector(selectSelectedInternalAccount);
const { attemptDepositTransaction } = usePoolStakedDeposit();
+ const { refreshPooledStakes } = usePooledStakes();
const handleStake = async () => {
if (!activeAccount?.address) return;
@@ -51,19 +54,17 @@ const FooterButtonGroup = ({ valueWei, action }: FooterButtonGroupProps) => {
({ transactionMeta }) => transactionMeta.id === transactionId,
);
- // Engine.controllerMessenger.subscribeOnceIf(
- // 'TransactionController:transactionConfirmed',
- // () => {
- // TODO: Call refreshPooledStakes();
- // refreshPooledStakes();
- // },
- // (transactionMeta) => transactionMeta.id === transactionId,
- // );
+ Engine.controllerMessenger.subscribeOnceIf(
+ 'TransactionController:transactionConfirmed',
+ () => {
+ refreshPooledStakes();
+ },
+ (transactionMeta) => transactionMeta.id === transactionId,
+ );
};
const handleConfirmation = () => {
if (action === FooterButtonGroupActions.STAKE) return handleStake();
- // TODO: Add handler (STAKE-803)
};
return (
@@ -78,12 +79,14 @@ const FooterButtonGroup = ({ valueWei, action }: FooterButtonGroupProps) => {
variant={ButtonVariants.Secondary}
width={ButtonWidthTypes.Full}
size={ButtonSize.Lg}
- onPress={() => navigate('Asset')}
+ onPress={() => {
+ navigation.goBack();
+ }}
/>
@@ -182,7 +182,7 @@ exports[`FooterButtonGroup render matches snapshot 1`] = `
}
}
>
- Confirm
+ Continue
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/__snapshots__/ConfirmationFooter.test.tsx.snap b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/__snapshots__/ConfirmationFooter.test.tsx.snap
index e29a06f61c9..1c2f638383d 100644
--- a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/__snapshots__/ConfirmationFooter.test.tsx.snap
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/__snapshots__/ConfirmationFooter.test.tsx.snap
@@ -154,7 +154,7 @@ exports[`ConfirmationFooter render matches snapshot 1`] = `
}
}
>
- Confirm
+ Continue
diff --git a/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.test.tsx b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.test.tsx
index ae8bebfe465..9b094473c11 100644
--- a/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.test.tsx
+++ b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.test.tsx
@@ -24,7 +24,7 @@ describe('RewardsCard', () => {
it('render matches snapshot', () => {
const props: RewardsCardProps = {
- rewardRate: '2.6',
+ rewardRate: '2.6%',
rewardsEth: '0.13 ETH',
rewardsFiat: '$334.93',
};
@@ -33,7 +33,7 @@ describe('RewardsCard', () => {
,
);
- expect(getByText(`${props.rewardRate}%`)).toBeDefined();
+ expect(getByText(props.rewardRate)).toBeDefined();
expect(getByText(props.rewardsEth)).toBeDefined();
expect(getByText(props.rewardsFiat)).toBeDefined();
@@ -42,7 +42,7 @@ describe('RewardsCard', () => {
it('reward rate tooltip displayed when pressed', () => {
const props: RewardsCardProps = {
- rewardRate: '2.6',
+ rewardRate: '2.6%',
rewardsEth: '0.13 ETH',
rewardsFiat: '$334.93',
};
@@ -69,7 +69,7 @@ describe('RewardsCard', () => {
it('reward frequency tooltip displayed when pressed', () => {
const props: RewardsCardProps = {
- rewardRate: '2.6',
+ rewardRate: '2.6%',
rewardsEth: '0.13 ETH',
rewardsFiat: '$334.93',
};
diff --git a/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.tsx b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.tsx
index 66b6eacedf5..8068cacd854 100644
--- a/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.tsx
+++ b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.tsx
@@ -12,7 +12,6 @@ import { useStyles } from '../../../../../hooks/useStyles';
import Card from '../../../../../../component-library/components/Cards/Card';
import styleSheet from './RewardsCard.styles';
import { RewardsCardProps } from './RewardsCard.types';
-import { fixDisplayAmount } from '../../../utils/value';
const RewardsCard = ({
rewardRate,
@@ -34,7 +33,7 @@ const RewardsCard = ({
}}
value={{
label: {
- text: `${fixDisplayAmount(rewardRate, 1)}%`,
+ text: rewardRate,
color: TextColor.Success,
variant: TextVariant.BodyMD,
},
diff --git a/app/components/UI/AssetOverview/StakingEarnings/StakingEarnings.styles.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarnings.styles.tsx
similarity index 92%
rename from app/components/UI/AssetOverview/StakingEarnings/StakingEarnings.styles.tsx
rename to app/components/UI/Stake/components/StakingEarnings/StakingEarnings.styles.tsx
index e5961497522..8d80278c2c8 100644
--- a/app/components/UI/AssetOverview/StakingEarnings/StakingEarnings.styles.tsx
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarnings.styles.tsx
@@ -1,5 +1,5 @@
-import { Theme } from '../../../../util/theme/models';
import { StyleSheet, TextStyle } from 'react-native';
+import type { Theme } from '../../../../../util/theme/models';
const styleSheet = (params: { theme: Theme }) => {
const { theme } = params;
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarnings.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarnings.test.tsx
new file mode 100644
index 00000000000..3ce860c1010
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarnings.test.tsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import StakingEarnings from './';
+import renderWithProvider from '../../../../../util/test/renderWithProvider';
+import { strings } from '../../../../../../locales/i18n';
+
+jest.mock('../../constants', () => ({
+ isPooledStakingFeatureEnabled: jest.fn().mockReturnValue(true),
+}));
+
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => {
+ const actualReactNavigation = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualReactNavigation,
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+ };
+});
+
+jest.mock('../../hooks/useStakingEligibility', () => ({
+ __esModule: true,
+ default: () => ({
+ isEligible: true,
+ loading: false,
+ error: null,
+ refreshPooledStakingEligibility: jest.fn(),
+ }),
+}));
+
+jest.mock('../../hooks/useStakingEarnings', () => ({
+ __esModule: true,
+ default: () => ({
+ annualRewardRate: '2.6%',
+ lifetimeRewardsETH: '2.5 ETH',
+ lifetimeRewardsFiat: '$5000',
+ estimatedAnnualEarningsETH: '2.5 ETH',
+ estimatedAnnualEarningsFiat: '$5000',
+ isLoadingEarningsData: false,
+ }),
+}));
+
+jest.mock('../../hooks/usePooledStakes', () => ({
+ __esModule: true,
+ default: () => ({
+ hasStakedPositions: true,
+ }),
+}));
+
+jest.mock('../../../../../core/Engine', () => ({
+ context: {
+ NetworkController: {
+ getNetworkClientById: () => ({
+ configuration: {
+ chainId: '0x1',
+ rpcUrl: 'https://mainnet.infura.io/v3',
+ ticker: 'ETH',
+ type: 'custom',
+ },
+ }),
+ findNetworkClientIdByChainId: () => 'mainnet',
+ },
+ },
+}));
+
+describe('Staking Earnings', () => {
+ it('should render correctly', () => {
+ const { toJSON, getByText } = renderWithProvider();
+
+ expect(getByText(strings('stake.your_earnings'))).toBeDefined();
+ expect(getByText(strings('stake.annual_rate'))).toBeDefined();
+ expect(getByText(strings('stake.lifetime_rewards'))).toBeDefined();
+ expect(getByText(strings('stake.estimated_annual_earnings'))).toBeDefined();
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/UI/AssetOverview/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap b/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap
similarity index 98%
rename from app/components/UI/AssetOverview/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap
rename to app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap
index 3f9bd60d677..17f64f7622b 100644
--- a/app/components/UI/AssetOverview/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap
+++ b/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap
@@ -159,7 +159,7 @@ exports[`Staking Earnings should render correctly 1`] = `
}
}
>
- $2
+ $5000
- 0.02151 ETH
+ 2.5 ETH
@@ -231,7 +231,7 @@ exports[`Staking Earnings should render correctly 1`] = `
}
}
>
- $15.93
+ $5000
- 0.0131 ETH
+ 2.5 ETH
diff --git a/app/components/UI/AssetOverview/StakingEarnings/index.tsx b/app/components/UI/Stake/components/StakingEarnings/index.tsx
similarity index 59%
rename from app/components/UI/AssetOverview/StakingEarnings/index.tsx
rename to app/components/UI/Stake/components/StakingEarnings/index.tsx
index d9538aaa50a..4ad6b7b2aaf 100644
--- a/app/components/UI/AssetOverview/StakingEarnings/index.tsx
+++ b/app/components/UI/Stake/components/StakingEarnings/index.tsx
@@ -3,49 +3,60 @@ import { View } from 'react-native';
import Text, {
TextColor,
TextVariant,
-} from '../../../../component-library/components/Texts/Text';
-import { useStyles } from '../../../../component-library/hooks';
+} from '../../../../../component-library/components/Texts/Text';
+import { useStyles } from '../../../../../component-library/hooks';
import styleSheet from './StakingEarnings.styles';
import {
IconColor,
IconName,
-} from '../../../../component-library/components/Icons/Icon';
+} from '../../../../../component-library/components/Icons/Icon';
import ButtonIcon, {
ButtonIconSizes,
-} from '../../../../component-library/components/Buttons/ButtonIcon';
-import useTooltipModal from '../../../../components/hooks/useTooltipModal';
-import { strings } from '../../../../../locales/i18n';
-import { isPooledStakingFeatureEnabled } from '../../Stake/constants';
-
-// TODO: Remove mock data when connecting component to backend.
-const MOCK_DATA = {
- ANNUAL_EARNING_RATE: '2.6%',
- LIFETIME_REWARDS: {
- FIAT: '$2',
- ETH: '0.02151 ETH',
- },
- EST_ANNUAL_EARNINGS: {
- FIAT: '$15.93',
- ETH: '0.0131 ETH',
- },
-};
-
-const StakingEarnings = () => {
- // TODO: Remove mock data when connecting component to backend.
- const { ANNUAL_EARNING_RATE, LIFETIME_REWARDS, EST_ANNUAL_EARNINGS } =
- MOCK_DATA;
+} from '../../../../../component-library/components/Buttons/ButtonIcon';
+import useTooltipModal from '../../../../../components/hooks/useTooltipModal';
+import { strings } from '../../../../../../locales/i18n';
+import { isPooledStakingFeatureEnabled } from '../../../Stake/constants';
+import useStakingEligibility from '../../hooks/useStakingEligibility';
+import useStakingChain from '../../hooks/useStakingChain';
+import { StakeSDKProvider } from '../../sdk/stakeSdkProvider';
+import useStakingEarnings from '../../hooks/useStakingEarnings';
+import usePooledStakes from '../../hooks/usePooledStakes';
+const StakingEarningsContent = () => {
const { styles } = useStyles(styleSheet, {});
const { openTooltipModal } = useTooltipModal();
+ const { hasStakedPositions } = usePooledStakes();
+
+ const {
+ annualRewardRate,
+ lifetimeRewardsETH,
+ lifetimeRewardsFiat,
+ estimatedAnnualEarningsETH,
+ estimatedAnnualEarningsFiat,
+ isLoadingEarningsData,
+ } = useStakingEarnings();
+
const onNavigateToTooltipModal = () =>
openTooltipModal(
strings('stake.annual_rate'),
strings('tooltip_modal.reward_rate.tooltip'),
);
- if (!isPooledStakingFeatureEnabled()) return <>>;
+ const { isEligible, isLoadingEligibility } = useStakingEligibility();
+
+ const { isStakingSupportedChain } = useStakingChain();
+
+ const isLoadingData = isLoadingEligibility || isLoadingEarningsData;
+ if (
+ !isPooledStakingFeatureEnabled() ||
+ !isEligible ||
+ !isStakingSupportedChain ||
+ !hasStakedPositions ||
+ isLoadingData
+ )
+ return <>>;
return (
@@ -74,7 +85,7 @@ const StakingEarnings = () => {
/>
- {ANNUAL_EARNING_RATE}
+ {annualRewardRate}
@@ -87,12 +98,12 @@ const StakingEarnings = () => {
- {LIFETIME_REWARDS.FIAT}
+ {lifetimeRewardsFiat}
- {LIFETIME_REWARDS.ETH}
+ {lifetimeRewardsETH}
@@ -106,12 +117,14 @@ const StakingEarnings = () => {
- {EST_ANNUAL_EARNINGS.FIAT}
+
+ {estimatedAnnualEarningsFiat}
+
- {EST_ANNUAL_EARNINGS.ETH}
+ {estimatedAnnualEarningsETH}
@@ -120,4 +133,10 @@ const StakingEarnings = () => {
);
};
+export const StakingEarnings = () => (
+
+
+
+);
+
export default StakingEarnings;
diff --git a/app/components/UI/Stake/hooks/useBalance.test.tsx b/app/components/UI/Stake/hooks/useBalance.test.tsx
new file mode 100644
index 00000000000..33bf7cc7903
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useBalance.test.tsx
@@ -0,0 +1,109 @@
+import { MOCK_GET_POOLED_STAKES_API_RESPONSE } from '../__mocks__/mockData';
+import { createMockAccountsControllerState } from '../../../../util/test/accountsControllerTestUtils';
+import { backgroundState } from '../../../../util/test/initial-root-state';
+import { renderHookWithProvider } from '../../../../util/test/renderWithProvider';
+import useBalance from './useBalance';
+import { toHex } from '@metamask/controller-utils';
+
+const MOCK_ADDRESS_1 = '0x0';
+
+const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([
+ MOCK_ADDRESS_1,
+]);
+
+const initialState = {
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
+ AccountTrackerController: {
+ accountsByChainId: {
+ '0x1': {
+ [MOCK_ADDRESS_1]: { balance: toHex('12345678909876543210000000') },
+ },
+ },
+ },
+ CurrencyRateController: {
+ currentCurrency: 'usd',
+ currencyRates: {
+ ETH: {
+ conversionRate: 3200,
+ },
+ },
+ },
+ },
+ },
+};
+
+const mockPooledStakeData = MOCK_GET_POOLED_STAKES_API_RESPONSE.accounts[0];
+const mockExchangeRate = MOCK_GET_POOLED_STAKES_API_RESPONSE.exchangeRate;
+
+jest.mock('../hooks/usePooledStakes', () => ({
+ __esModule: true,
+ default: () => ({
+ pooledStakesData: mockPooledStakeData,
+ exchangeRate: mockExchangeRate,
+ loading: false,
+ error: null,
+ refreshPooledStakes: jest.fn(),
+ hasStakedPositions: true,
+ hasEthToUnstake: true,
+ hasNeverStaked: false,
+ hasRewards: true,
+ hasRewardsOnly: false,
+ }),
+}));
+
+describe('useBalance', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns balance and fiat values based on account and pooled stake data', async () => {
+ const { result } = renderHookWithProvider(() => useBalance(), {
+ state: initialState,
+ });
+
+ expect(result.current.balance).toBe('12345678.90988'); // ETH balance
+ expect(result.current.balanceWei.toString()).toBe(
+ '12345678909876543210000000',
+ ); // Wei balance
+ expect(result.current.balanceFiat).toBe('$39506172511.60'); // Fiat balance
+ expect(result.current.balanceFiatNumber).toBe(39506172511.6); // Fiat number balance
+ expect(result.current.stakedBalanceWei).toBe('5791332670714232000'); // No staked assets
+ expect(result.current.formattedStakedBalanceETH).toBe('5.79133 ETH'); // Formatted ETH balance
+ expect(result.current.stakedBalanceFiatNumber).toBe(18532.26454); // Staked balance in fiat number
+ expect(result.current.formattedStakedBalanceFiat).toBe('$18532.26'); //
+ });
+
+ it('returns default values when no selected address and no account data', async () => {
+ const { result } = renderHookWithProvider(() => useBalance(), {
+ state: {
+ ...initialState,
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
+ CurrencyRateController: {
+ currentCurrency: 'usd',
+ currencyRates: {
+ ETH: {
+ conversionRate: 3200,
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ expect(result.current.balance).toBe('0'); // ETH balance
+ expect(result.current.balanceWei.toString()).toBe('0'); // Wei balance
+ expect(result.current.balanceFiat).toBe('$0.00'); // Fiat balance
+ expect(result.current.balanceFiatNumber).toBe(0); // Fiat number balance
+ });
+});
diff --git a/app/components/UI/Stake/hooks/useBalance.ts b/app/components/UI/Stake/hooks/useBalance.ts
index 2ce1dfc6af4..a96c9b6b39b 100644
--- a/app/components/UI/Stake/hooks/useBalance.ts
+++ b/app/components/UI/Stake/hooks/useBalance.ts
@@ -9,11 +9,12 @@ import {
import { selectChainId } from '../../../../selectors/networkController';
import {
hexToBN,
+ renderFiat,
renderFromWei,
- toHexadecimal,
weiToFiat,
weiToFiatNumber,
} from '../../../../util/number';
+import usePooledStakes from './usePooledStakes';
const useBalance = () => {
const accountsByChainId = useSelector(selectAccountsByChainId);
@@ -25,7 +26,7 @@ const useBalance = () => {
const currentCurrency = useSelector(selectCurrentCurrency);
const rawAccountBalance = selectedAddress
- ? accountsByChainId[toHexadecimal(chainId)]?.[selectedAddress]?.balance
+ ? accountsByChainId[chainId]?.[selectedAddress]?.balance
: '0';
const balance = useMemo(
@@ -48,7 +49,36 @@ const useBalance = () => {
[balanceWei, conversionRate],
);
- return { balance, balanceFiat, balanceWei, balanceFiatNumber, conversionRate, currentCurrency };
+ const { pooledStakesData } = usePooledStakes();
+ const assets = pooledStakesData.assets ?? 0;
+
+ const formattedStakedBalanceETH = useMemo(
+ () => `${renderFromWei(assets)} ETH`,
+ [assets],
+ );
+
+ const stakedBalanceFiatNumber = useMemo(
+ () => weiToFiatNumber(assets, conversionRate),
+ [assets, conversionRate],
+ );
+
+ const formattedStakedBalanceFiat = useMemo(
+ () => renderFiat(stakedBalanceFiatNumber, currentCurrency, 2),
+ [currentCurrency, stakedBalanceFiatNumber],
+ );
+
+ return {
+ balance,
+ balanceFiat,
+ balanceWei,
+ balanceFiatNumber,
+ stakedBalanceWei: assets,
+ formattedStakedBalanceETH,
+ stakedBalanceFiatNumber,
+ formattedStakedBalanceFiat,
+ conversionRate,
+ currentCurrency,
+ };
};
export default useBalance;
diff --git a/app/components/UI/Stake/hooks/usePooledStakes.test.tsx b/app/components/UI/Stake/hooks/usePooledStakes.test.tsx
new file mode 100644
index 00000000000..3145cda0f08
--- /dev/null
+++ b/app/components/UI/Stake/hooks/usePooledStakes.test.tsx
@@ -0,0 +1,217 @@
+import { type StakingApiService } from '@metamask/stake-sdk';
+
+import { MOCK_GET_POOLED_STAKES_API_RESPONSE } from '../__mocks__/mockData';
+import { createMockAccountsControllerState } from '../../../../util/test/accountsControllerTestUtils';
+import { backgroundState } from '../../../../util/test/initial-root-state';
+import { renderHookWithProvider } from '../../../../util/test/renderWithProvider';
+import type { Stake } from '../sdk/stakeSdkProvider';
+import usePooledStakes from './usePooledStakes';
+import { act, waitFor } from '@testing-library/react-native';
+
+const MOCK_ADDRESS_1 = '0x0';
+
+const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([
+ MOCK_ADDRESS_1,
+]);
+
+const mockInitialState = {
+ settings: {},
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
+ },
+ },
+};
+
+jest.mock('../../../../core/Engine', () => ({
+ context: {
+ NetworkController: {
+ getNetworkClientById: () => ({
+ configuration: {
+ chainId: '0x1',
+ rpcUrl: 'https://mainnet.infura.io/v3',
+ ticker: 'ETH',
+ type: 'custom',
+ },
+ }),
+ findNetworkClientIdByChainId: () => 'mainnet',
+ },
+ },
+}));
+
+const mockStakingApiService: Partial = {
+ getPooledStakes: jest.fn(),
+};
+
+const mockSdkContext: Stake = {
+ stakingApiService: mockStakingApiService as StakingApiService,
+ setSdkType: jest.fn(),
+};
+
+const mockPooledStakeData = MOCK_GET_POOLED_STAKES_API_RESPONSE.accounts[0];
+const mockExchangeRate = MOCK_GET_POOLED_STAKES_API_RESPONSE.exchangeRate;
+
+jest.mock('../hooks/useStakeContext', () => ({
+ useStakeContext: () => mockSdkContext as Stake,
+}));
+
+describe('usePooledStakes', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('when fetching pooled stakes data', () => {
+ it('fetches pooled stakes data and updates state', async () => {
+ (mockStakingApiService.getPooledStakes as jest.Mock).mockResolvedValue({
+ accounts: [mockPooledStakeData],
+ exchangeRate: mockExchangeRate,
+ });
+
+ const { result } = renderHookWithProvider(() => usePooledStakes(), {
+ state: mockInitialState,
+ });
+
+ // Use waitFor to wait for state updates
+ await waitFor(() => {
+ expect(result.current.pooledStakesData).toEqual(mockPooledStakeData);
+ expect(result.current.exchangeRate).toBe(mockExchangeRate);
+ expect(result.current.isLoadingPooledStakesData).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+ });
+
+ it('handles error if the API request fails', async () => {
+ (mockStakingApiService.getPooledStakes as jest.Mock).mockRejectedValue(
+ new Error('API Error'),
+ );
+
+ const { result } = renderHookWithProvider(() => usePooledStakes(), {
+ state: mockInitialState,
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoadingPooledStakesData).toBe(false);
+ expect(result.current.error).toBe('Failed to fetch pooled stakes');
+ expect(result.current.pooledStakesData).toEqual({});
+ });
+ });
+ });
+
+ describe('when handling staking statuses', () => {
+ it('returns ACTIVE status when assets are greater than 0', async () => {
+ (mockStakingApiService.getPooledStakes as jest.Mock).mockResolvedValue({
+ accounts: [{ ...mockPooledStakeData, assets: '100' }],
+ exchangeRate: '1.2',
+ });
+
+ const { result } = renderHookWithProvider(() => usePooledStakes(), {
+ state: mockInitialState,
+ });
+
+ await waitFor(() => {
+ expect(result.current.hasStakedPositions).toBe(true); // ACTIVE status
+ expect(result.current.hasEthToUnstake).toBe(true); // Able to unstake ETH
+ expect(result.current.hasRewards).toBe(true); // Has rewards
+ });
+ });
+
+ it('returns INACTIVE_WITH_EXIT_REQUESTS when assets are 0 and there are exit requests', async () => {
+ (mockStakingApiService.getPooledStakes as jest.Mock).mockResolvedValue({
+ accounts: [
+ {
+ ...mockPooledStakeData,
+ assets: '0',
+ exitRequests: [{ id: 'exit-1' }],
+ },
+ ],
+ exchangeRate: '1.2',
+ });
+
+ const { result } = renderHookWithProvider(() => usePooledStakes(), {
+ state: mockInitialState,
+ });
+
+ await waitFor(() => {
+ expect(result.current.hasStakedPositions).toBe(true); // INACTIVE_WITH_EXIT_REQUESTS
+ expect(result.current.hasEthToUnstake).toBe(false); // Unable to unstake ETH
+ });
+ });
+
+ it('returns INACTIVE_WITH_REWARDS_ONLY when assets are 0 but has rewards', async () => {
+ (mockStakingApiService.getPooledStakes as jest.Mock).mockResolvedValue({
+ accounts: [
+ {
+ ...mockPooledStakeData,
+ assets: '0',
+ lifetimeRewards: '50',
+ exitRequests: [],
+ },
+ ],
+ exchangeRate: '1.2',
+ });
+
+ const { result } = renderHookWithProvider(() => usePooledStakes(), {
+ state: mockInitialState,
+ });
+
+ await waitFor(() => {
+ expect(result.current.hasRewards).toBe(true); // Has rewards
+ expect(result.current.hasRewardsOnly).toBe(true); // INACTIVE_WITH_REWARDS_ONLY
+ });
+ });
+
+ it('returns NEVER_STAKED when assets and rewards are 0', async () => {
+ (mockStakingApiService.getPooledStakes as jest.Mock).mockResolvedValue({
+ accounts: [
+ {
+ ...mockPooledStakeData,
+ assets: '0',
+ lifetimeRewards: '0',
+ exitRequests: [],
+ },
+ ],
+ exchangeRate: '1.2',
+ });
+
+ const { result } = renderHookWithProvider(() => usePooledStakes(), {
+ state: mockInitialState,
+ });
+
+ await waitFor(() => {
+ expect(result.current.hasNeverStaked).toBe(true); // NEVER_STAKED status
+ expect(result.current.hasStakedPositions).toBe(false); // No staked positions
+ });
+ });
+ });
+
+ describe('when refreshing pooled stakes', () => {
+ it('refreshes pooled stakes when refreshPooledStakes is called', async () => {
+ (mockStakingApiService.getPooledStakes as jest.Mock).mockResolvedValue({
+ accounts: [mockPooledStakeData],
+ exchangeRate: mockExchangeRate,
+ });
+
+ const { result } = renderHookWithProvider(() => usePooledStakes(), {
+ state: mockInitialState,
+ });
+
+ await waitFor(() => {
+ expect(result.current.pooledStakesData).toEqual(mockPooledStakeData);
+ });
+
+ // Call refreshPooledStakes inside act() to ensure state update
+ await act(async () => {
+ result.current.refreshPooledStakes();
+ });
+
+ await waitFor(() => {
+ expect(mockStakingApiService.getPooledStakes).toHaveBeenCalledTimes(2);
+ });
+ });
+ });
+});
diff --git a/app/components/UI/Stake/hooks/usePooledStakes.ts b/app/components/UI/Stake/hooks/usePooledStakes.ts
new file mode 100644
index 00000000000..e6a0fb36217
--- /dev/null
+++ b/app/components/UI/Stake/hooks/usePooledStakes.ts
@@ -0,0 +1,120 @@
+import { useSelector } from 'react-redux';
+import { useState, useEffect, useMemo } from 'react';
+import { selectSelectedInternalAccountChecksummedAddress } from '../../../../selectors/accountsController';
+import { selectChainId } from '../../../../selectors/networkController';
+import { hexToNumber } from '@metamask/utils';
+import { PooledStake } from '@metamask/stake-sdk';
+import { useStakeContext } from './useStakeContext';
+
+export enum StakeAccountStatus {
+ // These statuses are only used internally rather than displayed to a user
+ ACTIVE = 'ACTIVE', // non-zero staked shares
+ NEVER_STAKED = 'NEVER_STAKED',
+ INACTIVE_WITH_EXIT_REQUESTS = 'INACTIVE_WITH_EXIT_REQUESTS', // zero staked shares, unstaking or claimable exit requests
+ INACTIVE_WITH_REWARDS_ONLY = 'INACTIVE_WITH_REWARDS_ONLY', // zero staked shares, no exit requests, previous lifetime rewards
+}
+
+const usePooledStakes = () => {
+ const chainId = useSelector(selectChainId);
+ const selectedAddress =
+ useSelector(selectSelectedInternalAccountChecksummedAddress) || '';
+ const { stakingApiService } = useStakeContext(); // Get the stakingApiService directly from context
+ const [pooledStakesData, setPooledStakesData] = useState({} as PooledStake);
+ const [exchangeRate, setExchangeRate] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ setLoading(true);
+
+ if (!stakingApiService) {
+ throw new Error('Staking API service is unavailable');
+ }
+
+ const addresses = selectedAddress ? [selectedAddress] : [];
+ const numericChainId = hexToNumber(chainId);
+
+ // Directly calling the stakingApiService
+ const { accounts = [], exchangeRate: fetchedExchangeRate } =
+ await stakingApiService.getPooledStakes(
+ addresses,
+ numericChainId,
+ true,
+ );
+
+ setPooledStakesData(accounts[0] || null);
+ setExchangeRate(fetchedExchangeRate);
+ } catch (err) {
+ setError('Failed to fetch pooled stakes');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [chainId, selectedAddress, stakingApiService, refreshKey]);
+
+ const refreshPooledStakes = () => {
+ setRefreshKey((prevKey) => prevKey + 1); // Increment `refreshKey` to trigger refetch
+ };
+
+ const getStatus = (stake: PooledStake) => {
+ if (stake.assets === '0' && stake.exitRequests.length > 0) {
+ return StakeAccountStatus.INACTIVE_WITH_EXIT_REQUESTS;
+ } else if (stake.assets === '0' && stake.lifetimeRewards !== '0') {
+ return StakeAccountStatus.INACTIVE_WITH_REWARDS_ONLY;
+ } else if (stake.assets === '0') {
+ return StakeAccountStatus.NEVER_STAKED;
+ }
+ return StakeAccountStatus.ACTIVE;
+ };
+
+ const status = useMemo(() => getStatus(pooledStakesData), [pooledStakesData]);
+
+ const hasStakedPositions = useMemo(
+ () =>
+ status === StakeAccountStatus.ACTIVE ||
+ status === StakeAccountStatus.INACTIVE_WITH_EXIT_REQUESTS,
+ [status],
+ );
+
+ const hasRewards = useMemo(
+ () =>
+ status === StakeAccountStatus.INACTIVE_WITH_REWARDS_ONLY ||
+ status === StakeAccountStatus.ACTIVE,
+ [status],
+ );
+
+ const hasRewardsOnly = useMemo(
+ () => status === StakeAccountStatus.INACTIVE_WITH_REWARDS_ONLY,
+ [status],
+ );
+
+ const hasNeverStaked = useMemo(
+ () => status === StakeAccountStatus.NEVER_STAKED,
+ [status],
+ );
+
+ const hasEthToUnstake = useMemo(
+ () => status === StakeAccountStatus.ACTIVE,
+ [status],
+ );
+
+ return {
+ pooledStakesData,
+ exchangeRate,
+ isLoadingPooledStakesData: loading,
+ error,
+ refreshPooledStakes,
+ hasStakedPositions,
+ hasEthToUnstake,
+ hasNeverStaked,
+ hasRewards,
+ hasRewardsOnly,
+ };
+};
+
+export default usePooledStakes;
diff --git a/app/components/UI/Stake/hooks/useStakeContext.ts b/app/components/UI/Stake/hooks/useStakeContext.ts
index 0fc280593da..2e9b915c65e 100644
--- a/app/components/UI/Stake/hooks/useStakeContext.ts
+++ b/app/components/UI/Stake/hooks/useStakeContext.ts
@@ -1,7 +1,10 @@
import { useContext } from 'react';
-import { Stake, StakeContext } from '../sdk/stakeSdkProvider';
+import { StakeContext } from '../sdk/stakeSdkProvider';
export const useStakeContext = () => {
- const context = useContext(StakeContext);
- return context as Stake;
+ const context = useContext(StakeContext);
+ if (!context) {
+ throw new Error('useStakeContext must be used within a StakeProvider');
+ }
+ return context;
};
diff --git a/app/components/UI/Stake/hooks/useStakingChain.test.tsx b/app/components/UI/Stake/hooks/useStakingChain.test.tsx
new file mode 100644
index 00000000000..c29df592c0a
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useStakingChain.test.tsx
@@ -0,0 +1,59 @@
+import { backgroundState } from '../../../../util/test/initial-root-state';
+import { renderHookWithProvider } from '../../../../util/test/renderWithProvider';
+import { toHex } from '@metamask/controller-utils';
+import useStakingChain from './useStakingChain';
+import { mockNetworkState } from '../../../../util/test/network';
+
+const buildStateWithNetwork = (chainId: string, nickname: string) => ({
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ NetworkController: {
+ ...mockNetworkState({
+ chainId: toHex(chainId),
+ nickname,
+ }),
+ },
+ },
+ },
+});
+
+describe('useStakingChain', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterAll(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('when the chainId is mainnet', () => {
+ it('returns true if chainId is mainnet', () => {
+ const { result } = renderHookWithProvider(() => useStakingChain(), {
+ state: buildStateWithNetwork('1', 'Ethereum'),
+ });
+
+ expect(result.current.isStakingSupportedChain).toBe(true);
+ });
+ });
+
+ describe('when the chainId is Holesky', () => {
+ it('returns true if chainId is holesky', () => {
+ const { result } = renderHookWithProvider(() => useStakingChain(), {
+ state: buildStateWithNetwork('17000', 'Holesky'),
+ });
+
+ expect(result.current.isStakingSupportedChain).toBe(true);
+ });
+ });
+
+ describe('when the chainId is neither mainnet nor Holesky', () => {
+ it('returns false if chainId is not mainnet or holesky', () => {
+ const { result } = renderHookWithProvider(() => useStakingChain(), {
+ state: buildStateWithNetwork('11', 'Test'),
+ });
+
+ expect(result.current.isStakingSupportedChain).toBe(false);
+ });
+ });
+});
diff --git a/app/components/UI/Stake/hooks/useStakingChain.ts b/app/components/UI/Stake/hooks/useStakingChain.ts
new file mode 100644
index 00000000000..d5da33c504e
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useStakingChain.ts
@@ -0,0 +1,16 @@
+import { useSelector } from 'react-redux';
+import { getDecimalChainId } from '../../../../util/networks';
+import { selectChainId } from '../../../../selectors/networkController';
+import { isSupportedChain } from '@metamask/stake-sdk';
+
+const useStakingChain = () => {
+ const chainId = useSelector(selectChainId);
+
+ const isStakingSupportedChain = isSupportedChain(getDecimalChainId(chainId));
+
+ return {
+ isStakingSupportedChain,
+ };
+};
+
+export default useStakingChain;
diff --git a/app/components/UI/Stake/hooks/useStakingEarnings.test.tsx b/app/components/UI/Stake/hooks/useStakingEarnings.test.tsx
new file mode 100644
index 00000000000..cbb95ee7702
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useStakingEarnings.test.tsx
@@ -0,0 +1,101 @@
+import { waitFor } from '@testing-library/react-native';
+import { renderHook } from '@testing-library/react-hooks';
+import useStakingEarnings from './useStakingEarnings';
+import usePooledStakes from './usePooledStakes';
+import useVaultData from './useVaultData';
+import useBalance from './useBalance';
+
+// Mock dependencies
+jest.mock('./usePooledStakes');
+jest.mock('./useVaultData');
+jest.mock('./useBalance');
+
+describe('useStakingEarnings', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('fetches and calculates staking earnings data correctly', async () => {
+ // Mock return values for useVaultData, useBalance, and usePooledStakes
+ (useVaultData as jest.Mock).mockReturnValue({
+ annualRewardRate: '2.5%',
+ annualRewardRateDecimal: 0.025,
+ isLoadingVaultData: false,
+ });
+
+ (useBalance as jest.Mock).mockReturnValue({
+ currentCurrency: 'usd',
+ conversionRate: 3000,
+ });
+
+ (usePooledStakes as jest.Mock).mockReturnValue({
+ pooledStakesData: {
+ lifetimeRewards: '5000000000000000000', // 5 ETH in wei
+ assets: '10000000000000000000', // 10 ETH in wei
+ },
+ isLoadingPooledStakesData: false,
+ });
+
+ const { result } = renderHook(() => useStakingEarnings());
+
+ // Wait for state updates
+ await waitFor(() => {
+ expect(result.current.isLoadingEarningsData).toBe(false);
+ expect(result.current.annualRewardRate).toBe('2.5%');
+ expect(result.current.lifetimeRewardsETH).toBe('5 ETH'); // Calculated by renderFromWei
+ expect(result.current.lifetimeRewardsFiat).toBe('$15000'); // 5 ETH * 3000 USD/ETH
+ expect(result.current.estimatedAnnualEarningsETH).toBe('0.25 ETH'); // Calculated based on assets and annualRewardRateDecimal
+ expect(result.current.estimatedAnnualEarningsFiat).toBe('$750'); // No earnings in fiat
+ });
+ });
+
+ it('returns loading state when either vault or pooled stakes data is loading', async () => {
+ // Mock return values for useVaultData and usePooledStakes
+ (useVaultData as jest.Mock).mockReturnValue({
+ annualRewardRate: '2.5%',
+ annualRewardRateDecimal: 0.025,
+ isLoadingVaultData: true, // Simulate loading
+ });
+
+ (usePooledStakes as jest.Mock).mockReturnValue({
+ pooledStakesData: {},
+ isLoadingPooledStakesData: false,
+ });
+
+ const { result } = renderHook(() => useStakingEarnings());
+
+ // Wait for state updates
+ await waitFor(() => {
+ expect(result.current.isLoadingEarningsData).toBe(true); // Should still be loading
+ });
+ });
+
+ it('handles absence of pooled stakes data correctly', async () => {
+ // Mock return values for useVaultData, useBalance, and usePooledStakes
+ (useVaultData as jest.Mock).mockReturnValue({
+ annualRewardRate: '2.5%',
+ annualRewardRateDecimal: 0.025,
+ isLoadingVaultData: false,
+ });
+
+ (useBalance as jest.Mock).mockReturnValue({
+ currentCurrency: 'usd',
+ conversionRate: 3000,
+ });
+
+ // Simulate missing pooled stakes data
+ (usePooledStakes as jest.Mock).mockReturnValue({
+ pooledStakesData: {},
+ isLoadingPooledStakesData: false,
+ });
+
+ const { result } = renderHook(() => useStakingEarnings());
+
+ await waitFor(() => {
+ expect(result.current.lifetimeRewardsETH).toBe('0 ETH'); // No lifetime rewards
+ expect(result.current.lifetimeRewardsFiat).toBe('$0'); // No fiat equivalent
+ expect(result.current.estimatedAnnualEarningsETH).toBe('0 ETH'); // No estimated earnings
+ expect(result.current.estimatedAnnualEarningsFiat).toBe('$0'); // No fiat earnings
+ });
+ });
+});
diff --git a/app/components/UI/Stake/hooks/useStakingEarnings.ts b/app/components/UI/Stake/hooks/useStakingEarnings.ts
new file mode 100644
index 00000000000..cf5ccdabf59
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useStakingEarnings.ts
@@ -0,0 +1,56 @@
+import {
+ renderFiat,
+ renderFromWei,
+ weiToFiatNumber,
+} from '../../../../util/number';
+import usePooledStakes from './usePooledStakes';
+import useVaultData from './useVaultData';
+import useBalance from './useBalance';
+import BigNumber from 'bignumber.js';
+
+const useStakingEarnings = () => {
+ const { annualRewardRate, annualRewardRateDecimal, isLoadingVaultData } =
+ useVaultData();
+
+ const { currentCurrency, conversionRate } = useBalance();
+
+ const { pooledStakesData, isLoadingPooledStakesData } = usePooledStakes();
+
+ const lifetimeRewards = pooledStakesData?.lifetimeRewards ?? '0';
+
+ const lifetimeRewardsETH = `${renderFromWei(lifetimeRewards, 5)} ETH`;
+
+ const lifetimeRewardsFiat = renderFiat(
+ weiToFiatNumber(lifetimeRewards, conversionRate),
+ currentCurrency,
+ 2,
+ );
+
+ const assets = pooledStakesData.assets ?? 0;
+ const estimatedAnnualEarnings = new BigNumber(assets)
+ .multipliedBy(annualRewardRateDecimal)
+ .toFixed(0);
+ const estimatedAnnualEarningsETH = `${renderFromWei(
+ estimatedAnnualEarnings.toString(),
+ 5,
+ )} ETH`;
+
+ const estimatedAnnualEarningsFiat = renderFiat(
+ weiToFiatNumber(estimatedAnnualEarnings, conversionRate),
+ currentCurrency,
+ 2,
+ );
+
+ const isLoadingEarningsData = isLoadingVaultData || isLoadingPooledStakesData;
+
+ return {
+ annualRewardRate,
+ lifetimeRewardsETH,
+ lifetimeRewardsFiat,
+ estimatedAnnualEarningsETH,
+ estimatedAnnualEarningsFiat,
+ isLoadingEarningsData,
+ };
+};
+
+export default useStakingEarnings;
diff --git a/app/components/UI/Stake/hooks/useStakingEligibility.test.tsx b/app/components/UI/Stake/hooks/useStakingEligibility.test.tsx
new file mode 100644
index 00000000000..ac3b6739b78
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useStakingEligibility.test.tsx
@@ -0,0 +1,128 @@
+import { act, waitFor } from '@testing-library/react-native';
+import { renderHookWithProvider } from '../../../../util/test/renderWithProvider';
+import { type StakingApiService } from '@metamask/stake-sdk';
+import { backgroundState } from '../../../../util/test/initial-root-state';
+import { createMockAccountsControllerState } from '../../../../util/test/accountsControllerTestUtils';
+import useStakingEligibility from './useStakingEligibility';
+import { Stake } from '../sdk/stakeSdkProvider';
+
+// Mock initial state for the hook
+const MOCK_ADDRESS_1 = '0xAddress';
+const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([
+ MOCK_ADDRESS_1,
+]);
+
+const mockInitialState = {
+ settings: {},
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
+ },
+ },
+};
+
+// Mock Staking API Service
+const mockStakingApiService: Partial = {
+ getPooledStakingEligibility: jest.fn(),
+};
+
+const mockSdkContext: Stake = {
+ setSdkType: jest.fn(),
+ stakingApiService: mockStakingApiService as StakingApiService,
+};
+
+// Mock the context
+jest.mock('./useStakeContext', () => ({
+ useStakeContext: () => mockSdkContext,
+}));
+
+describe('useStakingEligibility', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('when fetching staking eligibility', () => {
+ it('fetches staking eligibility and sets the state correctly', async () => {
+ // Mock API response
+ (
+ mockStakingApiService.getPooledStakingEligibility as jest.Mock
+ ).mockResolvedValue({
+ eligible: true,
+ });
+
+ const { result } = renderHookWithProvider(() => useStakingEligibility(), {
+ state: mockInitialState,
+ });
+
+ // Initially loading should be true
+ expect(result.current.isLoadingEligibility).toBe(true);
+
+ // Wait for state updates
+ await waitFor(() => {
+ expect(result.current.isEligible).toBe(true); // Eligible
+ expect(result.current.isLoadingEligibility).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+ });
+
+ it('handles error while fetching staking eligibility', async () => {
+ // Mock API error
+ (
+ mockStakingApiService.getPooledStakingEligibility as jest.Mock
+ ).mockRejectedValue(new Error('API Error'));
+
+ const { result } = renderHookWithProvider(() => useStakingEligibility(), {
+ state: mockInitialState,
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoadingEligibility).toBe(false);
+ expect(result.current.error).toBe(
+ 'Failed to fetch pooled staking eligibility',
+ );
+ expect(result.current.isEligible).toBe(false); // Default to false
+ });
+ });
+ });
+
+ describe('when refreshing staking eligibility', () => {
+ it('refreshes staking eligibility successfully', async () => {
+ // Mock initial API response
+ (
+ mockStakingApiService.getPooledStakingEligibility as jest.Mock
+ ).mockResolvedValueOnce({
+ eligible: false,
+ });
+
+ const { result } = renderHookWithProvider(() => useStakingEligibility(), {
+ state: mockInitialState,
+ });
+
+ // Initially not eligible
+ await waitFor(() => {
+ expect(result.current.isEligible).toBe(false);
+ });
+
+ // Simulate API response after refresh
+ (
+ mockStakingApiService.getPooledStakingEligibility as jest.Mock
+ ).mockResolvedValueOnce({
+ eligible: true,
+ });
+
+ // Trigger refresh
+ await act(async () => {
+ result.current.refreshPooledStakingEligibility();
+ });
+
+ // Wait for refresh result
+ await waitFor(() => {
+ expect(result.current.isEligible).toBe(true); // Updated to eligible
+ expect(
+ mockStakingApiService.getPooledStakingEligibility,
+ ).toHaveBeenCalledTimes(2);
+ });
+ });
+ });
+});
diff --git a/app/components/UI/Stake/hooks/useStakingEligibility.ts b/app/components/UI/Stake/hooks/useStakingEligibility.ts
new file mode 100644
index 00000000000..3dc302e370a
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useStakingEligibility.ts
@@ -0,0 +1,50 @@
+import { useSelector } from 'react-redux';
+import { useState, useEffect, useCallback } from 'react';
+import { selectSelectedInternalAccountChecksummedAddress } from '../../../../selectors/accountsController';
+import { useStakeContext } from './useStakeContext';
+
+const useStakingEligibility = () => {
+ const selectedAddress =
+ useSelector(selectSelectedInternalAccountChecksummedAddress) || '';
+ const { stakingApiService } = useStakeContext();
+
+ const [isEligible, setIsEligible] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchStakingEligibility = useCallback(async () => {
+ try {
+ setLoading(true);
+
+ if (!stakingApiService) {
+ throw new Error('Staking API service is unavailable');
+ }
+
+ const addresses = selectedAddress ? [selectedAddress] : [];
+
+ // Directly calling the stakingApiService to fetch staking eligibility
+ const { eligible } = await stakingApiService.getPooledStakingEligibility(
+ addresses,
+ );
+
+ setIsEligible(eligible);
+ } catch (err) {
+ setError('Failed to fetch pooled staking eligibility');
+ } finally {
+ setLoading(false);
+ }
+ }, [selectedAddress, stakingApiService]);
+
+ useEffect(() => {
+ fetchStakingEligibility();
+ }, [fetchStakingEligibility]);
+
+ return {
+ isEligible,
+ isLoadingEligibility: loading,
+ refreshPooledStakingEligibility: fetchStakingEligibility,
+ error,
+ };
+};
+
+export default useStakingEligibility;
diff --git a/app/components/UI/Stake/hooks/useStakingInput.ts b/app/components/UI/Stake/hooks/useStakingInput.ts
index d9d233d19af..7424703e0f1 100644
--- a/app/components/UI/Stake/hooks/useStakingInput.ts
+++ b/app/components/UI/Stake/hooks/useStakingInput.ts
@@ -15,6 +15,7 @@ import {
renderFiat,
} from '../../../../util/number';
import { strings } from '../../../../../locales/i18n';
+import useVaultData from './useVaultData';
const useStakingInputHandlers = (balance: BN) => {
const [amountEth, setAmountEth] = useState('0');
@@ -32,7 +33,8 @@ const useStakingInputHandlers = (balance: BN) => {
const currentCurrency = useSelector(selectCurrentCurrency);
const conversionRate = useSelector(selectConversionRate) || 1;
- const annualRewardRate = '0.026'; //TODO: Replace with actual value: STAKE-806
+ const { annualRewardRate, annualRewardRateDecimal, isLoadingVaultData } =
+ useVaultData();
const currencyToggleValue = isEth
? `${fiatAmount} ${currentCurrency.toUpperCase()}`
@@ -113,27 +115,42 @@ const useStakingInputHandlers = (balance: BN) => {
[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) {
- // Limiting the decimal places to keep it consistent with other eth values in the input screen
- const ethRewards = limitToMaximumDecimalPlaces(
- parseFloat(amountEth) * parseFloat(annualRewardRate),
- 5,
- );
if (isEth) {
- setEstimatedAnnualRewards(`${ethRewards} ETH`);
+ setEstimatedAnnualRewards(annualRewardsETH);
} else {
- const fiatRewards = renderFiat(
- parseFloat(fiatAmount) * parseFloat(annualRewardRate),
- currentCurrency,
- 2,
- );
- setEstimatedAnnualRewards(`${fiatRewards}`);
+ setEstimatedAnnualRewards(annualRewardsFiat);
}
} else {
- setEstimatedAnnualRewards(`${Number(annualRewardRate) * 100}%`);
+ setEstimatedAnnualRewards(annualRewardRate);
}
- }, [isNonZeroAmount, amountEth, isEth, fiatAmount, currentCurrency]);
+ }, [
+ isNonZeroAmount,
+ isEth,
+ annualRewardsETH,
+ annualRewardsFiat,
+ annualRewardRate,
+ ]);
return {
amountEth,
@@ -153,6 +170,10 @@ const useStakingInputHandlers = (balance: BN) => {
conversionRate,
estimatedAnnualRewards,
calculateEstimatedAnnualRewards,
+ annualRewardsETH,
+ annualRewardsFiat,
+ annualRewardRate,
+ isLoadingVaultData,
};
};
diff --git a/app/components/UI/Stake/hooks/useVaultData.test.tsx b/app/components/UI/Stake/hooks/useVaultData.test.tsx
new file mode 100644
index 00000000000..3e3cd5eab7f
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useVaultData.test.tsx
@@ -0,0 +1,105 @@
+import { waitFor } from '@testing-library/react-native';
+
+import { type StakingApiService } from '@metamask/stake-sdk';
+
+import useVaultData from './useVaultData';
+import type { Stake } from '../sdk/stakeSdkProvider';
+import { MOCK_GET_VAULT_RESPONSE } from '../__mocks__/mockData';
+import { renderHookWithProvider } from '../../../../util/test/renderWithProvider';
+
+const mockStakingApiService: Partial = {
+ getVaultData: jest.fn(),
+};
+
+const mockSdkContext: Stake = {
+ setSdkType: jest.fn(),
+ stakingApiService: mockStakingApiService as StakingApiService,
+};
+
+jest.mock('./useStakeContext', () => ({
+ useStakeContext: () => mockSdkContext,
+}));
+
+const mockVaultData = MOCK_GET_VAULT_RESPONSE;
+
+describe('useVaultData', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('when fetching vault data', () => {
+ it('fetches vault data and updates state', async () => {
+ // Mock API response
+ (mockStakingApiService.getVaultData as jest.Mock).mockResolvedValue(
+ mockVaultData,
+ );
+
+ const { result } = renderHookWithProvider(() => useVaultData());
+
+ // Initially loading should be true
+ expect(result.current.isLoadingVaultData).toBe(true);
+
+ // Wait for state updates
+ await waitFor(() => {
+ expect(result.current.vaultData).toEqual(mockVaultData);
+ expect(result.current.annualRewardRate).toBe('2.9%');
+ expect(result.current.annualRewardRateDecimal).toBe(
+ 0.02853065141088763,
+ );
+ expect(result.current.isLoadingVaultData).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+ });
+
+ it('handles error if the API request fails', async () => {
+ // Simulate API error
+ (mockStakingApiService.getVaultData as jest.Mock).mockRejectedValue(
+ new Error('API Error'),
+ );
+
+ const { result } = renderHookWithProvider(() => useVaultData());
+
+ await waitFor(() => {
+ expect(result.current.isLoadingVaultData).toBe(false);
+ expect(result.current.error).toBe('Failed to fetch vault data');
+ expect(result.current.vaultData).toEqual({});
+ });
+ });
+ });
+
+ describe('when validating annual reward rate', () => {
+ it('calculates the annual reward rate correctly based on the fetched APY', async () => {
+ // Mock API response with a custom APY
+ const customVaultData = { ...mockVaultData, apy: '7.0' };
+ (mockStakingApiService.getVaultData as jest.Mock).mockResolvedValue(
+ customVaultData,
+ );
+
+ const { result } = renderHookWithProvider(() => useVaultData());
+
+ await waitFor(() => {
+ expect(result.current.vaultData).toEqual(customVaultData);
+ expect(result.current.annualRewardRate).toBe('7.0%');
+ expect(result.current.annualRewardRateDecimal).toBe(0.07);
+ expect(result.current.isLoadingVaultData).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+ });
+
+ it('returns "0%" when the APY is not available', async () => {
+ // Mock API response with an empty APY
+ const emptyApyVaultData = { ...mockVaultData, apy: '' };
+ (mockStakingApiService.getVaultData as jest.Mock).mockResolvedValue(
+ emptyApyVaultData,
+ );
+
+ const { result } = renderHookWithProvider(() => useVaultData());
+
+ await waitFor(() => {
+ expect(result.current.vaultData).toEqual(emptyApyVaultData);
+ expect(result.current.annualRewardRate).toBe('0%');
+ expect(result.current.annualRewardRateDecimal).toBe(0);
+ });
+ });
+ });
+});
diff --git a/app/components/UI/Stake/hooks/useVaultData.ts b/app/components/UI/Stake/hooks/useVaultData.ts
new file mode 100644
index 00000000000..4a7993fc661
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useVaultData.ts
@@ -0,0 +1,59 @@
+import { useSelector } from 'react-redux';
+import { useState, useEffect } from 'react';
+import { selectChainId } from '../../../../selectors/networkController';
+import { hexToNumber } from '@metamask/utils';
+import { VaultData } from '@metamask/stake-sdk';
+import { useStakeContext } from './useStakeContext';
+
+const useVaultData = () => {
+ const chainId = useSelector(selectChainId);
+ const { stakingApiService } = useStakeContext(); // Get the stakingApiService directly from context
+
+ const [vaultData, setVaultData] = useState({} as VaultData);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchVaultData = async () => {
+ try {
+ setLoading(true);
+
+ if (!stakingApiService) {
+ throw new Error('Staking API service is unavailable');
+ }
+
+ const numericChainId = hexToNumber(chainId);
+ const vaultDataResponse = await stakingApiService.getVaultData(
+ numericChainId,
+ );
+
+ setVaultData(vaultDataResponse);
+ } catch (err) {
+ setError('Failed to fetch vault data');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchVaultData();
+ }, [chainId, stakingApiService]);
+
+ const apy = vaultData?.apy || '0';
+ const annualRewardRatePercentage = apy ? parseFloat(apy) : 0;
+ const annualRewardRateDecimal = annualRewardRatePercentage / 100;
+
+ const annualRewardRate =
+ annualRewardRatePercentage === 0
+ ? '0%'
+ : `${annualRewardRatePercentage.toFixed(1)}%`;
+
+ return {
+ vaultData,
+ isLoadingVaultData: loading,
+ error,
+ annualRewardRate,
+ annualRewardRateDecimal,
+ };
+};
+
+export default useVaultData;
diff --git a/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx b/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx
index 84ee9ad7324..c87832b4d3a 100644
--- a/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx
+++ b/app/components/UI/Stake/sdk/stakeSdkProvider.test.tsx
@@ -3,6 +3,7 @@ import {
ChainId,
PooledStakingContract,
StakingType,
+ type StakingApiService,
} from '@metamask/stake-sdk';
import renderWithProvider from '../../../../util/test/renderWithProvider';
import { backgroundState } from '../../../../util/test/initial-root-state';
@@ -28,8 +29,17 @@ const mockPooledStakingContractService: PooledStakingContract = {
estimateMulticallGas: jest.fn(),
};
+const mockStakingApiService: Partial = {
+ getPooledStakes: jest.fn(),
+ getVaultData: jest.fn(),
+ getPooledStakingEligibility: jest.fn(),
+ fetchFromApi: jest.fn(),
+ baseUrl: 'http://mockApiUrl.com',
+};
+
const mockSDK: Stake = {
stakingContract: mockPooledStakingContractService,
+ stakingApiService: mockStakingApiService as StakingApiService,
sdkType: StakingType.POOLED,
setSdkType: jest.fn(),
};
diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
index 7ab0ad470d5..46f5322c377 100644
--- a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
+++ b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
@@ -46,8 +46,9 @@ import { TokenI } from '../../types';
import { strings } from '../../../../../../locales/i18n';
import { ScamWarningIcon } from '../ScamWarningIcon';
import { ScamWarningModal } from '../ScamWarningModal';
-import { StakeButton } from '../StakeButton';
+import { StakeButton } from '../../../Stake/components/StakeButton';
import { CustomNetworkImgMapping } from '../../../../../util/networks/customNetworks';
+import useStakingChain from '../../../Stake/hooks/useStakingChain';
interface TokenListItemProps {
asset: TokenI;
@@ -151,6 +152,8 @@ export const TokenListItem = ({
const isMainnet = isMainnetByChainId(chainId);
const isLineaMainnet = isLineaMainnetByChainId(chainId);
+ const { isStakingSupportedChain } = useStakingChain();
+
const NetworkBadgeSource = () => {
if (isTestNet(chainId)) return getTestNetImageByChainId(chainId);
@@ -211,7 +214,9 @@ export const TokenListItem = ({
{asset.name || asset.symbol}
{/** Add button link to Portfolio Stake if token is mainnet ETH */}
- {asset.isETH && isMainnet && }
+ {asset.isETH && isStakingSupportedChain && (
+
+ )}
{!isTestNet(chainId) ? (
diff --git a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap
index c7991e8ad2c..105de0a20a5 100644
--- a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap
+++ b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap
@@ -781,7 +781,7 @@ exports[`Tokens should hide zero balance tokens when setting is on 1`] = `
}
}
>
- Stake
+ Earn
- Stake
+ Earn
- Stake
+ Earn
({
TokensController: {
ignoreTokens: jest.fn(() => Promise.resolve()),
},
+ NetworkController: {
+ getNetworkClientById: () => ({
+ configuration: {
+ chainId: '0x1',
+ rpcUrl: 'https://mainnet.infura.io/v3',
+ ticker: 'ETH',
+ type: 'custom',
+ },
+ }),
+ findNetworkClientIdByChainId: () => 'mainnet',
+ },
},
}));
@@ -105,6 +116,20 @@ jest.mock('@react-navigation/native', () => {
};
});
+jest.mock('../../UI/Stake/constants', () => ({
+ isPooledStakingFeatureEnabled: jest.fn().mockReturnValue(true),
+}));
+
+jest.mock('../../UI/Stake/hooks/useStakingEligibility', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ isEligible: false,
+ loading: false,
+ error: null,
+ refreshPooledStakingEligibility: jest.fn(),
+ })),
+}));
+
const Stack = createStackNavigator();
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -232,16 +257,19 @@ describe('Tokens', () => {
expect(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeDefined();
});
- it('navigates to Portfolio Stake url when stake button is pressed', () => {
+
+ it('navigates to Stake Input screen when stake button is pressed and user is not eligible', async () => {
const { getByTestId } = renderComponent(initialState);
fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON));
- expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, {
- params: {
- newTabUrl: `${AppConstants.STAKE.URL}?metamaskEntry=mobile`,
- timestamp: 123,
- },
- screen: Routes.BROWSER.VIEW,
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, {
+ params: {
+ newTabUrl: `${AppConstants.STAKE.URL}?metamaskEntry=mobile`,
+ timestamp: 123,
+ },
+ screen: Routes.BROWSER.VIEW,
+ });
});
});
});
diff --git a/locales/languages/en.json b/locales/languages/en.json
index cf03f8809d1..7a3beee37cb 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -3313,6 +3313,7 @@
},
"stake": {
"stake": "Stake",
+ "earn": "Earn",
"stake_eth": "Stake ETH",
"unstake_eth": "Unstake ETH",
"staked_balance": "Staked balance",
@@ -3383,7 +3384,8 @@
"terms_of_service": "Terms of service",
"risk_disclosure": "Risk disclosure",
"cancel": "Cancel",
- "confirm": "Confirm"
+ "confirm": "Confirm",
+ "continue": "Continue"
},
"default_settings": {
"title": "Your Wallet is ready",