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"