Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
33757f0
feat: adds support for custom actions such as paypal
georgeweiler Mar 3, 2026
b7a950e
chore: removes development code
georgeweiler Mar 3, 2026
97686df
feat: code review comments
georgeweiler Mar 4, 2026
3b17f64
chore: installs preview build of ramps controller
georgeweiler Mar 4, 2026
826e3a7
Merge branch 'main' of github.com:MetaMask/metamask-mobile into v2-cu…
georgeweiler Mar 4, 2026
2be7c8d
Merge branch 'main' of github.com:MetaMask/metamask-mobile into v2-cu…
georgeweiler Mar 4, 2026
4b43c56
chore: installs preview for ramps-controller
georgeweiler Mar 4, 2026
c5c1c83
[skip ci] Bump version number to 3890
metamaskbot Mar 4, 2026
f2d8bc1
[skip ci] Bump version number to 3892
metamaskbot Mar 4, 2026
6271a11
fix: use deep link redirect and navigate to order details after PayPa…
amitabh94 Mar 6, 2026
bd3c409
fix: override redirectUrl with deep link for external browser providers
amitabh94 Mar 6, 2026
69bedfa
fix: skip order details navigation when user cancels external browser
amitabh94 Mar 6, 2026
8dc210d
fix: always override redirectUrl on buyURL before fetching widget
amitabh94 Mar 6, 2026
e506bb1
chore: add temporary debug logging for PayPal redirect flow
amitabh94 Mar 6, 2026
63b75e2
chore: add comprehensive debug logging for PayPal redirect flow
amitabh94 Mar 6, 2026
bc012ac
fix: pass extracted orderCode to OrderDetails instead of full orderId
amitabh94 Mar 6, 2026
2ebe31d
chore: add DEV-only mock bypass for PayPal external browser flow
amitabh94 Mar 6, 2026
2fdfc44
chore: merge with main branch
georgeweiler Mar 9, 2026
f75705b
Merge branch 'v2-custom-actions' of github.com:MetaMask/metamask-mobi…
georgeweiler Mar 9, 2026
4f0a068
chore: debug instrumentation
georgeweiler Mar 9, 2026
93003e7
feat: hide precreated and expired orders from orders list
georgeweiler Mar 9, 2026
479f678
fix: OrderDetails hydration fallback and remove debug instrumentation
lorenzosantos Mar 9, 2026
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
2 changes: 1 addition & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionName "7.69.0"
versionCode 3607
versionCode 3890
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ const mockUseRampsControllerInitialValues: ReturnType<
paymentMethodsLoading: false,
paymentMethodsError: null,
getQuotes: jest.fn(),
getWidgetUrl: jest.fn(),
getBuyWidgetData: jest.fn(),
orders: [],
getOrderById: jest.fn(),
addOrder: jest.fn(),
Expand Down
55 changes: 33 additions & 22 deletions app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ const mockNavigate = jest.fn();
const mockSetOptions = jest.fn();
const mockGoBack = jest.fn();
const mockSetParams = jest.fn();
const mockGetWidgetUrl = jest.fn<
Promise<string | null>,
const mockGetBuyWidgetData = jest.fn<
Promise<{ url: string; orderId?: string | null } | null>,
[quote: Record<string, unknown>]
>(async (quote) => {
const buyUrl = (quote as { quote?: { buyURL: string } })?.quote?.buyURL;
if (!buyUrl) return null;
// Simulate the fetch behavior
return 'https://global.transak.com/?apiKey=test';
return {
url: 'https://global.transak.com/?apiKey=test',
orderId: null,
};
});

const MOCK_ASSET_ID =
Expand Down Expand Up @@ -179,12 +181,15 @@ let mockQuotesData: {
let mockQuotesLoading = false;
let mockQuotesError: string | null = null;

const mockAddPrecreatedOrder = jest.fn();

jest.mock('../../hooks/useRampsController', () => ({
useRampsController: () => ({
userRegion: mockUserRegion,
selectedProvider: mockSelectedProvider,
selectedToken: mockTokens?.allTokens?.[0] ?? null,
getWidgetUrl: mockGetWidgetUrl,
getBuyWidgetData: mockGetBuyWidgetData,
addPrecreatedOrder: mockAddPrecreatedOrder,
paymentMethodsLoading: false,
selectedPaymentMethod: mockSelectedPaymentMethod,
}),
Expand Down Expand Up @@ -694,9 +699,10 @@ describe('BuildQuote', () => {
const continueButton = getByTestId('build-quote-continue-button');
expect(continueButton).not.toBeDisabled();

mockGetWidgetUrl.mockResolvedValue(
'https://global.transak.com/?apiKey=test',
);
mockGetBuyWidgetData.mockResolvedValue({
url: 'https://global.transak.com/?apiKey=test',
orderId: null,
});

await act(async () => {
fireEvent.press(continueButton);
Expand Down Expand Up @@ -751,9 +757,10 @@ describe('BuildQuote', () => {
error: [],
customActions: [],
};
mockGetWidgetUrl.mockResolvedValue(
'https://global.transak.com/?apiKey=test',
);
mockGetBuyWidgetData.mockResolvedValue({
url: 'https://global.transak.com/?apiKey=test',
orderId: null,
});

const { getByTestId } = renderWithTheme(<BuildQuote />);

Expand Down Expand Up @@ -967,7 +974,7 @@ describe('BuildQuote', () => {

it('logs error when aggregator provider has no URL', async () => {
const mockLogger = jest.spyOn(Logger, 'error');
mockGetWidgetUrl.mockResolvedValue(null);
mockGetBuyWidgetData.mockResolvedValue(null);

const mockQuote = {
provider: '/providers/mercuryo',
Expand Down Expand Up @@ -1069,7 +1076,7 @@ describe('BuildQuote', () => {
});

expect(mockNavigate).not.toHaveBeenCalled();
expect(mockGetWidgetUrl).not.toHaveBeenCalled();
expect(mockGetBuyWidgetData).not.toHaveBeenCalled();
});

it('does not navigate when quote payment method does not match selected payment method', async () => {
Expand Down Expand Up @@ -1115,7 +1122,7 @@ describe('BuildQuote', () => {
});

expect(mockNavigate).not.toHaveBeenCalled();
expect(mockGetWidgetUrl).not.toHaveBeenCalled();
expect(mockGetBuyWidgetData).not.toHaveBeenCalled();
});

it('does not navigate when quote has payment method but selectedPaymentMethod is missing', async () => {
Expand Down Expand Up @@ -1158,12 +1165,12 @@ describe('BuildQuote', () => {
});

expect(mockNavigate).not.toHaveBeenCalled();
expect(mockGetWidgetUrl).not.toHaveBeenCalled();
expect(mockGetBuyWidgetData).not.toHaveBeenCalled();
});

it('logs error when getWidgetUrl throws', async () => {
it('logs error when getBuyWidgetData throws', async () => {
const mockLogger = jest.spyOn(Logger, 'error');
mockGetWidgetUrl.mockRejectedValue(new Error('Widget URL fetch failed'));
mockGetBuyWidgetData.mockRejectedValue(new Error('Widget URL fetch failed'));

const mockQuote = {
provider: '/providers/mercuryo',
Expand Down Expand Up @@ -1250,7 +1257,7 @@ describe('BuildQuote', () => {
});

expect(mockNavigate).not.toHaveBeenCalled();
expect(mockGetWidgetUrl).not.toHaveBeenCalled();
expect(mockGetBuyWidgetData).not.toHaveBeenCalled();
expect(mockTransakCheckExistingToken).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -1284,7 +1291,10 @@ describe('BuildQuote', () => {
error: [],
customActions: [],
};
mockGetWidgetUrl.mockResolvedValue('https://example.com/widget');
mockGetBuyWidgetData.mockResolvedValue({
url: 'https://example.com/widget',
orderId: null,
});

const { getByTestId } = renderWithTheme(<BuildQuote />);

Expand Down Expand Up @@ -1337,9 +1347,10 @@ describe('BuildQuote', () => {
customActions: [],
};

mockGetWidgetUrl.mockResolvedValue(
'https://global.transak.com/?apiKey=test',
);
mockGetBuyWidgetData.mockResolvedValue({
url: 'https://global.transak.com/?apiKey=test',
orderId: null,
});

const { getByTestId } = renderWithTheme(<BuildQuote />);

Expand Down
97 changes: 68 additions & 29 deletions app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React, {
useRef,
useState,
} from 'react';
import { View } from 'react-native';
import { Linking, View } from 'react-native';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import type { CaipChainId } from '@metamask/utils';

Expand All @@ -31,6 +31,7 @@ import { useStyles } from '../../../../hooks/useStyles';
import styleSheet from './BuildQuote.styles';
import { useFormatters } from '../../../../hooks/useFormatters';
import { useTokenNetworkInfo } from '../../hooks/useTokenNetworkInfo';
import { normalizeProviderCode } from '@metamask/ramps-controller';
import { useRampsController } from '../../hooks/useRampsController';
import { useRampsQuotes } from '../../hooks/useRampsQuotes';
import { createSettingsModalNavDetails } from '../Modals/SettingsModal';
Expand Down Expand Up @@ -63,6 +64,8 @@ import {
} from '../../../../../reducers/fiatOrders';
import TruncatedError from '../../components/TruncatedError';
import { PROVIDER_LINKS } from '../../Aggregator/types';
import InAppBrowser from 'react-native-inappbrowser-reborn';
import Device from '../../../../../util/device';

export interface BuildQuoteParams {
assetId?: string;
Expand Down Expand Up @@ -134,7 +137,8 @@ function BuildQuote() {
userRegion,
selectedProvider,
selectedToken,
getWidgetUrl,
getBuyWidgetData,
addPrecreatedOrder,
paymentMethodsLoading,
selectedPaymentMethod,
} = useRampsController();
Expand Down Expand Up @@ -306,18 +310,18 @@ function BuildQuote() {
const { success } = quotesResponse;
const providerMatches = (q: (typeof success)[0]) =>
q.provider === selectedProvider.id;
let result: (typeof success)[0] | null = null;
if (success.length === 1) {
return providerMatches(success[0]) ? success[0] : null;
}
if (success.length > 1) {
result = providerMatches(success[0]) ? success[0] : null;
} else if (success.length > 1) {
const match = success.find(
(q) =>
providerMatches(q) &&
q.quote?.paymentMethod === selectedPaymentMethod.id,
);
return match ?? null;
result = match ?? null;
}
return null;
return result;
}, [quotesResponse, selectedProvider, selectedPaymentMethod]);

const networkInfo = useMemo(() => {
Expand Down Expand Up @@ -430,25 +434,30 @@ function BuildQuote() {
]);

const handleContinuePress = useCallback(async () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleContinuePress was managing 2 separate branches for Native, Aggregator and now would have to handle custom actions. I've split the logic into separate functions for readability, testability, etc..

if (isNativeProvider(selectedQuote)) {
  await handleNativeProviderContinue();
} else if (isCustomAction(selectedQuote)) {
  await handleCustomActionContinue();
} else {
  await handleAggregatorContinue();
}

if (!selectedQuote || !selectedProvider) return;
if (!selectedQuote || !selectedProvider) {
return;
}
setNativeFlowError(null);

const quoteAmount =
const quoteAmountRaw =
selectedQuote.quote?.amountIn ??
(selectedQuote as { amountIn?: number }).amountIn;
const quoteAmount =
typeof quoteAmountRaw === 'string'
? Number(quoteAmountRaw)
: quoteAmountRaw;
Comment thread
cursor[bot] marked this conversation as resolved.
const quotePaymentMethod =
selectedQuote.quote?.paymentMethod ??
(selectedQuote as { paymentMethod?: string }).paymentMethod;

// Validate provider matches (prevents proceeding with wrong-provider quote)
if (selectedQuote.provider !== selectedProvider.id) return;
if (selectedQuote.provider !== selectedProvider.id) {
return;
}

// Validate amount matches
if (quoteAmount !== amountAsNumber) {
if (quoteAmount !== amountAsNumber || Number.isNaN(quoteAmount)) {
Comment thread
cursor[bot] marked this conversation as resolved.
return;
}

// Validate payment method context matches
if (quotePaymentMethod != null) {
if (
!selectedPaymentMethod ||
Expand Down Expand Up @@ -520,29 +529,57 @@ function BuildQuote() {

setIsContinueLoading(true);
try {
const fetchedWidgetUrl = await getWidgetUrl(selectedQuote);
const buyWidget = await getBuyWidgetData(selectedQuote);

if (fetchedWidgetUrl) {
const providerCode = selectedQuote.provider.startsWith('/providers/')
? selectedQuote.provider.split('/')[2] || selectedQuote.provider
: selectedQuote.provider;
if (buyWidget?.url) {
const isCustomAction = Boolean(
(selectedQuote.quote as { isCustomAction?: boolean })?.isCustomAction,
);
const providerCode = normalizeProviderCode(selectedQuote.provider);
const chainId = selectedToken?.chainId as CaipChainId | undefined;
const network = chainId?.includes(':')
? chainId.split(':')[1] || ''
: chainId || '';
const effectiveWallet = walletAddress ?? '';

const useExternalBrowser =
isCustomAction || buyWidget.browser === 'IN_APP_OS_BROWSER';
Comment thread
cursor[bot] marked this conversation as resolved.
if (useExternalBrowser) {
const effectiveOrderId = buyWidget.orderId?.trim() || null;
if (effectiveOrderId && effectiveWallet) {
addPrecreatedOrder({
Comment thread
georgeweiler marked this conversation as resolved.
orderId: effectiveOrderId,
providerCode,
walletAddress: effectiveWallet,
chainId: network || undefined,
Comment thread
cursor[bot] marked this conversation as resolved.
});
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (Device.isAndroid() || !(await InAppBrowser.isAvailable())) {
await Linking.openURL(buyWidget.url);
} else {
const redirectUrl = getRampCallbackBaseUrl();
try {
await InAppBrowser.openAuth(buyWidget.url, redirectUrl);
} finally {
InAppBrowser.closeAuth();
}
}
return;
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}

navigation.navigate(
...createCheckoutNavDetails({
Comment thread
georgeweiler marked this conversation as resolved.
url: fetchedWidgetUrl,
url: buyWidget.url,
providerName:
selectedProvider?.name || getQuoteProviderName(selectedQuote),
userAgent: getQuoteBuyUserAgent(selectedQuote),
providerCode,
providerType: FIAT_ORDER_PROVIDERS.RAMPS_V2,
walletAddress: walletAddress ?? undefined,
walletAddress: effectiveWallet || undefined,
network,
currency,
cryptocurrency: selectedToken?.symbol || '',
orderId: buyWidget.orderId?.trim() || undefined,
}),
);
} else {
Expand Down Expand Up @@ -573,7 +610,8 @@ function BuildQuote() {
walletAddress,
currency,
navigation,
getWidgetUrl,
getBuyWidgetData,
addPrecreatedOrder,
amountAsNumber,
selectedPaymentMethod,
transakCheckExistingToken,
Expand All @@ -587,24 +625,26 @@ function BuildQuote() {

const hasAmount = amountAsNumber > 0;

const quoteMatchesAmount =
debouncedPollingAmount === amountAsNumber && debouncedPollingAmount > 0;

const quoteMatchesCurrentContext = useMemo(() => {
if (!selectedQuote || !selectedProvider) return false;

const quoteAmount =
Comment thread
cursor[bot] marked this conversation as resolved.
const quoteAmountRaw =
selectedQuote.quote?.amountIn ??
(selectedQuote as { amountIn?: number }).amountIn;
const quoteAmount =
typeof quoteAmountRaw === 'string'
? Number(quoteAmountRaw)
: quoteAmountRaw;
const quotePaymentMethod =
selectedQuote.quote?.paymentMethod ??
(selectedQuote as { paymentMethod?: string }).paymentMethod;

// Provider must match (prevents using a stale quote for a different provider)
if (selectedQuote.provider !== selectedProvider.id) return false;

// Amount must match
if (quoteAmount !== amountAsNumber) return false;
// Amount must match (normalize: API may return amountIn as string)
Comment thread
cursor[bot] marked this conversation as resolved.
if (quoteAmount !== amountAsNumber || Number.isNaN(quoteAmount))
return false;
Comment thread
cursor[bot] marked this conversation as resolved.

// Payment method context must match
if (quotePaymentMethod != null) {
Expand All @@ -624,7 +664,6 @@ function BuildQuote() {
hasAmount &&
!selectedQuoteLoading &&
selectedQuote !== null &&
quoteMatchesAmount &&
Comment thread
cursor[bot] marked this conversation as resolved.
quoteMatchesCurrentContext;

const hasNoQuotes =
Expand Down
Loading