Skip to content
Draft
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
6 changes: 6 additions & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add pure quote-selection helpers `getSmartSelectedQuote` and `fitsProviderLimits`, the amount validator `validateBuyAmount`, and a `BuyAmountValidation` type so headless-buy consumers can share the controller's provider-agnostic quote ranking (`preferredProviderIds` then reliability then price then first surviving candidate), scope-aware in-app filtering, and per-provider fiat-limit enforcement instead of re-deriving them ([#9414](https://github.com/MetaMask/core/pull/9414))
- Add pure provider-availability helpers `providerServesAsset`, `getProvidersServingAsset`, `regionHasProviderForAsset`, and `isFiatDepositAvailable` so headless-buy consumers can share the controller's case-insensitive CAIP-19 asset matching and scope-aware region/availability gating instead of re-deriving it, keeping the two from disagreeing ([#9409](https://github.com/MetaMask/core/pull/9409))
- Add pure quote-classification helpers `isExternalBrowserQuote`, `isCustomActionQuote`, and `isInAppOnlyQuote` so consumers can share the controller's in-app-vs-external browser-mode classification without owning host redirect/deeplink concerns ([#9409](https://github.com/MetaMask/core/pull/9409))
- Add pure error-normalization helpers `getErrorMessage`, `extractExplicitTypedError`, and `normalizeToTypedError` (with a `TypedError<Code>` type) so consumers can share error-shape extraction while keeping their own error-code taxonomy ([#9409](https://github.com/MetaMask/core/pull/9409))

### Changed

- `RampsController.getQuotes` now derives its provider-widening decision from the `getProviderScope` scope (`off` stays native-only; `in-app`/`all` widen) rather than the `restrictToKnownOrNativeProviders` flag, and picks the widened auto-selected quote via the shared `getSmartSelectedQuote`, feeding in the user's completed-order provider preference so a returning user's previously-used provider is preferred over the reliability/price winner ([#9414](https://github.com/MetaMask/core/pull/9414))
- `RampsController` now derives its internal region provider-asset matching and quote in-app/custom-action/external filtering from the shared `providerAvailability` and `quoteClassification` helpers, so the exposed helpers stay behaviourally identical to the controller's own selection ([#9409](https://github.com/MetaMask/core/pull/9409))

### Deprecated

- **`RampsController.getQuotes`'s `restrictToKnownOrNativeProviders` option is deprecated** in favor of provider-class scope (`getProviderScope`): `off` keeps native-only auto-selection and `in-app`/`all` widen, so the flag is redundant. It is still honored for one release (like `autoSelectProvider`, it routes a no-`providers` request through the gated path) and will be removed in a later major ([#9414](https://github.com/MetaMask/core/pull/9414))

## [15.1.0]

### Added
Expand Down
133 changes: 133 additions & 0 deletions packages/ramps-controller/src/RampsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1238,6 +1238,139 @@ describe('RampsController', () => {
},
);
});

it('prefers a completed-order provider over the reliability winner on the widened pick', async () => {
const response: QuotesResponse = {
success: [
inAppScopeQuote(MOONPAY, 90),
inAppScopeQuote(REVOLUT, 80),
],
// MoonPay is the most reliable, but the user last completed an order
// with Revolut, so the order-history rung fed into
// `getSmartSelectedQuote` should win.
sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }],
error: [],
customActions: [],
};

await withController(
{
options: {
getProviderScope: () => 'in-app',
state: {
...scopeState([
buildScopeProvider(NATIVE, 'native'),
buildScopeProvider(MOONPAY, 'aggregator'),
buildScopeProvider(REVOLUT, 'aggregator'),
]),
orders: [
createMockOrder({
provider: buildScopeProvider(REVOLUT, 'aggregator'),
createdAt: 2000,
status: RampsOrderStatus.Completed,
}),
createMockOrder({
provider: buildScopeProvider(MOONPAY, 'aggregator'),
createdAt: 1000,
status: RampsOrderStatus.Completed,
}),
],
},
},
},
async ({ messenger, rootMessenger }) => {
rootMessenger.registerActionHandler(
'RampsService:getQuotes',
async () => response,
);

const quotes = await callScopedGetQuotes(messenger);

expect(quotes.success[0]?.provider).toBe(REVOLUT);
},
);
});

it('does not widen and stays native-only when the scope is off even with the deprecated restrict flag', async () => {
await withController(
{
options: {
getProviderScope: () => 'off',
state: scopeState([
buildScopeProvider(NATIVE, 'native'),
buildScopeProvider(MOONPAY, 'aggregator'),
buildScopeProvider(REVOLUT, 'aggregator'),
]),
},
},
async ({ messenger, rootMessenger }) => {
let quotedProviders: string[] | undefined;
rootMessenger.registerActionHandler(
'RampsService:getQuotes',
async (params: { providers?: string[] }) => {
quotedProviders = params.providers;
return {
success: [inAppScopeQuote(NATIVE, 70)],
sorted: [{ sortBy: 'reliability', ids: [NATIVE] }],
error: [],
customActions: [],
} satisfies QuotesResponse;
},
);

await callScopedGetQuotes(messenger, {
autoSelectProvider: true,
restrictToKnownOrNativeProviders: true,
});

// Scope `off` keeps the native-only resolution: only the native
// provider is quoted, not the widened supporting set.
expect(quotedProviders).toStrictEqual([NATIVE]);
},
);
});

it('still honors the deprecated restrict flag alone (no autoSelectProvider) under a widening scope', async () => {
const response: QuotesResponse = {
success: [inAppScopeQuote(MOONPAY, 90), inAppScopeQuote(REVOLUT, 80)],
sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }],
error: [],
customActions: [],
};

await withController(
{
options: {
getProviderScope: () => 'in-app',
state: scopeState([
buildScopeProvider(NATIVE, 'native'),
buildScopeProvider(MOONPAY, 'aggregator'),
buildScopeProvider(REVOLUT, 'aggregator'),
]),
},
},
async ({ messenger, rootMessenger }) => {
let quotedProviders: string[] | undefined;
rootMessenger.registerActionHandler(
'RampsService:getQuotes',
async (params: { providers?: string[] }) => {
quotedProviders = params.providers;
return response;
},
);

const quotes = await callScopedGetQuotes(messenger, {
autoSelectProvider: undefined,
restrictToKnownOrNativeProviders: true,
});

// The deprecated flag alone still routes through the widened/gated
// path: every supporting provider is quoted and the pick is returned.
expect(quotedProviders).toStrictEqual([NATIVE, MOONPAY, REVOLUT]);
expect(quotes.success[0]?.provider).toBe(MOONPAY);
},
);
});
});

describe('getProviders', () => {
Expand Down
132 changes: 40 additions & 92 deletions packages/ramps-controller/src/RampsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { Json } from '@metamask/utils';
import type { Draft } from 'immer';

import { getProvidersServingAsset } from './providerAvailability';
import { isCustomActionQuote, isExternalBrowserQuote } from './quoteClassification';
import { getSmartSelectedQuote } from './quoteSelection';
import type { RampsControllerMethodActions } from './RampsController-method-action-types';
import type { RampsErrorCode } from './rampsErrorCodes';
import { RAMPS_ERROR_CODES } from './rampsErrorCodes';
Expand All @@ -25,8 +25,6 @@ import type {
PaymentMethodsResponse,
QuotesResponse,
Quote,
QuoteSortBy,
QuoteCustomAction,
RampsToken,
RampsServiceActions,
RampsOrder,
Expand Down Expand Up @@ -1846,11 +1844,17 @@ export class RampsController extends BaseController<
* during auto-selection, in priority order (e.g. derived by the caller
* from completed-order history). Only used when `autoSelectProvider` is
* true and `providers` is omitted.
* @param options.restrictToKnownOrNativeProviders - Headless-buy v0 gating. When
* true, auto-selection resolves only a native provider, and an explicitly
* passed `providers` list is filtered to those supporting the region and
* asset. If nothing qualifies, `getQuotes` returns an empty response
* instead of quoting other providers.
* @param options.restrictToKnownOrNativeProviders - **Deprecated** (still
* honored for one release). Headless-buy v0 gating: when true,
* auto-selection resolves only a native provider, and an explicitly passed
* `providers` list is filtered to those supporting the region and asset; if
* nothing qualifies, `getQuotes` returns an empty response instead of
* quoting other providers. Provider-class scope (`getProviderScope`) is now
* the gate (`off` stays native-only; `in-app`/`all` widen), so this flag is
* redundant and will be removed in a later major. Like `autoSelectProvider`,
* it still routes a no-`providers` request through the gated path so
* existing callers keep working; new callers should rely on the scope
* instead of passing this flag.
* @param options.redirectUrl - Optional redirect URL after order completion.
* @param options.action - The ramp action type. Defaults to 'buy'.
* @param options.forceRefresh - Whether to bypass cache.
Expand Down Expand Up @@ -1892,18 +1896,25 @@ export class RampsController extends BaseController<
throw new Error('assetId is required.');
}

// When a non-`off` provider scope is active, widen the native-only
// auto-selection path to every supporting provider and pick the best in-app
// quote from the results (in-app vs external is only knowable per-quote via
// `buyWidget.browser`). Only the auto-select/restrict path that MM Pay's
// Provider-class scope is the gate (P2.M8): `off` keeps native-only
// auto-selection (the kill switch); `in-app`/`all` widen the auto-selection
// path to every supporting provider and pick the best quote from the
// results (in-app vs external is only knowable per-quote via
// `buyWidget.browser`). Only the auto-select path that MM Pay's
// `getRampsQuote` uses is affected; explicit-`providers` callers and the
// plain all-provider path are untouched.
const providerScope = this.#getProviderScope();
const widenToInAppProviders =
providerScope !== 'off' &&
const scopeWidens = providerScope !== 'off';
// The auto-select entry path. `restrictToKnownOrNativeProviders` is
// `@deprecated` (P2.M8) but still honored here: like `autoSelectProvider`,
// it routes a no-explicit-`providers` request through the resolved/gated
// path, so existing callers (TPC `getRampsQuote`) are unaffected until the
// option is removed in a later major.
const usesAutoSelectPath =
!options.providers &&
(options.autoSelectProvider === true ||
options.restrictToKnownOrNativeProviders === true);
const widenToInAppProviders = scopeWidens && usesAutoSelectPath;

let providersToUse: string[];
let inAppProviderCatalog: Provider[] = this.state.providers.data;
Expand Down Expand Up @@ -1971,7 +1982,8 @@ export class RampsController extends BaseController<
// fall through to unfiltered quotes from providers that do not support the
// asset.
if (
(options.restrictToKnownOrNativeProviders || widenToInAppProviders) &&
(widenToInAppProviders ||
options.restrictToKnownOrNativeProviders === true) &&
providersToUse.length === 0
) {
return { success: [], sorted: [], error: [], customActions: [] };
Expand Down Expand Up @@ -2063,10 +2075,12 @@ export class RampsController extends BaseController<
/**
* Selects the best in-app quote from a widened multi-provider response.
*
* Applies the Phase 1 in-app filter (drops custom-action providers and
* external-browser quotes), enforces per-provider fiat limits up front, then
* orders by reliability and falls back to price using the server-provided
* `sorted` order. Returns `undefined` when no in-app quote is usable.
* Thin wrapper over the pure, public `getSmartSelectedQuote`, feeding it the
* controller-owned order-history preference (`#getPreferredProviderIdsFromOrders`)
* as the top ranking rung so a returning user's previously-used provider wins
* over reliability/price. All the provider-agnostic selection logic (in-app
* filter, per-provider limit enforcement, reliability/price ranking) lives in
* `getSmartSelectedQuote` so this path and headless consumers pick identically.
*
* @param response - The multi-provider quotes response.
* @param options - Selection inputs.
Expand All @@ -2090,79 +2104,13 @@ export class RampsController extends BaseController<
providers: Provider[];
},
): Quote | undefined {
const providerByCode = new Map(
providers.map((provider) => [
normalizeProviderCode(provider.id),
provider,
]),
);
const customActionProviderCodes = new Set(
response.customActions.map((action: QuoteCustomAction) =>
normalizeProviderCode(action.buy.providerId),
),
);

const fitsProviderLimits = (quote: Quote): boolean => {
const provider = providerByCode.get(
normalizeProviderCode(quote.provider),
);
const limit = provider?.limits?.fiat?.[fiat]?.[quote.quote.paymentMethod];
if (!limit) {
// No published limits for this provider/payment method: treat as
// eligible and let the provider enforce limits at checkout.
return true;
}
return amount >= limit.minAmount && amount <= limit.maxAmount;
};

const isEligible = (quote: Quote): boolean => {
// `all` (Phase 2) skips the in-app-only exclusions; both scopes still
// enforce provider limits up front.
if (scope !== 'all') {
const providerCode = normalizeProviderCode(quote.provider);
if (customActionProviderCodes.has(providerCode)) {
return false;
}
// Custom-action and external-browser classification is shared with the
// consuming client via `quoteClassification` so both filter identically.
if (isCustomActionQuote(quote) || isExternalBrowserQuote(quote)) {
return false;
}
}
return fitsProviderLimits(quote);
};

const candidates = response.success.filter(isEligible);
if (candidates.length === 0) {
return undefined;
}

const candidateByCode = new Map(
candidates.map((quote) => [normalizeProviderCode(quote.provider), quote]),
);

const pickBySortOrder = (sortBy: QuoteSortBy): Quote | undefined => {
const order = response.sorted.find(
(entry) => entry.sortBy === sortBy,
)?.ids;
if (!order) {
return undefined;
}
for (const providerId of order) {
const match = candidateByCode.get(normalizeProviderCode(providerId));
if (match) {
return match;
}
}
return undefined;
};

// Reliability first, then price, then the first surviving candidate.
return (
pickBySortOrder('reliability') ??
pickBySortOrder('price') ??
candidates[0]
);
return getSmartSelectedQuote(response, {
scope,
amount,
fiat,
providers,
preferredProviderIds: this.#getPreferredProviderIdsFromOrders(),
});
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/ramps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ export {
isCustomActionQuote,
isInAppOnlyQuote,
} from './quoteClassification';
export type { BuyAmountValidation } from './quoteSelection';
export {
getSmartSelectedQuote,
validateBuyAmount,
fitsProviderLimits,
} from './quoteSelection';
export type { TypedError } from './errorNormalization';
export {
getErrorMessage,
Expand Down
Loading
Loading