Skip to content

Commit 026c91e

Browse files
committed
Merge branch 'main' of https://github.com/MetaMask/metamask-mobile into chore--add-money-deposit-hook
2 parents 240529b + 2adf64f commit 026c91e

27 files changed

Lines changed: 1139 additions & 495 deletions

File tree

app/components/UI/Earn/hooks/useTronClaimUnstakedTrx.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ describe('useTronClaimUnstakedTrx', () => {
2929
name: 'Tron Account',
3030
snap: {
3131
id: 'npm:@metamask/tron-wallet-snap',
32-
name: 'Tron Wallet Snap',
33-
enabled: true,
3432
},
3533
importTime: 0,
3634
keyring: { type: 'snap' },

app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ jest.mock('../../utils/v2OrderToast', () => ({
5555
showV2OrderToast: jest.fn(),
5656
}));
5757

58+
jest.mock('../../headless/sessionRegistry', () => ({
59+
getSession: jest.fn(),
60+
closeSession: jest.fn(),
61+
}));
62+
5863
jest.mock('../../../../../util/Logger', () => ({
5964
error: jest.fn(),
6065
log: jest.fn(),
@@ -607,4 +612,184 @@ describe('Checkout', () => {
607612
);
608613
});
609614
});
615+
616+
describe('headless session flow', () => {
617+
const mockGetSession = jest.requireMock('../../headless/sessionRegistry')
618+
.getSession as jest.Mock;
619+
const mockCloseSession = jest.requireMock('../../headless/sessionRegistry')
620+
.closeSession as jest.Mock;
621+
const showV2OrderToastMock = jest.requireMock('../../utils/v2OrderToast')
622+
.showV2OrderToast as jest.Mock;
623+
624+
const mockOrder = {
625+
providerOrderId: 'headless-order-1',
626+
cryptoCurrency: { symbol: 'ETH' },
627+
cryptoAmount: '0.5',
628+
status: 'Pending',
629+
};
630+
631+
const callbackFlowParams = {
632+
url: 'https://provider.example.com/checkout',
633+
providerName: 'Test Provider',
634+
providerCode: 'moonpay',
635+
walletAddress: '0xdeadbeef',
636+
headlessSessionId: 'hs-1',
637+
};
638+
639+
let mockParentPop: jest.Mock;
640+
641+
beforeEach(() => {
642+
mockGetSession.mockReset();
643+
mockCloseSession.mockReset();
644+
mockParentPop = jest.fn();
645+
mockNavigation.getParent.mockReturnValue({ pop: mockParentPop });
646+
mockGetOrderFromCallback.mockResolvedValue(mockOrder);
647+
mockUseRampsUnifiedV2Enabled.mockReturnValue(true);
648+
});
649+
650+
it('fires onOrderCreated, closes the session, and pops the ramp stack when a live session is present', async () => {
651+
const onOrderCreated = jest.fn();
652+
mockGetSession.mockReturnValue({
653+
id: 'hs-1',
654+
status: 'continued',
655+
callbacks: {
656+
onOrderCreated,
657+
onError: jest.fn(),
658+
onClose: jest.fn(),
659+
},
660+
});
661+
mockUseParams.mockReturnValue(callbackFlowParams);
662+
663+
const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);
664+
665+
await act(async () => {
666+
fireEvent.press(getByTestId('trigger-callback-navigation'));
667+
});
668+
669+
await waitFor(() => {
670+
expect(onOrderCreated).toHaveBeenCalledWith('headless-order-1');
671+
});
672+
expect(mockCloseSession).toHaveBeenCalledWith('hs-1', {
673+
reason: 'completed',
674+
});
675+
expect(mockParentPop).toHaveBeenCalled();
676+
expect(mockNavigation.reset).not.toHaveBeenCalled();
677+
expect(showV2OrderToastMock).not.toHaveBeenCalled();
678+
});
679+
680+
it('still adds the order to Redux and dispatches protect-wallet when headless', async () => {
681+
mockGetSession.mockReturnValue({
682+
id: 'hs-1',
683+
status: 'continued',
684+
callbacks: {
685+
onOrderCreated: jest.fn(),
686+
onError: jest.fn(),
687+
onClose: jest.fn(),
688+
},
689+
});
690+
mockUseParams.mockReturnValue(callbackFlowParams);
691+
692+
const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);
693+
694+
await act(async () => {
695+
fireEvent.press(getByTestId('trigger-callback-navigation'));
696+
});
697+
698+
await waitFor(() => {
699+
expect(mockAddOrder).toHaveBeenCalledWith(mockOrder);
700+
});
701+
expect(mockDispatch).toHaveBeenCalledWith({
702+
type: 'PROTECT_WALLET_MODAL_VISIBLE',
703+
});
704+
});
705+
706+
it('swallows consumer onOrderCreated errors and still closes + pops', async () => {
707+
const Logger = jest.requireMock('../../../../../util/Logger') as {
708+
error: jest.Mock;
709+
};
710+
const throwingCallback = jest.fn(() => {
711+
throw new Error('consumer bug');
712+
});
713+
mockGetSession.mockReturnValue({
714+
id: 'hs-1',
715+
status: 'continued',
716+
callbacks: {
717+
onOrderCreated: throwingCallback,
718+
onError: jest.fn(),
719+
onClose: jest.fn(),
720+
},
721+
});
722+
mockUseParams.mockReturnValue(callbackFlowParams);
723+
724+
const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);
725+
726+
await act(async () => {
727+
fireEvent.press(getByTestId('trigger-callback-navigation'));
728+
});
729+
730+
await waitFor(() => {
731+
expect(throwingCallback).toHaveBeenCalled();
732+
});
733+
expect(Logger.error).toHaveBeenCalledWith(
734+
expect.any(Error),
735+
'UnifiedCheckout: onOrderCreated callback threw',
736+
);
737+
expect(mockCloseSession).toHaveBeenCalledWith('hs-1', {
738+
reason: 'completed',
739+
});
740+
expect(mockParentPop).toHaveBeenCalled();
741+
});
742+
743+
it('falls back to the regular reset + toast when session id is present but session is missing from registry', async () => {
744+
mockGetSession.mockReturnValue(undefined);
745+
mockUseParams.mockReturnValue(callbackFlowParams);
746+
747+
const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);
748+
749+
await act(async () => {
750+
fireEvent.press(getByTestId('trigger-callback-navigation'));
751+
});
752+
753+
await waitFor(() => {
754+
expect(showV2OrderToastMock).toHaveBeenCalledWith(
755+
expect.objectContaining({ orderId: 'headless-order-1' }),
756+
);
757+
});
758+
expect(mockNavigation.reset).toHaveBeenCalledWith(
759+
expect.objectContaining({
760+
routes: [
761+
expect.objectContaining({
762+
params: expect.objectContaining({
763+
orderId: 'headless-order-1',
764+
}),
765+
}),
766+
],
767+
}),
768+
);
769+
expect(mockCloseSession).not.toHaveBeenCalled();
770+
expect(mockParentPop).not.toHaveBeenCalled();
771+
});
772+
773+
it('takes the regular non-headless path when headlessSessionId is absent', async () => {
774+
mockUseParams.mockReturnValue({
775+
url: 'https://provider.example.com/checkout',
776+
providerName: 'Test Provider',
777+
providerCode: 'moonpay',
778+
walletAddress: '0xdeadbeef',
779+
});
780+
781+
const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);
782+
783+
await act(async () => {
784+
fireEvent.press(getByTestId('trigger-callback-navigation'));
785+
});
786+
787+
await waitFor(() => {
788+
expect(mockNavigation.reset).toHaveBeenCalled();
789+
});
790+
expect(showV2OrderToastMock).toHaveBeenCalled();
791+
expect(mockCloseSession).not.toHaveBeenCalled();
792+
expect(mockParentPop).not.toHaveBeenCalled();
793+
});
794+
});
610795
});

app/components/UI/Ramp/Views/Checkout/Checkout.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard';
2828
import useRampsUnifiedV2Enabled from '../../hooks/useRampsUnifiedV2Enabled';
2929
import { showV2OrderToast } from '../../utils/v2OrderToast';
30+
import { closeSession, getSession } from '../../headless/sessionRegistry';
3031
import { useStyles } from '../../../../hooks/useStyles';
3132
import styleSheet from './Checkout.styles';
3233
import Device from '../../../../../util/device';
@@ -56,6 +57,14 @@ interface CheckoutParams {
5657
providerType?: FIAT_ORDER_PROVIDERS;
5758
/** Optional callback invoked on navigation state changes after URL de-duplication (e.g. redirect URLs). */
5859
onNavigationStateChange?: (navState: { url: string }) => void;
60+
/**
61+
* When set, Checkout is participating in a headless buy session. On
62+
* successful callback the screen fires the session's `onOrderCreated`
63+
* callback, closes the session, and pops the ramp stack instead of
64+
* resetting to `RAMPS_ORDER_DETAILS`. The `showV2OrderToast` surface is
65+
* also suppressed — headless consumers drive their own UI.
66+
*/
67+
headlessSessionId?: string;
5968
}
6069

6170
export const createCheckoutNavDetails = createNavigationDetails<CheckoutParams>(
@@ -87,6 +96,7 @@ const Checkout = () => {
8796
network,
8897
userAgent,
8998
onNavigationStateChange,
99+
headlessSessionId,
90100
} = params ?? {};
91101
const effectiveOrderId = (orderIdParam ?? customOrderId)?.trim() || null;
92102

@@ -178,6 +188,26 @@ const Checkout = () => {
178188
addOrder(rampsOrder);
179189
dispatch(protectWalletModalVisible());
180190

191+
// Headless mode: hand the orderId to the consumer, close the
192+
// session, and unwind out of the ramp stack so the caller regains
193+
// foreground. Skip the toast + RAMPS_ORDER_DETAILS reset — both
194+
// are user-facing UI the headless consumer didn't ask for.
195+
const session = getSession(headlessSessionId);
196+
if (headlessSessionId && session) {
197+
try {
198+
session.callbacks.onOrderCreated(rampsOrder.providerOrderId);
199+
} catch (callbackError) {
200+
Logger.error(
201+
callbackError as Error,
202+
'UnifiedCheckout: onOrderCreated callback threw',
203+
);
204+
}
205+
closeSession(headlessSessionId, { reason: 'completed' });
206+
// @ts-expect-error navigation prop mismatch
207+
navigation.getParent()?.pop();
208+
return;
209+
}
210+
181211
if (isV2Enabled) {
182212
showV2OrderToast({
183213
orderId: rampsOrder.providerOrderId,
@@ -217,6 +247,7 @@ const Checkout = () => {
217247
getOrderFromCallback,
218248
isV2Enabled,
219249
params?.cryptocurrency,
250+
headlessSessionId,
220251
],
221252
);
222253

app/components/UI/Ramp/headless/PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
- [x] **Phase 4c** — Make `useContinueWithQuote` headless-ready — extend `ContinueWithQuoteContext` with optional overrides so callers without controller state (the Host) can drive it from a `Quote`
1414
- [x] **Phase 5 (revised)** — Quote-first headless start path — `startHeadlessBuy({ quote, redirectUrl? })` creates a session carrying the quote, navigates to Headless Host, Host calls `continueWithQuote(quote, ctx)` and re-orchestrates after auth loops
1515
- [ ] **Phase 5b (deferred)**`startHeadlessBuy({ assetId, amount, paymentMethodId, providerId? })` "open BuildQuote / Host fetches quotes" mode — picked up after the quote-first path is stable
16-
- [ ] **Phase 6** — Bypass order-processing redirect in Transak/aggregator routing when headless; fire `onOrderCreated` and end session
16+
- [x] **Phase 6** — Bypass order-processing redirect in Transak/aggregator routing when headless; fire `onOrderCreated` and end session
1717
- [ ] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError`
1818
- [ ] **Phase 8** — Cancellation + `onClose` semantics (including user-dismissed detection)
1919
- [ ] **Phase 9** — Expose `getOrder` / `refreshOrder` from hook and show in playground

app/components/UI/Ramp/hooks/useContinueWithQuote.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ export function useContinueWithQuote(
328328
currency: effectiveCurrency,
329329
cryptocurrency: effectiveCryptoSymbol,
330330
orderId: buyWidget.orderId?.trim() || undefined,
331+
headlessSessionId: ctx.headlessSessionId,
331332
}),
332333
);
333334
} catch (error) {

0 commit comments

Comments
 (0)