From f21e1e0c93e3e06cc46c54dcf87982d142d67645 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Mon, 3 Aug 2026 04:25:01 -0700 Subject: [PATCH 1/5] feat(ramps-controller): add setSelectedProviderForAsset controller method Adds setSelectedProviderForAsset(assetId, options?) to RampsController. When the currently selected provider does not support the given CAIP-19 asset, the method switches providers.selected to the first provider in providers.data that does, using the existing providerServesAsset utility. Returns true if a switch was made, false otherwise (no-op when providers are not loaded, the selected provider already supports the asset, or no compatible alternative exists). This moves the "silent provider switch on asset mismatch" logic from duplicated UI-layer code (BuildQuote tier-1 effect, useEnsureCompatibleProvider) into the controller, where it can be tested in isolation and called from any surface without re-implementing providerServesAsset + find. Registers as RampsController:setSelectedProviderForAsset messenger action. Action-types file regenerated via messenger-action-types:generate. --- packages/ramps-controller/CHANGELOG.md | 6 + .../RampsController-method-action-types.ts | 27 +++ .../src/RampsController.test.ts | 212 ++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 56 ++++- 4 files changed, 300 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c7cd05d266d..10279ee0292 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `setSelectedProviderForAsset(assetId, options?)` method and `RampsController:setSelectedProviderForAsset` messenger action ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) + - Switches `providers.selected` to the first provider in `providers.data` that serves the given CAIP-19 asset when the currently selected provider does not, using the existing `providerServesAsset` utility. + - Returns `true` if a switch was made, `false` otherwise (no-op when providers are not yet loaded, the current provider already serves the asset, or no alternative provider serves the asset). + ## [18.0.1] ### Changed diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index e8bf7133789..2ec0513136f 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -87,6 +87,32 @@ export type RampsControllerSetSelectedProviderAction = { handler: RampsController['setSelectedProvider']; }; +/** + * Switches to the first provider in state that serves the given asset, + * when the currently selected provider does not. + * + * This is the controller-level equivalent of UB2's BuildQuote tier-1 + * silent-switch effect and MMPay's `useEnsureCompatibleProvider` hook: it + * keeps provider-asset compatibility logic in one place rather than + * duplicating `providerServesAsset` + find-and-switch across multiple UI + * layers. + * + * No-op when: + * - `providers.data` is empty (providers not yet loaded) + * - the currently selected provider already serves the asset + * - no provider in the list serves the asset (no safe fallback) + * + * @param assetId - CAIP-19 asset id of the deposit asset. + * @param options - Optional settings forwarded to `setSelectedProvider`. + * @param options.autoSelected - When true, marks the new selection as + * system-guessed (soft selection). Defaults to true. + * @returns `true` if the selected provider was changed, `false` otherwise. + */ +export type RampsControllerSetSelectedProviderForAssetAction = { + type: `RampsController:setSelectedProviderForAsset`; + handler: RampsController['setSelectedProviderForAsset']; +}; + /** * Initializes the controller by fetching the user's region from geolocation. * This should be called once at app startup to set up the initial region. @@ -648,6 +674,7 @@ export type RampsControllerMethodActions = | RampsControllerGetRequestStateAction | RampsControllerSetUserRegionAction | RampsControllerSetSelectedProviderAction + | RampsControllerSetSelectedProviderForAssetAction | RampsControllerInitAction | RampsControllerGetCountriesAction | RampsControllerGetTokensAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 1bb8099f86a..5858a50f2c2 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -5057,6 +5057,218 @@ describe('RampsController', () => { }); }); + describe('setSelectedProviderForAsset', () => { + const ASSET_ID = + 'eip155:143/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA'; + + const makeProvider = ( + id: string, + assetIds: string[] = [], + ): Provider => ({ + id, + name: id, + environmentType: 'PRODUCTION' as const, + description: '', + hqAddress: '', + links: [], + logos: { light: '', dark: '', height: 0, width: 0 }, + supportedCryptoCurrencies: Object.fromEntries( + assetIds.map((a) => [a.toLowerCase(), true]), + ), + }); + + it('switches to the first compatible provider when the selected one does not support the asset', async () => { + const incompatible = makeProvider('/providers/coinbase'); + const compatible = makeProvider('/providers/transak-native', [ASSET_ID]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState( + [incompatible, compatible], + incompatible, + ), + }, + }, + }, + ({ controller }) => { + const switched = controller.setSelectedProviderForAsset(ASSET_ID); + + expect(switched).toBe(true); + expect(controller.state.providers.selected).toStrictEqual(compatible); + expect(controller.state.providerAutoSelected).toBe(true); + }, + ); + }); + + it('does not switch when the selected provider already supports the asset', async () => { + const compatible = makeProvider('/providers/transak-native', [ASSET_ID]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([compatible], compatible), + }, + }, + }, + ({ controller }) => { + const switched = controller.setSelectedProviderForAsset(ASSET_ID); + + expect(switched).toBe(false); + expect(controller.state.providers.selected).toStrictEqual(compatible); + }, + ); + }); + + it('does not switch when no provider supports the asset', async () => { + const a = makeProvider('/providers/coinbase'); + const b = makeProvider('/providers/moonpay'); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([a, b], a), + }, + }, + }, + ({ controller }) => { + const switched = controller.setSelectedProviderForAsset(ASSET_ID); + + expect(switched).toBe(false); + expect(controller.state.providers.selected).toStrictEqual(a); + }, + ); + }); + + it('returns false and does not switch when providers.data is empty', async () => { + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([], null), + }, + }, + }, + ({ controller }) => { + const switched = controller.setSelectedProviderForAsset(ASSET_ID); + + expect(switched).toBe(false); + expect(controller.state.providers.selected).toBeNull(); + }, + ); + }); + + it('does not select the currently selected provider as its own replacement', async () => { + const provider = makeProvider('/providers/transak-native'); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([provider], provider), + }, + }, + }, + ({ controller }) => { + const switched = controller.setSelectedProviderForAsset(ASSET_ID); + + expect(switched).toBe(false); + }, + ); + }); + + it('matches the asset case-insensitively (API key lowercase, caller assetId checksummed)', async () => { + const incompatible = makeProvider('/providers/coinbase'); + // The providers API returns lowercase keys; the caller passes checksummed assetId. + const compatible: Provider = { + ...makeProvider('/providers/transak-native'), + supportedCryptoCurrencies: { [ASSET_ID.toLowerCase()]: true }, + }; + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState( + [incompatible, compatible], + incompatible, + ), + }, + }, + }, + ({ controller }) => { + const switched = controller.setSelectedProviderForAsset(ASSET_ID); + + expect(switched).toBe(true); + expect(controller.state.providers.selected).toStrictEqual(compatible); + }, + ); + }); + + it('forwards autoSelected option to setSelectedProvider', async () => { + const incompatible = makeProvider('/providers/coinbase'); + const compatible = makeProvider('/providers/transak-native', [ASSET_ID]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState( + [incompatible, compatible], + incompatible, + ), + }, + }, + }, + ({ controller }) => { + controller.setSelectedProviderForAsset(ASSET_ID, { + autoSelected: false, + }); + + expect(controller.state.providerAutoSelected).toBe(false); + }, + ); + }); + + it('is callable via the RampsController:setSelectedProviderForAsset messenger action', async () => { + const incompatible = makeProvider('/providers/coinbase'); + const compatible = makeProvider('/providers/transak-native', [ASSET_ID]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState( + [incompatible, compatible], + incompatible, + ), + }, + }, + }, + ({ controller, rootMessenger }) => { + const result = rootMessenger.call( + 'RampsController:setSelectedProviderForAsset', + ASSET_ID, + ); + + expect(result).toBe(true); + expect(controller.state.providers.selected).toStrictEqual(compatible); + }, + ); + }); + }); + describe('setSelectedToken', () => { const mockToken: RampsToken = { assetId: 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 3f388e457f3..38accb5a284 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -15,7 +15,10 @@ import { isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js'; -import { getProvidersServingAsset } from './providerAvailability.js'; +import { + getProvidersServingAsset, + providerServesAsset, +} from './providerAvailability.js'; import type { RampsControllerMethodActions } from './RampsController-method-action-types.js'; import type { RampsErrorCode } from './rampsErrorCodes.js'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes.js'; @@ -815,6 +818,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getRequestState', 'setUserRegion', 'setSelectedProvider', + 'setSelectedProviderForAsset', 'init', 'getCountries', 'getTokens', @@ -1445,6 +1449,56 @@ export class RampsController extends BaseController< } } + /** + * Switches to the first provider in state that serves the given asset, + * when the currently selected provider does not. + * + * This is the controller-level equivalent of UB2's BuildQuote tier-1 + * silent-switch effect and MMPay's `useEnsureCompatibleProvider` hook: it + * keeps provider-asset compatibility logic in one place rather than + * duplicating `providerServesAsset` + find-and-switch across multiple UI + * layers. + * + * No-op when: + * - `providers.data` is empty (providers not yet loaded) + * - the currently selected provider already serves the asset + * - no provider in the list serves the asset (no safe fallback) + * + * @param assetId - CAIP-19 asset id of the deposit asset. + * @param options - Optional settings forwarded to `setSelectedProvider`. + * @param options.autoSelected - When true, marks the new selection as + * system-guessed (soft selection). Defaults to true. + * @returns `true` if the selected provider was changed, `false` otherwise. + */ + setSelectedProviderForAsset( + assetId: string, + options?: { autoSelected?: boolean }, + ): boolean { + const providers = this.state.providers.data; + if (!providers?.length) { + return false; + } + + const selectedProvider = this.state.providers.selected; + if (selectedProvider && providerServesAsset(selectedProvider, assetId)) { + return false; + } + + const compatible = providers.find( + (p) => + p.id !== selectedProvider?.id && providerServesAsset(p, assetId), + ); + if (!compatible) { + return false; + } + + this.setSelectedProvider(compatible, { + autoSelected: true, + ...options, + }); + return true; + } + /** * Initializes the controller by fetching the user's region from geolocation. * This should be called once at app startup to set up the initial region. From 128253f835c52ddc082a15e67c57fc2023f6153c Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Mon, 3 Aug 2026 04:53:14 -0700 Subject: [PATCH 2/5] fix(ramps-controller): require value=true in providerServesAsset Previously providerServesAsset checked only for key presence in supportedCryptoCurrencies, so a provider with { "asset": false } would be treated as serving that asset. This aligns the function with the mobile UI's providerSupportsAsset utility, which has always required the value to be strictly true. Without this fix, setSelectedProviderForAsset could select a provider with a false-valued entry, causing an infinite re-run loop in consumers that combine a controller-side switch with a UI-side isTokenUnavailable check (which uses the stricter providerSupportsAsset). --- packages/ramps-controller/CHANGELOG.md | 7 ++++++- packages/ramps-controller/src/providerAvailability.test.ts | 7 +++++++ packages/ramps-controller/src/providerAvailability.ts | 4 +++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 10279ee0292..19558569e62 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,10 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `setSelectedProviderForAsset(assetId, options?)` method and `RampsController:setSelectedProviderForAsset` messenger action ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) +- Add `setSelectedProviderForAsset(assetId, options?)` method and `RampsController:setSelectedProviderForAsset` messenger action ([#9759](https://github.com/MetaMask/core/pull/9759)) - Switches `providers.selected` to the first provider in `providers.data` that serves the given CAIP-19 asset when the currently selected provider does not, using the existing `providerServesAsset` utility. - Returns `true` if a switch was made, `false` otherwise (no-op when providers are not yet loaded, the current provider already serves the asset, or no alternative provider serves the asset). +### Fixed + +- Fix `providerServesAsset` to require the `supportedCryptoCurrencies` map value to be `true`, not just key presence ([#9759](https://github.com/MetaMask/core/pull/9759)) + - Previously a provider with `{ "eip155:1/erc20:0x...": false }` would be treated as serving the asset. + ## [18.0.1] ### Changed diff --git a/packages/ramps-controller/src/providerAvailability.test.ts b/packages/ramps-controller/src/providerAvailability.test.ts index bcbd8434edb..856ee13baa3 100644 --- a/packages/ramps-controller/src/providerAvailability.test.ts +++ b/packages/ramps-controller/src/providerAvailability.test.ts @@ -50,6 +50,13 @@ describe('providerServesAsset', () => { }); expect(providerServesAsset(provider, ASSET_ID)).toBe(false); }); + + it('returns false when the asset key is present but its value is false', () => { + const provider = buildProvider('moonpay', 'aggregator', { + [ASSET_ID]: false, + }); + expect(providerServesAsset(provider, ASSET_ID)).toBe(false); + }); }); describe('getProvidersServingAsset', () => { diff --git a/packages/ramps-controller/src/providerAvailability.ts b/packages/ramps-controller/src/providerAvailability.ts index 59d6e2b4492..e06fc81781a 100644 --- a/packages/ramps-controller/src/providerAvailability.ts +++ b/packages/ramps-controller/src/providerAvailability.ts @@ -24,7 +24,9 @@ export function providerServesAsset( return false; } const target = assetId.toLowerCase(); - return Object.keys(map).some((key) => key.toLowerCase() === target); + return Object.keys(map).some( + (key) => key.toLowerCase() === target && map[key] === true, + ); } /** From c724bf752cd62d4c9250ad1adaeea3ec82a3e842 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Mon, 3 Aug 2026 06:48:07 -0700 Subject: [PATCH 3/5] test(ramps-controller): add self-selection guard test for setSelectedProviderForAsset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing test passes for the wrong reason: the provider has empty supportedCryptoCurrencies so providerServesAsset is false regardless, meaning the p.id !== selectedProvider.id guard in find() never needs to fire. Add a test that actually exercises the guard: providers.selected is a stale copy with no assets while providers.data has the same provider ID with the asset — the guard must skip it to prevent a spurious self-switch. --- .../src/RampsController.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 5858a50f2c2..a57d6b9006e 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -5185,6 +5185,32 @@ describe('RampsController', () => { ); }); + it('does not re-select the current provider when its data-list entry serves the asset but the selected copy does not', async () => { + // Simulates a stale-selected-copy scenario: providers.selected has empty + // assets while providers.data holds the fresh version of the same provider + // with the asset now included. The p.id !== selectedProvider.id guard in + // the find() is the only thing that prevents a spurious self-switch. + const selectedStale = makeProvider('/providers/transak-native'); + const selectedFresh = makeProvider('/providers/transak-native', [ASSET_ID]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([selectedFresh], selectedStale), + }, + }, + }, + ({ controller }) => { + const switched = controller.setSelectedProviderForAsset(ASSET_ID); + + expect(switched).toBe(false); + expect(controller.state.providers.selected).toStrictEqual(selectedStale); + }, + ); + }); + it('matches the asset case-insensitively (API key lowercase, caller assetId checksummed)', async () => { const incompatible = makeProvider('/providers/coinbase'); // The providers API returns lowercase keys; the caller passes checksummed assetId. From b7b209a0160f78f418456c974256a51da27b3aa4 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Mon, 3 Aug 2026 06:57:24 -0700 Subject: [PATCH 4/5] fix(ramps-controller): address ESLint violations in setSelectedProviderForAsset Rename single-letter `p` parameter in providers.find callback (id-length rule requires at least 2 chars). Drop the `=== true` comparison against a boolean value in providerAvailability (no-unnecessary-boolean-literal- compare). --- packages/ramps-controller/src/RampsController.ts | 5 +++-- packages/ramps-controller/src/providerAvailability.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 38accb5a284..9d48011e52a 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -1485,8 +1485,9 @@ export class RampsController extends BaseController< } const compatible = providers.find( - (p) => - p.id !== selectedProvider?.id && providerServesAsset(p, assetId), + (provider) => + provider.id !== selectedProvider?.id && + providerServesAsset(provider, assetId), ); if (!compatible) { return false; diff --git a/packages/ramps-controller/src/providerAvailability.ts b/packages/ramps-controller/src/providerAvailability.ts index e06fc81781a..7cd6e53bb8b 100644 --- a/packages/ramps-controller/src/providerAvailability.ts +++ b/packages/ramps-controller/src/providerAvailability.ts @@ -25,7 +25,7 @@ export function providerServesAsset( } const target = assetId.toLowerCase(); return Object.keys(map).some( - (key) => key.toLowerCase() === target && map[key] === true, + (key) => key.toLowerCase() === target && map[key], ); } From cd8bbb55b1a09495a7b8b3e2bf08f2ccf6bdd7bf Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Mon, 3 Aug 2026 09:44:56 -0700 Subject: [PATCH 5/5] fix(ramps-controller): resolve current provider from fresh providers data setSelectedProviderForAsset checked asset compatibility against the providers.selected snapshot, which can be stale once a newer providers list lands in providers.data. When the fresh entry for the current provider did serve the asset and another provider served it too, the method switched away and returned true. Look the current provider up in providers.data by id, falling back to providers.selected when it is absent, and keep the self-switch guard on the replacement lookup. Add a regression test for the case where a second compatible provider is available. Also export RampsControllerSetSelectedProviderForAssetAction from the package index alongside its sibling method action types, and apply oxfmt formatting to the test file. --- .../RampsController-method-action-types.ts | 4 ++ .../src/RampsController.test.ts | 72 ++++++++++++++++--- .../ramps-controller/src/RampsController.ts | 14 ++-- packages/ramps-controller/src/index.ts | 1 + 4 files changed, 79 insertions(+), 12 deletions(-) diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 2ec0513136f..714c11a3fc5 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -97,6 +97,10 @@ export type RampsControllerSetSelectedProviderAction = { * duplicating `providerServesAsset` + find-and-switch across multiple UI * layers. * + * The compatibility check prefers the current provider's entry in + * `providers.data` over the `providers.selected` copy, which can be stale + * once a fresh providers list arrives. + * * No-op when: * - `providers.data` is empty (providers not yet loaded) * - the currently selected provider already serves the asset diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index a57d6b9006e..6ba3c886128 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -5061,10 +5061,7 @@ describe('RampsController', () => { const ASSET_ID = 'eip155:143/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA'; - const makeProvider = ( - id: string, - assetIds: string[] = [], - ): Provider => ({ + const makeProvider = (id: string, assetIds: string[] = []): Provider => ({ id, name: id, environmentType: 'PRODUCTION' as const, @@ -5165,6 +5162,28 @@ describe('RampsController', () => { ); }); + it('switches to a compatible provider when nothing is selected yet', async () => { + const incompatible = makeProvider('/providers/coinbase'); + const compatible = makeProvider('/providers/transak-native', [ASSET_ID]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([incompatible, compatible], null), + }, + }, + }, + ({ controller }) => { + const switched = controller.setSelectedProviderForAsset(ASSET_ID); + + expect(switched).toBe(true); + expect(controller.state.providers.selected).toStrictEqual(compatible); + }, + ); + }); + it('does not select the currently selected provider as its own replacement', async () => { const provider = makeProvider('/providers/transak-native'); @@ -5188,10 +5207,12 @@ describe('RampsController', () => { it('does not re-select the current provider when its data-list entry serves the asset but the selected copy does not', async () => { // Simulates a stale-selected-copy scenario: providers.selected has empty // assets while providers.data holds the fresh version of the same provider - // with the asset now included. The p.id !== selectedProvider.id guard in - // the find() is the only thing that prevents a spurious self-switch. + // with the asset now included. The fresh entry is what decides + // compatibility, so there is no spurious self-switch. const selectedStale = makeProvider('/providers/transak-native'); - const selectedFresh = makeProvider('/providers/transak-native', [ASSET_ID]); + const selectedFresh = makeProvider('/providers/transak-native', [ + ASSET_ID, + ]); await withController( { @@ -5206,7 +5227,42 @@ describe('RampsController', () => { const switched = controller.setSelectedProviderForAsset(ASSET_ID); expect(switched).toBe(false); - expect(controller.state.providers.selected).toStrictEqual(selectedStale); + expect(controller.state.providers.selected).toStrictEqual( + selectedStale, + ); + }, + ); + }); + + it('does not switch away when the current provider data-list entry serves the asset and another provider serves it too', async () => { + // Same stale-selected-copy scenario, but with a second compatible + // provider available: the fresh entry for the current provider must win + // over the stale selected copy, so no switch happens. + const selectedStale = makeProvider('/providers/transak-native'); + const selectedFresh = makeProvider('/providers/transak-native', [ + ASSET_ID, + ]); + const other = makeProvider('/providers/moonpay', [ASSET_ID]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState( + [selectedFresh, other], + selectedStale, + ), + }, + }, + }, + ({ controller }) => { + const switched = controller.setSelectedProviderForAsset(ASSET_ID); + + expect(switched).toBe(false); + expect(controller.state.providers.selected).toStrictEqual( + selectedStale, + ); }, ); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 9d48011e52a..e8c24a8836f 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -1459,6 +1459,10 @@ export class RampsController extends BaseController< * duplicating `providerServesAsset` + find-and-switch across multiple UI * layers. * + * The compatibility check prefers the current provider's entry in + * `providers.data` over the `providers.selected` copy, which can be stale + * once a fresh providers list arrives. + * * No-op when: * - `providers.data` is empty (providers not yet loaded) * - the currently selected provider already serves the asset @@ -1479,15 +1483,17 @@ export class RampsController extends BaseController< return false; } - const selectedProvider = this.state.providers.selected; - if (selectedProvider && providerServesAsset(selectedProvider, assetId)) { + const selectedId = this.state.providers.selected?.id; + const currentProvider = + providers.find((provider) => provider.id === selectedId) ?? + this.state.providers.selected; + if (currentProvider && providerServesAsset(currentProvider, assetId)) { return false; } const compatible = providers.find( (provider) => - provider.id !== selectedProvider?.id && - providerServesAsset(provider, assetId), + provider.id !== selectedId && providerServesAsset(provider, assetId), ); if (!compatible) { return false; diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 239e3a5b7ec..7fd12e031c0 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -18,6 +18,7 @@ export type { RampsControllerGetRequestStateAction, RampsControllerSetUserRegionAction, RampsControllerSetSelectedProviderAction, + RampsControllerSetSelectedProviderForAssetAction, RampsControllerInitAction, RampsControllerGetCountriesAction, RampsControllerGetTokensAction,