Skip to content

Commit 65552c5

Browse files
fix: correctly handle the deep link order redirects (#29858)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** Adds a deeplink handler for the on-ramp provider return flow. When an external ramp provider (e.g. Transak) redirects the user back into the app after a purchase, the deeplink now lands on the Ramps Order Details screen for the corresponding order instead of being dropped or routed through the unrelated buy-intent flow. The change introduces a new `on-ramp` deeplink action distinct from the existing `buy` / `buy-crypto` actions. The new action represents the *return* leg of an external purchase (an order to inspect), whereas `buy` represents an *intent* to start a new purchase. The two collapse into the same `DeepLinkRoute.BUY` analytics bucket so the funnel stays unchanged. A URL of the form `https://link.metamask.io/on-ramp?orderId=<id>` (or the `metamask://on-ramp?orderId=<id>` scheme) parses `orderId` from the query string and navigates to `Routes.RAMP.RAMPS_ORDER_DETAILS` with `showCloseButton: true`. Errors during URL parsing are caught and logged. ## **Changelog** CHANGELOG entry: Added handling for on-ramp provider return deeplinks so users land directly on their order details after completing or canceling a purchase with an external provider. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-3533 ## **Manual testing steps** ```gherkin Feature: On-ramp return deeplink Scenario: user is redirected back to the app with an orderId Given the user has started a buy with an external ramp provider And the provider has issued a return URL with an orderId When the app opens the deeplink "https://link.metamask.io/on-ramp?orderId=<id>" Then the Ramps Order Details screen opens for that order And the screen shows the close button Scenario: user opens an on-ramp deeplink without an orderId Given the user opens a malformed or stripped on-ramp deeplink When the app opens the deeplink "https://link.metamask.io/on-ramp" Then the Ramps Order Details screen opens with no orderId And the screen handles the missing id gracefully (loading or empty state) Scenario: user opens an on-ramp deeplink with malformed input Given the deeplink contains an unparseable URL fragment When the app receives the deeplink Then no navigation occurs And the error is logged via Logger.error ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> <img width="511" height="1136" alt="image" src="https://github.com/user-attachments/assets/1fc31477-616f-45b7-9c94-ae3eace6a78e" /> ### **After** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/b4f629fd-5b1d-4c42-abd2-66225af25915 ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it extends universal-link parsing/routing and navigation for a new deep link action; mistakes could misroute users or break existing ramp deeplinks, but changes are scoped and covered by unit tests. > > **Overview** > Adds a new `on-ramp` deep link action to handle external on-ramp provider return redirects. > > Universal-link handling now routes `.../on-ramp...` to a new `handleRampReturnUrl` helper which parses `orderId` from the query string and navigates to `Routes.RAMP.RAMPS_ORDER_DETAILS` (with `showCloseButton: true`), logging and aborting on malformed URLs. Analytics mapping buckets `on-ramp` under the existing `BUY` route, and tests were added/updated to cover the new handler and routing. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit bb9767d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Darius Costolas <10818970+meltingice1337@users.noreply.github.com>
1 parent aab2c60 commit 65552c5

9 files changed

Lines changed: 150 additions & 0 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import Routes from '../../../../constants/navigation/Routes';
2+
import handleRampReturnUrl from './handleRampReturnUrl';
3+
import NavigationService from '../../../../core/NavigationService';
4+
import Logger from '../../../../util/Logger';
5+
6+
jest.mock('../../../../core/NavigationService', () => ({
7+
navigation: {
8+
navigate: jest.fn(),
9+
},
10+
}));
11+
12+
jest.mock('../../../../util/Logger', () => ({
13+
error: jest.fn(),
14+
}));
15+
16+
describe('handleRampReturnUrl', () => {
17+
beforeEach(() => {
18+
jest.clearAllMocks();
19+
});
20+
21+
it('navigates to RAMPS_ORDER_DETAILS with orderId from query string', () => {
22+
handleRampReturnUrl({ rampReturnPath: '?orderId=abc123' });
23+
24+
expect(NavigationService.navigation.navigate).toHaveBeenCalledWith(
25+
Routes.RAMP.RAMPS_ORDER_DETAILS,
26+
{ orderId: 'abc123', showCloseButton: true },
27+
);
28+
});
29+
30+
it('parses orderId from a path with leading slash', () => {
31+
handleRampReturnUrl({ rampReturnPath: '/return?orderId=order-42' });
32+
33+
expect(NavigationService.navigation.navigate).toHaveBeenCalledWith(
34+
Routes.RAMP.RAMPS_ORDER_DETAILS,
35+
{ orderId: 'order-42', showCloseButton: true },
36+
);
37+
});
38+
39+
it('parses orderId from an absolute URL', () => {
40+
handleRampReturnUrl({
41+
rampReturnPath: 'https://example.com/return?orderId=zzz',
42+
});
43+
44+
expect(NavigationService.navigation.navigate).toHaveBeenCalledWith(
45+
Routes.RAMP.RAMPS_ORDER_DETAILS,
46+
{ orderId: 'zzz', showCloseButton: true },
47+
);
48+
});
49+
50+
it('navigates with undefined orderId when the query parameter is absent', () => {
51+
handleRampReturnUrl({ rampReturnPath: '?other=value' });
52+
53+
expect(NavigationService.navigation.navigate).toHaveBeenCalledWith(
54+
Routes.RAMP.RAMPS_ORDER_DETAILS,
55+
{ orderId: undefined, showCloseButton: true },
56+
);
57+
});
58+
59+
it('navigates with undefined orderId when there is no query string', () => {
60+
handleRampReturnUrl({ rampReturnPath: '/some-path' });
61+
62+
expect(NavigationService.navigation.navigate).toHaveBeenCalledWith(
63+
Routes.RAMP.RAMPS_ORDER_DETAILS,
64+
{ orderId: undefined, showCloseButton: true },
65+
);
66+
});
67+
68+
it('navigates with undefined orderId for an empty path', () => {
69+
handleRampReturnUrl({ rampReturnPath: '' });
70+
71+
expect(NavigationService.navigation.navigate).toHaveBeenCalledWith(
72+
Routes.RAMP.RAMPS_ORDER_DETAILS,
73+
{ orderId: undefined, showCloseButton: true },
74+
);
75+
});
76+
77+
it('logs an error and does not navigate when the URL constructor throws', () => {
78+
handleRampReturnUrl({ rampReturnPath: 'http://[' });
79+
80+
expect(Logger.error).toHaveBeenCalled();
81+
expect(NavigationService.navigation.navigate).not.toHaveBeenCalled();
82+
});
83+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Routes from '../../../../constants/navigation/Routes';
2+
import NavigationService from '../../../../core/NavigationService';
3+
import Logger from '../../../../util/Logger';
4+
5+
interface HandleRampReturnUrlParams {
6+
rampReturnPath: string;
7+
}
8+
9+
export default function handleRampReturnUrl({
10+
rampReturnPath,
11+
}: HandleRampReturnUrlParams) {
12+
try {
13+
const parsed = new URL(rampReturnPath, 'https://placeholder.local');
14+
const orderId = parsed.searchParams.get('orderId') ?? undefined;
15+
16+
NavigationService.navigation.navigate(Routes.RAMP.RAMPS_ORDER_DETAILS, {
17+
orderId,
18+
showCloseButton: true,
19+
});
20+
} catch (error) {
21+
Logger.error(error as Error, {
22+
message: 'Error in handleRampReturnUrl',
23+
rampReturnPath,
24+
});
25+
}
26+
}

app/constants/deeplinks.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export enum ACTIONS {
5050
SOCIAL_TRADER_POSITION = 'social-trader-position',
5151
EARN_MUSD = 'earn-musd',
5252
NFT = 'nft',
53+
ON_RAMP = 'on-ramp',
5354
}
5455

5556
export const PREFIXES = {
@@ -86,5 +87,6 @@ export const PREFIXES = {
8687
[ACTIONS.SOCIAL_TRADER_POSITION]: '',
8788
[ACTIONS.EARN_MUSD]: '',
8889
[ACTIONS.NFT]: '',
90+
[ACTIONS.ON_RAMP]: '',
8991
METAMASK: 'metamask://',
9092
};

app/core/DeeplinkManager/handlers/legacy/__tests__/handleUniversalLink.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import handleDeepLinkModalDisplay from '../handleDeepLinkModalDisplay';
1515
import handleBrowserUrl from '../handleBrowserUrl';
1616
import { DeepLinkModalLinkType } from '../../../../../components/UI/DeepLinkModal';
1717
import handleMetaMaskDeeplink from '../handleMetaMaskDeeplink';
18+
import handleRampReturnUrl from '../handleRampReturnUrl';
1819
import { SHIELD_WEBSITE_URL } from '../../../../../constants/shield';
1920
import { handleSocialLeaderboardUrl } from '../handleSocialLeaderboardUrl';
2021
import { handleSocialTraderPositionUrl } from '../handleSocialTraderPositionUrl';
@@ -33,6 +34,7 @@ jest.mock('../../../../NativeModules', () => ({
3334
}));
3435
jest.mock('../handleDeepLinkModalDisplay');
3536
jest.mock('../handleRampUrl');
37+
jest.mock('../handleRampReturnUrl');
3638
jest.mock('../handleHomeUrl');
3739
jest.mock('../handleSwapUrl');
3840
jest.mock('../handleBrowserUrl');
@@ -207,6 +209,32 @@ describe('handleUniversalLink', () => {
207209
});
208210
});
209211

212+
describe('ACTIONS.ON_RAMP', () => {
213+
it('calls handleRampReturnUrl with the path after the action', async () => {
214+
const onRampPath = '/return?orderId=order-99';
215+
url = `https://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.ON_RAMP}${onRampPath}`;
216+
urlObj = {
217+
hostname: AppConstants.MM_UNIVERSAL_LINK_HOST,
218+
pathname: `/${ACTIONS.ON_RAMP}${onRampPath}`,
219+
href: url,
220+
} as ReturnType<typeof extractURLParams>['urlObj'];
221+
222+
await handleUniversalLink({
223+
instance,
224+
handled,
225+
urlObj,
226+
browserCallBack: mockBrowserCallBack,
227+
url,
228+
source: 'test-source',
229+
});
230+
231+
expect(handleRampReturnUrl).toHaveBeenCalledWith({
232+
rampReturnPath: onRampPath,
233+
});
234+
expect(handled).toHaveBeenCalled();
235+
});
236+
});
237+
210238
describe('ACTIONS.SELL_CRYPTO', () => {
211239
it('calls instance._handleSellCrypto if action is ACTIONS.SELL_CRYPTO', async () => {
212240
urlObj = {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from '../../../../components/UI/Ramp/deeplink/handleRampReturnUrl';

app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import handleDeepLinkModalDisplay from './handleDeepLinkModalDisplay';
1818
import handleMetaMaskDeeplink from './handleMetaMaskDeeplink';
1919
import { capitalize } from '../../../../util/general';
2020
import handleRampUrl from './handleRampUrl';
21+
import handleRampReturnUrl from './handleRampReturnUrl';
2122
import { navigateToHomeUrl } from './handleHomeUrl';
2223
import { handleSwapUrl } from './handleSwapUrl';
2324
import handleBrowserUrl from './handleBrowserUrl';
@@ -91,6 +92,7 @@ const SUPPORTED_ACTIONS = {
9192
SHIELD: ACTIONS.SHIELD,
9293
EARN_MUSD: ACTIONS.EARN_MUSD,
9394
NFT: ACTIONS.NFT,
95+
ON_RAMP: ACTIONS.ON_RAMP,
9496
// MetaMask SDK specific actions
9597
ANDROID_SDK: ACTIONS.ANDROID_SDK,
9698
CONNECT: ACTIONS.CONNECT,
@@ -124,6 +126,7 @@ const WHITELISTED_ACTIONS: SUPPORTED_ACTIONS[] = [
124126
SUPPORTED_ACTIONS.SOCIAL_TRADER_POSITION,
125127
SUPPORTED_ACTIONS.SHIELD,
126128
SUPPORTED_ACTIONS.EARN_MUSD,
129+
SUPPORTED_ACTIONS.ON_RAMP,
127130
];
128131

129132
/**
@@ -521,6 +524,10 @@ async function handleUniversalLink({
521524
});
522525
break;
523526
}
527+
case SUPPORTED_ACTIONS.ON_RAMP: {
528+
handleRampReturnUrl({ rampReturnPath: actionBasedRampPath });
529+
break;
530+
}
524531
case SUPPORTED_ACTIONS.HOME:
525532
navigateToHomeUrl({ homePath: actionBasedRampPath });
526533
return;

app/core/DeeplinkManager/types/deepLink.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ export const SUPPORTED_ACTIONS = [
138138
ACTIONS.CARD_HOME,
139139
ACTIONS.SHIELD,
140140
ACTIONS.NFT,
141+
ACTIONS.ON_RAMP,
141142
] as const satisfies readonly ACTIONS[];
142143

143144
export type SupportedAction = (typeof SUPPORTED_ACTIONS)[number];

app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ describe('deepLinkAnalytics', () => {
421421
it.each([
422422
[ACTIONS.BUY, DeepLinkRoute.BUY],
423423
[ACTIONS.BUY_CRYPTO, DeepLinkRoute.BUY],
424+
[ACTIONS.ON_RAMP, DeepLinkRoute.BUY],
424425
] as const)('maps buy action %s to BUY route', (action, expectedRoute) => {
425426
// Arrange & Act
426427
const result = mapSupportedActionToRoute(action);

app/core/DeeplinkManager/util/deeplinks/deepLinkAnalytics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,7 @@ export const mapSupportedActionToRoute = (
571571
return DeepLinkRoute.TRANSACTION;
572572
case ACTIONS.BUY:
573573
case ACTIONS.BUY_CRYPTO:
574+
case ACTIONS.ON_RAMP:
574575
return DeepLinkRoute.BUY;
575576
case ACTIONS.SELL:
576577
case ACTIONS.SELL_CRYPTO:

0 commit comments

Comments
 (0)