diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c84d5b0cf0..e6412464b3 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,6 +9,9 @@ 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 ([#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). - Add `RampsService.getDefaultRedirectCallbackUrl()` and the matching `RampsService:getDefaultRedirectCallbackUrl` messenger action (plus the exported `RampsServiceGetDefaultRedirectCallbackUrlAction` type), which return the widened Headless Buy default redirect ("fake callback") URL for the environment the service was constructed with. The method is synchronous. ([#9752](https://github.com/MetaMask/core/pull/9752)) - `baseUrlOverride` deliberately does not apply. It overrides the ramps API host for local development, which in production and staging is not the host that serves `/regions/fake-callback` (`on-ramp-content` versus `on-ramp{-cache}`), and the redirect URL is matched by client UI to detect flow completion. For development the callback already shares the API host family (`on-ramp.dev-api`). Use `RampsEnvironment.Local` for a localhost callback, noting it is pinned to `http://localhost:3000` and does not follow a non-3000 `baseUrlOverride`. - Add the exported `getDefaultRedirectCallbackUrl(environment)` helper, the canonical environment-to-callback map that `RampsService` uses: `on-ramp-content` hosts for production and staging, `on-ramp.dev-api` for development (there is no `on-ramp-content.dev-api` deployment), and `localhost:3000` for local. Client code that needs the value synchronously, without the messenger, can call it directly with the same environment the service was given. ([#9752](https://github.com/MetaMask/core/pull/9752)) @@ -23,6 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Remove the `getDefaultRedirectUrl` callback option from `RampsControllerOptions`. The controller asks `RampsService` for the default redirect URL instead, which keeps the environment a single runtime source of truth so the callback host cannot drift from the API host the service is talking to. ([#9752](https://github.com/MetaMask/core/pull/9752)) - Mobile should drop the `getDefaultRedirectUrl: () => getRampCallbackBaseUrl()` argument from its `RampsController` init once it upgrades, and reimplement `getRampCallbackBaseUrl()` as `getDefaultRedirectCallbackUrl(getRampsEnvironment())` so the UI callback matcher and the controller default resolve from the same environment source. +### 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/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index e8bf713378..714c11a3fc 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -87,6 +87,36 @@ 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. + * + * 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 + * - 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 +678,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 e6535ad34d..cde0a1ca64 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -5105,6 +5105,300 @@ 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('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'); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([provider], provider), + }, + }, + }, + ({ controller }) => { + const switched = controller.setSelectedProviderForAsset(ASSET_ID); + + expect(switched).toBe(false); + }, + ); + }); + + 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 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, + ]); + + 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('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, + ); + }, + ); + }); + + 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 5c3df5c318..d454386b80 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'; @@ -807,6 +810,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getRequestState', 'setUserRegion', 'setSelectedProvider', + 'setSelectedProviderForAsset', 'init', 'getCountries', 'getTokens', @@ -1424,6 +1428,63 @@ 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. + * + * 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 + * - 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 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 !== selectedId && providerServesAsset(provider, 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. diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index ef452ca562..b4633ad5b4 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, diff --git a/packages/ramps-controller/src/providerAvailability.test.ts b/packages/ramps-controller/src/providerAvailability.test.ts index bcbd8434ed..856ee13baa 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 59d6e2b449..7cd6e53bb8 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], + ); } /**