diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx index ca9dde719c2..d9b9c8146d1 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx @@ -46,6 +46,7 @@ import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { Hex } from '@metamask/utils'; import { MetaMetricsEvents } from '../../../../../core/Analytics/MetaMetrics.events'; +import { isPayWithBottomSheetEnabled } from '../../../../Views/confirmations/utils/transaction-pay'; const tokenIconStyles = StyleSheet.create({ iconSmall: { @@ -122,6 +123,10 @@ export const PerpsPayRow = ({ [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: PERPS_EVENT_VALUE.INTERACTION_TYPE.PAYMENT_TOKEN_SELECTOR, }); + if (isPayWithBottomSheetEnabled()) { + navigation.navigate(Routes.CONFIRMATION_PAY_WITH_BOTTOM_SHEET); + return; + } navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); }, [canEdit, navigation, setConfirmationMetric, track]); diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts index 33613b35b3e..f856a682d11 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts @@ -16,6 +16,7 @@ import { useNavigation } from '@react-navigation/native'; import useApprovalRequest from '../../../Views/confirmations/hooks/useApprovalRequest'; import { selectPerpsAccountState } from '../selectors/perpsController'; import { selectPerpsPayWithAnyTokenAllowlistAssets } from '../selectors/featureFlags'; +import { isPayWithBottomSheetEnabled } from '../../../Views/confirmations/utils/transaction-pay'; jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => key), @@ -42,6 +43,10 @@ jest.mock('images/perps-pay-token-icon.png', () => ({ uri: 'perps-pay-token-icon-uri', })); +jest.mock('../../../Views/confirmations/utils/transaction-pay', () => ({ + ...jest.requireActual('../../../Views/confirmations/utils/transaction-pay'), + isPayWithBottomSheetEnabled: jest.fn(() => false), +})); jest.mock('../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter', () => jest.fn( () => (value: { toNumber: () => number }) => @@ -70,6 +75,10 @@ const mockUseNavigation = useNavigation as jest.MockedFunction< const mockUseApprovalRequest = useApprovalRequest as jest.MockedFunction< typeof useApprovalRequest >; +const mockIsPayWithBottomSheetEnabled = + isPayWithBottomSheetEnabled as jest.MockedFunction< + typeof isPayWithBottomSheetEnabled + >; describe('usePerpsBalanceTokenFilter', () => { const chainId = '0xa4b1'; @@ -80,6 +89,7 @@ describe('usePerpsBalanceTokenFilter', () => { beforeEach(() => { jest.clearAllMocks(); + mockIsPayWithBottomSheetEnabled.mockReturnValue(false); mockUseTransactionMetadataRequest.mockReturnValue(undefined); mockUseIsPerpsBalanceSelected.mockReturnValue(false); mockUseSelector.mockImplementation( @@ -401,5 +411,62 @@ describe('usePerpsBalanceTokenFilter', () => { expect(mockOnPerpsPaymentTokenChange).toHaveBeenCalledWith(null); } }); + + it('omits the synthetic perps balance row when the new Pay With bottom sheet is enabled', () => { + mockIsPayWithBottomSheetEnabled.mockReturnValue(true); + const inputTokens: AssetType[] = [ + { + address: '0xabc', + chainId, + symbol: 'USDC', + name: 'USD Coin', + balance: '100', + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toHaveLength(1); + expect(isHighlightedItemOutsideAssetList(output[0])).toBe(false); + expect((output[0] as AssetType).address).toBe('0xabc'); + }); + + it('still applies the allowlist filter when the new Pay With bottom sheet is enabled', () => { + mockIsPayWithBottomSheetEnabled.mockReturnValue(true); + const allowlistKey = `${chainId}.0xusdc`.toLowerCase(); + mockUseSelector.mockImplementation( + (selector: (state: unknown) => unknown) => { + if (selector === selectPerpsAccountState) + return { spendableBalance: '100.00' }; + if (selector === selectPerpsPayWithAnyTokenAllowlistAssets) + return [allowlistKey]; + return []; + }, + ); + const inputTokens: AssetType[] = [ + { + address: '0xusdc', + chainId, + symbol: 'USDC', + name: 'USD Coin', + balance: '500', + } as AssetType, + { + address: '0xother', + chainId, + symbol: 'OTHER', + name: 'Other', + balance: '100', + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toHaveLength(1); + expect(isHighlightedItemOutsideAssetList(output[0])).toBe(false); + expect((output[0] as AssetType).address).toBe('0xusdc'); + }); }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts index 86ee09a367b..10605edf341 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts @@ -12,6 +12,7 @@ import { HighlightedItem, type TokenListItem, } from '../../../Views/confirmations/types/token'; +import { isPayWithBottomSheetEnabled } from '../../../Views/confirmations/utils/transaction-pay'; import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; import { selectPerpsPayWithAnyTokenAllowlistAssets } from '../selectors/featureFlags'; import { selectPerpsAccountState } from '../selectors/perpsController'; @@ -108,6 +109,10 @@ export function usePerpsBalanceTokenFilter(): ( return mappedTokens; } + if (isPayWithBottomSheetEnabled()) { + return mappedTokens; + } + const highlightedAction: HighlightedItem = { position: 'outside_of_asset_list', icon: PERPS_BALANCE_ICON_URI, diff --git a/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts b/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts index 6b5b62e1b32..546d3043c31 100644 --- a/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts +++ b/app/components/UI/Perps/hooks/usePerpsPaymentToken.ts @@ -5,15 +5,20 @@ import { useTransactionPayToken } from '../../../Views/confirmations/hooks/pay/u import Engine from '../../../../core/Engine'; import { parsePayWithToken } from '../utils/parsePayWithToken'; +export type PerpsPaymentTokenInput = + | AssetType + | { address: string; chainId: string } + | null; + export interface UsePerpsPaymentTokenResult { - onPaymentTokenChange: (token: AssetType | null) => void; + onPaymentTokenChange: (token: PerpsPaymentTokenInput) => void; } export function usePerpsPaymentToken(): UsePerpsPaymentTokenResult { const { setPayToken } = useTransactionPayToken(); const onPaymentTokenChange = useCallback( - (token: AssetType | null) => { + (token: PerpsPaymentTokenInput) => { const parsed = token === null || token === undefined ? null : parsePayWithToken(token); diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index fe3c4f4082f..a7a654fa34a 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -43,6 +43,7 @@ import { HIP3DebugView } from '../Debug'; import PerpsCrossMarginWarningBottomSheet from '../components/PerpsCrossMarginWarningBottomSheet'; import PerpsSelectProviderView from '../Views/PerpsSelectProviderView'; import { PayWithModal } from '../../../Views/confirmations/components/modals/pay-with-modal/pay-with-modal'; +import { PayWithBottomSheet } from '../../../Views/confirmations/components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; /* eslint-disable-next-line */ import { NavigationContext } from '@react-navigation/core'; @@ -441,6 +442,14 @@ const PerpsScreenStack = () => { ...transparentModalScreenOptions, }} /> + {/* Order redirect screen - handles one-click trade from token details */} 1, PayWithModal owns navigation on close by dispatching + * `StackActions.pop(N)` atomically instead of relying on the legacy + * `BottomSheet`'s built-in `navigation.goBack()`. Set to 2 by the new Pay + * With bottom sheet's "Other assets" launcher so picking a token pops both + * this modal AND the bottom sheet underneath in a single navigator + * dispatch — avoids the Android view-hierarchy race that crashes with + * `IllegalStateException` on two adjacent pops. + */ + dismissOnSelectCount?: number; +} + export function PayWithModal() { + const navigation = useNavigation(); + const { dismissOnSelectCount = 1 } = useParams({}); const transactionMeta = useTransactionMetadataRequest(); const hideNetworkFilter = hasTransactionType( transactionMeta, @@ -114,6 +131,10 @@ export function PayWithModal() { const handleTokenSelect = useCallback( (token: AssetType) => { const onClosed = async () => { + if (dismissOnSelectCount > 1) { + navigation.dispatch(StackActions.pop(dismissOnSelectCount)); + } + if ( hasTransactionType(transactionMeta, [TransactionType.musdConversion]) ) { @@ -171,8 +192,10 @@ export function PayWithModal() { }, [ close, + dismissOnSelectCount, isPredictContext, isWithdraw, + navigation, onMusdPaymentTokenChange, onPerpsPaymentTokenChange, onPredictPaymentTokenChange, @@ -245,6 +268,7 @@ export function PayWithModal() { isFullscreen ref={bottomSheetRef} keyboardAvoidingViewEnabled={false} + shouldNavigateBack={dismissOnSelectCount <= 1} > ({ jest.mock('../../../../../../util/navigation/navUtils'); jest.mock('../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'); jest.mock('../../transactions/useTransactionMetadataRequest'); +jest.mock('../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsPaymentToken'); jest.mock('../useLastUsedPaymentMethod'); jest.mock('../usePayWithPreferredToken'); jest.mock('../usePayWithSelectedToken'); @@ -71,6 +75,8 @@ describe('usePayWithCryptoSection', () => { const usePayWithPreferredTokenMock = jest.mocked(usePayWithPreferredToken); const usePayWithSelectedTokenMock = jest.mocked(usePayWithSelectedToken); const useLastUsedPaymentMethodMock = jest.mocked(useLastUsedPaymentMethod); + const useIsPerpsBalanceSelectedMock = jest.mocked(useIsPerpsBalanceSelected); + const usePerpsPaymentTokenMock = jest.mocked(usePerpsPaymentToken); const useTransactionPayFiatPaymentMock = jest.mocked( useTransactionPayFiatPayment, ); @@ -79,6 +85,7 @@ describe('usePayWithCryptoSection', () => { const goBackMock = jest.fn(); const selectTokenMock = jest.fn(); const setPayTokenMock = jest.fn(); + const onPerpsPaymentTokenChangeMock = jest.fn(); const isLastUsedMock = jest.fn().mockReturnValue(false); beforeEach(() => { @@ -87,6 +94,7 @@ describe('usePayWithCryptoSection', () => { useNavigationMock.mockReturnValue({ navigate: navigateMock, goBack: goBackMock, + isFocused: jest.fn().mockReturnValue(true), } as never); useParamsMock.mockReturnValue({}); useTransactionMetadataRequestMock.mockReturnValue(undefined); @@ -111,6 +119,10 @@ describe('usePayWithCryptoSection', () => { lastUsedToken: undefined, isLastUsed: isLastUsedMock, }); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + usePerpsPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPerpsPaymentTokenChangeMock, + }); useTransactionPayFiatPaymentMock.mockReturnValue(undefined); useTransactionPayTokenMock.mockReturnValue({ payToken: TOKEN_MOCK, @@ -257,6 +269,42 @@ describe('usePayWithCryptoSection', () => { expect(result.current?.rows[1].icon).toEqual(expect.any(Object)); }); + it('does not mark the preferred token row as selected on perpsDepositAndOrder flows when Perps balance is the implicit default', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + txParams: {}, + } as never); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + id: 'crypto-preferred-token', + isSelected: false, + trailingElement: 'none', + }), + ); + }); + + it('still marks the preferred token row as selected on perpsDepositAndOrder flows when the user explicitly picked the preferred token via "Other assets"', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + txParams: {}, + } as never); + useIsPerpsBalanceSelectedMock.mockReturnValue(false); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + id: 'crypto-preferred-token', + isSelected: true, + trailingElement: 'checkmark', + }), + ); + }); + it('does not select the preferred token row when another token is selected', () => { const distinctSelectedToken = { ...TOKEN_MOCK, @@ -399,6 +447,7 @@ describe('usePayWithCryptoSection', () => { address: TOKEN_MOCK.address, chainId: TOKEN_MOCK.chainId, }); + expect(onPerpsPaymentTokenChangeMock).not.toHaveBeenCalled(); expect(goBackMock).toHaveBeenCalledTimes(1); }); @@ -434,6 +483,56 @@ describe('usePayWithCryptoSection', () => { expect(goBackMock).toHaveBeenCalledTimes(1); }); + it('routes the preferred-row tap through onPerpsPaymentTokenChange on perpsDepositAndOrder flows', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + } as never); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(onPerpsPaymentTokenChangeMock).toHaveBeenCalledWith({ + address: TOKEN_MOCK.address, + chainId: TOKEN_MOCK.chainId, + }); + expect(setPayTokenMock).not.toHaveBeenCalled(); + expect(goBackMock).toHaveBeenCalledTimes(1); + }); + + it('hides the user-selected token row when Perps balance is the implicit default on perpsDepositAndOrder flows', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + type: TransactionType.perpsDepositAndOrder, + } as never); + useIsPerpsBalanceSelectedMock.mockReturnValue(true); + const distinctSelectedToken = { + ...TOKEN_MOCK, + address: SELECTED_TOKEN_MOCK.address, + symbol: SELECTED_TOKEN_MOCK.symbol, + }; + usePayWithPreferredTokenMock.mockReturnValue({ + hasTokens: true, + preferredToken: TOKEN_MOCK, + selectedToken: distinctSelectedToken, + }); + usePayWithSelectedTokenMock.mockReturnValue({ + isSelectedDistinctFromAutomatic: true, + selectedToken: SELECTED_TOKEN_MOCK, + selectToken: selectTokenMock, + }); + + const { result } = renderHook(() => usePayWithCryptoSection()); + + const selectedRow = result.current?.rows.find( + (row) => row.id === 'crypto-selected-token', + ); + + expect(selectedRow).toBeUndefined(); + }); + it('does not assign a tap handler to the user-selected token row', () => { const distinctSelectedToken = { ...TOKEN_MOCK, @@ -469,6 +568,7 @@ describe('usePayWithCryptoSection', () => { expect(navigateMock).toHaveBeenCalledWith( Routes.CONFIRMATION_PAY_WITH_MODAL, + { dismissOnSelectCount: 2 }, ); }); diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts index 176ee227457..598176271c0 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from 'react'; import { useNavigation } from '@react-navigation/native'; import { BigNumber } from 'bignumber.js'; +import { TransactionType } from '@metamask/transaction-controller'; import { Icon, IconColor, @@ -16,6 +17,8 @@ import { PayWithRowConfig, PayWithSectionConfig, } from '../../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; +import { useIsPerpsBalanceSelected } from '../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; +import { hasTransactionType } from '../../../utils/transaction'; import { isMatchingPayToken, resolvePreferredPayToken, @@ -26,6 +29,7 @@ import { usePayWithPreferredToken } from '../usePayWithPreferredToken'; import { usePayWithSelectedToken } from '../usePayWithSelectedToken'; import { useTransactionPayFiatPayment } from '../useTransactionPayData'; import { useTransactionPayToken } from '../useTransactionPayToken'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; interface PayWithCryptoSectionParams { @@ -63,24 +67,47 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { selectedToken: selectedTokenDisplay, } = usePayWithSelectedToken({ preferredToken: resolvedPreferredToken }); const { setPayToken } = useTransactionPayToken(); + const { onPaymentTokenChange: onPerpsPaymentTokenChange } = + usePerpsPaymentToken(); const { isLastUsed } = useLastUsedPaymentMethod(); + const isPerpsBalanceSelected = useIsPerpsBalanceSelected(); + const isPerpsDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]); + const isPerpsBalanceImplicitlySelected = + isPerpsDepositAndOrder && isPerpsBalanceSelected; const fiatPayment = useTransactionPayFiatPayment(); const hasFiatPaymentSelected = Boolean(fiatPayment?.selectedPaymentMethodId); + const isDedicatedSectionOwningSelection = + isPerpsBalanceImplicitlySelected || hasFiatPaymentSelected; const handleOtherAssetsPress = useCallback(() => { - navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL); + navigation.navigate(Routes.CONFIRMATION_PAY_WITH_MODAL, { + dismissOnSelectCount: 2, + }); }, [navigation]); const handlePreferredTokenPress = useCallback(() => { if (!preferredToken) { return; } - setPayToken({ + const target = { address: preferredToken.address, chainId: preferredToken.chainId, - }); + }; + if (isPerpsDepositAndOrder) { + onPerpsPaymentTokenChange(target); + } else { + setPayToken(target); + } navigation.goBack(); - }, [navigation, preferredToken, setPayToken]); + }, [ + isPerpsDepositAndOrder, + navigation, + onPerpsPaymentTokenChange, + preferredToken, + setPayToken, + ]); const preferredTokenBalance = useMemo( () => formatFiat(new BigNumber(preferredToken?.balanceUsd ?? '0')), @@ -100,8 +127,17 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { const rows: PayWithRowConfig[] = []; if (preferredToken) { + // When a dedicated section "owns" the selection (Perps balance is the + // implicit default in a perpsDepositAndOrder flow, OR a fiat payment + // method has been picked), the Crypto section's preferred-token row must + // not render a misleading checkmark, and the user-selected-token row is + // hidden below. When the user explicitly picks a crypto token via "Other + // assets" in a perps flow, `PerpsController` also stores it as + // `selectedPaymentToken`, and we honor that selection with a checkmark + // (handled by `isPerpsBalanceImplicitlySelected` being false in that + // case). const isPreferredTokenSelected = - !hasFiatPaymentSelected && + !isDedicatedSectionOwningSelection && isMatchingPayToken(selectedToken, preferredToken); rows.push({ @@ -126,7 +162,7 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { if ( isSelectedDistinctFromAutomatic && selectedTokenDisplay && - !hasFiatPaymentSelected + !isDedicatedSectionOwningSelection ) { rows.push({ id: 'crypto-selected-token', @@ -174,8 +210,8 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { }, [ handleOtherAssetsPress, handlePreferredTokenPress, - hasFiatPaymentSelected, hasTokens, + isDedicatedSectionOwningSelection, isLastUsed, isSelectedDistinctFromAutomatic, preferredToken, diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.test.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.test.tsx new file mode 100644 index 00000000000..c4d040aa6c7 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.test.tsx @@ -0,0 +1,223 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useNavigation } from '@react-navigation/native'; +import { TransactionType } from '@metamask/transaction-controller'; +import { useSelector } from 'react-redux'; +import Routes from '../../../../../../constants/navigation/Routes'; +import useFiatFormatter from '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { selectPerpsAccountState } from '../../../../../UI/Perps/selectors/perpsController'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { usePerpsTrading } from '../../../../../UI/Perps/hooks/usePerpsTrading'; +import useApprovalRequest from '../../useApprovalRequest'; +import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; +import { usePayWithPerpsSection } from './usePayWithPerpsSection'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: { balance?: string }) => { + const translations: Record = { + 'confirm.pay_with_bottom_sheet.perps': 'Perps', + 'confirm.pay_with_bottom_sheet.perps_account': 'Perps account', + 'confirm.pay_with_bottom_sheet.add': 'Add', + 'confirm.pay_with_bottom_sheet.available_balance': `${ + params?.balance ?? '' + } available`, + }; + return translations[key] ?? key; + }, +})); +jest.mock('../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsPaymentToken'); +jest.mock('../../../../../UI/Perps/hooks/usePerpsTrading'); +jest.mock('../../useApprovalRequest'); +jest.mock('../../transactions/useTransactionMetadataRequest'); + +describe('usePayWithPerpsSection', () => { + const useSelectorMock = jest.mocked(useSelector); + const useNavigationMock = jest.mocked(useNavigation); + const useFiatFormatterMock = jest.mocked(useFiatFormatter); + const usePerpsPaymentTokenMock = jest.mocked(usePerpsPaymentToken); + const usePerpsTradingMock = jest.mocked(usePerpsTrading); + const useApprovalRequestMock = jest.mocked(useApprovalRequest); + const useTransactionMetadataRequestMock = jest.mocked( + useTransactionMetadataRequest, + ); + + const navigateMock = jest.fn(); + const goBackMock = jest.fn(); + const onPaymentTokenChangeMock = jest.fn(); + const depositWithConfirmationMock = jest.fn(); + const onRejectMock = jest.fn(); + const formatFiatMock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + formatFiatMock.mockImplementation( + (value: { toString: () => string }) => + `$${Number(value.toString()).toFixed(2)}`, + ); + + useNavigationMock.mockReturnValue({ + navigate: navigateMock, + goBack: goBackMock, + } as never); + + useFiatFormatterMock.mockReturnValue(formatFiatMock as never); + + useTransactionMetadataRequestMock.mockReturnValue({ + id: 'tx-1', + type: TransactionType.perpsDepositAndOrder, + txParams: {}, + } as never); + + useSelectorMock.mockImplementation((selector) => { + if (selector === selectPerpsAccountState) { + return { spendableBalance: '500' }; + } + return undefined; + }); + + usePerpsPaymentTokenMock.mockReturnValue({ + onPaymentTokenChange: onPaymentTokenChangeMock, + } as never); + + usePerpsTradingMock.mockReturnValue({ + depositWithConfirmation: depositWithConfirmationMock.mockResolvedValue({ + result: Promise.resolve('ok'), + }), + } as never); + + useApprovalRequestMock.mockReturnValue({ + onReject: onRejectMock, + } as never); + }); + + it('returns null when the transaction type is not perpsDepositAndOrder', () => { + useTransactionMetadataRequestMock.mockReturnValue({ + id: 'tx-1', + type: TransactionType.perpsDeposit, + txParams: {}, + } as never); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current).toBeNull(); + }); + + it('returns null when there is no transaction metadata', () => { + useTransactionMetadataRequestMock.mockReturnValue(undefined); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current).toBeNull(); + }); + + it('returns the perps section config with a single perps account row when the transaction type is perpsDepositAndOrder', () => { + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current).toEqual( + expect.objectContaining({ + id: 'perps', + title: 'Perps', + testID: 'pay-with-section-perps', + }), + ); + expect(result.current?.rows).toHaveLength(1); + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + id: 'perps-balance', + title: 'Perps account', + subtitle: '$500.00 available', + isSelected: false, + testID: 'pay-with-perps-section-balance-row', + }), + ); + }); + + it('renders the row without a visual selected state (the Add button is the only call to action, no checkmark needed)', () => { + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current?.rows[0]).toEqual( + expect.objectContaining({ + isSelected: false, + trailingElement: expect.any(Object), + }), + ); + }); + + it('treats a missing spendable balance as zero', () => { + useSelectorMock.mockImplementation((selector) => { + if (selector === selectPerpsAccountState) { + return null; + } + return undefined; + }); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + expect(result.current?.rows[0].subtitle).toBe('$0.00 available'); + }); + + it('selects perps balance as payment token and dismisses the sheet when the row is pressed', () => { + const { result } = renderHook(() => usePayWithPerpsSection()); + + act(() => { + result.current?.rows[0].onPress?.(); + }); + + expect(onPaymentTokenChangeMock).toHaveBeenCalledWith(null); + expect(goBackMock).toHaveBeenCalledTimes(1); + }); + + it('rejects approval, triggers deposit confirmation, and navigates with perps header when Add is pressed', async () => { + const { result } = renderHook(() => usePayWithPerpsSection()); + + const trailing = result.current?.rows[0].trailingElement as + | { props: { onPress: () => Promise } } + | undefined; + + await act(async () => { + await trailing?.props.onPress(); + }); + + expect(onRejectMock).toHaveBeenCalledTimes(1); + expect(depositWithConfirmationMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + { showPerpsHeader: true }, + ); + }); + + it('does not navigate when deposit confirmation rejects', async () => { + depositWithConfirmationMock.mockRejectedValueOnce(new Error('user-cancel')); + + const { result } = renderHook(() => usePayWithPerpsSection()); + + const trailing = result.current?.rows[0].trailingElement as + | { props: { onPress: () => Promise } } + | undefined; + + await act(async () => { + await trailing?.props.onPress(); + }); + + expect(onRejectMock).toHaveBeenCalledTimes(1); + expect(depositWithConfirmationMock).toHaveBeenCalledTimes(1); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it('keeps the result reference stable across renders when nothing changes', () => { + const { result, rerender } = renderHook(() => usePayWithPerpsSection()); + const firstResult = result.current; + + rerender(); + + expect(result.current).toBe(firstResult); + }); +}); diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx new file mode 100644 index 00000000000..05b7337b224 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx @@ -0,0 +1,103 @@ +import React, { useCallback, useMemo } from 'react'; +import { Image } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../../locales/i18n'; +import useFiatFormatter from '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; +import { selectPerpsAccountState } from '../../../../../UI/Perps/selectors/perpsController'; +import { PERPS_BALANCE_ICON_URI } from '../../../../../UI/Perps/hooks/usePerpsBalanceTokenFilter'; +import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { usePerpsTrading } from '../../../../../UI/Perps/hooks/usePerpsTrading'; +import useApprovalRequest from '../../useApprovalRequest'; +import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; +import { + PayWithRowConfig, + PayWithSectionConfig, +} from '../../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; +import { hasTransactionType } from '../../../utils/transaction'; + +export const PAY_WITH_PERPS_SECTION_TEST_ID = 'pay-with-section-perps'; +export const PAY_WITH_PERPS_BALANCE_ROW_TEST_ID = + 'pay-with-perps-section-balance-row'; + +export function usePayWithPerpsSection(): PayWithSectionConfig | null { + const navigation = useNavigation(); + const transactionMeta = useTransactionMetadataRequest(); + const formatFiat = useFiatFormatter({ currency: 'usd' }); + const perpsAccount = useSelector(selectPerpsAccountState); + const { onPaymentTokenChange } = usePerpsPaymentToken(); + const { depositWithConfirmation } = usePerpsTrading(); + const { onReject } = useApprovalRequest(); + + const isPerpsDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]); + + const balance = useMemo( + () => formatFiat(new BigNumber(perpsAccount?.spendableBalance ?? '0')), + [formatFiat, perpsAccount?.spendableBalance], + ); + + const handleSelect = useCallback(() => { + onPaymentTokenChange(null); + navigation.goBack(); + }, [navigation, onPaymentTokenChange]); + + const handleAdd = useCallback(async () => { + onReject(); + try { + await depositWithConfirmation(); + navigation.navigate( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + { showPerpsHeader: true }, + ); + } catch { + // Deposit flow handles errors (e.g. user rejection or missing network). + } + }, [depositWithConfirmation, navigation, onReject]); + + return useMemo(() => { + if (!isPerpsDepositAndOrder) { + return null; + } + + const row: PayWithRowConfig = { + id: 'perps-balance', + icon: React.createElement(Image, { + source: { uri: PERPS_BALANCE_ICON_URI }, + style: { width: 24, height: 24 }, + }), + title: strings('confirm.pay_with_bottom_sheet.perps_account'), + subtitle: strings('confirm.pay_with_bottom_sheet.available_balance', { + balance, + }), + isSelected: false, + trailingElement: ( + + ), + onPress: handleSelect, + testID: PAY_WITH_PERPS_BALANCE_ROW_TEST_ID, + }; + + return { + id: 'perps', + title: strings('confirm.pay_with_bottom_sheet.perps'), + testID: PAY_WITH_PERPS_SECTION_TEST_ID, + rows: [row], + }; + }, [balance, handleAdd, handleSelect, isPerpsDepositAndOrder]); +} diff --git a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts index 82205a1406e..8534c17efde 100644 --- a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.test.ts @@ -41,15 +41,18 @@ describe('useDismissOnPaymentChange', () => { useTransactionPayFiatPayment, ); const goBackMock = jest.fn(); + const isFocusedMock = jest.fn().mockReturnValue(true); const setPayTokenMock: jest.MockedFn< ReturnType['setPayToken'] > = jest.fn(); beforeEach(() => { jest.resetAllMocks(); + isFocusedMock.mockReturnValue(true); useNavigationMock.mockReturnValue({ goBack: goBackMock, + isFocused: isFocusedMock, } as never); useTransactionPayTokenMock.mockReturnValue({ @@ -268,4 +271,40 @@ describe('useDismissOnPaymentChange', () => { expect(goBackMock).toHaveBeenCalledTimes(1); }); }); + + describe('focus guard (defers dismissal when an overlapping route is on top)', () => { + it('does not call goBack when the screen is not focused even if the pay token changes', () => { + isFocusedMock.mockReturnValue(false); + + const { rerender } = renderHook(() => useDismissOnPaymentChange()); + + useTransactionPayTokenMock.mockReturnValue({ + payToken: TOKEN_B, + setPayToken: setPayTokenMock, + }); + + rerender(); + + expect(goBackMock).not.toHaveBeenCalled(); + }); + + it('latches when defeating an unfocused change, so it does not re-fire after re-focus', () => { + isFocusedMock.mockReturnValue(false); + + const { rerender } = renderHook(() => useDismissOnPaymentChange()); + + useTransactionPayTokenMock.mockReturnValue({ + payToken: TOKEN_B, + setPayToken: setPayTokenMock, + }); + + rerender(); + + isFocusedMock.mockReturnValue(true); + + rerender(); + + expect(goBackMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts index 474bfe81974..09393fa2f4e 100644 --- a/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts +++ b/app/components/Views/confirmations/hooks/pay/useDismissOnPaymentChange.ts @@ -46,6 +46,11 @@ export function useDismissOnPaymentChange(): void { return; } + if (!navigation.isFocused()) { + isDismissingRef.current = true; + return; + } + isDismissingRef.current = true; navigation.goBack(); }, [navigation, payToken, selectedPaymentMethodId]); diff --git a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts index eb95e992cd1..e537d2c79b1 100644 --- a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts +++ b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts @@ -2,10 +2,12 @@ import { renderHook } from '@testing-library/react-hooks'; import { PayWithSectionConfig } from '../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; import { usePayWithCryptoSection } from './sections/usePayWithCryptoSection'; import { usePayWithFiatSection } from './sections/usePayWithFiatSection'; +import { usePayWithPerpsSection } from './sections/usePayWithPerpsSection'; import { usePayWithSections } from './usePayWithSections'; jest.mock('./sections/usePayWithCryptoSection'); jest.mock('./sections/usePayWithFiatSection'); +jest.mock('./sections/usePayWithPerpsSection'); const CRYPTO_SECTION_MOCK: PayWithSectionConfig = { id: 'crypto', @@ -19,6 +21,18 @@ const CRYPTO_SECTION_MOCK: PayWithSectionConfig = { ], }; +const PERPS_SECTION_MOCK: PayWithSectionConfig = { + id: 'perps', + title: 'Perps', + rows: [ + { + id: 'perps-balance', + icon: 'Perps', + title: 'Perps account', + }, + ], +}; + const BANK_CARD_SECTION_MOCK: PayWithSectionConfig = { id: 'bank-card', title: 'Bank and card', @@ -34,12 +48,14 @@ const BANK_CARD_SECTION_MOCK: PayWithSectionConfig = { describe('usePayWithSections', () => { const usePayWithCryptoSectionMock = jest.mocked(usePayWithCryptoSection); const usePayWithFiatSectionMock = jest.mocked(usePayWithFiatSection); + const usePayWithPerpsSectionMock = jest.mocked(usePayWithPerpsSection); beforeEach(() => { jest.resetAllMocks(); usePayWithCryptoSectionMock.mockReturnValue(null); usePayWithFiatSectionMock.mockReturnValue(null); + usePayWithPerpsSectionMock.mockReturnValue(null); }); it('returns empty sections array when no section is visible', () => { @@ -56,6 +72,14 @@ describe('usePayWithSections', () => { expect(result.current.sections).toEqual([CRYPTO_SECTION_MOCK]); }); + it('returns the visible perps section', () => { + usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([PERPS_SECTION_MOCK]); + }); + it('returns the visible bank-card section when only bank-card is available', () => { usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); @@ -64,6 +88,18 @@ describe('usePayWithSections', () => { expect(result.current.sections).toEqual([BANK_CARD_SECTION_MOCK]); }); + it('returns perps section before crypto section when both are visible', () => { + usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); + usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([ + PERPS_SECTION_MOCK, + CRYPTO_SECTION_MOCK, + ]); + }); + it('renders bank-card before crypto when both sections are visible', () => { usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); @@ -76,6 +112,20 @@ describe('usePayWithSections', () => { ]); }); + it('renders perps, bank-card, then crypto when all three sections are visible', () => { + usePayWithCryptoSectionMock.mockReturnValue(CRYPTO_SECTION_MOCK); + usePayWithFiatSectionMock.mockReturnValue(BANK_CARD_SECTION_MOCK); + usePayWithPerpsSectionMock.mockReturnValue(PERPS_SECTION_MOCK); + + const { result } = renderHook(() => usePayWithSections()); + + expect(result.current.sections).toEqual([ + PERPS_SECTION_MOCK, + BANK_CARD_SECTION_MOCK, + CRYPTO_SECTION_MOCK, + ]); + }); + it('returns the same sections reference across renders', () => { const { result, rerender } = renderHook(() => usePayWithSections()); const first = result.current.sections; diff --git a/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts b/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts index d8dac8eef97..8c83cf43648 100644 --- a/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts +++ b/app/components/Views/confirmations/hooks/pay/usePayWithSections.ts @@ -1,20 +1,27 @@ import { useMemo } from 'react'; import { PayWithSectionConfig } from '../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; -import { usePayWithCryptoSection, usePayWithFiatSection } from './sections'; +import { + usePayWithCryptoSection, + usePayWithFiatSection, + usePayWithPerpsSection, +} from './sections'; export interface UsePayWithSectionsResult { sections: PayWithSectionConfig[]; } export function usePayWithSections(): UsePayWithSectionsResult { + const perpsSection = usePayWithPerpsSection(); const bankCardSection = usePayWithFiatSection(); const cryptoSection = usePayWithCryptoSection(); return useMemo( () => ({ - sections: [bankCardSection, cryptoSection].filter(isPayWithSectionConfig), + sections: [perpsSection, bankCardSection, cryptoSection].filter( + isPayWithSectionConfig, + ), }), - [bankCardSection, cryptoSection], + [bankCardSection, cryptoSection, perpsSection], ); } diff --git a/locales/languages/en.json b/locales/languages/en.json index a9df55a930d..c0c666ba477 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7069,6 +7069,9 @@ "last_used": "Last used", "bank_and_card": "Bank and card", "crypto": "Crypto", + "perps": "Perps", + "perps_account": "Perps account", + "add": "Add", "available_balance": "{{balance}} available", "other_assets": "Other assets", "other_assets_description": "Select from your tokens"