From 685219bbf5a690bb1640af762d46a92ef5d9f55d Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Tue, 7 Jul 2026 07:58:05 -0700 Subject: [PATCH 1/2] feat(ramps-controller): share provider/quote/error helpers with consumers Expose the provider-availability, quote-classification, and error-normalization logic the controller uses internally as pure, tested helpers so headless-buy consumers (e.g. metamask-mobile) can share it instead of re-deriving it: - providerServesAsset, getProvidersServingAsset, regionHasProviderForAsset, isFiatDepositAvailable - isExternalBrowserQuote, isCustomActionQuote, isInAppOnlyQuote - getErrorMessage, extractExplicitTypedError, normalizeToTypedError (+ TypedError) RampsController now derives its own region asset-matching and quote filtering from these helpers so the shared surface stays behaviourally identical to the controller's selection. --- packages/ramps-controller/CHANGELOG.md | 10 + .../ramps-controller/src/RampsController.ts | 31 +--- .../src/errorNormalization.test.ts | 97 ++++++++++ .../src/errorNormalization.ts | 114 ++++++++++++ packages/ramps-controller/src/index.ts | 17 ++ .../src/providerAvailability.test.ts | 175 ++++++++++++++++++ .../src/providerAvailability.ts | 120 ++++++++++++ .../src/quoteClassification.test.ts | 75 ++++++++ .../src/quoteClassification.ts | 41 ++++ 9 files changed, 658 insertions(+), 22 deletions(-) create mode 100644 packages/ramps-controller/src/errorNormalization.test.ts create mode 100644 packages/ramps-controller/src/errorNormalization.ts create mode 100644 packages/ramps-controller/src/providerAvailability.test.ts create mode 100644 packages/ramps-controller/src/providerAvailability.ts create mode 100644 packages/ramps-controller/src/quoteClassification.test.ts create mode 100644 packages/ramps-controller/src/quoteClassification.ts diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 362d9d0c14..e603191caf 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add pure provider-availability helpers `providerServesAsset`, `getProvidersServingAsset`, `regionHasProviderForAsset`, and `isFiatDepositAvailable` so headless-buy consumers can share the controller's case-insensitive CAIP-19 asset matching and scope-aware region/availability gating instead of re-deriving it, keeping the two from disagreeing ([#9409](https://github.com/MetaMask/core/pull/9409)) +- Add pure quote-classification helpers `isExternalBrowserQuote`, `isCustomActionQuote`, and `isInAppOnlyQuote` so consumers can share the controller's in-app-vs-external browser-mode classification without owning host redirect/deeplink concerns ([#9409](https://github.com/MetaMask/core/pull/9409)) +- Add pure error-normalization helpers `getErrorMessage`, `extractExplicitTypedError`, and `normalizeToTypedError` (with a `TypedError` type) so consumers can share error-shape extraction while keeping their own error-code taxonomy ([#9409](https://github.com/MetaMask/core/pull/9409)) + +### Changed + +- `RampsController` now derives its internal region provider-asset matching and quote in-app/custom-action/external filtering from the shared `providerAvailability` and `quoteClassification` helpers, so the exposed helpers stay behaviourally identical to the controller's own selection ([#9409](https://github.com/MetaMask/core/pull/9409)) + ## [15.1.0] ### Added diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 6187c4a497..802bb2f96a 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -9,6 +9,8 @@ import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; import type { Draft } from 'immer'; +import { getProvidersServingAsset } from './providerAvailability'; +import { isCustomActionQuote, isExternalBrowserQuote } from './quoteClassification'; import type { RampsControllerMethodActions } from './RampsController-method-action-types'; import type { RampsErrorCode } from './rampsErrorCodes'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes'; @@ -2121,14 +2123,9 @@ export class RampsController extends BaseController< 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') { + // Custom-action and external-browser classification is shared with the + // consuming client via `quoteClassification` so both filter identically. + if (isCustomActionQuote(quote) || isExternalBrowserQuote(quote)) { return false; } } @@ -2199,20 +2196,10 @@ export class RampsController extends BaseController< ({ providers } = await this.getProviders(normalizedRegion)); } - // EVM CAIP-19 asset IDs may arrive checksummed or lowercased, and the - // providers API returns both forms, so match case-insensitively on both - // sides. Only the lowercased forms are compared (the original IDs are never - // returned), so case-sensitive non-EVM asset IDs are not corrupted. - const normalizedAssetId = assetId.toLowerCase(); - const supporting = providers.filter((provider) => { - const map = provider?.supportedCryptoCurrencies; - if (!map) { - return false; - } - return Object.keys(map).some( - (key) => key.toLowerCase() === normalizedAssetId, - ); - }); + // Case-insensitive CAIP-19 matching is shared with headless-buy consumers + // via `getProvidersServingAsset`, so the controller and the UI region gate + // cannot disagree about which providers serve the asset. + const supporting = getProvidersServingAsset(providers, assetId); return { supporting, all: providers }; } diff --git a/packages/ramps-controller/src/errorNormalization.test.ts b/packages/ramps-controller/src/errorNormalization.test.ts new file mode 100644 index 0000000000..f07abd93e4 --- /dev/null +++ b/packages/ramps-controller/src/errorNormalization.test.ts @@ -0,0 +1,97 @@ +import { + extractExplicitTypedError, + getErrorMessage, + normalizeToTypedError, +} from './errorNormalization'; + +type Code = 'NO_QUOTES' | 'QUOTE_FAILED' | 'UNKNOWN'; + +const isValidCode = (value: unknown): value is Code => + value === 'NO_QUOTES' || value === 'QUOTE_FAILED' || value === 'UNKNOWN'; + +describe('getErrorMessage', () => { + it('reads Error.message', () => { + expect(getErrorMessage(new Error('boom'))).toBe('boom'); + }); + + it('reads a record message', () => { + expect(getErrorMessage({ message: 'nope' })).toBe('nope'); + }); + + it('returns a string value directly', () => { + expect(getErrorMessage('raw')).toBe('raw'); + }); + + it('returns undefined when no message can be derived', () => { + expect(getErrorMessage(42)).toBeUndefined(); + expect(getErrorMessage(null)).toBeUndefined(); + }); +}); + +describe('extractExplicitTypedError', () => { + it('reads a valid code from the default `code` property', () => { + expect( + extractExplicitTypedError( + { code: 'NO_QUOTES', message: 'none' }, + { isValidCode }, + ), + ).toStrictEqual({ code: 'NO_QUOTES', message: 'none', details: undefined }); + }); + + it('honours codeProperties precedence order', () => { + expect( + extractExplicitTypedError( + { headlessCode: 'QUOTE_FAILED', code: 'NO_QUOTES' }, + { isValidCode, codeProperties: ['headlessCode', 'code'] }, + ), + ).toStrictEqual({ + code: 'QUOTE_FAILED', + message: undefined, + details: undefined, + }); + }); + + it('passes through a record details object', () => { + expect( + extractExplicitTypedError( + { code: 'NO_QUOTES', details: { providerId: 'moonpay' } }, + { isValidCode }, + ), + ).toStrictEqual({ + code: 'NO_QUOTES', + message: undefined, + details: { providerId: 'moonpay' }, + }); + }); + + it('returns undefined when no valid code is present', () => { + expect( + extractExplicitTypedError({ code: 'NOT_A_CODE' }, { isValidCode }), + ).toBeUndefined(); + expect(extractExplicitTypedError('boom', { isValidCode })).toBeUndefined(); + }); +}); + +describe('normalizeToTypedError', () => { + it('returns the explicit typed error when present', () => { + expect( + normalizeToTypedError( + { code: 'QUOTE_FAILED', message: 'x' }, + { isValidCode, fallbackCode: 'UNKNOWN' }, + ), + ).toStrictEqual({ + code: 'QUOTE_FAILED', + message: 'x', + details: undefined, + }); + }); + + it('falls back with the derived message when no valid code is present', () => { + expect( + normalizeToTypedError(new Error('boom'), { + isValidCode, + fallbackCode: 'UNKNOWN', + }), + ).toStrictEqual({ code: 'UNKNOWN', message: 'boom' }); + }); +}); diff --git a/packages/ramps-controller/src/errorNormalization.ts b/packages/ramps-controller/src/errorNormalization.ts new file mode 100644 index 0000000000..a3d82f3991 --- /dev/null +++ b/packages/ramps-controller/src/errorNormalization.ts @@ -0,0 +1,114 @@ +/** + * A typed error surface: a stable code plus an optional human message and + * structured details. Consumers parameterise `Code` with their own error-code + * union (e.g. headless-buy codes) so the taxonomy stays with the consumer while + * the pure extraction below is shared. + */ +export type TypedError = { + code: Code; + message?: string; + details?: Record; +}; + +/** + * Type guard for a non-null object. + * + * @param value - The value to test. + * @returns Whether the value is a record. + */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Best-effort human-readable message from an arbitrary thrown/native value. + * + * @param error - The caught value. + * @returns The message, or `undefined` when none can be derived. + */ +export function getErrorMessage(error: unknown): string | undefined { + if (error instanceof Error) { + return error.message; + } + if (isRecord(error) && typeof error.message === 'string') { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return undefined; +} + +/** + * Extracts a caller-recognised typed error from an arbitrary thrown/native + * value, when the value carries an explicit, valid code on one of the given + * properties. Pure: performs no side effects and applies no fallback, so + * callers keep full control over precedence (e.g. domain-specific special + * cases) and the fallback code. + * + * @param error - The caught value. + * @param options - The options. + * @param options.isValidCode - Type guard identifying a recognised code. + * @param options.codeProperties - Property names to read a code from, in + * precedence order. Defaults to `['code']`. + * @returns The typed error when an explicit valid code is present, else + * `undefined`. + */ +export function extractExplicitTypedError( + error: unknown, + { + isValidCode, + codeProperties = ['code'], + }: { + isValidCode: (value: unknown) => value is Code; + codeProperties?: string[]; + }, +): TypedError | undefined { + if (!isRecord(error)) { + return undefined; + } + for (const property of codeProperties) { + const candidate = error[property]; + if (isValidCode(candidate)) { + return { + code: candidate, + message: getErrorMessage(error), + details: isRecord(error.details) ? error.details : undefined, + }; + } + } + return undefined; +} + +/** + * Normalises an arbitrary thrown/native value into a {@link TypedError}, using + * the caller's recognised codes and falling back to `fallbackCode` when no + * explicit valid code is present. Pure. + * + * @param error - The caught value. + * @param options - The options. + * @param options.isValidCode - Type guard identifying a recognised code. + * @param options.fallbackCode - Code used when no explicit valid code is found. + * @param options.codeProperties - Property names to read a code from, in + * precedence order. Defaults to `['code']`. + * @returns The typed error. + */ +export function normalizeToTypedError( + error: unknown, + { + isValidCode, + fallbackCode, + codeProperties, + }: { + isValidCode: (value: unknown) => value is Code; + fallbackCode: Code; + codeProperties?: string[]; + }, +): TypedError { + return ( + extractExplicitTypedError(error, { isValidCode, codeProperties }) ?? { + code: fallbackCode, + message: getErrorMessage(error), + } + ); +} diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 372bcdefa9..8c8818dc30 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -142,6 +142,23 @@ export { export { RAMPS_ERROR_CODES } from './rampsErrorCodes'; export type { RequestSelectorResult } from './selectors'; export { createRequestSelector } from './selectors'; +export { + providerServesAsset, + getProvidersServingAsset, + regionHasProviderForAsset, + isFiatDepositAvailable, +} from './providerAvailability'; +export { + isExternalBrowserQuote, + isCustomActionQuote, + isInAppOnlyQuote, +} from './quoteClassification'; +export type { TypedError } from './errorNormalization'; +export { + getErrorMessage, + extractExplicitTypedError, + normalizeToTypedError, +} from './errorNormalization'; export type { TransakServiceActions, TransakServiceEvents, diff --git a/packages/ramps-controller/src/providerAvailability.test.ts b/packages/ramps-controller/src/providerAvailability.test.ts new file mode 100644 index 0000000000..4112beb9cf --- /dev/null +++ b/packages/ramps-controller/src/providerAvailability.test.ts @@ -0,0 +1,175 @@ +import { + getProvidersServingAsset, + isFiatDepositAvailable, + providerServesAsset, + regionHasProviderForAsset, +} from './providerAvailability'; +import type { Provider } from './RampsService'; + +const ASSET_ID = 'eip155:1/erc20:0xtoken'; + +const buildProvider = ( + id: string, + type: 'native' | 'aggregator', + supportedCryptoCurrencies?: Provider['supportedCryptoCurrencies'], +): Provider => ({ + id, + name: id, + type, + environmentType: 'STAGING', + description: '', + hqAddress: '', + links: [], + logos: { light: '', dark: '', height: 24, width: 77 }, + ...(supportedCryptoCurrencies ? { supportedCryptoCurrencies } : {}), +}); + +describe('providerServesAsset', () => { + it('returns true when the provider lists the asset', () => { + const provider = buildProvider('moonpay', 'aggregator', { + [ASSET_ID]: true, + }); + expect(providerServesAsset(provider, ASSET_ID)).toBe(true); + }); + + it('matches case-insensitively on both sides', () => { + const provider = buildProvider('moonpay', 'aggregator', { + 'EIP155:1/ERC20:0xTOKEN': true, + }); + expect(providerServesAsset(provider, ASSET_ID)).toBe(true); + }); + + it('returns false when the provider has no supported map', () => { + const provider = buildProvider('moonpay', 'aggregator'); + expect(providerServesAsset(provider, ASSET_ID)).toBe(false); + }); + + it('returns false when the asset is not listed', () => { + const provider = buildProvider('moonpay', 'aggregator', { + 'eip155:1/slip44:60': true, + }); + expect(providerServesAsset(provider, ASSET_ID)).toBe(false); + }); +}); + +describe('getProvidersServingAsset', () => { + it('keeps only providers serving the asset', () => { + const serving = buildProvider('moonpay', 'aggregator', { + [ASSET_ID]: true, + }); + const other = buildProvider('banxa', 'aggregator', { + 'eip155:1/slip44:60': true, + }); + expect(getProvidersServingAsset([serving, other], ASSET_ID)).toStrictEqual([ + serving, + ]); + }); +}); + +describe('regionHasProviderForAsset', () => { + it('returns false for an empty assetId regardless of scope', () => { + const provider = buildProvider('native', 'native', { '': true }); + expect( + regionHasProviderForAsset({ + providers: [provider], + assetId: '', + scope: 'all', + }), + ).toBe(false); + }); + + it('returns true when a native provider serves the asset, even under scope off', () => { + const provider = buildProvider('native', 'native', { [ASSET_ID]: true }); + expect( + regionHasProviderForAsset({ + providers: [provider], + assetId: ASSET_ID, + scope: 'off', + }), + ).toBe(true); + }); + + it('returns false under scope off when only an aggregator serves the asset', () => { + const provider = buildProvider('moonpay', 'aggregator', { + [ASSET_ID]: true, + }); + expect( + regionHasProviderForAsset({ + providers: [provider], + assetId: ASSET_ID, + scope: 'off', + }), + ).toBe(false); + }); + + it('returns true under scope in-app when an aggregator serves the asset', () => { + const provider = buildProvider('moonpay', 'aggregator', { + [ASSET_ID]: true, + }); + expect( + regionHasProviderForAsset({ + providers: [provider], + assetId: ASSET_ID, + scope: 'in-app', + }), + ).toBe(true); + }); + + it('returns false when no provider serves the asset, even when widened', () => { + const provider = buildProvider('moonpay', 'aggregator', { + 'eip155:1/slip44:60': true, + }); + expect( + regionHasProviderForAsset({ + providers: [provider], + assetId: ASSET_ID, + scope: 'all', + }), + ).toBe(false); + }); +}); + +describe('isFiatDepositAvailable', () => { + it('returns true when a native provider is selected, even under scope off', () => { + const selectedProvider = buildProvider('native', 'native'); + expect( + isFiatDepositAvailable({ + providers: [], + selectedProvider, + scope: 'off', + }), + ).toBe(true); + }); + + it('returns false under scope off when the selected provider is not native', () => { + const selectedProvider = buildProvider('moonpay', 'aggregator'); + expect( + isFiatDepositAvailable({ + providers: [selectedProvider], + selectedProvider, + scope: 'off', + }), + ).toBe(false); + }); + + it('returns true under scope in-app when the region has any provider', () => { + const provider = buildProvider('moonpay', 'aggregator'); + expect( + isFiatDepositAvailable({ + providers: [provider], + selectedProvider: null, + scope: 'in-app', + }), + ).toBe(true); + }); + + it('returns false when widened but the region has no providers', () => { + expect( + isFiatDepositAvailable({ + providers: [], + selectedProvider: null, + scope: 'all', + }), + ).toBe(false); + }); +}); diff --git a/packages/ramps-controller/src/providerAvailability.ts b/packages/ramps-controller/src/providerAvailability.ts new file mode 100644 index 0000000000..64caa4cf36 --- /dev/null +++ b/packages/ramps-controller/src/providerAvailability.ts @@ -0,0 +1,120 @@ +import type { ProviderScope } from './RampsController'; +import type { Provider } from './RampsService'; + +/** + * Whether a provider serves the given deposit asset. + * + * Mirrors the region-provider matching the controller performs internally: a + * provider serves the asset when its `supportedCryptoCurrencies` map (keyed by + * CAIP-19 asset id) contains the asset id, compared case-insensitively on both + * sides. EVM CAIP-19 asset ids may arrive checksummed or lowercased and the + * providers API returns both forms, so only the lowercased forms are compared. + * A provider without a `supportedCryptoCurrencies` map is treated as not + * serving the asset. + * + * @param provider - The provider to test. + * @param assetId - CAIP-19 asset id of the deposit asset. + * @returns Whether the provider serves the asset. + */ +export function providerServesAsset( + provider: Provider, + assetId: string, +): boolean { + const map = provider?.supportedCryptoCurrencies; + if (!map) { + return false; + } + const target = assetId.toLowerCase(); + return Object.keys(map).some((key) => key.toLowerCase() === target); +} + +/** + * Filters a provider list down to those serving the given deposit asset. + * + * @param providers - The providers to filter. + * @param assetId - CAIP-19 asset id of the deposit asset. + * @returns The subset of providers serving the asset. + */ +export function getProvidersServingAsset( + providers: Provider[], + assetId: string, +): Provider[] { + return providers.filter((provider) => providerServesAsset(provider, assetId)); +} + +/** + * Whether a region offers a usable on-ramp provider that serves the given + * deposit asset, under the active provider-class scope. This is the pure, + * asset-aware region gate shared between the controller and headless-buy + * consumers so the two never disagree about eligibility. + * + * Scope `off` is native-only: the region must offer a native provider (e.g. + * Transak Native) that serves the asset. Scope `in-app` / `all` treats the + * region as supported when any provider (native or in-app aggregator) serves + * the asset; the controller's scope-aware `getQuotes` performs the precise + * in-app provider selection at quote time. Fails closed: an empty or missing + * `assetId` returns `false`. + * + * @param options - The options. + * @param options.providers - The region's providers (native and aggregator). + * @param options.assetId - CAIP-19 asset id of the deposit asset. + * @param options.scope - The effective provider-class scope. + * @returns Whether the region has a provider serving the asset under scope. + */ +export function regionHasProviderForAsset({ + providers, + assetId, + scope, +}: { + providers: Provider[]; + assetId: string; + scope: ProviderScope; +}): boolean { + if (!assetId) { + return false; + } + const serving = getProvidersServingAsset(providers, assetId); + if (serving.some((provider) => provider.type === 'native')) { + return true; + } + if (scope === 'off') { + return false; + } + return serving.length > 0; +} + +/** + * Whether headless fiat deposit is available for the current region and + * provider-class scope. Scope-aware and independent of which single provider is + * currently selected once widened, so the availability gate cannot disagree + * with the controller's own scope-aware provider pick. + * + * Scope `off` keeps the native-only behaviour: a native provider must be the + * currently selected (preferred) one, since the controller resolves the + * selected provider first and an aggregator preferred provider would otherwise + * run a non-native deposit. Scope `in-app` / `all` make the flow available + * whenever the region has any provider. + * + * @param options - The options. + * @param options.providers - The region's providers. + * @param options.selectedProvider - The currently selected provider, if any. + * @param options.scope - The effective provider-class scope. + * @returns Whether headless fiat deposit is available. + */ +export function isFiatDepositAvailable({ + providers, + selectedProvider, + scope, +}: { + providers: Provider[]; + selectedProvider?: Provider | null; + scope: ProviderScope; +}): boolean { + if (selectedProvider?.type === 'native') { + return true; + } + if (scope === 'off') { + return false; + } + return providers.length > 0; +} diff --git a/packages/ramps-controller/src/quoteClassification.test.ts b/packages/ramps-controller/src/quoteClassification.test.ts new file mode 100644 index 0000000000..55735ae62e --- /dev/null +++ b/packages/ramps-controller/src/quoteClassification.test.ts @@ -0,0 +1,75 @@ +import { + isCustomActionQuote, + isExternalBrowserQuote, + isInAppOnlyQuote, +} from './quoteClassification'; +import type { Quote } from './RampsService'; + +const buildQuote = ( + overrides: { + browser?: string; + isCustomAction?: boolean; + } = {}, +): Quote => + ({ + provider: 'moonpay', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: 'credit_debit_card', + ...(overrides.browser + ? { buyWidget: { url: 'https://widget.example', browser: overrides.browser } } + : {}), + ...(overrides.isCustomAction === undefined + ? {} + : { isCustomAction: overrides.isCustomAction }), + }, + metadata: { reliability: 1 }, + }) as unknown as Quote; + +describe('isExternalBrowserQuote', () => { + it('returns true when the buy widget targets the OS browser', () => { + expect(isExternalBrowserQuote(buildQuote({ browser: 'IN_APP_OS_BROWSER' }))).toBe( + true, + ); + }); + + it('returns false for an in-app widget browser', () => { + expect(isExternalBrowserQuote(buildQuote({ browser: 'APP_BROWSER' }))).toBe( + false, + ); + }); + + it('returns false when no browser hint is present', () => { + expect(isExternalBrowserQuote(buildQuote())).toBe(false); + }); +}); + +describe('isCustomActionQuote', () => { + it('returns true when the inline isCustomAction flag is set', () => { + expect(isCustomActionQuote(buildQuote({ isCustomAction: true }))).toBe(true); + }); + + it('returns false when the flag is false or absent', () => { + expect(isCustomActionQuote(buildQuote({ isCustomAction: false }))).toBe( + false, + ); + expect(isCustomActionQuote(buildQuote())).toBe(false); + }); +}); + +describe('isInAppOnlyQuote', () => { + it('returns true for a plain in-app aggregator quote', () => { + expect(isInAppOnlyQuote(buildQuote({ browser: 'APP_BROWSER' }))).toBe(true); + }); + + it('returns false for an external-browser quote', () => { + expect(isInAppOnlyQuote(buildQuote({ browser: 'IN_APP_OS_BROWSER' }))).toBe( + false, + ); + }); + + it('returns false for a custom-action quote', () => { + expect(isInAppOnlyQuote(buildQuote({ isCustomAction: true }))).toBe(false); + }); +}); diff --git a/packages/ramps-controller/src/quoteClassification.ts b/packages/ramps-controller/src/quoteClassification.ts new file mode 100644 index 0000000000..dd0fbcf004 --- /dev/null +++ b/packages/ramps-controller/src/quoteClassification.ts @@ -0,0 +1,41 @@ +import type { Quote } from './RampsService'; + +/** + * Whether a quote's checkout runs in an external / system browser rather than + * an in-app WebView. Decided by the per-quote `buyWidget.browser` hint from the + * quotes API. When the hint is absent the quote is treated as in-app (the + * caller is responsible for the in-app WebView fail-safe). + * + * This is the pure classification only: it deliberately knows nothing about + * host redirect URLs or deeplink schemes, which stay in the consuming client. + * + * @param quote - The quote to classify. + * @returns Whether the quote uses an external browser. + */ +export function isExternalBrowserQuote(quote: Quote): boolean { + return quote.quote?.buyWidget?.browser === 'IN_APP_OS_BROWSER'; +} + +/** + * Whether a quote is a custom-action ("checkout outside of MetaMask") quote, + * e.g. PayPal or Robinhood. Reads the inline `isCustomAction` flag, which the + * wire may carry even though it is absent from the published `Quote` type. + * + * @param quote - The quote to classify. + * @returns Whether the quote is a custom-action quote. + */ +export function isCustomActionQuote(quote: Quote): boolean { + return (quote.quote as { isCustomAction?: boolean })?.isCustomAction === true; +} + +/** + * Whether a quote continues inside an in-app WebView, i.e. it is neither a + * custom-action quote nor an external-browser quote. This is the Phase 1 + * in-app-only inclusion test. + * + * @param quote - The quote to classify. + * @returns Whether the quote is an in-app WebView quote. + */ +export function isInAppOnlyQuote(quote: Quote): boolean { + return !isCustomActionQuote(quote) && !isExternalBrowserQuote(quote); +} From 68fc14e1ce01a86fa7408429ef350bca215f507a Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Sat, 11 Jul 2026 15:31:36 -0700 Subject: [PATCH 2/2] feat(ramps-controller): replace ProviderScope with moneyHeadlessAllProviders boolean flag read controller-side --- README.md | 1 + packages/ramps-controller/CHANGELOG.md | 12 +- packages/ramps-controller/package.json | 3 +- .../src/RampsController.test.ts | 486 +++++++++++++----- .../ramps-controller/src/RampsController.ts | 155 +++--- .../ramps-controller/src/featureFlags.test.ts | 92 ++++ packages/ramps-controller/src/featureFlags.ts | 51 ++ packages/ramps-controller/src/index.ts | 6 +- .../src/providerAvailability.test.ts | 32 +- .../src/providerAvailability.ts | 54 +- .../src/quoteClassification.test.ts | 17 +- packages/ramps-controller/tsconfig.build.json | 3 + packages/ramps-controller/tsconfig.json | 3 +- yarn.lock | 1 + 14 files changed, 638 insertions(+), 278 deletions(-) create mode 100644 packages/ramps-controller/src/featureFlags.test.ts create mode 100644 packages/ramps-controller/src/featureFlags.ts diff --git a/README.md b/README.md index 84701fb5e1..dcd7bf6218 100644 --- a/README.md +++ b/README.md @@ -530,6 +530,7 @@ linkStyle default opacity:0.5 ramps_controller --> controller_utils; ramps_controller --> messenger; ramps_controller --> profile_sync_controller; + ramps_controller --> remote_feature_flag_controller; rate_limit_controller --> base_controller; rate_limit_controller --> messenger; react_data_query --> base_data_service; diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index e603191caf..d4aba8b00b 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,13 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add pure provider-availability helpers `providerServesAsset`, `getProvidersServingAsset`, `regionHasProviderForAsset`, and `isFiatDepositAvailable` so headless-buy consumers can share the controller's case-insensitive CAIP-19 asset matching and scope-aware region/availability gating instead of re-deriving it, keeping the two from disagreeing ([#9409](https://github.com/MetaMask/core/pull/9409)) -- Add pure quote-classification helpers `isExternalBrowserQuote`, `isCustomActionQuote`, and `isInAppOnlyQuote` so consumers can share the controller's in-app-vs-external browser-mode classification without owning host redirect/deeplink concerns ([#9409](https://github.com/MetaMask/core/pull/9409)) +- Add the `MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY` constant (`moneyHeadlessAllProviders`) and the pure `isHeadlessAllProvidersEnabled(remoteFeatureFlagState)` helper (with a `HeadlessFeatureFlagsLookup` type) that own the flag key lookup, `localOverrides`-over-`remoteFeatureFlags` merging, and boolean coercion (only the literal `true` enables), so UI consumers resolve the flag exactly like the controller does ([#9409](https://github.com/MetaMask/core/pull/9409)) +- Add pure provider-availability helpers `providerServesAsset`, `getProvidersServingAsset`, `regionHasProviderForAsset`, and `isFiatDepositAvailable` so headless-buy consumers can share the controller's case-insensitive CAIP-19 asset matching and flag-aware region/availability gating instead of re-deriving it, keeping the two from disagreeing; `regionHasProviderForAsset` and `isFiatDepositAvailable` take an `allProvidersEnabled` boolean ([#9409](https://github.com/MetaMask/core/pull/9409)) +- Add pure quote-classification helpers `isExternalBrowserQuote`, `isCustomActionQuote`, and `isInAppOnlyQuote` so consumers can share the in-app-vs-external browser-mode classification without owning host redirect/deeplink concerns ([#9409](https://github.com/MetaMask/core/pull/9409)) - Add pure error-normalization helpers `getErrorMessage`, `extractExplicitTypedError`, and `normalizeToTypedError` (with a `TypedError` type) so consumers can share error-shape extraction while keeping their own error-code taxonomy ([#9409](https://github.com/MetaMask/core/pull/9409)) +- Add `@metamask/remote-feature-flag-controller` `^4.2.2` as a runtime dependency ([#9409](https://github.com/MetaMask/core/pull/9409)) ### Changed -- `RampsController` now derives its internal region provider-asset matching and quote in-app/custom-action/external filtering from the shared `providerAvailability` and `quoteClassification` helpers, so the exposed helpers stay behaviourally identical to the controller's own selection ([#9409](https://github.com/MetaMask/core/pull/9409)) +- **BREAKING:** Replace the `getProviderScope` constructor option and the exported `ProviderScope` type (`'off' | 'in-app' | 'all'`) with a controller-side read of the `moneyHeadlessAllProviders` boolean remote feature flag ([#9409](https://github.com/MetaMask/core/pull/9409)) + - `RampsController.getQuotes` resolves the flag through the `RemoteFeatureFlagController:getState` messenger action on each auto-selection call, so a remote fetch or a local dev override takes effect at runtime; consumers should delegate that action to the controller's messenger (when it is missing, the flag read fails closed and quoting stays native-only) + - When the flag is `true`, the auto-selection path (`autoSelectProvider` / `restrictToKnownOrNativeProviders`) widens to every supporting provider class (native, in-app WebView aggregator, and external-browser / custom-action) and returns the best quote at `success[0]`, enforcing per-provider fiat limits; when the flag is `false`, missing, or any non-boolean value, the path stays native-only + - The intermediate `in-app` scope (which excluded external-browser and custom-action quotes from selection) is removed +- `RampsController` now derives its internal region provider-asset matching from the shared `providerAvailability` helpers, so the exposed helpers stay behaviourally identical to the controller's own selection ([#9409](https://github.com/MetaMask/core/pull/9409)) ## [15.1.0] diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 6bd64f1ee3..529deb3c28 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -57,7 +57,8 @@ "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", "@metamask/messenger": "^2.0.0", - "@metamask/profile-sync-controller": "^28.2.0" + "@metamask/profile-sync-controller": "^28.2.0", + "@metamask/remote-feature-flag-controller": "^4.2.2" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index f4543d9f64..623e3bfd69 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -6,9 +6,11 @@ import type { MessengerActions, MessengerEvents, } from '@metamask/messenger'; +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; import * as fs from 'fs'; import * as path from 'path'; +import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags'; import type { RampsControllerMessenger, RampsControllerState, @@ -405,7 +407,7 @@ describe('RampsController', () => { }); }); - describe('getQuotes provider-scope widening (in-app)', () => { + describe('getQuotes all-providers widening', () => { const SCOPE_ASSET_ID = 'eip155:1/slip44:60'; const SCOPE_PAYMENT_METHOD = '/payments/debit-credit-card'; const SCOPE_WALLET = '0x1234567890abcdef1234567890abcdef12345678'; @@ -447,7 +449,7 @@ describe('RampsController', () => { }, }); - const inAppScopeQuote = (provider: string, reliability: number): Quote => ({ + const appBrowserQuote = (provider: string, reliability: number): Quote => ({ provider, quote: { amountIn: 100, @@ -461,7 +463,7 @@ describe('RampsController', () => { metadata: { reliability }, }); - const externalScopeQuote = ( + const externalBrowserQuote = ( provider: string, reliability: number, ): Quote => ({ @@ -485,6 +487,29 @@ describe('RampsController', () => { providers: createResourceState(providers, null), }); + /** + * Registers a `RemoteFeatureFlagController:getState` handler on the root + * messenger. Defaults to serving the all-providers flag as `true`. + * + * @param rootMessenger - The root messenger to register the handler on. + * @param flagState - `RemoteFeatureFlagController` state overrides. + */ + const registerFeatureFlagState = ( + rootMessenger: RootMessenger, + flagState: Partial = { + remoteFeatureFlags: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: true }, + }, + ): void => { + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags: {}, + cacheTimestamp: 0, + ...flagState, + }), + ); + }; + const callScopedGetQuotes = async ( messenger: RampsControllerMessenger, overrides: Record = {}, @@ -502,16 +527,16 @@ describe('RampsController', () => { ...overrides, }); - it('widens to all supporting providers and returns the reliability winner at success[0], excluding external quotes', async () => { + it('widens to all supporting providers and returns the reliability winner at success[0]', async () => { const response: QuotesResponse = { success: [ - inAppScopeQuote(MOONPAY, 90), - inAppScopeQuote(REVOLUT, 80), - externalScopeQuote(COINBASE, 99), - inAppScopeQuote(NATIVE, 70), + appBrowserQuote(MOONPAY, 90), + appBrowserQuote(REVOLUT, 80), + externalBrowserQuote(COINBASE, 99), + appBrowserQuote(NATIVE, 70), ], - // Coinbase is the most reliable but external, so it is skipped and the - // next in-app provider (MoonPay) wins. + // Coinbase is the most reliable; external-browser quotes are eligible + // under the all-providers flag, so it wins. sorted: [ { sortBy: 'reliability', ids: [COINBASE, MOONPAY, REVOLUT, NATIVE] }, ], @@ -522,7 +547,6 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', state: scopeState([ buildScopeProvider(NATIVE, 'native'), buildScopeProvider(MOONPAY, 'aggregator'), @@ -532,6 +556,7 @@ describe('RampsController', () => { }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); let quotedProviders: string[] | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', @@ -550,16 +575,57 @@ describe('RampsController', () => { REVOLUT, COINBASE, ]); - expect(quotes.success[0]?.provider).toBe(MOONPAY); + expect(quotes.success[0]?.provider).toBe(COINBASE); + }, + ); + }); + + it('keeps external-browser and custom-action quotes eligible', async () => { + const response: QuotesResponse = { + success: [ + externalBrowserQuote(COINBASE, 99), + appBrowserQuote(MOONPAY, 80), + ], + sorted: [{ sortBy: 'reliability', ids: [COINBASE, MOONPAY] }], + error: [], + customActions: [ + { + buy: { providerId: MOONPAY }, + paymentMethodId: SCOPE_PAYMENT_METHOD, + supportedPaymentMethodIds: [SCOPE_PAYMENT_METHOD], + }, + ], + }; + + await withController( + { + options: { + state: scopeState([ + buildScopeProvider(COINBASE, 'aggregator'), + buildScopeProvider(MOONPAY, 'aggregator'), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => response, + ); + + const quotes = await callScopedGetQuotes(messenger); + + // The external Coinbase quote (top reliability) stays eligible. + expect(quotes.success[0]?.provider).toBe(COINBASE); }, ); }); - it('keeps the native provider as a valid in-app candidate and selects it when it ranks first', async () => { + it('keeps the native provider as a valid 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. + success: [appBrowserQuote(NATIVE, 90), appBrowserQuote(MOONPAY, 80)], + // Transak Native is the most reliable quote, so it wins: widening + // must not exclude native providers. sorted: [{ sortBy: 'reliability', ids: [NATIVE, MOONPAY] }], error: [], customActions: [], @@ -568,7 +634,6 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', state: scopeState([ buildScopeProvider(NATIVE, 'native'), buildScopeProvider(MOONPAY, 'aggregator'), @@ -576,6 +641,7 @@ describe('RampsController', () => { }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); rootMessenger.registerActionHandler( 'RampsService:getQuotes', async () => response, @@ -590,7 +656,7 @@ describe('RampsController', () => { it('falls back to the price order when there is no reliability order', async () => { const response: QuotesResponse = { - success: [inAppScopeQuote(MOONPAY, 90), inAppScopeQuote(REVOLUT, 80)], + success: [appBrowserQuote(MOONPAY, 90), appBrowserQuote(REVOLUT, 80)], sorted: [{ sortBy: 'price', ids: [REVOLUT, MOONPAY] }], error: [], customActions: [], @@ -599,7 +665,6 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', state: scopeState([ buildScopeProvider(MOONPAY, 'aggregator'), buildScopeProvider(REVOLUT, 'aggregator'), @@ -607,6 +672,7 @@ describe('RampsController', () => { }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); rootMessenger.registerActionHandler( 'RampsService:getQuotes', async () => response, @@ -621,7 +687,7 @@ describe('RampsController', () => { 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)], + success: [appBrowserQuote(MOONPAY, 90), appBrowserQuote(REVOLUT, 80)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], error: [], customActions: [], @@ -630,7 +696,6 @@ describe('RampsController', () => { 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)), @@ -640,6 +705,7 @@ describe('RampsController', () => { }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); rootMessenger.registerActionHandler( 'RampsService:getQuotes', async () => response, @@ -652,22 +718,26 @@ describe('RampsController', () => { ); }); - it('returns an empty success list when no in-app quote is usable', async () => { + it('returns an empty success list when no quote fits the published provider limits', async () => { const response: QuotesResponse = { - success: [externalScopeQuote(COINBASE, 99)], - sorted: [{ sortBy: 'reliability', ids: [COINBASE] }], - error: [{ provider: MOONPAY, error: 'unavailable' }], + success: [appBrowserQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], + error: [{ provider: REVOLUT, error: 'unavailable' }], customActions: [], }; await withController( { options: { - getProviderScope: () => 'in-app', - state: scopeState([buildScopeProvider(COINBASE, 'aggregator')]), + state: scopeState([ + // MoonPay's minimum is above the $100 amount and no other quote + // exists, so nothing is usable. + buildScopeProvider(MOONPAY, 'aggregator', fiatLimit(200, 1000)), + ]), }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); rootMessenger.registerActionHandler( 'RampsService:getQuotes', async () => response, @@ -693,11 +763,11 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', state: scopeState([nonSupportingProvider]), }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); const getQuotesMock = jest.fn(); rootMessenger.registerActionHandler( 'RampsService:getQuotes', @@ -706,7 +776,7 @@ describe('RampsController', () => { // `autoSelectProvider` alone triggers widening; without // `restrictToKnownOrNativeProviders` the empty guard is reached via the - // `|| widenToInAppProviders` branch. + // `|| widenToAllProviders` branch. const quotes = await callScopedGetQuotes(messenger, { autoSelectProvider: true, restrictToKnownOrNativeProviders: false, @@ -723,47 +793,134 @@ describe('RampsController', () => { ); }); - it('excludes custom-action providers from selection', async () => { + it('does not widen and does not mutate providers.selected when the flag is false', async () => { const response: QuotesResponse = { - success: [inAppScopeQuote(MOONPAY, 90), inAppScopeQuote(REVOLUT, 80)], - sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], + success: [appBrowserQuote(NATIVE, 70)], + sorted: [{ sortBy: 'reliability', ids: [NATIVE] }], error: [], - customActions: [ - { - buy: { providerId: MOONPAY }, - paymentMethodId: SCOPE_PAYMENT_METHOD, - supportedPaymentMethodIds: [SCOPE_PAYMENT_METHOD], + customActions: [], + }; + + await withController( + { + options: { + state: scopeState([ + buildScopeProvider(NATIVE, 'native'), + buildScopeProvider(MOONPAY, 'aggregator'), + ]), }, - ], + }, + async ({ controller, messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false, + }, + }); + 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 flag is missing from remote feature flags', async () => { + const response: QuotesResponse = { + success: [appBrowserQuote(NATIVE, 70)], + sorted: [{ sortBy: 'reliability', ids: [NATIVE] }], + error: [], + customActions: [], }; await withController( { options: { - getProviderScope: () => 'in-app', state: scopeState([ + buildScopeProvider(NATIVE, 'native'), buildScopeProvider(MOONPAY, 'aggregator'), - buildScopeProvider(REVOLUT, 'aggregator'), ]), }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { remoteFeatureFlags: {} }); + let quotedProviders: string[] | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', - async () => response, + async (params: { providers?: string[] }) => { + quotedProviders = params.providers; + return response; + }, ); - const quotes = await callScopedGetQuotes(messenger); + await callScopedGetQuotes(messenger); - // MoonPay is a custom action, so Revolut wins despite ranking higher. - expect(quotes.success[0]?.provider).toBe(REVOLUT); + expect(quotedProviders).toStrictEqual([NATIVE]); }, ); }); - it('does not widen and does not mutate providers.selected when the scope is off', async () => { + it.each([ + ['the string "true"', 'true'], + ['a number', 1], + ['an object', { enabled: true }], + ['a scope string', 'all'], + ])( + 'does not widen when the flag value is %s', + async (_description, flagValue) => { + const response: QuotesResponse = { + success: [appBrowserQuote(NATIVE, 70)], + sorted: [{ sortBy: 'reliability', ids: [NATIVE] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + state: scopeState([ + buildScopeProvider(NATIVE, 'native'), + buildScopeProvider(MOONPAY, 'aggregator'), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: flagValue, + }, + }); + let quotedProviders: string[] | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params: { providers?: string[] }) => { + quotedProviders = params.providers; + return response; + }, + ); + + await callScopedGetQuotes(messenger); + + expect(quotedProviders).toStrictEqual([NATIVE]); + }, + ); + }, + ); + + it('does not widen when RemoteFeatureFlagController:getState is not wired up', async () => { const response: QuotesResponse = { - success: [inAppScopeQuote(NATIVE, 70)], + success: [appBrowserQuote(NATIVE, 70)], sorted: [{ sortBy: 'reliability', ids: [NATIVE] }], error: [], customActions: [], @@ -772,14 +929,15 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'off', state: scopeState([ buildScopeProvider(NATIVE, 'native'), buildScopeProvider(MOONPAY, 'aggregator'), ]), }, }, - async ({ controller, messenger, rootMessenger }) => { + async ({ messenger, rootMessenger }) => { + // No RemoteFeatureFlagController:getState handler is registered, so + // the flag read throws internally and the controller fails closed. let quotedProviders: string[] | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', @@ -791,18 +949,15 @@ describe('RampsController', () => { 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 () => { + it('honors a localOverrides true value when the remote flag is off', async () => { const response: QuotesResponse = { - success: [inAppScopeQuote(MOONPAY, 90)], + success: [appBrowserQuote(MOONPAY, 90)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], @@ -811,14 +966,19 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', state: scopeState([ + buildScopeProvider(NATIVE, 'native'), buildScopeProvider(MOONPAY, 'aggregator'), - buildScopeProvider(REVOLUT, 'aggregator'), ]), }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false, + }, + localOverrides: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: true }, + }); let quotedProviders: string[] | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', @@ -828,20 +988,59 @@ describe('RampsController', () => { }, ); - const quotes = await callScopedGetQuotes(messenger, { - providers: [MOONPAY], + await callScopedGetQuotes(messenger); + + // The dev override widens the path even though the remote flag is + // off. + expect(quotedProviders).toStrictEqual([NATIVE, MOONPAY]); + }, + ); + }); + + it('honors a localOverrides false value over a remote true value', async () => { + const response: QuotesResponse = { + success: [appBrowserQuote(NATIVE, 70)], + sorted: [{ sortBy: 'reliability', ids: [NATIVE] }], + error: [], + customActions: [], + }; + + await withController( + { + options: { + state: scopeState([ + buildScopeProvider(NATIVE, 'native'), + buildScopeProvider(MOONPAY, 'aggregator'), + ]), + }, + }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: true, + }, + localOverrides: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false }, }); + let quotedProviders: string[] | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params: { providers?: string[] }) => { + quotedProviders = params.providers; + return response; + }, + ); - expect(quotedProviders).toStrictEqual([MOONPAY]); - expect(quotes).toStrictEqual(response); + await callScopedGetQuotes(messenger); + + expect(quotedProviders).toStrictEqual([NATIVE]); }, ); }); - it('hydrates the provider catalog via getProviders when state is empty', async () => { + it('reads the flag on every getQuotes call so a runtime change takes effect', async () => { const response: QuotesResponse = { - success: [inAppScopeQuote(MOONPAY, 90)], - sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], + success: [appBrowserQuote(MOONPAY, 90), appBrowserQuote(NATIVE, 70)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY, NATIVE] }], error: [], customActions: [], }; @@ -849,41 +1048,48 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', - state: { userRegion: createMockUserRegion('us-ca') }, + state: scopeState([ + buildScopeProvider(NATIVE, 'native'), + buildScopeProvider(MOONPAY, 'aggregator'), + ]), }, }, async ({ messenger, rootMessenger }) => { + let flagValue = false; rootMessenger.registerActionHandler( - 'RampsService:getProviders', - async () => ({ - providers: [buildScopeProvider(MOONPAY, 'aggregator')], + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: flagValue, + }, + cacheTimestamp: 0, }), ); - let quotedProviders: string[] | undefined; + const quotedProviderLists: (string[] | undefined)[] = []; rootMessenger.registerActionHandler( 'RampsService:getQuotes', async (params: { providers?: string[] }) => { - quotedProviders = params.providers; + quotedProviderLists.push(params.providers); return response; }, ); - const quotes = await callScopedGetQuotes(messenger); + await callScopedGetQuotes(messenger); + flagValue = true; + await callScopedGetQuotes(messenger, { forceRefresh: true }); - expect(quotedProviders).toStrictEqual([MOONPAY]); - expect(quotes.success[0]?.provider).toBe(MOONPAY); + expect(quotedProviderLists).toStrictEqual([ + [NATIVE], + [NATIVE, MOONPAY], + ]); }, ); }); - it('excludes a quote carrying an inline isCustomAction flag', async () => { - const inlineCustom = inAppScopeQuote(MOONPAY, 90); - (inlineCustom.quote as { isCustomAction?: boolean }).isCustomAction = - true; + it('does not widen when the caller passes an explicit providers list', async () => { const response: QuotesResponse = { - success: [inlineCustom, inAppScopeQuote(REVOLUT, 80)], - sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], + success: [appBrowserQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], }; @@ -891,7 +1097,6 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', state: scopeState([ buildScopeProvider(MOONPAY, 'aggregator'), buildScopeProvider(REVOLUT, 'aggregator'), @@ -899,28 +1104,30 @@ describe('RampsController', () => { }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); + let quotedProviders: string[] | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', - async () => response, + async (params: { providers?: string[] }) => { + quotedProviders = params.providers; + return response; + }, ); - const quotes = await callScopedGetQuotes(messenger); + const quotes = await callScopedGetQuotes(messenger, { + providers: [MOONPAY], + }); - // The inline-flagged MoonPay quote is dropped, so Revolut wins. - expect(quotes.success[0]?.provider).toBe(REVOLUT); + expect(quotedProviders).toStrictEqual([MOONPAY]); + expect(quotes).toStrictEqual(response); }, ); }); - it('falls through to the first candidate when the sort orders reference no surviving provider', async () => { + it('hydrates the provider catalog via getProviders when state is empty', 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] }, - ], + success: [appBrowserQuote(MOONPAY, 90)], + sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], }; @@ -928,51 +1135,55 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', - state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), + state: { userRegion: createMockUserRegion('us-ca') }, }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ + providers: [buildScopeProvider(MOONPAY, 'aggregator')], + }), + ); + let quotedProviders: string[] | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', - async () => response, + 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); }, ); }); - it('with scope all, does not exclude external or custom-action quotes', async () => { + it('falls through to the first candidate when the sort orders reference no surviving provider', async () => { const response: QuotesResponse = { - success: [ - externalScopeQuote(COINBASE, 99), - inAppScopeQuote(MOONPAY, 80), + success: [appBrowserQuote(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] }, ], - sorted: [{ sortBy: 'reliability', ids: [COINBASE, MOONPAY] }], error: [], - customActions: [ - { - buy: { providerId: MOONPAY }, - paymentMethodId: SCOPE_PAYMENT_METHOD, - supportedPaymentMethodIds: [SCOPE_PAYMENT_METHOD], - }, - ], + customActions: [], }; await withController( { options: { - getProviderScope: () => 'all', - state: scopeState([ - buildScopeProvider(COINBASE, 'aggregator'), - buildScopeProvider(MOONPAY, 'aggregator'), - ]), + state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); rootMessenger.registerActionHandler( 'RampsService:getQuotes', async () => response, @@ -980,15 +1191,14 @@ describe('RampsController', () => { const quotes = await callScopedGetQuotes(messenger); - // `all` keeps the external Coinbase quote (top reliability) eligible. - expect(quotes.success[0]?.provider).toBe(COINBASE); + expect(quotes.success[0]?.provider).toBe(MOONPAY); }, ); }); it('widens on restrictToKnownOrNativeProviders alone and selects a provider whose limits fit', async () => { const response: QuotesResponse = { - success: [inAppScopeQuote(MOONPAY, 90)], + success: [appBrowserQuote(MOONPAY, 90)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], @@ -997,13 +1207,13 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', state: scopeState([ buildScopeProvider(MOONPAY, 'aggregator', fiatLimit(10, 1000)), ]), }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); rootMessenger.registerActionHandler( 'RampsService:getQuotes', async () => response, @@ -1022,7 +1232,7 @@ describe('RampsController', () => { it('skips a provider whose maximum limit is below the amount', async () => { const response: QuotesResponse = { - success: [inAppScopeQuote(MOONPAY, 90), inAppScopeQuote(REVOLUT, 80)], + success: [appBrowserQuote(MOONPAY, 90), appBrowserQuote(REVOLUT, 80)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY, REVOLUT] }], error: [], customActions: [], @@ -1031,7 +1241,6 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', state: scopeState([ buildScopeProvider(MOONPAY, 'aggregator', fiatLimit(10, 50)), buildScopeProvider(REVOLUT, 'aggregator'), @@ -1039,6 +1248,7 @@ describe('RampsController', () => { }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); rootMessenger.registerActionHandler( 'RampsService:getQuotes', async () => response, @@ -1052,11 +1262,11 @@ describe('RampsController', () => { ); }); - it('does not widen for a non-off scope when neither autoSelect nor restrict is set', async () => { + it('does not widen when the flag is enabled but neither autoSelect nor restrict is set', async () => { const response: QuotesResponse = { success: [ - inAppScopeQuote(MOONPAY, 90), - externalScopeQuote(COINBASE, 99), + appBrowserQuote(MOONPAY, 90), + externalBrowserQuote(COINBASE, 99), ], sorted: [{ sortBy: 'reliability', ids: [COINBASE, MOONPAY] }], error: [], @@ -1066,7 +1276,6 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', state: scopeState([ buildScopeProvider(MOONPAY, 'aggregator'), buildScopeProvider(COINBASE, 'aggregator'), @@ -1074,13 +1283,15 @@ describe('RampsController', () => { }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(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`. + // runs and the response is returned unfiltered even when the feature + // flag is enabled. const quotes = await callScopedGetQuotes(messenger, { autoSelectProvider: undefined, restrictToKnownOrNativeProviders: undefined, @@ -1093,7 +1304,7 @@ 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)], + success: [appBrowserQuote(MOONPAY, 90)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], @@ -1103,12 +1314,12 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', getDefaultRedirectUrl: () => DEFAULT_REDIRECT, state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); let forwardedRedirectUrl: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', @@ -1129,7 +1340,7 @@ describe('RampsController', () => { it('prefers an explicit caller redirectUrl over the injected default on the widened path', async () => { const response: QuotesResponse = { - success: [inAppScopeQuote(MOONPAY, 90)], + success: [appBrowserQuote(MOONPAY, 90)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], @@ -1140,12 +1351,12 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', getDefaultRedirectUrl: () => DEFAULT_REDIRECT, state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); let forwardedRedirectUrl: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', @@ -1166,9 +1377,9 @@ describe('RampsController', () => { ); }); - it('does not inject the default redirectUrl when the scope is off', async () => { + it('does not inject the default redirectUrl when the flag is disabled', async () => { const response: QuotesResponse = { - success: [inAppScopeQuote(NATIVE, 70)], + success: [appBrowserQuote(NATIVE, 70)], sorted: [{ sortBy: 'reliability', ids: [NATIVE] }], error: [], customActions: [], @@ -1178,12 +1389,16 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'off', getDefaultRedirectUrl: () => DEFAULT_REDIRECT, state: scopeState([buildScopeProvider(NATIVE, 'native')]), }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false, + }, + }); let forwardedRedirectUrl: string | undefined; rootMessenger.registerActionHandler( 'RampsService:getQuotes', @@ -1195,8 +1410,8 @@ describe('RampsController', () => { await callScopedGetQuotes(messenger); - // Scope `off` never widens, so the default is not injected even when a - // `getDefaultRedirectUrl` callback is present. + // The disabled flag never widens, so the default is not injected + // even when a `getDefaultRedirectUrl` callback is present. expect(forwardedRedirectUrl).toBeUndefined(); }, ); @@ -1204,7 +1419,7 @@ describe('RampsController', () => { it('forwards undefined on the widened path when no getDefaultRedirectUrl option is provided', async () => { const response: QuotesResponse = { - success: [inAppScopeQuote(MOONPAY, 90)], + success: [appBrowserQuote(MOONPAY, 90)], sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }], error: [], customActions: [], @@ -1213,11 +1428,11 @@ describe('RampsController', () => { await withController( { options: { - getProviderScope: () => 'in-app', state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]), }, }, async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); let forwardedRedirectUrl: string | undefined; let redirectUrlWasSeen = false; rootMessenger.registerActionHandler( @@ -10870,7 +11085,10 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { }); rootMessenger.delegate({ messenger, - actions: [...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS], + actions: [ + ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + 'RemoteFeatureFlagController:getState', + ], }); return messenger; } diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 802bb2f96a..19cd0677db 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -6,11 +6,12 @@ import type { import { BaseController } from '@metamask/base-controller'; import { BrokenCircuitError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { Json } from '@metamask/utils'; import type { Draft } from 'immer'; +import { isHeadlessAllProvidersEnabled } from './featureFlags'; import { getProvidersServingAsset } from './providerAvailability'; -import { isCustomActionQuote, isExternalBrowserQuote } from './quoteClassification'; import type { RampsControllerMethodActions } from './RampsController-method-action-types'; import type { RampsErrorCode } from './rampsErrorCodes'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes'; @@ -26,7 +27,6 @@ import type { QuotesResponse, Quote, QuoteSortBy, - QuoteCustomAction, RampsToken, RampsServiceActions, RampsOrder, @@ -588,6 +588,7 @@ export type RampsControllerActions = * Actions from other messengers that {@link RampsController} calls. */ type AllowedActions = + | RemoteFeatureFlagControllerGetStateAction | RampsServiceGetGeolocationAction | RampsServiceGetCountriesAction | RampsServiceGetTokensAction @@ -664,17 +665,6 @@ 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; @@ -684,21 +674,15 @@ 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; /** * 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 + * 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. + * aggregator quotes carry a usable widget URL. Only applied on the + * widened path; an explicit caller `redirectUrl` always wins and the + * native-only default never injects. Defaults to a callback returning + * `undefined` when omitted. */ getDefaultRedirectUrl?: () => string | undefined; }; @@ -891,13 +875,7 @@ 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; - - /** - * Resolves the default redirect URL for the widened in-app quote fetch when + * Resolves the default redirect URL for the widened quote fetch when * the caller omits `redirectUrl`. Defaults to `() => undefined` when no * callback is injected. */ @@ -969,10 +947,8 @@ 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`. * @param args.getDefaultRedirectUrl - Optional callback returning the default - * redirect URL used for the widened in-app quote fetch when the caller omits + * redirect URL used for the widened quote fetch when the caller omits * `redirectUrl`. Defaults to a callback returning `undefined`. */ constructor({ @@ -980,7 +956,6 @@ export class RampsController extends BaseController< state = {}, requestCacheTTL = DEFAULT_REQUEST_CACHE_TTL, requestCacheMaxSize = DEFAULT_REQUEST_CACHE_MAX_SIZE, - getProviderScope, getDefaultRedirectUrl, }: RampsControllerOptions) { super({ @@ -997,7 +972,6 @@ export class RampsController extends BaseController< this.#requestCacheTTL = requestCacheTTL; this.#requestCacheMaxSize = requestCacheMaxSize; - this.#getProviderScope = getProviderScope ?? ((): ProviderScope => 'off'); this.#getDefaultRedirectUrl = getDefaultRedirectUrl ?? ((): string | undefined => undefined); @@ -1007,6 +981,30 @@ export class RampsController extends BaseController< ); } + /** + * Whether the Headless Buy all-providers feature flag is enabled. + * + * Reads `RemoteFeatureFlagController` state through the messenger on every + * call, so a remote flag fetch or a local dev override takes effect at + * runtime without reconstructing the controller. Key lookup, local-override + * merging, and boolean coercion live in the shared + * `isHeadlessAllProvidersEnabled` helper so UI consumers resolve the flag + * identically. Fails closed: when `RemoteFeatureFlagController:getState` is + * not wired up, quoting stays native-only. + * + * @returns Whether all provider classes are enabled for fiat quote widening. + */ + #isAllProvidersEnabled(): boolean { + try { + const remoteFeatureFlagState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + return isHeadlessAllProvidersEnabled(remoteFeatureFlagState); + } catch { + return false; + } + } + /** * Executes a request with caching, deduplication, and at most one in-flight * request per resource type. @@ -1892,21 +1890,20 @@ 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 + // When the all-providers feature flag is enabled, widen the native-only + // auto-selection path to every supporting provider and pick the best + // quote from the results. 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' && + // plain all-provider path are untouched (and never read the flag). + const wantsAutoSelection = !options.providers && (options.autoSelectProvider === true || options.restrictToKnownOrNativeProviders === true); + const widenToAllProviders = + wantsAutoSelection && this.#isAllProvidersEnabled(); let providersToUse: string[]; - let inAppProviderCatalog: Provider[] = this.state.providers.data; + let widenedProviderCatalog: Provider[] = this.state.providers.data; if (options.providers) { providersToUse = options.restrictToKnownOrNativeProviders ? await this.#filterProviderIdsBySupport({ @@ -1915,7 +1912,7 @@ export class RampsController extends BaseController< region: regionToUse, }) : options.providers; - } else if (widenToInAppProviders) { + } else if (widenToAllProviders) { // `#getSupportingProvidersForRegion` also hydrates the provider catalog // when controller state is empty, so all-provider quoting cannot silently // return zero providers here. @@ -1923,7 +1920,7 @@ export class RampsController extends BaseController< assetId: normalizedAssetIdForValidation, region: regionToUse, }); - inAppProviderCatalog = supporting; + widenedProviderCatalog = supporting; providersToUse = supporting.map((provider) => provider.id); } else if ( options.autoSelectProvider || @@ -1965,13 +1962,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. This also guards the widened in-app path: + // and would quote every provider. This also guards the widened 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 || widenToInAppProviders) && + (options.restrictToKnownOrNativeProviders || widenToAllProviders) && providersToUse.length === 0 ) { return { success: [], sorted: [], error: [], customActions: [] }; @@ -1983,12 +1980,13 @@ export class RampsController extends BaseController< 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 + // present, so on the widened 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. + // explicit caller `redirectUrl` always wins, and the native-only path + // (flag off) never injects. const effectiveRedirectUrl = options.redirectUrl ?? - (widenToInAppProviders ? this.#getDefaultRedirectUrl() : undefined); + (widenToAllProviders ? this.#getDefaultRedirectUrl() : undefined); const cacheKey = createCacheKey('getQuotes', [ normalizedRegion, @@ -2025,24 +2023,23 @@ export class RampsController extends BaseController< }, ); - if (!widenToInAppProviders) { + if (!widenToAllProviders) { return response; } - // Reduce the widened multi-provider result to the single best in-app quote + // Reduce the widened multi-provider result to the single best 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, + const selectedQuote = this.#pickWidenedQuote(response, { amount: options.amount, fiat: normalizedFiat, - providers: inAppProviderCatalog, + providers: widenedProviderCatalog, }); if (!selectedQuote) { - // No usable in-app quote: surface "no quote" rather than leaking an - // external/custom quote to the single-pick consumer. + // No quote fits the published provider limits: surface "no quote" + // rather than handing the single-pick consumer an out-of-limits quote. return { success: [], sorted: response.sorted, @@ -2061,30 +2058,28 @@ export class RampsController extends BaseController< } /** - * Selects the best in-app quote from a widened multi-provider response. + * Selects the best 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. + * Every provider class is eligible (native, in-app WebView aggregator, and + * external-browser / custom-action). 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 quote fits + * the published limits. * * @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( + #pickWidenedQuote( response: QuotesResponse, { - scope, amount, fiat, providers, }: { - scope: ProviderScope; amount: number; fiat: string; providers: Provider[]; @@ -2096,11 +2091,6 @@ export class RampsController extends BaseController< provider, ]), ); - const customActionProviderCodes = new Set( - response.customActions.map((action: QuoteCustomAction) => - normalizeProviderCode(action.buy.providerId), - ), - ); const fitsProviderLimits = (quote: Quote): boolean => { const provider = providerByCode.get( @@ -2115,24 +2105,7 @@ export class RampsController extends BaseController< return amount >= limit.minAmount && amount <= limit.maxAmount; }; - const isEligible = (quote: Quote): boolean => { - // `all` (Phase 2) skips the in-app-only exclusions; both scopes still - // enforce provider limits up front. - if (scope !== 'all') { - const providerCode = normalizeProviderCode(quote.provider); - if (customActionProviderCodes.has(providerCode)) { - return false; - } - // Custom-action and external-browser classification is shared with the - // consuming client via `quoteClassification` so both filter identically. - if (isCustomActionQuote(quote) || isExternalBrowserQuote(quote)) { - return false; - } - } - return fitsProviderLimits(quote); - }; - - const candidates = response.success.filter(isEligible); + const candidates = response.success.filter(fitsProviderLimits); if (candidates.length === 0) { return undefined; } diff --git a/packages/ramps-controller/src/featureFlags.test.ts b/packages/ramps-controller/src/featureFlags.test.ts new file mode 100644 index 0000000000..3485d2f59a --- /dev/null +++ b/packages/ramps-controller/src/featureFlags.test.ts @@ -0,0 +1,92 @@ +import { + MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY, + isHeadlessAllProvidersEnabled, +} from './featureFlags'; + +describe('MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY', () => { + it('matches the LaunchDarkly flag key', () => { + expect(MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY).toBe( + 'moneyHeadlessAllProviders', + ); + }); +}); + +describe('isHeadlessAllProvidersEnabled', () => { + it('returns true when the remote flag is the literal boolean true', () => { + expect( + isHeadlessAllProvidersEnabled({ + remoteFeatureFlags: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: true }, + }), + ).toBe(true); + }); + + it('returns false when the remote flag is false', () => { + expect( + isHeadlessAllProvidersEnabled({ + remoteFeatureFlags: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false }, + }), + ).toBe(false); + }); + + it('returns false when the flag is missing', () => { + expect(isHeadlessAllProvidersEnabled({ remoteFeatureFlags: {} })).toBe( + false, + ); + }); + + it('returns false for null or undefined state', () => { + expect(isHeadlessAllProvidersEnabled(null)).toBe(false); + expect(isHeadlessAllProvidersEnabled(undefined)).toBe(false); + }); + + it.each([ + ['the string "true"', 'true'], + ['a number', 1], + ['an object', { enabled: true }], + ['an array', [true]], + ['null', null], + ['a scope string', 'all'], + ])('returns false when the flag value is %s', (_description, value) => { + expect( + isHeadlessAllProvidersEnabled({ + remoteFeatureFlags: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: value }, + }), + ).toBe(false); + }); + + it('honors a localOverrides true value when the remote flag is missing', () => { + expect( + isHeadlessAllProvidersEnabled({ + remoteFeatureFlags: {}, + localOverrides: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: true }, + }), + ).toBe(true); + }); + + it('honors a localOverrides true value over a remote false value', () => { + expect( + isHeadlessAllProvidersEnabled({ + remoteFeatureFlags: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false }, + localOverrides: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: true }, + }), + ).toBe(true); + }); + + it('honors a localOverrides false value over a remote true value', () => { + expect( + isHeadlessAllProvidersEnabled({ + remoteFeatureFlags: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: true }, + localOverrides: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false }, + }), + ).toBe(false); + }); + + it('ignores unrelated local overrides', () => { + expect( + isHeadlessAllProvidersEnabled({ + remoteFeatureFlags: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: true }, + localOverrides: { someOtherFlag: false }, + }), + ).toBe(true); + }); +}); diff --git a/packages/ramps-controller/src/featureFlags.ts b/packages/ramps-controller/src/featureFlags.ts new file mode 100644 index 0000000000..feb04485b4 --- /dev/null +++ b/packages/ramps-controller/src/featureFlags.ts @@ -0,0 +1,51 @@ +import type { FeatureFlags } from '@metamask/remote-feature-flag-controller'; + +/** + * Remote (LaunchDarkly) feature flag key for the Headless Buy all-providers + * expansion. A boolean flag: `true` widens the headless fiat quote path to + * every provider class (native, in-app WebView aggregator, and + * external-browser / custom-action); `false` or missing keeps the native-only + * default. Exported so the flag registry and every consumer stay in sync on + * the exact key string. + */ +export const MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY = + 'moneyHeadlessAllProviders'; + +/** + * The subset of `RemoteFeatureFlagController` state that + * {@link isHeadlessAllProvidersEnabled} reads. Structural so consumers can + * pass the whole controller state (or `undefined` before initialization) + * without depending on a specific controller version. + */ +export type HeadlessFeatureFlagsLookup = { + remoteFeatureFlags?: FeatureFlags; + localOverrides?: FeatureFlags; +}; + +/** + * Whether the Headless Buy all-providers feature flag is enabled. + * + * Owns the key lookup and coercion for {@link MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY} + * so the controller's quote widening and UI availability gates resolve the + * flag identically. `localOverrides` (written by dev-only override screens) + * are merged over `remoteFeatureFlags`, because not every published + * `RemoteFeatureFlagController` version folds overrides into + * `remoteFeatureFlags` state; when a version already does, the merge is a + * no-op. Coerces defensively: only the literal boolean `true` enables, and + * any other value (missing, string, object) resolves to `false`. + * + * @param remoteFeatureFlagState - `RemoteFeatureFlagController` state (or the + * relevant subset of it). May be `null`/`undefined` before the controller is + * initialized. + * @returns Whether all provider classes are enabled for the headless fiat + * quote path. + */ +export function isHeadlessAllProvidersEnabled( + remoteFeatureFlagState: HeadlessFeatureFlagsLookup | null | undefined, +): boolean { + const flags: FeatureFlags = { + ...(remoteFeatureFlagState?.remoteFeatureFlags ?? {}), + ...(remoteFeatureFlagState?.localOverrides ?? {}), + }; + return flags[MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY] === true; +} diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 8c8818dc30..df53f98b31 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -7,7 +7,6 @@ export type { RampsControllerStateChangeEvent, RampsControllerOrderStatusChangedEvent, RampsControllerOptions, - ProviderScope, UserRegion, ResourceState, TransakState, @@ -142,6 +141,11 @@ export { export { RAMPS_ERROR_CODES } from './rampsErrorCodes'; export type { RequestSelectorResult } from './selectors'; export { createRequestSelector } from './selectors'; +export type { HeadlessFeatureFlagsLookup } from './featureFlags'; +export { + MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY, + isHeadlessAllProvidersEnabled, +} from './featureFlags'; export { providerServesAsset, getProvidersServingAsset, diff --git a/packages/ramps-controller/src/providerAvailability.test.ts b/packages/ramps-controller/src/providerAvailability.test.ts index 4112beb9cf..70612ecd11 100644 --- a/packages/ramps-controller/src/providerAvailability.test.ts +++ b/packages/ramps-controller/src/providerAvailability.test.ts @@ -67,29 +67,29 @@ describe('getProvidersServingAsset', () => { }); describe('regionHasProviderForAsset', () => { - it('returns false for an empty assetId regardless of scope', () => { + it('returns false for an empty assetId even when all providers are enabled', () => { const provider = buildProvider('native', 'native', { '': true }); expect( regionHasProviderForAsset({ providers: [provider], assetId: '', - scope: 'all', + allProvidersEnabled: true, }), ).toBe(false); }); - it('returns true when a native provider serves the asset, even under scope off', () => { + it('returns true when a native provider serves the asset, even with the flag disabled', () => { const provider = buildProvider('native', 'native', { [ASSET_ID]: true }); expect( regionHasProviderForAsset({ providers: [provider], assetId: ASSET_ID, - scope: 'off', + allProvidersEnabled: false, }), ).toBe(true); }); - it('returns false under scope off when only an aggregator serves the asset', () => { + it('returns false with the flag disabled when only an aggregator serves the asset', () => { const provider = buildProvider('moonpay', 'aggregator', { [ASSET_ID]: true, }); @@ -97,12 +97,12 @@ describe('regionHasProviderForAsset', () => { regionHasProviderForAsset({ providers: [provider], assetId: ASSET_ID, - scope: 'off', + allProvidersEnabled: false, }), ).toBe(false); }); - it('returns true under scope in-app when an aggregator serves the asset', () => { + it('returns true with the flag enabled when an aggregator serves the asset', () => { const provider = buildProvider('moonpay', 'aggregator', { [ASSET_ID]: true, }); @@ -110,7 +110,7 @@ describe('regionHasProviderForAsset', () => { regionHasProviderForAsset({ providers: [provider], assetId: ASSET_ID, - scope: 'in-app', + allProvidersEnabled: true, }), ).toBe(true); }); @@ -123,42 +123,42 @@ describe('regionHasProviderForAsset', () => { regionHasProviderForAsset({ providers: [provider], assetId: ASSET_ID, - scope: 'all', + allProvidersEnabled: true, }), ).toBe(false); }); }); describe('isFiatDepositAvailable', () => { - it('returns true when a native provider is selected, even under scope off', () => { + it('returns true when a native provider is selected, even with the flag disabled', () => { const selectedProvider = buildProvider('native', 'native'); expect( isFiatDepositAvailable({ providers: [], selectedProvider, - scope: 'off', + allProvidersEnabled: false, }), ).toBe(true); }); - it('returns false under scope off when the selected provider is not native', () => { + it('returns false with the flag disabled when the selected provider is not native', () => { const selectedProvider = buildProvider('moonpay', 'aggregator'); expect( isFiatDepositAvailable({ providers: [selectedProvider], selectedProvider, - scope: 'off', + allProvidersEnabled: false, }), ).toBe(false); }); - it('returns true under scope in-app when the region has any provider', () => { + it('returns true with the flag enabled when the region has any provider', () => { const provider = buildProvider('moonpay', 'aggregator'); expect( isFiatDepositAvailable({ providers: [provider], selectedProvider: null, - scope: 'in-app', + allProvidersEnabled: true, }), ).toBe(true); }); @@ -168,7 +168,7 @@ describe('isFiatDepositAvailable', () => { isFiatDepositAvailable({ providers: [], selectedProvider: null, - scope: 'all', + allProvidersEnabled: true, }), ).toBe(false); }); diff --git a/packages/ramps-controller/src/providerAvailability.ts b/packages/ramps-controller/src/providerAvailability.ts index 64caa4cf36..d1e6345279 100644 --- a/packages/ramps-controller/src/providerAvailability.ts +++ b/packages/ramps-controller/src/providerAvailability.ts @@ -1,4 +1,3 @@ -import type { ProviderScope } from './RampsController'; import type { Provider } from './RampsService'; /** @@ -44,31 +43,32 @@ export function getProvidersServingAsset( /** * Whether a region offers a usable on-ramp provider that serves the given - * deposit asset, under the active provider-class scope. This is the pure, + * deposit asset, under the all-providers feature flag. This is the pure, * asset-aware region gate shared between the controller and headless-buy * consumers so the two never disagree about eligibility. * - * Scope `off` is native-only: the region must offer a native provider (e.g. - * Transak Native) that serves the asset. Scope `in-app` / `all` treats the - * region as supported when any provider (native or in-app aggregator) serves - * the asset; the controller's scope-aware `getQuotes` performs the precise - * in-app provider selection at quote time. Fails closed: an empty or missing - * `assetId` returns `false`. + * With `allProvidersEnabled` false the gate is native-only: the region must + * offer a native provider (e.g. Transak Native) that serves the asset. With + * `allProvidersEnabled` true the region is supported when any provider + * (native or aggregator) serves the asset; the controller's flag-aware + * `getQuotes` performs the precise provider selection at quote time. Fails + * closed: an empty or missing `assetId` returns `false`. * * @param options - The options. * @param options.providers - The region's providers (native and aggregator). * @param options.assetId - CAIP-19 asset id of the deposit asset. - * @param options.scope - The effective provider-class scope. - * @returns Whether the region has a provider serving the asset under scope. + * @param options.allProvidersEnabled - Whether the all-providers feature flag + * is enabled (see `isHeadlessAllProvidersEnabled`). + * @returns Whether the region has a provider serving the asset. */ export function regionHasProviderForAsset({ providers, assetId, - scope, + allProvidersEnabled, }: { providers: Provider[]; assetId: string; - scope: ProviderScope; + allProvidersEnabled: boolean; }): boolean { if (!assetId) { return false; @@ -77,43 +77,45 @@ export function regionHasProviderForAsset({ if (serving.some((provider) => provider.type === 'native')) { return true; } - if (scope === 'off') { + if (!allProvidersEnabled) { return false; } return serving.length > 0; } /** - * Whether headless fiat deposit is available for the current region and - * provider-class scope. Scope-aware and independent of which single provider is - * currently selected once widened, so the availability gate cannot disagree - * with the controller's own scope-aware provider pick. + * Whether headless fiat deposit is available for the current region, under + * the all-providers feature flag. Flag-aware and independent of which single + * provider is currently selected once widened, so the availability gate + * cannot disagree with the controller's own flag-aware provider pick. * - * Scope `off` keeps the native-only behaviour: a native provider must be the - * currently selected (preferred) one, since the controller resolves the - * selected provider first and an aggregator preferred provider would otherwise - * run a non-native deposit. Scope `in-app` / `all` make the flow available - * whenever the region has any provider. + * With `allProvidersEnabled` false the check keeps the native-only behaviour: + * a native provider must be the currently selected (preferred) one, since the + * controller resolves the selected provider first and an aggregator preferred + * provider would otherwise run a non-native deposit. With + * `allProvidersEnabled` true the flow is available whenever the region has + * any provider. * * @param options - The options. * @param options.providers - The region's providers. * @param options.selectedProvider - The currently selected provider, if any. - * @param options.scope - The effective provider-class scope. + * @param options.allProvidersEnabled - Whether the all-providers feature flag + * is enabled (see `isHeadlessAllProvidersEnabled`). * @returns Whether headless fiat deposit is available. */ export function isFiatDepositAvailable({ providers, selectedProvider, - scope, + allProvidersEnabled, }: { providers: Provider[]; selectedProvider?: Provider | null; - scope: ProviderScope; + allProvidersEnabled: boolean; }): boolean { if (selectedProvider?.type === 'native') { return true; } - if (scope === 'off') { + if (!allProvidersEnabled) { return false; } return providers.length > 0; diff --git a/packages/ramps-controller/src/quoteClassification.test.ts b/packages/ramps-controller/src/quoteClassification.test.ts index 55735ae62e..b590821252 100644 --- a/packages/ramps-controller/src/quoteClassification.test.ts +++ b/packages/ramps-controller/src/quoteClassification.test.ts @@ -18,7 +18,12 @@ const buildQuote = ( amountOut: '0.05', paymentMethod: 'credit_debit_card', ...(overrides.browser - ? { buyWidget: { url: 'https://widget.example', browser: overrides.browser } } + ? { + buyWidget: { + url: 'https://widget.example', + browser: overrides.browser, + }, + } : {}), ...(overrides.isCustomAction === undefined ? {} @@ -29,9 +34,9 @@ const buildQuote = ( describe('isExternalBrowserQuote', () => { it('returns true when the buy widget targets the OS browser', () => { - expect(isExternalBrowserQuote(buildQuote({ browser: 'IN_APP_OS_BROWSER' }))).toBe( - true, - ); + expect( + isExternalBrowserQuote(buildQuote({ browser: 'IN_APP_OS_BROWSER' })), + ).toBe(true); }); it('returns false for an in-app widget browser', () => { @@ -47,7 +52,9 @@ describe('isExternalBrowserQuote', () => { describe('isCustomActionQuote', () => { it('returns true when the inline isCustomAction flag is set', () => { - expect(isCustomActionQuote(buildQuote({ isCustomAction: true }))).toBe(true); + expect(isCustomActionQuote(buildQuote({ isCustomAction: true }))).toBe( + true, + ); }); it('returns false when the flag is false or absent', () => { diff --git a/packages/ramps-controller/tsconfig.build.json b/packages/ramps-controller/tsconfig.build.json index 0bbc936e61..c7f4c2add6 100644 --- a/packages/ramps-controller/tsconfig.build.json +++ b/packages/ramps-controller/tsconfig.build.json @@ -18,6 +18,9 @@ }, { "path": "../profile-sync-controller/tsconfig.build.json" + }, + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/ramps-controller/tsconfig.json b/packages/ramps-controller/tsconfig.json index a9d4d21ea3..4ac0eb9e7d 100644 --- a/packages/ramps-controller/tsconfig.json +++ b/packages/ramps-controller/tsconfig.json @@ -7,7 +7,8 @@ "references": [ { "path": "../base-controller" }, { "path": "../messenger" }, - { "path": "../profile-sync-controller" } + { "path": "../profile-sync-controller" }, + { "path": "../remote-feature-flag-controller" } ], "include": ["../../types", "./src"] } diff --git a/yarn.lock b/yarn.lock index db251e8aca..6cdb9c66bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8238,6 +8238,7 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^28.2.0" + "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2"