diff --git a/android/app/build.gradle b/android/app/build.gradle index 35041984aebe..401c273ee31b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,8 +187,13 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion +<<<<<<< HEAD + versionName "7.69.0" + versionCode 3892 +======= versionName "7.70.0" versionCode 3607 +>>>>>>> 1242530285d27aa5c0f2a7b7b53cc417231b3d58 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx index e1bfd11ab872..90ce3434ff8f 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx @@ -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(), diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx index d775e3c84719..57224581a4db 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx @@ -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, +const mockGetBuyWidgetData = jest.fn< + Promise<{ url: string; orderId?: string | null } | null>, [quote: Record] >(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 = @@ -215,12 +217,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, }), @@ -708,9 +713,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); @@ -765,9 +771,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(); @@ -981,7 +988,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', @@ -1083,7 +1090,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 () => { @@ -1129,7 +1136,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 () => { @@ -1172,12 +1179,14 @@ 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', @@ -1264,7 +1273,7 @@ describe('BuildQuote', () => { }); expect(mockNavigate).not.toHaveBeenCalled(); - expect(mockGetWidgetUrl).not.toHaveBeenCalled(); + expect(mockGetBuyWidgetData).not.toHaveBeenCalled(); expect(mockTransakCheckExistingToken).not.toHaveBeenCalled(); }); @@ -1298,7 +1307,10 @@ describe('BuildQuote', () => { error: [], customActions: [], }; - mockGetWidgetUrl.mockResolvedValue('https://example.com/widget'); + mockGetBuyWidgetData.mockResolvedValue({ + url: 'https://example.com/widget', + orderId: null, + }); const { getByTestId } = renderWithTheme(); @@ -1351,9 +1363,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(); diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index b1cf83689631..738e5019c50a 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -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'; @@ -32,6 +32,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'; @@ -64,7 +65,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; nativeFlowError?: string; @@ -135,7 +137,8 @@ function BuildQuote() { userRegion, selectedProvider, selectedToken, - getWidgetUrl, + getBuyWidgetData, + addPrecreatedOrder, paymentMethodsLoading, selectedPaymentMethod, } = useRampsController(); @@ -307,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(() => { @@ -422,25 +425,30 @@ function BuildQuote() { ]); const handleContinuePress = useCallback(async () => { - 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; 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)) { return; } - // Validate payment method context matches if (quotePaymentMethod != null) { if ( !selectedPaymentMethod || @@ -512,32 +520,185 @@ function BuildQuote() { setIsContinueLoading(true); try { - const fetchedWidgetUrl = await getWidgetUrl(selectedQuote); + const isCustomAction = Boolean( + (selectedQuote.quote as { isCustomAction?: boolean })?.isCustomAction, + ); + const providerCode = normalizeProviderCode(selectedQuote.provider); + + // TODO: remove all [Ramp][Debug] logging after PayPal redirect is verified + Logger.log('[Ramp][Debug] === handleContinuePress START ==='); + Logger.log('[Ramp][Debug] provider:', selectedQuote.provider); + Logger.log('[Ramp][Debug] providerCode:', providerCode); + Logger.log('[Ramp][Debug] isCustomAction:', isCustomAction); + Logger.log('[Ramp][Debug] quote.buyURL:', selectedQuote.quote?.buyURL); + Logger.log( + '[Ramp][Debug] quote.buyWidget:', + JSON.stringify(selectedQuote.quote?.buyWidget), + ); + Logger.log( + '[Ramp][Debug] quote keys:', + JSON.stringify(Object.keys(selectedQuote.quote ?? {})), + ); + + // The redirectUrl baked into the quote's buyURL at quote-fetch time is the + // HTTPS fake-callback. For providers that open an external OS browser (e.g. + // PayPal), we need a deep link instead so the browser redirects back to the + // app. We always override the redirectUrl before fetching the widget because + // the browser type is only known after the fetch (chicken-and-egg). This is + // safe: in-app browser providers ignore the redirectUrl parameter. + const deeplinkRedirectUrl = `metamask://on-ramp/providers/${providerCode}`; + Logger.log('[Ramp][Debug] deeplinkRedirectUrl:', deeplinkRedirectUrl); + + let quoteForWidget = selectedQuote; + if (selectedQuote.quote?.buyURL) { + const buyUrl = new URL(selectedQuote.quote.buyURL); + Logger.log( + '[Ramp][Debug] original buyURL redirectUrl param:', + buyUrl.searchParams.get('redirectUrl'), + ); + buyUrl.searchParams.set('redirectUrl', deeplinkRedirectUrl); + quoteForWidget = { + ...selectedQuote, + quote: { + ...selectedQuote.quote, + buyURL: buyUrl.toString(), + }, + }; + Logger.log('[Ramp][Debug] overridden buyURL:', buyUrl.toString()); + } else { + Logger.log( + '[Ramp][Debug] no buyURL on quote, skipping redirectUrl override', + ); + } + + Logger.log('[Ramp][Debug] calling getBuyWidgetData...'); + const buyWidget = await getBuyWidgetData(quoteForWidget); + Logger.log( + '[Ramp][Debug] getBuyWidgetData result:', + JSON.stringify(buyWidget), + ); + + if (buyWidget?.url) { + Logger.log('[Ramp][Debug] buyWidget.url:', buyWidget.url); + Logger.log('[Ramp][Debug] buyWidget.browser:', buyWidget.browser); + Logger.log('[Ramp][Debug] buyWidget.orderId:', buyWidget.orderId); - if (fetchedWidgetUrl) { - const providerCode = selectedQuote.provider.startsWith('/providers/') - ? selectedQuote.provider.split('/')[2] || selectedQuote.provider - : 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'; + Logger.log('[Ramp][Debug] useExternalBrowser:', useExternalBrowser); + + if (useExternalBrowser) { + const effectiveOrderId = buyWidget.orderId?.trim() || null; + Logger.log('[Ramp][Debug] effectiveOrderId:', effectiveOrderId); + Logger.log('[Ramp][Debug] effectiveWallet:', effectiveWallet); + + if (effectiveOrderId && effectiveWallet) { + Logger.log('[Ramp][Debug] calling addPrecreatedOrder...'); + addPrecreatedOrder({ + orderId: effectiveOrderId, + providerCode, + walletAddress: effectiveWallet, + chainId: network || undefined, + }); + } + + const isAndroid = Device.isAndroid(); + const inAppBrowserAvailable = + !isAndroid && (await InAppBrowser.isAvailable()); + Logger.log('[Ramp][Debug] isAndroid:', isAndroid); + Logger.log( + '[Ramp][Debug] InAppBrowser available:', + inAppBrowserAvailable, + ); + if (isAndroid || !inAppBrowserAvailable) { + Logger.log( + '[Ramp][Debug] opening with Linking.openURL:', + buyWidget.url, + ); + await Linking.openURL(buyWidget.url); + } else { + Logger.log('[Ramp][Debug] opening InAppBrowser.openAuth'); + Logger.log('[Ramp][Debug] url:', buyWidget.url); + Logger.log('[Ramp][Debug] redirectUrl:', deeplinkRedirectUrl); + try { + const result = await InAppBrowser.openAuth( + buyWidget.url, + deeplinkRedirectUrl, + ); + Logger.log( + '[Ramp][Debug] InAppBrowser result:', + JSON.stringify(result), + ); + if (result.type !== 'success' || !result.url) { + Logger.log( + '[Ramp][Debug] browser cancelled or no URL, returning early', + ); + return; + } + Logger.log( + '[Ramp][Debug] InAppBrowser success, proceeding to order details', + ); + } finally { + InAppBrowser.closeAuth(); + } + } + + if (effectiveOrderId) { + const orderCode = effectiveOrderId.includes('/orders/') + ? effectiveOrderId.split('/orders/')[1] + : effectiveOrderId; + Logger.log( + '[Ramp][Debug] navigating to RAMPS_ORDER_DETAILS with orderCode:', + orderCode, + '(from effectiveOrderId:', + effectiveOrderId, + ')', + ); + navigation.reset({ + index: 0, + routes: [ + { + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: { + orderId: orderCode, + showCloseButton: true, + providerCode, + walletAddress: effectiveWallet || undefined, + }, + }, + ], + }); + } else { + Logger.log('[Ramp][Debug] no orderId, skipping navigation'); + } + return; + } + + Logger.log('[Ramp][Debug] in-app browser flow, navigating to Checkout'); navigation.navigate( ...createCheckoutNavDetails({ - 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 { + Logger.log('[Ramp][Debug] buyWidget is null or has no url'); Logger.error( new Error('No widget URL available for aggregator provider'), { provider: selectedQuote.provider }, @@ -545,6 +706,7 @@ function BuildQuote() { setNativeFlowError(strings('deposit.buildQuote.unexpectedError')); } } catch (error) { + Logger.log('[Ramp][Debug] handleContinuePress CAUGHT ERROR:', error); Logger.error(error as Error, { provider: selectedQuote.provider, message: 'Failed to fetch widget URL', @@ -565,7 +727,8 @@ function BuildQuote() { walletAddress, currency, navigation, - getWidgetUrl, + getBuyWidgetData, + addPrecreatedOrder, amountAsNumber, selectedPaymentMethod, transakCheckExistingToken, @@ -579,15 +742,16 @@ function BuildQuote() { const hasAmount = amountAsNumber > 0; - const quoteMatchesAmount = - debouncedPollingAmount === amountAsNumber && debouncedPollingAmount > 0; - const quoteMatchesCurrentContext = useMemo(() => { if (!selectedQuote || !selectedProvider) return false; - const quoteAmount = + 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; @@ -595,8 +759,9 @@ function BuildQuote() { // 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) + if (quoteAmount !== amountAsNumber || Number.isNaN(quoteAmount)) + return false; // Payment method context must match if (quotePaymentMethod != null) { @@ -616,7 +781,6 @@ function BuildQuote() { hasAmount && !selectedQuoteLoading && selectedQuote !== null && - quoteMatchesAmount && quoteMatchesCurrentContext; const hasNoQuotes = diff --git a/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx b/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx index 3f4db1d34971..9eaa9312d149 100644 --- a/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx +++ b/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx @@ -36,12 +36,15 @@ jest.mock('../../../../hooks/useThunkDispatch', () => ({ const mockGetOrderFromCallback = jest.fn(); const mockAddOrder = jest.fn(); +const mockAddPrecreatedOrder = jest.fn(); jest.mock('../../../../../core/Engine', () => ({ context: { RampsController: { getOrderFromCallback: (...args: unknown[]) => mockGetOrderFromCallback(...args), addOrder: (...args: unknown[]) => mockAddOrder(...args), + addPrecreatedOrder: (...args: unknown[]) => + mockAddPrecreatedOrder(...args), }, }, })); @@ -208,15 +211,32 @@ describe('Checkout', () => { mockUseParams.mockReturnValue(V2_PARAMS); }); - it('dispatches addFiatCustomIdData on mount when customOrderId is provided', () => { + it('calls addPrecreatedOrder on mount when orderId is provided', () => { + mockUseParams.mockReturnValue({ + ...V2_PARAMS, + orderId: '/providers/transak/orders/custom-order-xyz', + }); + render(); + expect(mockAddPrecreatedOrder).toHaveBeenCalledWith({ + orderId: '/providers/transak/orders/custom-order-xyz', + providerCode: 'transak', + walletAddress: '0xabc', + chainId: '1', + }); + }); + + it('calls addPrecreatedOrder on mount when customOrderId is provided (backward compat)', () => { mockUseParams.mockReturnValue({ ...V2_PARAMS, customOrderId: 'custom-order-xyz', }); render(); - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ type: 'FIAT_ADD_CUSTOM_ID_DATA' }), - ); + expect(mockAddPrecreatedOrder).toHaveBeenCalledWith({ + orderId: 'custom-order-xyz', + providerCode: 'transak', + walletAddress: '0xabc', + chainId: '1', + }); }); it('ignores navigation state changes to non-callback URLs', async () => { @@ -354,9 +374,7 @@ describe('Checkout', () => { CALLBACK_URL, '0xabc', ); - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ type: 'FIAT_REMOVE_CUSTOM_ID_DATA' }), - ); + expect(mockAddOrder).toHaveBeenCalledWith(mockOrder); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ routes: expect.arrayContaining([ diff --git a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx index 2750806b14ba..4e565f9f9207 100644 --- a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx +++ b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx @@ -8,13 +8,9 @@ import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useTheme } from '../../../../../util/theme'; import { getDepositNavbarOptions } from '../../../Navbar'; import { callbackBaseUrl } from '../../Aggregator/sdk'; -import { - addFiatCustomIdData, - removeFiatCustomIdData, - getRampRoutingDecision, -} from '../../../../../reducers/fiatOrders'; +import { getRampRoutingDecision } from '../../../../../reducers/fiatOrders'; +import { normalizeProviderCode } from '@metamask/ramps-controller'; import { FIAT_ORDER_PROVIDERS } from '../../../../../constants/on-ramp'; -import { CustomIdData } from '../../../../../reducers/fiatOrders/types'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { @@ -47,7 +43,6 @@ import { getCheckoutCallback, removeCheckoutCallback, } from '../../utils/checkoutCallbackRegistry'; - interface CheckoutParams { url: string; providerName: string; @@ -55,7 +50,9 @@ interface CheckoutParams { userAgent?: string; /** V2 callback flow: provider code (e.g., "moonpay", "transak"). */ providerCode?: string; - /** V2: pre-order/custom order ID from BuyWidget. */ + /** V2: order ID from BuyWidget for polling. Prefer orderId; customOrderId kept for backward compatibility. */ + orderId?: string | null; + /** @deprecated Use orderId instead. */ customOrderId?: string | null; /** V2 callback flow: wallet address for this order. */ walletAddress?: string; @@ -85,14 +82,14 @@ const Checkout = () => { const previousUrlRef = useRef(null); const dispatch = useDispatch(); const [error, setError] = useState(''); - const [customIdData, setCustomIdData] = useState(); const isRedirectionHandledRef = useRef(false); const [key, setKey] = useState(0); const navigation = useNavigation(); const params = useParams(); const theme = useTheme(); const { styles } = useStyles(styleSheet, {}); - const { addOrder, getOrderFromCallback } = useRampsOrders(); + const { addOrder, addPrecreatedOrder, getOrderFromCallback } = + useRampsOrders(); const { trackEvent, createEventBuilder } = useAnalytics(); const rampRoutingDecision = useSelector(getRampRoutingDecision); const isV2Enabled = useRampsUnifiedV2Enabled(); @@ -101,6 +98,7 @@ const Checkout = () => { url: uri, providerCode, providerName, + orderId: orderIdParam, customOrderId, walletAddress, network, @@ -108,6 +106,7 @@ const Checkout = () => { onNavigationStateChange, callbackKey, } = params ?? {}; + const effectiveOrderId = (orderIdParam ?? customOrderId)?.trim() || null; const initialUriRef = useRef(uri); const callbackKeyRef = useRef(callbackKey); @@ -167,21 +166,27 @@ const Checkout = () => { }, [uri, createEventBuilder, trackEvent, rampRoutingDecision]); useEffect(() => { - if (!hasCallbackFlow || !customOrderId || !walletAddress || !network) { - return; - } - const data: CustomIdData = { - id: customOrderId, + // For external-browser flows (e.g. PayPal), addPrecreatedOrder is called in + // BuildQuote; the user never reaches Checkout. For WebView flows, + // 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. + const canRegister = + effectiveOrderId && providerCode && walletAddress && network; + if (!canRegister) return; + addPrecreatedOrder({ + orderId: effectiveOrderId, + providerCode: normalizeProviderCode(providerCode), + walletAddress, chainId: network, - account: walletAddress, - orderType: 'buy' as CustomIdData['orderType'], - createdAt: Date.now(), - lastTimeFetched: 0, - errorCount: 0, - }; - setCustomIdData(data); - dispatch(addFiatCustomIdData(data)); - }, [customOrderId, walletAddress, network, dispatch, hasCallbackFlow]); + }); + }, [ + effectiveOrderId, + walletAddress, + network, + providerCode, + addPrecreatedOrder, + ]); const handleNavigationStateChange = useCallback( async (navState: WebViewNavigation) => { @@ -217,10 +222,6 @@ const Checkout = () => { throw new Error('Order could not be retrieved from callback'); } - if (customIdData) { - dispatch(removeFiatCustomIdData(customIdData)); - } - addOrder(rampsOrder); dispatch(protectWalletModalVisible()); @@ -254,12 +255,11 @@ const Checkout = () => { } }, [ + dispatch, hasCallbackFlow, - customIdData, providerCode, walletAddress, navigation, - dispatch, addOrder, getOrderFromCallback, isV2Enabled, diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx index 1eff1cfc055e..ad21e6638b2d 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx @@ -12,11 +12,11 @@ const mockGetQuotes = jest.fn().mockResolvedValue({ customActions: [], }); -const mockGetWidgetUrl = jest.fn(); +const mockGetBuyWidgetData = jest.fn(); const defaultQuotesReturn = { getQuotes: mockGetQuotes, - getWidgetUrl: mockGetWidgetUrl, + getBuyWidgetData: mockGetBuyWidgetData, data: null, loading: false, error: null, diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx index f283c5fba98d..81f16e9cdbfc 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx @@ -159,9 +159,15 @@ function PaymentSelectionModal() { const renderPaymentMethod = useCallback( ({ item: paymentMethod }: { item: PaymentMethod }) => { + const isCustomActionQuote = (quote: { + quote?: { isCustomAction?: boolean }; + }) => + Boolean((quote.quote as { isCustomAction?: boolean })?.isCustomAction); const matchedQuote = quotes?.success?.find( - (quote) => quote.quote?.paymentMethod === paymentMethod.id, + (quote) => + quote.quote?.paymentMethod === paymentMethod.id && + !isCustomActionQuote(quote), ) ?? null; const hasQuoteError = !matchedQuote && diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx index 1f6237ce3658..269e0b8a2218 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.test.tsx @@ -62,7 +62,7 @@ const defaultMockController: UseRampsControllerResult = { paymentMethodsLoading: false, paymentMethodsError: null, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx index 561d3f4dda0f..aa603312f140 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelection.tsx @@ -235,14 +235,19 @@ const ProviderSelection: React.FC = ({ } const { provider } = item; + const isCustomActionQuote = (q: Quote) => + Boolean((q.quote as { isCustomAction?: boolean })?.isCustomAction); const matchedQuote = quotes?.success?.find( (q) => q.provider === provider.id && (!selectedPaymentMethod || - q.quote?.paymentMethod === selectedPaymentMethod.id), + q.quote?.paymentMethod === selectedPaymentMethod.id) && + !isCustomActionQuote(q), + ) ?? + quotes?.success?.find( + (q) => q.provider === provider.id && !isCustomActionQuote(q), ) ?? - quotes?.success?.find((q) => q.provider === provider.id) ?? null; const amountOut = matchedQuote?.quote?.amountOut; const cryptoAmount = diff --git a/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx b/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx index f9a0750f3ec9..a44f85d8f541 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/BankDetails.tsx @@ -22,6 +22,7 @@ import BankDetailRow from '../../Deposit/components/BankDetailRow'; import { RampsOrderStatus, type TransakDepositOrder, + normalizeProviderCode, } from '@metamask/ramps-controller'; import { useTheme } from '../../../../../util/theme'; import Button, { @@ -113,10 +114,7 @@ const V2BankDetails = () => { setDepositOrder(updatedDepositOrder); } - const providerCode = (order.provider?.id ?? '').replace( - '/providers/', - '', - ); + const providerCode = normalizeProviderCode(order.provider?.id ?? ''); await refreshOrder( providerCode, order.providerOrderId, diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx index a170648af590..e3999315832a 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx @@ -11,7 +11,10 @@ import { IconSize, FontWeight, } from '@metamask/design-system-react-native'; -import { RampsOrderStatus } from '@metamask/ramps-controller'; +import { + normalizeProviderCode, + RampsOrderStatus, +} from '@metamask/ramps-controller'; import Button, { ButtonVariants, ButtonSize, @@ -32,10 +35,13 @@ import { useRampsOrders } from '../../hooks/useRampsOrders'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { RampsOrderDetailsSelectorsIDs } from './OrderDetails.testIds'; - interface RampsOrderDetailsParams { orderId: string; showCloseButton?: boolean; + /** Optional: needed when order is not yet in controller state (e.g. race after PayPal return). */ + providerCode?: string; + /** Optional: needed with providerCode to fetch order from API when not in state. */ + walletAddress?: string; } export const createRampsOrderDetailsNavDetails = @@ -72,6 +78,7 @@ const OrderDetails = () => { const [isLoading, setIsLoading] = useState(isPending); const [error, setError] = useState(null); + const [hydrationAttempted, setHydrationAttempted] = useState(false); const theme = useTheme(); const { colors } = theme; const navigation = useNavigation(); @@ -119,10 +126,7 @@ const OrderDetails = () => { try { setError(null); setIsRefreshing(true); - const providerCode = (order.provider?.id ?? '').replace( - '/providers/', - '', - ); + const providerCode = normalizeProviderCode(order.provider?.id ?? ''); await refreshOrder( providerCode, order.providerOrderId, @@ -153,7 +157,81 @@ const OrderDetails = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // When order is missing but we have providerCode/wallet (e.g. race after PayPal return), + // fetch from API to hydrate controller state. + useEffect(() => { + if ( + order || + hydrationAttempted || + !params.providerCode || + !params.walletAddress || + !params.orderId + ) { + return; + } + setHydrationAttempted(true); + setIsLoading(true); + const providerCode = normalizeProviderCode(params.providerCode); + refreshOrder(providerCode, params.orderId, params.walletAddress) + .then(() => { + setIsLoading(false); + }) + .catch((err) => { + setError( + err instanceof Error + ? err.message + : strings('ramps_order_details.error_message'), + ); + setIsLoading(false); + }); + }, [ + order, + hydrationAttempted, + params.providerCode, + params.walletAddress, + params.orderId, + refreshOrder, + ]); + if (!order) { + if (isLoading) { + return ( + + + + + + + + ); + } + if (error && hydrationAttempted) { + return ( + + + + + + {strings('ramps_order_details.error_title')} + + + {error} + + + + + ); + } return ; } diff --git a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.test.tsx b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.test.tsx index ffe1511ddb2d..a08b3ce25f3f 100644 --- a/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.test.tsx +++ b/app/components/UI/Ramp/Views/Settings/RegionSelector/RegionSelector.test.tsx @@ -113,7 +113,7 @@ const mockUseRampsControllerInitialValues: ReturnType< paymentMethodsLoading: false, paymentMethodsError: null, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), diff --git a/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.test.tsx b/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.test.tsx index ce7fdc2f691c..48d7fe277178 100644 --- a/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.test.tsx +++ b/app/components/UI/Ramp/Views/TokenSelection/TokenSelection.test.tsx @@ -174,7 +174,7 @@ describe('TokenSelection Component', () => { paymentMethodsLoading: false, paymentMethodsError: null, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), @@ -315,7 +315,7 @@ describe('TokenSelection Component', () => { paymentMethodsLoading: false, paymentMethodsError: null, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), @@ -355,7 +355,7 @@ describe('TokenSelection Component', () => { paymentMethodsLoading: false, paymentMethodsError: null, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), @@ -409,7 +409,7 @@ describe('TokenSelection Component', () => { paymentMethodsLoading: false, paymentMethodsError: null, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), @@ -474,7 +474,7 @@ describe('TokenSelection Component', () => { paymentMethodsLoading: false, paymentMethodsError: null, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), @@ -549,7 +549,7 @@ describe('TokenSelection Component', () => { paymentMethodsLoading: false, paymentMethodsError: null, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), @@ -628,7 +628,7 @@ describe('TokenSelection Component', () => { paymentMethodsLoading: false, paymentMethodsError: null, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), @@ -688,7 +688,7 @@ describe('TokenSelection Component', () => { paymentMethodsLoading: false, paymentMethodsError: null, getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), orders: [], getOrderById: jest.fn(), addOrder: jest.fn(), diff --git a/app/components/UI/Ramp/hooks/useRampsController.test.ts b/app/components/UI/Ramp/hooks/useRampsController.test.ts index 7956b37ee51e..d99640d55fd4 100644 --- a/app/components/UI/Ramp/hooks/useRampsController.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsController.test.ts @@ -66,7 +66,7 @@ jest.mock('./useRampsPaymentMethods', () => ({ jest.mock('./useRampsQuotes', () => ({ useRampsQuotes: jest.fn(() => ({ getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), })), })); @@ -145,7 +145,7 @@ describe('useRampsController', () => { expect(typeof result.current.setSelectedToken).toBe('function'); expect(typeof result.current.setSelectedPaymentMethod).toBe('function'); expect(typeof result.current.getQuotes).toBe('function'); - expect(typeof result.current.getWidgetUrl).toBe('function'); + expect(typeof result.current.getBuyWidgetData).toBe('function'); expect(result.current.orders).toEqual([]); expect(typeof result.current.getOrderById).toBe('function'); diff --git a/app/components/UI/Ramp/hooks/useRampsController.ts b/app/components/UI/Ramp/hooks/useRampsController.ts index e7ca1dcc1f2b..2b6315e5859a 100644 --- a/app/components/UI/Ramp/hooks/useRampsController.ts +++ b/app/components/UI/Ramp/hooks/useRampsController.ts @@ -57,12 +57,13 @@ export interface UseRampsControllerResult { // Quotes getQuotes: UseRampsQuotesResult['getQuotes']; - getWidgetUrl: UseRampsQuotesResult['getWidgetUrl']; + getBuyWidgetData: UseRampsQuotesResult['getBuyWidgetData']; // Orders orders: UseRampsOrdersResult['orders']; getOrderById: UseRampsOrdersResult['getOrderById']; addOrder: UseRampsOrdersResult['addOrder']; + addPrecreatedOrder: UseRampsOrdersResult['addPrecreatedOrder']; removeOrder: UseRampsOrdersResult['removeOrder']; refreshOrder: UseRampsOrdersResult['refreshOrder']; getOrderFromCallback: UseRampsOrdersResult['getOrderFromCallback']; @@ -109,7 +110,7 @@ export interface UseRampsControllerResult { * * // Quotes * getQuotes, - * getWidgetUrl, + * getBuyWidgetData, * * } = useRampsController(); * ``` @@ -147,12 +148,13 @@ export function useRampsController(): UseRampsControllerResult { error: paymentMethodsError, } = useRampsPaymentMethods(); - const { getQuotes, getWidgetUrl } = useRampsQuotes(); + const { getQuotes, getBuyWidgetData } = useRampsQuotes(); const { orders, getOrderById, addOrder, + addPrecreatedOrder, removeOrder, refreshOrder, getOrderFromCallback, @@ -186,11 +188,12 @@ export function useRampsController(): UseRampsControllerResult { paymentMethodsError, getQuotes, - getWidgetUrl, + getBuyWidgetData, orders, getOrderById, addOrder, + addPrecreatedOrder, removeOrder, refreshOrder, getOrderFromCallback, diff --git a/app/components/UI/Ramp/hooks/useRampsOrders.test.ts b/app/components/UI/Ramp/hooks/useRampsOrders.test.ts index e5996b24eb5b..0c5048f6ba50 100644 --- a/app/components/UI/Ramp/hooks/useRampsOrders.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsOrders.test.ts @@ -6,6 +6,7 @@ import { RampsOrderStatus, type RampsOrder } from '@metamask/ramps-controller'; import { useRampsOrders } from './useRampsOrders'; const mockAddOrder = jest.fn(); +const mockAddPrecreatedOrder = jest.fn(); const mockRemoveOrder = jest.fn(); const mockGetOrder = jest.fn(); const mockGetOrderFromCallback = jest.fn(); @@ -14,6 +15,8 @@ jest.mock('../../../../core/Engine', () => ({ context: { RampsController: { addOrder: (...args: unknown[]) => mockAddOrder(...args), + addPrecreatedOrder: (...args: unknown[]) => + mockAddPrecreatedOrder(...args), removeOrder: (...args: unknown[]) => mockRemoveOrder(...args), getOrder: (...args: unknown[]) => mockGetOrder(...args), getOrderFromCallback: (...args: unknown[]) => @@ -182,6 +185,29 @@ describe('useRampsOrders', () => { expect(returnedOrder).toEqual(callbackOrder); }); + it('calls Engine.context.RampsController.addPrecreatedOrder', () => { + const store = createMockStore(); + const { result } = renderHook(() => useRampsOrders(), { + wrapper: wrapper(store), + }); + + act(() => { + result.current.addPrecreatedOrder({ + orderId: '/providers/transak/orders/abc-123', + providerCode: 'transak', + walletAddress: '0xabc', + chainId: '1', + }); + }); + + expect(mockAddPrecreatedOrder).toHaveBeenCalledWith({ + orderId: '/providers/transak/orders/abc-123', + providerCode: 'transak', + walletAddress: '0xabc', + chainId: '1', + }); + }); + it('exposes all expected functions', () => { const store = createMockStore(); const { result } = renderHook(() => useRampsOrders(), { @@ -190,6 +216,7 @@ describe('useRampsOrders', () => { expect(typeof result.current.getOrderById).toBe('function'); expect(typeof result.current.addOrder).toBe('function'); + expect(typeof result.current.addPrecreatedOrder).toBe('function'); expect(typeof result.current.removeOrder).toBe('function'); expect(typeof result.current.refreshOrder).toBe('function'); expect(typeof result.current.getOrderFromCallback).toBe('function'); diff --git a/app/components/UI/Ramp/hooks/useRampsOrders.ts b/app/components/UI/Ramp/hooks/useRampsOrders.ts index fac5e7ead232..7efcd4b0bd58 100644 --- a/app/components/UI/Ramp/hooks/useRampsOrders.ts +++ b/app/components/UI/Ramp/hooks/useRampsOrders.ts @@ -4,10 +4,18 @@ import type { RampsOrder } from '@metamask/ramps-controller'; import Engine from '../../../../core/Engine'; import { selectRampsOrders } from '../../../../selectors/rampsController'; +export interface AddPrecreatedOrderParams { + orderId: string; + providerCode: string; + walletAddress: string; + chainId?: string; +} + export interface UseRampsOrdersResult { orders: RampsOrder[]; getOrderById: (providerOrderId: string) => RampsOrder | undefined; addOrder: (order: RampsOrder) => void; + addPrecreatedOrder: (params: AddPrecreatedOrderParams) => void; removeOrder: (providerOrderId: string) => void; refreshOrder: ( providerCode: string, @@ -35,6 +43,12 @@ export function useRampsOrders(): UseRampsOrdersResult { [], ); + const addPrecreatedOrder = useCallback( + (params: AddPrecreatedOrderParams) => + Engine.context.RampsController.addPrecreatedOrder(params), + [], + ); + const removeOrder = useCallback( (providerOrderId: string) => Engine.context.RampsController.removeOrder(providerOrderId), @@ -61,6 +75,7 @@ export function useRampsOrders(): UseRampsOrdersResult { orders, getOrderById, addOrder, + addPrecreatedOrder, removeOrder, refreshOrder, getOrderFromCallback, diff --git a/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts b/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts index 0a0d4a7736cd..913cd22b6fbe 100644 --- a/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsQuotes.test.ts @@ -10,7 +10,7 @@ jest.mock('../../../../core/Engine', () => ({ context: { RampsController: { getQuotes: jest.fn(), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), }, }, })); @@ -44,14 +44,14 @@ describe('useRampsQuotes', () => { }); describe('return value structure', () => { - it('returns getQuotes and getWidgetUrl functions', () => { + it('returns getQuotes and getBuyWidgetData functions', () => { const store = createMockStore(); const { result } = renderHook(() => useRampsQuotes(), { wrapper: wrapper(store), }); expect(typeof result.current.getQuotes).toBe('function'); - expect(typeof result.current.getWidgetUrl).toBe('function'); + expect(typeof result.current.getBuyWidgetData).toBe('function'); }); it('returns data, loading, error with default values when no options', () => { @@ -93,8 +93,8 @@ describe('useRampsQuotes', () => { }); }); - describe('getWidgetUrl', () => { - it('calls Engine.context.RampsController.getWidgetUrl with quote', async () => { + describe('getBuyWidgetData', () => { + it('calls Engine.context.RampsController.getBuyWidgetData with quote', async () => { const store = createMockStore(); const { result } = renderHook(() => useRampsQuotes(), { wrapper: wrapper(store), @@ -111,16 +111,16 @@ describe('useRampsQuotes', () => { } as Quote; ( - Engine.context.RampsController.getWidgetUrl as jest.Mock + Engine.context.RampsController.getBuyWidgetData as jest.Mock ).mockResolvedValue('https://global.transak.com/?apiKey=test'); await act(async () => { - await result.current.getWidgetUrl(testQuote); + await result.current.getBuyWidgetData(testQuote); }); - expect(Engine.context.RampsController.getWidgetUrl).toHaveBeenCalledWith( - testQuote, - ); + expect( + Engine.context.RampsController.getBuyWidgetData, + ).toHaveBeenCalledWith(testQuote); }); }); diff --git a/app/components/UI/Ramp/hooks/useRampsQuotes.ts b/app/components/UI/Ramp/hooks/useRampsQuotes.ts index 6d1eb84c8440..63cb622d72e3 100644 --- a/app/components/UI/Ramp/hooks/useRampsQuotes.ts +++ b/app/components/UI/Ramp/hooks/useRampsQuotes.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import type { QuotesResponse } from '@metamask/ramps-controller'; +import type { BuyWidget, QuotesResponse } from '@metamask/ramps-controller'; import type { Quote } from '../types'; import Engine from '../../../../core/Engine'; @@ -28,12 +28,12 @@ export interface UseRampsQuotesResult { */ getQuotes: (options: GetQuotesOptions) => Promise; /** - * Fetches the widget URL from a quote for redirect providers. - * Makes a request to the buyURL endpoint to get the actual provider widget URL. + * Fetches the widget data from a quote for redirect providers. + * Makes a request to the buyURL endpoint to get the actual provider widget URL and order ID. * @param quote - The quote to fetch the widget URL from. - * @returns Promise resolving to the widget URL string, or null if not available. + * @returns Promise resolving to the full BuyWidget (url, browser, orderId), or null if not available. */ - getWidgetUrl: (quote: Quote) => Promise; + getBuyWidgetData: (quote: Quote) => Promise; /** Fetched quotes response when options is used. Null when not fetching or fetch skipped. */ data: QuotesResponse | null; /** True while a fetch is in progress. Reset when fetch settles, unless the effect was cancelled (component unmounted). */ @@ -50,7 +50,7 @@ export interface UseRampsQuotesResult { * Loading is reset when the fetch settles unless the effect was cancelled (avoids setState on unmounted component). * * @param options - GetQuotesOptions to fetch, or null/undefined to skip fetch. - * @returns getQuotes, getWidgetUrl, and when options used: data, loading, error. + * @returns getQuotes, getBuyWidgetData, and when options used: data, loading, error. */ export function useRampsQuotes( options?: GetQuotesOptions | null, @@ -60,8 +60,8 @@ export function useRampsQuotes( [], ); - const getWidgetUrl = useCallback( - (quote: Quote) => Engine.context.RampsController.getWidgetUrl(quote), + const getBuyWidgetData = useCallback( + (quote: Quote) => Engine.context.RampsController.getBuyWidgetData(quote), [], ); @@ -109,7 +109,7 @@ export function useRampsQuotes( return { getQuotes, - getWidgetUrl, + getBuyWidgetData, data, loading, error, diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts index 0a8acfdf5ef4..d4eda110bb93 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts @@ -163,6 +163,7 @@ jest.mock('@metamask/ramps-controller', () => ({ (orderId: string, _env: string) => `transformed-${orderId}`, ), }, + normalizeProviderCode: (code: string) => code.replace(/^\/providers\//, ''), })); jest.mock('../Deposit/constants', () => ({ diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.ts b/app/components/UI/Ramp/hooks/useTransakRouting.ts index 850a4ff187e5..e83dec59811d 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.ts @@ -5,7 +5,10 @@ import { useSelector } from 'react-redux'; import type { CaipChainId } from '@metamask/utils'; import { strings } from '../../../../../locales/i18n'; import { useTheme } from '../../../../util/theme'; -import { type TransakBuyQuote } from '@metamask/ramps-controller'; +import { + normalizeProviderCode, + type TransakBuyQuote, +} from '@metamask/ramps-controller'; import { REDIRECTION_URL } from '../Deposit/constants'; import { generateThemeParameters } from '../Deposit/utils'; import { BasicInfoFormData } from '../Deposit/Views/BasicInfo/BasicInfo'; @@ -276,9 +279,9 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { throw new Error('Missing order'); } - const providerCode = ( - depositOrder.provider || 'transak-native' - ).replace('/providers/', ''); + const providerCode = normalizeProviderCode( + String(depositOrder.provider ?? 'transak-native'), + ); const rampsOrder = await refreshOrder( providerCode, depositOrder.providerOrderId, @@ -427,9 +430,9 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { throw new Error('Missing order'); } - const providerCode = ( - depositOrder.provider || 'transak-native' - ).replace('/providers/', ''); + const providerCode = normalizeProviderCode( + String(depositOrder.provider ?? 'transak-native'), + ); const rampsOrder = await refreshOrder( providerCode, depositOrder.providerOrderId, diff --git a/app/components/UI/Ramp/orderProcessor/unifiedOrderProcessor.ts b/app/components/UI/Ramp/orderProcessor/unifiedOrderProcessor.ts index 1150981f5336..18505ac2e64a 100644 --- a/app/components/UI/Ramp/orderProcessor/unifiedOrderProcessor.ts +++ b/app/components/UI/Ramp/orderProcessor/unifiedOrderProcessor.ts @@ -1,4 +1,7 @@ -import type { RampsOrder } from '@metamask/ramps-controller'; +import { + normalizeProviderCode, + type RampsOrder, +} from '@metamask/ramps-controller'; import { FIAT_ORDER_PROVIDERS, FIAT_ORDER_STATES, @@ -101,7 +104,7 @@ export async function processUnifiedOrder( try { const data = order.data as RampsOrder; const orderCode = data.providerOrderId; - const providerCode = data.provider?.id?.replace('/providers/', '') ?? ''; + const providerCode = normalizeProviderCode(data.provider?.id ?? ''); if (!providerCode || !orderCode) { throw new Error( diff --git a/app/components/UI/Ramp/utils/displayOrder.test.ts b/app/components/UI/Ramp/utils/displayOrder.test.ts index 942059efd256..4e2314b11e9d 100644 --- a/app/components/UI/Ramp/utils/displayOrder.test.ts +++ b/app/components/UI/Ramp/utils/displayOrder.test.ts @@ -250,5 +250,28 @@ describe('displayOrder', () => { expect(result[0].source).toBe('v2'); expect(result[1].source).toBe('legacy'); }); + + it('filters out precreated and expired V2 orders', () => { + const precreatedOrder = createMockRampsOrder({ + providerOrderId: 'precreated-1', + status: RampsOrderStatus.Precreated, + }); + const expiredOrder = createMockRampsOrder({ + providerOrderId: 'expired-1', + status: RampsOrderStatus.IdExpired, + }); + const visibleOrder = createMockRampsOrder({ + providerOrderId: 'visible-1', + status: RampsOrderStatus.Completed, + }); + + const result = mergeDisplayOrders( + [], + [precreatedOrder, expiredOrder, visibleOrder], + ); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('visible-1'); + }); }); }); diff --git a/app/components/UI/Ramp/utils/displayOrder.ts b/app/components/UI/Ramp/utils/displayOrder.ts index 2b9499b6077c..b0dd2e5e8087 100644 --- a/app/components/UI/Ramp/utils/displayOrder.ts +++ b/app/components/UI/Ramp/utils/displayOrder.ts @@ -74,11 +74,19 @@ export function rampsOrderToDisplayOrder(order: RampsOrder): DisplayOrder { }; } +const HIDDEN_ORDER_STATUSES = new Set([ + RampsOrderStatus.Precreated, + RampsOrderStatus.IdExpired, +]); + export function mergeDisplayOrders( legacyOrders: FiatOrder[], v2Orders: RampsOrder[], ): DisplayOrder[] { - const v2Ids = new Set(v2Orders.map((o) => o.providerOrderId)); + const visibleV2Orders = v2Orders.filter( + (o) => !HIDDEN_ORDER_STATUSES.has(o.status), + ); + const v2Ids = new Set(visibleV2Orders.map((o) => o.providerOrderId)); const legacy = legacyOrders .filter((o) => { @@ -88,7 +96,7 @@ export function mergeDisplayOrders( }) .map(fiatOrderToDisplayOrder); - const v2 = v2Orders.map(rampsOrderToDisplayOrder); + const v2 = visibleV2Orders.map(rampsOrderToDisplayOrder); return [...legacy, ...v2].sort((a, b) => b.createdAt - a.createdAt); } diff --git a/app/core/__mocks__/MockedEngine.ts b/app/core/__mocks__/MockedEngine.ts index 473b470d0f20..1fce9d4b1ce2 100644 --- a/app/core/__mocks__/MockedEngine.ts +++ b/app/core/__mocks__/MockedEngine.ts @@ -117,7 +117,7 @@ export const mockedEngine = { error: [], customActions: [], }), - getWidgetUrl: jest.fn(), + getBuyWidgetData: jest.fn(), }, }, hasFunds: jest.fn(), diff --git a/bitrise.yml b/bitrise.yml index 5f18ca0a1b3e..19045af06219 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.70.0 - opts: is_expand: false - VERSION_NUMBER: 3821 + VERSION_NUMBER: 3892 - opts: is_expand: false FLASK_VERSION_NAME: 7.70.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3821 + FLASK_VERSION_NUMBER: 3892 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index a4dacee0d3be..cb08ef52664d 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3821; + CURRENT_PROJECT_VERSION = 3892; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3821; + CURRENT_PROJECT_VERSION = 3892; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3821; + CURRENT_PROJECT_VERSION = 3892; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3821; + CURRENT_PROJECT_VERSION = 3892; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3821; + CURRENT_PROJECT_VERSION = 3892; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3821; + CURRENT_PROJECT_VERSION = 3892; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; diff --git a/package.json b/package.json index ed22ba05e024..fd7921d19f7b 100644 --- a/package.json +++ b/package.json @@ -180,7 +180,8 @@ "bn.js@npm:4.11.6": "4.12.3", "bn.js@npm:5.2.1": "5.2.3", "@metamask/bridge-controller@npm:^67.1.1": "patch:@metamask/bridge-controller@npm%3A67.2.0#~/.yarn/patches/@metamask-bridge-controller-npm-67.2.0-32d3aafe1f.patch", - "@metamask/bridge-status-controller@npm:^67.0.1": "patch:@metamask/bridge-status-controller@npm%3A67.0.1#~/.yarn/patches/@metamask-bridge-status-controller-npm-67.0.1-d8a41d9c02.patch" + "@metamask/bridge-status-controller@npm:^67.0.1": "patch:@metamask/bridge-status-controller@npm%3A67.0.1#~/.yarn/patches/@metamask-bridge-status-controller-npm-67.0.1-d8a41d9c02.patch", + "@metamask/ramps-controller": "npm:@metamask-previews/ramps-controller@10.0.0-preview-685dbf46b" }, "dependencies": { "@config-plugins/detox": "^9.0.0", @@ -270,7 +271,7 @@ "@metamask/preinstalled-example-snap": "^0.7.2", "@metamask/profile-metrics-controller": "^2.0.0", "@metamask/profile-sync-controller": "^27.1.0", - "@metamask/ramps-controller": "^10.2.0", + "@metamask/ramps-controller": "^10.0.0", "@metamask/react-native-acm": "^1.0.1", "@metamask/react-native-actionsheet": "2.4.2", "@metamask/react-native-button": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index 57724be519ba..8ebb323229f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9486,14 +9486,14 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^10.2.0": - version: 10.2.0 - resolution: "@metamask/ramps-controller@npm:10.2.0" +"@metamask/ramps-controller@npm:@metamask-previews/ramps-controller@10.0.0-preview-685dbf46b": + version: 10.0.0-preview-685dbf46b + resolution: "@metamask-previews/ramps-controller@npm:10.0.0-preview-685dbf46b" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/messenger": "npm:^0.3.0" - checksum: 10/4c9e10f3948a4e0f44f3a98fd2a7a220585e74793ad4cc899b27be6ea3c428c76fb95b0987697a0dd62a98221868ce2bfb3e40495cef00f4909e5dd88dec152e + checksum: 10/0bcad109220cc3a7eecdb7dd575e209399edb2723d8ec70b503a72247ff1af3366d0da11e439bcd613e91eccab0177c24cc25ac34e37bca9ef0e78ec58a135ca languageName: node linkType: hard @@ -35486,7 +35486,7 @@ __metadata: "@metamask/profile-metrics-controller": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^27.1.0" "@metamask/providers": "npm:^18.3.1" - "@metamask/ramps-controller": "npm:^10.2.0" + "@metamask/ramps-controller": "npm:^10.0.0" "@metamask/react-native-acm": "npm:^1.0.1" "@metamask/react-native-actionsheet": "npm:2.4.2" "@metamask/react-native-button": "npm:^3.0.0"