From ced542a59dca141b0f495fbc30abc28a0132dc91 Mon Sep 17 00:00:00 2001 From: Simon <9662464+siibars@users.noreply.github.com> Date: Mon, 21 Oct 2024 15:16:50 +0200 Subject: [PATCH] Stake 806 to uat (#11912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** PR from [this one](https://github.com/MetaMask/metamask-mobile/pull/11891) onto a uat branch so that we can create a QA release from it. ## **Related issues** Fixes: stake-806 ## **Manual testing steps** 1. When merged get the apk built from the test/mobile-staking-uat branch and test it on your device. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Amitabh Aggarwal --- .../UI/AssetOverview/Balance/Balance.tsx | 6 +- .../TokenDetails/TokenDetails.tsx | 11 +- .../StakeConfirmationView.test.tsx | 8 +- .../StakeConfirmationView.tsx | 14 +- .../StakeConfirmationView.types.ts | 3 + .../StakeConfirmationView.test.tsx.snap | 6 +- .../StakeInputView/StakeInputView.test.tsx | 38 ++++- .../Views/StakeInputView/StakeInputView.tsx | 22 ++- .../StakeInputView.test.tsx.snap | 2 +- .../UnstakeInputView.test.tsx | 42 ++++- .../UnstakeInputView/UnstakeInputView.tsx | 12 +- .../UnstakeInputView.types.ts | 9 ++ .../UnstakeInputView.test.tsx.snap | 2 +- .../StakingBalance => __mocks__}/mockData.ts | 35 ++-- .../components/EstimatedAnnualRewardsCard.tsx | 19 ++- .../components}/StakeButton/index.tsx | 28 ++-- .../StakingBalance/StakingBalance.test.tsx | 71 +++++++- .../StakingBalance/StakingBalance.tsx | 100 ++++++++---- .../StakingButtons/StakingButtons.tsx | 21 ++- .../StakingBalance.test.tsx.snap | 151 +----------------- .../RewardsCard/RewardsCard.test.tsx | 8 +- .../RewardsCard/RewardsCard.tsx | 3 +- .../StakingEarnings.styles.tsx | 2 +- .../StakingEarnings/StakingEarnings.test.tsx | 35 +++- .../StakingEarnings.test.tsx.snap | 8 +- .../components}/StakingEarnings/index.tsx | 81 ++++++---- app/components/UI/Stake/hooks/useBalance.ts | 33 +++- .../UI/Stake/hooks/usePooledStakes.ts | 120 ++++++++++++++ .../UI/Stake/hooks/useStakeContext.ts | 9 +- .../UI/Stake/hooks/useStakingChain.ts | 16 ++ .../UI/Stake/hooks/useStakingEarnings.ts | 56 +++++++ .../UI/Stake/hooks/useStakingEligibility.ts | 48 ++++++ .../UI/Stake/hooks/useStakingInput.ts | 51 ++++-- app/components/UI/Stake/hooks/useVaultData.ts | 60 +++++++ .../stakeSdkProvider.test.tsx.snap | 40 +++-- .../UI/Stake/sdk/stakeSdkProvider.test.tsx | 21 ++- .../UI/Stake/sdk/stakeSdkProvider.tsx | 44 ++--- .../Tokens/TokenList/TokenListItem/index.tsx | 9 +- package.json | 2 +- yarn.lock | 33 ++-- 40 files changed, 895 insertions(+), 384 deletions(-) create mode 100644 app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.types.ts rename app/components/UI/Stake/{components/StakingBalance => __mocks__}/mockData.ts (76%) rename app/components/UI/{Tokens/TokenList => Stake/components}/StakeButton/index.tsx (79%) rename app/components/UI/{AssetOverview => Stake/components}/StakingEarnings/StakingEarnings.styles.tsx (92%) rename app/components/UI/{AssetOverview => Stake/components}/StakingEarnings/StakingEarnings.test.tsx (51%) rename app/components/UI/{AssetOverview => Stake/components}/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap (98%) rename app/components/UI/{AssetOverview => Stake/components}/StakingEarnings/index.tsx (59%) create mode 100644 app/components/UI/Stake/hooks/usePooledStakes.ts create mode 100644 app/components/UI/Stake/hooks/useStakingChain.ts create mode 100644 app/components/UI/Stake/hooks/useStakingEarnings.ts create mode 100644 app/components/UI/Stake/hooks/useStakingEligibility.ts create mode 100644 app/components/UI/Stake/hooks/useVaultData.ts 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/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 109fe3e7fac..69182f1fa44 100644 --- a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx +++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx @@ -58,7 +58,13 @@ describe('StakeConfirmationView', () => { 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 2f1a4890286..c78b5afc813 100644 --- a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx +++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx @@ -9,16 +9,8 @@ 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'; -const MOCK_REWARD_DATA = { - REWARDS: { - ETH: '0.13 ETH', - FIAT: '$334.93', - }, -}; - const MOCK_STAKING_CONTRACT_NAME = 'MM Pooled Staking'; const StakeConfirmationView = ({ route }: StakeConfirmationViewProps) => { @@ -46,9 +38,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..6d75c275c91 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 diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx index 9648fba601e..d1ddb887028 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx @@ -6,6 +6,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import { BN } from 'ethereumjs-util'; import { Stake } from '../../sdk/stakeSdkProvider'; +import { MOCK_GET_VAULT_RESPONSE } from '../../__mocks__/mockData'; function render(Component: React.ComponentType) { return renderScreen( @@ -57,11 +58,11 @@ jest.mock('../../hooks/useStakeContext.ts', () => ({ useStakeContext: jest.fn(() => { const stakeContext: Stake = { setSdkType: jest.fn(), - sdkService: undefined - } - return stakeContext - }) -})) + stakingContract: undefined, + }; + return stakeContext; + }), +})); jest.mock('../../hooks/useBalance', () => ({ __esModule: true, @@ -72,6 +73,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); @@ -105,7 +131,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 782e4d5ab3c..cf70050c558 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx @@ -19,7 +19,6 @@ import styleSheet from './StakeInputView.styles'; import useStakingInputHandlers from '../../hooks/useStakingInput'; import useBalance from '../../hooks/useBalance'; import InputDisplay from '../../components/InputDisplay'; -import { useStakeContext } from '../../hooks/useStakeContext'; const StakeInputView = () => { const title = strings('stake.stake_eth'); @@ -42,11 +41,12 @@ const StakeInputView = () => { handleKeypadChange, calculateEstimatedAnnualRewards, estimatedAnnualRewards, + annualRewardsEth: annualRewardsETH, + annualRewardsFiat, + annualRewardRate, + isLoadingVaultData, } = useStakingInputHandlers(balanceWei); - - const { sdkService } = useStakeContext(); - const navigateToLearnMoreModal = () => { navigation.navigate('StakeModals', { screen: Routes.STAKING.MODALS.LEARN_MORE, @@ -59,10 +59,19 @@ const StakeInputView = () => { params: { amountWei: amountWei.toString(), amountFiat: fiatAmount, + annualRewardsETH, + annualRewardsFiat, + annualRewardRate, }, }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [amountWei, fiatAmount, navigation, sdkService]); + }, [ + navigation, + amountWei, + fiatAmount, + annualRewardsETH, + annualRewardsFiat, + annualRewardRate, + ]); const balanceText = strings('stake.balance'); @@ -106,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 StakeButtonContent = ({ asset }: StakeButtonProps) => { const { colors } = useTheme(); const styles = createStyles(colors); const navigation = useNavigation(); @@ -40,8 +38,10 @@ export const StakeButton = ({ asset }: StakeButtonProps) => { const browserTabs = useSelector((state: RootState) => state.browser.tabs); const chainId = useSelector(selectChainId); + const { isEligible } = useStakingEligibility(); + const onStakeButtonPress = () => { - if (isPooledStakingFeatureEnabled()) { + if (isPooledStakingFeatureEnabled() && isEligible) { navigation.navigate('StakeScreens', { screen: Routes.STAKING.STAKE }); } else { const existingStakeTab = browserTabs.find((tab: BrowserTab) => @@ -93,3 +93,11 @@ export const StakeButton = ({ asset }: StakeButtonProps) => { ); }; + +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..4f363fd1d43 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,76 @@ 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, + }), +})); + 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 +109,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. - - - - { 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/AssetOverview/StakingEarnings/StakingEarnings.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarnings.test.tsx similarity index 51% rename from app/components/UI/AssetOverview/StakingEarnings/StakingEarnings.test.tsx rename to app/components/UI/Stake/components/StakingEarnings/StakingEarnings.test.tsx index f9a8c51b5a5..4c8bfc25942 100644 --- a/app/components/UI/AssetOverview/StakingEarnings/StakingEarnings.test.tsx +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarnings.test.tsx @@ -1,9 +1,9 @@ import React from 'react'; import StakingEarnings from './'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; -import { strings } from '../../../../../locales/i18n'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { strings } from '../../../../../../locales/i18n'; -jest.mock('../../Stake/constants', () => ({ +jest.mock('../../constants', () => ({ isPooledStakingFeatureEnabled: jest.fn().mockReturnValue(true), })); @@ -19,6 +19,35 @@ jest.mock('@react-navigation/native', () => { }; }); +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, + }), +})); + describe('Staking Earnings', () => { it('should render correctly', () => { const { toJSON, getByText } = renderWithProvider(); 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.ts b/app/components/UI/Stake/hooks/useBalance.ts index 2ce1dfc6af4..a1af62ab536 100644 --- a/app/components/UI/Stake/hooks/useBalance.ts +++ b/app/components/UI/Stake/hooks/useBalance.ts @@ -9,11 +9,13 @@ 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); @@ -48,7 +50,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.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.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.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.ts b/app/components/UI/Stake/hooks/useStakingEligibility.ts new file mode 100644 index 00000000000..2325145ab64 --- /dev/null +++ b/app/components/UI/Stake/hooks/useStakingEligibility.ts @@ -0,0 +1,48 @@ +import { useSelector } from 'react-redux'; +import { useState, useEffect } 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); + + useEffect(() => { + const fetchStakingEligibility = 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); + } + }; + + fetchStakingEligibility(); + }, [selectedAddress, stakingApiService]); + + return { + isEligible, + isLoadingEligibility: loading, + error, + }; +}; + +export default useStakingEligibility; diff --git a/app/components/UI/Stake/hooks/useStakingInput.ts b/app/components/UI/Stake/hooks/useStakingInput.ts index d9d233d19af..cd321d69e0a 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, + ), + [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} ETH`); } 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.ts b/app/components/UI/Stake/hooks/useVaultData.ts new file mode 100644 index 00000000000..1064ab8b73c --- /dev/null +++ b/app/components/UI/Stake/hooks/useVaultData.ts @@ -0,0 +1,60 @@ +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'; +import { limitToMaximumDecimalPlaces } from '../../../../util/number'; + +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 = `${limitToMaximumDecimalPlaces( + annualRewardRatePercentage, + 1, + )}%`; + + return { + vaultData, + isLoadingVaultData: loading, + error, + annualRewardRate, + annualRewardRateDecimal, + }; +}; + +export default useVaultData; diff --git a/app/components/UI/Stake/sdk/__snapshots__/stakeSdkProvider.test.tsx.snap b/app/components/UI/Stake/sdk/__snapshots__/stakeSdkProvider.test.tsx.snap index 12944c1a6ce..bdefc205d5c 100644 --- a/app/components/UI/Stake/sdk/__snapshots__/stakeSdkProvider.test.tsx.snap +++ b/app/components/UI/Stake/sdk/__snapshots__/stakeSdkProvider.test.tsx.snap @@ -1155,21 +1155,33 @@ exports[`Stake Modals With Stake Sdk Provider should render correctly stake scre } } > - - 2.6% - + + + + = { + getPooledStakes: jest.fn(), + getVaultData: jest.fn(), + getPooledStakingEligibility: jest.fn(), + fetchFromApi: jest.fn(), + baseUrl: 'http://mockApiUrl.com', +}; + const mockSDK: Stake = { - sdkService: mockPooledStakingContractService, + stakingContract: mockPooledStakingContractService, + stakingApiService: mockStakingApiService as StakingApiService, sdkType: StakingType.POOLED, setSdkType: jest.fn(), }; @@ -44,8 +54,8 @@ describe('Stake Modals With Stake Sdk Provider', () => { }; it('should render correctly stake screen with stake sdk provider and resolve the stake context', () => { const useStakeContextSpy = jest - .spyOn(useStakeContextHook, 'useStakeContext') - .mockReturnValue(mockSDK); + .spyOn(useStakeContextHook, 'useStakeContext') + .mockReturnValue(mockSDK); const { toJSON } = renderWithProvider(StakeScreenStack(), { state: initialState, @@ -57,8 +67,8 @@ describe('Stake Modals With Stake Sdk Provider', () => { it('should render correctly stake modal with stake sdk provider and resolve the stake context', () => { const useStakeContextSpy = jest - .spyOn(useStakeContextHook, 'useStakeContext') - .mockReturnValue(mockSDK); + .spyOn(useStakeContextHook, 'useStakeContext') + .mockReturnValue(mockSDK); const { toJSON } = renderWithProvider(StakeModalStack(), { state: initialState, @@ -66,6 +76,5 @@ describe('Stake Modals With Stake Sdk Provider', () => { expect(toJSON()).toMatchSnapshot(); expect(useStakeContextSpy).toHaveBeenCalledTimes(0); - }); }); diff --git a/app/components/UI/Stake/sdk/stakeSdkProvider.tsx b/app/components/UI/Stake/sdk/stakeSdkProvider.tsx index 19a6769949b..97e133c7012 100644 --- a/app/components/UI/Stake/sdk/stakeSdkProvider.tsx +++ b/app/components/UI/Stake/sdk/stakeSdkProvider.tsx @@ -1,4 +1,9 @@ -import { StakingType, StakeSdk, PooledStakingContract } from '@metamask/stake-sdk'; +import { + StakingType, + StakeSdk, + PooledStakingContract, + type StakingApiService, +} from '@metamask/stake-sdk'; import Logger from '../../../../util/Logger'; import React, { useState, @@ -11,11 +16,11 @@ import React, { export const SDK = StakeSdk.create({ stakingType: StakingType.POOLED }); export interface Stake { - sdkError?: Error; - sdkService?: PooledStakingContract; // to do : facade it for other services implementation - - sdkType?: StakingType; - setSdkType: (stakeType: StakingType) => void; + sdkError?: Error; + stakingContract?: PooledStakingContract; + stakingApiService?: StakingApiService; + sdkType?: StakingType; + setSdkType: (stakeType: StakingType) => void; } export const StakeContext = createContext(undefined); @@ -23,18 +28,23 @@ export const StakeContext = createContext(undefined); export interface StakeProviderProps { stakingType?: StakingType; } -export const StakeSDKProvider: React.FC> = ({ - children, -}) => { - const [sdkService, setSdkService] = useState(); +export const StakeSDKProvider: React.FC< + PropsWithChildren +> = ({ children }) => { + const [stakingContract, setStakingContract] = + useState(); + const [stakingApiService, setStakingApiService] = + useState(); + const [sdkError, setSdkError] = useState(); const [sdkType, setSdkType] = useState(StakingType.POOLED); useEffect(() => { (async () => { try { + setStakingApiService(SDK.stakingApiService); if (sdkType === StakingType?.POOLED) { - setSdkService(SDK.pooledStakingContractService); + setStakingContract(SDK.pooledStakingContract); } else { const notImplementedError = new Error( `StakeSDKProvider SDK.StakingType ${sdkType} not implemented yet`, @@ -52,20 +62,16 @@ export const StakeSDKProvider: React.FC> = const stakeContextValue = useMemo( (): Stake => ({ sdkError, - sdkService, + stakingContract, sdkType, setSdkType, + stakingApiService, }), - [ - sdkError, - sdkService, - sdkType, - setSdkType, - ], + [sdkError, stakingContract, sdkType, stakingApiService], ); return ( {children} ); -}; \ No newline at end of file +}; 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/package.json b/package.json index 22e0511d140..f61b481a676 100644 --- a/package.json +++ b/package.json @@ -182,7 +182,7 @@ "@metamask/snaps-rpc-methods": "^9.1.4", "@metamask/snaps-sdk": "^6.5.0", "@metamask/snaps-utils": "^8.1.1", - "@metamask/stake-sdk": "^0.2.11", + "@metamask/stake-sdk": "^0.2.13", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^9.0.12", "@metamask/transaction-controller": "^37.1.0", diff --git a/yarn.lock b/yarn.lock index dcd0616c19b..b299edebf9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5532,12 +5532,10 @@ ses "^1.1.0" validate-npm-package-name "^5.0.0" -"@metamask/stake-sdk@^0.2.11": - version "0.2.11" - resolved "https://registry.yarnpkg.com/@metamask/stake-sdk/-/stake-sdk-0.2.11.tgz#70b003a7b7f5208fad0d5a986aedd84b0987979f" - integrity sha512-l2novyUK7oVKO2vZDd2tCSyQ8e468hWp0ZB3ed2FoR61HGlZDMqv3hDtXPAfOeA0+cQwsZM861yoUdSXNo0WPA== - dependencies: - axios "^1.7.7" +"@metamask/stake-sdk@^0.2.13": + version "0.2.13" + resolved "https://registry.yarnpkg.com/@metamask/stake-sdk/-/stake-sdk-0.2.13.tgz#368749d698353f1ec9f9119ebc5bf9980582cc2b" + integrity sha512-F6JIfiCmQ+6xg9MA2HXJw7MVbEPK5HWy4IkI/2lmb1+tBGnK/9PddhhllNn50aU3Af/T0gPUQtLSVUXErfBHfg== "@metamask/superstruct@^3.1.0": version "3.1.0" @@ -12903,7 +12901,7 @@ asyncbox@2.x, asyncbox@^2.0.2, asyncbox@^2.0.4, asyncbox@^2.3.0, asyncbox@^2.3.1 asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== asyncstorage-down@4.2.0: version "4.2.0" @@ -12961,7 +12959,7 @@ axios-retry@^3.1.2: "@babel/runtime" "^7.15.4" is-retry-allowed "^2.2.0" -axios@1.4.0, axios@^0.26.0, axios@^0.28.0, axios@^0.x, axios@^1.6.7, axios@^1.6.8, axios@^1.7.4, axios@^1.7.7, axios@~1.6.8: +axios@1.4.0, axios@^0.26.0, axios@^0.28.0, axios@^0.x, axios@^1.6.7, axios@^1.6.8, axios@^1.7.4, axios@~1.6.8: version "1.7.4" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.4.tgz#4c8ded1b43683c8dd362973c393f3ede24052aa2" integrity sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw== @@ -15524,7 +15522,7 @@ degenerator@^5.0.0: delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== delegate@^3.1.2: version "3.2.0" @@ -18249,9 +18247,9 @@ focus-lock@^0.10.1: tslib "^2.0.3" follow-redirects@^1.15.6: - version "1.15.6" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" - integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== + version "1.15.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" + integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== for-each@^0.3.3: version "0.3.3" @@ -18321,7 +18319,7 @@ fork-ts-checker-webpack-plugin@^8.0.0: semver "^7.3.5" tapable "^2.2.1" -form-data@4.0.0, form-data@^4.0.0: +form-data@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== @@ -18339,6 +18337,15 @@ form-data@^2.5.0: combined-stream "^1.0.6" mime-types "^2.1.12" +form-data@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.1.tgz#ba1076daaaa5bfd7e99c1a6cb02aa0a5cff90d48" + integrity sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"