From 6d1efb5169ca1fbc1330a7b3b9f53e55059b74dd Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 1 Jul 2026 14:10:19 -0700 Subject: [PATCH 1/6] feat(ramps-controller): scope-aware in-app provider widening in getQuotes Add an optional `getProviderScope` callback to `RampsControllerOptions` (exported `ProviderScope` = 'off' | 'in-app' | 'all', read per getQuotes call). When the scope is non-'off', the native-only auto-selection path (autoSelectProvider / restrictToKnownOrNativeProviders) widens to every supporting provider and returns the best in-app quote at success[0]. `#pickInAppQuote` drops external-browser (buyWidget.browser 'IN_APP_OS_BROWSER') and custom-action quotes, enforces per-provider fiat limits up front, and orders by reliability then price off `sorted`. It also reuses the existing getProviders hydration fallback so an empty catalog does not silently quote nothing. The default stays native-only, explicit-`providers` callers are untouched, and providers.selected is never mutated. A host can now bump MM Pay's getRampsQuote (which reads success[0]) onto in-app providers with no transaction-pay-controller change. --- packages/ramps-controller/CHANGELOG.md | 5 + .../src/RampsController.test.ts | 398 ++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 200 ++++++++- packages/ramps-controller/src/index.ts | 1 + 4 files changed, 603 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 3218f1a4ed..6ca2d087c0 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add an optional `getProviderScope` callback to `RampsControllerOptions` and export the `ProviderScope` type (`'off' | 'in-app' | 'all'`) + - When the callback returns a non-`off` scope, `RampsController.getQuotes` widens its native-only auto-selection (the `autoSelectProvider` / `restrictToKnownOrNativeProviders` path) to every supporting provider, then returns the best in-app quote at `success[0]` (excluding external-browser and custom-action quotes and enforcing per-provider fiat limits). The default remains native-only, and explicit-`providers` callers are unaffected ([#0000](https://github.com/MetaMask/core/pull/0000)) + ## [15.0.0] ### Changed diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 46736e7c10..baa480b710 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -405,6 +405,404 @@ describe('RampsController', () => { }); }); + describe('getQuotes provider-scope widening (in-app)', () => { + const SCOPE_ASSET_ID = 'eip155:1/slip44:60'; + const SCOPE_PAYMENT_METHOD = '/payments/debit-credit-card'; + const SCOPE_WALLET = '0x1234567890abcdef1234567890abcdef12345678'; + const NATIVE = '/providers/transak-native'; + const MOONPAY = '/providers/moonpay'; + const REVOLUT = '/providers/revolut'; + const COINBASE = '/providers/coinbase'; + + const buildScopeProvider = ( + id: string, + type: 'native' | 'aggregator', + limits?: Provider['limits'], + ): Provider => ({ + id, + name: id, + type, + environmentType: 'STAGING', + description: '', + hqAddress: '', + links: [], + logos: { light: '', dark: '', height: 24, width: 77 }, + supportedCryptoCurrencies: { [SCOPE_ASSET_ID]: true }, + ...(limits ? { limits } : {}), + }); + + const fiatLimit = ( + minAmount: number, + maxAmount: number, + ): Provider['limits'] => ({ + fiat: { + usd: { + [SCOPE_PAYMENT_METHOD]: { + minAmount, + maxAmount, + feeFixedRate: 0, + feeDynamicRate: 0, + }, + }, + }, + }); + + const inAppScopeQuote = (provider: string, reliability: number): Quote => ({ + provider, + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: SCOPE_PAYMENT_METHOD, + buyWidget: { + url: 'https://widget.example/checkout', + browser: 'APP_BROWSER', + }, + }, + metadata: { reliability }, + }); + + const externalScopeQuote = ( + provider: string, + reliability: number, + ): Quote => ({ + provider, + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: SCOPE_PAYMENT_METHOD, + buyWidget: { + url: 'https://widget.example/checkout', + browser: 'IN_APP_OS_BROWSER', + }, + }, + metadata: { reliability }, + }); + + const scopeState = ( + providers: Provider[], + ): Partial => ({ + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState(providers, null), + }); + + const callScopedGetQuotes = async ( + messenger: RampsControllerMessenger, + overrides: Record = {}, + ): Promise => + messenger.call('RampsController:getQuotes', { + action: 'buy', + amount: 100, + assetId: SCOPE_ASSET_ID, + fiat: 'USD', + paymentMethods: [SCOPE_PAYMENT_METHOD], + region: 'us-ca', + walletAddress: SCOPE_WALLET, + autoSelectProvider: true, + restrictToKnownOrNativeProviders: true, + ...overrides, + }); + + it('widens to all supporting providers and returns the reliability winner at success[0], excluding external quotes', async () => { + const response: QuotesResponse = { + success: [ + inAppScopeQuote(MOONPAY, 90), + inAppScopeQuote(REVOLUT, 80), + externalScopeQuote(COINBASE, 99), + inAppScopeQuote(NATIVE, 70), + ], + // Coinbase is the most reliable but external, so it is skipped and the + // next in-app provider (MoonPay) wins. + sorted: [ + { sortBy: 'reliability', ids: [COINBASE, MOONPAY, REVOLUT, NATIVE] }, + ], + error: [], + customActions: [], + }; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + state: scopeState([ + buildScopeProvider(NATIVE, 'native'), + buildScopeProvider(MOONPAY, 'aggregator'), + buildScopeProvider(REVOLUT, 'aggregator'), + buildScopeProvider(COINBASE, '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); + + // Widened beyond native-only: every supporting provider was quoted. + expect(quotedProviders).toStrictEqual([ + NATIVE, + MOONPAY, + REVOLUT, + COINBASE, + ]); + expect(quotes.success[0]?.provider).toBe(MOONPAY); + }, + ); + }); + + it('falls back to the price order when there is no reliability order', async () => { + const response: QuotesResponse = { + success: [inAppScopeQuote(MOONPAY, 90), inAppScopeQuote(REVOLUT, 80)], + sorted: [{ sortBy: 'price', ids: [REVOLUT, MOONPAY] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + state: scopeState([ + buildScopeProvider(MOONPAY, 'aggregator'), + buildScopeProvider(REVOLUT, 'aggregator'), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + expect(quotes.success[0]?.provider).toBe(REVOLUT); + }, + ); + }); + + it('skips a provider whose fiat limits do not fit the amount and picks the next', 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([ + // MoonPay's minimum is above the $100 amount, so it is skipped. + buildScopeProvider(MOONPAY, 'aggregator', fiatLimit(200, 1000)), + // Revolut publishes no limits, so it stays eligible. + buildScopeProvider(REVOLUT, 'aggregator'), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + expect(quotes.success[0]?.provider).toBe(REVOLUT); + }, + ); + }); + + it('returns an empty success list when no in-app quote is usable', async () => { + const response: QuotesResponse = { + success: [externalScopeQuote(COINBASE, 99)], + sorted: [{ sortBy: 'reliability', ids: [COINBASE] }], + error: [{ provider: MOONPAY, error: 'unavailable' }], + customActions: [], + }; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + state: scopeState([buildScopeProvider(COINBASE, 'aggregator')]), + }, + }, + async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + expect(quotes.success).toStrictEqual([]); + expect(quotes.error).toStrictEqual(response.error); + }, + ); + }); + + it('excludes custom-action providers from selection', async () => { + const response: QuotesResponse = { + success: [inAppScopeQuote(MOONPAY, 90), inAppScopeQuote(REVOLUT, 80)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], + error: [], + customActions: [ + { + buy: { providerId: MOONPAY }, + paymentMethodId: SCOPE_PAYMENT_METHOD, + supportedPaymentMethodIds: [SCOPE_PAYMENT_METHOD], + }, + ], + }; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + state: scopeState([ + buildScopeProvider(MOONPAY, 'aggregator'), + buildScopeProvider(REVOLUT, 'aggregator'), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + // MoonPay is a custom action, so Revolut wins despite ranking higher. + expect(quotes.success[0]?.provider).toBe(REVOLUT); + }, + ); + }); + + it('does not widen and does not mutate providers.selected when the scope is off', async () => { + const response: QuotesResponse = { + success: [inAppScopeQuote(NATIVE, 70)], + sorted: [{ sortBy: 'reliability', ids: [NATIVE] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + getProviderScope: () => 'off', + state: scopeState([ + buildScopeProvider(NATIVE, 'native'), + buildScopeProvider(MOONPAY, 'aggregator'), + ]), + }, + }, + async ({ controller, messenger, rootMessenger }) => { + let quotedProviders: string[] | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params: { providers?: string[] }) => { + quotedProviders = params.providers; + return response; + }, + ); + + const quotes = await callScopedGetQuotes(messenger); + + // Native-only auto-selection is preserved: only the native provider + // is quoted and the response is returned untouched. + expect(quotedProviders).toStrictEqual([NATIVE]); + expect(quotes).toStrictEqual(response); + expect(controller.state.providers.selected).toBeNull(); + }, + ); + }); + + it('does not widen when the caller passes an explicit providers list', async () => { + const response: QuotesResponse = { + success: [inAppScopeQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + state: scopeState([ + 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, { + providers: [MOONPAY], + }); + + expect(quotedProviders).toStrictEqual([MOONPAY]); + expect(quotes).toStrictEqual(response); + }, + ); + }); + + it('hydrates the provider catalog via getProviders when state is empty', async () => { + const response: QuotesResponse = { + success: [inAppScopeQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + state: { userRegion: createMockUserRegion('us-ca') }, + }, + }, + async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ + providers: [buildScopeProvider(MOONPAY, 'aggregator')], + }), + ); + let quotedProviders: string[] | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params: { providers?: string[] }) => { + quotedProviders = params.providers; + return response; + }, + ); + + const quotes = await callScopedGetQuotes(messenger); + + expect(quotedProviders).toStrictEqual([MOONPAY]); + expect(quotes.success[0]?.provider).toBe(MOONPAY); + }, + ); + }); + }); + describe('getProviders', () => { const mockProviders: Provider[] = [ { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 7ab3bcdac9..6ee55089a5 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -23,6 +23,8 @@ import type { PaymentMethodsResponse, QuotesResponse, Quote, + QuoteSortBy, + QuoteCustomAction, RampsToken, RampsServiceActions, RampsOrder, @@ -660,6 +662,17 @@ export type RampsControllerMessenger = Messenger< /** * Configuration options for the RampsController. */ +/** + * Provider-class scope for fiat quote widening, resolved per `getQuotes` call. + * + * - `off`: native-only auto-selection (default; preserves prior behaviour). + * - `in-app`: also quote in-app WebView aggregator providers and select the + * best in-app quote. + * - `all`: additionally allow external-browser / custom-action providers + * (Phase 2). + */ +export type ProviderScope = 'off' | 'in-app' | 'all'; + export type RampsControllerOptions = { /** The messenger suited for this controller. */ messenger: RampsControllerMessenger; @@ -669,6 +682,13 @@ export type RampsControllerOptions = { requestCacheTTL?: number; /** Maximum number of entries in the request cache. Defaults to 250. */ requestCacheMaxSize?: number; + /** + * Optional callback returning the current provider-class scope for fiat quote + * widening. Read per `getQuotes` call so a host-side toggle takes effect at + * runtime without reconstructing the controller. Defaults to `off` + * (native-only) when omitted. + */ + getProviderScope?: () => ProviderScope; }; // === HELPER FUNCTIONS === @@ -858,6 +878,12 @@ export class RampsController extends BaseController< */ readonly #requestCacheMaxSize: number; + /** + * Resolves the current provider-class scope for fiat quote widening. Defaults + * to `() => 'off'` (native-only) when no callback is injected. + */ + readonly #getProviderScope: () => ProviderScope; + /** * Map of pending requests for deduplication. * Key is the cache key, value is the pending request with abort controller. @@ -924,12 +950,15 @@ export class RampsController extends BaseController< * controller. Missing properties will be filled in with defaults. * @param args.requestCacheTTL - Time to live for cached requests in milliseconds. * @param args.requestCacheMaxSize - Maximum number of entries in the request cache. + * @param args.getProviderScope - Optional callback returning the current + * provider-class scope for fiat quote widening. Defaults to `off`. */ constructor({ messenger, state = {}, requestCacheTTL = DEFAULT_REQUEST_CACHE_TTL, requestCacheMaxSize = DEFAULT_REQUEST_CACHE_MAX_SIZE, + getProviderScope, }: RampsControllerOptions) { super({ messenger, @@ -945,6 +974,7 @@ export class RampsController extends BaseController< this.#requestCacheTTL = requestCacheTTL; this.#requestCacheMaxSize = requestCacheMaxSize; + this.#getProviderScope = getProviderScope ?? ((): ProviderScope => 'off'); this.messenger.registerMethodActionHandlers( this, @@ -1797,7 +1827,21 @@ 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 + // `getRampsQuote` uses is affected; explicit-`providers` callers and the + // plain all-provider path are untouched. + const providerScope = this.#getProviderScope(); + const widenToInAppProviders = + providerScope !== 'off' && + !options.providers && + (options.autoSelectProvider === true || + options.restrictToKnownOrNativeProviders === true); + let providersToUse: string[]; + let inAppProviderCatalog: Provider[] = this.state.providers.data; if (options.providers) { providersToUse = options.restrictToKnownOrNativeProviders ? await this.#filterProviderIdsBySupport({ @@ -1806,6 +1850,16 @@ export class RampsController extends BaseController< region: regionToUse, }) : options.providers; + } else if (widenToInAppProviders) { + // `#getSupportingProvidersForRegion` also hydrates the provider catalog + // when controller state is empty, so all-provider quoting cannot silently + // return zero providers here. + const { supporting } = await this.#getSupportingProvidersForRegion({ + assetId: normalizedAssetIdForValidation, + region: regionToUse, + }); + inAppProviderCatalog = supporting; + providersToUse = supporting.map((provider) => provider.id); } else if ( options.autoSelectProvider || options.restrictToKnownOrNativeProviders @@ -1883,7 +1937,7 @@ export class RampsController extends BaseController< action, }; - return this.executeRequest( + const response = await this.executeRequest( cacheKey, async () => { return this.messenger.call('RampsService:getQuotes', params); @@ -1893,6 +1947,150 @@ export class RampsController extends BaseController< ttl: options.ttl ?? DEFAULT_QUOTES_TTL, }, ); + + if (!widenToInAppProviders) { + return response; + } + + // Reduce the widened multi-provider result to the single best in-app quote + // and place it at `success[0]`, since single-pick consumers + // (`getRampsQuote` -> `success?.[0]`) rely on index 0 while `success[]` + // order is server-defined rather than ranked. + const selectedQuote = this.#pickInAppQuote(response, { + scope: providerScope, + amount: options.amount, + fiat: normalizedFiat, + providers: inAppProviderCatalog, + }); + + if (!selectedQuote) { + // No usable in-app quote: surface "no quote" rather than leaking an + // external/custom quote to the single-pick consumer. + return { + success: [], + sorted: response.sorted, + error: response.error, + customActions: response.customActions, + }; + } + + return { + ...response, + success: [ + selectedQuote, + ...response.success.filter((quote) => quote !== selectedQuote), + ], + }; + } + + /** + * 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. + * + * @param response - The multi-provider quotes response. + * @param options - Selection inputs. + * @param options.scope - Active provider scope (`in-app` or `all`). + * @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. + * @returns The selected quote, or `undefined` when none is usable. + */ + #pickInAppQuote( + response: QuotesResponse, + { + scope, + amount, + fiat, + providers, + }: { + scope: ProviderScope; + amount: number; + fiat: string; + 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; + } + // Defensive: the wire may carry an inline `isCustomAction` flag that is + // absent from the published `Quote` type. + if ( + (quote.quote as { isCustomAction?: boolean }).isCustomAction === true + ) { + return false; + } + if (quote.quote.buyWidget?.browser === 'IN_APP_OS_BROWSER') { + 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] + ); } /** diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 84ab48769e..372bcdefa9 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -7,6 +7,7 @@ export type { RampsControllerStateChangeEvent, RampsControllerOrderStatusChangedEvent, RampsControllerOptions, + ProviderScope, UserRegion, ResourceState, TransakState, From 29a675b0fecb1eef4499e7345ec28cf971914051 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 1 Jul 2026 14:21:29 -0700 Subject: [PATCH 2/6] docs(ramps-controller): reference PR #9353 in changelog entry --- packages/ramps-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 6ca2d087c0..49cad60c47 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add an optional `getProviderScope` callback to `RampsControllerOptions` and export the `ProviderScope` type (`'off' | 'in-app' | 'all'`) - - When the callback returns a non-`off` scope, `RampsController.getQuotes` widens its native-only auto-selection (the `autoSelectProvider` / `restrictToKnownOrNativeProviders` path) to every supporting provider, then returns the best in-app quote at `success[0]` (excluding external-browser and custom-action quotes and enforcing per-provider fiat limits). The default remains native-only, and explicit-`providers` callers are unaffected ([#0000](https://github.com/MetaMask/core/pull/0000)) + - When the callback returns a non-`off` scope, `RampsController.getQuotes` widens its native-only auto-selection (the `autoSelectProvider` / `restrictToKnownOrNativeProviders` path) to every supporting provider, then returns the best in-app quote at `success[0]` (excluding external-browser and custom-action quotes and enforcing per-provider fiat limits). The default remains native-only, and explicit-`providers` callers are unaffected ([#9353](https://github.com/MetaMask/core/pull/9353)) ## [15.0.0] From 2855dbf4ef3d6068cb7b3b2aaa499e781cd1d30a Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 1 Jul 2026 15:21:24 -0700 Subject: [PATCH 3/6] test(ramps-controller): cover in-app scope branches; fix changelog PR link - Add tests for the remaining getQuotes/#pickInAppQuote branches to restore the package's 100% coverage: inline isCustomAction exclusion, sort-order fall-through to the first candidate, scope `all` keeping external/custom quotes, restrict-only widening with a fitting limit, and amount-above-max limit skip. - Flatten the CHANGELOG entry so the PR link sits on the top-level bullet (the check-changelog action requires each entry to link to the PR). --- packages/ramps-controller/CHANGELOG.md | 3 +- .../src/RampsController.test.ts | 207 ++++++++++++++++++ 2 files changed, 208 insertions(+), 2 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 49cad60c47..2300099769 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,8 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add an optional `getProviderScope` callback to `RampsControllerOptions` and export the `ProviderScope` type (`'off' | 'in-app' | 'all'`) - - When the callback returns a non-`off` scope, `RampsController.getQuotes` widens its native-only auto-selection (the `autoSelectProvider` / `restrictToKnownOrNativeProviders` path) to every supporting provider, then returns the best in-app quote at `success[0]` (excluding external-browser and custom-action quotes and enforcing per-provider fiat limits). The default remains native-only, and explicit-`providers` callers are unaffected ([#9353](https://github.com/MetaMask/core/pull/9353)) +- Add an optional `getProviderScope` callback to `RampsControllerOptions` and export the `ProviderScope` type (`'off' | 'in-app' | 'all'`); when it returns a non-`off` scope, `RampsController.getQuotes` widens its native-only auto-selection (the `autoSelectProvider` / `restrictToKnownOrNativeProviders` path) to every supporting provider and returns the best in-app quote at `success[0]`, excluding external-browser and custom-action quotes and enforcing per-provider fiat limits, while the default stays native-only and explicit-`providers` callers are unaffected ([#9353](https://github.com/MetaMask/core/pull/9353)) ## [15.0.0] diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index baa480b710..c4171769db 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -801,6 +801,213 @@ describe('RampsController', () => { }, ); }); + + it('excludes a quote carrying an inline isCustomAction flag', async () => { + const inlineCustom = inAppScopeQuote(MOONPAY, 90); + (inlineCustom.quote as { isCustomAction?: boolean }).isCustomAction = true; + const response: QuotesResponse = { + success: [inlineCustom, inAppScopeQuote(REVOLUT, 80)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + state: scopeState([ + buildScopeProvider(MOONPAY, 'aggregator'), + buildScopeProvider(REVOLUT, 'aggregator'), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + // The inline-flagged MoonPay quote is dropped, so Revolut wins. + expect(quotes.success[0]?.provider).toBe(REVOLUT); + }, + ); + }); + + it('falls through to the first candidate when the sort orders reference no surviving provider', async () => { + const response: QuotesResponse = { + success: [inAppScopeQuote(MOONPAY, 90)], + // Neither order lists MoonPay, so both walks fall through to + // the first surviving candidate. + sorted: [ + { sortBy: 'reliability', ids: [COINBASE] }, + { sortBy: 'price', ids: [REVOLUT] }, + ], + error: [], + customActions: [], + }; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), + }, + }, + async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + expect(quotes.success[0]?.provider).toBe(MOONPAY); + }, + ); + }); + + it('with scope all, does not exclude external or custom-action quotes', async () => { + const response: QuotesResponse = { + success: [externalScopeQuote(COINBASE, 99), inAppScopeQuote(MOONPAY, 80)], + sorted: [{ sortBy: 'reliability', ids: [COINBASE, MOONPAY] }], + error: [], + customActions: [ + { + buy: { providerId: MOONPAY }, + paymentMethodId: SCOPE_PAYMENT_METHOD, + supportedPaymentMethodIds: [SCOPE_PAYMENT_METHOD], + }, + ], + }; + + await withController( + { + options: { + getProviderScope: () => 'all', + state: scopeState([ + buildScopeProvider(COINBASE, 'aggregator'), + buildScopeProvider(MOONPAY, 'aggregator'), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + // `all` keeps the external Coinbase quote (top reliability) eligible. + expect(quotes.success[0]?.provider).toBe(COINBASE); + }, + ); + }); + + it('widens on restrictToKnownOrNativeProviders alone and selects a provider whose limits fit', async () => { + const response: QuotesResponse = { + success: [inAppScopeQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + state: scopeState([ + buildScopeProvider(MOONPAY, 'aggregator', fiatLimit(10, 1000)), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + // No autoSelectProvider flag: widening relies on the restrict flag, + // and MoonPay's limits (10-1000) accommodate the $100 amount. + const quotes = await callScopedGetQuotes(messenger, { + autoSelectProvider: undefined, + }); + + expect(quotes.success[0]?.provider).toBe(MOONPAY); + }, + ); + }); + + it('skips a provider whose maximum limit is below the amount', 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(MOONPAY, 'aggregator', fiatLimit(10, 50)), + buildScopeProvider(REVOLUT, 'aggregator'), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + // MoonPay's max (50) is below the $100 amount, so Revolut wins. + expect(quotes.success[0]?.provider).toBe(REVOLUT); + }, + ); + }); + + it('does not widen for a non-off scope when neither autoSelect nor restrict is set', async () => { + const response: QuotesResponse = { + success: [inAppScopeQuote(MOONPAY, 90), externalScopeQuote(COINBASE, 99)], + sorted: [{ sortBy: 'reliability', ids: [COINBASE, MOONPAY] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + state: scopeState([ + buildScopeProvider(MOONPAY, 'aggregator'), + buildScopeProvider(COINBASE, 'aggregator'), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + // Neither flag and no explicit providers: the plain all-provider path + // runs and the response is returned unfiltered even under `in-app`. + const quotes = await callScopedGetQuotes(messenger, { + autoSelectProvider: undefined, + restrictToKnownOrNativeProviders: undefined, + }); + + expect(quotes).toStrictEqual(response); + }, + ); + }); }); describe('getProviders', () => { From 9e722dac30f61f3c727ad9089698592b7a1bb586 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 1 Jul 2026 15:31:31 -0700 Subject: [PATCH 4/6] chore(TEMP): exclude Transak Native from in-app selection for device testing REVERT before release. Forces #pickInAppQuote to skip native providers so a non-native in-app WebView provider is the one suggested during physical-device testing of the Phase 1 in-app flow. istanbul-ignored to keep coverage at 100%. --- packages/ramps-controller/src/RampsController.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 6ee55089a5..d0b2f0ad5c 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -2039,6 +2039,16 @@ export class RampsController extends BaseController< }; const isEligible = (quote: Quote): boolean => { + // TEMP(device-testing): exclude Transak Native so a non-native in-app + // provider is the one suggested during physical-device testing of the + // in-app WebView flow. REVERT before this ships. + /* istanbul ignore next */ + if ( + providerByCode.get(normalizeProviderCode(quote.provider))?.type === + 'native' + ) { + return false; + } // `all` (Phase 2) skips the in-app-only exclusions; both scopes still // enforce provider limits up front. if (scope !== 'all') { From 0775ba45f707dc4680ca99ac74b3471a6fc36936 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Sun, 5 Jul 2026 11:33:02 -0700 Subject: [PATCH 5/6] style(ramps-controller): prettier-format the scope widening tests Fixes the lint:misc:check failure. Formatting only, no behaviour change. --- .../ramps-controller/src/RampsController.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index c4171769db..cbd45ab5eb 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -804,7 +804,8 @@ describe('RampsController', () => { it('excludes a quote carrying an inline isCustomAction flag', async () => { const inlineCustom = inAppScopeQuote(MOONPAY, 90); - (inlineCustom.quote as { isCustomAction?: boolean }).isCustomAction = true; + (inlineCustom.quote as { isCustomAction?: boolean }).isCustomAction = + true; const response: QuotesResponse = { success: [inlineCustom, inAppScopeQuote(REVOLUT, 80)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], @@ -871,7 +872,10 @@ describe('RampsController', () => { it('with scope all, does not exclude external or custom-action quotes', async () => { const response: QuotesResponse = { - success: [externalScopeQuote(COINBASE, 99), inAppScopeQuote(MOONPAY, 80)], + success: [ + externalScopeQuote(COINBASE, 99), + inAppScopeQuote(MOONPAY, 80), + ], sorted: [{ sortBy: 'reliability', ids: [COINBASE, MOONPAY] }], error: [], customActions: [ @@ -975,7 +979,10 @@ describe('RampsController', () => { it('does not widen for a non-off scope when neither autoSelect nor restrict is set', async () => { const response: QuotesResponse = { - success: [inAppScopeQuote(MOONPAY, 90), externalScopeQuote(COINBASE, 99)], + success: [ + inAppScopeQuote(MOONPAY, 90), + externalScopeQuote(COINBASE, 99), + ], sorted: [{ sortBy: 'reliability', ids: [COINBASE, MOONPAY] }], error: [], customActions: [], From c52e72214f05c4d791c9631b3000e45f151e0e6d Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Sun, 5 Jul 2026 13:45:14 -0700 Subject: [PATCH 6/6] fix(ramps-controller): keep native as a valid in-app candidate Remove the temporary device-testing exclusion of native providers from #pickInAppQuote. It dead-ended native-only assets in prefer-native regions (the on-ramp API dedups the aggregator away, leaving only Transak Native, which the block then excluded -> zero quotes). Add a regression test asserting native is selected when it ranks first among in-app candidates. --- .../src/RampsController.test.ts | 33 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 10 ------ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index d7475a0d91..ac40a48dc3 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -555,6 +555,39 @@ describe('RampsController', () => { ); }); + it('keeps the native provider as a valid in-app candidate and selects it when it ranks first', async () => { + const response: QuotesResponse = { + success: [inAppScopeQuote(NATIVE, 90), inAppScopeQuote(MOONPAY, 80)], + // Transak Native is the most reliable in-app quote, so it wins: the + // in-app scope must not exclude native providers. + sorted: [{ sortBy: 'reliability', ids: [NATIVE, MOONPAY] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + state: scopeState([ + buildScopeProvider(NATIVE, 'native'), + buildScopeProvider(MOONPAY, 'aggregator'), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + expect(quotes.success[0]?.provider).toBe(NATIVE); + }, + ); + }); + it('falls back to the price order when there is no reliability order', async () => { const response: QuotesResponse = { success: [inAppScopeQuote(MOONPAY, 90), inAppScopeQuote(REVOLUT, 80)], diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 87a771b83d..a8eb39e7ba 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -2079,16 +2079,6 @@ export class RampsController extends BaseController< }; const isEligible = (quote: Quote): boolean => { - // TEMP(device-testing): exclude Transak Native so a non-native in-app - // provider is the one suggested during physical-device testing of the - // in-app WebView flow. REVERT before this ships. - /* istanbul ignore next */ - if ( - providerByCode.get(normalizeProviderCode(quote.provider))?.type === - 'native' - ) { - return false; - } // `all` (Phase 2) skips the in-app-only exclusions; both scopes still // enforce provider limits up front. if (scope !== 'all') {