Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,22 @@ describe('Checkout', () => {
});
});
});

it('does not register when network/chainId is missing', async () => {
mockUseParams.mockReturnValue({
url: 'https://provider.example.com/checkout',
providerName: 'MoonPay',
providerCode: 'moonpay',
walletAddress: '0xabcdef1234567890',
orderId: 'mp-order-99',
});

renderWithProvider(<Checkout />, {}, true, false);

await waitFor(() => {
expect(mockAddPrecreatedOrder).not.toHaveBeenCalled();
});
});
});

describe('missing checkout URL', () => {
Expand Down
15 changes: 8 additions & 7 deletions app/components/UI/Ramp/Views/Checkout/Checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,21 +395,22 @@ const Checkout = () => {
// providerCode and walletAddress are passed, so hasCallbackFlow is true
// and we can register. hasCallbackFlow being false means we lack the data
// required for addPrecreatedOrder anyway.
// Note: network/chainId is optional in addPrecreatedOrder; do not require it
// in the guard, otherwise orders with unusual chain ID formats (e.g. empty
// string from chainId.split(':')[1]) would silently skip registration here
// while external-browser flows would still register (BuildQuote passes
// chainId: network || undefined without requiring network).
// RampsController requires a non-empty chainId (see Core #9777); skip
// registration when network is missing rather than seeding an empty stub.
const canRegister =
hasCallbackFlow && effectiveOrderId && providerCode && walletAddress;
hasCallbackFlow &&
effectiveOrderId &&
providerCode &&
walletAddress &&
network;
if (!canRegister) return;
if (registeredOrderIdsRef.current.has(effectiveOrderId)) return;
registeredOrderIdsRef.current.add(effectiveOrderId);
addPrecreatedOrder({
orderId: effectiveOrderId,
providerCode,
walletAddress,
chainId: network || undefined,
chainId: network,
});
}, [
hasCallbackFlow,
Expand Down
4 changes: 2 additions & 2 deletions app/components/UI/Ramp/hooks/useContinueWithQuote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,12 +345,12 @@ export function useContinueWithQuote(
);

if (useExternalBrowser) {
if (effectiveOrderId && effectiveWallet) {
if (effectiveOrderId && effectiveWallet && network) {
addPrecreatedOrder({
orderId: effectiveOrderId,
providerCode,
walletAddress: effectiveWallet,
chainId: network || undefined,
chainId: network,
});
}

Expand Down
3 changes: 2 additions & 1 deletion app/components/UI/Ramp/hooks/useRampsOrders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export interface AddPrecreatedOrderParams {
orderId: string;
providerCode: string;
walletAddress: string;
chainId?: string;
/** Non-empty chain id (decimal, hex, or CAIP-2). Required by RampsController. */
chainId: string;
}

export interface UseRampsOrdersResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTokens';
import { AssetType } from '../../types/token';
import { useWithdrawTokenFilter } from './useWithdrawTokenFilter';
import { useRampsPaymentMethods } from '../../../../UI/Ramp/hooks/useRampsPaymentMethods';
import { useFiatDepositPaymentMethods } from './useFiatDepositPaymentMethods';
import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest';
import { useTransactionAccountOverride } from '../transactions/useTransactionAccountOverride';
import { MUSD_TOKEN_ADDRESS } from '../../../../UI/Earn/constants/musd';
Expand All @@ -50,7 +50,7 @@ jest.mock('../../../../../selectors/transactionPayController');
jest.mock('./useTransactionPayData');
jest.mock('./useTransactionPayAvailableTokens');
jest.mock('./useWithdrawTokenFilter');
jest.mock('../../../../UI/Ramp/hooks/useRampsPaymentMethods');
jest.mock('./useFiatDepositPaymentMethods');
jest.mock('./useIsFiatPaymentAvailable');
jest.mock('./useMMPayFiatConfig');
jest.mock('../../../../../selectors/transactionController', () => ({
Expand Down Expand Up @@ -188,10 +188,10 @@ describe('useAutomaticTransactionPayToken', () => {

useTransactionPayFiatPaymentMock.mockReturnValue(undefined);

jest.mocked(useRampsPaymentMethods).mockReturnValue({
jest.mocked(useFiatDepositPaymentMethods).mockReturnValue({
paymentMethods: [],
selectedPaymentMethod: null,
setSelectedPaymentMethod: jest.fn(),
suggestedPaymentMethod: null,
assetId: 'eip155:1/slip44:60',
isLoading: false,
isFetching: false,
status: 'success',
Expand All @@ -203,6 +203,7 @@ describe('useAutomaticTransactionPayToken', () => {
jest.mocked(useMMPayFiatConfig).mockReturnValue({
enabledTransactionTypes: [],
maxDelayMinutesForPaymentMethods: 10,
assetPerTransactionType: {},
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { selectPaymentOverrideByTransactionId } from '../../../../../selectors/t
import { MUSD_TOKEN_ADDRESS } from '../../../../UI/Earn/constants/musd';
import { useWithdrawTokenFilter } from './useWithdrawTokenFilter';
import { useTransactionAccountOverride } from '../transactions/useTransactionAccountOverride';
import { useRampsPaymentMethods } from '../../../../UI/Ramp/hooks/useRampsPaymentMethods';
import { useFiatDepositPaymentMethods } from './useFiatDepositPaymentMethods';

export interface SetPayTokenRequest {
address: Hex;
Expand Down Expand Up @@ -167,7 +167,7 @@ export function useAutomaticTransactionPayToken({

const automaticToken = useMemo(() => selectBestToken(), [selectBestToken]);

const { paymentMethods } = useRampsPaymentMethods();
const { paymentMethods } = useFiatDepositPaymentMethods();
const { maxDelayMinutesForPaymentMethods } = useMMPayFiatConfig();
const isFiatEnabled = useIsFiatPaymentAvailable();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { type PaymentMethod } from '@metamask/ramps-controller';
import { TransactionType } from '@metamask/transaction-controller';
import { useFiatDepositPaymentMethods } from './useFiatDepositPaymentMethods';
import { useMMPayFiatConfig } from './useMMPayFiatConfig';
import { useTransactionPayFiatPayment } from './useTransactionPayData';
import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest';
import Engine from '../../../../../core/Engine';

const mockUseQuery = jest.fn();
const mockUseSelector = jest.fn();

jest.mock('@tanstack/react-query', () => ({
useQuery: (...args: unknown[]) => mockUseQuery(...args),
}));
jest.mock('react-redux', () => ({
useSelector: (...args: unknown[]) => mockUseSelector(...args),
}));
jest.mock('./useMMPayFiatConfig');
jest.mock('./useTransactionPayData');
jest.mock('../transactions/useTransactionMetadataRequest');
jest.mock('../../../../../core/Engine', () => ({
context: {
RampsController: {
getPaymentMethodsForContext: jest.fn(),
},
TransactionPayController: {
updateFiatPayment: jest.fn(),
},
},
}));

const CARD_METHOD = {
id: 'pm-card',
name: 'Credit Card',
} as PaymentMethod;

const REVOLUT_METHOD = {
id: 'pm-revolut-pay',
name: 'Revolut Pay',
} as PaymentMethod;

describe('useFiatDepositPaymentMethods', () => {
const useMMPayFiatConfigMock = jest.mocked(useMMPayFiatConfig);
const useTransactionPayFiatPaymentMock = jest.mocked(
useTransactionPayFiatPayment,
);
const useTransactionMetadataRequestMock = jest.mocked(
useTransactionMetadataRequest,
);
const getPaymentMethodsForContextMock = jest.mocked(
Engine.context.RampsController.getPaymentMethodsForContext,
);
const updateFiatPaymentMock = jest.mocked(
Engine.context.TransactionPayController.updateFiatPayment,
);

beforeEach(() => {
jest.resetAllMocks();

mockUseSelector.mockReturnValue({ regionCode: 'us' });
useMMPayFiatConfigMock.mockReturnValue({
enabledTransactionTypes: [TransactionType.moneyAccountDeposit],
maxDelayMinutesForPaymentMethods: 10,
assetPerTransactionType: {},
});
useTransactionMetadataRequestMock.mockReturnValue({
id: 'tx-1',
type: TransactionType.moneyAccountDeposit,
} as ReturnType<typeof useTransactionMetadataRequest>);
useTransactionPayFiatPaymentMock.mockReturnValue(undefined);

mockUseQuery.mockImplementation((options: { queryFn?: () => unknown }) => {
// Expose queryFn for request-shape assertions without running React Query.
(mockUseQuery as { lastOptions?: unknown }).lastOptions = options;
return {
data: { methods: [CARD_METHOD], selected: CARD_METHOD, providerIds: [] },
isLoading: false,
isFetching: false,
isSuccess: true,
isError: false,
error: null,
};
});
});

it('queries getPaymentMethodsForContext with deposit asset and headless flags', async () => {
renderHook(() => useFiatDepositPaymentMethods());

const options = (mockUseQuery as { lastOptions?: { queryFn: () => Promise<unknown>; queryKey: unknown[] } })
.lastOptions;
expect(options?.queryKey).toEqual([
'ramps',
'paymentMethodsForContext',
'us',
'eip155:1/slip44:60',
true,
true,
]);

getPaymentMethodsForContextMock.mockResolvedValue({
methods: [CARD_METHOD],
selected: CARD_METHOD,
providerIds: ['native'],
});

await act(async () => {
await options?.queryFn();
});

expect(getPaymentMethodsForContextMock).toHaveBeenCalledWith({
region: 'us',
assetId: 'eip155:1/slip44:60',
autoSelectProvider: true,
restrictToKnownOrNativeProviders: true,
preferPaymentMethodId: undefined,
updateState: false,
});
});

it('returns deposit-context methods without Buy-only Revolut Pay when absent', () => {
mockUseQuery.mockReturnValue({
data: { methods: [CARD_METHOD], selected: CARD_METHOD, providerIds: [] },
isLoading: false,
isFetching: false,
isSuccess: true,
isError: false,
error: null,
});

const { result } = renderHook(() => useFiatDepositPaymentMethods());

expect(result.current.paymentMethods).toEqual([CARD_METHOD]);
expect(result.current.paymentMethods).not.toContainEqual(REVOLUT_METHOD);
});

it('clears stale selectedPaymentMethodId after a successful fetch', () => {
useTransactionPayFiatPaymentMock.mockReturnValue({
selectedPaymentMethodId: 'pm-revolut-pay',
} as never);
mockUseQuery.mockReturnValue({
data: { methods: [CARD_METHOD], selected: CARD_METHOD, providerIds: [] },
isLoading: false,
isFetching: false,
isSuccess: true,
isError: false,
error: null,
});

renderHook(() => useFiatDepositPaymentMethods());

expect(updateFiatPaymentMock).toHaveBeenCalledWith({
transactionId: 'tx-1',
callback: expect.any(Function),
});

const fiatPayment = { selectedPaymentMethodId: 'pm-revolut-pay' };
updateFiatPaymentMock.mock.calls[0][0].callback(fiatPayment);
expect(fiatPayment.selectedPaymentMethodId).toBeUndefined();
});

it('does not clear selection while payment methods are loading', () => {
useTransactionPayFiatPaymentMock.mockReturnValue({
selectedPaymentMethodId: 'pm-revolut-pay',
} as never);
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: true,
isFetching: true,
isSuccess: false,
isError: false,
error: null,
});

renderHook(() => useFiatDepositPaymentMethods());

expect(updateFiatPaymentMock).not.toHaveBeenCalled();
});

it('does not clear selection for a transient empty non-success state', () => {
useTransactionPayFiatPaymentMock.mockReturnValue({
selectedPaymentMethodId: 'pm-card',
} as never);
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
isSuccess: false,
isError: false,
error: null,
});

renderHook(() => useFiatDepositPaymentMethods());

expect(updateFiatPaymentMock).not.toHaveBeenCalled();
});
});
Loading
Loading