diff --git a/app/components/UI/Ramp/headless/useHeadlessBuy.test.ts b/app/components/UI/Ramp/headless/useHeadlessBuy.test.ts index 0a3b4d55db33..faa0e93a00e6 100644 --- a/app/components/UI/Ramp/headless/useHeadlessBuy.test.ts +++ b/app/components/UI/Ramp/headless/useHeadlessBuy.test.ts @@ -241,9 +241,24 @@ describe('useHeadlessBuy', () => { providers: ['/providers/transak-native'], redirectUrl: 'https://callback.metamask.io', forceRefresh: undefined, + autoSelectProvider: true, }); }); + it('triggers the shared controller pick via autoSelectProvider so the UI never ranks locally', async () => { + const { result } = renderHook(() => useHeadlessBuy()); + await act(async () => { + await result.current.getQuotes({ + assetId: 'eip155:1/erc20:0xabc', + amount: 10, + walletAddress: '0xOVERRIDE', + }); + }); + expect(mockGetQuotesRaw).toHaveBeenCalledWith( + expect.objectContaining({ autoSelectProvider: true }), + ); + }); + it('uses an explicit walletAddress override when provided', async () => { const { result } = renderHook(() => useHeadlessBuy()); await act(async () => { diff --git a/app/components/UI/Ramp/headless/useHeadlessBuy.ts b/app/components/UI/Ramp/headless/useHeadlessBuy.ts index e13e1adb8ccc..4003d9475415 100644 --- a/app/components/UI/Ramp/headless/useHeadlessBuy.ts +++ b/app/components/UI/Ramp/headless/useHeadlessBuy.ts @@ -110,6 +110,11 @@ export function useHeadlessBuy(): HeadlessBuyResult { providers: params.providerIds, redirectUrl: params.redirectUrl ?? getRampCallbackBaseUrl(), forceRefresh: params.forceRefresh, + // Trigger the controller's shared, scope-aware selection so the best + // quote lands at `success[0]` and headless consumers never rank or + // sort quotes locally (P2.M6). Ignored by the controller when an + // explicit `providers` list is passed. + autoSelectProvider: true, }); }, [getQuotesRaw], diff --git a/app/components/UI/Ramp/hooks/useContinueWithQuote.test.ts b/app/components/UI/Ramp/hooks/useContinueWithQuote.test.ts index 55a1d844ed3e..2c18d00cffc2 100644 --- a/app/components/UI/Ramp/hooks/useContinueWithQuote.test.ts +++ b/app/components/UI/Ramp/hooks/useContinueWithQuote.test.ts @@ -1,8 +1,19 @@ import { renderHook, act } from '@testing-library/react-native'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; +import { RampsOrderStatus } from '@metamask/ramps-controller'; import Routes from '../../../../constants/navigation/Routes'; import { useContinueWithQuote } from './useContinueWithQuote'; +import { + createSession, + getSession, + setStatus, + __resetSessionRegistryForTests, +} from '../headless/sessionRegistry'; +import type { + HeadlessBuyCallbacks, + HeadlessBuyParams, +} from '../headless/types'; jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), @@ -112,6 +123,8 @@ const mockNavigate = jest.fn(); const mockNavigationReset = jest.fn(); const mockGetBuyWidgetData = jest.fn(); const mockAddPrecreatedOrder = jest.fn(); +const mockGetOrderFromCallback = jest.fn(); +const mockAddOrder = jest.fn(); const mockCheckExistingToken = jest.fn(); const mockGetBuyQuote = jest.fn(); const mockRouteAfterAuth = jest.fn(); @@ -201,6 +214,8 @@ const buildController = (overrides: ControllerOverrides = {}) => ({ selectedPaymentMethod: SELECTED_PAYMENT_METHOD, getBuyWidgetData: mockGetBuyWidgetData, addPrecreatedOrder: mockAddPrecreatedOrder, + getOrderFromCallback: mockGetOrderFromCallback, + addOrder: mockAddOrder, ...overrides, }); @@ -569,6 +584,217 @@ describe('useContinueWithQuote', () => { }); }); + describe('headless external-browser branch (P2.M1 / P2.M4)', () => { + const HEADLESS_ORDER = { + id: '/providers/moonpay/orders/ord-777', + providerOrderId: 'ord-777', + status: RampsOrderStatus.Pending, + }; + + const CUSTOM_ACTION_QUOTE = { + provider: 'paypal', + id: 'quote-paypal-1', + inputAmount: 100, + inputCurrency: 'USD', + outputAmount: '0.05', + outputCurrency: { symbol: 'ETH', assetId: 'eip155:1/slip44:60' }, + quote: { + // No external-browser hint; the custom-action flag alone must route it + // through the external path. + isCustomAction: true, + buyURL: 'https://widget.example.com/checkout', + }, + } as const; + + let callbacks: { + onOrderCreated: jest.Mock; + onError: jest.Mock; + onClose: jest.Mock; + }; + let sessionId: string; + + const startSession = () => { + callbacks = { + onOrderCreated: jest.fn(), + onError: jest.fn(), + onClose: jest.fn(), + }; + const session = createSession( + { + quote: WIDGET_PROVIDER_QUOTE as unknown as HeadlessBuyParams['quote'], + assetId: 'eip155:1/slip44:60', + amount: 100, + }, + callbacks as unknown as HeadlessBuyCallbacks, + ); + sessionId = session.id; + setStatus(sessionId, 'continued'); + }; + + beforeEach(() => { + __resetSessionRegistryForTests(); + mockDeviceIsAndroid.mockReturnValue(false); + mockInAppBrowser.isAvailable.mockResolvedValue(true); + mockGetBuyWidgetData.mockResolvedValue({ + url: 'https://widget.example.com/checkout', + browser: 'IN_APP_OS_BROWSER', + }); + startSession(); + }); + + it('resolves the order and fires onOrderCreated + onClose(completed) on iOS openAuth success', async () => { + mockInAppBrowser.openAuth.mockResolvedValue({ + type: 'success', + url: 'metamask://on-ramp/providers/moonpay?orderId=ord-777', + }); + mockGetOrderFromCallback.mockResolvedValue(HEADLESS_ORDER); + + const { result } = renderHook(() => useContinueWithQuote()); + const caught = await invoke(result, WIDGET_PROVIDER_QUOTE, { + ...CTX, + headlessSessionId: sessionId, + }); + + expect(caught).toBeUndefined(); + expect(mockGetOrderFromCallback).toHaveBeenCalledWith( + 'moonpay', + 'metamask://on-ramp/providers/moonpay?orderId=ord-777', + '0x1234567890123456789012345678901234567890', + ); + expect(mockAddOrder).toHaveBeenCalledWith(HEADLESS_ORDER); + expect(callbacks.onOrderCreated).toHaveBeenCalledWith('ord-777'); + expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'completed' }); + expect(callbacks.onError).not.toHaveBeenCalled(); + // Never navigates to RAMPS_ORDER_DETAILS in headless mode. + expect(mockNavigationReset).not.toHaveBeenCalled(); + expect(getSession(sessionId)).toBeUndefined(); + }); + + it('fires onClose(user_dismissed) when the iOS auth browser is cancelled', async () => { + mockInAppBrowser.openAuth.mockResolvedValue({ type: 'cancel' }); + + const { result } = renderHook(() => useContinueWithQuote()); + const caught = await invoke(result, WIDGET_PROVIDER_QUOTE, { + ...CTX, + headlessSessionId: sessionId, + }); + + expect(caught).toBeUndefined(); + expect(callbacks.onClose).toHaveBeenCalledWith({ + reason: 'user_dismissed', + }); + expect(callbacks.onOrderCreated).not.toHaveBeenCalled(); + expect(callbacks.onError).not.toHaveBeenCalled(); + expect(mockNavigationReset).not.toHaveBeenCalled(); + }); + + it('fires onClose(user_dismissed) when the resolved order is a bailed/placeholder order', async () => { + mockInAppBrowser.openAuth.mockResolvedValue({ + type: 'success', + url: 'metamask://on-ramp/providers/moonpay', + }); + mockGetOrderFromCallback.mockResolvedValue({ + ...HEADLESS_ORDER, + status: RampsOrderStatus.Precreated, + }); + + const { result } = renderHook(() => useContinueWithQuote()); + const caught = await invoke(result, WIDGET_PROVIDER_QUOTE, { + ...CTX, + headlessSessionId: sessionId, + }); + + expect(caught).toBeUndefined(); + expect(callbacks.onClose).toHaveBeenCalledWith({ + reason: 'user_dismissed', + }); + expect(callbacks.onOrderCreated).not.toHaveBeenCalled(); + expect(mockAddOrder).not.toHaveBeenCalled(); + }); + + it('fails the session when the callback order resolution throws', async () => { + mockInAppBrowser.openAuth.mockResolvedValue({ + type: 'success', + url: 'metamask://on-ramp/providers/moonpay', + }); + mockGetOrderFromCallback.mockRejectedValue(new Error('lookup failed')); + + const { result } = renderHook(() => useContinueWithQuote()); + const caught = await invoke(result, WIDGET_PROVIDER_QUOTE, { + ...CTX, + headlessSessionId: sessionId, + }); + + expect(caught).toBeUndefined(); + expect(callbacks.onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'QUOTE_FAILED' }), + ); + expect(callbacks.onOrderCreated).not.toHaveBeenCalled(); + expect(callbacks.onClose).not.toHaveBeenCalled(); + }); + + it('keeps the session live on Android/system-browser open success (deeplink return handles completion)', async () => { + mockDeviceIsAndroid.mockReturnValue(true); + + const { result } = renderHook(() => useContinueWithQuote()); + const caught = await invoke(result, WIDGET_PROVIDER_QUOTE, { + ...CTX, + headlessSessionId: sessionId, + }); + + expect(caught).toBeUndefined(); + expect(mockLinkingOpenURL).toHaveBeenCalledWith( + 'https://widget.example.com/checkout', + ); + // No terminal callback and no BuildQuote reset: the session stays live. + expect(callbacks.onOrderCreated).not.toHaveBeenCalled(); + expect(callbacks.onClose).not.toHaveBeenCalled(); + expect(callbacks.onError).not.toHaveBeenCalled(); + expect(mockNavigationReset).not.toHaveBeenCalled(); + expect(getSession(sessionId)).toBeDefined(); + }); + + it('throws (so the Host fails the session) when the external open is rejected', async () => { + mockDeviceIsAndroid.mockReturnValue(true); + mockLinkingOpenURL.mockRejectedValueOnce(new Error('cannot open url')); + + const { result } = renderHook(() => useContinueWithQuote()); + const caught = await invoke(result, WIDGET_PROVIDER_QUOTE, { + ...CTX, + headlessSessionId: sessionId, + }); + + expect(caught).toBeInstanceOf(Error); + expect(callbacks.onOrderCreated).not.toHaveBeenCalled(); + expect(callbacks.onClose).not.toHaveBeenCalled(); + }); + + it('routes a custom-action quote through the headless external path (P2.M4)', async () => { + mockInAppBrowser.openAuth.mockResolvedValue({ + type: 'success', + url: 'metamask://on-ramp/providers/paypal?orderId=ord-777', + }); + mockGetOrderFromCallback.mockResolvedValue(HEADLESS_ORDER); + + const { result } = renderHook(() => useContinueWithQuote()); + const caught = await invoke(result, CUSTOM_ACTION_QUOTE, { + ...CTX, + headlessSessionId: sessionId, + }); + + expect(caught).toBeUndefined(); + // Custom-action quotes always use the external path, so openAuth ran and + // the terminal order-created callback fired. + expect(mockInAppBrowser.openAuth).toHaveBeenCalled(); + expect(mockGetOrderFromCallback).toHaveBeenCalledWith( + 'paypal', + 'metamask://on-ramp/providers/paypal?orderId=ord-777', + '0x1234567890123456789012345678901234567890', + ); + expect(callbacks.onOrderCreated).toHaveBeenCalledWith('ord-777'); + }); + }); + describe('context overrides (headless-ready)', () => { // These tests prove that callers without controller-seeded state (the // headless flow's Host) can drive `continueWithQuote` end-to-end by diff --git a/app/components/UI/Ramp/hooks/useContinueWithQuote.ts b/app/components/UI/Ramp/hooks/useContinueWithQuote.ts index 4f2adad08f94..6e845a460034 100644 --- a/app/components/UI/Ramp/hooks/useContinueWithQuote.ts +++ b/app/components/UI/Ramp/hooks/useContinueWithQuote.ts @@ -4,12 +4,16 @@ import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import InAppBrowser from 'react-native-inappbrowser-reborn'; import type { CaipChainId } from '@metamask/utils'; -import { normalizeProviderCode } from '@metamask/ramps-controller'; +import { + normalizeProviderCode, + RampsOrderStatus, +} from '@metamask/ramps-controller'; import { strings } from '../../../../../locales/i18n'; import { FIAT_ORDER_PROVIDERS } from '../../../../constants/on-ramp'; import { selectHasAgreedTransakNativePolicy } from '../../../../reducers/fiatOrders'; import Device from '../../../../util/device'; +import Logger from '../../../../util/Logger'; import { buildQuoteWithRedirectUrl, @@ -29,11 +33,30 @@ import { createCheckoutNavDetails } from '../Views/Checkout'; import { createV2EnterEmailNavDetails } from '../Views/NativeFlow/EnterEmail'; import { createV2VerifyIdentityNavDetails } from '../Views/NativeFlow/VerifyIdentity'; +import { + closeSession, + failSession, + getSession, +} from '../headless/sessionRegistry'; +import { dismissHeadlessFlow } from '../headless/headlessEntryNavigation'; + import { useRampsController } from './useRampsController'; import { useTransakController } from './useTransakController'; import { useTransakRouting } from './useTransakRouting'; import useRampAccountAddress from './useRampAccountAddress'; +/** + * Order statuses that indicate the provider callback resolved to a + * non-completed / placeholder order (the user backed out or the order never + * materialized). Mirrors `BuildQuote`'s `isBailedOrderStatus`; duplicated here + * to avoid importing the heavy `BuildQuote` screen into this hook. + */ +const BAILED_ORDER_STATUSES: ReadonlySet = new Set([ + RampsOrderStatus.Precreated, + RampsOrderStatus.IdExpired, + RampsOrderStatus.Unknown, +]); + export interface ContinueWithQuoteContext { amount: number; assetId: string; @@ -110,6 +133,8 @@ export function useContinueWithQuote( userRegion, getBuyWidgetData, addPrecreatedOrder, + getOrderFromCallback, + addOrder, } = useRampsController(); const { checkExistingToken: transakCheckExistingToken, @@ -137,6 +162,60 @@ export function useContinueWithQuote( [navigation], ); + // Headless external-browser terminal for iOS `InAppBrowser.openAuth` success: + // resolve the provider callback into an order and route it to the session's + // terminal callbacks instead of navigating to `RAMPS_ORDER_DETAILS` + // (P2.M1). A resolved, non-bailed order fires `onOrderCreated` then + // `onClose({ reason: 'completed' })`; a null/bailed order is a user exit + // (`onClose({ reason: 'user_dismissed' })`); a resolution failure is a + // technical error (`failSession`). Always pops the transparent headless + // modal so the consumer regains foreground. + const completeHeadlessExternalOrder = useCallback( + async ({ + sessionId, + providerCode, + callbackUrl, + walletAddress: orderWallet, + }: { + sessionId: string; + providerCode: string; + callbackUrl: string; + walletAddress: string; + }): Promise => { + const session = getSession(sessionId); + if (!session) { + return; + } + try { + const order = await getOrderFromCallback( + providerCode, + callbackUrl, + orderWallet, + ); + if (!order || BAILED_ORDER_STATUSES.has(order.status)) { + closeSession(sessionId, { reason: 'user_dismissed' }); + dismissHeadlessFlow(navigation); + return; + } + addOrder(order); + try { + session.callbacks.onOrderCreated(order.providerOrderId); + } catch (callbackError) { + Logger.error( + callbackError as Error, + 'useContinueWithQuote: onOrderCreated callback threw', + ); + } + closeSession(sessionId, { reason: 'completed' }); + dismissHeadlessFlow(navigation); + } catch (error) { + failSession(sessionId, error, 'QUOTE_FAILED'); + dismissHeadlessFlow(navigation); + } + }, + [getOrderFromCallback, addOrder, navigation], + ); + // The aggregator-format `_quote` is used only by the caller to dispatch // to this branch via `isNativeProvider`. The native (Transak) path fetches // its own `TransakBuyQuote` via `transakGetBuyQuote` below. @@ -287,6 +366,8 @@ export function useContinueWithQuote( ); if (useExternalBrowser) { + const headlessSessionId = ctx.headlessSessionId; + if (effectiveOrderId && effectiveWallet) { addPrecreatedOrder({ orderId: effectiveOrderId, @@ -301,7 +382,16 @@ export function useContinueWithQuote( !isAndroid && (await InAppBrowser.isAvailable()); if (isAndroid || !inAppBrowserAvailable) { + // Only the OPEN is observable here; a rejection bubbles to the + // outer catch and (headless) fails the session. On success the + // provider page and completion are unobservable synchronously. await Linking.openURL(buyWidget.url); + if (headlessSessionId) { + // Keep the session live: a real success returns via the deeplink + // path (P2.M2) and abandonment is inferred by the Host's + // focus-dismissal machinery. Never navigate to BuildQuote here. + return; + } navigateAfterExternalBrowser({ returnDestination: 'buildQuote' }); return; } @@ -313,15 +403,38 @@ export function useContinueWithQuote( ); if (result.type !== 'success' || !result.url) { + // User cancelled / dismissed the auth browser. + if (headlessSessionId) { + closeSession(headlessSessionId, { reason: 'user_dismissed' }); + dismissHeadlessFlow(navigation); + return; + } navigateAfterExternalBrowser({ returnDestination: 'buildQuote' }); return; } if (!effectiveWallet) { + if (headlessSessionId) { + // A success URL with no wallet cannot be resolved into an + // order; surface a technical failure rather than a silent exit. + throw new Error( + 'Missing wallet address for external order resolution', + ); + } navigateAfterExternalBrowser({ returnDestination: 'buildQuote' }); return; } + if (headlessSessionId) { + await completeHeadlessExternalOrder({ + sessionId: headlessSessionId, + providerCode, + callbackUrl: result.url, + walletAddress: effectiveWallet, + }); + return; + } + navigateAfterExternalBrowser({ returnDestination: 'order', callbackUrl: result.url, @@ -371,6 +484,7 @@ export function useContinueWithQuote( getBuyWidgetData, addPrecreatedOrder, navigateAfterExternalBrowser, + completeHeadlessExternalOrder, ], ); diff --git a/app/components/UI/Ramp/hooks/useRampsQuotes.ts b/app/components/UI/Ramp/hooks/useRampsQuotes.ts index 6326b7e5fd54..fd32d558a031 100644 --- a/app/components/UI/Ramp/hooks/useRampsQuotes.ts +++ b/app/components/UI/Ramp/hooks/useRampsQuotes.ts @@ -17,6 +17,19 @@ export interface GetQuotesOptions { redirectUrl?: string; forceRefresh?: boolean; ttl?: number; + /** + * When true (and no explicit `providers` list is passed), the controller + * runs its shared, scope-aware selection (`getSmartSelectedQuote`) and places + * the single best quote at `success[0]`. Lets the headless facade consume the + * controller's ranking instead of re-sorting quotes in the UI. + */ + autoSelectProvider?: boolean; + /** + * Provider ids to prefer during selection, in priority order. Forwarded to + * the controller's `getSmartSelectedQuote` ranking. Only used on the + * auto-select path. + */ + preferredProviderIds?: string[]; } export interface UseRampsQuotesResult { diff --git a/package.json b/package.json index a21051eed672..82082f1ec6db 100644 --- a/package.json +++ b/package.json @@ -172,10 +172,11 @@ "previewBuilds": { "@metamask/ramps-controller": { "type": "non-breaking", - "previewVersion": "15.1.0-preview-685219bbf" + "previewVersion": "15.1.0-preview-bbd2df29f" } }, "resolutions": { + "@metamask/ramps-controller": "npm:@metamask-previews/ramps-controller@15.1.0-preview-bbd2df29f", "@metamask/perps-controller": "^9.0.0", "@metamask/network-controller": "33.0.0", "@react-native-community/viewpager": "patch:@react-native-community/viewpager@npm%3A3.3.0#~/.yarn/patches/@react-native-community-viewpager-npm-3.3.0.patch", diff --git a/yarn.lock b/yarn.lock index a2741903ea19..dbdcd35071fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9640,15 +9640,15 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:@metamask-previews/ramps-controller@15.1.0-preview-685219bbf": - version: 15.1.0-preview-685219bbf - resolution: "@metamask-previews/ramps-controller@npm:15.1.0-preview-685219bbf" +"@metamask/ramps-controller@npm:@metamask-previews/ramps-controller@15.1.0-preview-bbd2df29f": + version: 15.1.0-preview-bbd2df29f + resolution: "@metamask-previews/ramps-controller@npm:15.1.0-preview-bbd2df29f" dependencies: "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^28.2.0" - checksum: 10/4ca574eba554517f720b20d3539e71020632ffa09c9176713852687355dd24fc2613184c07a236e48af1af23065bdc0a50c54c53661f1b8b6e48c0843304a0df + checksum: 10/b1600dc26963da31e9ae1aef4f46ce479f21ec1a0684e7a7508394ae405cc74f1522ce62e8acb4bc3250452e5407a07704a5bdb4d20c0127d733955bbc80c254 languageName: node linkType: hard