Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add `getBestProviderForAsset` public method (and `RampsController:getBestProviderForAsset` messenger action) that returns the best `Provider` supporting a given CAIP-19 asset in the current (or supplied) region, using the same cascade as quote auto-selection (selected → preferred from order history → native → first supporting), without mutating any controller state ([#XXXX](https://github.com/MetaMask/core/pull/XXXX))
- Authenticate `RampsService.getPaymentMethods` and `RampsService.getQuotes` by sourcing a bearer token from `AuthenticationController:getBearerToken` and sending it as an `Authorization: Bearer <token>` header ([#8888](https://github.com/MetaMask/core/pull/8888))

## [14.1.1]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,28 @@ export type RampsControllerGetQuotesAction = {
handler: RampsController['getQuotes'];
};

/**
* Returns the best provider that supports the given asset in the specified
* region (defaulting to the current user region), using the same selection
* cascade as quote auto-selection:
* 1. The currently selected provider, if it supports the asset.
* 2. The first order-history provider that supports the asset.
* 3. A native provider (e.g. Transak Native).
* 4. The first supporting provider.
*
* Read-only: does not mutate `providers.selected`, `providerAutoSelected`,
* or any other controller state.
*
* @param options - The options.
* @param options.assetId - CAIP-19 asset type identifier to resolve for.
* @param options.region - Region code to resolve against; defaults to the current user region's region code. Returns null if no region available.
* @returns The best supporting Provider, or null if none supports the asset or no region is available.
*/
export type RampsControllerGetBestProviderForAssetAction = {
type: `RampsController:getBestProviderForAsset`;
handler: RampsController['getBestProviderForAsset'];
};

/**
* Adds or updates a V2 order in controller state.
* If an order with the same providerOrderId already exists, the incoming
Expand Down Expand Up @@ -641,6 +663,7 @@ export type RampsControllerMethodActions =
| RampsControllerGetPaymentMethodsAction
| RampsControllerSetSelectedPaymentMethodAction
| RampsControllerGetQuotesAction
| RampsControllerGetBestProviderForAssetAction
| RampsControllerAddOrderAction
| RampsControllerRemoveOrderAction
| RampsControllerStartOrderPollingAction
Expand Down
264 changes: 264 additions & 0 deletions packages/ramps-controller/src/RampsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7131,6 +7131,270 @@ describe('RampsController', () => {
});
});

describe('getBestProviderForAsset', () => {
const ASSET_ETH = 'eip155:1/slip44:60';
const ASSET_USDC =
'eip155:42161/erc20:0xaf88d065e77c8cc2239327c5edb3a432268e5831';

const moonpay = createMockProvider({
id: '/providers/moonpay',
name: 'MoonPay',
type: 'aggregator',
supportedCryptoCurrencies: { [ASSET_USDC]: true },
});
const transakNative = createMockProvider({
id: '/providers/transak-native',
name: 'Transak Native',
type: 'native',
supportedCryptoCurrencies: { [ASSET_USDC]: true },
});

it('returns the best supporting provider for the given asset', async () => {
await withController(
{
options: {
state: {
userRegion: createMockUserRegion('us'),
providers: createResourceState([moonpay, transakNative], null),
},
},
},
async ({ controller }) => {
const result = await controller.getBestProviderForAsset({
assetId: ASSET_USDC,
});

// moonpay is first-supporting (step 4), transakNative is native (step 3)
// step 3 (native) takes priority over step 4 (first-supporting)
expect(result).toStrictEqual(transakNative);
},
);
});

it('returns null when no provider supports the asset', async () => {
const ethOnly = createMockProvider({
id: '/providers/eth-only',
name: 'ETH Only',
type: 'aggregator',
supportedCryptoCurrencies: { [ASSET_ETH]: true },
});

await withController(
{
options: {
state: {
userRegion: createMockUserRegion('us'),
providers: createResourceState([ethOnly], null),
},
},
},
async ({ controller }) => {
const result = await controller.getBestProviderForAsset({
assetId: ASSET_USDC,
});

expect(result).toBeNull();
},
);
});

it('returns null when no user region is set', async () => {
await withController(
{
options: {
state: {
userRegion: null,
providers: createResourceState([moonpay], null),
},
},
},
async ({ controller }) => {
const result = await controller.getBestProviderForAsset({
assetId: ASSET_USDC,
});

expect(result).toBeNull();
},
);
});

it('is callable via messenger.call', async () => {
await withController(
{
options: {
state: {
userRegion: createMockUserRegion('us'),
providers: createResourceState([moonpay, transakNative], null),
},
},
},
async ({ rootMessenger }) => {
const result = await rootMessenger.call(
'RampsController:getBestProviderForAsset',
{ assetId: ASSET_USDC },
);

expect(result).toStrictEqual(transakNative);
},
);
});

it('does not mutate providers.selected after the call', async () => {
const preSetSelected = moonpay;

await withController(
{
options: {
state: {
userRegion: createMockUserRegion('us'),
providers: createResourceState([moonpay, transakNative], preSetSelected),
},
},
},
async ({ controller }) => {
await controller.getBestProviderForAsset({ assetId: ASSET_USDC });

expect(controller.state.providers.selected).toStrictEqual(
preSetSelected,
);
},
);
});

it('uses the explicit region override instead of the user region', async () => {
await withController(
{
options: {
state: {
// User region differs from the requested region, and its
// providers are not cached, so the explicit region must drive a
// fresh fetch.
userRegion: createMockUserRegion('fr'),
},
},
},
async ({ controller, rootMessenger }) => {
let requestedRegion: string | undefined;
rootMessenger.registerActionHandler(
'RampsService:getProviders',
async (region) => {
requestedRegion = region;
return { providers: [moonpay, transakNative] };
},
);

const result = await controller.getBestProviderForAsset({
assetId: ASSET_USDC,
region: 'us',
});

expect(requestedRegion).toBe('us');
expect(result).toStrictEqual(transakNative);
},
);
});

it('returns the selected provider when it supports the asset', async () => {
await withController(
{
options: {
state: {
userRegion: createMockUserRegion('us'),
// moonpay is selected; transakNative would otherwise win via the
// native step, so this proves the selected provider takes
// precedence.
providers: createResourceState([moonpay, transakNative], moonpay),
},
},
},
async ({ controller }) => {
const result = await controller.getBestProviderForAsset({
assetId: ASSET_USDC,
});

expect(result).toStrictEqual(moonpay);
},
);
});

it('returns the region-resolved selected provider, not the stale state object, under a region override', async () => {
// The selected provider in state carries limits scoped to the user's
// region (fr). The override region (us) returns the same provider id but
// with distinct, region-specific limits. The selected-step must return the
// region-resolved entry so callers get the correct region's data.
const selectedFrLimits = {
fiat: {
eur: {
'/payments/debit-credit-card': {
minAmount: 10,
maxAmount: 100,
feeFixedRate: 1,
feeDynamicRate: 0.01,
},
},
},
};
const moonpayFr = createMockProvider({
id: '/providers/moonpay',
name: 'MoonPay',
type: 'aggregator',
supportedCryptoCurrencies: { [ASSET_USDC]: true },
limits: selectedFrLimits,
});
const usLimits = {
fiat: {
usd: {
'/payments/debit-credit-card': {
minAmount: 20,
maxAmount: 500,
feeFixedRate: 2,
feeDynamicRate: 0.02,
},
},
},
};
const moonpayUs = createMockProvider({
id: '/providers/moonpay',
name: 'MoonPay',
type: 'aggregator',
supportedCryptoCurrencies: { [ASSET_USDC]: true },
limits: usLimits,
});

await withController(
{
options: {
state: {
userRegion: createMockUserRegion('fr'),
// Selected object reflects the fr region's limits.
providers: createResourceState([moonpayFr], moonpayFr),
},
},
},
async ({ controller, rootMessenger }) => {
rootMessenger.registerActionHandler(
'RampsService:getProviders',
async () => {
return { providers: [moonpayUs] };
},
);

const result = await controller.getBestProviderForAsset({
assetId: ASSET_USDC,
region: 'us',
});

// Same id as state.providers.selected, but must be the region-resolved
// entry with the us limits — never the stale fr selected object.
expect(result?.id).toBe('/providers/moonpay');
expect(result?.limits).toStrictEqual(usLimits);
expect(result).toBe(moonpayUs);
expect(result).not.toBe(moonpayFr);
},
);
});
});

describe('getOrder', () => {
const mockOrder = {
id: '/providers/transak-staging/orders/abc-123',
Expand Down
Loading
Loading