From 6d1efb5169ca1fbc1330a7b3b9f53e55059b74dd Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 1 Jul 2026 14:10:19 -0700 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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') { From 9e8ad9c329601034e8c739b1f2cf9ef734337e41 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Sun, 5 Jul 2026 20:56:26 -0700 Subject: [PATCH 7/8] feat(ramps-controller): supply a default redirect URL on the widened in-app path Add an optional getDefaultRedirectUrl callback. When getQuotes widens to in-app providers and the caller omits redirectUrl, forward this default so aggregator quotes carry the buyURL/buyWidget the client needs to open the in-app WebView. An explicit caller redirectUrl always wins; scope 'off' never injects a default. --- packages/ramps-controller/CHANGELOG.md | 1 + .../src/RampsController.test.ts | 148 ++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 35 ++++- 3 files changed, 182 insertions(+), 2 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index a06ed8a10a..64f50bf4e5 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -10,6 +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 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)) +- Add an optional `getDefaultRedirectUrl` callback to `RampsControllerOptions`; on the widened in-app auto-selection path, when the caller omits `redirectUrl`, `RampsController.getQuotes` now supplies this default so aggregator quotes carry the `buyURL`/`buyWidget` widget URL the app needs, while an explicit caller `redirectUrl` always wins and scope `off` never injects a default ([#9353](https://github.com/MetaMask/core/pull/9353)) ### Changed diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index ac40a48dc3..1c0dcb20d3 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1048,6 +1048,154 @@ describe('RampsController', () => { }, ); }); + + it('forwards the injected default redirectUrl on the widened path when the caller omits one', async () => { + const response: QuotesResponse = { + success: [inAppScopeQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], + error: [], + customActions: [], + }; + const DEFAULT_REDIRECT = 'https://default.example/callback'; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + getDefaultRedirectUrl: () => DEFAULT_REDIRECT, + state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), + }, + }, + async ({ messenger, rootMessenger }) => { + let forwardedRedirectUrl: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params: { redirectUrl?: string }) => { + forwardedRedirectUrl = params.redirectUrl; + return response; + }, + ); + + await callScopedGetQuotes(messenger); + + // The caller omitted redirectUrl, so the widened path supplies the + // injected default and forwards it to the service. + expect(forwardedRedirectUrl).toBe(DEFAULT_REDIRECT); + }, + ); + }); + + it('prefers an explicit caller redirectUrl over the injected default on the widened path', async () => { + const response: QuotesResponse = { + success: [inAppScopeQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], + error: [], + customActions: [], + }; + const DEFAULT_REDIRECT = 'https://default.example/callback'; + const EXPLICIT_REDIRECT = 'https://explicit.example/callback'; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + getDefaultRedirectUrl: () => DEFAULT_REDIRECT, + state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), + }, + }, + async ({ messenger, rootMessenger }) => { + let forwardedRedirectUrl: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params: { redirectUrl?: string }) => { + forwardedRedirectUrl = params.redirectUrl; + return response; + }, + ); + + await callScopedGetQuotes(messenger, { + redirectUrl: EXPLICIT_REDIRECT, + }); + + // An explicit caller redirectUrl always wins; the default is not + // applied. + expect(forwardedRedirectUrl).toBe(EXPLICIT_REDIRECT); + }, + ); + }); + + it('does not inject the default redirectUrl when the scope is off', async () => { + const response: QuotesResponse = { + success: [inAppScopeQuote(NATIVE, 70)], + sorted: [{ sortBy: 'reliability', ids: [NATIVE] }], + error: [], + customActions: [], + }; + const DEFAULT_REDIRECT = 'https://default.example/callback'; + + await withController( + { + options: { + getProviderScope: () => 'off', + getDefaultRedirectUrl: () => DEFAULT_REDIRECT, + state: scopeState([buildScopeProvider(NATIVE, 'native')]), + }, + }, + async ({ messenger, rootMessenger }) => { + let forwardedRedirectUrl: string | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params: { redirectUrl?: string }) => { + forwardedRedirectUrl = params.redirectUrl; + return response; + }, + ); + + await callScopedGetQuotes(messenger); + + // Scope `off` never widens, so the default is not injected even when a + // `getDefaultRedirectUrl` callback is present. + expect(forwardedRedirectUrl).toBeUndefined(); + }, + ); + }); + + it('forwards undefined on the widened path when no getDefaultRedirectUrl option is provided', 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')]), + }, + }, + async ({ messenger, rootMessenger }) => { + let forwardedRedirectUrl: string | undefined; + let redirectUrlWasSeen = false; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params: { redirectUrl?: string }) => { + forwardedRedirectUrl = params.redirectUrl; + redirectUrlWasSeen = true; + return response; + }, + ); + + await callScopedGetQuotes(messenger); + + // With no injected callback, the constructor default returns + // undefined, so the widened path forwards undefined. + expect(redirectUrlWasSeen).toBe(true); + expect(forwardedRedirectUrl).toBeUndefined(); + }, + ); + }); }); describe('getProviders', () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index a8eb39e7ba..f3adac4766 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -689,6 +689,16 @@ export type RampsControllerOptions = { * (native-only) when omitted. */ getProviderScope?: () => ProviderScope; + /** + * Optional callback returning the default redirect URL to use for the widened + * in-app quote fetch when the caller omits `redirectUrl`. The quotes API only + * embeds a `buyURL`/`buyWidget` (the WebView page a non-native provider needs) + * when a `redirectUrl` is present, so supplying this default lets widened + * in-app aggregator quotes carry a usable widget URL. Only applied on the + * widened path; an explicit caller `redirectUrl` always wins and scope `off` + * never injects. Defaults to a callback returning `undefined` when omitted. + */ + getDefaultRedirectUrl?: () => string | undefined; }; // === HELPER FUNCTIONS === @@ -884,6 +894,13 @@ export class RampsController extends BaseController< */ readonly #getProviderScope: () => ProviderScope; + /** + * Resolves the default redirect URL for the widened in-app quote fetch when + * the caller omits `redirectUrl`. Defaults to `() => undefined` when no + * callback is injected. + */ + readonly #getDefaultRedirectUrl: () => string | undefined; + /** * Map of pending requests for deduplication. * Key is the cache key, value is the pending request with abort controller. @@ -952,6 +969,9 @@ export class RampsController extends BaseController< * @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`. + * @param args.getDefaultRedirectUrl - Optional callback returning the default + * redirect URL used for the widened in-app quote fetch when the caller omits + * `redirectUrl`. Defaults to a callback returning `undefined`. */ constructor({ messenger, @@ -959,6 +979,7 @@ export class RampsController extends BaseController< requestCacheTTL = DEFAULT_REQUEST_CACHE_TTL, requestCacheMaxSize = DEFAULT_REQUEST_CACHE_MAX_SIZE, getProviderScope, + getDefaultRedirectUrl, }: RampsControllerOptions) { super({ messenger, @@ -975,6 +996,8 @@ export class RampsController extends BaseController< this.#requestCacheTTL = requestCacheTTL; this.#requestCacheMaxSize = requestCacheMaxSize; this.#getProviderScope = getProviderScope ?? ((): ProviderScope => 'off'); + this.#getDefaultRedirectUrl = + getDefaultRedirectUrl ?? ((): string | undefined => undefined); this.messenger.registerMethodActionHandlers( this, @@ -1953,6 +1976,14 @@ export class RampsController extends BaseController< const normalizedAssetId = normalizedAssetIdForValidation; const normalizedWalletAddress = options.walletAddress.trim(); + // The quotes API only embeds a `buyURL`/`buyWidget` when a `redirectUrl` is + // present, so on the widened in-app path (where MM Pay omits one) supply the + // injected default so aggregator quotes carry a usable widget URL. An + // explicit caller `redirectUrl` always wins, and scope `off` never injects. + const effectiveRedirectUrl = + options.redirectUrl ?? + (widenToInAppProviders ? this.#getDefaultRedirectUrl() : undefined); + const cacheKey = createCacheKey('getQuotes', [ normalizedRegion, normalizedFiat, @@ -1961,7 +1992,7 @@ export class RampsController extends BaseController< normalizedWalletAddress, [...paymentMethodsToUse].sort().join(','), [...providersToUse].sort().join(','), - options.redirectUrl, + effectiveRedirectUrl, action, ]); @@ -1973,7 +2004,7 @@ export class RampsController extends BaseController< walletAddress: normalizedWalletAddress, paymentMethods: paymentMethodsToUse, providers: providersToUse, - redirectUrl: options.redirectUrl, + redirectUrl: effectiveRedirectUrl, action, }; From 0b318e0137e351941198b470d4adcc22edde2a93 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Mon, 6 Jul 2026 02:21:23 -0700 Subject: [PATCH 8/8] fix(ramps-controller): return empty on widened path when no provider supports the asset The empty-provider guard in getQuotes only short-circuited on restrictToKnownOrNativeProviders. A caller that triggers in-app widening via autoSelectProvider alone, in a region with no asset-supporting provider, got an empty providersToUse that was then passed to the service, which omits the provider filter and quotes every provider. Broaden the guard to also cover the widened in-app path (widenToInAppProviders) so it returns an empty response instead. Reported by Cursor Bugbot. --- .../src/RampsController.test.ts | 42 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 8 +++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 1c0dcb20d3..f4543d9f64 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -681,6 +681,48 @@ describe('RampsController', () => { ); }); + it('returns an empty response on the widened path when no provider supports the asset, even without restrictToKnownOrNativeProviders', async () => { + // A provider is present (so `#getSupportingProvidersForRegion` reads state + // instead of hydrating), but it supports a different asset than + // `SCOPE_ASSET_ID`, leaving the supporting set empty. + const nonSupportingProvider: Provider = { + ...buildScopeProvider(MOONPAY, 'aggregator'), + supportedCryptoCurrencies: { 'eip155:1/slip44:0': true }, + }; + + await withController( + { + options: { + getProviderScope: () => 'in-app', + state: scopeState([nonSupportingProvider]), + }, + }, + async ({ messenger, rootMessenger }) => { + const getQuotesMock = jest.fn(); + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + getQuotesMock, + ); + + // `autoSelectProvider` alone triggers widening; without + // `restrictToKnownOrNativeProviders` the empty guard is reached via the + // `|| widenToInAppProviders` branch. + const quotes = await callScopedGetQuotes(messenger, { + autoSelectProvider: true, + restrictToKnownOrNativeProviders: false, + }); + + expect(quotes).toStrictEqual({ + success: [], + sorted: [], + error: [], + customActions: [], + }); + expect(getQuotesMock).not.toHaveBeenCalled(); + }, + ); + }); + it('excludes custom-action providers from selection', 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 f3adac4766..6187c4a497 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -1963,9 +1963,13 @@ export class RampsController extends BaseController< // Under headless-buy gating, an empty resolved provider list means no // eligible (native/supporting) provider exists. Return an empty response // rather than passing `[]` to the service, which omits the provider filter - // and would quote every provider. + // and would quote every provider. This also guards the widened in-app path: + // a caller may trigger widening with `autoSelectProvider` alone (no + // `restrictToKnownOrNativeProviders`), and an empty supporting set must not + // fall through to unfiltered quotes from providers that do not support the + // asset. if ( - options.restrictToKnownOrNativeProviders && + (options.restrictToKnownOrNativeProviders || widenToInAppProviders) && providersToUse.length === 0 ) { return { success: [], sorted: [], error: [], customActions: [] };