Skip to content

Commit a9111b3

Browse files
feat(ramp): Converts payment methods and quotes to use react-query for requ… (#27448)
## **Description** Fixes the "Token Not Available" modal and payment methods loading behavior in the Buy flow. Three issues are addressed: ### 1. React-query migration for request status tracking Previously, the controller only exposed `isLoading` (boolean) and `data` (array). This made it impossible to distinguish between: - **Never fetched yet** (idle) — `isLoading: false`, `data: []` - **Fetched successfully with no results** (token genuinely unavailable) — `isLoading: false`, `data: []` This ambiguity caused the app to flash the "Token Not Available" modal immediately when switching providers, before payment methods had even been fetched. By using react-query, we now have explicit status tracking (`idle` | `loading` | `success` | `error`) plus `isFetching` for background refetches. The "Token Not Available" modal only shows when `paymentMethodsStatus === 'success' && !paymentMethodsFetching && paymentMethods.length === 0`. ### 2. Fix token unavailability detection across all Buy entry points The `isTokenUnavailable` check in `BuildQuote` previously relied solely on `params?.assetId` (route navigation params). This worked for the **token buy** flow (`Home → Tokens → Token Info → Buy`) which passes `assetId` via `createBuildQuoteNavDetails`, but failed for the **home buy** flow (`Home → Buy → Token Selection → BuildQuote`) where `params.assetId` could be missing or stale. The fix: treat `route.params.assetId` as bootstrapping input only. Once on the BuildQuote screen, derive the effective asset ID from the controller state (`selectedToken?.assetId`) as the source of truth, falling back to `params?.assetId`. ### 3. Fix navigation behavior for three distinct Buy flow origins The "Token Not Available" modal previously had a single dismiss/change-token behavior regardless of how the user entered the Buy flow. This caused a crash (`cannot read property 'address' of undefined`) when navigating back via `navigation.navigate('Asset')` without params from the token info flow. Introduced `BuyFlowOrigin` type (`'tokenInfo' | 'homeTokenList' | undefined`) to distinguish the three entry points and handle navigation correctly: - **Token info flow** (`tokenInfo`): "Change token" → Tokens Full View; dismiss → `goBack()` twice (exits ramp flow, preserves Asset screen params) - **Home token list flow** (`homeTokenList`): "Change token" → Home; dismiss → Home - **Default/home buy flow** (`undefined`): "Change token" → Token Selection; dismiss → Token Selection Additional fixes: - 600ms debounce on modal navigation to prevent flashing when `isTokenUnavailable` is briefly true due to stale cached data - `lastShownUnavailableKeyRef` dedup pattern (`providerId:assetId`) to prevent duplicate modal navigations - `focusTrigger` counter via `useFocusEffect` to force modal re-evaluation on screen re-focus (e.g., after returning from provider picker) - `isOnBuildQuoteScreen` guard to prevent effects firing when screen is in background - Payment pill disabled when token is unavailable (`onPress={isTokenUnavailable ? undefined : handlePaymentPillPress}`) ### Changes - **New `queries/paymentMethods.ts`** — react-query `queryOptions` for fetching payment methods via `RampsController.getPaymentMethods`, keyed by region/fiat/assetId/providerId - **New `queries/quotes.ts`** — react-query `queryOptions` for fetching quotes via `RampsController.getQuotes`, keyed by assetId/amount/wallet/paymentMethod/provider - **New `queries/index.ts`** — barrel export combining both query definitions - **Updated `useRampsPaymentMethods.ts`** — rewired to use `useQuery` instead of reading from Redux store; exposes `status`, `isFetching`, `isSuccess`, `isLoading`, and `error` - **Updated `useRampsQuotes.ts`** — rewired to use `useQuery` with proper `enabled` gating; exposes `status`, `isSuccess`, `loading`, and `error` - **Updated `useRampsController.ts`** — passes through `paymentMethodsStatus` and `paymentMethodsFetching` from the payment methods hook - **Updated `BuildQuote.tsx`** — derives `effectiveAssetId` from controller state (source of truth) with route params as fallback; uses `paymentMethodsStatus === 'success' && !paymentMethodsFetching` check before determining token unavailability; 600ms debounced modal navigation with dedup; `BuyFlowOrigin` passthrough; disabled payment pill when unavailable - **Updated `TokenNotAvailableModal.tsx`** — handles three flows via `buyFlowOrigin` param with correct navigation for each - **Updated `useRampNavigation.ts`** — passes `buyFlowOrigin` through to `createBuildQuoteNavDetails` - **Updated `useTokenActions.ts`** — passes `{ buyFlowOrigin: 'tokenInfo' }` to `goToBuy` - **Updated `PopularTokenRow.tsx`** — passes `{ buyFlowOrigin: 'homeTokenList' }` to `goToBuy` - **Unit tests** — added/updated tests for `BuildQuote`, `TokenNotAvailableModal`, `useRampNavigation`, `useRampsPaymentMethods`, `useRampsQuotes`, `useRampsController`, and query definitions ## **Changelog** CHANGELOG entry: Fixed false "Token Not Available" errors during Buy flow when payment methods are still loading after provider change; fixed missing "Token Not Available" modal in home buy flow; fixed crash when navigating back from "Token Not Available" modal in token info buy flow ## **Related issues** Refs: [TRAM-3314](https://consensyssoftware.atlassian.net/browse/TRAM-3314) ## **Manual testing steps** ### Step-by-step walkthrough for testing **Test 1 — Home buy flow (the fixed path):** 1. Launch the app in the iOS 2. Tap **Buy** on the home screen 3. In the Token Selection screen, pick a niche token (e.g. **TRX on Tron**) 4. On the BuildQuote screen, verify the **"Token Not Available" modal** appears after loading 5. Press "Change token" — should go back to token selection 6. Press "Change provider" — should open provider picker **Test 2 — Token buy flow (existing path, verify no regression + crash fix):** 1. From the Home screen, tap on a token in the Tokens list (e.g. **TRX**) 2. On the Token Info screen (with graph), tap **Buy** 3. Verify the **"Token Not Available" modal** appears after loading 4. Press "Change token" — should go to Tokens Full View 5. Dismiss the modal — should go back to Token Info (NOT crash) **Test 3 — Home token list buy flow:** 1. From the Home screen, tap the **Buy** button next to a token in the token list 2. If the token is unavailable, verify the modal appears 3. Press "Change token" — should go to Home screen 4. Dismiss — should go to Home screen **Test 4 — Re-selecting tokens:** 1. Enter via token buy flow (Token Info → Buy) with a supported token 2. Go back to token selection 3. Select a token that is unavailable with the current provider 4. Verify the "Token Not Available" modal appears (previously it did NOT in this path) **Test 5 — Modal re-appears after provider change:** 1. Enter Buy flow with an unavailable token 2. After modal appears, tap "Change provider" 3. Select another provider that also doesn't support the token 4. Verify the modal appears again (previously it only showed once due to dedup) ## **Screenshots/Recordings** ### **Before** <!-- Video/screenshot showing the missing modal in home buy flow --> https://github.com/user-attachments/assets/efff2d42-698c-4084-8e64-8857852bd34a ### **After** <!-- Video/screenshot showing the modal appearing correctly in both flows --> https://github.com/user-attachments/assets/e8803901-9d83-4803-a324-1787c0a630ea https://github.com/user-attachments/assets/e2f8b51a-4e12-44c6-8d75-d83d8083929e https://github.com/user-attachments/assets/37a56f6c-af24-4cf4-85a6-00929312b41f ### **After rebasing 7.71.0** https://github.com/user-attachments/assets/db1da8ea-3641-4302-95cf-39735b6d9260 https://github.com/user-attachments/assets/d9598eda-2f5a-4788-b523-6e3b0738a189 https://github.com/user-attachments/assets/930866b3-0a75-454c-b5cc-96e132a9df7e ## **Pre-merge author checklist** - [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. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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** > Touches the core Buy flow by changing how payment methods/quotes are fetched and how the Token Unavailable modal is triggered/navigated, which can affect ramp availability and navigation behavior across entry points. > > **Overview** > **Improves Buy-flow reliability by migrating `paymentMethods` and `quotes` fetching to `@tanstack/react-query`** (new `rampsQueries` with stable keys/options) and exposing richer request state (`status`, `isFetching`, `isSuccess`) through `useRampsPaymentMethods`, `useRampsQuotes`, and `useRampsController`. > > **Fixes Token Unavailable handling in `BuildQuote`** by deriving the effective token from controller state (not just route params), gating the modal on settled payment-methods state, and adding focus-aware, de-duped, 600ms-debounced modal navigation; it also disables the payment-method pill when the token is unavailable. > > **Adds `BuyFlowOrigin` plumbing (`tokenInfo` | `homeTokenList`)** through `useRampNavigation`/callers and updates `TokenNotAvailableModal` to return the user to the correct screen on change-token/dismiss for each entry path. Tests are updated/added to cover the new query state and navigation behaviors, and the Ramp routes are wrapped in a `QueryClientProvider`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 45f4c0a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: George Weiler <george.weiler@consensys.net>
1 parent 2c92ecc commit a9111b3

28 files changed

Lines changed: 1178 additions & 426 deletions

app/components/UI/Ramp/Aggregator/Views/Settings/Settings.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ const mockUseRampsControllerInitialValues: ReturnType<
136136
setSelectedPaymentMethod: jest.fn(),
137137
paymentMethodsLoading: false,
138138
paymentMethodsError: null,
139+
paymentMethodsFetching: false,
140+
paymentMethodsStatus: 'idle' as const,
139141
getQuotes: jest.fn(),
140142
getBuyWidgetData: jest.fn(),
141143
orders: [],

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

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,18 @@ jest.mock('../../components/QuickAmounts', () => {
8383
});
8484

8585
jest.mock('@react-navigation/native', () => {
86+
const ReactActual = jest.requireActual('react');
8687
const actual = jest.requireActual('@react-navigation/native');
8788
return {
8889
...actual,
8990
useNavigation: jest.fn(),
91+
useIsFocused: jest.fn(() => true),
92+
useFocusEffect: (callback: () => void | (() => void)) => {
93+
ReactActual.useEffect(() => {
94+
const cleanup = callback();
95+
return typeof cleanup === 'function' ? cleanup : undefined;
96+
}, [callback]);
97+
},
9098
};
9199
});
92100

@@ -354,11 +362,14 @@ describe('BuildQuote', () => {
354362
userRegion: USER_REGION,
355363
selectedProvider: WIDGET_PROVIDER,
356364
selectedToken: SELECTED_TOKEN,
365+
paymentMethods: [SELECTED_PAYMENT_METHOD],
357366
getBuyWidgetData: mockGetBuyWidgetData,
358367
addPrecreatedOrder: mockAddPrecreatedOrder,
359368
addOrder: mockAddOrder,
360369
getOrderFromCallback: mockGetOrderFromCallback,
361370
paymentMethodsLoading: false,
371+
paymentMethodsFetching: false,
372+
paymentMethodsStatus: 'success',
362373
selectedPaymentMethod: SELECTED_PAYMENT_METHOD,
363374
});
364375
mockUseRampsQuotes.mockReturnValue({
@@ -676,9 +687,14 @@ describe('BuildQuote', () => {
676687
userRegion: USER_REGION,
677688
selectedProvider: NATIVE_PROVIDER,
678689
selectedToken: SELECTED_TOKEN,
690+
paymentMethods: [SELECTED_PAYMENT_METHOD],
679691
getBuyWidgetData: mockGetBuyWidgetData,
680692
addPrecreatedOrder: mockAddPrecreatedOrder,
693+
addOrder: mockAddOrder,
694+
getOrderFromCallback: mockGetOrderFromCallback,
681695
paymentMethodsLoading: false,
696+
paymentMethodsFetching: false,
697+
paymentMethodsStatus: 'success',
682698
selectedPaymentMethod: SELECTED_PAYMENT_METHOD,
683699
});
684700
mockUseRampsQuotes.mockReturnValue({
@@ -843,4 +859,178 @@ describe('BuildQuote', () => {
843859
expect(toJSON()).toMatchSnapshot();
844860
});
845861
});
862+
863+
describe('Token unavailable for provider', () => {
864+
const TOKEN_ASSET = 'eip155:1/slip44:60';
865+
866+
const transakProvider = {
867+
id: '/providers/transak',
868+
name: 'Transak',
869+
supportedCryptoCurrencies: { [TOKEN_ASSET]: true },
870+
links: [],
871+
};
872+
873+
const mockUnavailableController = (overrides: Record<string, unknown>) => {
874+
mockUseRampsController.mockReturnValue({
875+
userRegion: USER_REGION,
876+
selectedProvider: transakProvider,
877+
selectedToken: SELECTED_TOKEN,
878+
paymentMethods: [],
879+
getBuyWidgetData: mockGetBuyWidgetData,
880+
addPrecreatedOrder: mockAddPrecreatedOrder,
881+
addOrder: mockAddOrder,
882+
getOrderFromCallback: mockGetOrderFromCallback,
883+
paymentMethodsLoading: false,
884+
paymentMethodsFetching: false,
885+
paymentMethodsStatus: 'success',
886+
selectedPaymentMethod: null,
887+
...overrides,
888+
});
889+
};
890+
891+
beforeEach(() => {
892+
jest.useFakeTimers();
893+
mockUseParams.mockReturnValue({ assetId: TOKEN_ASSET });
894+
});
895+
896+
afterEach(() => {
897+
jest.useRealTimers();
898+
});
899+
900+
it('navigates to token unavailable modal after debounce when payment methods are empty', () => {
901+
mockUnavailableController({});
902+
renderWithProvider(<BuildQuote />, { state: initialRootState });
903+
act(() => {
904+
jest.advanceTimersByTime(650);
905+
});
906+
expect(mockNavigate).toHaveBeenCalledWith(
907+
'RampModals',
908+
expect.objectContaining({
909+
screen: 'RampTokenNotAvailableModal',
910+
params: expect.objectContaining({ assetId: TOKEN_ASSET }),
911+
}),
912+
);
913+
});
914+
915+
it('does not navigate while payment methods are still fetching', () => {
916+
mockUnavailableController({ paymentMethodsFetching: true });
917+
renderWithProvider(<BuildQuote />, { state: initialRootState });
918+
act(() => {
919+
jest.advanceTimersByTime(650);
920+
});
921+
expect(mockNavigate).not.toHaveBeenCalledWith(
922+
'RampModals',
923+
expect.objectContaining({
924+
screen: 'RampTokenNotAvailableModal',
925+
}),
926+
);
927+
});
928+
929+
it('does not navigate before payment methods status is success', () => {
930+
mockUnavailableController({ paymentMethodsStatus: 'loading' });
931+
renderWithProvider(<BuildQuote />, { state: initialRootState });
932+
act(() => {
933+
jest.advanceTimersByTime(650);
934+
});
935+
expect(mockNavigate).not.toHaveBeenCalledWith(
936+
'RampModals',
937+
expect.objectContaining({
938+
screen: 'RampTokenNotAvailableModal',
939+
}),
940+
);
941+
});
942+
943+
it('does not navigate when payment methods returned', () => {
944+
mockUnavailableController({
945+
paymentMethods: [SELECTED_PAYMENT_METHOD],
946+
});
947+
renderWithProvider(<BuildQuote />, { state: initialRootState });
948+
act(() => {
949+
jest.advanceTimersByTime(650);
950+
});
951+
expect(mockNavigate).not.toHaveBeenCalledWith(
952+
'RampModals',
953+
expect.objectContaining({
954+
screen: 'RampTokenNotAvailableModal',
955+
}),
956+
);
957+
});
958+
959+
it('passes buyFlowOrigin to token unavailable modal params', () => {
960+
mockUseParams.mockReturnValue({
961+
assetId: TOKEN_ASSET,
962+
buyFlowOrigin: 'tokenInfo' as const,
963+
});
964+
mockUnavailableController({});
965+
renderWithProvider(<BuildQuote />, { state: initialRootState });
966+
act(() => {
967+
jest.advanceTimersByTime(650);
968+
});
969+
expect(mockNavigate).toHaveBeenCalledWith(
970+
'RampModals',
971+
expect.objectContaining({
972+
screen: 'RampTokenNotAvailableModal',
973+
params: expect.objectContaining({
974+
assetId: TOKEN_ASSET,
975+
buyFlowOrigin: 'tokenInfo',
976+
}),
977+
}),
978+
);
979+
});
980+
981+
it('does not open payment selection when token unavailable disables pill', () => {
982+
mockUnavailableController({});
983+
const { getByTestId } = renderWithProvider(<BuildQuote />, {
984+
state: initialRootState,
985+
});
986+
act(() => {
987+
jest.advanceTimersByTime(650);
988+
});
989+
mockNavigate.mockClear();
990+
fireEvent.press(getByTestId('build-quote-payment-pill'));
991+
expect(mockNavigate).not.toHaveBeenCalledWith(
992+
'RampModals',
993+
expect.objectContaining({
994+
screen: 'RampPaymentSelectionModal',
995+
}),
996+
);
997+
});
998+
999+
it('re-navigates when provider id changes', () => {
1000+
mockUnavailableController({
1001+
selectedProvider: {
1002+
id: '/providers/a',
1003+
name: 'A',
1004+
supportedCryptoCurrencies: { [TOKEN_ASSET]: true },
1005+
links: [],
1006+
},
1007+
});
1008+
const { rerender } = renderWithProvider(<BuildQuote />, {
1009+
state: initialRootState,
1010+
});
1011+
act(() => {
1012+
jest.advanceTimersByTime(650);
1013+
});
1014+
expect(mockNavigate).toHaveBeenCalled();
1015+
mockNavigate.mockClear();
1016+
mockUnavailableController({
1017+
selectedProvider: {
1018+
id: '/providers/b',
1019+
name: 'B',
1020+
supportedCryptoCurrencies: { [TOKEN_ASSET]: true },
1021+
links: [],
1022+
},
1023+
});
1024+
rerender(<BuildQuote />);
1025+
act(() => {
1026+
jest.advanceTimersByTime(650);
1027+
});
1028+
expect(mockNavigate).toHaveBeenCalledWith(
1029+
'RampModals',
1030+
expect.objectContaining({
1031+
screen: 'RampTokenNotAvailableModal',
1032+
}),
1033+
);
1034+
});
1035+
});
8461036
});

0 commit comments

Comments
 (0)