diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarnings.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarnings.test.tsx index 3ce860c1010..5f05415e6b0 100644 --- a/app/components/UI/Stake/components/StakingEarnings/StakingEarnings.test.tsx +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarnings.test.tsx @@ -19,16 +19,6 @@ 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: () => ({ @@ -38,6 +28,7 @@ jest.mock('../../hooks/useStakingEarnings', () => ({ estimatedAnnualEarningsETH: '2.5 ETH', estimatedAnnualEarningsFiat: '$5000', isLoadingEarningsData: false, + hasStakedPositions: true, }), })); diff --git a/app/components/UI/Stake/components/StakingEarnings/index.tsx b/app/components/UI/Stake/components/StakingEarnings/index.tsx index ddd95331644..b5a7d035c02 100644 --- a/app/components/UI/Stake/components/StakingEarnings/index.tsx +++ b/app/components/UI/Stake/components/StakingEarnings/index.tsx @@ -19,7 +19,6 @@ import { isPooledStakingFeatureEnabled } from '../../../Stake/constants'; import useStakingChain from '../../hooks/useStakingChain'; import { StakeSDKProvider } from '../../sdk/stakeSdkProvider'; import useStakingEarnings from '../../hooks/useStakingEarnings'; -import usePooledStakes from '../../hooks/usePooledStakes'; import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; import { withMetaMetrics } from '../../utils/metaMetrics/withMetaMetrics'; import { MetaMetricsEvents } from '../../../../hooks/useMetrics'; @@ -30,8 +29,6 @@ const StakingEarningsContent = () => { const { openTooltipModal } = useTooltipModal(); - const { hasStakedPositions } = usePooledStakes(); - const { annualRewardRate, lifetimeRewardsETH, @@ -39,6 +36,7 @@ const StakingEarningsContent = () => { estimatedAnnualEarningsETH, estimatedAnnualEarningsFiat, isLoadingEarningsData, + hasStakedPositions, } = useStakingEarnings(); const onDisplayAnnualRateTooltip = () => diff --git a/app/components/UI/Stake/hooks/usePooledStakes.ts b/app/components/UI/Stake/hooks/usePooledStakes.ts index e6a0fb36217..1021811a9c7 100644 --- a/app/components/UI/Stake/hooks/usePooledStakes.ts +++ b/app/components/UI/Stake/hooks/usePooledStakes.ts @@ -1,10 +1,14 @@ -import { useSelector } from 'react-redux'; -import { useState, useEffect, useMemo } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useCallback, useEffect, useMemo, useState } 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'; +import { + selectPooledStakesData, + setPooledStakes, +} from '../../../../core/redux/slices/staking'; export enum StakeAccountStatus { // These statuses are only used internally rather than displayed to a user @@ -15,51 +19,48 @@ export enum StakeAccountStatus { } const usePooledStakes = () => { + const dispatch = useDispatch(); 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); + const { pooledStakesData, exchangeRate } = useSelector( + selectPooledStakesData, + ); + const { stakingApiService } = useStakeContext(); - // Directly calling the stakingApiService - const { accounts = [], exchangeRate: fetchedExchangeRate } = - await stakingApiService.getPooledStakes( - addresses, - numericChainId, - true, - ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); - setPooledStakesData(accounts[0] || null); - setExchangeRate(fetchedExchangeRate); - } catch (err) { - setError('Failed to fetch pooled stakes'); - } finally { - setLoading(false); - } - }; + const fetchData = useCallback(async () => { + if (!stakingApiService || !selectedAddress) return; + + setIsLoading(true); + setError(null); + + try { + const { accounts = [], exchangeRate: fetchedExchangeRate } = + await stakingApiService.getPooledStakes( + [selectedAddress], + hexToNumber(chainId), + true, + ); + + dispatch( + setPooledStakes({ + pooledStakes: accounts[0] || {}, + exchangeRate: fetchedExchangeRate, + }), + ); + } catch (err) { + setError('Failed to fetch pooled stakes'); + } finally { + setIsLoading(false); + } + }, [chainId, selectedAddress, stakingApiService, dispatch]); + useEffect(() => { fetchData(); - }, [chainId, selectedAddress, stakingApiService, refreshKey]); - - const refreshPooledStakes = () => { - setRefreshKey((prevKey) => prevKey + 1); // Increment `refreshKey` to trigger refetch - }; + }, [fetchData]); const getStatus = (stake: PooledStake) => { if (stake.assets === '0' && stake.exitRequests.length > 0) { @@ -72,48 +73,32 @@ const usePooledStakes = () => { 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], - ); + const statusFlags = useMemo(() => { + const currentStatus = pooledStakesData + ? getStatus(pooledStakesData) + : StakeAccountStatus.NEVER_STAKED; + + return { + hasStakedPositions: + currentStatus === StakeAccountStatus.ACTIVE || + currentStatus === StakeAccountStatus.INACTIVE_WITH_EXIT_REQUESTS, + hasRewards: + currentStatus === StakeAccountStatus.INACTIVE_WITH_REWARDS_ONLY || + currentStatus === StakeAccountStatus.ACTIVE, + hasRewardsOnly: + currentStatus === StakeAccountStatus.INACTIVE_WITH_REWARDS_ONLY, + hasNeverStaked: currentStatus === StakeAccountStatus.NEVER_STAKED, + hasEthToUnstake: currentStatus === StakeAccountStatus.ACTIVE, + }; + }, [pooledStakesData]); return { pooledStakesData, exchangeRate, - isLoadingPooledStakesData: loading, + isLoadingPooledStakesData: isLoading, error, - refreshPooledStakes, - hasStakedPositions, - hasEthToUnstake, - hasNeverStaked, - hasRewards, - hasRewardsOnly, + refreshPooledStakes: fetchData, + ...statusFlags, }; }; diff --git a/app/components/UI/Stake/hooks/useStakingEarnings.ts b/app/components/UI/Stake/hooks/useStakingEarnings.ts index cf5ccdabf59..9671b85dea3 100644 --- a/app/components/UI/Stake/hooks/useStakingEarnings.ts +++ b/app/components/UI/Stake/hooks/useStakingEarnings.ts @@ -14,7 +14,8 @@ const useStakingEarnings = () => { const { currentCurrency, conversionRate } = useBalance(); - const { pooledStakesData, isLoadingPooledStakesData } = usePooledStakes(); + const { pooledStakesData, isLoadingPooledStakesData, hasStakedPositions } = + usePooledStakes(); const lifetimeRewards = pooledStakesData?.lifetimeRewards ?? '0'; @@ -26,7 +27,7 @@ const useStakingEarnings = () => { 2, ); - const assets = pooledStakesData.assets ?? 0; + const assets = pooledStakesData?.assets ?? 0; const estimatedAnnualEarnings = new BigNumber(assets) .multipliedBy(annualRewardRateDecimal) .toFixed(0); @@ -50,6 +51,7 @@ const useStakingEarnings = () => { estimatedAnnualEarningsETH, estimatedAnnualEarningsFiat, isLoadingEarningsData, + hasStakedPositions, }; }; diff --git a/app/components/UI/Stake/hooks/useStakingEligibility.ts b/app/components/UI/Stake/hooks/useStakingEligibility.ts index 4f0386299f0..613a71e92c9 100644 --- a/app/components/UI/Stake/hooks/useStakingEligibility.ts +++ b/app/components/UI/Stake/hooks/useStakingEligibility.ts @@ -1,41 +1,44 @@ -import { useSelector } from 'react-redux'; -import { useState, useEffect, useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useEffect, useCallback, useState } from 'react'; import { selectSelectedInternalAccountChecksummedAddress } from '../../../../selectors/accountsController'; import { useStakeContext } from './useStakeContext'; +import { + selectStakingEligibility, + setStakingEligibility, +} from '../../../../core/redux/slices/staking'; const useStakingEligibility = () => { + const dispatch = useDispatch(); const selectedAddress = useSelector(selectSelectedInternalAccountChecksummedAddress) || ''; + const { isEligible } = useSelector(selectStakingEligibility); + const { stakingApiService } = useStakeContext(); - const [isEligible, setIsEligible] = useState(false); - const [loading, setLoading] = useState(true); + const [isLoading, setIsLoading] = useState(false); 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] : []; + if (!stakingApiService) { + throw new Error('Staking API service is unavailable'); + } - // Directly calling the stakingApiService to fetch staking eligibility - const { eligible } = await stakingApiService.getPooledStakingEligibility( - addresses, - ); + setIsLoading(true); + setError(null); - setIsEligible(eligible); + try { + const { eligible } = await stakingApiService.getPooledStakingEligibility([ + selectedAddress, + ]); + dispatch(setStakingEligibility(eligible)); return { isEligible: eligible }; } catch (err) { setError('Failed to fetch pooled staking eligibility'); return { isEligible: false }; } finally { - setLoading(false); + setIsLoading(false); } - }, [selectedAddress, stakingApiService]); + }, [selectedAddress, stakingApiService, dispatch]); useEffect(() => { fetchStakingEligibility(); @@ -43,9 +46,9 @@ const useStakingEligibility = () => { return { isEligible, - isLoadingEligibility: loading, - refreshPooledStakingEligibility: fetchStakingEligibility, + isLoadingEligibility: isLoading, error, + refreshPooledStakingEligibility: fetchStakingEligibility, }; }; diff --git a/app/components/UI/Stake/hooks/useVaultData.ts b/app/components/UI/Stake/hooks/useVaultData.ts index 4a7993fc661..e66256c8dd3 100644 --- a/app/components/UI/Stake/hooks/useVaultData.ts +++ b/app/components/UI/Stake/hooks/useVaultData.ts @@ -1,42 +1,44 @@ -import { useSelector } from 'react-redux'; -import { useState, useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useEffect, useCallback, useState } from 'react'; import { selectChainId } from '../../../../selectors/networkController'; import { hexToNumber } from '@metamask/utils'; -import { VaultData } from '@metamask/stake-sdk'; import { useStakeContext } from './useStakeContext'; +import { + selectVaultData, + setVaultData, +} from '../../../../core/redux/slices/staking'; const useVaultData = () => { + const dispatch = useDispatch(); const chainId = useSelector(selectChainId); - const { stakingApiService } = useStakeContext(); // Get the stakingApiService directly from context + const { vaultData } = useSelector(selectVaultData); + const { stakingApiService } = useStakeContext(); - const [vaultData, setVaultData] = useState({} as VaultData); - const [loading, setLoading] = useState(true); + const [isLoading, setIsLoading] = useState(false); 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); - } - }; + const fetchVaultData = useCallback(async () => { + if (!stakingApiService) return; + + setIsLoading(true); + setError(null); + + try { + const numericChainId = hexToNumber(chainId); + const vaultDataResponse = await stakingApiService.getVaultData( + numericChainId, + ); + dispatch(setVaultData(vaultDataResponse)); + } catch (err) { + setError('Failed to fetch vault data'); + } finally { + setIsLoading(false); + } + }, [chainId, stakingApiService, dispatch]); + useEffect(() => { fetchVaultData(); - }, [chainId, stakingApiService]); + }, [fetchVaultData]); const apy = vaultData?.apy || '0'; const annualRewardRatePercentage = apy ? parseFloat(apy) : 0; @@ -49,7 +51,7 @@ const useVaultData = () => { return { vaultData, - isLoadingVaultData: loading, + isLoadingVaultData: isLoading, error, annualRewardRate, annualRewardRateDecimal, diff --git a/app/core/redux/slices/staking/index.test.ts b/app/core/redux/slices/staking/index.test.ts new file mode 100644 index 00000000000..29a1ba0ebe7 --- /dev/null +++ b/app/core/redux/slices/staking/index.test.ts @@ -0,0 +1,128 @@ +import reducer, { + setPooledStakes, + setVaultData, + setStakingEligibility, + selectPooledStakesData, + selectVaultData, + selectStakingEligibility, +} from '.'; +import { + MOCK_GET_POOLED_STAKES_API_RESPONSE, + MOCK_GET_VAULT_RESPONSE, +} from '../../../../components/UI/Stake/__mocks__/mockData'; +import type { PooledStake, VaultData } from '@metamask/stake-sdk'; +import type { RootState } from '../../../../reducers'; + +describe('PooledStaking', () => { + const initialState = { + pooledStakes: {} as PooledStake, + exchangeRate: '', + vaultData: {} as VaultData, + isEligible: false, + }; + + const mockRootState: Partial = { + staking: { + pooledStakes: MOCK_GET_POOLED_STAKES_API_RESPONSE.accounts[0], + exchangeRate: MOCK_GET_POOLED_STAKES_API_RESPONSE.exchangeRate, + vaultData: MOCK_GET_VAULT_RESPONSE, + isEligible: true, + }, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when handling initial state', () => { + it('returns default initial state', () => { + const action = { type: 'unknown' }; + + const state = reducer(undefined, action); + + expect(state).toEqual(initialState); + }); + }); + + describe('when setting pooled stakes', () => { + it('updates pooled stakes and exchange rate', () => { + const { accounts, exchangeRate } = MOCK_GET_POOLED_STAKES_API_RESPONSE; + const pooledStakes = accounts[0]; + + const state = reducer( + initialState, + setPooledStakes({ pooledStakes, exchangeRate }), + ); + + expect(state.pooledStakes).toEqual(pooledStakes); + expect(state.exchangeRate).toEqual(exchangeRate); + expect(state.pooledStakes.assets).toEqual('5791332670714232000'); + }); + }); + + describe('when setting vault data', () => { + it('updates vault data', () => { + const state = reducer( + initialState, + setVaultData(MOCK_GET_VAULT_RESPONSE), + ); + + expect(state.vaultData).toEqual(MOCK_GET_VAULT_RESPONSE); + expect(state.vaultData.apy).toEqual( + '2.853065141088762750393474836309926', + ); + }); + }); + + describe('when setting staking eligibility', () => { + it('updates eligibility to true', () => { + const state = reducer(initialState, setStakingEligibility(true)); + + expect(state.isEligible).toEqual(true); + }); + + it('updates eligibility to false', () => { + const stateWithEligibility = { + ...initialState, + isEligible: true, + }; + + const state = reducer(stateWithEligibility, setStakingEligibility(false)); + + expect(state.isEligible).toEqual(false); + }); + }); + + describe('selectors', () => { + describe('when selecting pooled stakes data', () => { + it('returns pooled stakes and exchange rate', () => { + const result = selectPooledStakesData(mockRootState as RootState); + + expect(result).toEqual({ + pooledStakesData: MOCK_GET_POOLED_STAKES_API_RESPONSE.accounts[0], + exchangeRate: MOCK_GET_POOLED_STAKES_API_RESPONSE.exchangeRate, + }); + }); + }); + + describe('when selecting vault data', () => { + it('returns vault information', () => { + const result = selectVaultData(mockRootState as RootState); + + expect(result).toEqual({ + vaultData: MOCK_GET_VAULT_RESPONSE, + }); + }); + }); + + describe('when selecting staking eligibility', () => { + it('returns eligibility status', () => { + const result = selectStakingEligibility(mockRootState as RootState); + + expect(result).toEqual({ + isEligible: true, + }); + }); + }); + }); +}); diff --git a/app/core/redux/slices/staking/index.ts b/app/core/redux/slices/staking/index.ts new file mode 100644 index 00000000000..99ddc1c6ae4 --- /dev/null +++ b/app/core/redux/slices/staking/index.ts @@ -0,0 +1,72 @@ +import type { PooledStake, VaultData } from '@metamask/stake-sdk'; +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { createSelector } from 'reselect'; +import type { RootState } from '../../../../reducers'; + +interface PooledStakingState { + pooledStakes: PooledStake; + exchangeRate: string; + vaultData: VaultData; + isEligible: boolean; +} + +export const initialState: PooledStakingState = { + pooledStakes: {} as PooledStake, + exchangeRate: '', + vaultData: {} as VaultData, + isEligible: false, +}; + +export const name = 'staking'; + +const slice = createSlice({ + name, + initialState, + reducers: { + setPooledStakes: ( + state, + action: PayloadAction<{ + pooledStakes: PooledStake; + exchangeRate: string; + }>, + ) => { + state.pooledStakes = action.payload.pooledStakes; + state.exchangeRate = action.payload.exchangeRate; + }, + setVaultData: (state, action: PayloadAction) => { + state.vaultData = action.payload; + }, + setStakingEligibility: (state, action: PayloadAction) => { + state.isEligible = action.payload; + }, + }, +}); + +const { actions, reducer } = slice; +export default reducer; +export const { setPooledStakes, setVaultData, setStakingEligibility } = actions; + +// Selectors +const selectPooledStakingState = (state: RootState) => state.staking; + +export const selectPooledStakesData = createSelector( + selectPooledStakingState, + (stakingState) => ({ + pooledStakesData: stakingState.pooledStakes, + exchangeRate: stakingState.exchangeRate, + }), +); + +export const selectVaultData = createSelector( + selectPooledStakingState, + (stakingState) => ({ + vaultData: stakingState.vaultData, + }), +); + +export const selectStakingEligibility = createSelector( + selectPooledStakingState, + (stakingState) => ({ + isEligible: stakingState.isEligible, + }), +); diff --git a/app/reducers/index.ts b/app/reducers/index.ts index 39f5f91c8c8..dc95de44ca3 100644 --- a/app/reducers/index.ts +++ b/app/reducers/index.ts @@ -32,6 +32,7 @@ import inpageProviderReducer from '../core/redux/slices/inpageProvider'; import transactionMetricsReducer from '../core/redux/slices/transactionMetrics'; import originThrottlingReducer from '../core/redux/slices/originThrottling'; import notificationsAccountsProvider from '../core/redux/slices/notifications'; +import stakingReducer from '../core/redux/slices/staking'; /** * Infer state from a reducer * @@ -123,6 +124,7 @@ export interface RootState { transactionMetrics: StateFromReducer; originThrottling: StateFromReducer; notifications: StateFromReducer; + staking: StateFromReducer; } // TODO: Fix the Action type. It's set to `any` now because some of the @@ -162,6 +164,7 @@ const rootReducer = combineReducers({ transactionMetrics: transactionMetricsReducer, originThrottling: originThrottlingReducer, notifications: notificationsAccountsProvider, + staking: stakingReducer, }); export default rootReducer; diff --git a/app/util/test/initial-root-state.ts b/app/util/test/initial-root-state.ts index 58be6fea8d8..d7f50fbe654 100644 --- a/app/util/test/initial-root-state.ts +++ b/app/util/test/initial-root-state.ts @@ -8,6 +8,7 @@ import { initialState as originThrottling } from '../../core/redux/slices/origin import { initialState as initialFeatureFlagsState } from '../../core/redux/slices/featureFlags'; import initialBackgroundState from './initial-background-state.json'; import { userInitialState } from '../../reducers/user'; +import { initialState as initialStakingState } from '../../core/redux/slices/staking'; // A cast is needed here because we use enums in some controllers, and TypeScript doesn't consider // the string value of an enum as satisfying an enum type. @@ -49,6 +50,7 @@ const initialRootState: RootState = { transactionMetrics, originThrottling, notifications: {}, + staking: initialStakingState, }; export default initialRootState;