diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index bc654c9273dd..97a4a62f0acd 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -938,10 +938,7 @@ const MainNavigator = () => { // Get feature flag state for conditional Perps screen registration const perpsEnabledFlag = useSelector(selectPerpsEnabledFlag); const isEvmSelected = useSelector(selectIsEvmNetworkSelected); - const isPerpsEnabled = useMemo( - () => perpsEnabledFlag && isEvmSelected, - [perpsEnabledFlag, isEvmSelected], - ); + const isPerpsEnabled = useMemo(() => perpsEnabledFlag, [perpsEnabledFlag]); // Get feature flag state for conditional Predict screen registration const predictEnabledFlag = useSelector(selectPredictEnabledFlag); const isPredictEnabled = useMemo( diff --git a/app/components/UI/AssetOverview/utils/createStakedTrxAsset.ts b/app/components/UI/AssetOverview/utils/createStakedTrxAsset.ts index e29276839d8d..c0cd5134da3a 100644 --- a/app/components/UI/AssetOverview/utils/createStakedTrxAsset.ts +++ b/app/components/UI/AssetOverview/utils/createStakedTrxAsset.ts @@ -14,6 +14,7 @@ export function createStakedTrxAsset( ...base, name: 'Staked TRX', symbol: 'sTRX', + ticker: 'sTRX', isStaked: true, balance: formatWithThreshold(sum, minimumDisplayThreshold, I18n.locale, { minimumFractionDigits: 0, diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx index 1a0afe7ae447..471a97da43a0 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx @@ -61,6 +61,7 @@ import { getIsRedesignedStablecoinLendingScreenEnabled } from './utils'; import { selectConversionRate } from '../../../../../selectors/currencyRateController'; import { trace, TraceName } from '../../../../../util/trace'; import { MAINNET_DISPLAY_NAME } from '../../../../../core/Engine/constants'; +import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled'; jest.mock('./utils'); @@ -172,6 +173,13 @@ jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({ jest.mock('../../../../../selectors/featureFlagController/confirmations'); +jest.mock( + '../../../../../selectors/featureFlagController/trxStakingEnabled', + () => ({ + selectTrxStakingEnabled: jest.fn(() => false), + }), +); + jest.mock('../../../../../util/trace', () => ({ ...jest.requireActual('../../../../../util/trace'), trace: jest.fn(), @@ -388,6 +396,8 @@ describe('EarnInputView', () => { selectStablecoinLendingEnabledFlagMock.mockReturnValue(false); + (selectTrxStakingEnabled as unknown as jest.Mock).mockReturnValue(false); + (useEarnTokens as jest.Mock).mockReturnValue({ getEarnToken: jest.fn(() => ({ ...MOCK_ETH_MAINNET_ASSET, @@ -568,6 +578,39 @@ describe('EarnInputView', () => { }); }); + describe('TRON staking flow', () => { + it('constructs TRX earnToken and shows the ResourceToggle when staking enabled', () => { + (selectTrxStakingEnabled as unknown as jest.Mock).mockReturnValue(true); + + (useEarnTokens as jest.Mock).mockReturnValue({ + getEarnToken: jest.fn(() => undefined), + getOutputToken: jest.fn(() => undefined), + }); + + const TRX_TOKEN = { + name: 'TRON', + symbol: 'TRX', + ticker: 'TRX', + chainId: 'tron:main', + address: 'T1111111111111111111111111111111111', + balance: '0', + balanceFiat: '$0', + isETH: false, + } as unknown as typeof MOCK_ETH_MAINNET_ASSET; + + const { getByTestId } = render(EarnInputView, { + params: { + token: TRX_TOKEN, + }, + key: Routes.STAKING.STAKE, + name: 'params', + }); + + expect(getByTestId('resource-toggle-energy')).toBeTruthy(); + expect(getByTestId('resource-toggle-bandwidth')).toBeTruthy(); + }); + }); + describe('when values are entered in the keypad', () => { it('updates ETH and fiat values', async () => { const { toJSON, getByText } = renderComponent(); diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx index 6d0e16a8c88c..7bdc2c3eaad8 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx @@ -126,7 +126,44 @@ const EarnInputView = () => { const { trackEvent, createEventBuilder } = useMetrics(); const { attemptDepositTransaction } = usePoolStakedDeposit(); const { getEarnToken } = useEarnTokens(); - const earnToken = getEarnToken(token); + + ///: BEGIN:ONLY_INCLUDE_IF(tron) + const [resourceType, setResourceType] = useState('energy'); + const isTronNative = + token.ticker === 'TRX' && String(token.chainId).startsWith('tron:'); + ///: END:ONLY_INCLUDE_IF + + const earnTokenFromMap = getEarnToken(token); + + const earnToken = React.useMemo(() => { + if (earnTokenFromMap) return earnTokenFromMap; + + ///: BEGIN:ONLY_INCLUDE_IF(tron) + if (isTrxStakingEnabled && isTronNative) { + const experiences = [{ type: EARN_EXPERIENCES.POOLED_STAKING, apr: '0' }]; + return { + ...token, + isETH: false, + balanceMinimalUnit: '0', + balanceFormatted: token.balance ?? '0', + balanceFiat: token.balanceFiat ?? '0', + tokenUsdExchangeRate: 0, + experiences, + experience: experiences[0], + } as EarnTokenDetails; + } + ///: END:ONLY_INCLUDE_IF + + return undefined; + }, [ + earnTokenFromMap, + ///: BEGIN:ONLY_INCLUDE_IF(tron) + isTrxStakingEnabled, + isTronNative, + token, + ///: END:ONLY_INCLUDE_IF + ]); + const networkClientId = useSelector(selectNetworkClientId); const { isFiat, @@ -159,12 +196,6 @@ const EarnInputView = () => { exchangeRate, }); - ///: BEGIN:ONLY_INCLUDE_IF(tron) - const [resourceType, setResourceType] = useState('energy'); - const isTronNative = - token.ticker === 'TRX' && String(token.chainId).startsWith('tron:'); - ///: END:ONLY_INCLUDE_IF - const { shouldLogStablecoinEvent, shouldLogStakingEvent } = useEarnAnalyticsEventLogging({ earnToken, diff --git a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap index 8c0f524f1194..9f78bf56e268 100644 --- a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap +++ b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap @@ -1949,7 +1949,7 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia "flexGrow": 1, } } - handlerTag={6} + handlerTag={8} handlerType="NativeViewGestureHandler" onGestureHandlerEvent={[Function]} onGestureHandlerStateChange={[Function]} diff --git a/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.test.tsx b/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.test.tsx index c68b1da75bf2..e33598e9744a 100644 --- a/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.test.tsx +++ b/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.test.tsx @@ -174,6 +174,13 @@ jest.mock('../../../Stake/hooks/usePoolStakedUnstake', () => ({ jest.mock('../../../../../selectors/featureFlagController/confirmations'); +jest.mock( + '../../../../../selectors/featureFlagController/trxStakingEnabled', + () => ({ + selectTrxStakingEnabled: jest.fn(() => true), + }), +); + jest.mock('../../selectors/featureFlags', () => ({ selectStablecoinLendingEnabledFlag: jest.fn().mockReturnValue(false), selectPooledStakingEnabledFlag: jest.fn().mockReturnValue(true), @@ -754,6 +761,32 @@ describe('EarnWithdrawInputView', () => { }); }); + describe('TRON unstake flow', () => { + it('renders Unstake label when TRX staking is enabled and TRON asset is used', async () => { + const tronToken: TokenI = { + name: 'Tron', + symbol: 'TRX', + ticker: 'TRX', + chainId: 'tron:728126428', + address: 'tron:728126428/slip44:195', + decimals: 6, + balance: '1000', + balanceFiat: '$100', + isNative: true, + } as unknown as TokenI; + + render(EarnWithdrawInputView, tronToken); + + await act(async () => { + fireEvent.press(screen.getByText('1')); + }); + + await waitFor(() => { + expect(screen.getByText('Unstake')).toBeTruthy(); + }); + }); + }); + describe('Analytics', () => { it('tracks EARN_INPUT_OPENED on render for stablecoin lending withdrawal', () => { ( diff --git a/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx b/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx index 8de77a376001..8f303a3f2521 100644 --- a/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx +++ b/app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx @@ -61,17 +61,74 @@ import { ScrollView } from 'react-native-gesture-handler'; import { trace, TraceName } from '../../../../../util/trace'; import useEndTraceOnMount from '../../../../hooks/useEndTraceOnMount'; import { EVM_SCOPE } from '../../constants/networks'; +///: BEGIN:ONLY_INCLUDE_IF(tron) +import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled'; +///: END:ONLY_INCLUDE_IF const EarnWithdrawInputView = () => { const route = useRoute(); const { token } = route.params; + ///: BEGIN:ONLY_INCLUDE_IF(tron) + const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled); + ///: END:ONLY_INCLUDE_IF const isStablecoinLendingEnabled = useSelector( selectStablecoinLendingEnabledFlag, ); - const { getPairedEarnTokens } = useEarnTokens(); + const { getPairedEarnTokens, getEarnToken } = useEarnTokens(); const { outputToken: receiptToken } = getPairedEarnTokens(token); + const earnTokenFromMap = getEarnToken(token); + + ///: BEGIN:ONLY_INCLUDE_IF(tron) + const isTronAsset = token.chainId?.startsWith('tron:'); + ///: END:ONLY_INCLUDE_IF + + const earnToken = React.useMemo(() => { + if (earnTokenFromMap) return earnTokenFromMap; + + ///: BEGIN:ONLY_INCLUDE_IF(tron) + if (isTrxStakingEnabled && isTronAsset) { + const experiences = [{ type: EARN_EXPERIENCES.POOLED_STAKING, apr: '0' }]; + return { + ...token, + isETH: false, + balanceMinimalUnit: '0', + balanceFormatted: token.balance ?? '0', + balanceFiat: token.balanceFiat ?? '0', + tokenUsdExchangeRate: 0, + experiences, + experience: experiences[0], + } as EarnTokenDetails; + } + ///: END:ONLY_INCLUDE_IF + return undefined; + }, [ + earnTokenFromMap, + ///: BEGIN:ONLY_INCLUDE_IF(tron) + isTrxStakingEnabled, + isTronAsset, + token, + ///: END:ONLY_INCLUDE_IF + ]); + + const receiptTokenToUse: EarnTokenDetails | undefined = + receiptToken as EarnTokenDetails; + + const withdrawalToken: EarnTokenDetails | undefined = useMemo(() => { + if ( + receiptTokenToUse?.experience?.type === + EARN_EXPERIENCES.STABLECOIN_LENDING || + receiptTokenToUse?.experience?.type === EARN_EXPERIENCES.POOLED_STAKING + ) { + return receiptTokenToUse; + } + if (earnToken) { + return earnToken as EarnTokenDetails; + } + return undefined; + }, [receiptTokenToUse, earnToken]); + const navigation = useNavigation>(); const { styles, theme } = useStyles(styleSheet, {}); @@ -121,21 +178,20 @@ const EarnWithdrawInputView = () => { handleKeypadChange, earnBalanceValue, } = useEarnWithdrawInput({ - earnToken: receiptToken as EarnTokenDetails, + earnToken: withdrawalToken as EarnTokenDetails, conversionRate, exchangeRate, }); - useEffect(() => { trackEvent( createEventBuilder(MetaMetricsEvents.EARN_INPUT_OPENED) .addProperties({ action_type: 'withdrawal', - token: receiptToken?.symbol, - token_name: receiptToken?.name, + token: withdrawalToken?.symbol, + token_name: withdrawalToken?.name, network: network?.name, - user_token_balance: receiptToken?.balanceFormatted, - experience: receiptToken?.experience?.type, + user_token_balance: withdrawalToken?.balanceFormatted, + experience: withdrawalToken?.experience?.type, }) .build(), ); @@ -154,10 +210,11 @@ const EarnWithdrawInputView = () => { // For lending withdrawals, fetch AAVE pool metadata once on render. useEffect(() => { if ( - receiptToken?.experience?.type !== EARN_EXPERIENCES.STABLECOIN_LENDING || + withdrawalToken?.experience?.type !== + EARN_EXPERIENCES.STABLECOIN_LENDING || !selectedAccount?.address || - !receiptToken?.address || - !receiptToken?.chainId + !withdrawalToken?.address || + !withdrawalToken?.chainId ) return; setMaxRiskAwareWithdrawalAmount(undefined); @@ -166,7 +223,7 @@ const EarnWithdrawInputView = () => { getAaveV3MaxRiskAwareWithdrawalAmount( selectedAccount.address, - receiptToken as EarnTokenDetails, + withdrawalToken, ) .then((maxAmount) => { setMaxRiskAwareWithdrawalAmount(maxAmount); @@ -177,10 +234,10 @@ const EarnWithdrawInputView = () => { // Call once on render and only once // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - receiptToken?.experience?.type, + withdrawalToken?.experience?.type, selectedAccount?.address, - receiptToken?.address, - receiptToken?.chainId, + withdrawalToken?.address, + withdrawalToken?.chainId, ]); const stakedBalanceText = strings('stake.staked_balance'); @@ -337,12 +394,12 @@ const EarnWithdrawInputView = () => { createEventBuilder(MetaMetricsEvents.EARN_REVIEW_BUTTON_CLICKED) .addProperties({ action_type: 'withdrawal', - token: receiptToken?.symbol, + token: withdrawalToken?.symbol, network: network?.name, - user_token_balance: receiptToken?.balanceFormatted, - transaction_value: `${amountToken} ${receiptToken?.symbol}`, + user_token_balance: withdrawalToken?.balanceFormatted, + transaction_value: `${amountToken} ${withdrawalToken?.symbol}`, lastQuickAmountButtonPressed: lastQuickAmountButtonPressed.current, - experience: receiptToken?.experience?.type, + experience: withdrawalToken?.experience?.type, }) .build(), ); @@ -352,9 +409,9 @@ const EarnWithdrawInputView = () => { // We likely want to inform the user if this data is missing and the withdrawal fails. if ( !selectedAccount?.address || - !receiptToken?.experience?.market?.underlying.address || - !receiptToken?.address || - !receiptToken?.chainId + !withdrawalToken?.experience?.market?.underlying.address || + !withdrawalToken?.address || + !withdrawalToken?.chainId ) return; @@ -362,7 +419,7 @@ const EarnWithdrawInputView = () => { await calculateAaveV3HealthFactorAfterWithdrawal( selectedAccount.address, amountTokenMinimalUnit.toString(), - receiptToken as EarnTokenDetails, + withdrawalToken, ); setIsSubmittingStakeWithdrawalTransaction(true); @@ -371,15 +428,17 @@ const EarnWithdrawInputView = () => { try { const lendingPoolContractAddress = - CHAIN_ID_TO_AAVE_V3_POOL_CONTRACT_ADDRESS[receiptToken.chainId] ?? ''; + CHAIN_ID_TO_AAVE_V3_POOL_CONTRACT_ADDRESS[ + withdrawalToken?.chainId as Hex + ] ?? ''; navigation.navigate(Routes.EARN.ROOT, { screen: Routes.EARN.LENDING_WITHDRAWAL_CONFIRMATION, params: { - token: receiptToken, + token: withdrawalToken, amountTokenMinimalUnit: amountToWithdraw, amountFiat: amountFiatNumber, - lendingProtocol: receiptToken.experience?.market?.protocol, + lendingProtocol: withdrawalToken?.experience?.market?.protocol, lendingContractAddress: lendingPoolContractAddress, healthFactorSimulation: simulatedHealthFactorAfterWithdrawal, }, @@ -396,7 +455,7 @@ const EarnWithdrawInputView = () => { createEventBuilder, navigation, network?.name, - receiptToken, + withdrawalToken, trackEvent, ]); @@ -486,16 +545,16 @@ const EarnWithdrawInputView = () => { // should we be able to, consider the implications of not being able to const handleWithdrawPress = useCallback(async () => { if ( - receiptToken?.experience?.type === EARN_EXPERIENCES.STABLECOIN_LENDING + withdrawalToken?.experience?.type === EARN_EXPERIENCES.STABLECOIN_LENDING ) { return handleLendingWithdrawalFlow(); } - if (receiptToken?.experience?.type === EARN_EXPERIENCES.POOLED_STAKING) { + if (withdrawalToken?.experience?.type === EARN_EXPERIENCES.POOLED_STAKING) { return handleUnstakeWithdrawalFlow(); } }, [ - receiptToken?.experience?.type, + withdrawalToken?.experience?.type, handleLendingWithdrawalFlow, handleUnstakeWithdrawalFlow, ]); @@ -510,7 +569,7 @@ const EarnWithdrawInputView = () => { if (isLoadingMaxSafeWithdrawalAmount) return; // We don't want to display the max safe withdrawal text if it isn't applicable. - if (maxRiskAwareWithdrawalAmount === receiptToken?.balanceMinimalUnit) + if (maxRiskAwareWithdrawalAmount === withdrawalToken?.balanceMinimalUnit) return; if (!maxRiskAwareWithdrawalAmount) { @@ -519,19 +578,19 @@ const EarnWithdrawInputView = () => { return renderFromTokenMinimalUnit( maxRiskAwareWithdrawalAmount, - receiptToken?.decimals as number, + withdrawalToken?.decimals as number, ); }, [ isLoadingMaxSafeWithdrawalAmount, maxRiskAwareWithdrawalAmount, - receiptToken?.balanceMinimalUnit, - receiptToken?.decimals, + withdrawalToken?.balanceMinimalUnit, + withdrawalToken?.decimals, ]); const isWithdrawingMoreThanAvailableForLendingToken = useMemo(() => { // This check only applies to lending experience. if ( - receiptToken?.experience?.type !== EARN_EXPERIENCES.STABLECOIN_LENDING + withdrawalToken?.experience?.type !== EARN_EXPERIENCES.STABLECOIN_LENDING ) { return false; } @@ -545,7 +604,7 @@ const EarnWithdrawInputView = () => { ); }, [ amountTokenMinimalUnit, - receiptToken?.experience?.type, + withdrawalToken?.experience?.type, maxRiskAwareWithdrawalAmount, ]); @@ -555,7 +614,7 @@ const EarnWithdrawInputView = () => { } if (isOverMaximum.isOverMaximumToken) { return strings('stake.not_enough_token', { - ticker: receiptToken?.ticker ?? receiptToken?.symbol ?? '', + ticker: withdrawalToken?.ticker ?? withdrawalToken?.symbol ?? '', }); } if (isOverMaximum.isOverMaximumEth) { @@ -572,8 +631,8 @@ const EarnWithdrawInputView = () => { isOverMaximum.isOverMaximumToken, isOverMaximum.isOverMaximumEth, isWithdrawingMoreThanAvailableForLendingToken, - receiptToken?.ticker, - receiptToken?.symbol, + withdrawalToken?.ticker, + withdrawalToken?.symbol, ]); const handleCurrencySwitchWithTracking = useCallback(() => { @@ -715,7 +774,7 @@ const EarnWithdrawInputView = () => { diff --git a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx index fa4fbb2390f9..7cf39ad6dfbc 100644 --- a/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx +++ b/app/components/UI/Earn/components/EarnBalance/EarnBalance.test.tsx @@ -4,6 +4,10 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider'; import StakingBalance from '../../../Stake/components/StakingBalance/StakingBalance'; import { TokenI } from '../../../Tokens/types'; import EarnLendingBalance from '../EarnLendingBalance'; +import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled'; +import { selectTronResourcesBySelectedAccountGroup } from '../../../../../selectors/assets/assets-list'; +import TronStakingButtons from '../Tron/TronStakingButtons'; +import TronStakingCta from '../Tron/TronStakingButtons/TronStakingCta'; /** * We mock underlying components because we only care about the conditional rendering. @@ -14,6 +18,28 @@ jest.mock('../../../Stake/components/StakingBalance/StakingBalance', () => ({ default: jest.fn(), })); +jest.mock( + '../../../../../selectors/featureFlagController/trxStakingEnabled', + () => ({ + selectTrxStakingEnabled: jest.fn(), + }), +); + +jest.mock('../../../../../selectors/assets/assets-list', () => ({ + ...jest.requireActual('../../../../../selectors/assets/assets-list'), + selectTronResourcesBySelectedAccountGroup: jest.fn(), +})); + +jest.mock('../Tron/TronStakingButtons', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +jest.mock('../Tron/TronStakingButtons/TronStakingCta', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + jest.mock('../../../../../selectors/earnController', () => ({ ...jest.requireActual('../../../../../selectors/earnController'), earnSelectors: { @@ -66,6 +92,10 @@ jest.mock('../EarnLendingBalance', () => ({ describe('EarnBalance', () => { beforeEach(() => { jest.clearAllMocks(); + (jest.mocked(selectTrxStakingEnabled) as jest.Mock).mockReturnValue(false); + ( + jest.mocked(selectTronResourcesBySelectedAccountGroup) as jest.Mock + ).mockReturnValue([]); }); describe('Ethereum Mainnet', () => { @@ -159,4 +189,54 @@ describe('EarnBalance', () => { expect(EarnLendingBalance).not.toHaveBeenCalled(); }); }); + + describe('TRON', () => { + const mockFlag = selectTrxStakingEnabled as unknown as jest.Mock; + const mockTronResources = + selectTronResourcesBySelectedAccountGroup as unknown as jest.Mock; + + it('renders TRON CTA and stake button for TRX without staked positions', () => { + const trx: Partial = { + chainId: 'tron:728126428', + ticker: 'TRX', + symbol: 'TRX', + }; + + mockFlag.mockReturnValue(true); + mockTronResources.mockReturnValue([]); + + renderWithProvider(); + + expect(TronStakingCta).toHaveBeenCalled(); + expect(TronStakingButtons).toHaveBeenCalled(); + const props = (TronStakingButtons as jest.Mock).mock.calls[0][0]; + expect(props.asset).toBe(trx); + expect(props.showUnstake).toBeUndefined(); + expect(props.hasStakedPositions).toBeUndefined(); + }); + + it('renders TRON stake more and unstake for sTRX with staked positions', () => { + const strx: Partial = { + chainId: 'tron:728126428', + ticker: 'sTRX', + symbol: 'sTRX', + isStaked: true, + }; + + mockFlag.mockReturnValue(true); + mockTronResources.mockReturnValue([ + { symbol: 'strx-energy', balance: '1' }, + { symbol: 'strx-bandwidth', balance: '2' }, + ]); + + renderWithProvider(); + + expect(TronStakingCta).not.toHaveBeenCalled(); + expect(TronStakingButtons).toHaveBeenCalled(); + const props = (TronStakingButtons as jest.Mock).mock.calls[0][0]; + expect(props.asset).toBe(strx); + expect(props.showUnstake).toBe(true); + expect(props.hasStakedPositions).toBe(true); + }); + }); }); diff --git a/app/components/UI/Earn/components/EarnBalance/index.tsx b/app/components/UI/Earn/components/EarnBalance/index.tsx index 8f2945643420..5b20b7f356b5 100644 --- a/app/components/UI/Earn/components/EarnBalance/index.tsx +++ b/app/components/UI/Earn/components/EarnBalance/index.tsx @@ -5,7 +5,14 @@ import { earnSelectors } from '../../../../../selectors/earnController'; import StakingBalance from '../../../Stake/components/StakingBalance/StakingBalance'; import { TokenI } from '../../../Tokens/types'; import EarnLendingBalance from '../EarnLendingBalance'; - +import { selectIsStakeableToken } from '../../../Stake/selectors/stakeableTokens'; +///: BEGIN:ONLY_INCLUDE_IF(tron) +import TronStakingButtons from '../Tron/TronStakingButtons'; +import TronStakingCta from '../Tron/TronStakingButtons/TronStakingCta'; +import { selectTronResourcesBySelectedAccountGroup } from '../../../../../selectors/assets/assets-list'; +import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled'; +import { hasStakedTrxPositions as hasStakedTrxPositionsUtil } from '../../utils/tron'; +///: END:ONLY_INCLUDE_IF export interface EarnBalanceProps { asset: TokenI; } @@ -18,8 +25,47 @@ const EarnBalance = ({ asset }: EarnBalanceProps) => { const isReceiptToken = useSelector((state: RootState) => earnSelectors.selectEarnOutputToken(state, asset), ); + const isStakeableToken = useSelector((state: RootState) => + selectIsStakeableToken(state, asset), + ); + + ///: BEGIN:ONLY_INCLUDE_IF(tron) + const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled); + + const isTron = asset?.chainId?.startsWith('tron:'); + const isStakedTrxAsset = + isTron && (asset?.ticker === 'sTRX' || asset?.symbol === 'sTRX'); + + const tronResources = useSelector(selectTronResourcesBySelectedAccountGroup); + const hasStakedTrxPositions = React.useMemo( + () => hasStakedTrxPositionsUtil(tronResources), + [tronResources], + ); + + if (isTron && isTrxStakingEnabled) { + if (hasStakedTrxPositions && isStakedTrxAsset) { + // sTRX row: show Unstake + Stake more + return ( + + ); + } + + if (!hasStakedTrxPositions && !isStakedTrxAsset) { + // TRX native row: show CTA + single Stake button + return ( + <> + + + + ); + } + + return null; + } + ///: END:ONLY_INCLUDE_IF - if (asset?.isETH && !asset.isStaked) { + // EVM staking: only when stakeable and not a staked output token + if (isStakeableToken && !asset.isStaked) { return ; } diff --git a/app/components/UI/Earn/components/EarnTokenSelector/index.tsx b/app/components/UI/Earn/components/EarnTokenSelector/index.tsx index 3eac00424bd5..c75254142935 100644 --- a/app/components/UI/Earn/components/EarnTokenSelector/index.tsx +++ b/app/components/UI/Earn/components/EarnTokenSelector/index.tsx @@ -45,8 +45,19 @@ const EarnTokenSelector = ({ const { getEarnToken, getOutputToken } = useEarnTokens(); const earnToken = getEarnToken(someEarnToken); const outputToken = getOutputToken(someEarnToken); - const token = (earnToken || outputToken) as EarnTokenDetails; - const apr = parseFloat(token?.experience?.apr ?? '0').toFixed(1); + + const tokenToRender = (earnToken || outputToken || someEarnToken) as + | EarnTokenDetails + | TokenI + | undefined; + + const rawApr = + (tokenToRender as EarnTokenDetails | undefined)?.experience?.apr ?? ''; + + const aprValue = parseFloat(rawApr); + + const apr = + Number.isFinite(aprValue) && aprValue > 0 ? aprValue.toFixed(1) : undefined; const handlePress = () => { trace({ name: TraceName.EarnTokenList }); @@ -69,14 +80,14 @@ const EarnTokenSelector = ({ }; const renderTokenAvatar = () => { - if (token.isNative) { + if (tokenToRender?.isNative) { return ( ); @@ -84,8 +95,8 @@ const EarnTokenSelector = ({ return ( ); @@ -99,8 +110,11 @@ const EarnTokenSelector = ({ } > @@ -111,22 +125,25 @@ const EarnTokenSelector = ({ style={styles.tokenText} numberOfLines={1} > - {token.name} + {tokenToRender?.name ?? ''} ); const renderEndAccessory = () => ( - - {`${apr}% APR`} - + {apr ? ( + + {`${apr}% APR`} + + ) : null} - {token?.balanceFormatted !== undefined && ( + {(tokenToRender as EarnTokenDetails | undefined)?.balanceFormatted !== + undefined && ( - {token?.balanceFormatted} + {(tokenToRender as EarnTokenDetails | undefined)?.balanceFormatted} )} diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.styles.ts b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.styles.ts new file mode 100644 index 000000000000..344826167185 --- /dev/null +++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.styles.ts @@ -0,0 +1,18 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + balanceButtonsContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 8, + }, + balanceActionButton: { + flex: 1, + }, + topMargin: { + marginTop: 16, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx new file mode 100644 index 000000000000..d5c3a4c07f3d --- /dev/null +++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.test.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import TronStakingButtons from './TronStakingButtons'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { TokenI } from '../../../../Tokens/types'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('../../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: { + balanceButtonsContainer: {}, + balanceActionButton: {}, + }, + }), +})); + +const mockTrackEvent = jest.fn(); +const mockBuilderAddProps = jest.fn().mockReturnThis(); +const mockBuilderBuild = jest.fn().mockReturnValue({}); + +jest.mock('../../../../../hooks/useMetrics', () => ({ + MetaMetricsEvents: { + STAKE_BUTTON_CLICKED: 'STAKE_BUTTON_CLICKED', + }, + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: () => ({ + addProperties: mockBuilderAddProps, + build: mockBuilderBuild, + }), + }), +})); + +jest.mock('../../../../../../util/trace', () => ({ + trace: jest.fn(), + TraceName: { + EarnDepositScreen: 'EarnDepositScreen', + EarnWithdrawScreen: 'EarnWithdrawScreen', + }, +})); + +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +describe('TronStakingButtons', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const baseAsset = { + address: '0xtron', + chainId: 'tron:111', + symbol: 'TRX', + ticker: 'TRX', + name: 'Tron', + isStaked: false, + } as TokenI; + + it('navigates to stake screen with base asset TRX when not staked', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByText('stake.stake')).toBeTruthy(); + + fireEvent.press(getByTestId('stake-more-button')); + + expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { + screen: Routes.STAKING.STAKE, + params: { token: baseAsset }, + }); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('navigates to stake with synthesized TRX when asset is staked TRX without nativeAsset', () => { + const stakedTrx = { + ...baseAsset, + symbol: 'sTRX', + ticker: 'sTRX', + isStaked: true, + nativeAsset: undefined, + } as TokenI; + + const { getByTestId } = render( + , + ); + fireEvent.press(getByTestId('stake-more-button')); + + expect(mockNavigate).toHaveBeenCalled(); + const call = mockNavigate.mock.calls.find((c) => c[0] === 'StakeScreens'); + expect(call?.[1]?.screen).toBe(Routes.STAKING.STAKE); + const tokenArg = call?.[1]?.params?.token; + expect(tokenArg.symbol).toBe('TRX'); + expect(tokenArg.ticker).toBe('TRX'); + expect(tokenArg.isStaked).toBe(false); + }); + + it('shows Unstake button when showUnstake is true and navigates on press', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('unstake-button')); + + expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { + screen: Routes.STAKING.UNSTAKE, + params: { token: baseAsset }, + }); + }); +}); diff --git a/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx new file mode 100644 index 000000000000..7551daf32fe9 --- /dev/null +++ b/app/components/UI/Earn/components/Tron/TronStakingButtons/TronStakingButtons.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { View, ViewProps } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import Button, { + ButtonVariants, +} from '../../../../../../component-library/components/Buttons/Button'; +import { useStyles } from '../../../../../../component-library/hooks'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { TokenI } from '../../../../Tokens/types'; +import styleSheet from './TronStakingButtons.styles'; +import { strings } from '../../../../../../../locales/i18n'; +import { MetaMetricsEvents, useMetrics } from '../../../../../hooks/useMetrics'; +import { EVENT_LOCATIONS } from '../../../../../UI/Stake/constants/events'; +import { trace, TraceName } from '../../../../../../util/trace'; + +interface TronStakingButtonsProps extends Pick { + asset: TokenI; + showUnstake?: boolean; + hasStakedPositions?: boolean; +} + +const TronStakingButtons = ({ + asset, + showUnstake = false, + hasStakedPositions = false, +}: TronStakingButtonsProps) => { + const { styles } = useStyles(styleSheet, {}); + const navigation = useNavigation(); + const { trackEvent, createEventBuilder } = useMetrics(); + + const isStakedTrx = + asset?.isStaked || asset?.symbol === 'sTRX' || asset?.ticker === 'sTRX'; + + const baseAssetForStake = React.useMemo( + () => + !isStakedTrx + ? asset + : // we prefer nativeAsset if present; otherwise synthesize TRX view + (asset.nativeAsset ?? { + ...asset, + name: 'Tron', + symbol: 'TRX', + ticker: 'TRX', + isStaked: false, + }), + [asset, isStakedTrx], + ); + + const onStakePress = () => { + trace({ name: TraceName.EarnDepositScreen }); + navigation.navigate('StakeScreens', { + screen: Routes.STAKING.STAKE, + params: { token: baseAssetForStake }, + }); + trackEvent( + createEventBuilder(MetaMetricsEvents.STAKE_BUTTON_CLICKED) + .addProperties({ + location: EVENT_LOCATIONS.HOME_SCREEN, + text: 'Stake', + token: asset.symbol, + }) + .build(), + ); + }; + + const onUnstakePress = () => { + trace({ name: TraceName.EarnWithdrawScreen }); + navigation.navigate('StakeScreens', { + screen: Routes.STAKING.UNSTAKE, + params: { token: asset }, + }); + }; + + return ( + + {showUnstake ? ( +