Skip to content
Merged
8 changes: 8 additions & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
};
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* 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.
Expand Down Expand Up @@ -648,6 +678,7 @@ export type RampsControllerMethodActions =
| RampsControllerGetRequestStateAction
| RampsControllerSetUserRegionAction
| RampsControllerSetSelectedProviderAction
| RampsControllerSetSelectedProviderForAssetAction
| RampsControllerInitAction
| RampsControllerGetCountriesAction
| RampsControllerGetTokensAction
Expand Down
294 changes: 294 additions & 0 deletions packages/ramps-controller/src/RampsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading