feat(ramps-controller): scope-aware in-app provider widening in getQuotes#9353
Conversation
…otes 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.
|
@metamaskbot publish-preview |
|
Preview builds have been published. Learn how to use preview builds in other projects. Expand for full list of packages and versions. |
… 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).
|
@metamaskbot publish-preview |
…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%.
|
@metamaskbot publish-preview |
|
Preview builds have been published. Learn how to use preview builds in other projects. Expand for full list of packages and versions. |
|
Preview builds have been published. Learn how to use preview builds in other projects. Expand for full list of packages and versions. |
Fixes the lint:misc:check failure. Formatting only, no behaviour change.
…-in-app-provider-scope # Conflicts: # packages/ramps-controller/CHANGELOG.md
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.
|
@metamaskbot publish-preview |
|
Preview builds have been published. Learn how to use preview builds in other projects. Expand for full list of packages and versions. |
…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.
|
@metamaskbot publish-preview |
|
Preview builds have been published. Learn how to use preview builds in other projects. Expand for full list of packages and versions. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9e8ad9c. Configure here.
…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.
## Explanation This release branch publishes `@metamask/ramps-controller` `15.1.0`. The release includes the scope-aware in-app provider widening work from MetaMask#9353, plus the existing unreleased `init()` / countries catalog refresh entries from MetaMask#9261. The release tool also bumped the root monorepo version to `1095.0.0` and updated `@metamask/transaction-pay-controller`'s workspace dependency on `@metamask/ramps-controller` to `^15.1.0`. This branch was generated by `yarn create-release-branch`. ## References - Related to MetaMask#9353 - Related to MetaMask#9261 - Related to MetaMask/metamask-mobile#32682 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them Validation: `yarn changelog:validate` Made with [Cursor](https://cursor.com) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…ty (MetaMask#9448) ## Explanation `@metamask/ramps-controller` normalized provider IDs by stripping a `/providers/` prefix through a `normalizeProviderCode` helper. This normalization was applied in two places: across the `getQuotes` scope-aware selection logic (matching the providers list, each quote's `provider`, custom-action provider IDs, and the sort-order IDs) and when building a precreated stub order, where the provider code was re-wrapped as `/providers/<code>`. The Ramps backend now returns canonical, non-prefixed provider IDs, so this normalization is unnecessary and inconsistent with the new contract. This PR removes provider-ID normalization entirely so IDs are compared and stored as-is (canonical). Concretely: the `normalizeProviderCode` function and its public export are removed; the six `normalizeProviderCode(...)` call sites in the `getQuotes` selection logic are dropped so the map keys and lookups use the raw provider IDs; and a precreated stub order's `provider.id` is now the canonical provider code passed to the create-order call rather than a `/providers/`-prefixed value. The dedicated `normalizeProviderCode` unit test is removed and the stub-order test now expects the canonical `paypal` ID. One detail worth calling out for reviewers: the six removed call sites were added recently in MetaMask#9353 (scope-aware in-app provider widening). Because normalization is removed uniformly from both sides of every map key and lookup, matching behavior is preserved for consistently-formatted IDs, and the existing `getQuotes` tests continue to pass unchanged (308 tests green). This is a breaking change: `normalizeProviderCode` is no longer exported, and the precreated stub order's `provider.id` changes shape from `/providers/<code>` to `<code>`. Consumers must supply non-prefixed (canonical) provider IDs. The mobile consumer PR removes all `normalizeProviderCode` usages and the app-side normalization to adopt this. ## References * Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-3560 * Consumer PR (mobile): MetaMask/metamask-mobile#32819 * Related to MetaMask#9353 (introduced the `getQuotes` normalization removed here) ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [x] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Breaking API and state shape changes for buy/quote flows; wrong IDs from consumers that still pass `/providers/`-prefixed values could break quote selection or order polling until clients migrate. > > **Overview** > **Breaking change:** `@metamask/ramps-controller` drops `/providers/` prefix normalization so provider IDs are compared and stored as canonical codes (e.g. `paypal`, `transak`). > > The **`normalizeProviderCode` helper and its public export are removed**. In **`getQuotes`** scope-aware selection, maps and lookups now use raw `provider.id`, quote `provider`, custom-action `providerId`, and sort-order IDs with no stripping. **`addPrecreatedOrder`** sets stub `provider.id` to the passed `providerCode` instead of `/providers/<code>`. > > JSDoc and the changelog document the contract. Tests drop the normalizer suite, expect canonical `paypal` on stubs, and add coverage for plain-ID matching in scoped quote selection. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e352e4f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Darius Costolas <10818970+meltingice1337@users.noreply.github.com>
…ller-side; share provider/quote/error helpers (MetaMask#9409) ## Explanation Headless Buy provider expansion previously reached `RampsController` through an injected `getProviderScope` callback returning a multivariate scope (`off` | `in-app` | `all`), and `metamask-mobile` re-derived gating logic the controller already owns privately. This PR replaces the scope concept with a single boolean remote feature flag that the controller reads itself, and lifts the shared logic into pure, tested, exported helpers so consumers cannot drift from the controller's own selection. Flag semantics (`moneyHeadlessAllProviders`, boolean; this is the client-side key, which the ClientConfig API camelizes from the kebab-case LaunchDarkly dashboard key `money-headless-all-providers`): - `true`: the auto-selection quote path (`autoSelectProvider` / `restrictToKnownOrNativeProviders`) widens to every supporting provider class: native, in-app WebView aggregator, and external-browser / custom-action. The best quote is returned at `success[0]`, enforcing per-provider fiat limits. - `false`, missing, or any non-boolean value: today's native-only behavior. The intermediate `in-app` scope is removed, along with the exported `ProviderScope` type and the `getProviderScope` constructor option (breaking). Controller-side consumption: - `RemoteFeatureFlagController:getState` is added to the controller's allowed messenger actions (same pattern as `ConfigRegistryController` / `bridge-controller`), and a private `#isAllProvidersEnabled()` resolves the flag per `getQuotes` call, so a remote fetch or a local dev override takes effect at runtime. The read fails closed: if the action is not delegated, quoting stays native-only. - New `featureFlags` module exports `MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY` and the pure `isHeadlessAllProvidersEnabled(remoteFeatureFlagState)` helper (plus a `HeadlessFeatureFlagsLookup` type). The helper owns the key lookup, the `localOverrides`-over-`remoteFeatureFlags` merge (mobile's Settings > Feature flag override screen writes `localOverrides`, and published `RemoteFeatureFlagController` versions such as `4.2.2` do not fold overrides into `remoteFeatureFlags` state), and defensive coercion: only the literal boolean `true` enables. Mobile UI hooks call the same helper, so the UI gate and the controller resolve the flag identically. Shared pure helpers (consumed by the controller internally so they stay behaviourally identical to its own selection): - `providerAvailability`: `providerServesAsset`, `getProvidersServingAsset`, `regionHasProviderForAsset`, `isFiatDepositAvailable`. The latter two take an `allProvidersEnabled: boolean` instead of a scope. - `quoteClassification`: `isExternalBrowserQuote`, `isCustomActionQuote`, `isInAppOnlyQuote`. - `errorNormalization`: `getErrorMessage`, `extractExplicitTypedError`, `normalizeToTypedError` (+ `TypedError<Code>`). The deliberately host-specific bits (deeplink schemes, redirect URLs, session lifecycle, error taxonomy values) stay in the consumer. All 601 package tests pass with 100% coverage, including: widening on/off via the flag, `localOverrides` honored in both directions, garbage flag values coerced to `false`, an unwired `RemoteFeatureFlagController:getState` failing closed, and per-call flag reads picking up runtime changes. ## References - Consumer PR: MetaMask/metamask-mobile#32890 (adopts these helpers, removes the mobile-side scope plumbing, and consumes the flag via the exported helper) - Replaces the scope-aware widening surface introduced in MetaMask#9353 with the boolean flag - Part of the Headless Buy all-providers plan (`app/components/UI/Ramp/headless/PLAN_-_ALL_PROVIDERS_SUPPORT.md` in metamask-mobile) ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [x] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them Made with [Cursor](https://cursor.com)

Explanation
Part of Headless Buy "all providers" support (Phase 1). This adds an opt-in way for a host to widen
RampsController.getQuotesfrom native-only auto-selection to in-app WebView aggregator providers, with no change to callers that pass explicit providers.getProviderScopecallback onRampsControllerOptions, plus an exportedProviderScopetype ('off' | 'in-app' | 'all'). The callback is read on everygetQuotescall, so a host toggle takes effect at runtime. It defaults to'off'(today's behaviour) when omitted.'off', the native-only auto-selection path (autoSelectProvider/restrictToKnownOrNativeProviders) widens to every supporting provider and returns the single best in-app quote atsuccess[0]. A private#pickInAppQuotedrops 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 using the server-providedsortedorder. It reuses the existinggetProvidershydration fallback so an empty catalog cannot silently quote nothing.getDefaultRedirectUrlcallback onRampsControllerOptions. On the widened in-app path, when the caller does not pass aredirectUrl,getQuotesuses this default so aggregator quotes carry thebuyURL/buyWidgetthe client needs to open the WebView. An explicit callerredirectUrlalways wins, and scope'off'never injects one. (This closes a gap where MetaMask Pay's quote fetch omitsredirectUrl, leaving aggregator quotes without a buy URL.)providersare unaffected, andproviders.selectedis never mutated.This lets a client move the caller that reads
getQuotes(...).success[0]onto in-app providers behind its own gate, with no other core changes.VIDEO IN MOBILE PR that uses this new core version: MetaMask/metamask-mobile#32682
Here's a Google Drive link since Github is uploading this video very slowly: https://drive.google.com/file/d/14Ialq7Tg9Ep0tpCljAle5ttAX92zTPIh/view?usp=sharing
Uploading ScreenRecording_07-06-2026 00-12-51_1.MP4…
References
Checklist
Note
Medium Risk
Changes which ramp provider and quote
getQuotesreturns for gated auto-select callers (fiat purchase flow), though defaults preserve prior native-only behavior and coverage is extensive.Overview
Adds opt-in Headless Buy Phase 1 behavior so hosts can widen fiat quote auto-selection from native-only to in-app aggregator providers without changing explicit-
providerscallers.getProviderScope('off' | 'in-app' | 'all', default'off') is read on eachgetQuotescall. When scope is not'off'and the caller usesautoSelectProviderorrestrictToKnownOrNativeProviderswithout an explicitproviderslist, the controller quotes every region/asset-supporting provider, then#pickInAppQuotereduces the response to a single winner atsuccess[0](forsuccess?.[0]consumers). Under'in-app', selection skips external-browser (IN_APP_OS_BROWSER), custom-action providers, and quotes outside per-provider fiat limits; ordering follows serversorted(reliability, then price). Scope'all'keeps external/custom quotes eligible. Empty supporting sets return an empty success list instead of unfiltered service quotes.getDefaultRedirectUrlfillsredirectUrlon that widened path when the caller omits it so aggregator quotes get a usablebuyWidgetURL; explicitredirectUrlwins and scope'off'never injects.ProviderScopeis exported from the package index.Reviewed by Cursor Bugbot for commit 0b318e0. Bugbot is set up for automated code reviews on this repo. Configure here.