diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index e603191caf..bd9e08abde 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -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` 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 diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index f4543d9f64..839448b2ab 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -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', () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 802bb2f96a..73933ac9b5 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -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'; @@ -25,8 +25,6 @@ import type { PaymentMethodsResponse, QuotesResponse, Quote, - QuoteSortBy, - QuoteCustomAction, RampsToken, RampsServiceActions, RampsOrder, @@ -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. @@ -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; @@ -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: [] }; @@ -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. @@ -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(), + }); } /** diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 8c8818dc30..636757607a 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -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, diff --git a/packages/ramps-controller/src/quoteSelection.test.ts b/packages/ramps-controller/src/quoteSelection.test.ts new file mode 100644 index 0000000000..4874c0abc4 --- /dev/null +++ b/packages/ramps-controller/src/quoteSelection.test.ts @@ -0,0 +1,533 @@ +import { + getSmartSelectedQuote, + validateBuyAmount, + fitsProviderLimits, +} from './quoteSelection'; +import type { ProviderScope } from './RampsController'; +import type { + Provider, + ProviderLimit, + Quote, + QuotesResponse, +} from './RampsService'; + +const NATIVE = '/providers/transak-native'; +const MOONPAY = '/providers/moonpay'; +const REVOLUT = '/providers/revolut'; +const COINBASE = '/providers/coinbase'; +const PAYPAL = '/providers/paypal'; +const PAYMENT_METHOD = '/payments/debit-credit-card'; +const FIAT = 'usd'; + +/** + * Builds a fiat limits map for a provider keyed by the shared fiat/payment + * method used across these tests. + * + * @param minAmount - Minimum fiat amount. + * @param maxAmount - Maximum fiat amount. + * @returns The provider limits. + */ +const fiatLimits = (minAmount: number, maxAmount: number): Provider['limits'] => ({ + fiat: { + [FIAT]: { + [PAYMENT_METHOD]: { + minAmount, + maxAmount, + feeFixedRate: 0, + feeDynamicRate: 0, + }, + }, + }, +}); + +/** + * Builds a provider fixture. + * + * @param id - The provider id. + * @param type - Provider classification. + * @param limits - Optional published limits. + * @returns The provider. + */ +const provider = ( + id: string, + type: 'native' | 'aggregator' = 'aggregator', + limits?: Provider['limits'], +): Provider => ({ + id, + name: id, + type, + environmentType: 'STAGING', + description: '', + hqAddress: '', + links: [], + logos: { light: '', dark: '', height: 24, width: 77 }, + ...(limits ? { limits } : {}), +}); + +/** + * Builds an in-app WebView quote (browser hint `APP_BROWSER`). + * + * @param providerId - The provider id. + * @param reliability - Reliability score for metadata. + * @returns The quote. + */ +const inAppQuote = (providerId: string, reliability = 50): Quote => ({ + provider: providerId, + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: PAYMENT_METHOD, + buyWidget: { url: 'https://widget.example/checkout', browser: 'APP_BROWSER' }, + }, + metadata: { reliability }, +}); + +/** + * Builds an external-browser quote (browser hint `IN_APP_OS_BROWSER`). + * + * @param providerId - The provider id. + * @param reliability - Reliability score for metadata. + * @returns The quote. + */ +const externalQuote = (providerId: string, reliability = 50): Quote => ({ + provider: providerId, + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: PAYMENT_METHOD, + buyWidget: { + url: 'https://widget.example/checkout', + browser: 'IN_APP_OS_BROWSER', + }, + }, + metadata: { reliability }, +}); + +/** + * Builds a quote carrying the inline `isCustomAction` flag. + * + * @param providerId - The provider id. + * @param reliability - Reliability score for metadata. + * @returns The quote. + */ +const customActionQuote = (providerId: string, reliability = 50): Quote => { + const quote = inAppQuote(providerId, reliability); + (quote.quote as { isCustomAction?: boolean }).isCustomAction = true; + return quote; +}; + +const catalog = [ + provider(NATIVE, 'native'), + provider(MOONPAY), + provider(REVOLUT), + provider(COINBASE), + provider(PAYPAL), +]; + +/** + * Invokes `getSmartSelectedQuote` with sensible defaults for these tests. + * + * @param response - The quotes response. + * @param overrides - Selection option overrides. + * @returns The selected quote or `undefined`. + */ +const select = ( + response: QuotesResponse, + overrides: Partial<{ + scope: ProviderScope; + amount: number; + fiat: string; + providers: Provider[]; + preferredProviderIds: string[]; + }> = {}, +): Quote | undefined => + getSmartSelectedQuote(response, { + scope: 'in-app', + amount: 100, + fiat: FIAT, + providers: catalog, + ...overrides, + }); + +describe('getSmartSelectedQuote', () => { + describe('ranking', () => { + it('picks the reliability winner among eligible candidates', () => { + const response: QuotesResponse = { + success: [inAppQuote(MOONPAY, 90), inAppQuote(REVOLUT, 80)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], + error: [], + customActions: [], + }; + + expect(select(response)?.provider).toBe(MOONPAY); + }); + + it('falls back to the price order when there is no reliability order', () => { + const response: QuotesResponse = { + success: [inAppQuote(MOONPAY), inAppQuote(REVOLUT)], + sorted: [{ sortBy: 'price', ids: [REVOLUT, MOONPAY] }], + error: [], + customActions: [], + }; + + expect(select(response)?.provider).toBe(REVOLUT); + }); + + it('falls back to the first surviving candidate when no sort order matches', () => { + const response: QuotesResponse = { + success: [inAppQuote(REVOLUT), inAppQuote(MOONPAY)], + sorted: [], + error: [], + customActions: [], + }; + + expect(select(response)?.provider).toBe(REVOLUT); + }); + + it('prefers reliability over price when both orders exist', () => { + const response: QuotesResponse = { + success: [inAppQuote(MOONPAY), inAppQuote(REVOLUT)], + sorted: [ + { sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }, + { sortBy: 'price', ids: [REVOLUT, MOONPAY] }, + ], + error: [], + customActions: [], + }; + + expect(select(response)?.provider).toBe(MOONPAY); + }); + + it('skips a reliability leader that was filtered out and picks the next eligible one', () => { + const response: QuotesResponse = { + success: [ + inAppQuote(MOONPAY, 90), + inAppQuote(REVOLUT, 80), + externalQuote(COINBASE, 99), + ], + sorted: [ + { sortBy: 'reliability', ids: [COINBASE, MOONPAY, REVOLUT] }, + ], + error: [], + customActions: [], + }; + + expect(select(response)?.provider).toBe(MOONPAY); + }); + }); + + describe('preferredProviderIds rung', () => { + it('prefers a preferred provider over the reliability winner', () => { + const response: QuotesResponse = { + success: [inAppQuote(MOONPAY, 90), inAppQuote(REVOLUT, 80)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], + error: [], + customActions: [], + }; + + expect( + select(response, { preferredProviderIds: [REVOLUT] })?.provider, + ).toBe(REVOLUT); + }); + + it('honors the priority order of preferredProviderIds', () => { + const response: QuotesResponse = { + success: [inAppQuote(MOONPAY, 90), inAppQuote(REVOLUT, 80)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], + error: [], + customActions: [], + }; + + expect( + select(response, { + preferredProviderIds: [COINBASE, REVOLUT, MOONPAY], + })?.provider, + ).toBe(REVOLUT); + }); + + it('ignores a preferred provider that is not an eligible candidate and falls through to reliability', () => { + const response: QuotesResponse = { + success: [inAppQuote(MOONPAY, 90), externalQuote(COINBASE, 99)], + sorted: [{ sortBy: 'reliability', ids: [COINBASE, MOONPAY] }], + error: [], + customActions: [], + }; + + // COINBASE is preferred but external (filtered out under in-app), so the + // reliability winner among eligible candidates wins instead. + expect( + select(response, { preferredProviderIds: [COINBASE] })?.provider, + ).toBe(MOONPAY); + }); + + it('matches preferred ids regardless of the /providers/ prefix', () => { + const response: QuotesResponse = { + success: [inAppQuote(MOONPAY, 90), inAppQuote(REVOLUT, 80)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], + error: [], + customActions: [], + }; + + expect( + select(response, { preferredProviderIds: ['revolut'] })?.provider, + ).toBe(REVOLUT); + }); + }); + + describe('in-app filter', () => { + it('excludes external-browser quotes under in-app scope', () => { + const response: QuotesResponse = { + success: [externalQuote(COINBASE, 99), inAppQuote(MOONPAY, 50)], + sorted: [{ sortBy: 'reliability', ids: [COINBASE, MOONPAY] }], + error: [], + customActions: [], + }; + + expect(select(response, { scope: 'in-app' })?.provider).toBe(MOONPAY); + }); + + it('excludes quotes carrying the inline isCustomAction flag under in-app scope', () => { + const response: QuotesResponse = { + success: [customActionQuote(PAYPAL, 99), inAppQuote(MOONPAY, 50)], + sorted: [{ sortBy: 'reliability', ids: [PAYPAL, MOONPAY] }], + error: [], + customActions: [], + }; + + expect(select(response, { scope: 'in-app' })?.provider).toBe(MOONPAY); + }); + + it('excludes providers listed in the response customActions array under in-app scope', () => { + const response: QuotesResponse = { + success: [inAppQuote(PAYPAL, 99), inAppQuote(MOONPAY, 50)], + sorted: [{ sortBy: 'reliability', ids: [PAYPAL, MOONPAY] }], + error: [], + customActions: [ + { + buy: { providerId: PAYPAL }, + paymentMethodId: PAYMENT_METHOD, + supportedPaymentMethodIds: [PAYMENT_METHOD], + }, + ], + }; + + expect(select(response, { scope: 'in-app' })?.provider).toBe(MOONPAY); + }); + + it('returns undefined when every candidate is filtered out', () => { + const response: QuotesResponse = { + success: [externalQuote(COINBASE, 99), customActionQuote(PAYPAL, 80)], + sorted: [{ sortBy: 'reliability', ids: [COINBASE, PAYPAL] }], + error: [], + customActions: [], + }; + + expect(select(response, { scope: 'in-app' })).toBeUndefined(); + }); + + it('treats scope off like in-app for filtering (native-only gate is applied earlier)', () => { + const response: QuotesResponse = { + success: [externalQuote(COINBASE, 99), inAppQuote(MOONPAY, 50)], + sorted: [{ sortBy: 'reliability', ids: [COINBASE, MOONPAY] }], + error: [], + customActions: [], + }; + + expect(select(response, { scope: 'off' })?.provider).toBe(MOONPAY); + }); + }); + + describe('all scope', () => { + it('keeps external-browser quotes eligible', () => { + const response: QuotesResponse = { + success: [externalQuote(COINBASE, 99), inAppQuote(MOONPAY, 50)], + sorted: [{ sortBy: 'reliability', ids: [COINBASE, MOONPAY] }], + error: [], + customActions: [], + }; + + expect(select(response, { scope: 'all' })?.provider).toBe(COINBASE); + }); + + it('keeps custom-action quotes eligible', () => { + const response: QuotesResponse = { + success: [customActionQuote(PAYPAL, 99), inAppQuote(MOONPAY, 50)], + sorted: [{ sortBy: 'reliability', ids: [PAYPAL, MOONPAY] }], + error: [], + customActions: [ + { + buy: { providerId: PAYPAL }, + paymentMethodId: PAYMENT_METHOD, + supportedPaymentMethodIds: [PAYMENT_METHOD], + }, + ], + }; + + expect(select(response, { scope: 'all' })?.provider).toBe(PAYPAL); + }); + }); + + describe('provider-limit enforcement', () => { + it('drops a candidate whose amount is below the provider minimum', () => { + const response: QuotesResponse = { + success: [inAppQuote(MOONPAY, 90), inAppQuote(REVOLUT, 80)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], + error: [], + customActions: [], + }; + + const providers = [ + provider(MOONPAY, 'aggregator', fiatLimits(200, 1000)), + provider(REVOLUT, 'aggregator', fiatLimits(10, 1000)), + ]; + + expect(select(response, { amount: 100, providers })?.provider).toBe( + REVOLUT, + ); + }); + + it('drops a candidate whose amount is above the provider maximum', () => { + const response: QuotesResponse = { + success: [inAppQuote(MOONPAY, 90), inAppQuote(REVOLUT, 80)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], + error: [], + customActions: [], + }; + + const providers = [ + provider(MOONPAY, 'aggregator', fiatLimits(10, 50)), + provider(REVOLUT, 'aggregator', fiatLimits(10, 1000)), + ]; + + expect(select(response, { amount: 100, providers })?.provider).toBe( + REVOLUT, + ); + }); + + it('accepts a candidate exactly at the provider bounds', () => { + const response: QuotesResponse = { + success: [inAppQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], + error: [], + customActions: [], + }; + + const providers = [provider(MOONPAY, 'aggregator', fiatLimits(100, 100))]; + + expect(select(response, { amount: 100, providers })?.provider).toBe( + MOONPAY, + ); + }); + + it('treats a candidate with no published limits as eligible', () => { + const response: QuotesResponse = { + success: [inAppQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], + error: [], + customActions: [], + }; + + expect( + select(response, { amount: 100_000, providers: [provider(MOONPAY)] }) + ?.provider, + ).toBe(MOONPAY); + }); + }); + + it('returns undefined for an empty success list', () => { + const response: QuotesResponse = { + success: [], + sorted: [], + error: [], + customActions: [], + }; + + expect(select(response)).toBeUndefined(); + }); +}); + +describe('validateBuyAmount', () => { + const limit: ProviderLimit = { + minAmount: 20, + maxAmount: 500, + feeFixedRate: 0, + feeDynamicRate: 0, + }; + + it('is valid when there is no limit to enforce', () => { + expect(validateBuyAmount({ amount: 1_000_000 })).toStrictEqual({ + valid: true, + }); + }); + + it('rejects an amount below the minimum with the offending limit', () => { + expect(validateBuyAmount({ amount: 10, limit })).toStrictEqual({ + valid: false, + reason: 'below_minimum', + limit, + }); + }); + + it('rejects an amount above the maximum with the offending limit', () => { + expect(validateBuyAmount({ amount: 900, limit })).toStrictEqual({ + valid: false, + reason: 'above_maximum', + limit, + }); + }); + + it('accepts an amount at the inclusive minimum bound', () => { + expect(validateBuyAmount({ amount: 20, limit })).toStrictEqual({ + valid: true, + }); + }); + + it('accepts an amount at the inclusive maximum bound', () => { + expect(validateBuyAmount({ amount: 500, limit })).toStrictEqual({ + valid: true, + }); + }); +}); + +describe('fitsProviderLimits', () => { + it('resolves the provider limit and accepts an in-bounds amount', () => { + expect( + fitsProviderLimits(inAppQuote(MOONPAY), { + amount: 100, + fiat: FIAT, + providers: [provider(MOONPAY, 'aggregator', fiatLimits(10, 1000))], + }), + ).toBe(true); + }); + + it('rejects an out-of-bounds amount for the resolved provider limit', () => { + expect( + fitsProviderLimits(inAppQuote(MOONPAY), { + amount: 5, + fiat: FIAT, + providers: [provider(MOONPAY, 'aggregator', fiatLimits(10, 1000))], + }), + ).toBe(false); + }); + + it('treats a quote whose provider is absent from the catalog as eligible', () => { + expect( + fitsProviderLimits(inAppQuote(MOONPAY), { + amount: 100, + fiat: FIAT, + providers: [provider(REVOLUT, 'aggregator', fiatLimits(200, 300))], + }), + ).toBe(true); + }); + + it('matches the provider regardless of the /providers/ prefix', () => { + const quote = inAppQuote('moonpay'); + expect( + fitsProviderLimits(quote, { + amount: 5, + fiat: FIAT, + providers: [provider(MOONPAY, 'aggregator', fiatLimits(10, 1000))], + }), + ).toBe(false); + }); +}); diff --git a/packages/ramps-controller/src/quoteSelection.ts b/packages/ramps-controller/src/quoteSelection.ts new file mode 100644 index 0000000000..560929833d --- /dev/null +++ b/packages/ramps-controller/src/quoteSelection.ts @@ -0,0 +1,227 @@ +import { normalizeProviderCode } from './RampsController'; +import type { ProviderScope } from './RampsController'; +import { isInAppOnlyQuote } from './quoteClassification'; +import type { + Provider, + ProviderLimit, + Quote, + QuotesResponse, + QuoteSortBy, +} from './RampsService'; + +/** + * The result of validating a fiat buy amount against a provider's published + * limit for a payment method. + * + * `valid` is `true` when the amount fits (or when there is no published limit + * to enforce). When `false`, `reason` says which bound was crossed and `limit` + * carries the offending limit so a consumer can surface a min/max message. + */ +export type BuyAmountValidation = + | { valid: true } + | { + valid: false; + reason: 'below_minimum' | 'above_maximum'; + limit: ProviderLimit; + }; + +/** + * Validates a fiat amount against a single provider limit. + * + * This is the pure amount-vs-limit check with no provider-catalog lookup: when + * `limit` is omitted (the provider/payment method publishes no limit) the + * amount is treated as valid and the provider is left to enforce limits at + * checkout. + * + * @param options - The inputs. + * @param options.amount - The fiat amount to validate. + * @param options.limit - The provider's published limit for the relevant fiat + * and payment method, if any. + * @returns The validation result. + */ +export function validateBuyAmount({ + amount, + limit, +}: { + amount: number; + limit?: ProviderLimit; +}): BuyAmountValidation { + if (!limit) { + return { valid: true }; + } + if (amount < limit.minAmount) { + return { valid: false, reason: 'below_minimum', limit }; + } + if (amount > limit.maxAmount) { + return { valid: false, reason: 'above_maximum', limit }; + } + return { valid: true }; +} + +/** + * Resolves the published fiat limit for a quote's provider and payment method. + * + * @param quote - The quote whose provider/payment method to resolve. + * @param fiat - Lowercased fiat short code used to key the limits map. + * @param providerByCode - Provider catalog keyed by normalized provider code. + * @returns The matching limit, or `undefined` when none is published. + */ +function getQuoteLimit( + quote: Quote, + fiat: string, + providerByCode: Map, +): ProviderLimit | undefined { + const provider = providerByCode.get(normalizeProviderCode(quote.provider)); + return provider?.limits?.fiat?.[fiat]?.[quote.quote.paymentMethod]; +} + +/** + * Whether a quote's fiat amount fits the provider's published limits. + * + * Convenience wrapper over {@link validateBuyAmount} that resolves the provider + * limit from the catalog for a single quote. When the provider/payment method + * publishes no limit, the quote is considered eligible. + * + * @param quote - The quote to check. + * @param options - The inputs. + * @param options.amount - The fiat amount. + * @param options.fiat - Lowercased fiat short code used to key the limits map. + * @param options.providers - Provider catalog for the limit lookup. + * @returns Whether the amount fits the provider limits. + */ +export function fitsProviderLimits( + quote: Quote, + { + amount, + fiat, + providers, + }: { amount: number; fiat: string; providers: Provider[] }, +): boolean { + const providerByCode = new Map( + providers.map((provider) => [ + normalizeProviderCode(provider.id), + provider, + ]), + ); + const limit = getQuoteLimit(quote, fiat, providerByCode); + return validateBuyAmount({ amount, limit }).valid; +} + +/** + * Selects the best quote from a widened multi-provider response. + * + * This is the pure, provider-agnostic selection shared by the controller's + * auto-select path (`getQuotes`) and headless consumers, so both derive an + * identical pick from the same response instead of re-ranking locally. It knows + * nothing about host redirect URLs, deeplink schemes, navigation, or analytics. + * + * Behavior: + * + * - Under `in-app` scope it drops custom-action and external-browser quotes + * (via the shared `isInAppOnlyQuote` classification and the response's + * `customActions` provider codes); `all` scope keeps them. Both scopes + * enforce per-provider fiat limits up front via {@link validateBuyAmount}. + * - Ranks the surviving candidates by `preferredProviderIds` (in the given + * order), then reliability, then price (both from the response's `sorted` + * orders), then the first surviving candidate. + * + * @param response - The multi-provider quotes response. + * @param options - Selection inputs. + * @param options.scope - Active provider scope (`off`, `in-app`, or `all`). + * `off` is treated like `in-app` for filtering, since the native-only gate is + * applied earlier at provider resolution rather than here. + * @param options.amount - Fiat amount, for the limit-fit check. + * @param options.fiat - Lowercased fiat short code, for the limit lookup. + * @param options.providers - Provider catalog for the limit lookup. + * @param options.preferredProviderIds - Provider IDs to prefer, in priority + * order (e.g. derived from the caller's completed-order history). Applied as + * the top ranking rung ahead of reliability and price. + * @returns The selected quote, or `undefined` when no quote is usable. + */ +export function getSmartSelectedQuote( + response: QuotesResponse, + { + scope, + amount, + fiat, + providers, + preferredProviderIds, + }: { + scope: ProviderScope; + amount: number; + fiat: string; + providers: Provider[]; + preferredProviderIds?: string[]; + }, +): Quote | undefined { + const providerByCode = new Map( + providers.map((provider) => [ + normalizeProviderCode(provider.id), + provider, + ]), + ); + const customActionProviderCodes = new Set( + response.customActions.map((action) => + normalizeProviderCode(action.buy.providerId), + ), + ); + + 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 (!isInAppOnlyQuote(quote)) { + return false; + } + } + const limit = getQuoteLimit(quote, fiat, providerByCode); + return validateBuyAmount({ amount, limit }).valid; + }; + + const candidates = response.success.filter(isEligible); + if (candidates.length === 0) { + return undefined; + } + + const candidateByCode = new Map( + candidates.map((quote) => [normalizeProviderCode(quote.provider), quote]), + ); + + // 1. A provider the caller prefers (e.g. previously transacted with), + // honored in the given priority order. + for (const preferredId of preferredProviderIds ?? []) { + const match = candidateByCode.get(normalizeProviderCode(preferredId)); + if (match) { + return match; + } + } + + 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; + }; + + // 2. Reliability, 3. price, 4. the first surviving candidate. + return ( + pickBySortOrder('reliability') ?? + pickBySortOrder('price') ?? + candidates[0] + ); +}