Skip to content

fix(ramps): Single-owner React Query fetching for providers, tokens, and payment methods#28224

Merged
imyugioh merged 11 commits into
mainfrom
fix/ramps-3398-payment-methods-single-fetch
Apr 3, 2026
Merged

fix(ramps): Single-owner React Query fetching for providers, tokens, and payment methods#28224
imyugioh merged 11 commits into
mainfrom
fix/ramps-3398-payment-methods-single-fetch

Conversation

@imyugioh

@imyugioh imyugioh commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

Description

Payment methods, providers, and tokens were being fetched redundantly from two independent paths (controller fireAndForget + React Query), causing 2–3 duplicate API calls per user action with a ~10s spinner.

This PR makes the mobile client the single fetch owner for all ramp data. React Query handles providers and payment methods with proper caching and invalidation. Tokens are fetched directly from RampsBootstrap. Screens that don't need payment methods no longer trigger fetches.

Changes:

  1. RampsBootstrap.tsx — Now fetches all three data sources at app root: useRampsProviders (React Query), useRampsPaymentMethods (React Query), and getTokens (direct controller call on region change). This follows the agreed flow: app loads → fetch providers and tokens → provider selected → fetch payment methods.

  2. useRampsProviders.ts — Reads provider list from React Query cache (not controller state). Invalidates all ramp queries on region change to force fresh fetches. Passes full Provider object to setSelectedProvider for auto-selection (avoids crash when controller state is empty).

  3. useRampsPaymentMethods.ts — Query key reduced to [regionCode, providerId] only. Token/fiat are passed to the API call but not the key, so token changes don't trigger refetches. Passes full PaymentMethod object to controller (avoids crash when controller state is empty).

  4. paymentMethods.ts (query config)staleTime: 5min. Query key only includes regionCode and providerId.

  5. providers.ts (query config)staleTime: 15min. Removed forceRefresh: true from queryFn (controller's own cache handles it).

  6. SettingsModal.tsx — Uses useRampsProviders instead of useRampsController (no more payment methods fetch on settings screen).

  7. TokenNotAvailableModal.tsx — Uses useRampsProviders + useRampsTokens instead of useRampsController.

  8. RegionSelector.tsx — Uses useRampsUserRegion + useRampsCountries instead of useRampsController.

  9. PaymentSelectionModal.tsx — Filters out payment methods with no available quote once quotes load, preventing dead-end options (e.g. Apple Pay shown but no provider returns a quote for it).

  10. Tests updated — Removed tokenSupportedByProvider test, updated query key and staleTime assertions.

Changelog

CHANGELOG entry: Fixed duplicate payment method, provider, and token API calls during buy flow; React Query is now the single fetch owner for ramp data

Related issues

Fixes: TRAM-3398

Depends on core PR: MetaMask/core#8354 (removes fireAndForget calls from controller)

Manual testing steps

Feature: Single-owner fetching for ramp data

  Scenario: App load fetches providers, tokens, and payment methods
    Given user opens the app (password screen)
    When the app loads
    Then getProviders, getTokens, and getPaymentMethods each fire once
    And providers, tokens, and payment methods are populated in state

  Scenario: Token selection does not trigger payment methods fetch
    Given user is on the token selection screen
    When user selects a token (e.g. Ethereum)
    Then getPaymentMethods is NOT called
    And the BuildQuote screen loads with cached payment methods

  Scenario: Provider change triggers a single payment methods fetch
    Given user is on the BuildQuote screen with a provider selected
    When user changes the provider (e.g. Transak -> Coinbase)
    Then getPaymentMethods fires exactly once for the new provider
    And the payment method pill auto-switches to Debit or Credit

  Scenario: Region switch refreshes all data
    Given user is on the settings screen
    When user changes region (e.g. France -> Finland -> France)
    Then getProviders and getPaymentMethods fire for each new region
    And switching back to a previous region also triggers fresh fetches

  Scenario: Settings screen does not trigger payment methods fetch
    Given user navigates to the Buy & Sell settings screen
    Then getPaymentMethods is NOT called
    And no unnecessary API calls appear in the debug dashboard

  Scenario: Payment selection modal hides dead-end options
    Given user taps the payment method pill
    When quotes load for all payment methods
    Then payment methods with no available quote are filtered out

Screenshots/Recordings

Before

After

Pre-merge author checklist

Pre-merge reviewer checklist

  • 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.

Note

Medium Risk
Changes ramp buy-flow data fetching/caching and controller interaction patterns (query keys, invalidation, and provider/payment-method selection), which could affect availability/performance across regions and providers. UI impact is limited but touches core buy-flow bootstrap and selection logic.

Overview
Makes the mobile client the single fetch owner for ramps buy data. RampsBootstrap now preloads providers (with side effects), payment methods, and triggers token fetching on region availability so downstream screens don’t cause redundant requests.

Reworks React Query behavior for providers and payment methods. useRampsProviders reads the provider list from the React Query cache, adds optional enableSideEffects to avoid duplicate invalidation/auto-selection, and invalidates all ramps queries on region changes. useRampsPaymentMethods simplifies the query to be provider-scoped (query key is regionCode + providerId, 5-min staleTime) and updates controller setters to accept full objects/null.

UI behavior tweaks and hook decoupling. PaymentSelectionModal now hides payment methods that have no non-custom-action quote once quotes load, showing the “no payment methods available” state instead. Several screens (SettingsModal, TokenNotAvailableModal, RegionSelector) switch from useRampsController to narrower hooks (useRampsProviders, useRampsTokens, useRampsUserRegion, useRampsCountries) to prevent unrelated fetches. Dependency bump updates @metamask/ramps-controller to ^13.0.0.

Reviewed by Cursor Bugbot for commit 75f85ca. Bugbot is set up for automated code reviews on this repo. Configure here.

@imyugioh imyugioh self-assigned this Apr 1, 2026
@imyugioh imyugioh added the team-money-movement issues related to Money Movement features label Apr 1, 2026
@github-actions github-actions Bot added the size-S label Apr 1, 2026
@imyugioh imyugioh changed the title fix(ramps): Use single React Query fetch for payment methods on provider change fix(ramps): Single-owner React Query fetching for providers, tokens, and payment methods Apr 2, 2026
@imyugioh imyugioh force-pushed the fix/ramps-3398-payment-methods-single-fetch branch from 450cb9f to beb8a1f Compare April 2, 2026 04:09
@github-actions github-actions Bot added size-M and removed size-S labels Apr 2, 2026
@imyugioh imyugioh force-pushed the fix/ramps-3398-payment-methods-single-fetch branch from 2cb4330 to 0fb7c5d Compare April 2, 2026 13:37
@socket-security

socket-security Bot commented Apr 2, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatednpm/​@​metamask/​controller-utils@​11.19.0 ⏵ 11.20.09710077 +195 +5100
Addednpm/​@​metamask/​ramps-controller@​13.0.0961007898100

View full report

@imyugioh imyugioh force-pushed the fix/ramps-3398-payment-methods-single-fetch branch from b32fb20 to 3ac83f7 Compare April 2, 2026 17:17
@github-actions github-actions Bot added size-L and removed size-M labels Apr 2, 2026
@imyugioh imyugioh force-pushed the fix/ramps-3398-payment-methods-single-fetch branch from 3ac83f7 to 29f7519 Compare April 2, 2026 18:58
@imyugioh imyugioh force-pushed the fix/ramps-3398-payment-methods-single-fetch branch from 29f7519 to 450475b Compare April 3, 2026 00:01
@imyugioh imyugioh marked this pull request as ready for review April 3, 2026 04:52
Comment thread app/components/UI/Ramp/hooks/useRampsPaymentMethods.ts
Comment thread app/components/UI/Ramp/hooks/useRampsProviders.test.ts
Comment thread package.json Outdated
"@metamask/profile-metrics-controller": "^3.1.0",
"@metamask/profile-sync-controller": "^28.0.2",
"@metamask/ramps-controller": "^12.1.0",
"@metamask/ramps-controller": "npm:@metamask-previews/ramps-controller@12.1.0-preview-1d5c02c",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

highlight: MetaMask/core#8354 - has preview publish - so using that.

github-merge-queue Bot pushed a commit to MetaMask/core that referenced this pull request Apr 3, 2026
…t own data lifecycle (#8354)

## Explanation

Payment methods, providers, and tokens were being fetched redundantly
from two independent paths: the controller's `fireAndForget` calls
(inside `setSelectedToken`, `setSelectedProvider`, and `setUserRegion`)
and React Query on the mobile side. This caused 2–3 duplicate API calls
per user action, with a ~10s spinner on the `/payments` endpoint due to
cold DB connection pooling.

This PR removes all `fireAndForget` data-fetching side effects from the
controller. The mobile client (React Query + RampsBootstrap) is now the
single owner of when providers, tokens, and payment methods are fetched.
The controller only manages state updates and selections.

Changes:

1. **`#runInit` (geolocation fix)** — `forceRefresh` no longer overrides
a persisted `userRegion` with the geolocation endpoint. Geolocation is
only used to seed the initial region when `userRegion` is null.

2. **`setSelectedToken`** — Removed
`fireAndForget(getPaymentMethods(...))` and
`resetResource('paymentMethods')`. Token change no longer triggers
payment methods fetch or clears payment methods state. Payment methods
are provider-scoped, not token-scoped.

3. **`setSelectedProvider`** — Removed
`fireAndForget(getPaymentMethods(...))`,
`resetResource('paymentMethods')`, and `tokenSupportedByProvider` gate.
Now accepts a full `Provider` object (not just ID) to avoid dependency
on `state.providers.data` being populated. Silently ignores when
provider ID is not found instead of throwing.

4. **`setUserRegion`** — Removed `fireAndForget(getTokens(...))` and
`fireAndForget(getProviders(...))`. The mobile client handles all data
fetching via React Query (providers, payment methods) and direct
controller calls (tokens) from RampsBootstrap.

5. **`setSelectedPaymentMethod`** — Now accepts a full `PaymentMethod`
object (not just ID) to avoid dependency on `state.paymentMethods.data`
being populated. Silently sets `null` when payment method ID is not
found instead of throwing.

6. **`getPaymentMethods` response handler** — Always selects the first
(highest-scored) payment method when new data arrives, preventing
dead-end states where a payment method with no quotes stays selected
after provider switch.

7. **`#fireAndForget`** — Removed (no remaining callers).

8. **`executeRequest` generation counter** — Added
`#pendingResourceGeneration` map to prevent stale in-flight requests
from corrupting `isLoading` state. When `setUserRegion` resets dependent
resource counts, the generation is bumped. Orphaned finally blocks from
the previous generation skip their decrement instead of prematurely
clearing `isLoading`.

## Link to metamask-mobile

Depends on mobile PR:
[MetaMask/metamask-mobile#28224](MetaMask/metamask-mobile#28224)

## References

[TRAM-3398](https://consensyssoftware.atlassian.net/browse/TRAM-3398)

## Changelog

CHANGELOG entry: See `packages/ramps-controller/CHANGELOG.md` Unreleased
section.

## Checklist

- [x] I've updated the test suite for new or updated code as appropriate
- [x] I've updated documentation (JSDoc, Markdown, etc.) for new or
updated code as appropriate
- [x] I've communicated my changes to consumers by [updating changelogs
for packages I've
changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md)
- [ ] I've introduced [breaking
changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md)
in this PR and have prepared draft pull requests for clients and
consumer packages to resolve them

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Behavior changes in `RampsController` selection/region flows (no
longer auto-fetching or clearing dependent data) could affect consumers
that relied on controller-side side effects. Adds new stale-request
invalidation for `executeRequest`, which impacts loading/error state
handling across resources.
> 
> **Overview**
> **Shifts ramps data lifecycle ownership to the client** by removing
controller-side `fireAndForget` fetching from `setUserRegion`,
`setSelectedToken`, and `setSelectedProvider`, and by no longer clearing
`paymentMethods` on token/provider changes.
> 
> `setSelectedProvider` and `setSelectedPaymentMethod` now accept either
an ID or a full object and **stop throwing when backing resource data
isn’t loaded/not found** (silently leaving selection `null`). `init` is
fixed to **never override a persisted `userRegion` with geolocation**,
even on `forceRefresh`.
> 
> Request tracking is hardened by adding a per-resource generation
counter so stale in-flight requests can’t incorrectly decrement
ref-counted `isLoading` after dependent-resource resets; tests are
updated/added to cover these races and the new selection semantics.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
7073fe5. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
import useRampsProviders from './hooks/useRampsProviders';
import useRampsPaymentMethods from './hooks/useRampsPaymentMethods';
import { selectUserRegion } from '../../../selectors/rampsController';
import Engine from '../../../core/Engine';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Payment methods hook is new here
selectUserRegion + Engine are for the token fetch below

function RampsBootstrap(): null {
useRampsSmartRouting();
useRampsProviders();
useRampsProviders({ enableSideEffects: true });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enableSideEffects: true means only RampsBootStrap runs auto-selection and cache invalidation
Other screens that call useRampsProviders - just read - no side effects

Engine.context.RampsController.getTokens(userRegion.regionCode, 'buy');
}
}, [userRegion?.regionCode]);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tokens are fetched directly - no react query - because the controller needs them for bvalidation in setSelectedToken - fires only once when region becomes available & re-fires if region changes

import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard';
import MenuItem from '../../../components/MenuItem';
import { useRampsController } from '../../../hooks/useRampsController';
import { useRampsProviders } from '../../../hooks/useRampsProviders';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

settings only needs provider data - before useRampsController was pulling in everything and triggering paymentMethods fetches as a side effect

import { useStyles } from '../../../../../hooks/useStyles';
import { useRampsController } from '../../../hooks/useRampsController';
import { useRampsProviders } from '../../../hooks/useRampsProviders';
import { useRampsTokens } from '../../../hooks/useRampsTokens';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

split into 2 focused hooks instead of 1 monolithic one

refetchType: 'none',
});
}
}, [enableSideEffects, regionCode, queryClient]);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if region is changed, mark all ramp queries as stale. without refetchType: none, invalidateQueries would trigger a second fetch on top of the o1 react query - that is already fires from the query key change.
the stale-marking matters when the user switches Back to a previous region - cached data gets refetched instead of served stale.

() => providersQuery?.data ?? providersStateData ?? [],
[providersQuery?.data, providersStateData],
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

react query cache is the primary source - fall back to controller state for initial render


const setSelectedProvider = useCallback(
(provider: Provider | null, options?: { autoSelected?: boolean }) =>
(provider: Provider | null, setOptions?: { autoSelected?: boolean }) =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

passes the full provider obj(just reference id only - no memory booming) - this avoids depending on state.providers.data being populated (it might not be after a region reset)

error:
providersQuery?.error instanceof Error
? providersQuery.error.message
: providersStateError,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loading and error now come from react query first, fall back to controller state

if (paymentMethods.length === 0) {
// Filter out payment methods that have no available quote once quotes
// have loaded. This avoids showing dead-end options to the user.
const visiblePaymentMethods =

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

once quotes finish loading, filter out any payment method that has no real quote.
that is a dead-end option we do not want to show

@imyugioh imyugioh requested a review from a team as a code owner April 3, 2026 21:12
@github-actions github-actions Bot added the risk-high Extensive testing required · High bug introduction risk label Apr 3, 2026
@github-actions

github-actions Bot commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

🔍 Smart E2E Test Selection

  • Selected E2E tags: SmokeAccounts, SmokeConfirmations, SmokeIdentity, SmokeNetworkAbstractions, SmokeNetworkExpansion, SmokeTrade, SmokeWalletPlatform, SmokeCard, SmokePerps, SmokeRamps, SmokeMultiChainAPI, SmokePredictions, FlaskBuildTests
  • Selected Performance tags: @PerformanceAccountList, @PerformanceOnboarding, @PerformanceLogin, @PerformanceSwaps, @PerformanceLaunch, @PerformanceAssetLoading, @PerformancePredict, @PerformancePreps
  • Risk Level: high
  • AI Confidence: 100%
click to see 🤖 AI reasoning details

E2E Test Selection:
Hard rule (controller-version-update): @MetaMask controller package version updated in package.json: @metamask/ramps-controller. Running all tests.

Performance Test Selection:
Hard rule (controller-version-update): @MetaMask controller package version updated in package.json: @metamask/ramps-controller. Running all tests.

View GitHub Actions results

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 75f85ca. Configure here.

mockDeterminePreferredProvider.mockReturnValue(null);

renderHook(() => useRampsProviders(), {
renderHook(() => useRampsProviders({ enableSideEffects: true }), {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Negative auto-selection tests now test wrong condition

Medium Severity

Three negative tests for the preferred provider auto-selection effect — "does not call determinePreferredProvider when providers is empty" (line 333), "when selectedProvider is already set" (line 344), and "when providers is undefined" (line 358) — all call useRampsProviders() without enableSideEffects: true. Since enableSideEffects defaults to false, the auto-selection effect is gated by that flag and never runs, regardless of the provider/selection state. These tests pass trivially and no longer verify the conditions they describe. If someone later removes the providers.length > 0 or !selectedProvider guards, these tests would still pass, providing a false safety net.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75f85ca. Configure here.

@sonarqubecloud

sonarqubecloud Bot commented Apr 3, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

E2E Fixture Validation — Schema is up to date
17 value mismatches detected (expected — fixture represents an existing user).
View details

@imyugioh imyugioh requested review from amitabh94 April 3, 2026 22:45
@imyugioh imyugioh added this pull request to the merge queue Apr 3, 2026
Merged via the queue into main with commit 5d16844 Apr 3, 2026
202 of 205 checks passed
@imyugioh imyugioh deleted the fix/ramps-3398-payment-methods-single-fetch branch April 3, 2026 23:28
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 3, 2026
@metamaskbot metamaskbot added the release-7.74.0 Issue or pull request that will be included in release 7.74.0 label Apr 3, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

release-7.74.0 Issue or pull request that will be included in release 7.74.0 risk-high Extensive testing required · High bug introduction risk size-L team-money-movement issues related to Money Movement features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants