Skip to content

Commit

Permalink
feat: add EarnTokenSelector component for stablecoin lending (#13595)
Browse files Browse the repository at this point in the history
<!--
Please submit this PR as a draft initially.
Do not mark it as "Ready for review" until the template has been
completely filled out, and PR status checks have passed at least once.
-->

## **Description**

This PR introduces a new `EarnTokenSelector` component to enhance the
Input screen for stablecoin lending. The component provides a more
intuitive way to select and view earning opportunities, displaying token
information, APR, and balance in a unified interface.

This PR also adds the token selector on the Input screen behind the
stablecoin feature flag.

## **Related issues**

Fixes:
[STAKE-899](https://consensyssoftware.atlassian.net/browse/STAKE-899)

## **Manual testing steps**

1. Turn on the stablecoin lending feature flag in .js.env
2. Click on earn button in the wallet actions
3. Select a token 
4. You should be redirected to the input screen with a new token
selector component
5. Select any other token from token selector and you should see the
details for that token

## **Screenshots/Recordings**



https://github.com/user-attachments/assets/f2c85572-8deb-4c66-b24c-4768290d1425



## **Pre-merge author checklist**

- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.


[STAKE-899]:
https://consensyssoftware.atlassian.net/browse/STAKE-899?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
  • Loading branch information
amitabh94 authored Feb 19, 2025
1 parent e3c59ba commit 0a30188
Show file tree
Hide file tree
Showing 9 changed files with 796 additions and 96 deletions.
33 changes: 18 additions & 15 deletions app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { selectSelectedInternalAccount } from '../../../../../selectors/accounts
import { StakeInputViewProps } from './StakeInputView.types';
import { getStakeInputViewTitle } from './utils';
import { isStablecoinLendingFeatureEnabled } from '../../constants';
import EarnTokenSelector from '../../components/EarnTokenSelector';

const StakeInputView = ({ route }: StakeInputViewProps) => {
const navigation = useNavigation();
Expand Down Expand Up @@ -220,26 +221,29 @@ const StakeInputView = ({ route }: StakeInputViewProps) => {
selected_provider: EVENT_PROVIDERS.CONSENSYS,
text: 'Currency Switch Trigger',
location: EVENT_LOCATIONS.STAKE_INPUT_VIEW,
// We want to track the currency switching to. Not the current currency.
currency_type: isEth ? 'fiat' : 'native',
},
})}
currencyToggleValue={currencyToggleValue}
/>
<View style={styles.rewardsRateContainer}>
<EstimatedAnnualRewardsCard
estimatedAnnualRewards={estimatedAnnualRewards}
onIconPress={withMetaMetrics(navigateToLearnMoreModal, {
event: MetaMetricsEvents.TOOLTIP_OPENED,
properties: {
selected_provider: EVENT_PROVIDERS.CONSENSYS,
text: 'Tooltip Opened',
location: EVENT_LOCATIONS.STAKE_INPUT_VIEW,
tooltip_name: 'MetaMask Pool Estimated Rewards',
},
})}
isLoading={isLoadingVaultApyAverages}
/>
{isStablecoinLendingFeatureEnabled() ? (
<EarnTokenSelector token={route?.params?.token} />
) : (
<EstimatedAnnualRewardsCard
estimatedAnnualRewards={estimatedAnnualRewards}
onIconPress={withMetaMetrics(navigateToLearnMoreModal, {
event: MetaMetricsEvents.TOOLTIP_OPENED,
properties: {
selected_provider: EVENT_PROVIDERS.CONSENSYS,
text: 'Tooltip Opened',
location: EVENT_LOCATIONS.STAKE_INPUT_VIEW,
tooltip_name: 'MetaMask Pool Estimated Rewards',
},
})}
isLoading={isLoadingVaultApyAverages}
/>
)}
</View>
<QuickAmounts
amounts={percentageOptions}
Expand All @@ -249,7 +253,6 @@ const StakeInputView = ({ route }: StakeInputViewProps) => {
properties: {
location: EVENT_LOCATIONS.STAKE_INPUT_VIEW,
amount: value,
// onMaxPress is called instead when it's defined and the max is clicked.
is_max: false,
mode: isEth ? 'native' : 'fiat',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jest.mock('@react-navigation/native', () => {
...actualNav,
useNavigation: () => ({
navigate: mockNavigate,
goBack: jest.fn(),
}),
};
});
Expand Down Expand Up @@ -280,6 +281,7 @@ describe('EarnTokenList', () => {
symbol: 'Ethereum',
ticker: 'ETH',
tokenBalanceFormatted: ' ETH',
apr: '2.3',
},
},
screen: 'Stake',
Expand Down Expand Up @@ -321,6 +323,7 @@ describe('EarnTokenList', () => {
symbol: 'USDC',
ticker: 'USDC',
tokenBalanceFormatted: 'tokenBalanceLoading',
apr: '4.5',
},
},
screen: 'Stake',
Expand Down
109 changes: 28 additions & 81 deletions app/components/UI/Stake/components/EarnTokenList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React, { useCallback, useMemo } from 'react';
import BottomSheet from '../../../../../component-library/components/BottomSheets/BottomSheet';
import React, { useMemo, useRef, useCallback } from 'react';
import BottomSheet, {
BottomSheetRef,
} from '../../../../../component-library/components/BottomSheets/BottomSheet';
import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader';
import Text, {
TextColor,
Expand All @@ -17,17 +19,7 @@ import {
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';
Expand All @@ -45,18 +37,11 @@ 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';
import { useEarnTokenDetails } from '../../hooks/useEarnTokenDetails';

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';

Expand Down Expand Up @@ -84,10 +69,10 @@ const EarnTokenListSkeletonPlaceholder = () => (

const EarnTokenList = () => {
const { createEventBuilder, trackEvent } = useMetrics();

const { styles } = useStyles(styleSheet, {});

const { navigate } = useNavigation();
const { getTokenWithBalanceAndApr } = useEarnTokenDetails();
const bottomSheetRef = useRef<BottomSheetRef>(null);

const tokens = useSelector((state: RootState) =>
isPortfolioViewEnabled() ? selectAccountTokensAcrossChains(state) : {},
Expand All @@ -98,54 +83,6 @@ const EarnTokenList = () => {
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 [];

Expand All @@ -162,7 +99,7 @@ const EarnTokenList = () => {
);

const eligibleTokensWithBalances = eligibleTokens?.map((token) =>
getTokenBalance(token),
getTokenWithBalanceAndApr(token),
);

// Tokens with a balance of 0 are placed at the end of the list.
Expand All @@ -172,7 +109,19 @@ const EarnTokenList = () => {

return (fiatBalanceA === 0 ? 1 : 0) - (fiatBalanceB === 0 ? 1 : 0);
});
}, [getTokenBalance, isEligibleToStake, isLoadingStakingEligibility, tokens]);
}, [
getTokenWithBalanceAndApr,
isEligibleToStake,
isLoadingStakingEligibility,
tokens,
]);

const closeBottomSheetAndNavigate = useCallback(
(navigateFunc: () => void) => {
bottomSheetRef.current?.onCloseBottomSheet(navigateFunc);
},
[],
);

const handleRedirectToInputScreen = async (token: TokenI) => {
const { NetworkController } = Engine.context;
Expand All @@ -194,9 +143,11 @@ const EarnTokenList = () => {
? STAKE_INPUT_VIEW_ACTIONS.STAKE
: STAKE_INPUT_VIEW_ACTIONS.LEND;

navigate('StakeScreens', {
screen: Routes.STAKING.STAKE,
params: { token, action },
closeBottomSheetAndNavigate(() => {
navigate('StakeScreens', {
screen: Routes.STAKING.STAKE,
params: { token, action },
});
});

trackEvent(
Expand All @@ -214,7 +165,7 @@ const EarnTokenList = () => {
};

return (
<BottomSheet>
<BottomSheet ref={bottomSheetRef}>
<BottomSheetHeader>
<Text variant={TextVariant.HeadingSM}>
{strings('stake.select_a_token')}
Expand All @@ -240,11 +191,7 @@ const EarnTokenList = () => {
token={token}
onPress={handleRedirectToInputScreen}
primaryText={{
value: `${new BigNumber(
MOCK_STABLECOIN_API_RESPONSE[token.symbol],
).toFixed(1, BigNumber.ROUND_DOWN)}% ${strings(
'stake.apr',
)}`,
value: `${token.apr}% APR`,
color: TextColor.Success,
}}
{...(!isEmptyBalance(token) && {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// app/components/UI/Stake/components/EarnTokenSelector/EarnTokenSelector.styles.ts
import { StyleSheet } from 'react-native';
import { Theme } from '../../../../../util/theme/models';

export default (params: { theme: Theme }) => {
const { theme } = params;

return StyleSheet.create({
container: {
backgroundColor: theme.colors.background.default,
borderRadius: 8,
minHeight: 56,
borderWidth: 1,
borderColor: theme.colors.border.default,
},
startAccessoryContainer: {
marginRight: 8,
flexDirection: 'row',
alignItems: 'center',
},
endAccessoryContainer: {
alignItems: 'flex-end',
},
aprText: {
color: theme.colors.success.default,
marginBottom: 2,
},
tokenText: {
marginLeft: 8,
},
networkAvatar: {
width: 24,
height: 24,
},
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// app/components/UI/Stake/components/EarnTokenSelector/EarnTokenSelector.test.tsx
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import EarnTokenSelector from './';
import renderWithProvider from '../../../../../util/test/renderWithProvider';
import { MOCK_USDC_MAINNET_ASSET } from '../../__mocks__/mockData';
import { backgroundState } from '../../../../../util/test/initial-root-state';
import { TokenI } from '../../../../UI/Tokens/types';

const mockNavigate = jest.fn();

const MOCK_APR_VALUES: { [symbol: string]: string } = {
Ethereum: '2.3',
USDC: '4.5',
USDT: '4.1',
DAI: '5.0',
};

jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
return {
...actualNav,
useNavigation: () => ({
navigate: mockNavigate,
}),
};
});

// Mock the useEarnTokenDetails hook
jest.mock('../../hooks/useEarnTokenDetails', () => ({
useEarnTokenDetails: () => ({
getTokenWithBalanceAndApr: (token: TokenI) => ({
...token,
apr: MOCK_APR_VALUES[token.symbol] || '0.0',
tokenBalanceFormatted: token.symbol === 'USDC' ? '6.84314 USDC' : '0',
balanceFiat: token.symbol === 'USDC' ? '$6.84' : '$0.00',
}),
}),
}));

describe('EarnTokenSelector', () => {
const mockProps = {
token: MOCK_USDC_MAINNET_ASSET,
};

const mockInitialState = {
engine: {
backgroundState,
},
};

beforeEach(() => {
jest.clearAllMocks();
});

it('renders correctly', () => {
const { toJSON } = renderWithProvider(
<EarnTokenSelector {...mockProps} />,
{
state: mockInitialState,
},
);
expect(toJSON()).toMatchSnapshot();
});

it('displays token symbol and APR', () => {
const { getByText } = renderWithProvider(
<EarnTokenSelector {...mockProps} />,
{ state: mockInitialState },
);
expect(getByText('4.5% APR')).toBeDefined();
expect(getByText('6.84314 USDC')).toBeDefined();
});

it('navigates to earn token list when pressed', () => {
const { getByTestId } = renderWithProvider(
<EarnTokenSelector {...mockProps} />,
{ state: mockInitialState },
);
const button = getByTestId('earn-token-selector');
fireEvent.press(button);
expect(mockNavigate).toHaveBeenCalledWith('StakeModals', {
screen: 'EarnTokenList',
});
});
});
Loading

0 comments on commit 0a30188

Please sign in to comment.