diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx index fa1854612e7..38dd991f61a 100644 --- a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx +++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import StakeEarningsHistoryView from './StakeEarningsHistoryView'; import useStakingEarningsHistory from '../../hooks/useStakingEarningsHistory'; -import { MOCK_STAKED_ETH_ASSET } from '../../__mocks__/mockData'; +import { MOCK_STAKED_ETH_MAINNET_ASSET } from '../../__mocks__/mockData'; import { fireLayoutEvent } from '../../../../../util/testUtils/react-native-svg-charts'; import { getStakingNavbar } from '../../../Navbar'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; @@ -24,7 +24,7 @@ jest.mock('@react-navigation/native', () => { useRoute: () => ({ key: '1', name: 'params', - params: { asset: MOCK_STAKED_ETH_ASSET }, + params: { asset: MOCK_STAKED_ETH_MAINNET_ASSET }, }), }; }); diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx index 559c068e427..ce5c12aec28 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx @@ -1,33 +1,28 @@ import React from 'react'; -import { fireEvent, screen } from '@testing-library/react-native'; +import { fireEvent } from '@testing-library/react-native'; import StakeInputView from './StakeInputView'; -import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import renderWithProvider, { + DeepPartial, +} from '../../../../../util/test/renderWithProvider'; import Routes from '../../../../../constants/navigation/Routes'; -import { backgroundState } from '../../../../../util/test/initial-root-state'; import { Stake } from '../../sdk/stakeSdkProvider'; import { ChainId, PooledStakingContract } from '@metamask/stake-sdk'; import { Contract } from 'ethers'; -import { MOCK_GET_VAULT_RESPONSE } from '../../__mocks__/mockData'; +import { + MOCK_ETH_MAINNET_ASSET, + MOCK_GET_VAULT_RESPONSE, +} from '../../__mocks__/mockData'; import { toWei } from '../../../../../util/number'; import { strings } from '../../../../../../locales/i18n'; // eslint-disable-next-line import/no-namespace import * as useStakingGasFee from '../../hooks/useStakingGasFee'; - -function render(Component: React.ComponentType) { - return renderScreen( - Component, - { - name: Routes.STAKING.STAKE, - }, - { - state: { - engine: { - backgroundState, - }, - }, - }, - ); -} +import { + STAKE_INPUT_VIEW_ACTIONS, + StakeInputViewProps, +} from './StakeInputView.types'; +import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../../util/test/accountsControllerTestUtils'; +import { RootState } from '../../../../../reducers'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; const mockSetOptions = jest.fn(); const mockNavigate = jest.fn(); @@ -40,9 +35,7 @@ jest.mock('@react-navigation/native', () => { ...actualReactNavigation, useNavigation: () => ({ navigate: mockNavigate, - setOptions: mockSetOptions.mockImplementation( - actualReactNavigation.useNavigation().setOptions, - ), + setOptions: mockSetOptions, reset: mockReset, dangerouslyGetParent: () => ({ pop: mockPop, @@ -148,77 +141,105 @@ jest.mock('../../hooks/useVaultData', () => ({ }), })); +const mockInitialState: DeepPartial = { + settings: {}, + engine: { + backgroundState: { + ...backgroundState, + AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, + }, + }, +}; + describe('StakeInputView', () => { + const baseProps: StakeInputViewProps = { + route: { + params: { + action: STAKE_INPUT_VIEW_ACTIONS.STAKE, + token: MOCK_ETH_MAINNET_ASSET, + }, + key: Routes.STAKING.STAKE, + name: 'params', + }, + }; + + const renderComponent = () => + renderWithProvider(, { + state: mockInitialState, + }); + it('render matches snapshot', () => { - render(StakeInputView); - expect(screen.toJSON()).toMatchSnapshot(); + const { toJSON } = renderComponent(); + expect(toJSON()).toMatchSnapshot(); }); describe('when values are entered in the keypad', () => { it('updates ETH and fiat values', () => { - render(StakeInputView); + const { toJSON, getByText } = renderComponent(); + + expect(toJSON()).toMatchSnapshot(); - fireEvent.press(screen.getByText('2')); + fireEvent.press(getByText('2')); - expect(screen.getByText('4000 USD')).toBeTruthy(); + expect(getByText('4000 USD')).toBeTruthy(); }); }); describe('currency toggle functionality', () => { it('switches between ETH and fiat correctly', () => { - render(StakeInputView); + const { getByText } = renderComponent(); - expect(screen.getByText('ETH')).toBeTruthy(); - fireEvent.press(screen.getByText('0 USD')); + expect(getByText('ETH')).toBeTruthy(); + fireEvent.press(getByText('0 USD')); - expect(screen.getByText('USD')).toBeTruthy(); + expect(getByText('USD')).toBeTruthy(); }); }); describe('when calculating rewards', () => { it('calculates estimated annual rewards based on input', () => { - render(StakeInputView); + const { getByText } = renderComponent(); - fireEvent.press(screen.getByText('2')); + fireEvent.press(getByText('2')); - expect(screen.getByText('0.05 ETH')).toBeTruthy(); + expect(getByText('0.05 ETH')).toBeTruthy(); }); }); describe('quick amount buttons', () => { it('handles 25% quick amount button press correctly', () => { - render(StakeInputView); + const { getByText } = renderComponent(); - fireEvent.press(screen.getByText('25%')); + fireEvent.press(getByText('25%')); - expect(screen.getByText('0.375')).toBeTruthy(); + expect(getByText('0.375')).toBeTruthy(); }); }); describe('stake button states', () => { it('displays `Enter amount` if input is 0', () => { - render(StakeInputView); + const { getByText } = renderComponent(); - expect(screen.getByText('Enter amount')).toBeTruthy(); + expect(getByText('Enter amount')).toBeTruthy(); }); it('displays `Review` on stake button if input is valid', () => { - render(StakeInputView); + const { getByText } = renderComponent(); - fireEvent.press(screen.getByText('1')); - expect(screen.getByText('Review')).toBeTruthy(); + fireEvent.press(getByText('1')); + expect(getByText('Review')).toBeTruthy(); }); it('displays `Not enough ETH` when input exceeds balance', () => { - render(StakeInputView); + const { getByText, queryAllByText } = renderComponent(); - fireEvent.press(screen.getByText('4')); - expect(screen.queryAllByText('Not enough ETH')).toHaveLength(2); + fireEvent.press(getByText('4')); + expect(queryAllByText('Not enough ETH')).toHaveLength(2); }); it('navigates to Learn more modal when learn icon is pressed', () => { - render(StakeInputView); - fireEvent.press(screen.getByLabelText('Learn More')); + const { getByLabelText } = renderComponent(); + fireEvent.press(getByLabelText('Learn More')); expect(mockNavigate).toHaveBeenCalledWith('StakeModals', { screen: Routes.STAKING.MODALS.LEARN_MORE, }); @@ -232,11 +253,11 @@ describe('StakeInputView', () => { refreshGasValues: jest.fn(), }); - render(StakeInputView); + const { getByText } = renderComponent(); - fireEvent.press(screen.getByText('25%')); + fireEvent.press(getByText('25%')); - fireEvent.press(screen.getByText(strings('stake.review'))); + fireEvent.press(getByText(strings('stake.review'))); expect(mockNavigate).toHaveBeenLastCalledWith('StakeModals', { screen: Routes.STAKING.MODALS.GAS_IMPACT, diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx index 2862f0c5f97..a7cbd59161f 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx @@ -26,9 +26,11 @@ import { formatEther } from 'ethers/lib/utils'; import { EVENT_PROVIDERS, EVENT_LOCATIONS } from '../../constants/events'; import { selectConfirmationRedesignFlags } from '../../../../../selectors/featureFlagController'; import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; +import { StakeInputViewProps } from './StakeInputView.types'; +import { getStakeInputViewTitle } from './utils'; +import { isStablecoinLendingFeatureEnabled } from '../../constants'; -const StakeInputView = () => { - const title = strings('stake.stake_eth'); +const StakeInputView = ({ route }: StakeInputViewProps) => { const navigation = useNavigation(); const { styles, theme } = useStyles(styleSheet, {}); const { trackEvent, createEventBuilder } = useMetrics(); @@ -169,6 +171,14 @@ const StakeInputView = () => { : strings('stake.review'); useEffect(() => { + const title = isStablecoinLendingFeatureEnabled() + ? getStakeInputViewTitle( + route?.params?.action, + route?.params?.token.symbol, + route?.params?.token.isETH, + ) + : strings('stake.stake_eth'); + navigation.setOptions( getStakingNavbar( title, @@ -188,7 +198,7 @@ const StakeInputView = () => { }, ), ); - }, [navigation, theme.colors, title]); + }, [navigation, route.params, theme.colors]); useEffect(() => { calculateEstimatedAnnualRewards(); diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.types.ts b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.types.ts new file mode 100644 index 00000000000..cd79091b20b --- /dev/null +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.types.ts @@ -0,0 +1,22 @@ +import { RouteProp } from '@react-navigation/native'; +import { TokenI } from '../../../Tokens/types'; +import { strings } from '../../../../../../locales/i18n'; + +export enum STAKE_INPUT_VIEW_ACTIONS { + STAKE = 'STAKE', + LEND = 'LEND', +} + +export const STAKE_INPUT_ACTION_TO_LABEL_MAP = { + [STAKE_INPUT_VIEW_ACTIONS.STAKE]: strings('stake.stake'), + [STAKE_INPUT_VIEW_ACTIONS.LEND]: strings('stake.deposit'), +}; + +interface StakeInputViewRouteParams { + token: TokenI; + action: STAKE_INPUT_VIEW_ACTIONS; +} + +export interface StakeInputViewProps { + route: RouteProp<{ params: StakeInputViewRouteParams }, 'params'>; +} diff --git a/app/components/UI/Stake/Views/StakeInputView/__snapshots__/StakeInputView.test.tsx.snap b/app/components/UI/Stake/Views/StakeInputView/__snapshots__/StakeInputView.test.tsx.snap index b23e11ae13b..8091b5cc806 100644 --- a/app/components/UI/Stake/Views/StakeInputView/__snapshots__/StakeInputView.test.tsx.snap +++ b/app/components/UI/Stake/Views/StakeInputView/__snapshots__/StakeInputView.test.tsx.snap @@ -1,1656 +1,2575 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`StakeInputView render matches snapshot 1`] = ` - - + + + Balance + : + 1.5 ETH + + - - + - - + ETH + + + + - + 0 USD + + + + + + + + - - - + + - - Stake ETH - - - - + + + + - + - - Cancel - - - + } + > + Estimated annual rewards + - - + + 25% + + + - + 50% + + + + + 75% + + + + - + Max + + + + + + + + 1 + + + + + 2 + + + + + 3 + + + + + + + 4 + + + - + 5 + + + + - + + + + + - - - - - - - - - - Balance - : - 1.5 ETH - - - - - 0 - - - ETH - - - - - - 0 USD - - - - - - - - - - - MetaMask Pool - - - - - - - - 2.5% - - - Estimated annual rewards - - - - - - - - - 25% - - - - - 50% - - - - - 75% - - - - - - Max - - - - - - - - 1 - - - - - 2 - - - - - 3 - - - - - - - 4 - - - - - 5 - - - - - 6 - - - - - - - 7 - - - - - 8 - - - - - 9 - - - - - - - . - - - - - 0 - - - - -  - - - - - - - - Enter amount - - - - - - - - - - - - - - - + "color": "#141618", + "fontSize": 30, + "textAlign": "center", + }, + undefined, + ], + ] + } + > + 7 + + + + + 8 + + + + + 9 + + + + + + + . + + + + + 0 + + + + +  + + + + + + + + Enter amount + + + + + +`; + +exports[`StakeInputView when values are entered in the keypad updates ETH and fiat values 1`] = ` + + + + + + Balance + : + 1.5 ETH + + + + + 0 + + + ETH + + + + + + 0 USD + + + + + + + + + + + MetaMask Pool + + + + + + + + 2.5% + + + Estimated annual rewards + + + + + + + + + 25% + + + + + 50% + + + + + 75% + + + + + + Max + + + + + + + + 1 + + + + + 2 + + + + + 3 + + + + + + + 4 + + + + + 5 + + + + + 6 + + + + + + + 7 + + + + + 8 + + + + + 9 + + + + + + + . + + + + + 0 + + + + +  + + + + + + + + Enter amount + + + + + `; diff --git a/app/components/UI/Stake/Views/StakeInputView/utils.test.ts b/app/components/UI/Stake/Views/StakeInputView/utils.test.ts new file mode 100644 index 00000000000..fdac37a28c4 --- /dev/null +++ b/app/components/UI/Stake/Views/StakeInputView/utils.test.ts @@ -0,0 +1,40 @@ +import { + MOCK_ETH_MAINNET_ASSET, + MOCK_SUPPORTED_EARN_TOKENS_WITH_FIAT_BALANCE, +} from '../../__mocks__/mockData'; +import { + STAKE_INPUT_ACTION_TO_LABEL_MAP, + STAKE_INPUT_VIEW_ACTIONS, +} from './StakeInputView.types'; +import { getStakeInputViewTitle } from './utils'; + +describe('StakeInputView Utils', () => { + describe('getStakeInputViewTitle', () => { + it('returns ETH staking title', () => { + const { symbol, isETH } = MOCK_ETH_MAINNET_ASSET; + const action = + STAKE_INPUT_ACTION_TO_LABEL_MAP[STAKE_INPUT_VIEW_ACTIONS.STAKE]; + + const result = getStakeInputViewTitle( + STAKE_INPUT_VIEW_ACTIONS.STAKE, + symbol, + isETH, + ); + expect(result).toEqual(`${action} ETH`); + }); + + it('returns stablecoin lending deposit title', () => { + // Dai Stablecoin + const { symbol, isETH } = MOCK_SUPPORTED_EARN_TOKENS_WITH_FIAT_BALANCE[1]; + const action = + STAKE_INPUT_ACTION_TO_LABEL_MAP[STAKE_INPUT_VIEW_ACTIONS.LEND]; + + const result = getStakeInputViewTitle( + STAKE_INPUT_VIEW_ACTIONS.LEND, + symbol, + isETH, + ); + expect(result).toEqual(`${action} ${symbol}`); + }); + }); +}); diff --git a/app/components/UI/Stake/Views/StakeInputView/utils.ts b/app/components/UI/Stake/Views/StakeInputView/utils.ts new file mode 100644 index 00000000000..c9cb4644188 --- /dev/null +++ b/app/components/UI/Stake/Views/StakeInputView/utils.ts @@ -0,0 +1,15 @@ +import { + STAKE_INPUT_ACTION_TO_LABEL_MAP, + STAKE_INPUT_VIEW_ACTIONS, +} from './StakeInputView.types'; + +export const getStakeInputViewTitle = ( + action: STAKE_INPUT_VIEW_ACTIONS, + tokenSymbol: string, + isEth = false, +) => { + const prefix = STAKE_INPUT_ACTION_TO_LABEL_MAP[action]; + const suffix = isEth ? 'ETH' : tokenSymbol; + + return `${prefix} ${suffix}`; +}; diff --git a/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.test.tsx b/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.test.tsx index b2f83588cf4..8e9d8c16997 100644 --- a/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.test.tsx +++ b/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.test.tsx @@ -7,7 +7,7 @@ import { backgroundState } from '../../../../../util/test/initial-root-state'; import { MOCK_GET_POOLED_STAKES_API_RESPONSE, MOCK_GET_VAULT_RESPONSE, - MOCK_STAKED_ETH_ASSET, + MOCK_STAKED_ETH_MAINNET_ASSET, } from '../../__mocks__/mockData'; jest.mock('../../../../../selectors/multichain', () => ({ @@ -97,7 +97,7 @@ jest.mock('../../hooks/useBalance', () => ({ __esModule: true, default: () => ({ stakedBalanceWei: mockPooledStakeData.assets, - stakedBalanceFiat: MOCK_STAKED_ETH_ASSET.balanceFiat, + stakedBalanceFiat: MOCK_STAKED_ETH_MAINNET_ASSET.balanceFiat, formattedStakedBalanceETH: '5.79133 ETH', }), })); diff --git a/app/components/UI/Stake/__mocks__/mockData.ts b/app/components/UI/Stake/__mocks__/mockData.ts index 8cc42489a85..fa9f61163ba 100644 --- a/app/components/UI/Stake/__mocks__/mockData.ts +++ b/app/components/UI/Stake/__mocks__/mockData.ts @@ -1,6 +1,5 @@ import { ChainId, - StakingApiService, StakingType, type PooledStakes, type VaultData, @@ -8,30 +7,9 @@ import { import { TokenI } from '../../Tokens/types'; import { Contract } from 'ethers'; import { Stake } from '../sdk/stakeSdkProvider'; - -export const MOCK_STAKED_ETH_ASSET = { - decimals: 18, - address: '0x0000000000000000000000000000000000000000', - chainId: '0x1', - balance: '4.9999 ETH', - balanceFiat: '$13,292.20', - name: 'Staked Ethereum', - symbol: 'Ethereum', - ticker: 'ETH', - isETH: true, -} as TokenI; - -export const MOCK_USDC_ASSET = { - decimals: 6, - address: '0xUSDC000000000000000000000000000000000000', - chainId: '0x1', - balance: '200.9999 USDC', - balanceFiat: '$200.98', - name: 'USD Coin', - symbol: 'USD Coin', - ticker: 'USDC', - isETH: false, -} as TokenI; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { createMockToken, getCreateMockTokenOptions } from '../testUtils'; +import { TOKENS_WITH_DEFAULT_OPTIONS } from '../testUtils/testUtils.types'; export const MOCK_GET_POOLED_STAKES_API_RESPONSE: PooledStakes = { accounts: [ @@ -106,35 +84,6 @@ export const MOCK_GET_VAULT_RESPONSE: VaultData = { vaultAddress: '0x0a1b2c3d4e5f6a7b8c9dabecfd0123456789abcd', }; -export const MOCK_STAKING_EARNINGS_DATA = { - ANNUAL_EARNING_RATE: '2.6%', - LIFETIME_REWARDS: { - FIAT: '$2', - ETH: '0.02151 ETH', - }, - EST_ANNUAL_EARNINGS: { - FIAT: '$15.93', - ETH: '0.0131 ETH', - }, -}; - -export const MOCK_REWARD_DATA = { - REWARDS: { - ETH: '0.13 ETH', - FIAT: '$334.93', - }, -}; - -export const MOCK_STAKING_API_SERVICE: Partial = { - fetchFromApi: jest.fn(), - getPooledStakes: jest.fn(), - getVaultData: jest.fn(), - getPooledStakingEligibility: jest.fn(), - getVaultDailyApys: jest.fn(), - getVaultApyAverages: jest.fn(), - baseUrl: 'https://staking.api.com', -}; - const MOCK_POOLED_STAKING_CONTRACT_SERVICE = { chainId: ChainId.ETHEREUM, connectSignerOrProvider: jest.fn(), @@ -156,3 +105,95 @@ export const MOCK_POOL_STAKING_SDK: Stake = { sdkType: StakingType.POOLED, setSdkType: jest.fn(), }; + +export const MOCK_ETH_MAINNET_ASSET = createMockToken( + getCreateMockTokenOptions(CHAIN_IDS.MAINNET, TOKENS_WITH_DEFAULT_OPTIONS.ETH), +); + +export const MOCK_STAKED_ETH_MAINNET_ASSET = createMockToken( + getCreateMockTokenOptions( + CHAIN_IDS.MAINNET, + TOKENS_WITH_DEFAULT_OPTIONS.STAKED_ETH, + ), +); + +export const MOCK_USDC_MAINNET_ASSET = createMockToken( + getCreateMockTokenOptions( + CHAIN_IDS.MAINNET, + TOKENS_WITH_DEFAULT_OPTIONS.USDC, + ), +); + +const MOCK_USDT_MAINNET_ASSET = createMockToken( + getCreateMockTokenOptions( + CHAIN_IDS.MAINNET, + TOKENS_WITH_DEFAULT_OPTIONS.USDT, + ), +); + +const MOCK_DAI_MAINNET_ASSET = createMockToken( + getCreateMockTokenOptions(CHAIN_IDS.MAINNET, TOKENS_WITH_DEFAULT_OPTIONS.DAI), +); + +const MOCK_LINK_MAINNET_ASSET = createMockToken( + getCreateMockTokenOptions( + CHAIN_IDS.MAINNET, + TOKENS_WITH_DEFAULT_OPTIONS.LINK, + ), +); + +const MOCK_MATIC_MAINNET_ASSET = createMockToken( + getCreateMockTokenOptions( + CHAIN_IDS.MAINNET, + TOKENS_WITH_DEFAULT_OPTIONS.MATIC, + ), +); + +const MOCK_ETH_BASE_MAINNET_ASSET = createMockToken( + getCreateMockTokenOptions(CHAIN_IDS.BASE, TOKENS_WITH_DEFAULT_OPTIONS.ETH), +); + +export const MOCK_USDC_BASE_MAINNET_ASSET = createMockToken( + getCreateMockTokenOptions(CHAIN_IDS.BASE, TOKENS_WITH_DEFAULT_OPTIONS.USDC), +); + +export const MOCK_ACCOUNT_MULTI_CHAIN_TOKENS = [ + MOCK_ETH_MAINNET_ASSET, + MOCK_STAKED_ETH_MAINNET_ASSET, + MOCK_LINK_MAINNET_ASSET, + MOCK_DAI_MAINNET_ASSET, + MOCK_MATIC_MAINNET_ASSET, + MOCK_USDC_MAINNET_ASSET, + MOCK_USDT_MAINNET_ASSET, +] as unknown as TokenI[]; + +export const MOCK_SUPPORTED_EARN_TOKENS_NO_FIAT_BALANCE = [ + MOCK_ETH_MAINNET_ASSET, + MOCK_DAI_MAINNET_ASSET, + MOCK_USDC_MAINNET_ASSET, + MOCK_USDT_MAINNET_ASSET, +] as unknown as TokenI[]; + +export const MOCK_SUPPORTED_EARN_TOKENS_WITH_FIAT_BALANCE = [ + { + ...MOCK_ETH_MAINNET_ASSET, + tokenBalanceFormatted: '0.29166 ETH', + }, + { ...MOCK_DAI_MAINNET_ASSET, tokenBalanceFormatted: '108.06408 DAI' }, + { + ...MOCK_USDC_MAINNET_ASSET, + tokenBalanceFormatted: '6.84314 USDC', + }, + { + ...MOCK_USDT_MAINNET_ASSET, + tokenBalanceFormatted: '0 USDT', + }, + { + ...MOCK_ETH_BASE_MAINNET_ASSET, + tokenBalanceFormatted: '390.76791 ETH', + }, + { + ...MOCK_USDC_BASE_MAINNET_ASSET, + tokenBalanceFormatted: '33.39041 USDC', + }, +]; diff --git a/app/components/UI/Stake/components/EarnTokenList/EarnTokenList.styles.ts b/app/components/UI/Stake/components/EarnTokenList/EarnTokenList.styles.ts new file mode 100644 index 00000000000..7cdebbe475f --- /dev/null +++ b/app/components/UI/Stake/components/EarnTokenList/EarnTokenList.styles.ts @@ -0,0 +1,15 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + container: { + maxHeight: 552, + paddingHorizontal: 16, + gap: 12, + }, + listItemContainer: { + padding: 16, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Stake/components/EarnTokenList/EarnTokenList.test.tsx b/app/components/UI/Stake/components/EarnTokenList/EarnTokenList.test.tsx new file mode 100644 index 00000000000..1fb51688ba8 --- /dev/null +++ b/app/components/UI/Stake/components/EarnTokenList/EarnTokenList.test.tsx @@ -0,0 +1,329 @@ +/* eslint-disable import/no-namespace */ +import React from 'react'; +import EarnTokenList from '.'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../../util/test/accountsControllerTestUtils'; +import initialRootState from '../../../../../util/test/initial-root-state'; +import { Metrics, SafeAreaProvider } from 'react-native-safe-area-context'; +import { strings } from '../../../../../../locales/i18n'; +import { + MOCK_SUPPORTED_EARN_TOKENS_NO_FIAT_BALANCE, + MOCK_USDC_BASE_MAINNET_ASSET, +} from '../../__mocks__/mockData'; +import Engine from '../../../../../core/Engine'; +import * as tokenUtils from '../../utils/token'; +import * as useStakingEligibilityHook from '../../hooks/useStakingEligibility'; +import * as stakeConstants from '../../constants'; +import * as portfolioNetworkUtils from '../../../../../util/networks'; +import { act, fireEvent } from '@testing-library/react-native'; + +jest.mock('../../../../../core/Engine', () => ({ + context: { + NetworkController: { + getNetworkClientById: () => ({ + configuration: { + chainId: '0x1', + rpcUrl: 'https://mainnet.infura.io/v3', + ticker: 'ETH', + type: 'custom', + }, + }), + findNetworkClientIdByChainId: () => 'mainnet', + setActiveNetwork: jest.fn(), + }, + }, +})); + +jest.mock('../../../../../util/networks', () => ({ + isPortfolioViewEnabled: jest.fn().mockReturnValue(true), +})); + +jest.mock('../../constants', () => ({ + isStablecoinLendingFeatureEnabled: jest.fn().mockReturnValue(true), +})); + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: mockNavigate, + }), + }; +}); + +jest.mock('../../../../../util/networks', () => ({ + ...jest.requireActual('../../../../../util/networks'), + getNetworkImageSource: jest.fn().mockReturnValue(10), +})); + +const initialState = { + ...initialRootState, + engine: { + ...initialRootState.engine, + backgroundState: { + ...initialRootState.engine.backgroundState, + AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, + }, + }, +}; + +const initialMetrics: Metrics = { + frame: { x: 0, y: 0, width: 320, height: 640 }, + insets: { top: 0, left: 0, right: 0, bottom: 0 }, +}; + +let useStakingEligibilitySpy: jest.SpyInstance; +let getSupportedEarnTokensSpy: jest.SpyInstance; +let filterEligibleTokensSpy: jest.SpyInstance; + +describe('EarnTokenList', () => { + beforeEach(() => { + jest.clearAllMocks(); + + useStakingEligibilitySpy = jest + .spyOn(useStakingEligibilityHook, 'default') + .mockReturnValue({ + isEligible: true, + isLoadingEligibility: false, + refreshPooledStakingEligibility: jest.fn().mockResolvedValue({ + isEligible: true, + }), + error: '', + }); + + getSupportedEarnTokensSpy = jest + .spyOn(tokenUtils, 'getSupportedEarnTokens') + .mockReturnValue(MOCK_SUPPORTED_EARN_TOKENS_NO_FIAT_BALANCE); + + filterEligibleTokensSpy = jest.spyOn(tokenUtils, 'filterEligibleTokens'); + }); + + it('render matches snapshot', () => { + const { toJSON, getByText, getAllByText } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + expect(toJSON()).toMatchSnapshot(); + + // Bottom Sheet Title + expect(getByText(strings('stake.select_a_token'))).toBeDefined(); + + // Upsell Banner + expect(getByText(strings('stake.you_could_earn'))).toBeDefined(); + expect(getByText(strings('stake.per_year_on_your_tokens'))).toBeDefined(); + + // Token List + // Ethereum + expect(getAllByText('Ethereum').length).toBe(1); + expect(getAllByText('2.3% APR').length).toBe(1); + + // DAI + expect(getByText('Dai Stablecoin')).toBeDefined(); + expect(getByText('5.0% APR')).toBeDefined(); + + // USDT + expect(getByText('Tether USD')).toBeDefined(); + expect(getByText('4.1% APR')).toBeDefined(); + + // USDC + expect(getByText('USDC')).toBeDefined(); + expect(getAllByText('4.5% APR').length).toBe(1); + + expect(getSupportedEarnTokensSpy).toHaveBeenCalled(); + expect(filterEligibleTokensSpy).toHaveBeenCalled(); + }); + + it('does not render the EarnTokenList when required feature flags are disabled', () => { + jest + .spyOn(stakeConstants, 'isStablecoinLendingFeatureEnabled') + .mockReturnValueOnce(false); + jest + .spyOn(portfolioNetworkUtils, 'isPortfolioViewEnabled') + .mockReturnValueOnce(false); + + const { toJSON } = renderWithProvider(); + + expect(toJSON()).toBeNull(); + }); + + it('changes active network if selected token is on a different network', async () => { + getSupportedEarnTokensSpy = jest + .spyOn(tokenUtils, 'getSupportedEarnTokens') + .mockReturnValue([MOCK_USDC_BASE_MAINNET_ASSET]); + + const { getByText } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + const baseUsdc = getByText('USDC'); + + await act(() => { + fireEvent.press(baseUsdc); + }); + + expect( + Engine.context.NetworkController.setActiveNetwork, + ).toHaveBeenCalledWith('mainnet'); + + expect(getSupportedEarnTokensSpy).toHaveBeenCalled(); + expect(filterEligibleTokensSpy).toHaveBeenCalled(); + }); + + it('hides staking tokens if user is not eligible', () => { + useStakingEligibilitySpy.mockReturnValue({ + isEligible: false, + isLoadingEligibility: false, + refreshPooledStakingEligibility: jest.fn().mockResolvedValue({ + isEligible: false, + }), + error: '', + }); + + const { queryByText, getByText } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + expect(queryByText('Ethereum')).toBeNull(); + expect(queryByText('Staked Ethereum')).toBeNull(); + + expect(getByText('Dai Stablecoin')).toBeDefined(); + expect(getByText('USDC')).toBeDefined(); + expect(getByText('Tether USD')).toBeDefined(); + + expect(getSupportedEarnTokensSpy).toHaveBeenCalled(); + + expect(filterEligibleTokensSpy).toHaveBeenCalledWith( + MOCK_SUPPORTED_EARN_TOKENS_NO_FIAT_BALANCE, + { canStake: false, canLend: true }, + ); + }); + + it('hides lending tokens if user is not eligible', () => { + filterEligibleTokensSpy.mockImplementationOnce(() => + tokenUtils.filterEligibleTokens( + MOCK_SUPPORTED_EARN_TOKENS_NO_FIAT_BALANCE, + { canStake: true, canLend: false }, + ), + ); + + const { queryByText, getAllByText } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + expect(getAllByText('Ethereum').length).toBe(1); + expect(queryByText('Staked Ethereum')).toBeDefined(); + + expect(queryByText('Dai Stablecoin')).toBeNull(); + expect(queryByText('USDC')).toBeNull(); + expect(queryByText('Tether USD')).toBeNull(); + expect(queryByText('USD Coin')).toBeNull(); + + expect(getSupportedEarnTokensSpy).toHaveBeenCalled(); + expect(filterEligibleTokensSpy).toHaveBeenCalled(); + }); + + it('redirects to StakeInputView with pooled staking navigation params for staking token', async () => { + const { getByText } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + const ethButton = getByText('Ethereum'); + + await act(() => { + fireEvent.press(ethButton); + }); + + expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { + params: { + action: 'STAKE', + token: { + address: '0xabc', + aggregators: [], + balance: '', + balanceFiat: '', + chainId: '0x1', + decimals: 18, + image: '', + isETH: true, + isNative: true, + isStaked: false, + logo: '', + name: 'Ethereum', + symbol: 'Ethereum', + ticker: 'ETH', + tokenBalanceFormatted: ' ETH', + }, + }, + screen: 'Stake', + }); + }); + + it('redirect to StakeInputView with stablecoin lending navigation params for lending token', async () => { + const { getByText } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + const usdcButton = getByText('USDC'); + + await act(() => { + fireEvent.press(usdcButton); + }); + + expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { + params: { + action: 'LEND', + token: { + address: '0xabc', + aggregators: [], + balance: '', + balanceFiat: 'tokenBalanceLoading', + chainId: '0x1', + decimals: 6, + image: '', + isETH: false, + isNative: false, + isStaked: false, + logo: '', + name: 'USDC', + symbol: 'USDC', + ticker: 'USDC', + tokenBalanceFormatted: 'tokenBalanceLoading', + }, + }, + screen: 'Stake', + }); + }); +}); diff --git a/app/components/UI/Stake/components/EarnTokenList/__snapshots__/EarnTokenList.test.tsx.snap b/app/components/UI/Stake/components/EarnTokenList/__snapshots__/EarnTokenList.test.tsx.snap new file mode 100644 index 00000000000..39896ada375 --- /dev/null +++ b/app/components/UI/Stake/components/EarnTokenList/__snapshots__/EarnTokenList.test.tsx.snap @@ -0,0 +1,1115 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EarnTokenList render matches snapshot 1`] = ` + + + + + + + + + + + + + + + + + Select a token + + + + + + + + + + You could earn + + + $454 + + + per year on your tokens + + + + + + + + + + + + + + + + + + + + + + + + + + Ethereum + + + + + 2.3% APR + + + ETH + + + + + + + + + + + + + + + + + + + + + + + Dai Stablecoin + + + + + 5.0% APR + + + tokenBalanceLoading + + + + + + + + + + + + + + + + + + + + + + + USDC + + + + + 4.5% APR + + + tokenBalanceLoading + + + + + + + + + + + + + + + + + + + + + + + Tether USD + + + + + 4.1% APR + + + tokenBalanceLoading + + + + + + + + + + + +`; diff --git a/app/components/UI/Stake/components/EarnTokenList/index.tsx b/app/components/UI/Stake/components/EarnTokenList/index.tsx new file mode 100644 index 00000000000..843d3711ba5 --- /dev/null +++ b/app/components/UI/Stake/components/EarnTokenList/index.tsx @@ -0,0 +1,282 @@ +import React, { useCallback, useMemo } from 'react'; +import BottomSheet from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { View } from 'react-native'; +import { useStyles } from '../../../../hooks/useStyles'; +import styleSheet from './EarnTokenList.styles'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../../BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal.test'; +import { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../../../util/networks'; +import { selectAccountTokensAcrossChains } from '../../../../../selectors/multichain'; +import { TokenI } from '../../../Tokens/types'; +import { ScrollView } from 'react-native-gesture-handler'; +import BigNumber from 'bignumber.js'; +import { deriveBalanceFromAssetMarketDetails } from '../../../Tokens/util'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from '../../../../../selectors/currencyRateController'; +import { selectTokensBalances } from '../../../../../selectors/tokenBalancesController'; +import { selectTokenMarketData } from '../../../../../selectors/tokenRatesController'; +import { Hex } from '@metamask/utils'; +import { selectSelectedInternalAccountAddress } from '../../../../../selectors/accountsController'; +import { selectNetworkConfigurations } from '../../../../../selectors/networkController'; +import { useNavigation } from '@react-navigation/native'; +import Routes from '../../../../../constants/navigation/Routes'; +import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; +import { EVENT_LOCATIONS, EVENT_PROVIDERS } from '../../constants/events'; +import { strings } from '../../../../../../locales/i18n'; +import UpsellBanner from '../UpsellBanner'; +import { UPSELL_BANNER_VARIANTS } from '../UpsellBanner/UpsellBanner.types'; +import { isStablecoinLendingFeatureEnabled } from '../../constants'; +import { + filterEligibleTokens, + getSupportedEarnTokens, +} from '../../utils/token'; +import EarnTokenListItem from '../EarnTokenListItem'; +import Engine from '../../../../../core/Engine'; +import { STAKE_INPUT_VIEW_ACTIONS } from '../../Views/StakeInputView/StakeInputView.types'; +import useStakingEligibility from '../../hooks/useStakingEligibility'; +import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; + +const isEmptyBalance = (token: { tokenBalanceFormatted: string }) => + parseFloat(token?.tokenBalanceFormatted) === 0; + +// Temporary: Will be replaced by actual API call in near future. +export const MOCK_STABLECOIN_API_RESPONSE: { [key: string]: string } = { + USDC: '4.5', + USDT: '4.1', + DAI: '5.0', + Ethereum: '2.3', +}; + +// Temporary: Will be replaced by actual API call in near future. +const MOCK_ESTIMATE_REWARDS = '$454'; + +const EarnTokenListSkeletonPlaceholder = () => ( + + + <> + {[1, 2, 3, 4, 5].map((value) => ( + + ))} + + +); + +const EarnTokenList = () => { + const { createEventBuilder, trackEvent } = useMetrics(); + + const { styles } = useStyles(styleSheet, {}); + + const { navigate } = useNavigation(); + + const tokens = useSelector((state: RootState) => + isPortfolioViewEnabled() ? selectAccountTokensAcrossChains(state) : {}, + ); + + const { + isEligible: isEligibleToStake, + isLoadingEligibility: isLoadingStakingEligibility, + } = useStakingEligibility(); + + const multiChainTokenBalance = useSelector(selectTokensBalances); + + const multiChainMarketData = useSelector(selectTokenMarketData); + + const multiChainCurrencyRates = useSelector(selectCurrencyRates); + + const selectedInternalAccountAddress = useSelector( + selectSelectedInternalAccountAddress, + ); + + const networkConfigurations = useSelector(selectNetworkConfigurations); + + const currentCurrency = useSelector(selectCurrentCurrency); + + const getTokenBalance = useCallback( + (token: TokenI) => { + const tokenChainId = token.chainId as Hex; + + const nativeCurrency = + networkConfigurations?.[tokenChainId]?.nativeCurrency; + + const { balanceValueFormatted, balanceFiat } = + deriveBalanceFromAssetMarketDetails( + token, + multiChainMarketData?.[tokenChainId] || {}, + multiChainTokenBalance?.[selectedInternalAccountAddress as Hex]?.[ + tokenChainId + ] || {}, + multiChainCurrencyRates?.[nativeCurrency]?.conversionRate ?? 0, + currentCurrency || '', + ); + + return { + ...token, + tokenBalanceFormatted: balanceValueFormatted, + balanceFiat, + }; + }, + [ + currentCurrency, + multiChainCurrencyRates, + multiChainMarketData, + multiChainTokenBalance, + networkConfigurations, + selectedInternalAccountAddress, + ], + ); + + const supportedStablecoins = useMemo(() => { + if (isLoadingStakingEligibility) return []; + + const allTokens = Object.values(tokens).flat() as TokenI[]; + + if (!allTokens.length) return []; + + const supportedTokens = getSupportedEarnTokens(allTokens); + + const eligibleTokens = filterEligibleTokens( + supportedTokens, + // Temporary: hardcoded canLend will be replaced before launch with an eligibility check. + { canStake: isEligibleToStake, canLend: true }, + ); + + const eligibleTokensWithBalances = eligibleTokens?.map((token) => + getTokenBalance(token), + ); + + // Tokens with a balance of 0 are placed at the end of the list. + return eligibleTokensWithBalances.sort((a, b) => { + const fiatBalanceA = parseFloat(a.tokenBalanceFormatted); + const fiatBalanceB = parseFloat(b.tokenBalanceFormatted); + + return (fiatBalanceA === 0 ? 1 : 0) - (fiatBalanceB === 0 ? 1 : 0); + }); + }, [getTokenBalance, isEligibleToStake, isLoadingStakingEligibility, tokens]); + + const handleRedirectToInputScreen = async (token: TokenI) => { + const { NetworkController } = Engine.context; + + const networkClientId = NetworkController.findNetworkClientIdByChainId( + token.chainId as Hex, + ); + + if (!networkClientId) { + console.error( + `EarnTokenListItem redirect failed: could not retrieve networkClientId for chainId: ${token.chainId}`, + ); + return; + } + + await Engine.context.NetworkController.setActiveNetwork(networkClientId); + + const action = token.isETH + ? STAKE_INPUT_VIEW_ACTIONS.STAKE + : STAKE_INPUT_VIEW_ACTIONS.LEND; + + navigate('StakeScreens', { + screen: Routes.STAKING.STAKE, + params: { token, action }, + }); + + trackEvent( + createEventBuilder(MetaMetricsEvents.EARN_TOKEN_LIST_ITEM_CLICKED) + .addProperties({ + provider: EVENT_PROVIDERS.CONSENSYS, + location: EVENT_LOCATIONS.WALLET_ACTIONS_BOTTOM_SHEET, + token_name: token.name, + token_symbol: token.symbol, + token_chain_id: getDecimalChainId(token.chainId), + action, + }) + .build(), + ); + }; + + return ( + + + + {strings('stake.select_a_token')} + + + + {supportedStablecoins?.length ? ( + <> + + + {supportedStablecoins?.map( + (token, index) => + token?.chainId && ( + + + + ), + )} + + + ) : ( + + )} + + + ); +}; + +/** + * Temporary wrapper to prevent rending if feature flags aren't enabled. + * We can delete this wrapped once these feature flags are removed. + */ +const EarnTokenListWrapper = () => { + if (isStablecoinLendingFeatureEnabled() && isPortfolioViewEnabled()) { + return ; + } + + return <>; +}; + +export default EarnTokenListWrapper; diff --git a/app/components/UI/Stake/components/EarnTokenListItem/EarnTokenListItem.types.ts b/app/components/UI/Stake/components/EarnTokenListItem/EarnTokenListItem.types.ts index 7f1152fda81..bb216a5a2c6 100644 --- a/app/components/UI/Stake/components/EarnTokenListItem/EarnTokenListItem.types.ts +++ b/app/components/UI/Stake/components/EarnTokenListItem/EarnTokenListItem.types.ts @@ -2,7 +2,7 @@ import { TextProps } from '../../../../../component-library/components/Texts/Tex import { TokenI } from '../../../Tokens/types'; interface Text extends Omit { - value: string; + value?: string; } export interface EarnTokenListItemProps { diff --git a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx index 2aada3df5b4..568edcfa33e 100644 --- a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx +++ b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx @@ -4,12 +4,13 @@ import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/W import StakeButton from './index'; import Routes from '../../../../../constants/navigation/Routes'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { MOCK_STAKED_ETH_ASSET } from '../../__mocks__/mockData'; +import { MOCK_ETH_MAINNET_ASSET } from '../../__mocks__/mockData'; import { useMetrics } from '../../../../hooks/useMetrics'; import { MetricsEventBuilder } from '../../../../../core/Analytics/MetricsEventBuilder'; import { mockNetworkState } from '../../../../../util/test/network'; import AppConstants from '../../../../../core/AppConstants'; import useStakingEligibility from '../../hooks/useStakingEligibility'; +import { STAKE_INPUT_VIEW_ACTIONS } from '../../Views/StakeInputView/StakeInputView.types'; const mockNavigate = jest.fn(); @@ -88,7 +89,7 @@ const STATE_MOCK = { }; const renderComponent = (state = STATE_MOCK) => - renderWithProvider(, { + renderWithProvider(, { state, }); @@ -140,6 +141,10 @@ describe('StakeButton', () => { await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { screen: Routes.STAKING.STAKE, + params: { + token: MOCK_ETH_MAINNET_ASSET, + action: STAKE_INPUT_VIEW_ACTIONS.STAKE, + }, }); }); }); @@ -161,6 +166,10 @@ describe('StakeButton', () => { await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { screen: Routes.STAKING.STAKE, + params: { + token: MOCK_ETH_MAINNET_ASSET, + action: STAKE_INPUT_VIEW_ACTIONS.STAKE, + }, }); }); }); diff --git a/app/components/UI/Stake/components/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx index 9e18af410f0..d967dda9108 100644 --- a/app/components/UI/Stake/components/StakeButton/index.tsx +++ b/app/components/UI/Stake/components/StakeButton/index.tsx @@ -27,6 +27,7 @@ import { StakeSDKProvider } from '../../sdk/stakeSdkProvider'; import { EVENT_LOCATIONS } from '../../constants/events'; import useStakingChain from '../../hooks/useStakingChain'; import Engine from '../../../../../core/Engine'; +import { STAKE_INPUT_VIEW_ACTIONS } from '../../Views/StakeInputView/StakeInputView.types'; interface StakeButtonProps { asset: TokenI; @@ -48,7 +49,13 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { await NetworkController.setActiveNetwork('mainnet'); } if (isEligible) { - navigation.navigate('StakeScreens', { screen: Routes.STAKING.STAKE }); + navigation.navigate('StakeScreens', { + screen: Routes.STAKING.STAKE, + params: { + token: asset, + action: STAKE_INPUT_VIEW_ACTIONS.STAKE, + }, + }); } else { const existingStakeTab = browserTabs.find((tab: BrowserTab) => tab.url.includes(AppConstants.STAKE.URL), diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx b/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx index 667d5b0074b..be64ebf0943 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx +++ b/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx @@ -6,9 +6,10 @@ import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { Image } from 'react-native'; import { + MOCK_ETH_MAINNET_ASSET, MOCK_GET_POOLED_STAKES_API_RESPONSE, MOCK_GET_VAULT_RESPONSE, - MOCK_STAKED_ETH_ASSET, + MOCK_STAKED_ETH_MAINNET_ASSET, } from '../../__mocks__/mockData'; import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils'; import { backgroundState } from '../../../../../util/test/initial-root-state'; @@ -16,6 +17,7 @@ import { backgroundState } from '../../../../../util/test/initial-root-state'; import * as networks from '../../../../../util/networks'; import { mockNetworkState } from '../../../../../util/test/network'; import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { STAKE_INPUT_VIEW_ACTIONS } from '../../Views/StakeInputView/StakeInputView.types'; const MOCK_ADDRESS_1 = '0x0'; @@ -96,8 +98,8 @@ jest.mock('../../hooks/useVaultData', () => ({ jest.mock('../../hooks/useBalance', () => ({ __esModule: true, default: () => ({ - stakedBalanceWei: MOCK_STAKED_ETH_ASSET.balance, - stakedBalanceFiat: MOCK_STAKED_ETH_ASSET.balanceFiat, + stakedBalanceWei: MOCK_STAKED_ETH_MAINNET_ASSET.balance, + stakedBalanceFiat: MOCK_STAKED_ETH_MAINNET_ASSET.balanceFiat, }), })); @@ -128,7 +130,7 @@ describe('StakingBalance', () => { it('render matches snapshot', () => { const { toJSON } = renderWithProvider( - , + , { state: mockInitialState }, ); expect(toJSON()).toMatchSnapshot(); @@ -137,7 +139,7 @@ describe('StakingBalance', () => { it('should match the snapshot when portfolio view is enabled ', () => { jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); const { toJSON } = renderWithProvider( - , + , { state: mockInitialState }, ); expect(toJSON()).toMatchSnapshot(); @@ -145,7 +147,7 @@ describe('StakingBalance', () => { it('redirects to StakeInputView on stake button click', async () => { const { getByText } = renderWithProvider( - , + , { state: mockInitialState }, ); @@ -156,12 +158,16 @@ describe('StakingBalance', () => { expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { screen: Routes.STAKING.STAKE, + params: { + token: MOCK_ETH_MAINNET_ASSET, + action: STAKE_INPUT_VIEW_ACTIONS.STAKE, + }, }); }); it('redirects to UnstakeInputView on unstake button click', async () => { const { getByText } = renderWithProvider( - , + , { state: mockInitialState }, ); @@ -177,7 +183,9 @@ describe('StakingBalance', () => { it('should not render if asset chainId is not a staking supporting chain', () => { const { queryByText, queryByTestId } = renderWithProvider( - , + , { state: mockInitialState }, ); expect(queryByTestId('staking-balance-container')).toBeNull(); @@ -188,7 +196,7 @@ describe('StakingBalance', () => { it('should render claim link and action buttons if supported asset.chainId is not selected chainId', () => { const { queryByText, queryByTestId } = renderWithProvider( - , + , { state: { ...mockInitialState, diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx b/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx index 380424121cb..32d3d298469 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx +++ b/app/components/UI/Stake/components/StakingBalance/StakingBalance.tsx @@ -194,6 +194,7 @@ const StakingBalanceContent = ({ asset }: StakingBalanceProps) => { )} ({ ...jest.requireActual('@react-navigation/native'), @@ -82,6 +84,7 @@ describe('StakingButtons', () => { style: {}, hasStakedPositions: true, hasEthToUnstake: true, + asset: MOCK_ETH_MAINNET_ASSET, }; const { getByText } = renderWithProvider(, { state: mockInitialState, @@ -98,6 +101,7 @@ describe('StakingButtons', () => { style: {}, hasStakedPositions: true, hasEthToUnstake: true, + asset: MOCK_ETH_MAINNET_ASSET, }; const { getByText } = renderWithProvider(, { state: mockSepoliaNetworkState, @@ -112,6 +116,10 @@ describe('StakingButtons', () => { ).toHaveBeenCalledWith('mainnet'); expect(navigate).toHaveBeenCalledWith('StakeScreens', { screen: Routes.STAKING.STAKE, + params: { + action: STAKE_INPUT_VIEW_ACTIONS.STAKE, + token: MOCK_ETH_MAINNET_ASSET, + }, }); }); @@ -123,6 +131,7 @@ describe('StakingButtons', () => { style: {}, hasStakedPositions: true, hasEthToUnstake: true, + asset: MOCK_ETH_MAINNET_ASSET, }; const { getByText } = renderWithProvider(, { state: mockSepoliaNetworkState, diff --git a/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx b/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx index c488492cd58..109801ce83c 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx +++ b/app/components/UI/Stake/components/StakingBalance/StakingButtons/StakingButtons.tsx @@ -14,14 +14,18 @@ import { selectChainId } from '../../../../../../selectors/networkController'; import { EVENT_LOCATIONS } from '../../../constants/events'; import useStakingChain from '../../../hooks/useStakingChain'; import Engine from '../../../../../../core/Engine'; +import { STAKE_INPUT_VIEW_ACTIONS } from '../../../Views/StakeInputView/StakeInputView.types'; +import { TokenI } from '../../../../Tokens/types'; interface StakingButtonsProps extends Pick { + asset: TokenI; hasStakedPositions: boolean; hasEthToUnstake: boolean; } const StakingButtons = ({ style, + asset, hasStakedPositions, hasEthToUnstake, }: StakingButtonsProps) => { @@ -57,7 +61,13 @@ const StakingButtons = ({ const onStakePress = async () => { await handleIsStakingSupportedChain(); - navigate('StakeScreens', { screen: Routes.STAKING.STAKE }); + navigate('StakeScreens', { + screen: Routes.STAKING.STAKE, + params: { + token: asset, + action: STAKE_INPUT_VIEW_ACTIONS.STAKE, + }, + }); trackEvent( createEventBuilder(MetaMetricsEvents.STAKE_BUTTON_CLICKED) .addProperties({ diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.test.tsx index f2bf3bd0a50..bf5ec1571ee 100644 --- a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.test.tsx +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { fireEvent } from '@testing-library/react-native'; import StakingEarningsHistory from './StakingEarningsHistory'; import useStakingEarningsHistory from '../../../hooks/useStakingEarningsHistory'; -import { MOCK_STAKED_ETH_ASSET } from '../../../__mocks__/mockData'; +import { MOCK_STAKED_ETH_MAINNET_ASSET } from '../../../__mocks__/mockData'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../../../util/test/accountsControllerTestUtils'; import { backgroundState } from '../../../../../../util/test/initial-root-state'; @@ -82,7 +82,9 @@ describe('StakingEarningsHistory', () => { it('renders correctly with earnings history', () => { const { getByText } = renderWithProvider( - , + , { state: mockInitialState, }, @@ -125,7 +127,7 @@ describe('StakingEarningsHistory', () => { }); const { getByText, queryByText } = renderWithProvider( - , + , { state: mockInitialState, }, @@ -281,7 +283,7 @@ describe('StakingEarningsHistory', () => { }); const { getByText, queryByText } = renderWithProvider( - , + , { state: mockInitialState, }, @@ -303,7 +305,7 @@ describe('StakingEarningsHistory', () => { it('calls onTimePeriodChange and updates the selected time period', () => { const { getByText } = renderWithProvider( - , + , { state: mockInitialState, }, diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts index 5bba1a3c256..8a0818a7394 100644 --- a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts @@ -1,6 +1,6 @@ import { - MOCK_STAKED_ETH_ASSET, - MOCK_USDC_ASSET, + MOCK_STAKED_ETH_MAINNET_ASSET, + MOCK_USDC_MAINNET_ASSET, } from '../../../__mocks__/mockData'; import { getEntryTimePeriodGroupInfo, @@ -97,36 +97,36 @@ describe('StakingEarningsHistory Utils', () => { describe('formatRewardsWei', () => { it('should format rewards value with special characters', () => { - const result = formatRewardsWei('1', MOCK_STAKED_ETH_ASSET); + const result = formatRewardsWei('1', MOCK_STAKED_ETH_MAINNET_ASSET); expect(result).toBe('< 0.00001'); }); it('should format rewards value with special characters when asset.isETH is false', () => { - const result = formatRewardsWei('1', MOCK_USDC_ASSET); + const result = formatRewardsWei('1', MOCK_USDC_MAINNET_ASSET); expect(result).toBe('< 0.00001'); }); it('should format rewards value without special characters', () => { - const result = formatRewardsWei('1', MOCK_STAKED_ETH_ASSET, true); + const result = formatRewardsWei('1', MOCK_STAKED_ETH_MAINNET_ASSET, true); expect(result).toBe('0.000000000000000001'); }); it('should format rewards value without special characters when asset.isETH is false', () => { - const result = formatRewardsWei('1', MOCK_USDC_ASSET, true); + const result = formatRewardsWei('1', MOCK_USDC_MAINNET_ASSET, true); expect(result).toBe('0.000001'); }); }); describe('formatRewardsNumber', () => { it('should format short rewards number correctly', () => { - const result = formatRewardsNumber(1.456, MOCK_STAKED_ETH_ASSET); + const result = formatRewardsNumber(1.456, MOCK_STAKED_ETH_MAINNET_ASSET); expect(result).toBe('1.456'); }); it('should format long rewards number with 5 decimals', () => { const result = formatRewardsNumber( 1.456234265436536, - MOCK_STAKED_ETH_ASSET, + MOCK_STAKED_ETH_MAINNET_ASSET, ); expect(result).toBe('1.45623'); }); @@ -136,7 +136,7 @@ describe('StakingEarningsHistory Utils', () => { it('should format rewards to fiat currency', () => { const result = formatRewardsFiat( '1000000000000000000', - MOCK_STAKED_ETH_ASSET, + MOCK_STAKED_ETH_MAINNET_ASSET, 'usd', 2000, 1, @@ -147,7 +147,7 @@ describe('StakingEarningsHistory Utils', () => { it('should format rewards to fiat currency when asset.isETH is false', () => { const result = formatRewardsFiat( '1000000', - MOCK_USDC_ASSET, + MOCK_USDC_MAINNET_ASSET, 'usd', 2000, 1, diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.test.tsx index abeeb275305..05a96995131 100644 --- a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.test.tsx +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, waitFor } from '@testing-library/react-native'; import { WalletViewSelectorsIDs } from '../../../../../../../e2e/selectors/wallet/WalletView.selectors'; import Routes from '../../../../../../constants/navigation/Routes'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; -import { MOCK_STAKED_ETH_ASSET } from '../../../__mocks__/mockData'; +import { MOCK_STAKED_ETH_MAINNET_ASSET } from '../../../__mocks__/mockData'; import StakingEarningsHistoryButton from './StakingEarningsHistoryButton'; const mockNavigate = jest.fn(); @@ -20,7 +20,7 @@ jest.mock('@react-navigation/native', () => { const renderComponent = () => renderWithProvider( - , + , ); describe('StakingEarningsHistoryButton', () => { @@ -45,7 +45,7 @@ describe('StakingEarningsHistoryButton', () => { await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { screen: Routes.STAKING.EARNINGS_HISTORY, - params: { asset: MOCK_STAKED_ETH_ASSET }, + params: { asset: MOCK_STAKED_ETH_MAINNET_ASSET }, }); }); }); diff --git a/app/components/UI/Stake/constants/events.ts b/app/components/UI/Stake/constants/events.ts index 8d916bafdb1..adb37173890 100644 --- a/app/components/UI/Stake/constants/events.ts +++ b/app/components/UI/Stake/constants/events.ts @@ -12,5 +12,6 @@ export const EVENT_LOCATIONS = { STAKE_CONFIRMATION_VIEW: 'StakeConfirmationView', UNSTAKE_INPUT_VIEW: 'UnstakeInputView', UNSTAKE_CONFIRMATION_VIEW: 'UnstakeConfirmationView', + WALLET_ACTIONS_BOTTOM_SHEET: 'WalletActionsBottomSheet', UNIT_TEST: 'UnitTest', }; diff --git a/app/components/UI/Stake/routes/index.tsx b/app/components/UI/Stake/routes/index.tsx index 9357dfcdf88..28f741c37bf 100644 --- a/app/components/UI/Stake/routes/index.tsx +++ b/app/components/UI/Stake/routes/index.tsx @@ -10,6 +10,7 @@ import MaxInputModal from '../components/MaxInputModal'; import GasImpactModal from '../components/GasImpactModal'; import StakeEarningsHistoryView from '../Views/StakeEarningsHistoryView/StakeEarningsHistoryView'; import PoolStakingLearnMoreModal from '../components/PoolStakingLearnMoreModal'; +import EarnTokenList from '../components/EarnTokenList'; const Stack = createStackNavigator(); const ModalStack = createStackNavigator(); @@ -68,6 +69,11 @@ const StakeModalStack = () => ( component={GasImpactModal} options={{ headerShown: false }} /> + ); diff --git a/app/components/UI/Stake/testUtils/index.ts b/app/components/UI/Stake/testUtils/index.ts new file mode 100644 index 00000000000..e28f0a9d0f4 --- /dev/null +++ b/app/components/UI/Stake/testUtils/index.ts @@ -0,0 +1,110 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { + CreateMockTokenOptions, + TOKENS_WITH_DEFAULT_OPTIONS, +} from './testUtils.types'; + +export const HOLESKY_CHAIN_ID = '0x4268'; + +export const createMockToken = (options: CreateMockTokenOptions) => { + const { + chainId, + name, + symbol, + address = '0xabc', + decimals = 0, + isStaked = false, + ticker = '', + } = options; + + const isETH = symbol === 'ETH' || symbol === 'Ethereum'; + + const nativeChainIds = [ + CHAIN_IDS.MAINNET, + CHAIN_IDS.SEPOLIA, + HOLESKY_CHAIN_ID, + ]; + const isNative = nativeChainIds.includes(chainId) && isETH; + + return { + address, + aggregators: [], + balance: '', + balanceFiat: '', + chainId, + decimals: decimals ?? 0, + image: '', + isETH, + isNative, + isStaked, + logo: '', + name, + symbol, + ticker: ticker ?? symbol, + }; +}; + +export const getCreateMockTokenOptions = ( + chainId: (typeof CHAIN_IDS)[keyof typeof CHAIN_IDS], + token: TOKENS_WITH_DEFAULT_OPTIONS, +) => { + const tokenOptions: Record< + TOKENS_WITH_DEFAULT_OPTIONS, + Omit + > = { + ETH: { + name: 'Ethereum', + symbol: 'Ethereum', + ticker: 'ETH', + isStaked: false, + decimals: 18, + }, + STAKED_ETH: { + name: 'Staked Ethereum', + symbol: 'Ethereum', + ticker: 'ETH', + isStaked: true, + decimals: 18, + }, + USDC: { + name: 'USDC', + symbol: 'USDC', + ticker: 'USDC', + isStaked: false, + decimals: 6, + }, + USDT: { + name: 'Tether USD', + symbol: 'USDT', + ticker: 'USDT', + isStaked: false, + decimals: 6, + }, + DAI: { + name: 'Dai Stablecoin', + symbol: 'DAI', + ticker: 'DAI', + isStaked: false, + decimals: 18, + }, + LINK: { + name: 'Chainlink Token', + symbol: 'LINK', + ticker: 'LINK', + isStaked: false, + decimals: 18, + }, + MATIC: { + name: 'Matic Network Token', + symbol: 'MATIC', + ticker: 'MATIC', + isStaked: false, + decimals: 18, + }, + }; + + return { + chainId, + ...tokenOptions[token], + }; +}; diff --git a/app/components/UI/Stake/testUtils/testUtils.test.ts b/app/components/UI/Stake/testUtils/testUtils.test.ts new file mode 100644 index 00000000000..0d3a159ade6 --- /dev/null +++ b/app/components/UI/Stake/testUtils/testUtils.test.ts @@ -0,0 +1,96 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { createMockToken, getCreateMockTokenOptions } from '.'; +import { TOKENS_WITH_DEFAULT_OPTIONS } from './testUtils.types'; + +describe('Staking Test Utils', () => { + describe('createMockToken', () => { + it('creates a mock token mainnet ETH token', () => { + const token = createMockToken({ + chainId: CHAIN_IDS.MAINNET, + name: 'Ethereum', + symbol: 'Ethereum', + decimals: 18, + isStaked: false, + ticker: 'ETH', + }); + + expect(token).toStrictEqual({ + address: '0xabc', + aggregators: [], + balance: '', + balanceFiat: '', + chainId: '0x1', + decimals: 18, + image: '', + isETH: true, + isNative: true, + isStaked: false, + logo: '', + name: 'Ethereum', + symbol: 'Ethereum', + ticker: 'ETH', + }); + }); + + it('creates a mock USDC Base token', () => { + const token = createMockToken({ + chainId: CHAIN_IDS.BASE, + name: 'USD Coin', + symbol: 'USDC', + decimals: 18, + ticker: 'USDC', + }); + + expect(token).toStrictEqual({ + address: '0xabc', + aggregators: [], + balance: '', + balanceFiat: '', + chainId: '0x2105', + decimals: 18, + image: '', + isETH: false, + isNative: false, + isStaked: false, + logo: '', + name: 'USD Coin', + symbol: 'USDC', + ticker: 'USDC', + }); + }); + }); + + describe('getCreateMockTokenOptions', () => { + it('returns prebuilt options for mainnet ETH token mock', () => { + const options = getCreateMockTokenOptions( + CHAIN_IDS.MAINNET, + TOKENS_WITH_DEFAULT_OPTIONS.ETH, + ); + + expect(options).toStrictEqual({ + chainId: '0x1', + decimals: 18, + isStaked: false, + name: 'Ethereum', + symbol: 'Ethereum', + ticker: 'ETH', + }); + }); + + it('returns prebuilt options for BASE USDC token mock', () => { + const options = getCreateMockTokenOptions( + CHAIN_IDS.BASE, + TOKENS_WITH_DEFAULT_OPTIONS.USDC, + ); + + expect(options).toStrictEqual({ + chainId: '0x2105', + decimals: 6, + isStaked: false, + name: 'USDC', + symbol: 'USDC', + ticker: 'USDC', + }); + }); + }); +}); diff --git a/app/components/UI/Stake/testUtils/testUtils.types.ts b/app/components/UI/Stake/testUtils/testUtils.types.ts new file mode 100644 index 00000000000..ef267ca04ed --- /dev/null +++ b/app/components/UI/Stake/testUtils/testUtils.types.ts @@ -0,0 +1,21 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; + +export interface CreateMockTokenOptions { + chainId: (typeof CHAIN_IDS)[keyof typeof CHAIN_IDS]; + name: string; + symbol: string; + address?: string; + ticker?: string; + decimals?: number; + isStaked?: boolean; +} + +export enum TOKENS_WITH_DEFAULT_OPTIONS { + ETH = 'ETH', + STAKED_ETH = 'STAKED_ETH', + USDT = 'USDT', + USDC = 'USDC', + DAI = 'DAI', + LINK = 'LINK', + MATIC = 'MATIC', +} diff --git a/app/components/UI/Stake/utils/token/index.ts b/app/components/UI/Stake/utils/token/index.ts new file mode 100644 index 00000000000..f2f16a9b3e9 --- /dev/null +++ b/app/components/UI/Stake/utils/token/index.ts @@ -0,0 +1,69 @@ +import { isSupportedChain } from '@metamask/stake-sdk'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { getDecimalChainId } from '../../../../../util/networks'; +import { TokenI } from '../../../Tokens/types'; + +// Temporary: Will be replaced with supported vaults from API request +const HOLESKY_CHAIN_ID_HEX = '0x4268'; + +const SUPPORTED_STAKING_TOKENS = new Set(['Ethereum']); + +export const SUPPORTED_LENDING_TOKENS = new Set(['DAI', 'USDC', 'USDT']); + +const SUPPORTED_EARN_TOKENS = new Set([ + ...SUPPORTED_STAKING_TOKENS, + ...SUPPORTED_LENDING_TOKENS, +]); +const SUPPORTED_CHAIN_IDS = new Set([ + CHAIN_IDS.MAINNET, + CHAIN_IDS.BASE, + CHAIN_IDS.BSC, + CHAIN_IDS.SEPOLIA, + HOLESKY_CHAIN_ID_HEX, +]); + +export const getSupportedEarnTokens = (tokens: TokenI[]) => + Object.values(tokens).filter(({ isETH, isStaked, symbol, chainId }) => { + // We only support staking on Ethereum + if (isETH && !isSupportedChain(getDecimalChainId(chainId))) return false; + if (isStaked) return false; + + return ( + SUPPORTED_CHAIN_IDS.has(chainId as string) && + SUPPORTED_EARN_TOKENS.has(symbol) + ); + }); + +const removeStakingTokens = (tokens: TokenI[]) => { + const tokensCopy = [...tokens]; + + return tokensCopy.filter( + (token) => !SUPPORTED_STAKING_TOKENS.has(token.symbol), + ); +}; + +const removeLendingTokens = (tokens: TokenI[]) => { + const tokensCopy = [...tokens]; + return tokensCopy.filter( + (token) => !SUPPORTED_LENDING_TOKENS.has(token.symbol), + ); +}; + +export const filterEligibleTokens = ( + tokens: TokenI[], + options: { canStake: boolean; canLend: boolean }, +) => { + const { canStake = false, canLend = false } = options; + + let tokensCopy = [...tokens]; + + if (!canStake) { + tokensCopy = removeStakingTokens(tokensCopy); + } + + if (!canLend) { + tokensCopy = removeLendingTokens(tokensCopy); + } + + return tokensCopy; +}; diff --git a/app/components/UI/Stake/utils/token/token.test.ts b/app/components/UI/Stake/utils/token/token.test.ts new file mode 100644 index 00000000000..298284cbbc3 --- /dev/null +++ b/app/components/UI/Stake/utils/token/token.test.ts @@ -0,0 +1,161 @@ +import { + filterEligibleTokens, + getSupportedEarnTokens, + SUPPORTED_LENDING_TOKENS, +} from '.'; +import { TokenI } from '../../../Tokens/types'; +import { + MOCK_ACCOUNT_MULTI_CHAIN_TOKENS, + MOCK_SUPPORTED_EARN_TOKENS_NO_FIAT_BALANCE, +} from '../../__mocks__/mockData'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { createMockToken, getCreateMockTokenOptions } from '../../testUtils'; +import { TOKENS_WITH_DEFAULT_OPTIONS } from '../../testUtils/testUtils.types'; + +describe('tokenUtils', () => { + describe('getSupportedEarnTokens', () => { + const MOCK_ETH_TOKEN = createMockToken( + getCreateMockTokenOptions( + CHAIN_IDS.MAINNET, + TOKENS_WITH_DEFAULT_OPTIONS.ETH, + ), + ); + + const MOCK_STAKED_ETH_TOKEN = createMockToken( + getCreateMockTokenOptions( + CHAIN_IDS.MAINNET, + TOKENS_WITH_DEFAULT_OPTIONS.STAKED_ETH, + ), + ); + + const MOCK_MAINNET_DAI = createMockToken( + getCreateMockTokenOptions( + CHAIN_IDS.MAINNET, + TOKENS_WITH_DEFAULT_OPTIONS.DAI, + ), + ); + + const MOCK_MAINNET_USDC = createMockToken( + getCreateMockTokenOptions( + CHAIN_IDS.MAINNET, + TOKENS_WITH_DEFAULT_OPTIONS.USDC, + ), + ); + + const MOCK_MAINNET_USDT_NO_BALANCE = createMockToken( + getCreateMockTokenOptions( + CHAIN_IDS.MAINNET, + TOKENS_WITH_DEFAULT_OPTIONS.USDT, + ), + ); + + const MOCK_BASE_USDC = createMockToken({ + chainId: CHAIN_IDS.BASE, + name: 'USD Coin', + symbol: 'USDC', + ticker: 'USDC', + decimals: 6, + }); + + const MOCK_BSC_USDC = createMockToken({ + chainId: CHAIN_IDS.BSC, + name: 'USD Coin', + symbol: 'USDC', + ticker: 'USDC', + decimals: 6, + }); + + const MOCK_SEPOLIA_USDC = createMockToken({ + chainId: CHAIN_IDS.SEPOLIA, + name: 'USD Coin', + symbol: 'USDC', + ticker: 'USDC', + decimals: 6, + }); + + it('extracts supported stable coins from owned tokens', () => { + const result = getSupportedEarnTokens(MOCK_ACCOUNT_MULTI_CHAIN_TOKENS); + expect(result).toEqual(MOCK_SUPPORTED_EARN_TOKENS_NO_FIAT_BALANCE); + }); + + it('filters out Staked Ethereum', () => { + const tokens = [MOCK_ETH_TOKEN, MOCK_STAKED_ETH_TOKEN]; + const result = getSupportedEarnTokens(tokens); + expect(result).toEqual([MOCK_ETH_TOKEN]); + }); + + it('allows supported stablecoins on mainnet', () => { + const tokens = [ + MOCK_ETH_TOKEN, + MOCK_MAINNET_DAI, + MOCK_MAINNET_USDC, + MOCK_MAINNET_USDT_NO_BALANCE, + ]; + const result = getSupportedEarnTokens(tokens as TokenI[]); + expect(result).toEqual(tokens); + }); + + it('allows supported stablecoins on BASE', () => { + const tokens = [MOCK_ETH_TOKEN, MOCK_BASE_USDC]; + const result = getSupportedEarnTokens(tokens as TokenI[]); + expect(result).toEqual(tokens); + }); + + it('allows supported stablecoins on BSC', () => { + const tokens = [MOCK_ETH_TOKEN, MOCK_BSC_USDC]; + const result = getSupportedEarnTokens(tokens as TokenI[]); + expect(result).toEqual(tokens); + }); + + it('allows supported stablecoins on Sepolia', () => { + const tokens = [MOCK_ETH_TOKEN, MOCK_SEPOLIA_USDC]; + const result = getSupportedEarnTokens(tokens as TokenI[]); + expect(result).toEqual(tokens); + }); + + it('does not filter out tokens that have empty fiatBalance', () => { + const tokens = [MOCK_ETH_TOKEN, MOCK_MAINNET_USDT_NO_BALANCE]; + const result = getSupportedEarnTokens(tokens as TokenI[]); + expect(result).toEqual(tokens); + }); + }); + + describe('filterEligibleTokens', () => { + it('removes staking tokens if canStake is false', () => { + const withoutStakingTokens = + MOCK_SUPPORTED_EARN_TOKENS_NO_FIAT_BALANCE.filter( + (token) => token.symbol !== 'Ethereum', + ); + + const result = filterEligibleTokens( + MOCK_SUPPORTED_EARN_TOKENS_NO_FIAT_BALANCE, + { canStake: false, canLend: true }, + ); + + expect(result).toStrictEqual(withoutStakingTokens); + }); + + it('removes lending tokens if canLend is false', () => { + const withoutLendingTokens = + MOCK_SUPPORTED_EARN_TOKENS_NO_FIAT_BALANCE.filter( + (token) => !SUPPORTED_LENDING_TOKENS.has(token.symbol), + ); + + const result = filterEligibleTokens( + MOCK_SUPPORTED_EARN_TOKENS_NO_FIAT_BALANCE, + { canStake: true, canLend: false }, + ); + + expect(result).toStrictEqual(withoutLendingTokens); + }); + + it('returns empty list if user cannot stake or lend', () => { + const result = filterEligibleTokens( + MOCK_SUPPORTED_EARN_TOKENS_NO_FIAT_BALANCE, + { canStake: false, canLend: false }, + ); + + expect(result).toStrictEqual([]); + }); + }); +}); diff --git a/app/components/Views/WalletActions/WalletActions.test.tsx b/app/components/Views/WalletActions/WalletActions.test.tsx index 9618ce5616f..04034ca5c7f 100644 --- a/app/components/Views/WalletActions/WalletActions.test.tsx +++ b/app/components/Views/WalletActions/WalletActions.test.tsx @@ -15,7 +15,6 @@ import { expectedUuid2, MOCK_ACCOUNTS_CONTROLLER_STATE, } from '../../../util/test/accountsControllerTestUtils'; -import useStakingChain from '../../UI/Stake/hooks/useStakingChain'; import Engine from '../../../core/Engine'; import { isStablecoinLendingFeatureEnabled } from '../../UI/Stake/constants'; @@ -30,12 +29,6 @@ jest.mock('../../../core/Engine', () => ({ }, }, })); -jest.mock('../../../components/UI/Stake/hooks/useStakingChain', () => ({ - __esModule: true, - default: jest.fn().mockReturnValue({ - isStakingSupportedChain: true, - }), -})); const mockInitialState: DeepPartial = { swaps: { '0x1': { isLive: true }, hasOnboarded: false, isLive: true }, @@ -207,6 +200,7 @@ describe('WalletActions', () => { expect(mockNavigate).toHaveBeenCalled(); }); + it('should call the goToSwaps function when the Swap button is pressed', () => { const { getByTestId } = renderWithProvider(, { state: mockInitialState, @@ -218,6 +212,7 @@ describe('WalletActions', () => { expect(mockNavigate).toHaveBeenCalled(); }); + it('should call the goToBridge function when the Bridge button is pressed', () => { const { getByTestId } = renderWithProvider(, { state: mockInitialState, @@ -229,6 +224,7 @@ describe('WalletActions', () => { expect(mockNavigate).toHaveBeenCalled(); }); + it('should call the onEarn function when the Earn button is pressed', () => { (isStablecoinLendingFeatureEnabled as jest.Mock).mockReturnValue(true); const { getByTestId } = renderWithProvider(, { @@ -245,23 +241,8 @@ describe('WalletActions', () => { ).not.toHaveBeenCalled(); }); - it('should switch to mainnet when onEarn called on unsupported staking network', () => { - (isStablecoinLendingFeatureEnabled as jest.Mock).mockReturnValue(true); - (useStakingChain as jest.Mock).mockReturnValue({ - isStakingSupportedChain: false, - }); - const { getByTestId } = renderWithProvider(, { - state: mockInitialState, - }); - - fireEvent.press( - getByTestId(WalletActionsBottomSheetSelectorsIDs.EARN_BUTTON), - ); - expect( - Engine.context.NetworkController.setActiveNetwork, - ).toHaveBeenCalledWith('mainnet'); - }); it('disables action buttons when the account cannot sign transactions', () => { + (isStablecoinLendingFeatureEnabled as jest.Mock).mockReturnValue(true); const mockStateWithoutSigning: DeepPartial = { ...mockInitialState, engine: { diff --git a/app/components/Views/WalletActions/WalletActions.tsx b/app/components/Views/WalletActions/WalletActions.tsx index 54ff78139c4..fa971377f3b 100644 --- a/app/components/Views/WalletActions/WalletActions.tsx +++ b/app/components/Views/WalletActions/WalletActions.tsx @@ -40,9 +40,8 @@ import { } from '../../UI/Ramp/routes/utils'; import { selectCanSignTransactions } from '../../../selectors/accountsController'; import { WalletActionType } from '../../UI/WalletAction/WalletAction.types'; -import Engine from '../../../core/Engine'; -import useStakingChain from '../../UI/Stake/hooks/useStakingChain'; import { isStablecoinLendingFeatureEnabled } from '../../UI/Stake/constants'; +import { EVENT_LOCATIONS as STAKE_EVENT_LOCATIONS } from '../../UI/Stake/constants/events'; const WalletActions = () => { const { styles } = useStyles(styleSheet, {}); @@ -54,7 +53,6 @@ const WalletActions = () => { const ticker = useSelector(selectTicker); const swapsIsLive = useSelector(swapsLivenessSelector); const dispatch = useDispatch(); - const { isStakingSupportedChain } = useStakingChain(); const [isNetworkRampSupported] = useRampNetwork(); const { trackEvent, createEventBuilder } = useMetrics(); @@ -93,18 +91,17 @@ const WalletActions = () => { ]); const onEarn = useCallback(async () => { - if (!isStakingSupportedChain) { - await Engine.context.NetworkController.setActiveNetwork('mainnet'); - } - closeBottomSheetAndNavigate(() => { - navigate('StakeScreens', { screen: Routes.STAKING.STAKE }); + navigate('StakeModals', { + screen: Routes.STAKING.MODALS.EARN_TOKEN_LIST, + }); }); + trackEvent( createEventBuilder(MetaMetricsEvents.EARN_BUTTON_CLICKED) .addProperties({ text: 'Earn', - location: 'TabBar', + location: STAKE_EVENT_LOCATIONS.WALLET_ACTIONS_BOTTOM_SHEET, chain_id_destination: getDecimalChainId(chainId), }) .build(), @@ -115,7 +112,6 @@ const WalletActions = () => { chainId, createEventBuilder, trackEvent, - isStakingSupportedChain, ]); const onBuy = useCallback(() => { @@ -250,18 +246,17 @@ const WalletActions = () => { disabled={!canSignTransactions} /> )} - {AppConstants.SWAPS.ACTIVE && - isSwapsAllowed(chainId) && ( - - )} + {AppConstants.SWAPS.ACTIVE && isSwapsAllowed(chainId) && ( + + )} {isBridgeAllowed(chainId) && (